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.
@@ -0,0 +1,425 @@
1
+ const { AqualinkEvents } = require('./AqualinkEvents')
2
+ const { reportSuppressedError } = require('./Reporting')
3
+
4
+ class ConnectionRecovery {
5
+ constructor(connection, deps) {
6
+ this.connection = connection
7
+ this._functions = deps._functions
8
+ this.STATE = deps.STATE
9
+ this.RECONNECT_DELAY = deps.RECONNECT_DELAY
10
+ this.MAX_RECONNECT_ATTEMPTS = deps.MAX_RECONNECT_ATTEMPTS
11
+ this.RESUME_BACKOFF_MAX = deps.RESUME_BACKOFF_MAX
12
+ }
13
+
14
+ setServerUpdate(data) {
15
+ const conn = this.connection
16
+ if (conn._destroyed || !data?.token) return
17
+
18
+ const endpoint =
19
+ typeof data.endpoint === 'string' ? data.endpoint.trim() : ''
20
+ if (!endpoint) return
21
+
22
+ if (conn._lastEndpoint === endpoint && conn.token === data.token) return
23
+ if (data.txId && data.txId < conn.txId) return
24
+
25
+ conn._stateGeneration++
26
+
27
+ if (conn._lastEndpoint !== endpoint) {
28
+ conn.sequence = 0
29
+ conn._lastEndpoint = endpoint
30
+ conn._reconnectAttempts = 0
31
+ conn._consecutiveFailures = 0
32
+ conn._regionMigrationAttempted = false
33
+ }
34
+
35
+ conn.endpoint = endpoint
36
+ conn.region = this._functions.extractRegion(endpoint)
37
+ conn.token = data.token
38
+ conn.channelId = data.channel_id || conn.channelId || conn.voiceChannel
39
+ conn._lastVoiceDataUpdate = Date.now()
40
+ if (conn._aqua?.debugTrace) {
41
+ conn._aqua._trace('connection.serverUpdate', {
42
+ guildId: conn._guildId,
43
+ endpoint: conn.endpoint,
44
+ region: conn.region,
45
+ txId: data.txId || null
46
+ })
47
+ }
48
+ conn._stateFlags &= ~this.STATE.VOICE_DATA_STALE
49
+
50
+ const migrated = this.checkRegionMigration()
51
+ if (migrated) return
52
+ conn._scheduleVoiceUpdate()
53
+ conn._player?._flushDeferredPlay?.()
54
+ }
55
+
56
+ checkRegionMigration() {
57
+ const conn = this.connection
58
+ if (conn._destroyed || conn._regionMigrationAttempted) return false
59
+ if (
60
+ !conn._aqua?.autoRegionMigrate ||
61
+ !conn.region ||
62
+ conn.region === 'unknown'
63
+ )
64
+ return false
65
+
66
+ const player = conn._player
67
+ if (!player || player.destroyed || player._resuming || player._reconnecting)
68
+ return false
69
+
70
+ const currentNode = player.nodes
71
+ if (!currentNode) return false
72
+
73
+ const currentRegions = Array.isArray(currentNode.regions)
74
+ ? currentNode.regions
75
+ : []
76
+ const alreadyMatching = currentRegions.some((r) =>
77
+ conn._aqua._regionMatches?.(r, conn.region)
78
+ )
79
+ if (alreadyMatching) {
80
+ conn._regionMigrationAttempted = true
81
+ return false
82
+ }
83
+
84
+ const targetNode = conn._aqua._findBestNodeForRegion?.(conn.region)
85
+ if (!targetNode || targetNode === currentNode) return false
86
+
87
+ conn._regionMigrationAttempted = true
88
+ if (conn._aqua?.debugTrace) {
89
+ conn._aqua._trace('connection.region.migrate', {
90
+ guildId: conn._guildId,
91
+ region: conn.region,
92
+ from: currentNode?.name || currentNode?.host,
93
+ to: targetNode?.name || targetNode?.host
94
+ })
95
+ }
96
+
97
+ queueMicrotask(() => {
98
+ conn._aqua
99
+ .movePlayerToNode?.(conn._guildId, targetNode, 'region')
100
+ .catch((error) => {
101
+ conn._regionMigrationAttempted = false
102
+ reportSuppressedError(
103
+ conn._aqua,
104
+ 'connection.region.migrate',
105
+ error,
106
+ {
107
+ guildId: conn._guildId,
108
+ region: conn.region
109
+ }
110
+ )
111
+ })
112
+ })
113
+ return true
114
+ }
115
+
116
+ async attemptResume() {
117
+ const conn = this.connection
118
+ if (!conn._canAttemptResumeCore()) return false
119
+ if (conn._aqua?.debugTrace) {
120
+ conn._aqua._trace('connection.resume.attempt', {
121
+ guildId: conn._guildId,
122
+ reconnectAttempts: conn._reconnectAttempts,
123
+ hasSessionId: !!conn.sessionId,
124
+ hasEndpoint: !!conn.endpoint,
125
+ hasToken: !!conn.token
126
+ })
127
+ }
128
+
129
+ const currentGen = conn._stateGeneration
130
+ if (
131
+ !conn.sessionId ||
132
+ !conn.endpoint ||
133
+ !conn.token ||
134
+ conn._stateFlags & this.STATE.VOICE_DATA_STALE
135
+ ) {
136
+ const now = Date.now()
137
+ if (now - (conn._lastResumeBlockedLogAt || 0) >= 5000) {
138
+ conn._lastResumeBlockedLogAt = now
139
+ conn._aqua.emit(
140
+ AqualinkEvents.Debug,
141
+ `Resume blocked: missing voice data for guild ${conn._guildId}, requesting voice state`
142
+ )
143
+ }
144
+ conn._requestVoiceState()
145
+ return false
146
+ }
147
+
148
+ conn.txId = conn._player.txId || conn.txId
149
+ conn._stateFlags |= this.STATE.ATTEMPTING_RESUME
150
+ conn._reconnectAttempts++
151
+ conn._aqua.emit(
152
+ AqualinkEvents.Debug,
153
+ `Attempt resume: guild=${conn._guildId} endpoint=${conn.endpoint} session=${conn.sessionId}`
154
+ )
155
+
156
+ const payload = sharedPool.acquire()
157
+ try {
158
+ this._functions.fillVoicePayload(
159
+ payload,
160
+ conn._guildId,
161
+ conn,
162
+ conn._player,
163
+ true
164
+ )
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
+
174
+ if (conn._stateGeneration !== currentGen) {
175
+ conn._aqua.emit(
176
+ AqualinkEvents.Debug,
177
+ `Resume aborted: State changed during attempt for guild ${conn._guildId}`
178
+ )
179
+ return false
180
+ }
181
+
182
+ await this.sendUpdate(payload)
183
+ if (conn._aqua?.debugTrace) {
184
+ conn._aqua._trace('connection.resume.success', {
185
+ guildId: conn._guildId
186
+ })
187
+ }
188
+
189
+ if (conn._destroyed || conn._player?.destroyed) {
190
+ return false
191
+ }
192
+
193
+ conn._reconnectAttempts = 0
194
+ conn._consecutiveFailures = 0
195
+ if (conn._player) conn._player._resuming = false
196
+
197
+ conn._aqua.emit(
198
+ AqualinkEvents.Debug,
199
+ `Resume PATCH sent for guild ${conn._guildId}`
200
+ )
201
+ return true
202
+ } catch (error) {
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
+ }
211
+ conn._consecutiveFailures++
212
+ conn._aqua.emit(
213
+ AqualinkEvents.Debug,
214
+ `Resume failed for guild ${conn._guildId} (sessionId=${conn.sessionId || 'none'}, endpoint=${conn.endpoint || 'none'}): ${error?.message || error}`
215
+ )
216
+ if (conn._aqua?.debugTrace) {
217
+ conn._aqua._trace('connection.resume.error', {
218
+ guildId: conn._guildId,
219
+ error: error?.message || String(error)
220
+ })
221
+ }
222
+
223
+ if (
224
+ conn._reconnectAttempts < this.MAX_RECONNECT_ATTEMPTS &&
225
+ !conn._destroyed &&
226
+ conn._consecutiveFailures < 5 &&
227
+ !conn._player?.destroyed
228
+ ) {
229
+ const delay = Math.min(
230
+ this.RECONNECT_DELAY * (1 << (conn._reconnectAttempts - 1)),
231
+ this.RESUME_BACKOFF_MAX
232
+ )
233
+ conn._setReconnectTimer(delay)
234
+ } else {
235
+ conn._aqua.emit(
236
+ AqualinkEvents.Debug,
237
+ `Max reconnect attempts/failures reached for guild ${conn._guildId}`
238
+ )
239
+ if (conn._player) conn._player._resuming = false
240
+ conn._handleDisconnect()
241
+ }
242
+ return false
243
+ } finally {
244
+ conn._stateFlags &= ~this.STATE.ATTEMPTING_RESUME
245
+ sharedPool.release(payload)
246
+ }
247
+ }
248
+
249
+ async recoverMissingPlayer(isSessionError) {
250
+ const conn = this.connection
251
+ if (conn._destroyed || !conn._player || conn._missingPlayerRecovering)
252
+ return false
253
+
254
+ const now = Date.now()
255
+ if (now - conn._lastMissingPlayerRecoverAt < 5000) return false
256
+
257
+ conn._missingPlayerRecovering = true
258
+ conn._lastMissingPlayerRecoverAt = now
259
+ if (conn._aqua?.debugTrace) {
260
+ conn._aqua._trace('connection.playerMissing.recover.start', {
261
+ guildId: conn._guildId,
262
+ isSessionError: !!isSessionError
263
+ })
264
+ }
265
+
266
+ try {
267
+ const recoveryToken = conn._player._claimVoiceRecovery?.(
268
+ 'missing_player_recover'
269
+ )
270
+ if (isSessionError && conn._player?.nodes?._clearSession) {
271
+ conn._player.nodes._clearSession()
272
+ }
273
+
274
+ if (conn._player?._isVoiceRecoveryActive?.(recoveryToken))
275
+ conn._requestVoiceState()
276
+ const resumed = await this.attemptResume().catch((error) => {
277
+ reportSuppressedError(
278
+ conn._aqua,
279
+ 'connection.playerMissing.resume',
280
+ error,
281
+ {
282
+ guildId: conn._guildId
283
+ }
284
+ )
285
+ return false
286
+ })
287
+ if (resumed) {
288
+ conn._player?._clearVoiceRecovery?.(
289
+ recoveryToken,
290
+ 'missing_player_resumed'
291
+ )
292
+ } else if (conn._player?._isVoiceRecoveryActive?.(recoveryToken)) {
293
+ conn.resendVoiceUpdate(true)
294
+ }
295
+
296
+ if (conn._player.playing && conn._player.current?.track) {
297
+ const data = {
298
+ track: { encoded: conn._player.current.track },
299
+ paused: !!conn._player.paused
300
+ }
301
+ if (conn._player.position > 0) data.position = conn._player.position
302
+ await conn._rest.updatePlayer({
303
+ guildId: conn._guildId,
304
+ data,
305
+ noReplace: false
306
+ })
307
+ }
308
+
309
+ if (conn._aqua?.debugTrace) {
310
+ conn._aqua._trace('connection.playerMissing.recover.ok', {
311
+ guildId: conn._guildId,
312
+ resumed: !!resumed,
313
+ playing: !!conn._player.playing
314
+ })
315
+ }
316
+ return true
317
+ } catch (error) {
318
+ if (conn._aqua?.debugTrace) {
319
+ conn._aqua._trace('connection.playerMissing.recover.error', {
320
+ guildId: conn._guildId,
321
+ error: error?.message || String(error)
322
+ })
323
+ }
324
+ return false
325
+ } finally {
326
+ conn._missingPlayerRecovering = false
327
+ }
328
+ }
329
+
330
+ async sendUpdate(payload) {
331
+ const conn = this.connection
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
+ )
338
+
339
+ try {
340
+ if (conn._aqua?.debugTrace) {
341
+ conn._aqua._trace('connection.update.send', {
342
+ guildId: conn._guildId,
343
+ hasSessionId: !!conn._rest?.sessionId,
344
+ hasVoice:
345
+ !!payload?.data?.voice?.sessionId &&
346
+ !!payload?.data?.voice?.endpoint
347
+ })
348
+ }
349
+ await conn._rest.updatePlayer(payload)
350
+ if (conn._aqua?.debugTrace) {
351
+ conn._aqua._trace('connection.update.ok', {
352
+ guildId: conn._guildId
353
+ })
354
+ }
355
+ } catch (error) {
356
+ if (conn._aqua?.debugTrace) {
357
+ conn._aqua._trace('connection.update.error', {
358
+ guildId: conn._guildId,
359
+ statusCode: error?.statusCode || error?.response?.statusCode || null,
360
+ error: error?.message || String(error)
361
+ })
362
+ }
363
+ if (error.statusCode === 404 || error.response?.statusCode === 404) {
364
+ const isSessionError =
365
+ error.body?.message?.includes('sessionId') || false
366
+ const recovered = await this.recoverMissingPlayer(isSessionError)
367
+ if (recovered) return
368
+
369
+ if (conn._aqua) {
370
+ conn._aqua.emit(
371
+ AqualinkEvents.Debug,
372
+ `[Aqua/Connection] Player ${conn._guildId} not found (404, sessionId=${conn.sessionId || 'none'}, endpoint=${conn.endpoint || 'none'})${isSessionError ? ' - Session invalid' : ''}. Recovery failed, destroying.`
373
+ )
374
+ await conn._aqua.destroyPlayer(conn._guildId)
375
+ }
376
+ throw error
377
+ }
378
+ if (!this._functions.isNetworkError(error)) {
379
+ conn._aqua.emit(
380
+ AqualinkEvents.Debug,
381
+ new Error(`Voice update failed: ${error?.message || error}`)
382
+ )
383
+ }
384
+ throw error
385
+ }
386
+ }
387
+ }
388
+
389
+ class PayloadPool {
390
+ constructor() {
391
+ this._pool = []
392
+ this._size = 0
393
+ }
394
+
395
+ _create() {
396
+ return {
397
+ guildId: null,
398
+ data: {
399
+ voice: {
400
+ token: null,
401
+ endpoint: null,
402
+ sessionId: null
403
+ },
404
+ volume: null
405
+ }
406
+ }
407
+ }
408
+
409
+ acquire() {
410
+ return this._size > 0 ? this._pool[--this._size] : this._create()
411
+ }
412
+
413
+ release(payload) {
414
+ if (!payload || this._size >= 12) return
415
+ payload.guildId = null
416
+ const v = payload.data.voice
417
+ v.token = v.endpoint = v.sessionId = null
418
+ payload.data.volume = null
419
+ this._pool[this._size++] = payload
420
+ }
421
+ }
422
+
423
+ const sharedPool = new PayloadPool()
424
+
425
+ module.exports = ConnectionRecovery
@@ -27,6 +27,7 @@ const FILTER_DEFAULTS = Object.freeze({
27
27
  }),
