diff --git a/packages/bot/src/functions/music/commands/play/index.ts b/packages/bot/src/functions/music/commands/play/index.ts index bc47828e9..84e58fe6c 100644 --- a/packages/bot/src/functions/music/commands/play/index.ts +++ b/packages/bot/src/functions/music/commands/play/index.ts @@ -151,13 +151,26 @@ export default new Command({ } catch (spotifyError) { if (searchEngine !== QueryType.AUTO) { debugLog({ - message: 'Spotify search failed, falling back to auto', + message: + 'Spotify search failed, falling back to YouTube', data: { query }, }) - result = await client.player.play(voiceChannel, query, { - ...playOptions, - searchEngine: QueryType.AUTO, - }) + try { + result = await client.player.play(voiceChannel, query, { + ...playOptions, + searchEngine: QueryType.YOUTUBE_SEARCH, + }) + } catch (youtubeError) { + debugLog({ + message: + 'YouTube search failed, falling back to auto', + data: { query }, + }) + result = await client.player.play(voiceChannel, query, { + ...playOptions, + searchEngine: QueryType.AUTO, + }) + } } else { throw spotifyError } diff --git a/packages/bot/src/handlers/player/playerFactory.bridge.spec.ts b/packages/bot/src/handlers/player/playerFactory.bridge.spec.ts index 1efe5c08e..7e7765063 100644 --- a/packages/bot/src/handlers/player/playerFactory.bridge.spec.ts +++ b/packages/bot/src/handlers/player/playerFactory.bridge.spec.ts @@ -172,10 +172,19 @@ describe('createResilientStream', () => { playdlStreamMock.mockReset() }) - it('succeeds on SoundCloud primary search (title+author)', async () => { - // Primary search uses the cleaned title+author query, so the result - // name must contain every token ("Bohemian", "Rhapsody", "Queen") for - // the tokenized match to accept it on the first try. + it('streams directly from source URL on first attempt', async () => { + playdlStreamMock.mockResolvedValueOnce({ stream: fakeStream }) + + const result = await createResilientStream(makeTrack()) + expect(result).toBe(fakeStream) + expect(playdlStreamMock).toHaveBeenCalledWith( + 'https://youtube.com/watch?v=fakeBohemian', + ) + expect(playdlSearchMock).not.toHaveBeenCalled() + }) + + it('falls back to SoundCloud primary search when direct stream fails', async () => { + playdlStreamMock.mockRejectedValueOnce(new Error('403')) playdlSearchMock.mockResolvedValueOnce([ { name: 'Bohemian Rhapsody - Queen', @@ -188,9 +197,11 @@ describe('createResilientStream', () => { const result = await createResilientStream(makeTrack()) expect(result).toBe(fakeStream) expect(playdlSearchMock).toHaveBeenCalledTimes(1) + expect(playdlStreamMock).toHaveBeenCalledWith('sc://primary') }) it('falls back to SoundCloud title-only when primary returns no validated match', async () => { + playdlStreamMock.mockRejectedValueOnce(new Error('403')) playdlSearchMock .mockResolvedValueOnce([ { name: 'Unrelated', url: 'sc://miss', durationInSec: 180 }, @@ -210,58 +221,33 @@ describe('createResilientStream', () => { expect(playdlStreamMock).toHaveBeenCalledWith('sc://secondary') }) - it('falls back to direct playdl.stream(track.url) when both SoundCloud stages miss', async () => { - playdlSearchMock.mockResolvedValue([]) - playdlStreamMock.mockResolvedValueOnce({ stream: fakeStream }) - - const result = await createResilientStream(makeTrack()) - expect(result).toBe(fakeStream) - expect(playdlStreamMock).toHaveBeenCalledWith( - 'https://youtube.com/watch?v=fakeBohemian', - ) - }) - - it('skips SoundCloud entirely for known spam uploader channels', async () => { + it('streams directly from source URL even for spam uploader channels', async () => { playdlStreamMock.mockResolvedValueOnce({ stream: fakeStream }) const result = await createResilientStream( makeTrack({ - title: 'GOLDEN - KPOP DEMON HUNTERS - HUNTR/X - Golden Huntrix [Download]', + title: 'GOLDEN - KPOP DEMON HUNTERS - HUNTR/X [Download]', author: 'Best Songs', }), ) expect(result).toBe(fakeStream) - // Primary SC search must not fire for spam-channel uploads — they - // would never have matched the real track on SoundCloud anyway. expect(playdlSearchMock).not.toHaveBeenCalled() }) - it('throws "Bridge exhausted" when spam channel has no source URL', async () => { + it('throws "Bridge exhausted" when a track has no URL and SoundCloud fails', async () => { + playdlSearchMock.mockResolvedValue([]) + await expect( - createResilientStream( - makeTrack({ - title: 'Garbage [Download]', - author: 'NCS', - url: undefined, - }), - ), + createResilientStream(makeTrack({ url: undefined })), ).rejects.toThrow(/bridge exhausted/i) }) - it('propagates direct-stream errors when the last-resort fallback fails', async () => { - playdlSearchMock.mockResolvedValue([]) + it('throws "Bridge exhausted" when all stages fail', async () => { playdlStreamMock.mockRejectedValueOnce(new Error('youtube 403')) + playdlSearchMock.mockResolvedValue([]) await expect(createResilientStream(makeTrack())).rejects.toThrow( - /youtube 403/i, + /bridge exhausted/i, ) }) - - it('throws "Bridge exhausted" when a track has no URL and SoundCloud fails', async () => { - playdlSearchMock.mockResolvedValue([]) - - await expect( - createResilientStream(makeTrack({ url: undefined })), - ).rejects.toThrow(/bridge exhausted/i) - }) }) diff --git a/packages/bot/src/handlers/player/playerFactory.ts b/packages/bot/src/handlers/player/playerFactory.ts index bc1028736..454e1ffaa 100644 --- a/packages/bot/src/handlers/player/playerFactory.ts +++ b/packages/bot/src/handlers/player/playerFactory.ts @@ -9,7 +9,6 @@ import { cleanTitle, cleanAuthor, cleanSearchQuery, - isSpamChannel, } from '../../utils/music/searchQueryCleaner' type CreatePlayerParams = { @@ -105,13 +104,11 @@ const loadYoutubeExtractor = async (player: Player): Promise => { /** * Bridge fallback chain (discord-player-youtubei v3 createStream signature). * - * 1. SoundCloud search with the cleaned "${title} ${author}" query - * 2. SoundCloud search with cleaned title only (drops uploader-channel noise) - * 3. Direct YouTube stream via play-dl when the track carries a YouTube URL - * and SoundCloud has no viable match (kpop, niche, indie tracks). + * Priority: YouTube direct → SoundCloud (full query) → SoundCloud (title only). * - * Every stage logs its attempt + outcome so Sentry events carry enough - * context to understand WHY the bridge fell through. + * YouTube direct is attempted first since the track always carries its source + * URL and play-dl can stream it independently of the extractor. SoundCloud + * search is the fallback for tracks blocked by YouTube bot-detection. */ export async function createResilientStream( track: Pick, @@ -119,7 +116,6 @@ export async function createResilientStream( ): Promise { const cleanedTitle = cleanTitle(track.title) const cleanedAuthor = cleanAuthor(track.author) - const authorIsSpam = isSpamChannel(track.author) debugLog({ message: 'Bridge: resolving stream', @@ -128,47 +124,10 @@ export async function createResilientStream( author: track.author, cleanedTitle, cleanedAuthor, - authorIsSpam, hasUrl: Boolean(track.url), }, }) - // Spam-channel uploads (e.g. "Best Songs" compilations) never match the - // real track on SoundCloud — skip that stage outright. - if (!authorIsSpam) { - try { - return await streamViaSoundCloud( - cleanSearchQuery(cleanedTitle, cleanedAuthor), - track.duration, - ) - } catch (primaryError) { - debugLog({ - message: - 'Bridge: SoundCloud primary search failed, retrying with title only', - data: { - error: (primaryError as Error).message, - cleanedTitle, - }, - }) - } - - try { - return await streamViaSoundCloud(cleanedTitle, track.duration) - } catch (titleOnlyError) { - debugLog({ - message: - 'Bridge: SoundCloud title-only search failed, falling back to direct stream', - data: { - error: (titleOnlyError as Error).message, - cleanedTitle, - }, - }) - } - } - - // Last-resort: stream the actual YouTube source URL directly via play-dl. - // This is the fix for tracks that genuinely are not on SoundCloud and for - // spam-channel uploads where the bridge would never have worked anyway. if (track.url) { try { const direct = await playdl.stream(track.url) @@ -178,18 +137,44 @@ export async function createResilientStream( }) return direct.stream } catch (directError) { - errorLog({ - message: 'Bridge: direct stream from source URL failed', - error: directError, - data: { url: track.url, title: track.title }, + debugLog({ + message: + 'Bridge: direct stream failed, falling back to SoundCloud', + data: { + error: (directError as Error).message, + cleanedTitle, + }, }) - throw directError } } - throw new Error( - `Bridge exhausted: no SoundCloud match and no source URL for "${track.title}"`, - ) + try { + return await streamViaSoundCloud( + cleanSearchQuery(cleanedTitle, cleanedAuthor), + track.duration, + ) + } catch (primaryError) { + debugLog({ + message: + 'Bridge: SoundCloud primary search failed, retrying with title only', + data: { + error: (primaryError as Error).message, + cleanedTitle, + }, + }) + } + + try { + return await streamViaSoundCloud(cleanedTitle, track.duration) + } catch (titleOnlyError) { + errorLog({ + message: 'Bridge: all stages exhausted', + error: titleOnlyError, + data: { title: track.title }, + }) + } + + throw new Error(`Bridge exhausted: no stream for "${track.title}"`) } /** diff --git a/packages/bot/src/utils/music/queueManipulation.spec.ts b/packages/bot/src/utils/music/queueManipulation.spec.ts index 08d438a8f..6e14cbd12 100644 --- a/packages/bot/src/utils/music/queueManipulation.spec.ts +++ b/packages/bot/src/utils/music/queueManipulation.spec.ts @@ -315,12 +315,19 @@ describe('queueManipulation.replenishQueue', () => { }), ) expect(queue.player.search).toHaveBeenNthCalledWith( - 3, + 2, 'Song A Artist A', expect.objectContaining({ searchEngine: QueryType.YOUTUBE_SEARCH, }), ) + expect(queue.player.search).toHaveBeenNthCalledWith( + 3, + 'Song A Artist A', + expect.objectContaining({ + searchEngine: QueryType.AUTO, + }), + ) expect(queue.addTrack).toHaveBeenCalledWith( expect.objectContaining({ url: fallbackUrl, @@ -940,7 +947,7 @@ describe('queueManipulation.replenishQueue', () => { ) }) - it('falls back to AUTO engine for last.fm search when Spotify fails', async () => { + it('falls back to YouTube search for last.fm when Spotify fails', async () => { getLastFmSeedTracksMock.mockResolvedValueOnce([ { artist: 'Radiohead', title: 'Creep' }, ]) @@ -957,7 +964,7 @@ describe('queueManipulation.replenishQueue', () => { }, ], }) - // Last.fm seed: Spotify rejects, AUTO returns tracks + // Last.fm seed: Spotify rejects, YouTube returns tracks .mockRejectedValueOnce(new Error('Spotify down')) .mockResolvedValueOnce({ tracks: [ @@ -991,7 +998,7 @@ describe('queueManipulation.replenishQueue', () => { expect(searchMock).toHaveBeenCalledWith( expect.stringContaining('Creep'), expect.objectContaining({ - searchEngine: QueryType.AUTO, + searchEngine: QueryType.YOUTUBE_SEARCH, }), ) expect(queue.addTrack).toHaveBeenCalledWith( diff --git a/packages/bot/src/utils/music/queueManipulation.ts b/packages/bot/src/utils/music/queueManipulation.ts index fd6fe5a65..2fba4d972 100644 --- a/packages/bot/src/utils/music/queueManipulation.ts +++ b/packages/bot/src/utils/music/queueManipulation.ts @@ -400,8 +400,8 @@ async function searchSeedCandidates( const query = cleanSearchQuery(seed.title, seed.author) const engines: QueryType[] = [ QueryType.SPOTIFY_SEARCH, - QueryType.AUTO, QueryType.YOUTUBE_SEARCH, + QueryType.AUTO, ] for (const engine of engines) { @@ -563,7 +563,11 @@ async function searchLastFmQuery( query: string, requestedBy: User, ): Promise { - const engines: QueryType[] = [QueryType.SPOTIFY_SEARCH, QueryType.AUTO] + const engines: QueryType[] = [ + QueryType.SPOTIFY_SEARCH, + QueryType.YOUTUBE_SEARCH, + QueryType.AUTO, + ] for (const engine of engines) { try { const result = await queue.player.search(query, {