aqualink 2.20.1 → 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.
@@ -2,6 +2,9 @@ const { EventEmitter } = require('node:events')
2
2
  const { AqualinkEvents } = require('./AqualinkEvents')
3
3
  const Connection = require('./Connection')
4
4
  const Filters = require('./Filters')
5
+ const PlayerLifecycle = require('./PlayerLifecycle')
6
+ const { attachPlayerLifecycleState } = require('./PlayerLifecycleState')
7
+ const { reportSuppressedError } = require('./Reporting')
5
8
  const { spAutoPlay, scAutoPlay } = require('../handlers/autoplay')
6
9
  const Queue = require('./Queue')
7
10
 
@@ -43,6 +46,8 @@ const MUTE_TOGGLE_DELAY = 300
43
46
  const SEEK_DELAY = 800
44
47
  const PAUSE_DELAY = 1200
45
48
  const VOICE_TRACE_INTERVAL = 15000
49
+ const PLAYER_UPDATE_SILENCE_THRESHOLD = 45000
50
+ const VOICE_FORCE_DESTROY_MS = 15 * 60 * 1000
46
51
  const RETRY_BACKOFF_BASE = 1500
47
52
  const RETRY_BACKOFF_MAX = 5000
48
53
  const PREVIOUS_TRACKS_SIZE = 50
@@ -75,6 +80,20 @@ const _functions = {
75
80
  for (const t of set) clearTimeout(t)
76
81
  set.clear()
77
82
  },
83
+ safeCall(fn) {
84
+ try {
85
+ return fn?.()
86
+ } catch {}
87
+ return null
88
+ },
89
+ emitAquaError(aqua, error) {
90
+ if (!aqua?.listenerCount) return
91
+ try {
92
+ if (aqua.listenerCount(AqualinkEvents.Error) > 0) {
93
+ aqua.emit(AqualinkEvents.Error, error)
94
+ }
95
+ } catch {}
96
+ },
78
97
  emitIfActive(player, event, ...args) {
79
98
  if (!player.destroyed) player.aqua.emit(event, player, ...args)
80
99
  }
