diff --git a/packages/bot/src/spotify/spotifyApi.spec.ts b/packages/bot/src/spotify/spotifyApi.spec.ts index 5aa3c7fca..3f11f1164 100644 --- a/packages/bot/src/spotify/spotifyApi.spec.ts +++ b/packages/bot/src/spotify/spotifyApi.spec.ts @@ -1,11 +1,28 @@ -import { describe, it, expect, beforeEach, afterEach } from '@jest/globals' -import { getAudioFeatures, searchSpotifyTrack } from './spotifyApi' +import { describe, it, expect, beforeEach, jest, afterEach } from '@jest/globals' +import { + getAudioFeatures, + searchSpotifyTrack, + getBatchAudioFeatures, + getArtistPopularity, +} from './spotifyApi' + +type MockFetchResponse = { + ok: boolean + json?: () => Promise +} + +const fetchMock = jest.fn< + (input: RequestInfo | URL, init?: RequestInit) => Promise +>() describe('spotifyApi', () => { let originalFetch: typeof global.fetch beforeEach(() => { originalFetch = global.fetch + jest.clearAllMocks() + ;(globalThis as { fetch: typeof fetch }).fetch = + fetchMock as unknown as typeof fetch }) afterEach(() => { @@ -14,17 +31,16 @@ describe('spotifyApi', () => { describe('getAudioFeatures', () => { it('returns audio features on successful response', async () => { - const mockResponse = { + fetchMock.mockResolvedValue({ ok: true, - json: jest.fn().mockResolvedValue({ + json: async () => ({ energy: 0.8, valence: 0.75, danceability: 0.65, tempo: 120, acousticness: 0.1, }), - } - global.fetch = jest.fn().mockResolvedValue(mockResponse) + }) const result = await getAudioFeatures('test-token', 'track-123') @@ -35,26 +51,16 @@ describe('spotifyApi', () => { tempo: 120, acousticness: 0.1, }) - expect(global.fetch).toHaveBeenCalledWith( - 'https://api.spotify.com/v1/audio-features/track-123', - { - method: 'GET', - headers: { - Authorization: 'Bearer test-token', - }, - }, - ) }) it('uses default values for missing optional properties', async () => { - const mockResponse = { + fetchMock.mockResolvedValue({ ok: true, - json: jest.fn().mockResolvedValue({ + json: async () => ({ energy: 0.8, valence: 0.75, }), - } - global.fetch = jest.fn().mockResolvedValue(mockResponse) + }) const result = await getAudioFeatures('test-token', 'track-123') @@ -68,10 +74,7 @@ describe('spotifyApi', () => { }) it('returns null when response is not ok', async () => { - const mockResponse = { - ok: false, - } - global.fetch = jest.fn().mockResolvedValue(mockResponse) + fetchMock.mockResolvedValue({ ok: false }) const result = await getAudioFeatures('test-token', 'track-123') @@ -79,11 +82,10 @@ describe('spotifyApi', () => { }) it('returns null when json parsing fails', async () => { - const mockResponse = { + fetchMock.mockResolvedValue({ ok: true, - json: jest.fn().mockRejectedValue(new Error('JSON parse error')), - } - global.fetch = jest.fn().mockResolvedValue(mockResponse) + json: async () => { throw new Error('JSON parse error') }, + }) const result = await getAudioFeatures('test-token', 'track-123') @@ -91,14 +93,10 @@ describe('spotifyApi', () => { }) it('returns null when energy is missing', async () => { - const mockResponse = { + fetchMock.mockResolvedValue({ ok: true, - json: jest.fn().mockResolvedValue({ - valence: 0.75, - danceability: 0.65, - }), - } - global.fetch = jest.fn().mockResolvedValue(mockResponse) + json: async () => ({ valence: 0.75, danceability: 0.65 }), + }) const result = await getAudioFeatures('test-token', 'track-123') @@ -106,14 +104,10 @@ describe('spotifyApi', () => { }) it('returns null when valence is not a number', async () => { - const mockResponse = { + fetchMock.mockResolvedValue({ ok: true, - json: jest.fn().mockResolvedValue({ - energy: 0.8, - valence: 'high', - }), - } - global.fetch = jest.fn().mockResolvedValue(mockResponse) + json: async () => ({ energy: 0.8, valence: 'high' }), + }) const result = await getAudioFeatures('test-token', 'track-123') @@ -121,9 +115,7 @@ describe('spotifyApi', () => { }) it('catches and returns null on fetch error', async () => { - global.fetch = jest - .fn() - .mockRejectedValue(new Error('Network error')) + fetchMock.mockRejectedValue(new Error('Network error')) const result = await getAudioFeatures('test-token', 'track-123') @@ -133,135 +125,184 @@ describe('spotifyApi', () => { describe('searchSpotifyTrack', () => { it('returns track id on successful search', async () => { - const mockResponse = { + fetchMock.mockResolvedValue({ ok: true, - json: jest.fn().mockResolvedValue({ - tracks: { - items: [{ id: 'spotify:track:abc123' }], - }, + json: async () => ({ + tracks: { items: [{ id: 'spotify:track:abc123' }] }, }), - } - global.fetch = jest.fn().mockResolvedValue(mockResponse) + }) - const result = await searchSpotifyTrack( - 'test-token', - 'Song Title', - 'Artist Name', - ) + const result = await searchSpotifyTrack('test-token', 'Song Title', 'Artist Name') expect(result).toBe('spotify:track:abc123') - expect(global.fetch).toHaveBeenCalledWith( - expect.stringContaining( - 'https://api.spotify.com/v1/search?q=track%3A%22Song+Title%22+artist%3A%22Artist+Name%22', - ), - { - method: 'GET', - headers: { - Authorization: 'Bearer test-token', - }, - }, - ) }) it('returns null when no tracks found', async () => { - const mockResponse = { + fetchMock.mockResolvedValue({ ok: true, - json: jest.fn().mockResolvedValue({ - tracks: { - items: [], - }, - }), - } - global.fetch = jest.fn().mockResolvedValue(mockResponse) + json: async () => ({ tracks: { items: [] } }), + }) - const result = await searchSpotifyTrack( - 'test-token', - 'Unknown Song', - 'Unknown Artist', - ) + const result = await searchSpotifyTrack('test-token', 'Unknown Song', 'Unknown Artist') expect(result).toBeNull() }) it('returns null when tracks property is missing', async () => { - const mockResponse = { + fetchMock.mockResolvedValue({ ok: true, - json: jest.fn().mockResolvedValue({}), - } - global.fetch = jest.fn().mockResolvedValue(mockResponse) + json: async () => ({}), + }) - const result = await searchSpotifyTrack( - 'test-token', - 'Song', - 'Artist', - ) + const result = await searchSpotifyTrack('test-token', 'Song', 'Artist') expect(result).toBeNull() }) it('returns null when response is not ok', async () => { - const mockResponse = { - ok: false, - } - global.fetch = jest.fn().mockResolvedValue(mockResponse) + fetchMock.mockResolvedValue({ ok: false }) - const result = await searchSpotifyTrack( - 'test-token', - 'Song', - 'Artist', - ) + const result = await searchSpotifyTrack('test-token', 'Song', 'Artist') expect(result).toBeNull() }) it('returns null when json parsing fails', async () => { - const mockResponse = { + fetchMock.mockResolvedValue({ ok: true, - json: jest.fn().mockRejectedValue(new Error('JSON parse error')), - } - global.fetch = jest.fn().mockResolvedValue(mockResponse) + json: async () => { throw new Error('JSON parse error') }, + }) - const result = await searchSpotifyTrack( - 'test-token', - 'Song', - 'Artist', - ) + const result = await searchSpotifyTrack('test-token', 'Song', 'Artist') expect(result).toBeNull() }) it('catches and returns null on fetch error', async () => { - global.fetch = jest - .fn() - .mockRejectedValue(new Error('Network error')) + fetchMock.mockRejectedValue(new Error('Network error')) - const result = await searchSpotifyTrack( - 'test-token', - 'Song', - 'Artist', - ) + const result = await searchSpotifyTrack('test-token', 'Song', 'Artist') expect(result).toBeNull() }) + }) + + describe('getBatchAudioFeatures', () => { + it('returns empty map for empty ids array', async () => { + const result = await getBatchAudioFeatures('token', []) + expect(result.size).toBe(0) + }) - it('encodes special characters in search query', async () => { - const mockResponse = { + it('fetches audio features for multiple tracks', async () => { + fetchMock.mockResolvedValue({ ok: true, - json: jest.fn().mockResolvedValue({ - tracks: { items: [{ id: 'track-123' }] }, + json: async () => ({ + audio_features: [ + { id: 'track1', energy: 0.8, valence: 0.7, danceability: 0.6, tempo: 120, acousticness: 0.1 }, + { id: 'track2', energy: 0.5, valence: 0.6, danceability: 0.7, tempo: 100, acousticness: 0.3 }, + ], }), - } - global.fetch = jest.fn().mockResolvedValue(mockResponse) - - await searchSpotifyTrack( - 'test-token', - "Song's Title", - 'Artist & Co.', - ) - - const fetchUrl = (global.fetch as jest.Mock).mock.calls[0][0] - expect(fetchUrl).toContain('Song%27s+Title') - expect(fetchUrl).toContain('Artist+%26+Co.') + }) + + const result = await getBatchAudioFeatures('token', ['track1', 'track2']) + + expect(result.size).toBe(2) + expect(result.get('track1')).toEqual({ energy: 0.8, valence: 0.7, danceability: 0.6, tempo: 120, acousticness: 0.1 }) + expect(result.get('track2')).toEqual({ energy: 0.5, valence: 0.6, danceability: 0.7, tempo: 100, acousticness: 0.3 }) + }) + + it('skips null entries in audio_features array', async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ + audio_features: [ + { id: 'track1', energy: 0.8, valence: 0.7, danceability: 0.6, tempo: 120, acousticness: 0.1 }, + null, + { id: 'track3', energy: 0.5, valence: 0.6, danceability: 0.7, tempo: 100, acousticness: 0.3 }, + ], + }), + }) + + const result = await getBatchAudioFeatures('token', ['track1', 'invalid', 'track3']) + + expect(result.size).toBe(2) + expect(result.has('track1')).toBe(true) + expect(result.has('track3')).toBe(true) + }) + + it('returns empty map on fetch error', async () => { + fetchMock.mockResolvedValue({ ok: false }) + + const result = await getBatchAudioFeatures('token', ['track1']) + expect(result.size).toBe(0) + }) + + it('returns empty map on json parse error', async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: async () => { throw new Error('JSON error') }, + }) + + const result = await getBatchAudioFeatures('token', ['track1']) + expect(result.size).toBe(0) + }) + + it('handles missing optional audio feature fields', async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ + audio_features: [{ id: 'track1', energy: 0.8, valence: 0.7 }], + }), + }) + + const result = await getBatchAudioFeatures('token', ['track1']) + expect(result.get('track1')).toEqual({ energy: 0.8, valence: 0.7, danceability: 0, tempo: 0, acousticness: 0 }) + }) + }) + + describe('getArtistPopularity', () => { + it('returns artist popularity from search', async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ artists: { items: [{ popularity: 75 }] } }), + }) + + const result = await getArtistPopularity('token', 'The Beatles') + expect(result).toBe(75) + }) + + it('returns null when no artists found', async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ artists: { items: [] } }), + }) + + const result = await getArtistPopularity('token', 'Unknown Artist') + expect(result).toBeNull() + }) + + it('returns null when response is not ok', async () => { + fetchMock.mockResolvedValue({ ok: false }) + + const result = await getArtistPopularity('token', 'Some Artist') + expect(result).toBeNull() + }) + + it('returns null on fetch error', async () => { + fetchMock.mockRejectedValue(new Error('Network error')) + + const result = await getArtistPopularity('token', 'Some Artist') + expect(result).toBeNull() + }) + + it('returns null on json parse error', async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: async () => { throw new Error('JSON error') }, + }) + + const result = await getArtistPopularity('token', 'Some Artist') + expect(result).toBeNull() }) }) }) diff --git a/packages/bot/src/spotify/spotifyApi.ts b/packages/bot/src/spotify/spotifyApi.ts index fcf39d48a..3cb1da4db 100644 --- a/packages/bot/src/spotify/spotifyApi.ts +++ b/packages/bot/src/spotify/spotifyApi.ts @@ -6,6 +6,8 @@ export interface SpotifyAudioFeatures { acousticness: number } +const artistPopularityCache = new Map() + export async function getAudioFeatures( accessToken: string, spotifyTrackId: string, @@ -49,6 +51,106 @@ export async function getAudioFeatures( } } +export async function getBatchAudioFeatures( + accessToken: string, + spotifyIds: string[], +): Promise> { + if (spotifyIds.length === 0) return new Map() + + try { + const ids = spotifyIds.slice(0, 100).join(',') + const res = await fetch( + `https://api.spotify.com/v1/audio-features?ids=${ids}`, + { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + ) + + if (!res.ok) return new Map() + + const data = (await res.json().catch(() => null)) as { + audio_features?: Array<{ + id?: string + energy?: number + valence?: number + danceability?: number + tempo?: number + acousticness?: number + } | null> + } + + const result = new Map() + if (!data?.audio_features) return result + + for (const feature of data.audio_features) { + if ( + !feature?.id || + typeof feature.energy !== 'number' || + typeof feature.valence !== 'number' + ) { + continue + } + + result.set(feature.id, { + energy: feature.energy, + valence: feature.valence, + danceability: feature.danceability ?? 0, + tempo: feature.tempo ?? 0, + acousticness: feature.acousticness ?? 0, + }) + } + + return result + } catch { + return new Map() + } +} + +export async function getArtistPopularity( + accessToken: string, + artistName: string, +): Promise { + const cached = artistPopularityCache.get(artistName) + if (cached !== undefined) return cached + + try { + const params = new URLSearchParams({ + q: artistName, + type: 'artist', + limit: '1', + }) + + const res = await fetch( + `https://api.spotify.com/v1/search?${params.toString()}`, + { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + ) + + if (!res.ok) { + artistPopularityCache.set(artistName, null) + return null + } + + const data = (await res.json().catch(() => null)) as { + artists?: { items?: Array<{ popularity?: number }> } + } + + const popularity = data?.artists?.items?.[0]?.popularity ?? null + artistPopularityCache.set(artistName, popularity) + return popularity + } catch { + artistPopularityCache.set(artistName, null) + return null + } +} + export async function searchSpotifyTrack( accessToken: string, title: string, diff --git a/packages/bot/src/utils/music/autoplay/lastFmSeeds.spec.ts b/packages/bot/src/utils/music/autoplay/lastFmSeeds.spec.ts index 65a6f0062..d0d35628f 100644 --- a/packages/bot/src/utils/music/autoplay/lastFmSeeds.spec.ts +++ b/packages/bot/src/utils/music/autoplay/lastFmSeeds.spec.ts @@ -537,4 +537,46 @@ describe('consumeBlendedSeedSlice', () => { expect(slice).toEqual([]) }) + + it('allocates weighted slices proportionally with weights map', async () => { + getByDiscordIdMock.mockImplementation((userId) => + Promise.resolve({ + lastFmUsername: `user-${userId}`, + }), + ) + getTopTracksMock.mockImplementation((username) => { + if (username === 'user-user-1') + return Promise.resolve([ + { artist: 'User1Artist', title: 'Track1', playCount: 1 }, + { artist: 'User1Artist', title: 'Track2', playCount: 1 }, + { artist: 'User1Artist', title: 'Track3', playCount: 1 }, + { artist: 'User1Artist', title: 'Track4', playCount: 1 }, + ]) + if (username === 'user-user-2') + return Promise.resolve([ + { artist: 'User2Artist', title: 'TrackA', playCount: 1 }, + { artist: 'User2Artist', title: 'TrackB', playCount: 1 }, + ]) + return Promise.resolve([]) + }) + getRecentTracksMock.mockResolvedValue([]) + getLovedTracksMock.mockResolvedValue([]) + + const weights = new Map([ + ['user-1', 2], + ['user-2', 1], + ]) + const slice = await consumeBlendedSeedSlice(['user-1', 'user-2'], 3, weights) + + expect(slice.length).toBeLessThanOrEqual(3) + const user1Tracks = slice.filter((t) => + t.artist.includes('User1'), + ) + const user2Tracks = slice.filter((t) => + t.artist.includes('User2'), + ) + expect(user1Tracks.length).toBeGreaterThanOrEqual( + user2Tracks.length, + ) + }) }) diff --git a/packages/bot/src/utils/music/autoplay/lastFmSeeds.ts b/packages/bot/src/utils/music/autoplay/lastFmSeeds.ts index 83c294589..2592d9c3d 100644 --- a/packages/bot/src/utils/music/autoplay/lastFmSeeds.ts +++ b/packages/bot/src/utils/music/autoplay/lastFmSeeds.ts @@ -138,12 +138,34 @@ export async function consumeLastFmSeedSlice( export async function consumeBlendedSeedSlice( userIds: string[], count: number, + weights?: Map, ): Promise<{ artist: string; title: string }[]> { if (userIds.length === 0) return [] - const perUserCount = Math.ceil(count / userIds.length) + let sliceSizes: number[] + if (weights && weights.size > 0) { + const totalWeight = Array.from(weights.values()).reduce( + (sum, w) => sum + w, + 0, + ) + sliceSizes = userIds.map((id) => { + const weight = weights.get(id) ?? 1 + return Math.max(1, Math.round((count * weight) / totalWeight)) + }) + const sumSlices = sliceSizes.reduce((sum, s) => sum + s, 0) + if (sumSlices > count) { + const toRemove = sumSlices - count + for (let i = 0; i < toRemove && i < sliceSizes.length; i++) { + if (sliceSizes[i] > 1) sliceSizes[i]-- + } + } + } else { + const perUserCount = Math.ceil(count / userIds.length) + sliceSizes = userIds.map(() => perUserCount) + } + const slices = await Promise.all( - userIds.map((id) => consumeLastFmSeedSlice(id, perUserCount)), + userIds.map((id, idx) => consumeLastFmSeedSlice(id, sliceSizes[idx])), ) const interleaved: { artist: string; title: string }[] = [] diff --git a/packages/bot/src/utils/music/queueManipulation.spec.ts b/packages/bot/src/utils/music/queueManipulation.spec.ts index c736447cb..750af7da6 100644 --- a/packages/bot/src/utils/music/queueManipulation.spec.ts +++ b/packages/bot/src/utils/music/queueManipulation.spec.ts @@ -7,6 +7,7 @@ import { moveTrackInQueue, rescueQueue, moveUserTrackToPriority, + buildVcContributionWeights, } from './queueManipulation' jest.mock('discord-player', () => ({ @@ -89,6 +90,8 @@ jest.mock('../../lastfm', () => ({ jest.mock('../../spotify/spotifyApi', () => ({ getAudioFeatures: jest.fn().mockResolvedValue(null), searchSpotifyTrack: jest.fn().mockResolvedValue(null), + getBatchAudioFeatures: jest.fn().mockResolvedValue(new Map()), + getArtistPopularity: jest.fn().mockResolvedValue(null), })) const dislikedTrackWeightsMock = jest.fn() @@ -2409,6 +2412,8 @@ describe('queueManipulation — multi-user VC blend', () => { likedTrackWeightsMock.mockResolvedValue(new Map()) getPreferredArtistKeysMock.mockResolvedValue(new Set()) getBlockedArtistKeysMock.mockResolvedValue(new Set()) + getImplicitDislikeKeysMock.mockResolvedValue(new Set()) + getImplicitLikeKeysMock.mockResolvedValue(new Set()) getSimilarTracksMock.mockResolvedValue([]) }) @@ -2454,6 +2459,7 @@ describe('queueManipulation — multi-user VC blend', () => { expect(consumeBlendedSeedSliceMock).toHaveBeenCalledWith( ['user-1', 'user-2'], expect.any(Number), + expect.any(Object), ) }) @@ -2971,4 +2977,153 @@ describe('queueManipulation — multi-user VC blend', () => { expect(addTrackMock).toHaveBeenCalled() }) + + it('applies energy/valence score boost when spotify candidate matches current track features', async () => { + const sharedMocks = jest.requireMock('@lucky/shared/services') as any + sharedMocks.spotifyLinkService.getValidAccessToken + .mockResolvedValueOnce('token-for-current') + .mockResolvedValueOnce('token-for-enrich') + + const spotifyMocks = jest.requireMock('../../spotify/spotifyApi') as any + spotifyMocks.getAudioFeatures.mockResolvedValueOnce({ + energy: 0.7, + valence: 0.65, + danceability: 0.6, + tempo: 125, + acousticness: 0.2, + }) + const candidateFeatureMap = new Map([ + ['candidateSpotifyId01', { energy: 0.72, valence: 0.67, danceability: 0.58, tempo: 120, acousticness: 0.25 }], + ]) + spotifyMocks.getBatchAudioFeatures.mockResolvedValueOnce(candidateFeatureMap) + + const addTrackMock = jest.fn() + const queue = createQueueMock({ + currentTrack: { + url: 'https://open.spotify.com/track/currentSpotifyId01', + title: 'Current Spotify Track', + author: 'Spotify Artist', + id: 'currentSpotifyId01', + requestedBy: { id: 'user-1' }, + } as unknown as Track, + player: { + search: jest.fn().mockResolvedValue({ + tracks: [ + { + url: 'https://open.spotify.com/track/candidateSpotifyId01', + title: 'Candidate Spotify Track', + author: 'Other Artist', + id: 'candidateSpotifyId01', + durationMS: 200000, + requestedBy: null, + }, + ], + }), + }, + addTrack: addTrackMock, + metadata: { requestedBy: { id: 'user-1' } }, + }) + + await replenishQueue(queue as unknown as GuildQueue) + + expect(spotifyMocks.getBatchAudioFeatures).toHaveBeenCalledWith( + 'token-for-enrich', + ['candidateSpotifyId01'], + ) + expect(addTrackMock).toHaveBeenCalled() + }) + + it('applies artist popularity boost in popular mode when popularity >= 70', async () => { + getGuildSettingsMock.mockResolvedValue({ autoplayMode: 'popular', autoplayGenres: [] }) + + const sharedMocks = jest.requireMock('@lucky/shared/services') as any + sharedMocks.spotifyLinkService.getValidAccessToken + .mockResolvedValue('pop-token') + + const spotifyMocks = jest.requireMock('../../spotify/spotifyApi') as any + spotifyMocks.getArtistPopularity.mockResolvedValue(85) + + const addTrackMock = jest.fn() + const queue = createQueueMock({ + currentTrack: { + url: 'https://example.com/current', + title: 'Current Track', + author: 'Popular Artist', + id: 'curr', + requestedBy: { id: 'user-1' }, + } as unknown as Track, + player: { + search: jest.fn().mockResolvedValue({ + tracks: [ + { + url: 'https://example.com/popular', + title: 'Hit Song', + author: 'Chart Topper', + id: 'pop1', + durationMS: 210000, + requestedBy: null, + }, + ], + }), + }, + addTrack: addTrackMock, + metadata: { requestedBy: { id: 'user-1' } }, + }) + + await replenishQueue(queue as unknown as GuildQueue) + + expect(spotifyMocks.getArtistPopularity).toHaveBeenCalled() + expect(addTrackMock).toHaveBeenCalled() + }) +}) + +describe('buildVcContributionWeights', () => { + it('returns equal weights when all users have equal contributions', () => { + const historyTracks = [ + { requestedBy: { id: 'user-1' } }, + { requestedBy: { id: 'user-2' } }, + { requestedBy: { id: 'user-1' } }, + { requestedBy: { id: 'user-2' } }, + ] + const vcMemberIds = ['user-1', 'user-2'] + + const weights = buildVcContributionWeights(historyTracks, vcMemberIds) + + expect(weights.size).toBe(2) + expect(weights.get('user-1')).toBe(1) + expect(weights.get('user-2')).toBe(1) + }) + + it('returns higher weight for heavy listener', () => { + const historyTracks = [ + { requestedBy: { id: 'user-1' } }, + { requestedBy: { id: 'user-1' } }, + { requestedBy: { id: 'user-1' } }, + { requestedBy: { id: 'user-2' } }, + ] + const vcMemberIds = ['user-1', 'user-2'] + + const weights = buildVcContributionWeights(historyTracks, vcMemberIds) + + expect(weights.get('user-1')).toBeGreaterThan(weights.get('user-2')!) + const totalWeight = + weights.get('user-1')! + weights.get('user-2')! + expect(totalWeight).toBe(2) + }) + + it('gives baseline weight of 1 to users with zero contributions', () => { + const historyTracks = [ + { requestedBy: { id: 'user-1' } }, + { requestedBy: { id: 'user-1' } }, + ] + const vcMemberIds = ['user-1', 'user-2'] + + const weights = buildVcContributionWeights(historyTracks, vcMemberIds) + + expect(weights.get('user-1')).toBeGreaterThan(0) + expect(weights.get('user-2')).toBeGreaterThan(0) + const totalWeight = + weights.get('user-1')! + weights.get('user-2')! + expect(totalWeight).toBe(vcMemberIds.length) + }) }) diff --git a/packages/bot/src/utils/music/queueManipulation.ts b/packages/bot/src/utils/music/queueManipulation.ts index b8dea3135..441e670e9 100644 --- a/packages/bot/src/utils/music/queueManipulation.ts +++ b/packages/bot/src/utils/music/queueManipulation.ts @@ -14,7 +14,13 @@ import { lastFmLinkService, spotifyLinkService, } from '@lucky/shared/services' -import { getAudioFeatures, searchSpotifyTrack, type SpotifyAudioFeatures } from '../../spotify/spotifyApi' +import { + getAudioFeatures, + searchSpotifyTrack, + getBatchAudioFeatures, + getArtistPopularity, + type SpotifyAudioFeatures, +} from '../../spotify/spotifyApi' import { consumeLastFmSeedSlice, consumeBlendedSeedSlice, @@ -391,6 +397,11 @@ async function _replenishQueue( if (requestedBy?.id) { const beforeLastFm = candidates.size + const metadata = queue.metadata as QueueMetadata | undefined + const vcMemberIds = metadata?.vcMemberIds ?? [] + const contributionWeights = vcMemberIds.length > 1 + ? buildVcContributionWeights(allHistoryTracks, vcMemberIds) + : new Map() await collectLastFmCandidates( queue, requestedBy, @@ -408,6 +419,7 @@ async function _replenishQueue( implicitDislikeKeys, implicitLikeKeys, sessionMood, + contributionWeights, ) debugLog({ message: 'Autoplay: last.fm candidates', @@ -491,7 +503,49 @@ async function _replenishQueue( const selected = selectDiverseCandidates(candidates, missingTracks) - if (selected.length === 0) { + const currentAudioFeatures = await getTrackAudioFeatures( + currentTrack, + requestedBy?.id ?? '', + ) + const enriched = await enrichWithAudioFeatures( + selected, + requestedBy?.id ?? '', + currentAudioFeatures, + ) + + if ( + (autoplayMode === 'discover' || autoplayMode === 'popular') && + requestedBy?.id + ) { + const token = await Promise.resolve(spotifyLinkService + .getValidAccessToken(requestedBy.id)) + .catch(() => null) + if (token) { + await Promise.all( + enriched.slice(0, 3).map(async (track) => { + const popularity = await getArtistPopularity( + token, + track.track.author, + ).catch(() => null) + if (popularity === null) return + if ( + autoplayMode === 'popular' && + popularity >= 70 + ) { + track.score += 0.12 + } else if ( + autoplayMode === 'discover' && + popularity <= 40 + ) { + track.score += 0.12 + } + }), + ) + enriched.sort((a, b) => b.score - a.score) + } + } + + if (enriched.length === 0) { warnLog({ message: 'Autoplay: no candidates selected — queue may stall', data: { guildId: queue.guild.id, candidatePoolSize: candidates.size }, @@ -504,7 +558,7 @@ async function _replenishQueue( message: 'Autoplay: tracks selected for queue', data: { guildId: queue.guild.id, - tracks: selected.map((s) => ({ + tracks: enriched.map((s) => ({ title: s.track.title, author: s.track.author, score: s.score.toFixed(3), @@ -516,7 +570,7 @@ async function _replenishQueue( await addSelectedTracks( queue, - selected, + enriched, excludedUrls, excludedKeys, requestedBy?.id, @@ -870,6 +924,7 @@ async function collectLastFmCandidates( implicitDislikeKeys: Set = new Set(), implicitLikeKeys: Set = new Set(), sessionMood: SessionMood | null = null, + contributionWeights?: Map, ): Promise { const metadata = queue.metadata as QueueMetadata const vcMemberIds = metadata?.vcMemberIds ?? [] @@ -892,6 +947,7 @@ async function collectLastFmCandidates( seedSlice = await consumeBlendedSeedSlice( linkedUserIds, LASTFM_SEED_COUNT, + contributionWeights, ) } else if (linkedUserIds.length === 1) { seedSlice = await consumeLastFmSeedSlice( @@ -1083,6 +1139,55 @@ async function collectGenreCandidates( } } + +async function enrichWithAudioFeatures( + tracks: ScoredTrack[], + userId: string, + currentFeatures: SpotifyAudioFeatures | null, +): Promise { + if (!currentFeatures || !userId) return tracks + + const token = await Promise.resolve(spotifyLinkService.getValidAccessToken(userId)).catch(() => null) + if (!token) return tracks + + const spotifyIds: string[] = [] + const idToTrack = new Map() + + for (const track of tracks) { + if (track.track.url?.includes('open.spotify.com/track/')) { + const match = track.track.url.match(/track\/([a-zA-Z0-9]+)/) + if (match?.[1]) { + spotifyIds.push(match[1]) + idToTrack.set(match[1], track) + } + } + } + + if (spotifyIds.length === 0) return tracks + + const features = await getBatchAudioFeatures(token, spotifyIds).catch( + () => new Map(), + ) + + for (const [id, feature] of features) { + const track = idToTrack.get(id) + if (!track) continue + + const energyDelta = Math.abs(feature.energy - currentFeatures.energy) + const valenceDelta = Math.abs(feature.valence - currentFeatures.valence) + + if (energyDelta < 0.15 && valenceDelta < 0.2) { + track.score += 0.15 + } else if (energyDelta < 0.3 || valenceDelta < 0.35) { + track.score += 0.07 + } else if (energyDelta > 0.6) { + track.score -= 0.1 + } + } + + return tracks.sort((a, b) => b.score - a.score) +} + function selectDiverseCandidates( candidates: Map, missingTracks: number, @@ -1274,6 +1379,33 @@ function getHistoryTracks(queue: GuildQueue): Track[] { return getAllHistoryTracks(queue).slice(0, HISTORY_SEED_LIMIT) } +export function buildVcContributionWeights( + historyTracks: { requestedBy?: { id?: string } | null }[], + vcMemberIds: string[], +): Map { + const contributions = new Map() + + for (const memberId of vcMemberIds) { + const count = historyTracks.filter( + (t) => t.requestedBy?.id === memberId, + ).length + contributions.set(memberId, count > 0 ? count : 1) + } + + const totalWeight = Array.from(contributions.values()).reduce( + (sum, w) => sum + w, + 0, + ) + const scaleFactor = vcMemberIds.length / totalWeight + + const weights = new Map() + for (const [memberId, count] of contributions) { + weights.set(memberId, count * scaleFactor) + } + + return weights +} + function normalizeTrackKey(title?: string, author?: string): string { const cleanedTitle = title ? cleanTitle(title) : '' const cleanedAuthor = author ? cleanAuthor(author) : '' @@ -1377,7 +1509,17 @@ function calculateRecommendationScore( reasons.push('completed before') } - if (!recentArtists.has(candidateArtist)) { + if (candidateArtist === currentArtist) { + score -= 0.35 + const titleSim = sharedTitleTokenScore( + candidate.title ?? '', + currentTrack.title ?? '', + ) + if (titleSim > 0.4) { + score += 0.12 + reasons.push('album match') + } + } else if (!recentArtists.has(candidateArtist)) { score += 0.15 reasons.push('session novelty') }