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
41 changes: 19 additions & 22 deletions packages/bot/src/handlers/player/playerFactory.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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...' })
Expand Down Expand Up @@ -74,29 +68,31 @@ const loadYoutubeExtractor = async (player: Player): Promise<void> => {
// 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,
})
Comment on lines 68 to +83

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🌐 Web query:

discord-player-youtubei v3 YoutubeExtractor export createStream registration

💡 Result:

discord-player-youtubei does not export YouTubeExtractor (that’s from @discord-player/extractor, and Discord Player v7 removed YoutubeExtractor entirely). Use YoutubeiExtractor and register it once during startup. [1], [2]

Register (YoutubeiExtractor)

import { Player } from "discord-player";
import { YoutubeiExtractor } from "discord-player-youtubei";

const player = new Player(client);

// register ONCE (not inside commands)
player.extractors.register(YoutubeiExtractor, {
  // options...
});

[1]

Override createStream (custom stream factory)

discord-player-youtubei supports a createStream option with this shape:

createStream?: (q: Track, extractor: YoutubeiExtractor) => Promise<string | Readable>

Example:

import { Readable } from "node:stream";

player.extractors.register(YoutubeiExtractor, {
  async createStream(track, extractor) {
    // return a Readable OR a direct audio URL (string)
    // (implement your own streaming logic here)
    return "https://example.com/audio-stream-url";
    // or: return Readable.from(...)
  }
});

[1]

If you meant YouTube Data API v3 (Google API key-based) rather than YouTubei/InnerTube, say so—those are different systems and the setup is different. [1]

Sources:
[1] discord-player-youtubei README (usage, register, createStream option)
[2] Discord Player v7 migration guide (YoutubeExtractor removed)


Simplify extractor fallback: only YoutubeiExtractor is exported by discord-player-youtubei.

The fallback check mod.YoutubeExtractor ?? mod.YoutubeiExtractor is misleading—discord-player-youtubei v3 exports only YoutubeiExtractor. The first check will always be undefined and can be removed. The createStream registration is correct per the package API docs (discord-player-youtubei README). Replace the fallback with a direct check on mod.YoutubeiExtractor for clarity.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/bot/src/handlers/player/playerFactory.ts` around lines 68 - 83, The
code currently checks both mod.YoutubeExtractor and mod.YoutubeiExtractor but
the package only exports YoutubeiExtractor; replace the fallback logic by
directly reading mod.YoutubeiExtractor (e.g., const YoutubeExtractor =
mod.YoutubeiExtractor), keep the same warnLog if it's missing, and then call
player.extractors.register(YoutubeExtractor, { createStream:
createResilientStream }) as before; update references to the YoutubeExtractor
variable accordingly so the code is unambiguous and only relies on
mod.YoutubeiExtractor.


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({
Expand All @@ -107,7 +103,7 @@ const loadYoutubeExtractor = async (player: Player): Promise<void> => {
}

/**
* 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)
Expand All @@ -118,7 +114,8 @@ const loadYoutubeExtractor = async (player: Player): Promise<void> => {
* context to understand WHY the bridge fell through.
*/
export async function createResilientStream(
track: BridgeTrack,
track: Pick<Track, 'title' | 'author' | 'duration' | 'url'>,
_ext?: unknown,
): Promise<Readable> {
const cleanedTitle = cleanTitle(track.title)
const cleanedAuthor = cleanAuthor(track.author)
Expand Down
63 changes: 57 additions & 6 deletions packages/bot/tests/handlers/player/playerFactory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ jest.mock('@discord-player/extractor', () => ({
}))

jest.mock('discord-player-youtubei', () => ({
YoutubeiExtractor: class MockYoutubeiExtractor {},
YoutubeExtractor: class MockYoutubeExtractor {},
}))

jest.mock('play-dl', () => ({
Expand Down Expand Up @@ -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')

Expand All @@ -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 () => {
Expand All @@ -98,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()
})
})
})