@@ -103,13 +122,13 @@ class MicrotaskUpdateBatcher {
103
122
  const { player: p, updates: u } = this
104
123
  this.updates = null
105
124
  this.scheduled = false
106
- if (!u || !p) return Promise.resolve()
125
+ if (!u || !p || p.destroyed || p.state === PLAYER_STATE.DISCONNECTING)
126
+ return Promise.resolve()
107
127
  return p.updatePlayer(u).catch((err) => {
108
- p.aqua?.emit?.(
109
- AqualinkEvents.Error,
128
+ _functions.emitAquaError(
129
+ p.aqua,
110
130
  new Error(`Update error: ${err.message}`)
111
131
  )
112
- throw err
113
132
  })
114
133
  }
115
134
 
@@ -190,11 +209,11 @@ class Player extends EventEmitter {
190
209
  this.mute = !!options.mute
191
210
  this.autoplayRetries = this.reconnectionRetries = 0
192
211
  this._voiceDownSince = 0
193
- this._voiceRecovering = this._reconnecting = false
194
- this._resuming = !!options.resuming
212
+ attachPlayerLifecycleState(this, { resuming: !!options.resuming })
195
213
  this._voiceWatchdogTimer = null
196
214
  this._pendingTimers = new Set()
197
215
  this._reconnectTimers = null
216
+ this._reconnectNonce = 0
198
217
  this._dataStore = null
199
218
 
200
219
  this.volume = _functions.clamp(options.defaultVolume || 100)
@@ -210,12 +229,30 @@ class Player extends EventEmitter {
210
229
  this.previousIdentifiers = new Set()
211
230
  this.previousTracks = new CircularBuffer(PREVIOUS_TRACKS_SIZE)
212
231
  this._updateBatcher = batcherPool.acquire(this)
232
+ this._lifecycleController = new PlayerLifecycle(this, {
233
+ _functions,
234
+ PLAYER_STATE,
235
+ VOICE_TRACE_INTERVAL,
236
+ PLAYER_UPDATE_SILENCE_THRESHOLD,
237
+ VOICE_DOWN_THRESHOLD,
238
+ VOICE_ABANDON_MULTIPLIER,
239
+ VOICE_FORCE_DESTROY_MS,
240
+ RECONNECT_MAX,
241
+ MUTE_TOGGLE_DELAY,
242
+ SEEK_DELAY,
243
+ PAUSE_DELAY,
244
+ RETRY_BACKOFF_BASE,
245
+ RETRY_BACKOFF_MAX
246
+ })
213
247
 
214
248
  this._voiceRequestAt = 0
215
249
  this._voiceRequestChannel = null
216
250
  this._suppressResumeUntil = 0
217
- this._deferredStart = false
218
251
  this._lastVoiceUpTraceAt = 0
252
+ this._lastPlayerUpdateAt = Date.now()
253
+ this._voiceRecoverySeq = 0
254
+ this._activeVoiceRecoveryToken = 0
255
+ this._voiceRecoveryReason = null
219
256
  this._bindEvents()
220
257
  this._startWatchdog()
221
258
  }
@@ -252,58 +289,28 @@ class Player extends EventEmitter {
252
289
  return new Promise((r) => this._createTimer(r, ms))
253
290
  }
254
291
 
255
- _handlePlayerUpdate(packet) {
256
- if (this.destroyed || !packet?.state) return
257
- const s = packet.state
258
- const wasConnected = this.connected
259
- this.position = _functions.isNum(s.position) ? s.position : 0
260
- this.connected = !!s.connected
261
- this.ping = _functions.isNum(s.ping) ? s.ping : 0
262
- this.timestamp = _functions.isNum(s.time) ? s.time : Date.now()
263
-
264
- if (!this.connected) {
265
- if (wasConnected || !this._voiceDownSince) {
266
- this.aqua?._trace?.('player.voice.down', {
267
- guildId: this.guildId,
268
- reconnecting: !!this._reconnecting,
269
- recovering: !!this._voiceRecovering
270
- })
271
- }
272
- if (
273
- !this._voiceDownSince &&
274
- !this._reconnecting &&
275
- !this._voiceRecovering
276
- ) {
277
- this._voiceDownSince = Date.now()
278
- this._createTimer(() => {
279
- if (
280
- this.connected ||
281
- this.destroyed ||
282
- this._reconnecting ||
283
- this._voiceRecovering ||
284
- this.nodes?.info?.isNodelink
285
- )
286
- return
287
- this.connection.attemptResume()
288
- }, 1000)
289
- }
290
- } else {
291
- this._voiceDownSince = 0
292
- this.state = PLAYER_STATE.READY
293
- const now = Date.now()
294
- if (
295
- !wasConnected ||
296
- now - this._lastVoiceUpTraceAt >= VOICE_TRACE_INTERVAL
297
- ) {
298
- this._lastVoiceUpTraceAt = now
299
- this.aqua?._trace?.('player.voice.up', {
300
- guildId: this.guildId,
301
- ping: this.ping
302
- })
303
- }
304
- }
292
+ _claimVoiceRecovery(reason = 'unknown') {
293
+ const token = ++this._voiceRecoverySeq
294
+ this._activeVoiceRecoveryToken = token
295
+ this._voiceRecoveryReason = reason
296
+ return token
297
+ }
298
+
299
+ _isVoiceRecoveryActive(token) {
300
+ return (
301
+ !!token && !this.destroyed && this._activeVoiceRecoveryToken === token
302
+ )
303
+ }
305
304
 
306
- this.aqua.emit(AqualinkEvents.PlayerUpdate, this, packet)
305
+ _clearVoiceRecovery(token = this._activeVoiceRecoveryToken, reason = null) {
306
+ if (!token || this._activeVoiceRecoveryToken !== token) return false
307
+ this._activeVoiceRecoveryToken = 0
308
+ this._voiceRecoveryReason = reason
309
+ return true
310
+ }
311
+
312
+ _handlePlayerUpdate(packet) {
313
+ return this._lifecycleController.handlePlayerUpdate(packet)
307
314
  }
308
315
 
309
316
  async _handleEvent(payload) {
@@ -318,13 +325,9 @@ class Player extends EventEmitter {
318
325
  return
319
326
  }
320
327
  try {
321
- const trackArg =
322
- payload.type === 'TrackStartEvent'
323
- ? payload.track || this.current
324
- : this.current
325
- await this[handler](this, trackArg, payload)
328
+ await this[handler](this, this.current, payload)
326
329
  } catch (error) {
327
- this.aqua.emit(AqualinkEvents.Error, error)
330
+ _functions.emitAquaError(this.aqua, error)
328
331
  }
329
332
  }
330
333
 
@@ -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) {
@@ -370,30 +378,85 @@ class Player extends EventEmitter {
370
378
  }
371
379
  this.current = resolvedItem
372
380
  if (this.destroyed) return this
373
- if (!this.current?.track) throw new Error('Failed to resolve track')
381
+ if (!this.current?.track) {
382
+ this.current = null
383
+ this.playing = false
384
+ if (this.aqua?.debugTrace) {
385
+ this.aqua._trace('player.play.unresolved', {
386
+ guildId: this.guildId,
387
+ reconnecting: !!this._reconnecting,
388
+ resuming: !!this._resuming,
389
+ voiceRecovering: !!this._voiceRecovering
390
+ })
391
+ }
392
+ if (this._reconnecting || this._resuming || this._voiceRecovering)
393
+ return this
394
+ throw new Error('Failed to resolve track')
395
+ }
374
396
 
375
397
  this.playing = true
376
398
  this.paused = !!options.paused
377
399
  this.position = options.startTime || 0
378
- this.aqua?._trace?.('player.play', {
379
- guildId: this.guildId,
380
- paused: this.paused,
381
- startTime: this.position,
382
- hasTrack: !!this.current?.track
383
- })
400
+ if (this.aqua?.debugTrace) {
401
+ this.aqua._trace('player.play', {
402
+ guildId: this.guildId,
403
+ paused: this.paused,
404
+ startTime: this.position,
405
+ hasTrack: !!this.current?.track
406
+ })
407
+ }
384
408
 
385
409
  if (this.destroyed || !this._updateBatcher) return this
386
410
 
411
+ if (
412
+ this.voiceChannel &&
413
+ !this.connected &&
414
+ !this._reconnecting &&
415
+ !this._voiceRecovering
416
+ ) {
417
+ this._deferredStart = true
418
+ const recoveryToken = this._claimVoiceRecovery('play_deferred')
419
+ if (this.aqua?.debugTrace) {
420
+ this.aqua._trace('player.play.deferred', {
421
+ guildId: this.guildId,
422
+ reason: 'voice_not_connected'
423
+ })
424
+ }
425
+ const now = Date.now()
426
+ if (
427
+ now - (this._voiceRequestAt || 0) >= 1200 &&
428
+ this._isVoiceRecoveryActive(recoveryToken)
429
+ ) {
430
+ this._voiceRequestAt = now
431
+ if (this._isVoiceRecoveryActive(recoveryToken))
432
+ this.connection?._requestVoiceState?.()
433
+ if (this._isVoiceRecoveryActive(recoveryToken))
434
+ this.connection?.resendVoiceUpdate?.(true)
435
+ if (this._isVoiceRecoveryActive(recoveryToken))
436
+ _functions.safeCall(() =>
437
+ this.connect({
438
+ guildId: this.guildId,
439
+ voiceChannel: this.voiceChannel,
440
+ deaf: this.deaf,
441
+ mute: this.mute
442
+ })
443
+ )
444
+ }
445
+ return this
446
+ }
447
+
387
448
  if (
388
449
  this.aqua?.autoRegionMigrate &&
389
450
  !this._resuming &&
390
451
  !this.connection?.endpoint
391
452
  ) {
392
453
  this._deferredStart = true
393
- this.aqua?._trace?.('player.play.deferred', {
394
- guildId: this.guildId,
395
- reason: 'awaiting_voice_server_update'
396
- })
454
+ if (this.aqua?.debugTrace) {
455
+ this.aqua._trace('player.play.deferred', {
456
+ guildId: this.guildId,
457
+ reason: 'awaiting_voice_server_update'
458
+ })
459
+ }
397
460
  return this
398
461
  }
399
462
 
@@ -404,16 +467,33 @@ class Player extends EventEmitter {
404
467
  if (this.position > 0) updateData.position = this.position
405
468
 
406
469
  this._deferredStart = false
407
- await this.batchUpdatePlayer(updateData, true)
470
+ await this.batchUpdatePlayer(updateData, true).catch((err) => {
471
+ if (!this.destroyed) _functions.emitAquaError(this.aqua, err)
472
+ })
408
473
  } catch (error) {
409
- if (!this.destroyed) this.aqua?.emit(AqualinkEvents.Error, error)
410
- if (this.queue?.size && !track) return this.play()
474
+ if (
475
+ !this.destroyed &&
476
+ !this._reconnecting &&
477
+ !this._resuming &&
478
+ !this._voiceRecovering
479
+ ) {
480
+ _functions.emitAquaError(this.aqua, error)
481
+ }
482
+ if (
483
+ this.queue?.size &&
484
+ !track &&
485
+ !this._reconnecting &&
486
+ !this._resuming &&
487
+ !this._voiceRecovering
488
+ )
489
+ return this.play()
411
490
  }
412
491
  return this
413
492
  }
414
493
 
415
494
  connect(options = {}) {
416
- 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})`)
417
497
 
418
498
  const voiceChannel = _functions.toId(
419
499
  options.voiceChannel || this.voiceChannel
@@ -429,19 +509,22 @@ class Player extends EventEmitter {
429
509
  this._voiceRequestChannel = voiceChannel
430
510
 
431
511
  this.voiceChannel = voiceChannel
512
+ this._voiceDownSince = 0
432
513
  this.send({
433
514
  guild_id: this.guildId,
434
515
  channel_id: voiceChannel,
435
516
  self_deaf: this.deaf,
436
517
  self_mute: this.mute
437
518
  })
438
- this.aqua?._trace?.('player.connect.request', {
439
- guildId: this.guildId,
440
- txId: this.txId,
441
- voiceChannel,
442
- deaf: this.deaf,
443
- mute: this.mute
444
- })
519
+ if (this.aqua?.debugTrace) {
520
+ this.aqua._trace('player.connect.request', {
521
+ guildId: this.guildId,
522
+ txId: this.txId,
523
+ voiceChannel,
524
+ deaf: this.deaf,
525
+ mute: this.mute
526
+ })
527
+ }
445
528
  return this
446
529
  }
447
530
 
@@ -464,72 +547,33 @@ class Player extends EventEmitter {
464
547
  }
465
548
 
466
549
  async _voiceWatchdog() {
467
- if (!this._shouldAttemptVoiceRecovery()) return
468
-
469
- const hasVoiceData =
470
- this.connection?.sessionId &&
471
- this.connection?.endpoint &&
472
- this.connection?.token
473
- if (!hasVoiceData) {
474
- if (
475
- Date.now() - this._voiceDownSince >
476
- VOICE_DOWN_THRESHOLD * VOICE_ABANDON_MULTIPLIER
477
- )
478
- this.destroy()
479
- return
480
- }
481
-
482
- this._voiceRecovering = true
483
- try {
484
- if (await this.connection.attemptResume()) {
485
- this.reconnectionRetries = this._voiceDownSince = 0
486
- return
487
- }
488
- const originalMute = this.mute
489
- this.send({
490
- guild_id: this.guildId,
491
- channel_id: this.voiceChannel,
492
- self_deaf: this.deaf,
493
- self_mute: !originalMute
494
- })
495
- await this._delay(MUTE_TOGGLE_DELAY)
496
- if (!this.destroyed) {
497
- this.send({
498
- guild_id: this.guildId,
499
- channel_id: this.voiceChannel,
500
- self_deaf: this.deaf,
501
- self_mute: originalMute
502
- })
503
- }
504
- this.connection.resendVoiceUpdate()
505
- this.reconnectionRetries++
506
- } catch {
507
- if (++this.reconnectionRetries >= RECONNECT_MAX) this.destroy()
508
- } finally {
509
- this._voiceRecovering = false
510
- }
550
+ return this._lifecycleController.voiceWatchdog()
511
551
  }
512
552
 
513
553
  destroy(options = {}) {
554
+ if (this.destroyed) return this
555
+
514
556
  const {
515
557
  preserveClient = true,
516
558
  skipRemote = false,
517
559
  preserveMessage = false,
518
560
  preserveReconnecting = false,
519
- preserveTracks = false
561
+ preserveTracks = false,
562
+ abortSignal = null
520
563
  } = options
521
- if (this.destroyed && !this.queue) return this
522
564
 
523
- if (!this.destroyed) {
524
- this.destroyed = true
525
- this.aqua?._trace?.('player.destroy', {
565
+ this._reconnectNonce++
566
+ this.destroyed = true
567
+ this._clearVoiceRecovery(undefined, 'destroyed')
568
+ if (this.aqua?.debugTrace) {
569
+ this.aqua._trace('player.destroy', {
526
570
  guildId: this.guildId,
527
571
  skipRemote: !!skipRemote,
528
572
  preserveTracks: !!preserveTracks,
529
573
  preserveReconnecting: !!preserveReconnecting
530
574
  })
531
- this.emit('destroy')
532
575
  }
576
+ this.emit('destroy')
533
577
 
534
578
  if (this._voiceWatchdogTimer) {
535
579
  clearInterval(this._voiceWatchdogTimer)
@@ -539,7 +583,6 @@ class Player extends EventEmitter {
539
583
  _functions.clearTimers(this._pendingTimers)
540
584
  this._pendingTimers = null
541
585
 
542
- // Clear reconnection timers to prevent memory leaks when destroyed externally
543
586
  if (this._reconnectTimers) {
544
587
  _functions.clearTimers(this._reconnectTimers)
545
588
  this._reconnectTimers = null
@@ -553,6 +596,7 @@ class Player extends EventEmitter {
553
596
  this._lastVoiceChannel = this.voiceChannel
554
597
  this._lastTextChannel = this.textChannel
555
598
  this.voiceChannel = null
599
+ this._isActivelyReconnecting = false
556
600
 
557
601
  if (
558
602
  this.shouldDeleteMessage &&
@@ -574,12 +618,37 @@ class Player extends EventEmitter {
574
618
  this._updateBatcher = null
575
619
  }
576
620
 
621
+ if (this.filters) {
622
+ try {
623
+ this.filters.destroy()
624
+ } catch (error) {
625
+ reportSuppressedError(this, 'player.destroy.filters', error, {
626
+ guildId: this.guildId
627
+ })
628
+ }
629
+ }
630
+
577
631
  this.previousTracks?.clear()
578
632
  this.previousTracks = null
579
633
  this.previousIdentifiers?.clear()
580
634
  this.previousIdentifiers = null
581
- 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
+ }
582
650
  this.queue = null
651
+ // ML-1: Clear _dataStore to prevent unbounded growth
583
652
  this._dataStore?.clear()
584
653
  this._dataStore = null
585
654
 
@@ -592,39 +661,92 @@ class Player extends EventEmitter {
592
661
  if (this.connection) {
593
662
  try {
594
663
  this.connection.destroy()
595
- } catch {}
664
+ } catch (error) {
665
+ reportSuppressedError(this, 'player.destroy.connection', error, {
666
+ guildId: this.guildId
667
+ })
668
+ }
596
669
  }
597
- this.connection = this.filters = this.current = this.autoplaySeed = null
670
+ this.connection =
671
+ this.filters =
672
+ this.current =
673
+ this.autoplaySeed =
674
+ this._lifecycleController =
675
+ null
598
676
 
599
677
  if (!skipRemote) {
600
678
  try {
601
- this.send({ guild_id: this.guildId, channel_id: null })
602
- this.aqua?.destroyPlayer?.(this.guildId)
603
- if (this.nodes?.connected)
604
- this.nodes.rest?.destroyPlayer(this.guildId).catch(() => {})
605
- } catch {}
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'
684
+ })
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
+ }
698
+ } catch (error) {
699
+ reportSuppressedError(this, 'player.destroy.gateway', error, {
700
+ guildId: this.guildId
701
+ })
702
+ }
606
703
  }
607
704
 
608
- 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
+ }
609
711
  return this
610
712
  }
611
713
 
612
714
  pause(paused) {
613
- 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
614
722
  this.paused = !!paused
615
- this.batchUpdatePlayer({ paused: this.paused }, true).catch(() => {})
723
+ this.batchUpdatePlayer({ paused: this.paused }, true).catch((error) =>
724
+ reportSuppressedError(this, 'player.pause', error, {
725
+ guildId: this.guildId,
726
+ paused: this.paused
727
+ })
728
+ )
616
729
  return this
617
730
  }
618
731
 
619
732
  seek(position) {
620
- if (this.destroyed || !this.playing || !_functions.isNum(position))
621
- 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
622
739
  const len = this.current?.info?.length || 0
623
740
  const clamped = len
624
741
  ? Math.min(Math.max(position, 0), len)
625
742
  : Math.max(position, 0)
626
743
  this.position = clamped
627
- this.batchUpdatePlayer({ position: clamped }, true).catch(() => {})
744
+ this.batchUpdatePlayer({ position: clamped }, true).catch((error) =>
745
+ reportSuppressedError(this, 'player.seek', error, {
746
+ guildId: this.guildId,
747
+ position: clamped
748
+ })
749
+ )
628
750
  return this
629
751
  }
630
752
 
@@ -678,15 +800,32 @@ class Player extends EventEmitter {
678
800
  this.batchUpdatePlayer(
679
801
  { track: { encoded: null }, paused: this.paused },
680
802
  true
681
- ).catch(() => {})
803
+ ).catch((error) =>
804
+ reportSuppressedError(this, 'player.stop', error, {
805
+ guildId: this.guildId
806
+ })
807
+ )
682
808
  return this
683
809
  }
684
810
 
685
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
+ }
686
820
  const vol = _functions.clamp(volume)
687
821
  if (this.destroyed || this.volume === vol) return this
688
822
  this.volume = vol
689
- this.batchUpdatePlayer({ volume: vol }).catch(() => {})
823
+ this.batchUpdatePlayer({ volume: vol }).catch((error) =>
824
+ reportSuppressedError(this, 'player.setVolume', error, {
825
+ guildId: this.guildId,
826
+ volume: vol
827
+ })
828
+ )
690
829
  return this
691
830
  }
692
831
 
@@ -703,7 +842,6 @@ class Player extends EventEmitter {
703
842
  const id = _functions.toId(channel)
704
843
  if (!id) throw new TypeError('Invalid text channel')
705
844
  this.textChannel = id
706
- this.batchUpdatePlayer({ text_channel: id }).catch(() => {})
707
845
  return this
708
846
  }
709
847
 
@@ -739,7 +877,31 @@ class Player extends EventEmitter {
739
877
  replay() {
740
878
  return this.seek(0)
741
879
  }
742
- skip() {
880
+ skip(target) {
881
+ if (this.destroyed || !this.playing) return this
882
+
883
+ if (target === undefined || target === null) return this.stop()
884
+
885
+ if (typeof target === 'number') {
886
+ const idx = target | 0
887
+ if (idx <= 0) return this.stop()
888
+ if (!this.queue?.size || idx >= this.queue.size) return this.stop()
889
+ for (let i = 0; i < idx; i++) this.queue.dequeue()
890
+ return this.stop()
891
+ }
892
+
893
+ const targetId = _functions.toId(target)
894
+ if (targetId && this.queue?.size) {
895
+ const arr = this.queue.toArray()
896
+ const idx = arr.findIndex(
897
+ (t) =>
898
+ _functions.toId(t) === targetId ||
899
+ _functions.toId(t?.info?.identifier) === targetId
900
+ )
901
+ if (idx > 0) {
902
+ for (let i = 0; i < idx; i++) this.queue.dequeue()
903
+ }
904
+ }
743
905
  return this.stop()
744
906
  }
745
907
 
@@ -835,8 +997,8 @@ class Player extends EventEmitter {
835
997
  }
836
998
  } catch (err) {
837
999
  if (this.destroyed) return this
838
- this.aqua?.emit(
839
- AqualinkEvents.Error,
1000
+ _functions.emitAquaError(
1001
+ this.aqua,
840
1002
  new Error(`Autoplay ${i + 1} fail: ${err.message}`)
841
1003
  )
842
1004
  }
@@ -858,7 +1020,7 @@ class Player extends EventEmitter {
858
1020
  }
859
1021
 
860
1022
  async _getAutoplayTrack(sourceName, identifier, uri, requester) {
861
- if (sourceName === 'youtube') {
1023
+ if (sourceName === 'youtube' || sourceName === 'ytmusic') {
862
1024
  const res = await this.aqua.resolve({
863
1025
  query: `https://www.youtube.com/watch?v=${identifier}&list=RD${identifier}`,
864
1026
  source: 'ytmsearch',
@@ -915,6 +1077,7 @@ class Player extends EventEmitter {
915
1077
  const isReplaced = reason === 'replaced'
916
1078
 
917
1079
  if (track) this.previousTracks.push(track)
1080
+ if (isReplaced) return
918
1081
  if (this.shouldDeleteMessage && !this._reconnecting && !this._resuming)
919
1082
  _functions.safeDel(this.nowPlayingMessage)
920
1083
  if (!isReplaced) this.current = null
@@ -930,12 +1093,15 @@ class Player extends EventEmitter {
930
1093
  return
931
1094
  }
932
1095
 
933
- if (
934
- track &&
935
- reason === 'finished' &&
936
- (this.loop === LOOP_MODES.TRACK || this.loop === LOOP_MODES.QUEUE)
937
- ) {
938
- this.queue.add(track)
1096
+ if (track && reason === 'finished') {
1097
+ if (this.loop === LOOP_MODES.TRACK) {
1098
+ this.aqua.emit(AqualinkEvents.TrackEnd, this, track, reason)
1099
+ await this.play(track)
1100
+ return
1101
+ }
1102
+ if (this.loop === LOOP_MODES.QUEUE) {
1103
+ this.queue.add(track)
1104
+ }
939
1105
  }
940
1106
 
941
1107
  if (this.queue.size) {
@@ -1006,171 +1172,28 @@ class Player extends EventEmitter {
1006
1172
  _functions.emitIfActive(this, AqualinkEvents.MixEnded, t, payload)
1007
1173
  }
1008
1174
 
1009
- async _attemptVoiceResume() {
1010
- if (!this.connection?.sessionId) throw new Error('No session')
1011
- if (!(await this.connection.attemptResume()))
1012
- throw new Error('Resume failed')
1175
+ async _attemptVoiceResume(abortSignal) {
1176
+ return this._lifecycleController.attemptVoiceResume(abortSignal)
1013
1177
  }
1014
1178
 
1015
1179
  async socketClosed(_player, _track, payload) {
1016
- if (this.destroyed || this._reconnecting) return
1017
- this.aqua?._trace?.('player.socketClosed', {
1018
- guildId: this.guildId,
1019
- code: payload?.code
1020
- })
1021
-
1022
- const code = payload?.code
1023
- if (code === 4006 && this._resuming) {
1024
- this.aqua?._trace?.('player.socketClosed.ignored', {
1025
- guildId: this.guildId,
1026
- code,
1027
- reason: 'transient_while_resuming'
1028
- })
1029
- return
1030
- }
1031
-
1032
- let isRecoverable = [4015, 4009, 4006, 4014, 4022].includes(code)
1033
- if (code === 4014 && this.connection?.isWaitingForDisconnect)
1034
- isRecoverable = false
1035
-
1036
- if (code === 4015 && !this.nodes?.info?.isNodelink) {
1037
- this._reconnecting = true
1038
- try {
1039
- await this._attemptVoiceResume()
1040
- this._reconnecting = false
1041
- return
1042
- } catch {
1043
- this._reconnecting = false
1044
- }
1045
- }
1046
-
1047
- if (!isRecoverable) {
1048
- this.aqua.emit(AqualinkEvents.SocketClosed, this, payload)
1049
- this.destroy()
1050
- return
1051
- }
1052
-
1053
- if (code === 4014 || code === 4022) {
1054
- this.connected = false
1055
- if (!this._voiceDownSince) this._voiceDownSince = Date.now()
1056
- if (code === 4022) this._suppressResumeUntil = Date.now() + 3000
1057
- }
1058
-
1059
- const aqua = this.aqua
1060
- const vcId = _functions.toId(this.voiceChannel)
1061
- const tcId = _functions.toId(this.textChannel)
1062
- const { guildId, deaf, mute } = this
1063
-
1064
- if (!vcId) {
1065
- aqua?.emit?.(AqualinkEvents.SocketClosed, this, payload)
1066
- return
1067
- }
1068
-
1069
- const state = {
1070
- volume: this.volume,
1071
- position: this.position,
1072
- paused: this.paused,
1073
- loop: this.loop,
1074
- isAutoplayEnabled: this.isAutoplayEnabled,
1075
- currentTrack: this.current,
1076
- queue: this.queue?.toArray() || [],
1077
- previousIdentifiers: Array.from(this.previousIdentifiers),
1078
- autoplaySeed: this.autoplaySeed,
1079
- nowPlayingMessage: this.nowPlayingMessage
1080
- }
1081
-
1082
- this._reconnecting = true
1083
- this.destroy({
1084
- preserveClient: true,
1085
- skipRemote: true,
1086
- preserveMessage: true,
1087
- preserveReconnecting: true,
1088
- preserveTracks: true
1089
- })
1090
-
1091
- // Store reconnect timers on instance for cleanup in destroy()
1092
- this._reconnectTimers = new Set()
1093
- const reconnectTimers = this._reconnectTimers
1094
- const tryReconnect = async (attempt) => {
1095
- if (aqua?.destroyed) {
1096
- _functions.clearTimers(reconnectTimers)
1097
- return
1098
- }
1099
- try {
1100
- const np = await aqua.createConnection({
1101
- guildId,
1102
- voiceChannel: vcId,
1103
- textChannel: tcId,
1104
- deaf,
1105
- mute,
1106
- defaultVolume: state.volume,
1107
- preserveMessage: true,
1108
- resuming: true
1109
- })
1110
- if (!np) throw new Error('Failed to create player')
1111
-
1112
- np.reconnectionRetries = 0
1113
- np.loop = state.loop
1114
- np.isAutoplayEnabled = state.isAutoplayEnabled
1115
- np.autoplaySeed = state.autoplaySeed
1116
- np.previousIdentifiers = new Set(state.previousIdentifiers)
1117
- np.nowPlayingMessage = state.nowPlayingMessage
1118
-
1119
- const ct = state.currentTrack
1120
- if (ct) np.queue.add(ct)
1121
- for (const q of state.queue) if (q !== ct) np.queue.add(q)
1122
-
1123
- if (ct) {
1124
- await np.play()
1125
- if (state.position > 5000)
1126
- np._createTimer(
1127
- () => !np.destroyed && np.seek(state.position),
1128
- SEEK_DELAY
1129
- )
1130
- if (state.paused)
1131
- np._createTimer(() => !np.destroyed && np.pause(true), PAUSE_DELAY)
1132
- }
1133
-
1134
- _functions.clearTimers(reconnectTimers)
1135
- this._reconnecting = false
1136
- aqua.emit(AqualinkEvents.PlayerReconnected, np, {
1137
- oldPlayer: this,
1138
- restoredState: state
1139
- })
1140
- } catch (error) {
1141
- const retriesLeft = RECONNECT_MAX - attempt
1142
- aqua.emit(AqualinkEvents.ReconnectionFailed, this, {
1143
- error,
1144
- code,
1145
- payload,
1146
- retriesLeft
1147
- })
1148
-
1149
- if (retriesLeft > 0) {
1150
- _functions.createTimer(
1151
- () => tryReconnect(attempt + 1),
1152
- Math.min(RETRY_BACKOFF_BASE * attempt, RETRY_BACKOFF_MAX),
1153
- reconnectTimers
1154
- )
1155
- } else {
1156
- _functions.clearTimers(reconnectTimers)
1157
- this._reconnecting = false
1158
- aqua.emit(AqualinkEvents.SocketClosed, this, payload)
1159
- }
1160
- }
1161
- }
1162
-
1163
- tryReconnect(1)
1180
+ return this._lifecycleController.socketClosed(_player, _track, payload)
1164
1181
  }
1165
1182
 
1166
1183
  send(data) {
1167
1184
  try {
1168
- this.aqua.send({ op: 4, d: data })
1185
+ if (this.aqua?.queueVoiceStateUpdate) {
1186
+ return this.aqua.queueVoiceStateUpdate(data)
1187
+ } else {
1188
+ this.aqua.send({ op: 4, d: data })
1189
+ return true
1190
+ }
1169
1191
  } catch (err) {
1170
- this.aqua.emit(
1171
- AqualinkEvents.Error,
1172
- new Error(`Send fail: ${err.message}`)
1192
+ _functions.emitAquaError(
1193
+ this.aqua,
1194
+ new Error(`Send fail (guild=${this.guildId}): ${err.message}`)
1173
1195
  )
1196
+ return false
1174
1197
  }
1175
1198
  }
1176
1199
 
@@ -1203,24 +1226,7 @@ class Player extends EventEmitter {
1203
1226
  }
1204
1227
 
1205
1228
  _flushDeferredPlay() {
1206
- if (
1207
- !this._deferredStart ||
1208
- this.destroyed ||
1209
- !this.current?.track ||
1210
- !this._updateBatcher
1211
- )
1212
- return
1213
- this._deferredStart = false
1214
- const updateData = {
1215
- track: { encoded: this.current.track },
1216
- paused: this.paused
1217
- }
1218
- if (this.position > 0) updateData.position = this.position
1219
- this.aqua?._trace?.('player.play.deferred.flush', {
1220
- guildId: this.guildId,
1221
- hasEndpoint: !!this.connection?.endpoint
1222
- })
1223
- this.batchUpdatePlayer(updateData, true).catch(() => {})
1229
+ return this._lifecycleController.flushDeferredPlay()
1224
1230
  }
1225
1231
 
1226
1232
  cleanup() {