28
28
  lowPass: Object.freeze({ smoothing: 20 })
29
29
  })
30
+ const { reportSuppressedError } = require('./Reporting')
30
31
 
31
32
  const FILTER_KEYS = Object.freeze(
32
33
  Object.fromEntries(
@@ -38,6 +39,7 @@ const FILTER_KEYS = Object.freeze(
38
39
  )
39
40
 
40
41
  const EMPTY_ARRAY = Object.freeze([])
42
+ const EMPTY_OBJECT = Object.freeze({})
41
43
 
42
44
  const FILTER_POOL_SIZE = 16
43
45
  const filterPool = {
@@ -88,6 +90,27 @@ const _utils = Object.freeze({
88
90
  return !arr || arr.length === 0
89
91
  },
90
92
 
93
+ objectEqual(a, b) {
94
+ if (a === b) return true
95
+ const keysA = a ? Object.keys(a) : EMPTY_ARRAY
96
+ const keysB = b ? Object.keys(b) : EMPTY_ARRAY
97
+ if (keysA.length !== keysB.length) return false
98
+ for (let i = 0; i < keysA.length; i++) {
99
+ const key = keysA[i]
100
+ if (!(key in b) || a[key] !== b[key]) return false
101
+ }
102
+ return true
103
+ },
104
+
105
+ normalizePluginFilters(filters) {
106
+ if (!filters || typeof filters !== 'object') return null
107
+ const entries = Object.entries(filters).filter(
108
+ ([key, value]) => key && value && typeof value === 'object'
109
+ )
110
+ if (!entries.length) return null
111
+ return Object.fromEntries(entries)
112
+ },
113
+
91
114
  makeEqArray(len, gain) {
92
115
  const out = new Array(len)
93
116
  for (let i = 0; i < len; i++) out[i] = { band: i, gain }
@@ -125,7 +148,8 @@ class Filters {
125
148
  rotation: options.rotation ?? null,
126
149
  distortion: options.distortion ?? null,
127
150
  channelMix: options.channelMix ?? null,
128
- lowPass: options.lowPass ?? null
151
+ lowPass: options.lowPass ?? null,
152
+ pluginFilters: _utils.normalizePluginFilters(options.pluginFilters)
129
153
  }
130
154
 
131
155
  this.presets = {
@@ -140,6 +164,7 @@ class Filters {
140
164
  destroy() {
141
165
  for (const [key, value] of Object.entries(this.filters)) {
142
166
  if (value && typeof value === 'object' && key !== 'equalizer') {
167
+ if (key === 'pluginFilters') continue
143
168
  filterPool.release(key, value)
144
169
  }
145
170
  }
@@ -177,12 +202,17 @@ class Filters {
177
202
  }
178
203
 
179
204
  _scheduleUpdate() {
180
- if (this._pendingUpdate || !this.player) return this
205
+ if (this._pendingUpdate || !this.player || this.player.destroyed)
206
+ return this
181
207
  this._pendingUpdate = true
182
208
  queueMicrotask(() => {
183
209
  this._pendingUpdate = false
184
210
  if (this.player) {
185
- this.updateFilters().catch(() => {})
211
+ this.updateFilters().catch((error) =>
212
+ reportSuppressedError(this.player, 'filters.update', error, {
213
+ guildId: this.player.guildId
214
+ })
215
+ )
186
216
  }
187
217
  })
188
218
  return this
@@ -221,6 +251,37 @@ class Filters {
221
251
  return this._setFilter('lowPass', enabled, options)
222
252
  }
223
253
 
254
+ setPluginFilters(filters) {
255
+ const next = _utils.normalizePluginFilters(filters)
256
+ if (_utils.objectEqual(this.filters.pluginFilters, next)) return this
257
+ this.filters.pluginFilters = next
258
+ this._dirty.add('pluginFilters')
259
+ return this._scheduleUpdate()
260
+ }
261
+
262
+ setPluginFilter(name, config) {
263
+ if (!name || typeof name !== 'string')
264
+ throw new TypeError('Plugin filter name is required')
265
+
266
+ if (!config || typeof config !== 'object') {
267
+ if (!this.filters.pluginFilters?.[name]) return this
268
+ const next = { ...this.filters.pluginFilters }
269
+ delete next[name]
270
+ return this.setPluginFilters(next)
271
+ }
272
+
273
+ const current = this.filters.pluginFilters || EMPTY_OBJECT
274
+ if (current[name] === config) return this
275
+ return this.setPluginFilters({ ...current, [name]: config })
276
+ }
277
+
278
+ clearPluginFilters() {
279
+ if (!this.filters.pluginFilters) return this
280
+ this.filters.pluginFilters = null
281
+ this._dirty.add('pluginFilters')
282
+ return this._scheduleUpdate()
283
+ }
284
+
224
285
  setBassboost(enabled, options = {}) {
225
286
  if (!enabled) {
226
287
  if (
@@ -317,12 +378,19 @@ class Filters {
317
378
  for (let i = 0; i < filterNames.length; i++) {
318
379
  const key = filterNames[i]
319
380
  if (f[key] !== null) {
381
+ if (key !== 'pluginFilters') filterPool.release(key, f[key])
320
382
  f[key] = null
321
383
  this._dirty.add(key)
322
384
  changed = true
323
385
  }
324
386
  }
325
387
 
388
+ if (f.pluginFilters !== null) {
389
+ f.pluginFilters = null
390
+ this._dirty.add('pluginFilters')
391
+ changed = true
392
+ }
393
+
326
394
  for (const key in this.presets) {
327
395
  if (this.presets[key] !== null) this.presets[key] = null
328
396
  }
@@ -331,19 +399,34 @@ class Filters {
331
399
  }
332
400
 
333
401
  async updateFilters() {
402
+ this._pendingUpdate = false
334
403
  if (!this.player || !this._dirty.size) return this
335
404
 
336
- const payload = {}
337
- for (const key of this._dirty) {
338
- payload[key] = this.filters[key]
405
+ const dirtyKeys = [...this._dirty]
406
+ const dirtySet = new Set(dirtyKeys)
407
+ const payload = {
408
+ volume: this.filters.volume,
409
+ equalizer: this.filters.equalizer
410
+ }
411
+ const filterNames = Object.keys(FILTER_DEFAULTS)
412
+ for (let i = 0; i < filterNames.length; i++) {
413
+ const key = filterNames[i]
414
+ if (this.filters[key] !== null) payload[key] = this.filters[key]
415
+ else if (dirtySet.has(key)) payload[key] = null
416
+ }
417
+ payload.pluginFilters = this.filters.pluginFilters || {}
418
+
419
+ for (const key of dirtyKeys) this._dirty.delete(key)
420
+
421
+ try {
422
+ await this.player.nodes.rest.updatePlayer({
423
+ guildId: this.player.guildId,
424
+ data: { filters: payload }
425
+ })
426
+ } catch (error) {
427
+ for (const key of dirtyKeys) this._dirty.add(key)
428
+ throw error
339
429
  }
340
-
341
- this._dirty.clear()
342
-
343
- await this.player.nodes.rest.updatePlayer({
344
- guildId: this.player.guildId,
345
- data: { filters: payload }
346
- })
347
430
  return this
348
431
  }
349
432
  }