aqualink 2.17.2 → 2.17.3
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.
package/build/structures/Aqua.js
CHANGED
|
@@ -2,12 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
const fs = require('node:fs')
|
|
4
4
|
const readline = require('node:readline')
|
|
5
|
-
const {EventEmitter} = require('tseep')
|
|
6
|
-
const {AqualinkEvents} = require('./AqualinkEvents')
|
|
5
|
+
const { EventEmitter } = require('tseep')
|
|
6
|
+
const { AqualinkEvents } = require('./AqualinkEvents')
|
|
7
7
|
const Node = require('./Node')
|
|
8
8
|
const Player = require('./Player')
|
|
9
9
|
const Track = require('./Track')
|
|
10
|
-
const {version: pkgVersion} = require('../../package.json')
|
|
10
|
+
const { version: pkgVersion } = require('../../package.json')
|
|
11
11
|
|
|
12
12
|
const SEARCH_PREFIX = ':'
|
|
13
13
|
const EMPTY_ARRAY = Object.freeze([])
|
|
@@ -33,7 +33,6 @@ const MAX_REBUILD_LOCKS = 100
|
|
|
33
33
|
const WRITE_BUFFER_SIZE = 100
|
|
34
34
|
const MAX_QUEUE_SAVE = 10
|
|
35
35
|
const MAX_TRACKS_RESTORE = 20
|
|
36
|
-
const URL_PATTERN = /^https?:\/\//i
|
|
37
36
|
|
|
38
37
|
const DEFAULT_OPTIONS = Object.freeze({
|
|
39
38
|
shouldDeleteMessage: false,
|
|
@@ -56,11 +55,17 @@ const DEFAULT_OPTIONS = Object.freeze({
|
|
|
56
55
|
})
|
|
57
56
|
})
|
|
58
57
|
|
|
59
|
-
// Shared helper functions
|
|
60
58
|
const _functions = {
|
|
61
|
-
delay: ms => new Promise(r =>
|
|
62
|
-
|
|
63
|
-
|
|
59
|
+
delay: ms => new Promise(r => {
|
|
60
|
+
const t = setTimeout(r, ms)
|
|
61
|
+
t.unref?.()
|
|
62
|
+
}),
|
|
63
|
+
noop: () => { },
|
|
64
|
+
isUrl: query => {
|
|
65
|
+
if (typeof query !== 'string' || query.length <= 8) return false
|
|
66
|
+
const q = query.trimStart()
|
|
67
|
+
return q.startsWith('http://') || q.startsWith('https://')
|
|
68
|
+
},
|
|
64
69
|
formatQuery(query, source) {
|
|
65
70
|
return this.isUrl(query) ? query : `${source}${SEARCH_PREFIX}${query}`
|
|
66
71
|
},
|
|
@@ -69,12 +74,17 @@ const _functions = {
|
|
|
69
74
|
try {
|
|
70
75
|
const result = fn()
|
|
71
76
|
return result?.then ? result.catch(this.noop) : result
|
|
72
|
-
} catch {}
|
|
77
|
+
} catch { }
|
|
73
78
|
},
|
|
74
79
|
parseRequester(str) {
|
|
75
80
|
if (!str || typeof str !== 'string') return null
|
|
76
81
|
const i = str.indexOf(':')
|
|
77
|
-
return i > 0 ? {id: str.substring(0, i), username: str.substring(i + 1)} : null
|
|
82
|
+
return i > 0 ? { id: str.substring(0, i), username: str.substring(i + 1) } : null
|
|
83
|
+
},
|
|
84
|
+
unrefTimeout: (fn, ms) => {
|
|
85
|
+
const t = setTimeout(fn, ms)
|
|
86
|
+
t.unref?.()
|
|
87
|
+
return t
|
|
78
88
|
}
|
|
79
89
|
}
|
|
80
90
|
|
|
@@ -92,9 +102,9 @@ class Aqua extends EventEmitter {
|
|
|
92
102
|
this.initiated = false
|
|
93
103
|
this.version = pkgVersion
|
|
94
104
|
|
|
95
|
-
const merged = {...DEFAULT_OPTIONS, ...options}
|
|
105
|
+
const merged = { ...DEFAULT_OPTIONS, ...options }
|
|
96
106
|
this.options = merged
|
|
97
|
-
this.failoverOptions = {...DEFAULT_OPTIONS.failoverOptions, ...options.failoverOptions}
|
|
107
|
+
this.failoverOptions = { ...DEFAULT_OPTIONS.failoverOptions, ...options.failoverOptions }
|
|
98
108
|
|
|
99
109
|
this.shouldDeleteMessage = merged.shouldDeleteMessage
|
|
100
110
|
this.defaultSearchPlatform = merged.defaultSearchPlatform
|
|
@@ -149,13 +159,13 @@ class Aqua extends EventEmitter {
|
|
|
149
159
|
this._performCleanup()
|
|
150
160
|
})
|
|
151
161
|
},
|
|
152
|
-
onNodeReady: (node, {resumed}) => {
|
|
162
|
+
onNodeReady: (node, { resumed }) => {
|
|
153
163
|
if (!resumed) return
|
|
154
164
|
const batch = []
|
|
155
165
|
for (const player of this.players.values()) {
|
|
156
166
|
if (player.nodes === node && player.connection) batch.push(player)
|
|
157
167
|
}
|
|
158
|
-
if (batch.length) queueMicrotask(() => batch.forEach(p => p.connection.resendVoiceUpdate(
|
|
168
|
+
if (batch.length) queueMicrotask(() => batch.forEach(p => p.connection.resendVoiceUpdate()))
|
|
159
169
|
}
|
|
160
170
|
}
|
|
161
171
|
this.on(AqualinkEvents.NodeConnect, this._eventHandlers.onNodeConnect)
|
|
@@ -171,8 +181,10 @@ class Aqua extends EventEmitter {
|
|
|
171
181
|
this._eventHandlers = null
|
|
172
182
|
}
|
|
173
183
|
this.removeAllListeners()
|
|
174
|
-
|
|
175
|
-
|
|
184
|
+
|
|
185
|
+
for (const id of Array.from(this.nodeMap.keys())) this._destroyNode(id)
|
|
186
|
+
for (const player of Array.from(this.players.values())) _functions.safeCall(() => player.destroy())
|
|
187
|
+
|
|
176
188
|
this.players.clear()
|
|
177
189
|
this._nodeStates.clear()
|
|
178
190
|
this._failoverQueue.clear()
|
|
@@ -220,7 +232,7 @@ class Aqua extends EventEmitter {
|
|
|
220
232
|
(stats.playingPlayers || 0) * 0.75 +
|
|
221
233
|
(stats.memory ? stats.memory.used / reservable : 0) * 40 +
|
|
222
234
|
(node.rest?.calls || 0) * 0.001
|
|
223
|
-
this._nodeLoadCache.set(id, {load, time: now})
|
|
235
|
+
this._nodeLoadCache.set(id, { load, time: now })
|
|
224
236
|
if (this._nodeLoadCache.size > MAX_CACHE_SIZE) {
|
|
225
237
|
const first = this._nodeLoadCache.keys().next().value
|
|
226
238
|
this._nodeLoadCache.delete(first)
|
|
@@ -233,7 +245,7 @@ class Aqua extends EventEmitter {
|
|
|
233
245
|
this.clientId = clientId
|
|
234
246
|
if (!this.clientId) return this
|
|
235
247
|
const results = await Promise.allSettled(
|
|
236
|
-
this.nodes.map(n => Promise.race([this._createNode(n), _functions.delay(NODE_TIMEOUT).then(() => {throw new Error('Timeout')})]))
|
|
248
|
+
this.nodes.map(n => Promise.race([this._createNode(n), _functions.delay(NODE_TIMEOUT).then(() => { throw new Error('Timeout') })]))
|
|
237
249
|
)
|
|
238
250
|
if (!results.some(r => r.status === 'fulfilled')) throw new Error('No nodes connected')
|
|
239
251
|
if (this.plugins?.length) {
|
|
@@ -249,10 +261,10 @@ class Aqua extends EventEmitter {
|
|
|
249
261
|
const node = new Node(this, options, this.options)
|
|
250
262
|
node.players = new Set()
|
|
251
263
|
this.nodeMap.set(id, node)
|
|
252
|
-
this._nodeStates.set(id, {connected: false, failoverInProgress: false})
|
|
264
|
+
this._nodeStates.set(id, { connected: false, failoverInProgress: false })
|
|
253
265
|
try {
|
|
254
266
|
await node.connect()
|
|
255
|
-
this._nodeStates.set(id, {connected: true, failoverInProgress: false})
|
|
267
|
+
this._nodeStates.set(id, { connected: true, failoverInProgress: false })
|
|
256
268
|
this._invalidateCache()
|
|
257
269
|
this.emit(AqualinkEvents.NodeCreate, node)
|
|
258
270
|
return node
|
|
@@ -304,7 +316,7 @@ class Aqua extends EventEmitter {
|
|
|
304
316
|
const now = Date.now()
|
|
305
317
|
for (const [guildId, state] of this._brokenPlayers) {
|
|
306
318
|
if (state.originalNodeId === id && (now - state.brokenAt) < BROKEN_PLAYER_TTL) {
|
|
307
|
-
rebuilds.push({guildId, state})
|
|
319
|
+
rebuilds.push({ guildId, state })
|
|
308
320
|
}
|
|
309
321
|
}
|
|
310
322
|
if (!rebuilds.length) return
|
|
@@ -312,7 +324,7 @@ class Aqua extends EventEmitter {
|
|
|
312
324
|
for (let i = 0; i < rebuilds.length; i += MAX_CONCURRENT_OPS) {
|
|
313
325
|
const batch = rebuilds.slice(i, i + MAX_CONCURRENT_OPS)
|
|
314
326
|
const results = await Promise.allSettled(
|
|
315
|
-
batch.map(({guildId, state}) => this._rebuildPlayer(state, node).then(() => guildId))
|
|
327
|
+
batch.map(({ guildId, state }) => this._rebuildPlayer(state, node).then(() => guildId))
|
|
316
328
|
)
|
|
317
329
|
for (const r of results) {
|
|
318
330
|
if (r.status === 'fulfilled') successes.push(r.value)
|
|
@@ -324,7 +336,7 @@ class Aqua extends EventEmitter {
|
|
|
324
336
|
}
|
|
325
337
|
|
|
326
338
|
async _rebuildPlayer(state, targetNode) {
|
|
327
|
-
const {guildId, textChannel, voiceChannel, current, volume = 65, deaf = true} = state
|
|
339
|
+
const { guildId, textChannel, voiceChannel, current, volume = 65, deaf = true } = state
|
|
328
340
|
const lockKey = `rebuild_${guildId}`
|
|
329
341
|
if (this._rebuildLocks.has(lockKey)) return
|
|
330
342
|
this._rebuildLocks.add(lockKey)
|
|
@@ -333,11 +345,11 @@ class Aqua extends EventEmitter {
|
|
|
333
345
|
await this.destroyPlayer(guildId)
|
|
334
346
|
await _functions.delay(RECONNECT_DELAY)
|
|
335
347
|
}
|
|
336
|
-
const player = this.createPlayer(targetNode, {guildId, textChannel, voiceChannel, defaultVolume: volume, deaf})
|
|
348
|
+
const player = this.createPlayer(targetNode, { guildId, textChannel, voiceChannel, defaultVolume: volume, deaf })
|
|
337
349
|
if (current && player?.queue?.add) {
|
|
338
350
|
player.queue.add(current)
|
|
339
351
|
await player.play()
|
|
340
|
-
if (state.position > 0)
|
|
352
|
+
if (state.position > 0) _functions.unrefTimeout(() => player.seek?.(state.position), SEEK_DELAY)
|
|
341
353
|
if (state.paused) player.pause(true)
|
|
342
354
|
}
|
|
343
355
|
return player
|
|
@@ -357,7 +369,7 @@ class Aqua extends EventEmitter {
|
|
|
357
369
|
const attempts = this._failoverQueue.get(id) || 0
|
|
358
370
|
if (attempts >= this.failoverOptions.maxFailoverAttempts) return
|
|
359
371
|
|
|
360
|
-
this._nodeStates.set(id, {connected: false, failoverInProgress: true})
|
|
372
|
+
this._nodeStates.set(id, { connected: false, failoverInProgress: true })
|
|
361
373
|
this._lastFailoverAttempt.set(id, now)
|
|
362
374
|
this._failoverQueue.set(id, attempts + 1)
|
|
363
375
|
|
|
@@ -379,7 +391,7 @@ class Aqua extends EventEmitter {
|
|
|
379
391
|
} catch (error) {
|
|
380
392
|
this.emit(AqualinkEvents.Error, null, error)
|
|
381
393
|
} finally {
|
|
382
|
-
this._nodeStates.set(id, {connected: false, failoverInProgress: false})
|
|
394
|
+
this._nodeStates.set(id, { connected: false, failoverInProgress: false })
|
|
383
395
|
}
|
|
384
396
|
}
|
|
385
397
|
|
|
@@ -403,7 +415,7 @@ class Aqua extends EventEmitter {
|
|
|
403
415
|
for (let i = 0; i < players.length; i += MAX_CONCURRENT_OPS) {
|
|
404
416
|
const batch = players.slice(i, i + MAX_CONCURRENT_OPS)
|
|
405
417
|
const batchResults = await Promise.allSettled(batch.map(p => this._migratePlayer(p, pickNode)))
|
|
406
|
-
for (const r of batchResults) results.push({success: r.status === 'fulfilled', error: r.reason})
|
|
418
|
+
for (const r of batchResults) results.push({ success: r.status === 'fulfilled', error: r.reason })
|
|
407
419
|
}
|
|
408
420
|
return results
|
|
409
421
|
}
|
|
@@ -411,7 +423,7 @@ class Aqua extends EventEmitter {
|
|
|
411
423
|
async _migratePlayer(player, pickNode) {
|
|
412
424
|
const state = this._capturePlayerState(player)
|
|
413
425
|
if (!state) throw new Error('Failed to capture state')
|
|
414
|
-
const {maxRetries, retryDelay} = this.failoverOptions
|
|
426
|
+
const { maxRetries, retryDelay } = this.failoverOptions
|
|
415
427
|
for (let retry = 0; retry < maxRetries; retry++) {
|
|
416
428
|
try {
|
|
417
429
|
const targetNode = pickNode()
|
|
@@ -462,10 +474,10 @@ class Aqua extends EventEmitter {
|
|
|
462
474
|
}
|
|
463
475
|
if (state.queue?.length && newPlayer.queue?.add) newPlayer.queue.add(...state.queue)
|
|
464
476
|
if (state.current && this.failoverOptions.preservePosition) {
|
|
465
|
-
newPlayer.queue?.add?.(state.current, {toFront: true})
|
|
477
|
+
newPlayer.queue?.add?.(state.current, { toFront: true })
|
|
466
478
|
if (this.failoverOptions.resumePlayback) {
|
|
467
479
|
ops.push(newPlayer.play())
|
|
468
|
-
if (state.position > 0)
|
|
480
|
+
if (state.position > 0) _functions.unrefTimeout(() => newPlayer.seek?.(state.position), SEEK_DELAY)
|
|
469
481
|
if (state.paused) ops.push(newPlayer.pause(true))
|
|
470
482
|
}
|
|
471
483
|
}
|
|
@@ -474,7 +486,7 @@ class Aqua extends EventEmitter {
|
|
|
474
486
|
await Promise.allSettled(ops)
|
|
475
487
|
}
|
|
476
488
|
|
|
477
|
-
updateVoiceState({d, t}) {
|
|
489
|
+
updateVoiceState({ d, t }) {
|
|
478
490
|
if (!d?.guild_id || (t !== 'VOICE_STATE_UPDATE' && t !== 'VOICE_SERVER_UPDATE')) return
|
|
479
491
|
const player = this.players.get(d.guild_id)
|
|
480
492
|
if (!player || !player.nodes?.connected) return
|
|
@@ -540,7 +552,7 @@ class Aqua extends EventEmitter {
|
|
|
540
552
|
await _functions.safeCall(() => player.destroy())
|
|
541
553
|
}
|
|
542
554
|
|
|
543
|
-
async resolve({query, source, requester, nodes}) {
|
|
555
|
+
async resolve({ query, source, requester, nodes }) {
|
|
544
556
|
if (!this.initiated) throw new Error('Aqua not initialized')
|
|
545
557
|
const node = this._getRequestNode(nodes)
|
|
546
558
|
if (!node) throw new Error('No nodes available')
|
|
@@ -581,8 +593,8 @@ class Aqua extends EventEmitter {
|
|
|
581
593
|
}
|
|
582
594
|
|
|
583
595
|
_constructResponse(response, requester, node) {
|
|
584
|
-
const {loadType, data, pluginInfo: rootPlugin} = response || {}
|
|
585
|
-
const base = {loadType, exception: null, playlistInfo: null, pluginInfo: rootPlugin || {}, tracks: []}
|
|
596
|
+
const { loadType, data, pluginInfo: rootPlugin } = response || {}
|
|
597
|
+
const base = { loadType, exception: null, playlistInfo: null, pluginInfo: rootPlugin || {}, tracks: [] }
|
|
586
598
|
if (loadType === 'error' || loadType === 'LOAD_FAILED') {
|
|
587
599
|
base.exception = data || response.exception || null
|
|
588
600
|
return base
|
|
@@ -599,7 +611,7 @@ class Aqua extends EventEmitter {
|
|
|
599
611
|
...info
|
|
600
612
|
}
|
|
601
613
|
}
|
|
602
|
-
base.pluginInfo = data.pluginInfo || base.pluginInfo
|
|
614
|
+
base.pluginInfo = data.pluginInfo || rootPlugin || base.pluginInfo
|
|
603
615
|
base.tracks = Array.isArray(data.tracks) ? data.tracks.map(t => _functions.makeTrack(t, requester, node)) : []
|
|
604
616
|
} else if (loadType === 'search') {
|
|
605
617
|
base.tracks = Array.isArray(data) ? data.map(t => _functions.makeTrack(t, requester, node)) : []
|
|
@@ -616,7 +628,7 @@ class Aqua extends EventEmitter {
|
|
|
616
628
|
async search(query, requester, source) {
|
|
617
629
|
if (!query || !requester) return null
|
|
618
630
|
try {
|
|
619
|
-
const {tracks} = await this.resolve({query, source: source || this.defaultSearchPlatform, requester})
|
|
631
|
+
const { tracks } = await this.resolve({ query, source: source || this.defaultSearchPlatform, requester })
|
|
620
632
|
return tracks || null
|
|
621
633
|
} catch {
|
|
622
634
|
return null
|
|
@@ -628,8 +640,8 @@ class Aqua extends EventEmitter {
|
|
|
628
640
|
const tempFile = `${filePath}.tmp`
|
|
629
641
|
let ws = null
|
|
630
642
|
try {
|
|
631
|
-
await fs.promises.writeFile(lockFile, String(process.pid), {flag: 'wx'})
|
|
632
|
-
ws = fs.createWriteStream(tempFile, {encoding: 'utf8', flags: 'w'})
|
|
643
|
+
await fs.promises.writeFile(lockFile, String(process.pid), { flag: 'wx' })
|
|
644
|
+
ws = fs.createWriteStream(tempFile, { encoding: 'utf8', flags: 'w' })
|
|
633
645
|
const buffer = []
|
|
634
646
|
let drainPromise = Promise.resolve()
|
|
635
647
|
|
|
@@ -680,11 +692,11 @@ class Aqua extends EventEmitter {
|
|
|
680
692
|
let stream = null, rl = null
|
|
681
693
|
try {
|
|
682
694
|
await fs.promises.access(filePath)
|
|
683
|
-
await fs.promises.writeFile(lockFile, String(process.pid), {flag: 'wx'})
|
|
695
|
+
await fs.promises.writeFile(lockFile, String(process.pid), { flag: 'wx' })
|
|
684
696
|
await this._waitForFirstNode()
|
|
685
697
|
|
|
686
|
-
stream = fs.createReadStream(filePath, {encoding: 'utf8'})
|
|
687
|
-
rl = readline.createInterface({input: stream, crlfDelay: Infinity})
|
|
698
|
+
stream = fs.createReadStream(filePath, { encoding: 'utf8' })
|
|
699
|
+
rl = readline.createInterface({ input: stream, crlfDelay: Infinity })
|
|
688
700
|
|
|
689
701
|
const batch = []
|
|
690
702
|
for await (const line of rl) {
|
|
@@ -719,7 +731,7 @@ class Aqua extends EventEmitter {
|
|
|
719
731
|
player._resuming = !!p.resuming
|
|
720
732
|
const requester = _functions.parseRequester(p.r)
|
|
721
733
|
const tracksToResolve = [p.u, ...(p.q || [])].filter(Boolean).slice(0, MAX_TRACKS_RESTORE)
|
|
722
|
-
const resolved = await Promise.all(tracksToResolve.map(uri => this.resolve({query: uri, requester}).catch(() => null)))
|
|
734
|
+
const resolved = await Promise.all(tracksToResolve.map(uri => this.resolve({ query: uri, requester }).catch(() => null)))
|
|
723
735
|
const validTracks = resolved.flatMap(r => r?.tracks || [])
|
|
724
736
|
if (validTracks.length && player.queue?.add) {
|
|
725
737
|
if (player.queue.length <= 2) player.queue.length = 0
|
|
@@ -731,14 +743,14 @@ class Aqua extends EventEmitter {
|
|
|
731
743
|
else player.volume = p.vol
|
|
732
744
|
}
|
|
733
745
|
await player.play()
|
|
734
|
-
if (p.p > 0)
|
|
746
|
+
if (p.p > 0) _functions.unrefTimeout(() => player.seek?.(p.p), SEEK_DELAY)
|
|
735
747
|
if (p.pa) await player.pause(true)
|
|
736
748
|
}
|
|
737
749
|
if (p.nw && p.t) {
|
|
738
750
|
const channel = this.client.channels?.cache?.get(p.t)
|
|
739
751
|
if (channel?.messages) player.nowPlayingMessage = await channel.messages.fetch(p.nw).catch(() => null)
|
|
740
752
|
}
|
|
741
|
-
} catch {}
|
|
753
|
+
} catch { }
|
|
742
754
|
}
|
|
743
755
|
|
|
744
756
|
async _waitForFirstNode(timeout = NODE_TIMEOUT) {
|
|
@@ -756,6 +768,7 @@ class Aqua extends EventEmitter {
|
|
|
756
768
|
if (this.leastUsedNodes.length) { cleanup(); resolve() }
|
|
757
769
|
}
|
|
758
770
|
const timer = setTimeout(() => { cleanup(); reject(new Error('Timeout waiting for first node')) }, timeout)
|
|
771
|
+
timer.unref?.()
|
|
759
772
|
this.on(AqualinkEvents.NodeConnect, onReady)
|
|
760
773
|
this.on(AqualinkEvents.NodeCreate, onReady)
|
|
761
774
|
onReady()
|
|
@@ -4,11 +4,17 @@ const { AqualinkEvents } = require('./AqualinkEvents')
|
|
|
4
4
|
|
|
5
5
|
const POOL_SIZE = 12
|
|
6
6
|
const UPDATE_TIMEOUT = 4000
|
|
7
|
+
|
|
7
8
|
const RECONNECT_DELAY = 1000
|
|
8
9
|
const MAX_RECONNECT_ATTEMPTS = 3
|
|
9
10
|
const RESUME_BACKOFF_MAX = 8000
|
|
11
|
+
|
|
10
12
|
const VOICE_DATA_TIMEOUT = 30000
|
|
11
13
|
|
|
14
|
+
const VOICE_FLUSH_DELAY = 50
|
|
15
|
+
|
|
16
|
+
const NULL_CHANNEL_GRACE_MS = 1500
|
|
17
|
+
|
|
12
18
|
const STATE = {
|
|
13
19
|
CONNECTED: 1,
|
|
14
20
|
UPDATE_SCHEDULED: 64,
|
|
@@ -53,6 +59,7 @@ const _functions = {
|
|
|
53
59
|
v.token = conn.token
|
|
54
60
|
v.endpoint = conn.endpoint
|
|
55
61
|
v.sessionId = conn.sessionId
|
|
62
|
+
v.channelId = player.voiceChannel
|
|
56
63
|
v.resume = resume ? true : undefined
|
|
57
64
|
v.sequence = resume ? conn.sequence : undefined
|
|
58
65
|
payload.data.volume = player?.volume ?? 100
|
|
@@ -110,19 +117,28 @@ class Connection {
|
|
|
110
117
|
|
|
111
118
|
this.voiceChannel = player.voiceChannel
|
|
112
119
|
this.sessionId = null
|
|
120
|
+
this.channelId = null
|
|
113
121
|
this.endpoint = null
|
|
114
122
|
this.token = null
|
|
115
123
|
this.region = null
|
|
116
124
|
this.sequence = 0
|
|
117
125
|
|
|
118
126
|
this._lastEndpoint = null
|
|
119
|
-
this._pendingUpdate = null
|
|
120
127
|
this._stateFlags = 0
|
|
121
128
|
this._reconnectAttempts = 0
|
|
122
129
|
this._destroyed = false
|
|
123
130
|
this._reconnectTimer = null
|
|
124
131
|
this._lastVoiceDataUpdate = 0
|
|
125
132
|
this._consecutiveFailures = 0
|
|
133
|
+
|
|
134
|
+
this._voiceFlushTimer = null
|
|
135
|
+
this._pendingUpdate = null
|
|
136
|
+
this._lastSentVoiceKey = ''
|
|
137
|
+
|
|
138
|
+
this._nullChannelTimer = null
|
|
139
|
+
|
|
140
|
+
this._lastStateReqAt = 0
|
|
141
|
+
this._stateGeneration = 0
|
|
126
142
|
}
|
|
127
143
|
|
|
128
144
|
_hasValidVoiceData() {
|
|
@@ -134,10 +150,17 @@ class Connection {
|
|
|
134
150
|
return true
|
|
135
151
|
}
|
|
136
152
|
|
|
137
|
-
|
|
138
|
-
if (this.
|
|
153
|
+
_clearNullChannelTimer() {
|
|
154
|
+
if (!this._nullChannelTimer) return
|
|
155
|
+
clearTimeout(this._nullChannelTimer)
|
|
156
|
+
this._nullChannelTimer = null
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
_canAttemptResumeCore() {
|
|
160
|
+
if (this._destroyed) return false
|
|
161
|
+
if (this._reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) return false
|
|
139
162
|
if (this._stateFlags & (STATE.ATTEMPTING_RESUME | STATE.DISCONNECTING)) return false
|
|
140
|
-
return
|
|
163
|
+
return true
|
|
141
164
|
}
|
|
142
165
|
|
|
143
166
|
_setReconnectTimer(delay) {
|
|
@@ -148,12 +171,15 @@ class Connection {
|
|
|
148
171
|
}
|
|
149
172
|
|
|
150
173
|
setServerUpdate(data) {
|
|
151
|
-
if (this._destroyed || !data?.
|
|
174
|
+
if (this._destroyed || !data?.token) return
|
|
152
175
|
|
|
153
176
|
const endpoint = typeof data.endpoint === 'string' ? data.endpoint.trim() : ''
|
|
154
|
-
if (!endpoint
|
|
177
|
+
if (!endpoint) return
|
|
178
|
+
|
|
155
179
|
if (this._lastEndpoint === endpoint && this.token === data.token) return
|
|
156
180
|
|
|
181
|
+
this._stateGeneration++
|
|
182
|
+
|
|
157
183
|
if (this._lastEndpoint !== endpoint) {
|
|
158
184
|
this.sequence = 0
|
|
159
185
|
this._lastEndpoint = endpoint
|
|
@@ -164,10 +190,11 @@ class Connection {
|
|
|
164
190
|
this.endpoint = endpoint
|
|
165
191
|
this.region = _functions.extractRegion(endpoint)
|
|
166
192
|
this.token = data.token
|
|
193
|
+
this.channelId = data.channel_id || this.channelId || this.voiceChannel
|
|
167
194
|
this._lastVoiceDataUpdate = Date.now()
|
|
168
195
|
this._stateFlags &= ~STATE.VOICE_DATA_STALE
|
|
169
196
|
|
|
170
|
-
if (this._player
|
|
197
|
+
if (this._player?.paused) this._player.pause(false)
|
|
171
198
|
this._scheduleVoiceUpdate()
|
|
172
199
|
}
|
|
173
200
|
|
|
@@ -181,15 +208,38 @@ class Connection {
|
|
|
181
208
|
if (this._destroyed || !data || data.user_id !== this._clientId) return
|
|
182
209
|
|
|
183
210
|
const { session_id: sessionId, channel_id: channelId, self_deaf: selfDeaf, self_mute: selfMute } = data
|
|
211
|
+
const p = this._player
|
|
184
212
|
|
|
185
|
-
if (
|
|
213
|
+
if (channelId) this._clearNullChannelTimer()
|
|
214
|
+
|
|
215
|
+
const reqCh = p?._voiceRequestChannel
|
|
216
|
+
const reqFresh = !!(reqCh && (Date.now() - (p._voiceRequestAt || 0)) < 5000)
|
|
217
|
+
|
|
218
|
+
if (!channelId) {
|
|
219
|
+
if (reqFresh) return
|
|
220
|
+
|
|
221
|
+
if (!this._nullChannelTimer) {
|
|
222
|
+
this._nullChannelTimer = setTimeout(() => {
|
|
223
|
+
this._nullChannelTimer = null
|
|
224
|
+
this._handleDisconnect()
|
|
225
|
+
}, NULL_CHANNEL_GRACE_MS)
|
|
226
|
+
_functions.safeUnref(this._nullChannelTimer)
|
|
227
|
+
}
|
|
228
|
+
return
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (reqFresh && channelId !== reqCh) return
|
|
232
|
+
|
|
233
|
+
if (reqCh && channelId === reqCh) {
|
|
234
|
+
p._voiceRequestChannel = null
|
|
235
|
+
}
|
|
186
236
|
|
|
187
237
|
let needsUpdate = false
|
|
188
238
|
|
|
189
239
|
if (this.voiceChannel !== channelId) {
|
|
190
240
|
this._aqua.emit(AqualinkEvents.PlayerMove, this.voiceChannel, channelId)
|
|
191
241
|
this.voiceChannel = channelId
|
|
192
|
-
|
|
242
|
+
p.voiceChannel = channelId
|
|
193
243
|
needsUpdate = true
|
|
194
244
|
}
|
|
195
245
|
|
|
@@ -202,30 +252,29 @@ class Connection {
|
|
|
202
252
|
needsUpdate = true
|
|
203
253
|
}
|
|
204
254
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
this._player.self_mute = this._player.selfMute = !!selfMute
|
|
208
|
-
this._player.connected = true
|
|
255
|
+
p.self_deaf = p.selfDeaf = !!selfDeaf
|
|
256
|
+
p.self_mute = p.selfMute = !!selfMute
|
|
209
257
|
this._stateFlags |= STATE.CONNECTED
|
|
210
258
|
|
|
211
259
|
if (needsUpdate) this._scheduleVoiceUpdate()
|
|
212
260
|
}
|
|
213
261
|
|
|
214
262
|
_handleDisconnect() {
|
|
215
|
-
if (this._destroyed
|
|
263
|
+
if (this._destroyed) return
|
|
216
264
|
|
|
217
265
|
this._stateFlags = (this._stateFlags | STATE.DISCONNECTING) & ~STATE.CONNECTED
|
|
218
|
-
this.
|
|
266
|
+
this._clearNullChannelTimer()
|
|
219
267
|
this._clearPendingUpdate()
|
|
220
268
|
this._clearReconnectTimer()
|
|
221
269
|
|
|
222
|
-
this.voiceChannel =
|
|
270
|
+
this.voiceChannel = null
|
|
271
|
+
this.sessionId = null
|
|
223
272
|
this.sequence = 0
|
|
224
273
|
this._lastVoiceDataUpdate = 0
|
|
225
274
|
this._stateFlags |= STATE.VOICE_DATA_STALE
|
|
226
275
|
|
|
227
276
|
try {
|
|
228
|
-
this._player
|
|
277
|
+
this._player?.destroy?.()
|
|
229
278
|
} catch (e) {
|
|
230
279
|
this._aqua.emit(AqualinkEvents.Debug, new Error(`Player destroy failed: ${e?.message || e}`))
|
|
231
280
|
} finally {
|
|
@@ -235,6 +284,10 @@ class Connection {
|
|
|
235
284
|
|
|
236
285
|
_requestVoiceState() {
|
|
237
286
|
try {
|
|
287
|
+
const now = Date.now()
|
|
288
|
+
if (now - (this._lastStateReqAt || 0) < 1500) return false
|
|
289
|
+
this._lastStateReqAt = now
|
|
290
|
+
|
|
238
291
|
if (typeof this._player?.send !== 'function' || !this._player.voiceChannel) return false
|
|
239
292
|
this._player.send({
|
|
240
293
|
guild_id: this._guildId,
|
|
@@ -242,7 +295,6 @@ class Connection {
|
|
|
242
295
|
self_deaf: this._player.deaf,
|
|
243
296
|
self_mute: this._player.mute
|
|
244
297
|
})
|
|
245
|
-
this._setReconnectTimer(1500)
|
|
246
298
|
return true
|
|
247
299
|
} catch {
|
|
248
300
|
return false
|
|
@@ -250,47 +302,38 @@ class Connection {
|
|
|
250
302
|
}
|
|
251
303
|
|
|
252
304
|
async attemptResume() {
|
|
253
|
-
if (!this.
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
`Resume blocked: destroyed=${this._destroyed}, hasValidData=${this._hasValidVoiceData()}, attempts=${this._reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS}`
|
|
257
|
-
)
|
|
258
|
-
|
|
259
|
-
const isResuming = this._player?._resuming
|
|
260
|
-
const isStale = this._stateFlags & STATE.VOICE_DATA_STALE
|
|
261
|
-
const needsVoiceData = !this.sessionId || !this.endpoint || !this.token
|
|
262
|
-
|
|
263
|
-
if ((isStale || needsVoiceData) && isResuming) {
|
|
264
|
-
this._aqua.emit(AqualinkEvents.Debug, `Requesting fresh voice state for guild ${this._guildId}`)
|
|
265
|
-
this._requestVoiceState()
|
|
266
|
-
} else if (this._reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
|
|
267
|
-
this._handleDisconnect()
|
|
268
|
-
}
|
|
269
|
-
return false
|
|
270
|
-
}
|
|
305
|
+
if (!this._canAttemptResumeCore()) return false
|
|
306
|
+
|
|
307
|
+
const currentGen = this._stateGeneration
|
|
271
308
|
|
|
272
|
-
|
|
273
|
-
|
|
309
|
+
|
|
310
|
+
if (!this.sessionId || !this.endpoint || !this.token || (this._stateFlags & STATE.VOICE_DATA_STALE)) {
|
|
311
|
+
this._aqua.emit(AqualinkEvents.Debug, `Resume blocked: missing voice data for guild ${this._guildId}, requesting voice state`)
|
|
274
312
|
this._requestVoiceState()
|
|
275
313
|
return false
|
|
276
314
|
}
|
|
277
315
|
|
|
278
316
|
this._stateFlags |= STATE.ATTEMPTING_RESUME
|
|
279
317
|
this._reconnectAttempts++
|
|
280
|
-
this._aqua.emit(
|
|
281
|
-
AqualinkEvents.Debug,
|
|
282
|
-
`Attempt resume: guild=${this._guildId} endpoint=${this.endpoint} session=${this.sessionId}`
|
|
283
|
-
)
|
|
318
|
+
this._aqua.emit(AqualinkEvents.Debug, `Attempt resume: guild=${this._guildId} endpoint=${this.endpoint} session=${this.sessionId}`)
|
|
284
319
|
|
|
285
320
|
const payload = sharedPool.acquire()
|
|
286
321
|
try {
|
|
287
322
|
_functions.fillVoicePayload(payload, this._guildId, this, this._player, true)
|
|
323
|
+
|
|
324
|
+
if (this._stateGeneration !== currentGen) {
|
|
325
|
+
this._aqua.emit(AqualinkEvents.Debug, `Resume aborted: State changed during attempt for guild ${this._guildId}`)
|
|
326
|
+
return false
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
|
|
288
330
|
await this._sendUpdate(payload)
|
|
289
331
|
|
|
290
332
|
this._reconnectAttempts = 0
|
|
291
333
|
this._consecutiveFailures = 0
|
|
292
334
|
if (this._player) this._player._resuming = false
|
|
293
|
-
|
|
335
|
+
|
|
336
|
+
this._aqua.emit(AqualinkEvents.Debug, `Resume PATCH sent for guild ${this._guildId}`)
|
|
294
337
|
return true
|
|
295
338
|
} catch (e) {
|
|
296
339
|
this._consecutiveFailures++
|
|
@@ -300,7 +343,7 @@ class Connection {
|
|
|
300
343
|
const delay = Math.min(RECONNECT_DELAY * (1 << (this._reconnectAttempts - 1)), RESUME_BACKOFF_MAX)
|
|
301
344
|
this._setReconnectTimer(delay)
|
|
302
345
|
} else {
|
|
303
|
-
this._aqua.emit(AqualinkEvents.Debug, `Max reconnect attempts
|
|
346
|
+
this._aqua.emit(AqualinkEvents.Debug, `Max reconnect attempts/failures reached for guild ${this._guildId}`)
|
|
304
347
|
if (this._player) this._player._resuming = false
|
|
305
348
|
this._handleDisconnect()
|
|
306
349
|
}
|
|
@@ -330,36 +373,64 @@ class Connection {
|
|
|
330
373
|
this._stateFlags &= ~STATE.UPDATE_SCHEDULED
|
|
331
374
|
if (this._pendingUpdate?.payload) sharedPool.release(this._pendingUpdate.payload)
|
|
332
375
|
this._pendingUpdate = null
|
|
376
|
+
if (this._voiceFlushTimer) {
|
|
377
|
+
clearTimeout(this._voiceFlushTimer)
|
|
378
|
+
this._voiceFlushTimer = null
|
|
379
|
+
}
|
|
333
380
|
}
|
|
334
381
|
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
this.
|
|
382
|
+
_makeVoiceKey() {
|
|
383
|
+
const p = this._player
|
|
384
|
+
const vol = p?.volume ?? 100
|
|
385
|
+
return (this.sessionId || '') + '|' +
|
|
386
|
+
(this.token || '') + '|' +
|
|
387
|
+
(this.endpoint || '') + '|' +
|
|
388
|
+
(p?.voiceChannel || '') + '|' +
|
|
389
|
+
vol
|
|
390
|
+
}
|
|
339
391
|
|
|
340
|
-
|
|
341
|
-
|
|
392
|
+
_scheduleVoiceUpdate() {
|
|
393
|
+
if (this._destroyed) return
|
|
394
|
+
if (!this._hasValidVoiceData()) return
|
|
395
|
+
|
|
396
|
+
if (!this._pendingUpdate) {
|
|
397
|
+
const payload = sharedPool.acquire()
|
|
398
|
+
_functions.fillVoicePayload(payload, this._guildId, this, this._player, false)
|
|
399
|
+
this._pendingUpdate = { payload, timestamp: Date.now() }
|
|
400
|
+
} else {
|
|
401
|
+
this._pendingUpdate.timestamp = Date.now()
|
|
402
|
+
_functions.fillVoicePayload(this._pendingUpdate.payload, this._guildId, this, this._player, false)
|
|
403
|
+
}
|
|
342
404
|
|
|
343
|
-
this.
|
|
405
|
+
if (this._stateFlags & STATE.UPDATE_SCHEDULED) return
|
|
344
406
|
this._stateFlags |= STATE.UPDATE_SCHEDULED
|
|
345
|
-
|
|
407
|
+
|
|
408
|
+
this._voiceFlushTimer = setTimeout(() => this._executeVoiceUpdate(), VOICE_FLUSH_DELAY)
|
|
409
|
+
_functions.safeUnref(this._voiceFlushTimer)
|
|
346
410
|
}
|
|
347
411
|
|
|
348
412
|
_executeVoiceUpdate() {
|
|
349
413
|
if (this._destroyed) return
|
|
350
414
|
this._stateFlags &= ~STATE.UPDATE_SCHEDULED
|
|
415
|
+
this._voiceFlushTimer = null
|
|
351
416
|
|
|
352
417
|
const pending = this._pendingUpdate
|
|
353
|
-
if (!pending) return
|
|
354
418
|
this._pendingUpdate = null
|
|
355
419
|
|
|
420
|
+
if (!pending) return
|
|
356
421
|
if (Date.now() - pending.timestamp > UPDATE_TIMEOUT) {
|
|
357
422
|
sharedPool.release(pending.payload)
|
|
358
423
|
return
|
|
359
424
|
}
|
|
360
425
|
|
|
361
|
-
const
|
|
362
|
-
|
|
426
|
+
const key = this._makeVoiceKey()
|
|
427
|
+
if (key === this._lastSentVoiceKey) {
|
|
428
|
+
sharedPool.release(pending.payload)
|
|
429
|
+
return
|
|
430
|
+
}
|
|
431
|
+
this._lastSentVoiceKey = key
|
|
432
|
+
|
|
433
|
+
this._sendUpdate(pending.payload).finally(() => sharedPool.release(pending.payload))
|
|
363
434
|
}
|
|
364
435
|
|
|
365
436
|
async _sendUpdate(payload) {
|
|
@@ -380,6 +451,7 @@ class Connection {
|
|
|
380
451
|
if (this._destroyed) return
|
|
381
452
|
this._destroyed = true
|
|
382
453
|
|
|
454
|
+
this._clearNullChannelTimer()
|
|
383
455
|
this._clearPendingUpdate()
|
|
384
456
|
this._clearReconnectTimer()
|
|
385
457
|
|
package/build/structures/Node.js
CHANGED
|
@@ -193,6 +193,9 @@ class Player extends EventEmitter {
|
|
|
193
193
|
this.previousTracks = new CircularBuffer(PREVIOUS_TRACKS_SIZE)
|
|
194
194
|
this._updateBatcher = batcherPool.acquire(this)
|
|
195
195
|
|
|
196
|
+
this._voiceRequestAt = 0
|
|
197
|
+
this._voiceRequestChannel = null
|
|
198
|
+
this._suppressResumeUntil = 0
|
|
196
199
|
this._bindEvents()
|
|
197
200
|
this._startWatchdog()
|
|
198
201
|
}
|
|
@@ -241,6 +244,7 @@ class Player extends EventEmitter {
|
|
|
241
244
|
this._voiceDownSince = Date.now()
|
|
242
245
|
this._createTimer(() => {
|
|
243
246
|
if (this.connected || this.destroyed || this.nodes?.info?.isNodelink) return
|
|
247
|
+
if (Date.now() < (this._suppressResumeUntil || 0)) return
|
|
244
248
|
this.connection.attemptResume()
|
|
245
249
|
}, 1000)
|
|
246
250
|
}
|
|
@@ -337,13 +341,24 @@ class Player extends EventEmitter {
|
|
|
337
341
|
|
|
338
342
|
connect(options = {}) {
|
|
339
343
|
if (this.destroyed) throw new Error('Cannot connect destroyed player')
|
|
344
|
+
|
|
340
345
|
const voiceChannel = _functions.toId(options.voiceChannel || this.voiceChannel)
|
|
341
346
|
if (!voiceChannel) throw new TypeError('Voice channel required')
|
|
347
|
+
|
|
342
348
|
this.deaf = options.deaf !== undefined ? !!options.deaf : true
|
|
343
349
|
this.mute = !!options.mute
|
|
344
350
|
this.destroyed = false
|
|
351
|
+
|
|
352
|
+
this._voiceRequestAt = Date.now()
|
|
353
|
+
this._voiceRequestChannel = voiceChannel
|
|
354
|
+
|
|
345
355
|
this.voiceChannel = voiceChannel
|
|
346
|
-
this.send({
|
|
356
|
+
this.send({
|
|
357
|
+
guild_id: this.guildId,
|
|
358
|
+
channel_id: voiceChannel,
|
|
359
|
+
self_deaf: this.deaf,
|
|
360
|
+
self_mute: this.mute
|
|
361
|
+
})
|
|
347
362
|
return this
|
|
348
363
|
}
|
|
349
364
|
|
|
@@ -432,6 +447,9 @@ class Player extends EventEmitter {
|
|
|
432
447
|
this._dataStore = null
|
|
433
448
|
|
|
434
449
|
if (this.current?.dispose && !this.aqua?.options?.autoResume) this.current.dispose()
|
|
450
|
+
if (this.connection) {
|
|
451
|
+
try { this.connection.destroy() } catch { }
|
|
452
|
+
}
|
|
435
453
|
this.connection = this.filters = this.current = this.autoplaySeed = null
|
|
436
454
|
|
|
437
455
|
if (!skipRemote) {
|
|
@@ -757,14 +775,20 @@ class Player extends EventEmitter {
|
|
|
757
775
|
if (this.destroyed) return
|
|
758
776
|
const code = payload?.code
|
|
759
777
|
|
|
760
|
-
if (code === 4022) {
|
|
778
|
+
if (code === 4014 || code === 4022) {
|
|
761
779
|
this.aqua.emit(AqualinkEvents.SocketClosed, this, payload)
|
|
762
|
-
|
|
780
|
+
|
|
781
|
+
this.connected = false
|
|
782
|
+
if (!this._voiceDownSince) this._voiceDownSince = Date.now()
|
|
783
|
+
|
|
784
|
+
if (code !== 4014) {
|
|
785
|
+
this._suppressResumeUntil = Date.now() + 3000
|
|
786
|
+
}
|
|
763
787
|
return
|
|
764
788
|
}
|
|
765
789
|
|
|
766
790
|
if (code === 4015 && !this.nodes?.info?.isNodelink) {
|
|
767
|
-
try { await this._attemptVoiceResume(); return } catch { }
|
|
791
|
+
try { await this._attemptVoiceResume(); return } catch { /* ignore */ }
|
|
768
792
|
}
|
|
769
793
|
|
|
770
794
|
if (![4015, 4009, 4006].includes(code)) {
|
|
@@ -851,8 +875,6 @@ class Player extends EventEmitter {
|
|
|
851
875
|
_handleAquaPlayerMove(oldChannel, newChannel) {
|
|
852
876
|
if (_functions.toId(oldChannel) !== _functions.toId(this.voiceChannel)) return
|
|
853
877
|
this.voiceChannel = _functions.toId(newChannel)
|
|
854
|
-
this.connected = !!newChannel
|
|
855
|
-
this.send({ guild_id: this.guildId, channel_id: this.voiceChannel, self_deaf: this.deaf, self_mute: this.mute })
|
|
856
878
|
}
|
|
857
879
|
|
|
858
880
|
send(data) {
|
package/build/structures/Rest.js
CHANGED
|
@@ -22,6 +22,7 @@ class Track {
|
|
|
22
22
|
this.uri = _h.str(info.uri)
|
|
23
23
|
this.sourceName = _h.str(info.sourceName)
|
|
24
24
|
this.artworkUrl = _h.str(info.artworkUrl)
|
|
25
|
+
this.pluginInfo = info.pluginInfo || data.pluginInfo || {}
|
|
25
26
|
|
|
26
27
|
this.playlist = data.playlist || null
|
|
27
28
|
this.node = node || data.node || null
|
|
@@ -92,6 +93,7 @@ class Track {
|
|
|
92
93
|
this.uri = fi.uri ?? this.uri
|
|
93
94
|
this.sourceName = fi.sourceName ?? this.sourceName
|
|
94
95
|
this.artworkUrl = fi.artworkUrl ?? this.artworkUrl
|
|
96
|
+
this.pluginInfo = fi.pluginInfo ?? found.pluginInfo ?? this.pluginInfo
|
|
95
97
|
this.isSeekable = fi.isSeekable ?? this.isSeekable
|
|
96
98
|
this.isStream = fi.isStream ?? this.isStream
|
|
97
99
|
this.position = _h.num(fi.position, this.position)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "aqualink",
|
|
3
|
-
"version": "2.17.
|
|
3
|
+
"version": "2.17.3",
|
|
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",
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
},
|
|
46
46
|
"homepage": "https://aqualink-6006388d.mintlify.app/",
|
|
47
47
|
"dependencies": {
|
|
48
|
-
"ws": "^8.
|
|
48
|
+
"ws": "^8.19.0",
|
|
49
49
|
"tseep": "^1.3.1"
|
|
50
50
|
},
|
|
51
51
|
"contributors": [
|