aqualink 2.9.13 → 2.10.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.
@@ -1,281 +1,400 @@
1
- const { Agent: HttpsAgent, request: httpsRequest } = require('node:https')
2
- const { Agent: HttpAgent, request: httpRequest } = require('node:http')
3
-
4
- const JSON_TYPE_REGEX = /^application\/json/i
5
- const MAX_RESPONSE_SIZE = 10485760
6
- const TRACK_VALIDATION_REGEX = /^[A-Za-z0-9+/]+=*$/
1
+ const { Agent: HttpsAgent, request: httpsRequest } = require('node:https');
2
+ const { Agent: HttpAgent, request: httpRequest } = require('node:http');
3
+ const { URL } = require('node:url');
4
+
5
+ const JSON_TYPE_REGEX = /^application\/json/i;
6
+ const MAX_RESPONSE_SIZE = 10485760; // 10MB
7
+ const BASE64_REGEX = /^[A-Za-z0-9+/]+={0,2}$/;
8
+ const METADATA_PHRASES = [
9
+ 'Official Visualizer',
10
+ 'Official',
11
+ 'Official Video',
12
+ 'Music Video',
13
+ 'Live',
14
+ 'Lyrics',
15
+ 'Audio',
16
+ 'HD',
17
+ 'Remix',
18
+ 'Cover',
19
+ 'Acoustic',
20
+ 'Instrumental',
21
+ 'Karaoke',
22
+ 'ft',
23
+ 'feat'
24
+ ];
25
+
26
+ const METADATA_REGEX = new RegExp(
27
+ `\\s*[[({](${METADATA_PHRASES.map(phrase =>
28
+ phrase.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
29
+ ).join('|')})[^\\]}()]*[\\])}]`,
30
+ 'gi'
31
+ );
32
+ const BRACKETS_REGEX = /\s*(\[[^\]]*\]|\([^\)]*\)|\{[^\}]*\})\s*/g;
33
+
34
+ const ERRORS = {
35
+ NO_SESSION: 'Session ID required',
36
+ INVALID_TRACK: 'Invalid encoded track format',
37
+ INVALID_TRACKS: 'One or more tracks have invalid format',
38
+ RESPONSE_TOO_LARGE: 'Response too large',
39
+ JSON_PARSE: 'JSON parse error: ',
40
+ REQUEST_TIMEOUT: 'Request timeout: '
41
+ };
42
+
43
+ const cleanTitle = title => title
44
+ .replace(METADATA_REGEX, '')
45
+ .replace(BRACKETS_REGEX, '')
46
+ .trim();
47
+
48
+
49
+ const isValidBase64 = str => {
50
+ if (typeof str !== 'string' || str.length === 0) return false;
51
+ const len = str.length;
52
+
53
+ if (len % 4 !== 0) return false;
54
+
55
+ if (!BASE64_REGEX.test(str)) return false;
56
+
57
+ let pad = 0;
58
+ for (let i = len - 1; i >= 0; i--) {
59
+ if (str[i] !== '=') break;
60
+ pad++;
61
+ }
62
+ return pad <= 2;
63
+ };
7
64
 
8
65
  class Rest {
9
66
  constructor(aqua, { secure = false, host, port, sessionId = null, password, timeout = 15000 }) {
10
- this.aqua = aqua
11
- this.sessionId = sessionId
12
- this.version = 'v4'
13
- this.baseUrl = `${secure ? 'https' : 'http'}://${host}:${port}`
14
- this.headers = {
67
+ this.aqua = aqua;
68
+ this.sessionId = sessionId;
69
+ this.version = 'v4';
70
+ this.secure = secure;
71
+ this.timeout = timeout;
72
+
73
+ this.baseUrl = new URL(`${secure ? 'https' : 'http'}://${host}:${port}`);
74
+ this.headers = Object.freeze({
15
75
  'Content-Type': 'application/json',
16
- 'Authorization': password
17
- }
18
- this.timeout = timeout
19
- this.secure = secure
76
+ 'Authorization': password,
77
+ 'Accept': 'application/json'
78
+ });
20
79
 
21
- const AgentClass = secure ? HttpsAgent : HttpAgent
80
+ const AgentClass = secure ? HttpsAgent : HttpAgent;
22
81
  this.agent = new AgentClass({
23
82
  keepAlive: true,
24
- maxSockets: 5,
25
- maxFreeSockets: 2,
83
+ maxSockets: 10,
84
+ maxFreeSockets: 5,
26
85
  timeout: this.timeout,
27
- freeSocketTimeout: 45000,
28
- keepAliveMsecs: 1000
29
- })
86
+ freeSocketTimeout: 30000,
87
+ keepAliveMsecs: 1000,
88
+ scheduling: 'fifo'
89
+ });
30
90
 
