From a2e1af0d65e08b37c80675acd8ecf2cf968fa8d5 Mon Sep 17 00:00:00 2001 From: Lucas Santana Date: Mon, 13 Apr 2026 09:39:03 -0300 Subject: [PATCH 1/2] fix: plug within-cycle dedup gaps for same-song variants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit selectDiverseCandidates only checked normalizeTitleOnly in selectedTitleKeys so "Beyoncé - Halo" and "Halo - Beyoncé (Lyrics)" both passed and two variants of the same song were selected in the same replenish cycle. addSelectedTracks only added 2 exclusion keys (normalizeTrackKey + normalizeTitleOnly), missing the extractSongCore key, so subsequent phases within the same invocation also couldn't catch cross-format duplicates. Both now compute and check extractSongCore — same logic as isDuplicateCandidate. Also adds warnLog when Spotify search falls back to YouTube so connectivity issues are observable in production logs. --- .../bot/src/utils/music/queueManipulation.ts | 25 +++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/packages/bot/src/utils/music/queueManipulation.ts b/packages/bot/src/utils/music/queueManipulation.ts index be4cdda82..40c8e2172 100644 --- a/packages/bot/src/utils/music/queueManipulation.ts +++ b/packages/bot/src/utils/music/queueManipulation.ts @@ -785,7 +785,7 @@ async function searchSeedCandidates( QueryType.AUTO, ] - for (const engine of engines) { + for (const [idx, engine] of engines.entries()) { try { const searchResult = await queue.player.search(query, { requestedBy: requestedBy ?? undefined, @@ -800,7 +800,16 @@ async function searchSeedCandidates( ) .slice(0, SEARCH_RESULTS_LIMIT) - if (tracks.length > 0) return tracks + if (tracks.length > 0) { + if (idx > 0) { + warnLog({ + message: + 'Autoplay: primary search returned no results, using fallback engine', + data: { engine, query }, + }) + } + return tracks + } } catch (error) { debugLog({ message: 'Search failed for seed, trying next engine', @@ -1221,15 +1230,22 @@ function selectDiverseCandidates( const artistKey = candidate.track.author.toLowerCase() const sourceKey = (candidate.track.source ?? 'unknown').toLowerCase() const titleKey = normalizeTitleOnly(candidate.track.title) + const core = extractSongCore( + candidate.track.title ?? '', + candidate.track.author, + ) + const coreKey = core ? normalizeText(core) : null if ((artistCount.get(artistKey) ?? 0) >= maxPerArtist) continue if ((sourceCount.get(sourceKey) ?? 0) >= maxPerSource) continue if (selectedTitleKeys.has(titleKey || artistKey)) continue + if (coreKey && selectedTitleKeys.has(coreKey)) continue selected.push(candidate) artistCount.set(artistKey, (artistCount.get(artistKey) ?? 0) + 1) sourceCount.set(sourceKey, (sourceCount.get(sourceKey) ?? 0) + 1) selectedTitleKeys.add(titleKey || artistKey) + if (coreKey) selectedTitleKeys.add(coreKey) if (selected.length >= missingTracks) { break } @@ -1258,6 +1274,11 @@ async function addSelectedTracks( normalizeTrackKey(candidate.track.title, candidate.track.author), ) excludedKeys.add(normalizeTitleOnly(candidate.track.title)) + const core = extractSongCore( + candidate.track.title ?? '', + candidate.track.author, + ) + if (core) excludedKeys.add(normalizeText(core)) // Write to Redis immediately so the NEXT replenish call (from the // subsequent event) also excludes this track — not just the local set. historyWrites.push( From 5cb8d0295fbee10355ed5927ac7c29c1fd3cca16 Mon Sep 17 00:00:00 2001 From: Lucas Santana Date: Mon, 13 Apr 2026 09:53:56 -0300 Subject: [PATCH 2/2] test(bot): cover within-cycle dedup and fallback engine branches --- .../src/utils/music/queueManipulation.spec.ts | 131 ++++++++++++++++-- 1 file changed, 120 insertions(+), 11 deletions(-) diff --git a/packages/bot/src/utils/music/queueManipulation.spec.ts b/packages/bot/src/utils/music/queueManipulation.spec.ts index 750af7da6..2058c4f03 100644 --- a/packages/bot/src/utils/music/queueManipulation.spec.ts +++ b/packages/bot/src/utils/music/queueManipulation.spec.ts @@ -441,7 +441,9 @@ describe('queueManipulation.replenishQueue', () => { ) }) it('boosts liked tracks to the top of autoplay recommendations', async () => { - likedTrackWeightsMock.mockResolvedValue(new Map([['likedtrack::artistb', 1.0]])) + likedTrackWeightsMock.mockResolvedValue( + new Map([['likedtrack::artistb', 1.0]]), + ) const queue = createQueueMock({ tracks: { @@ -888,7 +890,9 @@ describe('queueManipulation.replenishQueue', () => { }, }) - likedTrackWeightsMock.mockResolvedValueOnce(new Map([[likedTrackKey, 1.0]])) + likedTrackWeightsMock.mockResolvedValueOnce( + new Map([[likedTrackKey, 1.0]]), + ) getGuildSettingsMock.mockResolvedValueOnce({ autoplayMode: 'popular', }) @@ -2993,9 +2997,20 @@ describe('queueManipulation — multi-user VC blend', () => { acousticness: 0.2, }) const candidateFeatureMap = new Map([ - ['candidateSpotifyId01', { energy: 0.72, valence: 0.67, danceability: 0.58, tempo: 120, acousticness: 0.25 }], + [ + 'candidateSpotifyId01', + { + energy: 0.72, + valence: 0.67, + danceability: 0.58, + tempo: 120, + acousticness: 0.25, + }, + ], ]) - spotifyMocks.getBatchAudioFeatures.mockResolvedValueOnce(candidateFeatureMap) + spotifyMocks.getBatchAudioFeatures.mockResolvedValueOnce( + candidateFeatureMap, + ) const addTrackMock = jest.fn() const queue = createQueueMock({ @@ -3034,11 +3049,15 @@ describe('queueManipulation — multi-user VC blend', () => { }) it('applies artist popularity boost in popular mode when popularity >= 70', async () => { - getGuildSettingsMock.mockResolvedValue({ autoplayMode: 'popular', autoplayGenres: [] }) + getGuildSettingsMock.mockResolvedValue({ + autoplayMode: 'popular', + autoplayGenres: [], + }) const sharedMocks = jest.requireMock('@lucky/shared/services') as any - sharedMocks.spotifyLinkService.getValidAccessToken - .mockResolvedValue('pop-token') + sharedMocks.spotifyLinkService.getValidAccessToken.mockResolvedValue( + 'pop-token', + ) const spotifyMocks = jest.requireMock('../../spotify/spotifyApi') as any spotifyMocks.getArtistPopularity.mockResolvedValue(85) @@ -3106,8 +3125,7 @@ describe('buildVcContributionWeights', () => { 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')! + const totalWeight = weights.get('user-1')! + weights.get('user-2')! expect(totalWeight).toBe(2) }) @@ -3122,8 +3140,99 @@ describe('buildVcContributionWeights', () => { expect(weights.get('user-1')).toBeGreaterThan(0) expect(weights.get('user-2')).toBeGreaterThan(0) - const totalWeight = - weights.get('user-1')! + weights.get('user-2')! + const totalWeight = weights.get('user-1')! + weights.get('user-2')! expect(totalWeight).toBe(vcMemberIds.length) }) }) + +describe('queueManipulation — within-cycle dedup via extractSongCore', () => { + beforeEach(() => { + likedTrackWeightsMock.mockResolvedValue(new Map()) + dislikedTrackWeightsMock.mockResolvedValue(new Map()) + getPreferredArtistKeysMock.mockResolvedValue(new Set()) + getBlockedArtistKeysMock.mockResolvedValue(new Set()) + getImplicitDislikeKeysMock.mockResolvedValue(new Set()) + getImplicitLikeKeysMock.mockResolvedValue(new Set()) + consumeLastFmSeedSliceMock.mockResolvedValue([]) + getSimilarTracksMock.mockResolvedValue([]) + getTrackHistoryMock.mockResolvedValue([]) + getTagTopTracksMock.mockResolvedValue([]) + getGuildSettingsMock.mockResolvedValue({ autoplayMode: 'similar' }) + }) + + it('deduplicates inverted-format same-song within a single replenish cycle', async () => { + const addedTracks: unknown[] = [] + const queue = createQueueMock({ + currentTrack: { + title: 'Crazy In Love', + author: 'BeyoncéVEVO', + url: 'https://youtube.com/watch?v=seed001', + requestedBy: { id: 'user-1' }, + } as unknown as Track, + metadata: { requestedBy: { id: 'user-1' } }, + tracks: { size: 0, toArray: jest.fn().mockReturnValue([]) }, + addTrack: jest.fn((t: unknown) => addedTracks.push(t)), + player: { + search: jest.fn().mockResolvedValue({ + tracks: [ + { + title: 'Beyoncé - Halo', + author: 'BeyoncéVEVO', + url: 'https://youtube.com/watch?v=halo001', + durationMS: 240000, + }, + { + title: 'Halo - Beyoncé (Lyrics)', + author: 'BeyoncéVEVO', + url: 'https://youtube.com/watch?v=halo002', + durationMS: 240000, + }, + ], + }), + }, + }) + + await replenishQueue(queue as unknown as GuildQueue) + + const haloTracks = addedTracks.filter((t: any) => + (t.title as string).toLowerCase().includes('halo'), + ) + expect(haloTracks).toHaveLength(1) + }) + + it('logs warnLog when primary Spotify search returns no results and falls back', async () => { + const fallbackTrack = { + title: 'Fallback Song', + author: 'Fallback Artist', + url: 'https://youtube.com/watch?v=fallback01', + durationMS: 200000, + } + const searchMock = jest + .fn() + .mockResolvedValueOnce({ tracks: [] }) + .mockResolvedValue({ tracks: [fallbackTrack] }) + + const queue = createQueueMock({ + currentTrack: { + title: 'Current Song', + author: 'Current Artist', + url: 'https://youtube.com/watch?v=curr001', + requestedBy: { id: 'user-1' }, + } as unknown as Track, + metadata: { requestedBy: { id: 'user-1' } }, + tracks: { size: 0, toArray: jest.fn().mockReturnValue([]) }, + player: { search: searchMock }, + }) + + await replenishQueue(queue as unknown as GuildQueue) + + const { warnLog } = jest.requireMock('@lucky/shared/utils') as { + warnLog: jest.Mock + } + expect(warnLog).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('fallback engine'), + }), + ) + }) +})