@stella_project/stellalib 1.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.
Files changed (64) hide show
  1. package/LICENSE +213 -0
  2. package/README.md +285 -0
  3. package/THIRD-PARTY-NOTICES.md +84 -0
  4. package/dist/Structures/Filters.d.ts +140 -0
  5. package/dist/Structures/Filters.d.ts.map +1 -0
  6. package/dist/Structures/Filters.js +315 -0
  7. package/dist/Structures/Filters.js.map +1 -0
  8. package/dist/Structures/LRUCache.d.ts +36 -0
  9. package/dist/Structures/LRUCache.d.ts.map +1 -0
  10. package/dist/Structures/LRUCache.js +94 -0
  11. package/dist/Structures/LRUCache.js.map +1 -0
  12. package/dist/Structures/Manager.d.ts +146 -0
  13. package/dist/Structures/Manager.d.ts.map +1 -0
  14. package/dist/Structures/Manager.js +503 -0
  15. package/dist/Structures/Manager.js.map +1 -0
  16. package/dist/Structures/Node.d.ts +118 -0
  17. package/dist/Structures/Node.d.ts.map +1 -0
  18. package/dist/Structures/Node.js +927 -0
  19. package/dist/Structures/Node.js.map +1 -0
  20. package/dist/Structures/Player.d.ts +193 -0
  21. package/dist/Structures/Player.d.ts.map +1 -0
  22. package/dist/Structures/Player.js +598 -0
  23. package/dist/Structures/Player.js.map +1 -0
  24. package/dist/Structures/Queue.d.ts +48 -0
  25. package/dist/Structures/Queue.d.ts.map +1 -0
  26. package/dist/Structures/Queue.js +114 -0
  27. package/dist/Structures/Queue.js.map +1 -0
  28. package/dist/Structures/Rest.d.ts +105 -0
  29. package/dist/Structures/Rest.d.ts.map +1 -0
  30. package/dist/Structures/Rest.js +343 -0
  31. package/dist/Structures/Rest.js.map +1 -0
  32. package/dist/Structures/SessionStore.d.ts +42 -0
  33. package/dist/Structures/SessionStore.d.ts.map +1 -0
  34. package/dist/Structures/SessionStore.js +94 -0
  35. package/dist/Structures/SessionStore.js.map +1 -0
  36. package/dist/Structures/Types.d.ts +450 -0
  37. package/dist/Structures/Types.d.ts.map +1 -0
  38. package/dist/Structures/Types.js +13 -0
  39. package/dist/Structures/Types.js.map +1 -0
  40. package/dist/Structures/Utils.d.ts +61 -0
  41. package/dist/Structures/Utils.d.ts.map +1 -0
  42. package/dist/Structures/Utils.js +204 -0
  43. package/dist/Structures/Utils.js.map +1 -0
  44. package/dist/Utils/FiltersEqualizers.d.ts +20 -0
  45. package/dist/Utils/FiltersEqualizers.d.ts.map +1 -0
  46. package/dist/Utils/FiltersEqualizers.js +96 -0
  47. package/dist/Utils/FiltersEqualizers.js.map +1 -0
  48. package/dist/Utils/ManagerCheck.d.ts +9 -0
  49. package/dist/Utils/ManagerCheck.d.ts.map +1 -0
  50. package/dist/Utils/ManagerCheck.js +45 -0
  51. package/dist/Utils/ManagerCheck.js.map +1 -0
  52. package/dist/Utils/NodeCheck.d.ts +9 -0
  53. package/dist/Utils/NodeCheck.d.ts.map +1 -0
  54. package/dist/Utils/NodeCheck.js +31 -0
  55. package/dist/Utils/NodeCheck.js.map +1 -0
  56. package/dist/Utils/PlayerCheck.d.ts +9 -0
  57. package/dist/Utils/PlayerCheck.d.ts.map +1 -0
  58. package/dist/Utils/PlayerCheck.js +23 -0
  59. package/dist/Utils/PlayerCheck.js.map +1 -0
  60. package/dist/index.d.ts +12 -0
  61. package/dist/index.d.ts.map +1 -0
  62. package/dist/index.js +28 -0
  63. package/dist/index.js.map +1 -0
  64. package/package.json +64 -0
