@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.
- package/LICENSE +213 -0
- package/README.md +285 -0
- package/THIRD-PARTY-NOTICES.md +84 -0
- package/dist/Structures/Filters.d.ts +140 -0
- package/dist/Structures/Filters.d.ts.map +1 -0
- package/dist/Structures/Filters.js +315 -0
- package/dist/Structures/Filters.js.map +1 -0
- package/dist/Structures/LRUCache.d.ts +36 -0
- package/dist/Structures/LRUCache.d.ts.map +1 -0
- package/dist/Structures/LRUCache.js +94 -0
- package/dist/Structures/LRUCache.js.map +1 -0
- package/dist/Structures/Manager.d.ts +146 -0
- package/dist/Structures/Manager.d.ts.map +1 -0
- package/dist/Structures/Manager.js +503 -0
- package/dist/Structures/Manager.js.map +1 -0
- package/dist/Structures/Node.d.ts +118 -0
- package/dist/Structures/Node.d.ts.map +1 -0
- package/dist/Structures/Node.js +927 -0
- package/dist/Structures/Node.js.map +1 -0
- package/dist/Structures/Player.d.ts +193 -0
- package/dist/Structures/Player.d.ts.map +1 -0
- package/dist/Structures/Player.js +598 -0
- package/dist/Structures/Player.js.map +1 -0
- package/dist/Structures/Queue.d.ts +48 -0
- package/dist/Structures/Queue.d.ts.map +1 -0
- package/dist/Structures/Queue.js +114 -0
- package/dist/Structures/Queue.js.map +1 -0
- package/dist/Structures/Rest.d.ts +105 -0
- package/dist/Structures/Rest.d.ts.map +1 -0
- package/dist/Structures/Rest.js +343 -0
- package/dist/Structures/Rest.js.map +1 -0
- package/dist/Structures/SessionStore.d.ts +42 -0
- package/dist/Structures/SessionStore.d.ts.map +1 -0
- package/dist/Structures/SessionStore.js +94 -0
- package/dist/Structures/SessionStore.js.map +1 -0
- package/dist/Structures/Types.d.ts +450 -0
- package/dist/Structures/Types.d.ts.map +1 -0
- package/dist/Structures/Types.js +13 -0
- package/dist/Structures/Types.js.map +1 -0
- package/dist/Structures/Utils.d.ts +61 -0
- package/dist/Structures/Utils.d.ts.map +1 -0
- package/dist/Structures/Utils.js +204 -0
- package/dist/Structures/Utils.js.map +1 -0
- package/dist/Utils/FiltersEqualizers.d.ts +20 -0
- package/dist/Utils/FiltersEqualizers.d.ts.map +1 -0
- package/dist/Utils/FiltersEqualizers.js +96 -0
- package/dist/Utils/FiltersEqualizers.js.map +1 -0
- package/dist/Utils/ManagerCheck.d.ts +9 -0
- package/dist/Utils/ManagerCheck.d.ts.map +1 -0
- package/dist/Utils/ManagerCheck.js +45 -0
- package/dist/Utils/ManagerCheck.js.map +1 -0
- package/dist/Utils/NodeCheck.d.ts +9 -0
- package/dist/Utils/NodeCheck.d.ts.map +1 -0
- package/dist/Utils/NodeCheck.js +31 -0
- package/dist/Utils/NodeCheck.js.map +1 -0
- package/dist/Utils/PlayerCheck.d.ts +9 -0
- package/dist/Utils/PlayerCheck.d.ts.map +1 -0
- package/dist/Utils/PlayerCheck.js +23 -0
- package/dist/Utils/PlayerCheck.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +28 -0
- package/dist/index.js.map +1 -0
- 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
|