UNPKG

@terron/djs-music

Version:

New simple voice player with yt-dlp supporting, filters & events for discord.js v14.

526 lines (457 loc) 19.3 kB
const ytSearch = require('yt-search'); const { joinVoiceChannel, createAudioPlayer, createAudioResource, getVoiceConnection, StreamType, AudioPlayerStatus } = require('@discordjs/voice'); const { EventEmitter } = require('events'); const prism = require('prism-media'); const { PlayerEvents, LoopStatuses } = require('./constants.js'); const { Resolvable, shuffleArray, getFullStream } = require('./utils.js'); const basePrismOptions = [ '-analyzeduration', '0', '-loglevel', '0', '-f', 's16le', '-ar', '48000', '-ac', '2', ]; class Player extends EventEmitter { /** * @param {Object} options - Configuration options for the player. * @param {number} [options.defaultVolume=100] - The default volume percentage. * @param {boolean} [options.leaveAfterEnd=true] - Whether to leave the voice channel after playback ends. * @param {boolean} [options.keepVoiceIfExists=true] - Whether to reuse existing voice connections. * @param {Discord.Client} [options.client=null] - The Discord.js client instance. * @param {string} [options.ytDlpOptions='...'] - Your options for yt-dlp scrapping. */ constructor({ defaultVolume = 100, leaveAfterEnd = true, keepVoiceIfExists = true, client = null, ytDlpOptions = '-f bestaudio/best --extract-audio --audio-quality 0 --concurrent-fragments 16 --buffer-size 16M --no-check-certificate' } = {}) { super(); this.defaultVolume = defaultVolume; this.leaveAfterEnd = leaveAfterEnd; this.keepVoiceIfExists = keepVoiceIfExists; this.client = client; this.ytDlpOptions = ytDlpOptions this._queues = new Map(); this._players = new Map(); this._volumes = new Map(); this._filters = new Map(); this._queueStatuses = new Map(); this._lastPlayed = new Map(); if (this.client) { this.client.on?.('voiceStateUpdate', this._handleVoiceStateUpdate.bind(this)); } } /** * Handles voice state updates for the bot user. * @param {VoiceState} oldState - The previous voice state. * @param {VoiceState} newState - The updated voice state. * @private */ _handleVoiceStateUpdate(oldState, newState) { if (newState.id !== this.client.user.id) return; if (!oldState.channelId && newState.channelId) { this.emit(PlayerEvents.VoiceJoin, newState.channel); } else if (oldState.channelId && !newState.channelId) { this.emit(PlayerEvents.VoiceLeft, oldState.channel); } else if (oldState.channelId !== newState.channelId) { this.emit(PlayerEvents.VoiceUpdate, oldState.channel, newState.channel); } } /** * Searches YouTube for videos based on a query. * @param {string} query - The search query. * @param {number} [trackLimit=10] - Maximum number of tracks to return. * @returns {Promise<Array<Object>>} - Array of video objects. */ async search(query, trackLimit = 10) { try { const result = await ytSearch(query); return result.videos.slice(0, trackLimit); } catch (error) { console.error("Search error:", error); return []; } } /** * Force connects to a specified voice channel. * @param {string|Discord.Snowflake} channelResolvable - Channel ID or channel object. * @param {Object} [voiceJoinOptions={}] - Options for joining the voice channel. * @returns {Promise<VoiceConnection>} - The voice connection object. * @throws {Error} - If the channel is invalid or not a voice channel. */ async connect(channelResolvable, voiceJoinOptions = {}) { const channel = await this.client.channels.fetch(Resolvable.from(channelResolvable)).catch(() => null); if (!channel || !channel.guildId || channel.type !== 2) { throw new Error('Invalid channel or not a voice type'); } const connectionOptions = { channelId: channel.id, guildId: channel.guildId, adapterCreator: channel.guild.voiceAdapterCreator, ...voiceJoinOptions, }; const connection = joinVoiceChannel(connectionOptions); this._initializeGuildData(channel.guildId); return connection; } /** * Plays a track in the specified voice channel. * @param {string|Discord.Snowflake} channelResolvable - Channel ID or channel object. * @param {string} query - The search query for the track. * @param {Object} [voiceJoinOptions={}] - Options for joining the voice channel. * @returns {Promise<Object>} - The video object that is played. */ async play(channelResolvable, query, voiceJoinOptions = {}, skipSearching = false) { const channel = await this.client.channels.fetch(Resolvable.from(channelResolvable)).catch(() => {}); let connection = this.getVoiceConnectionChannel(channel?.guildId); connection = this.keepVoiceIfExists && connection ? connection : await this.connect(channelResolvable, voiceJoinOptions); const guildId = connection.joinConfig.guildId; const videos = skipSearching ? [query] : (await this.search(query)); if (videos.length === 0) { throw new Error('No video results found'); } const queue = this._queues.get(guildId); const track = videos[0]; queue.push(track); this.emit(PlayerEvents.TrackAdd, track, guildId); const player = this._players.get(guildId); if (player.state.status !== AudioPlayerStatus.Playing) { await this._playNext(guildId); } connection.subscribe(player); return track; } /** * Initializes guild-specific data maps. * @param {string} guildId - The ID of the guild. * @private */ _initializeGuildData(guildId) { if (!this._queues.has(guildId)) this._queues.set(guildId, []); if (!this._filters.has(guildId)) this._filters.set(guildId, []); if (!this._players.has(guildId)) this._players.set(guildId, createAudioPlayer()); if (!this._queueStatuses.has(guildId)) this._queueStatuses.set(guildId, LoopStatuses.Nothing); if (!this._lastPlayed.has(guildId)) this._lastPlayed.set(guildId, null); } /** * Plays the next track in the queue for a guild. * @param {string|Discord.Snowflake} guildResolvable - Guild ID or guild object. * @private */ async _playNext(guildResolvable) { const guildId = Resolvable.from(guildResolvable); const queue = this._queues.get(guildId); const player = this._players.get(guildId); const filters = this._filters.get(guildId); const volume = this._volumes.get(guildId) || (this.defaultVolume); const queueStatus = this._queueStatuses.get(guildId); const next = this._getNextTrack(queue, queueStatus, guildId); if (!player || !next) { player?.stop(); this._cleanUpResources(guildId); this.emit(PlayerEvents.QueueEnd, guildId); return; } const transcoder = new prism.FFmpeg({ args: [ ...basePrismOptions, '-filter:a', filters?.map(x => x.ffmpeg).join() || 'atempo=1.0' ] }); const stream = await getFullStream(next.url, this.ytDlpOptions.split(' ')); const transcodedStream = stream.pipe(transcoder); const resource = createAudioResource(transcodedStream, { inputType: StreamType.Raw, inlineVolume: true, }); resource.volume.setVolume(volume / 100); resource.ytData = next; this._lastPlayed.set(guildId, next); player.play(resource); this.emit(PlayerEvents.TrackStart, next, guildId); player.once(AudioPlayerStatus.Idle, async () => await this._playNext(guildId)); return next; } /** * Determines the next track based on queue status. * @param {Array} queue - The current queue of tracks. * @param {string} queueStatus - Current loop status (Nothing, Track, Queue). * @param {string} guildId - Guild ID. * @returns {Object|null} - The next track to play. * @private */ _getNextTrack(queue, queueStatus, guildId) { switch (queueStatus) { case LoopStatuses.Nothing: return queue.shift(); case LoopStatuses.Track: return { ...(this._lastPlayed.get(guildId)) }; case LoopStatuses.Queue: this._queues.set(guildId, [...queue, { ...(this._lastPlayed.get(guildId)) } ]); return this._queues.get(guildId).shift(); default: return null; } } /** * Cleans up all resources associated with a guild. * @param {string} guildId - Guild ID. * @private */ _cleanUpResources(guildId) { this._queues.delete(guildId); this._players.delete(guildId); this._volumes.delete(guildId); this._filters.delete(guildId); this._queueStatuses.delete(guildId); this._lastPlayed.delete(guildId) } /** * Pauses playback in a guild's voice channel. * @param {string|Discord.Snowflake} guildResolvable - Guild ID or guild object. * @returns {boolean} - True if successfully paused, false otherwise. */ pause(guildResolvable) { const guildId = Resolvable.from(guildResolvable); const player = this._players.get(guildId); if (player?.state.status === AudioPlayerStatus.Playing) { player.pause(); } return this.paused(guildId); } /** * Resumes playback in a guild's voice channel. * @param {string|Discord.Snowflake} guildResolvable - Guild ID or guild object. * @returns {boolean} - True if successfully resumed, false otherwise. */ unpause(guildResolvable) { const guildId = Resolvable.from(guildResolvable); const player = this._players.get(guildId); if (player?.state.status === AudioPlayerStatus.Paused) { player.unpause(); } return this.paused(guildId); } /** * Sets the volume for playback in a guild's voice channel. * @param {string|Discord.Snowflake} guildResolvable - Guild ID or guild object. * @param {number} volume - The volume percentage. */ setVolume(guildResolvable, volume) { const guildId = Resolvable.from(guildResolvable); const player = this._players.get(guildId); if (!this.nowPlaying(guildId)) return; this._volumes.set(guildId, isFinite(volume) ? volume : 100); player?.state?.resource?.volume?.setVolume(volume / 100); } /** * Skips the current track and plays the next one in the queue. * @param {string|Discord.Snowflake} guildResolvable - Guild ID or guild object. * @returns {Object|null} - The video object of the next track, or null if no next track. */ async skip(guildResolvable) { return await this._playNext(guildResolvable) } /** * Stops playback in a guild's voice channel. * @param {string|Discord.Snowflake} guildResolvable - Guild ID or guild object. */ async stop(guildResolvable) { const guildId = Resolvable.from(guildResolvable); const player = this._players.get(guildId); if (player) { this._cleanUpResources(guildId) player?.stop(); } } /** * Checks if playback is currently paused in a guild's voice channel. * @param {string|Discord.Snowflake} guildResolvable - Guild ID or guild object. * @returns {boolean} - True if playback is paused, false otherwise. */ paused(guildResolvable) { const guildId = Resolvable.from(guildResolvable); const player = this._players.get(guildId); return player?.state.status === AudioPlayerStatus.Paused; } /** * Sets the loop status for the queue in a guild's voice channel. * @param {string|Discord.Snowflake} guildResolvable - Guild ID or guild object. * @param {number} status - The loop status to set: Nothing (0), Track (1), Queue (2). */ loopQueue(guildResolvable, status) { const guildId = Resolvable.from(guildResolvable); const validStatuses = [LoopStatuses.Nothing, LoopStatuses.Track, LoopStatuses.Queue]; const finalStatus = validStatuses.includes(status) ? status : LoopStatuses.Nothing; this._queueStatuses.set(guildId, finalStatus); this.emit(PlayerEvents.UpdateLoopStatus, guildId, finalStatus); } /** * Retrieves the current loop status for the queue in a guild's voice channel. * @param {string|Discord.Snowflake} guildResolvable - Guild ID or guild object. * @returns {number} - The current loop status. */ getLoopQueueStatus(guildResolvable) { const guildId = Resolvable.from(guildResolvable); return this._queueStatuses.get(guildId) || LoopStatuses.Nothing; } /** * Retrieves the current playback queue for a guild. * @param {string|Discord.Snowflake} guildResolvable - Guild ID or guild object. * @returns {Array<Object>} - Array of video objects in the queue. */ getQueue(guildResolvable) { const guildId = Resolvable.from(guildResolvable); return this._queues.get(guildId) || []; } /** * Shuffles the current playback queue for a guild. * @param {string|Discord.Snowflake} guildResolvable - Guild ID or guild object. */ shuffleQueue(guildResolvable) { const guildId = Resolvable.from(guildResolvable); const queue = this.getQueue(guildId); this._queues.set(guildId, shuffleArray([...queue])); } /** * Retrieves the currently playing track in a guild's voice channel. * @param {string|Discord.Snowflake} guildResolvable - Guild ID or guild object. * @returns {Object|null} - The currently playing track, or null if none. */ nowPlaying(guildResolvable) { const guildId = Resolvable.from(guildResolvable); const player = this._players.get(guildId); if (player?.state.status === AudioPlayerStatus.Playing) { return player.state.resource?.ytData || null; } return null; } /** * Adds an FFmpeg filter to the audio stream. * @param {string|Discord.Snowflake} guildResolvable - Guild ID or guild object. * @param {string} ffmpegFilter - The FFmpeg filter to add. */ addFilter(guildResolvable, ffmpegFilter) { const guildId = Resolvable.from(guildResolvable); const filters = this._filters.get(guildId) || []; this._filters.set(guildId, [...filters, ffmpegFilter]); this.restartCurrentTrack(guildId); } /** * Sets an FFmpeg filter for the audio stream, replacing existing ones. * @param {string|Discord.Snowflake} guildResolvable - Guild ID or guild object. * @param {string} ffmpegFilter - The FFmpeg filter to set. */ setFilter(guildResolvable, ffmpegFilter) { const guildId = Resolvable.from(guildResolvable); this._filters.set(guildId, [ffmpegFilter]); this.restartCurrentTrack(guildId); } /** * Removes an FFmpeg filter from the audio stream. * @param {string|Discord.Snowflake} guildResolvable - Guild ID or guild object. * @param {string} ffmpegFilter - The FFmpeg filter to remove. */ removeFilter(guildResolvable, ffmpegFilter) { const guildId = Resolvable.from(guildResolvable); const filters = this._filters.get(guildId) || []; this._filters.set(guildId, filters.filter(filter => filter !== ffmpegFilter)); this.restartCurrentTrack(guildId); } /** * Resets all FFmpeg filters for the audio stream. * @param {string|Discord.Snowflake} guildResolvable - Guild ID or guild object. */ resetFilters(guildResolvable) { const guildId = Resolvable.from(guildResolvable); this._filters.delete(guildId); this.restartCurrentTrack(guildId); } /** * Restarts the current track, applying changes (e.g., filters). * @param {string|Discord.Snowflake} guildResolvable - Guild ID or guild object. */ restartCurrentTrack(guildResolvable) { const guildId = Resolvable.from(guildResolvable); const queue = this.getQueue(guildId); const currentTrack = this.nowPlaying(guildId); if (currentTrack) { queue.unshift(currentTrack); // Re-add the current track at the start. this._playNext(guildId); } } /** * Disconnects from the guild's voice channel and cleans up resources. * @param {string|Discord.Snowflake} guildResolvable - Guild ID or guild object. */ disconnect(guildResolvable) { const guildId = Resolvable.from(guildResolvable); const connection = getVoiceConnection(guildId); if (connection) { connection.destroy(); } this._cleanUpResources(guildId); this.emit(PlayerEvents.Disconnected, guildId); } /** * Gets the voice connection in the guild. * @param {string|Discord.Snowflake} guildResolvable - Guild ID or guild object. * @returns {VoiceConnection|null} - The voice connection, or null if none. */ getVoiceConnection(guildResolvable) { return getVoiceConnection(Resolvable.from(guildResolvable)) || null; } /** * Gets the voice channel associated with the current connection. * @param {string|Discord.Snowflake} guildResolvable - Guild ID or guild object. * @returns {Promise<Object|null>} - The voice channel object, or null if none. */ async getVoiceConnectionChannel(guildResolvable) { const connection = this.getVoiceConnection(guildResolvable); if (!connection) return null; return await this.client.channels.fetch(connection.joinConfig?.channelId).catch(() => null); } /** * Removes a specific track from the queue. * @param {string|Discord.Snowflake} guildResolvable - Guild ID or guild object. * @param {number} index - Index of the track to remove (starting from 0). * @returns {Object|null} - The removed track, or null if the index is invalid. */ removeTrack(guildResolvable, index) { const guildId = Resolvable.from(guildResolvable); const queue = this.getQueue(guildId); if (index < 0 || index >= queue.length) return null; const removedTrack = queue.splice(index, 1)[0]; this._queues.set(guildId, queue); return removedTrack; } /** * Moves a track to a different position in the queue. * @param {string|Discord.Snowflake} guildResolvable - Guild ID or guild object. * @param {number} fromIndex - Current index of the track. * @param {number} toIndex - Desired index to move the track to. * @returns {boolean} - True if the move was successful, false otherwise. */ moveTrack(guildResolvable, fromIndex, toIndex) { const guildId = Resolvable.from(guildResolvable); const queue = this.getQueue(guildId); if (fromIndex < 0 || fromIndex >= queue.length || toIndex < 0 || toIndex >= queue.length) { return false; } const [track] = queue.splice(fromIndex, 1); queue.splice(toIndex, 0, track); this._queues.set(guildId, queue); return true; } /** * Updates the voice status of a specified channel. * @param {string|Discord.Snowflake} channelResolvable - Channel ID or channel object. * @param {string} status - The voice status to set (e.g., "muted", "unmuted"). */ async setChannelStatus(channelResolvable, status) { const channelId = Resolvable.from(channelResolvable); await this.client.rest.put(`/channels/${channelId}/voice-status`, { body: { status } }); } } module.exports = Player;