|
|
|
const ytdl = require("ytdl-core"),
|
|
|
|
ypi = require('youtube-playlist-info'),
|
|
|
|
yttl = require('get-youtube-title'),
|
|
|
|
config = require('../../config.json'),
|
|
|
|
utils = require('../utils'),
|
|
|
|
xevents = require('../utils/extended-events'),
|
|
|
|
logging = require('../utils/logging'),
|
|
|
|
ytapiKey = config.api.youTubeApiKey;
|
|
|
|
|
|
|
|
/**
|
|
|
|
* The Music Player class is used to handle music playing tasks on Discord Servers (Guilds).
|
|
|
|
* @type {MusicPlayer}
|
|
|
|
*/
|
|
|
|
class MusicPlayer extends xevents.ExtendedEventEmitter {
|
|
|
|
/**
|
|
|
|
* Constructor
|
|
|
|
* @param [voiceChannel] {Discord.VoiceChannel}
|
|
|
|
*/
|
|
|
|
constructor(voiceChannel) {
|
|
|
|
super();
|
|
|
|
this.conn = null;
|
|
|
|
this.disp = null;
|
|
|
|
this.queue = [];
|
|
|
|
this.playing = false;
|
|
|
|
this.current = null;
|
|
|
|
this.repeat = false;
|
|
|
|
this.volume = 0.5;
|
|
|
|
this.voiceChannel = voiceChannel;
|
|
|
|
this.exitTimeout = null;
|
|
|
|
this._logger = new logging.Logger(this);
|
|
|
|
this._logger.silly('Initialized Music Player');
|
|
|
|
config.music ? this.quality = config.music.quality || 'lowest' : this.quality = 'lowest';
|
|
|
|
config.music ? this.liveBuffer = config.music.liveBuffer || 10000 : 10000;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Connects to the given voice channel. Disconnects from the previous one if it exists.
|
|
|
|
* When the bot was moved and connect is executed again, it connects to the initial VoiceChannel because the
|
|
|
|
* VoiceChannel is saved as object variable.
|
|
|
|
*/
|
|
|
|
async connect(voiceChannel) {
|
|
|
|
if (this.connected)
|
|
|
|
if (voiceChannel && this.voiceChannel !== voiceChannel) {
|
|
|
|
this.voiceChannel = voiceChannel;
|
|
|
|
this.stop();
|
|
|
|
} else {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
else if (voiceChannel)
|
|
|
|
this.voiceChannel = voiceChannel;
|
|
|
|
this._logger.verbose(`Connecting to voiceChannel ${this.voiceChannel.name}`);
|
|
|
|
let connection = await this.voiceChannel.join();
|
|
|
|
|
|
|
|
connection.on('error', (err) => {
|
|
|
|
this._logger.error(err.message);
|
|
|
|
this._logger.debug(err.stack);
|
|
|
|
});
|
|
|
|
this._logger.info(`Connected to Voicechannel ${this.voiceChannel.name}`);
|
|
|
|
this.conn = connection;
|
|
|
|
this.emit('connected');
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Defining setter for listenOnRepeat to include the current song into the repeating loop.
|
|
|
|
* @param value {Boolean}
|
|
|
|
*/
|
|
|
|
set listenOnRepeat(value) {
|
|
|
|
this.repeat = value;
|
|
|
|
if (this.current)
|
|
|
|
this.queue.push(this.current);
|
|
|
|
this.emit('listenOnRepeat', this.repeat);
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Returns if a connection exists
|
|
|
|
* @returns {boolean}
|
|
|
|
*/
|
|
|
|
get connected() {
|
|
|
|
return (
|
|
|
|
this.conn !== null &&
|
|
|
|
this.conn !== undefined &&
|
|
|
|
this.conn.status !== 4 // status 4 means disconnected.
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Updates the channel e.g. when the bot is moved to another channel.
|
|
|
|
* @param voiceChannel {Discord.VoiceChannel}
|
|
|
|
*/
|
|
|
|
updateChannel(voiceChannel) {
|
|
|
|
if (voiceChannel) {
|
|
|
|
this.voiceChannel = voiceChannel;
|
|
|
|
this._logger.debug(`Updated voiceChannel to ${this.voiceChannel.name}`);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Plays a file for the given filename.
|
|
|
|
* TODO: Implement queue
|
|
|
|
* @param filename {String}
|
|
|
|
* @todo
|
|
|
|
*/
|
|
|
|
playFile(filename) {
|
|
|
|
if (this.connected) {
|
|
|
|
this.disp = this.conn.playFile(filename);
|
|
|
|
this.playing = true;
|
|
|
|
} else {
|
|
|
|
this._logger.warn("Not connected to a voicechannel. Connection now.");
|
|
|
|
this.connect(this.voiceChannel).then(() => {
|
|
|
|
this.playFile(filename);
|
|
|
|
});
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Checks if there are still members listening and sets an exit timeout (5 min)
|
|
|
|
* not connected).
|
|
|
|
*/
|
|
|
|
checkListeners() {
|
|
|
|
if (this.exitTimeout) {
|
|
|
|
clearTimeout(this.exitTimeout);
|
|
|
|
this.exitTimeout = null;
|
|
|
|
this._logger.debug(`Cleared exit timout for ${this.voiceChannel.name}`);
|
|
|
|
}
|
|
|
|
if (this.connected && this.voiceChannel.members.size === 1) {
|
|
|
|
this._logger.debug(`Set exit timout for ${this.voiceChannel.name}`);
|
|
|
|
this.exitTimeout = setTimeout(() => {
|
|
|
|
if (this.connected && this.voiceChannel.members.size === 1)
|
|
|
|
this._logger.verbose(`Exiting ${this.voiceChannel.name}`);
|
|
|
|
this.stop();
|
|
|
|
}, config.music.timeout || 300000);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Plays the url of the current song if there is no song playing or puts it in the queue.
|
|
|
|
* If the url is a playlist, the videos of the playlist are fetched and put
|
|
|
|
* in the queue. For each song the title is saved in the queue too.
|
|
|
|
* @param url {String}
|
|
|
|
* @param [playnext] {Boolean}
|
|
|
|
*/
|
|
|
|
async playYouTube(url, playnext) {
|
|
|
|
let plist = utils.YouTube.getPlaylistIdFromUrl(url);
|
|
|
|
if (plist) {
|
|
|
|
this._logger.debug(`Adding playlist ${plist} to queue`);
|
|
|
|
let playlistItems = await ypi(ytapiKey, plist);
|
|
|
|
let firstSong = utils.YouTube.getVideoUrlFromId(playlistItems.shift().resourceId.videoId);
|
|
|
|
let firstSongTitle = null;
|
|
|
|
try {
|
|
|
|
firstSongTitle = await this.getVideoName(firstSong);
|
|
|
|
} catch (err) {
|
|
|
|
if (err.message !== 'Not found') {
|
|
|
|
this._logger.warn(err.message);
|
|
|
|
this._logger.debug(err.stack);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if (this.repeat)
|
|
|
|
this.queue.push({'url': firstSong, 'title': firstSongTitle});
|
|
|
|
this.playYouTube(firstSong).catch((err) => this._logger.warn(err.message));
|
|
|
|
|
|
|
|
for (let item of playlistItems) {
|
|
|
|
let vurl = utils.YouTube.getVideoUrlFromId(item.resourceId.videoId);
|
|
|
|
try {
|
|
|
|
this.queue.push({'url': vurl, 'title': await this.getVideoName(vurl)}); //eslint-disable-line no-await-in-loop
|
|
|
|
} catch (err) {
|
|
|
|
if (err.message !== 'Not found') {
|
|
|
|
this._logger.verbose(err.message);
|
|
|
|
this._logger.silly(err.stack);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
this._logger.debug(`Added ${playlistItems.length} songs to the queue`);
|
|
|
|
return playlistItems.length;
|
|
|
|
} else if (!this.playing || !this.disp) {
|
|
|
|
this._logger.debug(`Playing ${url}`);
|
|
|
|
this.current = ({'url': url, 'title': await this.getVideoName(url)});
|
|
|
|
if (this.repeat)
|
|
|
|
this.queue.push(this.current);
|
|
|
|
let toggleNext = () => {
|
|
|
|
if (this.queue.length > 0) {
|
|
|
|
this.current = this.queue.shift();
|
|
|
|
this.emit('next', this.current);
|
|
|
|
this.playYouTube(this.current.url).catch((err) => this._logger.warn(err.message));
|
|
|
|
} else {
|
|
|
|
this.stop();
|
|
|
|
this.current = null;
|
|
|
|
this.playing = false;
|
|
|
|
}
|
|
|
|
};
|
|
|
|
try {
|
|
|
|
this.disp = this.conn.playStream(await ytdl(url,
|
|
|
|
{filter: 'audioonly', quality: this.quality, liveBuffer: this.liveBuffer}, {volume: this.volume}));
|
|
|
|
this.disp.on('error', (err) => {
|
|
|
|
this._logger.error(err.message);
|
|
|
|
this._logger.debug(err.stack);
|
|
|
|
});
|
|
|
|
this.disp.on('end', (reason) => { // end event triggers the next song to play when the reason is not stop
|
|
|
|
if (reason !== 'stop')
|
|
|
|
toggleNext();
|
|
|
|
});
|
|
|
|
this.playing = true;
|
|
|
|
} catch (err) {
|
|
|
|
this._logger.verbose(err.message);
|
|
|
|
this._logger.silly(err.stack);
|
|
|
|
toggleNext();
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
this._logger.debug(`Added ${url} to the queue`);
|
|
|
|
if (playnext)
|
|
|
|
this.queue.unshift({'url': url, 'title': await this.getVideoName(url)});
|
|
|
|
else
|
|
|
|
this.queue.push({'url': url, 'title': await this.getVideoName(url)});
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Gets the name of the YouTube Video at url
|
|
|
|
* @param url {String}
|
|
|
|
* @returns {Promise}
|
|
|
|
*/
|
|
|
|
getVideoName(url) {
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
yttl(utils.YouTube.getVideoIdFromUrl(url), (err, title) => {
|
|
|
|
if (err) {
|
|
|
|
this._logger.debug(JSON.stringify(err));
|
|
|
|
reject(err);
|
|
|
|
} else {
|
|
|
|
resolve(title);
|
|
|
|
}
|
|
|
|
});
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Sets the volume of the dispatcher to the given value
|
|
|
|
* @param percentage {Number}
|
|
|
|
*/
|
|
|
|
setVolume(percentage) {
|
|
|
|
this._logger.verbose(`Setting volume to ${percentage}`);
|
|
|
|
this.volume = percentage;
|
|
|
|
if (this.disp !== null)
|
|
|
|
this.disp.setVolume(percentage);
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Pauses if a dispatcher exists
|
|
|
|
*/
|
|
|
|
pause() {
|
|
|
|
this._logger.verbose("Pausing music...");
|
|
|
|
if (this.disp !== null)
|
|
|
|
this.disp.pause();
|
|
|
|
else
|
|
|
|
this._logger.warn("No dispatcher found");
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Resumes if a dispatcher exists
|
|
|
|
*/
|
|
|
|
resume() {
|
|
|
|
this._logger.verbose("Resuming music...");
|
|
|
|
if (this.disp !== null)
|
|
|
|
this.disp.resume();
|
|
|
|
else
|
|
|
|
this._logger.warn("No dispatcher found");
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Stops playing music by ending the Dispatcher and disconnecting.
|
|
|
|
* Also sets playing to false and clears the queue and the current song.
|
|
|
|
*/
|
|
|
|
stop() {
|
|
|
|
this.playing = false;
|
|
|
|
this.queue = [];
|
|
|
|
this.current = null;
|
|
|
|
this._logger.verbose("Stopping music...");
|
|
|
|
try {
|
|
|
|
if (this.disp) {
|
|
|
|
this.disp.end('stop');
|
|
|
|
this.disp = null;
|
|
|
|
this._logger.debug("Ended dispatcher");
|
|
|
|
}
|
|
|
|
if (this.conn) {
|
|
|
|
this.conn.disconnect();
|
|
|
|
this.conn = null;
|
|
|
|
this._logger.debug("Ended connection");
|
|
|
|
}
|
|
|
|
if (this.voiceChannel) {
|
|
|
|
this.voiceChannel.leave();
|
|
|
|
this._logger.debug("Left VoiceChannel");
|
|
|
|
this._logger.info(`Disconnected from Voicechannel ${this.voiceChannel.name}`);
|
|
|
|
}
|
|
|
|
} catch (error) {
|
|
|
|
this._logger.verbose(JSON.stringify(error));
|
|
|
|
}
|
|
|
|
this.emit('stop');
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Skips to the next song by ending the current StreamDispatcher and thereby triggering the
|
|
|
|
* end event of the dispatcher that automatically plays the next song. If no dispatcher is found
|
|
|
|
* It tries to play the next song with playYouTube
|
|
|
|
*/
|
|
|
|
skip() {
|
|
|
|
this._logger.debug("Skipping song");
|
|
|
|
if (this.disp !== null) {
|
|
|
|
let disp = this.disp;
|
|
|
|
this.disp = null;
|
|
|
|
disp.end();
|
|
|
|
} else {
|
|
|
|
this.playing = false;
|
|
|
|
if (this.queue.length > 0) {
|
|
|
|
this.current = this.queue.shift();
|
|
|
|
this.playYouTube(this.current.url).catch((err) => {
|
|
|
|
this._logger.error(err.message);
|
|
|
|
this._logger.debug(err.stack);
|
|
|
|
});
|
|
|
|
} else {
|
|
|
|
this.stop();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
this.emit('skip', this.current);
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Returns the song saved in the private variable 'current'
|
|
|
|
* @returns {null|*}
|
|
|
|
*/
|
|
|
|
get song() {
|
|
|
|
return this.current;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Shuffles the queue
|
|
|
|
*/
|
|
|
|
shuffle() {
|
|
|
|
this.queue = utils.shuffleArray(this.queue);
|
|
|
|
this.emit('shuffle');
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Clears the playlist
|
|
|
|
*/
|
|
|
|
clear() {
|
|
|
|
this.queue = [];
|
|
|
|
this.emit('clear');
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
Object.assign(exports, {
|
|
|
|
MusicPlayer: MusicPlayer
|
|
|
|
});
|