aqualink 2.20.0 → 3.0.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,398 @@
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._stateGeneration !== currentGen) {
167
+ conn._aqua.emit(
168
+ AqualinkEvents.Debug,
169
+ `Resume aborted: State changed during attempt for guild ${conn._guildId}`
170
+ )
171
+ return false
172
+ }
173
+
174
+ await this.sendUpdate(payload)
175
+ if (conn._aqua?.debugTrace) {
176
+ conn._aqua._trace('connection.resume.success', {
177
+ guildId: conn._guildId
178
+ })
179
+ }
180
+
181
+ conn._reconnectAttempts = 0
182
+ conn._consecutiveFailures = 0
183
+ if (conn._player) conn._player._resuming = false
184
+
185
+ conn._aqua.emit(
186
+ AqualinkEvents.Debug,
187
+ `Resume PATCH sent for guild ${conn._guildId}`
188
+ )
189
+ return true
190
+ } catch (error) {
191
+ if (conn._destroyed || !conn._aqua) throw error
192
+ conn._consecutiveFailures++
193
+ conn._aqua.emit(
194
+ AqualinkEvents.Debug,
195
+ `Resume failed for guild ${conn._guildId}: ${error?.message || error}`
196
+ )
197
+ if (conn._aqua?.debugTrace) {
198
+ conn._aqua._trace('connection.resume.error', {
199
+ guildId: conn._guildId,
200
+ error: error?.message || String(error)
201
+ })
202
+ }
203
+
204
+ if (
205
+ conn._reconnectAttempts < this.MAX_RECONNECT_ATTEMPTS &&
206
+ !conn._destroyed &&
207
+ conn._consecutiveFailures < 5
208
+ ) {
209
+ const delay = Math.min(
210
+ this.RECONNECT_DELAY * (1 << (conn._reconnectAttempts - 1)),
211
+ this.RESUME_BACKOFF_MAX
212
+ )
213
+ conn._setReconnectTimer(delay)
214
+ } else {
215
+ conn._aqua.emit(
216
+ AqualinkEvents.Debug,
217
+ `Max reconnect attempts/failures reached for guild ${conn._guildId}`
218
+ )
219
+ if (conn._player) conn._player._resuming = false
220
+ conn._handleDisconnect()
221
+ }
222
+ return false
223
+ } finally {
224
+ conn._stateFlags &= ~this.STATE.ATTEMPTING_RESUME
225
+ sharedPool.release(payload)
226
+ }
227
+ }
228
+
229
+ async recoverMissingPlayer(isSessionError) {
230
+ const conn = this.connection
231
+ if (conn._destroyed || !conn._player || conn._missingPlayerRecovering)
232
+ return false
233
+
234
+ const now = Date.now()
235
+ if (now - conn._lastMissingPlayerRecoverAt < 5000) return false
236
+
237
+ conn._missingPlayerRecovering = true
238
+ conn._lastMissingPlayerRecoverAt = now
239
+ if (conn._aqua?.debugTrace) {
240
+ conn._aqua._trace('connection.playerMissing.recover.start', {
241
+ guildId: conn._guildId,
242
+ isSessionError: !!isSessionError
243
+ })
244
+ }
245
+
246
+ try {
247
+ const recoveryToken = conn._player._claimVoiceRecovery?.(
248
+ 'missing_player_recover'
249
+ )
250
+ if (isSessionError && conn._player?.nodes?._clearSession) {
251
+ conn._player.nodes._clearSession()
252
+ }
253
+
254
+ if (conn._player?._isVoiceRecoveryActive?.(recoveryToken))
255
+ conn._requestVoiceState()
256
+ const resumed = await this.attemptResume().catch((error) => {
257
+ reportSuppressedError(
258
+ conn._aqua,
259
+ 'connection.playerMissing.resume',
260
+ error,
261
+ {
262
+ guildId: conn._guildId
263
+ }
264
+ )
265
+ return false
266
+ })
267
+ if (resumed) {
268
+ conn._player?._clearVoiceRecovery?.(recoveryToken, 'missing_player_resumed')
269
+ } else if (conn._player?._isVoiceRecoveryActive?.(recoveryToken)) {
270
+ conn.resendVoiceUpdate(true)
271
+ }
272
+
273
+ if (conn._player.playing && conn._player.current?.track) {
274
+ const data = {
275
+ track: { encoded: conn._player.current.track },
276
+ paused: !!conn._player.paused
277
+ }
278
+ if (conn._player.position > 0) data.position = conn._player.position
279
+ await conn._rest.updatePlayer({
280
+ guildId: conn._guildId,
281
+ data,
282
+ noReplace: false
283
+ })
284
+ }
285
+
286
+ if (conn._aqua?.debugTrace) {
287
+ conn._aqua._trace('connection.playerMissing.recover.ok', {
288
+ guildId: conn._guildId,
289
+ resumed: !!resumed,
290
+ playing: !!conn._player.playing
291
+ })
292
+ }
293
+ return true
294
+ } catch (error) {
295
+ if (conn._aqua?.debugTrace) {
296
+ conn._aqua._trace('connection.playerMissing.recover.error', {
297
+ guildId: conn._guildId,
298
+ error: error?.message || String(error)
299
+ })
300
+ }
301
+ return false
302
+ } finally {
303
+ conn._missingPlayerRecovering = false
304
+ }
305
+ }
306
+
307
+ async sendUpdate(payload) {
308
+ const conn = this.connection
309
+ if (conn._destroyed) throw new Error('Connection destroyed')
310
+ if (!conn._rest) throw new Error('REST interface unavailable')
311
+
312
+ try {
313
+ if (conn._aqua?.debugTrace) {
314
+ conn._aqua._trace('connection.update.send', {
315
+ guildId: conn._guildId,
316
+ hasSessionId: !!conn._rest?.sessionId,
317
+ hasVoice:
318
+ !!payload?.data?.voice?.sessionId &&
319
+ !!payload?.data?.voice?.endpoint
320
+ })
321
+ }
322
+ await conn._rest.updatePlayer(payload)
323
+ if (conn._aqua?.debugTrace) {
324
+ conn._aqua._trace('connection.update.ok', {
325
+ guildId: conn._guildId
326
+ })
327
+ }
328
+ } catch (error) {
329
+ if (conn._aqua?.debugTrace) {
330
+ conn._aqua._trace('connection.update.error', {
331
+ guildId: conn._guildId,
332
+ statusCode: error?.statusCode || error?.response?.statusCode || null,
333
+ error: error?.message || String(error)
334
+ })
335
+ }
336
+ if (error.statusCode === 404 || error.response?.statusCode === 404) {
337
+ const isSessionError =
338
+ error.body?.message?.includes('sessionId') || false
339
+ const recovered = await this.recoverMissingPlayer(isSessionError)
340
+ if (recovered) return
341
+
342
+ if (conn._aqua) {
343
+ conn._aqua.emit(
344
+ AqualinkEvents.Debug,
345
+ `[Aqua/Connection] Player ${conn._guildId} not found (404)${isSessionError ? ' - Session invalid' : ''}. Recovery failed, destroying.`
346
+ )
347
+ await conn._aqua.destroyPlayer(conn._guildId)
348
+ }
349
+ throw error
350
+ }
351
+ if (!this._functions.isNetworkError(error)) {
352
+ conn._aqua.emit(
353
+ AqualinkEvents.Debug,
354
+ new Error(`Voice update failed: ${error?.message || error}`)
355
+ )
356
+ }
357
+ throw error
358
+ }
359
+ }
360
+ }
361
+
362
+ class PayloadPool {
363
+ constructor() {
364
+ this._pool = []
365
+ this._size = 0
366
+ }
367
+
368
+ _create() {
369
+ return {
370
+ guildId: null,
371
+ data: {
372
+ voice: {
373
+ token: null,
374
+ endpoint: null,
375
+ sessionId: null
376
+ },
377
+ volume: null
378
+ }
379
+ }
380
+ }
381
+
382
+ acquire() {
383
+ return this._size > 0 ? this._pool[--this._size] : this._create()
384
+ }
385
+
386
+ release(payload) {
387
+ if (!payload || this._size >= 12) return
388
+ payload.guildId = null
389
+ const v = payload.data.voice
390
+ v.token = v.endpoint = v.sessionId = null
391
+ payload.data.volume = null
392
+ this._pool[this._size++] = payload
393
+ }
394
+ }
395
+
396
+ const sharedPool = new PayloadPool()
397
+
398
+ 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
  }