31
- this.request = secure ? httpsRequest : httpRequest
91
+ this.request = secure ? httpsRequest : httpRequest;
32
92
  }
33
93
 
34
94
  setSessionId(sessionId) {
35
- this.sessionId = sessionId
95
+ this.sessionId = sessionId;
36
96
  }
37
97
 
38
98
  _validateSessionId() {
39
- if (!this.sessionId) {
40
- throw new Error('Session ID required')
41
- }
99
+ if (!this.sessionId) throw new Error(ERRORS.NO_SESSION);
42
100
  }
43
101
 
44
102
  _isValidEncodedTrack(track) {
45
- return typeof track === 'string' && TRACK_VALIDATION_REGEX.test(track)
103
+ return isValidBase64(track);
46
104
  }
47
105
 
48
106
  async makeRequest(method, endpoint, body = null) {
49
- const url = `${this.baseUrl}${endpoint}`
107
+ const url = new URL(endpoint, this.baseUrl);
50
108
  const options = {
51
109
  method,
52
110
  headers: this.headers,
53
111
  timeout: this.timeout,
54
112
  agent: this.agent
55
- }
113
+ };
56
114
 
57
115
  return new Promise((resolve, reject) => {
58
116
  const req = this.request(url, options, res => {
59
- if (res.statusCode === 204) return resolve(null)
117
+ if (res.statusCode === 204) {
118
+ res.resume();
119
+ return resolve(null);
120
+ }
60
121
 
61
- // Optimized for Lavalink's typical JSON responses
62
- const chunks = []
63
- let totalLength = 0
122
+ const chunks = [];
123
+ let totalLength = 0;
124
+ const isJson = JSON_TYPE_REGEX.test(res.headers['content-type'] || '');
64
125
 
65
126
  res.on('data', chunk => {
66
- totalLength += chunk.length
127
+ totalLength += chunk.length;
67
128
  if (totalLength > MAX_RESPONSE_SIZE) {
68
- req.destroy()
69
- return reject(new Error('Response too large'))
129
+ req.destroy();
130
+ return reject(new Error(ERRORS.RESPONSE_TOO_LARGE));
70
131
  }
71
- chunks.push(chunk)
72
- })
132
+ chunks.push(chunk);
133
+ });
73
134
 
74
135
  res.on('end', () => {
75
- if (totalLength === 0) return resolve(null)
76
-
77
- const data = Buffer.concat(chunks, totalLength).toString()
78
-
79
- if (JSON_TYPE_REGEX.test(res.headers['content-type'] || '')) {
80
- try {
81
- resolve(JSON.parse(data))
82
- } catch (err) {
83
- reject(new Error(`JSON parse error: ${err.message}`))
84
- }
85
- } else {
86
- resolve(data)
136
+ if (totalLength === 0) return resolve(null);
137
+
138
+ const data = Buffer.concat(chunks);
139
+ if (!isJson) return resolve(data.toString());
140
+
141
+ try {
142
+ resolve(JSON.parse(data));
143
+ } catch (err) {
144
+ reject(new Error(`${ERRORS.JSON_PARSE}${err.message}`));
87
145
  }
88
- })
89
- })
146
+ });
90
147
 
91
- req.on('error', reject)
148
+ res.on('error', reject);
149
+ });
150
+
151
+ req.on('error', reject);
92
152
  req.on('timeout', () => {
93
- req.destroy()
94
- reject(new Error(`Request timeout: ${this.timeout}ms`))
95
- })
153
+ req.destroy();
154
+ reject(new Error(`${ERRORS.REQUEST_TIMEOUT}${this.timeout}ms`));
155
+ });
96
156
 
97
157
  if (body) {
98
- const payload = typeof body === 'string' ? body : JSON.stringify(body)
99
- req.write(payload)
158
+ const payload = typeof body === 'string' ? body : JSON.stringify(body);
159
+ req.setHeader('Content-Length', Buffer.byteLength(payload));
160
+ req.write(payload);
100
161
  }
101
162
 
102
- req.end()
103
- })
163
+ req.end();
164
+ });
165
+ }
166
+
167
+ async batchRequests(requests) {
168
+ return Promise.all(requests.map(({ method, endpoint, body }) =>
169
+ this.makeRequest(method, endpoint, body)
170
+ ));
104
171
  }
105
172
 
