// Copyright 2018-2022 Campbell Crowley. All rights reserved. // Author: Campbell Crowley (dev@campbellcrowley.com) const ytdl = require('youtube-dl'); // Music thread uses separate require. const fs = require('fs'); // Music thread uses separate require. const https = require('https'); // const ogg = require('ogg'); // const opus = require('node-opus'); const spawn = require('threads').spawn; const Readable = require('stream').Readable; require('./subModule.js').extend(Music); /** * @classdesc Music and audio related commands. * @class * @augments SubModule * @listens Discord~Client#voiceStateUpdate * @listens Command#play * @listens Command#pause * @listens Command#resume * @listens Command#leave * @listens Command#stop * @listens Command#stfu * @listens Command#skip * @listens Command#q * @listens Command#queue * @listens Command#playing * @listens Command#remove * @listens Command#dequeue * @listens Command#lyrics * @listens Command#record * @listens Command#follow * @listens Command#unfollow * @listens Command#stalk * @listens Command#unstalk * @listens Command#musicstats * @listens Command#volume * @fires Command#stop */ function Music() { const self = this; this.myName = 'Music'; /** * The Genuius client token we use to fetch information from their api. * * @private * @type {string} * @constant */ const geniusClient = 'l5zrX9XIDrJuz-kS1u7zS5sE81KzrH3qxZL5tAvprE9GG-L1KYlZklQDXL6wf3sn'; /** * The request headers to send to genius. * * @private * @type {object} * @default * @constant */ const geniusRequest = { hostname: 'api.genius.com', path: '/search/', headers: { 'Accept': 'application/json', 'Authorization': 'Bearer ' + geniusClient, 'User-Agent': require('./common.js').ua, }, method: 'GET', }; /** * Information about a server's music and queue. * * @typedef {object} Music~Broadcast * * @property {string[]} queue Requests that have been queued. * @property {object.<boolean>} skips Stores user id's and whether * they have voted to skip. Non-existent user means they have not voted to * skip. * @property {boolean} isPlaying Is audio currntly being streamed to the * channel. * @property {?Discord~VoiceBroadcast} broadcast The Discord voice broadcast * actually playing the audio. * @property {?Discord~VoiceConnection} voice The current voice connection * audio is being streamed to. * @property {?Discord~StreamDispatcher} dispatcher The Discord dispatcher for * the current audio channel. * @property {?object} current The current broadcast information including * thread, readable stream, and song information. */ /** * All current audio broadcasts to voice channels. Stores all relavent data. * Stored by guild id. * * @private * @type {object.<Music~Broadcast>} */ const broadcasts = {}; /** * The current user IDs of the users to follow into new voice channels. This * is mapped by guild id. * * @private * @type {object.<string>} */ const follows = {}; /** * Special cases of requests to handle seperately. * * @private * @type {object.<object.<{cmd: string, url: ?string, file: string}>>} * @constant */ const special = { 'nice try vi': { cmd: 'vi', url: 'https://www.youtube.com/watch?v=c1NoTNCiomU', file: './sounds/viRap.ogg', }, 'airhorn': { cmd: 'airhorn', url: '', file: './sounds/airhorn.ogg', }, 'rickroll': { cmd: 'rickroll', url: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', file: './sounds/rickRoll.ogg', }, 'kokomo': { cmd: 'kokomo', url: 'https://www.youtube.com/watch?v=bOyJUF0p9Wc', file: './sounds/kokomo.ogg', }, 'felix': { cmd: 'felix', url: 'https://uploads.twitchalerts.com/000/046/734/035/DontTouchMeImViolent-oxG.wav', file: './sounds/DontTouchMeImViolent.wav', }, }; /** * Options passed to youtube-dl for fetching videos. * * @private * @type {string[]} * @default * @constant */ const ytdlOpts = ['-f', 'bestaudio/worst', '--no-playlist', '--default-search=auto']; /** * Options to pass into the primary stream dispatcher (The one in charge of * volume control). * [StreamOptions]( * https://discord.js.org/#/docs/main/master/typedef/StreamOptions). * * @private * @constant * @type {Discord~StreamOptions} * @default */ const primaryStreamOptions = { passes: 1, fec: true, bitrate: 'auto', volume: 0.5, plp: 0.0, highWaterMark: 5, }; /** * Options to pass into the secondary stream dispatcher (for Discord). * * @private * @constant * @type {Discord~StreamOptions} * @default */ const secondaryStreamOptions = { passes: 1, fec: true, bitrate: 'auto', volume: 0.5, plp: 0.05, highWaterMark: 1000, }; /** @inheritdoc */ this.helpMessage = 'Loading...'; /** * The object that stores all data to be formatted into the help message. * * @private * @constant */ const helpObject = JSON.parse(fs.readFileSync('./docs/musicHelp.json')); /** @inheritdoc */ this.initialize = function() { self.command.on('join', commandJoin, true); self.command.on('play', commandPlay, true); self.command.on('pause', commandPause, true); self.command.on('resume', commandResume, true); self.command.on(['leave', 'stop', 'stfu'], commandLeave, true); self.command.on('skip', commandSkip, true); self.command.on(['queue', 'q', 'playing'], commandQueue, true); self.command.on(['remove', 'dequeue'], commandRemove, true); self.command.on('lyrics', commandLyrics); self.command.on('record', commandRecord, true); self.command.on( ['follow', 'unfollow', 'stalk', 'stalkme'], commandFollow, true); self.command.on('musicstats', commandStats); self.command.on(['volume', 'vol', 'v'], commandVolume, true); self.command.on(['clear', 'empty'], commandClearQueue, true); self.command.on('kokomo', (msg) => { msg.content = msg.prefix + 'play kokomo'; self.command.trigger(msg); }); self.command.on('vi', (msg) => { msg.content = msg.prefix + 'play nice try vi'; self.command.trigger(msg); }); self.command.on('airhorn', (msg) => { msg.content = msg.prefix + 'play airhorn'; self.command.trigger(msg); }); self.command.on('rickroll', (msg) => { msg.content = msg.prefix + 'play rickroll'; self.command.trigger(msg); }); self.command.on('felix', (msg) => { msg.content = msg.prefix + 'play felix'; self.command.trigger(msg); }); self.client.on('voiceStateUpdate', handleVoiceStateUpdate); // Format help message into rich embed. const tmpHelp = new self.Discord.EmbedBuilder(); tmpHelp.setTitle( helpObject.title.replaceAll('{prefix}', self.bot.getPrefix())); tmpHelp.setURL(self.common.webURL); tmpHelp.setDescription( helpObject.description.replaceAll('{prefix}', self.bot.getPrefix())); helpObject.sections.forEach((obj) => { const titleID = encodeURIComponent(obj.title.replace(/\s/g, '_')); const titleURL = `${self.common.webHelp}#${titleID} `; tmpHelp.addFields([ { name: obj.title, value: titleURL + '```js\n' + obj.rows .map((row) => { if (typeof row === 'string') { return self.bot.getPrefix() + row.replaceAll('{prefix}', self.bot.getPrefix()); } else if (typeof row === 'object') { return self.bot.getPrefix() + row.command.replaceAll( '{prefix}', self.bot.getPrefix()) + ' // ' + row.description.replaceAll( '{prefix}', self.bot.getPrefix()); } }) .join('\n') + '\n```', }, ]); }); tmpHelp.setFooter({ text: 'Note: If a custom prefix is being used, replace `' + self.bot.getPrefix() + '` with the custom prefix.\nNote 2: Custom prefixes will not have ' + 'a space after them.', }); self.helpMessage = tmpHelp; }; /** @inheritdoc */ this.shutdown = function() { self.command.deleteEvent('join'); self.command.deleteEvent('play'); self.command.deleteEvent('pause'); self.command.deleteEvent('resume'); self.command.deleteEvent('leave'); self.command.deleteEvent('skip'); self.command.deleteEvent('q'); self.command.deleteEvent('remove'); self.command.deleteEvent('lyrics'); self.command.deleteEvent('record'); self.command.deleteEvent('kokomo'); self.command.deleteEvent('vi'); self.command.deleteEvent('airhorn'); self.command.deleteEvent('rickroll'); self.command.deleteEvent('felix'); self.command.deleteEvent('follow'); self.command.deleteEvent('musicstats'); self.command.deleteEvent('volume'); self.command.deleteEvent('clear'); self.client.removeListener('voiceStateUpdate', handleVoiceStateUpdate); for (const b in broadcasts) { if (broadcasts[b] && broadcasts[b].voice) { try { broadcasts[b].voice.disconnect(); } catch (err) { self.error( 'Failed to disconenct from voice channel: ' + broadcasts[b].voice && broadcasts[b].voice.channel.id); console.error(err); } } } }; /** @inheritdoc */ this.save = function() { if (!self.initialized) return; // Purge broadcasts that have been paused for more than 15 minutes. const entries = Object.entries(broadcasts); const now = Date.now(); for (let i = 0; i < entries.length; i++) { if (!entries[i][1].broadcast || !entries[i][1].broadcast.dispatcher) { delete broadcasts[entries[i][0]]; continue; } const pauseTime = entries[i][1].broadcast.dispatcher.pausedSince; if (pauseTime && now - pauseTime > 15 * 60 * 1000) { self.debug(entries[i][0] + ' purged: ' + (now - pauseTime)); if (entries[i][1].voice && entries[i][1].voice.disconnect) { entries[i][1].voice.disconnect(); } delete broadcasts[entries[i][0]]; } } }; /** @inheritdoc */ this.unloadable = function() { // If a broadcast has been paused for more than 5 minutes, it is okay to // reload this submodule. const entries = Object.entries(broadcasts); let numAlive = entries.length; const now = Date.now(); for (let i = 0; i < entries.length; i++) { if (!entries[i][1].broadcast || !entries[i][1].broadcast.dispatcher) { continue; } const pauseTime = entries[i][1].broadcast.dispatcher.pausedSince; if (pauseTime && now - pauseTime > 5 * 60 * 1000) { numAlive--; } } return numAlive <= 0; }; /** * Creates formatted string for mentioning the author of msg. * * @private * @param {Discord~Message} msg Message to format a mention for the author of. * @returns {string} Formatted mention string. */ function mention(msg) { return `<@${msg.author.id}>`; } /** * Replies to the author and channel of msg with the given message. * * @deprecated Use {@link Common.reply} instead. * * @private * @param {Discord~Message} msg Message to reply to. * @param {string} text The main body of the message. * @param {string} post The footer of the message. * @returns {Promise} Promise of Discord~Message that we attempted to send. */ function reply(msg, text, post) { return self.common.reply(msg, text, post); } /** * Leave a voice channel if all other users have left. Should also cause music * and recordings to stop. * * @private * @param {Discord~VoiceState} oldState State before status update. * @param {Discord~VoiceState} newState State after status update. * @listens Discord~Client#voiceStateUpdate */ function handleVoiceStateUpdate(oldState, newState) { // User set to follow has changed channel. if (follows[oldState.guild.id] == oldState.id && newState.channelId) { newState.channel.join().catch(() => {}); return; } const broadcast = broadcasts[oldState.guild.id]; if (broadcast) { if (oldState.id === self.client.user.id && !newState.channel) { self.error( 'Forcibly ejected from voice channel: ' + oldState.guild.id + ' ' + oldState.channelId); delete broadcasts[oldState.guild.id]; if (broadcast.request && broadcast.request.channel) { broadcast.request.channel.send({ content: '`I was forcibly ejected from the voice channel for an ' + 'unknown reason!`', }); } return; } if (oldState.channel && oldState.channel.members) { const numMembers = oldState.channel.members.filter((el) => !el.user.bot).size; if (numMembers === 0 && oldState.channel.members.resolve(self.client.user.id)) { if (broadcast.subjugated) { if (broadcast.voice) { broadcast.voice.disconnect(); if (broadcast.voice) broadcast.voice.removeAllListeners(); } delete broadcasts[oldState.guild.id]; return; } else if (pauseBroadcast(broadcast)) { if (broadcast.current.request && broadcast.current.request.channel) { const prefix = self.bot.getPrefix(oldState.guild.id); let followInst = ''; if (oldState.channelId && newState.channelId) { followInst = '\n`' + prefix + 'join` to join your channel.'; } self.common.reply( broadcast.current.request, 'Music paused because everyone left me alone :(\nType `' + prefix + 'resume`, to unpause the music.' + followInst); } } } } // If the bot changed channel, continue playing previous music. if (oldState.id === self.client.user.id && broadcast.voice && broadcast.voice.channel.id != newState.channelId && oldState.channelId != newState.channelId && oldState.channelId && newState.channelId && newState.channel && newState.channel.connection) { if (broadcast.voice) broadcast.voice.removeAllListeners(); broadcast.voice = newState.channel.connection; broadcast.dispatcher = broadcast.voice.play(broadcast.broadcast, secondaryStreamOptions); } } } /** * Format the info response from ytdl into a human readable format. * * @private * @param {object} info The info received from ytdl about the song. * @param {Discord~StreamDispatcher} [dispatcher] The broadcast dispatcher * that is currently broadcasting audio. If defined, this will be used to * determine remaining play time. * @param {number} [seek=0] The offset to add to totalStreamTime to correct * for starting playback somewhere other than the beginning. * @returns {Discord~EmbedBuilder} The formatted song info. */ function formatSongInfo(info, dispatcher, seek) { if (!seek) seek = 0; let remaining = ''; let currentTime = ''; if (dispatcher) { currentTime = '[' + formatPlaytime( Math.round( ((seek * 1000 + dispatcher.totalStreamTime) - dispatcher.pausedTime) / 1000)) + '] / '; remaining = ' (' + formatPlaytime(getRemainingSeconds(info, dispatcher) - seek) + ' left)'; } const output = new self.Discord.EmbedBuilder(); const title = info.track || info.title; const author = info.uploader ? `Uploaded by ${info.uploader}\n` : ''; const likes = (info.like_count || info.dislike_count) ? `[👍 ${formNum(info.like_count)} 👎 ${formNum(info.dislike_count)}]: ` : ''; const views = info.view_count ? `[👁️ ${formNum(info.view_count)}]\n` : ''; const duration = info._duration_raw ? `[${formatPlaytime(info._duration_raw)}]` : ''; output.setDescription( title + '\n' + author + likes + views + currentTime + duration + remaining); if (info.thumbnail) output.setThumbnail(info.thumbnail); output.setURL(info.webpage_url); output.setColor([50, 200, 255]); return output; } /** * Get the remaining playtime in the given song info and broadcast. * * @private * @param {object} info The song info received from ytdl. * @param {Discord~StreamDispatcher} dispatcher The dispatcher playing the * song currently. * @returns {number} Number of seconds remaining in the song playtime. */ function getRemainingSeconds(info, dispatcher) { if (!dispatcher.totalStreamTime) return info._duration_raw; return info._duration_raw - Math.round((dispatcher.totalStreamTime - dispatcher.pausedTime) / 1000); } /** * Get the current progress into the song in the given context. * * @public * @param {Discord~Message} msg The context to use to fetch the info. * @returns {?number} Time in seconds, or null if nothing is playing. */ this.getProgress = function(msg) { const broadcast = broadcasts[msg.guild.id]; if (!broadcast) return null; if (!broadcast.broadcast) return null; if (!broadcast.broadcast.dispatcher) return null; return Math.round( ((broadcast.broadcast.dispatcher.totalStreamTime || 0) - (broadcast.broadcast.dispatcher.pausedTime || 0)) / 1000) + (broadcast.current.seek || 0); }; /** * Get the song's length of the song playing in the given context. * * @public * @param {Discord~Message} msg The context to use to fetch the info. * @returns {?number} Time in seconds, or null if nothing is playing. */ this.getDuration = function(msg) { const broadcast = broadcasts[msg.guild.id]; if (!broadcast) return null; if (!broadcast.current) return null; if (!broadcast.current.info) return null; return broadcast.current.info._duration_raw; }; /** * Format the given number of seconds into the playtime format. * * @private * @param {number} seconds The duration in seconds. * @returns {string} The formatted string in minutes and seconds. */ function formatPlaytime(seconds) { return Math.floor(seconds / 60) + 'm ' + seconds % 60 + 's'; } /** * Add commas between digits on large numbers. * * @private * @param {number|string} num The number to format. * @returns {string} The formatted number. */ function formNum(num) { const numString = (num + ''); const tmpString = []; for (let i = 0; i < numString.length; i++) { if (i > 0 && i % 3 === 0) tmpString.push(','); tmpString.push(numString.substr(-i - 1, 1)); } return tmpString.reverse().join(''); } /** * Add a song to the given broadcast's queue and start playing it not already. * * @private * @param {Music~Broadcast} broadcast The broadcast storage container. * @param {string} song The song that was requested. * @param {Discord~Message} msg The message that requested the song. * @param {object} [info] The info from ytdl about the song. * @param {number} [seek=0] The number of seconds into a song to start * playing. * @fires Command#stop */ function enqueueSong(broadcast, song, msg, info, seek) { broadcast.queue.push({request: msg, song: song, info: info, seek: seek}); if (broadcast.voice && broadcast.voice.channel) { try { startPlaying(broadcast); } catch (err) { console.log(err); reply(msg, 'Failed to start music stream!'); self.command.trigger('stop', msg); } } else { msg.member.voice.channel.join() .then((conn) => { if (broadcast.voice) broadcast.voice.removeAllListeners(); broadcast.voice = conn; try { startPlaying(broadcast); } catch (err) { console.log(err); reply(msg, 'Failed to start music stream!'); self.command.trigger('stop', msg); } }) .catch((err) => { reply(msg, 'I am unable to join your voice channel.', err.message); }); } } /** * Start playing the first item in the queue of the broadcast. * * @private * @param {Music~Broadcast} broadcast The container storing all information * about the song. */ function startPlaying(broadcast) { if (!broadcast || broadcast.isPlaying || broadcast.isLoading || (broadcast.current && !broadcasts[broadcast.current.request.guild.id])) { return; } if (broadcast.queue.length === 0) { if (!broadcast.subjugated) { setTimeout(() => { if (broadcast.voice) { broadcast.voice.disconnect(); if (broadcast.voice) broadcast.voice.removeAllListeners(); } delete broadcasts[broadcast.current.request.guild.id]; }, 500); broadcast.current.request.channel.send({content: '`Queue is empty!`'}); } return; } broadcast.isLoading = true; broadcast.skips = {}; if (broadcast.current && broadcast.current.thread) { broadcast.current.thread.kill(); } broadcast.current = broadcast.queue.splice(0, 1)[0]; try { makeBroadcast(broadcast); // broadcast.voice.play(broadcast.broadcast); } catch (err) { console.log(err); endSong(broadcast); broadcast.isLoading = false; } broadcast.isPlaying = true; if (broadcast.current.info) { if (!broadcast.subjugated && broadcast.current.request) { const embed = formatSongInfo(broadcast.current.info); embed.setTitle( 'Now playing [' + broadcast.queue.length + ' left in queue]'); broadcast.current.request.channel.send({embeds: [embed]}); } broadcast.current.oninfo = function() { broadcast.isLoading = false; }; } else { if (special[broadcast.current.song]) { if (!special[broadcast.current.song].url) { broadcast.isLoading = false; if (!broadcast.subjugated && broadcast.current.request) { const embed = new self.Discord.EmbedBuilder(); embed.setTitle( 'Now playing [' + broadcast.queue.length + ' left in queue]'); embed.setColor([50, 200, 255]); embed.setDescription(broadcast.current.song); broadcast.current.request.channel.send({embeds: [embed]}); } } else { ytdl.getInfo( special[broadcast.current.song].url, ytdlOpts, (err, info) => { broadcast.isLoading = false; if (err) { self.error(err.message.split('\n')[1]); if (broadcast.current.request) { broadcast.current.request.channel.send({ content: '```Oops, something went wrong while getting info ' + 'for this song!```\n' + err.message.split('\n')[1], }); } } else { broadcast.current.info = info; if (!broadcast.subjugated && broadcast.current.request) { const embed = formatSongInfo(broadcast.current.info); embed.setTitle( 'Now playing [' + broadcast.queue.length + ' left in queue]'); broadcast.current.request.channel.send({embeds: [embed]}); } } }); } } else { broadcast.current.oninfo = function(info) { broadcast.isLoading = false; broadcast.current.info = info; if (!broadcast.subjugated && broadcast.current.request) { const embed = formatSongInfo(broadcast.current.info); embed.setTitle( 'Now playing [' + broadcast.queue.length + ' left in queue]'); broadcast.current.request.channel.send({embeds: [embed]}); } }; } } } /** * Create a voice channel broadcast based off of the media source, and start * playing the audio. * * @private * @param {Music~Broadcast} broadcast The object storing all relevant * information. */ function makeBroadcast(broadcast) { // Setup voice connection listeners. if (!broadcast.voice || !broadcast.voice.channel) return; // This isn't normally a good idea, but there are no other disconnect // listeners on this voice connection ever so this will work fine until that // changes. broadcast.voice.removeAllListeners('disconnect'); broadcast.voice.on('disconnect', onDC); /** * Fires on when voice connection is disconnected. Cleans up anything that * could be left behind. * * @private */ function onDC() { if (broadcast.current.readable) broadcast.current.readable.destroy(); if (broadcast.current.thread) broadcast.current.thread.kill(); if (broadcast.dispatcher) broadcast.dispatcher.destroy(); if (broadcast.broadcast && broadcast.broadcast.dispatcher) { broadcast.broadcast.dispatcher.destroy(); } } // Setup readable stream for audio data. broadcast.current.readable = new Readable(); broadcast.current.readable._read = function() {}; broadcast.broadcast = self.client.voice.createBroadcast(); primaryStreamOptions.seek = broadcast.current.seek; broadcast.broadcast.play(broadcast.current.readable, primaryStreamOptions); broadcast.dispatcher = broadcast.voice.play(broadcast.broadcast, secondaryStreamOptions); broadcast.broadcast.dispatcher.on('end', function() { endSong(broadcast); }); broadcast.broadcast.dispatcher.on('close', function() { endSong(broadcast); }); broadcast.dispatcher.on('error', function(err) { self.error('Error in starting broadcast: ' + broadcast.current.song); console.log(err); if (broadcast.current.request) { self.common.reply( broadcast.current.request, 'An error occured while attempting to play ' + broadcast.current.song + '.', err.message); } broadcast.isLoading = false; skipSong(broadcast); }); // Spawn thread for starting audio stream. const input = { song: broadcast.current.song, special: special, ytdlOpts: ytdlOpts, }; if (broadcast.current.info) { input.song = broadcast.current.info.url; } broadcast.current.thread = spawn(startStream); broadcast.current.thread.send(input); broadcast.current.thread.on('progress', function(data) { if (data.ytdlinfo) { broadcast.current.oninfo(data.ytdlinfo); return; } if (data.data) data.data = Buffer.from(data.data); broadcast.current.readable.push(data.data); }); broadcast.current.thread.on('done', function() { broadcast.current.thread.kill(); }); broadcast.current.thread.on('error', function(err) { self.error('Error in thread: ' + broadcast.current.song); console.log(err); if (broadcast.current.request) { const text = err.message.match(/ERROR:[^;]+/); self.common.reply( broadcast.current.request, 'An error occured while attempting to play ' + broadcast.current.song + '.', text && text[0]); } broadcast.isLoading = false; skipSong(broadcast); }); } /** * Starts the streams as a thread and reports done with the streams. * * @private * @param {object} input Input vars. * @param {Function} done Done callback. * @param {Function} progress Progress callback. */ function startStream(input, done, progress) { let stream; if (input.special[input.song]) { stream = require('fs').createReadStream(input.special[input.song].file); } else { stream = require('youtube-dl')(input.song, input.ytdlOpts); stream.on('info', function(info) { progress({ytdlinfo: info}); }); // youtube-dl npm module emits `end` and not `close`. stream.on('end', function() { progress({data: null}); done(); }); } stream.on('data', function(chunk) { progress({data: chunk}); }); stream.on('close', function() { progress({data: null}); done(); }); } /** * Triggered when a song has finished playing. * * @private * @param {Music~Broadcast} broadcast The object storing all relevant * information. */ function endSong(broadcast) { if (broadcast.isLoading) return; if (broadcast.isPlaying) skipSong(broadcast); } /** * Skip the current song, then attempt to play the next. * * @private * @param {Music~Broadcast} broadcast The object storing all relevant * information. */ function skipSong(broadcast) { broadcast.isPlaying = false; startPlaying(broadcast); } /** * Skip the current song with the given context. * * @public * @param {Discord~Message} msg The context storing guild information for * looking up. */ this.skipSong = function(msg) { if (!broadcasts[msg.guild.id]) return; skipSong(broadcasts[msg.guild.id]); }; /** * Join a voice channel that the user is in. * * @private * @type {commandHandler} * @param {Discord~Message} msg The message that triggered command. * @listens Command#join */ function commandJoin(msg) { if (msg.member.voice.channel === null) { reply(msg, 'You aren\'t in a voice channel!'); } else { msg.member.voice.channel.join().catch((err) => { reply(msg, 'I am unable to join your voice channel.', err.message); }); } } /** * Pause the currently playing music broadcast. * * @private * @type {commandHandler} * @param {Discord~Message} msg The message that triggered command. * @listens Command#pause */ function commandPause(msg) { if (!broadcasts[msg.guild.id]) { self.common.reply(msg, 'Nothing is playing!'); } else if (!broadcasts[msg.guild.id].dispatcher) { self.common.reply(msg, 'Nothing is playing!'); } else if ( broadcasts[msg.guild.id] && broadcasts[msg.guild.id].subjugated) { reply(msg, 'Music is currently being controlled automatically.'); } else { if (pauseBroadcast(broadcasts[msg.guild.id])) { self.common.reply(msg, 'Music paused.'); } else { self.common.reply(msg, 'Music was already paused!'); } } } /** * Attempt to pause the current broadcast in a guild. * * @public * @param {Discord~Message} msg The context to lookup guild info. * @returns {boolean} True if success, false if failed. */ this.pause = function(msg) { return pauseBroadcast(broadcasts[msg.guild.id]); }; /** * Cause the given broadcast to be paused. * * @private * @param {Music~Broadcast} broadcast The object storing all relevant * information. * @returns {boolean} If the music was actully paused. False if the music is * already paused or nothing is playing. */ function pauseBroadcast(broadcast) { if (!broadcast) return false; if (!broadcast.broadcast) return false; if (!broadcast.broadcast.dispatcher) return false; if (!broadcast.broadcast.dispatcher.pause) return false; if (broadcast.broadcast.dispatcher.paused) return false; broadcast.broadcast.dispatcher.pause(true); return true; } /** * Resume the currently paused music broadcast. * * @private * @type {commandHandler} * @param {Discord~Message} msg The message that triggered command. * @listens Command#resume */ function commandResume(msg) { if (!broadcasts[msg.guild.id]) { self.common.reply(msg, 'Nothing is playing!'); } else if (!broadcasts[msg.guild.id].dispatcher) { self.common.reply(msg, 'Nothing is playing!'); } else if ( broadcasts[msg.guild.id] && broadcasts[msg.guild.id].subjugated) { reply(msg, 'Music is currently being controlled automatically.'); } else { if (resumeBroadcast(broadcasts[msg.guild.id])) { self.common.reply(msg, 'Music resumed.'); } else { self.common.reply( msg, 'I am unable to resume. I need music to play to somebody.'); } } } /** * Attempt to resume the current broadcast in a guild. * * @public * @param {Discord~Message} msg The context to lookup guild info. * @returns {boolean} True if success, false if failed. */ this.resume = function(msg) { return resumeBroadcast(broadcasts[msg.guild.id]); }; /** * Cause the given broadcast to be resumed. * * @private * @param {Music~Broadcast} broadcast The object storing all relevant * information. * @returns {boolean} If the music was actully resumed. False if the music is * already playing or nothing is playing or the bot is alone in a channel. */ function resumeBroadcast(broadcast) { if (!broadcast) return false; if (!broadcast.broadcast) return false; if (!broadcast.broadcast.dispatcher) return false; if (!broadcast.broadcast.dispatcher.resume) return false; if (!broadcast.broadcast.dispatcher.paused) return false; if (!broadcast.voice || (broadcast.voice.channel.members.size === 1 && broadcast.voice.channel.members.resolve(self.client.user.id))) { return false; } broadcast.broadcast.dispatcher.resume(); return true; } /** * Search for a song to play based off user request. * * @private * @type {commandHandler} * @param {Discord~Message} msg The message that triggered command. * @listens Command#play */ function commandPlay(msg) { if (broadcasts[msg.guild.id] && broadcasts[msg.guild.id].subjugated) { reply(msg, 'Music is currently being controlled automatically.'); } else if (msg.member.voice.channel === null) { reply(msg, 'You aren\'t in a voice channel!'); } else { let song = msg.text; let seek = 0; if (!song.startsWith(' ')) { reply(msg, 'Please specify a song to play.'); return; } else { seek = song.match(/&& seek\D*(\d+)\D*$/); if (seek) seek = seek[1]; song = song.replace(/^\s|\s*&&\s*seek.*$/g, ''); } self.playSong(msg, song, seek); } } /** * Start playing or enqueue the requested song. * * @public * @param {Discord~Message} msg The message that triggered command, used for * context. * @param {string} song The song search criteria. * @param {number} [seek] The time in seconds to seek to. * @param {?boolean} [subjugate] Force all control be via external sources * using public function calls. All queue control commands are disabled. Also * suppresses most information messages that would otherwise be sent to the * user. Null means leave as current value. */ this.playSong = function(msg, song, seek, subjugate) { if (!broadcasts[msg.guild.id]) { if (!subjugate) { self.common.reply(msg, 'Loading ' + song + '\nPlease wait...') .then((msg) => setTimeout(() => msg.delete(), 10000)); } broadcasts[msg.guild.id] = { queue: [], skips: {}, isPlaying: false, subjugated: subjugate || false, }; enqueueSong(broadcasts[msg.guild.id], song, msg, null, seek); } else { if (subjugate != null) { broadcasts[msg.guild.id].subjugated = subjugate; } if (special[song]) { if (!broadcasts[msg.guild.id].subjugated) { const embed = new self.Discord.EmbedBuilder(); embed.setTitle( 'Enqueuing ' + song + ' [' + (broadcasts[msg.guild.id].queue.length + 1) + ' in queue]'); embed.setColor([50, 200, 255]); msg.channel.send({content: mention(msg), embeds: [embed]}); } enqueueSong(broadcasts[msg.guild.id], song, msg, null, seek); } else { let loadingMsg; if (!broadcasts[msg.guild.id].subjugated) { reply(msg, 'Loading ' + song + '\nPlease wait...') .then((msg) => loadingMsg = msg); } ytdl.getInfo(song, ytdlOpts, (err, info) => { if (err) { self.error(err.message.split('\n')[1]); reply( msg, 'Oops, something went wrong while searching for that song!', err.message.split('\n')[1]); } else if (info._duration_raw === 0) { reply(msg, 'Sorry, but I can\'t play live streams currently.'); } else { if (broadcasts[msg.guild.id] && (broadcasts[msg.guild.id].isPlaying || broadcasts[msg.guild.id].subjugated)) { if (!broadcasts[msg.guild.id].subjugated) { const embed = formatSongInfo(info); embed.setTitle( 'Enqueuing ' + song + ' [' + (broadcasts[msg.guild.id].queue.length + 1) + ' in queue]'); msg.channel.send({content: mention(msg), embeds: [embed]}); } enqueueSong(broadcasts[msg.guild.id], song, msg, info, seek); } } if (loadingMsg) loadingMsg.delete(); }); } } }; /** * Release subjugation. Does not modify any current queue or playing * information. * * @public * @param {Discord~Message} msg The context to lookup the information. */ this.release = function(msg) { if (broadcasts[msg.guild.id]) { broadcasts[msg.guild.id].subjugated = false; } }; /** * Begin subjugation. Does not modify any current queue or playing * information. * * @public * @param {Discord~Message} msg The context to lookup the information. */ this.subjugate = function(msg) { if (broadcasts[msg.guild.id]) { broadcasts[msg.guild.id].subjugated = true; } }; /** * Check if music is being subjugated by another script. * * @public * @param {Discord~Message} msg The context to lookup the information. * @returns {?boolean} Null if nothing is playing, true if subjugated, false * if not subjugated. */ this.isSubjugated = function(msg) { if (broadcasts[msg.guild.id]) { return broadcasts[msg.guild.id].subjugated; } else { return null; } }; /** * Cause the bot to leave the voice channel and stop playing music. * * @private * @type {commandHandler} * @param {Discord~Message} msg The message that triggered the command. * @listens Command#leave * @listens Command#stop * @listens Command#stfu */ function commandLeave(msg) { if (msg.guild.members.me.voice.channel) { const followMsg = follows[msg.guild.id] ? 'No longer following <@' + follows[msg.guild.id] + '>' : null; delete follows[msg.guild.id]; msg.guild.members.me.voice.channel.leave(); reply(msg, 'Goodbye!', followMsg); } else { reply(msg, 'I\'m not playing anything.'); } delete broadcasts[msg.guild.id]; } /** * Skip the currently playing song and continue to the next in the queue. * * @private * @type {commandHandler} * @param {Discord~Message} msg The message that triggered the command. * @listens Command#skip */ function commandSkip(msg) { if (!broadcasts[msg.guild.id]) { reply(msg, 'I\'m not playing anything, I can\'t skip nothing!'); } else if ( broadcasts[msg.guild.id] && broadcasts[msg.guild.id].subjugated) { reply(msg, 'Music is currently being controlled automatically.'); } else { reply(msg, 'Skipping current song...'); skipSong(broadcasts[msg.guild.id]); } } /** * Show the user what is in the queue. * * @private * @type {commandHandler} * @param {Discord~Message} msg The message that triggered the command. * @listens Command#q * @listens Command#queue * @listens Command#playing */ function commandQueue(msg) { if (!broadcasts[msg.guild.id]) { reply( msg, 'I\'m not playing anything. Use "' + msg.prefix + 'play Kokomo" to start playing something!'); } else if (msg.text.trim().match(/^clear|^empty|^reset/)) { commandClearQueue(msg); } else { let embed; if (broadcasts[msg.guild.id].current) { if (broadcasts[msg.guild.id].current.info) { embed = formatSongInfo( broadcasts[msg.guild.id].current.info, broadcasts[msg.guild.id].broadcast.dispatcher, broadcasts[msg.guild.id].current.seek); } else { embed = new self.Discord.EmbedBuilder(); embed.setColor([50, 200, 255]); embed.setDescription(broadcasts[msg.guild.id].current.song); } embed.setTitle('Current Song Queue'); } else { embed = new self.Discord.EmbedBuilder(); } if (broadcasts[msg.guild.id].queue.length > 0) { let queueDuration = 0; let queueExact = true; const queueString = broadcasts[msg.guild.id] .queue .map((obj, index) => { if (obj.info) { queueDuration += obj.info._duration_raw; return (index + 1) + ') ' + obj.info.title; } else { queueExact = false; return (index + 1) + ') ' + obj.song; } }) .join('\n'); embed.addFields([{ name: 'Queue [' + (queueExact ? '' : '>') + formatPlaytime(queueDuration) + ']', value: queueString.substr(0, 1024), }]); } msg.channel.send({embeds: [embed]}); } } /** * Removes all songs from the current queue except for the currently playing * song. * * @private * * @type {commandHandler} * @param {Discord~Message} msg The message that triggered the command. * @listens Command#clear * @listens Command#empty */ function commandClearQueue(msg) { if (broadcasts[msg.guild.id] && broadcasts[msg.guild.id].subjugated) { reply(msg, 'Music is currently being controlled automatically.'); } else if (!broadcasts[msg.guild.id] || broadcasts[msg.guild.id].queue.length === 0) { reply( msg, 'The queue appears to be empty.\nI can\'t remove nothing ' + 'from nothing!'); } else { self.clearQueue(msg); reply(msg, 'All songs removed from queue.'); } } /** * Empty a guild's current music queue. * * @public * * @param {Discord~Message} msg The context for looking up the guild queue to * modify. */ this.clearQueue = function(msg) { if (!broadcasts[msg.guild.id]) return; broadcasts[msg.guild.id].queue = []; }; /** * Remove a song from the queue. * * @private * @type {commandHandler} * @param {Discord~Message} msg The message that triggered the command. * @listens Command#remove * @listens Command#dequeue */ function commandRemove(msg) { if (broadcasts[msg.guild.id] && broadcasts[msg.guild.id].subjugated) { reply(msg, 'Music is currently being controlled automatically.'); } else if (!broadcasts[msg.guild.id] || broadcasts[msg.guild.id].queue.length === 0) { reply( msg, 'The queue appears to be empty.\nI can\'t remove nothing ' + 'from nothing!'); } else { const indexString = msg.text; if (!indexString.startsWith(' ')) { reply( msg, 'You must specify the index of the song to dequeue, or "all".\nYo' + 'u can view the queue with "' + msg.prefix + 'queue".'); } else { if (indexString.trim().match(/^all|^everything/)) { commandClearQueue(msg); return; } const index = Number(indexString.replace(' ', '')); if (typeof index !== 'number' || index <= 0 || index > broadcasts[msg.guild.id].queue.length) { reply(msg, 'That is not a valid index!'); } else { const removed = broadcasts[msg.guild.id].queue.splice(index - 1, 1)[0]; reply(msg, 'Dequeued #' + index + ': ' + removed.info.title); } } } } /** * Search for a song's lyrics via Genius. * * @private * @type {commandHandler} * @param {Discord~Message} msg The message that triggered the command. * @listens Command#lyrics */ function commandLyrics(msg) { let song = msg.text; if (song.length <= 1) { reply(msg, 'Please specify a song.'); return; } song = song.replace(' ', ''); const thisReq = geniusRequest; thisReq.path = '/search?q=' + encodeURIComponent(song); const req = https.request(thisReq, function(response) { let content = ''; response.on('data', function(chunk) { content += chunk; }); response.on('end', function() { if (response.statusCode == 200) { const parsed = JSON.parse(content); if (parsed.response.hits.length === 0) { reply(msg, '`Failed to find lyrics. No matches found.`'); } else { reqLyricsURL(msg, parsed.response.hits[0].result.id); } } else { msg.channel.send({ content: response.statusCode + '```json\n' + JSON.stringify(response.headers, null, 2) + '```\n```html\n' + content + '\n```', }); } }); response.on('close', function() { self.warn('Genius request closed! ' + content.length); }); response.on('error', function() { self.warn('Genius request errored! ' + content.length); }); }); req.end(); req.on('error', function(e) { self.error(e); }); msg.channel.send({content: '`Loading...`'}).then((msg) => { msg.delete(30000); }); } /** * Request the song information from Genius from previous search to find the * page where the lyrics are. * * @private * @param {Discord~Message} msg The message that triggered the command. * @param {string} id The id of the first song in the search results. */ function reqLyricsURL(msg, id) { const thisReq = geniusRequest; thisReq.path = '/songs/' + id + '?text_format=plain'; const req = https.request(thisReq, function(response) { let content = ''; response.on('data', function(chunk) { content += chunk; }); response.on('end', function() { if (response.statusCode == 200) { const parsed = JSON.parse(content); fetchLyricsPage( msg, parsed.response.song.url, parsed.response.song.full_title, parsed.response.song.song_art_image_thumbnail_url); } else { msg.channel.send({ content: response.statusCode + '```json\n' + JSON.stringify(response.headers, null, 2) + '```\n```html\n' + content + '\n```', }); } }); response.on('close', function() { self.warn('Genius request closed! ' + content.length); }); response.on('error', function() { self.warn('Genius request errored! ' + content.length); }); }); req.end(); req.on('error', function(e) { self.error(e); }); } /** * Request the webpage that has the song lyrics on them from Genius. * * @private * @param {Discord~Message} msg The message that triggered the command. * @param {string} url The url of the page to request. * @param {string} title The song title for showing the user later. * @param {string} thumb The url of the album art thumbnail to show the user * later. */ function fetchLyricsPage(msg, url, title, thumb) { const URL = url.match(/https:\/\/([^/]*)(.*)/); const thisReq = { hostname: URL[1], path: URL[2], method: 'GET', headers: { 'User-Agent': self.common.ua, }, }; const req = https.request(thisReq, function(response) { let content = ''; response.on('data', function(chunk) { content += chunk; }); response.on('end', function() { if (response.statusCode == 200) { stripLyrics(msg, content, title, url, thumb); } else { msg.channel.send( response.statusCode + '```json\n' + JSON.stringify(response.headers, null, 2) + '```\n```html\n' + content + '\n```'); } }); response.on('close', function() { self.warn('Genius request closed! ' + content.length); }); response.on('error', function() { self.warn('Genius request errored! ' + content.length); }); }); req.end(); req.on('error', function(e) { self.error(e); }); } /** * Crawl the received webpage for the data we need, then format the data and * show it to the user. * * @private * @param {Discord~Message} msg The message that triggered the command. * @param {string} content The entire page received. * @param {string} title The song title for showing the user. * @param {string} url The url of where we fetched the lyrics to show the * user. * @param {string} thumb The url of the album art thumbnail to show the user * later. */ function stripLyrics(msg, content, title, url, thumb) { try { const body = content.match(/<!--sse-->([\s\S]*?)<!--\/sse-->/gm)[1]; const lyrics = []; let matches = []; const regex = /<a[^>]*>([^<]*)<\/a>/gm; while (matches = regex.exec(body)) { lyrics.push(matches[1]); } const splitLyrics = lyrics.join('\n').match(/(\[[^\]]*\][^[]*)/gm).slice(1); const embed = new self.Discord.EmbedBuilder(); if (title) embed.setTitle(title); if (url) embed.setURL(url); if (thumb) embed.setThumbnail(thumb); let numFields = 0; for (let i = 0; numFields < 25 && i < splitLyrics.length; i++) { const splitLine = splitLyrics[i].match(/\[([^\]]*)\]\n([^]*)/m); if (!splitLine) continue; const secTitle = splitLine[1].substr(0, 256); const secBody = splitLine[2]; for (let j = 0; numFields < 25 && j * 1024 < secBody.length; j++) { embed.addFields([{ name: j === 0 ? secTitle : (secTitle + ' continued...').substr(0, 256), value: secBody.substr(j * 1024, 1024) || '\u200B', }]); numFields++; } } embed.setColor([0, 255, 255]); msg.channel.send({embeds: [embed]}).catch((err) => { console.log(err); msg.channel.send({ content: '`Something went wrong while formatting the lyrics.' + '\nHere is the link to the page I found:`\n' + url, }); }); } catch (err) { console.log(err); msg.channel.send({ content: '`Something went wrong while formatting the lyrics.' + '\nHere is the link to the page I found:`\n' + url, }); } } /** * Join a voice channel and record the specified users audio to a file on this * server. * * @private * @type {commandHandler} * @param {Discord~Message} msg The message that triggered the command. * @listens Command#record */ function commandRecord(msg) { if (msg.member.voice.channel === null) { reply(msg, 'You aren\'t in a voice channel!'); return; } const filename = 'recordings/' + encodeURIComponent( msg.member.voice.channel.id + '_' + formatDateTime(Date.now())) + '.ogg'; const url = self.common.webURL + filename; if (msg.mentions.users.size === 0) { reply( msg, 'Recording everyone in voice channel. Type ' + msg.prefix + 'stop to stop', url); } else { reply( msg, 'Only recording ' + msg.mentions.users .map((obj) => '`' + obj.username.replaceAll('`', '\\`') + '`') .join(', '), url); } const streams = {}; const file = fs.createWriteStream(filename); file.on('close', () => { msg.channel.send({content: 'Saved to ' + url}); }); const listen = function(user, receiver /* , conn*/) { if (streams[user.id] || (msg.mentions.users.size > 0 && !msg.mentions.users.resolve(user.id))) { return; } const stream = receiver.createStream(msg.author, {end: 'manual', mode: 'pcm'}); streams[user.id] = stream; // stream.pipe(file); Music.streamToOgg(stream, file); /* conn.on('disconnect', () => { stream.destroy(); }); */ }; msg.member.voice.channel.join() .then((conn) => { // Timeout and sound are due to current Discord bug requiring bot to // play sound for 0.1s before being able to receive audio. conn.play('./sounds/plink.ogg'); setTimeout(() => { const receiver = conn.receiver; msg.member.voice.channel.members.forEach( (member) => listen(member.user, receiver, conn)); conn.on('speaking', (user, speaking) => { if (speaking) { listen(user, receiver, conn); } }); }, 100); }) .catch((err) => { reply(msg, 'I am unable to join your voice channel.', err.message); }); } /** * Follow a user as they change voice channels. * * @private * @type {commandHandler} * @param {Discord~Message} msg The message that triggered command. * @listens Command#join */ function commandFollow(msg) { if (msg.mentions.users.size > 0) { const targetMember = msg.mentions.members.first(); const target = targetMember.id; if (follows[msg.guild.id]) { if (follows[msg.guild.id] === target) { delete follows[msg.guild.id]; self.common.reply( msg, 'I will no longer follow ' + targetMember.user.username + ' to new voice channels.'); if (targetMember.voice.channel) { targetMember.voice.channel.join().catch(() => {}); } } else { self.common.reply( msg, 'I will follow ' + targetMember.user.username + ' into new voice channels.\nType ' + self.bot.getPrefix(msg.guild.id) + 'follow to make me stop following you.', 'I will no longer follow <@' + follows[msg.guild.id] + '>'); follows[msg.guild.id] = target; } } else { follows[msg.guild.id] = target; self.common.reply( msg, 'When ' + targetMember.user.username + ' changes voice channels, I will follow them and continue ' + 'playing music.\nType ' + self.bot.getPrefix(msg.guild.id) + 'follow to make me stop following you.'); if (targetMember.voice.channel) { targetMember.voice.channel.join().catch(() => {}); } } return; } if (follows[msg.guild.id]) { if (follows[msg.guild.id] == msg.member.id) { delete follows[msg.guild.id]; self.common.reply( msg, 'I will no longer follow you to new voice channels.'); if (msg.member.voice.channel) { msg.member.voice.channel.join().catch(() => {}); } } else { self.common.reply( msg, 'I will follow you into new voice channels.\nType ' + self.bot.getPrefix(msg.guild.id) + 'follow to make me stop following you.', 'I will no longer follow <@' + follows[msg.guild.id] + '>'); follows[msg.guild.id] = msg.author.id; } } else { follows[msg.guild.id] = msg.member.id; self.common.reply( msg, 'When you change voice channels, I will follow you and continue ' + 'playing music.\nType ' + self.bot.getPrefix(msg.guild.id) + 'follow to make me stop following you.'); if (msg.member.voice.channel) { msg.member.voice.channel.join().catch(() => {}); } } } /** * Formats a given date into a datestring. * * @private * @param {?Date|number|string} date The date that Date() can accept. * @returns {string} The formatted datetime. */ function formatDateTime(date) { const d = new Date(date); return monthToShort(d.getUTCMonth()) + '-' + d.getUTCDate() + '-' + d.getUTCFullYear() + '_at_' + d.getUTCHours() + '-' + ('0' + d.getUTCMinutes()).slice(-2) + '-' + ('0' + d.getUTCSeconds()).slice(-2) + '_UTC'; } /** * Convert the month number to a 3 letter string of the month's name. * * @private * @param {number} month The month number (1-12). * @returns {string} The 3 character string. */ function monthToShort(month) { return [ 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec', ][month]; } /** * Show statistics about current music broadcasts. * * @private * @type {commandHandler} * @param {Discord~Message} msg The message that triggered command. * @listens Command#musicstats */ function commandStats(msg) { let queueLength = 0; const bList = Object.entries(broadcasts); let longestChannel; let isPaused; let pauseTime; bList.forEach((el) => { let qDuration = 0; if (el[1].current && el[1].current.info) { qDuration += el[1].current.info._duration_raw - Math.round( (el[1].broadcast.dispatcher.streamTime - el[1].broadcast.dispatcher.pausedTime) / 1000); } el[1].queue.forEach((q) => { if (!q.info) return; qDuration += q.info._duration_raw; }); if (qDuration > queueLength) { queueLength = qDuration; isPaused = el[1].broadcast.dispatcher.paused; pauseTime = el[1].broadcast.dispatcher.pausedTime; longestChannel = el[1].voice.channel.id; } }); if (queueLength) { self.common.reply( msg, 'I am currently playing music for ' + bList.length + ' channels.\nThe longest queue has a length of ' + formatPlaytime(queueLength) + (isPaused ? ' (paused)' : '') + '.', msg.author.id === self.common.spikeyId ? (longestChannel + ' paused for ' + pauseTime + '(' + formatPlaytime(pauseTime) + ')') : null); } else { self.common.reply( msg, 'I am currently playing music for ' + bList.length + ' channels.'); } } /** * Change the volume of the current music stream. * * @private * @type {commandHandler} * @param {Discord~Message} msg The message that triggered command. * @listens Command#volume * @listens Command#vol * @listens Command#v */ function commandVolume(msg) { if (!broadcasts[msg.guild.id]) { self.common.reply(msg, 'Nothing is playing!'); } else if (!msg.text) { self.common.reply( msg, 'Volume is at ' + (getVolume(broadcasts[msg.guild.id]) * 100) + '%', 'Specify a percentage to change the volume.'); } else { let newVol = msg.text.match(/[0-9]*\.?[0-9]+/); if (!newVol) { self.common.reply( msg, 'Sorry, but I wasn\'t sure what volume to set to that.'); } else { if ((newVol + '').indexOf('.') < 0) { newVol /= 100; } if (changeVolume(broadcasts[msg.guild.id], newVol)) { self.common.reply(msg, 'Changed volume to ' + (newVol * 100) + '%.'); } else { self.common.reply( msg, 'Oops! I wasn\'t able to change the volume to ' + (newVol * 100) + '%.'); } } } } /** * Change the volume of the current broadcast. * * @private * @param {Music~Broadcast} broadcast The objected storing the current * broadcast information. * @param {number} percentage The volume percentage to set to. 0.5 is half, 2 * is double. * @returns {boolean} True if success, false if something went wrong. */ function changeVolume(broadcast, percentage) { if (!broadcast) return false; if (!broadcast.broadcast) return false; if (!broadcast.broadcast.dispatcher) return false; if (!broadcast.broadcast.dispatcher.setVolume) return false; try { broadcast.broadcast.dispatcher.setVolumeLogarithmic(percentage); } catch (err) { self.error('Failed to change volume to ' + percentage); console.error(err); return false; } return true; } /** * Get the volume of the current broadcast. * * @private * @param {Music~Broadcast} broadcast The objected storing the current * broadcast information. * @returns {?number} The logarithmic volume percentage. 0.5 is half, 2 is * double. Null if error. */ function getVolume(broadcast) { if (!broadcast) return null; if (!broadcast.broadcast) return null; if (!broadcast.broadcast.dispatcher) return null; return broadcast.broadcast.dispatcher.volumeLogarithmic; } } /** * Coverts an incoming Opus stream to a ogg format and writes it to file. * * @param {ReadableStream} input The opus stream from Discord. * @param {WritableStream} file The file stream we are writing to. */ Music.streamToOgg = function(input, file) { input; file; return; }; // Music.streamToOgg = function(input, file) { // const opusEncoder = new opus.Encoder(); // const oggEncoder = new ogg.Encoder(); // input.pipe(opusEncoder).pipe(oggEncoder.stream()); // oggEncoder.pipe(file); // }; module.exports = new Music();