UNPKG

aqualink

Version:

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

1,238 lines (1,120 loc) 34.5 kB
const { EventEmitter } = require('node:events') const { AqualinkEvents } = require('./AqualinkEvents') const Connection = require('./Connection') const Filters = require('./Filters') const PlayerLifecycle = require('./PlayerLifecycle') const { attachPlayerLifecycleState } = require('./PlayerLifecycleState') const { reportSuppressedError } = require('./Reporting') const { spAutoPlay, scAutoPlay } = require('../handlers/autoplay') const Queue = require('./Queue') const PLAYER_STATE = Object.freeze({ IDLE: 0, CONNECTING: 1, READY: 2, DISCONNECTING: 3, DESTROYED: 4 }) const LOOP_MODES = Object.freeze({ NONE: 0, TRACK: 1, QUEUE: 2 }) const LOOP_MODE_NAMES = Object.freeze(['none', 'track', 'queue']) const EVENT_HANDLERS = Object.freeze({ TrackStartEvent: 'trackStart', TrackEndEvent: 'trackEnd', TrackExceptionEvent: 'trackError', TrackStuckEvent: 'trackStuck', TrackChangeEvent: 'trackChange', WebSocketClosedEvent: 'socketClosed', LyricsLineEvent: 'lyricsLine', LyricsFoundEvent: 'lyricsFound', VolumeChangedEvent: 'volumeChanged', FiltersChangedEvent: 'filtersChanged', SeekEvent: 'seekEvent', PlayerCreatedEvent: 'playerCreated', PauseEvent: 'pauseEvent', PlayerConnectedEvent: 'playerConnected', PlayerDestroyedEvent: 'playerDestroyed', LyricsNotFoundEvent: 'lyricsNotFound', MixStartedEvent: 'mixStarted', MixEndedEvent: 'mixEnded' }) const WATCHDOG_INTERVAL = 15000 const VOICE_DOWN_THRESHOLD = 10000 const VOICE_ABANDON_MULTIPLIER = 12 const RECONNECT_MAX = 15 const MUTE_TOGGLE_DELAY = 300 const SEEK_DELAY = 800 const PAUSE_DELAY = 1200 const VOICE_TRACE_INTERVAL = 15000 const PLAYER_UPDATE_SILENCE_THRESHOLD = 45000 const VOICE_FORCE_DESTROY_MS = 15 * 60 * 1000 const RETRY_BACKOFF_BASE = 1500 const RETRY_BACKOFF_MAX = 5000 const PREVIOUS_TRACKS_SIZE = 50 const PREVIOUS_IDS_MAX = 20 const AUTOPLAY_MAX = 3 const BATCHER_POOL_SIZE = 2 const INVALID_LOADS = new Set(['error', 'empty', 'LOAD_FAILED', 'NO_MATCHES']) const _functions = { clamp(v) { const n = +v return Number.isNaN(n) ? 100 : n < 0 ? 0 : n > 1000 ? 1000 : n }, randIdx: (len) => (Math.random() * len) | 0, toId: (v) => v?.id || v || null, isNum: (v) => typeof v === 'number' && !Number.isNaN(v), isInvalidLoad: (r) => !r?.tracks?.length || INVALID_LOADS.has(r.loadType), safeDel: (msg) => msg?.delete?.().catch(() => {}), createTimer(fn, delay, timerSet, unref = true) { const t = setTimeout(() => { timerSet?.delete(t) fn() }, delay) if (unref) t.unref?.() timerSet?.add(t) return t }, clearTimers(set) { if (!set) return for (const t of set) clearTimeout(t) set.clear() }, safeCall(fn) { try { return fn?.() } catch {} return null }, emitAquaError(aqua, error) { if (!aqua?.listenerCount) return try { if (aqua.listenerCount(AqualinkEvents.Error) > 0) { aqua.emit(AqualinkEvents.Error, error) } } catch {} }, emitIfActive(player, event, ...args) { if (!player.destroyed) player.aqua.emit(event, player, ...args) } } class MicrotaskUpdateBatcher { constructor(player) { this.player = player this.updates = null this.scheduled = false } batch(data, immediate) { if (!this.player) return Promise.reject(new Error('Player destroyed')) this.updates = Object.assign(this.updates || {}, data) if (immediate || 'track' in data || 'paused' in data || 'position' in data) return this._flush() if (!this.scheduled) { this.scheduled = true queueMicrotask(() => this._flush()) } return Promise.resolve() } _flush() { const { player: p, updates: u } = this this.updates = null this.scheduled = false if (!u || !p || p.destroyed || p.state === PLAYER_STATE.DISCONNECTING) return Promise.resolve() return p.updatePlayer(u).catch((err) => { _functions.emitAquaError( p.aqua, new Error(`Update error: ${err.message}`) ) }) } reset() { this.updates = null this.scheduled = false this.player = null } } const batcherPool = { pool: [], acquire(player) { const b = this.pool.pop() if (b) { b.player = player return b } return new MicrotaskUpdateBatcher(player) }, release(batcher) { if (this.pool.length < BATCHER_POOL_SIZE && batcher) { batcher.reset() this.pool.push(batcher) } } } class CircularBuffer { constructor(size) { this.buffer = new Array(size) this.size = size this.index = 0 this.count = 0 } push(item) { if (!item) return this.buffer[this.index] = item this.index = (this.index + 1) % this.size if (this.count < this.size) this.count++ } getLast() { return this.count ? this.buffer[(this.index - 1 + this.size) % this.size] : null } clear() { if (!this.count) return this.buffer.fill(undefined) this.count = this.index = 0 } } class Player extends EventEmitter { static LOOP_MODES = LOOP_MODES static EVENT_HANDLERS = EVENT_HANDLERS constructor(aqua, nodes, options) { super() if (!aqua || !nodes || !options.guildId) throw new TypeError('Missing required parameters') this.aqua = aqua this.nodes = nodes this.guildId = String(options.guildId) this.textChannel = options.textChannel this.voiceChannel = options.voiceChannel this.playing = this.paused = this.connected = this.destroyed = false this.state = PLAYER_STATE.IDLE this.txId = 0 this.isAutoplayEnabled = this.isAutoplay = false this.autoplaySeed = this.current = this.nowPlayingMessage = null this.position = this.timestamp = this.ping = 0 this.deaf = options.deaf !== false this.mute = !!options.mute this.autoplayRetries = this.reconnectionRetries = 0 this._voiceDownSince = 0 attachPlayerLifecycleState(this, { resuming: !!options.resuming }) this._voiceWatchdogTimer = null this._pendingTimers = new Set() this._reconnectTimers = null this._reconnectNonce = 0 this._dataStore = null this.volume = _functions.clamp(options.defaultVolume || 100) this.loop = this._parseLoop(options.loop) const aquaOpts = aqua.options || {} this.shouldDeleteMessage = !!aquaOpts.shouldDeleteMessage this.leaveOnEnd = !!aquaOpts.leaveOnEnd this.connection = new Connection(this) this.filters = new Filters(this) this.queue = new Queue() this.previousIdentifiers = new Set() this.previousTracks = new CircularBuffer(PREVIOUS_TRACKS_SIZE) this._updateBatcher = batcherPool.acquire(this) this._lifecycleController = new PlayerLifecycle(this, { _functions, PLAYER_STATE, VOICE_TRACE_INTERVAL, PLAYER_UPDATE_SILENCE_THRESHOLD, VOICE_DOWN_THRESHOLD, VOICE_ABANDON_MULTIPLIER, VOICE_FORCE_DESTROY_MS, RECONNECT_MAX, MUTE_TOGGLE_DELAY, SEEK_DELAY, PAUSE_DELAY, RETRY_BACKOFF_BASE, RETRY_BACKOFF_MAX }) this._voiceRequestAt = 0 this._voiceRequestChannel = null this._suppressResumeUntil = 0 this._lastVoiceUpTraceAt = 0 this._lastPlayerUpdateAt = Date.now() this._voiceRecoverySeq = 0 this._activeVoiceRecoveryToken = 0 this._voiceRecoveryReason = null this._bindEvents() this._startWatchdog() } _parseLoop(loop) { if (typeof loop === 'string') { const idx = LOOP_MODE_NAMES.indexOf(loop) return idx >= 0 && idx <= 2 ? idx : 0 } return loop >= 0 && loop <= 2 ? loop : 0 } _bindEvents() { this._boundPlayerUpdate = this._handlePlayerUpdate.bind(this) this._boundEvent = this._handleEvent.bind(this) this.on('playerUpdate', this._boundPlayerUpdate) this.on('event', this._boundEvent) } _startWatchdog() { this._voiceWatchdogTimer = setInterval( () => this._voiceWatchdog(), WATCHDOG_INTERVAL ) this._voiceWatchdogTimer.unref?.() } _createTimer(fn, delay, unref = true) { return _functions.createTimer(fn, delay, this._pendingTimers, unref) } _delay(ms) { return new Promise((r) => this._createTimer(r, ms)) } _claimVoiceRecovery(reason = 'unknown') { const token = ++this._voiceRecoverySeq this._activeVoiceRecoveryToken = token this._voiceRecoveryReason = reason return token } _isVoiceRecoveryActive(token) { return ( !!token && !this.destroyed && this._activeVoiceRecoveryToken === token ) } _clearVoiceRecovery(token = this._activeVoiceRecoveryToken, reason = null) { if (!token || this._activeVoiceRecoveryToken !== token) return false this._activeVoiceRecoveryToken = 0 this._voiceRecoveryReason = reason return true } _handlePlayerUpdate(packet) { return this._lifecycleController.handlePlayerUpdate(packet) } async _handleEvent(payload) { if (this.destroyed || !payload?.type) return const handler = EVENT_HANDLERS[payload.type] if (typeof this[handler] !== 'function') { this.aqua.emit( AqualinkEvents.NodeError, this, new Error(`Unknown event: ${payload.type}`) ) return } try { await this[handler](this, this.current, payload) } catch (error) { _functions.emitAquaError(this.aqua, error) } } get previous() { return this.previousTracks?.getLast() || null } get currenttrack() { return this.current } getQueue() { return this.queue } batchUpdatePlayer(data, immediate) { return this._updateBatcher.batch(data, immediate) } setAutoplay(enabled) { this.isAutoplayEnabled = !!enabled this.autoplayRetries = 0 return this } async play(track, options = {}) { if (this.destroyed || !this.queue) return this if (options != null && typeof options !== 'object') { throw new TypeError( `Player.play(): options must be an object, got ${typeof options}` ) } let item = track if (!item) { if (!this.queue.size) return this item = this.queue.dequeue() } if (!item) return this try { let resolvedItem = null if (item?.track) { resolvedItem = item } else if (typeof item?.resolve === 'function') { resolvedItem = await item.resolve(this.aqua) } this.current = resolvedItem if (this.destroyed) return this if (!this.current?.track) { this.current = null this.playing = false if (this.aqua?.debugTrace) { this.aqua._trace('player.play.unresolved', { guildId: this.guildId, reconnecting: !!this._reconnecting, resuming: !!this._resuming, voiceRecovering: !!this._voiceRecovering }) } if (this._reconnecting || this._resuming || this._voiceRecovering) return this throw new Error('Failed to resolve track') } this.playing = true this.paused = !!options.paused this.position = options.startTime || 0 if (this.aqua?.debugTrace) { this.aqua._trace('player.play', { guildId: this.guildId, paused: this.paused, startTime: this.position, hasTrack: !!this.current?.track }) } if (this.destroyed || !this._updateBatcher) return this if ( this.voiceChannel && !this.connected && !this._reconnecting && !this._voiceRecovering ) { this._deferredStart = true const recoveryToken = this._claimVoiceRecovery('play_deferred') if (this.aqua?.debugTrace) { this.aqua._trace('player.play.deferred', { guildId: this.guildId, reason: 'voice_not_connected' }) } const now = Date.now() if ( now - (this._voiceRequestAt || 0) >= 1200 && this._isVoiceRecoveryActive(recoveryToken) ) { this._voiceRequestAt = now if (this._isVoiceRecoveryActive(recoveryToken)) this.connection?._requestVoiceState?.() if (this._isVoiceRecoveryActive(recoveryToken)) this.connection?.resendVoiceUpdate?.(true) if (this._isVoiceRecoveryActive(recoveryToken)) _functions.safeCall(() => this.connect({ guildId: this.guildId, voiceChannel: this.voiceChannel, deaf: this.deaf, mute: this.mute }) ) } return this } if ( this.aqua?.autoRegionMigrate && !this._resuming && !this.connection?.endpoint ) { this._deferredStart = true if (this.aqua?.debugTrace) { this.aqua._trace('player.play.deferred', { guildId: this.guildId, reason: 'awaiting_voice_server_update' }) } return this } const updateData = { track: { encoded: this.current.track }, paused: this.paused } if (this.position > 0) updateData.position = this.position this._deferredStart = false await this.batchUpdatePlayer(updateData, true).catch((err) => { if (!this.destroyed) _functions.emitAquaError(this.aqua, err) }) } catch (error) { if ( !this.destroyed && !this._reconnecting && !this._resuming && !this._voiceRecovering ) { _functions.emitAquaError(this.aqua, error) } if ( this.queue?.size && !track && !this._reconnecting && !this._resuming && !this._voiceRecovering ) return this.play() } return this } connect(options = {}) { if (this.destroyed) throw new Error(`Cannot connect destroyed player (guild=${this.guildId})`) const voiceChannel = _functions.toId( options.voiceChannel || this.voiceChannel ) if (!voiceChannel) throw new TypeError('Voice channel required') this.deaf = options.deaf !== undefined ? !!options.deaf : true this.mute = !!options.mute this.destroyed = false this.state = PLAYER_STATE.CONNECTING this.txId++ this._voiceRequestChannel = voiceChannel this.voiceChannel = voiceChannel this._voiceDownSince = 0 this.send({ guild_id: this.guildId, channel_id: voiceChannel, self_deaf: this.deaf, self_mute: this.mute }) if (this.aqua?.debugTrace) { this.aqua._trace('player.connect.request', { guildId: this.guildId, txId: this.txId, voiceChannel, deaf: this.deaf, mute: this.mute }) } return this } _shouldAttemptVoiceRecovery() { if ( this.nodes?.info?.isNodelink || this.destroyed || !this.voiceChannel || this.connected || this._reconnecting || this._voiceRecovering ) return false if ( !this._voiceDownSince || Date.now() - this._voiceDownSince < VOICE_DOWN_THRESHOLD ) return false return this.reconnectionRetries < RECONNECT_MAX } async _voiceWatchdog() { return this._lifecycleController.voiceWatchdog() } destroy(options = {}) { if (this.destroyed) return this const { preserveClient = true, skipRemote = false, preserveMessage = false, preserveReconnecting = false, preserveTracks = false, abortSignal = null } = options this._reconnectNonce++ this.destroyed = true this._clearVoiceRecovery(undefined, 'destroyed') if (this.aqua?.debugTrace) { this.aqua._trace('player.destroy', { guildId: this.guildId, skipRemote: !!skipRemote, preserveTracks: !!preserveTracks, preserveReconnecting: !!preserveReconnecting }) } this.emit('destroy') if (this._voiceWatchdogTimer) { clearInterval(this._voiceWatchdogTimer) this._voiceWatchdogTimer = null } _functions.clearTimers(this._pendingTimers) this._pendingTimers = null if (this._reconnectTimers) { _functions.clearTimers(this._reconnectTimers) this._reconnectTimers = null } this.connected = this.playing = this.paused = this.isAutoplay = false this._deferredStart = false this.state = PLAYER_STATE.DESTROYED this.autoplayRetries = this.reconnectionRetries = 0 if (!preserveReconnecting) this._reconnecting = false this._lastVoiceChannel = this.voiceChannel this._lastTextChannel = this.textChannel this.voiceChannel = null this._isActivelyReconnecting = false if ( this.shouldDeleteMessage && this.nowPlayingMessage && !preserveMessage ) { _functions.safeDel(this.nowPlayingMessage) this.nowPlayingMessage = null } if (this._boundPlayerUpdate) this.removeListener('playerUpdate', this._boundPlayerUpdate) if (this._boundEvent) this.removeListener('event', this._boundEvent) this._boundPlayerUpdate = this._boundEvent = null this.removeAllListeners() if (this._updateBatcher) { batcherPool.release(this._updateBatcher) this._updateBatcher = null } if (this.filters) { try { this.filters.destroy() } catch (error) { reportSuppressedError(this, 'player.destroy.filters', error, { guildId: this.guildId }) } } this.previousTracks?.clear() this.previousTracks = null this.previousIdentifiers?.clear() this.previousIdentifiers = null if (this.queue) { for ( let i = this.queue._head || 0; i < (this.queue._items?.length || 0); i++ ) { const t = this.queue._items[i] if (t && typeof t.dispose === 'function') { try { t.dispose() } catch {} } } this.queue.clear() } this.queue = null // ML-1: Clear _dataStore to prevent unbounded growth this._dataStore?.clear() this._dataStore = null if ( this.current?.dispose && !this.aqua?.options?.autoResume && !preserveTracks ) this.current.dispose() if (this.connection) { try { this.connection.destroy() } catch (error) { reportSuppressedError(this, 'player.destroy.connection', error, { guildId: this.guildId }) } } this.connection = this.filters = this.current = this.autoplaySeed = this._lifecycleController = null if (!skipRemote) { try { if (abortSignal?.aborted) { if (this.aqua?.debugTrace) { this.aqua._trace('player.destroy.aborted', { guildId: this.guildId, reason: 'abort_signal_already_set' }) } } else { this.send({ guild_id: this.guildId, channel_id: null }) this.aqua?.destroyPlayer?.(this.guildId) if (this.nodes?.connected) this.nodes.rest ?.destroyPlayer(this.guildId, abortSignal) .catch((error) => { reportSuppressedError(this, 'player.destroy.remote', error, { guildId: this.guildId }) }) } } catch (error) { reportSuppressedError(this, 'player.destroy.gateway', error, { guildId: this.guildId }) } } if (!preserveClient) { try { this.aqua?.removeListener?.('playerUpdate', this._boundPlayerUpdate) } catch {} this.aqua = this.nodes = null } return this } pause(paused) { if (this.destroyed) return this if (paused != null && typeof paused !== 'boolean') { throw new TypeError( `Player.pause(): paused must be a boolean, got ${typeof paused}` ) } if (this.paused === !!paused) return this this.paused = !!paused this.batchUpdatePlayer({ paused: this.paused }, true).catch((error) => reportSuppressedError(this, 'player.pause', error, { guildId: this.guildId, paused: this.paused }) ) return this } seek(position) { if (position == null || !_functions.isNum(position)) { throw new TypeError( `Player.seek(): position must be a non-negative number, got ${typeof position}` ) } if (this.destroyed || !this.playing) return this const len = this.current?.info?.length || 0 const clamped = len ? Math.min(Math.max(position, 0), len) : Math.max(position, 0) this.position = clamped this.batchUpdatePlayer({ position: clamped }, true).catch((error) => reportSuppressedError(this, 'player.seek', error, { guildId: this.guildId, position: clamped }) ) return this } async getActiveMixer(guildId) { if (this.destroyed) return null return await this.nodes.rest.getActiveMixer(guildId) } async updateMixerVolume(guildId, mix, volume) { if (this.destroyed) return null return await this.nodes.rest.updateMixerVolume(guildId, mix, volume) } async removeMixer(guildId, mix) { if (this.destroyed) return null return await this.nodes.rest.removeMixer(guildId, mix) } async addMixer(guildId, options) { if (this.destroyed) return null if (options.identifier && !options.encoded) { try { const resolved = await this.aqua.resolve({ query: options.identifier, requester: options.requester || this.current?.requester }) if (resolved?.tracks?.[0]) { const track = resolved.tracks[0] options = { ...options, encoded: track.track || track.encoded, userData: options.userData } } else { throw new Error('Failed to resolve track identifier') } } catch (error) { throw new Error(`Failed to resolve track: ${error.message}`) } } return await this.nodes.rest.addMixer(guildId, options) } stop() { if (this.destroyed || !this.playing) return this this.playing = this.paused = false this.position = 0 this.batchUpdatePlayer( { track: { encoded: null }, paused: this.paused }, true ).catch((error) => reportSuppressedError(this, 'player.stop', error, { guildId: this.guildId }) ) return this } setVolume(volume) { if ( volume == null || (typeof volume !== 'number' && typeof volume !== 'string') ) { throw new TypeError( `Player.setVolume(): volume must be a number, got ${typeof volume}` ) } const vol = _functions.clamp(volume) if (this.destroyed || this.volume === vol) return this this.volume = vol this.batchUpdatePlayer({ volume: vol }).catch((error) => reportSuppressedError(this, 'player.setVolume', error, { guildId: this.guildId, volume: vol }) ) return this } setLoop(mode) { if (this.destroyed) return this const idx = typeof mode === 'string' ? LOOP_MODE_NAMES.indexOf(mode) : mode if (idx < 0 || idx > 2) throw new Error('Invalid loop mode') this.loop = idx return this } setTextChannel(channel) { if (this.destroyed) return this const id = _functions.toId(channel) if (!id) throw new TypeError('Invalid text channel') this.textChannel = id return this } setVoiceChannel(channel) { if (this.destroyed) return this const id = _functions.toId(channel) if (!id) throw new TypeError('Voice channel required') if (this.connected && id === _functions.toId(this.voiceChannel)) return this this.voiceChannel = id this.connect({ deaf: this.deaf, guildId: this.guildId, voiceChannel: id, mute: this.mute }) return this } disconnect() { if (this.destroyed || !this.connected) return this this.connected = false this.voiceChannel = null this.send({ guild_id: this.guildId, channel_id: null }) return this } shuffle() { if (this.destroyed || !this.queue?.size) return this this.queue.shuffle() return this } replay() { return this.seek(0) } skip(target) { if (this.destroyed || !this.playing) return this if (target === undefined || target === null) return this.stop() if (typeof target === 'number') { const idx = target | 0 if (idx <= 0) return this.stop() if (!this.queue?.size || idx >= this.queue.size) return this.stop() for (let i = 0; i < idx; i++) this.queue.dequeue() return this.stop() } const targetId = _functions.toId(target) if (targetId && this.queue?.size) { const arr = this.queue.toArray() const idx = arr.findIndex( (t) => _functions.toId(t) === targetId || _functions.toId(t?.info?.identifier) === targetId ) if (idx > 0) { for (let i = 0; i < idx; i++) this.queue.dequeue() } } return this.stop() } async getLyrics(options = {}) { if (this.destroyed || !this.nodes?.rest) return null const { query, useCurrentTrack = true, skipTrackSource = false } = options if (query) return this.nodes.rest.getLyrics({ track: { info: { title: query } }, skipTrackSource }) if (useCurrentTrack && this.playing && this.current) { const info = this.current.info return this.nodes.rest.getLyrics({ track: { info, encoded: this.current.track, identifier: info.identifier, guild_id: this.guildId }, skipTrackSource }) } return null } getLoadLyrics(encodedTrack) { return this.destroyed || !this.nodes?.rest ? null : this.nodes.rest.getLoadLyrics(encodedTrack) } subscribeLiveLyrics() { return this.destroyed ? Promise.reject(new Error('Player destroyed')) : this.nodes?.rest?.subscribeLiveLyrics(this.guildId, false) } unsubscribeLiveLyrics() { return this.destroyed ? Promise.reject(new Error('Player destroyed')) : this.nodes?.rest?.unsubscribeLiveLyrics(this.guildId) } async autoplay() { if ( this.destroyed || !this.isAutoplayEnabled || !this.previous || this.queue?.size ) return this const prev = this.previous const info = prev?.info if (!info?.sourceName || !info.identifier) return this const { sourceName, identifier, uri, author } = info this.isAutoplay = true if (sourceName === 'spotify' && info.identifier) { this.previousIdentifiers.add(info.identifier) if (this.previousIdentifiers.size > PREVIOUS_IDS_MAX) { this.previousIdentifiers.delete( this.previousIdentifiers.values().next().value ) } if (!this.autoplaySeed) { this.autoplaySeed = { trackId: identifier, artistIds: Array.isArray(author) ? author.join(',') : author } } } for ( let i = 0; !this.destroyed && i < AUTOPLAY_MAX && this.queue && !this.queue.size; i++ ) { try { const track = await this._getAutoplayTrack( sourceName, identifier, uri, prev.requester ) if (this.destroyed || !this.queue) return this if (track?.info?.title) { this.autoplayRetries = 0 track.requester = prev.requester || { id: 'Unknown' } this.queue.add(track) await this.play() return this } } catch (err) { if (this.destroyed) return this _functions.emitAquaError( this.aqua, new Error(`Autoplay ${i + 1} fail: ${err.message}`) ) } } if (this.destroyed) return this this.aqua?.emit( AqualinkEvents.AutoplayFailed, this, new Error('Max retries') ) this.stop() return this } async liveLyrics(guildId, state) { if (state) return await this.nodes.rest.subscribeLiveLyrics(guildId) else return await this.nodes.rest.unsubscribeLiveLyrics(guildId) } async _getAutoplayTrack(sourceName, identifier, uri, requester) { if (sourceName === 'youtube' || sourceName === 'ytmusic') { const res = await this.aqua.resolve({ query: `https://www.youtube.com/watch?v=${identifier}&list=RD${identifier}`, source: 'ytmsearch', requester }) return _functions.isInvalidLoad(res) ? null : res.tracks[_functions.randIdx(res.tracks.length)] } if (sourceName === 'soundcloud') { const scRes = await scAutoPlay(uri) if (!scRes?.length) return null const res = await this.aqua.resolve({ query: scRes[0], source: 'scsearch', requester }) return _functions.isInvalidLoad(res) ? null : res.tracks[_functions.randIdx(res.tracks.length)] } if (sourceName === 'spotify') { const res = await spAutoPlay( this.autoplaySeed, this, requester, Array.from(this.previousIdentifiers) ) return res?.length ? res[_functions.randIdx(res.length)] : null } return null } trackStart(_player, _track, payload = {}) { if (this.destroyed) return const startedTrack = this.current || _track if (!startedTrack) return if (!this.current) this.current = startedTrack this.playing = true this.paused = false this.aqua.emit(AqualinkEvents.TrackStart, this, startedTrack, { ...payload, resumed: this._resuming }) this._resuming = false } async trackEnd(_player, track, payload) { if (this.destroyed) return const reason = payload?.reason const isFailure = reason === 'loadFailed' const isCleanup = reason === 'cleanup' const isReplaced = reason === 'replaced' if (track) this.previousTracks.push(track) if (isReplaced) return if (this.shouldDeleteMessage && !this._reconnecting && !this._resuming) _functions.safeDel(this.nowPlayingMessage) if (!isReplaced) this.current = null if (isFailure || isCleanup) { if (!this.queue.size || isCleanup) { this.clearData({ preserveTracks: this._reconnecting || this._resuming }) this.aqua.emit(AqualinkEvents.QueueEnd, this) } else { this.aqua.emit(AqualinkEvents.TrackEnd, this, track, reason) await this.play() } return } if (track && reason === 'finished') { if (this.loop === LOOP_MODES.TRACK) { this.aqua.emit(AqualinkEvents.TrackEnd, this, track, reason) await this.play(track) return } if (this.loop === LOOP_MODES.QUEUE) { this.queue.add(track) } } if (this.queue.size) { if (!isReplaced) this.aqua.emit(AqualinkEvents.TrackEnd, this, track, reason) await this.play() } else if (this.isAutoplayEnabled && !isReplaced) { await this.autoplay() } else { this.playing = false if (this.leaveOnEnd && !this.destroyed) { this.clearData({ preserveTracks: this._reconnecting || this._resuming }) this.destroy() } this.aqua.emit(AqualinkEvents.QueueEnd, this) } } trackError(_player, track, payload) { if (this.destroyed) return this.aqua.emit(AqualinkEvents.TrackError, this, track, payload) this.stop() } trackStuck(_player, track, payload) { if (this.destroyed) return this.aqua.emit(AqualinkEvents.TrackStuck, this, track, payload) this.stop() } trackChange(_p, t, payload) { _functions.emitIfActive(this, AqualinkEvents.TrackChange, t, payload) } lyricsLine(_p, t, payload) { _functions.emitIfActive(this, AqualinkEvents.LyricsLine, t, payload) } volumeChanged(_p, t, payload) { _functions.emitIfActive(this, AqualinkEvents.VolumeChanged, t, payload) } filtersChanged(_p, t, payload) { _functions.emitIfActive(this, AqualinkEvents.FiltersChanged, t, payload) } seekEvent(_p, t, payload) { _functions.emitIfActive(this, AqualinkEvents.Seek, t, payload) } lyricsFound(_p, t, payload) { _functions.emitIfActive(this, AqualinkEvents.LyricsFound, t, payload) } lyricsNotFound(_p, t, payload) { _functions.emitIfActive(this, AqualinkEvents.LyricsNotFound, t, payload) } playerCreated(_p, _t, payload) { _functions.emitIfActive(this, AqualinkEvents.PlayerCreated, payload) } playerConnected(_p, _t, payload) { _functions.emitIfActive(this, AqualinkEvents.PlayerConnected, payload) } playerDestroyed(_p, _t, payload) { _functions.emitIfActive(this, AqualinkEvents.PlayerDestroyed, payload) } pauseEvent(_p, _t, payload) { _functions.emitIfActive(this, AqualinkEvents.PauseEvent, payload) } mixStarted(_p, t, payload) { _functions.emitIfActive(this, AqualinkEvents.MixStarted, t, payload) } mixEnded(_p, t, payload) { _functions.emitIfActive(this, AqualinkEvents.MixEnded, t, payload) } async _attemptVoiceResume(abortSignal) { return this._lifecycleController.attemptVoiceResume(abortSignal) } async socketClosed(_player, _track, payload) { return this._lifecycleController.socketClosed(_player, _track, payload) } send(data) { try { if (this.aqua?.queueVoiceStateUpdate) { return this.aqua.queueVoiceStateUpdate(data) } else { this.aqua.send({ op: 4, d: data }) return true } } catch (err) { _functions.emitAquaError( this.aqua, new Error(`Send fail (guild=${this.guildId}): ${err.message}`) ) return false } } set(key, value) { if (this.destroyed) return if (!this._dataStore) { this._dataStore = new Map() } this._dataStore.set(key, value) } get(key) { return this._dataStore?.get(key) } clearData(options = {}) { const { preserveTracks = false } = options this.previousTracks?.clear() this._dataStore?.clear() this.previousIdentifiers?.clear() if (this.current?.dispose && !preserveTracks) this.current.dispose() this.current = null this.position = this.timestamp = 0 this.queue?.clear() return this } updatePlayer(data) { return this.nodes.rest.updatePlayer({ guildId: this.guildId, data }) } _flushDeferredPlay() { return this._lifecycleController.flushDeferredPlay() } cleanup() { if (!this.playing && !this.paused && !this.queue?.size) this.destroy() } } module.exports = Player