aqualink 3.1.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.
@@ -5,6 +5,7 @@ const WebSocketImpl = process.isBun ? globalThis.WebSocket : require('ws')
5
5
 
6
6
  const Rest = require('./Rest')
7
7
  const { AqualinkEvents } = require('./AqualinkEvents')
8
+ const { emitOperationalError } = require('./Reporting')
8
9
 
9
10
  const privateData = new WeakMap()
10
11
 
@@ -36,6 +37,8 @@ const unrefTimer = (t) => {
36
37
  }
37
38
 
38
39
  const _functions = {
40
+ noop() {},
41
+
39
42
  buildWsUrl(host, port, ssl) {
40
43
  const needsBrackets = host.includes(':') && !host.startsWith('[')
41
44
  return `ws${ssl ? 's' : ''}://${needsBrackets ? `[${host}]` : host}:${port}${WS_PATH}`
@@ -138,6 +141,7 @@ class Node {
138
141
  this.reconnectTimeoutId = null
139
142
  this.isDestroyed = false
140
143
  this._isConnecting = false
144
+ this._connectPromise = null
141
145
  this.isNodelink = false
142
146
 
143
147
  this._wsIsBun = !!process.isBun
@@ -162,7 +166,7 @@ class Node {
162
166
  error: this._handleError.bind(this),
163
167
  message: this._handleMessage.bind(this),
164
168
  close: this._handleClose.bind(this),
165
- connect: this.connect.bind(this)
169
+ connect: () => this.connect().catch(() => {})
166
170
  }
167
171
  })
168
172
  }
