yukimu 1.0.0 → 1.2.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/.cache/replit/env/latest +88 -0
- package/.cache/replit/env/latest.json +1 -0
- package/.cache/replit/modules/python-3.11.res +1 -0
- package/.cache/replit/nix/dotreplitenv.json +1 -1
- package/.cache/replit/toolchain.json +1 -1
- package/.local/state/workflow-logs/KRgHXizaECjWI5nWtS7Dj/configure_your_app.packager.installForAll.0 +1 -0
- package/.local/state/workflow-logs/KRgHXizaECjWI5nWtS7Dj/configure_your_app.shell.exec.1 +1 -0
- package/.local/state/workflow-logs/jVavLOnv1MqxUvxhMmqER/configure_your_app.packager.installForAll.0 +1 -0
- package/.local/state/workflow-logs/jVavLOnv1MqxUvxhMmqER/configure_your_app.shell.exec.1 +1 -0
- package/.replit +4 -1
- package/.upm/store.json +1 -1
- package/dist/ConnectionPool.d.ts +34 -0
- package/dist/ConnectionPool.d.ts.map +1 -0
- package/dist/ConnectionPool.js +124 -0
- package/dist/ConnectionPool.js.map +1 -0
- package/dist/Constants.d.ts +66 -0
- package/dist/Constants.d.ts.map +1 -0
- package/dist/Constants.js +101 -0
- package/dist/Constants.js.map +1 -0
- package/dist/Node.d.ts +26 -10
- package/dist/Node.d.ts.map +1 -1
- package/dist/Node.js +203 -81
- package/dist/Node.js.map +1 -1
- package/dist/Player.d.ts +30 -29
- package/dist/Player.d.ts.map +1 -1
- package/dist/Player.js +178 -97
- package/dist/Player.js.map +1 -1
- package/dist/Plugin.d.ts +21 -0
- package/dist/Plugin.d.ts.map +1 -0
- package/dist/Plugin.js +19 -0
- package/dist/Plugin.js.map +1 -0
- package/dist/Queue.d.ts +10 -16
- package/dist/Queue.d.ts.map +1 -1
- package/dist/Queue.js +39 -32
- package/dist/Queue.js.map +1 -1
- package/dist/Resolver.d.ts +4 -21
- package/dist/Resolver.d.ts.map +1 -1
- package/dist/Resolver.js +28 -66
- package/dist/Resolver.js.map +1 -1
- package/dist/Rest.d.ts +24 -0
- package/dist/Rest.d.ts.map +1 -0
- package/dist/Rest.js +104 -0
- package/dist/Rest.js.map +1 -0
- package/dist/TrackCache.d.ts +21 -0
- package/dist/TrackCache.d.ts.map +1 -0
- package/dist/TrackCache.js +57 -0
- package/dist/TrackCache.js.map +1 -0
- package/dist/WsQueue.d.ts +20 -0
- package/dist/WsQueue.d.ts.map +1 -0
- package/dist/WsQueue.js +74 -0
- package/dist/WsQueue.js.map +1 -0
- package/dist/Yukimu.d.ts +33 -32
- package/dist/Yukimu.d.ts.map +1 -1
- package/dist/Yukimu.js +133 -65
- package/dist/Yukimu.js.map +1 -1
- package/dist/connector/Connector.d.ts +8 -0
- package/dist/connector/Connector.d.ts.map +1 -0
- package/dist/connector/Connector.js +11 -0
- package/dist/connector/Connector.js.map +1 -0
- package/dist/connector/DiscordJS.d.ts +25 -0
- package/dist/connector/DiscordJS.d.ts.map +1 -0
- package/dist/connector/DiscordJS.js +27 -0
- package/dist/connector/DiscordJS.js.map +1 -0
- package/dist/connector/Eris.d.ts +23 -0
- package/dist/connector/Eris.d.ts.map +1 -0
- package/dist/connector/Eris.js +30 -0
- package/dist/connector/Eris.js.map +1 -0
- package/dist/connector/Oceanic.d.ts +20 -0
- package/dist/connector/Oceanic.d.ts.map +1 -0
- package/dist/connector/Oceanic.js +27 -0
- package/dist/connector/Oceanic.js.map +1 -0
- package/dist/errors/YukimuError.d.ts +15 -0
- package/dist/errors/YukimuError.d.ts.map +1 -0
- package/dist/errors/YukimuError.js +35 -0
- package/dist/errors/YukimuError.js.map +1 -0
- package/dist/index.d.ts +14 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +34 -2
- package/dist/index.js.map +1 -1
- package/dist/plugins/AutoResume.d.ts +21 -0
- package/dist/plugins/AutoResume.d.ts.map +1 -0
- package/dist/plugins/AutoResume.js +52 -0
- package/dist/plugins/AutoResume.js.map +1 -0
- package/dist/plugins/PlayerMoved.d.ts +7 -0
- package/dist/plugins/PlayerMoved.d.ts.map +1 -0
- package/dist/plugins/PlayerMoved.js +30 -0
- package/dist/plugins/PlayerMoved.js.map +1 -0
- package/dist/types.d.ts +92 -38
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +0 -12
- package/dist/types.js.map +1 -1
- package/package.json +1 -1
- package/src/ConnectionPool.ts +131 -0
- package/src/Constants.ts +101 -0
- package/src/Node.ts +157 -174
- package/src/Player.ts +200 -108
- package/src/Plugin.ts +23 -0
- package/src/Queue.ts +43 -34
- package/src/Resolver.ts +29 -77
- package/src/Rest.ts +121 -0
- package/src/TrackCache.ts +69 -0
- package/src/WsQueue.ts +78 -0
- package/src/Yukimu.ts +156 -85
- package/src/connector/Connector.ts +13 -0
- package/src/connector/DiscordJS.ts +33 -0
- package/src/connector/Eris.ts +35 -0
- package/src/connector/Oceanic.ts +32 -0
- package/src/errors/YukimuError.ts +33 -0
- package/src/index.ts +41 -1
- package/src/plugins/AutoResume.ts +61 -0
- package/src/plugins/PlayerMoved.ts +26 -0
- package/src/types.ts +50 -83
- package/tsconfig.json +4 -2
- package/README.md +0 -152
package/src/Constants.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/** Lavalink v3/v4 supported ops and constants */
|
|
2
|
+
|
|
3
|
+
export const VERSION = "2.0.0";
|
|
4
|
+
export const CLIENT_NAME = `Yukimu/${VERSION}`;
|
|
5
|
+
|
|
6
|
+
export enum State {
|
|
7
|
+
CONNECTING = 0,
|
|
8
|
+
NEARLY = 1,
|
|
9
|
+
CONNECTED = 2,
|
|
10
|
+
DISCONNECTING = 3,
|
|
11
|
+
DISCONNECTED = 4,
|
|
12
|
+
RECONNECTING = 5,
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export enum PlayerState {
|
|
16
|
+
CONNECTING = 0,
|
|
17
|
+
CONNECTED = 1,
|
|
18
|
+
DISCONNECTING = 2,
|
|
19
|
+
DISCONNECTED = 3,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export enum OpCodes {
|
|
23
|
+
// v3 WebSocket ops
|
|
24
|
+
PLAY = "play",
|
|
25
|
+
STOP = "stop",
|
|
26
|
+
PAUSE = "pause",
|
|
27
|
+
SEEK = "seek",
|
|
28
|
+
VOLUME = "volume",
|
|
29
|
+
FILTERS = "filters",
|
|
30
|
+
DESTROY = "destroy",
|
|
31
|
+
VOICE_UPDATE = "voiceUpdate",
|
|
32
|
+
EQUALIZER = "equalizer",
|
|
33
|
+
// Incoming
|
|
34
|
+
STATS = "stats",
|
|
35
|
+
PLAYER_UPDATE = "playerUpdate",
|
|
36
|
+
EVENT = "event",
|
|
37
|
+
READY = "ready",
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export enum LoadType {
|
|
41
|
+
TRACK = "track",
|
|
42
|
+
PLAYLIST = "playlist",
|
|
43
|
+
SEARCH = "search",
|
|
44
|
+
EMPTY = "empty",
|
|
45
|
+
ERROR = "error",
|
|
46
|
+
// v3 equivalents (normalized internally)
|
|
47
|
+
TRACK_LOADED = "track_loaded",
|
|
48
|
+
PLAYLIST_LOADED = "playlist_loaded",
|
|
49
|
+
SEARCH_RESULT = "search_result",
|
|
50
|
+
NO_MATCHES = "no_matches",
|
|
51
|
+
LOAD_FAILED = "load_failed",
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export enum EventType {
|
|
55
|
+
TRACK_START = "TrackStartEvent",
|
|
56
|
+
TRACK_END = "TrackEndEvent",
|
|
57
|
+
TRACK_EXCEPTION = "TrackExceptionEvent",
|
|
58
|
+
TRACK_STUCK = "TrackStuckEvent",
|
|
59
|
+
WS_CLOSED = "WebSocketClosedEvent",
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export enum TrackEndReason {
|
|
63
|
+
FINISHED = "finished",
|
|
64
|
+
LOAD_FAILED = "loadFailed",
|
|
65
|
+
STOPPED = "stopped",
|
|
66
|
+
REPLACED = "replaced",
|
|
67
|
+
CLEANUP = "cleanup",
|
|
68
|
+
// v3
|
|
69
|
+
FINISHED_V3 = "FINISHED",
|
|
70
|
+
STOPPED_V3 = "STOPPED",
|
|
71
|
+
REPLACED_V3 = "REPLACED",
|
|
72
|
+
CLEANUP_V3 = "CLEANUP",
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export const STOPPED_REASONS = new Set([
|
|
76
|
+
"replaced", "stopped", "REPLACED", "STOPPED",
|
|
77
|
+
]);
|
|
78
|
+
|
|
79
|
+
export const SOURCE_PREFIXES: Record<string, string> = {
|
|
80
|
+
youtube: "ytsearch",
|
|
81
|
+
youtubemusic: "ytmsearch",
|
|
82
|
+
spotify: "spsearch",
|
|
83
|
+
deezer: "dzsearch",
|
|
84
|
+
applemusic: "amsearch",
|
|
85
|
+
soundcloud: "scsearch",
|
|
86
|
+
tidal: "tdsearch",
|
|
87
|
+
jiosaavn: "jssearch",
|
|
88
|
+
yandexmusic: "ymsearch",
|
|
89
|
+
flowery: "ftts",
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
export const URL_PATTERNS: Record<string, RegExp[]> = {
|
|
93
|
+
youtube: [/^https?:\/\/(www\.)?(youtube\.com|youtu\.be)\/.+/, /^https?:\/\/music\.youtube\.com\/.+/],
|
|
94
|
+
spotify: [/^https?:\/\/open\.spotify\.com\/(track|album|playlist|artist)\/.+/],
|
|
95
|
+
soundcloud: [/^https?:\/\/(www\.)?soundcloud\.com\/.+/],
|
|
96
|
+
deezer: [/^https?:\/\/(www\.)?deezer\.com\/(track|album|playlist)\/.+/],
|
|
97
|
+
applemusic: [/^https?:\/\/music\.apple\.com\/.+/],
|
|
98
|
+
tidal: [/^https?:\/\/(www\.)?tidal\.com\/(browse\/)?(track|album|playlist)\/.+/],
|
|
99
|
+
jiosaavn: [/^https?:\/\/(www\.)?jiosaavn\.com\/.+/],
|
|
100
|
+
yandexmusic: [/^https?:\/\/music\.yandex\.(ru|com)\/.+/],
|
|
101
|
+
};
|
package/src/Node.ts
CHANGED
|
@@ -1,119 +1,138 @@
|
|
|
1
1
|
import WebSocket from "ws";
|
|
2
|
-
import {
|
|
2
|
+
import { Rest } from "./Rest";
|
|
3
|
+
import { WsQueue } from "./WsQueue";
|
|
4
|
+
import { State } from "./Constants";
|
|
5
|
+
import { NodeError } from "./errors/YukimuError";
|
|
3
6
|
import type { Yukimu } from "./Yukimu";
|
|
7
|
+
import type { NodeOptions, NodeStats, NodeInfo, Track, SearchResult, LavalinkException } from "./types";
|
|
8
|
+
|
|
9
|
+
const WS_CLOSE_NORMAL = 1000;
|
|
10
|
+
const HEARTBEAT_INTERVAL = 30000;
|
|
4
11
|
|
|
5
12
|
export class Node {
|
|
6
13
|
public readonly manager: Yukimu;
|
|
7
14
|
public readonly options: NodeOptions;
|
|
15
|
+
public readonly rest: Rest;
|
|
16
|
+
public readonly wsQueue: WsQueue;
|
|
17
|
+
public readonly version: 3 | 4;
|
|
18
|
+
|
|
8
19
|
public ws: WebSocket | null = null;
|
|
9
|
-
public
|
|
20
|
+
public state: State = State.DISCONNECTED;
|
|
10
21
|
public stats: NodeStats | null = null;
|
|
22
|
+
public info: NodeInfo | null = null;
|
|
11
23
|
public sessionId: string | null = null;
|
|
24
|
+
public resumed: boolean = false;
|
|
25
|
+
public penalties: number = 0;
|
|
26
|
+
public ping: number = -1;
|
|
12
27
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
private reconnectAttempts: number = 0;
|
|
28
|
+
private restQueue: Promise<unknown> = Promise.resolve();
|
|
29
|
+
private reconnectAttempts = 0;
|
|
17
30
|
private reconnectTimeout?: ReturnType<typeof setTimeout>;
|
|
31
|
+
private heartbeatInterval?: ReturnType<typeof setInterval>;
|
|
32
|
+
private lastHeartbeatAt = 0;
|
|
18
33
|
|
|
19
34
|
constructor(manager: Yukimu, options: NodeOptions) {
|
|
20
35
|
this.manager = manager;
|
|
21
|
-
this.options = {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
...options,
|
|
26
|
-
};
|
|
27
|
-
this.version = this.options.version ?? 4;
|
|
36
|
+
this.options = { secure: false, retries: 5, version: 4, resumeTimeout: 60, ...options };
|
|
37
|
+
this.version = (this.options.version ?? 4) as 3 | 4;
|
|
38
|
+
this.rest = new Rest(this);
|
|
39
|
+
this.wsQueue = new WsQueue();
|
|
28
40
|
}
|
|
29
41
|
|
|
30
|
-
|
|
42
|
+
get connected(): boolean {
|
|
43
|
+
return this.state === State.CONNECTED || this.state === State.NEARLY;
|
|
44
|
+
}
|
|
31
45
|
|
|
32
|
-
/** WebSocket URL differs between v3 and v4 */
|
|
33
46
|
get wsUrl(): string {
|
|
34
47
|
const protocol = this.options.secure ? "wss" : "ws";
|
|
35
48
|
const base = `${protocol}://${this.options.host}:${this.options.port}`;
|
|
36
49
|
return this.version === 4 ? `${base}/v4/websocket` : base;
|
|
37
50
|
}
|
|
38
51
|
|
|
39
|
-
|
|
40
|
-
get restUrl(): string {
|
|
41
|
-
const protocol = this.options.secure ? "https" : "http";
|
|
42
|
-
return `${protocol}://${this.options.host}:${this.options.port}`;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/** API prefix — v4 uses /v4, v3 uses nothing */
|
|
46
|
-
get apiPrefix(): string {
|
|
47
|
-
return this.version === 4 ? "/v4" : "";
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
get headers(): Record<string, string> {
|
|
52
|
+
get wsHeaders(): Record<string, string> {
|
|
51
53
|
const h: Record<string, string> = {
|
|
52
54
|
Authorization: this.options.password,
|
|
53
55
|
"User-Id": this.manager.options.clientId,
|
|
54
|
-
"Client-Name": "Yukimu/1.
|
|
56
|
+
"Client-Name": "Yukimu/1.2.0",
|
|
55
57
|
};
|
|
56
|
-
|
|
57
|
-
if (this.
|
|
58
|
-
h["Num-Shards"] = "1";
|
|
59
|
-
}
|
|
58
|
+
if (this.version === 3) h["Num-Shards"] = "1";
|
|
59
|
+
if (this.options.resumeKey) h["Resume-Key"] = this.options.resumeKey;
|
|
60
60
|
return h;
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
-
|
|
63
|
+
public calculatePenalties(): number {
|
|
64
|
+
if (!this.stats) return Infinity;
|
|
65
|
+
const cpu = Math.pow(1.05, 100 * this.stats.cpu.systemLoad) * 10 - 10;
|
|
66
|
+
const fs = this.stats.frameStats;
|
|
67
|
+
const deficit = fs ? Math.pow(1.03, 500 * fs.deficit / 3000) * 600 - 600 : 0;
|
|
68
|
+
const nulled = fs ? Math.pow(1.03, 500 * fs.nulled / 3000) * 300 - 300 : 0;
|
|
69
|
+
this.penalties = ~~(cpu + deficit + nulled + this.stats.playingPlayers);
|
|
70
|
+
return this.penalties;
|
|
71
|
+
}
|
|
64
72
|
|
|
65
73
|
public connect(): void {
|
|
66
74
|
if (this.ws?.readyState === WebSocket.OPEN) return;
|
|
67
|
-
this.
|
|
75
|
+
this.state = State.CONNECTING;
|
|
76
|
+
this.manager.emit("nodeConnecting", this);
|
|
77
|
+
this.ws = new WebSocket(this.wsUrl, { headers: this.wsHeaders });
|
|
78
|
+
this.wsQueue.setSocket(this.ws);
|
|
68
79
|
this.ws.on("open", () => this.onOpen());
|
|
69
|
-
this.ws.on("message", (
|
|
70
|
-
this.ws.on("close", (code, reason) => this.onClose(code, reason.toString()));
|
|
71
|
-
this.ws.on("error", (err) => this.onError(err));
|
|
80
|
+
this.ws.on("message", (d: WebSocket.RawData) => this.onMessage(d));
|
|
81
|
+
this.ws.on("close", (code: number, reason: Buffer) => this.onClose(code, reason.toString()));
|
|
82
|
+
this.ws.on("error", (err: Error) => this.onError(err));
|
|
72
83
|
}
|
|
73
84
|
|
|
74
|
-
public destroy(): void {
|
|
85
|
+
public destroy(reconnect = false): void {
|
|
75
86
|
if (this.reconnectTimeout) clearTimeout(this.reconnectTimeout);
|
|
87
|
+
if (this.heartbeatInterval) clearInterval(this.heartbeatInterval);
|
|
88
|
+
this.wsQueue.clear();
|
|
89
|
+
this.wsQueue.setSocket(null);
|
|
76
90
|
this.ws?.removeAllListeners();
|
|
77
|
-
this.ws?.close();
|
|
91
|
+
this.ws?.close(WS_CLOSE_NORMAL, "destroy");
|
|
78
92
|
this.ws = null;
|
|
79
|
-
this.
|
|
93
|
+
this.state = State.DISCONNECTED;
|
|
94
|
+
if (!reconnect) this.sessionId = null;
|
|
80
95
|
}
|
|
81
96
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
private onOpen(): void {
|
|
85
|
-
this.connected = true;
|
|
97
|
+
private async onOpen(): Promise<void> {
|
|
98
|
+
this.state = State.NEARLY;
|
|
86
99
|
this.reconnectAttempts = 0;
|
|
87
100
|
this.manager.emit("nodeConnect", this);
|
|
88
|
-
console.log(`[Yukimu] Node "${this.options.name}" connected (
|
|
101
|
+
console.log(`[Yukimu] Node "${this.options.name}" connected (v${this.version})`);
|
|
102
|
+
|
|
103
|
+
try { this.info = await this.rest.getInfo(); } catch {}
|
|
104
|
+
|
|
105
|
+
if (this.version === 4 && this.options.resumeKey) {
|
|
106
|
+
await this.rest.updateSession({ resuming: true, timeout: this.options.resumeTimeout ?? 60 }).catch(() => {});
|
|
107
|
+
}
|
|
89
108
|
|
|
90
|
-
// v3 doesn't send a "ready" op — mark ready immediately on open
|
|
91
109
|
if (this.version === 3) {
|
|
92
|
-
this.
|
|
110
|
+
this.state = State.CONNECTED;
|
|
111
|
+
this.sessionId = this.options.resumeKey ?? `v3-${Date.now()}`;
|
|
93
112
|
this.manager.emit("nodeReady", this);
|
|
94
113
|
}
|
|
114
|
+
|
|
115
|
+
this.startHeartbeat();
|
|
95
116
|
}
|
|
96
117
|
|
|
97
118
|
private onMessage(raw: WebSocket.RawData): void {
|
|
98
119
|
let payload: Record<string, unknown>;
|
|
99
|
-
try {
|
|
100
|
-
payload = JSON.parse(raw.toString());
|
|
101
|
-
} catch {
|
|
102
|
-
return;
|
|
103
|
-
}
|
|
104
|
-
|
|
120
|
+
try { payload = JSON.parse(raw.toString()); } catch { return; }
|
|
105
121
|
const op = payload.op as string;
|
|
106
122
|
|
|
107
|
-
// ── v4 ops ──
|
|
108
123
|
if (this.version === 4) {
|
|
109
124
|
switch (op) {
|
|
110
125
|
case "ready":
|
|
126
|
+
this.state = State.CONNECTED;
|
|
111
127
|
this.sessionId = payload.sessionId as string;
|
|
128
|
+
this.resumed = (payload.resumed as boolean) ?? false;
|
|
112
129
|
this.manager.emit("nodeReady", this);
|
|
113
|
-
console.log(`[Yukimu] Node "${this.options.name}" ready | session: ${this.sessionId}`);
|
|
130
|
+
console.log(`[Yukimu] Node "${this.options.name}" ready | session: ${this.sessionId} | resumed: ${this.resumed}`);
|
|
131
|
+
if (this.resumed) this.reconnectPlayers();
|
|
114
132
|
break;
|
|
115
133
|
case "stats":
|
|
116
134
|
this.stats = payload as unknown as NodeStats;
|
|
135
|
+
this.calculatePenalties();
|
|
117
136
|
break;
|
|
118
137
|
case "playerUpdate":
|
|
119
138
|
this.handlePlayerUpdate(payload);
|
|
@@ -125,10 +144,10 @@ export class Node {
|
|
|
125
144
|
return;
|
|
126
145
|
}
|
|
127
146
|
|
|
128
|
-
// ── v3 ops ──
|
|
129
147
|
switch (op) {
|
|
130
148
|
case "stats":
|
|
131
149
|
this.stats = payload as unknown as NodeStats;
|
|
150
|
+
this.calculatePenalties();
|
|
132
151
|
break;
|
|
133
152
|
case "playerUpdate":
|
|
134
153
|
this.handlePlayerUpdate(payload);
|
|
@@ -140,10 +159,12 @@ export class Node {
|
|
|
140
159
|
}
|
|
141
160
|
|
|
142
161
|
private onClose(code: number, reason: string): void {
|
|
143
|
-
this.
|
|
162
|
+
if (this.heartbeatInterval) clearInterval(this.heartbeatInterval);
|
|
163
|
+
this.state = State.DISCONNECTED;
|
|
144
164
|
this.manager.emit("nodeDisconnect", this, code, reason);
|
|
145
|
-
console.warn(`[Yukimu] Node "${this.options.name}" disconnected (${code}): ${reason}`);
|
|
146
|
-
this.scheduleReconnect();
|
|
165
|
+
console.warn(`[Yukimu] Node "${this.options.name}" disconnected (${code}): ${reason || "No reason"}`);
|
|
166
|
+
if (code !== WS_CLOSE_NORMAL) this.scheduleReconnect();
|
|
167
|
+
else this.movePlayers();
|
|
147
168
|
}
|
|
148
169
|
|
|
149
170
|
private onError(error: Error): void {
|
|
@@ -151,28 +172,56 @@ export class Node {
|
|
|
151
172
|
console.error(`[Yukimu] Node "${this.options.name}" error:`, error.message);
|
|
152
173
|
}
|
|
153
174
|
|
|
154
|
-
|
|
175
|
+
private startHeartbeat(): void {
|
|
176
|
+
if (this.heartbeatInterval) clearInterval(this.heartbeatInterval);
|
|
177
|
+
this.heartbeatInterval = setInterval(async () => {
|
|
178
|
+
if (!this.connected) return;
|
|
179
|
+
this.lastHeartbeatAt = Date.now();
|
|
180
|
+
try {
|
|
181
|
+
this.stats = await this.rest.getStats();
|
|
182
|
+
this.ping = Date.now() - this.lastHeartbeatAt;
|
|
183
|
+
this.calculatePenalties();
|
|
184
|
+
} catch {}
|
|
185
|
+
}, HEARTBEAT_INTERVAL);
|
|
186
|
+
}
|
|
155
187
|
|
|
156
188
|
private scheduleReconnect(): void {
|
|
157
|
-
const
|
|
158
|
-
if (this.reconnectAttempts >=
|
|
159
|
-
|
|
189
|
+
const max = this.options.retries ?? 5;
|
|
190
|
+
if (this.reconnectAttempts >= max) {
|
|
191
|
+
this.manager.emit("nodeError", this, new NodeError(`Node "${this.options.name}" max retries reached`));
|
|
192
|
+
this.movePlayers();
|
|
160
193
|
return;
|
|
161
194
|
}
|
|
162
|
-
const delay = Math.min(1000 * 2
|
|
195
|
+
const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30000);
|
|
163
196
|
this.reconnectAttempts++;
|
|
164
|
-
|
|
165
|
-
|
|
197
|
+
this.state = State.RECONNECTING;
|
|
198
|
+
console.log(`[Yukimu] Reconnecting "${this.options.name}" in ${delay}ms (${this.reconnectAttempts}/${max})`);
|
|
199
|
+
this.reconnectTimeout = setTimeout(() => { this.destroy(true); this.connect(); }, delay);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
private movePlayers(): void {
|
|
203
|
+
const players = [...this.manager.players.values()].filter(p => p.node === this);
|
|
204
|
+
if (!players.length) return;
|
|
205
|
+
let best: Node;
|
|
206
|
+
try { best = this.manager.getBestNode(); } catch { return; }
|
|
207
|
+
if (best === this) return;
|
|
208
|
+
console.log(`[Yukimu] Moving ${players.length} players to "${best.options.name}"`);
|
|
209
|
+
for (const player of players) player.moveToNode(best).catch(console.error);
|
|
166
210
|
}
|
|
167
211
|
|
|
168
|
-
|
|
212
|
+
private reconnectPlayers(): void {
|
|
213
|
+
for (const player of this.manager.players.values()) {
|
|
214
|
+
if (player.node === this) player.checkVoiceReady();
|
|
215
|
+
}
|
|
216
|
+
}
|
|
169
217
|
|
|
170
218
|
private handlePlayerUpdate(payload: Record<string, unknown>): void {
|
|
171
219
|
const player = this.manager.players.get(payload.guildId as string);
|
|
172
220
|
if (!player) return;
|
|
173
|
-
const state = payload.state as { position?: number;
|
|
174
|
-
player.position = state.position ??
|
|
175
|
-
player.ping = state.ping ??
|
|
221
|
+
const state = payload.state as { position?: number; ping?: number; time?: number };
|
|
222
|
+
player.position = state.position ?? player.position;
|
|
223
|
+
player.ping = state.ping ?? player.ping;
|
|
224
|
+
player.lastUpdated = state.time ?? Date.now();
|
|
176
225
|
this.manager.emit("playerUpdate", player);
|
|
177
226
|
}
|
|
178
227
|
|
|
@@ -180,36 +229,48 @@ export class Node {
|
|
|
180
229
|
const player = this.manager.players.get(payload.guildId as string);
|
|
181
230
|
if (!player) return;
|
|
182
231
|
|
|
183
|
-
// Normalize track — v3 uses { track: "encoded_string" }, v4 uses { track: { encoded, info } }
|
|
184
232
|
let track: Track;
|
|
185
233
|
if (this.version === 3) {
|
|
186
|
-
track = {
|
|
187
|
-
encoded: payload.track as string,
|
|
188
|
-
info: {} as Track["info"],
|
|
189
|
-
};
|
|
234
|
+
track = { encoded: payload.track as string, info: {} as Track["info"], pluginInfo: {} };
|
|
190
235
|
} else {
|
|
191
236
|
track = payload.track as Track;
|
|
237
|
+
if (track?.encoded) this.manager.trackCache.set(track.encoded, track);
|
|
192
238
|
}
|
|
193
239
|
|
|
194
240
|
switch (payload.type) {
|
|
195
241
|
case "TrackStartEvent":
|
|
196
242
|
player.playing = true;
|
|
243
|
+
player.paused = false;
|
|
244
|
+
player.lastUpdated = Date.now();
|
|
197
245
|
this.manager.emit("trackStart", player, track);
|
|
198
246
|
break;
|
|
199
247
|
|
|
200
|
-
case "TrackEndEvent":
|
|
248
|
+
case "TrackEndEvent": {
|
|
249
|
+
const reason = payload.reason as string;
|
|
201
250
|
player.playing = false;
|
|
202
251
|
player.position = 0;
|
|
203
|
-
this.manager.emit("trackEnd", player, track,
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
if (player.queue.current) {
|
|
207
|
-
player.play(player.queue.current);
|
|
252
|
+
this.manager.emit("trackEnd", player, track, reason);
|
|
253
|
+
const stopped = ["replaced", "stopped", "REPLACED", "STOPPED"].includes(reason);
|
|
254
|
+
if (!stopped) {
|
|
255
|
+
if (player.loop === "track" && player.queue.current) {
|
|
256
|
+
player.play(player.queue.current).catch(() => {});
|
|
208
257
|
} else {
|
|
209
|
-
|
|
258
|
+
if (player.loop === "queue" && player.queue.current) {
|
|
259
|
+
player.queue.add(player.queue.current);
|
|
260
|
+
}
|
|
261
|
+
const next = player.queue.next();
|
|
262
|
+
if (next) {
|
|
263
|
+
player.play(next).catch(() => {});
|
|
264
|
+
} else {
|
|
265
|
+
this.manager.emit("queueEnd", player);
|
|
266
|
+
if (player.autoplay && track.info?.uri) {
|
|
267
|
+
this.manager.emit("autoplayRequest", player, track);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
210
270
|
}
|
|
211
271
|
}
|
|
212
272
|
break;
|
|
273
|
+
}
|
|
213
274
|
|
|
214
275
|
case "TrackExceptionEvent":
|
|
215
276
|
this.manager.emit("trackError", player, track, payload.exception as LavalinkException);
|
|
@@ -217,110 +278,32 @@ export class Node {
|
|
|
217
278
|
|
|
218
279
|
case "TrackStuckEvent":
|
|
219
280
|
this.manager.emit("trackStuck", player, track, payload.thresholdMs as number);
|
|
281
|
+
player.skip().catch(() => {});
|
|
220
282
|
break;
|
|
221
283
|
|
|
222
284
|
case "WebSocketClosedEvent":
|
|
223
|
-
this.manager.emit("socketClosed", player, payload.code as number, payload.reason as string);
|
|
285
|
+
this.manager.emit("socketClosed", player, payload.code as number, payload.reason as string, payload.byRemote as boolean);
|
|
224
286
|
break;
|
|
225
287
|
}
|
|
226
288
|
}
|
|
227
289
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
public async request<T = unknown>(
|
|
231
|
-
method: string,
|
|
232
|
-
path: string,
|
|
233
|
-
body?: unknown
|
|
234
|
-
): Promise<T> {
|
|
235
|
-
const url = `${this.restUrl}${this.apiPrefix}${path}`;
|
|
236
|
-
const res = await fetch(url, {
|
|
237
|
-
method,
|
|
238
|
-
headers: {
|
|
239
|
-
...this.headers,
|
|
240
|
-
"Content-Type": "application/json",
|
|
241
|
-
},
|
|
242
|
-
body: body ? JSON.stringify(body) : undefined,
|
|
243
|
-
});
|
|
244
|
-
|
|
245
|
-
if (!res.ok) {
|
|
246
|
-
const text = await res.text();
|
|
247
|
-
throw new Error(`[Yukimu] REST error ${res.status} on ${method} ${path}: ${text}`);
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
if (res.status === 204) return undefined as T;
|
|
251
|
-
return res.json() as Promise<T>;
|
|
290
|
+
public send(data: Record<string, unknown>): Promise<void> {
|
|
291
|
+
return this.wsQueue.send(data);
|
|
252
292
|
}
|
|
253
293
|
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
`/loadtracks?identifier=${encodeURIComponent(identifier)}`
|
|
259
|
-
);
|
|
260
|
-
|
|
261
|
-
// Normalize v3 response to v4 format
|
|
262
|
-
if (this.version === 3) {
|
|
263
|
-
return this.normalizeV3Response(raw);
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
return raw as unknown as SearchResult;
|
|
294
|
+
public enqueueRest<T>(fn: () => Promise<T>): Promise<T> {
|
|
295
|
+
const result = this.restQueue.then(() => fn());
|
|
296
|
+
this.restQueue = result.catch(() => {});
|
|
297
|
+
return result;
|
|
267
298
|
}
|
|
268
299
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
* of the codebase only needs to handle one format
|
|
272
|
-
*/
|
|
273
|
-
private normalizeV3Response(raw: Record<string, unknown>): SearchResult {
|
|
274
|
-
const loadType = (raw.loadType as string).toLowerCase();
|
|
275
|
-
|
|
276
|
-
// v3 tracks are { track: "encoded", info: {...} }
|
|
277
|
-
// v4 tracks are { encoded: "...", info: {...} }
|
|
278
|
-
const normalizeTracks = (tracks: unknown[]): Track[] =>
|
|
279
|
-
tracks.map((t: unknown) => {
|
|
280
|
-
const track = t as Record<string, unknown>;
|
|
281
|
-
return {
|
|
282
|
-
encoded: (track.track ?? track.encoded) as string,
|
|
283
|
-
info: track.info as Track["info"],
|
|
284
|
-
pluginInfo: track.pluginInfo as Record<string, unknown> | undefined,
|
|
285
|
-
};
|
|
286
|
-
});
|
|
287
|
-
|
|
288
|
-
switch (loadType) {
|
|
289
|
-
case "track_loaded":
|
|
290
|
-
return {
|
|
291
|
-
loadType: "track",
|
|
292
|
-
tracks: normalizeTracks(raw.tracks as unknown[]),
|
|
293
|
-
};
|
|
294
|
-
case "playlist_loaded":
|
|
295
|
-
return {
|
|
296
|
-
loadType: "playlist",
|
|
297
|
-
tracks: normalizeTracks(raw.tracks as unknown[]),
|
|
298
|
-
playlistInfo: raw.playlistInfo as SearchResult["playlistInfo"],
|
|
299
|
-
};
|
|
300
|
-
case "search_result":
|
|
301
|
-
return {
|
|
302
|
-
loadType: "search",
|
|
303
|
-
tracks: normalizeTracks(raw.tracks as unknown[]),
|
|
304
|
-
};
|
|
305
|
-
case "no_matches":
|
|
306
|
-
return { loadType: "empty", tracks: [] };
|
|
307
|
-
case "load_failed":
|
|
308
|
-
return {
|
|
309
|
-
loadType: "error",
|
|
310
|
-
tracks: [],
|
|
311
|
-
exception: raw.exception as SearchResult["exception"],
|
|
312
|
-
};
|
|
313
|
-
default:
|
|
314
|
-
return { loadType: "empty", tracks: [] };
|
|
315
|
-
}
|
|
300
|
+
public async loadTracks(identifier: string): Promise<SearchResult> {
|
|
301
|
+
return this.rest.loadTracks(identifier);
|
|
316
302
|
}
|
|
317
303
|
|
|
318
|
-
/** Get player endpoint path — differs between v3 and v4 */
|
|
319
304
|
public playerPath(guildId: string): string {
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
// v3 uses /players/{guildId}
|
|
324
|
-
return `/players/${guildId}`;
|
|
305
|
+
return this.version === 4
|
|
306
|
+
? `/sessions/${this.sessionId}/players/${guildId}`
|
|
307
|
+
: `/players/${guildId}`;
|
|
325
308
|
}
|
|
326
|
-
}
|
|
309
|
+
}
|