UNPKG

aqualink

Version:

An Lavalink client, focused in pure performance and features

383 lines (313 loc) 13 kB
"use strict"; const Node = require("./Node"); const Player = require("./Player"); const Track = require("./Track"); const { version: pkgVersion } = require("../../package.json"); const { EventEmitter } = require('eventemitter3'); const URL_REGEX = /^https?:\/\//; const DEFAULT_OPTIONS = Object.freeze({ shouldDeleteMessage: false, defaultSearchPlatform: 'ytsearch', leaveOnEnd: true, restVersion: 'v4', plugins: [], autoResume: false, infiniteReconnects: false }); const LEAST_USED_CACHE_TTL = 50; class Aqua extends EventEmitter { constructor(client, nodes, options = {}) { super(); if (!client) throw new Error("Client is required to initialize Aqua"); if (!Array.isArray(nodes) || !nodes.length) { throw new TypeError(`Nodes must be a non-empty Array (Received ${typeof nodes})`); } 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); const { shouldDeleteMessage, defaultSearchPlatform, leaveOnEnd, restVersion, plugins, autoResume, infiniteReconnects, send } = this.options; this.shouldDeleteMessage = shouldDeleteMessage; this.defaultSearchPlatform = defaultSearchPlatform; this.leaveOnEnd = leaveOnEnd; this.restVersion = restVersion; this.plugins = plugins; this.autoResume = autoResume; this.infiniteReconnects = infiniteReconnects; this.send = send || this.defaultSendFunction.bind(this); this._leastUsedCache = { nodes: [], timestamp: 0 }; } defaultSendFunction(payload) { const guild = this.client.guilds.cache.get(payload.d.guild_id); if (guild) guild.shard.send(payload); } get leastUsedNodes() { const now = Date.now(); if (now - this._leastUsedCache.timestamp < LEAST_USED_CACHE_TTL) { return this._leastUsedCache.nodes; } const connectedNodes = []; for (const node of this.nodeMap.values()) { if (node.connected) connectedNodes.push(node); } connectedNodes.sort((a, b) => a.rest.calls - b.rest.calls); this._leastUsedCache = { nodes: connectedNodes, timestamp: now }; return connectedNodes; } async init(clientId) { if (this.initiated) return this; this.clientId = clientId; try { const nodePromises = []; for (const node of this.nodes) { nodePromises.push(this.createNode(node)); } await Promise.all(nodePromises); for (const plugin of this.plugins) { plugin.load(this); } this.initiated = true; } catch (error) { this.initiated = false; throw error; } return this; } async 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; try { await node.connect(); this.emit("nodeCreate", node); return node; } catch (error) { this.nodeMap.delete(nodeId); console.error("Failed to connect node:", error); throw error; } } destroyNode(identifier) { const node = this.nodeMap.get(identifier); if (!node) return; node.destroy(); this.nodeMap.delete(identifier); this._leastUsedCache.timestamp = 0; this.emit("nodeDestroy", node); } 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)) { if (t === "VOICE_SERVER_UPDATE") { player.connection?.setServerUpdate?.(d); } else { player.connection?.setStateUpdate?.(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); } } const loadCache = new Map(); regionNodes.sort((a, b) => { if (!loadCache.has(a)) loadCache.set(a, this.calculateLoad(a)); if (!loadCache.has(b)) loadCache.set(b, this.calculateLoad(b)); return loadCache.get(a) - loadCache.get(b); }); return regionNodes; } calculateLoad(node) { const stats = node?.stats?.cpu; if (!stats) return 0; const { systemLoad, cores } = stats; return (systemLoad / cores) * 100; } createConnection(options) { if (!this.initiated) throw new Error("Aqua must be initialized before this operation"); 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.players.delete(options.guildId); }); 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 }) { if (!this.initiated) throw new Error("Aqua must be initialized before this operation"); const requestNode = this.getRequestNode(nodes); const formattedQuery = URL_REGEX.test(query) ? query : `${source}:${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 await this.handleNoMatches(requestNode.rest, query); } return this.constructResponse(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) 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(`'nodes' must be a string or Node instance, received: ${typeof nodes}`); } async handleNoMatches(rest, query) { try { const ytEndpoint = `/v4/loadtracks?identifier=https://www.youtube.com/watch?v=${query}`; const youtubeResponse = await rest.makeRequest("GET", ytEndpoint); if (!["empty", "NO_MATCHES"].includes(youtubeResponse.loadType)) { return youtubeResponse; } const spotifyEndpoint = `/v4/loadtracks?identifier=https://open.spotify.com/track/${query}`; return await rest.makeRequest("GET", spotifyEndpoint); } catch (error) { console.error(`Failed to resolve track: ${error.message}`); throw error; } } 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; } 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": { const info = response.data?.info; if (info) { baseResponse.playlistInfo = { name: info.name ?? info.title, ...info }; } const tracks = response.data?.tracks; if (tracks?.length) { const trackCount = tracks.length; baseResponse.tracks = new Array(trackCount); for (let i = 0; i < trackCount; i++) { baseResponse.tracks[i] = trackFactory(tracks[i]); } } break; } case "search": { const searchData = response.data ?? []; const dataLength = searchData.length; if (dataLength) { baseResponse.tracks = new Array(dataLength); for (let i = 0; i < dataLength; i++) { baseResponse.tracks[i] = trackFactory(searchData[i]); } } 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; } 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 (error) { console.error("Search error:", error); return null; } } async cleanupPlayer(player) { if (!player) return; try { if (player.connection) { try { await player.connection.disconnect(); player.connection = null; } catch (error) { console.error(`Error disconnecting player connection: ${error.message}`); } } player.queue?.clear(); if (typeof player.stop === 'function') { try { await player.stop(); } catch (error) { console.error(`Error stopping player: ${error.message}`); } } player.removeAllListeners(); this.players.delete(player.guildId); this.emit("playerCleanup", player.guildId); } catch (error) { console.error(`Error during player cleanup: ${error.message}`); } } } module.exports = Aqua;