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.
Files changed (114) hide show
  1. package/.cache/replit/env/latest +88 -0
  2. package/.cache/replit/env/latest.json +1 -0
  3. package/.cache/replit/modules/python-3.11.res +1 -0
  4. package/.cache/replit/nix/dotreplitenv.json +1 -1
  5. package/.cache/replit/toolchain.json +1 -1
  6. package/.local/state/workflow-logs/KRgHXizaECjWI5nWtS7Dj/configure_your_app.packager.installForAll.0 +1 -0
  7. package/.local/state/workflow-logs/KRgHXizaECjWI5nWtS7Dj/configure_your_app.shell.exec.1 +1 -0
  8. package/.local/state/workflow-logs/jVavLOnv1MqxUvxhMmqER/configure_your_app.packager.installForAll.0 +1 -0
  9. package/.local/state/workflow-logs/jVavLOnv1MqxUvxhMmqER/configure_your_app.shell.exec.1 +1 -0
  10. package/.replit +4 -1
  11. package/.upm/store.json +1 -1
  12. package/dist/ConnectionPool.d.ts +34 -0
  13. package/dist/ConnectionPool.d.ts.map +1 -0
  14. package/dist/ConnectionPool.js +124 -0
  15. package/dist/ConnectionPool.js.map +1 -0
  16. package/dist/Constants.d.ts +66 -0
  17. package/dist/Constants.d.ts.map +1 -0
  18. package/dist/Constants.js +101 -0
  19. package/dist/Constants.js.map +1 -0
  20. package/dist/Node.d.ts +26 -10
  21. package/dist/Node.d.ts.map +1 -1
  22. package/dist/Node.js +203 -81
  23. package/dist/Node.js.map +1 -1
  24. package/dist/Player.d.ts +30 -29
  25. package/dist/Player.d.ts.map +1 -1
  26. package/dist/Player.js +178 -97
  27. package/dist/Player.js.map +1 -1
  28. package/dist/Plugin.d.ts +21 -0
  29. package/dist/Plugin.d.ts.map +1 -0
  30. package/dist/Plugin.js +19 -0
  31. package/dist/Plugin.js.map +1 -0
  32. package/dist/Queue.d.ts +10 -16
  33. package/dist/Queue.d.ts.map +1 -1
  34. package/dist/Queue.js +39 -32
  35. package/dist/Queue.js.map +1 -1
  36. package/dist/Resolver.d.ts +4 -21
  37. package/dist/Resolver.d.ts.map +1 -1
  38. package/dist/Resolver.js +28 -66
  39. package/dist/Resolver.js.map +1 -1
  40. package/dist/Rest.d.ts +24 -0
  41. package/dist/Rest.d.ts.map +1 -0
  42. package/dist/Rest.js +104 -0
  43. package/dist/Rest.js.map +1 -0
  44. package/dist/TrackCache.d.ts +21 -0
  45. package/dist/TrackCache.d.ts.map +1 -0
  46. package/dist/TrackCache.js +57 -0
  47. package/dist/TrackCache.js.map +1 -0
  48. package/dist/WsQueue.d.ts +20 -0
  49. package/dist/WsQueue.d.ts.map +1 -0
  50. package/dist/WsQueue.js +74 -0
  51. package/dist/WsQueue.js.map +1 -0
  52. package/dist/Yukimu.d.ts +33 -32
  53. package/dist/Yukimu.d.ts.map +1 -1
  54. package/dist/Yukimu.js +133 -65
  55. package/dist/Yukimu.js.map +1 -1
  56. package/dist/connector/Connector.d.ts +8 -0
  57. package/dist/connector/Connector.d.ts.map +1 -0
  58. package/dist/connector/Connector.js +11 -0
  59. package/dist/connector/Connector.js.map +1 -0
  60. package/dist/connector/DiscordJS.d.ts +25 -0
  61. package/dist/connector/DiscordJS.d.ts.map +1 -0
  62. package/dist/connector/DiscordJS.js +27 -0
  63. package/dist/connector/DiscordJS.js.map +1 -0
  64. package/dist/connector/Eris.d.ts +23 -0
  65. package/dist/connector/Eris.d.ts.map +1 -0
  66. package/dist/connector/Eris.js +30 -0
  67. package/dist/connector/Eris.js.map +1 -0
  68. package/dist/connector/Oceanic.d.ts +20 -0
  69. package/dist/connector/Oceanic.d.ts.map +1 -0
  70. package/dist/connector/Oceanic.js +27 -0
  71. package/dist/connector/Oceanic.js.map +1 -0
  72. package/dist/errors/YukimuError.d.ts +15 -0
  73. package/dist/errors/YukimuError.d.ts.map +1 -0
  74. package/dist/errors/YukimuError.js +35 -0
  75. package/dist/errors/YukimuError.js.map +1 -0
  76. package/dist/index.d.ts +14 -1
  77. package/dist/index.d.ts.map +1 -1
  78. package/dist/index.js +34 -2
  79. package/dist/index.js.map +1 -1
  80. package/dist/plugins/AutoResume.d.ts +21 -0
  81. package/dist/plugins/AutoResume.d.ts.map +1 -0
  82. package/dist/plugins/AutoResume.js +52 -0
  83. package/dist/plugins/AutoResume.js.map +1 -0
  84. package/dist/plugins/PlayerMoved.d.ts +7 -0
  85. package/dist/plugins/PlayerMoved.d.ts.map +1 -0
  86. package/dist/plugins/PlayerMoved.js +30 -0
  87. package/dist/plugins/PlayerMoved.js.map +1 -0
  88. package/dist/types.d.ts +92 -38
  89. package/dist/types.d.ts.map +1 -1
  90. package/dist/types.js +0 -12
  91. package/dist/types.js.map +1 -1
  92. package/package.json +1 -1
  93. package/src/ConnectionPool.ts +131 -0
  94. package/src/Constants.ts +101 -0
  95. package/src/Node.ts +157 -174
  96. package/src/Player.ts +200 -108
  97. package/src/Plugin.ts +23 -0
  98. package/src/Queue.ts +43 -34
  99. package/src/Resolver.ts +29 -77
  100. package/src/Rest.ts +121 -0
  101. package/src/TrackCache.ts +69 -0
  102. package/src/WsQueue.ts +78 -0
  103. package/src/Yukimu.ts +156 -85
  104. package/src/connector/Connector.ts +13 -0
  105. package/src/connector/DiscordJS.ts +33 -0
  106. package/src/connector/Eris.ts +35 -0
  107. package/src/connector/Oceanic.ts +32 -0
  108. package/src/errors/YukimuError.ts +33 -0
  109. package/src/index.ts +41 -1
  110. package/src/plugins/AutoResume.ts +61 -0
  111. package/src/plugins/PlayerMoved.ts +26 -0
  112. package/src/types.ts +50 -83
  113. package/tsconfig.json +4 -2
  114. package/README.md +0 -152
