const Lavalink = require('./Lavalink');
const Player = require('./Player');
/**
* Player Manager
* @extends Map
* @prop {Player} baseObject The player class used to create new players
* @prop {object} client The discord.js client
* @prop {object} defaultRegions The default region config
* @prop {object} regions The region config being used
*/
class PlayerManager extends Map {
/**
* PlayerManager constructor
* @param {Client} client Eris client
* @param {Object[]} nodes The Lavalink nodes to connect to
* @param {Object} [options] Setup options
* @param {string} [options.defaultRegion] The default region
* @param {Number} [options.failoverRate=250] Failover rate in ms
* @param {Number} [options.failoverLimit=1] Number of connections to failover per rate limit
* @param {Object} [options.player] Optional Player class to replace the default Player
* @param {Number} [options.reconnectThreshold=2000] The amount of time to skip ahead in a song when reconnecting in ms
* @param {Object} [options.regions] Region mapping object
*/
constructor(client, nodes, options) {
super();
this.baseObject = options.player || Player;
this.client = client;
this.nodes = new Map();
this.pendingGuilds = {};
this.options = options || {};
this.failoverQueue = [];
this.failoverRate = options.failoverRate || 250;
this.failoverLimit = options.failoverLimit || 1;
this.defaultRegions = {
asia: ['hongkong', 'singapore', 'sydney'],
eu: ['eu', 'amsterdam', 'frankfurt', 'russia'],
us: ['us', 'brazil']
};
this.regions = options.regions || this.defaultRegions;
for (const node of nodes) {
this.createNode(Object.assign({}, node, options));
}
this.onReadyListener = this.onReady.bind(this);
this.client.on('ready', this.onReadyListener);
this.client.on('raw', this.onRaw.bind(this));
}
/**
* Create a Lavalink node
* @param {Object} options Lavalink node options
* @param {string} options.host The hostname to connect to
* @param {string} options.port The port to connect with
* @param {string} options.region The region of the node
* @param {Number} options.numShards The number of shards the bot is running
* @param {string} options.userId The user id of the bot
* @param {string} options.password The password for the Lavalink node
*/
createNode(options) {
const node = new Lavalink({
host: options.host,
port: options.port,
region: options.region,
numShards: options.numShards,
userId: options.userId,
password: options.password
});
node.on('error', this.onError.bind(this, node));
node.on('disconnect', this.onDisconnect.bind(this, node));
node.on('message', this.onMessage.bind(this, node));
this.nodes.set(options.host, node);
}
/**
* Remove a Lavalink node
* @param {string} host The hostname of the node
*/
removeNode(host) {
const node = this.nodes.get(host);
if (!host) return;
node.destroy();
this.nodes.delete(host);
this.onDisconnect(node);
}
/**
* Check the failover queue
* @private
*/
checkFailoverQueue() {
if (this.failoverQueue.length > 0) {
const fns = this.failoverQueue.splice(0, this.failoverLimit);
for (const fn of fns) {
this.processQueue(fn);
}
}
}
/**
* Queue a failover
* @param {Function} fn The failover function to queue
* @returns {void}
* @private
*/
queueFailover(fn) {
if (this.failoverQueue.length > 0) {
this.failoverQueue.push(fn);
} else {
return this.processQueue(fn);
}
}
/**
* Process the failover queue
* @param {Function} fn The failover function to call
* @private
*/
processQueue(fn) {
fn();
setTimeout(() => this.checkFailoverQueue(), this.failoverRate);
}
/**
* Called when an error is received from a Lavalink node
* @param {Lavalink} node The Lavalink node
* @param {string|Error} err The error received
* @private
*/
onError(node, err) {
this.client.emit(err);
}
/**
* Called when a node disconnects
* @param {Lavalink} node The Lavalink node
* @param {*} msg The disconnect message if sent
* @private
*/
onDisconnect(node, msg) { // eslint-disable-line
const players = [...this.values()].filter(player => player.node.host === node.host);
for (const player of players) {
this.queueFailover(this.switchNode.bind(this, player, true));
}
}
/**
* Called when the shard readies
* @private
*/
onReady() {
for (const player of [...this.values()]) {
this.queueFailover(this.switchNode.bind(this, player));
}
}
/**
* Client raw event listener
* @param {object} packet Packet received from the gateway
* @private
**/
onRaw(packet) {
switch (packet.t) {
case 'VOICE_SERVER_UPDATE':
packet.d.session_id = this.client.ws.connection.sessionID;
this.voiceServerUpdate(packet.d).catch(() => null);
break;
case 'VOICE_STATE_UPDATE':
if (packet.d.id && packet.d.id === this.client.user.id) {
const player = this.get(packet.d.guild_id);
if (player && player.channelId !== packet.d.channel_id) {
player.switchChannel(packet.d.channel_id, true);
}
}
}
}
/**
* Switch the voice node of a player
* @param {Player} player The Player instance
* @param {boolean} leave Whether to leave the channel or not on our side
*/
switchNode(player, leave) {
const { guildId, channelId, track } = player,
position = (player.state.position || 0) + (this.options.reconnectThreshold || 2000);
const listeners = player.listeners('end'),
endListeners = [];
if (listeners && listeners.length) {
for (const listener of listeners) {
endListeners.push(listener);
player.removeListener('end', listener);
}
}
player.once('end', () => {
for (const listener of endListeners) {
player.on('end', listener);
}
});
this.delete(guildId);
player.playing = false;
if (leave) {
player.updateVoiceState(null);
} else {
player.node.send({ op: 'disconnect', guildId: guildId });
}
process.nextTick(() => {
this.join(guildId, channelId, null, player).then(player => { // eslint-disable-line
player.play(track, { startTime: position });
player.emit('reconnect');
this.set(guildId, player);
})
.catch(err => {
player.emit('disconnect', err);
player.disconnect();
});
});
}
/**
* Called when a message is received from the voice node
* @param {Lavalink} node The Lavalink node
* @param {*} message The message received
* @returns {*}
* @private
*/
onMessage(node, message) {
if (!message.op) return;
switch (message.op) {
case 'validationReq': {
const payload = {
op: 'validationRes',
guildId: message.guildId
};
let guildValid = false;
let channelValid = false;
if (message.guildId && message.guildId.length) {
guildValid = this.client.guilds.has(message.guildId);
} else {
guildValid = true;
}
if (message.channelId && message.channelId.length) {
const voiceChannel = this.client.channels.get(message.channelId);
if (voiceChannel) {
payload.channelId = voiceChannel.id;
channelValid = true;
}
} else {
channelValid = true;
}
payload.valid = guildValid && channelValid;
return node.send(payload);
}
case 'isConnectedReq': {
const payload = {
op: 'isConnectedRes',
shardId: parseInt(message.shardId),
connected: false
};
if (this.client.status === 0) {
payload.connected = true;
}
return node.send(payload);
}
case 'sendWS': {
const payload = JSON.parse(message.message);
this.client.ws.send(payload);
if (payload.op === 4 && payload.d.channel_id === null) {
this.delete(payload.d.guild_id);
}
}
case 'playerUpdate': {
const player = this.get(message.guildId);
if (!player) return;
return player.stateUpdate(message.state);
}
case 'event': {
const player = this.get(message.guildId);
if (!player) return;
switch (message.type) {
case 'TrackEndEvent':
return player.onTrackEnd(message);
case 'TrackExceptionEvent':
return player.onTrackException(message);
case 'TrackStuckEvent':
return player.onTrackStuck(message);
default:
return player.emit('warn', `Unexpected event type: ${message.type}`);
}
}
}
}
/**
* Join a voice channel
* @param {string} guildId The guild ID
* @param {string} channelId The channel ID
* @param {Object} options Join options
* @param {Player} [player] Optionally pass an existing player
* @returns {Promise<Player>}
*/
async join(guildId, channelId, options, player) {
options = options || {};
player = player || this.get(guildId);
if (player && player.channelId !== channelId) {
player.switchChannel(channelId);
return Promise.resolve(player);
}
const region = this.getRegionFromData(options.region || 'us');
const node = await this.findIdealNode(region);
if (!node) {
return Promise.reject('No available voice nodes.');
}
return new Promise((res, rej) => {
this.pendingGuilds[guildId] = {
channelId: channelId,
options: options || {},
player: player || null,
node: node,
res: res,
rej: rej,
timeout: setTimeout(() => {
node.send({ op: 'disconnect', guildId: guildId });
delete this.pendingGuilds[guildId];
rej(new Error('Voice connection timeout'));
}, 10000)
};
node.send({
op: 'connect',
guildId: guildId,
channelId: channelId
});
});
}
/**
* Leave a voice channel
* @param {string} guildId The guild ID
*/
async leave(guildId) {
const player = this.get(guildId);
if (!player) {
return;
}
player.disconnect();
this.delete(player);
}
/**
* Find the ideal voice node based on load and region
* @param {string} region Guild region
* @returns {void}
* @private
*/
async findIdealNode(region) {
let nodes = [...this.nodes.values()].filter(node => !node.draining && node.ws && node.connected);
if (region) {
const regionalNodes = nodes.filter(node => node.region === region);
if (regionalNodes && regionalNodes.length) {
nodes = regionalNodes;
}
}
nodes = nodes.sort((a, b) => {
const aload = a.stats.cpu ? (a.stats.cpu.systemLoad / a.stats.cpu.cores) * 100 : 0,
bload = b.stats.cpu ? (b.stats.cpu.systemLoad / b.stats.cpu.cores) * 100 : 0;
return aload - bload;
});
return nodes[0];
}
/**
* Called by eris when a voice server update is received
* @param {*} data The voice server update from eris
* @private
*/
async voiceServerUpdate(data) {
if (this.pendingGuilds[data.guild_id] && this.pendingGuilds[data.guild_id].timeout) {
clearTimeout(this.pendingGuilds[data.guild_id].timeout);
this.pendingGuilds[data.guild_id].timeout = null;
}
let player = this.get(data.guild_id);
if (!player) {
if (!this.pendingGuilds[data.guild_id]) {
return;
}
player = this.pendingGuilds[data.guild_id].player;
if (player) {
player.sessionId = data.sessionId;
player.hostname = this.pendingGuilds[data.guild_id].hostname;
player.node = this.pendingGuilds[data.guild_id].node;
player.event = data;
this.set(data.guild_id, player);
}
player = player || new this.baseObject(data.guild_id, { // eslint-disable-line
client: this.client,
guildId: data.guild_id,
sessionId: data.session_id,
channelId: this.pendingGuilds[data.guild_id].channelId,
hostname: this.pendingGuilds[data.guild_id].hostname,
node: this.pendingGuilds[data.guild_id].node,
options: this.pendingGuilds[data.guild_id].options,
event: data,
manager: this
});
this.set(data.guild_id, player);
player.connect({
sessionId: data.session_id,
guildId: data.guild_id,
channelId: this.pendingGuilds[data.guild_id].channelId,
event: {
endpoint: data.endpoint,
guild_id: data.guild_id,
token: data.token
}
});
}
const disconnectHandler = () => {
player = this.get(data.guild_id);
if (!this.pendingGuilds[data.guild_id]) {
if (player) {
player.removeListener('ready', readyHandler);
}
return;
}
player.removeListener('ready', readyHandler);
this.pendingGuilds[data.guild_id].rej(new Error('Disconnected'));
delete this.pendingGuilds[data.guild_id];
};
const readyHandler = () => {
player = this.get(data.guild_id);
if (!this.pendingGuilds[data.guild_id]) {
if (player) {
player.removeListener('disconnect', disconnectHandler);
}
return;
}
player.removeListener('disconnect', disconnectHandler);
this.pendingGuilds[data.guild_id].res(player);
delete this.pendingGuilds[data.guild_id];
};
player.once('ready', readyHandler).once('disconnect', disconnectHandler);
}
/**
* Get ideal region from data
* @param {string} endpoint Endpoint or region
* @returns {void}
* @private
*/
getRegionFromData(endpoint) {
if (!endpoint) return this.options.defaultRegion || 'us';
endpoint = endpoint.replace('vip-', '');
for (const key in this.regions) {
const nodes = [...this.nodes.values()].filter(n => n.region === key);
if (!nodes || !nodes.length) continue;
if (!nodes.find(n => n.connected && !n.draining)) continue;
for (const region of this.regions[key]) {
if (endpoint.startsWith(region)) {
return key;
}
}
}
return this.options.defaultRegion || 'us';
}
}
module.exports = PlayerManager;