diff --git a/src/classes/Cobalt.ts b/src/classes/Cobalt.ts index 9eb4cc0..f1c0c00 100644 --- a/src/classes/Cobalt.ts +++ b/src/classes/Cobalt.ts @@ -110,7 +110,7 @@ export class Cobalt { body: JSON.stringify({ url, downloadMode: 'audio', - audioFormat: 'mp3', + audioFormat: 'wav', }), }); diff --git a/src/classes/DirectDownloader.ts b/src/classes/DirectDownloader.ts new file mode 100644 index 0000000..36d13a1 --- /dev/null +++ b/src/classes/DirectDownloader.ts @@ -0,0 +1,82 @@ +import { Readable } from 'stream'; +import { AudioResource, createAudioResource, demuxProbe } from '@discordjs/voice'; +import { stream as playStream } from 'play-dl'; +import { JukebotGlobals } from '../global'; +import { FFmpegProcessor } from './FfmpegProcessor'; +import { MusicDisc } from './MusicDisc'; + +export class DirectDownloader { + private readonly _disc: MusicDisc; + + public constructor(disc: MusicDisc) { + this._disc = disc; + } + + public async getResource(controller: AbortController): Promise | null> { + try { + // Use play-dl to get the best audio stream directly from the source. + // Use a typed alias for the awaited return so we can narrow safely. + type PlayDLStreamInfo = Awaited>; + + const streamInfo = (await playStream(this._disc._url, { quality: 2 })) as unknown as PlayDLStreamInfo; + + // play-dl may return either a Readable directly or an object with a + // `.stream` property containing the Readable. Narrow both cases. + let inputStream: Readable | null = null; + + if ((streamInfo as unknown as Readable).readable !== undefined) { + inputStream = streamInfo as unknown as Readable; + } else if (typeof streamInfo === 'object' && streamInfo !== null && 'stream' in streamInfo) { + inputStream = (streamInfo as { stream?: Readable }).stream ?? null; + } + + if (!inputStream) { + this._disc._requestedIn + .send({ content: `Direct download failed: no stream for ${this._disc._url}` }) + .catch(() => null); + return null; + } + + // Ensure the stream is aborted if the controller signals + if (controller) { + const abortHandler = () => { + try { + inputStream.destroy(new Error('Aborted')); + } catch (e) { + /* ignore */ + } + }; + + if (controller.signal.aborted) abortHandler(); + controller.signal.addEventListener('abort', abortHandler); + } + + const processed = FFmpegProcessor.process( + inputStream, + this._disc.playbackSpeed, + this._disc.isPitchChangedOnPlaybackSpeed, + this._disc.isReversed, + this._disc.isEcho, + controller, + ); + + const { stream: probedStream, type } = await demuxProbe(processed); + + const resource = createAudioResource(probedStream, { + inputType: type, + metadata: this._disc, + inlineVolume: JukebotGlobals.config.volumeModifier !== 1, + }); + + if (JukebotGlobals.config.volumeModifier !== 1 && resource.volume !== undefined) { + resource.volume.setVolume(JukebotGlobals.config.volumeModifier); + } + + return resource; + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + this._disc._requestedIn.send({ content: `DirectDownloader error:\n\`${msg}\`` }).catch(() => null); + return null; + } + } +} diff --git a/src/classes/MusicDisc.ts b/src/classes/MusicDisc.ts index 867ae0c..726cdf1 100644 --- a/src/classes/MusicDisc.ts +++ b/src/classes/MusicDisc.ts @@ -4,7 +4,7 @@ import { YouTubeVideo } from 'play-dl'; import { JukebotGlobals } from '../global'; import { DiscImage } from '../types'; import { awaitOrTimeout } from '../util'; -import { Cobalt } from './Cobalt'; +import { DirectDownloader } from './DirectDownloader'; /** A track that is ready to be prepared and then played. */ export class MusicDisc { @@ -91,15 +91,13 @@ export class MusicDisc { const controller = new AbortController(); - const resourcePromise = new Promise>((resolve, reject) => { - new Cobalt(this).getResource(controller).then((res) => { - if (res !== null) { - resolve(res); - } else { - reject(); - } - }); - }); + // Use the direct downloader (play-dl) exclusively — cobalt has a hard + // 1-minute limit for some YouTube sources in your environment. + const resourcePromise = (async (): Promise> => { + const direct = await new DirectDownloader(this).getResource(controller); + if (direct !== null) return direct; + throw new Error('Unable to create audio resource via direct downloader'); + })(); this._resource = await awaitOrTimeout( resourcePromise,