@@ -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 { NodeOptions, NodeStats, Track, SearchResult, LavalinkException } from "./types";
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 connected: boolean = false;
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
- /** Lavalink version this node runs (3 or 4) */
14
- public readonly version: 3 | 4;
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
- secure: false,
23
- retries: 5,
24
- version: 4,
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
- // ─── URL Helpers ──────────────────────────────────────────────────
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
- /** REST base URL */
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.0.0",
56
+ "Client-Name": "Yukimu/1.2.0",
55
57
  };
56
- // v3 uses Num-Shards header
57
- if (this.version === 3) {
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
- // ─── Connection ───────────────────────────────────────────────────
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.ws = new WebSocket(this.wsUrl, { headers: this.headers });
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", (data) => this.onMessage(data));
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.connected = false;
93
+ this.state = State.DISCONNECTED;
94
+ if (!reconnect) this.sessionId = null;
80
95
  }
81
96
 
82
- // ─── WebSocket Events ─────────────────────────────────────────────
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 (Lavalink v${this.version})`);
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.sessionId = "v3-no-session";
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.connected = false;
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
- // ─── Reconnect ────────────────────────────────────────────────────
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 maxRetries = this.options.retries ?? 5;
158
- if (this.reconnectAttempts >= maxRetries) {
159
- console.error(`[Yukimu] Node "${this.options.name}" max reconnect attempts reached`);
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 ** this.reconnectAttempts, 30000);
195
+ const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30000);
163
196
  this.reconnectAttempts++;
164
- console.log(`[Yukimu] Reconnecting node "${this.options.name}" in ${delay}ms (attempt ${this.reconnectAttempts})`);
165
- this.reconnectTimeout = setTimeout(() => this.connect(), delay);
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
- // ─── Event Handlers ───────────────────────────────────────────────
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; connected?: boolean; ping?: number };
174
- player.position = state.position ?? 0;
175
- player.ping = state.ping ?? -1;
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, payload.reason as string);
204
- if (payload.reason !== "replaced" && payload.reason !== "stopped" && payload.reason !== "REPLACED" && payload.reason !== "STOPPED") {
205
- player.queue.next();
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
- this.manager.emit("queueEnd", player);
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
- // ─── REST API ─────────────────────────────────────────────────────
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
- /** Load tracks works for both v3 and v4 */
255
- public async loadTracks(identifier: string): Promise<SearchResult> {
256
- const raw = await this.request<Record<string, unknown>>(
257
- "GET",
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
- * Convert v3 loadtracks response to v4 format so the rest
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
- if (this.version === 4) {
321
- return `/sessions/${this.sessionId}/players/${guildId}`;
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
+ }