@@ -307,7 +311,7 @@ class Node {
307
311
  if (
308
312
  code !== Node.WS_CLOSE_NORMAL &&
309
313
  code !== 1001 &&
310
- !isFatal &&
314
+ isFatal &&
311
315
  this.sessionId
312
316
  ) {
313
317
  this._clearSession()
@@ -393,16 +397,17 @@ class Node {
393
397
  }
394
398
 
395
399
  connect() {
396
- if (this.isDestroyed || this._isConnecting) return
400
+ if (this.isDestroyed) return Promise.reject(new Error('Node is destroyed'))
401
+ if (this._connectPromise) return this._connectPromise
397
402
 
398
403
  const state = this.ws?.readyState
399
404
  if (state === WS_STATES.OPEN) {
400
405
  this._emitDebug('WebSocket already connected')
401
- return
406
+ return Promise.resolve()
402
407
  }
403
408
  if (state === WS_STATES.CONNECTING || state === WS_STATES.CLOSING) {
404
409
  this._emitDebug('WebSocket is connecting/closing; skipping new connect')
405
- return
410
+ return Promise.reject(new Error('WebSocket is closing'))
406
411
  }
407
412
 
408
413
  this._isConnecting = true
@@ -415,95 +420,165 @@ class Node {
415
420
  })
416
421
  }
417
422
 
418
- try {
419
- const h = this._boundHandlers
420
-
421
- if (this._wsIsBun) {
422
- const ws = new WebSocketImpl(this.wsUrl, { headers: this._headers })
423
- ws.binaryType = 'arraybuffer'
424
-
425
- const offs = []
426
- const add = (type, fn, once = false) => {
427
- const wrapped = once
428
- ? (ev) => {
429
- try {
430
- ws.removeEventListener(type, wrapped)
431
- } catch {}
432
- fn(ev)
433
- }
434
- : fn
435
- ws.addEventListener(type, wrapped)
436
- offs.push(() => {
437
- try {
438
- ws.removeEventListener(type, wrapped)
439
- } catch {}
440
- })
423
+ let connectPromise
424
+ connectPromise = new Promise((resolve, reject) => {
425
+ let settled = false
426
+ let opened = false
427
+ let timer = null
428
+ const settle = (ok, error) => {
429
+ if (settled) return
430
+ settled = true
431
+ if (timer) clearTimeout(timer)
432
+ queueMicrotask(() => {
433
+ if (this._connectPromise === connectPromise)
434
+ this._connectPromise = null
435
+ })
436
+ this._rejectConnect = null
437
+ ok ? resolve() : reject(error)
438
+ }
439
+ timer = setTimeout(() => {
440
+ const error = new Error(`WebSocket open timeout: ${this.timeout}ms`)
441
+ this._isConnecting = false
442
+ this._cleanup()
443
+ settle(false, error)
444
+ this._scheduleReconnect()
445
+ }, this.timeout)
446
+ unrefTimer(timer)
447
+ this._rejectConnect = (error) => settle(false, error)
448
+
449
+ try {
450
+ const h = this._boundHandlers
451
+ const onOpen = () => {
452
+ opened = true
453
+ h.open()
454
+ settle(true)
455
+ }
456
+ const onError = (error) => {
457
+ h.error(error)
458
+ if (!opened) {
459
+ this._isConnecting = false
460
+ this._cleanup()
461
+ settle(false, error)
462
+ this._scheduleReconnect()
463
+ }
464
+ }
465
+ const onClose = (code, reason) => {
466
+ if (!opened) {
467
+ settle(
468
+ false,
469
+ new Error(
470
+ `WebSocket closed before open (code ${code}): ${_functions.reasonToString(reason)}`
471
+ )
472
+ )
473
+ }
474
+ h.close(code, reason)
441
475
  }
442
476
 
443
- add('open', () => h.open(), true)
477
+ if (this._wsIsBun) {
478
+ const ws = new WebSocketImpl(this.wsUrl, { headers: this._headers })
479
+ ws.binaryType = 'arraybuffer'
480
+
481
+ const offs = []
482
+ const add = (type, fn, once = false) => {
483
+ const wrapped = once
484
+ ? (ev) => {
485
+ try {
486
+ ws.removeEventListener(type, wrapped)
487
+ } catch {}
488
+ fn(ev)
489
+ }
490
+ : fn
491
+ ws.addEventListener(type, wrapped)
492
+ offs.push(() => {
493
+ try {
494
+ ws.removeEventListener(type, wrapped)
495
+ } catch {}
496
+ })
497
+ }
444
498
 
445
- add(
446
- 'error',
447
- (event) => {
448
- const err = event?.error
449
- h.error(err instanceof Error ? err : new Error('WebSocket error'))
450
- },
451
- true
452
- )
499
+ add('open', onOpen, true)
453
500
 
454
- add('message', (event) => {
455
- const data = event?.data
456
- if (typeof data === 'string') h.message(data, false)
457
- else h.message(data, true)
458
- })
501
+ add(
502
+ 'error',
503
+ (event) => {
504
+ const err = event?.error
505
+ onError(err instanceof Error ? err : new Error('WebSocket error'))
506
+ },
507
+ false
508
+ )
459
509
 
460
- add(
461
- 'close',
462
- (event) => {
463
- h.close(
464
- typeof event?.code === 'number'
465
- ? event.code
466
- : Node.WS_CLOSE_NORMAL,
467
- typeof event?.reason === 'string' ? event.reason : ''
468
- )
469
- },
470
- true
471
- )
510
+ add('message', (event) => {
511
+ const data = event?.data
512
+ if (typeof data === 'string') h.message(data, false)
513
+ else h.message(data, true)
514
+ })
472
515
 
473
- this._bunCleanup = () => {
474
- for (let i = 0; i < offs.length; i++) offs[i]()
475
- }
476
- this.ws = ws
477
- return
478
- }
516
+ add(
517
+ 'close',
518
+ (event) => {
519
+ onClose(
520
+ typeof event?.code === 'number'
521
+ ? event.code
522
+ : Node.WS_CLOSE_NORMAL,
523
+ typeof event?.reason === 'string' ? event.reason : ''
524
+ )
525
+ },
526
+ true
527
+ )
479
528
 
480
- const ws = new WebSocketImpl(this.wsUrl, {
481
- headers: this._headers,
482
- perMessageDeflate: false,
483
- handshakeTimeout: this.timeout,
484
- maxPayload: this.maxPayload,
485
- skipUTF8Validation: this.skipUTF8Validation
486
- })
529
+ this._bunCleanup = () => {
530
+ for (let i = 0; i < offs.length; i++) offs[i]()
531
+ }
532
+ this.ws = ws
533
+ return
534
+ }
487
535
 
488
- ws.binaryType = 'nodebuffer'
536
+ const ws = new WebSocketImpl(this.wsUrl, {
537
+ headers: this._headers,
538
+ perMessageDeflate: false,
539
+ handshakeTimeout: this.timeout,
540
+ maxPayload: this.maxPayload,
541
+ skipUTF8Validation: this.skipUTF8Validation
542
+ })
489
543
 
490
- ws.once('open', h.open)
491
- ws.once('error', h.error)
492
- ws.on('message', h.message)
493
- ws.once('close', h.close)
544
+ ws.once('open', () => {
545
+ onOpen()
546
+ this._wsPingInterval = setInterval(() => {
547
+ if (ws.readyState === WS_STATES.OPEN) ws.ping?.()
548
+ }, 30000)
549
+ this._wsPingInterval.unref?.()
550
+ })
551
+ ws.on('error', onError)
552
+ ws.on('message', h.message)
553
+ ws.once('close', (...args) => {
554
+ if (this._wsPingInterval) {
555
+ clearInterval(this._wsPingInterval)
556
+ this._wsPingInterval = null
557
+ }
558
+ onClose(...args)
559
+ })
494
560
 
495
- this.ws = ws
496
- } catch (err) {
497
- this._isConnecting = false
498
- this._emitError(`Failed to create WebSocket: ${_functions.errMsg(err)}`)
499
- this._scheduleReconnect()
500
- }
561
+ this.ws = ws
562
+ } catch (err) {
563
+ this._isConnecting = false
564
+ this._emitError(`Failed to create WebSocket: ${_functions.errMsg(err)}`)
565
+ settle(false, err)
566
+ this._scheduleReconnect()
567
+ }
568
+ })
569
+ this._connectPromise = connectPromise
570
+ return connectPromise
501
571
  }
502
572
 
503
573
  _cleanup() {
504
574
  const ws = this.ws
505
575
  if (!ws) return
506
576
 
577
+ if (this._wsPingInterval) {
578
+ clearInterval(this._wsPingInterval)
579
+ this._wsPingInterval = null
580
+ }
581
+
507
582
  if (this._wsIsBun) {
508
583
  try {
509
584
  this._bunCleanup?.()
@@ -511,6 +586,7 @@ class Node {
511
586
  this._bunCleanup = null
512
587
  } else {
513
588
  ws.removeAllListeners?.()
589
+ ws.on?.('error', _functions.noop)
514
590
  }
515
591
 
516
592
  try {
@@ -531,15 +607,17 @@ class Node {
531
607
  if (this.isDestroyed) return
532
608
 
533
609
  this.isDestroyed = true
610
+ this._rejectConnect?.(new Error('Node destroyed while connecting'))
534
611
  this.state = NODE_STATE.IDLE
535
612
  this._isConnecting = false
613
+ this._connectPromise = null
536
614
  this._clearReconnectTimeout()
537
615
  this._cleanup()
538
616
 
539
617
  if (!clean) this.aqua.handleNodeFailover?.(this)
540
618
 
541
619
  this.connected = false
542
- this.aqua.destroyNode?.(this.name)
620
+ this.aqua._destroyNode?.(this.name)
543
621
  this.aqua.emit(AqualinkEvents.NodeDestroy, this)
544
622
 
545
623
  this.rest?.destroy?.()
@@ -792,7 +870,7 @@ class Node {
792
870
 
793
871
  _emitError(error) {
794
872
  const errorObj = error instanceof Error ? error : new Error(String(error))
795
- this.aqua.emit(AqualinkEvents.Error, this, errorObj)
873
+ emitOperationalError(this.aqua, this, errorObj)
796
874
  }
797
875
 
798
876
  _emitDebug(message) {
@@ -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
 
@@ -957,22 +958,24 @@ class Player extends EventEmitter {
957
958
  const prev = this.previous
958
959
  const info = prev?.info
959
960
  if (!info?.sourceName || !info.identifier) return this
960
- const { sourceName, identifier, uri, author } = info
961
+ const { sourceName, identifier, uri, author, title } = info
961
962
  this.isAutoplay = true
962
963
 
963
- if (sourceName === 'spotify' && info.identifier) {
964
- this.previousIdentifiers.add(info.identifier)
965
- if (this.previousIdentifiers.size > PREVIOUS_IDS_MAX) {
966
- this.previousIdentifiers.delete(
967
- this.previousIdentifiers.values().next().value
968
- )
969
- }
970
- if (!this.autoplaySeed) {
971
- this.autoplaySeed = {
972
- trackId: identifier,
973
- 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
+ )
974
971
  }
975
972
  }
973
+ this.autoplaySeed = {
974
+ trackId: identifier,
975
+ isrc: prev.isrc || null,
976
+ title: title || null,
977
+ author: author || null
978
+ }
976
979
  }
977
980
 
978
981
  for (
@@ -1020,27 +1023,45 @@ class Player extends EventEmitter {
1020
1023
  }
1021
1024
 
1022
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
+
1023
1030
  if (sourceName === 'youtube' || sourceName === 'ytmusic') {
1024
1031
  const res = await this.aqua.resolve({
1025
1032
  query: `https://www.youtube.com/watch?v=${identifier}&list=RD${identifier}`,
1026
1033
  source: 'ytmsearch',
1027
1034
  requester
1028
1035
  })
1029
- return _functions.isInvalidLoad(res)
1030
- ? 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)]
1031
1042
  : res.tracks[_functions.randIdx(res.tracks.length)]
1032
1043
  }
1033
1044
  if (sourceName === 'soundcloud') {
1034
- const scRes = await scAutoPlay(uri)
1045
+ const scRes = await scAutoPlay(uri, this.nodes?.rest?._autoplayAgent)
1035
1046
  if (!scRes?.length) return null
1036
- const res = await this.aqua.resolve({
1037
- query: scRes[0],
1038
- source: 'scsearch',
1039
- requester
1040
- })
1041
- return _functions.isInvalidLoad(res)
1042
- ? null
1043
- : 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
1044
1065
  }
1045
1066
  if (sourceName === 'spotify') {
1046
1067
  const res = await spAutoPlay(
@@ -1171,6 +1192,16 @@ class Player extends EventEmitter {
1171
1192
  mixEnded(_p, t, payload) {
1172
1193
  _functions.emitIfActive(this, AqualinkEvents.MixEnded, t, payload)
1173
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
+ }
1174
1205
 
1175
1206
  async _attemptVoiceResume(abortSignal) {
1176
1207
  return this._lifecycleController.attemptVoiceResume(abortSignal)
@@ -241,6 +241,11 @@ class PlayerLifecycle {
241
241
  }
242
242
 
243
243
  const code = payload?.code
244
+ if (code === 4014) {
245
+ await player._delay(150)
246
+ if (player.destroyed) return
247
+ }
248
+
244
249
  if (code === 4006 && player._resuming) {
245
250
  if (player.aqua?.debugTrace) {
246
251
  player.aqua._trace('player.socketClosed.ignored', {
@@ -253,7 +258,10 @@ class PlayerLifecycle {
253
258
  }
254
259
 
255
260
  let isRecoverable = [4015, 4009, 4006, 4014, 4022].includes(code)
256
- if (code === 4014 && player.connection?.isWaitingForDisconnect)
261
+ if (
262
+ code === 4014 &&
263
+ (player.connection?.isWaitingForDisconnect || !player.voiceChannel)
264
+ )
257
265
  isRecoverable = false
258
266
 
259
267
  if (code === 4015 && !player.nodes?.info?.isNodelink) {
@@ -452,6 +460,7 @@ class PlayerLifecycle {
452
460
  if (
453
461
  latestActivePlayer &&
454
462
  latestActivePlayer !== player &&
463
+ latestActivePlayer !== np &&
455
464
  !latestActivePlayer.destroyed
456
465
  ) {
457
466
  try {
@@ -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
  }