UNPKG

distube

Version:

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

1,594 lines (1,578 loc) 83.3 kB
var __defProp = Object.defineProperty; var __name = (target, value) => __defProp(target, "name", { value, configurable: true }); // src/constant.ts var version = "5.2.3"; var AUDIO_SAMPLE_RATE = 48e3; var AUDIO_CHANNELS = 2; var DEFAULT_VOLUME = 50; var JOIN_TIMEOUT_MS = 3e4; var RECONNECT_TIMEOUT_MS = 5e3; var RECONNECT_MAX_ATTEMPTS = 5; var HTTP_REDIRECT_CODES = /* @__PURE__ */ new Set([301, 302, 303, 307, 308]); var MAX_REDIRECT_DEPTH = 5; 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/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/DisTubeHandler.ts import { request } from "undici"; // src/struct/DisTubeError.ts import { inspect } from "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/util.ts import { URL as URL2 } from "url"; import { Constants as Constants2, GatewayIntentBits, IntentsBitField, SnowflakeUtil } from "discord.js"; // src/core/DisTubeVoice.ts import { AudioPlayerStatus, createAudioPlayer, entersState, joinVoiceChannel, VoiceConnectionDisconnectReason, VoiceConnectionStatus } from "@discordjs/voice"; import { Constants } from "discord.js"; import { TypedEmitter } from "tiny-typed-emitter"; 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, RECONNECT_TIMEOUT_MS).catch(() => { if (![VoiceConnectionStatus.Ready, VoiceConnectionStatus.Connecting].includes(this.connection.state.status)) { this.leave(); } }); } else if (this.connection.rejoinAttempts < RECONNECT_MAX_ATTEMPTS) { setTimeout( () => { this.connection.rejoin(); }, (this.connection.rejoinAttempts + 1) * RECONNECT_TIMEOUT_MS ).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) { if (channel) this.channel = channel; try { await entersState(this.connection, VoiceConnectionStatus.Ready, JOIN_TIMEOUT_MS); } 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", JOIN_TIMEOUT_MS / 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); } #streamErrorHandler; /** * Play a {@link DisTubeStream} * @param dtStream - DisTubeStream */ async play(dtStream) { if (!await checkEncryptionLibraries()) { dtStream.kill(); throw new DisTubeError("ENCRYPTION_LIBRARIES_MISSING"); } this.emittedError = false; if (this.stream && this.#streamErrorHandler) { this.stream.off("error", this.#streamErrorHandler); } this.#streamErrorHandler = (error) => { if (this.emittedError || error.code === "ERR_STREAM_PREMATURE_CLOSE") return; this.emittedError = true; this.emit("error", error); }; dtStream.on("error", this.#streamErrorHandler); 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" || 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((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 (time since playback started) */ get playbackDuration() { return (this.stream?.audioResource?.playbackDuration ?? 0) / 1e3; } /** * Current playback time in seconds, accounting for seek offset */ get playbackTime() { return this.playbackDuration + (this.stream?.seekTime ?? 0); } 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/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/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.hasOwn(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/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/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/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 queue is active. * * Note: This remains `true` when paused. It only becomes `false` when stopped. * @deprecated Use `!queue.paused` to check if audio is playing. Will be removed in v6.0. */ 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; /** * What time in the song to begin (in seconds). * @internal */ _beginTime; #filters; /** * Whether or not the queue is being updated manually (skip, jump, previous) * @internal */ _manualUpdate; /** * Task queuing system * @internal */ _taskQueue; /** * {@link DisTubeVoice} listener * @internal */ _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 = DEFAULT_VOLUME; this.songs = []; this.previousSongs = []; this.stopped = false; this._manualUpdate = 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 = { global: { ...this.options.ffmpeg.args.global }, input: { ...this.options.ffmpeg.args.input }, output: { ...this.options.ffmpeg.args.output } }; } #addToPreviousSongs(songs) { if (Array.isArray(songs)) { if (this.options.savePreviousSongs) { this.previousSongs.push(...songs); } else { this.previousSongs.push(...songs.map((s) => ({ id: s.id }))); } } else if (this.options.savePreviousSongs) { this.previousSongs.push(songs); } else { this.previousSongs.push({ id: songs.id }); } } #stop() { this._manualUpdate = true; this.voice.stop(); } /** * The client user as a `GuildMember` of this queue's guild */ get clientMember() { return this.voice.channel.guild.members.me ?? void 0; } /** * The filter manager of the queue */ get filters() { return this.#filters; } /** * Formatted duration string. */ get formattedDuration() { return formatDuration(this.duration); } /** * Queue's duration. */ get duration() { return this.songs.length ? this.songs.reduce((prev, next) => prev + next.duration, 0) : 0; } /** * What time in the song is playing (in seconds). */ get currentTime() { return this.voice.playbackTime; } /** * Formatted {@link Queue#currentTime} string. */ get formattedCurrentTime() { return formatDuration(this.currentTime); } /** * The voice channel playing in. */ get voiceChannel() { return this.clientMember?.voice?.channel ?? null; } /** * Get or set the stream volume. Default value: `50`. */ get volume() { return this.voice.volume; } set volume(value) { this.voice.volume = value; } /** * @throws {DisTubeError} * @param song - Song to add * @param position - Position to add, \<= 0 to add to the end of the queue * @returns The guild queue */ addToQueue(song, position = 0) { if (this.stopped) throw new DisTubeError("QUEUE_STOPPED"); if (!song || Array.isArray(song) && !song.length) { throw new DisTubeError("INVALID_TYPE", ["Song", "Array<Song>"], song, "song"); } if (typeof position !== "number" || !Number.isInteger(position)) { throw new DisTubeError("INVALID_TYPE", "integer", position, "position"); } if (position <= 0) { if (Array.isArray(song)) this.songs.push(...song); else this.songs.push(song); } else if (Array.isArray(song)) { this.songs.splice(position, 0, ...song); } else { this.songs.splice(position, 0, song); } return this; } /** * @returns `true` if the queue is active (not stopped) * @deprecated Use `!queue.paused` to check if audio is playing. Will be removed in v6.0. */ isPlaying() { return this.playing; } /** * @returns `true` if the queue is paused * @deprecated Use `queue.paused` property instead. Will be removed in v6.0. */ isPaused() { return this.paused; } /** * Pause the guild stream * @returns The guild queue */ async pause() { await this._taskQueue.queuing(); try { if (this.paused) throw new DisTubeError("PAUSED"); this.paused = true; this.voice.pause(); return this; } finally { this._taskQueue.resolve(); } } /** * Resume the guild stream * @returns The guild queue */ async resume() { await this._taskQueue.queuing(); try { if (!this.paused) throw new DisTubeError("RESUMED"); this.paused = false; this.voice.unpause(); return this; } finally { this._taskQueue.resolve(); } } /** * Set the guild stream's volume * @param percent - The percentage of volume you want to set * @returns The guild queue */ setVolume(percent) { this.volume = percent; return this; } /** * Skip the playing song if there is a next song in the queue. <info>If {@link * Queue#autoplay} is `true` and there is no up next song, DisTube will add and * play a related song.</info> * @param options - Skip options * @returns The song will skip to */ async skip(options) { return this.jump(1, options); } /** * Play the previous song if exists * @returns The guild queue */ async previous() { await this._taskQueue.queuing(); try { if (!this.options.savePreviousSongs) throw new DisTubeError("DISABLED_OPTION", "savePreviousSongs"); if (this.previousSongs.length === 0 && this.repeatMode !== 2 /* QUEUE */) { throw new DisTubeError("NO_PREVIOUS"); } const song = this.repeatMode === 2 /* QUEUE */ && this.previousSongs.length === 0 ? this.songs[this.songs.length - 1] : this.previousSongs.pop(); this.songs.unshift(song); this.#stop(); return song; } finally { this._taskQueue.resolve(); } } /** * Shuffle the queue's songs * @returns The guild queue */ async shuffle() { await this._taskQueue.queuing(); try { const playing = this.songs.shift(); if (playing === void 0) return this; for (let i = this.songs.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [this.songs[i], this.songs[j]] = [this.songs[j], this.songs[i]]; } this.songs.unshift(playing); return this; } finally { this._taskQueue.resolve(); } } /** * Jump to the song position in the queue. The next one is 1, 2,... The previous * one is -1, -2,... * if `num` is invalid number * @param position - The song position to play * @param options - Skip options * @returns The new Song will be played */ async jump(position, options) { await this._taskQueue.queuing(); try { if (typeof position !== "number") throw new DisTubeError("INVALID_TYPE", "number", position, "position"); if (!position || position > this.songs.length || -position > this.previousSongs.length) { throw new DisTubeError("NO_SONG_POSITION"); } if (position > 0) { if (position >= this.songs.length) { if (this.autoplay) { await this._addRelatedSong(); } else { throw new DisTubeError("NO_UP_NEXT"); } } const skipped = this.songs.splice(0, position); if (options?.requeue) { this.songs.push(...skipped); } else { this.#addToPreviousSongs(skipped); } } else if (!this.options.savePreviousSongs) { throw new DisTubeError("DISABLED_OPTION", "savePreviousSongs"); } else { const skipped = this.previousSongs.splice(position); this.songs.unshift(...skipped); } this.#stop(); return this.songs[0]; } finally { this._taskQueue.resolve(); } } /** * Set the repeat mode of the guild queue. * Toggle mode `(Disabled -> Song -> Queue -> Disabled ->...)` if `mode` is `undefined` * @param mode - The repeat modes (toggle if `undefined`) * @returns The new repeat mode */ setRepeatMode(mode) { if (mode !== void 0 && !Object.values(RepeatMode).includes(mode)) { throw new DisTubeError("INVALID_TYPE", ["RepeatMode", "undefined"], mode, "mode"); } if (mode === void 0) this.repeatMode = (this.repeatMode + 1) % 3; else if (this.repeatMode === mode) this.repeatMode = 0 /* DISABLED */; else this.repeatMode = mode; return this.repeatMode; } /** * Set the playing time to another position * @param time - Time in seconds * @returns The guild queue */ async seek(time) { await this._taskQueue.queuing(); try { if (typeof time !== "number") throw new DisTubeError("INVALID_TYPE", "number", time, "time"); if (Number.isNaN(time) || time < 0) throw new DisTubeError("NUMBER_COMPARE", "time", "bigger or equal to", 0); this._beginTime = time; await this.play(false); return this; } finally { this._taskQueue.resolve(); } } async #getRelatedSong(current) { const plugin = await this.handler._getPluginFromSong(current); if (plugin) return plugin.getRelatedSongs(current); return []; } /** * Internal implementation of addRelatedSong without task queue protection. * Used by methods that already hold the task queue lock. * @internal */ async _addRelatedSong(song) { const current = song ?? this.songs?.[0]; if (!current) throw new DisTubeError("NO_PLAYING_SONG"); const prevIds = this.previousSongs.map((p) => p.id); const relatedSongs = (await this.#getRelatedSong(current)).filter((s) => !prevIds.includes(s.id)); this.debug(`[${this.id}] Getting related songs from: ${current}`); if (!relatedSongs.length && !current.stream.playFromSource) { const altSong = current.stream.song; if (altSong) relatedSongs.push(...(await this.#getRelatedSong(altSong)).filter((s) => !prevIds.includes(s.id))); this.debug(`[${this.id}] Getting related songs from streamed song: ${altSong}`); } const nextSong = relatedSongs[0]; if (!nextSong) throw new DisTubeError("NO_RELATED"); nextSong.metadata = current.metadata; nextSong.member = this.clientMember; this.addToQueue(nextSong); return nextSong; } /** * Add a related song of the playing song to the queue * @param song - The song to get related songs from. Defaults to the current playing song. * @returns The added song */ async addRelatedSong(song) { await this._taskQueue.queuing(); try { return await this._addRelatedSong(song); } finally { this._taskQueue.resolve(); } } /** * Stop the guild stream and delete the queue */ async stop() { await this._taskQueue.queuing(); try { this.voice.stop(); this.remove(); } finally { this._taskQueue.resolve(); } } /** * Remove the queue from the manager */ remove() { this.playing = false; this.paused = true; this.stopped = true; this.songs = []; this.previousSongs = []; if (this._listeners) for (const event of objectKeys(this._listeners)) this.voice.off(event, this._listeners[event]); this.queues.remove(this.id); this.emit("deleteQueue" /* DELETE_QUEUE */, this); } /** * Toggle autoplay mode * @returns Autoplay mode state */ toggleAutoplay() { this.autoplay = !this.autoplay; return this.autoplay; } /** * Play the first song in the queue * @param emitPlaySong - Whether or not emit {@link Events.PLAY_SONG} event */ play(emitPlaySong = true) { if (this.stopped) throw new DisTubeError("QUEUE_STOPPED"); this.playing = true; return this.queues.playSong(this, emitPlaySong); } }; // src/util.ts var formatInt = /* @__PURE__ */ __name((int) => int < 10 ? `0${int}` : int, "formatInt"); function formatDuration(sec) { if (!sec || !Number(sec)) return "00:00"; const seconds = Math.floor(sec % 60); const minutes = Math.floor(sec % 3600 / 60); const hours = Math.floor(sec / 3600); if (hours > 0) return `${formatInt(hours)}:${formatInt(minutes)}:${formatInt(seconds)}`; if (minutes > 0) return `${formatInt(minutes)}:${formatInt(seconds)}`; return `00:${formatInt(seconds)}`; } __name(formatDuration, "formatDuration"); var SUPPORTED_PROTOCOL = ["https:", "http:", "file:"]; function isURL(input) { if (typeof input !== "string" || input.includes(" ")) return false; try { const url = new URL2(input); if (!SUPPORTED_PROTOCOL.some((p) => p === url.protocol)) return false; } catch { return false; } return true; } __name(isURL, "isURL"); function checkIntents(options) { const intents = options.intents instanceof IntentsBitField ? options.intents : new IntentsBitField(options.intents); if (!intents.has(GatewayIntentBits.GuildVoiceStates)) throw new DisTubeError("MISSING_INTENTS", "GuildVoiceStates"); } __name(checkIntents, "checkIntents"); function isVoiceChannelEmpty(voiceState) { const guild = voiceState.guild; const clientId = voiceState.client.user?.id; if (!guild || !clientId) return false; const voiceChannel = guild.members.me?.voice?.channel; if (!voiceChannel) return false; const members = voiceChannel.members.filter((m) => !m.user.bot); return !members.size; } __name(isVoiceChannelEmpty, "isVoiceChannelEmpty"); function isSnowflake(id) { try { return SnowflakeUtil.deconstruct(id).timestamp > SnowflakeUtil.epoch; } catch { return false; } } __name(isSnowflake, "isSnowflake"); function isMemberInstance(member) { return Boolean(member) && isSnowflake(member.id) && isSnowflake(member.guild?.id) && isSnowflake(member.user?.id) && member.id === member.user.id; } __name(isMemberInstance, "isMemberInstance"); function isTextChannelInstance(channel) { return Boolean(channel) && isSnowflake(channel.id) && isSnowflake(channel.guildId || channel.guild?.id) && Constants2.TextBasedChannelTypes.includes(channel.type) && typeof channel.send === "function" && (typeof channel.nsfw === "boolean" || typeof channel.parent?.nsfw === "boolean"); } __name(isTextChannelInstance, "isTextChannelInstance"); function isMessageInstance(message) { return Boolean(message) && isSnowflake(message.id) && isSnowflake(message.guildId || message.guild?.id) && isMemberInstance(message.member) && isTextChannelInstance(message.channel) && Constants2.NonSystemMessageTypes.includes(message.type) && message.member.id === message.author?.id; } __name(isMessageInstance, "isMessageInstance"); function isSupportedVoiceChannel(channel) { return Boolean(channel) && isSnowflake(channel.id) && isSnowflake(channel.guildId || channel.guild?.id) && Constants2.VoiceBasedChannelTypes.includes(channel.type); } __name(isSupportedVoiceChannel, "isSupportedVoiceChannel"); function isGuildInstance(guild) { return Boolean(guild) && isSnowflake(guild.id) && isSnowflake(guild.ownerId) && typeof guild.name === "string"; } __name(isGuildInstance, "isGuildInstance"); function resolveGuildId(resolvable) { let guildId; if (typeof resolvable === "string") { guildId = resolvable; } else if (isObject(resolvable)) { if ("guildId" in resolvable && resolvable.guildId) { guildId = resolvable.guildId; } else if (resolvable instanceof Queue || resolvable instanceof DisTubeVoice || isGuildInstance(resolvable)) { guildId = resolvable.id; } else if ("guild" in resolvable && isGuildInstance(resolvable.guild)) { guildId = resolvable.guild.id; } } if (!isSnowflake(guildId)) throw new DisTubeError("INVALID_TYPE", "GuildIdResolvable", resolvable); return guildId; } __name(resolveGuildId, "resolveGuildId"); function isClientInstance(client) { return Boolean(client) && typeof client.login === "function"; } __name(isClientInstance, "isClientInstance"); function checkInvalidKey(target, source, sourceName) { if (!isObject(target)) throw new DisTubeError("INVALID_TYPE", "object", target, sourceName); const sourceKeys = Array.isArray(source) ? source : objectKeys(source); const invalidKey = objectKeys(target).find((key) => !sourceKeys.includes(key)); if (invalidKey) throw new DisTubeError("INVALID_KEY", sourceName, invalidKey); } __name(checkInvalidKey, "checkInvalidKey"); function isObject(obj) { return typeof obj === "object" && obj !== null && !Array.isArray(obj); } __name(isObject, "isObject"); function objectKeys(obj) { if (!isObject(obj)) return []; return Object.keys(obj); } __name(objectKeys, "objectKeys"); function isNsfwChannel(channel) { if (!isTextChannelInstance(channel)) return false; if (channel.isThread()) return channel.parent?.nsfw ?? false; return channel.nsfw; } __name(isNsfwChannel, "isNsfwChannel"); var isTruthy = /* @__PURE__ */ __name((x) => Boolean(x), "isTruthy"); var checkEncryptionLibraries = /* @__PURE__ */ __name(async () => { if (await import("crypto").then((m) => m.getCiphers().includes("aes-256-gcm"))) return true; for (const lib of [ "@noble/ciphers", "@stablelib/xchacha20poly1305", "sodium-native", "sodium", "libsodium-wrappers", "tweetnacl" ]) { try { await import(lib); return true; } catch { } } return false; }, "checkEncryptionLibraries"); // 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/DisTubeHandler.ts 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 = MAX_REDIRECT_DEPTH) { 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 (HTTP_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; joinNewVoiceCha