@@ -182,7 +207,11 @@ class Filters {
182
207
  queueMicrotask(() => {
183
208
  this._pendingUpdate = false
184
209
  if (this.player) {
185
- this.updateFilters().catch(() => {})
210
+ this.updateFilters().catch((error) =>
211
+ reportSuppressedError(this.player, 'filters.update', error, {
212
+ guildId: this.player.guildId
213
+ })
214
+ )
186
215
  }
187
216
  })
188
217
  return this
@@ -221,6 +250,37 @@ class Filters {
221
250
  return this._setFilter('lowPass', enabled, options)
222
251
  }
223
252
 
253
+ setPluginFilters(filters) {
254
+ const next = _utils.normalizePluginFilters(filters)
255
+ if (_utils.objectEqual(this.filters.pluginFilters, next)) return this
256
+ this.filters.pluginFilters = next
257
+ this._dirty.add('pluginFilters')
258
+ return this._scheduleUpdate()
259
+ }
260
+
261
+ setPluginFilter(name, config) {
262
+ if (!name || typeof name !== 'string')
263
+ throw new TypeError('Plugin filter name is required')
264
+
265
+ if (!config || typeof config !== 'object') {
266
+ if (!this.filters.pluginFilters?.[name]) return this
267
+ const next = { ...this.filters.pluginFilters }
268
+ delete next[name]
269
+ return this.setPluginFilters(next)
270
+ }
271
+
272
+ const current = this.filters.pluginFilters || EMPTY_OBJECT
273
+ if (current[name] === config) return this
274
+ return this.setPluginFilters({ ...current, [name]: config })
275
+ }
276
+
277
+ clearPluginFilters() {
278
+ if (!this.filters.pluginFilters) return this
279
+ this.filters.pluginFilters = null
280
+ this._dirty.add('pluginFilters')
281
+ return this._scheduleUpdate()
282
+ }
283
+
224
284
  setBassboost(enabled, options = {}) {
225
285
  if (!enabled) {
226
286
  if (
@@ -317,12 +377,19 @@ class Filters {
317
377
  for (let i = 0; i < filterNames.length; i++) {
318
378
  const key = filterNames[i]
319
379
  if (f[key] !== null) {
380
+ if (key !== 'pluginFilters') filterPool.release(key, f[key])
320
381
  f[key] = null
321
382
  this._dirty.add(key)
322
383
  changed = true
323
384
  }
324
385
  }
325
386
 
387
+ if (f.pluginFilters !== null) {
388
+ f.pluginFilters = null
389
+ this._dirty.add('pluginFilters')
390
+ changed = true
391
+ }
392
+
326
393
  for (const key in this.presets) {
327
394
  if (this.presets[key] !== null) this.presets[key] = null
328
395
  }
@@ -333,17 +400,31 @@ class Filters {
333
400
  async updateFilters() {
334
401
  if (!this.player || !this._dirty.size) return this
335
402
 
336
- const payload = {}
337
- for (const key of this._dirty) {
338
- payload[key] = this.filters[key]
403
+ const dirtyKeys = [...this._dirty]
404
+ const dirtySet = new Set(dirtyKeys)
405
+ const payload = {
406
+ volume: this.filters.volume,
407
+ equalizer: this.filters.equalizer
408
+ }
409
+ const filterNames = Object.keys(FILTER_DEFAULTS)
410
+ for (let i = 0; i < filterNames.length; i++) {
411
+ const key = filterNames[i]
412
+ if (this.filters[key] !== null) payload[key] = this.filters[key]
413
+ else if (dirtySet.has(key)) payload[key] = null
414
+ }
415
+ payload.pluginFilters = this.filters.pluginFilters || {}
416
+
417
+ for (const key of dirtyKeys) this._dirty.delete(key)
418
+
419
+ try {
420
+ await this.player.nodes.rest.updatePlayer({
421
+ guildId: this.player.guildId,
422
+ data: { filters: payload }
423
+ })
424
+ } catch (error) {
425
+ for (const key of dirtyKeys) this._dirty.add(key)
426
+ throw error
339
427
  }
340
-
341
- this._dirty.clear()
342
-
343
- await this.player.nodes.rest.updatePlayer({
344
- guildId: this.player.guildId,
345
- data: { filters: payload }
346
- })
347
428
  return this
348
429
  }
349
430
  }