UNPKG

aqualink

Version:

An Lavalink/Nodelink client, focused in pure performance and features

969 lines (893 loc) 29.4 kB
const fs = require('node:fs') const path = require('node:path') const _readline = require('node:readline') const { EventEmitter } = require('node:events') const { AqualinkEvents } = require('./AqualinkEvents') const AquaRecovery = require('./AquaRecovery') const Node = require('./Node') const Player = require('./Player') const Track = require('./Track') const { reportSuppressedError } = require('./Reporting') const { version: pkgVersion } = require('../../package.json') const SEARCH_PREFIX = ':' const EMPTY_ARRAY = Object.freeze([]) const EMPTY_TRACKS_RESPONSE = Object.freeze({ loadType: 'empty', exception: null, playlistInfo: null, pluginInfo: {}, tracks: EMPTY_ARRAY }) const MAX_CONCURRENT_OPS = 10 const BROKEN_PLAYER_TTL = 300000 const FAILOVER_CLEANUP_TTL = 600000 const PLAYER_BATCH_SIZE = 20 const RECONNECT_DELAY = 400 const CACHE_VALID_TIME = 12000 const NODE_TIMEOUT = 30000 const MAX_CACHE_SIZE = 20 const MAX_FAILOVER_QUEUE = 50 const MAX_REBUILD_LOCKS = 100 const WRITE_BUFFER_SIZE = 100 const TRACE_BUFFER_SIZE = 3000 const VOICE_STATE_QUEUE_INTERVAL = 900 const DEFAULT_OPTIONS = Object.freeze({ shouldDeleteMessage: false, defaultSearchPlatform: 'ytsearch', leaveOnEnd: false, restVersion: 'v4', plugins: [], autoResume: true, infiniteReconnects: true, loadBalancer: 'leastLoad', useHttp2: false, debugTrace: false, traceMaxEntries: TRACE_BUFFER_SIZE, traceSink: null, autoRegionMigrate: false, failoverOptions: Object.freeze({ enabled: true, maxRetries: 3, retryDelay: 1000, preservePosition: true, resumePlayback: true, cooldownTime: 5000, maxFailoverAttempts: 5 }), maxQueueSave: 10, maxTracksRestore: 20, trackResolveConcurrency: 4, brokenPlayerStorePath: null }) const _functions = { delay: (ms) => new Promise((r) => { const t = setTimeout(r, ms) t.unref?.() }), noop: () => {}, isUrl: (query) => { if (typeof query !== 'string' || query.length <= 8) return false const q = query.trimStart() return q.startsWith('http://') || q.startsWith('https://') }, formatQuery(query, source) { return this.isUrl(query) ? query : `${source}${SEARCH_PREFIX}${query}` }, makeTrack: (t, requester, node) => new Track(t, requester, node), safeCall(fn) { try { const result = fn() return result?.then ? result.catch(this.noop) : result } catch {} }, parseRequester(str) { if (!str || typeof str !== 'string') return null const i = str.indexOf(':') return i > 0 ? { id: str.substring(0, i), username: str.substring(i + 1) } : null }, unrefTimeout: (fn, ms) => { const t = setTimeout(fn, ms) t.unref?.() return t } } 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 const merged = { ...DEFAULT_OPTIONS, ...options } this.options = merged this.failoverOptions = { ...DEFAULT_OPTIONS.failoverOptions, ...options.failoverOptions } this.shouldDeleteMessage = merged.shouldDeleteMessage this.defaultSearchPlatform = merged.defaultSearchPlatform this.leaveOnEnd = merged.leaveOnEnd this.restVersion = merged.restVersion || 'v4' this.plugins = merged.plugins this.autoResume = merged.autoResume this.infiniteReconnects = merged.infiniteReconnects this.urlFilteringEnabled = merged.urlFilteringEnabled this.restrictedDomains = merged.restrictedDomains || [] this.allowedDomains = merged.allowedDomains || [] this.loadBalancer = merged.loadBalancer this.autoRegionMigrate = merged.autoRegionMigrate this.useHttp2 = merged.useHttp2 this.maxQueueSave = merged.maxQueueSave this.maxTracksRestore = merged.maxTracksRestore this.trackResolveConcurrency = Math.max( 1, Number(merged.trackResolveConcurrency) || 4 ) this.brokenPlayerStorePath = typeof merged.brokenPlayerStorePath === 'string' && merged.brokenPlayerStorePath.trim() ? merged.brokenPlayerStorePath : path.join(process.cwd(), `AquaBrokenPlayers.${process.pid}.jsonl`) this.send = merged.send || this._createDefaultSend() this.debugTrace = !!merged.debugTrace this.traceMaxEntries = Math.max( 100, Number(merged.traceMaxEntries) || TRACE_BUFFER_SIZE ) this.traceSink = typeof merged.traceSink === 'function' ? merged.traceSink : null this._traceBuffer = this.debugTrace ? new Array(this.traceMaxEntries) : null this._traceBufferCount = 0 this._traceBufferIndex = 0 this._traceSeq = 0 this._failoverState = Object.create(null) this._guildLifecycleLocks = new Map() this._brokenPlayers = new Map() this._rebuildLocks = new Set() this._leastUsedNodesCache = null this._leastUsedNodesCacheTime = 0 this._nodeLoadCache = new Map() this._eventHandlers = null this._loading = false this._voiceStateQueue = [] this._voiceStateQueueHead = 0 this._voiceStateQueued = new Set() this._voiceStatePending = new Map() this._voiceStateFlushTimer = null this._lastVoiceStateSendAt = 0 this._recovery = new AquaRecovery(this, { _functions, MAX_CONCURRENT_OPS, BROKEN_PLAYER_TTL, FAILOVER_CLEANUP_TTL, MAX_FAILOVER_QUEUE, MAX_REBUILD_LOCKS, PLAYER_BATCH_SIZE, RECONNECT_DELAY, NODE_TIMEOUT, EMPTY_ARRAY }) if (this.autoResume) this._bindEventHandlers() } _trace(event, data = null) { if (!this.debugTrace) return if ( !this._traceBuffer || this._traceBuffer.length !== this.traceMaxEntries ) { this._traceBuffer = new Array(this.traceMaxEntries) this._traceBufferCount = 0 this._traceBufferIndex = 0 } const resolvedData = typeof data === 'function' ? data() : data const entry = { seq: ++this._traceSeq, at: Date.now(), event, data: resolvedData } this._traceBuffer[this._traceBufferIndex] = entry this._traceBufferIndex = (this._traceBufferIndex + 1) % this.traceMaxEntries if (this._traceBufferCount < this.traceMaxEntries) this._traceBufferCount++ if (this.traceSink) _functions.safeCall(() => this.traceSink(entry)) if (this.listenerCount(AqualinkEvents.Debug) > 0) { this.emit(AqualinkEvents.Debug, 'trace', JSON.stringify(entry)) } } getTrace(limit = 300) { const max = Math.max(1, Number(limit) || 300) if (!this._traceBuffer) return [] const count = Math.min(max, this._traceBufferCount) if (!count) return [] const out = new Array(count) let start = (this._traceBufferIndex - count + this.traceMaxEntries) % this.traceMaxEntries for (let i = 0; i < count; i++) { out[i] = this._traceBuffer[start] start = (start + 1) % this.traceMaxEntries } return out } clearTrace() { if (this._traceBuffer) this._traceBuffer.fill(undefined) this._traceBufferCount = 0 this._traceBufferIndex = 0 } _createDefaultSend() { return (packet) => { const guildId = packet?.d?.guild_id if (!guildId) return const guild = this.client.guilds?.cache?.get?.(guildId) || this.client.cache?.guilds?.get?.(guildId) if (!guild) return const gateway = this.client.gateway if (gateway?.send) gateway.send(gateway.calculateShardId(guildId), packet) else if (guild.shard?.send) guild.shard.send(packet) } } queueVoiceStateUpdate(data) { const guildId = data?.guild_id ? String(data.guild_id) : null if (!guildId) return false this._voiceStatePending.set(guildId, data) if (!this._voiceStateQueued.has(guildId)) { this._voiceStateQueued.add(guildId) this._voiceStateQueue.push(guildId) } if (this.debugTrace) { this._trace('voice.queue.enqueue', { guildId, size: this._voiceStateQueued.size }) } this._scheduleVoiceStateFlush() return true } _scheduleVoiceStateFlush(delay = 0) { if (this._voiceStateFlushTimer) return this._voiceStateFlushTimer = setTimeout( () => { this._voiceStateFlushTimer = null this._flushVoiceStateQueue() }, Math.max(0, delay) ) this._voiceStateFlushTimer.unref?.() } _flushVoiceStateQueue() { if (!this._voiceStateQueued.size) return const now = Date.now() const waitFor = VOICE_STATE_QUEUE_INTERVAL - (now - this._lastVoiceStateSendAt) if (waitFor > 0) { this._scheduleVoiceStateFlush(waitFor) return } let guildId = null while (this._voiceStateQueueHead < this._voiceStateQueue.length) { const candidate = this._voiceStateQueue[this._voiceStateQueueHead] this._voiceStateQueue[this._voiceStateQueueHead] = undefined this._voiceStateQueueHead++ if (candidate && this._voiceStateQueued.has(candidate)) { guildId = candidate this._voiceStateQueued.delete(candidate) break } } if ( this._voiceStateQueueHead > 1024 || this._voiceStateQueueHead > this._voiceStateQueue.length / 2 ) { this._voiceStateQueue = this._voiceStateQueue.slice( this._voiceStateQueueHead ) this._voiceStateQueueHead = 0 } const data = guildId ? this._voiceStatePending.get(guildId) : null if (guildId) this._voiceStatePending.delete(guildId) if (data) { this._lastVoiceStateSendAt = now if (this.debugTrace) { this._trace('voice.queue.send', { guildId, remaining: this._voiceStateQueued.size }) } _functions.safeCall(() => this.send({ op: 4, d: data })) } if (this._voiceStateQueued.size) { this._scheduleVoiceStateFlush(VOICE_STATE_QUEUE_INTERVAL) } } _bindEventHandlers() { this._eventHandlers = { onNodeConnect: (node) => { if (this.debugTrace) this._trace('node.connect', { node: node?.name || node?.host }) this._invalidateCache() this._performCleanup() }, onNodeDisconnect: (node) => { if (this.debugTrace) this._trace('node.disconnect', { node: node?.name || node?.host }) this._invalidateCache() queueMicrotask(() => { this._storeBrokenPlayers(node).catch((error) => reportSuppressedError( this, 'aqua.nodeDisconnect.storeBrokenPlayers', error, { node: node?.name || node?.host } ) ) this._performCleanup() }) }, onNodeReady: (node, { resumed }) => { if (this.debugTrace) { this._trace('node.ready', { node: node?.name || node?.host, resumed: !!resumed, players: this.players.size }) } if (resumed) { const batch = [] for (const player of this.players.values()) { if (player.nodes === node && player.connection) batch.push(player) } if (batch.length) queueMicrotask(() => batch.forEach((p) => { p.connection.resendVoiceUpdate() }) ) return } queueMicrotask(() => { this._rebuildBrokenPlayers(node).catch((error) => reportSuppressedError( this, 'aqua.nodeReady.rebuildBrokenPlayers', error, { node: node?.name || node?.host } ) ) }) } } this.on(AqualinkEvents.NodeConnect, this._eventHandlers.onNodeConnect) this.on(AqualinkEvents.NodeDisconnect, this._eventHandlers.onNodeDisconnect) this.on(AqualinkEvents.NodeReady, this._eventHandlers.onNodeReady) } destroy() { if (this._eventHandlers) { this.off(AqualinkEvents.NodeConnect, this._eventHandlers.onNodeConnect) this.off( AqualinkEvents.NodeDisconnect, this._eventHandlers.onNodeDisconnect ) this.off(AqualinkEvents.NodeReady, this._eventHandlers.onNodeReady) this._eventHandlers = null } this.removeAllListeners() if (this._voiceStateFlushTimer) { clearTimeout(this._voiceStateFlushTimer) this._voiceStateFlushTimer = null } this._voiceStateQueue.length = 0 this._voiceStateQueueHead = 0 this._voiceStateQueued.clear() this._voiceStatePending.clear() for (const id of Array.from(this.nodeMap.keys())) this._destroyNode(id) for (const player of Array.from(this.players.values())) _functions.safeCall(() => player.destroy()) this.players.clear() this._failoverState = Object.create(null) this._guildLifecycleLocks.clear() this._brokenPlayers.clear() this._rebuildLocks.clear() this._nodeLoadCache.clear() this._invalidateCache() _functions.safeCall(() => this._recovery?.dispose?.()) this._recovery = null } get leastUsedNodes() { const now = Date.now() if ( this._leastUsedNodesCache && now - this._leastUsedNodesCacheTime < CACHE_VALID_TIME ) { return this._leastUsedNodesCache } const connected = [] for (const n of this.nodeMap.values()) { if (n.connected) connected.push(n) } let sorted if (this.loadBalancer === 'leastRest') { sorted = connected.sort( (a, b) => (a.rest?.calls || 0) - (b.rest?.calls || 0) ) } else if (this.loadBalancer === 'random') { sorted = connected.sort(() => Math.random() - 0.5) } else { const withLoads = connected.map((n) => ({ node: n, load: this._getNodeLoad(n) })) withLoads.sort((a, b) => a.load - b.load) sorted = withLoads.map((x) => x.node) } this._leastUsedNodesCache = Object.freeze(sorted) this._leastUsedNodesCacheTime = now return this._leastUsedNodesCache } _invalidateCache() { this._leastUsedNodesCache = null this._leastUsedNodesCacheTime = 0 } _getNodeLoad(node) { const id = node.name || node.host const now = Date.now() const cached = this._nodeLoadCache.get(id) if (cached && now - cached.time < 5000) { this._nodeLoadCache.delete(id) this._nodeLoadCache.set(id, cached) return cached.load } const stats = node?.stats if (!stats) return 0 const cores = Math.max(1, stats.cpu?.cores || 1) const reservable = Math.max(1, stats.memory?.reservable || 1) const load = (stats.cpu ? stats.cpu.systemLoad / cores : 0) * 100 + (stats.playingPlayers || 0) * 0.75 + (stats.memory ? stats.memory.used / reservable : 0) * 40 + (node.rest?.calls || 0) * 0.001 if (this._nodeLoadCache.size >= MAX_CACHE_SIZE) { const iterator = this._nodeLoadCache.keys() while (this._nodeLoadCache.size >= MAX_CACHE_SIZE) { const oldest = iterator.next().value if (!oldest) break this._nodeLoadCache.delete(oldest) } } this._nodeLoadCache.set(id, { load, time: now }) return load } async init(clientId) { if (clientId) { const newId = String(clientId) if (this.clientId !== newId) { this.clientId = newId } } if (this.initiated) return this if (!this.clientId) return this await this._loadNodeSessions().catch((error) => reportSuppressedError(this, 'aqua.init.loadNodeSessions', error) ) const results = await Promise.allSettled( this.nodes.map((n) => Promise.race([ this._createNode(n), _functions.delay(NODE_TIMEOUT).then(() => { throw new Error('Timeout') }) ]) ) ) if (!results.some((r) => r.status === 'fulfilled')) throw new Error('No nodes connected') if (this.plugins?.length) { await Promise.allSettled( this.plugins.map((p) => _functions.safeCall(() => p.load(this))) ) } this.initiated = true return this } async _createNode(options) { const id = options.name || options.host this._destroyNode(id) const node = new Node(this, options, this.options) node.players = new Set() this.nodeMap.set(id, node) this._failoverState[id] = { connected: false, failoverInProgress: false, attempts: 0, lastAttempt: 0 } try { await node.connect() this._failoverState[id].connected = true this._failoverState[id].failoverInProgress = false this._invalidateCache() this.emit(AqualinkEvents.NodeCreate, node) return node } catch (error) { this._cleanupNode(id) throw error } } _destroyNode(id) { const node = this.nodeMap.get(id) if (!node) return _functions.safeCall(() => node.destroy(true)) this._cleanupNode(id) } _cleanupNode(id) { const node = this.nodeMap.get(id) if (node) { _functions.safeCall(() => node.removeAllListeners()) _functions.safeCall(() => node.players.clear()) this.nodeMap.delete(id) } delete this._failoverState[id] this._nodeLoadCache.delete(id) this._invalidateCache() } _storeBrokenPlayers(node) { return this._recovery.storeBrokenPlayers(node) } async _rebuildBrokenPlayers(node) { return this._recovery.rebuildBrokenPlayers(node) } async _rebuildPlayer(state, targetNode) { return this._recovery.rebuildPlayer(state, targetNode) } async handleNodeFailover(failedNode) { return this._recovery.handleNodeFailover(failedNode) } async _migratePlayersOptimized(players, nodes) { return this._recovery.migratePlayersOptimized(players, nodes) } async _migratePlayer(player, pickNode) { return this._recovery.migratePlayer(player, pickNode) } _regionMatches(configuredRegion, extractedRegion) { return this._recovery.regionMatches(configuredRegion, extractedRegion) } _findBestNodeForRegion(region) { return this._recovery.findBestNodeForRegion(region) } async movePlayerToNode(guildId, targetNode, reason = 'region') { return this._recovery.movePlayerToNode(guildId, targetNode, reason) } _capturePlayerState(player) { return this._recovery.capturePlayerState(player) } _createPlayerOnNode(targetNode, state) { return this._recovery.createPlayerOnNode(targetNode, state) } _seekAfterTrackStart(player, guildId, position, delay = 50) { return this._recovery.seekAfterTrackStart(player, guildId, position, delay) } async _restorePlayerState(newPlayer, state) { return this._recovery.restorePlayerState(newPlayer, state) } updateVoiceState({ d, t }) { if ( !d?.guild_id || (t !== 'VOICE_STATE_UPDATE' && t !== 'VOICE_SERVER_UPDATE') ) return const player = this.players.get(String(d.guild_id)) if (!player) return if (this.debugTrace) { this._trace('voice.gateway', { guildId: String(d.guild_id), type: t, hasSessionId: !!d.session_id, hasEndpoint: !!d.endpoint, hasChannelId: d.channel_id !== undefined }) } d.txId = player.txId if (t === 'VOICE_STATE_UPDATE') { if (d.user_id !== this.clientId) return if (player.connection) { if (!d.channel_id && player.connection.voiceChannel) { player.connection.setStateUpdate(d) } else { player.connection.sessionId = d.session_id player.connection.setStateUpdate(d) } } } else { player.connection?.setServerUpdate(d) } } fetchRegion(region) { if (!region) return this.leastUsedNodes const lower = region.toLowerCase() const filtered = [] for (const n of this.nodeMap.values()) { if (n.connected && n.regions?.includes(lower)) filtered.push(n) } return Object.freeze( filtered.sort((a, b) => this._getNodeLoad(a) - this._getNodeLoad(b)) ) } createConnection(options) { if (!this.initiated) throw new Error('Aqua not initialized') const existing = this.players.get(String(options.guildId)) if (existing && !existing.destroyed) { if ( options.voiceChannel && existing.voiceChannel !== options.voiceChannel ) { _functions.safeCall(() => existing.connect(options)) } return existing } const candidates = options.region ? this.fetchRegion(options.region) : this.leastUsedNodes if (!candidates.length) throw new Error('No nodes available') return this.createPlayer(candidates[0], options) } createPlayer(node, options) { const guildId = String(options.guildId) const existing = this.players.get(guildId) if (existing) { _functions.safeCall(() => existing.destroy({ preserveMessage: options.preserveMessage || !!options.resuming || false, preserveTracks: !!options.resuming || false }) ) } const player = new Player(this, node, options) this.players.set(guildId, player) if (this.debugTrace) { this._trace('player.create', { guildId, node: node?.name || node?.host, voiceChannel: options.voiceChannel, textChannel: options.textChannel, resuming: !!options.resuming }) } node?.players?.add?.(player) player.once('destroy', () => this._handlePlayerDestroy(player)) player.connect(options) this.emit(AqualinkEvents.PlayerCreate, player) return player } _handlePlayerDestroy(player) { player.nodes?.players?.delete?.(player) const guildId = String(player.guildId) if (this.players.get(guildId) === player) this.players.delete(guildId) if (this.debugTrace) { this._trace('player.destroyed', { guildId, node: player?.nodes?.name || player?.nodes?.host }) } this.emit(AqualinkEvents.PlayerDestroyed, player) } async destroyPlayer(guildId) { const id = String(guildId) const player = this.players.get(id) if (!player) return // Guard against recursive destroy calls triggered by Player.destroy(). this.players.delete(id) await _functions.safeCall(() => player.destroy()) // Fallback cleanup in case the player "destroy" listener was not attached. if (player?.nodes?.players?.has?.(player)) this._handlePlayerDestroy(player) } async resolve({ query, source, requester, nodes }) { if (!this.initiated) throw new Error('Aqua not initialized') const node = this._getRequestNode(nodes) if (!node) throw new Error('No nodes available') const formatted = _functions.formatQuery( query, source || this.defaultSearchPlatform ) const endpoint = `/${this.restVersion}/loadtracks?identifier=${encodeURIComponent(formatted)}` try { const response = await node.rest.makeRequest('GET', endpoint) if ( !response || response.loadType === 'empty' || response.loadType === 'NO_MATCHES' ) return EMPTY_TRACKS_RESPONSE return this._constructResponse(response, requester, node) } catch (error) { throw new Error( error?.name === 'AbortError' ? 'Request timeout' : `Resolve failed: ${error?.message || error}` ) } } _getRequestNode(nodes) { if (!nodes) return this._chooseLeastBusyNode(this.leastUsedNodes) if (nodes instanceof Node) return nodes if (Array.isArray(nodes)) { const candidates = nodes.filter((n) => n?.connected) return this._chooseLeastBusyNode( candidates.length ? candidates : this.leastUsedNodes ) } if (typeof nodes === 'string') { const node = this.nodeMap.get(nodes) return node?.connected ? node : this._chooseLeastBusyNode(this.leastUsedNodes) } throw new TypeError(`Invalid nodes: ${typeof nodes}`) } _chooseLeastBusyNode(nodes) { if (!nodes?.length) return null if (nodes.length === 1) return nodes[0] let best = nodes[0], bestScore = this._getNodeLoad(best) for (let i = 1; i < nodes.length; i++) { const score = this._getNodeLoad(nodes[i]) if (score < bestScore) { best = nodes[i] bestScore = score } } return best } _constructResponse(response, requester, node) { const { loadType, data, pluginInfo: rootPlugin } = response || {} const base = { loadType, exception: null, playlistInfo: null, pluginInfo: rootPlugin || {}, tracks: [] } if (loadType === 'error' || loadType === 'LOAD_FAILED') { base.exception = data || response.exception || null return base } if (loadType === 'track' && data) { base.pluginInfo = data.pluginInfo || data.info?.pluginInfo || rootPlugin || base.pluginInfo base.tracks.push(_functions.makeTrack(data, requester, node)) } else if (loadType === 'playlist' && data) { const info = data.info if (info) { base.playlistInfo = { name: info.name || info.title, thumbnail: data.pluginInfo?.artworkUrl || data.tracks?.[0]?.info?.artworkUrl || null, ...info } } base.pluginInfo = data.pluginInfo || rootPlugin || base.pluginInfo base.tracks = Array.isArray(data.tracks) ? data.tracks.map((t) => _functions.makeTrack(t, requester, node)) : [] } else if (loadType === 'search') { base.tracks = Array.isArray(data) ? data.map((t) => _functions.makeTrack(t, requester, node)) : [] } return base } get(guildId) { const player = this.players.get(String(guildId)) if (!player) throw new Error(`Player not found: ${guildId}`) return player } async search(query, requester, source) { if (!query || !requester) return null try { const { tracks } = await this.resolve({ query, source: source || this.defaultSearchPlatform, requester }) return tracks || null } catch { return null } } async savePlayer(filePath = './AquaPlayers.jsonl') { const lockFile = `${filePath}.lock` const tempFile = `${filePath}.tmp` let ws = null try { await fs.promises.writeFile(lockFile, String(process.pid), { flag: 'wx' }) ws = fs.createWriteStream(tempFile, { encoding: 'utf8', flags: 'w' }) const buffer = [] let drainPromise = Promise.resolve() const nodeSessions = {} for (const node of this.nodeMap.values()) { if (node.sessionId) nodeSessions[node.name] = node.sessionId } buffer.push(JSON.stringify({ type: 'node_sessions', data: nodeSessions })) for (const player of this.players.values()) { const requester = player.requester || player.current?.requester const data = { 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 .toArray() .slice(0, this.maxQueueSave) .map((tr) => tr.uri), r: requester ? `${requester.id}:${requester.username}` : null, vol: player.volume, pa: player.paused, pl: player.playing, nw: player.nowPlayingMessage?.id || null, resuming: true } buffer.push(JSON.stringify(data)) if (buffer.length >= WRITE_BUFFER_SIZE) { const chunk = `${buffer.join('\n')}\n` buffer.length = 0 if (!ws.write(chunk)) { drainPromise = drainPromise.then( () => new Promise((r) => ws.once('drain', r)) ) } } } if (buffer.length) ws.write(`${buffer.join('\n')}\n`) await drainPromise await new Promise((resolve, reject) => ws.end((err) => (err ? reject(err) : resolve())) ) ws = null await fs.promises.rename(tempFile, filePath) } catch (error) { console.error(`[Aqua/Autoresume]Error saving players:`, error) this.emit(AqualinkEvents.Error, null, error) if (ws) _functions.safeCall(() => ws.destroy()) await fs.promises.unlink(tempFile).catch(_functions.noop) } finally { if (ws) _functions.safeCall(() => ws.destroy()) await fs.promises.unlink(lockFile).catch(_functions.noop) } } async loadPlayers(filePath = './AquaPlayers.jsonl') { return this._recovery.loadPlayers(filePath) } async _restorePlayer(p) { return this._recovery.restorePlayer(p) } async _waitForFirstNode(timeout = NODE_TIMEOUT) { return this._recovery.waitForFirstNode(timeout) } _performCleanup() { return this._recovery.performCleanup() } async _loadNodeSessions(filePath = './AquaPlayers.jsonl') { return this._recovery.loadNodeSessions(filePath) } } module.exports = Aqua