aqualink
Version:
An Lavalink wrapper, focused in speed, performance, and features, Based in Riffy!
279 lines (250 loc) • 11 kB
JavaScript
"use strict";
const { EventEmitter } = require("node:events");
const Node = require("./Node");
const Player = require("./Player");
const Track = require("./Track");
const { version: pkgVersion } = require("../../package.json");
const URL_REGEX = /^https?:\/\//;
class Aqua extends EventEmitter {
/**
* @param {Object} client - The client instance.
* @param {Array<Object>} nodes - An array of node configurations.
* @param {Object} options - Configuration options for Aqua.
* @param {Function} options.send - Function to send data.
* @param {string} [options.defaultSearchPlatform="ytsearch"] - Default search platform.
* @param {string} [options.restVersion="v4"] - Version of the REST API.
* @param {Array<Object>} [options.plugins=[]] - Plugins to load.
* @param {boolean} [options.autoResume=false] - Automatically resume tracks on reconnect.
* @param {boolean} [options.infiniteReconnects=false] - Reconnect infinitely.
*/
constructor(client, nodes, options = {}) {
super();
this.validateInputs(client, nodes, options);
this.client = client;
this.nodes = nodes;
this.nodeMap = new Map();
this.players = new Map();
this.clientId = null;
this.initiated = false;
this.options = options;
this.shouldDeleteMessage = this.getOption(options, 'shouldDeleteMessage', false);
this.defaultSearchPlatform = this.getOption(options, 'defaultSearchPlatform', 'ytsearch');
this.leaveOnEnd = this.getOption(options, 'leaveOnEnd', true);
this.restVersion = this.getOption(options, 'restVersion', 'v4');
this.plugins = this.getOption(options, 'plugins', []);
this.version = pkgVersion;
this.send = options.send || this.defaultSendFunction;
this.autoResume = this.getOption(options, 'autoResume', false);
this.infiniteReconnects = this.getOption(options, 'infiniteReconnects', false);
this.setMaxListeners(0);
this._leastUsedCache = { nodes: [], timestamp: 0 };
}
getOption(options, key, defaultValue) {
return Object.prototype.hasOwnProperty.call(options, key) ? options[key] : defaultValue;
}
defaultSendFunction(payload) {
const guild = this.client.guilds.cache.get(payload.d.guild_id);
if (guild) guild.shard.send(payload);
}
validateInputs(client, nodes) {
if (!client) throw new Error("Client is required to initialize Aqua");
if (!Array.isArray(nodes) || !nodes.length) {
throw new Error(`Nodes must be a non-empty Array (Received ${typeof nodes})`);
}
}
get leastUsedNodes() {
const now = Date.now();
if (now - this._leastUsedCache.timestamp < 50) return this._leastUsedCache.nodes;
const nodes = [];
for (const node of this.nodeMap.values()) {
if (node.connected) nodes.push(node);
}
nodes.sort((a, b) => a.rest.calls - b.rest.calls);
this._leastUsedCache = { nodes, timestamp: now };
return nodes;
}
init(clientId) {
if (this.initiated) return this;
this.clientId = clientId;
try {
this.nodes.forEach(nodeConfig => this.createNode(nodeConfig));
this.initiated = true;
this.plugins.forEach(plugin => plugin.load(this));
} catch (error) {
this.initiated = false;
throw error;
}
return this;
}
createNode(options) {
const nodeId = options.name || options.host;
this.destroyNode(nodeId);
const node = new Node(this, options, this.options);
this.nodeMap.set(nodeId, node);
this._leastUsedCache.timestamp = 0;
node.connect().then(() => {
this.emit("nodeCreate", node);
}).catch(error => {
this.nodeMap.delete(nodeId);
throw error;
});
return node;
}
destroyNode(identifier) {
const node = this.nodeMap.get(identifier);
if (!node) return;
node.disconnect().then(() => {
node.removeAllListeners();
this.nodeMap.delete(identifier);
this._leastUsedCache.timestamp = 0;
this.emit("nodeDestroy", node);
}).catch(error => console.error(`Error destroying node ${identifier}:`, error));
}
updateVoiceState({ d, t }) {
const player = this.players.get(d.guild_id);
if (!player) return;
if (t === "VOICE_SERVER_UPDATE" || (t === "VOICE_STATE_UPDATE" && d.user_id === this.clientId)) {
const updateMethod = t === "VOICE_SERVER_UPDATE" ? "setServerUpdate" : "setStateUpdate";
if (player.connection && typeof player.connection[updateMethod] === "function") {
player.connection[updateMethod](d);
}
if (d.channel_id === null) {
this.cleanupPlayer(player);
}
}
}
fetchRegion(region) {
if (!region) return this.leastUsedNodes;
const lowerRegion = region.toLowerCase();
const regionNodes = [];
for (const node of this.nodeMap.values()) {
if (node.connected && node.regions?.includes(lowerRegion)) {
regionNodes.push(node);
}
}
regionNodes.sort((a, b) => this.calculateLoad(a) - this.calculateLoad(b));
return regionNodes;
}
calculateLoad(node) {
if (!node?.stats?.cpu) return 0;
const { systemLoad, cores } = node.stats.cpu;
return (systemLoad / cores) * 100;
}
createConnection(options) {
this.ensureInitialized();
const existingPlayer = this.players.get(options.guildId);
if (existingPlayer && existingPlayer.voiceChannel) return existingPlayer;
const availableNodes = options.region ? this.fetchRegion(options.region) : this.leastUsedNodes;
const node = availableNodes[0];
if (!node) throw new Error("No nodes are available");
return this.createPlayer(node, 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.cleanupPlayer(player));
player.connect(options);
this.emit("playerCreate", player);
return player;
}
async destroyPlayer(guildId) {
const player = this.players.get(guildId);
if (!player) return;
try {
await player.clearData();
player.removeAllListeners();
this.players.delete(guildId);
this.emit("playerDestroy", player);
} catch (error) {
console.error(`Error destroying player for guild ${guildId}:`, error);
}
}
async resolve({ query, source = this.defaultSearchPlatform, requester, nodes }) {
this.ensureInitialized();
const requestNode = this.getRequestNode(nodes);
const formattedQuery = this.formatQuery(query, source);
try {
const response = await requestNode.rest.makeRequest("GET", `/v4/loadtracks?identifier=${encodeURIComponent(formattedQuery)}`);
if (["empty", "NO_MATCHES"].includes(response.loadType)) {
return await this.handleNoMatches(requestNode.rest, query);
}
return this.constructorResponse(response, requester, requestNode);
} catch (error) {
if (error.name === "AbortError") {
throw new Error("Request timed out");
}
throw new Error(`Failed to resolve track: ${error.message}`);
}
}
getRequestNode(nodes) {
if (nodes && !(typeof nodes === "string" || nodes instanceof Node)) {
throw new TypeError(`'nodes' must be a string or Node instance, received: ${typeof nodes}`);
}
return (typeof nodes === "string" ? this.nodeMap.get(nodes) : nodes) ?? this.leastUsedNodes[0];
}
ensureInitialized() {
if (!this.initiated) throw new Error("Aqua must be initialized before this operation");
}
formatQuery(query, source) {
return URL_REGEX.test(query) ? query : `${source}:${query}`;
}
async handleNoMatches(rest, query) {
try {
const ytIdentifier = `/v4/loadtracks?identifier=https://www.youtube.com/watch?v=${query}`;
const youtubeResponse = await rest.makeRequest("GET", ytIdentifier);
if (["empty", "NO_MATCHES"].includes(youtubeResponse.loadType)) {
const spotifyIdentifier = `/v4/loadtracks?identifier=https://open.spotify.com/track/${query}`;
return await rest.makeRequest("GET", spotifyIdentifier);
}
return youtubeResponse;
} catch (error) {
console.error(`Failed to resolve track: ${error.message}`);
}
}
constructorResponse(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;
}
const trackFactory = trackData => new Track(trackData, requester, requestNode);
switch (response.loadType) {
case "track":
if (response.data) {
baseResponse.tracks.push(trackFactory(response.data));
}
break;
case "playlist":
if (response.data?.info) {
baseResponse.playlistInfo = {
name: response.data.info.name ?? response.data.info.title,
...response.data.info
};
}
baseResponse.tracks = (response.data?.tracks ?? []).map(trackFactory);
break;
case "search":
baseResponse.tracks = (response.data ?? []).map(trackFactory);
break;
}
return baseResponse;
}
get(guildId) {
const player = this.players.get(guildId);
if (!player) throw new Error(`Player not found for guild ID: ${guildId}`);
return player;
}
cleanupPlayer(player) {
if (player && this.players.has(player.guildId)) {
this.players.delete(player.guildId);
}
}
}
module.exports = Aqua