aqualink 2.11.5 → 2.11.7

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.
@@ -28,6 +28,7 @@ const DEFAULT_OPTIONS = Object.freeze({
28
28
  plugins: [],
29
29
  autoResume: false,
30
30
  infiniteReconnects: false,
31
+ loadBancer: 'leastLoad', // cpu, memory, rest: leastLoad, only rest: leastRest, no check: random
31
32
  failoverOptions: Object.freeze({
32
33
  enabled: true,
33
34
  maxRetries: 3,
@@ -142,21 +143,37 @@ class Aqua extends EventEmitter {
142
143
  }
143
144
 
144
145
  get leastUsedNodes() {
145
- const now = Date.now()
146
+ const now = Date.now();
146
147
  if (this._leastUsedNodesCache && (now - this._leastUsedNodesCacheTime) < CACHE_VALID_TIME) {
147
- return this._leastUsedNodesCache
148
+ return this._leastUsedNodesCache;
148
149
  }
149
150
 
150
- const connected = []
151
+ const connected = [];
151
152
  for (const node of this.nodeMap.values()) {
152
- if (node.connected) connected.push(node)
153
+ if (node.connected) connected.push(node);
153
154
  }
154
155
 
155
- connected.sort((a, b) => this._getCachedNodeLoad(a) - this._getCachedNodeLoad(b))
156
+ let sorted;
157
+ switch (this.options.loadBancer) {
158
+ case 'leastRest':
159
+ sorted = connected.slice().sort((a, b) => {
160
+ const restA = a?.rest?.calls || 0;
161
+ const restB = b?.rest?.calls || 0;
162
+ return restA - restB;
163
+ });
164
+ break;
165
+ case 'random':
166
+ sorted = Array.from(connected);
167
+ break;
168
+ case 'leastLoad':
169
+ default:
170
+ sorted = connected.slice().sort((a, b) => this._getCachedNodeLoad(a) - this._getCachedNodeLoad(b));
171
+ break;
172
+ }
156
173
 
157
- this._leastUsedNodesCache = Object.freeze(connected.slice())
158
- this._leastUsedNodesCacheTime = now
159
- return this._leastUsedNodesCache
174
+ this._leastUsedNodesCache = Object.freeze(sorted);
175
+ this._leastUsedNodesCacheTime = now;
176
+ return this._leastUsedNodesCache;
160
177
  }
161
178
 
162
179
  _invalidateCache() {
@@ -142,8 +142,8 @@ class Connection {
142
142
 
143
143
  setServerUpdate(data) {
144
144
  if (!data?.endpoint || !data.token ||
145
- typeof data.endpoint !== 'string' ||
146
- typeof data.token !== 'string') {
145
+ typeof data.endpoint !== 'string' ||
146
+ typeof data.token !== 'string') {
147
147
  return
148
148
  }
149
149
 
@@ -175,18 +175,18 @@ class Connection {
175
175
  this.token = data.token
176
176
 
177
177
  if (this._player.paused) {
178
- this._player.paused = false
178
+ this._player.pause(false)
179
179
  }
180
180
 
181
181
  this._scheduleVoiceUpdate()
182
182
  }
183
183
 
184
- resendVoiceUpdate({ resume = false } = {}) {
184
+ resendVoiceUpdate() { // Remove the parameter
185
185
  if (!(this.sessionId && this.endpoint && this.token)) {
186
186
  return false
187
187
  }
188
188
 
189
- this._scheduleVoiceUpdate(resume)
189
+ this._scheduleVoiceUpdate() // No parameter needed
190
190
  return true
191
191
  }
192
192
 
@@ -201,9 +201,7 @@ class Connection {
201
201
  let needsUpdate = false
202
202
 
203
203
  if (this.voiceChannel !== channel_id) {
204
- if (this._stateFlags & STATE_FLAGS.HAS_MOVE_LISTENERS) {
205
- this._aqua.emit('playerMove', this.voiceChannel, channel_id)
206
- }
204
+ this._aqua.emit('playerMove', this.voiceChannel, channel_id)
207
205
  this.voiceChannel = channel_id
208
206
  this._player.voiceChannel = channel_id
209
207
  needsUpdate = true
@@ -249,32 +247,29 @@ class Connection {
249
247
 
250
248
  async attemptResume() {
251
249
  if (!(this.sessionId && this.endpoint && this.token)) {
252
- throw new Error('Missing required voice state')
250
+ return false;
253
251
  }
254
252
 
255
- const payload = this._payloadPool.acquire()
253
+ const payload = this._payloadPool.acquire(); // USE THE POOL
256
254
 
257
255
  try {
258
- payload.guildId = this._guildId
259
- payload.data.voice.token = this.token
260
- payload.data.voice.endpoint = this.endpoint
261
- payload.data.voice.sessionId = this.sessionId
262
- payload.data.voice.resume = true
263
- payload.data.volume = this._player?.volume
264
-
265
- if (this.sequence >= 0 && Number.isFinite(this.sequence)) {
266
- payload.data.voice.sequence = this.sequence
267
- }
268
-
269
- await this._sendUpdate(payload)
270
- return true
256
+ payload.guildId = this._guildId;
257
+ payload.data.voice.token = this.token;
258
+ payload.data.voice.endpoint = this.endpoint;
259
+ payload.data.voice.sessionId = this.sessionId;
260
+ payload.data.voice.resume = true;
261
+ payload.data.volume = this._player?.volume;
262
+
263
+ await this._sendUpdate(payload);
264
+ return true;
271
265
  } catch (error) {
266
+ console.log(`Voice connection resume failed in guild ${this._guildId}: ${error?.message}`);
272
267
  if (this._stateFlags & STATE_FLAGS.HAS_DEBUG_LISTENERS) {
273
- this._aqua.emit('debug', `[Player ${this._guildId}] Resume update failed: ${error?.message}`)
268
+ this._aqua.emit('debug', `[Player ${this._guildId}] Voice update failed: ${error?.message}`);
274
269
  }
275
- return false
270
+ return false;
276
271
  } finally {
277
- this._payloadPool.release(payload)
272
+ this._payloadPool.release(payload); // ALWAYS RELEASE
278
273
  }
279
274
  }
280
275
 
@@ -294,40 +289,31 @@ class Connection {
294
289
  this._pendingUpdate = null
295
290
  }
296
291
 
297
- _scheduleVoiceUpdate(isResume = false) {
292
+ _scheduleVoiceUpdate() {
298
293
  if (!(this.sessionId && this.endpoint && this.token)) {
299
- return
294
+ return;
300
295
  }
301
296
 
302
297
  if (this._stateFlags & STATE_FLAGS.UPDATE_SCHEDULED) {
303
- return
298
+ return;
304
299
  }
305
300
 
306
- this._clearPendingUpdate()
307
-
308
- const payload = this._payloadPool.acquire()
301
+ this._clearPendingUpdate();
309
302
 
310
- payload.guildId = this._guildId
311
- const voice = payload.data.voice
312
- voice.token = this.token
313
- voice.endpoint = this.endpoint
314
- voice.sessionId = this.sessionId
315
- payload.data.volume = this._player.volume
316
-
317
- if (isResume) {
318
- voice.resume = true
319
- voice.sequence = this.sequence
320
- }
303
+ const payload = this._payloadPool.acquire();
304
+ payload.guildId = this._guildId;
305
+ payload.data.voice.token = this.token;
306
+ payload.data.voice.endpoint = this.endpoint;
307
+ payload.data.voice.sessionId = this.sessionId;
308
+ payload.data.volume = this._player.volume;
321
309
 
322
310
  this._pendingUpdate = {
323
- isResume,
324
311
  payload,
325
312
  timestamp: Date.now()
326
- }
327
-
328
- this._stateFlags |= STATE_FLAGS.UPDATE_SCHEDULED
313
+ };
329
314
 
330
- queueMicrotask(this._executeVoiceUpdate)
315
+ this._stateFlags |= STATE_FLAGS.UPDATE_SCHEDULED;
316
+ queueMicrotask(this._executeVoiceUpdate);
331
317
  }
332
318
 
333
319
  _executeVoiceUpdate() {
@@ -366,8 +352,8 @@ class Connection {
366
352
  await this._nodes.rest.updatePlayer(payload)
367
353
  } catch (error) {
368
354
  if (error.code !== 'ECONNREFUSED' &&
369
- error.code !== 'ENOTFOUND' &&
370
- (this._stateFlags & STATE_FLAGS.HAS_DEBUG_LISTENERS)) {
355
+ error.code !== 'ENOTFOUND' &&
356
+ (this._stateFlags & STATE_FLAGS.HAS_DEBUG_LISTENERS)) {
371
357
  this._aqua.emit('debug', `[Player ${this._guildId}] Update failed: ${error.message}`)
372
358
  }
373
359
  throw error
@@ -107,7 +107,7 @@ class MicrotaskUpdateBatcher {
107
107
 
108
108
  try {
109
109
  this.player?.aqua?.emit?.('error', new Error(`Update player error: ${err.message}`))
110
- } catch {}
110
+ } catch { }
111
111
  throw err
112
112
  })
113
113
  }
@@ -235,6 +235,12 @@ class Player extends EventEmitter {
235
235
  this._boundEvent = this._handleEvent.bind(this)
236
236
  this.on('playerUpdate', this._boundPlayerUpdate)
237
237
  this.on('event', this._boundEvent)
238
+ this._boundAquaPlayerMove = this._handleAquaPlayerMove.bind(this)
239
+ this.aqua.on('playerMove', this._boundAquaPlayerMove)
240
+ this._voiceDownSince = 0
241
+ this._voiceRecovering = false
242
+ this._voiceWatchdogTimer = setInterval(() => this._voiceWatchdog(), 15000)
243
+ this._voiceWatchdogTimer.unref?.()
238
244
  }
239
245
 
240
246
  _handlePlayerUpdate(packet) {
@@ -246,6 +252,20 @@ class Player extends EventEmitter {
246
252
  this.connected = !!state.connected
247
253
  this.ping = typeof state.ping === 'number' ? state.ping : 0
248
254
  this.timestamp = typeof state.time === 'number' ? state.time : Date.now()
255
+
256
+ if (!this.connected) {
257
+ if (!this._voiceDownSince) {
258
+ this._voiceDownSince = Date.now()
259
+ setTimeout(() => {
260
+ if (!this.connected && !this.destroyed) {
261
+ this.connection.attemptResume()
262
+ }
263
+ }, 1000)
264
+ }
265
+ } else {
266
+ this._voiceDownSince = 0
267
+ }
268
+
249
269
  this.aqua.emit('playerUpdate', this, packet)
250
270
  }
251
271
 
@@ -336,9 +356,55 @@ class Player extends EventEmitter {
336
356
  return this
337
357
  }
338
358
 
359
+ async _voiceWatchdog() {
360
+ if (this.destroyed || !this.voiceChannel || this.connected) return
361
+ if (!this._voiceDownSince || (Date.now() - this._voiceDownSince) < 10000) return
362
+ if (this._voiceRecovering) return
363
+
364
+ this._voiceRecovering = true
365
+ try {
366
+ try {
367
+ await this.connection.attemptResume()
368
+ this.aqua.emit('debug', `[Player ${this.guildId}] Watchdog: resume sent`)
369
+ return
370
+ } catch { }
371
+
372
+ // 2) Force a new Discord voice update to re-trigger VSU/VSS
373
+ const toggleMute = !this.mute
374
+ this.send({
375
+ guild_id: this.guildId,
376
+ channel_id: this.voiceChannel,
377
+ self_deaf: this.deaf,
378
+ self_mute: toggleMute
379
+ })
380
+ setTimeout(() => {
381
+ if (!this.destroyed) {
382
+ this.send({
383
+ guild_id: this.guildId,
384
+ channel_id: this.voiceChannel,
385
+ self_deaf: this.deaf,
386
+ self_mute: this.mute
387
+ })
388
+ }
389
+ }, 300)
390
+
391
+ this.connection.resendVoiceUpdate({ resume: false })
392
+ this.aqua.emit('debug', `[Player ${this.guildId}] Watchdog: forced voice update/rejoin`)
393
+ } catch (err) {
394
+ this.aqua.emit('debug', `[Player ${this.guildId}] Watchdog recover failed: ${err?.message || err}`)
395
+ } finally {
396
+ this._voiceRecovering = false
397
+ }
398
+ }
399
+
339
400
  destroy({ preserveClient = true, skipRemote = false } = {}) {
340
401
  if (this.destroyed) return this
341
402
 
403
+ if (this._voiceWatchdogTimer) {
404
+ clearInterval(this._voiceWatchdogTimer)
405
+ this._voiceWatchdogTimer = null
406
+ }
407
+
342
408
  this.destroyed = true
343
409
  this.connected = false
344
410
  this.playing = false
@@ -360,6 +426,11 @@ class Player extends EventEmitter {
360
426
  this._updateBatcher = null
361
427
  }
362
428
 
429
+ if (this._boundAquaPlayerMove) {
430
+ try { this.aqua.off('playerMove', this._boundAquaPlayerMove) } catch { }
431
+ this._boundAquaPlayerMove = null
432
+ }
433
+
363
434
  if (!skipRemote) {
364
435
  try {
365
436
  this.send({ guild_id: this.guildId, channel_id: null })
@@ -634,10 +705,10 @@ class Player extends EventEmitter {
634
705
  _isInvalidResponse(response) {
635
706
 
636
707
  return !response?.tracks?.length ||
637
- response.loadType === 'error' ||
638
- response.loadType === 'empty' ||
639
- response.loadType === 'LOAD_FAILED' ||
640
- response.loadType === 'NO_MATCHES'
708
+ response.loadType === 'error' ||
709
+ response.loadType === 'empty' ||
710
+ response.loadType === 'LOAD_FAILED' ||
711
+ response.loadType === 'NO_MATCHES'
641
712
  }
642
713
 
643
714
  async trackStart(player, track) {
@@ -718,54 +789,56 @@ class Player extends EventEmitter {
718
789
  this.aqua.emit('trackChange', this, track, payload)
719
790
  }
720
791
 
721
- async _attemptVoiceResume() {
722
- if (!this.connection || !this.connection.sessionId) {
723
- throw new Error('Missing connection or sessionId')
724
- }
725
-
726
- const ok = await this.connection.attemptResume()
727
- if (!ok) throw new Error('Resume request failed')
728
- return new Promise((resolve, reject) => {
729
- const timeout = setTimeout(() => {
730
- this.off('playerUpdate', onUpdate)
731
- reject(new Error('No resume confirmation'))
732
- }, 5000)
792
+ async _attemptVoiceResume() {
793
+ if (!this.connection || !this.connection.sessionId) {
794
+ throw new Error('Missing connection or sessionId')
795
+ }
733
796
 
734
- const onUpdate = (payload) => {
735
- if (payload?.state?.connected || typeof payload?.state?.time === 'number') {
736
- clearTimeout(timeout)
797
+ const ok = await this.connection.attemptResume()
798
+ if (!ok) throw new Error('Resume request failed')
799
+ return new Promise((resolve, reject) => {
800
+ const timeout = setTimeout(() => {
737
801
  this.off('playerUpdate', onUpdate)
738
- resolve()
802
+ reject(new Error('No resume confirmation'))
803
+ }, 5000)
804
+
805
+ const onUpdate = (payload) => {
806
+ if (payload?.state?.connected || typeof payload?.state?.time === 'number') {
807
+ clearTimeout(timeout)
808
+ this.off('playerUpdate', onUpdate)
809
+ resolve()
810
+ }
739
811
  }
740
- }
741
812
 
742
- this.on('playerUpdate', onUpdate)
743
- })
744
- }
813
+ this.on('playerUpdate', onUpdate)
814
+ })
815
+ }
745
816
 
746
817
  async socketClosed(player, track, payload) {
747
818
  if (this.destroyed) return
748
819
 
749
820
  const code = payload?.code
750
821
 
751
- if (code === 4014 || code === 4022) {
822
+ // playerMove is handled by a single listener registered in constructor
823
+
824
+ if (code === 4022) {
752
825
  this.aqua.emit('socketClosed', this, payload)
753
826
  this.destroy()
754
827
  return
755
828
  }
756
829
 
757
- if (code === 4015) {
758
- this.aqua.emit('debug', `[Player ${this.guildId}] Voice server crashed (4015), attempting resume...`)
759
- try {
760
- // Race resume with timeout inside _attemptVoiceResume
761
- await this._attemptVoiceResume()
762
- this.aqua.emit('debug', `[Player ${this.guildId}] Voice resume succeeded`)
763
- return
764
- } catch (err) {
765
- this.aqua.emit('debug', `[Player ${this.guildId}] Resume failed: ${err.message}. Falling back to reconnect`)
766
- // fall through to your existing reconnect flow below
830
+ if (code === 4015) {
831
+ this.aqua.emit('debug', `[Player ${this.guildId}] Voice server crashed (4015), attempting resume...`)
832
+ try {
833
+ // Race resume with timeout inside _attemptVoiceResume
834
+ await this._attemptVoiceResume()
835
+ this.aqua.emit('debug', `[Player ${this.guildId}] Voice resume succeeded`)
836
+ return
837
+ } catch (err) {
838
+ this.aqua.emit('debug', `[Player ${this.guildId}] Resume failed: ${err.message}. Falling back to reconnect`)
839
+ // fall through to your existing reconnect flow below
840
+ }
767
841
  }
768
- }
769
842
 
770
843
  if (code !== 4015 && code !== 4009 && code !== 4006) {
771
844
  this.aqua.emit('socketClosed', this, payload)
@@ -884,6 +957,23 @@ async _attemptVoiceResume() {
884
957
  this.aqua.emit('lyricsNotFound', this, track, payload)
885
958
  }
886
959
 
960
+ _handleAquaPlayerMove(oldChannel, newChannel) {
961
+ try {
962
+ if (fnToId(oldChannel) === fnToId(this.voiceChannel)) {
963
+ this.voiceChannel = fnToId(newChannel)
964
+ this.connected = !!newChannel
965
+ this.send({
966
+ guild_id: options.guildId || this.guildId,
967
+ channel_id: this.voiceChannel,
968
+ self_deaf: this.deaf,
969
+ self_mute: this.mute
970
+ })
971
+ }
972
+ } catch (err) {
973
+ // don't let playerMove errors propagate
974
+ }
975
+ }
976
+
887
977
  send(data) {
888
978
  try {
889
979
  this.aqua.send({ op: 4, d: data })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aqualink",
3
- "version": "2.11.5",
3
+ "version": "2.11.7",
4
4
  "description": "An Lavalink client, focused in pure performance and features",
5
5
  "main": "./build/index.js",
6
6
  "types": "./build/index.d.ts",