106
- // Lavalink player operations
107
173
  async updatePlayer({ guildId, data }) {
108
- this._validateSessionId()
109
- return this.makeRequest('PATCH', `/${this.version}/sessions/${this.sessionId}/players/${guildId}?noReplace=false`, data)
174
+ this._validateSessionId();
175
+ return this.makeRequest(
176
+ 'PATCH',
177
+ `/${this.version}/sessions/${this.sessionId}/players/${guildId}?noReplace=false`,
178
+ data
179
+ );
110
180
  }
111
181
 
112
182
  async getPlayer(guildId) {
113
- this._validateSessionId()
114
- return this.makeRequest('GET', `/${this.version}/sessions/${this.sessionId}/players/${guildId}`)
183
+ this._validateSessionId();
184
+ return this.makeRequest(
185
+ 'GET',
186
+ `/${this.version}/sessions/${this.sessionId}/players/${guildId}`
187
+ );
115
188
  }
116
189
 
117
190
  async getPlayers() {
118
- this._validateSessionId()
119
- return this.makeRequest('GET', `/${this.version}/sessions/${this.sessionId}/players`)
191
+ this._validateSessionId();
192
+ return this.makeRequest(
193
+ 'GET',
194
+ `/${this.version}/sessions/${this.sessionId}/players`
195
+ );
120
196
  }
121
197
 
122
198
  async destroyPlayer(guildId) {
123
- this._validateSessionId()
124
- return this.makeRequest('DELETE', `/${this.version}/sessions/${this.sessionId}/players/${guildId}`)
199
+ this._validateSessionId();
200
+ return this.makeRequest(
201
+ 'DELETE',
202
+ `/${this.version}/sessions/${this.sessionId}/players/${guildId}`
203
+ );
125
204
  }
126
205
 
127
- // Track operations
128
206
  async loadTracks(identifier) {
129
- return this.makeRequest('GET', `/${this.version}/loadtracks?identifier=${encodeURIComponent(identifier)}`)
207
+ const params = new URLSearchParams({ identifier });
208
+ return this.makeRequest(
209
+ 'GET',
210
+ `/${this.version}/loadtracks?${params}`
211
+ );
130
212
  }
131
213
 
132
214
  async decodeTrack(encodedTrack) {
133
215
  if (!this._isValidEncodedTrack(encodedTrack)) {
134
- throw new Error('Invalid encoded track format')
216
+ throw new Error(ERRORS.INVALID_TRACK);
135
217
  }
136
- return this.makeRequest('GET', `/${this.version}/decodetrack?encodedTrack=${encodeURIComponent(encodedTrack)}`)
218
+ const params = new URLSearchParams({ encodedTrack });
219
+ return this.makeRequest('GET', `/${this.version}/decodetrack?${params}`);
137
220
  }
138
221
 
139
222
  async decodeTracks(encodedTracks) {
140
- const validTracks = encodedTracks.filter(track => this._isValidEncodedTrack(track))
141
- if (validTracks.length !== encodedTracks.length) {
142
- throw new Error('One or more tracks have invalid format')
143
- }
144
- return this.makeRequest('POST', `/${this.version}/decodetracks`, validTracks)
223
+ const invalid = encodedTracks.find(t => !this._isValidEncodedTrack(t));
224
+ if (invalid) throw new Error(ERRORS.INVALID_TRACKS);
225
+
226
+ return this.makeRequest('POST', `/${this.version}/decodetracks`, encodedTracks);
145
227
  }
146
228
 
147
- // Server info
229
+ // Server info and management
148
230
  async getStats() {
149
- return this.makeRequest('GET', `/${this.version}/stats`)
231
+ return this.makeRequest('GET', `/${this.version}/stats`);
150
232
  }
151
233
 
152
234
  async getInfo() {
153
- return this.makeRequest('GET', `/${this.version}/info`)
235
+ return this.makeRequest('GET', `/${this.version}/info`);
154
236
  }
155
237
 
156
238
  async getVersion() {
157
- return this.makeRequest('GET', `/${this.version}/version`)
239
+ return this.makeRequest('GET', `/${this.version}/version`);
158
240
  }
159
241
 
160
- // Route planner
161
242
  async getRoutePlannerStatus() {
162
- return this.makeRequest('GET', `/${this.version}/routeplanner/status`)
243
+ return this.makeRequest('GET', `/${this.version}/routeplanner/status`);
163
244
  }
164
245
 
165
246
  async freeRoutePlannerAddress(address) {
166
- return this.makeRequest('POST', `/${this.version}/routeplanner/free/address`, { address })
247
+ return this.makeRequest(
248
+ 'POST',
249
+ `/${this.version}/routeplanner/free/address`,
250
+ { address }
251
+ );
167
252
  }
