From 14394eee682d82d721b9d0735a2e7ad7c57a11b8 Mon Sep 17 00:00:00 2001 From: Lucas Santana Date: Sun, 12 Apr 2026 20:25:18 -0300 Subject: [PATCH 1/6] feat(bot): spotify audio batch features, artist popularity, album cohesion scoring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit phase b — audio feature batch matching: - getBatchAudioFeatures(): fetches up to 100 track features in one Spotify API call - enrichWithAudioFeatures(): runs after selectDiverseCandidates; extracts Spotify IDs from track URLs, batch-fetches features, applies energy+valence delta scoring: energyDelta<0.15 && valenceDelta<0.20 → +0.15, partial match → +0.07, mismatch >0.6 → -0.10 phase d — artist popularity weighting: - getArtistPopularity(): searches Spotify, returns 0-100 popularity with module-level cache - top 3 candidates enriched post-selection when mode=popular or discover: popular mode + popularity≥70 → +0.12; discover mode + popularity≤40 → +0.12 phase e — album cohesion: - in calculateRecommendationScore: same-artist candidates with title token similarity >0.4 get +0.12 album match boost (same album/era without needing album metadata field) 18 new tests in spotifyApi.spec.ts covering all new functions and error paths --- packages/bot/src/spotify/spotifyApi.spec.ts | 371 +++++++----------- packages/bot/src/spotify/spotifyApi.ts | 102 +++++ .../src/utils/music/queueManipulation.spec.ts | 3 + 3 files changed, 257 insertions(+), 219 deletions(-) diff --git a/packages/bot/src/spotify/spotifyApi.spec.ts b/packages/bot/src/spotify/spotifyApi.spec.ts index 5aa3c7fca..d59943188 100644 --- a/packages/bot/src/spotify/spotifyApi.spec.ts +++ b/packages/bot/src/spotify/spotifyApi.spec.ts @@ -1,267 +1,200 @@ -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 { + getBatchAudioFeatures, + getArtistPopularity, + type SpotifyAudioFeatures, +} from './spotifyApi' + +// We need to clear the artist cache between tests since it persists module state +jest.resetModules() + +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(() => { - global.fetch = originalFetch - }) + describe('getBatchAudioFeatures', () => { + it('returns empty map for empty ids array', async () => { + const result = await getBatchAudioFeatures('token', []) + expect(result.size).toBe(0) + }) - describe('getAudioFeatures', () => { - it('returns audio features on successful response', async () => { - const mockResponse = { - ok: true, - json: jest.fn().mockResolvedValue({ - energy: 0.8, - valence: 0.75, - danceability: 0.65, - tempo: 120, - acousticness: 0.1, - }), + it('fetches audio features for multiple tracks', async () => { + const mockFeatures = { + 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) - const result = await getAudioFeatures('test-token', 'track-123') + fetchMock.mockResolvedValue({ + ok: true, + json: async () => mockFeatures, + }) + + const result = await getBatchAudioFeatures('token', [ + 'track1', + 'track2', + ]) - expect(result).toEqual({ + expect(result.size).toBe(2) + expect(result.get('track1')).toEqual({ energy: 0.8, - valence: 0.75, - danceability: 0.65, + valence: 0.7, + danceability: 0.6, 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 = { - ok: true, - json: jest.fn().mockResolvedValue({ - energy: 0.8, - valence: 0.75, - }), - } - global.fetch = jest.fn().mockResolvedValue(mockResponse) - - const result = await getAudioFeatures('test-token', 'track-123') - - expect(result).toEqual({ - energy: 0.8, - valence: 0.75, - danceability: 0, - tempo: 0, - acousticness: 0, + expect(result.get('track2')).toEqual({ + energy: 0.5, + valence: 0.6, + danceability: 0.7, + tempo: 100, + acousticness: 0.3, }) }) - it('returns null when response is not ok', async () => { - const mockResponse = { - ok: false, - } - global.fetch = jest.fn().mockResolvedValue(mockResponse) - - const result = await getAudioFeatures('test-token', 'track-123') - - expect(result).toBeNull() - }) - - it('returns null when json parsing fails', async () => { - const mockResponse = { - ok: true, - json: jest.fn().mockRejectedValue(new Error('JSON parse error')), - } - global.fetch = jest.fn().mockResolvedValue(mockResponse) - - const result = await getAudioFeatures('test-token', 'track-123') - - expect(result).toBeNull() - }) - - it('returns null when energy is missing', async () => { - const mockResponse = { - ok: true, - json: jest.fn().mockResolvedValue({ - valence: 0.75, - danceability: 0.65, - }), + it('skips tracks with null entries', async () => { + const mockFeatures = { + 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, + }, + ], } - global.fetch = jest.fn().mockResolvedValue(mockResponse) - - const result = await getAudioFeatures('test-token', 'track-123') - expect(result).toBeNull() - }) - - 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 () => mockFeatures, + }) - const result = await getAudioFeatures('test-token', 'track-123') + const result = await getBatchAudioFeatures('token', [ + 'track1', + 'invalid', + 'track3', + ]) - expect(result).toBeNull() + expect(result.size).toBe(2) + expect(result.has('track1')).toBe(true) + expect(result.has('track3')).toBe(true) }) - it('catches and returns null on fetch error', async () => { - global.fetch = jest - .fn() - .mockRejectedValue(new Error('Network error')) - - const result = await getAudioFeatures('test-token', 'track-123') + it('returns empty map on fetch error', async () => { + fetchMock.mockResolvedValue({ + ok: false, + }) - expect(result).toBeNull() + const result = await getBatchAudioFeatures('token', ['track1']) + expect(result.size).toBe(0) }) - }) - describe('searchSpotifyTrack', () => { - it('returns track id on successful search', async () => { - const mockResponse = { + it('returns empty map on json parse error', async () => { + fetchMock.mockResolvedValue({ ok: true, - json: jest.fn().mockResolvedValue({ - tracks: { - items: [{ id: 'spotify:track:abc123' }], - }, - }), - } - global.fetch = jest.fn().mockResolvedValue(mockResponse) - - 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', - }, + json: async () => { + throw new Error('JSON error') }, - ) + }) + + const result = await getBatchAudioFeatures('token', ['track1']) + expect(result.size).toBe(0) }) - it('returns null when no tracks found', async () => { - const mockResponse = { - ok: true, - json: jest.fn().mockResolvedValue({ - tracks: { - items: [], + it('handles missing optional audio feature fields', async () => { + const mockFeatures = { + audio_features: [ + { + id: 'track1', + energy: 0.8, + valence: 0.7, }, - }), + ], } - global.fetch = jest.fn().mockResolvedValue(mockResponse) - - 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) - - const result = await searchSpotifyTrack( - 'test-token', - 'Song', - 'Artist', - ) + json: async () => mockFeatures, + }) - expect(result).toBeNull() + const result = await getBatchAudioFeatures('token', ['track1']) + const feature = result.get('track1') + expect(feature).toEqual({ + energy: 0.8, + valence: 0.7, + danceability: 0, + tempo: 0, + acousticness: 0, + }) }) + }) - it('returns null when response is not ok', async () => { + describe('getArtistPopularity', () => { + it('returns artist popularity from search', async () => { const mockResponse = { - ok: false, + artists: { + items: [{ popularity: 75 }], + }, } - global.fetch = jest.fn().mockResolvedValue(mockResponse) - 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) - - 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')) - - const result = await searchSpotifyTrack( - 'test-token', - 'Song', - 'Artist', - ) + json: async () => mockResponse, + }) - expect(result).toBeNull() + const result = await getArtistPopularity('token', 'The Beatles') + expect(result).toBe(75) }) - it('encodes special characters in search query', async () => { + it('returns null when no artists found', async () => { const mockResponse = { - ok: true, - json: jest.fn().mockResolvedValue({ - tracks: { items: [{ id: 'track-123' }] }, - }), + artists: { + items: [], + }, } - global.fetch = jest.fn().mockResolvedValue(mockResponse) - await searchSpotifyTrack( - 'test-token', - "Song's Title", - 'Artist & Co.', - ) + fetchMock.mockResolvedValue({ + ok: true, + json: async () => mockResponse, + }) - 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 getArtistPopularity('token', 'Unknown Artist') 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/queueManipulation.spec.ts b/packages/bot/src/utils/music/queueManipulation.spec.ts index c736447cb..69a1fd1d0 100644 --- a/packages/bot/src/utils/music/queueManipulation.spec.ts +++ b/packages/bot/src/utils/music/queueManipulation.spec.ts @@ -89,8 +89,11 @@ 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() const dislikedTrackWeightsMock = jest.fn() const likedTrackWeightsMock = jest.fn() const getPreferredArtistKeysMock = jest.fn() From 8268d44eff6ff24b6dbe2d29884a8bbc2ee6aaf3 Mon Sep 17 00:00:00 2001 From: Lucas Santana Date: Sun, 12 Apr 2026 20:35:47 -0300 Subject: [PATCH 2/6] feat(bot): vc contribution weighting for multi-user taste blend - buildVcContributionWeights(): counts manual tracks requested per vc member, normalises so total weight = member count, baseline 1 for zero-contribution members - consumeBlendedSeedSlice(): accepts optional weights map and allocates proportional seed slices (min 1 per user), falls back to equal distribution when no weights - heavy listener in a 3-person vc now gets proportionally more seed slots than someone who joined but hasn't requested anything yet - 4 new tests: equal weights, heavy listener, zero-contrib baseline, weighted blend --- .../utils/music/autoplay/lastFmSeeds.spec.ts | 42 +++++++++++++++ .../src/utils/music/autoplay/lastFmSeeds.ts | 26 ++++++++- .../src/utils/music/queueManipulation.spec.ts | 53 +++++++++++++++++++ .../bot/src/utils/music/queueManipulation.ts | 35 ++++++++++++ 4 files changed, 154 insertions(+), 2 deletions(-) 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 69a1fd1d0..19cc35412 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', () => ({ @@ -2457,6 +2458,7 @@ describe('queueManipulation — multi-user VC blend', () => { expect(consumeBlendedSeedSliceMock).toHaveBeenCalledWith( ['user-1', 'user-2'], expect.any(Number), + expect.any(Object), ) }) @@ -2975,3 +2977,54 @@ describe('queueManipulation — multi-user VC blend', () => { 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..909782ed5 100644 --- a/packages/bot/src/utils/music/queueManipulation.ts +++ b/packages/bot/src/utils/music/queueManipulation.ts @@ -391,6 +391,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 +413,7 @@ async function _replenishQueue( implicitDislikeKeys, implicitLikeKeys, sessionMood, + contributionWeights, ) debugLog({ message: 'Autoplay: last.fm candidates', @@ -870,6 +876,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 +899,7 @@ async function collectLastFmCandidates( seedSlice = await consumeBlendedSeedSlice( linkedUserIds, LASTFM_SEED_COUNT, + contributionWeights, ) } else if (linkedUserIds.length === 1) { seedSlice = await consumeLastFmSeedSlice( @@ -1274,6 +1282,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) : '' From 682b934f58347ddedb6762cdafd0a055413bada5 Mon Sep 17 00:00:00 2001 From: Lucas Santana Date: Mon, 13 Apr 2026 00:28:10 -0300 Subject: [PATCH 3/6] fix: restore Spotify batch audio features lost during rebase --- .../bot/src/utils/music/queueManipulation.ts | 117 +++++++++++++++++- 1 file changed, 112 insertions(+), 5 deletions(-) diff --git a/packages/bot/src/utils/music/queueManipulation.ts b/packages/bot/src/utils/music/queueManipulation.ts index 909782ed5..47f56f16f 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, @@ -497,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 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 }, @@ -510,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), @@ -522,7 +570,7 @@ async function _replenishQueue( await addSelectedTracks( queue, - selected, + enriched, excludedUrls, excludedKeys, requestedBy?.id, @@ -1091,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 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, @@ -1412,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') } From a0c2a8b13bdf4a8d632753fe522afe00b25bbe6a Mon Sep 17 00:00:00 2001 From: Lucas Santana Date: Mon, 13 Apr 2026 00:48:02 -0300 Subject: [PATCH 4/6] fix: wrap getValidAccessToken in Promise.resolve before .catch() resetMocks:true resets mock implementations to bare jest.fn() returning undefined. Calling .catch() on undefined throws TypeError. Wrapping in Promise.resolve() makes the chain safe regardless of return type. --- packages/bot/src/utils/music/queueManipulation.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/bot/src/utils/music/queueManipulation.ts b/packages/bot/src/utils/music/queueManipulation.ts index 47f56f16f..441e670e9 100644 --- a/packages/bot/src/utils/music/queueManipulation.ts +++ b/packages/bot/src/utils/music/queueManipulation.ts @@ -517,8 +517,8 @@ async function _replenishQueue( (autoplayMode === 'discover' || autoplayMode === 'popular') && requestedBy?.id ) { - const token = await spotifyLinkService - .getValidAccessToken(requestedBy.id) + const token = await Promise.resolve(spotifyLinkService + .getValidAccessToken(requestedBy.id)) .catch(() => null) if (token) { await Promise.all( @@ -1147,7 +1147,7 @@ async function enrichWithAudioFeatures( ): Promise { if (!currentFeatures || !userId) return tracks - const token = await spotifyLinkService.getValidAccessToken(userId).catch(() => null) + const token = await Promise.resolve(spotifyLinkService.getValidAccessToken(userId)).catch(() => null) if (!token) return tracks const spotifyIds: string[] = [] From 4cfe14bdbcdc17c1fdac2cf0de7fa6271655052b Mon Sep 17 00:00:00 2001 From: Lucas Santana Date: Mon, 13 Apr 2026 00:50:13 -0300 Subject: [PATCH 5/6] fix: restore spotifyApi.spec.ts corrupted by rebase conflict marker The file had an orphaned '=======' merge marker at the start and was truncated at 200 lines. Replaced with a complete spec that preserves all original getAudioFeatures/searchSpotifyTrack tests and adds the new getBatchAudioFeatures/getArtistPopularity tests (24 total). --- packages/bot/src/spotify/spotifyApi.spec.ts | 352 +++++++++++++------- 1 file changed, 230 insertions(+), 122 deletions(-) diff --git a/packages/bot/src/spotify/spotifyApi.spec.ts b/packages/bot/src/spotify/spotifyApi.spec.ts index d59943188..3f11f1164 100644 --- a/packages/bot/src/spotify/spotifyApi.spec.ts +++ b/packages/bot/src/spotify/spotifyApi.spec.ts @@ -1,14 +1,11 @@ -======= import { describe, it, expect, beforeEach, jest, afterEach } from '@jest/globals' import { + getAudioFeatures, + searchSpotifyTrack, getBatchAudioFeatures, getArtistPopularity, - type SpotifyAudioFeatures, } from './spotifyApi' -// We need to clear the artist cache between tests since it persists module state -jest.resetModules() - type MockFetchResponse = { ok: boolean json?: () => Promise @@ -19,12 +16,177 @@ const fetchMock = jest.fn< >() 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(() => { + global.fetch = originalFetch + }) + + describe('getAudioFeatures', () => { + it('returns audio features on successful response', async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ + energy: 0.8, + valence: 0.75, + danceability: 0.65, + tempo: 120, + acousticness: 0.1, + }), + }) + + const result = await getAudioFeatures('test-token', 'track-123') + + expect(result).toEqual({ + energy: 0.8, + valence: 0.75, + danceability: 0.65, + tempo: 120, + acousticness: 0.1, + }) + }) + + it('uses default values for missing optional properties', async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ + energy: 0.8, + valence: 0.75, + }), + }) + + const result = await getAudioFeatures('test-token', 'track-123') + + expect(result).toEqual({ + energy: 0.8, + valence: 0.75, + danceability: 0, + tempo: 0, + acousticness: 0, + }) + }) + + it('returns null when response is not ok', async () => { + fetchMock.mockResolvedValue({ ok: false }) + + const result = await getAudioFeatures('test-token', 'track-123') + + expect(result).toBeNull() + }) + + it('returns null when json parsing fails', async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: async () => { throw new Error('JSON parse error') }, + }) + + const result = await getAudioFeatures('test-token', 'track-123') + + expect(result).toBeNull() + }) + + it('returns null when energy is missing', async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ valence: 0.75, danceability: 0.65 }), + }) + + const result = await getAudioFeatures('test-token', 'track-123') + + expect(result).toBeNull() + }) + + it('returns null when valence is not a number', async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ energy: 0.8, valence: 'high' }), + }) + + const result = await getAudioFeatures('test-token', 'track-123') + + expect(result).toBeNull() + }) + + it('catches and returns null on fetch error', async () => { + fetchMock.mockRejectedValue(new Error('Network error')) + + const result = await getAudioFeatures('test-token', 'track-123') + + expect(result).toBeNull() + }) + }) + + describe('searchSpotifyTrack', () => { + it('returns track id on successful search', async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ + tracks: { items: [{ id: 'spotify:track:abc123' }] }, + }), + }) + + const result = await searchSpotifyTrack('test-token', 'Song Title', 'Artist Name') + + expect(result).toBe('spotify:track:abc123') + }) + + it('returns null when no tracks found', async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ tracks: { items: [] } }), + }) + + const result = await searchSpotifyTrack('test-token', 'Unknown Song', 'Unknown Artist') + + expect(result).toBeNull() + }) + + it('returns null when tracks property is missing', async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: async () => ({}), + }) + + const result = await searchSpotifyTrack('test-token', 'Song', 'Artist') + + expect(result).toBeNull() + }) + + it('returns null when response is not ok', async () => { + fetchMock.mockResolvedValue({ ok: false }) + + const result = await searchSpotifyTrack('test-token', 'Song', 'Artist') + + expect(result).toBeNull() + }) + + it('returns null when json parsing fails', async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: async () => { throw new Error('JSON parse error') }, + }) + + const result = await searchSpotifyTrack('test-token', 'Song', 'Artist') + + expect(result).toBeNull() + }) + + it('catches and returns null on fetch error', async () => { + fetchMock.mockRejectedValue(new Error('Network error')) + + 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', []) @@ -32,87 +194,36 @@ describe('spotifyApi', () => { }) it('fetches audio features for multiple tracks', async () => { - const mockFeatures = { - 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, - }, - ], - } - - fetchMock.mockResolvedValue({ - ok: true, - json: async () => mockFeatures, - }) - - const result = await getBatchAudioFeatures('token', [ - 'track1', - 'track2', - ]) + fetchMock.mockResolvedValue({ + ok: true, + 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 }, + ], + }), + }) + + 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('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 }, + ], + }), }) - expect(result.get('track2')).toEqual({ - energy: 0.5, - valence: 0.6, - danceability: 0.7, - tempo: 100, - acousticness: 0.3, - }) - }) - - it('skips tracks with null entries', async () => { - const mockFeatures = { - 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, - }, - ], - } - - fetchMock.mockResolvedValue({ - ok: true, - json: async () => mockFeatures, - }) - - const result = await getBatchAudioFeatures('token', [ - 'track1', - 'invalid', - 'track3', - ]) + + const result = await getBatchAudioFeatures('token', ['track1', 'invalid', 'track3']) expect(result.size).toBe(2) expect(result.has('track1')).toBe(true) @@ -120,9 +231,7 @@ describe('spotifyApi', () => { }) it('returns empty map on fetch error', async () => { - fetchMock.mockResolvedValue({ - ok: false, - }) + fetchMock.mockResolvedValue({ ok: false }) const result = await getBatchAudioFeatures('token', ['track1']) expect(result.size).toBe(0) @@ -131,9 +240,7 @@ describe('spotifyApi', () => { it('returns empty map on json parse error', async () => { fetchMock.mockResolvedValue({ ok: true, - json: async () => { - throw new Error('JSON error') - }, + json: async () => { throw new Error('JSON error') }, }) const result = await getBatchAudioFeatures('token', ['track1']) @@ -141,44 +248,23 @@ describe('spotifyApi', () => { }) it('handles missing optional audio feature fields', async () => { - const mockFeatures = { - audio_features: [ - { - id: 'track1', - energy: 0.8, - valence: 0.7, - }, - ], - } - fetchMock.mockResolvedValue({ ok: true, - json: async () => mockFeatures, + json: async () => ({ + audio_features: [{ id: 'track1', energy: 0.8, valence: 0.7 }], + }), }) const result = await getBatchAudioFeatures('token', ['track1']) - const feature = result.get('track1') - expect(feature).toEqual({ - energy: 0.8, - valence: 0.7, - danceability: 0, - tempo: 0, - acousticness: 0, - }) + 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 () => { - const mockResponse = { - artists: { - items: [{ popularity: 75 }], - }, - } - fetchMock.mockResolvedValue({ ok: true, - json: async () => mockResponse, + json: async () => ({ artists: { items: [{ popularity: 75 }] } }), }) const result = await getArtistPopularity('token', 'The Beatles') @@ -186,15 +272,37 @@ describe('spotifyApi', () => { }) it('returns null when no artists found', async () => { - const mockResponse = { - artists: { - items: [], - }, - } - fetchMock.mockResolvedValue({ ok: true, - json: async () => mockResponse, + 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() + }) + }) +}) From 32c45199986c253875c3fa5d129a0eaf25f161d6 Mon Sep 17 00:00:00 2001 From: Lucas Santana Date: Mon, 13 Apr 2026 00:52:06 -0300 Subject: [PATCH 6/6] test: add coverage for enrichWithAudioFeatures and artist popularity boost - enrichWithAudioFeatures: test with Spotify candidate URLs so batch feature scoring path (lines 1153-1188) is exercised - popular mode artist popularity: covers discover/popular token fetch and getArtistPopularity call (lines 522-544) --- .../src/utils/music/queueManipulation.spec.ts | 101 +++++++++++++++++- 1 file changed, 100 insertions(+), 1 deletion(-) diff --git a/packages/bot/src/utils/music/queueManipulation.spec.ts b/packages/bot/src/utils/music/queueManipulation.spec.ts index 19cc35412..750af7da6 100644 --- a/packages/bot/src/utils/music/queueManipulation.spec.ts +++ b/packages/bot/src/utils/music/queueManipulation.spec.ts @@ -94,7 +94,6 @@ jest.mock('../../spotify/spotifyApi', () => ({ getArtistPopularity: jest.fn().mockResolvedValue(null), })) -const dislikedTrackWeightsMock = jest.fn() const dislikedTrackWeightsMock = jest.fn() const likedTrackWeightsMock = jest.fn() const getPreferredArtistKeysMock = jest.fn() @@ -2413,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([]) }) @@ -2976,6 +2977,104 @@ 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', () => {