Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 18 additions & 5 deletions packages/bot/src/functions/music/commands/play/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,13 +151,26 @@
} 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,
})
}

Check warning on line 173 in packages/bot/src/functions/music/commands/play/index.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Handle this exception or don't catch it at all.

See more on https://sonarcloud.io/project/issues?id=LucasSantana-Dev_Lucky&issues=AZ11NiEOsbRZ9rWRlGBC&open=AZ11NiEOsbRZ9rWRlGBC&pullRequest=515
} else {
throw spotifyError
}
Expand Down
62 changes: 24 additions & 38 deletions packages/bot/src/handlers/player/playerFactory.bridge.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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 },
Expand All @@ -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)
})
})
91 changes: 38 additions & 53 deletions packages/bot/src/handlers/player/playerFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import {
cleanTitle,
cleanAuthor,
cleanSearchQuery,
isSpamChannel,
} from '../../utils/music/searchQueryCleaner'

type CreatePlayerParams = {
Expand Down Expand Up @@ -105,21 +104,18 @@ const loadYoutubeExtractor = async (player: Player): Promise<void> => {
/**
* 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<Track, 'title' | 'author' | 'duration' | 'url'>,
_ext?: unknown,
): Promise<Readable> {
const cleanedTitle = cleanTitle(track.title)
const cleanedAuthor = cleanAuthor(track.author)
const authorIsSpam = isSpamChannel(track.author)

debugLog({
message: 'Bridge: resolving stream',
Expand All @@ -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)
Expand All @@ -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}"`)
}

/**
Expand Down
15 changes: 11 additions & 4 deletions packages/bot/src/utils/music/queueManipulation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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' },
])
Expand All @@ -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: [
Expand Down Expand Up @@ -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(
Expand Down
8 changes: 6 additions & 2 deletions packages/bot/src/utils/music/queueManipulation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -563,7 +563,11 @@ async function searchLastFmQuery(
query: string,
requestedBy: User,
): Promise<Track[]> {
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, {
Expand Down