UNPKG

aqualink

Version:

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

585 lines (547 loc) 19.2 kB
const { AqualinkEvents } = require('./AqualinkEvents') const { reportSuppressedError } = require('./Reporting') class PlayerLifecycle { constructor(player, deps) { this.player = player this._functions = deps._functions this.PLAYER_STATE = deps.PLAYER_STATE this.VOICE_TRACE_INTERVAL = deps.VOICE_TRACE_INTERVAL this.PLAYER_UPDATE_SILENCE_THRESHOLD = deps.PLAYER_UPDATE_SILENCE_THRESHOLD this.VOICE_DOWN_THRESHOLD = deps.VOICE_DOWN_THRESHOLD this.VOICE_ABANDON_MULTIPLIER = deps.VOICE_ABANDON_MULTIPLIER this.VOICE_FORCE_DESTROY_MS = deps.VOICE_FORCE_DESTROY_MS this.RECONNECT_MAX = deps.RECONNECT_MAX this.MUTE_TOGGLE_DELAY = deps.MUTE_TOGGLE_DELAY this.SEEK_DELAY = deps.SEEK_DELAY this.PAUSE_DELAY = deps.PAUSE_DELAY this.RETRY_BACKOFF_BASE = deps.RETRY_BACKOFF_BASE this.RETRY_BACKOFF_MAX = deps.RETRY_BACKOFF_MAX } handlePlayerUpdate(packet) { const player = this.player if (player.destroyed || !packet?.state) return const s = packet.state player._lastPlayerUpdateAt = Date.now() const wasConnected = player.connected player.position = this._functions.isNum(s.position) ? s.position : 0 player.connected = !!s.connected player.ping = this._functions.isNum(s.ping) ? s.ping : 0 player.timestamp = this._functions.isNum(s.time) ? s.time : Date.now() if (player.destroyed) return if (!player.connected) { if (wasConnected || !player._voiceDownSince) { if (player.aqua?.debugTrace) { player.aqua._trace('player.voice.down', { guildId: player.guildId, reconnecting: !!player._reconnecting, recovering: !!player._voiceRecovering }) } } if ( !player._voiceDownSince && !player._reconnecting && !player._voiceRecovering ) { player._voiceDownSince = Date.now() const recoveryToken = player._claimVoiceRecovery('player_update_resume') player._createTimer(() => { if ( !player._isVoiceRecoveryActive(recoveryToken) || player.connected || player.destroyed || player._reconnecting || player._voiceRecovering || player.nodes?.info?.isNodelink || !player.voiceChannel ) return player.connection.attemptResume() }, 1000) } } else { player._voiceDownSince = 0 player.state = this.PLAYER_STATE.READY player._clearVoiceRecovery(undefined, 'connected') player._voiceRecovering = false if (player._reconnecting && !player._isActivelyReconnecting) { player._reconnecting = false } if (player._resuming) { player._resuming = false } const now = Date.now() if ( !wasConnected || now - player._lastVoiceUpTraceAt >= this.VOICE_TRACE_INTERVAL ) { player._lastVoiceUpTraceAt = now if (player.aqua?.debugTrace) { player.aqua._trace('player.voice.up', { guildId: player.guildId, ping: player.ping }) } } this.flushDeferredPlay() } player.aqua.emit(AqualinkEvents.PlayerUpdate, player, packet) } async voiceWatchdog() { const player = this.player if (player.destroyed || !player.connection) return const now = Date.now() const silentPlayer = player.playing && !player.paused && !!player.voiceChannel && !player._reconnecting && !player._voiceRecovering && now - (player._lastPlayerUpdateAt || 0) >= this.PLAYER_UPDATE_SILENCE_THRESHOLD if (silentPlayer) { const silenceMs = now - (player._lastPlayerUpdateAt || now) if (!player._voiceDownSince) player._voiceDownSince = now - this.VOICE_DOWN_THRESHOLD - 1 player._lastPlayerUpdateAt = now player.connected = false if (player.aqua?.debugTrace) { player.aqua._trace('player.voice.silence', { guildId: player.guildId, silenceMs, playing: !!player.playing, paused: !!player.paused }) } } if (player._voiceDownSince && !player.connected) { const downFor = Date.now() - player._voiceDownSince if ( downFor > this.VOICE_FORCE_DESTROY_MS && player.reconnectionRetries >= this.RECONNECT_MAX ) { if (player.aqua?.debugTrace) { player.aqua._trace('player.forceDestroy', { guildId: player.guildId }) } player.destroy() return } } if (!player._shouldAttemptVoiceRecovery()) return const hasVoiceData = player.connection?.sessionId && player.connection?.endpoint && player.connection?.token if (!hasVoiceData) { const downFor = Date.now() - player._voiceDownSince if (downFor > this.VOICE_DOWN_THRESHOLD * this.VOICE_ABANDON_MULTIPLIER) { const recoveryToken = player._claimVoiceRecovery( 'watchdog_voice_refresh' ) if (player._isVoiceRecoveryActive(recoveryToken)) player.connection?._requestVoiceState?.() if (player._isVoiceRecoveryActive(recoveryToken)) player.connection?.resendVoiceUpdate(true) player.reconnectionRetries = Math.min( player.reconnectionRetries + 1, 30 ) if ( downFor > this.VOICE_FORCE_DESTROY_MS && player.reconnectionRetries >= this.RECONNECT_MAX * 2 ) { player.destroy() } } return } const recoveryToken = player._claimVoiceRecovery('watchdog_resume') player._voiceRecovering = true try { if (!player._isVoiceRecoveryActive(recoveryToken)) return if (await player.connection.attemptResume()) { player.reconnectionRetries = player._voiceDownSince = 0 player._clearVoiceRecovery(recoveryToken, 'resumed') return } if (!player._isVoiceRecoveryActive(recoveryToken)) return const originalMute = player.mute player.send({ guild_id: player.guildId, channel_id: player.voiceChannel, self_deaf: player.deaf, self_mute: !originalMute }) await player._delay(this.MUTE_TOGGLE_DELAY) if (!player.destroyed && player._isVoiceRecoveryActive(recoveryToken)) { player.send({ guild_id: player.guildId, channel_id: player.voiceChannel, self_deaf: player.deaf, self_mute: originalMute }) } if (player._isVoiceRecoveryActive(recoveryToken)) player.connection.resendVoiceUpdate() player.reconnectionRetries++ } catch (error) { player.reconnectionRetries++ reportSuppressedError(player, 'player.voiceWatchdog', error, { guildId: player.guildId }) if (player.reconnectionRetries >= this.RECONNECT_MAX) { if (player._isVoiceRecoveryActive(recoveryToken)) player.connection?._requestVoiceState?.() if (player._isVoiceRecoveryActive(recoveryToken)) player.connection?.resendVoiceUpdate(true) player.reconnectionRetries = this.RECONNECT_MAX - 2 } } finally { if (player._isVoiceRecoveryActive(recoveryToken)) { player._voiceRecovering = false } } } async attemptVoiceResume(abortSignal) { const player = this.player if (!player.connection?.sessionId) throw new Error(`No session (guild=${player.guildId})`) if (abortSignal?.aborted) throw new Error('Resume aborted by signal') if (!(await player.connection.attemptResume())) throw new Error( `Resume failed (guild=${player.guildId}, endpoint=${player.connection.endpoint || 'none'})` ) } async socketClosed(_player, _track, payload) { const player = this.player if (player.destroyed || player._reconnecting) return if (player.aqua?.debugTrace) { player.aqua._trace('player.socketClosed', { guildId: player.guildId, code: payload?.code }) } const code = payload?.code if (code === 4006 && player._resuming) { if (player.aqua?.debugTrace) { player.aqua._trace('player.socketClosed.ignored', { guildId: player.guildId, code, reason: 'transient_while_resuming' }) } return } let isRecoverable = [4015, 4009, 4006, 4014, 4022].includes(code) if (code === 4014 && player.connection?.isWaitingForDisconnect) isRecoverable = false if (code === 4015 && !player.nodes?.info?.isNodelink) { const recoveryToken = player._claimVoiceRecovery('socket_closed_resume') player._reconnecting = true player._isActivelyReconnecting = true try { if (!player._isVoiceRecoveryActive(recoveryToken)) return await this.attemptVoiceResume() player._clearVoiceRecovery(recoveryToken, 'socket_closed_resumed') player._reconnecting = false player._isActivelyReconnecting = false return } catch (error) { player._reconnecting = false reportSuppressedError(player, 'player.socketClosed.resume', error, { guildId: player.guildId, code }) } } if (!isRecoverable) { player.aqua.emit(AqualinkEvents.SocketClosed, player, payload) player.destroy() return } if (code === 4014 || code === 4022) { player.connected = false if (!player._voiceDownSince) player._voiceDownSince = Date.now() player._suppressResumeUntil = Date.now() + (code === 4022 ? 3000 : 2000) } const aqua = player.aqua const vcId = this._functions.toId(player.voiceChannel) const tcId = this._functions.toId(player.textChannel) const { guildId, deaf, mute } = player if (!vcId) { aqua?.emit?.(AqualinkEvents.SocketClosed, player, payload) return } if (code === 4014 || code === 4022) { const recoveryToken = player._claimVoiceRecovery('socket_closed_soft') const now = Date.now() if ( now - (player._voiceRequestAt || 0) >= 1200 && player._isVoiceRecoveryActive(recoveryToken) ) { player._voiceRequestAt = now if (player._isVoiceRecoveryActive(recoveryToken)) player.connection?._requestVoiceState?.() if (player._isVoiceRecoveryActive(recoveryToken)) player.connection?.resendVoiceUpdate?.(true) if (player._isVoiceRecoveryActive(recoveryToken)) this._functions.safeCall(() => player.connect({ guildId, voiceChannel: vcId, deaf, mute }) ) } if (player.aqua?.debugTrace) { player.aqua._trace('player.socketClosed.softRecover', { guildId: player.guildId, code, strategy: code === 4022 ? 'resume_after_voice_refresh' : '4014_retry' }) } const waitMs = Math.max(0, player._suppressResumeUntil - Date.now()) if (waitMs > 0) await player._delay(waitMs) let resumed = false if ( player._isVoiceRecoveryActive(recoveryToken) && !player.destroyed && !player.connected ) { resumed = await player.connection?.attemptResume?.().catch((error) => { reportSuppressedError( player, 'player.socketClosed.softRecover', error, { guildId: player.guildId, code } ) return false }) } if (resumed) player._clearVoiceRecovery(recoveryToken, 'socket_soft_resumed') if ( resumed || player.connected || player.destroyed || player._reconnecting ) { if (player.aqua?.debugTrace) { player.aqua._trace('player.socketClosed.softRecover.ok', { guildId: player.guildId, code, resumed: !!resumed }) } return } if (player.aqua?.debugTrace) { player.aqua._trace('player.socketClosed.softRecover.failed', { guildId: player.guildId, code }) } } const state = { volume: player.volume, position: player.position, paused: player.paused, loop: player.loop, isAutoplayEnabled: player.isAutoplayEnabled, currentTrack: player.current, queue: player.queue?.toArray() || [], previousIdentifiers: Array.from(player.previousIdentifiers), autoplaySeed: player.autoplaySeed, nowPlayingMessage: player.nowPlayingMessage, voiceState: player.connection ? { sessionId: player.connection.sessionId || null, endpoint: player.connection.endpoint || null, token: player.connection.token || null, region: player.connection.region || null, channelId: player.connection.channelId || null } : null } player._reconnecting = true player._isActivelyReconnecting = true player.destroy({ preserveClient: true, skipRemote: true, preserveMessage: true, preserveReconnecting: true, preserveTracks: true }) const reconnectNonce = player._reconnectNonce player._reconnectTimers = new Set() const reconnectTimers = player._reconnectTimers const tryReconnect = async (attempt) => { if (aqua?.destroyed || player._reconnectNonce !== reconnectNonce) { this._functions.clearTimers(reconnectTimers) player._reconnectTimers = null player._reconnecting = false player._isActivelyReconnecting = false return } const activePlayer = aqua?.players?.get?.(String(guildId)) if (activePlayer && activePlayer !== player && !activePlayer.destroyed) { this._functions.clearTimers(reconnectTimers) player._reconnectTimers = null player._reconnecting = false player._isActivelyReconnecting = false return } try { const np = await aqua.createConnection({ guildId, voiceChannel: vcId, textChannel: tcId, deaf, mute, defaultVolume: state.volume, preserveMessage: true, resuming: true }) if (!np) throw new Error('Failed to create player') if (player._reconnectNonce !== reconnectNonce || aqua?.destroyed) { try { np.destroy?.() } catch {} this._functions.clearTimers(reconnectTimers) player._reconnectTimers = null player._reconnecting = false player._isActivelyReconnecting = false return } const latestActivePlayer = aqua?.players?.get?.(String(guildId)) if ( latestActivePlayer && latestActivePlayer !== player && !latestActivePlayer.destroyed ) { try { np.destroy?.() } catch {} this._functions.clearTimers(reconnectTimers) player._reconnectTimers = null player._reconnecting = false player._isActivelyReconnecting = false return } np.reconnectionRetries = 0 np.loop = state.loop np.isAutoplayEnabled = state.isAutoplayEnabled np.autoplaySeed = state.autoplaySeed np.previousIdentifiers = new Set(state.previousIdentifiers) np.nowPlayingMessage = state.nowPlayingMessage if (state.voiceState && np.connection) { np.connection.sessionId = state.voiceState.sessionId || np.connection.sessionId np.connection.endpoint = state.voiceState.endpoint || np.connection.endpoint np.connection.token = state.voiceState.token || np.connection.token np.connection.region = state.voiceState.region || np.connection.region np.connection.channelId = state.voiceState.channelId || np.connection.channelId np.connection._lastEndpoint = state.voiceState.endpoint || np.connection._lastEndpoint if ( np.connection.sessionId && np.connection.endpoint && np.connection.token ) { np.connection._lastVoiceDataUpdate = Date.now() np.connection.resendVoiceUpdate(true) } } const ct = state.currentTrack if (ct) np.queue.add(ct) for (const q of state.queue) if (q !== ct) np.queue.add(q) if (ct) { await np.play() if (state.position > 5000) np._createTimer( () => !np.destroyed && np.seek(state.position), this.SEEK_DELAY ) if (state.paused) np._createTimer( () => !np.destroyed && np.pause(true), this.PAUSE_DELAY ) } this._functions.clearTimers(reconnectTimers) player._reconnectTimers = null player._reconnecting = false player._isActivelyReconnecting = false aqua.emit(AqualinkEvents.PlayerReconnected, np, { oldPlayer: player, restoredState: state }) } catch (error) { if (player._reconnectNonce !== reconnectNonce || aqua?.destroyed) { this._functions.clearTimers(reconnectTimers) player._reconnectTimers = null player._reconnecting = false player._isActivelyReconnecting = false return } const retriesLeft = this.RECONNECT_MAX - attempt aqua.emit(AqualinkEvents.ReconnectionFailed, player, { error, code, payload, retriesLeft }) if (retriesLeft > 0) { this._functions.createTimer( () => tryReconnect(attempt + 1), Math.min(this.RETRY_BACKOFF_BASE * attempt, this.RETRY_BACKOFF_MAX), reconnectTimers ) } else { this._functions.clearTimers(reconnectTimers) player._reconnectTimers = null player._reconnecting = false player._isActivelyReconnecting = false aqua.emit(AqualinkEvents.SocketClosed, player, payload) } } } tryReconnect(1) } flushDeferredPlay() { const player = this.player if ( !player._deferredStart || player.destroyed || !player.current?.track || !player._updateBatcher ) return player._deferredStart = false const updateData = { track: { encoded: player.current.track }, paused: player.paused } if (player.position > 0) updateData.position = player.position if (player.aqua?.debugTrace) { player.aqua._trace('player.play.deferred.flush', { guildId: player.guildId, hasEndpoint: !!player.connection?.endpoint }) } player.batchUpdatePlayer(updateData, true).catch((error) => reportSuppressedError(player, 'player.deferredPlay.flush', error, { guildId: player.guildId }) ) } } module.exports = PlayerLifecycle