168
253
 
169
254
  async freeAllRoutePlannerAddresses() {
170
- return this.makeRequest('POST', `/${this.version}/routeplanner/free/all`)
255
+ return this.makeRequest(
256
+ 'POST',
257
+ `/${this.version}/routeplanner/free/all`
258
+ );
171
259
  }
172
260
 
173
- // Lyrics functionality
261
+ // Optimized lyrics handling
174
262
  async getLyrics({ track, skipTrackSource = false }) {
175
263
  if (!this._isValidTrackForLyrics(track)) {
176
- this.aqua.emit('error', '[Aqua/Lyrics] Invalid track object')
177
- return null
264
+ this.aqua.emit('error', '[Aqua/Lyrics] Invalid track object');
265
+ return null;
178
266
  }
179
267
 
180
- const strategies = this._getLyricsStrategies(track, skipTrackSource)
181
-
182
- for (const strategy of strategies) {
268
+ // Priority 1: Current player track
269
+ if (track.guild_id) {
183
270
  try {
184
- const result = await strategy()
185
- if (result && !this._isLyricsError(result)) {
186
- return result
187
- }
271
+ const lyrics = await this._getPlayerTrackLyrics(track.guild_id, skipTrackSource);
272
+ if (this._isValidLyrics(lyrics)) return lyrics;
188
273
  } catch (error) {
189
- this.aqua.emit('debug', `[Aqua/Lyrics] Strategy failed: ${error.message}`)
274
+ this.aqua.emit('debug', `[Aqua/Lyrics] Player track failed: ${error.message}`);
190
275
  }
191
276
  }
192
277
 
193
- this.aqua.emit('debug', '[Aqua/Lyrics] No lyrics found')
194
- return null
195
- }
196
-
197
- _isValidTrackForLyrics(track) {
198
- return track && (track.identifier || track.info?.title || track.guild_id)
199
- }
200
-
201
- _getLyricsStrategies(track, skipTrackSource) {
202
- const strategies = []
203
-
204
- if (track.guild_id) {
205
- strategies.push(() => this._getPlayerLyrics(track, skipTrackSource))
278
+ // Priority 2: Encoded track
279
+ if (track.encoded && this._isValidEncodedTrack(track.encoded)) {
280
+ try {
281
+ const lyrics = await this._getEncodedTrackLyrics(track.encoded, skipTrackSource);
282
+ if (this._isValidLyrics(lyrics)) return lyrics;
283
+ } catch (error) {
284
+ this.aqua.emit('debug', `[Aqua/Lyrics] Encoded track failed: ${error.message}`);
285
+ }
206
286
  }
207
287
 
208
- if (track.identifier) {
209
- strategies.push(() => this._getIdentifierLyrics(track))
288
+ // Priority 3: Title/author search
289
+ if (track.info?.title) {
290
+ try {
291
+ const query = this._buildSearchQuery(track.info);
292
+ const lyrics = await this._searchLyrics(query);
293
+ if (this._isValidLyrics(lyrics)) return lyrics;
294
+ } catch (error) {
295
+ this.aqua.emit('debug', `[Aqua/Lyrics] Search failed: ${error.message}`);
296
+ }
210
297
  }
211
298
 
212
- if (track.info?.title) {
213
- strategies.push(() => this._getSearchLyrics(track))
299
+ // Priority 4: Cleaned title search
300
+ if (track.info?.title && track.info?.author) {
301
+ try {
302
+ const cleanedTitle = cleanTitle(track.info.title);
303
+ const query = `${cleanedTitle} ${track.info.author}`;
304
+ const lyrics = await this._searchLyrics(query);
305
+ if (this._isValidLyrics(lyrics)) return lyrics;
306
+ } catch (error) {
307
+ this.aqua.emit('debug', `[Aqua/Lyrics] Clean search failed: ${error.message}`);
308
+ }
214
309
  }
215
310
 
216
- return strategies
311
+ this.aqua.emit('debug', '[Aqua/Lyrics] No lyrics found');
312
+ return null;
313
+ }
314
+
315
+ async _getPlayerTrackLyrics(guildId, skipTrackSource) {
316
+ this._validateSessionId();
317
+ const params = new URLSearchParams({
318
+ skipTrackSource: skipTrackSource ? 'true' : 'false'
319
+ });
320
+ return this.makeRequest(
321
+ 'GET',
322
+ `/${this.version}/sessions/${this.sessionId}/players/${guildId}/track/lyrics?${params}`
323
+ );
217
324
  }
218
325
 
219
- async _getPlayerLyrics(track, skipTrackSource) {
220
- this._validateSessionId()
221
- const baseUrl = `/${this.version}/sessions/${this.sessionId}/players/${track.guild_id}`
222
- const query = skipTrackSource ? '?skipTrackSource=true' : ''
326
+ async _getEncodedTrackLyrics(encodedTrack, skipTrackSource) {
327
+ const params = new URLSearchParams({
328
+ track: encodedTrack,
329
+ skipTrackSource: skipTrackSource ? 'true' : 'false'
330
+ });
331
+ return this.makeRequest('GET', `/${this.version}/lyrics?${params}`);
332
+ }
223
333
 
224
- try {
225
- return await this.makeRequest('GET', `${baseUrl}/lyrics${query}`)
226
- } catch {
227
- return await this.makeRequest('GET', `${baseUrl}/track/lyrics${query}`)
228
- }
334
+ async _searchLyrics(query) {
335
+ const params = new URLSearchParams({ query });
336
+ return this.makeRequest('GET', `/${this.version}/lyrics/search?${params}`);
229
337
  }
230
338
 
231
- async _getIdentifierLyrics(track) {
232
- return this.makeRequest('GET', `/${this.version}/lyrics/${encodeURIComponent(track.identifier)}`)
339
+ _isValidTrackForLyrics(track) {
340
+ return track && (
341
+ track.guild_id ||
342
+ (track.encoded && this._isValidEncodedTrack(track.encoded)) ||
343
+ track.info?.title
344
+ );
233
345
  }
234
346
 
235
- async _getSearchLyrics(track) {
236
- const query = encodeURIComponent(track.info.title)
237
- return this.makeRequest('GET', `/${this.version}/lyrics/search?query=${query}&source=genius`)
347
+ _isValidLyrics(response) {
348
+ return response &&
349
+ response.status !== 204 &&
350
+ !(Array.isArray(response) && response.length === 0);
238
351
  }
239
352
 
240
- _isLyricsError(response) {
241
- return response?.status === 404 || response?.status === 500
353
+ _buildSearchQuery(info) {
354
+ return [info.title, info.author]
355
+ .filter(Boolean)
356
+ .join(' ');
242
357
  }
243
358
 
359
+ // Live lyrics management
244
360
  async subscribeLiveLyrics(guildId, skipTrackSource = false) {
245
- this._validateSessionId()
361
+ this._validateSessionId();
246
362
  try {
247
- const query = skipTrackSource ? '?skipTrackSource=true' : ''
363
+ const params = new URLSearchParams({
364
+ skipTrackSource: skipTrackSource ? 'true' : 'false'
365
+ });
248
366
  const result = await this.makeRequest(
249
367
  'POST',
250
- `/${this.version}/sessions/${this.sessionId}/players/${guildId}/lyrics/subscribe${query}`
251
- )
252
- return result === null
368
+ `/${this.version}/sessions/${this.sessionId}/players/${guildId}/lyrics/subscribe?${params}`
369
+ );
370
+ return result === null;
253
371
  } catch (error) {
254
- this.aqua.emit('debug', `[Aqua/Lyrics] Subscribe failed: ${error.message}`)
255
- return false
372
+ this.aqua.emit('debug', `[Aqua/Lyrics] Subscribe failed: ${error.message}`);
373
+ return false;
256
374
  }
257
375
  }
258
376
 
259
377
  async unsubscribeLiveLyrics(guildId) {
260
- this._validateSessionId()
378
+ this._validateSessionId();
261
379
  try {
262
380
  const result = await this.makeRequest(
263
381
  'DELETE',
264
382
  `/${this.version}/sessions/${this.sessionId}/players/${guildId}/lyrics/subscribe`
265
- )
266
- return result === null
383
+ );
384
+ return result === null;
267
385
  } catch (error) {
268
- this.aqua.emit('debug', `[Aqua/Lyrics] Unsubscribe failed: ${error.message}`)
269
- return false
386
+ this.aqua.emit('debug', `[Aqua/Lyrics] Unsubscribe failed: ${error.message}`);
387
+ return false;
270
388
  }
271
389
  }
272
390
 
273
- // Cleanup
391
+ // Resource cleanup
274
392
  destroy() {
275
393
  if (this.agent) {
276
- this.agent.destroy()
394
+ this.agent.destroy();
395
+ this.agent = null;
277
396
  }
278
397
  }
279
398
  }
280
399
 
281
- module.exports = Rest
400
+ module.exports = Rest;