UNPKG

distube

Version:

A powerful Discord.js module for simplifying music commands and effortless playback of various sources with integrated audio filters.

1,552 lines (1,535 loc) 79.9 kB
var __defProp = Object.defineProperty; var __name = (target, value) => __defProp(target, "name", { value, configurable: true }); // src/type.ts var Events = /* @__PURE__ */ ((Events2) => { Events2["ERROR"] = "error"; Events2["ADD_LIST"] = "addList"; Events2["ADD_SONG"] = "addSong"; Events2["PLAY_SONG"] = "playSong"; Events2["FINISH_SONG"] = "finishSong"; Events2["EMPTY"] = "empty"; Events2["FINISH"] = "finish"; Events2["INIT_QUEUE"] = "initQueue"; Events2["NO_RELATED"] = "noRelated"; Events2["DISCONNECT"] = "disconnect"; Events2["DELETE_QUEUE"] = "deleteQueue"; Events2["FFMPEG_DEBUG"] = "ffmpegDebug"; Events2["DEBUG"] = "debug"; return Events2; })(Events || {}); var RepeatMode = /* @__PURE__ */ ((RepeatMode2) => { RepeatMode2[RepeatMode2["DISABLED"] = 0] = "DISABLED"; RepeatMode2[RepeatMode2["SONG"] = 1] = "SONG"; RepeatMode2[RepeatMode2["QUEUE"] = 2] = "QUEUE"; return RepeatMode2; })(RepeatMode || {}); var PluginType = /* @__PURE__ */ ((PluginType2) => { PluginType2["EXTRACTOR"] = "extractor"; PluginType2["INFO_EXTRACTOR"] = "info-extractor"; PluginType2["PLAYABLE_EXTRACTOR"] = "playable-extractor"; return PluginType2; })(PluginType || {}); // src/constant.ts var defaultFilters = { "3d": "apulsator=hz=0.125", bassboost: "bass=g=10", echo: "aecho=0.8:0.9:1000:0.3", flanger: "flanger", gate: "agate", haas: "haas", karaoke: "stereotools=mlev=0.1", nightcore: "asetrate=48000*1.25,aresample=48000,bass=g=5", reverse: "areverse", vaporwave: "asetrate=48000*0.8,aresample=48000,atempo=1.1", mcompand: "mcompand", phaser: "aphaser", tremolo: "tremolo", surround: "surround", earwax: "earwax" }; var defaultOptions = { plugins: [], emitNewSongOnly: false, savePreviousSongs: true, nsfw: false, emitAddSongWhenCreatingQueue: true, emitAddListWhenCreatingQueue: true, joinNewVoiceChannel: true }; // src/struct/DisTubeError.ts import { inspect } from "node:util"; var ERROR_MESSAGES = { INVALID_TYPE: /* @__PURE__ */ __name((expected, got, name) => `Expected ${Array.isArray(expected) ? expected.map((e) => typeof e === "number" ? e : `'${e}'`).join(" or ") : `'${expected}'`}${name ? ` for '${name}'` : ""}, but got ${inspect(got)} (${typeof got})`, "INVALID_TYPE"), NUMBER_COMPARE: /* @__PURE__ */ __name((name, expected, value) => `'${name}' must be ${expected} ${value}`, "NUMBER_COMPARE"), EMPTY_ARRAY: /* @__PURE__ */ __name((name) => `'${name}' is an empty array`, "EMPTY_ARRAY"), EMPTY_FILTERED_ARRAY: /* @__PURE__ */ __name((name, type) => `There is no valid '${type}' in the '${name}' array`, "EMPTY_FILTERED_ARRAY"), EMPTY_STRING: /* @__PURE__ */ __name((name) => `'${name}' string must not be empty`, "EMPTY_STRING"), INVALID_KEY: /* @__PURE__ */ __name((obj, key) => `'${key}' does not need to be provided in ${obj}`, "INVALID_KEY"), MISSING_KEY: /* @__PURE__ */ __name((obj, key) => `'${key}' needs to be provided in ${obj}`, "MISSING_KEY"), MISSING_KEYS: /* @__PURE__ */ __name((obj, key, all) => `${key.map((k) => `'${k}'`).join(all ? " and " : " or ")} need to be provided in ${obj}`, "MISSING_KEYS"), MISSING_INTENTS: /* @__PURE__ */ __name((i) => `${i} intent must be provided for the Client`, "MISSING_INTENTS"), DISABLED_OPTION: /* @__PURE__ */ __name((o) => `DisTubeOptions.${o} is disabled`, "DISABLED_OPTION"), ENABLED_OPTION: /* @__PURE__ */ __name((o) => `DisTubeOptions.${o} is enabled`, "ENABLED_OPTION"), NOT_IN_VOICE: "User is not in any voice channel", VOICE_FULL: "The voice channel is full", VOICE_ALREADY_CREATED: "This guild already has a voice connection which is not managed by DisTube", VOICE_CONNECT_FAILED: /* @__PURE__ */ __name((s) => `Cannot connect to the voice channel after ${s} seconds`, "VOICE_CONNECT_FAILED"), VOICE_MISSING_PERMS: "I do not have permission to join this voice channel", VOICE_RECONNECT_FAILED: "Cannot reconnect to the voice channel", VOICE_DIFFERENT_GUILD: "Cannot join a voice channel in a different guild", VOICE_DIFFERENT_CLIENT: "Cannot join a voice channel created by a different client", FFMPEG_EXITED: /* @__PURE__ */ __name((code) => `ffmpeg exited with code ${code}`, "FFMPEG_EXITED"), FFMPEG_NOT_INSTALLED: /* @__PURE__ */ __name((path) => `ffmpeg is not installed at '${path}' path`, "FFMPEG_NOT_INSTALLED"), ENCRYPTION_LIBRARIES_MISSING: "Cannot play audio as no valid encryption package is installed and your node doesn't support aes-256-gcm.\nPlease install @noble/ciphers, @stablelib/xchacha20poly1305, sodium-native or libsodium-wrappers.", NO_QUEUE: "There is no playing queue in this guild", QUEUE_EXIST: "This guild has a Queue already", QUEUE_STOPPED: "The queue has been stopped already", PAUSED: "The queue has been paused already", RESUMED: "The queue has been playing already", NO_PREVIOUS: "There is no previous song in this queue", NO_UP_NEXT: "There is no up next song", NO_SONG_POSITION: "Does not have any song at this position", NO_PLAYING_SONG: "There is no playing song in the queue", NO_EXTRACTOR_PLUGIN: "There is no extractor plugin in the DisTubeOptions.plugins, please add one for searching songs", NO_RELATED: "Cannot find any related songs", CANNOT_PLAY_RELATED: "Cannot play the related song", UNAVAILABLE_VIDEO: "This video is unavailable", UNPLAYABLE_FORMATS: "No playable format found", NON_NSFW: "Cannot play age-restricted content in non-NSFW channel", NOT_SUPPORTED_URL: "This url is not supported", NOT_SUPPORTED_SONG: /* @__PURE__ */ __name((song) => `There is no plugin supporting this song (${song})`, "NOT_SUPPORTED_SONG"), NO_VALID_SONG: "'songs' array does not have any valid Song or url", CANNOT_RESOLVE_SONG: /* @__PURE__ */ __name((t) => `Cannot resolve ${inspect(t)} to a Song`, "CANNOT_RESOLVE_SONG"), CANNOT_GET_STREAM_URL: /* @__PURE__ */ __name((song) => `Cannot get stream url with this song (${song})`, "CANNOT_GET_STREAM_URL"), CANNOT_GET_SEARCH_QUERY: /* @__PURE__ */ __name((song) => `Cannot get search query with this song (${song})`, "CANNOT_GET_SEARCH_QUERY"), NO_RESULT: /* @__PURE__ */ __name((query) => `Cannot find any song with this query (${query})`, "NO_RESULT"), NO_STREAM_URL: /* @__PURE__ */ __name((song) => `No stream url attached (${song})`, "NO_STREAM_URL"), EMPTY_FILTERED_PLAYLIST: "There is no valid video in the playlist\nMaybe age-restricted contents is filtered because you are in non-NSFW channel", EMPTY_PLAYLIST: "There is no valid video in the playlist" }; var haveCode = /* @__PURE__ */ __name((code) => Object.keys(ERROR_MESSAGES).includes(code), "haveCode"); var parseMessage = /* @__PURE__ */ __name((m, ...args) => typeof m === "string" ? m : m(...args), "parseMessage"); var getErrorMessage = /* @__PURE__ */ __name((code, ...args) => haveCode(code) ? parseMessage(ERROR_MESSAGES[code], ...args) : args[0], "getErrorMessage"); var DisTubeError = class _DisTubeError extends Error { static { __name(this, "DisTubeError"); } errorCode; constructor(code, ...args) { super(getErrorMessage(code, ...args)); this.errorCode = code; if (Error.captureStackTrace) Error.captureStackTrace(this, _DisTubeError); } get name() { return `DisTubeError [${this.errorCode}]`; } get code() { return this.errorCode; } }; // src/struct/TaskQueue.ts var Task = class { static { __name(this, "Task"); } resolve; promise; isPlay; constructor(isPlay) { this.isPlay = isPlay; this.promise = new Promise((res) => { this.resolve = res; }); } }; var TaskQueue = class { static { __name(this, "TaskQueue"); } /** * The task array */ #tasks = []; /** * Waits for last task finished and queues a new task */ queuing(isPlay = false) { const next = this.remaining ? this.#tasks[this.#tasks.length - 1].promise : Promise.resolve(); this.#tasks.push(new Task(isPlay)); return next; } /** * Removes the finished task and processes the next task */ resolve() { this.#tasks.shift()?.resolve(); } /** * The remaining number of tasks */ get remaining() { return this.#tasks.length; } /** * Whether or not having a play task */ get hasPlayTask() { return this.#tasks.some((t) => t.isPlay); } }; // src/struct/Playlist.ts var Playlist = class { static { __name(this, "Playlist"); } /** * Playlist source. */ source; /** * Songs in the playlist. */ songs; /** * Playlist ID. */ id; /** * Playlist name. */ name; /** * Playlist URL. */ url; /** * Playlist thumbnail. */ thumbnail; #metadata; #member; /** * Create a Playlist * @param playlist - Raw playlist info * @param options - Optional data */ constructor(playlist, { member, metadata } = {}) { if (!Array.isArray(playlist.songs) || !playlist.songs.length) throw new DisTubeError("EMPTY_PLAYLIST"); this.source = playlist.source.toLowerCase(); this.songs = playlist.songs; this.name = playlist.name; this.id = playlist.id; this.url = playlist.url; this.thumbnail = playlist.thumbnail; this.member = member; this.songs.forEach((s) => s.playlist = this); this.metadata = metadata; } /** * Playlist duration in second. */ get duration() { return this.songs.reduce((prev, next) => prev + next.duration, 0); } /** * Formatted duration string `hh:mm:ss`. */ get formattedDuration() { return formatDuration(this.duration); } /** * User requested. */ get member() { return this.#member; } set member(member) { if (!isMemberInstance(member)) return; this.#member = member; this.songs.forEach((s) => s.member = this.member); } /** * User requested. */ get user() { return this.member?.user; } /** * Optional metadata that can be used to identify the playlist. */ get metadata() { return this.#metadata; } set metadata(metadata) { this.#metadata = metadata; this.songs.forEach((s) => s.metadata = metadata); } toString() { return `${this.name} (${this.songs.length} songs)`; } }; // src/struct/Song.ts var Song = class { static { __name(this, "Song"); } /** * The source of this song info */ source; /** * Song ID. */ id; /** * Song name. */ name; /** * Indicates if the song is an active live. */ isLive; /** * Song duration. */ duration; /** * Formatted duration string (`hh:mm:ss`, `mm:ss` or `Live`). */ formattedDuration; /** * Song URL. */ url; /** * Song thumbnail. */ thumbnail; /** * Song view count */ views; /** * Song like count */ likes; /** * Song dislike count */ dislikes; /** * Song repost (share) count */ reposts; /** * Song uploader */ uploader; /** * Whether or not an age-restricted content */ ageRestricted; /** * Stream info */ stream; /** * The plugin that created this song */ plugin; #metadata; #member; #playlist; /** * Create a Song * * @param info - Raw song info * @param options - Optional data */ constructor(info, { member, metadata } = {}) { this.source = info.source.toLowerCase(); this.metadata = metadata; this.member = member; this.id = info.id; this.name = info.name; this.isLive = info.isLive; this.duration = this.isLive || !info.duration ? 0 : info.duration; this.formattedDuration = this.isLive ? "Live" : formatDuration(this.duration); this.url = info.url; this.thumbnail = info.thumbnail; this.views = info.views; this.likes = info.likes; this.dislikes = info.dislikes; this.reposts = info.reposts; this.uploader = { name: info.uploader?.name, url: info.uploader?.url }; this.ageRestricted = info.ageRestricted; this.stream = { playFromSource: info.playFromSource }; this.plugin = info.plugin; } /** * The playlist this song belongs to */ get playlist() { return this.#playlist; } set playlist(playlist) { if (!(playlist instanceof Playlist)) throw new DisTubeError("INVALID_TYPE", "Playlist", playlist, "Song#playlist"); this.#playlist = playlist; this.member = playlist.member; } /** * User requested to play this song. */ get member() { return this.#member; } set member(member) { if (isMemberInstance(member)) this.#member = member; } /** * User requested to play this song. */ get user() { return this.member?.user; } /** * Optional metadata that can be used to identify the song. This is attached by the * {@link DisTube#play} method. */ get metadata() { return this.#metadata; } set metadata(metadata) { this.#metadata = metadata; } toString() { return this.name || this.url || this.id || "Unknown"; } }; // src/core/DisTubeBase.ts var DisTubeBase = class { static { __name(this, "DisTubeBase"); } distube; constructor(distube) { this.distube = distube; } /** * Emit the {@link DisTube} of this base * @param eventName - Event name * @param args - arguments */ emit(eventName, ...args) { return this.distube.emit(eventName, ...args); } /** * Emit error event * @param error - error * @param queue - The queue encountered the error * @param song - The playing song when encountered the error */ emitError(error, queue, song) { this.distube.emitError(error, queue, song); } /** * Emit debug event * @param message - debug message */ debug(message) { this.distube.debug(message); } /** * The queue manager */ get queues() { return this.distube.queues; } /** * The voice manager */ get voices() { return this.distube.voices; } /** * Discord.js client */ get client() { return this.distube.client; } /** * DisTube options */ get options() { return this.distube.options; } /** * DisTube handler */ get handler() { return this.distube.handler; } /** * DisTube plugins */ get plugins() { return this.distube.plugins; } }; // src/core/DisTubeVoice.ts import { Constants } from "discord.js"; import { TypedEmitter } from "tiny-typed-emitter"; import { AudioPlayerStatus, VoiceConnectionDisconnectReason, VoiceConnectionStatus, createAudioPlayer, entersState, joinVoiceChannel } from "@discordjs/voice"; var DisTubeVoice = class extends TypedEmitter { static { __name(this, "DisTubeVoice"); } id; voices; audioPlayer; connection; emittedError; isDisconnected = false; stream; pausingStream; #channel; #volume = 100; constructor(voiceManager, channel) { super(); this.voices = voiceManager; this.id = channel.guildId; this.channel = channel; this.voices.add(this.id, this); this.audioPlayer = createAudioPlayer().on(AudioPlayerStatus.Idle, (oldState) => { if (oldState.status !== AudioPlayerStatus.Idle) this.emit("finish"); }).on("error", (error) => { if (this.emittedError) return; this.emittedError = true; this.emit("error", error); }); this.connection.on(VoiceConnectionStatus.Disconnected, (_, newState) => { if (newState.reason === VoiceConnectionDisconnectReason.Manual) { this.leave(); } else if (newState.reason === VoiceConnectionDisconnectReason.WebSocketClose && newState.closeCode === 4014) { entersState(this.connection, VoiceConnectionStatus.Connecting, 5e3).catch(() => { if (![VoiceConnectionStatus.Ready, VoiceConnectionStatus.Connecting].includes(this.connection.state.status)) { this.leave(); } }); } else if (this.connection.rejoinAttempts < 5) { setTimeout( () => { this.connection.rejoin(); }, (this.connection.rejoinAttempts + 1) * 5e3 ).unref(); } else if (this.connection.state.status !== VoiceConnectionStatus.Destroyed) { this.leave(new DisTubeError("VOICE_RECONNECT_FAILED")); } }).on(VoiceConnectionStatus.Destroyed, () => { this.leave(); }).on("error", () => void 0); this.connection.subscribe(this.audioPlayer); } /** * The voice channel id the bot is in */ get channelId() { return this.connection?.joinConfig?.channelId ?? void 0; } get channel() { if (!this.channelId) return this.#channel; if (this.#channel?.id === this.channelId) return this.#channel; const channel = this.voices.client.channels.cache.get(this.channelId); if (!channel) return this.#channel; for (const type of Constants.VoiceBasedChannelTypes) { if (channel.type === type) { this.#channel = channel; return channel; } } return this.#channel; } set channel(channel) { if (!isSupportedVoiceChannel(channel)) { throw new DisTubeError("INVALID_TYPE", "BaseGuildVoiceChannel", channel, "DisTubeVoice#channel"); } if (channel.guildId !== this.id) throw new DisTubeError("VOICE_DIFFERENT_GUILD"); if (channel.client.user?.id !== this.voices.client.user?.id) throw new DisTubeError("VOICE_DIFFERENT_CLIENT"); if (channel.id === this.channelId) return; if (!channel.joinable) { if (channel.full) throw new DisTubeError("VOICE_FULL"); else throw new DisTubeError("VOICE_MISSING_PERMS"); } this.connection = this.#join(channel); this.#channel = channel; } #join(channel) { return joinVoiceChannel({ channelId: channel.id, guildId: this.id, adapterCreator: channel.guild.voiceAdapterCreator, group: channel.client.user?.id }); } /** * Join a voice channel with this connection * @param channel - A voice channel */ async join(channel) { const TIMEOUT = 3e4; if (channel) this.channel = channel; try { await entersState(this.connection, VoiceConnectionStatus.Ready, TIMEOUT); } catch { if (this.connection.state.status === VoiceConnectionStatus.Ready) return this; if (this.connection.state.status !== VoiceConnectionStatus.Destroyed) this.connection.destroy(); this.voices.remove(this.id); throw new DisTubeError("VOICE_CONNECT_FAILED", TIMEOUT / 1e3); } return this; } /** * Leave the voice channel of this connection * @param error - Optional, an error to emit with 'error' event. */ leave(error) { this.stop(true); if (!this.isDisconnected) { this.emit("disconnect", error); this.isDisconnected = true; } if (this.connection.state.status !== VoiceConnectionStatus.Destroyed) this.connection.destroy(); this.voices.remove(this.id); } /** * Stop the playing stream * @param force - If true, will force the {@link DisTubeVoice#audioPlayer} to enter the Idle state even * if the {@link DisTubeStream#audioResource} has silence padding frames. */ stop(force = false) { this.audioPlayer.stop(force); } /** * Play a {@link DisTubeStream} * @param dtStream - DisTubeStream */ async play(dtStream) { if (!await checkEncryptionLibraries()) { dtStream.kill(); throw new DisTubeError("ENCRYPTION_LIBRARIES_MISSING"); } this.emittedError = false; dtStream.on("error", (error) => { if (this.emittedError || error.code === "ERR_STREAM_PREMATURE_CLOSE") return; this.emittedError = true; this.emit("error", error); }); if (this.audioPlayer.state.status !== AudioPlayerStatus.Paused) { this.audioPlayer.play(dtStream.audioResource); this.stream?.kill(); dtStream.spawn(); } else if (!this.pausingStream) { this.pausingStream = this.stream; } this.stream = dtStream; this.volume = this.#volume; } set volume(volume) { if (typeof volume !== "number" || isNaN(volume)) { throw new DisTubeError("INVALID_TYPE", "number", volume, "volume"); } if (volume < 0) { throw new DisTubeError("NUMBER_COMPARE", "Volume", "bigger or equal to", 0); } this.#volume = volume; this.stream?.setVolume(Math.pow(this.#volume / 100, 0.5 / Math.log10(2))); } /** * Get or set the volume percentage */ get volume() { return this.#volume; } /** * Playback duration of the audio resource in seconds */ get playbackDuration() { return (this.stream?.audioResource?.playbackDuration ?? 0) / 1e3; } pause() { this.audioPlayer.pause(); } unpause() { const state = this.audioPlayer.state; if (state.status !== AudioPlayerStatus.Paused) return; if (this.stream?.audioResource && state.resource !== this.stream.audioResource) { this.audioPlayer.play(this.stream.audioResource); this.stream.spawn(); this.pausingStream?.kill(); delete this.pausingStream; } else { this.audioPlayer.unpause(); } } /** * Whether the bot is self-deafened */ get selfDeaf() { return this.connection.joinConfig.selfDeaf; } /** * Whether the bot is self-muted */ get selfMute() { return this.connection.joinConfig.selfMute; } /** * Self-deafens/undeafens the bot. * @param selfDeaf - Whether or not the bot should be self-deafened * @returns true if the voice state was successfully updated, otherwise false */ setSelfDeaf(selfDeaf) { if (typeof selfDeaf !== "boolean") { throw new DisTubeError("INVALID_TYPE", "boolean", selfDeaf, "selfDeaf"); } return this.connection.rejoin({ ...this.connection.joinConfig, selfDeaf }); } /** * Self-mutes/unmutes the bot. * @param selfMute - Whether or not the bot should be self-muted * @returns true if the voice state was successfully updated, otherwise false */ setSelfMute(selfMute) { if (typeof selfMute !== "boolean") { throw new DisTubeError("INVALID_TYPE", "boolean", selfMute, "selfMute"); } return this.connection.rejoin({ ...this.connection.joinConfig, selfMute }); } /** * The voice state of this connection */ get voiceState() { return this.channel?.guild?.members?.me?.voice; } }; // src/core/DisTubeStream.ts import { Transform } from "stream"; import { spawn, spawnSync } from "child_process"; import { TypedEmitter as TypedEmitter2 } from "tiny-typed-emitter"; import { StreamType, createAudioResource } from "@discordjs/voice"; var checked = process.env.NODE_ENV === "test"; var checkFFmpeg = /* @__PURE__ */ __name((distube) => { if (checked) return; const path = distube.options.ffmpeg.path; const debug = /* @__PURE__ */ __name((str) => distube.emit("ffmpegDebug" /* FFMPEG_DEBUG */, str), "debug"); try { debug(`[test] spawn ffmpeg at '${path}' path`); const process2 = spawnSync(path, ["-h"], { windowsHide: true, shell: true, encoding: "utf-8" }); if (process2.error) throw process2.error; if (process2.stderr && !process2.stdout) throw new Error(process2.stderr); const result = process2.output.join("\n"); const version2 = /ffmpeg version (\S+)/iu.exec(result)?.[1]; if (!version2) throw new Error("Invalid FFmpeg version"); debug(`[test] ffmpeg version: ${version2}`); } catch (e) { debug(`[test] failed to spawn ffmpeg at '${path}': ${e?.stack ?? e}`); throw new DisTubeError("FFMPEG_NOT_INSTALLED", path); } checked = true; }, "checkFFmpeg"); var DisTubeStream = class extends TypedEmitter2 { static { __name(this, "DisTubeStream"); } #ffmpegPath; #opts; process; stream; audioResource; /** * Create a DisTubeStream to play with {@link DisTubeVoice} * @param url - Stream URL * @param options - Stream options */ constructor(url, options) { super(); const { ffmpeg, seek } = options; const opts = { reconnect: 1, reconnect_streamed: 1, reconnect_delay_max: 5, analyzeduration: 0, hide_banner: true, ...ffmpeg.args.global, ...ffmpeg.args.input, i: url, ar: 48e3, ac: 2, ...ffmpeg.args.output, f: "s16le" }; if (typeof seek === "number" && seek > 0) opts.ss = seek.toString(); const fileUrl = new URL(url); if (fileUrl.protocol === "file:") { opts.reconnect = null; opts.reconnect_streamed = null; opts.reconnect_delay_max = null; opts.i = fileUrl.hostname + fileUrl.pathname; } this.#ffmpegPath = ffmpeg.path; this.#opts = [ ...Object.entries(opts).flatMap( ([key, value]) => Array.isArray(value) ? value.filter(Boolean).map((v) => [`-${key}`, String(v)]) : value == null || value === false ? [] : [value === true ? `-${key}` : [`-${key}`, String(value)]] ).flat(), "pipe:1" ]; this.stream = new VolumeTransformer(); this.stream.on("close", () => this.kill()).on("error", (err) => { this.debug(`[stream] error: ${err.message}`); this.emit("error", err); }).on("finish", () => this.debug("[stream] log: stream finished")); this.audioResource = createAudioResource(this.stream, { inputType: StreamType.Raw, inlineVolume: false }); } spawn() { this.debug(`[process] spawn: ${this.#ffmpegPath} ${this.#opts.join(" ")}`); this.process = spawn(this.#ffmpegPath, this.#opts, { stdio: ["ignore", "pipe", "pipe"], shell: false, windowsHide: true }).on("error", (err) => { this.debug(`[process] error: ${err.message}`); this.emit("error", err); }).on("exit", (code, signal) => { this.debug(`[process] exit: code=${code ?? "unknown"} signal=${signal ?? "unknown"}`); if (!code || [0, 255].includes(code)) return; this.debug(`[process] error: ffmpeg exited with code ${code}`); this.emit("error", new DisTubeError("FFMPEG_EXITED", code)); }); if (!this.process.stdout || !this.process.stderr) { this.kill(); throw new Error("Failed to create ffmpeg process"); } this.process.stdout.pipe(this.stream); this.process.stderr.setEncoding("utf8")?.on("data", (data) => { const lines = data.split(/\r\n|\r|\n/u); for (const line of lines) { if (/^\s*$/.test(line)) continue; this.debug(`[ffmpeg] log: ${line}`); } }); } debug(debug) { this.emit("debug", debug); } setVolume(volume) { this.stream.vol = volume; } kill() { if (!this.stream.destroyed) this.stream.destroy(); if (this.process && !this.process.killed) this.process.kill("SIGKILL"); } }; var VolumeTransformer = class extends Transform { static { __name(this, "VolumeTransformer"); } buffer = Buffer.allocUnsafe(0); extrema = [-Math.pow(2, 16 - 1), Math.pow(2, 16 - 1) - 1]; vol = 1; _transform(newChunk, _encoding, done) { const { vol } = this; if (vol === 1) { this.push(newChunk); done(); return; } const bytes = 2; const chunk = Buffer.concat([this.buffer, newChunk]); const readableLength = Math.floor(chunk.length / bytes) * bytes; for (let i = 0; i < readableLength; i += bytes) { const value = chunk.readInt16LE(i); const clampedValue = Math.min(this.extrema[1], Math.max(this.extrema[0], value * vol)); chunk.writeInt16LE(clampedValue, i); } this.buffer = chunk.subarray(readableLength); this.push(chunk.subarray(0, readableLength)); done(); } }; // src/core/DisTubeHandler.ts import { request } from "undici"; var REDIRECT_CODES = /* @__PURE__ */ new Set([301, 302, 303, 307, 308]); var DisTubeHandler = class extends DisTubeBase { static { __name(this, "DisTubeHandler"); } /** * Resolve a url or a supported object to a {@link Song} or {@link Playlist} * @throws {@link DisTubeError} * @param input - Resolvable input * @param options - Optional options * @returns Resolved */ async resolve(input, options = {}) { if (input instanceof Song || input instanceof Playlist) { if ("metadata" in options) input.metadata = options.metadata; if ("member" in options) input.member = options.member; return input; } if (typeof input === "string") { if (isURL(input)) { const plugin = await this._getPluginFromURL(input) || await this._getPluginFromURL(input = await this.followRedirectLink(input)); if (!plugin) throw new DisTubeError("NOT_SUPPORTED_URL"); this.debug(`[${plugin.constructor.name}] Resolving from url: ${input}`); return plugin.resolve(input, options); } try { const song = await this.#searchSong(input, options); if (song) return song; } catch { throw new DisTubeError("NO_RESULT", input); } } throw new DisTubeError("CANNOT_RESOLVE_SONG", input); } async _getPluginFromURL(url) { for (const plugin of this.plugins) if (await plugin.validate(url)) return plugin; return null; } async _getPluginFromSong(song, types, validate = true) { if (!types || types.includes(song.plugin?.type)) return song.plugin; if (!song.url) return null; for (const plugin of this.plugins) { if ((!types || types.includes(plugin?.type)) && (!validate || await plugin.validate(song.url))) { return plugin; } } return null; } async #searchSong(query, options = {}, getStreamURL = false) { const plugins = this.plugins.filter((p) => p.type === "extractor" /* EXTRACTOR */); if (!plugins.length) throw new DisTubeError("NO_EXTRACTOR_PLUGIN"); for (const plugin of plugins) { this.debug(`[${plugin.constructor.name}] Searching for song: ${query}`); const result = await plugin.searchSong(query, options); if (result) { if (getStreamURL && result.stream.playFromSource) result.stream.url = await plugin.getStreamURL(result); return result; } } return null; } /** * Get {@link Song}'s stream info and attach it to the song. * @param song - A Song */ async attachStreamInfo(song) { if (song.stream.playFromSource) { if (song.stream.url) return; this.debug(`[DisTubeHandler] Getting stream info: ${song}`); const plugin = await this._getPluginFromSong(song, ["extractor" /* EXTRACTOR */, "playable-extractor" /* PLAYABLE_EXTRACTOR */]); if (!plugin) throw new DisTubeError("NOT_SUPPORTED_SONG", song.toString()); this.debug(`[${plugin.constructor.name}] Getting stream URL: ${song}`); song.stream.url = await plugin.getStreamURL(song); if (!song.stream.url) throw new DisTubeError("CANNOT_GET_STREAM_URL", song.toString()); } else { if (song.stream.song?.stream?.playFromSource && song.stream.song.stream.url) return; this.debug(`[DisTubeHandler] Getting stream info: ${song}`); const plugin = await this._getPluginFromSong(song, ["info-extractor" /* INFO_EXTRACTOR */]); if (!plugin) throw new DisTubeError("NOT_SUPPORTED_SONG", song.toString()); this.debug(`[${plugin.constructor.name}] Creating search query for: ${song}`); const query = await plugin.createSearchQuery(song); if (!query) throw new DisTubeError("CANNOT_GET_SEARCH_QUERY", song.toString()); const altSong = await this.#searchSong(query, { metadata: song.metadata, member: song.member }, true); if (!altSong || !altSong.stream.playFromSource) throw new DisTubeError("NO_RESULT", query || song.toString()); song.stream.song = altSong; } } async followRedirectLink(url, maxRedirect = 5) { if (maxRedirect === 0) return url; const res = await request(url, { method: "HEAD", headers: { "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.3" } }); if (REDIRECT_CODES.has(res.statusCode ?? 200)) { let location = res.headers.location; if (typeof location !== "string") location = location?.[0] ?? url; return this.followRedirectLink(location, --maxRedirect); } return url; } }; // src/core/DisTubeOptions.ts var Options = class { static { __name(this, "Options"); } plugins; emitNewSongOnly; savePreviousSongs; customFilters; nsfw; emitAddSongWhenCreatingQueue; emitAddListWhenCreatingQueue; joinNewVoiceChannel; ffmpeg; constructor(options) { if (typeof options !== "object" || Array.isArray(options)) { throw new DisTubeError("INVALID_TYPE", "object", options, "DisTubeOptions"); } const opts = { ...defaultOptions, ...options }; this.plugins = opts.plugins; this.emitNewSongOnly = opts.emitNewSongOnly; this.savePreviousSongs = opts.savePreviousSongs; this.customFilters = opts.customFilters; this.nsfw = opts.nsfw; this.emitAddSongWhenCreatingQueue = opts.emitAddSongWhenCreatingQueue; this.emitAddListWhenCreatingQueue = opts.emitAddListWhenCreatingQueue; this.joinNewVoiceChannel = opts.joinNewVoiceChannel; this.ffmpeg = this.#ffmpegOption(options); checkInvalidKey(opts, this, "DisTubeOptions"); this.#validateOptions(); } #validateOptions(options = this) { const booleanOptions = /* @__PURE__ */ new Set([ "emitNewSongOnly", "savePreviousSongs", "joinNewVoiceChannel", "nsfw", "emitAddSongWhenCreatingQueue", "emitAddListWhenCreatingQueue" ]); const numberOptions = /* @__PURE__ */ new Set(); const stringOptions = /* @__PURE__ */ new Set(); const objectOptions = /* @__PURE__ */ new Set(["customFilters", "ffmpeg"]); const optionalOptions = /* @__PURE__ */ new Set(["customFilters"]); for (const [key, value] of Object.entries(options)) { if (value === void 0 && optionalOptions.has(key)) continue; if (key === "plugins" && !Array.isArray(value)) { throw new DisTubeError("INVALID_TYPE", "Array<Plugin>", value, `DisTubeOptions.${key}`); } else if (booleanOptions.has(key)) { if (typeof value !== "boolean") { throw new DisTubeError("INVALID_TYPE", "boolean", value, `DisTubeOptions.${key}`); } } else if (numberOptions.has(key)) { if (typeof value !== "number" || isNaN(value)) { throw new DisTubeError("INVALID_TYPE", "number", value, `DisTubeOptions.${key}`); } } else if (stringOptions.has(key)) { if (typeof value !== "string") { throw new DisTubeError("INVALID_TYPE", "string", value, `DisTubeOptions.${key}`); } } else if (objectOptions.has(key)) { if (typeof value !== "object" || Array.isArray(value)) { throw new DisTubeError("INVALID_TYPE", "object", value, `DisTubeOptions.${key}`); } } } } #ffmpegOption(opts) { const args = { global: {}, input: {}, output: {} }; if (opts.ffmpeg?.args) { if (opts.ffmpeg.args.global) args.global = opts.ffmpeg.args.global; if (opts.ffmpeg.args.input) args.input = opts.ffmpeg.args.input; if (opts.ffmpeg.args.output) args.output = opts.ffmpeg.args.output; } const path = opts.ffmpeg?.path ?? "ffmpeg"; if (typeof path !== "string") { throw new DisTubeError("INVALID_TYPE", "string", path, "DisTubeOptions.ffmpeg.path"); } for (const [key, value] of Object.entries(args)) { if (typeof value !== "object" || Array.isArray(value)) { throw new DisTubeError("INVALID_TYPE", "object", value, `DisTubeOptions.ffmpeg.${key}`); } for (const [k, v] of Object.entries(value)) { if (typeof v !== "string" && typeof v !== "number" && typeof v !== "boolean" && !Array.isArray(v) && v !== null && v !== void 0) { throw new DisTubeError( "INVALID_TYPE", ["string", "number", "boolean", "Array<string | null | undefined>", "null", "undefined"], v, `DisTubeOptions.ffmpeg.${key}.${k}` ); } } } return { path, args }; } }; // src/core/manager/BaseManager.ts import { Collection } from "discord.js"; var BaseManager = class extends DisTubeBase { static { __name(this, "BaseManager"); } /** * The collection of items for this manager. */ collection = new Collection(); /** * The size of the collection. */ get size() { return this.collection.size; } }; // src/core/manager/GuildIdManager.ts var GuildIdManager = class extends BaseManager { static { __name(this, "GuildIdManager"); } add(idOrInstance, data) { const id = resolveGuildId(idOrInstance); const existing = this.get(id); if (existing) return this; this.collection.set(id, data); return this; } get(idOrInstance) { return this.collection.get(resolveGuildId(idOrInstance)); } remove(idOrInstance) { return this.collection.delete(resolveGuildId(idOrInstance)); } has(idOrInstance) { return this.collection.has(resolveGuildId(idOrInstance)); } }; // src/core/manager/DisTubeVoiceManager.ts import { VoiceConnectionStatus as VoiceConnectionStatus2, getVoiceConnection } from "@discordjs/voice"; var DisTubeVoiceManager = class extends GuildIdManager { static { __name(this, "DisTubeVoiceManager"); } /** * Create a {@link DisTubeVoice} instance * @param channel - A voice channel to join */ create(channel) { const existing = this.get(channel.guildId); if (existing) { existing.channel = channel; return existing; } if (getVoiceConnection(resolveGuildId(channel), this.client.user?.id) || getVoiceConnection(resolveGuildId(channel))) { throw new DisTubeError("VOICE_ALREADY_CREATED"); } return new DisTubeVoice(this, channel); } /** * Join a voice channel and wait until the connection is ready * @param channel - A voice channel to join */ join(channel) { const existing = this.get(channel.guildId); if (existing) return existing.join(channel); return this.create(channel).join(); } /** * Leave the connected voice channel in a guild * @param guild - Queue Resolvable */ leave(guild) { const voice = this.get(guild); if (voice) { voice.leave(); } else { const connection = getVoiceConnection(resolveGuildId(guild), this.client.user?.id) ?? getVoiceConnection(resolveGuildId(guild)); if (connection && connection.state.status !== VoiceConnectionStatus2.Destroyed) { connection.destroy(); } } } }; // src/core/manager/FilterManager.ts var FilterManager = class extends BaseManager { static { __name(this, "FilterManager"); } /** * The queue to manage */ queue; constructor(queue) { super(queue.distube); this.queue = queue; } #resolve(filter) { if (typeof filter === "object" && typeof filter.name === "string" && typeof filter.value === "string") { return filter; } if (typeof filter === "string" && Object.prototype.hasOwnProperty.call(this.distube.filters, filter)) { return { name: filter, value: this.distube.filters[filter] }; } throw new DisTubeError("INVALID_TYPE", "FilterResolvable", filter, "filter"); } #apply() { this.queue._beginTime = this.queue.currentTime; this.queue.play(false); } /** * Enable a filter or multiple filters to the manager * @param filterOrFilters - The filter or filters to enable * @param override - Wether or not override the applied filter with new filter value */ add(filterOrFilters, override = false) { if (Array.isArray(filterOrFilters)) { for (const filter of filterOrFilters) { const ft = this.#resolve(filter); if (override || !this.has(ft)) this.collection.set(ft.name, ft); } } else { const ft = this.#resolve(filterOrFilters); if (override || !this.has(ft)) this.collection.set(ft.name, ft); } this.#apply(); return this; } /** * Clear enabled filters of the manager */ clear() { return this.set([]); } /** * Set the filters applied to the manager * @param filters - The filters to apply */ set(filters) { if (!Array.isArray(filters)) throw new DisTubeError("INVALID_TYPE", "Array<FilterResolvable>", filters, "filters"); this.collection.clear(); for (const f of filters) { const filter = this.#resolve(f); this.collection.set(filter.name, filter); } this.#apply(); return this; } #removeFn(f) { return this.collection.delete(this.#resolve(f).name); } /** * Disable a filter or multiple filters * @param filterOrFilters - The filter or filters to disable */ remove(filterOrFilters) { if (Array.isArray(filterOrFilters)) filterOrFilters.forEach((f) => this.#removeFn(f)); else this.#removeFn(filterOrFilters); this.#apply(); return this; } /** * Check whether a filter enabled or not * @param filter - The filter to check */ has(filter) { return this.collection.has(typeof filter === "string" ? filter : this.#resolve(filter).name); } /** * Array of enabled filter names */ get names() { return [...this.collection.keys()]; } /** * Array of enabled filters */ get values() { return [...this.collection.values()]; } get ffmpegArgs() { return this.size ? { af: this.values.map((f) => f.value).join(",") } : {}; } toString() { return this.names.toString(); } }; // src/core/manager/QueueManager.ts var QueueManager = class extends GuildIdManager { static { __name(this, "QueueManager"); } /** * Create a {@link Queue} * @param channel - A voice channel * @param textChannel - Default text channel * @returns Returns `true` if encounter an error */ async create(channel, textChannel) { if (this.has(channel.guildId)) throw new DisTubeError("QUEUE_EXIST"); this.debug(`[QueueManager] Creating queue for guild: ${channel.guildId}`); const voice = this.voices.create(channel); const queue = new Queue(this.distube, voice, textChannel); await queue._taskQueue.queuing(); try { checkFFmpeg(this.distube); this.debug(`[QueueManager] Joining voice channel: ${channel.id}`); await voice.join(); this.#voiceEventHandler(queue); this.add(queue.id, queue); this.emit("initQueue" /* INIT_QUEUE */, queue); return queue; } finally { queue._taskQueue.resolve(); } } /** * Listen to DisTubeVoice events and handle the Queue * @param queue - Queue */ #voiceEventHandler(queue) { queue._listeners = { disconnect: /* @__PURE__ */ __name((error) => { queue.remove(); this.emit("disconnect" /* DISCONNECT */, queue); if (error) this.emitError(error, queue, queue.songs?.[0]); }, "disconnect"), error: /* @__PURE__ */ __name((error) => this.#handlePlayingError(queue, error), "error"), finish: /* @__PURE__ */ __name(() => this.#handleSongFinish(queue), "finish") }; for (const event of objectKeys(queue._listeners)) { queue.voice.on(event, queue._listeners[event]); } } /** * Whether or not emit playSong event * @param queue - Queue */ #emitPlaySong(queue) { if (!this.options.emitNewSongOnly) return true; if (queue.repeatMode === 1 /* SONG */) return queue._next || queue._prev; return queue.songs[0].id !== queue.songs[1].id; } /** * Handle the queue when a Song finish * @param queue - queue */ async #handleSongFinish(queue) { this.debug(`[QueueManager] Handling song finish: ${queue.id}`); const song = queue.songs[0]; this.emit("finishSong" /* FINISH_SONG */, queue, queue.songs[0]); await queue._taskQueue.queuing(); try { if (queue.stopped) return; if (queue.repeatMode === 2 /* QUEUE */ && !queue._prev) queue.songs.push(song); if (queue._prev) { if (queue.repeatMode === 2 /* QUEUE */) queue.songs.unshift(queue.songs.pop()); else queue.songs.unshift(queue.previousSongs.pop()); } if (queue.songs.length <= 1 && (queue._next || queue.repeatMode === 0 /* DISABLED */)) { if (queue.autoplay) { try { this.debug(`[QueueManager] Adding related song: ${queue.id}`); await queue.addRelatedSong(); } catch (e) { this.debug(`[${queue.id}] Add related song error: ${e.message}`); this.emit("noRelated" /* NO_RELATED */, queue, e); } } if (queue.songs.length <= 1) { this.debug(`[${queue.id}] Queue is empty, stopping...`); if (!queue.autoplay) this.emit("finish" /* FINISH */, queue); queue.remove(); return; } } const emitPlaySong = this.#emitPlaySong(queue); if (!queue._prev && (queue.repeatMode !== 1 /* SONG */ || queue._next)) { const prev = queue.songs.shift(); if (this.options.savePreviousSongs) queue.previousSongs.push(prev); else queue.previousSongs.push({ id: prev.id }); } queue._next = queue._prev = false; queue._beginTime = 0; if (song !== queue.songs[0]) { const playedSong = song.stream.playFromSource ? song : song.stream.song; if (playedSong?.stream.playFromSource) delete playedSong.stream.url; } await this.playSong(queue, emitPlaySong); } finally { queue._taskQueue.resolve(); } } /** * Handle error while playing * @param queue - queue * @param error - error */ #handlePlayingError(queue, error) { const song = queue.songs.shift(); try { error.name = "PlayingError"; } catch { } this.debug(`[${queue.id}] Error while playing: ${error.stack || error.message}`); this.emitError(error, queue, song); if (queue.songs.length > 0) { this.debug(`[${queue.id}] Playing next song: ${queue.songs[0]}`); queue._next = queue._prev = false; queue._beginTime = 0; this.playSong(queue); } else { this.debug(`[${queue.id}] Queue is empty, stopping...`); queue.stop(); } } /** * Play a song on voice connection with queue properties * @param queue - The guild queue to play * @param emitPlaySong - Whether or not emit {@link Events.PLAY_SONG} event */ async playSong(queue, emitPlaySong = true) { if (!queue) return; if (queue.stopped || !queue.songs.length) { queue.stop(); return; } try { const song = queue.songs[0]; this.debug(`[${queue.id}] Getting stream from: ${song}`); await this.handler.attachStreamInfo(song); const willPlaySong = song.stream.playFromSource ? song : song.stream.song; const stream = willPlaySong?.stream; if (!willPlaySong || !stream?.playFromSource || !stream.url) throw new DisTubeError("NO_STREAM_URL", `${song}`); this.debug(`[${queue.id}] Creating DisTubeStream for: ${willPlaySong}`); const streamOptions = { ffmpeg: { path: this.options.ffmpeg.path, args: { global: { ...queue.ffmpegArgs.global }, input: { ...queue.ffmpegArgs.input }, output: { ...queue.ffmpegArgs.output, ...queue.filters.ffmpegArgs } } }, seek: willPlaySong.duration ? queue._beginTime : void 0 }; const dtStream = new DisTubeStream(stream.url, streamOptions); dtStream.on("debug", (data) => this.emit("ffmpegDebug" /* FFMPEG_DEBUG */, `[${queue.id}] ${data}`)); this.debug(`[${queue.id}] Started playing: ${willPlaySong}`); await queue.voice.play(dtStream); if (emitPlaySong) this.emit("playSong" /* PLAY_SONG */, queue, song); } catch (e) { this.#handlePlayingError(queue, e); } } }; // src/struct/Queue.ts var Queue = class extends DisTubeBase { static { __name(this, "Queue"); } /** * Queue id (Guild id) */ id; /** * Voice connection of this queue. */ voice; /** * List of songs in the queue (The first one is the playing song) */ songs; /** * List of the previous songs. */ previousSongs; /** * Whether stream is currently stopped. */ stopped; /** * Whether or not the stream is currently playing. */ playing; /** * Whether or not the stream is currently paused. */ paused; /** * Type of repeat mode (`0` is disabled, `1` is repeating a song, `2` is repeating * all the queue). Default value: `0` (disabled) */ repeatMode; /** * Whether or not the autoplay mode is enabled. Default value: `false` */ autoplay; /** * FFmpeg arguments for the current queue. Default value is defined with {@link DisTubeOptions}.ffmpeg.args. * `af` output argument will be replaced with {@link Queue#filters} manager */ ffmpegArgs; /** * The text channel of the Queue. (Default: where the first command is called). */ textChannel; #filters; /** * What time in the song to begin (in seconds). */ _beginTime; /** * Whether or not the last song was skipped to next song. */ _next; /** * Whether or not the last song was skipped to previous song. */ _prev; /** * Task queuing system */ _taskQueue; /** * {@link DisTubeVoice} listener */ _listeners; /** * Create a queue for the guild * @param distube - DisTube * @param voice - Voice connection * @param textChannel - Default text channel */ constructor(distube, voice, textChannel) { super(distube); this.voice = voice; this.id = voice.id; this.volume = 50; this.songs = []; this.previousSongs = []; this.stopped = false; this._next = false; this._prev = false; this.playing = false; this.paused = false; this.repeatMode = 0 /* DISABLED */; this.autoplay = false; this.#filters = new FilterManager(this); this._beginTime = 0; this.textChannel = textChannel; this._taskQueue = new TaskQueue(); this._listeners = void 0; this.ffmpegArgs = { glob