UNPKG

euralink

Version:

🎵 The most advanced, blazing-fast Lavalink client for Node.js with SponsorBlock, real-time lyrics, 60% less RAM usage, and the ultimate music bot experience.

793 lines (666 loc) • 28.8 kB
const { EventEmitter } = require("tseep"); const { Node } = require("./Node"); const { Player } = require("./Player"); const { Track } = require("./Track"); const https = require('https'); const { version: pkgVersion } = require("../../package.json") const fs = require('fs/promises'); const { EuraSync } = require("./EuraSync"); const versions = ["v3", "v4"]; class Euralink extends EventEmitter { /** * @param {Client} client - Your Discord.js client * @param {Array} nodes - Lavalink node configs * @param {Object} options - Euralink options (modern structured config) * @param {Object} options.rest - REST API config * @param {Array} [options.plugins] - Array of Euralink plugins * @param {Object} [options.sync] - EuraSync config * @param {Object} [options.activityStatus] - Activity status config * @param {Object} [options.resume] - Auto-resume config * @param {Object} [options.node] - Node connection config * @param {boolean} [options.autopauseOnEmpty] - Auto-pause when empty * @param {Object} [options.lazyLoad] - Lazy loading config * @param {string} [options.defaultSearchPlatform] - Default search platform * @param {Object} [options.track] - Track-related config */ constructor(client, nodes, options) { super(); // Config validation if (!client) throw new Error("[Euralink] Client is required to initialize Euralink"); if (!nodes || !Array.isArray(nodes)) throw new Error(`[Euralink] Nodes are required & Must Be an Array (Received ${typeof nodes})`); if (!options || typeof options !== 'object') throw new Error("[Euralink] Options object is required to initialize Euralink"); if (!options.defaultSearchPlatform) throw new Error("[Euralink] defaultSearchPlatform is required in options"); /** * WARNING: The resume.enabled option controls whether player state is kept up to date for auto-resume. * You can always call loadPlayersState to restore players, but if resume.enabled is false, their state will NOT be updated for future saves. * For true auto-resume, set resume.enabled: true and always call savePlayersState on shutdown and loadPlayersState on startup. */ // Modern structured config with full backward compatibility this.options = { rest: { version: options.rest?.version || options.restVersion || 'v4', retryCount: options.rest?.retryCount || options.retryCount || 3, timeout: options.rest?.timeout || options.timeout || 5000 }, plugins: options.plugins || [], // EuraSync (backward compatibility: euraSync, eurasync, sync) euraSync: options.euraSync || options.eurasync || options.sync || { enabled: false, template: options.euraSync?.template || options.eurasync?.template || options.sync?.template || '🎵 {title} by {author}' }, // Activity status (backward compatibility: setActivityStatus, activityStatus) activityStatus: options.activityStatus || options.setActivityStatus || { enabled: false, template: '🎵 {title} by {author}' }, // Resume (backward compatibility: autoResume, resume) resume: options.resume || { enabled: options.autoResume ?? options.resume?.enabled ?? false, key: options.resumeKey || options.resume?.key || 'euralink-resume', timeout: options.resumeTimeout || options.resume?.timeout || 60000 }, // Node configuration (backward compatibility for all old options) node: options.node || { dynamicSwitching: options.dynamicSwitching ?? options.node?.dynamicSwitching ?? true, autoReconnect: options.autoReconnect ?? options.node?.autoReconnect ?? true, ws: { reconnectTries: options.reconnectTries || options.node?.ws?.reconnectTries || options.node?.reconnectTries || 5, reconnectInterval: options.reconnectInterval || options.node?.ws?.reconnectInterval || options.node?.reconnectInterval || 5000 } }, // Legacy options with backward compatibility autopauseOnEmpty: options.autopauseOnEmpty ?? true, lazyLoad: options.lazyLoad || { enabled: false, timeout: 5000 }, defaultSearchPlatform: options.defaultSearchPlatform || 'ytmsearch', track: options.track || { historyLimit: 20, enableVoting: true, enableFavorites: true, enableUserNotes: true }, // Additional backward compatibility for legacy options bypassChecks: options.bypassChecks || {}, debug: options.debug ?? false }; this.client = client; this.nodes = nodes; this.nodeMap = new Map(); this.players = new Map(); this.clientId = null; this.initiated = false; this.send = options.send || null; this.defaultSearchPlatform = this.options.defaultSearchPlatform; this.restVersion = this.options.rest.version; this.tracks = []; this.loadType = null; this.playlistInfo = null; this.pluginInfo = null; this.plugins = this.options.plugins; // Performance optimizations this.regionCache = new Map(); this.nodeHealthCache = new Map(); this.cacheTimeout = 30000; // 30 seconds // Lazy loading support this.lazyLoad = this.options.lazyLoad.enabled; this.lazyLoadTimeout = this.options.lazyLoad.timeout; // Enhanced performance optimizations this.enhancedPerformance = { enabled: options.enhancedPerformance?.enabled ?? true, connectionPooling: options.enhancedPerformance?.connectionPooling ?? true, requestBatching: options.enhancedPerformance?.requestBatching ?? true, memoryOptimization: options.enhancedPerformance?.memoryOptimization ?? true }; // Always check for updates on startup this.checkForUpdates(); // EuraSync support (accepts 'eurasync', 'euraSync', or 'sync' for config key) const syncConfig = options.eurasync || options.euraSync || options.sync; if (syncConfig?.enabled) { this.euraSync = new EuraSync(client, syncConfig); } else { this.euraSync = null; } // setActivityStatus support if (this.options.activityStatus?.enabled) { this.setActivityStatus = this.options.activityStatus; } else { this.setActivityStatus = null; } /** * @description Package Version Of Euralink */ this.version = pkgVersion; if (this.restVersion && !versions.includes(this.restVersion)) throw new RangeError(`${this.restVersion} is not a valid version`); } /** * Validate the current config. Throws if invalid. */ validateConfig() { if (!this.client) throw new Error("[Euralink] Client is required"); if (!this.nodes || !Array.isArray(this.nodes)) throw new Error("[Euralink] Nodes must be an array"); if (!this.options.defaultSearchPlatform) throw new Error("[Euralink] defaultSearchPlatform is required"); // Add more checks as needed return true; } /** * Clear all internal caches (region, node health, etc) */ clearAllCaches() { this.regionCache.clear(); this.nodeHealthCache.clear(); this.emit("debug", "All caches cleared"); } get leastUsedNodes() { return [...this.nodeMap.values()] .filter((node) => node.connected) .sort((a, b) => { // Improved node selection with health metrics const aHealth = this.getNodeHealth(a); const bHealth = this.getNodeHealth(b); return aHealth.score - bHealth.score; }); } // Get node health score for better load balancing getNodeHealth(node) { const now = Date.now(); const cached = this.nodeHealthCache.get(node.name); if (cached && (now - cached.timestamp) < this.cacheTimeout) { return cached.health; } const health = node.getHealthStatus(); const score = this.calculateNodeScore(health); this.nodeHealthCache.set(node.name, { health: { ...health, score }, timestamp: now }); return { ...health, score }; } // Calculate node score for load balancing calculateNodeScore(health) { let score = 0; // Lower score = better node score += health.penalties * 10; score += health.cpuLoad * 100; score += health.memoryUsage * 0.5; score += health.ping * 0.1; score += health.players * 2; score += health.playingPlayers * 5; return score; } init(clientId) { if (this.initiated) return this; this.clientId = clientId; this.nodes.forEach((node) => this.createNode(node)); this.initiated = true; this.emit("debug", `Euralink initialized, connecting to ${this.nodes.length} node(s)`); if (this.plugins) { this.emit("debug", `Loading ${this.plugins.length} Euralink plugin(s)`); this.plugins.forEach((plugin) => { plugin.load(this); }); } } createNode(options) { // Ensure restVersion is included in node config const nodeConfig = { ...options, restVersion: this.options.rest.version || options.restVersion || 'v4', }; const node = new Node(this, nodeConfig, this.options); this.nodeMap.set(nodeConfig.name || nodeConfig.host, node); node.connect(); this.emit("nodeCreate", node); return node; } destroyNode(identifier) { const node = this.nodeMap.get(identifier); if (!node) return; node.disconnect(); this.nodeMap.delete(identifier); this.nodeHealthCache.delete(identifier); this.emit("nodeDestroy", node); } updateVoiceState(packet) { if (!["VOICE_STATE_UPDATE", "VOICE_SERVER_UPDATE"].includes(packet.t)) return; const player = this.players.get(packet.d.guild_id); if (!player) return; if (packet.t === "VOICE_SERVER_UPDATE") { player.connection.setServerUpdate(packet.d); } else if (packet.t === "VOICE_STATE_UPDATE") { if (packet.d.user_id !== this.clientId) return; player.connection.setStateUpdate(packet.d); } } // Improved region fetching with caching and better performance fetchRegion(region) { const now = Date.now(); const cacheKey = `region_${region}`; const cached = this.regionCache.get(cacheKey); if (cached && (now - cached.timestamp) < this.cacheTimeout) { return cached.nodes; } const nodesByRegion = [...this.nodeMap.values()] .filter((node) => node.connected && node.regions?.includes(region?.toLowerCase())) .sort((a, b) => { const aHealth = this.getNodeHealth(a); const bHealth = this.getNodeHealth(b); return aHealth.score - bHealth.score; }); // Cache the result this.regionCache.set(cacheKey, { nodes: nodesByRegion, timestamp: now }); return nodesByRegion; } async checkForUpdates() { try { const { version: pkgVersion } = require("../../package.json"); // Skip update check if we're on a development/local version if (pkgVersion.includes('dev') || pkgVersion.includes('local')) { this.emit("debug", `[Euralink] Development version detected: ${pkgVersion}, skipping update check`); return; } await new Promise((resolve, reject) => { https.get('https://registry.npmjs.org/euralink', (res) => { let data = ''; res.on('data', chunk => data += chunk); res.on('end', () => { try { const info = JSON.parse(data); const latest = info['dist-tags'].latest; // Better version comparison - don't show update if current is newer if (latest && this.isNewerVersion(latest, pkgVersion)) { console.log(`[Euralink] 🚨 New version available: ${latest} (current: ${pkgVersion})\nRun \`npm install euralink@latest\` to update!`); } else if (latest && latest !== pkgVersion) { this.emit("debug", `[Euralink] Version check: NPM has ${latest}, you have ${pkgVersion} (you may have a newer local version)`); } resolve(); } catch (e) { this.emit("debug", `Failed checking updates for euralink: ${e.message}`); resolve(); } }); }).on('error', (err) => { this.emit("debug", `Failed checking updates for euralink: ${err.message}`); resolve(); }); }); } catch (err) { this.emit("debug", `[Euralink] Error in checkForUpdates: ${err.message}`); } } // Helper function to compare versions properly isNewerVersion(version1, version2) { const v1parts = version1.replace(/[^\d.]/g, '').split('.').map(Number); const v2parts = version2.replace(/[^\d.]/g, '').split('.').map(Number); for (let i = 0; i < Math.max(v1parts.length, v2parts.length); i++) { const v1part = v1parts[i] || 0; const v2part = v2parts[i] || 0; if (v1part > v2part) return true; if (v1part < v2part) return false; } return false; } // Get best node for a specific region getBestNodeForRegion(region) { const regionNodes = this.fetchRegion(region); return regionNodes.length > 0 ? regionNodes[0] : this.leastUsedNodes[0]; } /** * Creates a connection based on the provided options. * * @param {Object} options - The options for creating the connection. * @param {string} options.guildId - The ID of the guild. * @param {string} [options.region] - The region for the connection. * @param {number} [options.defaultVolume] - The default volume of the player. **By-Default**: **100** * @param {import("..").LoopOption} [options.loop] - The loop mode of the player. * @throws {Error} Throws an error if Euralink is not initialized or no nodes are available. * @return {Player} The created player. */ createConnection(options) { if (!this.initiated) throw new Error("You have to initialize Euralink in your ready event"); const player = this.players.get(options.guildId); if (player) return player; if (this.leastUsedNodes.length === 0) throw new Error("No nodes are available"); let node; if (options.region) { node = this.getBestNodeForRegion(options.region); } else { node = this.leastUsedNodes[0]; } if (!node) throw new Error("No nodes are available"); return this.createPlayer(node, options); } createPlayer(node, options) { const player = new Player(this, node, options); this.players.set(options.guildId, player); player.connect(options); this.emit('debug', `Created a player (${options.guildId}) on node ${node.name}`); this.emit("playerCreate", player); return player; } destroyPlayer(guildId) { const player = this.players.get(guildId); if (!player) return; player.destroy(); this.players.delete(guildId); this.emit("playerDestroy", player); } removeConnection(guildId) { this.players.get(guildId)?.destroy(); this.players.delete(guildId); } /** * @param {object} param0 * @param {string} param0.query used for searching as a search Query * @param {*} param0.source A source to search the query on example:ytmsearch for youtube music * @param {*} param0.requester the requester who's requesting * @param {(string | Node)} [param0.node] the node to request the query on either use node identifier/name or the node class itself * @returns {import("..").nodeResponse} returned properties values are nullable if lavalink doesn't give them * */ async resolve({ query, source, requester, node }) { try { if (!this.initiated) throw new Error("You have to initialize Euralink in your ready event"); if(node && (typeof node !== "string" && !(node instanceof Node))) throw new Error(`'node' property must either be an node identifier/name('string') or an Node/Node Class, But Received: ${typeof node}`) const querySource = source || this.defaultSearchPlatform; const requestNode = (node && typeof node === 'string' ? this.nodeMap.get(node) : node) || this.leastUsedNodes[0]; if (!requestNode) throw new Error("No nodes are available."); const regex = /^https?:\/\//; const identifier = regex.test(query) ? query : `${querySource}:${query}`; this.emit("debug", `Searching for ${query} on node "${requestNode.name}"`); let response = await requestNode.rest.makeRequest(`GET`, `/${requestNode.rest.version}/loadtracks?identifier=${encodeURIComponent(identifier)}`); // Handle failed requests (like 500 errors) if (!response || response.loadType === "error") { this.emit("debug", `Search failed for "${query}" on node "${requestNode.name}": ${response?.data?.message || 'Unknown error'}`); // Try fallback search if it's a URL if (regex.test(query)) { this.emit("debug", `Attempting fallback search for "${query}"`); const fallbackIdentifier = `${querySource}:${query}`; response = await requestNode.rest.makeRequest(`GET`, `/${requestNode.rest.version}/loadtracks?identifier=${encodeURIComponent(fallbackIdentifier)}`); } // If still failed, throw error if (!response || response.loadType === "error") { throw new Error(response?.data?.message || 'Failed to load tracks'); } } // for resolving identifiers - Only works in Spotify and Youtube if (response.loadType === "empty" || response.loadType === "NO_MATCHES") { response = await requestNode.rest.makeRequest(`GET`, `/${requestNode.rest.version}/loadtracks?identifier=https://open.spotify.com/track/${query}`); if (response.loadType === "empty" || response.loadType === "NO_MATCHES") { response = await requestNode.rest.makeRequest(`GET`, `/${requestNode.rest.version}/loadtracks?identifier=https://www.youtube.com/watch?v=${query}`); } } if (requestNode.rest.version === "v4") { if (response.loadType === "track") { this.tracks = response.data ? [new Track(response.data, requester, requestNode)] : []; this.emit("debug", `Search Success for "${query}" on node "${requestNode.name}", loadType: ${response.loadType}, Resulted track Title: ${this.tracks[0].info.title} by ${this.tracks[0].info.author}`); } else if (response.loadType === "playlist") { // Fast parallel track creation for playlists const trackData = response.data?.tracks || []; this.tracks = new Array(trackData.length); // Synchronously create Track instances for each track this.tracks = trackData.map((track, index) => new Track(track, requester, requestNode)); this.emit("debug", `Search Success for "${query}" on node "${requestNode.name}", loadType: ${response.loadType} tracks: ${this.tracks.length}`); } else { // Fast parallel track creation for search results const trackData = response.loadType === "search" && response.data ? response.data : []; this.tracks = new Array(trackData.length); // Use Promise.all for parallel processing const trackPromises = trackData.map((track, index) => { return Promise.resolve(new Track(track, requester, requestNode)); }); this.tracks = await Promise.all(trackPromises); this.emit("debug", `Search ${this.loadType !== "error" ? "Success" : "Failed"} for "${query}" on node "${requestNode.name}", loadType: ${response.loadType} tracks: ${this.tracks.length}`); } } else { // v3 (Legacy or Lavalink V3) - Fast parallel processing const trackData = response?.tracks || []; this.tracks = new Array(trackData.length); // Use Promise.all for parallel processing const trackPromises = trackData.map((track, index) => { return Promise.resolve(new Track(track, requester, requestNode)); }); this.tracks = await Promise.all(trackPromises); this.emit("debug", `Search ${this.loadType !== "error" || this.loadType !== "LOAD_FAILED" ? "Success" : "Failed"} for "${query}" on node "${requestNode.name}", loadType: ${response.loadType} tracks: ${this.tracks.length}`); } if ( requestNode.rest.version === "v4" && response.loadType === "playlist" ) { this.playlistInfo = response.data?.info || null; } else { this.playlistInfo = null; } this.loadType = response.loadType; return { loadType: response.loadType, tracks: this.tracks, playlistInfo: this.playlistInfo, pluginInfo: this.pluginInfo, }; } catch (error) { this.emit("debug", `Search failed for "${query}": ${error.message}`); throw error; } } get(guildId) { return this.players.get(guildId); } async search(query, requester, source = this.defaultSearchPlatform) { return this.resolve({ query, source, requester }); } // Get all nodes health status getNodesHealth() { const health = {}; for (const [name, node] of this.nodeMap) { health[name] = this.getNodeHealth(node); } return health; } // Get overall system health getSystemHealth() { const nodesHealth = this.getNodesHealth(); const connectedNodes = Object.values(nodesHealth).filter(h => h.connected); const totalPlayers = Object.values(nodesHealth).reduce((sum, h) => sum + h.players, 0); const totalPlayingPlayers = Object.values(nodesHealth).reduce((sum, h) => sum + h.playingPlayers, 0); return { totalNodes: Object.keys(nodesHealth).length, connectedNodes: connectedNodes.length, totalPlayers, totalPlayingPlayers, averagePing: connectedNodes.length > 0 ? connectedNodes.reduce((sum, h) => sum + h.averagePing, 0) / connectedNodes.length : 0, nodesHealth }; } // Clear caches clearCaches() { this.regionCache.clear(); this.nodeHealthCache.clear(); this.emit("debug", "All caches cleared"); } // Save player states for autoResume async savePlayersState(filePath) { try { const playersData = {}; for (const [guildId, player] of this.players) { if (player.current || player.queue.length > 0) { playersData[guildId] = player.toJSON(); } } await fs.writeFile(filePath, JSON.stringify(playersData, null, 2)); this.emit("debug", `Saved ${Object.keys(playersData).length} player states to ${filePath}`); return playersData; } catch (error) { this.emit("debug", `Failed to save player states: ${error.message}`); throw error; } } // Load player states for autoResume async loadPlayersState(filePath) { try { // Warn if resume.enabled is false if (!this.options.resume?.enabled) { this.emit("debug", `[Euralink] WARNING: loadPlayersState called but resume.enabled is false. Players will be restored, but their state will NOT be kept up to date for future saves. Set resume.enabled: true for full auto-resume support.`); } const data = await fs.readFile(filePath, 'utf8'); const playersData = JSON.parse(data); let loadedCount = 0; for (const [guildId, playerData] of Object.entries(playersData)) { try { // Find the best node for this player const node = this.leastUsedNodes[0]; if (!node) { this.emit("debug", `No available nodes to restore player for guild ${guildId}`); continue; } // Create player from saved state const player = Player.fromJSON(this, node, playerData); // Force autoResumeState.enabled to match current config player.autoResumeState.enabled = !!this.options.resume?.enabled; this.players.set(guildId, player); // Save autoResume state if enabled if (this.options.resume?.enabled && player.autoResumeState.enabled) { player.saveAutoResumeState(); } loadedCount++; this.emit("playerCreate", player); this.emit("debug", `Restored player for guild ${guildId}`); } catch (error) { this.emit("debug", `Failed to restore player for guild ${guildId}: ${error.message}`); } } this.emit("debug", `Loaded ${loadedCount} player states from ${filePath}`); return loadedCount; } catch (error) { if (error.code === 'ENOENT') { this.emit("debug", `No player state file found at ${filePath}`); return 0; } this.emit("debug", `Failed to load player states: ${error.message}`); throw error; } } /** * Enhanced error recovery system */ async recoverFromError(error, context = 'unknown') { this.emit("debug", `Starting error recovery for: ${context}`); try { // Check node connectivity const healthyNodes = this.leastUsedNodes; if (healthyNodes.length === 0) { this.emit("debug", "No healthy nodes available, attempting reconnection"); for (const [name, node] of this.nodeMap) { if (!node.connected) { await node.connect(); } } } // Migrate players from failed nodes const failedPlayers = []; for (const [guildId, player] of this.players) { if (!player.node.connected && player.connected) { failedPlayers.push(player); } } if (failedPlayers.length > 0) { this.emit("debug", `Migrating ${failedPlayers.length} players from failed nodes`); for (const player of failedPlayers) { await this.migratePlayer(player); } } this.emit("errorRecovered", context, error); return true; } catch (recoveryError) { this.emit("errorRecoveryFailed", context, error, recoveryError); return false; } } /** * Migrate player to a healthy node */ async migratePlayer(player) { try { const healthyNodes = this.leastUsedNodes; if (healthyNodes.length === 0) { throw new Error("No healthy nodes available for migration"); } const newNode = healthyNodes[0]; const oldNode = player.node; // Save current state const playerState = { current: player.current, position: player.position, volume: player.volume, paused: player.paused, queue: [...player.queue], filters: player.filters.getPayload ? player.filters.getPayload() : {} }; // Update player node player.node = newNode; // Restore state on new node if (playerState.current) { await player.restart(); } this.emit("playerMigrated", player, oldNode, newNode); this.emit("debug", `Player ${player.guildId} migrated from ${oldNode.name} to ${newNode.name}`); return true; } catch (error) { this.emit("playerMigrationFailed", player, error); return false; } } /** * System health check */ async performHealthCheck() { const healthReport = { timestamp: Date.now(), overall: 'healthy', nodes: {}, players: {}, performance: {} }; // Check nodes for (const [name, node] of this.nodeMap) { const nodeHealth = node.getHealthStatus(); healthReport.nodes[name] = nodeHealth; if (!nodeHealth.connected || nodeHealth.penalties > 100) { healthReport.overall = 'degraded'; } } // Check players let activePlayerCount = 0; let playingPlayerCount = 0; for (const [guildId, player] of this.players) { activePlayerCount++; if (player.playing) playingPlayerCount++; if (!player.connected && player.voiceChannel) { healthReport.players[guildId] = 'disconnected'; healthReport.overall = 'degraded'; } } healthReport.performance = { activePlayerCount, playingPlayerCount, memoryUsage: process.memoryUsage(), cacheSize: this.regionCache.size + this.nodeHealthCache.size }; this.emit("healthCheck", healthReport); return healthReport; } // Destroy all resources destroy() { // Destroy all players for (const player of this.players.values()) { player.destroy(); } this.players.clear(); // Destroy all nodes for (const node of this.nodeMap.values()) { node.destroy(); } this.nodeMap.clear(); // Clear caches this.clearCaches(); this.initiated = false; this.emit("destroy"); } } module.exports = { Euralink };