UNPKG

aqualink

Version:

An Lavalink client, focused in pure performance and features

641 lines (530 loc) 17.5 kB
'use strict' const { EventEmitter } = require('tseep') const Connection = require('./Connection') const Queue = require('./Queue') const Filters = require('./Filters') const { spAutoPlay, scAutoPlay } = require('../handlers/autoplay') const LOOP_MODES = Object.freeze({ NONE: 'none', TRACK: 'track', QUEUE: 'queue' }) const EVENT_HANDLERS = Object.freeze({ TrackStartEvent: 'trackStart', TrackEndEvent: 'trackEnd', TrackExceptionEvent: 'trackError', TrackStuckEvent: 'trackStuck', TrackChangeEvent: 'trackChange', WebSocketClosedEvent: 'socketClosed', LyricsLineEvent: 'lyricsLine', LyricsFoundEvent: 'lyricsFound', LyricsNotFoundEvent: 'lyricsNotFound' }) const VALID_MODES = new Set(Object.values(LOOP_MODES)) const FAILURE_REASONS = new Set(['LOAD_FAILED', 'CLEANUP']) const RECONNECT_CODES = new Set([4015, 4009, 4006]) const FAIL_LOAD_TYPES = new Set(['error', 'empty', 'LOAD_FAILED', 'NO_MATCHES']) const clamp = v => Math.max(0, Math.min(200, +v || 0)) const isValidVolume = v => typeof v === 'number' && v >= 0 && v <= 200 const isValidPosition = p => typeof p === 'number' && !isNaN(p) && p >= 0 const getRandomIndex = arr => (Math.random() * arr.length) | 0 class MicrotaskUpdateBatcher { constructor(player) { this.player = player this.updates = null this.isScheduled = false } batch(data, immediate = false) { this.updates ??= Object.create(null) const keys = Object.keys(data) for (let i = 0; i < keys.length; i++) { const key = keys[i] this.updates[key] = data[key] } if (immediate || data.track) { return this._flush() } if (!this.isScheduled) { this.isScheduled = true queueMicrotask(() => { this._flush() this.isScheduled = false }) } return Promise.resolve() } _flush() { if (!this.updates) return const updates = this.updates this.updates = null this.player.updatePlayer(updates).catch(err => { console.error('Update player error:', err) }) } destroy() { this.updates = null this.isScheduled = false } } class CircularBuffer { constructor(size = 50) { this.buffer = new Array(size) this.size = size this.index = 0 this.count = 0 } push(item) { 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() { this.count = 0 this.index = 0 } } class Player extends EventEmitter { static LOOP_MODES = LOOP_MODES static EVENT_HANDLERS = EVENT_HANDLERS static validModes = VALID_MODES playing = false paused = false connected = false isAutoplayEnabled = false isAutoplay = false autoplaySeed = null current = null position = 0 timestamp = 0 ping = 0 nowPlayingMessage = null constructor(aqua, nodes, options = {}) { super() this.aqua = aqua this.nodes = nodes this.guildId = options.guildId this.textChannel = options.textChannel this.voiceChannel = options.voiceChannel this.connection = new Connection(this) this.filters = new Filters(this) this.queue = new Queue() const vol = options.defaultVolume ?? 100 this.volume = isValidVolume(vol) ? vol : clamp(vol) this.loop = VALID_MODES.has(options.loop) ? options.loop : LOOP_MODES.NONE this.shouldDeleteMessage = !!aqua.options?.shouldDeleteMessage this.leaveOnEnd = !!aqua.options?.leaveOnEnd this.previousTracks = new CircularBuffer(50) this._updateBatcher = new MicrotaskUpdateBatcher(this) this._dataStore = new Map() this._boundPlayerUpdate = this._handlePlayerUpdate.bind(this) this._boundEvent = this._handleEvent.bind(this) this.on('playerUpdate', this._boundPlayerUpdate) this.on('event', this._boundEvent) } _handlePlayerUpdate(packet) { const state = packet.state this.position = state.position this.connected = state.connected this.ping = state.ping this.timestamp = state.time this.aqua.emit('playerUpdate', this, packet) } async _handleEvent(payload) { const handlerName = EVENT_HANDLERS[payload.type] if (!handlerName || typeof this[handlerName] !== 'function') { this.aqua.emit('nodeError', this, new Error(`Unknown event: ${payload.type}`)) return } try { await this[handlerName](this, this.current, payload) } catch (error) { this.aqua.emit('error', error) } } get previous() { return this.previousTracks.getLast() } get currenttrack() { return this.current } getQueue() { return this.queue } batchUpdatePlayer(data, immediate = false) { return this._updateBatcher.batch(data, immediate) } setAutoplay(enabled) { this.isAutoplayEnabled = !!enabled return this } async play() { if (!this.connected || !this.queue.length) return const item = this.queue.shift() this.current = item.track ? item : await item.resolve(this.aqua) this.playing = true this.position = 0 return this.batchUpdatePlayer({ track: { encoded: this.current.track } }, true) } connect(options = this) { const { guildId, voiceChannel, deaf = true, mute = false } = options this.deaf = deaf this.mute = mute this.connected = true this.send({ guild_id: guildId, channel_id: voiceChannel, self_deaf: deaf, self_mute: mute }) return this } destroy() { if (!this.connected) return this this.connected = false this._updateBatcher?.destroy() this.send({ guild_id: this.guildId, channel_id: null }) if (this.nowPlayingMessage) { this.nowPlayingMessage.delete().catch(() => { }) this.nowPlayingMessage = null } this.voiceChannel = null this.isAutoplay = false this.aqua.destroyPlayer(this.guildId) if (this.nodes?.connected) { this.nodes.rest.destroyPlayer(this.guildId).catch(error => { if (!error.message.includes('ECONNREFUSED')) { console.error(`[Player ${this.guildId}] Destroy error:`, error.message) } }) } this.previousTracks?.clear() this._dataStore?.clear() this.removeAllListeners() this.queue = this.previousTracks = this.connection = this.filters = this._updateBatcher = this._dataStore = null return this } pause(paused) { if (this.paused === paused) return this this.paused = paused this.batchUpdatePlayer({ paused }) return this } seek(position) { if (!this.playing || !isValidPosition(position)) return this const maxPos = this.current?.info?.length this.position = maxPos ? Math.min(Math.max(0, position), maxPos) : Math.max(0, position) this.batchUpdatePlayer({ position: this.position }) return this } stop() { if (!this.playing) return this this.playing = false this.position = 0 this.batchUpdatePlayer({ track: { encoded: null } }, true) return this } setVolume(volume) { if (!isValidVolume(volume)) return this const vol = clamp(volume) if (this.volume === vol) return this this.volume = vol this.batchUpdatePlayer({ volume: vol }) return this } setLoop(mode) { if (!VALID_MODES.has(mode)) throw new Error('Invalid loop mode') this.loop = mode this.batchUpdatePlayer({ loop: mode }) return this } setTextChannel(channel) { this.textChannel = channel this.batchUpdatePlayer({ text_channel: channel }) return this } setVoiceChannel(channel) { if (!channel) throw new TypeError('Channel required') if (this.connected && channel === this.voiceChannel) { throw new ReferenceError(`Already connected to ${channel}`) } this.voiceChannel = channel this.connect({ deaf: this.deaf, guildId: this.guildId, voiceChannel: channel, mute: this.mute }) return this } disconnect() { if (!this.connected) return this this.connected = false this.voiceChannel = null this.send({ guild_id: this.guildId, channel_id: null }) return this } shuffle() { const queue = this.queue const len = queue.length for (let i = len - 1; i > 0; i--) { const j = (Math.random() * (i + 1)) | 0 ;[queue[i], queue[j]] = [queue[j], queue[i]] } return this } replay() { return this.seek(0) } skip() { this.stop() return this.playing ? this.play() : undefined } async getLyrics({ query, useCurrentTrack = true, skipTrackSource = false } = {}) { if (query) { return this.nodes.rest.getLyrics({ track: { info: { title: query } }, skipTrackSource }) } if (useCurrentTrack && this.playing && this.current) { const currentInfo = this.current.info return this.nodes.rest.getLyrics({ track: { info: currentInfo, encoded: this.current.track, identifier: currentInfo.identifier, guild_id: this.guildId }, skipTrackSource }) } return null } subscribeLiveLyrics() { return this.nodes.rest.subscribeLiveLyrics(this.guildId, false) } unsubscribeLiveLyrics() { return this.nodes.rest.unsubscribeLiveLyrics(this.guildId) } async autoplay() { if (!this.isAutoplayEnabled || !this.previous) return this this.isAutoplay = true const prevInfo = this.previous.info const sourceName = prevInfo.sourceName const identifier = prevInfo.identifier const uri = prevInfo.uri const requester = prevInfo.requester const author = prevInfo.author try { let query = null let source = null let resolved = null let track = null if (sourceName === 'youtube') { query = `https://www.youtube.com/watch?v=${identifier}&list=RD${identifier}` source = 'ytmsearch' } else if (sourceName === 'soundcloud') { const scResults = await scAutoPlay(uri) if (!scResults?.length) return this query = scResults[0] source = 'scsearch' } else if (sourceName === 'spotify') { this.previousIdentifiers ??= [] if (this.previous) { this.previousIdentifiers.unshift(this.previous.identifier) if (this.previousIdentifiers.length >= 20) this.previousIdentifiers.pop() } if (!this.autoplaySeed) { this.autoplaySeed = { trackId: identifier, artistIds: Array.isArray(author) ? author.join(',') : author } } resolved = await spAutoPlay( this.autoplaySeed, this, requester, this.previousIdentifiers ) if (!resolved?.length) return this } else { return this } if (resolved) { track = resolved[getRandomIndex(resolved)] } else { const response = await this.aqua.resolve({ query, source, requester }) if (!response?.tracks?.length || FAIL_LOAD_TYPES.has(response.loadType)) { return this.stop() } track = response.tracks[getRandomIndex(response.tracks)] } if (!track?.info?.title) throw new Error('Invalid track object') track.requester = this.previous.requester || { id: 'Unknown' } this.queue.push(track) await this.play() return this } catch (err) { console.error('Autoplay failed:', err) return this.stop() } } async trackStart(player, track) { this.playing = true this.paused = false this.aqua.emit('trackStart', player, track) } async trackEnd(player, track, payload) { if (track) this.previousTracks.push(track) if (this.shouldDeleteMessage && this.nowPlayingMessage) { this.nowPlayingMessage.delete().catch(() => { }) this.nowPlayingMessage = null } const reason = payload.reason if (FAILURE_REASONS.has(reason)) { if (!this.queue.length) { this.clearData() this.aqua.emit('queueEnd', player) } else { this.aqua.emit('trackEnd', player, track, reason) await player.play() } return } if (this.loop === LOOP_MODES.TRACK) { player.queue.unshift(track) } else if (this.loop === LOOP_MODES.QUEUE) { player.queue.push(track) } if (player.queue.isEmpty()) { if (this.isAutoplayEnabled) { await player.autoplay() } else { this.playing = false if (this.leaveOnEnd) { this.clearData() this.destroy() } this.aqua.emit('queueEnd', player) } } else { this.aqua.emit('trackEnd', player, track, reason) await player.play() } } async trackError(player, track, payload) { this.aqua.emit('trackError', player, track, payload) return this.stop() } async trackStuck(player, track, payload) { this.aqua.emit('trackStuck', player, track, payload) return this.stop() } async socketClosed(player, track, payload) { if (payload.code === 4014) return this.destroy() if (payload.code === 4015) { try { if (this.connection) { this.connection._updatePlayerVoiceData(true); this.aqua.emit('debug', `[Player ${this.guildId}] Attempting resume...`); return; } } catch (error) { console.error('Resume failed, falling back to reconnect', error); } } if (!RECONNECT_CODES.has(payload.code)) { this.aqua.emit('socketClosed', player, payload) return } try { const voiceChannelId = this.voiceChannel?.id || this.voiceChannel if (!voiceChannelId) { this.aqua.emit('socketClosed', player, payload) return } const connection = this.connection const savedState = { sessionId: connection?.sessionId, endpoint: connection?.endpoint, token: connection?.token, region: connection?.region, volume: this.volume, position: this.position, paused: this.paused, loop: this.loop, isAutoplayEnabled: this.isAutoplayEnabled, currentTrack: this.current, queue: [...this.queue] } if (!player.destroyed) { await player.destroy() this.aqua.emit('playerDestroy', player) } const newPlayer = await this.aqua.createConnection({ guildId: payload.guildId, voiceChannel: voiceChannelId, textChannel: this.textChannel?.id || this.textChannel, deaf: this.deaf, mute: this.mute, defaultVolume: savedState.volume, }) if (!newPlayer) throw new Error('Failed to create a new player during reconnection.') newPlayer.loop = savedState.loop newPlayer.isAutoplayEnabled = savedState.isAutoplayEnabled if (savedState.sessionId && newPlayer.connection) { const newConnection = newPlayer.connection newConnection.sessionId = savedState.sessionId newConnection.endpoint = savedState.endpoint newConnection.token = savedState.token newConnection.region = savedState.region } if (savedState.currentTrack) { newPlayer.queue.add(savedState.currentTrack) const queue = savedState.queue for (let i = 0; i < queue.length; i++) { newPlayer.queue.add(queue[i]) } await newPlayer.play() if (savedState.position > 5000) { setTimeout(() => newPlayer.seek(savedState.position), 1000) } if (savedState.paused) { setTimeout(() => newPlayer.pause(true), 1500) } } this.aqua.emit('playerReconnected', newPlayer, { oldPlayer: player, restoredState: savedState }) } catch (error) { console.error('Reconnection failed:', error) this.aqua.emit('reconnectionFailed', player, { error, code: payload.code, payload }) this.aqua.emit('socketClosed', player, payload) } } async lyricsLine(player, track, payload) { this.aqua.emit('lyricsLine', player, track, payload) } async lyricsFound(player, track, payload) { this.aqua.emit('lyricsFound', player, track, payload) } async lyricsNotFound(player, track, payload) { this.aqua.emit('lyricsNotFound', player, track, payload) } send(data) { this.aqua.send({ op: 4, d: data }) } set(key, value) { this._dataStore.set(key, value) } get(key) { return this._dataStore.get(key) } clearData() { this.previousTracks?.clear() this._dataStore?.clear() return this } updatePlayer(data) { return this.nodes.rest.updatePlayer({ guildId: this.guildId, data }) } async cleanup() { if (!this.playing && !this.paused && this.queue.isEmpty()) this.destroy() } } module.exports = Player