aqualink 3.0.0 → 3.2.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.
@@ -35,6 +35,7 @@ const EVENT_HANDLERS = Object.freeze({
35
35
  PlayerDestroyedEvent: 'playerDestroyed',
36
36
  LyricsNotFoundEvent: 'lyricsNotFound',
37
37
  MixStartedEvent: 'mixStarted',
38
+ PlayerReconnectingEvent: 'PlayerReconnectingEvent',
38
39
  MixEndedEvent: 'mixEnded'
39
40
  })
40
41
 
@@ -122,7 +123,8 @@ class MicrotaskUpdateBatcher {
122
123
  const { player: p, updates: u } = this
123
124
  this.updates = null
124
125
  this.scheduled = false
125
- if (!u || !p) return Promise.resolve()
126
+ if (!u || !p || p.destroyed || p.state === PLAYER_STATE.DISCONNECTING)
127
+ return Promise.resolve()
126
128
  return p.updatePlayer(u).catch((err) => {
127
129
  _functions.emitAquaError(
128
130
  p.aqua,
@@ -296,7 +298,9 @@ class Player extends EventEmitter {
296
298
  }
297
299
 
298
300
  _isVoiceRecoveryActive(token) {
299
- return !!token && !this.destroyed && this._activeVoiceRecoveryToken === token
301
+ return (
302
+ !!token && !this.destroyed && this._activeVoiceRecoveryToken === token
303
+ )
300
304
  }
301
305
 
302
306
  _clearVoiceRecovery(token = this._activeVoiceRecoveryToken, reason = null) {
@@ -352,6 +356,11 @@ class Player extends EventEmitter {
352
356
 
353
357
  async play(track, options = {}) {
354
358
  if (this.destroyed || !this.queue) return this
359
+ if (options != null && typeof options !== 'object') {
360
+ throw new TypeError(
361
+ `Player.play(): options must be an object, got ${typeof options}`
362
+ )
363
+ }
355
364
 
356
365
  let item = track
357
366
  if (!item) {
@@ -484,7 +493,8 @@ class Player extends EventEmitter {
484
493
  }
485
494
 
486
495
  connect(options = {}) {
487
- if (this.destroyed) throw new Error('Cannot connect destroyed player')
496
+ if (this.destroyed)
497
+ throw new Error(`Cannot connect destroyed player (guild=${this.guildId})`)
488
498
 
489
499
  const voiceChannel = _functions.toId(
490
500
  options.voiceChannel || this.voiceChannel
@@ -542,38 +552,29 @@ class Player extends EventEmitter {
542
552
  }
543
553
 
544
554
  destroy(options = {}) {
555
+ if (this.destroyed) return this
556
+
545
557
  const {
546
558
  preserveClient = true,
547
559
  skipRemote = false,
548
560
  preserveMessage = false,
549
561
  preserveReconnecting = false,
550
- preserveTracks = false
562
+ preserveTracks = false,
563
+ abortSignal = null
551
564
  } = 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
565
 
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')
566
+ this._reconnectNonce++
567
+ this.destroyed = true
568
+ this._clearVoiceRecovery(undefined, 'destroyed')
569
+ if (this.aqua?.debugTrace) {
570
+ this.aqua._trace('player.destroy', {
571
+ guildId: this.guildId,
572
+ skipRemote: !!skipRemote,
573
+ preserveTracks: !!preserveTracks,
574
+ preserveReconnecting: !!preserveReconnecting
575
+ })
576
576
  }
577
+ this.emit('destroy')
577
578
 
578
579
  if (this._voiceWatchdogTimer) {
579
580
  clearInterval(this._voiceWatchdogTimer)
@@ -583,7 +584,6 @@ class Player extends EventEmitter {
583
584
  _functions.clearTimers(this._pendingTimers)
584
585
  this._pendingTimers = null
585
586
 
586
- // Clear reconnection timers to prevent memory leaks when destroyed externally
587
587
  if (this._reconnectTimers) {
588
588
  _functions.clearTimers(this._reconnectTimers)
589
589
  this._reconnectTimers = null
@@ -633,8 +633,23 @@ class Player extends EventEmitter {
633
633
  this.previousTracks = null
634
634
  this.previousIdentifiers?.clear()
635
635
  this.previousIdentifiers = null
636
- this.queue?.clear()
636
+ if (this.queue) {
637
+ for (
638
+ let i = this.queue._head || 0;
639
+ i < (this.queue._items?.length || 0);
640
+ i++
641
+ ) {
642
+ const t = this.queue._items[i]
643
+ if (t && typeof t.dispose === 'function') {
644
+ try {
645
+ t.dispose()
646
+ } catch {}
647
+ }
648
+ }
649
+ this.queue.clear()
650
+ }
637
651
  this.queue = null
652
+ // ML-1: Clear _dataStore to prevent unbounded growth
638
653
  this._dataStore?.clear()
639
654
  this._dataStore = null
640
655
 
@@ -662,14 +677,25 @@ class Player extends EventEmitter {
662
677
 
663
678
  if (!skipRemote) {
664
679
  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
680
+ if (abortSignal?.aborted) {
681
+ if (this.aqua?.debugTrace) {
682
+ this.aqua._trace('player.destroy.aborted', {
683
+ guildId: this.guildId,
684
+ reason: 'abort_signal_already_set'
671
685
  })
672
- )
686
+ }
687
+ } else {
688
+ this.send({ guild_id: this.guildId, channel_id: null })
689
+ this.aqua?.destroyPlayer?.(this.guildId)
690
+ if (this.nodes?.connected)
691
+ this.nodes.rest
692
+ ?.destroyPlayer(this.guildId, abortSignal)
693
+ .catch((error) => {
694
+ reportSuppressedError(this, 'player.destroy.remote', error, {
695
+ guildId: this.guildId
696
+ })
697
+ })
698
+ }
673
699
  } catch (error) {
674
700
  reportSuppressedError(this, 'player.destroy.gateway', error, {
675
701
  guildId: this.guildId
@@ -677,12 +703,23 @@ class Player extends EventEmitter {
677
703
  }
678
704
  }
679
705
 
680
- if (!preserveClient) this.aqua = this.nodes = null
706
+ if (!preserveClient) {
707
+ try {
708
+ this.aqua?.removeListener?.('playerUpdate', this._boundPlayerUpdate)
709
+ } catch {}
710
+ this.aqua = this.nodes = null
711
+ }
681
712
  return this
682
713
  }
683
714
 
684
715
  pause(paused) {
685
- if (this.destroyed || this.paused === !!paused) return this
716
+ if (this.destroyed) return this
717
+ if (paused != null && typeof paused !== 'boolean') {
718
+ throw new TypeError(
719
+ `Player.pause(): paused must be a boolean, got ${typeof paused}`
720
+ )
721
+ }
722
+ if (this.paused === !!paused) return this
686
723
  this.paused = !!paused
687
724
  this.batchUpdatePlayer({ paused: this.paused }, true).catch((error) =>
688
725
  reportSuppressedError(this, 'player.pause', error, {
@@ -694,8 +731,12 @@ class Player extends EventEmitter {
694
731
  }
695
732
 
696
733
  seek(position) {
697
- if (this.destroyed || !this.playing || !_functions.isNum(position))
698
- return this
734
+ if (position == null || !_functions.isNum(position)) {
735
+ throw new TypeError(
736
+ `Player.seek(): position must be a non-negative number, got ${typeof position}`
737
+ )
738
+ }
739
+ if (this.destroyed || !this.playing) return this
699
740
  const len = this.current?.info?.length || 0
700
741
  const clamped = len
701
742
  ? Math.min(Math.max(position, 0), len)
@@ -769,6 +810,14 @@ class Player extends EventEmitter {
769
810
  }
770
811
 
771
812
  setVolume(volume) {
813
+ if (
814
+ volume == null ||
815
+ (typeof volume !== 'number' && typeof volume !== 'string')
816
+ ) {
817
+ throw new TypeError(
818
+ `Player.setVolume(): volume must be a number, got ${typeof volume}`
819
+ )
820
+ }
772
821
  const vol = _functions.clamp(volume)
773
822
  if (this.destroyed || this.volume === vol) return this
774
823
  this.volume = vol
@@ -909,22 +958,24 @@ class Player extends EventEmitter {
909
958
  const prev = this.previous
910
959
  const info = prev?.info
911
960
  if (!info?.sourceName || !info.identifier) return this
912
- const { sourceName, identifier, uri, author } = info
961
+ const { sourceName, identifier, uri, author, title } = info
913
962
  this.isAutoplay = true
914
963
 
915
- if (sourceName === 'spotify' && info.identifier) {
916
- this.previousIdentifiers.add(info.identifier)
917
- if (this.previousIdentifiers.size > PREVIOUS_IDS_MAX) {
918
- this.previousIdentifiers.delete(
919
- this.previousIdentifiers.values().next().value
920
- )
921
- }
922
- if (!this.autoplaySeed) {
923
- this.autoplaySeed = {
924
- trackId: identifier,
925
- artistIds: Array.isArray(author) ? author.join(',') : author
964
+ if (sourceName === 'spotify') {
965
+ if (info.identifier && info.identifier !== 'local') {
966
+ this.previousIdentifiers.add(info.identifier)
967
+ if (this.previousIdentifiers.size > PREVIOUS_IDS_MAX) {
968
+ this.previousIdentifiers.delete(
969
+ this.previousIdentifiers.values().next().value
970
+ )
926
971
  }
927
972
  }
973
+ this.autoplaySeed = {
974
+ trackId: identifier,
975
+ isrc: prev.isrc || null,
976
+ title: title || null,
977
+ author: author || null
978
+ }
928
979
  }
929
980
 
930
981
  for (
@@ -972,27 +1023,45 @@ class Player extends EventEmitter {
972
1023
  }
973
1024
 
974
1025
  async _getAutoplayTrack(sourceName, identifier, uri, requester) {
1026
+ const seen = new Set(this.previousIdentifiers)
1027
+ const prevId = this.current?.identifier
1028
+ if (prevId) seen.add(prevId)
1029
+
975
1030
  if (sourceName === 'youtube' || sourceName === 'ytmusic') {
976
1031
  const res = await this.aqua.resolve({
977
1032
  query: `https://www.youtube.com/watch?v=${identifier}&list=RD${identifier}`,
978
1033
  source: 'ytmsearch',
979
1034
  requester
980
1035
  })
981
- return _functions.isInvalidLoad(res)
982
- ? null
1036
+ if (_functions.isInvalidLoad(res) || !res.tracks?.length) return null
1037
+ const candidates = res.tracks.filter(
1038
+ (t) => t.identifier && !seen.has(t.identifier)
1039
+ )
1040
+ return candidates.length
1041
+ ? candidates[_functions.randIdx(candidates.length)]
983
1042
  : res.tracks[_functions.randIdx(res.tracks.length)]
984
1043
  }
985
1044
  if (sourceName === 'soundcloud') {
986
- const scRes = await scAutoPlay(uri)
1045
+ const scRes = await scAutoPlay(uri, this.nodes?.rest?._autoplayAgent)
987
1046
  if (!scRes?.length) return null
988
- const res = await this.aqua.resolve({
989
- query: scRes[0],
990
- source: 'scsearch',
991
- requester
992
- })
993
- return _functions.isInvalidLoad(res)
994
- ? null
995
- : res.tracks[_functions.randIdx(res.tracks.length)]
1047
+
1048
+ let anyValid = null
1049
+ for (const link of scRes) {
1050
+ const res = await this.aqua.resolve({
1051
+ query: link,
1052
+ source: 'scsearch',
1053
+ requester
1054
+ })
1055
+ if (_functions.isInvalidLoad(res) || !res.tracks?.length) continue
1056
+ if (!anyValid)
1057
+ anyValid = res.tracks[_functions.randIdx(res.tracks.length)]
1058
+ const candidates = res.tracks.filter(
1059
+ (t) => t.identifier && !seen.has(t.identifier)
1060
+ )
1061
+ if (candidates.length)
1062
+ return candidates[_functions.randIdx(candidates.length)]
1063
+ }
1064
+ return anyValid
996
1065
  }
997
1066
  if (sourceName === 'spotify') {
998
1067
  const res = await spAutoPlay(
@@ -1029,6 +1098,7 @@ class Player extends EventEmitter {
1029
1098
  const isReplaced = reason === 'replaced'
1030
1099
 
1031
1100
  if (track) this.previousTracks.push(track)
1101
+ if (isReplaced) return
1032
1102
  if (this.shouldDeleteMessage && !this._reconnecting && !this._resuming)
1033
1103
  _functions.safeDel(this.nowPlayingMessage)
1034
1104
  if (!isReplaced) this.current = null
@@ -1122,9 +1192,19 @@ class Player extends EventEmitter {
1122
1192
  mixEnded(_p, t, payload) {
1123
1193
  _functions.emitIfActive(this, AqualinkEvents.MixEnded, t, payload)
1124
1194
  }
1195
+ PlayerReconnectingEvent(_p, _t, payload) {
1196
+ this._resuming = true
1197
+ _functions.emitIfActive(
1198
+ this,
1199
+ AqualinkEvents.PlayerReconnectingEvent,
1200
+ payload
1201
+ )
1202
+ this.connection?.resendVoiceUpdate?.(true)
1203
+ this.aqua.emit(AqualinkEvents.PlayerReconnect, this, { resuming: true })
1204
+ }
1125
1205
 
1126
- async _attemptVoiceResume() {
1127
- return this._lifecycleController.attemptVoiceResume()
1206
+ async _attemptVoiceResume(abortSignal) {
1207
+ return this._lifecycleController.attemptVoiceResume(abortSignal)
1128
1208
  }
1129
1209
 
1130
1210
  async socketClosed(_player, _track, payload) {
@@ -1142,7 +1222,7 @@ class Player extends EventEmitter {
1142
1222
  } catch (err) {
1143
1223
  _functions.emitAquaError(
1144
1224
  this.aqua,
1145
- new Error(`Send fail: ${err.message}`)
1225
+ new Error(`Send fail (guild=${this.guildId}): ${err.message}`)
1146
1226
  )
1147
1227
  return false
1148
1228
  }
@@ -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) {
@@ -233,6 +241,11 @@ class PlayerLifecycle {
233
241
  }
234
242
 
235
243
  const code = payload?.code
244
+ if (code === 4014) {
245
+ await player._delay(150)
246
+ if (player.destroyed) return
247
+ }
248
+
236
249
  if (code === 4006 && player._resuming) {
237
250
  if (player.aqua?.debugTrace) {
238
251
  player.aqua._trace('player.socketClosed.ignored', {
@@ -245,7 +258,10 @@ class PlayerLifecycle {
245
258
  }
246
259
 
247
260
  let isRecoverable = [4015, 4009, 4006, 4014, 4022].includes(code)
248
- if (code === 4014 && player.connection?.isWaitingForDisconnect)
261
+ if (
262
+ code === 4014 &&
263
+ (player.connection?.isWaitingForDisconnect || !player.voiceChannel)
264
+ )
249
265
  isRecoverable = false
250
266
 
251
267
  if (code === 4015 && !player.nodes?.info?.isNodelink) {
@@ -342,7 +358,8 @@ class PlayerLifecycle {
342
358
  return false
343
359
  })
344
360
  }
345
- if (resumed) player._clearVoiceRecovery(recoveryToken, 'socket_soft_resumed')
361
+ if (resumed)
362
+ player._clearVoiceRecovery(recoveryToken, 'socket_soft_resumed')
346
363
  if (
347
364
  resumed ||
348
365
  player.connected ||
@@ -443,6 +460,7 @@ class PlayerLifecycle {
443
460
  if (
444
461
  latestActivePlayer &&
445
462
  latestActivePlayer !== player &&
463
+ latestActivePlayer !== np &&
446
464
  !latestActivePlayer.destroyed
447
465
  ) {
448
466
  try {
@@ -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() {
@@ -26,7 +26,19 @@ function reportSuppressedError(target, scope, error, data = null) {
26
26
  return err
27
27
  }
28
28
 
29
+ function emitOperationalError(target, source, error) {
30
+ const aqua = getAqua(target)
31
+ const err = normalizeError(error)
32
+ if (aqua?.listenerCount?.(AqualinkEvents.Error) > 0) {
33
+ aqua.emit(AqualinkEvents.Error, source, err)
34
+ } else if (aqua?.debugTrace) {
35
+ aqua._trace('aqua.error.unhandled', { error: err.message })
36
+ }
37
+ return err
38
+ }
39
+
29
40
  module.exports = {
41
+ emitOperationalError,
30
42
  normalizeError,
31
43
  reportSuppressedError
32
44
  }