aqualink 1.5.1 → 1.6.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.
package/README.md CHANGED
@@ -18,23 +18,27 @@ This code is based in riffy, but its an 100% Rewrite made from scratch...
18
18
  - Minimal Requests to the lavalink server (helps the lavalink recourses!)
19
19
  - Easy player, node, aqua managing
20
20
  - Fast responses from rest and node
21
- - Playlist support (My mix playlists, youtube playlists)
21
+ - Playlist support (My mix playlists, youtube playlists, spotify playlists)
22
22
 
23
23
  # Docs (Wiki)
24
24
  - https://github.com/ToddyTheNoobDud/AquaLink/wiki
25
25
 
26
26
  - Example bot: https://github.com/ToddyTheNoobDud/Thorium-Music
27
27
 
28
- # Yay, Version 1.5.0 || 1.5.1 is released, wow
28
+ # Omg version 1.6.0 woah aqualink
29
29
 
30
- - Optimized memory usage in `FetchImage` by removing unnecessary code and saving, addressing some memory leaks.
31
- - Updated `AQUA` with additional cleanup options, faster response arrays, Remade `ConstructResponse` and `resolve`, and improved the `UpdateVoice` handler.
32
- - Updated `CONNECTION` to fix bugs, improve the cleanup system, and Improve speed.
33
- - Improved cleanup in the `NODE` manager and fixed issues for VPS.
34
- - Rewrited the `TRACK` handler for better speed by removing redundant checks and code.
35
- - REMADE the `PLAYER` system to fix bugs, resolve message sending issues, Fixes `EventEmitter` memory leaks (also Fixes `AQUA`), remove unnecessary JSDoc comments, rewrite some methods, and enhance cleanup.
36
- - 1.5.1 : Fully fixed destroy and the queue handling for delete, also now it deletes from lavalink...
30
+ - Reworked the `TRACK` Manager (This improves the speed by wayyy more, also uses objects, removed useless code)
31
+ - Improved the `REST` Manager (This improves the garbage collector, an faster code, and more optimized)
32
+ - Added enqueue to `QUEUE` (this gets the previous, made for dev), removed addMultiple (useless)
33
+ - Fully Rewrite the `PLAYER` Manager (Way faster resolving, way less recourse intensive, more responsive, better error handling)
37
34
 
35
+ ^^ Now uses the WeakMap and WeakSet for an garbage collector, making it with an better memory management.
36
+
37
+ - Rewrite the `NODE` Manager (reconnect speeds improved, various methods improved, Rewrite the cache and status handler, improve the performance) - Also fixed player resuming.
38
+ - Remade some stuff in `CONNECTION` (this improves error handling, cleaning up, and speed)
39
+ - Rewrite `AQUA` Manager (remade every single method, improved the resolve, made the code dynamic, fixed lots of bugs, uses weakMap too.) - Added autoResume option (true false)
40
+
41
+ - There are way more stuff that i forgot to add on changelog. pls report bugs on my github !
38
42
  # How to install
39
43
 
40
44
  `npm install aqualink`
