From 6da0c0de45deedebda58c96307d1bea4ce459fda Mon Sep 17 00:00:00 2001 From: Lucas Santana Date: Fri, 10 Apr 2026 20:34:30 -0300 Subject: [PATCH 1/2] fix(bot): surface stream failures to discord and capture yt-dlp stderr MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - errorHandlers: notify the discord text channel with an error embed when stream extraction fails and all recovery paths are exhausted. user previously saw nothing — the bot silently skipped the track. - playerFactory: capture yt-dlp stderr and include the first line in the rejection message. adds structured 'stages' array to bridge- exhausted error logs. - tests: add stderr to spawn mocks, test stderr inclusion in rejection. --- .../bot/src/handlers/player/errorHandlers.ts | 50 ++++++++++++++++++- .../player/playerFactory.bridge.spec.ts | 25 +++++++++- .../bot/src/handlers/player/playerFactory.ts | 29 +++++++++-- 3 files changed, 97 insertions(+), 7 deletions(-) diff --git a/packages/bot/src/handlers/player/errorHandlers.ts b/packages/bot/src/handlers/player/errorHandlers.ts index 47b596019..4951f9376 100644 --- a/packages/bot/src/handlers/player/errorHandlers.ts +++ b/packages/bot/src/handlers/player/errorHandlers.ts @@ -1,6 +1,7 @@ import { QueryType, type GuildQueue } from 'discord-player' -import type { User } from 'discord.js' -import { errorLog, debugLog } from '@lucky/shared/utils' +import type { TextChannel, User } from 'discord.js' +import { errorLog, debugLog, warnLog } from '@lucky/shared/utils' +import { createErrorEmbed } from '../../utils/general/embeds' import { analyzeYouTubeError, logYouTubeError, @@ -20,6 +21,27 @@ type PlayerEvents = { interface IQueueMetadata { requestedBy?: User | null + channel?: TextChannel | null +} + +async function notifyChannelStreamFailed( + queue: GuildQueue, + trackTitle: string, +): Promise { + const channel = (queue.metadata as IQueueMetadata)?.channel + if (!channel) return + try { + await channel.send({ + embeds: [ + createErrorEmbed( + '⚠️ Could not play track', + `**${trackTitle}** could not be streamed from any source. It may be unavailable in your region or not on SoundCloud. Skipping to next track.`, + ), + ], + }) + } catch { + // non-critical — don't crash if we can't notify + } } function toErrorDetails(error: unknown): { @@ -202,6 +224,11 @@ async function recoverFromStreamExtractionError( (queue.metadata as IQueueMetadata).requestedBy ?? undefined if (!requestedByUser) { + warnLog({ + message: 'Stream failed, skipping — no requestedBy to search with', + data: { title: currentTrack.title, guildId: queue.guild.id }, + }) + await notifyChannelStreamFailed(queue, currentTrack.title) queue.node.skip() return } @@ -212,6 +239,11 @@ async function recoverFromStreamExtractionError( }) if (!searchResult || searchResult.tracks.length === 0) { + warnLog({ + message: 'Stream failed, YouTube recovery found nothing — skipping', + data: { title: currentTrack.title, guildId: queue.guild.id }, + }) + await notifyChannelStreamFailed(queue, currentTrack.title) queue.node.skip() return } @@ -229,8 +261,18 @@ async function recoverFromStreamExtractionError( providerHealthService.recordSuccess(providerFromTrack(currentTrack)) debugLog({ message: 'Successfully recovered from stream extraction error', + data: { + title: currentTrack.title, + alternativeUrl: alternativeTrack.url, + }, }) } else { + warnLog({ + message: + 'Stream failed, all YouTube alternatives already in queue — skipping', + data: { title: currentTrack.title, guildId: queue.guild.id }, + }) + await notifyChannelStreamFailed(queue, currentTrack.title) queue.node.skip() } } @@ -289,6 +331,10 @@ const handlePlayerError = async ( 'Failed to recover from stream extraction error:', recoveryError, ) + const failedTrack = queue.currentTrack + if (failedTrack) { + await notifyChannelStreamFailed(queue, failedTrack.title) + } queue.node.skip() } } diff --git a/packages/bot/src/handlers/player/playerFactory.bridge.spec.ts b/packages/bot/src/handlers/player/playerFactory.bridge.spec.ts index 7c9ffac07..b9c224be2 100644 --- a/packages/bot/src/handlers/player/playerFactory.bridge.spec.ts +++ b/packages/bot/src/handlers/player/playerFactory.bridge.spec.ts @@ -51,21 +51,28 @@ const fakeStream = { on: jest.fn() } as unknown as Readable function makeSpawnSuccess() { const stdout = new PassThrough() + const stderr = new PassThrough() const proc = Object.assign(new EventEmitter(), { stdout, + stderr, kill: jest.fn(), }) setImmediate(() => stdout.emit('data', Buffer.from('audio'))) return proc } -function makeSpawnError(code = 1) { +function makeSpawnError(code = 1, stderrText = '') { const stdout = new PassThrough() + const stderr = new PassThrough() const proc = Object.assign(new EventEmitter(), { stdout, + stderr, kill: jest.fn(), }) - setImmediate(() => proc.emit('close', code)) + setImmediate(() => { + if (stderrText) stderr.emit('data', Buffer.from(stderrText)) + proc.emit('close', code) + }) return proc } @@ -298,10 +305,24 @@ describe('streamViaYtDlp', () => { ).rejects.toThrow(/exited with code 1/) }) + it('includes first stderr line in rejection message for diagnostics', async () => { + const proc = makeSpawnError( + 1, + 'ERROR: Video unavailable: This video is not available in your country', + ) + spawnMock.mockReturnValue(proc) + + await expect( + streamViaYtDlp('https://youtube.com/watch?v=test'), + ).rejects.toThrow(/Video unavailable/) + }) + it('rejects when spawn emits error', async () => { const stdout = new PassThrough() + const stderr = new PassThrough() const proc = Object.assign(new EventEmitter(), { stdout, + stderr, kill: jest.fn(), }) spawnMock.mockReturnValue(proc) diff --git a/packages/bot/src/handlers/player/playerFactory.ts b/packages/bot/src/handlers/player/playerFactory.ts index 132f90874..a17da7232 100644 --- a/packages/bot/src/handlers/player/playerFactory.ts +++ b/packages/bot/src/handlers/player/playerFactory.ts @@ -168,6 +168,11 @@ export function streamViaYtDlp(url: string): Promise { reject(new Error('yt-dlp: timed out waiting for stream start')) }, 15_000) + // Collect stderr so failures include yt-dlp's reason (bot detection, + // geo-restriction, unavailable video, etc.) in the error message. + const stderrChunks: Buffer[] = [] + proc.stderr!.on('data', (chunk: Buffer) => stderrChunks.push(chunk)) + proc.stdout!.once('data', (firstChunk: Buffer) => { clearTimeout(timeout) // The `once('data')` callback consumes the first chunk from the @@ -187,7 +192,9 @@ export function streamViaYtDlp(url: string): Promise { proc.once('close', (code) => { clearTimeout(timeout) if (code && code !== 0) { - reject(new Error(`yt-dlp exited with code ${code}`)) + const stderr = Buffer.concat(stderrChunks).toString().trim() + const reason = stderr ? ` — ${stderr.split('\n')[0]}` : '' + reject(new Error(`yt-dlp exited with code ${code}${reason}`)) } }) }) @@ -276,13 +283,29 @@ export async function createResilientStream( errorLog({ message: 'Bridge: all stages exhausted', error: coreError, - data: { title: track.title, coreTitle }, + data: { + title: track.title, + cleanedTitle, + coreTitle, + url: track.url, + stages: [ + 'yt-dlp', + 'soundcloud-full', + 'soundcloud-title', + 'soundcloud-core', + ], + }, }) } } else { errorLog({ message: 'Bridge: all stages exhausted', - data: { title: track.title }, + data: { + title: track.title, + cleanedTitle, + url: track.url, + stages: ['yt-dlp', 'soundcloud-full', 'soundcloud-title'], + }, }) } From c6b033b56fe62344a53bc69395aab1f9af00d9da Mon Sep 17 00:00:00 2001 From: Lucas Santana Date: Fri, 10 Apr 2026 20:40:33 -0300 Subject: [PATCH 2/2] test(bot): coverage for discord error notification on stream failure --- .../src/handlers/player/errorHandlers.spec.ts | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/packages/bot/src/handlers/player/errorHandlers.spec.ts b/packages/bot/src/handlers/player/errorHandlers.spec.ts index 694870a1b..293b81e23 100644 --- a/packages/bot/src/handlers/player/errorHandlers.spec.ts +++ b/packages/bot/src/handlers/player/errorHandlers.spec.ts @@ -3,15 +3,22 @@ import { setupErrorHandlers } from './errorHandlers' const debugLogMock = jest.fn() const errorLogMock = jest.fn() +const warnLogMock = jest.fn() const analyzeYouTubeErrorMock = jest.fn() const logYouTubeErrorMock = jest.fn() const recordFailureMock = jest.fn() const recordSuccessMock = jest.fn() const providerFromTrackMock = jest.fn() +const createErrorEmbedMock = jest.fn(() => ({ type: 'error' })) jest.mock('@lucky/shared/utils', () => ({ debugLog: (...args: unknown[]) => debugLogMock(...args), errorLog: (...args: unknown[]) => errorLogMock(...args), + warnLog: (...args: unknown[]) => warnLogMock(...args), +})) + +jest.mock('../../utils/general/embeds', () => ({ + createErrorEmbed: (...args: unknown[]) => createErrorEmbedMock(...args), })) jest.mock('../../utils/music/youtubeErrorHandler', () => ({ @@ -466,4 +473,77 @@ describe('setupErrorHandlers', () => { }), ) }) + + it('sends Discord error embed when YouTube recovery finds no tracks', async () => { + const { queueHandlers } = createPlayerWithHandlers() + providerFromTrackMock.mockReturnValue('youtube') + analyzeYouTubeErrorMock.mockReturnValue({ isParserError: false }) + + const channelSendMock = jest.fn().mockResolvedValue({}) + const queue = { + guild: { id: 'guild-notify', name: 'Guild Notify' }, + metadata: { + requestedBy: { id: 'user-1' }, + channel: { id: 'ch-1', send: channelSendMock }, + }, + currentTrack: { + url: 'https://example.com/current', + title: 'Unavailable Song', + requestedBy: { id: 'user-1' }, + }, + player: { search: jest.fn().mockResolvedValue({ tracks: [] }) }, + insertTrack: jest.fn(), + node: { skip: jest.fn() }, + } + + ;(queueHandlers.playerError as PlayerErrorHandler)( + queue as any, + new Error('Could not extract stream'), + ) + await flushPromises() + + expect(channelSendMock).toHaveBeenCalledWith( + expect.objectContaining({ embeds: expect.any(Array) }), + ) + expect(queue.node.skip).toHaveBeenCalled() + expect(warnLogMock).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining( + 'YouTube recovery found nothing', + ), + }), + ) + }) + + it('sends Discord error embed when no requestedBy user', async () => { + const { queueHandlers } = createPlayerWithHandlers() + providerFromTrackMock.mockReturnValue('youtube') + analyzeYouTubeErrorMock.mockReturnValue({ isParserError: false }) + + const channelSendMock = jest.fn().mockResolvedValue({}) + const queue = { + guild: { id: 'guild-no-user', name: 'Guild No User' }, + metadata: { + requestedBy: null, + channel: { id: 'ch-1', send: channelSendMock }, + }, + currentTrack: { + url: 'https://example.com/current', + title: 'Song No User', + requestedBy: null, + }, + player: { search: jest.fn() }, + insertTrack: jest.fn(), + node: { skip: jest.fn() }, + } + + ;(queueHandlers.playerError as PlayerErrorHandler)( + queue as any, + new Error('Could not extract stream'), + ) + await flushPromises() + + expect(channelSendMock).toHaveBeenCalled() + expect(queue.node.skip).toHaveBeenCalled() + }) })