aqualink
Version:
An Lavalink client, focused in pure performance and features
820 lines (686 loc) • 25 kB
JavaScript
'use strict';
const { promises: fs } = require('node:fs');
const { EventEmitter } = require('tseep');
const Node = require('./Node');
const Player = require('./Player');
const Track = require('./Track');
const { version: pkgVersion } = require('../../package.json');
const URL_PATTERN = /^https?:\/\//;
const SEARCH_PREFIX = ':';
const REQUESTER_PATTERN = /^([^:]+):(.+)$/;
const EMPTY_ARRAY = Object.freeze([]);
const EMPTY_TRACKS_RESPONSE = Object.freeze({
loadType: 'empty',
exception: null,
playlistInfo: null,
pluginInfo: {},
tracks: EMPTY_ARRAY
});
const DEFAULT_OPTIONS = Object.freeze({
shouldDeleteMessage: false,
defaultSearchPlatform: 'ytsearch',
leaveOnEnd: true,
restVersion: 'v4',
plugins: [],
autoResume: false,
infiniteReconnects: false,
failoverOptions: Object.freeze({
enabled: true,
maxRetries: 3,
retryDelay: 1000,
preservePosition: true,
resumePlayback: true,
cooldownTime: 5000,
maxFailoverAttempts: 5
})
});
const CLEANUP_INTERVAL = 60000;
const MAX_CONCURRENT_OPS = 4;
const BROKEN_PLAYER_TTL = 300000;
const FAILOVER_CLEANUP_TTL = 600000;
const NODE_BATCH_SIZE = 3;
const PLAYER_BATCH_SIZE = 8;
const SEEK_DELAY = 150;
const RECONNECT_DELAY = 800;
const NODE_CHECK_INTERVAL = 100;
const CACHE_VALID_TIME = 5000;
const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
class Aqua extends EventEmitter {
constructor(client, nodes, options = {}) {
super();
if (!client) throw new Error('Client is required');
if (!Array.isArray(nodes) || !nodes.length) throw new TypeError('Nodes must be non-empty Array');
this.client = client;
this.nodes = nodes;
this.nodeMap = new Map();
this.players = new Map();
this.clientId = null;
this.initiated = false;
this.version = pkgVersion;
this.options = Object.assign({}, DEFAULT_OPTIONS, options);
this.failoverOptions = Object.assign({}, DEFAULT_OPTIONS.failoverOptions, options.failoverOptions);
Object.assign(this, {
shouldDeleteMessage: this.options.shouldDeleteMessage,
defaultSearchPlatform: this.options.defaultSearchPlatform,
leaveOnEnd: this.options.leaveOnEnd,
restVersion: this.options.restVersion,
plugins: this.options.plugins,
autoResume: this.options.autoResume,
infiniteReconnects: this.options.infiniteReconnects,
send: this.options.send || this._defaultSend
});
this._nodeStates = new Map();
this._failoverQueue = new Map();
this._lastFailoverAttempt = new Map();
this._brokenPlayers = new Map();
this._leastUsedNodesCache = null;
this._leastUsedNodesCacheTime = 0;
this._bindEventHandlers();
this._startCleanupTimer();
}
_defaultSend = packet => {
const guildId = packet.d.guild_id;
const guild = this.client.cache?.guilds.get(guildId) ?? this.client.guilds?.cache?.get(guildId);
if (!guild) return;
const gateway = this.client.gateway;
if (gateway) {
gateway.send(gateway.calculateShardId(guildId), packet);
} else if (guild.shard) {
guild.shard.send(packet);
}
};
_bindEventHandlers() {
this.on('nodeConnect', node => this.autoResume && queueMicrotask(() => this._rebuildBrokenPlayers(node)));
this.on('nodeDisconnect', node => this.autoResume && queueMicrotask(() => this._storeBrokenPlayers(node)));
}
_startCleanupTimer() {
this._cleanupTimer = setInterval(() => this._performCleanup(), CLEANUP_INTERVAL);
this._cleanupTimer.unref();
}
get leastUsedNodes() {
const now = Date.now();
if (this._leastUsedNodesCache && (now - this._leastUsedNodesCacheTime) < CACHE_VALID_TIME) {
return this._leastUsedNodesCache;
}
const connectedNodes = [];
for (const node of this.nodeMap.values()) {
if (node.connected) connectedNodes.push(node);
}
if (connectedNodes.length > 1) {
for (let i = 1; i < connectedNodes.length; i++) {
const current = connectedNodes[i];
const calls = current.rest?.calls || 0;
let j = i - 1;
while (j >= 0 && (connectedNodes[j].rest?.calls || 0) > calls) {
connectedNodes[j + 1] = connectedNodes[j];
j--;
}
connectedNodes[j + 1] = current;
}
}
this._leastUsedNodesCache = connectedNodes;
this._leastUsedNodesCacheTime = now;
return connectedNodes;
}
async init(clientId) {
if (this.initiated) return this;
this.clientId = clientId;
let successCount = 0;
for (let i = 0; i < this.nodes.length; i += NODE_BATCH_SIZE) {
const batch = this.nodes.slice(i, i + NODE_BATCH_SIZE);
successCount += await this._processNodeBatch(batch);
}
if (!successCount) throw new Error('No nodes connected');
await this._loadPlugins();
this.initiated = true;
return this;
}
async _processNodeBatch(batch) {
const results = await Promise.allSettled(batch.map(n => this._createNode(n)));
return results.filter(r => r.status === 'fulfilled').length;
}
async _loadPlugins() {
const pluginPromises = this.plugins.map(plugin => {
return plugin.load(this).catch(err => {
this.emit('error', null, new Error(`Plugin error: ${err.message}`));
});
});
await Promise.all(pluginPromises);
}
async _createNode(options) {
const nodeId = options.name || options.host;
this._destroyNode(nodeId);
const node = new Node(this, options, this.options);
node.players = new Set();
this.nodeMap.set(nodeId, node);
this._nodeStates.set(nodeId, { connected: false, failoverInProgress: false });
try {
node.connect();
this._nodeStates.set(nodeId, { connected: true, failoverInProgress: false });
this.emit('nodeCreate', node);
return node;
} catch (error) {
this._cleanupNode(nodeId);
throw error;
}
}
_storeBrokenPlayers(node) {
const nodeId = node.name || node.host;
const now = Date.now();
for (const player of this.players.values()) {
if (player.nodes !== node) continue;
const state = this._capturePlayerState(player);
if (state) {
state.originalNodeId = nodeId;
state.brokenAt = now;
this._brokenPlayers.set(player.guildId, state);
}
}
}
_rebuildBrokenPlayers(node) {
const nodeId = node.name || node.host;
let rebuiltCount = 0;
const toDelete = [];
for (const [guildId, brokenState] of this._brokenPlayers) {
if (brokenState.originalNodeId !== nodeId) continue;
this._rebuildPlayer(brokenState, node)
.then(() => {
toDelete.push(guildId);
rebuiltCount++;
})
.catch(() => {
if (Date.now() - brokenState.brokenAt > BROKEN_PLAYER_TTL) {
toDelete.push(guildId);
}
});
}
for (const guildId of toDelete) {
this._brokenPlayers.delete(guildId);
}
if (rebuiltCount) this.emit('playersRebuilt', node, rebuiltCount);
}
async _rebuildPlayer(brokenState, targetNode) {
const { guildId, textChannel, voiceChannel, current, volume = 65, deaf = true } = brokenState;
const existingPlayer = this.players.get(guildId);
if (existingPlayer) await existingPlayer.destroy();
await delay(RECONNECT_DELAY);
try {
const player = await this.createConnection({
guildId,
textChannel,
voiceChannel,
defaultVolume: volume,
deaf
});
if (current) {
await player.queue.add(current);
await player.play();
if (brokenState.position > 0) {
setTimeout(() => player.seek(brokenState.position), SEEK_DELAY);
}
if (brokenState.paused) player.pause();
this.emit('trackStart', player, current);
}
} catch {
this._brokenPlayers.delete(guildId);
}
}
_destroyNode(identifier) {
const node = this.nodeMap.get(identifier);
if (node) {
this._cleanupNode(identifier);
this.emit('nodeDestroy', node);
}
}
_cleanupNode(nodeId) {
const node = this.nodeMap.get(nodeId);
if (node) {
node.removeAllListeners();
this.nodeMap.delete(nodeId);
}
this._nodeStates.delete(nodeId);
this._failoverQueue.delete(nodeId);
this._lastFailoverAttempt.delete(nodeId);
this._invalidateCache();
}
_invalidateCache() {
this._leastUsedNodesCache = null;
this._leastUsedNodesCacheTime = 0;
}
async handleNodeFailover(failedNode) {
if (!this.failoverOptions.enabled) return;
const nodeId = failedNode.name || failedNode.host;
const now = Date.now();
const nodeState = this._nodeStates.get(nodeId);
if (nodeState?.failoverInProgress) return;
const lastAttempt = this._lastFailoverAttempt.get(nodeId);
if (lastAttempt && (now - lastAttempt) < this.failoverOptions.cooldownTime) return;
const attempts = this._failoverQueue.get(nodeId) || 0;
if (attempts >= this.failoverOptions.maxFailoverAttempts) return;
this._nodeStates.set(nodeId, { connected: false, failoverInProgress: true });
this._lastFailoverAttempt.set(nodeId, now);
this._failoverQueue.set(nodeId, attempts + 1);
try {
this.emit('nodeFailover', failedNode);
const affectedPlayers = Array.from(failedNode.players);
if (!affectedPlayers.length) {
this._nodeStates.set(nodeId, { connected: false, failoverInProgress: false });
return;
}
const availableNodes = this._getAvailableNodes(failedNode);
if (!availableNodes.length) {
this.emit('error', null, new Error('No failover nodes available'));
return;
}
const results = await this._migratePlayersOptimized(affectedPlayers, availableNodes);
const successful = results.filter(r => r.success).length;
if (successful) {
this.emit('nodeFailoverComplete', failedNode, successful, results.length - successful);
}
} catch (error) {
this.emit('error', null, new Error(`Failover failed: ${error.message}`));
} finally {
this._nodeStates.set(nodeId, { connected: false, failoverInProgress: false });
}
}
async _migratePlayersOptimized(players, availableNodes) {
const results = [];
for (let i = 0; i < players.length; i += MAX_CONCURRENT_OPS) {
const batch = players.slice(i, i + MAX_CONCURRENT_OPS);
const batchResults = await Promise.allSettled(
batch.map(p => this._migratePlayer(p, availableNodes))
);
results.push(...batchResults.map(r => ({
success: r.status === 'fulfilled',
error: r.reason
})));
}
return results;
}
async _migratePlayer(player, availableNodes) {
let retryCount = 0;
while (retryCount < this.failoverOptions.maxRetries) {
try {
const targetNode = availableNodes[0];
const playerState = this._capturePlayerState(player);
if (!playerState) throw new Error('Failed to capture state');
const newPlayer = await this._createPlayerOnNode(targetNode, playerState);
await this._restorePlayerState(newPlayer, playerState);
this.emit('playerMigrated', player, newPlayer, targetNode);
return newPlayer;
} catch (error) {
retryCount++;
if (retryCount >= this.failoverOptions.maxRetries) throw error;
await delay(this.failoverOptions.retryDelay);
}
}
}
_capturePlayerState(player) {
try {
return {
guildId: player.guildId,
textChannel: player.textChannel,
voiceChannel: player.voiceChannel,
volume: player.volume || 100,
paused: player.paused || false,
position: player.position || 0,
current: player.current || null,
queue: player.queue?.tracks?.slice(0, 10) || EMPTY_ARRAY,
repeat: player.loop,
shuffle: player.shuffle,
deaf: player.deaf || false,
connected: player.connected || false
};
} catch {
return null;
}
}
async _createPlayerOnNode(targetNode, playerState) {
return this.createPlayer(targetNode, {
guildId: playerState.guildId,
textChannel: playerState.textChannel,
voiceChannel: playerState.voiceChannel,
defaultVolume: playerState.volume || 100,
deaf: playerState.deaf || false
});
}
async _restorePlayerState(newPlayer, playerState) {
if (playerState.volume !== undefined) newPlayer.setVolume(playerState.volume);
if (playerState.queue?.length) newPlayer.queue.add(...playerState.queue);
if (playerState.current && this.failoverOptions.preservePosition) {
newPlayer.queue.unshift(playerState.current);
if (this.failoverOptions.resumePlayback) {
await newPlayer.play();
if (playerState.position > 0) {
setTimeout(() => newPlayer.seek(playerState.position), SEEK_DELAY);
}
if (playerState.paused) newPlayer.pause();
}
}
newPlayer.repeat = playerState.repeat;
newPlayer.shuffle = playerState.shuffle;
}
_cleanupPlayer(player) {
if (!player) return;
player.destroy();
player.voiceChannel = null;
this.emit('playerDestroy', player);
}
updateVoiceState({ d, t }) {
if (!d.guild_id || (t !== 'VOICE_STATE_UPDATE' && t !== 'VOICE_SERVER_UPDATE')) return;
const player = this.players.get(d.guild_id);
if (!player) return;
if (t === 'VOICE_STATE_UPDATE') {
if (d.user_id !== this.clientId) return;
if (!d.channel_id) return this._cleanupPlayer(player);
if (player.connection && !player.connection.sessionId && d.session_id) {
player.connection.sessionId = d.session_id;
return;
}
if (player.connection && d.session_id && player.connection.sessionId !== d.session_id) {
player.connection.sessionId = d.session_id;
this.emit('debug', `[Player ${player.guildId}] Session updated to ${d.session_id}`);
}
player.connection?.setStateUpdate(d);
} else {
player.connection?.setServerUpdate(d);
}
}
fetchRegion(region) {
if (!region) return this.leastUsedNodes;
const lowerRegion = region.toLowerCase();
const filtered = [];
for (const node of this.nodeMap.values()) {
if (node.connected && node.regions?.includes(lowerRegion)) {
filtered.push(node);
}
}
return filtered.sort((a, b) => this._getNodeLoad(a) - this._getNodeLoad(b));
}
_getNodeLoad(node) {
const stats = node?.stats?.cpu;
return stats ? (stats.systemLoad / stats.cores) * 100 : 0;
}
createConnection(options) {
if (!this.initiated) throw new Error('Aqua not initialized');
const existingPlayer = this.players.get(options.guildId);
if (existingPlayer?.voiceChannel) return existingPlayer;
const availableNodes = options.region ?
this.fetchRegion(options.region) :
this.leastUsedNodes;
if (!availableNodes.length) throw new Error('No nodes available');
return this.createPlayer(availableNodes[0], options);
}
createPlayer(node, options) {
this.destroyPlayer(options.guildId);
const player = new Player(this, node, options);
this.players.set(options.guildId, player);
player.once('destroy', this._handlePlayerDestroy.bind(this));
player.connect(options);
this.emit('playerCreate', player);
return player;
}
_handlePlayerDestroy(player) {
const node = player.nodes;
node?.players?.delete(player);
this.players.delete(player.guildId);
this.emit('playerDestroy', player);
}
async destroyPlayer(guildId) {
const player = this.players.get(guildId);
if (!player) return;
try {
await player.clearData();
player.removeAllListeners();
this.players.delete(guildId);
this.nodes.rest.destroyPlayer(guildId);
this.emit('playerDestroy', player);
} catch { }
}
async resolve({ query, source = this.defaultSearchPlatform, requester, nodes }) {
if (!this.initiated) throw new Error('Aqua not initialized');
const requestNode = this._getRequestNode(nodes);
const formattedQuery = URL_PATTERN.test(query) ?
query :
`${source}${SEARCH_PREFIX}${query}`;
try {
const endpoint = `/v4/loadtracks?identifier=${encodeURIComponent(formattedQuery)}`;
const response = await requestNode.rest.makeRequest('GET', endpoint);
if (['empty', 'NO_MATCHES'].includes(response.loadType)) {
return EMPTY_TRACKS_RESPONSE;
}
return this._constructResponse(response, requester, requestNode);
} catch (error) {
throw new Error(error.name === 'AbortError' ?
'Request timeout' :
`Resolve failed: ${error.message}`
);
}
}
_getRequestNode(nodes) {
if (!nodes) return this.leastUsedNodes[0];
if (nodes instanceof Node) return nodes;
if (typeof nodes === 'string') return this.nodeMap.get(nodes) || this.leastUsedNodes[0];
throw new TypeError(`Invalid nodes parameter: ${typeof nodes}`);
}
_constructResponse(response, requester, requestNode) {
const baseResponse = {
loadType: response.loadType,
exception: null,
playlistInfo: null,
pluginInfo: response.pluginInfo || {},
tracks: []
};
if (response.loadType === 'error' || response.loadType === 'LOAD_FAILED') {
baseResponse.exception = response.data || response.exception;
return baseResponse;
}
switch (response.loadType) {
case 'track':
if (response.data) {
baseResponse.tracks.push(new Track(response.data, requester, requestNode));
}
break;
case 'playlist': {
const info = response.data?.info;
if (info) {
baseResponse.playlistInfo = {
name: info.name || info.title,
thumbnail: response.data.pluginInfo?.artworkUrl ||
response.data.tracks?.[0]?.info?.artworkUrl ||
null,
...info
};
}
const tracks = response.data?.tracks;
if (tracks?.length) {
baseResponse.tracks = tracks.map(t => new Track(t, requester, requestNode));
}
break;
}
case 'search': {
const searchData = response.data || EMPTY_ARRAY;
if (searchData.length) {
baseResponse.tracks = searchData.map(t => new Track(t, requester, requestNode));
}
break;
}
}
return baseResponse;
}
get(guildId) {
const player = this.players.get(guildId);
if (!player) throw new Error(`Player not found: ${guildId}`);
return player;
}
async search(query, requester, source = this.defaultSearchPlatform) {
if (!query || !requester) return null;
try {
const { tracks } = await this.resolve({ query, source, requester });
return tracks || null;
} catch {
return null;
}
}
async loadPlayers(filePath = './AquaPlayers.jsonl') {
try {
await fs.access(filePath);
await this._waitForFirstNode();
const rawData = await fs.readFile(filePath, 'utf8');
const lines = rawData.trim().split('\n').filter(Boolean);
for (let i = 0; i < lines.length; i += PLAYER_BATCH_SIZE) {
const batch = lines.slice(i, i + PLAYER_BATCH_SIZE)
.map(line => JSON.parse(line));
await Promise.all(batch.map(p => this._restorePlayer(p)));
}
await fs.writeFile(filePath, '', 'utf8');
} catch { }
}
async savePlayer(filePath = './AquaPlayers.jsonl') {
const lines = [];
for (const player of this.players.values()) {
const requester = player.requester || player.current?.requester;
const compactData = {
g: player.guildId,
t: player.textChannel,
v: player.voiceChannel,
u: player.current?.uri || null,
p: player.position || 0,
ts: player.timestamp || 0,
q: player.queue?.tracks?.slice(0, 5).map(tr => tr.uri) || EMPTY_ARRAY,
r: requester ? `${requester.id}:${requester.username}` : null,
vol: player.volume,
pa: player.paused,
pl: player.playing,
nw: player.nowPlayingMessage?.id || null
};
lines.push(JSON.stringify(compactData));
}
await fs.writeFile(filePath, lines.join('\n'), 'utf8');
this.emit('debug', 'Aqua', `Saved ${lines.length} players to ${filePath}`);
}
async _restorePlayer(p) {
try {
let player = this.players.get(p.g);
if (!player) {
const targetNode = this.leastUsedNodes[0];
if (!targetNode) return;
player = await this.createConnection({
guildId: p.g,
textChannel: p.t,
voiceChannel: p.v,
defaultVolume: p.vol || 65,
deaf: true
});
}
if (p.u && player) {
const resolved = await this.resolve({
query: p.u,
requester: this._parseRequester(p.r)
});
if (resolved.tracks?.[0]) {
player.queue.add(resolved.tracks[0]);
player.position = p.p || 0;
player.paused = !!p.pa;
}
}
if (p.nw && player) {
let message = this.client.cache.messages.get(p.nw);
if (!message) {
const channel = this.client.cache.channels.get(p.t);
if (channel) {
try {
if (channel.client.messages) {
message = await channel.client.messages.fetch(p.nw, channel.id).catch(() => null);
} else {
message = await this.client.messages.fetch(channel.id, p.nw).catch(() => null);
}
} catch (error) {
this.emit('debug', 'Aqua', `Failed to fetch nowPlayingMessage ${p.nw} for guild ${p.g}: ${error.message}`);
}
}
}
player.nowPlayingMessage = message || null;
}
if (p.q?.length && player) {
const tracks = await Promise.all(
p.q.filter(uri => uri !== p.u).map(uri => this.resolve({ query: uri, requester: p.r }))
).then(resolved => resolved.flatMap(r => r.tracks));
for (const track of tracks) {
player.queue.add(track);
}
}
if (player) {
if (typeof p.vol === 'number') player.volume = p.vol;
player.paused = !!p.pa;
if (p.u && player.queue.size > 0) {
player.play();
player.seek(p.p || 0);
}
}
this.emit("playerRestore", player);
} catch (error) {
this.emit('debug', 'Aqua', `Error restoring player for guild ${p.g}: ${error.message}`);
}
}
_parseRequester(requesterString) {
if (!requesterString) return null;
const match = REQUESTER_PATTERN.exec(requesterString);
return match ? { id: match[1], username: match[2] } : null;
}
async _waitForFirstNode() {
if (this.leastUsedNodes.length) return;
return new Promise(resolve => {
const checkInterval = setInterval(() => {
if (this.leastUsedNodes.length) {
clearInterval(checkInterval);
resolve();
}
}, NODE_CHECK_INTERVAL);
});
}
_performCleanup() {
const now = Date.now();
for (const [guildId, state] of this._brokenPlayers) {
if (now - state.brokenAt > BROKEN_PLAYER_TTL) {
this._brokenPlayers.delete(guildId);
}
}
for (const [nodeId, timestamp] of this._lastFailoverAttempt) {
if (now - timestamp > FAILOVER_CLEANUP_TTL) {
this._lastFailoverAttempt.delete(nodeId);
this._failoverQueue.delete(nodeId);
}
}
this._invalidateCache();
}
_getAvailableNodes(excludeNode) {
const available = [];
for (const node of this.nodeMap.values()) {
if (node !== excludeNode && node.connected) {
available.push(node);
}
}
return available;
}
destroy() {
if (this._cleanupTimer) {
clearInterval(this._cleanupTimer);
this._cleanupTimer = null;
}
for (const player of this.players.values()) {
player.destroy();
}
for (const node of this.nodeMap.values()) {
node.removeAllListeners();
}
this.removeAllListeners();
this.players.clear();
this.nodeMap.clear();
this._nodeStates.clear();
this._failoverQueue.clear();
this._lastFailoverAttempt.clear();
this._brokenPlayers.clear();
}
}
module.exports = Aqua;