@@ -22,10 +22,9 @@ async function getImageUrl(info) {
22
22
 
23
23
  async function fetchThumbnail(url) {
24
24
  try {
25
- const response = await request(url, { method: "GET" });
26
- const json = await response.json();
27
- response.body.destroy();
28
-
25
+ const { body } = await request(url, { method: "GET" });
26
+ const json = await body.json();
27
+ await body.dump();
29
28
  return json.thumbnail_url || null;
30
29
  } catch (error) {
31
30
  console.error(`Error fetching ${url}:`, error);
@@ -3,7 +3,8 @@ const { Node } = require("./Node");
3
3
  const { Player } = require("./Player");
4
4
  const { Track } = require("./Track");
5
5
  const { version: pkgVersion } = require("../../package.json");
6
-
6
+ const URL_REGEX = /^https?:\/\//;
7
+ const REQUEST_TIMEOUT = 10000;
7
8
  class Aqua extends EventEmitter {
8
9
  /**
9
10
  * @param {Object} client - The client instance.
@@ -14,6 +15,7 @@ class Aqua extends EventEmitter {
14
15
  * @param {string} [options.restVersion="v4"] - Version of the REST API.
15
16
  * @param {Array<Object>} [options.plugins=[]] - Plugins to load.
16
17
  * @param {string} [options.shouldDeleteMessage='none'] - Should delete your message? (true, false)
18
+ * @param {boolean} [options.autoResume=false] - Automatically resume tracks on reconnect.
17
19
  */
18
20
  constructor(client, nodes, options) {
19
21
  super();
@@ -31,138 +33,218 @@ class Aqua extends EventEmitter {
31
33
  this.version = pkgVersion;
32
34
  this.options = options;
33
35
  this.send = options.send;
36
+ this.autoResume = options.autoResume || false;
34
37
  this.setMaxListeners(0);
35
38
  }
36
39
 
37
40
  validateInputs(client, nodes, options) {
38
41
  if (!client) throw new Error("Client is required to initialize Aqua");
39
- if (!Array.isArray(nodes) || nodes.length === 0) throw new Error(`Nodes must be a non-empty Array (Received ${typeof nodes})`);
42
+ if (!Array.isArray(nodes) || !nodes.length) throw new Error(`Nodes must be a non-empty Array (Received ${typeof nodes})`);
40
43
  if (typeof options?.send !== "function") throw new Error("Send function is required to initialize Aqua");
41
44
  }
42
45
 
43
46
  get leastUsedNodes() {
44
- return Array.from(this.nodeMap.values())
45
- .filter(node => node.connected)
46
- .sort((a, b) => a.rest.calls - b.rest.calls);
47
+ const activeNodes = [...this.nodeMap.values()].filter(node => node.connected);
48
+ if (!activeNodes.length) return [];
49
+ return activeNodes.sort((a, b) => a.rest.calls - b.rest.calls);
47
50
  }
48
51
 
49
52
  init(clientId) {
50
53
  if (this.initiated) return this;
51
54
  this.clientId = clientId;
52
- this.nodes.forEach(nodeConfig => this.createNode(nodeConfig));
53
- this.initiated = true;
54
- this.plugins.forEach(plugin => plugin.load(this));
55
+
56
+ try {
57
+ this.nodes.forEach(nodeConfig => this.createNode(nodeConfig));
58
+ this.initiated = true;
59
+ this.plugins.forEach(plugin => plugin.load(this));
60
+ } catch (error) {
61
+ this.initiated = false;
62
+ throw error;
63
+ }
64
+
55
65
  return this;
56
66
  }
57
67
 
58
68
  createNode(options) {
69
+ const nodeId = options.name || options.host;
70
+ if (this.nodeMap.has(nodeId)) {
71
+ this.destroyNode(nodeId);
72
+ }
73
+
59
74
  const node = new Node(this, options, this.options);
60
- this.nodeMap.set(options.name || options.host, node);
61
- node.connect();
62
- this.emit("nodeCreate", node);
63
- return node;
75
+ this.nodeMap.set(nodeId, node);
76
+
77
+ try {
78
+ node.connect();
79
+ this.emit("nodeCreate", node);
80
+ return node;
81
+ } catch (error) {
82
+ this.nodeMap.delete(nodeId);
83
+ throw error;
84
+ }
64
85
  }
65
86
 
66
87
  destroyNode(identifier) {
67
88
  const node = this.nodeMap.get(identifier);
68
- if (node) {
89
+ if (!node) return;
90
+
91
+ try {
69
92
  node.disconnect();
93
+ node.removeAllListeners();
70
94
  this.nodeMap.delete(identifier);
71
95
  this.emit("nodeDestroy", node);
96
+ } catch (error) {
97
+ console.error(`Error destroying node ${identifier}:`, error);
72
98
  }
73
99
  }
74
100
 
75
101
  updateVoiceState(packet) {
102
+ if (!packet?.d?.guild_id) return;
103
+
76
104
  const player = this.players.get(packet.d.guild_id);
77
- if (player && (packet.t === "VOICE_SERVER_UPDATE" || packet.t === "VOICE_STATE_UPDATE" && packet.d.user_id === this.clientId)) {
78
- player.connection[packet.t === "VOICE_SERVER_UPDATE" ? "setServerUpdate" : "setStateUpdate"](packet.d);
105
+ if (!player) return;
106
+
107
+ if (packet.t === "VOICE_SERVER_UPDATE" ||
108
+ (packet.t === "VOICE_STATE_UPDATE" && packet.d.user_id === this.clientId)) {
109
+
110
+ const updateType = packet.t === "VOICE_SERVER_UPDATE" ? "setServerUpdate" : "setStateUpdate";
111
+ player.connection[updateType](packet.d);
112
+
79
113
  if (packet.d.status === "disconnected") {
80
- this.cleanupPlayer(player); // Cleanup when disconnected
114
+ this.cleanupPlayer(player);
81
115
  }
82
116
  }
83
117
  }
84
118
 
85
119
  fetchRegion(region) {
86
- const lowerRegion = region?.toLowerCase();
87
- return [...this.nodeMap.values()]
88
- .filter(node => node.connected && node.regions?.includes(lowerRegion))
89
- .sort((a, b) => this.calculateLoad(a) - this.calculateLoad(b));
120
+ if (!region) return this.leastUsedNodes;
121
+
122
+ const lowerRegion = region.toLowerCase();
123
+ const eligibleNodes = [...this.nodeMap.values()].filter(
124
+ node => node.connected && node.regions?.includes(lowerRegion)
125
+ );
126
+
127
+ return eligibleNodes.sort((a, b) => this.calculateLoad(a) - this.calculateLoad(b));
90
128
  }
91
129
 
92
130
  calculateLoad(node) {
93
- return node.stats.cpu ? (node.stats.cpu.systemLoad / node.stats.cpu.cores) * 100 : 0;
131
+ if (!node?.stats?.cpu) return 0;
132
+ const { systemLoad, cores } = node.stats.cpu;
133
+ return (systemLoad / cores) * 100;
94
134
  }
95
135
 
96
136
  createConnection(options) {
97
137
  this.ensureInitialized();
98
- const player = this.players.get(options.guildId);
99
- if (player && player.voiceChannel) return player;
100
- const node = options.region ? this.fetchRegion(options.region)[0] : this.leastUsedNodes[0];
138
+
139
+ const existingPlayer = this.players.get(options.guildId);
140
+ if (existingPlayer?.voiceChannel) return existingPlayer;
141
+
142
+ const node = options.region ?
143
+ this.fetchRegion(options.region)[0] :
144
+ this.leastUsedNodes[0];
145
+
101
146
  if (!node) throw new Error("No nodes are available");
102
147
  return this.createPlayer(node, options);
103
148
  }
104
149
 
105
150
  createPlayer(node, options) {
151
+ this.destroyPlayer(options.guildId);
152
+
106
153
  const player = new Player(this, node, options);
107
154
  this.players.set(options.guildId, player);
108
- player.setMaxListeners(0);
155
+ const weakPlayer = new WeakRef(player);
156
+
157
+ const destroyHandler = () => {
158
+ const playerInstance = weakPlayer.deref();
159
+ if (playerInstance) {
160
+ this.cleanupPlayer(playerInstance);
161
+ }
162
+ };
163
+
164
+ player.once("destroy", destroyHandler);
109
165
  player.connect(options);
110
- player.on("destroy", () => this.cleanupPlayer(player)); // Listen for player destruction
111
166
  this.emit("playerCreate", player);
167
+
112
168
  return player;
113
169
  }
114
170
 
115
171
  destroyPlayer(guildId) {
116
172
  const player = this.players.get(guildId);
117
- if (player) {
173
+ if (!player) return;
174
+
175
+ try {
118
176
  player.clearData();
177
+ player.removeAllListeners(); // Clear all event listeners
119
178
  player.destroy();
120
179
  this.players.delete(guildId);
121
180
  this.emit("playerDestroy", player);
181
+ } catch (error) {
182
+ console.error(`Error destroying player for guild ${guildId}:`, error);
122
183
  }
123
184
  }
124
-
125
185
  async resolve({ query, source = this.defaultSearchPlatform, requester, nodes }) {
126
186
  this.ensureInitialized();
127
187
  const requestNode = this.getRequestNode(nodes);
128
188
  const formattedQuery = this.formatQuery(query, source);
189
+
129
190
  try {
130
- let response = await requestNode.rest.makeRequest("GET", `/v4/loadtracks?identifier=${encodeURIComponent(formattedQuery)}`);
191
+ const controller = new AbortController();
192
+ const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT);
193
+
194
+ const response = await requestNode.rest.makeRequest("GET",
195
+ `/v4/loadtracks?identifier=${encodeURIComponent(formattedQuery)}`,
196
+ { signal: controller.signal }
197
+ );
198
+
199
+ clearTimeout(timeoutId);
200
+
131
201
  if (["empty", "NO_MATCHES"].includes(response.loadType)) {
132
- response = await this.handleNoMatches(requestNode.rest, query);
202
+ return await this.handleNoMatches(requestNode.rest, query);
133
203
  }
134
204
  return this.constructorResponse(response, requester, requestNode);
135
205
  } catch (error) {
136
- console.error("Error resolving track:", error);
137
- throw new Error("Failed to resolve track");
206
+ if (error.name === 'AbortError') {
207
+ throw new Error("Request timed out");
208
+ }
209
+ throw new Error(`Failed to resolve track: ${error.message}`);
210
+ }
211
+ }
212
+
213
+ getRequestNode(nodes) {
214
+ if (nodes && !(typeof nodes === "string" || nodes instanceof Node)) {
215
+ throw new TypeError(`'nodes' must be a string or Node instance, received: ${typeof nodes}`);
138
216
  }
217
+ return (typeof nodes === 'string' ? this.nodeMap.get(nodes) : nodes) ?? this.leastUsedNodes[0];
139
218
  }
140
219
 
141
220
  ensureInitialized() {
142
221
  if (!this.initiated) throw new Error("Aqua must be initialized before this operation");
143
222
  }
144
223
 
145
- getRequestNode(nodes) {
146
- if (nodes && (typeof nodes !== "string" && !(nodes instanceof Node))) {
147
- throw new Error(`'nodes' must be a string or Node instance, but received: ${typeof nodes}`);
148
- }
149
- return (typeof nodes === 'string' ? this.nodeMap.get(nodes) : nodes) || this.leastUsedNodes[0];
150
- }
151
224
 
152
225
  formatQuery(query, source) {
153
- return /^https?:\/\//.test(query) ? query : `${source}:${query}`;
226
+ return URL_REGEX.test(query) ? query : `${source}:${query}`;
154
227
  }
155
228
 
156
229
  async handleNoMatches(rest, query) {
230
+ const controller = new AbortController();
231
+ const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT);
232
+
157
233
  try {
158
- const youtubeResponse = await rest.makeRequest("GET", `/v4/loadtracks?identifier=https://www.youtube.com/watch?v=${query}`);
234
+ const youtubeResponse = await rest.makeRequest("GET",
235
+ `/v4/loadtracks?identifier=https://www.youtube.com/watch?v=${query}`,
236
+ { signal: controller.signal }
237
+ );
238
+
159
239
  if (["empty", "NO_MATCHES"].includes(youtubeResponse.loadType)) {
160
- return await rest.makeRequest("GET", `/v4/loadtracks?identifier=https://open.spotify.com/track/${query}`);
240
+ return await rest.makeRequest("GET",
241
+ `/v4/loadtracks?identifier=https://open.spotify.com/track/${query}`,
242
+ { signal: controller.signal }
243
+ );
161
244
  }
162
245
  return youtubeResponse;
163
- } catch (error) {
164
- console.error("Error handling no matches:", error);
165
- throw new Error("Failed to handle no matches");
246
+ } finally {
247
+ clearTimeout(timeoutId);
166
248
  }
167
249
  }
168
250
 
@@ -171,29 +253,34 @@ class Aqua extends EventEmitter {
171
253
  loadType: response.loadType,
172
254
  exception: null,
173
255
  playlistInfo: null,
174
- pluginInfo: response.pluginInfo || {},
256
+ pluginInfo: response.pluginInfo ?? {},
175
257
  tracks: [],
176
258
  };
177
- const { loadType, data } = response;
178
- if (loadType === "error" || loadType === "LOAD_FAILED") {
179
- baseResponse.exception = data || response.exception;
259
+
260
+ if (response.loadType === "error" || response.loadType === "LOAD_FAILED") {
261
+ baseResponse.exception = response.data ?? response.exception;
180
262
  return baseResponse;
181
263
  }
182
- switch (loadType) {
264
+
265
+ const trackFactory = (trackData) => new Track(trackData, requester, requestNode);
266
+
267
+ switch (response.loadType) {
183
268
  case "track":
184
- if (data) {
185
- baseResponse.tracks.push(new Track(data, requester, requestNode));
269
+ if (response.data) {
270
+ baseResponse.tracks.push(trackFactory(response.data));
186
271
  }
187
272
  break;
188
273
  case "playlist":
189
- baseResponse.playlistInfo = {
190
- name: data?.info?.name || data?.info?.title,
191
- ...data?.info,
192
- };
193
- baseResponse.tracks = (data?.tracks || []).map(track => new Track(track, requester, requestNode));
274
+ if (response.data?.info) {
275
+ baseResponse.playlistInfo = {
276
+ name: response.data.info.name ?? response.data.info.title,
277
+ ...response.data.info,
278
+ };
279
+ }
280
+ baseResponse.tracks = (response.data?.tracks ?? []).map(trackFactory);
194
281
  break;
195
282
  case "search":
196
- baseResponse.tracks = (data || []).map(track => new Track(track, requester, requestNode));
283
+ baseResponse.tracks = (response.data ?? []).map(trackFactory);
197
284
  break;
198
285
  }
199
286
  return baseResponse;
@@ -206,38 +293,55 @@ class Aqua extends EventEmitter {
206
293
  }
207
294
 
208
295
  cleanupIdle() {
296
+ const now = Date.now();
209
297
  for (const [guildId, player] of this.players) {
210
- if (!player.playing && !player.paused && player.queue.isEmpty()) {
298
+ if (!player.playing && !player.paused && player.queue.isEmpty() &&
299
+ (now - player.lastActivity) > this.options.idleTimeout) {
211
300
  this.cleanupPlayer(player);
212
301
  }
213
302
  }
214
303
  }
215
304
 
216
305
  cleanupPlayer(player) {
217
- if (player) {
306
+ if (!player) return;
307
+
308
+ try {
218
309
  player.clearData();
310
+ player.removeAllListeners();
219
311
  player.destroy();
220
312
  this.players.delete(player.guildId);
221
313
  this.emit("playerDestroy", player);
314
+ } catch (error) {
315
+ console.error(`Error during player cleanup: ${error.message}`);
222
316
  }
223
317
  }
224
318
 
225
319
  cleanup() {
320
+ // Clear all players
226
321
  for (const player of this.players.values()) {
227
322
  this.cleanupPlayer(player);
228
323
  }
324
+
325
+ // Clear all nodes
229
326
  for (const node of this.nodeMap.values()) {
230
327
  this.destroyNode(node.name || node.host);
231
328
  }
329
+
330
+ // Clear maps
232
331
  this.nodeMap.clear();
233
332
  this.players.clear();
333
+
334
+ // Clear references
234
335
  this.client = null;
235
336
  this.nodes = null;
337
+ this.plugins?.forEach(plugin => plugin.unload?.(this));
236
338
  this.plugins = null;
237
339
  this.options = null;
238
340
  this.send = null;
239
341
  this.version = null;
342
+
343
+ // Remove all listeners
344
+ this.removeAllListeners();
240
345
  }
241
346
  }
242
-
243
347
  module.exports = { Aqua };
@@ -8,24 +8,11 @@ class Connection {
8
8
  this.voiceChannel = player.voiceChannel;
9
9
  this.lastUpdateTime = 0;
10
10
  this.updateThrottle = 1000;
11
-
12
- // Bind event listeners
13
- this.onPlayerMove = this.onPlayerMove.bind(this);
14
- this.onPlayerLeave = this.onPlayerLeave.bind(this);
15
- this.player.aqua.on("playerMove", this.onPlayerMove);
16
- this.player.aqua.on("playerLeave", this.onPlayerLeave);
17
- }
18
-
19
- onPlayerMove(oldChannel, newChannel) {
20
- // Handle player movement
21
- }
22
-
23
- onPlayerLeave(channelId) {
24
- // Handle player leaving
25
11
  }
26
12
 
27
13
  setServerUpdate({ endpoint, token }) {
28
14
  if (!endpoint) throw new Error("Missing 'endpoint' property in VOICE_SERVER_UPDATE");
15
+
29
16
  const newRegion = endpoint.split('.')[0].replace(/[0-9]/g, "");
30
17
  if (this.region !== newRegion) {
31
18
  this.updateRegion(newRegion, endpoint, token);
@@ -38,7 +25,9 @@ class Connection {
38
25
  this.region = newRegion;
39
26
  this.voice.endpoint = endpoint;
40
27
  this.voice.token = token;
28
+
41
29
  this.player.aqua.emit("debug", `[Player ${this.player.guildId} - CONNECTION] ${previousVoiceRegion ? `Changed Voice Region from ${previousVoiceRegion} to ${this.region}` : `Voice Server: ${this.region}`}`);
30
+
42
31
  if (this.player.paused) {
43
32
  this.player.pause(false);
44
33
  }
@@ -49,11 +38,13 @@ class Connection {
49
38
  this.cleanup();
50
39
  return;
51
40
  }
41
+
52
42
  if (this.player.voiceChannel !== data.channel_id) {
53
43
  this.player.aqua.emit("playerMove", this.player.voiceChannel, data.channel_id);
54
44
  this.player.voiceChannel = data.channel_id;
55
45
  this.voiceChannel = data.channel_id;
56
46
  }
47
+
57
48
  this.selfDeaf = data.self_deaf;
58
49
  this.selfMute = data.self_mute;
59
50
  this.voice.sessionId = data.session_id;
@@ -63,25 +54,29 @@ class Connection {
63
54
  const currentTime = Date.now();
64
55
  if (currentTime - this.lastUpdateTime >= this.updateThrottle) {
65
56
  this.lastUpdateTime = currentTime;
66
- const data = ({
57
+
58
+ const data = {
67
59
  voice: this.voice,
68
60
  volume: this.player.volume,
69
- });
61
+ };
62
+
70
63
  this.player.nodes.rest.updatePlayer({
71
64
  guildId: this.player.guildId,
72
65
  data,
66
+ }).catch(err => {
67
+ this.player.aqua.emit("apiError", "updatePlayer", err);
73
68
  });
74
69
  }
75
70
  }
76
71
 
77
72
  cleanup() {
78
- this.player.aqua.off("playerMove", this.onPlayerMove);
79
- this.player.aqua.off("playerLeave", this.onPlayerLeave);
80
73
  this.player.aqua.emit("playerLeave", this.player.voiceChannel);
81
74
  this.player.voiceChannel = null;
82
75
  this.voiceChannel = null;
76
+
83
77
  this.player.destroy();
84
78
  this.player.aqua.emit("playerDestroy", this.player);
79
+
85
80
  this.player = null;
86
81
  this.voice = null;
87
82
  this.region = null;