diff --git a/index.js b/index.js index e773011..76fafbb 100644 --- a/index.js +++ b/index.js @@ -1,4 +1,4 @@ module.exports = { version: require('./package.json').version, Player: require('./src/Player') -}; \ No newline at end of file +} diff --git a/package.json b/package.json index 1140cd7..8a10c49 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "merge-options": "^2.0.0", "node-fetch": "^2.6.0", "spotify-url-info": "^1.3.1", - "ytpl": "^0.2.0", + "ytpl": "^0.2.4", "ytsr": "^0.1.19" }, "devDependencies": { diff --git a/src/Player.js b/src/Player.js index a4542f0..2d0e52f 100644 --- a/src/Player.js +++ b/src/Player.js @@ -5,6 +5,8 @@ const ytpl = require('ytpl') const spotify = require('spotify-url-info') const Queue = require('./Queue') const Track = require('./Track') +const Util = require('./Util') +const { EventEmitter } = require('events') /** * @typedef Filters @@ -54,6 +56,7 @@ const filters = { * @property {boolean} [leaveOnEnd=true] Whether the bot should leave the current voice channel when the queue ends. * @property {boolean} [leaveOnStop=true] Whether the bot should leave the current voice channel when the stop() function is used. * @property {boolean} [leaveOnEmpty=true] Whether the bot should leave the voice channel if there is no more member in it. + * @property {number} [leaveOnEmptyCooldown=0] Used when leaveOnEmpty is enabled, to let the time to users to come back in the voice channel. */ /** @@ -67,14 +70,20 @@ const defaultPlayerOptions = { leaveOnEmpty: true } -class Player { +class Player extends EventEmitter { /** * @param {Discord.Client} client Discord.js client * @param {PlayerOptions} options Player options */ constructor (client, options = {}) { if (!client) throw new SyntaxError('Invalid Discord client') + super() + /** + * Utilities + * @type {Util} + */ + this.util = Util /** * Discord.js client instance * @type {Discord.Client} @@ -82,9 +91,9 @@ class Player { this.client = client /** * Player queues - * @type {Queue[]} + * @type {Discord.Collection} */ - this.queues = [] + this.queues = new Discord.Collection() /** * Player options * @type {PlayerOptions} @@ -103,38 +112,90 @@ class Player { client.on('voiceStateUpdate', (oldState, newState) => this._handleVoiceStateUpdate(oldState, newState)) } - /** - * Set the filters enabled for the guild. [Full list of the filters](https://discord-player.js.org/global.html#Filters) - * @param {Discord.Snowflake} guildID - * @param {Filters} newFilters - * - * @example - * client.on('message', async (message) => { - * - * const args = message.content.slice(settings.prefix.length).trim().split(/ +/g); - * const command = args.shift().toLowerCase(); - * - * if(command === 'bassboost'){ - * const bassboostEnabled = client.player.getQueue(message.guild.id).filters.bassboost; - * if(!bassboostEnabled){ - * client.player.setFilters(message.guild.id, { - * bassboost: true - * }); - * message.channel.send("Bassboost effect has been enabled!"); - * } else { - * client.player.setFilters(message.guild.id, { - * bassboost: false - * }); - * message.channel.send("Bassboost effect has been disabled!"); - * } - * } - * - * }); - */ - setFilters (guildID, newFilters) { + resolveQueryType (query) { + if (this.util.isSpotifyLink(query)) { + return 'spotify-song' + } else if (this.util.isYTPlaylistLink(query)) { + return 'youtube-playlist' + } else if (this.util.isYTVideoLink(query)) { + return 'youtube-video' + } else { + return 'youtube-video-keywords' + } + } + + async _searchTracks (message, query) { + const tracks = [] + + const queryType = this.resolveQueryType(query) + + if (queryType === 'spotify-song') { + const matchSpotifyURL = query.match(/https?:\/\/(?:embed\.|open\.)(?:spotify\.com\/)(?:track\/|\?uri=spotify:track:)((\w|-){22})/) + if (matchSpotifyURL) { + const spotifyData = await spotify.getPreview(query).catch(() => {}) + if (spotifyData) { + const YTQuery = `${spotifyData.artist} - ${spotifyData.track}` + const results = await ytsr(query) + + if (results.items.length !== 0) { + const resultsVideo = results.items.filter((i) => i.type === 'video') + tracks.push(...resultsVideo.map((r) => new Track(r, message.author, null))) + } + } + } + } else if (queryType === 'youtube-playlist') { + const playlistID = await ytpl.getPlaylistID(query).catch(() => {}) + if (playlistID) { + const playlist = await ytpl(playlistID).catch(() => {}) + if (playlist) { + tracks.push(...playlist.items.map((i) => new Track({ + title: i.title, + duration: i.duration, + thumbnail: i.thumbnail, + author: i.author, + link: i.url, + fromPlaylist: true + }, message.author, null))) + } + } + } else if (queryType === 'youtube-video-keywords') { + await ytsr(query).then((results) => { + if (results.items.length !== 0) { + const resultsVideo = results.items.filter((i) => i.type === 'video') + tracks.push(...resultsVideo.map((r) => new Track(r, null, null))) + } + }).catch(() => {}) + } + + if (tracks.length === 0) throw new Error('No tracks found for the specified query.') + + let track = tracks[0] + + try { + this.emit('searchResults', message, query, tracks) + const answers = await message.channel.awaitMessages(m => m.author.id === message.author.id, { + max: 1, + time: 60000, + errors: ['time'] + }) + const index = parseInt(answers.first().content, 10) + if (isNaN(index) || index > tracks.length || index < 1) { + this.emit('searchCancel', message) + return + } + track = tracks[index - 1] + } catch { + this.emit('searchCancel', message) + return + } + + return track + } + + setFilters (message, newFilters) { return new Promise((resolve, reject) => { // Get guild queue - const queue = this.queues.find((g) => g.guildID === guildID) + const queue = this.queues.find((g) => g.guildID === message.guild.id) if (!queue) return reject(new Error('Not playing')) Object.keys(newFilters).forEach((filterName) => { queue.filters[filterName] = newFilters[filterName] @@ -143,183 +204,85 @@ class Player { }) } - /** - * Resolve an array of tracks objects from a query string - * @param {string} query The query - * @param {boolean} allResults Whether all the results should be returned, or only the first one - * @returns {Promise} - * - * @example - * client.on('message', async (message) => { - * - * const args = message.content.slice(settings.prefix.length).trim().split(/ +/g); - * const command = args.shift().toLowerCase(); - * - * if(command === 'play'){ - * // Search for tracks - * let tracks = await client.player.searchTracks(args[0]); - * // Sends an embed with the 10 first songs - * if(tracks.length > 10) tracks = tracks.substr(0, 10); - * const embed = new Discord.MessageEmbed() - * .setDescription(tracks.map((t, i) => `**${i+1} -** ${t.name}`).join("\n")) - * .setFooter("Send the number of the track you want to play!"); - * message.channel.send(embed); - * // Wait for user answer - * await message.channel.awaitMessages((m) => m.content > 0 && m.content < 10, { max: 1, time: 20000, errors: ["time"] }).then(async (answers) => { - * let index = parseInt(answers.first().content, 10); - * track = track[index-1]; - * // Then play the song - * client.player.play(message.member.voice.channel, track); - * }); - * } - * - * }); - */ - searchTracks (query, allResults = false) { - return new Promise(async (resolve, reject) => { - if (ytpl.validateURL(query)) { - const playlistID = await ytpl.getPlaylistID(query).catch(() => {}) - if (playlistID) { - const playlist = await ytpl(playlistID).catch(() => {}) - if (playlist) { - return resolve(playlist.items.map((i) => new Track({ - title: i.title, - duration: i.duration, - thumbnail: i.thumbnail, - author: i.author, - link: i.url, - fromPlaylist: true - }, null, null))) - } - } - } - const matchSpotifyURL = query.match(/https?:\/\/(?:embed\.|open\.)(?:spotify\.com\/)(?:track\/|\?uri=spotify:track:)((\w|-){22})/) - if (matchSpotifyURL) { - const spotifyData = await spotify.getPreview(query).catch(e => resolve([])) - query = `${spotifyData.artist} - ${spotifyData.track}` - } - // eslint-disable-next-line no-useless-escape - const matchYoutubeURL = query.match(/(?:youtube\.com\/(?:[^\/]+\/.+\/|(?:v|e(?:mbed)?)\/|.*[?&]v=)|youtu\.be\/)([^"&?\/\s]{11})/) - if (matchYoutubeURL) { - query = matchYoutubeURL[1] - } - ytsr(query).then((results) => { - if (results.items.length < 1) return resolve([]) - const resultsVideo = results.items.filter((i) => i.type === 'video') - resolve(allResults ? resultsVideo.map((r) => new Track(r, null, null)) : [new Track(resultsVideo[0], null, null)]) - }).catch(() => { - return resolve([]) - }) - }) + isPlaying (message) { + return this.queues.some((g) => g.guildID === message.guild.id) } - /** - * Whether a guild is currently playing something - * @param {Discord.Snowflake} guildID The guild ID to check - * @returns {boolean} Whether the guild is currently playing tracks - */ - isPlaying (guildID) { - return this.queues.some((g) => g.guildID === guildID) + _addTracksToQueue (message, tracks) { + const queue = this.getQueue(message) + if (!queue) throw new Error('Cannot add tracks to queue because no song is currently played on the server.') + queue.tracks = queue.tracks.concat(tracks) + return queue } - /** - * Play a track in a voice channel - * @param {Discord.VoiceChannel} voiceChannel The voice channel in which the track will be played - * @param {Track|string} track The name of the track to play - * @param {Discord.User?} user The user who requested the track - * @returns {any} The played content - * - * @example - * client.on('message', async (message) => { - * - * const args = message.content.slice(settings.prefix.length).trim().split(/ +/g); - * const command = args.shift().toLowerCase(); - * - * // !play Despacito - * // will play "Despacito" in the member voice channel - * - * if(command === 'play'){ - * const result = await client.player.play(message.member.voice.channel, args.join(" ")); - * if(result.type === 'playlist'){ - * message.channel.send(`${result.tracks.length} songs added to the queue!\nCurrently playing **${result.tracks[0].name}**...`); - * } else { - * message.channel.send(`Currently playing ${result.name}...`); - * } - * } - * - * }); - */ - play (voiceChannel, track, user) { - this.queues = this.queues.filter((g) => g.guildID !== voiceChannel.id) - return new Promise(async (resolve, reject) => { - if (!voiceChannel || typeof voiceChannel !== 'object') { - return reject(new Error(`voiceChannel must be type of VoiceChannel. value=${voiceChannel}`)) - } - const connection = voiceChannel.client.voice.connections.find((c) => c.channel.id === voiceChannel.id) || await voiceChannel.join() - // Create a new guild with data - const queue = new Queue(voiceChannel.guild.id) - queue.voiceConnection = connection - queue.filters = {} - Object.keys(this.filters).forEach((f) => { - queue.filters[f] = false - }) - let result = null - if (typeof track === 'object') { - track.requestedBy = user - result = track - // Add the track to the queue + _createQueue (message, track) { + return new Promise((resolve, reject) => { + const channel = message.member.voice ? message.member.voice.channel : null + if (!channel) reject('NotConnected') + const queue = new Queue(message.guild.id, message, this.filters) + this.queues.set(message.guild.id, queue) + channel.join().then((connection) => { + queue.voiceConnection = connection queue.tracks.push(track) - } else if (typeof track === 'string') { - const results = await this.searchTracks(track).catch(() => { - return reject(new Error('Not found')) - }) - if (!results) return - if (results.length > 1) { - result = { - type: 'playlist', - tracks: results - } - } else if (results[0]) { - result = results[0] - } else { - return reject(new Error('Not found')) - } - results.forEach((i) => { - i.requestedBy = user - queue.tracks.push(i) - }) - } - // Add the queue to the list - this.queues.push(queue) - // Play the track - this._playTrack(queue.guildID, true) - // Resolve the track - resolve(result) + this.emit('queueCreate', message, queue) + this._playTrack(message.guild.id, true) + }).catch((err) => { + console.error(err) + this.queues.delete(message.guild.id) + reject('UnableToJoin') + }) }) } - /** - * Pause the current track - * @param {Discord.Snowflake} guildID The ID of the guild where the current track should be paused - * @returns {Promise} The paused track - * - * @example - * client.on('message', async (message) => { - * - * const args = message.content.slice(settings.prefix.length).trim().split(/ +/g); - * const command = args.shift().toLowerCase(); - * - * if(command === 'pause'){ - * const track = await client.player.pause(message.guild.id); - * message.channel.send(`${track.name} paused!`); - * } - * - * }); - */ - pause (guildID) { + async _handlePlaylist (message, query) { + const playlist = await ytpl(query).catch(() => {}) + if (!playlist) return this.emit('noResults', message, query) + playlist.tracks = playlist.items.map((item) => new Track(item, message.author)) + playlist.duration = playlist.tracks.reduce((prev, next) => prev + next.duration, 0) + playlist.thumbnail = playlist.tracks[0].thumbnail + playlist.requestedBy = message.author + if (this.isPlaying(message)) { + const queue = this._addTracksToQueue(message, playlist.tracks) + this.emit('addList', message, queue, playlist) + } else { + const track = new Track(playlist.tracks.shift(), message.author) + const queue = await this._createQueue(message, track).catch((e) => this.emit('error', message, e)) + this._addTracksToQueue(message, playlist.tracks) + this.emit('playlistStart', message, queue, playlist, queue.tracks[0]) + } + } + + async play (message, query) { + const isPlaying = this.isPlaying(message) + if (!isPlaying) { + if (this.util.isYTPlaylistLink(query)) { + this._handlePlaylist(message, query) + } + let trackToPlay + if (query instanceof Track) { + trackToPlay = query + } else if (this.util.isYTVideoLink(query)) { + const videoData = await ytdl.getBasicInfo(query) + trackToPlay = new Track(videoData, message.author) + } else { + trackToPlay = await this._searchTracks(message, query) + } + if (trackToPlay) { + if (this.isPlaying(message)) { + const queue = this._addToQueue(message, trackToPlay) + this.emit('addSong', message, queue, queue.tracks[queue.tracks.length - 1]) + } else { + const queue = await this._createQueue(message, trackToPlay) + this.emit('playSong', message, queue, queue.tracks[0]) + } + } + } + } + + pause (message) { return new Promise((resolve, reject) => { // Get guild queue - const queue = this.queues.find((g) => g.guildID === guildID) + const queue = this.queues.find((g) => g.guildID === message.guild.id) if (!queue) return reject(new Error('Not playing')) // Pause the dispatcher queue.voiceConnection.dispatcher.pause() @@ -329,28 +292,10 @@ class Player { }) } - /** - * Resume the current track - * @param {Discord.Snowflake} guildID The ID of the guild where the current track should be resumed - * @returns {Promise} The resumed track - * - * @example - * client.on('message', async (message) => { - * - * const args = message.content.slice(settings.prefix.length).trim().split(/ +/g); - * const command = args.shift().toLowerCase(); - * - * if(command === 'resume'){ - * const track = await client.player.resume(message.guild.id); - * message.channel.send(`${track.name} resumed!`); - * } - * - * }); - */ - resume (guildID) { + resume (message) { return new Promise((resolve, reject) => { // Get guild queue - const queue = this.queues.find((g) => g.guildID === guildID) + const queue = this.queues.find((g) => g.guildID === message.guild.id) if (!queue) return reject(new Error('Not playing')) // Pause the dispatcher queue.voiceConnection.dispatcher.resume() @@ -360,58 +305,23 @@ class Player { }) } - /** - * Stop the music in the guild - * @param {Discord.Snowflake} guildID The ID of the guild where the music should be stopped - * @returns {Promise} - * - * @example - * client.on('message', (message) => { - * - * const args = message.content.slice(settings.prefix.length).trim().split(/ +/g); - * const command = args.shift().toLowerCase(); - * - * if(command === 'stop'){ - * client.player.stop(message.guild.id); - * message.channel.send('Music stopped!'); - * } - * - * }); - */ - stop (guildID) { + stop (message) { return new Promise((resolve, reject) => { // Get guild queue - const queue = this.queues.find((g) => g.guildID === guildID) + const queue = this.queues.find((g) => g.guildID === message.guild.id) if (!queue) return reject(new Error('Not playing')) // Stop the dispatcher queue.stopped = true queue.tracks = [] if (queue.stream) queue.stream.destroy() queue.voiceConnection.dispatcher.end() + if (this.options.leaveOnStop) queue.voiceConnection.channel.leave() + this.queues.delete(message.guild.id) // Resolve resolve() }) } - /** - * Update the volume - * @param {Discord.Snowflake} guildID The ID of the guild where the music should be modified - * @param {number} percent The new volume (0-100) - * @returns {Promise} - * - * @example - * client.on('message', (message) => { - * - * const args = message.content.slice(settings.prefix.length).trim().split(/ +/g); - * const command = args.shift().toLowerCase(); - * - * if(command === 'set-volume'){ - * client.player.setVolume(message.guild.id, parseInt(args[0])); - * message.channel.send(`Volume set to ${args[0]} !`); - * } - * - * }); - */ setVolume (guildID, percent) { return new Promise((resolve, reject) => { // Get guild queue @@ -425,130 +335,12 @@ class Player { }) } - /** - * Get a guild queue - * @param {Discord.Snowflake} guildID - * @returns {?Queue} - * - * @example - * client.on('message', (message) => { - * - * const args = message.content.slice(settings.prefix.length).trim().split(/ +/g); - * const command = args.shift().toLowerCase(); - * - * if(command === 'queue'){ - * const queue = await client.player.getQueue(message.guild.id); - * message.channel.send('Server queue:\n'+(queue.tracks.map((track, i) => { - * return `${i === 0 ? 'Current' : `#${i+1}`} - ${track.name} | ${track.author}`; - * }).join('\n'))); - * } - * - * // Output: - * - * // Server queue: - * // Current - Despacito | Luis Fonsi - * // #2 - Memories | Maroon 5 - * // #3 - Dance Monkey | Tones And I - * // #4 - Circles | Post Malone - * }); - */ - getQueue (guildID) { + getQueue (message) { // Gets guild queue - const queue = this.queues.find((g) => g.guildID === guildID) + const queue = this.queues.find((g) => g.guildID === message.guild.id) return queue } - /** - * Add a track to the guild queue - * @param {Discord.Snowflake} guildID The ID of the guild where the track should be added - * @param {Track|string} trackName The name of the track to add to the queue - * @param {Discord.User?} user The user who requested the track - * @returns {any} The content added to the queue - * - * @example - * client.on('message', async (message) => { - * - * const args = message.content.slice(settings.prefix.length).trim().split(/ +/g); - * const command = args.shift().toLowerCase(); - * - * if(command === 'play'){ - * let trackPlaying = client.player.isPlaying(message.guild.id); - * // If there's already a track being played - * if(trackPlaying){ - * const result = await client.player.addToQueue(message.guild.id, args.join(" ")); - * if(result.type === 'playlist'){ - * message.channel.send(`${result.tracks.length} songs added to the queue!`); - * } else { - * message.channel.send(`${result.name} added to the queue!`); - * } - * } else { - * // Else, play the track - * const result = await client.player.addToQueue(message.member.voice.channel, args[0]); - * if(result.type === 'playlist'){ - * message.channel.send(`${result.tracks.length} songs added to the queue\nCurrently playing **${result.tracks[0].name}**!`); - * } else { - * message.channel.send(`Currently playing ${result.name}`); - * } - * } - * } - * - * }); - */ - addToQueue (guildID, track, user) { - return new Promise(async (resolve, reject) => { - // Get guild queue - const queue = this.queues.find((g) => g.guildID === guildID) - if (!queue) return reject(new Error('Not playing')) - // Search the track - let result = null - if (typeof track === 'object') { - track.requestedBy = user - result = track - // Add the track to the queue - queue.tracks.push(track) - } else if (typeof track === 'string') { - const results = await this.searchTracks(track).catch(() => { - return reject(new Error('Not found')) - }) - if (!results) return - if (results.length > 1) { - result = { - type: 'playlist', - tracks: results - } - } else if (results[0]) { - result = results[0] - } else { - return reject(new Error('Not found')) - } - results.forEach((i) => { - i.requestedBy = user - queue.tracks.push(i) - }) - } - // Resolve the result - resolve(result) - }) - } - - /** - * Clear the guild queue, except the current track - * @param {Discord.Snowflake} guildID The ID of the guild where the queue should be cleared - * @returns {Promise} The updated queue - * - * @example - * client.on('message', (message) => { - * - * const args = message.content.slice(settings.prefix.length).trim().split(/ +/g); - * const command = args.shift().toLowerCase(); - * - * if(command === 'clear-queue'){ - * client.player.clearQueue(message.guild.id); - * message.channel.send('Queue cleared!'); - * } - * - * }); - */ clearQueue (guildID) { return new Promise((resolve, reject) => { // Get guild queue @@ -561,24 +353,6 @@ class Player { }) } - /** - * Skip a track - * @param {Discord.Snowflake} guildID The ID of the guild where the track should be skipped - * @returns {Promise} - * - * @example - * client.on('message', async (message) => { - * - * const args = message.content.slice(settings.prefix.length).trim().split(/ +/g); - * const command = args.shift().toLowerCase(); - * - * if(command === 'skip'){ - * const track = await client.player.skip(message.guild.id); - * message.channel.send(`${track.name} skipped!`); - * } - * - * }); - */ skip (guildID) { return new Promise((resolve, reject) => { // Get guild queue @@ -593,62 +367,17 @@ class Player { }) } - /** - * Get the currently playing track - * @param {Discord.Snowflake} guildID - * @returns {Promise} The track which is currently played - * - * @example - * client.on('message', async (message) => { - * - * const args = message.content.slice(settings.prefix.length).trim().split(/ +/g); - * const command = args.shift().toLowerCase(); - * - * if(command === 'now-playing'){ - * let track = await client.player.nowPlaying(message.guild.id); - * message.channel.send(`Currently playing ${track.name}...`); - * } - * - * }); - */ nowPlaying (guildID) { return new Promise((resolve, reject) => { // Get guild queue const queue = this.queues.find((g) => g.guildID === guildID) if (!queue) return reject(new Error('Not playing')) - const currentTrack = queue.playing + const currentTrack = queue.tracks[0] // Resolve the current track resolve(currentTrack) }) } - /** - * Enable or disable the repeat mode - * @param {Discord.Snowflake} guildID - * @param {Boolean} enabled Whether the repeat mode should be enabled - * @returns {Promise} - * - * @example - * client.on('message', async (message) => { - * - * const args = message.content.slice(settings.prefix.length).trim().split(/ +/g); - * const command = args.shift().toLowerCase(); - * - * if(command === 'repeat-mode'){ - * const repeatModeEnabled = client.player.getQueue(message.guild.id).repeatMode; - * if(repeatModeEnabled){ - * // if the repeat mode is currently enabled, disable it - * client.player.setRepeatMode(message.guild.id, false); - * message.channel.send("Repeat mode disabled! The current song will no longer be played again and again..."); - * } else { - * // if the repeat mode is currently disabled, enable it - * client.player.setRepeatMode(message.guild.id, true); - * message.channel.send("Repeat mode enabled! The current song will be played again and again until you run the command again!"); - * } - * } - * - * }); - */ setRepeatMode (guildID, enabled) { return new Promise((resolve, reject) => { // Get guild queue @@ -661,26 +390,6 @@ class Player { }) } - /** - * Shuffle the guild queue (except the first track) - * @param {Discord.Snowflake} guildID The ID of the guild where the queue should be shuffled - * @returns {Promise} The updated queue - * - * @example - * client.on('message', async (message) => { - * - * const args = message.content.slice(settings.prefix.length).trim().split(/ +/g); - * const command = args.shift().toLowerCase(); - * - * if(command === 'shuffle'){ - * // Shuffle the server queue - * client.player.shuffle(message.guild.id).then(() => { - * message.channel.send('Queue shuffled!'); - * }); - * } - * - * }); - */ shuffle (guildID) { return new Promise((resolve, reject) => { // Get guild queue @@ -695,27 +404,6 @@ class Player { }) } - /** - * Remove a track from the queue - * @param {Discord.Snowflake} guildID The ID of the guild where the track should be removed - * @param {number|Track} track The index of the track to remove or the track to remove object - * @returns {Promise} - * - * @example - * client.on('message', async (message) => { - * - * const args = message.content.slice(settings.prefix.length).trim().split(/ +/g); - * const command = args.shift().toLowerCase(); - * - * if(command === 'remove'){ - * // Remove a track from the queue - * client.player.remove(message.guild.id, args[0]).then(() => { - * message.channel.send('Removed track!'); - * }); - * } - * - * }); - */ remove (guildID, track) { return new Promise((resolve, reject) => { // Gets guild queue @@ -739,25 +427,6 @@ class Player { }) } - /** - * Creates progress bar of the current song - * @param {Discord.Snowflake} guildID - * @returns {String} - * - * @example - * client.on('message', async (message) => { - * - * const args = message.content.slice(settings.prefix.length).trim().split(/ +/g); - * const command = args.shift().toLowerCase(); - * - * if(command === 'now-playing'){ - * client.player.nowPlaying(message.guild.id).then((song) => { - * message.channel.send('Currently playing ' + song.name + '\n\n'+ client.player.createProgressBar(message.guild.id)); - * }); - * } - * - * }); - */ createProgressBar (guildID) { // Gets guild queue const queue = this.queues.find((g) => g.guildID === guildID) @@ -767,7 +436,7 @@ class Player { ? queue.voiceConnection.dispatcher.streamTime + queue.additionalStreamTime : 0 // Total stream time - const totalTime = queue.playing.durationMS + const totalTime = queue.tracks[0].durationMS // Stream progress const index = Math.round((currentStreamTime / totalTime) * 15) // conditions @@ -780,14 +449,8 @@ class Player { } } - /** - * Handle the voice state update event - * @ignore - * @private - * @param {Discord.VoiceState} oldState - * @param {Discord.VoiceState} newState - */ _handleVoiceStateUpdate (oldState, newState) { + const isEmpty = (channel) => (channel.members.filter((member) => !member.user.bot)).size === 0 if (!this.options.leaveOnEmpty) return // If the member leaves a voice channel if (!oldState.channelID || newState.channelID) return @@ -795,25 +458,19 @@ class Player { const queue = this.queues.find((g) => g.voiceConnection.channel.id === oldState.channelID) if (queue) { // If the channel is not empty - if (queue.voiceConnection.channel.members.size > 1) return - // Disconnect from the voice channel - queue.voiceConnection.channel.leave() - // Delete the queue - this.queues = this.queues.filter((g) => g.guildID !== queue.guildID) - // Emit end event - queue.emit('channelEmpty') + if (!isEmpty(queue.voiceConnection.channel)) return + setTimeout(() => { + // Disconnect from the voice channel + queue.voiceConnection.channel.leave() + // Delete the queue + this.queues.delete(queue.guildID) + // Emit end event + queue.emit('channelEmpty', queue.firstMessage, queue) + }, this.options.leaveOnEmptyCooldown ?? 0) } } - /** - * Play a stream in a channel - * @ignore - * @private - * @param {Queue} queue The queue to play - * @param {Boolean} updateFilter Whether this method is called to update some ffmpeg filters - * @returns {Promise} - */ - _playYTDLStream (queue, updateFilter) { + _playYTDLStream (track, queue, updateFilter) { return new Promise((resolve) => { const seekTime = updateFilter ? queue.voiceConnection.dispatcher.streamTime + queue.additionalStreamTime : undefined const encoderArgsFilters = [] @@ -828,7 +485,7 @@ class Player { } else { encoderArgs = ['-af', encoderArgsFilters.join(',')] } - const newStream = ytdl(queue.playing.url, { + const newStream = ytdl(track.url, { filter: 'audioonly', opusEncoded: true, encoderArgs, @@ -861,38 +518,32 @@ class Player { }) } - /** - * Start playing a track in a guild - * @ignore - * @private - * @param {Discord.Snowflake} guildID - * @param {Boolean} firstPlay Whether the function was called from the play() one - */ async _playTrack (guildID, firstPlay) { // Get guild queue const queue = this.queues.find((g) => g.guildID === guildID) + if (queue.stopped) return // If there isn't any music in the queue - if (queue.tracks.length < 1 && !firstPlay && !queue.repeatMode) { + if (queue.tracks.length === 0) { // Leave the voice channel if (this.options.leaveOnEnd && !queue.stopped) queue.voiceConnection.channel.leave() // Remove the guild from the guilds list - this.queues = this.queues.filter((g) => g.guildID !== guildID) + this.queues.delete(guildID) // Emit stop event if (queue.stopped) { - if (this.options.leaveOnStop) queue.voiceConnection.channel.leave() return queue.emit('stop') } // Emit end event return queue.emit('end') } - const wasPlaying = queue.playing - const nowPlaying = queue.playing = queue.repeatMode ? wasPlaying : queue.tracks.shift() + // if the track needs to be the next one + if (!queue.repeatMode && !firstPlay) queue.tracks.shift() + const track = queue.playing // Reset lastSkipped state queue.lastSkipped = false - this._playYTDLStream(queue, false).then(() => { + this._playYTDLStream(track, queue, false).then(() => { // Emit trackChanged event if (!firstPlay) { - queue.emit('trackChanged', wasPlaying, nowPlaying, queue.lastSkipped, queue.repeatMode) + queue.emit('trackStart', queue.firstMessage, track, queue) } }) } diff --git a/src/Queue.js b/src/Queue.js index a3735b6..22a1b86 100644 --- a/src/Queue.js +++ b/src/Queue.js @@ -1,6 +1,7 @@ const Discord = require('discord.js') const { EventEmitter } = require('events') const Track = require('./Track') +const Player = require('./Player') /** * Represents a guild queue. @@ -8,8 +9,10 @@ const Track = require('./Track') class Queue extends EventEmitter { /** * @param {Discord.Snowflake} guildID ID of the guild this queue is for. + * @param {Discord.Message} message Message that initialized the queue + * @param {import('./Player').Filters[]} filters Filters the queue should be initialized with. */ - constructor (guildID) { + constructor (guildID, message, filters) { super() /** * ID of the guild this queue is for. @@ -21,11 +24,6 @@ class Queue extends EventEmitter { * @type {Discord.VoiceConnection} */ this.voiceConnection = null - /** - * The song currently played. - * @type {Track} - */ - this.playing = null /** * The tracks of this queue. The first one is currenlty playing and the others are going to be played. * @type {Track[]} @@ -61,11 +59,23 @@ class Queue extends EventEmitter { * @type {Filters} */ this.filters = {} + Object.keys(filters).forEach((f) => { + this.filters[f] = false + }) /** * Additional stream time * @type {Number} */ this.additionalStreamTime = 0 + /** + * Message that initialized the queue + * @type {Discord.Message} + */ + this.firstMessage = message + } + + get playing () { + return this.tracks[0] } get calculatedVolume () { diff --git a/src/Util.js b/src/Util.js new file mode 100644 index 0000000..204a054 --- /dev/null +++ b/src/Util.js @@ -0,0 +1,61 @@ +const ytpl = require('ytpl') +const Discord = require('discord.js') + +const youtubeVideoRegex = (/(?:youtube\.com\/(?:[^\/]+\/.+\/|(?:v|e(?:mbed)?)\/|.*[?&]v=)|youtu\.be\/)([^"&?\/\s]{11})/) +const spotifySongRegex = (/https?:\/\/(?:embed\.|open\.)(?:spotify\.com\/)(?:track\/|\?uri=spotify:track:)((\w|-){22})/) + +module.exports = class Util { + constructor () { + throw new Error(`The ${this.constructor.name} class may not be instantiated.`) + } + + static isSpotifyLink (query) { + return spotifySongRegex.test(query) + } + + static isYTPlaylistLink (query) { + return ytpl.validateURL(query) + } + + static isYTVideoLink (query) { + return youtubeVideoRegex.test(query) + } + + /** + * Sends a selection embed in a channel and await for the member's answer + * @param {Discord.User} user The user able to choose the song + * @param {Discord.TextChannel} channel The channel in which the selection embed will be sent + * @param {Tracks[]} tracks The tracks the selection embed should contain + * @param {Object} options + * @param {number} options.trackCount The number of tracks to show on the embed + * @param {Function} options.formatTrack Function use to map songs in the selection embed + * @param {String} options.embedColor Color of the selection embed + * @param {String} options.embedFooter Text of the footer of the selection embed + * @param {number} options.collectorTimeout Number of time before the bot cancels the selection and send the collectorTimeoutMessage message + * @param {number} options.collectorTimeoutMessage Message sent when the selection time has expired + * @param {Function} options.embedCallback Function called to allow users to edit the selection embed + * @returns {Promise} + */ + static async awaitSelection (user, channel, tracks, { trackCount, formatTrack, embedColor, embedFooter, collectorTimeout, collectorTimeoutMessage, embedCallback }) { + if (trackCount) tracks.splice(trackCount) + formatTrack = formatTrack || ((track, index) => `${++index} - ${track.name}`) + const embed = new Discord.MessageEmbed() + .setDescription(tracks.map(formatTrack)) + .setColor(embedColor || 'RED') + .setFooter(embedFooter || 'Please send the number of the track you would like to listen.') + await channel.send(embed) + const collected = await channel.awaitMessages((message) => message.author.id === user.id && !isNaN(message.content) && parseInt(message.content) > 0 && parseInt(message.content) < tracks, { + time: collectorTimeout, + errors: ['time'] + }).catch((reason) => { + channel.send(collectorTimeoutMessage) + embedCallback(embed, null) + return null + }) + if (!collected) return null + const index = parseInt(collected.first().content) - 1 + const track = tracks[index] + embedCallback(embed, track) + return track + } +}