From ba4d77c6755d8d3977767d9f5b6a2e8bf6224bc6 Mon Sep 17 00:00:00 2001 From: Lucas Santana Date: Mon, 13 Apr 2026 19:12:30 -0300 Subject: [PATCH] fix(autoplay): reject long tracks, boost spotify, strip tribute noise - hard-reject candidates with durationMS > 15 min (7-hour looped videos no longer slip through) - spotify score boost 0.15 -> 0.4 so spotify decisively beats youtube fallbacks - low-quality penalty -0.4 for legendado/traduzido/traducao in resolved title - noise patterns: strip (Tributo ao X), (Homenagem...), HH:MM:SS duration annotations - noiseTerms.json: add legendas to bareTitleNoise - 12 new tests (2188 total, all green) --- packages/bot/src/utils/music/noiseTerms.json | 6 +- .../src/utils/music/queueManipulation.spec.ts | 129 +++++++++++++++--- .../bot/src/utils/music/queueManipulation.ts | 42 +++++- .../utils/music/searchQueryCleaner.spec.ts | 40 +++++- .../bot/src/utils/music/searchQueryCleaner.ts | 24 +++- 5 files changed, 203 insertions(+), 38 deletions(-) diff --git a/packages/bot/src/utils/music/noiseTerms.json b/packages/bot/src/utils/music/noiseTerms.json index a22cca575..f0bba50c9 100644 --- a/packages/bot/src/utils/music/noiseTerms.json +++ b/packages/bot/src/utils/music/noiseTerms.json @@ -27,9 +27,5 @@ "single version", "album version" ], - "bareTitleNoise": [ - "lyrics", - "legendado", - "traduzido" - ] + "bareTitleNoise": ["lyrics", "legendado", "traduzido", "legendas"] } diff --git a/packages/bot/src/utils/music/queueManipulation.spec.ts b/packages/bot/src/utils/music/queueManipulation.spec.ts index fa04720cd..8a27cbc82 100644 --- a/packages/bot/src/utils/music/queueManipulation.spec.ts +++ b/packages/bot/src/utils/music/queueManipulation.spec.ts @@ -2902,6 +2902,77 @@ describe('queueManipulation — multi-user VC blend', () => { ) }) + it('rejects candidates over 15 minutes as track too long', async () => { + const addTrackMock = jest.fn() + const queue = createQueueMock({ + currentTrack: { + url: 'https://example.com/current', + title: 'Current Song', + author: 'Current Artist', + id: 'curr', + durationMS: 200000, + requestedBy: { id: 'user-1' }, + } as unknown as Track, + player: { + search: jest.fn().mockResolvedValue({ + tracks: [ + { + url: 'https://example.com/looped', + title: 'Pearl Jam - Sirens (07:05:14)', + author: 'SomeFanChannel', + id: 'loop1', + durationMS: 25_514_000, + requestedBy: null, + }, + ], + }), + }, + addTrack: addTrackMock, + metadata: { requestedBy: { id: 'user-1' } }, + }) + + await replenishQueue(queue as unknown as GuildQueue) + + expect(addTrackMock).not.toHaveBeenCalled() + }) + + it('penalizes low-quality uploads with noise indicators in title', async () => { + const addTrackMock = jest.fn() + const queue = createQueueMock({ + currentTrack: { + url: 'https://example.com/current', + title: 'Current Song', + author: 'Current Artist', + id: 'curr', + durationMS: 200000, + requestedBy: { id: 'user-1' }, + } as unknown as Track, + player: { + search: jest.fn().mockResolvedValue({ + tracks: [ + { + url: 'https://example.com/noisy', + title: 'Pearl Jam - Sirens (Legendado)', + author: 'FanChannel', + id: 'noisy1', + durationMS: 312000, + requestedBy: null, + }, + ], + }), + }, + addTrack: addTrackMock, + metadata: { requestedBy: { id: 'user-1' } }, + }) + + await replenishQueue(queue as unknown as GuildQueue) + + const addedTrack = addTrackMock.mock.calls[0]?.[0] as Track + expect(addedTrack?.metadata?.recommendationReason).toContain( + 'low quality upload', + ) + }) + it('applies outer duration-ratio partial boost (0.7–0.8 range)', async () => { const addTrackMock = jest.fn() const queue = createQueueMock({ @@ -3252,9 +3323,17 @@ describe('queueManipulation — Spotify priority', () => { }) it('uses song-core query for Spotify engine when seed has artist-song format', async () => { - const searchMock = jest - .fn() - .mockResolvedValue({ tracks: [{ title: 'Shape of You', author: 'Ed Sheeran', url: 'https://open.spotify.com/track/abc', source: 'spotify', durationMS: 234000 }] }) + const searchMock = jest.fn().mockResolvedValue({ + tracks: [ + { + title: 'Shape of You', + author: 'Ed Sheeran', + url: 'https://open.spotify.com/track/abc', + source: 'spotify', + durationMS: 234000, + }, + ], + }) const queue = createQueueMock({ currentTrack: { @@ -3271,7 +3350,9 @@ describe('queueManipulation — Spotify priority', () => { await replenishQueue(queue as unknown as GuildQueue) const firstCallQuery: string = searchMock.mock.calls[0]?.[0] ?? '' - expect(firstCallQuery).not.toContain('Ed Sheeran - Shape of You Ed Sheeran') + expect(firstCallQuery).not.toContain( + 'Ed Sheeran - Shape of You Ed Sheeran', + ) expect(firstCallQuery).toContain('Shape of You') expect(firstCallQuery).toContain('Ed Sheeran') }) @@ -3416,20 +3497,24 @@ describe('queueManipulation — Spotify priority', () => { it('never appends query modifiers to the Spotify engine query on subsequent replenish cycles', async () => { const capturedQueries: Array<{ query: string; engine: unknown }> = [] - const searchMock = jest.fn().mockImplementation((query: string, opts: { searchEngine: unknown }) => { - capturedQueries.push({ query, engine: opts?.searchEngine }) - return Promise.resolve({ - tracks: [ - { - title: 'Shape of You', - author: 'Ed Sheeran', - url: 'https://open.spotify.com/track/shapeofyou', - source: 'spotify', - durationMS: 234000, - }, - ], - }) - }) + const searchMock = jest + .fn() + .mockImplementation( + (query: string, opts: { searchEngine: unknown }) => { + capturedQueries.push({ query, engine: opts?.searchEngine }) + return Promise.resolve({ + tracks: [ + { + title: 'Shape of You', + author: 'Ed Sheeran', + url: 'https://open.spotify.com/track/shapeofyou', + source: 'spotify', + durationMS: 234000, + }, + ], + }) + }, + ) const queue = createQueueMock({ guild: { id: 'guild-spotify-modifier-test' }, @@ -3626,8 +3711,12 @@ describe('queueManipulation — diversity improvements', () => { await replenishQueue(queue as unknown as GuildQueue) - const artistsAdded = addedTracks.map((t) => (t as { author: string }).author) - const anatomiaCount = artistsAdded.filter((a) => a === 'ANATOMIA').length + const artistsAdded = addedTracks.map( + (t) => (t as { author: string }).author, + ) + const anatomiaCount = artistsAdded.filter( + (a) => a === 'ANATOMIA', + ).length expect(anatomiaCount).toBeLessThanOrEqual(1) }) }) diff --git a/packages/bot/src/utils/music/queueManipulation.ts b/packages/bot/src/utils/music/queueManipulation.ts index ebfd7f8a6..8ca977dad 100644 --- a/packages/bot/src/utils/music/queueManipulation.ts +++ b/packages/bot/src/utils/music/queueManipulation.ts @@ -510,7 +510,13 @@ async function _replenishQueue( const seedArtistKey = currentTrack.author.toLowerCase() const selected = interleaveByArtist( - selectDiverseCandidates(candidates, missingTracks, MAX_TRACKS_PER_ARTIST, MAX_TRACKS_PER_SOURCE, seedArtistKey), + selectDiverseCandidates( + candidates, + missingTracks, + MAX_TRACKS_PER_ARTIST, + MAX_TRACKS_PER_SOURCE, + seedArtistKey, + ), ) const currentAudioFeatures = await getTrackAudioFeatures( @@ -816,7 +822,10 @@ function extractTitleArtistFromSong( const left = cleanedTitle.slice(0, idx).trim() if (/[()[\]]/.test(left) || left.length < 2) continue const right = cleanedTitle.slice(idx + sep.length).trim() - if (corePrefix.length >= 3 && normalizeText(left).startsWith(corePrefix)) { + if ( + corePrefix.length >= 3 && + normalizeText(left).startsWith(corePrefix) + ) { return right } return left @@ -849,7 +858,10 @@ async function searchSeedCandidates( } else { const songCore = extractSongCore(seed.title, seed.author) if (songCore) { - const titleArtist = extractTitleArtistFromSong(cleanedTitle, songCore) + const titleArtist = extractTitleArtistFromSong( + cleanedTitle, + songCore, + ) spotifyBase = `${songCore} ${titleArtist ?? cleanedAuthor}`.trim() } else { spotifyBase = baseQuery @@ -883,7 +895,8 @@ async function searchSeedCandidates( if (tracks.length > 0) { if (idx > 0) { warnLog({ - message: 'Autoplay: Spotify returned 0 results, using fallback', + message: + 'Autoplay: Spotify returned 0 results, using fallback', data: { fallbackEngine: engine, spotifyQuery, @@ -1617,6 +1630,14 @@ function calculateRecommendationScore( return { score: -Infinity, reason: 'blocked artist' } } + const MAX_CANDIDATE_DURATION_MS = 15 * 60 * 1000 + if ( + candidate.durationMS && + candidate.durationMS > MAX_CANDIDATE_DURATION_MS + ) { + return { score: -Infinity, reason: 'track too long' } + } + let score = 1 const reasons: string[] = [] @@ -1737,7 +1758,7 @@ function calculateRecommendationScore( } if (candidate.source === 'spotify') { - score += 0.15 + score += 0.4 reasons.push('spotify preferred') } @@ -1750,6 +1771,17 @@ function calculateRecommendationScore( reasons.push('version variant') } + if ( + /\b(?:legendad[ao]|traduzido|tradução|legendas?)\b/i.test( + candidate.title ?? '', + ) || + /\(tributo[^)]*\)/i.test(candidate.title ?? '') || + /\(\d{1,2}:\d{2}:\d{2}\)/.test(candidate.title ?? '') + ) { + score -= 0.4 + reasons.push('low quality upload') + } + if (autoplayMode === 'discover') { if (!recentArtists.has(candidateArtist)) { score += 0.25 diff --git a/packages/bot/src/utils/music/searchQueryCleaner.spec.ts b/packages/bot/src/utils/music/searchQueryCleaner.spec.ts index 58d873882..3f41ed750 100644 --- a/packages/bot/src/utils/music/searchQueryCleaner.spec.ts +++ b/packages/bot/src/utils/music/searchQueryCleaner.spec.ts @@ -416,9 +416,9 @@ describe('cleanTitle — hyphenated version suffixes', () => { describe('cleanTitle — Acústico variants', () => { it('strips "(Acústico ao vivo)" parenthetical', () => { - expect(cleanTitle('ANATOMIA - Eu sei que é você (Acústico ao vivo)')).toBe( - 'ANATOMIA - Eu sei que é você', - ) + expect( + cleanTitle('ANATOMIA - Eu sei que é você (Acústico ao vivo)'), + ).toBe('ANATOMIA - Eu sei que é você') }) it('strips "(Acústico)" parenthetical', () => { @@ -452,7 +452,9 @@ describe('cleanTitle — Cover variants', () => { }) it('strips "(Cover - Ao vivo)" parenthetical with dash and extra content', () => { - expect(cleanTitle('ANATOMIA - Água viva (Cover - Ao vivo)')).toBe('ANATOMIA - Água viva') + expect(cleanTitle('ANATOMIA - Água viva (Cover - Ao vivo)')).toBe( + 'ANATOMIA - Água viva', + ) }) it('strips "[Cover]" bracketed', () => { @@ -463,3 +465,33 @@ describe('cleanTitle — Cover variants', () => { expect(cleanTitle('Água viva - Cover')).toBe('Água viva') }) }) + +describe('cleanTitle — tribute and duration annotation noise', () => { + it('strips "(Tributo ao Batman)" parenthetical', () => { + expect(cleanTitle('Pearl Jam - Sirens (Tributo ao Batman)')).toBe( + 'Pearl Jam - Sirens', + ) + }) + + it('strips "[Tributo a Led Zeppelin]" bracket', () => { + expect(cleanTitle('Stairway to Heaven [Tributo a Led Zeppelin]')).toBe( + 'Stairway to Heaven', + ) + }) + + it('strips "(Homenagem a Raul Seixas)" parenthetical', () => { + expect( + cleanTitle('Metamorfose Ambulante (Homenagem a Raul Seixas)'), + ).toBe('Metamorfose Ambulante') + }) + + it('strips HH:MM:SS duration annotation from title', () => { + expect(cleanTitle('Pearl Jam - Sirens (Legendado) (07:05:14)')).toBe( + 'Pearl Jam - Sirens', + ) + }) + + it('leaves MM:SS intact (only strip 3-part HH:MM:SS)', () => { + expect(cleanTitle('Song Title (03:42)')).toBe('Song Title (03:42)') + }) +}) diff --git a/packages/bot/src/utils/music/searchQueryCleaner.ts b/packages/bot/src/utils/music/searchQueryCleaner.ts index 490ff1936..37d7da4ea 100644 --- a/packages/bot/src/utils/music/searchQueryCleaner.ts +++ b/packages/bot/src/utils/music/searchQueryCleaner.ts @@ -29,10 +29,17 @@ type TermPattern = 'word' | 'paren' | 'bracket' | 'suffix' function buildTermRe(term: string, type: TermPattern): RegExp { const inner = termInner(term) switch (type) { - case 'paren': return new RegExp(`\\(${inner}[^)]*\\)`, 'gi') // NOSONAR - case 'bracket': return new RegExp(`\\[${inner}[^\\]]*\\]`, 'gi') // NOSONAR - case 'suffix': return new RegExp(`^${inner}(?:\\s{0,3}(?:version|edit|mix))?$`, 'i') // NOSONAR - default: return new RegExp(`\\b${inner}\\b`, 'gi') // NOSONAR + case 'paren': + return new RegExp(`\\(${inner}[^)]*\\)`, 'gi') // NOSONAR + case 'bracket': + return new RegExp(`\\[${inner}[^\\]]*\\]`, 'gi') // NOSONAR + case 'suffix': + return new RegExp( + `^${inner}(?:\\s{0,3}(?:version|edit|mix))?$`, + 'i', + ) // NOSONAR + default: + return new RegExp(`\\b${inner}\\b`, 'gi') // NOSONAR } } @@ -144,6 +151,15 @@ const NOISE_PATTERNS: readonly RegExp[] = [ /\(ac[uú]stico[^)]*\)/gi, /\[ac[uú]stico[^\]]*\]/gi, + // Tribute / homage tags (e.g. "(Tributo ao Batman)") + /\(tributo[^)]*\)/gi, + /\[tributo[^\]]*\]/gi, + /\(homenagem[^)]*\)/gi, + /\[homenagem[^\]]*\]/gi, + + // Duration annotations in parentheses: "(07:05:14)" — looped/extended YouTube uploads + /\(\d{1,2}:\d{2}:\d{2}\)/g, + // Dynamic patterns built from noiseTerms.json at module init ...DYNAMIC_NOISE_PATTERNS, ]