From 304887c6d9b7448c6a32eb19bedf0904ee04573f Mon Sep 17 00:00:00 2001 From: Lucas Santana Date: Thu, 9 Apr 2026 20:45:54 -0300 Subject: [PATCH 1/3] fix(bot): update discord-player-youtubei v3 extractor registration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v3.0.0-beta.4 renamed YoutubeiExtractor → YoutubeExtractor and removed the streamOptions/generateWithPoToken registration options. The old name resolved to undefined, silently skipping YouTube extractor registration on every startup. All YouTube tracks then fell through to the SoundCloud extractor which failed for tracks not on SoundCloud, causing NoResultError. - Resolve export by name with v2 fallback (YoutubeExtractor ?? YoutubeiExtractor) - Drop removed options (streamOptions.useClient, generateWithPoToken) - Update createResilientStream signature to v3 (Track, _ext?) instead of BridgeTrack - Explicit guard when no extractor export found (logs warn instead of throwing) --- .../bot/src/handlers/player/playerFactory.ts | 41 +++++++++---------- 1 file changed, 19 insertions(+), 22 deletions(-) diff --git a/packages/bot/src/handlers/player/playerFactory.ts b/packages/bot/src/handlers/player/playerFactory.ts index 3b325a013..bc1028736 100644 --- a/packages/bot/src/handlers/player/playerFactory.ts +++ b/packages/bot/src/handlers/player/playerFactory.ts @@ -1,4 +1,5 @@ import { Player } from 'discord-player' +import type { Track } from 'discord-player' import { DefaultExtractors } from '@discord-player/extractor' import * as playdl from 'play-dl' import type { Readable } from 'stream' @@ -15,13 +16,6 @@ type CreatePlayerParams = { client: CustomClient } -type BridgeTrack = { - title: string - author: string - duration?: string - url?: string -} - export const createPlayer = ({ client }: CreatePlayerParams): Player => { try { infoLog({ message: 'Creating player...' }) @@ -74,29 +68,31 @@ const loadYoutubeExtractor = async (player: Player): Promise => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const mod = (await import('discord-player-youtubei')) as any - const registered = await player.extractors.register( - mod.YoutubeiExtractor, - { - streamOptions: { - useClient: 'IOS' as const, - highWaterMark: 1 << 25, - }, - generateWithPoToken: true, - createStream: createResilientStream, - }, - ) + // v3 renamed YoutubeiExtractor → YoutubeExtractor + const YoutubeExtractor = mod.YoutubeExtractor ?? mod.YoutubeiExtractor + if (!YoutubeExtractor) { + warnLog({ + message: + 'discord-player-youtubei: no extractor export found — skipping YouTube extractor', + }) + return + } + + const registered = await player.extractors.register(YoutubeExtractor, { + createStream: createResilientStream, + }) if (!registered) { warnLog({ message: - 'YoutubeiExtractor registration returned null — activation may have failed', + 'YoutubeExtractor registration returned null — activation may have failed', }) return } infoLog({ message: - 'Registered YoutubeiExtractor (SoundCloud bridge + YouTube fallback)', + 'Registered YoutubeExtractor (SoundCloud bridge + YouTube fallback)', }) } catch (error) { warnLog({ @@ -107,7 +103,7 @@ const loadYoutubeExtractor = async (player: Player): Promise => { } /** - * Bridge fallback chain. + * 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) @@ -118,7 +114,8 @@ const loadYoutubeExtractor = async (player: Player): Promise => { * context to understand WHY the bridge fell through. */ export async function createResilientStream( - track: BridgeTrack, + track: Pick, + _ext?: unknown, ): Promise { const cleanedTitle = cleanTitle(track.title) const cleanedAuthor = cleanAuthor(track.author) From b6c33b69be670c84b6ccd5e8951f59affbf89324 Mon Sep 17 00:00:00 2001 From: Lucas Santana Date: Thu, 9 Apr 2026 20:51:37 -0300 Subject: [PATCH 2/3] test(bot): update player factory spec for youtubei v3 api remove assertions for removed options (streamOptions, generateWithPoToken) and update mock to export the renamed YoutubeExtractor class --- .../bot/tests/handlers/player/playerFactory.test.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/bot/tests/handlers/player/playerFactory.test.ts b/packages/bot/tests/handlers/player/playerFactory.test.ts index cc41cea1f..e89d7304f 100644 --- a/packages/bot/tests/handlers/player/playerFactory.test.ts +++ b/packages/bot/tests/handlers/player/playerFactory.test.ts @@ -17,7 +17,7 @@ jest.mock('@discord-player/extractor', () => ({ })) jest.mock('discord-player-youtubei', () => ({ - YoutubeiExtractor: class MockYoutubeiExtractor {}, + YoutubeExtractor: class MockYoutubeExtractor {}, })) jest.mock('play-dl', () => ({ @@ -60,8 +60,8 @@ describe('playerFactory', () => { }) }) - describe('YoutubeiExtractor registration', () => { - it('registers YoutubeiExtractor with IOS client', async () => { + describe('YoutubeExtractor registration', () => { + it('registers YoutubeExtractor with createStream bridge', async () => { const { createPlayer } = await import('../../../src/handlers/player/playerFactory') @@ -77,9 +77,10 @@ describe('playerFactory', () => { expect(player.extractors.register).toHaveBeenCalled() const [, options] = player.extractors.register.mock.calls[0] - expect(options.streamOptions.useClient).toBe('IOS') - expect(options.streamOptions.highWaterMark).toBe(1 << 25) - expect(options.generateWithPoToken).toBe(true) + expect(typeof options.createStream).toBe('function') + // v3 API: streamOptions/generateWithPoToken removed + expect(options.streamOptions).toBeUndefined() + expect(options.generateWithPoToken).toBeUndefined() }) it('sets a createStream override to route audio via SoundCloud', async () => { From 270645e44c0dce8052aba34520afb0e3b760011c Mon Sep 17 00:00:00 2001 From: Lucas Santana Date: Thu, 9 Apr 2026 20:57:13 -0300 Subject: [PATCH 3/3] test(bot): add coverage for youtubei v3 guard and fallback paths add tests for: missing extractor export (no-op + warnLog), v2 fallback (YoutubeiExtractor used when YoutubeExtractor absent), and explicit absence of removed v3 options (streamOptions, generateWithPoToken) --- .../handlers/player/playerFactory.test.ts | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/packages/bot/tests/handlers/player/playerFactory.test.ts b/packages/bot/tests/handlers/player/playerFactory.test.ts index e89d7304f..557284ce2 100644 --- a/packages/bot/tests/handlers/player/playerFactory.test.ts +++ b/packages/bot/tests/handlers/player/playerFactory.test.ts @@ -99,5 +99,55 @@ describe('playerFactory', () => { const [, options] = player.extractors.register.mock.calls[0] expect(typeof options.createStream).toBe('function') }) + + it('falls back to YoutubeiExtractor when YoutubeExtractor is absent (v2 compat)', async () => { + jest.resetModules() + jest.doMock('discord-player-youtubei', () => ({ + YoutubeiExtractor: class MockYoutubeiExtractorV2 {}, + })) + + const { createPlayer } = + await import('../../../src/handlers/player/playerFactory') + const player = createPlayer({ + client: { user: { id: '123' } } as any, + }) as unknown as { extractors: { register: jest.Mock } } + + for (let i = 0; i < 50; i++) { + if (player.extractors.register.mock.calls.length > 0) break + await new Promise((resolve) => setTimeout(resolve, 10)) + } + + expect(player.extractors.register).toHaveBeenCalled() + }) + + it('logs warn and skips registration when no extractor export is found', async () => { + jest.resetModules() + jest.doMock('discord-player-youtubei', () => ({})) + jest.doMock('@lucky/shared/utils', () => ({ + errorLog: jest.fn(), + infoLog: jest.fn(), + warnLog: jest.fn(), + debugLog: jest.fn(), + })) + + const { createPlayer } = + await import('../../../src/handlers/player/playerFactory') + const { warnLog } = await import('@lucky/shared/utils') + + const player = createPlayer({ + client: { user: { id: '123' } } as any, + }) as unknown as { extractors: { register: jest.Mock } } + + await new Promise((resolve) => setTimeout(resolve, 200)) + + expect( + (warnLog as jest.Mock).mock.calls.some((call) => + (call[0]?.message as string)?.includes( + 'no extractor export found', + ), + ), + ).toBe(true) + expect(player.extractors.register).not.toHaveBeenCalled() + }) }) })