UNPKG

distube

Version:

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

1 lines 151 kB
{"version":3,"sources":["../src/type.ts","../src/constant.ts","../src/struct/DisTubeError.ts","../src/struct/TaskQueue.ts","../src/struct/Playlist.ts","../src/struct/Song.ts","../src/core/DisTubeBase.ts","../src/core/DisTubeVoice.ts","../src/core/DisTubeStream.ts","../src/core/DisTubeHandler.ts","../src/core/DisTubeOptions.ts","../src/core/manager/BaseManager.ts","../src/core/manager/GuildIdManager.ts","../src/core/manager/DisTubeVoiceManager.ts","../src/core/manager/FilterManager.ts","../src/core/manager/QueueManager.ts","../src/struct/Queue.ts","../src/struct/Plugin.ts","../src/struct/ExtractorPlugin.ts","../src/struct/InfoExtratorPlugin.ts","../src/struct/PlayableExtratorPlugin.ts","../src/util.ts","../src/DisTube.ts","../src/index.ts"],"sourcesContent":["import type {\n DisTubeError,\n DisTubeVoice,\n ExtractorPlugin,\n InfoExtractorPlugin,\n PlayableExtractorPlugin,\n Playlist,\n Queue,\n Song,\n} from \".\";\nimport type {\n Guild,\n GuildMember,\n GuildTextBasedChannel,\n Interaction,\n Message,\n Snowflake,\n VoiceBasedChannel,\n VoiceState,\n} from \"discord.js\";\n\nexport type Awaitable<T = any> = T | PromiseLike<T>;\n\nexport enum Events {\n ERROR = \"error\",\n ADD_LIST = \"addList\",\n ADD_SONG = \"addSong\",\n PLAY_SONG = \"playSong\",\n FINISH_SONG = \"finishSong\",\n EMPTY = \"empty\",\n FINISH = \"finish\",\n INIT_QUEUE = \"initQueue\",\n NO_RELATED = \"noRelated\",\n DISCONNECT = \"disconnect\",\n DELETE_QUEUE = \"deleteQueue\",\n FFMPEG_DEBUG = \"ffmpegDebug\",\n DEBUG = \"debug\",\n}\n\nexport type DisTubeEvents = {\n [Events.ADD_LIST]: [queue: Queue, playlist: Playlist];\n [Events.ADD_SONG]: [queue: Queue, song: Song];\n [Events.DELETE_QUEUE]: [queue: Queue];\n [Events.DISCONNECT]: [queue: Queue];\n [Events.ERROR]: [error: Error, queue: Queue, song: Song | undefined];\n [Events.FFMPEG_DEBUG]: [debug: string];\n [Events.DEBUG]: [debug: string];\n [Events.FINISH]: [queue: Queue];\n [Events.FINISH_SONG]: [queue: Queue, song: Song];\n [Events.INIT_QUEUE]: [queue: Queue];\n [Events.NO_RELATED]: [queue: Queue, error: DisTubeError];\n [Events.PLAY_SONG]: [queue: Queue, song: Song];\n};\n\nexport type TypedDisTubeEvents = {\n [K in keyof DisTubeEvents]: (...args: DisTubeEvents[K]) => Awaitable;\n};\n\nexport type DisTubeVoiceEvents = {\n disconnect: (error?: Error) => Awaitable;\n error: (error: Error) => Awaitable;\n finish: () => Awaitable;\n};\n\n/**\n * An FFmpeg audio filter object\n * ```ts\n * {\n * name: \"bassboost\",\n * value: \"bass=g=10\"\n * }\n * ```ts\n */\nexport interface Filter {\n /**\n * Name of the filter\n */\n name: string;\n /**\n * FFmpeg audio filter argument\n */\n value: string;\n}\n\n/**\n * Data that resolves to give an FFmpeg audio filter. This can be:\n * - A name of a default filters or custom filters (`string`)\n * - A {@link Filter} object\n * @see {@link defaultFilters}\n * @see {@link DisTubeOptions|DisTubeOptions.customFilters}\n */\nexport type FilterResolvable = string | Filter;\n\n/**\n * FFmpeg Filters\n * ```ts\n * {\n * \"Filter Name\": \"Filter Value\",\n * \"bassboost\": \"bass=g=10\"\n * }\n * ```\n * @see {@link defaultFilters}\n */\nexport type Filters = Record<string, string>;\n\n/**\n * DisTube options\n */\nexport type DisTubeOptions = {\n /**\n * DisTube plugins.\n * The order of this effects the priority of the plugins when verifying the input.\n */\n plugins?: DisTubePlugin[];\n /**\n * Whether or not emitting {@link Events.PLAY_SONG} event when looping a song\n * or next song is the same as the previous one\n */\n emitNewSongOnly?: boolean;\n /**\n * Whether or not saving the previous songs of the queue and enable {@link\n * DisTube#previous} method. Disable it may help to reduce the memory usage\n */\n savePreviousSongs?: boolean;\n /**\n * Override {@link defaultFilters} or add more ffmpeg filters\n */\n customFilters?: Filters;\n /**\n * Whether or not playing age-restricted content and disabling safe search in\n * non-NSFW channel\n */\n nsfw?: boolean;\n /**\n * Whether or not emitting `addSong` event when creating a new Queue\n */\n emitAddSongWhenCreatingQueue?: boolean;\n /**\n * Whether or not emitting `addList` event when creating a new Queue\n */\n emitAddListWhenCreatingQueue?: boolean;\n /**\n * Whether or not joining the new voice channel when using {@link DisTube#play}\n * method\n */\n joinNewVoiceChannel?: boolean;\n /**\n * FFmpeg options\n */\n ffmpeg?: {\n /**\n * FFmpeg path\n */\n path?: string;\n /**\n * FFmpeg default arguments\n */\n args?: Partial<FFmpegArgs>;\n };\n};\n\n/**\n * Data that can be resolved to give a guild id string. This can be:\n * - A guild id string | a guild {@link https://discord.js.org/#/docs/main/stable/class/Snowflake|Snowflake}\n * - A {@link https://discord.js.org/#/docs/main/stable/class/Guild | Guild}\n * - A {@link https://discord.js.org/#/docs/main/stable/class/Message | Message}\n * - A {@link https://discord.js.org/#/docs/main/stable/class/BaseGuildVoiceChannel\n * | BaseGuildVoiceChannel}\n * - A {@link https://discord.js.org/#/docs/main/stable/class/BaseGuildTextChannel\n * | BaseGuildTextChannel}\n * - A {@link https://discord.js.org/#/docs/main/stable/class/VoiceState |\n * VoiceState}\n * - A {@link https://discord.js.org/#/docs/main/stable/class/GuildMember |\n * GuildMember}\n * - A {@link https://discord.js.org/#/docs/main/stable/class/Interaction |\n * Interaction}\n * - A {@link DisTubeVoice}\n * - A {@link Queue}\n */\nexport type GuildIdResolvable =\n | Queue\n | DisTubeVoice\n | Snowflake\n | Message\n | GuildTextBasedChannel\n | VoiceBasedChannel\n | VoiceState\n | Guild\n | GuildMember\n | Interaction\n | string;\n\nexport interface SongInfo {\n plugin: DisTubePlugin | null;\n source: string;\n playFromSource: boolean;\n id: string;\n name?: string;\n isLive?: boolean;\n duration?: number;\n url?: string;\n thumbnail?: string;\n views?: number;\n likes?: number;\n dislikes?: number;\n reposts?: number;\n uploader?: {\n name?: string;\n url?: string;\n };\n ageRestricted?: boolean;\n}\n\nexport interface PlaylistInfo {\n source: string;\n songs: Song[];\n id?: string;\n name?: string;\n url?: string;\n thumbnail?: string;\n}\n\nexport type RelatedSong = Omit<Song, \"related\">;\n\nexport type PlayHandlerOptions = {\n /**\n * [Default: false] Skip the playing song (if exists) and play the added playlist\n * instantly\n */\n skip?: boolean;\n /**\n * [Default: 0] Position of the song/playlist to add to the queue, \\<= 0 to add to\n * the end of the queue\n */\n position?: number;\n /**\n * The default text channel of the queue\n */\n textChannel?: GuildTextBasedChannel;\n};\n\nexport interface PlayOptions<T = unknown> extends PlayHandlerOptions, ResolveOptions<T> {\n /**\n * Called message (For built-in search events. If this is a {@link\n * https://developer.mozilla.org/en-US/docs/Glossary/Falsy | falsy value}, it will\n * play the first result instead)\n */\n message?: Message;\n}\n\nexport interface ResolveOptions<T = unknown> {\n /**\n * Requested user\n */\n member?: GuildMember;\n /**\n * Metadata\n */\n metadata?: T;\n}\n\nexport interface ResolvePlaylistOptions<T = unknown> extends ResolveOptions<T> {\n /**\n * Source of the playlist\n */\n source?: string;\n}\n\nexport interface CustomPlaylistOptions {\n /**\n * A guild member creating the playlist\n */\n member?: GuildMember;\n /**\n * Whether or not fetch the songs in parallel\n */\n parallel?: boolean;\n /**\n * Metadata\n */\n metadata?: any;\n /**\n * Playlist name\n */\n name?: string;\n /**\n * Playlist source\n */\n source?: string;\n /**\n * Playlist url\n */\n url?: string;\n /**\n * Playlist thumbnail\n */\n thumbnail?: string;\n}\n\n/**\n * The repeat mode of a {@link Queue}\n * - `DISABLED` = 0\n * - `SONG` = 1\n * - `QUEUE` = 2\n */\nexport enum RepeatMode {\n DISABLED,\n SONG,\n QUEUE,\n}\n\n/**\n * All available plugin types:\n * - `EXTRACTOR` = `\"extractor\"`: {@link ExtractorPlugin}\n * - `INFO_EXTRACTOR` = `\"info-extractor\"`: {@link InfoExtractorPlugin}\n * - `PLAYABLE_EXTRACTOR` = `\"playable-extractor\"`: {@link PlayableExtractorPlugin}\n */\nexport enum PluginType {\n EXTRACTOR = \"extractor\",\n INFO_EXTRACTOR = \"info-extractor\",\n PLAYABLE_EXTRACTOR = \"playable-extractor\",\n}\n\nexport type DisTubePlugin = ExtractorPlugin | InfoExtractorPlugin | PlayableExtractorPlugin;\n\nexport type FFmpegArg = Record<string, string | number | boolean | Array<string | null | undefined> | null | undefined>;\n\n/**\n * FFmpeg arguments for different use cases\n */\nexport type FFmpegArgs = {\n global: FFmpegArg;\n input: FFmpegArg;\n output: FFmpegArg;\n};\n\n/**\n * FFmpeg options\n */\nexport type FFmpegOptions = {\n /**\n * Path to the ffmpeg executable\n */\n path: string;\n /**\n * Arguments\n */\n args: FFmpegArgs;\n};\n","import type { DisTubeOptions, Filters } from \".\";\n\n/**\n * Default DisTube audio filters.\n */\nexport const defaultFilters: Filters = {\n \"3d\": \"apulsator=hz=0.125\",\n bassboost: \"bass=g=10\",\n echo: \"aecho=0.8:0.9:1000:0.3\",\n flanger: \"flanger\",\n gate: \"agate\",\n haas: \"haas\",\n karaoke: \"stereotools=mlev=0.1\",\n nightcore: \"asetrate=48000*1.25,aresample=48000,bass=g=5\",\n reverse: \"areverse\",\n vaporwave: \"asetrate=48000*0.8,aresample=48000,atempo=1.1\",\n mcompand: \"mcompand\",\n phaser: \"aphaser\",\n tremolo: \"tremolo\",\n surround: \"surround\",\n earwax: \"earwax\",\n};\n\nexport const defaultOptions = {\n plugins: [],\n emitNewSongOnly: false,\n savePreviousSongs: true,\n nsfw: false,\n emitAddSongWhenCreatingQueue: true,\n emitAddListWhenCreatingQueue: true,\n joinNewVoiceChannel: true,\n} satisfies DisTubeOptions;\n","import { inspect } from \"node:util\";\n\nconst ERROR_MESSAGES = {\n INVALID_TYPE: (expected: (number | string) | readonly (number | string)[], got: any, name?: string) =>\n `Expected ${\n Array.isArray(expected) ? expected.map(e => (typeof e === \"number\" ? e : `'${e}'`)).join(\" or \") : `'${expected}'`\n }${name ? ` for '${name}'` : \"\"}, but got ${inspect(got)} (${typeof got})`,\n NUMBER_COMPARE: (name: string, expected: string, value: number) => `'${name}' must be ${expected} ${value}`,\n EMPTY_ARRAY: (name: string) => `'${name}' is an empty array`,\n EMPTY_FILTERED_ARRAY: (name: string, type: string) => `There is no valid '${type}' in the '${name}' array`,\n EMPTY_STRING: (name: string) => `'${name}' string must not be empty`,\n INVALID_KEY: (obj: string, key: string) => `'${key}' does not need to be provided in ${obj}`,\n MISSING_KEY: (obj: string, key: string) => `'${key}' needs to be provided in ${obj}`,\n MISSING_KEYS: (obj: string, key: string[], all: boolean) =>\n `${key.map(k => `'${k}'`).join(all ? \" and \" : \" or \")} need to be provided in ${obj}`,\n\n MISSING_INTENTS: (i: string) => `${i} intent must be provided for the Client`,\n DISABLED_OPTION: (o: string) => `DisTubeOptions.${o} is disabled`,\n ENABLED_OPTION: (o: string) => `DisTubeOptions.${o} is enabled`,\n\n NOT_IN_VOICE: \"User is not in any voice channel\",\n VOICE_FULL: \"The voice channel is full\",\n VOICE_ALREADY_CREATED: \"This guild already has a voice connection which is not managed by DisTube\",\n VOICE_CONNECT_FAILED: (s: number) => `Cannot connect to the voice channel after ${s} seconds`,\n VOICE_MISSING_PERMS: \"I do not have permission to join this voice channel\",\n VOICE_RECONNECT_FAILED: \"Cannot reconnect to the voice channel\",\n VOICE_DIFFERENT_GUILD: \"Cannot join a voice channel in a different guild\",\n VOICE_DIFFERENT_CLIENT: \"Cannot join a voice channel created by a different client\",\n\n FFMPEG_EXITED: (code: number) => `ffmpeg exited with code ${code}`,\n FFMPEG_NOT_INSTALLED: (path: string) => `ffmpeg is not installed at '${path}' path`,\n ENCRYPTION_LIBRARIES_MISSING:\n \"Cannot play audio as no valid encryption package is installed and your node doesn't support aes-256-gcm.\\n\" +\n \"Please install @noble/ciphers, @stablelib/xchacha20poly1305, sodium-native or libsodium-wrappers.\",\n\n NO_QUEUE: \"There is no playing queue in this guild\",\n QUEUE_EXIST: \"This guild has a Queue already\",\n QUEUE_STOPPED: \"The queue has been stopped already\",\n PAUSED: \"The queue has been paused already\",\n RESUMED: \"The queue has been playing already\",\n NO_PREVIOUS: \"There is no previous song in this queue\",\n NO_UP_NEXT: \"There is no up next song\",\n NO_SONG_POSITION: \"Does not have any song at this position\",\n NO_PLAYING_SONG: \"There is no playing song in the queue\",\n NO_EXTRACTOR_PLUGIN: \"There is no extractor plugin in the DisTubeOptions.plugins, please add one for searching songs\",\n NO_RELATED: \"Cannot find any related songs\",\n CANNOT_PLAY_RELATED: \"Cannot play the related song\",\n UNAVAILABLE_VIDEO: \"This video is unavailable\",\n UNPLAYABLE_FORMATS: \"No playable format found\",\n NON_NSFW: \"Cannot play age-restricted content in non-NSFW channel\",\n NOT_SUPPORTED_URL: \"This url is not supported\",\n NOT_SUPPORTED_SONG: (song: string) => `There is no plugin supporting this song (${song})`,\n NO_VALID_SONG: \"'songs' array does not have any valid Song or url\",\n CANNOT_RESOLVE_SONG: (t: any) => `Cannot resolve ${inspect(t)} to a Song`,\n CANNOT_GET_STREAM_URL: (song: string) => `Cannot get stream url with this song (${song})`,\n CANNOT_GET_SEARCH_QUERY: (song: string) => `Cannot get search query with this song (${song})`,\n NO_RESULT: (query: string) => `Cannot find any song with this query (${query})`,\n NO_STREAM_URL: (song: string) => `No stream url attached (${song})`,\n\n EMPTY_FILTERED_PLAYLIST:\n \"There is no valid video in the playlist\\n\" +\n \"Maybe age-restricted contents is filtered because you are in non-NSFW channel\",\n EMPTY_PLAYLIST: \"There is no valid video in the playlist\",\n};\n\ntype ErrorMessage = typeof ERROR_MESSAGES;\ntype ErrorCode = keyof ErrorMessage;\ntype StaticErrorCode = { [K in ErrorCode]-?: ErrorMessage[K] extends string ? K : never }[ErrorCode];\ntype TemplateErrorCode = Exclude<keyof typeof ERROR_MESSAGES, StaticErrorCode>;\n\nconst haveCode = (code: string): code is ErrorCode => Object.keys(ERROR_MESSAGES).includes(code);\nconst parseMessage = (m: string | ((...x: any) => string), ...args: any) => (typeof m === \"string\" ? m : m(...args));\nconst getErrorMessage = (code: string, ...args: any): string =>\n haveCode(code) ? parseMessage(ERROR_MESSAGES[code], ...args) : args[0];\nexport class DisTubeError<T extends string = any> extends Error {\n errorCode: string;\n constructor(code: T extends StaticErrorCode ? T : never);\n constructor(code: T extends TemplateErrorCode ? T : never, ...args: Parameters<ErrorMessage[typeof code]>);\n constructor(code: TemplateErrorCode, _: never);\n constructor(code: T extends ErrorCode ? never : T, message: string);\n constructor(code: string, ...args: any) {\n super(getErrorMessage(code, ...args));\n\n this.errorCode = code;\n if (Error.captureStackTrace) Error.captureStackTrace(this, DisTubeError);\n }\n\n override get name() {\n return `DisTubeError [${this.errorCode}]`;\n }\n\n get code() {\n return this.errorCode;\n }\n}\n","class Task {\n resolve!: () => void;\n promise: Promise<void>;\n isPlay: boolean;\n constructor(isPlay: boolean) {\n this.isPlay = isPlay;\n this.promise = new Promise<void>(res => {\n this.resolve = res;\n });\n }\n}\n\n/**\n * Task queuing system\n */\nexport class TaskQueue {\n /**\n * The task array\n */\n #tasks: Task[] = [];\n\n /**\n * Waits for last task finished and queues a new task\n */\n queuing(isPlay = false): Promise<void> {\n const next = this.remaining ? this.#tasks[this.#tasks.length - 1].promise : Promise.resolve();\n this.#tasks.push(new Task(isPlay));\n return next;\n }\n\n /**\n * Removes the finished task and processes the next task\n */\n resolve(): void {\n this.#tasks.shift()?.resolve();\n }\n\n /**\n * The remaining number of tasks\n */\n get remaining(): number {\n return this.#tasks.length;\n }\n\n /**\n * Whether or not having a play task\n */\n get hasPlayTask(): boolean {\n return this.#tasks.some(t => t.isPlay);\n }\n}\n","import { DisTubeError, formatDuration, isMemberInstance } from \"..\";\nimport type { GuildMember } from \"discord.js\";\nimport type { PlaylistInfo, ResolveOptions, Song } from \"..\";\n\n/**\n * Class representing a playlist.\n */\nexport class Playlist<T = unknown> implements PlaylistInfo {\n /**\n * Playlist source.\n */\n source: string;\n /**\n * Songs in the playlist.\n */\n songs: Song[];\n /**\n * Playlist ID.\n */\n id?: string;\n /**\n * Playlist name.\n */\n name?: string;\n /**\n * Playlist URL.\n */\n url?: string;\n /**\n * Playlist thumbnail.\n */\n thumbnail?: string;\n #metadata!: T;\n #member?: GuildMember;\n /**\n * Create a Playlist\n * @param playlist - Raw playlist info\n * @param options - Optional data\n */\n constructor(playlist: PlaylistInfo, { member, metadata }: ResolveOptions<T> = {}) {\n if (!Array.isArray(playlist.songs) || !playlist.songs.length) throw new DisTubeError(\"EMPTY_PLAYLIST\");\n\n this.source = playlist.source.toLowerCase();\n this.songs = playlist.songs;\n this.name = playlist.name;\n this.id = playlist.id;\n this.url = playlist.url;\n this.thumbnail = playlist.thumbnail;\n this.member = member;\n this.songs.forEach(s => (s.playlist = this));\n this.metadata = metadata as T;\n }\n\n /**\n * Playlist duration in second.\n */\n get duration() {\n return this.songs.reduce((prev, next) => prev + next.duration, 0);\n }\n\n /**\n * Formatted duration string `hh:mm:ss`.\n */\n get formattedDuration() {\n return formatDuration(this.duration);\n }\n\n /**\n * User requested.\n */\n get member() {\n return this.#member;\n }\n\n set member(member: GuildMember | undefined) {\n if (!isMemberInstance(member)) return;\n this.#member = member;\n this.songs.forEach(s => (s.member = this.member));\n }\n\n /**\n * User requested.\n */\n get user() {\n return this.member?.user;\n }\n\n /**\n * Optional metadata that can be used to identify the playlist.\n */\n get metadata() {\n return this.#metadata;\n }\n\n set metadata(metadata: T) {\n this.#metadata = metadata;\n this.songs.forEach(s => (s.metadata = metadata));\n }\n\n toString() {\n return `${this.name} (${this.songs.length} songs)`;\n }\n}\n","import { Playlist } from \".\";\nimport { DisTubeError, formatDuration, isMemberInstance } from \"..\";\nimport type { GuildMember } from \"discord.js\";\nimport type { DisTubePlugin, ResolveOptions, SongInfo } from \"..\";\n\n/**\n * Class representing a song.\n */\nexport class Song<T = unknown> {\n /**\n * The source of this song info\n */\n source: string;\n /**\n * Song ID.\n */\n id: string;\n /**\n * Song name.\n */\n name?: string;\n /**\n * Indicates if the song is an active live.\n */\n isLive?: boolean;\n /**\n * Song duration.\n */\n duration: number;\n /**\n * Formatted duration string (`hh:mm:ss`, `mm:ss` or `Live`).\n */\n formattedDuration: string;\n /**\n * Song URL.\n */\n url?: string;\n /**\n * Song thumbnail.\n */\n thumbnail?: string;\n /**\n * Song view count\n */\n views?: number;\n /**\n * Song like count\n */\n likes?: number;\n /**\n * Song dislike count\n */\n dislikes?: number;\n /**\n * Song repost (share) count\n */\n reposts?: number;\n /**\n * Song uploader\n */\n uploader: {\n name?: string;\n url?: string;\n };\n /**\n * Whether or not an age-restricted content\n */\n ageRestricted?: boolean;\n /**\n * Stream info\n */\n stream:\n | {\n /**\n * The stream of this song will be played from source\n */\n playFromSource: true;\n /**\n * Stream URL of this song\n */\n url?: string;\n }\n | {\n /**\n * The stream of this song will be played from another song\n */\n playFromSource: false;\n /**\n * The song that this song will be played from\n */\n song?: Song<T>;\n };\n /**\n * The plugin that created this song\n */\n plugin: DisTubePlugin | null;\n #metadata!: T;\n #member?: GuildMember;\n #playlist?: Playlist;\n /**\n * Create a Song\n *\n * @param info - Raw song info\n * @param options - Optional data\n */\n constructor(info: SongInfo, { member, metadata }: ResolveOptions<T> = {}) {\n this.source = info.source.toLowerCase();\n this.metadata = <T>metadata;\n this.member = member;\n this.id = info.id;\n this.name = info.name;\n this.isLive = info.isLive;\n this.duration = this.isLive || !info.duration ? 0 : info.duration;\n this.formattedDuration = this.isLive ? \"Live\" : formatDuration(this.duration);\n this.url = info.url;\n this.thumbnail = info.thumbnail;\n this.views = info.views;\n this.likes = info.likes;\n this.dislikes = info.dislikes;\n this.reposts = info.reposts;\n this.uploader = {\n name: info.uploader?.name,\n url: info.uploader?.url,\n };\n this.ageRestricted = info.ageRestricted;\n this.stream = { playFromSource: info.playFromSource };\n this.plugin = info.plugin;\n }\n\n /**\n * The playlist this song belongs to\n */\n get playlist() {\n return this.#playlist;\n }\n\n set playlist(playlist: Playlist | undefined) {\n if (!(playlist instanceof Playlist)) throw new DisTubeError(\"INVALID_TYPE\", \"Playlist\", playlist, \"Song#playlist\");\n this.#playlist = playlist;\n this.member = playlist.member;\n }\n\n /**\n * User requested to play this song.\n */\n get member() {\n return this.#member;\n }\n\n set member(member: GuildMember | undefined) {\n if (isMemberInstance(member)) this.#member = member;\n }\n\n /**\n * User requested to play this song.\n */\n get user() {\n return this.member?.user;\n }\n\n /**\n * Optional metadata that can be used to identify the song. This is attached by the\n * {@link DisTube#play} method.\n */\n get metadata() {\n return this.#metadata;\n }\n\n set metadata(metadata: T) {\n this.#metadata = metadata;\n }\n\n toString() {\n return this.name || this.url || this.id || \"Unknown\";\n }\n}\n","import type { Client } from \"discord.js\";\nimport type {\n DisTube,\n DisTubeEvents,\n DisTubeHandler,\n DisTubePlugin,\n DisTubeVoiceManager,\n Options,\n Queue,\n QueueManager,\n Song,\n} from \"..\";\n\nexport abstract class DisTubeBase {\n distube: DisTube;\n constructor(distube: DisTube) {\n /**\n * DisTube\n */\n this.distube = distube;\n }\n /**\n * Emit the {@link DisTube} of this base\n * @param eventName - Event name\n * @param args - arguments\n */\n emit(eventName: keyof DisTubeEvents, ...args: any): boolean {\n return this.distube.emit(eventName, ...args);\n }\n /**\n * Emit error event\n * @param error - error\n * @param queue - The queue encountered the error\n * @param song - The playing song when encountered the error\n */\n emitError(error: Error, queue: Queue, song?: Song) {\n this.distube.emitError(error, queue, song);\n }\n /**\n * Emit debug event\n * @param message - debug message\n */\n debug(message: string) {\n this.distube.debug(message);\n }\n /**\n * The queue manager\n */\n get queues(): QueueManager {\n return this.distube.queues;\n }\n /**\n * The voice manager\n */\n get voices(): DisTubeVoiceManager {\n return this.distube.voices;\n }\n /**\n * Discord.js client\n */\n get client(): Client {\n return this.distube.client;\n }\n /**\n * DisTube options\n */\n get options(): Options {\n return this.distube.options;\n }\n /**\n * DisTube handler\n */\n get handler(): DisTubeHandler {\n return this.distube.handler;\n }\n /**\n * DisTube plugins\n */\n get plugins(): DisTubePlugin[] {\n return this.distube.plugins;\n }\n}\n","import { Constants } from \"discord.js\";\nimport { TypedEmitter } from \"tiny-typed-emitter\";\nimport { DisTubeError, checkEncryptionLibraries, isSupportedVoiceChannel } from \"..\";\nimport {\n AudioPlayerStatus,\n VoiceConnectionDisconnectReason,\n VoiceConnectionStatus,\n createAudioPlayer,\n entersState,\n joinVoiceChannel,\n} from \"@discordjs/voice\";\nimport type { AudioPlayer, VoiceConnection } from \"@discordjs/voice\";\nimport type { Snowflake, VoiceBasedChannel, VoiceState } from \"discord.js\";\nimport type { DisTubeStream, DisTubeVoiceEvents, DisTubeVoiceManager } from \"..\";\n\n/**\n * Create a voice connection to the voice channel\n */\nexport class DisTubeVoice extends TypedEmitter<DisTubeVoiceEvents> {\n readonly id: Snowflake;\n readonly voices: DisTubeVoiceManager;\n readonly audioPlayer: AudioPlayer;\n connection!: VoiceConnection;\n emittedError!: boolean;\n isDisconnected = false;\n stream?: DisTubeStream;\n pausingStream?: DisTubeStream;\n #channel!: VoiceBasedChannel;\n #volume = 100;\n constructor(voiceManager: DisTubeVoiceManager, channel: VoiceBasedChannel) {\n super();\n /**\n * The voice manager that instantiated this connection\n */\n this.voices = voiceManager;\n this.id = channel.guildId;\n this.channel = channel;\n this.voices.add(this.id, this);\n this.audioPlayer = createAudioPlayer()\n .on(AudioPlayerStatus.Idle, oldState => {\n if (oldState.status !== AudioPlayerStatus.Idle) this.emit(\"finish\");\n })\n .on(\"error\", (error: NodeJS.ErrnoException) => {\n if (this.emittedError) return;\n this.emittedError = true;\n this.emit(\"error\", error);\n });\n this.connection\n .on(VoiceConnectionStatus.Disconnected, (_, newState) => {\n if (newState.reason === VoiceConnectionDisconnectReason.Manual) {\n // User disconnect\n this.leave();\n } else if (newState.reason === VoiceConnectionDisconnectReason.WebSocketClose && newState.closeCode === 4014) {\n // Move to other channel\n entersState(this.connection, VoiceConnectionStatus.Connecting, 5e3).catch(() => {\n if (\n ![VoiceConnectionStatus.Ready, VoiceConnectionStatus.Connecting].includes(this.connection.state.status)\n ) {\n this.leave();\n }\n });\n } else if (this.connection.rejoinAttempts < 5) {\n // Try to rejoin\n setTimeout(\n () => {\n this.connection.rejoin();\n },\n (this.connection.rejoinAttempts + 1) * 5e3,\n ).unref();\n } else if (this.connection.state.status !== VoiceConnectionStatus.Destroyed) {\n // Leave after 5 attempts\n this.leave(new DisTubeError(\"VOICE_RECONNECT_FAILED\"));\n }\n })\n .on(VoiceConnectionStatus.Destroyed, () => {\n this.leave();\n })\n .on(\"error\", () => undefined);\n this.connection.subscribe(this.audioPlayer);\n }\n /**\n * The voice channel id the bot is in\n */\n get channelId() {\n return this.connection?.joinConfig?.channelId ?? undefined;\n }\n get channel() {\n if (!this.channelId) return this.#channel;\n if (this.#channel?.id === this.channelId) return this.#channel;\n const channel = this.voices.client.channels.cache.get(this.channelId);\n if (!channel) return this.#channel;\n for (const type of Constants.VoiceBasedChannelTypes) {\n if (channel.type === type) {\n this.#channel = channel;\n return channel;\n }\n }\n return this.#channel;\n }\n set channel(channel: VoiceBasedChannel) {\n if (!isSupportedVoiceChannel(channel)) {\n throw new DisTubeError(\"INVALID_TYPE\", \"BaseGuildVoiceChannel\", channel, \"DisTubeVoice#channel\");\n }\n if (channel.guildId !== this.id) throw new DisTubeError(\"VOICE_DIFFERENT_GUILD\");\n if (channel.client.user?.id !== this.voices.client.user?.id) throw new DisTubeError(\"VOICE_DIFFERENT_CLIENT\");\n if (channel.id === this.channelId) return;\n if (!channel.joinable) {\n if (channel.full) throw new DisTubeError(\"VOICE_FULL\");\n else throw new DisTubeError(\"VOICE_MISSING_PERMS\");\n }\n this.connection = this.#join(channel);\n this.#channel = channel;\n }\n #join(channel: VoiceBasedChannel) {\n return joinVoiceChannel({\n channelId: channel.id,\n guildId: this.id,\n adapterCreator: channel.guild.voiceAdapterCreator,\n group: channel.client.user?.id,\n });\n }\n /**\n * Join a voice channel with this connection\n * @param channel - A voice channel\n */\n async join(channel?: VoiceBasedChannel): Promise<DisTubeVoice> {\n const TIMEOUT = 30e3;\n if (channel) this.channel = channel;\n try {\n await entersState(this.connection, VoiceConnectionStatus.Ready, TIMEOUT);\n } catch {\n if (this.connection.state.status === VoiceConnectionStatus.Ready) return this;\n if (this.connection.state.status !== VoiceConnectionStatus.Destroyed) this.connection.destroy();\n this.voices.remove(this.id);\n throw new DisTubeError(\"VOICE_CONNECT_FAILED\", TIMEOUT / 1e3);\n }\n return this;\n }\n /**\n * Leave the voice channel of this connection\n * @param error - Optional, an error to emit with 'error' event.\n */\n leave(error?: Error) {\n this.stop(true);\n if (!this.isDisconnected) {\n this.emit(\"disconnect\", error);\n this.isDisconnected = true;\n }\n if (this.connection.state.status !== VoiceConnectionStatus.Destroyed) this.connection.destroy();\n this.voices.remove(this.id);\n }\n /**\n * Stop the playing stream\n * @param force - If true, will force the {@link DisTubeVoice#audioPlayer} to enter the Idle state even\n * if the {@link DisTubeStream#audioResource} has silence padding frames.\n */\n stop(force = false) {\n this.audioPlayer.stop(force);\n }\n /**\n * Play a {@link DisTubeStream}\n * @param dtStream - DisTubeStream\n */\n async play(dtStream: DisTubeStream) {\n if (!(await checkEncryptionLibraries())) {\n dtStream.kill();\n throw new DisTubeError(\"ENCRYPTION_LIBRARIES_MISSING\");\n }\n this.emittedError = false;\n dtStream.on(\"error\", (error: NodeJS.ErrnoException) => {\n if (this.emittedError || error.code === \"ERR_STREAM_PREMATURE_CLOSE\") return;\n this.emittedError = true;\n this.emit(\"error\", error);\n });\n if (this.audioPlayer.state.status !== AudioPlayerStatus.Paused) {\n this.audioPlayer.play(dtStream.audioResource);\n this.stream?.kill();\n dtStream.spawn();\n } else if (!this.pausingStream) {\n this.pausingStream = this.stream;\n }\n this.stream = dtStream;\n this.volume = this.#volume;\n }\n set volume(volume: number) {\n if (typeof volume !== \"number\" || isNaN(volume)) {\n throw new DisTubeError(\"INVALID_TYPE\", \"number\", volume, \"volume\");\n }\n if (volume < 0) {\n throw new DisTubeError(\"NUMBER_COMPARE\", \"Volume\", \"bigger or equal to\", 0);\n }\n this.#volume = volume;\n this.stream?.setVolume(Math.pow(this.#volume / 100, 0.5 / Math.log10(2)));\n }\n /**\n * Get or set the volume percentage\n */\n get volume() {\n return this.#volume;\n }\n /**\n * Playback duration of the audio resource in seconds\n */\n get playbackDuration() {\n return (this.stream?.audioResource?.playbackDuration ?? 0) / 1000;\n }\n pause() {\n this.audioPlayer.pause();\n }\n unpause() {\n const state = this.audioPlayer.state;\n if (state.status !== AudioPlayerStatus.Paused) return;\n if (this.stream?.audioResource && state.resource !== this.stream.audioResource) {\n this.audioPlayer.play(this.stream.audioResource);\n this.stream.spawn();\n this.pausingStream?.kill();\n delete this.pausingStream;\n } else {\n this.audioPlayer.unpause();\n }\n }\n /**\n * Whether the bot is self-deafened\n */\n get selfDeaf(): boolean {\n return this.connection.joinConfig.selfDeaf;\n }\n /**\n * Whether the bot is self-muted\n */\n get selfMute(): boolean {\n return this.connection.joinConfig.selfMute;\n }\n /**\n * Self-deafens/undeafens the bot.\n * @param selfDeaf - Whether or not the bot should be self-deafened\n * @returns true if the voice state was successfully updated, otherwise false\n */\n setSelfDeaf(selfDeaf: boolean): boolean {\n if (typeof selfDeaf !== \"boolean\") {\n throw new DisTubeError(\"INVALID_TYPE\", \"boolean\", selfDeaf, \"selfDeaf\");\n }\n return this.connection.rejoin({\n ...this.connection.joinConfig,\n selfDeaf,\n });\n }\n /**\n * Self-mutes/unmutes the bot.\n * @param selfMute - Whether or not the bot should be self-muted\n * @returns true if the voice state was successfully updated, otherwise false\n */\n setSelfMute(selfMute: boolean): boolean {\n if (typeof selfMute !== \"boolean\") {\n throw new DisTubeError(\"INVALID_TYPE\", \"boolean\", selfMute, \"selfMute\");\n }\n return this.connection.rejoin({\n ...this.connection.joinConfig,\n selfMute,\n });\n }\n /**\n * The voice state of this connection\n */\n get voiceState(): VoiceState | undefined {\n return this.channel?.guild?.members?.me?.voice;\n }\n}\n","import { Transform } from \"stream\";\nimport { DisTubeError, Events } from \"..\";\nimport { spawn, spawnSync } from \"child_process\";\nimport { TypedEmitter } from \"tiny-typed-emitter\";\nimport { StreamType, createAudioResource } from \"@discordjs/voice\";\nimport type { TransformCallback } from \"stream\";\nimport type { ChildProcess } from \"child_process\";\nimport type { AudioResource } from \"@discordjs/voice\";\nimport type { Awaitable, DisTube, FFmpegArg, FFmpegOptions } from \"..\";\n\n/**\n * Options for {@link DisTubeStream}\n */\nexport interface StreamOptions {\n /**\n * FFmpeg options\n */\n ffmpeg: FFmpegOptions;\n /**\n * Seek time (in seconds).\n * @default 0\n */\n seek?: number;\n}\n\nlet checked = process.env.NODE_ENV === \"test\";\nexport const checkFFmpeg = (distube: DisTube) => {\n if (checked) return;\n const path = distube.options.ffmpeg.path;\n const debug = (str: string) => distube.emit(Events.FFMPEG_DEBUG, str);\n try {\n debug(`[test] spawn ffmpeg at '${path}' path`);\n const process = spawnSync(path, [\"-h\"], { windowsHide: true, shell: true, encoding: \"utf-8\" });\n if (process.error) throw process.error;\n if (process.stderr && !process.stdout) throw new Error(process.stderr);\n\n const result = process.output.join(\"\\n\");\n const version = /ffmpeg version (\\S+)/iu.exec(result)?.[1];\n if (!version) throw new Error(\"Invalid FFmpeg version\");\n debug(`[test] ffmpeg version: ${version}`);\n } catch (e: any) {\n debug(`[test] failed to spawn ffmpeg at '${path}': ${e?.stack ?? e}`);\n throw new DisTubeError(\"FFMPEG_NOT_INSTALLED\", path);\n }\n checked = true;\n};\n\n/**\n * Create a stream to play with {@link DisTubeVoice}\n */\nexport class DisTubeStream extends TypedEmitter<{\n debug: (debug: string) => Awaitable;\n error: (error: Error) => Awaitable;\n}> {\n #ffmpegPath: string;\n #opts: string[];\n process?: ChildProcess;\n stream: VolumeTransformer;\n audioResource: AudioResource;\n /**\n * Create a DisTubeStream to play with {@link DisTubeVoice}\n * @param url - Stream URL\n * @param options - Stream options\n */\n constructor(url: string, options: StreamOptions) {\n super();\n const { ffmpeg, seek } = options;\n const opts: FFmpegArg = {\n reconnect: 1,\n reconnect_streamed: 1,\n reconnect_delay_max: 5,\n analyzeduration: 0,\n hide_banner: true,\n ...ffmpeg.args.global,\n ...ffmpeg.args.input,\n i: url,\n ar: 48000,\n ac: 2,\n ...ffmpeg.args.output,\n f: \"s16le\",\n };\n\n if (typeof seek === \"number\" && seek > 0) opts.ss = seek.toString();\n\n const fileUrl = new URL(url);\n if (fileUrl.protocol === \"file:\") {\n opts.reconnect = null;\n opts.reconnect_streamed = null;\n opts.reconnect_delay_max = null;\n opts.i = fileUrl.hostname + fileUrl.pathname;\n }\n\n this.#ffmpegPath = ffmpeg.path;\n this.#opts = [\n ...Object.entries(opts)\n .flatMap(([key, value]) =>\n Array.isArray(value)\n ? value.filter(Boolean).map(v => [`-${key}`, String(v)])\n : value == null || value === false\n ? []\n : [value === true ? `-${key}` : [`-${key}`, String(value)]],\n )\n .flat(),\n \"pipe:1\",\n ];\n\n this.stream = new VolumeTransformer();\n this.stream\n .on(\"close\", () => this.kill())\n .on(\"error\", err => {\n this.debug(`[stream] error: ${err.message}`);\n this.emit(\"error\", err);\n })\n .on(\"finish\", () => this.debug(\"[stream] log: stream finished\"));\n\n this.audioResource = createAudioResource(this.stream, { inputType: StreamType.Raw, inlineVolume: false });\n }\n\n spawn() {\n this.debug(`[process] spawn: ${this.#ffmpegPath} ${this.#opts.join(\" \")}`);\n this.process = spawn(this.#ffmpegPath, this.#opts, {\n stdio: [\"ignore\", \"pipe\", \"pipe\"],\n shell: false,\n windowsHide: true,\n })\n .on(\"error\", err => {\n this.debug(`[process] error: ${err.message}`);\n this.emit(\"error\", err);\n })\n .on(\"exit\", (code, signal) => {\n this.debug(`[process] exit: code=${code ?? \"unknown\"} signal=${signal ?? \"unknown\"}`);\n if (!code || [0, 255].includes(code)) return;\n this.debug(`[process] error: ffmpeg exited with code ${code}`);\n this.emit(\"error\", new DisTubeError(\"FFMPEG_EXITED\", code));\n });\n\n if (!this.process.stdout || !this.process.stderr) {\n this.kill();\n throw new Error(\"Failed to create ffmpeg process\");\n }\n\n this.process.stdout.pipe(this.stream);\n this.process.stderr.setEncoding(\"utf8\")?.on(\"data\", (data: string) => {\n const lines = data.split(/\\r\\n|\\r|\\n/u);\n for (const line of lines) {\n if (/^\\s*$/.test(line)) continue;\n this.debug(`[ffmpeg] log: ${line}`);\n }\n });\n }\n\n private debug(debug: string) {\n this.emit(\"debug\", debug);\n }\n\n setVolume(volume: number) {\n this.stream.vol = volume;\n }\n\n kill() {\n if (!this.stream.destroyed) this.stream.destroy();\n if (this.process && !this.process.killed) this.process.kill(\"SIGKILL\");\n }\n}\n\n// Based on prism-media\nclass VolumeTransformer extends Transform {\n private buffer = Buffer.allocUnsafe(0);\n private readonly extrema = [-Math.pow(2, 16 - 1), Math.pow(2, 16 - 1) - 1];\n vol = 1;\n\n override _transform(newChunk: Buffer, _encoding: BufferEncoding, done: TransformCallback): void {\n const { vol } = this;\n if (vol === 1) {\n this.push(newChunk);\n done();\n return;\n }\n\n const bytes = 2;\n const chunk = Buffer.concat([this.buffer, newChunk]);\n const readableLength = Math.floor(chunk.length / bytes) * bytes;\n\n for (let i = 0; i < readableLength; i += bytes) {\n const value = chunk.readInt16LE(i);\n const clampedValue = Math.min(this.extrema[1], Math.max(this.extrema[0], value * vol));\n chunk.writeInt16LE(clampedValue, i);\n }\n\n this.buffer = chunk.subarray(readableLength);\n this.push(chunk.subarray(0, readableLength));\n done();\n }\n}\n","import { DisTubeBase } from \".\";\nimport { request } from \"undici\";\nimport { DisTubeError, Playlist, PluginType, Song, isURL } from \"..\";\nimport type { DisTubePlugin, ResolveOptions } from \"..\";\n\nconst REDIRECT_CODES = new Set([301, 302, 303, 307, 308]);\n\n/**\n * DisTube's Handler\n */\nexport class DisTubeHandler extends DisTubeBase {\n resolve<T = unknown>(song: Song<T>, options?: Omit<ResolveOptions, \"metadata\">): Promise<Song<T>>;\n resolve<T = unknown>(song: Playlist<T>, options?: Omit<ResolveOptions, \"metadata\">): Promise<Playlist<T>>;\n resolve<T = unknown>(song: string, options?: ResolveOptions<T>): Promise<Song<T> | Playlist<T>>;\n resolve<T = unknown>(song: Song, options: ResolveOptions<T>): Promise<Song<T>>;\n resolve<T = unknown>(song: Playlist, options: ResolveOptions<T>): Promise<Playlist<T>>;\n resolve(song: string | Song | Playlist, options?: ResolveOptions): Promise<Song | Playlist>;\n /**\n * Resolve a url or a supported object to a {@link Song} or {@link Playlist}\n * @throws {@link DisTubeError}\n * @param input - Resolvable input\n * @param options - Optional options\n * @returns Resolved\n */\n async resolve(input: string | Song | Playlist, options: ResolveOptions = {}): Promise<Song | Playlist> {\n if (input instanceof Song || input instanceof Playlist) {\n if (\"metadata\" in options) input.metadata = options.metadata;\n if (\"member\" in options) input.member = options.member;\n return input;\n }\n if (typeof input === \"string\") {\n if (isURL(input)) {\n const plugin =\n (await this._getPluginFromURL(input)) ||\n (await this._getPluginFromURL((input = await this.followRedirectLink(input))));\n if (!plugin) throw new DisTubeError(\"NOT_SUPPORTED_URL\");\n this.debug(`[${plugin.constructor.name}] Resolving from url: ${input}`);\n return plugin.resolve(input, options);\n }\n try {\n const song = await this.#searchSong(input, options);\n if (song) return song;\n } catch {\n throw new DisTubeError(\"NO_RESULT\", input);\n }\n }\n throw new DisTubeError(\"CANNOT_RESOLVE_SONG\", input);\n }\n\n async _getPluginFromURL(url: string): Promise<DisTubePlugin | null> {\n for (const plugin of this.plugins) if (await plugin.validate(url)) return plugin;\n return null;\n }\n\n _getPluginFromSong(song: Song): Promise<DisTubePlugin | null>;\n _getPluginFromSong<T extends PluginType>(\n song: Song,\n types: T[],\n validate?: boolean,\n ): Promise<(DisTubePlugin & { type: T }) | null>;\n async _getPluginFromSong<T extends PluginType>(\n song: Song,\n types?: T[],\n validate = true,\n ): Promise<(DisTubePlugin & { type: T }) | null> {\n if (!types || types.includes(<T>song.plugin?.type)) return song.plugin as DisTubePlugin & { type: T };\n if (!song.url) return null;\n for (const plugin of this.plugins) {\n if ((!types || types.includes(<T>plugin?.type)) && (!validate || (await plugin.validate(song.url)))) {\n return plugin as DisTubePlugin & { type: T };\n }\n }\n return null;\n }\n\n async #searchSong(query: string, options: ResolveOptions = {}, getStreamURL = false): Promise<Song | null> {\n const plugins = this.plugins.filter(p => p.type === PluginType.EXTRACTOR);\n if (!plugins.length) throw new DisTubeError(\"NO_EXTRACTOR_PLUGIN\");\n for (const plugin of plugins) {\n this.debug(`[${plugin.constructor.name}] Searching for song: ${query}`);\n const result = await plugin.searchSong(query, options);\n if (result) {\n if (getStreamURL && result.stream.playFromSource) result.stream.url = await plugin.getStreamURL(result);\n return result;\n }\n }\n return null;\n }\n\n /**\n * Get {@link Song}'s stream info and attach it to the song.\n * @param song - A Song\n */\n async attachStreamInfo(song: Song) {\n if (song.stream.playFromSource) {\n if (song.stream.url) return;\n this.debug(`[DisTubeHandler] Getting stream info: ${song}`);\n const plugin = await this._getPluginFromSong(song, [PluginType.EXTRACTOR, PluginType.PLAYABLE_EXTRACTOR]);\n if (!plugin) throw new DisTubeError(\"NOT_SUPPORTED_SONG\", song.toString());\n this.debug(`[${plugin.constructor.name}] Getting stream URL: ${song}`);\n song.stream.url = await plugin.getStreamURL(song);\n if (!song.stream.url) throw new DisTubeError(\"CANNOT_GET_STREAM_URL\", song.toString());\n } else {\n if (song.stream.song?.stream?.playFromSource && song.stream.song.stream.url) return;\n this.debug(`[DisTubeHandler] Getting stream info: ${song}`);\n const plugin = await this._getPluginFromSong(song, [PluginType.INFO_EXTRACTOR]);\n if (!plugin) throw new DisTubeError(\"NOT_SUPPORTED_SONG\", song.toString());\n this.debug(`[${plugin.constructor.name}] Creating search query for: ${song}`);\n const query = await plugin.createSearchQuery(song);\n if (!query) throw new DisTubeError(\"CANNOT_GET_SEARCH_QUERY\", song.toString());\n const altSong = await this.#searchSong(query, { metadata: song.metadata, member: song.member }, true);\n if (!altSong || !altSong.stream.playFromSource) throw new DisTubeError(\"NO_RESULT\", query || song.toString());\n song.stream.song = altSong;\n }\n }\n\n async followRedirectLink(url: string, maxRedirect = 5): Promise<string> {\n if (maxRedirect === 0) return url;\n\n const res = await request(url, {\n method: \"HEAD\",\n headers: {\n \"user-agent\":\n \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) \" +\n \"Chrome/129.0.0.0 Safari/537.3\",\n },\n });\n\n if (REDIRECT_CODES.has(res.statusCode ?? 200)) {\n let location = res.headers.location;\n if (typeof location !== \"string\") location = location?.[0] ?? url;\n return this.followRedirectLink(location, --maxRedirect);\n }\n\n return url;\n }\n}\n","import { DisTubeError, checkInvalidKey, defaultOptions } from \"..\";\nimport type { DisTubeOptions, DisTubePlugin, FFmpegArgs, FFmpegOptions, Filters } from \"..\";\n\nexport class Options {\n plugins: DisTubePlugin[];\n emitNewSongOnly: boolean;\n savePreviousSongs: boolean;\n customFilters?: Filters;\n nsfw: boolean;\n emitAddSongWhenCreatingQueue: boolean;\n emitAddListWhenCreatingQueue: boolean;\n joinNewVoiceChannel: boolean;\n ffmpeg: FFmpegOptions;\n constructor(options: DisTubeOptions) {\n if (typeof options !== \"object\" || Array.isArray(options)) {\n throw new DisTubeError(\"INVALID_TYPE\", \"object\", options, \"DisTubeOptions\");\n }\n const opts = { ...defaultOptions, ...options };\n this.plugins = opts.plugins;\n this.emitNewSongOnly = opts.emitNewSongOnly;\n this.savePreviousSongs = opts.savePreviousSongs;\n this.customFilters = opts.customFilters;\n this.nsfw = opts.nsfw;\n this.emitAddSongWhenCreatingQueue = opts.emitAddSongWhenCreatingQueue;\n this.emitAddListWhenCreatingQueue = opts.emitAddListWhenCreatingQueue;\n this.joinNewVoiceChannel = opts.joinNewVoiceChannel;\n this.ffmpeg = this.#ffmpegOption(options);\n checkInvalidKey(opts, this, \"DisTubeOptions\");\n this.#validateOptions();\n }\n\n #validateOptions(options = this) {\n const booleanOptions = new Set([\n \"emitNewSongOnly\",\n \"savePreviousSongs\",\n \"joinNewVoiceChannel\",\n \"nsfw\",\n \"emitAddSongWhenCreatingQueue\",\n \"emitAddListWhenCreatingQueue\",\n ]);\n const numberOptions = new Set();\n const stringOptions = new Set();\n const objectOptions = new Set([\"customFilters\", \"ffmpeg\"]);\n const optionalOptions = new Set([\"customFilters\"]);\n\n for (const [key, value] of Object.entries(options)) {\n if (value === undefined && optionalOptions.has(key)) continue;\n if (key === \"plugins\" && !Array.isArray(value)) {\n throw new DisTubeError(\"INVALID_TYPE\", \"Array<Plugin>\", value, `DisTubeOptions.${key}`);\n } else if (booleanOptions.has(key)) {\n if (typeof value !== \"boolean\") {\n throw new DisTubeError(\"INVALID_TYPE\", \"boolean\", value, `DisTubeOptions.${key}`);\n }\n } else if (numberOptions.has(key)) {\n if (typeof value !== \"number\" || isNaN(value)) {\n throw new DisTubeError(\"INVALID_TYPE\", \"number\", value, `DisTubeOptions.${key}`);\n }\n } else if (stringOptions.has(key)) {\n if (typeof value !== \"string\") {\n throw new DisTubeError(\"INVALID_TYPE\", \"string\", value, `DisTubeOptions.${key}`);\n }\n } else if (objectOptions.has(key)) {\n if (typeof value !== \"object\" || Array.isArray(value)) {\n throw new DisTubeError(\"INVALID_TYPE\", \"object\", value, `DisTubeOptions.${key}`);\n }\n }\n }\n }\n\n #ffmpegOption(opts: DisTubeOptions) {\n const args: FFmpegArgs = { global: {}, input: {}, output: {} };\n if (opts.ffmpeg?.args) {\n if (opts.ffmpeg.args.global) args.global = opts.ffmpeg.args.global;\n if (opts.ffmpeg.args.input) args.input = opts.ffmpeg.args.input;\n if (opts.ffmpeg.args.output) args.output = opts.ffmpeg.args.output;\n }\n const path = opts.ffmpeg?.path ?? \"ffmpeg\";\n if (typeof path !== \"string\") {\n throw new DisTubeError(\"INVALID_TYPE\", \"string\", path, \"DisTubeOptions.ffmpeg.path\");\n }\n for (const [key, value] of Object.entries(args)) {\n if (typeof value !== \"object\" || Array.isArray(value)) {\n throw new DisTubeError(\"INVALID_TYPE\",