diff --git a/apps/eris-bot/src/index.ts b/apps/eris-bot/src/index.ts index 1f1714d73..70da5a265 100644 --- a/apps/eris-bot/src/index.ts +++ b/apps/eris-bot/src/index.ts @@ -9,10 +9,10 @@ const client = Eris(process.env.DISCORD_TOKEN!, { const player = new Player(createErisCompat(client)); -player.extractors.loadDefault((ext) => ext !== 'YouTubeExtractor'); - player.on('debug', console.log).events.on('debug', (queue, msg) => console.log(`[${queue.guild.name}] ${msg}`)); +player.extractors.loadDefault(); + client.once('ready', () => { console.log('Ready!'); console.log(player.scanDeps()); @@ -21,13 +21,13 @@ client.once('ready', () => { player.events.on('playerStart', async (queue, track) => { const meta = queue.metadata as { channel: string }; - await client.createMessage(meta.channel, `Now playing: ${track.title}`); + await client.createMessage(meta.channel, `Now playing: ${track.title} (Extractor: \`${track.extractor?.identifier}\`/Bridge: \`${track.bridgedExtractor?.identifier}\`)`); }); player.events.on('playerFinish', async (queue, track) => { const meta = queue.metadata as { channel: string }; - await client.createMessage(meta.channel, `Finished track: ${track.title}`); + await client.createMessage(meta.channel, `Finished track: ${track.title} (Extractor: \`${track.extractor?.identifier}\`)/Bridge: \`${track.bridgedExtractor?.identifier}\`)`); }); client.on('messageCreate', async (message) => { @@ -54,7 +54,7 @@ client.on('messageCreate', async (message) => { } }); - return client.createMessage(message.channel.id, `Loaded: ${track.title}`); + return client.createMessage(message.channel.id, `Loaded: ${track.title} (Extractor: \`${track.extractor?.identifier}\`)/Bridge: \`${track.bridgedExtractor?.identifier}\`)`); } case 'pause': { const queue = player.queues.get(message.guildID); diff --git a/packages/discord-player/__test__/Player.spec.ts b/packages/discord-player/__test__/Player.spec.ts index d0d3020af..35880532b 100644 --- a/packages/discord-player/__test__/Player.spec.ts +++ b/packages/discord-player/__test__/Player.spec.ts @@ -39,13 +39,13 @@ describe('Player', () => { expect(response).toBeInstanceOf(SearchResult); }); - test("should set process.env.FFMPEG_PATH to given path", () => { + test('should set process.env.FFMPEG_PATH to given path', () => { // not actual ffmpeg path. just dummy new Player(client, { - ffmpegPath: "./packages/ffmpeg", + ffmpegPath: './packages/ffmpeg', ignoreInstance: true - }) - - expect(process.env.FFMPEG_PATH).toBe("./packages/ffmpeg") - }) + }); + + expect(process.env.FFMPEG_PATH).toBe('./packages/ffmpeg'); + }); }); diff --git a/packages/discord-player/__test__/QueryResolver.spec.ts b/packages/discord-player/__test__/QueryResolver.spec.ts index 4b1595169..3767f263d 100644 --- a/packages/discord-player/__test__/QueryResolver.spec.ts +++ b/packages/discord-player/__test__/QueryResolver.spec.ts @@ -49,11 +49,11 @@ describe('QueryResolver', () => { expect(qr.resolve(query).type).toBe(QueryType.VIMEO); }); - it("should be soundcloud", async () => { - const query = "https://on.soundcloud.com/YVLyjzk2mmp5TJF99" - const rediected = await qr.preResolve(query) - expect(rediected).toMatch(qr.regex.soundcloudTrackRegex) - }) + it('should be soundcloud', async () => { + const query = 'https://on.soundcloud.com/YVLyjzk2mmp5TJF99'; + const rediected = await qr.preResolve(query); + expect(rediected).toMatch(qr.regex.soundcloudTrackRegex); + }); it('should be soundcloudTrack', () => { const query = 'https://soundcloud.com/rick-astley-official/never-gonna-give-you-up-4'; diff --git a/packages/discord-player/package.json b/packages/discord-player/package.json index 8a472f4e7..c31ea2426 100644 --- a/packages/discord-player/package.json +++ b/packages/discord-player/package.json @@ -1,6 +1,6 @@ { "name": "discord-player", - "version": "6.8.0-dev.0", + "version": "6.8.0-dev.1", "description": "Complete framework to facilitate music commands using discord.js", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/discord-player/src/errors/index.ts b/packages/discord-player/src/errors/index.ts index 62081a719..df6b5bcb2 100644 --- a/packages/discord-player/src/errors/index.ts +++ b/packages/discord-player/src/errors/index.ts @@ -115,28 +115,35 @@ const DiscordPlayerErrors = { name: 'ERR_SERIALIZATION_FAILED', type: Error, createError() { - return `[${this.constructor.name}]` + "Don't know how to serialize this data"; + return `[${this.type.name}]` + "Don't know how to serialize this data"; } }, ERR_DESERIALIZATION_FAILED: { name: 'ERR_DESERIALIZATION_FAILED', type: Error, createError() { - return `[${this.constructor.name}]` + "Don't know how to deserialize this data"; + return `[${this.type.name}]` + "Don't know how to deserialize this data"; } }, ERR_ILLEGAL_HOOK_INVOCATION: { name: 'ERR_ILLEGAL_HOOK_INVOCATION', type: Error, createError(target: string, message?: string) { - return `[${this.constructor.name}]` + `Illegal invocation of ${target} hook.${message ? ` ${message}` : ''}`; + return `[${this.type.name}]` + `Illegal invocation of ${target} hook.${message ? ` ${message}` : ''}`; } }, ERR_NOT_EXISTING_MODULE: { name: 'ERR_NOT_EXISTING_MODULE', type: Error, createError(target: string, description = '') { - return `[${this.constructor.name}]` + `${target} module does not exist. Install it with \`npm install ${target}\`.${description ? ' ' + description : ''}`; + return `[${this.type.name}]` + `${target} module does not exist. Install it with \`npm install ${target}\`.${description ? ' ' + description : ''}`; + } + }, + ERR_BRIDGE_FAILED: { + name: 'ERR_BRIDGE_FAILED', + type: Error, + createError(id: string | null, error: string) { + return `[${this.type.name}]` + `${id ? `(Extractor Execution Context ID is ${id})` : ''}Failed to bridge this query:\n${error}`; } } } as const; @@ -160,8 +167,7 @@ const handler: ProxyHandler = { return (...args: Parameters<(typeof err)['createError']>) => { // @ts-expect-error const exception = new err.type(err.createError(...args)); - const originalName = exception.name; - exception.name = `${err.name} [${originalName}]`; + exception.name = err.name; return exception; }; diff --git a/packages/discord-player/src/extractors/ExtractorExecutionContext.ts b/packages/discord-player/src/extractors/ExtractorExecutionContext.ts index 601650e90..83bffd7a6 100644 --- a/packages/discord-player/src/extractors/ExtractorExecutionContext.ts +++ b/packages/discord-player/src/extractors/ExtractorExecutionContext.ts @@ -5,6 +5,8 @@ import { Util } from '../utils/Util'; import { PlayerEventsEmitter } from '../utils/PlayerEventsEmitter'; import { TypeUtil } from '../utils/TypeUtil'; import { Track } from '../fabric'; +import { createContext } from '../hooks'; +import { Exceptions } from '../errors'; // prettier-ignore const knownExtractorKeys = [ @@ -23,6 +25,12 @@ export type ExtractorLoaderOptionDict = { [K in (typeof knownExtractorKeys)[number]]?: ConstructorParameters[1]; }; +export interface ExtractorSession { + id: string; + attemptedExtractors: Set; + bridgeAttemptedExtractors: Set; +} + export interface ExtractorExecutionEvents { /** * Emitted when a extractor is registered @@ -62,10 +70,26 @@ export class ExtractorExecutionContext extends PlayerEventsEmitter(); + public readonly context = createContext(); + public constructor(public player: Player) { super(['error']); } + /** + * Get the current execution id + */ + public getExecutionId(): string | null { + return this.context.consume()?.id ?? null; + } + + /** + * Get the current execution context + */ + public getContext() { + return this.context.consume() ?? null; + } + /** * Load default extractors from `@discord-player/extractor` */ @@ -228,12 +252,26 @@ export class ExtractorExecutionContext extends PlayerEventsEmitter(async (ext) => { + const previouslyAttempted = this.getContext()?.bridgeAttemptedExtractors ?? new Set(); + + const result = await this.run(async (ext) => { if (sourceExtractor && ext.identifier === sourceExtractor.identifier) return false; + if (previouslyAttempted.has(ext.identifier)) return false; + + previouslyAttempted.add(ext.identifier); + const result = await ext.bridge(track, sourceExtractor); + if (!result) return false; + return result; }); + + if (!result?.result) throw Exceptions.ERR_BRIDGE_FAILED(this.getExecutionId(), result?.error?.stack || result?.error?.message || 'No extractors available to bridge'); + + track.bridgedExtractor = result.extractor; + + return result; } /** diff --git a/packages/discord-player/src/fabric/Track.ts b/packages/discord-player/src/fabric/Track.ts index 043b629e3..a27c82144 100644 --- a/packages/discord-player/src/fabric/Track.ts +++ b/packages/discord-player/src/fabric/Track.ts @@ -30,6 +30,7 @@ export class Track { public requestedBy: User | null = null; public playlist?: Playlist; public queryType: SearchQueryType | null | undefined = null; + // eslint-disable-next-line @typescript-eslint/no-explicit-any public raw: any; public extractor: BaseExtractor | null = null; public readonly id = SnowflakeUtil.generate().toString(); @@ -37,6 +38,8 @@ export class Track { private __reqMetadataFn: () => Promise; public cleanTitle: string; public live: boolean = false; + public bridgedExtractor: BaseExtractor | null = null; + public bridgedTrack: Track | null = null; /** * Track constructor diff --git a/packages/discord-player/src/queue/GuildNodeManager.ts b/packages/discord-player/src/queue/GuildNodeManager.ts index bcbb9246c..47e53077e 100644 --- a/packages/discord-player/src/queue/GuildNodeManager.ts +++ b/packages/discord-player/src/queue/GuildNodeManager.ts @@ -39,6 +39,7 @@ export interface GuildNodeCreateOptions { disableFilterer?: boolean; disableBiquad?: boolean; disableResampler?: boolean; + disableFallbackStream?: boolean; } export type NodeResolvable = GuildQueue | GuildResolvable; @@ -87,6 +88,7 @@ export class GuildNodeManager { options.disableFilterer ??= false; options.disableVolume ??= false; options.disableResampler ??= true; + options.disableFallbackStream ??= false; if (getGlobalRegistry().has('@[onBeforeCreateStream]') && !options.onBeforeCreateStream) { options.onBeforeCreateStream = getGlobalRegistry().get('@[onBeforeCreateStream]') as OnBeforeCreateStreamHandler; @@ -128,7 +130,8 @@ export class GuildNodeManager { disableEqualizer: options.disableEqualizer, disableFilterer: options.disableFilterer, disableResampler: options.disableResampler, - disableVolume: options.disableVolume + disableVolume: options.disableVolume, + disableFallbackStream: options.disableFallbackStream }); this.cache.set(server.id, queue); @@ -168,6 +171,7 @@ export class GuildNodeManager { queue.setTransitioning(true); queue.node.stop(true); + // @ts-ignore queue.connection?.removeAllListeners(); queue.dispatcher?.removeAllListeners(); queue.dispatcher?.disconnect(); diff --git a/packages/discord-player/src/queue/GuildQueue.ts b/packages/discord-player/src/queue/GuildQueue.ts index 53f9e9013..8b0cf6db4 100644 --- a/packages/discord-player/src/queue/GuildQueue.ts +++ b/packages/discord-player/src/queue/GuildQueue.ts @@ -53,6 +53,7 @@ export interface GuildNodeInit { disableFilterer: boolean; disableBiquad: boolean; disableResampler: boolean; + disableFallbackStream: boolean; } export interface VoiceConnectConfig { diff --git a/packages/discord-player/src/queue/GuildQueuePlayerNode.ts b/packages/discord-player/src/queue/GuildQueuePlayerNode.ts index 64240aa1b..f399655ab 100644 --- a/packages/discord-player/src/queue/GuildQueuePlayerNode.ts +++ b/packages/discord-player/src/queue/GuildQueuePlayerNode.ts @@ -148,16 +148,23 @@ export class GuildQueuePlayerNode { const prefersBridgedMetadata = this.queue.options.preferBridgedMetadata; const track = this.queue.currentTrack; - if (prefersBridgedMetadata && track?.metadata != null && typeof track.metadata === 'object' && 'bridge' in track.metadata && track.metadata.bridge != null) { - const duration = ( - track as Track<{ - bridge: { - duration: number; - }; - }> - ).metadata?.bridge.duration; - - if (TypeUtil.isNumber(duration)) return duration; + if (prefersBridgedMetadata) { + const trackHasLegacyMetadata = track?.metadata != null && typeof track.metadata === 'object' && 'bridge' in track.metadata && track.metadata.bridge != null; + const trackHasMetadata = track?.bridgedTrack != null; + + if (trackHasLegacyMetadata || trackHasMetadata) { + const duration = + track.bridgedTrack?.durationMS ?? + ( + track as Track<{ + bridge: { + duration: number; + }; + }> + ).metadata?.bridge.duration; + + if (TypeUtil.isNumber(duration)) return duration; + } } return track?.durationMS ?? 0; @@ -516,21 +523,29 @@ export class GuildQueuePlayerNode { // default behavior when 'onBeforeCreateStream' did not panic if (!streamSrc.stream) { if (this.queue.hasDebugger) this.queue.debug('Failed to get stream from onBeforeCreateStream!'); - await this.#createGenericStream(track).then( - (r) => { - if (r?.result) { - streamSrc.stream = r.result; - return; - } - - if (r?.error) { - streamSrc.error = r.error; - return; - } - - streamSrc.stream = streamSrc.error = null; + await this.queue.player.extractors.context.provide( + { + id: crypto.randomUUID(), + attemptedExtractors: new Set(), + bridgeAttemptedExtractors: new Set() }, - (e: Error) => (streamSrc.error = e) + () => + this.#createGenericStream(track).then( + (r) => { + if (r?.result) { + streamSrc.stream = r.result; + return; + } + + if (r?.error) { + streamSrc.error = r.error; + return; + } + + streamSrc.stream = streamSrc.error = null; + }, + (e: Error) => (streamSrc.error = e) + ) ); } @@ -694,14 +709,28 @@ export class GuildQueuePlayerNode { async #createGenericStream(track: Track) { if (this.queue.hasDebugger) this.queue.debug(`Attempting to extract stream for Track { title: ${track.title}, url: ${track.url} } using registered extractors`); + + const attemptedExtractors = this.queue.player.extractors.getContext()?.attemptedExtractors || new Set(); + const streamInfo = await this.queue.player.extractors.run(async (extractor) => { if (this.queue.player.options.blockStreamFrom?.some((ext) => ext === extractor.identifier)) return false; + if (attemptedExtractors.has(extractor.identifier)) return false; + attemptedExtractors.add(extractor.identifier); const canStream = await extractor.validate(track.url, track.queryType || QueryResolver.resolve(track.url).type); if (!canStream) return false; return await extractor.stream(track); }, false); + if (!streamInfo || !streamInfo.result) { - if (this.queue.hasDebugger) this.queue.debug(`Failed to extract stream for Track { title: ${track.title}, url: ${track.url} } using registered extractors`); + if (this.queue.hasDebugger) { + this.queue.debug(`Failed to extract stream for Track { title: ${track.title}, url: ${track.url} } using registered extractors`); + } + + if (!this.queue.options.disableFallbackStream) { + if (this.queue.hasDebugger) this.queue.debug(`Generic stream extraction failed and fallback stream extraction is enabled`); + return this.#createFallbackStream(track); + } + return streamInfo || null; } @@ -711,6 +740,41 @@ export class GuildQueuePlayerNode { return streamInfo; } + async #createFallbackStream(track: Track) { + if (this.queue.hasDebugger) this.queue.debug(`Attempting to extract stream for Track { title: ${track.title}, url: ${track.url} } using fallback streaming method...`); + + const fallbackStream = await this.queue.player.extractors.run(async (extractor) => { + if (extractor.identifier === track.extractor?.identifier) return false; + if (this.queue.player.options.blockStreamFrom?.some((ext) => ext === extractor.identifier)) return false; + + const query = `${track.title} ${track.author}`; + const fallbackTracks = await extractor.handle(query, { + requestedBy: track.requestedBy + }); + + const fallbackTrack = fallbackTracks.tracks[0]; + + if (!fallbackTrack) return false; + + const stream = await extractor.stream(fallbackTrack); + + if (!stream) return false; + + track.bridgedTrack = fallbackTrack; + + return stream; + }, true); + + if (!fallbackStream || !fallbackStream.result) { + if (this.queue.hasDebugger) this.queue.debug(`Failed to extract stream for Track { title: ${track.title}, url: ${track.url} } using fallback streaming method`); + return fallbackStream || null; + } + + track.bridgedExtractor = fallbackStream.extractor; + + return fallbackStream; + } + #createFFmpegStream(stream: Readable | string, track: Track, seek = 0, cookies?: string, opus?: boolean) { const ffmpegStream = this.queue.filters.ffmpeg .createStream(stream, { diff --git a/packages/discord-player/src/utils/QueryResolver.ts b/packages/discord-player/src/utils/QueryResolver.ts index 0ce35ede7..302f9e946 100644 --- a/packages/discord-player/src/utils/QueryResolver.ts +++ b/packages/discord-player/src/utils/QueryResolver.ts @@ -59,7 +59,7 @@ class QueryResolver { appleMusicSongRegex, soundcloudTrackRegex, soundcloudPlaylistRegex, - youtubePlaylistRegex, + youtubePlaylistRegex }; } diff --git a/packages/extractor/package.json b/packages/extractor/package.json index 8f97ac354..6f97d0871 100644 --- a/packages/extractor/package.json +++ b/packages/extractor/package.json @@ -1,6 +1,6 @@ { "name": "@discord-player/extractor", - "version": "4.5.1", + "version": "4.6.0-dev.0", "description": "Extractors for discord-player", "keywords": [ "discord-player", diff --git a/packages/extractor/src/extractors/SoundCloudExtractor.ts b/packages/extractor/src/extractors/SoundCloudExtractor.ts index 446898b1d..cfef1c534 100644 --- a/packages/extractor/src/extractors/SoundCloudExtractor.ts +++ b/packages/extractor/src/extractors/SoundCloudExtractor.ts @@ -238,6 +238,13 @@ export class SoundCloudExtractor extends BaseExtractor if (!info.tracks.length) return null; - return this.stream(info.tracks[0]); + const result = await this.stream(info.tracks[0]); + + if (result) { + track.bridgedTrack = info.tracks[0]; + track.bridgedExtractor = this; + } + + return result; } } diff --git a/packages/extractor/src/extractors/YoutubeExtractor.ts b/packages/extractor/src/extractors/YoutubeExtractor.ts index 1daaeaba2..e0cf6abd1 100644 --- a/packages/extractor/src/extractors/YoutubeExtractor.ts +++ b/packages/extractor/src/extractors/YoutubeExtractor.ts @@ -279,7 +279,15 @@ export class YoutubeExtractor extends BaseExtractor { }); if (!info.tracks.length) return null; - return this.stream(info.tracks[0]); + + const result = await this.stream(info.tracks[0]); + + if (result) { + track.bridgedTrack = info.tracks[0]; + track.bridgedExtractor = this; + } + + return result; } public static validateURL(link: string) {