// 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();