@@ -0,0 +1,927 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.StellaNode = void 0;
7
+ const Utils_1 = require("./Utils");
8
+ const Rest_1 = require("./Rest");
9
+ const NodeCheck_1 = __importDefault(require("../Utils/NodeCheck"));
10
+ const ws_1 = __importDefault(require("ws"));
11
+ /** Lavalink close codes that should NOT trigger a reconnect. */
12
+ const FATAL_CLOSE_CODES = new Set([
13
+ 4001, // Authentication failed
14
+ 4004, // Authentication failed (not configured)
15
+ ]);
16
+ /** Lavalink close codes where session is invalidated and should be cleared. */
17
+ const SESSION_INVALID_CODES = new Set([
18
+ 4006, // Session is no longer valid
19
+ 4009, // Session timed out
20
+ ]);
21
+ class StellaNode {
22
+ options;
23
+ /** The WebSocket connection for the node. */
24
+ socket = null;
25
+ /** The stats for the node. */
26
+ stats;
27
+ /** The Manager instance. */
28
+ manager;
29
+ /** The node's session ID from Lavalink. */
30
+ sessionId = null;
31
+ /** The REST instance. */
32
+ rest;
33
+ /** Whether the connection is alive (heartbeat). */
34
+ isAlive = false;
35
+ /** Timestamp of last successful pong. */
36
+ lastHeartbeatAck = 0;
37
+ /** Lavalink server info (cached after first fetch). */
38
+ info = null;
39
+ /** Detected Lavalink version (3 or 4). Auto-detected on connect. */
40
+ version = 4;
41
+ static _manager;
42
+ reconnectTimeout;
43
+ reconnectAttempts = 1;
44
+ heartbeatTimer;
45
+ statsLastUpdated = 0;
46
+ /** Returns if connected to the Node. */
47
+ get connected() {
48
+ if (!this.socket)
49
+ return false;
50
+ return this.socket.readyState === ws_1.default.OPEN;
51
+ }
52
+ /** Returns the address for this node. */
53
+ get address() {
54
+ return `${this.options.host}:${this.options.port}`;
55
+ }
56
+ /** Returns the uptime of the connection in ms. */
57
+ get uptime() {
58
+ return this.stats.uptime;
59
+ }
60
+ /** Returns the penalty score for load balancing (lower = better). */
61
+ get penalties() {
62
+ const stats = this.stats;
63
+ if (!stats)
64
+ return 0;
65
+ let penalties = 0;
66
+ // CPU load penalty
67
+ if (stats.cpu) {
68
+ penalties += Math.pow(1.05, 100 * stats.cpu.systemLoad) * 10 - 10;
69
+ penalties += Math.pow(1.03, 100 * (stats.cpu.lavalinkLoad / Math.max(stats.cpu.cores, 1))) * 5 - 5;
70
+ }
71
+ // Frame deficit/null penalty
72
+ if (stats.frameStats) {
73
+ if (stats.frameStats.deficit && stats.frameStats.deficit > 0) {
74
+ penalties += Math.pow(1.03, 500 * (stats.frameStats.deficit / 3000)) * 600 - 600;
75
+ }
76
+ if (stats.frameStats.nulled && stats.frameStats.nulled > 0) {
77
+ penalties += Math.pow(1.03, 500 * (stats.frameStats.nulled / 3000)) * 300 - 300;
78
+ }
79
+ }
80
+ // Player count penalty
81
+ penalties += stats.playingPlayers;
82
+ return penalties;
83
+ }
84
+ /** @hidden */
85
+ static init(manager) {
86
+ this._manager = manager;
87
+ }
88
+ /**
89
+ * Creates an instance of StellaNode.
90
+ * @param options The node options.
91
+ */
92
+ constructor(options) {
93
+ this.options = options;
94
+ if (!this.manager)
95
+ this.manager = Utils_1.Structure.get("Node")._manager;
96
+ if (!this.manager)
97
+ throw new RangeError("Manager has not been initiated.");
98
+ if (this.manager.nodes.has(options.identifier || options.host)) {
99
+ return this.manager.nodes.get(options.identifier || options.host);
100
+ }
101
+ (0, NodeCheck_1.default)(options);
102
+ this.options = {
103
+ port: 2333,
104
+ password: "youshallnotpass",
105
+ secure: false,
106
+ retryAmount: 30,
107
+ retryDelay: 60000,
108
+ priority: 0,
109
+ resumeStatus: true,
110
+ resumeTimeout: 60,
111
+ requestTimeout: 15000,
112
+ heartbeatInterval: 30000,
113
+ ...options,
114
+ };
115
+ if (this.options.secure) {
116
+ this.options.port = 443;
117
+ }
118
+ this.options.identifier = options.identifier || options.host;
119
+ this.stats = {
120
+ players: 0,
121
+ playingPlayers: 0,
122
+ uptime: 0,
123
+ memory: { free: 0, used: 0, allocated: 0, reservable: 0 },
124
+ cpu: { cores: 0, systemLoad: 0, lavalinkLoad: 0 },
125
+ frameStats: { sent: 0, nulled: 0, deficit: 0 },
126
+ };
127
+ this.manager.nodes.set(this.options.identifier, this);
128
+ this.manager.emit("NodeCreate", this);
129
+ this.rest = new Rest_1.StellaRest(this);
130
+ }
131
+ /** Sends a JSON payload over the WebSocket (used for v3 ops). */
132
+ sendWs(data) {
133
+ if (!this.socket || this.socket.readyState !== ws_1.default.OPEN)
134
+ return;
135
+ this.socket.send(JSON.stringify(data));
136
+ }
137
+ /**
138
+ * Auto-detects the Lavalink version by probing REST endpoints.
139
+ * Tries v4 /v4/info first, falls back to v3 /version.
140
+ */
141
+ async detectVersion() {
142
+ const protocol = this.options.secure ? "https" : "http";
143
+ const baseUrl = `${protocol}://${this.address}`;
144
+ const headers = { Authorization: this.options.password };
145
+ // Try v4 first
146
+ try {
147
+ const res = await fetch(`${baseUrl}/v4/info`, {
148
+ headers,
149
+ signal: AbortSignal.timeout(5000),
150
+ });
151
+ if (res.ok) {
152
+ this.version = 4;
153
+ this.info = (await res.json());
154
+ this.rest.setVersion(4);
155
+ this.manager.emit("Debug", `[Node:${this.options.identifier}] Detected Lavalink v4 (${this.info.version.semver})`);
156
+ return;
157
+ }
158
+ }
159
+ catch {
160
+ // Not v4, try v3
161
+ }
162
+ // Try v3
163
+ try {
164
+ const res = await fetch(`${baseUrl}/version`, {
165
+ headers,
166
+ signal: AbortSignal.timeout(5000),
167
+ });
168
+ if (res.ok) {
169
+ const versionStr = (await res.text()).trim();
170
+ this.version = 3;
171
+ this.rest.setVersion(3);
172
+ this.manager.emit("Debug", `[Node:${this.options.identifier}] Detected Lavalink v3 (${versionStr})`);
173
+ return;
174
+ }
175
+ }
176
+ catch {
177
+ // Fall through
178
+ }
179
+ // Default to v4
180
+ this.version = 4;
181
+ this.rest.setVersion(4);
182
+ this.manager.emit("Debug", `[Node:${this.options.identifier}] Could not detect version, defaulting to v4`);
183
+ }
184
+ /** Connects to the Node, auto-detecting version and loading persisted session. */
185
+ async connect() {
186
+ if (this.connected)
187
+ return;
188
+ // Auto-detect Lavalink version before connecting
189
+ await this.detectVersion();
190
+ // Try to load session ID / resume key from store
191
+ if (!this.sessionId && this.manager.options.sessionStore) {
192
+ try {
193
+ const saved = await this.manager.options.sessionStore.get(this.options.identifier);
194
+ if (saved) {
195
+ this.sessionId = saved;
196
+ this.manager.emit("Debug", `[Node:${this.options.identifier}] Loaded persisted sessionId: ${saved}`);
197
+ }
198
+ }
199
+ catch {
200
+ // Ignore store errors
201
+ }
202
+ }
203
+ const headers = {
204
+ Authorization: this.options.password,
205
+ "Num-Shards": String(this.manager.options.shards ?? 1),
206
+ "User-Id": this.manager.options.clientId,
207
+ "Client-Name": this.manager.options.clientName,
208
+ };
209
+ // v3 uses Resume-Key header, v4 uses Session-Id header
210
+ if (this.version === 3) {
211
+ if (this.sessionId)
212
+ headers["Resume-Key"] = this.sessionId;
213
+ }
214
+ else {
215
+ if (this.sessionId)
216
+ headers["Session-Id"] = this.sessionId;
217
+ }
218
+ // v3 connects to ws://host:port, v4 to ws://host:port/v4/websocket
219
+ const protocol = this.options.secure ? "wss" : "ws";
220
+ const url = this.version === 3
221
+ ? `${protocol}://${this.address}`
222
+ : `${protocol}://${this.address}/v4/websocket`;
223
+ this.manager.emit("Debug", `[Node:${this.options.identifier}] Connecting to ${url}${this.sessionId ? " (resuming)" : ""}`);
224
+ this.socket = new ws_1.default(url, { headers });
225
+ this.socket.on("open", this.open.bind(this));
226
+ this.socket.on("close", this.close.bind(this));
227
+ this.socket.on("message", this.message.bind(this));
228
+ this.socket.on("error", this.error.bind(this));
229
+ this.socket.on("pong", this.heartbeatAck.bind(this));
230
+ }
231
+ /** Destroys the Node and all players connected with it. */
232
+ destroy() {
233
+ if (!this.connected)
234
+ return;
235
+ this.stopHeartbeat();
236
+ for (const [, p] of this.manager.players) {
237
+ if (p.node === this)
238
+ p.destroy();
239
+ }
240
+ this.socket?.close(1000, "destroy");
241
+ this.socket?.removeAllListeners();
242
+ this.socket = null;
243
+ this.isAlive = false;
244
+ this.reconnectAttempts = 1;
245
+ if (this.reconnectTimeout)
246
+ clearTimeout(this.reconnectTimeout);
247
+ this.manager.emit("NodeDestroy", this);
248
+ this.manager.destroyNode(this.options.identifier);
249
+ }
250
+ /** Gracefully closes the connection, persisting the session for resume. */
251
+ async gracefulClose() {
252
+ this.stopHeartbeat();
253
+ // Persist session before closing
254
+ if (this.sessionId && this.manager.options.sessionStore) {
255
+ try {
256
+ await this.manager.options.sessionStore.set(this.options.identifier, this.sessionId);
257
+ }
258
+ catch {
259
+ // Ignore
260
+ }
261
+ }
262
+ this.socket?.close(1000, "graceful");
263
+ this.socket?.removeAllListeners();
264
+ this.socket = null;
265
+ this.isAlive = false;
266
+ if (this.reconnectTimeout)
267
+ clearTimeout(this.reconnectTimeout);
268
+ }
269
+ /** Starts the heartbeat interval to detect dead connections. */
270
+ startHeartbeat() {
271
+ this.stopHeartbeat();
272
+ const interval = this.options.heartbeatInterval ?? 30000;
273
+ if (interval <= 0)
274
+ return;
275
+ this.isAlive = true;
276
+ this.lastHeartbeatAck = Date.now();
277
+ this.heartbeatTimer = setInterval(() => {
278
+ if (!this.connected) {
279
+ this.stopHeartbeat();
280
+ return;
281
+ }
282
+ // If we haven't received a pong since last ping, connection is dead
283
+ if (!this.isAlive) {
284
+ this.manager.emit("Debug", `[Node:${this.options.identifier}] Heartbeat timeout — connection assumed dead`);
285
+ this.socket?.terminate();
286
+ return;
287
+ }
288
+ this.isAlive = false;
289
+ this.socket?.ping();
290
+ }, interval);
291
+ }
292
+ /** Stops the heartbeat interval. */
293
+ stopHeartbeat() {
294
+ if (this.heartbeatTimer) {
295
+ clearInterval(this.heartbeatTimer);
296
+ this.heartbeatTimer = undefined;
297
+ }
298
+ }
299
+ /** Called when a pong is received from the server. */
300
+ heartbeatAck() {
301
+ this.isAlive = true;
302
+ this.lastHeartbeatAck = Date.now();
303
+ }
304
+ /**
305
+ * Reconnects to the node with exponential backoff + jitter.
306
+ * Jitter prevents thundering herd when multiple nodes reconnect simultaneously.
307
+ */
308
+ reconnect() {
309
+ const baseDelay = this.options.retryDelay ?? 60000;
310
+ const maxDelay = 120000;
311
+ const exponentialDelay = Math.min(baseDelay * Math.pow(1.5, this.reconnectAttempts - 1), maxDelay);
312
+ // Add ±25% jitter
313
+ const jitter = exponentialDelay * (0.75 + Math.random() * 0.5);
314
+ const delay = Math.floor(jitter);
315
+ this.manager.emit("Debug", `[Node:${this.options.identifier}] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts}/${this.options.retryAmount ?? 30})`);
316
+ this.reconnectTimeout = setTimeout(() => {
317
+ if (this.reconnectAttempts >= (this.options.retryAmount ?? 30)) {
318
+ const error = new Error(`Unable to connect after ${this.options.retryAmount} attempts.`);
319
+ this.manager.emit("NodeError", this, error);
320
+ return this.destroy();
321
+ }
322
+ this.socket?.removeAllListeners();
323
+ this.socket = null;
324
+ this.manager.emit("NodeReconnect", this);
325
+ this.connect();
326
+ this.reconnectAttempts++;
327
+ }, delay);
328
+ }
329
+ open() {
330
+ if (this.reconnectTimeout)
331
+ clearTimeout(this.reconnectTimeout);
332
+ this.reconnectAttempts = 1;
333
+ this.startHeartbeat();
334
+ this.manager.emit("NodeConnect", this);
335
+ this.manager.emit("Debug", `[Node:${this.options.identifier}] Connected`);
336
+ // v3 has no "ready" op — handle session setup immediately on open
337
+ if (this.version === 3) {
338
+ const hadPriorSession = !!this.sessionId;
339
+ if (!this.sessionId) {
340
+ this.sessionId = `StellaLib-${this.options.identifier}-${Date.now()}`;
341
+ }
342
+ this.handleReady({ sessionId: this.sessionId, resumed: hadPriorSession });
343
+ }
344
+ }
345
+ close(code, reason) {
346
+ const reasonStr = reason.toString();
347
+ this.stopHeartbeat();
348
+ this.isAlive = false;
349
+ this.manager.emit("NodeDisconnect", this, { code, reason: reasonStr });
350
+ this.manager.emit("Debug", `[Node:${this.options.identifier}] Disconnected (code: ${code}, reason: ${reasonStr})`);
351
+ // Fatal close codes — don't retry
352
+ if (FATAL_CLOSE_CODES.has(code)) {
353
+ this.manager.emit("NodeError", this, new Error(`[Node:${this.options.identifier}] Fatal close code ${code}: ${reasonStr}. Not reconnecting.`));
354
+ return;
355
+ }
356
+ // Session invalidated — clear session ID so we get a fresh one
357
+ if (SESSION_INVALID_CODES.has(code)) {
358
+ this.manager.emit("Debug", `[Node:${this.options.identifier}] Session invalidated (code: ${code}), clearing sessionId`);
359
+ this.sessionId = null;
360
+ if (this.manager.options.sessionStore) {
361
+ try {
362
+ this.manager.options.sessionStore.delete(this.options.identifier);
363
+ }
364
+ catch { /* ignore */ }
365
+ }
366
+ }
367
+ if (code !== 1000 || reasonStr !== "destroy")
368
+ this.reconnect();
369
+ }
370
+ error(error) {
371
+ if (!error)
372
+ return;
373
+ this.manager.emit("NodeError", this, error);
374
+ }
375
+ message(d) {
376
+ if (Array.isArray(d))
377
+ d = Buffer.concat(d);
378
+ else if (d instanceof ArrayBuffer)
379
+ d = Buffer.from(d);
380
+ const payload = JSON.parse(d.toString());
381
+ if (!payload.op)
382
+ return;
383
+ this.manager.emit("NodeRaw", payload);
384
+ switch (payload.op) {
385
+ case "stats":
386
+ delete payload.op;
387
+ this.stats = { ...payload };
388
+ this.statsLastUpdated = Date.now();
389
+ break;
390
+ case "playerUpdate": {
391
+ const player = this.manager.players.get(payload.guildId);
392
+ if (player) {
393
+ player.position = payload.state.position || 0;
394
+ // v3 playerUpdate may not include connected/ping
395
+ if (payload.state.connected !== undefined)
396
+ player.connected = payload.state.connected;
397
+ if (payload.state.ping !== undefined)
398
+ player.ping = payload.state.ping;
399
+ }
400
+ break;
401
+ }
402
+ case "event":
403
+ this.handleEvent(payload);
404
+ break;
405
+ case "ready":
406
+ this.handleReady(payload);
407
+ break;
408
+ default:
409
+ this.manager.emit("NodeError", this, new Error(`Unexpected op "${payload.op}" with data: ${payload.message}`));
410
+ return;
411
+ }
412
+ }
413
+ /** Handles the 'ready' op from Lavalink (v4) or synthetic ready (v3). */
414
+ handleReady(payload) {
415
+ this.rest.setSessionId(payload.sessionId);
416
+ this.sessionId = payload.sessionId;
417
+ this.manager.emit("Debug", `[Node:${this.options.identifier}] Ready — sessionId: ${payload.sessionId}, resumed: ${payload.resumed}`);
418
+ // Persist session ID / resume key for bot restart resume
419
+ if (this.manager.options.sessionStore) {
420
+ try {
421
+ this.manager.options.sessionStore.set(this.options.identifier, payload.sessionId);
422
+ }
423
+ catch {
424
+ // Best effort
425
+ }
426
+ }
427
+ // Configure session resuming — version-aware (v3: WS op, v4: REST PATCH)
428
+ if (this.options.resumeStatus) {
429
+ this.rest
430
+ .configureResume(this.options.resumeTimeout ?? 60)
431
+ .catch((err) => {
432
+ this.manager.emit("NodeError", this, new Error(`Failed to configure session resume: ${err.message}`));
433
+ });
434
+ }
435
+ // Sync players from Lavalink (v4 only — v3 has no player list endpoint)
436
+ if (this.version === 4) {
437
+ this.syncPlayers().catch((err) => {
438
+ this.manager.emit("Debug", `[Node:${this.options.identifier}] Player sync: ${err.message}`);
439
+ });
440
+ }
441
+ // For non-resumed sessions, also try rebuilding players from local state
442
+ if (!payload.resumed && this.reconnectAttempts <= 1) {
443
+ this.rebuildPlayers().catch((err) => {
444
+ this.manager.emit("Debug", `[Node:${this.options.identifier}] Player rebuild skipped: ${err.message}`);
445
+ });
446
+ }
447
+ // Fetch and cache Lavalink info (if not already detected)
448
+ if (!this.info) {
449
+ this.fetchInfo().catch(() => { });
450
+ }
451
+ }
452
+ /** Fetches and caches Lavalink server info (version-aware). */
453
+ async fetchInfo() {
454
+ const info = await this.rest.getInfo();
455
+ this.info = info;
456
+ this.manager.emit("Debug", `[Node:${this.options.identifier}] Lavalink v${info.version.semver}, lavaplayer: ${info.lavaplayer}`);
457
+ return info;
458
+ }
459
+ /** Syncs player states after a session resume. */
460
+ async syncPlayers() {
461
+ const players = (await this.rest.getAllPlayers());
462
+ if (!Array.isArray(players))
463
+ return;
464
+ for (const data of players) {
465
+ let player = this.manager.players.get(data.guildId);
466
+ // Player exists on Lavalink but not locally — recreate it
467
+ if (!player) {
468
+ this.manager.emit("Debug", `[Node:${this.options.identifier}] Restoring player for guild ${data.guildId} from Lavalink`);
469
+ player = this.manager.create({
470
+ guild: data.guildId,
471
+ voiceChannel: data.voice?.channelId ?? undefined,
472
+ textChannel: undefined,
473
+ selfDeafen: true,
474
+ node: this.options.identifier,
475
+ volume: data.volume,
476
+ });
477
+ // Mark voice as ready since Lavalink is already connected
478
+ player.state = "CONNECTED";
479
+ player.voiceReady = true;
480
+ player.connected = data.state.connected;
481
+ }
482
+ player.position = data.state.position;
483
+ player.connected = data.state.connected;
484
+ player.ping = data.state.ping;
485
+ player.volume = data.volume;
486
+ player.paused = data.paused;
487
+ player.playing = !data.paused && data.track !== null;
488
+ // Rebuild current track from Lavalink data if we lost it
489
+ if (data.track && !player.queue.current) {
490
+ try {
491
+ player.queue.current = Utils_1.TrackUtils.build({ encoded: data.track.encoded, info: data.track.info, pluginInfo: {} }, player.get("Internal_BotUser"));
492
+ }
493
+ catch {
494
+ // Ignore track rebuild errors
495
+ }
496
+ }
497
+ // Force Discord to re-establish voice connection with fresh tokens
498
+ // This prevents the ~15-20s cutoff after restart when voice tokens expire
499
+ if (data.voice?.channelId && this.manager.options.send) {
500
+ setTimeout(() => {
501
+ this.manager.emit("Debug", `[Node:${this.options.identifier}] Re-joining voice channel ${data.voice?.channelId} for guild ${data.guildId} to refresh tokens`);
502
+ this.manager.options.send(data.guildId, {
503
+ op: 4,
504
+ d: {
505
+ guild_id: data.guildId,
506
+ channel_id: data.voice?.channelId ?? null,
507
+ self_mute: false,
508
+ self_deaf: true,
509
+ },
510
+ });
511
+ }, 1500); // Small delay to let player state settle
512
+ }
513
+ this.manager.emit("Debug", `[Node:${this.options.identifier}] Synced player for guild ${data.guildId} (pos: ${data.state.position}, playing: ${player.playing}, track: ${data.track?.info?.title ?? "none"})`);
514
+ }
515
+ }
516
+ /**
517
+ * Attempts to rebuild players that have a voice state but lost their Lavalink player
518
+ * (e.g., after bot restart with session persistence).
519
+ */
520
+ async rebuildPlayers() {
521
+ for (const [, player] of this.manager.players) {
522
+ if (player.node !== this)
523
+ continue;
524
+ if (!player.voiceState?.sessionId || !player.voiceState?.event)
525
+ continue;
526
+ try {
527
+ // Re-send voice state to Lavalink
528
+ await this.rest.updatePlayer({
529
+ guildId: player.guild,
530
+ data: {
531
+ voice: {
532
+ token: player.voiceState.event.token,
533
+ endpoint: player.voiceState.event.endpoint,
534
+ sessionId: player.voiceState.sessionId,
535
+ channelId: player.voiceChannel ?? undefined,
536
+ },
537
+ },
538
+ });
539
+ // Re-send track if we have one
540
+ if (player.queue.current?.track) {
541
+ await this.rest.updatePlayer({
542
+ guildId: player.guild,
543
+ data: {
544
+ encodedTrack: player.queue.current.track,
545
+ position: player.position,
546
+ volume: player.volume,
547
+ paused: player.paused,
548
+ },
549
+ });
550
+ }
551
+ this.manager.emit("Debug", `[Node:${this.options.identifier}] Rebuilt player for guild ${player.guild}`);
552
+ }
553
+ catch {
554
+ // Ignore rebuild errors — the player may not be recoverable
555
+ }
556
+ }
557
+ }
558
+ async handleEvent(payload) {
559
+ if (!payload.guildId)
560
+ return;
561
+ const player = this.manager.players.get(payload.guildId);
562
+ if (!player)
563
+ return;
564
+ const track = player.queue.current;
565
+ const type = payload.type;
566
+ switch (type) {
567
+ case "TrackStartEvent":
568
+ this.trackStart(player, track, payload);
569
+ break;
570
+ case "TrackEndEvent":
571
+ this.trackEnd(player, track, payload);
572
+ break;
573
+ case "TrackStuckEvent":
574
+ this.trackStuck(player, track, payload);
575
+ break;
576
+ case "TrackExceptionEvent":
577
+ this.trackError(player, track, payload);
578
+ break;
579
+ case "WebSocketClosedEvent":
580
+ this.socketClosed(player, payload);
581
+ break;
582
+ default:
583
+ this.manager.emit("NodeError", this, new Error(`Node#event unknown event '${type}'.`));
584
+ break;
585
+ }
586
+ }
587
+ trackStart(player, track, payload) {
588
+ player.playing = true;
589
+ player.paused = false;
590
+ this.manager.emit("TrackStart", player, track, payload);
591
+ }
592
+ async trackEnd(player, track, payload) {
593
+ const { reason } = payload;
594
+ if (["loadFailed", "cleanup"].includes(reason)) {
595
+ this.handleFailedTrack(player, track, payload);
596
+ }
597
+ else if (reason === "replaced") {
598
+ this.manager.emit("TrackEnd", player, track, payload);
599
+ player.queue.previous = player.queue.current;
600
+ }
601
+ else if (track && (player.trackRepeat || player.queueRepeat)) {
602
+ this.handleRepeatedTrack(player, track, payload);
603
+ }
604
+ else if (player.queue.length) {
605
+ this.playNextTrack(player, track, payload);
606
+ }
607
+ else {
608
+ await this.queueEnd(player, track, payload);
609
+ }
610
+ }
611
+ extractSpotifyTrackID(url) {
612
+ const match = url.match(/https:\/\/open\.spotify\.com\/track\/([a-zA-Z0-9]+)/);
613
+ return match ? match[1] : null;
614
+ }
615
+ extractSpotifyArtistID(url) {
616
+ const match = url.match(/https:\/\/open\.spotify\.com\/artist\/([a-zA-Z0-9]+)/);
617
+ return match ? match[1] : null;
618
+ }
619
+ /**
620
+ * Smart auto-mix: finds the best transition track for seamless 24/7 playback.
621
+ *
622
+ * Scores candidates on:
623
+ * - Duration similarity (±30s = perfect, ±2min = good)
624
+ * - Author match (same artist = high score, shared words = partial)
625
+ * - Title keyword overlap (shared theme/language)
626
+ * - Source consistency (same platform bonus)
627
+ * - Seed pool diversity (avoids drifting into one artist)
628
+ *
629
+ * Uses multi-seed context from the last 5 tracks for better recommendations.
630
+ */
631
+ async handleAutoplay(player, track) {
632
+ const previousTrack = player.queue.previous;
633
+ if (!player.isAutoplay || !previousTrack)
634
+ return;
635
+ const requester = player.get("Internal_BotUser");
636
+ const historySet = new Set(player.autoplayHistory);
637
+ // ── Update seed pool with the track that just finished ──────────────
638
+ player.autoplaySeedPool.push({
639
+ title: previousTrack.title ?? "",
640
+ author: previousTrack.author ?? "",
641
+ uri: previousTrack.uri ?? "",
642
+ duration: previousTrack.duration ?? 0,
643
+ sourceName: previousTrack.sourceName ?? "",
644
+ });
645
+ if (player.autoplaySeedPool.length > 5) {
646
+ player.autoplaySeedPool.shift();
647
+ }
648
+ const seedPool = player.autoplaySeedPool;
649
+ const avgDuration = seedPool.reduce((sum, s) => sum + s.duration, 0) / (seedPool.length || 1);
650
+ // ── Transition scoring engine ───────────────────────────────────────
651
+ const scoreTrack = (candidate) => {
652
+ let score = 0;
653
+ // Duration similarity: ±30s = +40, ±60s = +25, ±120s = +10
654
+ const durDiff = Math.abs((candidate.duration ?? 0) - avgDuration);
655
+ if (durDiff < 30_000)
656
+ score += 40;
657
+ else if (durDiff < 60_000)
658
+ score += 25;
659
+ else if (durDiff < 120_000)
660
+ score += 10;
661
+ // Author match against previous track
662
+ const prevAuthor = (previousTrack.author ?? "").toLowerCase();
663
+ const candAuthor = (candidate.author ?? "").toLowerCase();
664
+ if (prevAuthor && candAuthor) {
665
+ if (candAuthor === prevAuthor) {
666
+ score += 30;
667
+ }
668
+ else {
669
+ // Partial word overlap (e.g. "Silo" in "Silo Music")
670
+ const prevWords = prevAuthor.split(/[\s,&]+/).filter((w) => w.length > 2);
671
+ const candWords = candAuthor.split(/[\s,&]+/).filter((w) => w.length > 2);
672
+ const overlap = prevWords.filter((w) => candWords.includes(w)).length;
673
+ score += Math.min(overlap * 10, 20);
674
+ }
675
+ }
676
+ // Title keyword overlap (shared theme/vibe/language)
677
+ const prevTitle = (previousTrack.title ?? "").toLowerCase();
678
+ const candTitle = (candidate.title ?? "").toLowerCase();
679
+ if (prevTitle && candTitle) {
680
+ const prevWords = prevTitle.split(/[\s\-_()[\]]+/).filter((w) => w.length > 2);
681
+ const candWords = candTitle.split(/[\s\-_()[\]]+/).filter((w) => w.length > 2);
682
+ const overlap = prevWords.filter((w) => candWords.includes(w)).length;
683
+ score += Math.min(overlap * 8, 24);
684
+ }
685
+ // Seed pool diversity bonus: avoid same author for 3+ tracks in a row
686
+ const recentAuthors = seedPool.slice(-3).map((s) => s.author.toLowerCase());
687
+ const authorRepeatCount = recentAuthors.filter((a) => a === candAuthor).length;
688
+ if (authorRepeatCount === 0 && candAuthor !== prevAuthor) {
689
+ score += 15; // Diversity bonus — fresh artist
690
+ }
691
+ else if (authorRepeatCount >= 2) {
692
+ score -= 20; // Penalty — too repetitive
693
+ }
694
+ // Source consistency: prefer same platform for smoother vibe
695
+ if (candidate.sourceName === previousTrack.sourceName) {
696
+ score += 5;
697
+ }
698
+ // Not a stream (streams have unknown duration, bad for mix flow)
699
+ if (candidate.isStream)
700
+ score -= 30;
701
+ // Prefer reasonable duration (1min to 8min)
702
+ const dur = candidate.duration ?? 0;
703
+ if (dur > 60_000 && dur < 480_000)
704
+ score += 10;
705
+ return score;
706
+ };
707
+ // Helper: filter out history + current/previous, score & rank, return best
708
+ const pickBestTransition = (tracks) => {
709
+ const eligible = tracks.filter((t) => t.uri !== previousTrack.uri && t.uri !== track.uri && !historySet.has(t.uri));
710
+ if (!eligible.length)
711
+ return undefined;
712
+ const scored = eligible.map((t) => ({ track: t, score: scoreTrack(t) }));
713
+ scored.sort((a, b) => b.score - a.score);
714
+ // Pick from top 3 with slight randomness for variety
715
+ const topN = scored.slice(0, Math.min(3, scored.length));
716
+ const pick = topN[Math.floor(Math.random() * topN.length)];
717
+ this.manager.emit("Debug", `[AutoMix] Best candidates: ${scored.slice(0, 5).map((s) => `"${s.track.title}" (${s.score}pts)`).join(", ")}`);
718
+ return pick?.track;
719
+ };
720
+ // Helper: add track to history (bounded ring buffer)
721
+ const addToHistory = (t) => {
722
+ if (t.uri) {
723
+ player.autoplayHistory.push(t.uri);
724
+ if (player.autoplayHistory.length > 50) {
725
+ player.autoplayHistory.splice(0, player.autoplayHistory.length - 50);
726
+ }
727
+ }
728
+ };
729
+ // Helper: search → score → return best transition
730
+ const tryMixSearch = async (query) => {
731
+ try {
732
+ const res = await player.search(query, requester);
733
+ if (res.loadType === "empty" || res.loadType === "error")
734
+ return undefined;
735
+ let tracks = res.tracks;
736
+ if (res.loadType === "playlist" && res.playlist)
737
+ tracks = res.playlist.tracks;
738
+ return pickBestTransition(tracks);
739
+ }
740
+ catch {
741
+ return undefined;
742
+ }
743
+ };
744
+ // Helper: commit a found track and play it
745
+ const commitTrack = (found, strategy) => {
746
+ addToHistory(found);
747
+ player.queue.add(found);
748
+ player.play();
749
+ this.manager.emit("Debug", `[AutoMix] Playing (${strategy}): "${found.title}" by "${found.author}"`);
750
+ };
751
+ this.manager.emit("Debug", `[AutoMix] Finding best transition (from: "${previousTrack.title}" by "${previousTrack.author}", avgDur: ${Math.round(avgDuration / 1000)}s, seeds: ${seedPool.length})`);
752
+ // ── Strategy 1: Spotify Recommendations (multi-seed) ────────────────
753
+ if (this.info?.sourceManagers?.includes("spotify")) {
754
+ try {
755
+ // Build multi-seed from seed pool (up to 5 seed tracks)
756
+ const spotifySeeds = seedPool
757
+ .filter((s) => s.uri?.includes("spotify.com"))
758
+ .map((s) => this.extractSpotifyTrackID(s.uri))
759
+ .filter(Boolean);
760
+ const artistID = previousTrack.pluginInfo?.artistUrl
761
+ ? this.extractSpotifyArtistID(previousTrack.pluginInfo.artistUrl)
762
+ : null;
763
+ let identifier = "";
764
+ if (spotifySeeds.length > 0) {
765
+ const seedTracks = spotifySeeds.slice(-3).join(",");
766
+ identifier = artistID
767
+ ? `sprec:seed_artists=${artistID}&seed_tracks=${seedTracks}`
768
+ : `sprec:seed_tracks=${seedTracks}`;
769
+ }
770
+ else if (previousTrack.uri?.includes("spotify.com")) {
771
+ const trackID = this.extractSpotifyTrackID(previousTrack.uri);
772
+ if (trackID) {
773
+ identifier = artistID
774
+ ? `sprec:seed_artists=${artistID}&seed_tracks=${trackID}`
775
+ : `sprec:seed_tracks=${trackID}`;
776
+ }
777
+ }
778
+ if (identifier) {
779
+ const recResult = await this.rest.loadTracks(identifier);
780
+ if (recResult.loadType === "playlist") {
781
+ const playlistData = recResult.data;
782
+ const candidates = playlistData.tracks.map((t) => Utils_1.TrackUtils.build(t, requester));
783
+ const picked = pickBestTransition(candidates);
784
+ if (picked) {
785
+ // Re-search on SoundCloud for a streamable version
786
+ const streamable = await tryMixSearch({ source: "soundcloud", query: `${picked.author} ${picked.title}` });
787
+ if (streamable) {
788
+ commitTrack(streamable, "Spotify rec → SoundCloud");
789
+ return;
790
+ }
791
+ }
792
+ }
793
+ }
794
+ }
795
+ catch {
796
+ // Fall through
797
+ }
798
+ }
799
+ // ── Strategy 2: Author-based mix ────────────────────────────────────
800
+ // Build diverse search queries from the seed pool to keep the mix flowing
801
+ if (previousTrack.author) {
802
+ const uniqueAuthors = [...new Set(seedPool.map((s) => s.author).filter(Boolean))];
803
+ const searchQueries = [
804
+ { source: "soundcloud", query: previousTrack.author },
805
+ // Also try another recent artist for cross-artist transitions
806
+ ...(uniqueAuthors.length > 1
807
+ ? [{ source: "soundcloud", query: uniqueAuthors.find((a) => a !== previousTrack.author) ?? previousTrack.author }]
808
+ : []),
809
+ { source: "youtube", query: `${previousTrack.author} music` },
810
+ ];
811
+ for (const sq of searchQueries) {
812
+ const found = await tryMixSearch(sq);
813
+ if (found) {
814
+ commitTrack(found, `author mix on ${sq.source}`);
815
+ return;
816
+ }
817
+ }
818
+ }
819
+ // ── Strategy 3: Title/theme-based mix ───────────────────────────────
820
+ // Extract theme keywords from seed pool for broader but on-theme results
821
+ if (previousTrack.title) {
822
+ const allTitles = seedPool.map((s) => s.title).join(" ");
823
+ const keywords = allTitles
824
+ .toLowerCase()
825
+ .split(/[\s\-_()[\],]+/)
826
+ .filter((w) => w.length > 3)
827
+ .reduce((acc, w) => { acc.set(w, (acc.get(w) ?? 0) + 1); return acc; }, new Map());
828
+ // Get the most common theme words from recent tracks
829
+ const themeWords = [...keywords.entries()]
830
+ .sort((a, b) => b[1] - a[1])
831
+ .slice(0, 3)
832
+ .map(([w]) => w)
833
+ .join(" ");
834
+ const searchQueries = [
835
+ { source: "soundcloud", query: `${previousTrack.author} ${previousTrack.title}` },
836
+ { source: "soundcloud", query: previousTrack.title },
837
+ ...(themeWords ? [{ source: "soundcloud", query: themeWords }] : []),
838
+ { source: "youtube", query: `${previousTrack.title} ${previousTrack.author}` },
839
+ ];
840
+ for (const sq of searchQueries) {
841
+ const found = await tryMixSearch(sq);
842
+ if (found) {
843
+ commitTrack(found, `theme mix on ${sq.source}`);
844
+ return;
845
+ }
846
+ }
847
+ }
848
+ // ── Strategy 4: YouTube Radio Mix (last resort) ─────────────────────
849
+ const hasYouTubeURL = ["youtube.com", "youtu.be"].some((url) => previousTrack.uri?.includes(url));
850
+ if (hasYouTubeURL) {
851
+ const videoID = previousTrack.uri?.substring(previousTrack.uri.indexOf("=") + 1);
852
+ if (videoID) {
853
+ const randomIndex = Math.floor(Math.random() * 23) + 2;
854
+ const mixURI = `https://www.youtube.com/watch?v=${videoID}&list=RD${videoID}&index=${randomIndex}`;
855
+ const found = await tryMixSearch(mixURI);
856
+ if (found) {
857
+ commitTrack(found, "YouTube radio mix");
858
+ return;
859
+ }
860
+ }
861
+ }
862
+ // All strategies exhausted
863
+ this.manager.emit("Debug", `[AutoMix] No suitable transition found, stopping.`);
864
+ player.playing = false;
865
+ this.manager.emit("QueueEnd", player, track, { type: "TrackEndEvent", reason: "finished" });
866
+ }
867
+ handleFailedTrack(player, track, payload) {
868
+ player.queue.previous = player.queue.current;
869
+ player.queue.current = player.queue.shift() ?? null;
870
+ if (!player.queue.current) {
871
+ this.queueEnd(player, track, payload);
872
+ return;
873
+ }
874
+ this.manager.emit("TrackEnd", player, track, payload);
875
+ if (this.manager.options.autoPlay)
876
+ player.play();
877
+ }
878
+ handleRepeatedTrack(player, track, payload) {
879
+ const { queue, trackRepeat, queueRepeat } = player;
880
+ const { autoPlay } = this.manager.options;
881
+ if (trackRepeat) {
882
+ queue.unshift(queue.current);
883
+ }
884
+ else if (queueRepeat) {
885
+ queue.add(queue.current);
886
+ }
887
+ queue.previous = queue.current;
888
+ queue.current = queue.shift() ?? null;
889
+ this.manager.emit("TrackEnd", player, track, payload);
890
+ if (payload.reason === "stopped" && !(queue.current = queue.shift() ?? null)) {
891
+ this.queueEnd(player, track, payload);
892
+ return;
893
+ }
894
+ if (autoPlay)
895
+ player.play();
896
+ }
897
+ playNextTrack(player, track, payload) {
898
+ player.queue.previous = player.queue.current;
899
+ player.queue.current = player.queue.shift() ?? null;
900
+ this.manager.emit("TrackEnd", player, track, payload);
901
+ if (this.manager.options.autoPlay)
902
+ player.play();
903
+ }
904
+ async queueEnd(player, track, payload) {
905
+ player.queue.previous = player.queue.current;
906
+ player.queue.current = null;
907
+ if (!player.isAutoplay) {
908
+ player.playing = false;
909
+ this.manager.emit("QueueEnd", player, track, payload);
910
+ return;
911
+ }
912
+ await this.handleAutoplay(player, track);
913
+ }
914
+ trackStuck(player, track, payload) {
915
+ player.stop();
916
+ this.manager.emit("TrackStuck", player, track, payload);
917
+ }
918
+ trackError(player, track, payload) {
919
+ player.stop();
920
+ this.manager.emit("TrackError", player, track, payload);
921
+ }
922
+ socketClosed(player, payload) {
923
+ this.manager.emit("SocketClosed", player, payload);
924
+ }
925
+ }
926
+ exports.StellaNode = StellaNode;
927
+ //# sourceMappingURL=Node.js.map