aqualink 3.0.0 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/build/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { EventEmitter } from 'events'
1
+ import { EventEmitter } from 'node:events'
2
2
 
3
3
  declare module 'aqualink' {
4
4
  // Main Classes
@@ -1,4 +1,4 @@
1
- const fs = require('node:fs')
1
+ const fs = require('node:fs')
2
2
  const path = require('node:path')
3
3
  const _readline = require('node:readline')
4
4
  const { EventEmitter } = require('node:events')
@@ -191,7 +191,10 @@ class Aqua extends EventEmitter {
191
191
 
192
192
  _trace(event, data = null) {
193
193
  if (!this.debugTrace) return
194
- if (!this._traceBuffer || this._traceBuffer.length !== this.traceMaxEntries) {
194
+ if (
195
+ !this._traceBuffer ||
196
+ this._traceBuffer.length !== this.traceMaxEntries
197
+ ) {
195
198
  this._traceBuffer = new Array(this.traceMaxEntries)
196
199
  this._traceBufferCount = 0
197
200
  this._traceBufferIndex = 0
@@ -346,9 +349,14 @@ class Aqua extends EventEmitter {
346
349
  this._invalidateCache()
347
350
  queueMicrotask(() => {
348
351
  this._storeBrokenPlayers(node).catch((error) =>
349
- reportSuppressedError(this, 'aqua.nodeDisconnect.storeBrokenPlayers', error, {
350
- node: node?.name || node?.host
351
- })
352
+ reportSuppressedError(
353
+ this,
354
+ 'aqua.nodeDisconnect.storeBrokenPlayers',
355
+ error,
356
+ {
357
+ node: node?.name || node?.host
358
+ }
359
+ )
352
360
  )
353
361
  this._performCleanup()
354
362
  })
@@ -112,7 +112,10 @@ class AquaRecovery {
112
112
  const batch = rebuilds.slice(i, i + this.MAX_CONCURRENT_OPS)
113
113
  const results = await Promise.allSettled(
114
114
  batch.map((state) =>
115
- this.restorePlayer(state, node).then((ok) => ({ ok, guildId: state.g }))
115
+ this.restorePlayer(state, node).then((ok) => ({
116
+ ok,
117
+ guildId: state.g
118
+ }))
116
119
  )
117
120
  )
118
121
  for (let j = 0; j < results.length; j++) {
@@ -551,8 +554,9 @@ class AquaRecovery {
551
554
  if (existing?.playing && !existing.destroyed) return true
552
555
  if (existing?.destroyed) this.aqua.players.delete(gId)
553
556
 
554
- const targetNode =
555
- preferredNode?.connected ? preferredNode : this.aqua.leastUsedNodes[0]
557
+ const targetNode = preferredNode?.connected
558
+ ? preferredNode
559
+ : this.aqua.leastUsedNodes[0]
556
560
  if (!targetNode?.connected) {
557
561
  throw new Error(`No connected node available to restore guild ${gId}`)
558
562
  }
@@ -321,7 +321,9 @@ class Connection {
321
321
  } catch (e) {
322
322
  this._aqua?.emit?.(
323
323
  AqualinkEvents.Debug,
324
- new Error(`Player destroy failed: ${e?.message || e}`)
324
+ new Error(
325
+ `Player destroy failed (guild=${this._guildId}, sessionId=${this.sessionId || 'none'}): ${e?.message || e}`
326
+ )
325
327
  )
326
328
  } finally {
327
329
  this._stateFlags &= ~STATE.DISCONNECTING
@@ -452,6 +454,15 @@ class Connection {
452
454
 
453
455
  _executeVoiceUpdate() {
454
456
  if (this._destroyed) return
457
+ if (this._stateFlags & STATE.DISCONNECTING) {
458
+ this._stateFlags &= ~STATE.UPDATE_SCHEDULED
459
+ this._voiceFlushTimer = null
460
+ if (this._pendingUpdate) {
461
+ sharedPool.release(this._pendingUpdate.payload)
462
+ this._pendingUpdate = null
463
+ }
464
+ return
465
+ }
455
466
  this._stateFlags &= ~STATE.UPDATE_SCHEDULED
456
467
  this._voiceFlushTimer = null
457
468
 
@@ -163,6 +163,14 @@ class ConnectionRecovery {
163
163
  true
164
164
  )
165
165
 
166
+ if (conn._destroyed || !conn._player || conn._player.destroyed) {
167
+ conn._aqua.emit(
168
+ AqualinkEvents.Debug,
169
+ `Resume aborted: player destroyed during attempt for guild ${conn._guildId}`
170
+ )
171
+ return false
172
+ }
173
+
166
174
  if (conn._stateGeneration !== currentGen) {
167
175
  conn._aqua.emit(
168
176
  AqualinkEvents.Debug,
@@ -178,6 +186,10 @@ class ConnectionRecovery {
178
186
  })
179
187
  }
180
188
 
189
+ if (conn._destroyed || conn._player?.destroyed) {
190
+ return false
191
+ }
192
+
181
193
  conn._reconnectAttempts = 0
182
194
  conn._consecutiveFailures = 0
183
195
  if (conn._player) conn._player._resuming = false
@@ -189,10 +201,17 @@ class ConnectionRecovery {
189
201
  return true
190
202
  } catch (error) {
191
203
  if (conn._destroyed || !conn._aqua) throw error
204
+ if (conn._player?.destroyed) {
205
+ conn._aqua.emit(
206
+ AqualinkEvents.Debug,
207
+ `Resume aborted: player destroyed during retry for guild ${conn._guildId}`
208
+ )
209
+ return false
210
+ }
192
211
  conn._consecutiveFailures++
193
212
  conn._aqua.emit(
194
213
  AqualinkEvents.Debug,
195
- `Resume failed for guild ${conn._guildId}: ${error?.message || error}`
214
+ `Resume failed for guild ${conn._guildId} (sessionId=${conn.sessionId || 'none'}, endpoint=${conn.endpoint || 'none'}): ${error?.message || error}`
196
215
  )
197
216
  if (conn._aqua?.debugTrace) {
198
217
  conn._aqua._trace('connection.resume.error', {
@@ -204,7 +223,8 @@ class ConnectionRecovery {
204
223
  if (
205
224
  conn._reconnectAttempts < this.MAX_RECONNECT_ATTEMPTS &&
206
225
  !conn._destroyed &&
207
- conn._consecutiveFailures < 5
226
+ conn._consecutiveFailures < 5 &&
227
+ !conn._player?.destroyed
208
228
  ) {
209
229
  const delay = Math.min(
210
230
  this.RECONNECT_DELAY * (1 << (conn._reconnectAttempts - 1)),
@@ -265,7 +285,10 @@ class ConnectionRecovery {
265
285
  return false
266
286
  })
267
287
  if (resumed) {
268
- conn._player?._clearVoiceRecovery?.(recoveryToken, 'missing_player_resumed')
288
+ conn._player?._clearVoiceRecovery?.(
289
+ recoveryToken,
290
+ 'missing_player_resumed'
291
+ )
269
292
  } else if (conn._player?._isVoiceRecoveryActive?.(recoveryToken)) {
270
293
  conn.resendVoiceUpdate(true)
271
294
  }
@@ -306,8 +329,12 @@ class ConnectionRecovery {
306
329
 
307
330
  async sendUpdate(payload) {
308
331
  const conn = this.connection
309
- if (conn._destroyed) throw new Error('Connection destroyed')
310
- if (!conn._rest) throw new Error('REST interface unavailable')
332
+ if (conn._destroyed)
333
+ throw new Error(`Connection destroyed (guild=${conn._guildId})`)
334
+ if (!conn._rest)
335
+ throw new Error(
336
+ `REST interface unavailable (guild=${conn._guildId}, sessionId=${conn.sessionId || 'none'})`
337
+ )
311
338
 
312
339
  try {
313
340
  if (conn._aqua?.debugTrace) {
@@ -342,7 +369,7 @@ class ConnectionRecovery {
342
369
  if (conn._aqua) {
343
370
  conn._aqua.emit(
344
371
  AqualinkEvents.Debug,
345
- `[Aqua/Connection] Player ${conn._guildId} not found (404)${isSessionError ? ' - Session invalid' : ''}. Recovery failed, destroying.`
372
+ `[Aqua/Connection] Player ${conn._guildId} not found (404, sessionId=${conn.sessionId || 'none'}, endpoint=${conn.endpoint || 'none'})${isSessionError ? ' - Session invalid' : ''}. Recovery failed, destroying.`
346
373
  )
347
374
  await conn._aqua.destroyPlayer(conn._guildId)
348
375
  }
@@ -202,7 +202,8 @@ class Filters {
202
202
  }
203
203
 
204
204
  _scheduleUpdate() {
205
- if (this._pendingUpdate || !this.player) return this
205
+ if (this._pendingUpdate || !this.player || this.player.destroyed)
206
+ return this
206
207
  this._pendingUpdate = true
207
208
  queueMicrotask(() => {
208
209
  this._pendingUpdate = false
@@ -398,6 +399,7 @@ class Filters {
398
399
  }
399
400
 
400
401
  async updateFilters() {
402
+ this._pendingUpdate = false
401
403
  if (!this.player || !this._dirty.size) return this
402
404
 
403
405
  const dirtyKeys = [...this._dirty]
@@ -80,6 +80,16 @@ const _functions = {
80
80
 
81
81
  errMsg(err) {
82
82
  return err?.message || String(err)
83
+ },
84
+
85
+ statusCode(err) {
86
+ return (
87
+ err?.statusCode ||
88
+ err?.status ||
89
+ err?.response?.statusCode ||
90
+ err?.response?.status ||
91
+ null
92
+ )
83
93
  }
84
94
  }
85
95
 
@@ -205,7 +215,15 @@ class Node {
205
215
  this.isNodelink = !!this.info?.isNodelink
206
216
  } catch (err) {
207
217
  this.info = null
208
- this._emitError(`Failed to fetch node info: ${_functions.errMsg(err)}`)
218
+ if (_functions.statusCode(err) === 404) {
219
+ this._emitDebug(
220
+ 'Node info endpoint unavailable (HTTP 404); continuing without remote info'
221
+ )
222
+ } else {
223
+ this._emitError(
224
+ `Failed to fetch node info: ${_functions.errMsg(err)}`
225
+ )
226
+ }
209
227
  } finally {
210
228
  clearTimeout(timeoutId)
211
229
  }
@@ -592,8 +610,8 @@ class Node {
592
610
  }
593
611
 
594
612
  const oldSessionId = this.sessionId
595
- const sessionChanged =
596
- oldSessionId && oldSessionId !== sessionId && !payload.resumed
613
+ const sessionInvalidated = !payload.resumed && !!oldSessionId
614
+ const sessionChanged = sessionInvalidated && oldSessionId !== sessionId
597
615
 
598
616
  this.sessionId = sessionId
599
617
  if (this.aqua?.debugTrace) {
@@ -607,9 +625,9 @@ class Node {
607
625
  this.rest.setSessionId(sessionId)
608
626
  this._headers['Session-Id'] = sessionId
609
627
 
610
- if (sessionChanged && this.aqua?.players) {
628
+ if (sessionInvalidated && this.aqua?.players) {
611
629
  this._emitDebug(
612
- `Session changed from ${oldSessionId} to ${sessionId}, invalidating stale players`
630
+ `Session invalidated (resumed=${!!payload.resumed}, old=${oldSessionId}, new=${sessionId}), invalidating stale players`
613
631
  )
614
632
  try {
615
633
  await this.aqua._storeBrokenPlayers?.(this)
@@ -618,6 +636,15 @@ class Node {
618
636
  `Failed to snapshot stale players before invalidation: ${e?.message || e}`
619
637
  )
620
638
  }
639
+ for (const [, player] of this.aqua.players) {
640
+ if (player?.nodes === this || player?.nodes?.name === this.name) {
641
+ if (player.connection) {
642
+ player.connection._lastEndpoint = null
643
+ player.connection._stateFlags |= 512
644
+ }
645
+ }
646
+ }
647
+
621
648
  const playersToDestroy = []
622
649
  for (const [guildId, player] of this.aqua.players) {
623
650
  if (player?.nodes === this || player?.nodes?.name === this.name) {
@@ -650,7 +677,8 @@ class Node {
650
677
 
651
678
  this.aqua.emit(AqualinkEvents.NodeReady, this, {
652
679
  resumed: !!payload.resumed,
653
- sessionChanged
680
+ sessionChanged,
681
+ sessionInvalidated
654
682
  })
655
683
 
656
684
  if (this.autoResume) {
@@ -672,57 +700,93 @@ class Node {
672
700
  })
673
701
  }
674
702
 
703
+ let resumeSupported = true
675
704
  try {
676
705
  await this.rest.makeRequest('PATCH', `/v4/sessions/${this.sessionId}`, {
677
706
  resuming: true,
678
707
  timeout: this.resumeTimeout
679
708
  })
709
+ } catch (err) {
710
+ if (_functions.statusCode(err) === 404) {
711
+ resumeSupported = false
712
+ this._emitDebug(
713
+ 'Session resume endpoint unavailable (HTTP 404); falling back without server-side session resume'
714
+ )
715
+ } else {
716
+ if (this.aqua?.debugTrace) {
717
+ this.aqua._trace('node.resume.error', {
718
+ node: this.name,
719
+ error: _functions.errMsg(err)
720
+ })
721
+ }
722
+ this._emitError(`Failed to resume session: ${_functions.errMsg(err)}`)
723
+ throw err
724
+ }
725
+ }
726
+
727
+ if (!resumeSupported) {
728
+ try {
729
+ const existingPlayers = await this.rest.getPlayers()
730
+ if (Array.isArray(existingPlayers) && existingPlayers.length === 0) {
731
+ this._emitDebug(
732
+ 'No players found on Lavalink for this session; will rejoin voice only'
733
+ )
734
+ }
735
+ } catch (_) {
736
+ // getPlayers may also 404 — that's fine, we already know session is invalid
737
+ }
738
+ }
680
739
 
681
- if (this.aqua?.players) {
682
- for (const [guildId, player] of this.aqua.players) {
683
- if (
684
- (player?.nodes === this || player?.nodes?.name === this.name) &&
685
- player.voiceChannel
686
- ) {
687
- try {
688
- const recoveryToken = player._claimVoiceRecovery?.(
689
- 'node_resume_rejoin'
690
- )
691
- this._emitDebug(`Rejoining voice for guild ${guildId} on resume`)
692
- if (this.aqua?.debugTrace) {
693
- this.aqua._trace('node.resume.rejoin', {
694
- node: this.name,
695
- guildId,
696
- voiceChannel: player.voiceChannel
697
- })
698
- }
699
- if (player._isVoiceRecoveryActive?.(recoveryToken))
700
- player.connect({
701
- voiceChannel: player.voiceChannel,
702
- deaf: player.deaf,
703
- mute: player.mute
704
- })
740
+ if (this.aqua?.players) {
741
+ const PLAYER_BATCH_SIZE = 20
742
+ const playersToResume = []
743
+ for (const [guildId, player] of this.aqua.players) {
744
+ if (
745
+ (player?.nodes === this || player?.nodes?.name === this.name) &&
746
+ player.voiceChannel &&
747
+ !player.destroyed
748
+ ) {
749
+ playersToResume.push({ guildId, player })
750
+ }
751
+ }
752
+
753
+ for (let i = 0; i < playersToResume.length; i += PLAYER_BATCH_SIZE) {
754
+ const batch = playersToResume.slice(i, i + PLAYER_BATCH_SIZE)
755
+ await Promise.allSettled(
756
+ batch.map(async ({ guildId, player }) => {
757
+ try {
758
+ const recoveryToken = player._claimVoiceRecovery?.(
759
+ resumeSupported
760
+ ? 'node_resume_rejoin'
761
+ : 'node_rejoin_after_resume_404'
762
+ )
763
+ this._emitDebug(`Rejoining voice for guild ${guildId} on resume`)
764
+ if (this.aqua?.debugTrace) {
765
+ this.aqua._trace('node.resume.rejoin', {
766
+ node: this.name,
767
+ guildId,
768
+ voiceChannel: player.voiceChannel,
769
+ resumeSupported
770
+ })
771
+ }
772
+ if (player._isVoiceRecoveryActive?.(recoveryToken))
773
+ player.connect({
774
+ voiceChannel: player.voiceChannel,
775
+ deaf: player.deaf,
776
+ mute: player.mute
777
+ })
705
778
  } catch (e) {
706
779
  this._emitDebug(
707
780
  `Failed to rejoin voice for ${guildId}: ${e?.message || e}`
708
781
  )
709
782
  }
710
- }
711
- }
783
+ })
784
+ )
712
785
  }
786
+ }
713
787
 
714
- if (this.aqua.loadPlayers && this.aqua.players.size === 0) {
715
- await this.aqua.loadPlayers()
716
- }
717
- } catch (err) {
718
- if (this.aqua?.debugTrace) {
719
- this.aqua._trace('node.resume.error', {
720
- node: this.name,
721
- error: _functions.errMsg(err)
722
- })
723
- }
724
- this._emitError(`Failed to resume session: ${_functions.errMsg(err)}`)
725
- throw err
788
+ if (this.aqua.loadPlayers && this.aqua.players.size === 0) {
789
+ await this.aqua.loadPlayers()
726
790
  }
727
791
  }
728
792
 
@@ -122,7 +122,8 @@ class MicrotaskUpdateBatcher {
122
122
  const { player: p, updates: u } = this
123
123
  this.updates = null
124
124
  this.scheduled = false
125
- if (!u || !p) return Promise.resolve()
125
+ if (!u || !p || p.destroyed || p.state === PLAYER_STATE.DISCONNECTING)
126
+ return Promise.resolve()
126
127
  return p.updatePlayer(u).catch((err) => {
127
128
  _functions.emitAquaError(
128
129
  p.aqua,
@@ -296,7 +297,9 @@ class Player extends EventEmitter {
296
297
  }
297
298
 
298
299
  _isVoiceRecoveryActive(token) {
299
- return !!token && !this.destroyed && this._activeVoiceRecoveryToken === token
300
+ return (
301
+ !!token && !this.destroyed && this._activeVoiceRecoveryToken === token
302
+ )
300
303
  }
301
304
 
302
305
  _clearVoiceRecovery(token = this._activeVoiceRecoveryToken, reason = null) {
@@ -352,6 +355,11 @@ class Player extends EventEmitter {
352
355
 
353
356
  async play(track, options = {}) {
354
357
  if (this.destroyed || !this.queue) return this
358
+ if (options != null && typeof options !== 'object') {
359
+ throw new TypeError(
360
+ `Player.play(): options must be an object, got ${typeof options}`
361
+ )
362
+ }
355
363
 
356
364
  let item = track
357
365
  if (!item) {
@@ -484,7 +492,8 @@ class Player extends EventEmitter {
484
492
  }
485
493
 
486
494
  connect(options = {}) {
487
- if (this.destroyed) throw new Error('Cannot connect destroyed player')
495
+ if (this.destroyed)
496
+ throw new Error(`Cannot connect destroyed player (guild=${this.guildId})`)
488
497
 
489
498
  const voiceChannel = _functions.toId(
490
499
  options.voiceChannel || this.voiceChannel
@@ -542,38 +551,29 @@ class Player extends EventEmitter {
542
551
  }
543
552
 
544
553
  destroy(options = {}) {
554
+ if (this.destroyed) return this
555
+
545
556
  const {
546
557
  preserveClient = true,
547
558
  skipRemote = false,
548
559
  preserveMessage = false,
549
560
  preserveReconnecting = false,
550
- preserveTracks = false
561
+ preserveTracks = false,
562
+ abortSignal = null
551
563
  } = options
552
- if (this.destroyed && !this.queue) {
553
- this._reconnectNonce++
554
- if (this._reconnectTimers) {
555
- _functions.clearTimers(this._reconnectTimers)
556
- this._reconnectTimers = null
557
- }
558
- this._reconnecting = false
559
- this._isActivelyReconnecting = false
560
- return this
561
- }
562
564
 
563
- if (!this.destroyed) {
564
- this._reconnectNonce++
565
- this.destroyed = true
566
- this._clearVoiceRecovery(undefined, 'destroyed')
567
- if (this.aqua?.debugTrace) {
568
- this.aqua._trace('player.destroy', {
569
- guildId: this.guildId,
570
- skipRemote: !!skipRemote,
571
- preserveTracks: !!preserveTracks,
572
- preserveReconnecting: !!preserveReconnecting
573
- })
574
- }
575
- this.emit('destroy')
565
+ this._reconnectNonce++
566
+ this.destroyed = true
567
+ this._clearVoiceRecovery(undefined, 'destroyed')
568
+ if (this.aqua?.debugTrace) {
569
+ this.aqua._trace('player.destroy', {
570
+ guildId: this.guildId,
571
+ skipRemote: !!skipRemote,
572
+ preserveTracks: !!preserveTracks,
573
+ preserveReconnecting: !!preserveReconnecting
574
+ })
576
575
  }
576
+ this.emit('destroy')
577
577
 
578
578
  if (this._voiceWatchdogTimer) {
579
579
  clearInterval(this._voiceWatchdogTimer)
@@ -583,7 +583,6 @@ class Player extends EventEmitter {
583
583
  _functions.clearTimers(this._pendingTimers)
584
584
  this._pendingTimers = null
585
585
 
586
- // Clear reconnection timers to prevent memory leaks when destroyed externally
587
586
  if (this._reconnectTimers) {
588
587
  _functions.clearTimers(this._reconnectTimers)
589
588
  this._reconnectTimers = null
@@ -633,8 +632,23 @@ class Player extends EventEmitter {
633
632
  this.previousTracks = null
634
633
  this.previousIdentifiers?.clear()
635
634
  this.previousIdentifiers = null
636
- this.queue?.clear()
635
+ if (this.queue) {
636
+ for (
637
+ let i = this.queue._head || 0;
638
+ i < (this.queue._items?.length || 0);
639
+ i++
640
+ ) {
641
+ const t = this.queue._items[i]
642
+ if (t && typeof t.dispose === 'function') {
643
+ try {
644
+ t.dispose()
645
+ } catch {}
646
+ }
647
+ }
648
+ this.queue.clear()
649
+ }
637
650
  this.queue = null
651
+ // ML-1: Clear _dataStore to prevent unbounded growth
638
652
  this._dataStore?.clear()
639
653
  this._dataStore = null
640
654
 
@@ -662,14 +676,25 @@ class Player extends EventEmitter {
662
676
 
663
677
  if (!skipRemote) {
664
678
  try {
665
- this.send({ guild_id: this.guildId, channel_id: null })
666
- this.aqua?.destroyPlayer?.(this.guildId)
667
- if (this.nodes?.connected)
668
- this.nodes.rest?.destroyPlayer(this.guildId).catch((error) =>
669
- reportSuppressedError(this, 'player.destroy.remote', error, {
670
- guildId: this.guildId
679
+ if (abortSignal?.aborted) {
680
+ if (this.aqua?.debugTrace) {
681
+ this.aqua._trace('player.destroy.aborted', {
682
+ guildId: this.guildId,
683
+ reason: 'abort_signal_already_set'
671
684
  })
672
- )
685
+ }
686
+ } else {
687
+ this.send({ guild_id: this.guildId, channel_id: null })
688
+ this.aqua?.destroyPlayer?.(this.guildId)
689
+ if (this.nodes?.connected)
690
+ this.nodes.rest
691
+ ?.destroyPlayer(this.guildId, abortSignal)
692
+ .catch((error) => {
693
+ reportSuppressedError(this, 'player.destroy.remote', error, {
694
+ guildId: this.guildId
695
+ })
696
+ })
697
+ }
673
698
  } catch (error) {
674
699
  reportSuppressedError(this, 'player.destroy.gateway', error, {
675
700
  guildId: this.guildId
@@ -677,12 +702,23 @@ class Player extends EventEmitter {
677
702
  }
678
703
  }
679
704
 
680
- if (!preserveClient) this.aqua = this.nodes = null
705
+ if (!preserveClient) {
706
+ try {
707
+ this.aqua?.removeListener?.('playerUpdate', this._boundPlayerUpdate)
708
+ } catch {}
709
+ this.aqua = this.nodes = null
710
+ }
681
711
  return this
682
712
  }
683
713
 
684
714
  pause(paused) {
685
- if (this.destroyed || this.paused === !!paused) return this
715
+ if (this.destroyed) return this
716
+ if (paused != null && typeof paused !== 'boolean') {
717
+ throw new TypeError(
718
+ `Player.pause(): paused must be a boolean, got ${typeof paused}`
719
+ )
720
+ }
721
+ if (this.paused === !!paused) return this
686
722
  this.paused = !!paused
687
723
  this.batchUpdatePlayer({ paused: this.paused }, true).catch((error) =>
688
724
  reportSuppressedError(this, 'player.pause', error, {
@@ -694,8 +730,12 @@ class Player extends EventEmitter {
694
730
  }
695
731
 
696
732
  seek(position) {
697
- if (this.destroyed || !this.playing || !_functions.isNum(position))
698
- return this
733
+ if (position == null || !_functions.isNum(position)) {
734
+ throw new TypeError(
735
+ `Player.seek(): position must be a non-negative number, got ${typeof position}`
736
+ )
737
+ }
738
+ if (this.destroyed || !this.playing) return this
699
739
  const len = this.current?.info?.length || 0
700
740
  const clamped = len
701
741
  ? Math.min(Math.max(position, 0), len)
@@ -769,6 +809,14 @@ class Player extends EventEmitter {
769
809
  }
770
810
 
771
811
  setVolume(volume) {
812
+ if (
813
+ volume == null ||
814
+ (typeof volume !== 'number' && typeof volume !== 'string')
815
+ ) {
816
+ throw new TypeError(
817
+ `Player.setVolume(): volume must be a number, got ${typeof volume}`
818
+ )
819
+ }
772
820
  const vol = _functions.clamp(volume)
773
821
  if (this.destroyed || this.volume === vol) return this
774
822
  this.volume = vol
@@ -1029,6 +1077,7 @@ class Player extends EventEmitter {
1029
1077
  const isReplaced = reason === 'replaced'
1030
1078
 
1031
1079
  if (track) this.previousTracks.push(track)
1080
+ if (isReplaced) return
1032
1081
  if (this.shouldDeleteMessage && !this._reconnecting && !this._resuming)
1033
1082
  _functions.safeDel(this.nowPlayingMessage)
1034
1083
  if (!isReplaced) this.current = null
@@ -1123,8 +1172,8 @@ class Player extends EventEmitter {
1123
1172
  _functions.emitIfActive(this, AqualinkEvents.MixEnded, t, payload)
1124
1173
  }
1125
1174
 
1126
- async _attemptVoiceResume() {
1127
- return this._lifecycleController.attemptVoiceResume()
1175
+ async _attemptVoiceResume(abortSignal) {
1176
+ return this._lifecycleController.attemptVoiceResume(abortSignal)
1128
1177
  }
1129
1178
 
1130
1179
  async socketClosed(_player, _track, payload) {
@@ -1142,7 +1191,7 @@ class Player extends EventEmitter {
1142
1191
  } catch (err) {
1143
1192
  _functions.emitAquaError(
1144
1193
  this.aqua,
1145
- new Error(`Send fail: ${err.message}`)
1194
+ new Error(`Send fail (guild=${this.guildId}): ${err.message}`)
1146
1195
  )
1147
1196
  return false
1148
1197
  }
@@ -30,6 +30,8 @@ class PlayerLifecycle {
30
30
  player.ping = this._functions.isNum(s.ping) ? s.ping : 0
31
31
  player.timestamp = this._functions.isNum(s.time) ? s.time : Date.now()
32
32
 
33
+ if (player.destroyed) return
34
+
33
35
  if (!player.connected) {
34
36
  if (wasConnected || !player._voiceDownSince) {
35
37
  if (player.aqua?.debugTrace) {
@@ -148,7 +150,9 @@ class PlayerLifecycle {
148
150
  if (!hasVoiceData) {
149
151
  const downFor = Date.now() - player._voiceDownSince
150
152
  if (downFor > this.VOICE_DOWN_THRESHOLD * this.VOICE_ABANDON_MULTIPLIER) {
151
- const recoveryToken = player._claimVoiceRecovery('watchdog_voice_refresh')
153
+ const recoveryToken = player._claimVoiceRecovery(
154
+ 'watchdog_voice_refresh'
155
+ )
152
156
  if (player._isVoiceRecoveryActive(recoveryToken))
153
157
  player.connection?._requestVoiceState?.()
154
158
  if (player._isVoiceRecoveryActive(recoveryToken))
@@ -215,11 +219,15 @@ class PlayerLifecycle {
215
219
  }
216
220
  }
217
221
 
218
- async attemptVoiceResume() {
222
+ async attemptVoiceResume(abortSignal) {
219
223
  const player = this.player
220
- if (!player.connection?.sessionId) throw new Error('No session')
224
+ if (!player.connection?.sessionId)
225
+ throw new Error(`No session (guild=${player.guildId})`)
226
+ if (abortSignal?.aborted) throw new Error('Resume aborted by signal')
221
227
  if (!(await player.connection.attemptResume()))
222
- throw new Error('Resume failed')
228
+ throw new Error(
229
+ `Resume failed (guild=${player.guildId}, endpoint=${player.connection.endpoint || 'none'})`
230
+ )
223
231
  }
224
232
 
225
233
  async socketClosed(_player, _track, payload) {
@@ -342,7 +350,8 @@ class PlayerLifecycle {
342
350
  return false
343
351
  })
344
352
  }
345
- if (resumed) player._clearVoiceRecovery(recoveryToken, 'socket_soft_resumed')
353
+ if (resumed)
354
+ player._clearVoiceRecovery(recoveryToken, 'socket_soft_resumed')
346
355
  if (
347
356
  resumed ||
348
357
  player.connected ||
@@ -2,6 +2,7 @@ class Queue {
2
2
  constructor() {
3
3
  this._items = []
4
4
  this._head = 0
5
+ this._compactThreshold = 64
5
6
  }
6
7
 
7
8
  get size() {
@@ -40,13 +41,16 @@ class Queue {
40
41
 
41
42
  _compact(force = false) {
42
43
  if (this._head <= 0) return
43
- if (!force && this._head <= this._items.length / 2) return
44
+ if (!force && this._head < this._compactThreshold) return
44
45
  const len = this._items.length - this._head
45
46
  for (let i = 0; i < len; i++) {
46
47
  this._items[i] = this._items[this._head + i]
47
48
  }
48
49
  this._items.length = len
49
50
  this._head = 0
51
+ if (this._compactThreshold < len) {
52
+ this._compactThreshold = Math.min(len * 2, 1024)
53
+ }
50
54
  }
51
55
 
52
56
  shuffle() {
@@ -134,6 +134,7 @@ class Rest {
134
134
  this.aqua = aqua
135
135
  this.node = node
136
136
  this.sessionId = node.sessionId
137
+ this._sessionGeneration = 0
137
138
  this.timeout = node.timeout || 30000
138
139
 
139
140
  const protocol = node.ssl ? 'https:' : 'http:'
@@ -236,10 +237,18 @@ class Rest {
236
237
 
237
238
  setSessionId(sessionId) {
238
239
  this.sessionId = sessionId
240
+ this._sessionGeneration++
239
241
  }
240
242
 
241
- _getSessionPath() {
243
+ _getSessionPath(generation) {
242
244
  if (!this.sessionId) throw ERRORS.NO_SESSION
245
+ if (generation != null && generation !== this._sessionGeneration) {
246
+ const staleErr = new Error(
247
+ `Stale session: sessionId was updated (expected gen ${generation}, current ${this._sessionGeneration}) for session ${this.sessionId}`
248
+ )
249
+ staleErr.statusCode = 404
250
+ throw staleErr
251
+ }
243
252
  return `${this._apiBase}/sessions/${this.sessionId}`
244
253
  }
245
254
 
@@ -639,28 +648,33 @@ class Rest {
639
648
  }
640
649
 
641
650
  async updatePlayer({ guildId, data, noReplace = false }) {
651
+ const gen = this._sessionGeneration
642
652
  return this.makeRequest(
643
653
  'PATCH',
644
- `${this._getSessionPath()}/players/${guildId}?noReplace=${noReplace}`,
654
+ `${this._getSessionPath(gen)}/players/${guildId}?noReplace=${noReplace}`,
645
655
  data
646
656
  )
647
657
  }
648
658
 
649
659
  async getPlayer(guildId) {
660
+ const gen = this._sessionGeneration
650
661
  return this.makeRequest(
651
662
  'GET',
652
- `${this._getSessionPath()}/players/${guildId}`
663
+ `${this._getSessionPath(gen)}/players/${guildId}`
653
664
  )
654
665
  }
655
666
 
656
667
  async getPlayers() {
657
- return this.makeRequest('GET', `${this._getSessionPath()}/players`)
668
+ const gen = this._sessionGeneration
669
+ return this.makeRequest('GET', `${this._getSessionPath(gen)}/players`)
658
670
  }
659
671
 
660
- async destroyPlayer(guildId) {
672
+ async destroyPlayer(guildId, abortSignal) {
673
+ const gen = this._sessionGeneration
674
+ if (abortSignal?.aborted) return null
661
675
  return this.makeRequest(
662
676
  'DELETE',
663
- `${this._getSessionPath()}/players/${guildId}`
677
+ `${this._getSessionPath(gen)}/players/${guildId}`
664
678
  )
665
679
  }
666
680
 
@@ -733,9 +747,10 @@ class Rest {
733
747
 
734
748
  if (guildId) {
735
749
  try {
750
+ const gen = this._sessionGeneration
736
751
  const lyrics = await this.makeRequest(
737
752
  'GET',
738
- `${this._getSessionPath()}/players/${guildId}/track/lyrics?skipTrackSource=${skip}`
753
+ `${this._getSessionPath(gen)}/players/${guildId}/track/lyrics?skipTrackSource=${skip}`
739
754
  )
740
755
  if (this._validLyrics(lyrics)) return lyrics
741
756
  } catch {}
@@ -776,10 +791,11 @@ class Rest {
776
791
 
777
792
  async subscribeLiveLyrics(guildId, skipTrackSource = false) {
778
793
  try {
794
+ const gen = this._sessionGeneration
779
795
  return (
780
796
  (await this.makeRequest(
781
797
  'POST',
782
- `${this._getSessionPath()}/players/${guildId}/lyrics/subscribe?skipTrackSource=${skipTrackSource ? 'true' : 'false'}`
798
+ `${this._getSessionPath(gen)}/players/${guildId}/lyrics/subscribe?skipTrackSource=${skipTrackSource ? 'true' : 'false'}`
783
799
  )) === null
784
800
  )
785
801
  } catch {
@@ -789,10 +805,11 @@ class Rest {
789
805
 
790
806
  async unsubscribeLiveLyrics(guildId) {
791
807
  try {
808
+ const gen = this._sessionGeneration
792
809
  return (
793
810
  (await this.makeRequest(
794
811
  'DELETE',
795
- `${this._getSessionPath()}/players/${guildId}/lyrics/subscribe`
812
+ `${this._getSessionPath(gen)}/players/${guildId}/lyrics/subscribe`
796
813
  )) === null
797
814
  )
798
815
  } catch {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aqualink",
3
- "version": "3.0.0",
3
+ "version": "3.1.0",
4
4
  "description": "An Lavalink/Nodelink client, focused in pure performance and features",
5
5
  "main": "./build/index.js",
6
6
  "types": "./build/index.d.ts",