ryanlink 1.0.1

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 (60) hide show
  1. package/LICENSE +37 -0
  2. package/README.md +455 -0
  3. package/dist/index.d.mts +1335 -0
  4. package/dist/index.d.ts +1335 -0
  5. package/dist/index.js +4694 -0
  6. package/dist/index.js.map +1 -0
  7. package/dist/index.mjs +4604 -0
  8. package/dist/index.mjs.map +1 -0
  9. package/package.json +82 -0
  10. package/src/audio/AudioFilters.ts +316 -0
  11. package/src/audio/AudioQueue.ts +782 -0
  12. package/src/audio/AudioTrack.ts +242 -0
  13. package/src/audio/QueueController.ts +252 -0
  14. package/src/audio/TrackCollection.ts +138 -0
  15. package/src/audio/index.ts +9 -0
  16. package/src/config/defaults.ts +223 -0
  17. package/src/config/endpoints.ts +99 -0
  18. package/src/config/index.ts +9 -0
  19. package/src/config/patterns.ts +55 -0
  20. package/src/config/presets.ts +400 -0
  21. package/src/config/symbols.ts +31 -0
  22. package/src/core/PluginSystem.ts +50 -0
  23. package/src/core/RyanlinkPlayer.ts +403 -0
  24. package/src/core/index.ts +6 -0
  25. package/src/extensions/AutoplayExtension.ts +283 -0
  26. package/src/extensions/FairPlayExtension.ts +154 -0
  27. package/src/extensions/LyricsExtension.ts +187 -0
  28. package/src/extensions/PersistenceExtension.ts +182 -0
  29. package/src/extensions/SponsorBlockExtension.ts +81 -0
  30. package/src/extensions/index.ts +9 -0
  31. package/src/index.ts +19 -0
  32. package/src/lavalink/ConnectionPool.ts +326 -0
  33. package/src/lavalink/HttpClient.ts +316 -0
  34. package/src/lavalink/LavalinkConnection.ts +409 -0
  35. package/src/lavalink/index.ts +7 -0
  36. package/src/metadata.ts +88 -0
  37. package/src/types/api/Rest.ts +949 -0
  38. package/src/types/api/Websocket.ts +463 -0
  39. package/src/types/api/index.ts +6 -0
  40. package/src/types/audio/FilterManager.ts +29 -0
  41. package/src/types/audio/Queue.ts +4 -0
  42. package/src/types/audio/QueueManager.ts +30 -0
  43. package/src/types/audio/index.ts +7 -0
  44. package/src/types/common.ts +63 -0
  45. package/src/types/core/Player.ts +322 -0
  46. package/src/types/core/index.ts +5 -0
  47. package/src/types/index.ts +6 -0
  48. package/src/types/lavalink/Node.ts +173 -0
  49. package/src/types/lavalink/NodeManager.ts +34 -0
  50. package/src/types/lavalink/REST.ts +144 -0
  51. package/src/types/lavalink/index.ts +32 -0
  52. package/src/types/voice/VoiceManager.ts +176 -0
  53. package/src/types/voice/index.ts +5 -0
  54. package/src/utils/helpers.ts +169 -0
  55. package/src/utils/index.ts +6 -0
  56. package/src/utils/validators.ts +184 -0
  57. package/src/voice/RegionSelector.ts +184 -0
  58. package/src/voice/VoiceConnection.ts +451 -0
  59. package/src/voice/VoiceSession.ts +297 -0
  60. package/src/voice/index.ts +7 -0
@@ -0,0 +1,184 @@
1
+ import { OnPingUpdateSymbol } from "../config/symbols";
2
+ import type { PlayerState } from "../types";
3
+ import type { Player } from "../core/RyanlinkPlayer";
4
+
5
+ /**
6
+ * Voice node ping statistics
7
+ */
8
+ interface VoiceNodePingStats {
9
+ /**
10
+ * History of ping measurements (up to 5 samples)
11
+ */
12
+ history: number[];
13
+
14
+ /**
15
+ * Timestamp of last ping measurement
16
+ */
17
+ lastPingTime: number;
18
+ }
19
+
20
+ /**
21
+ * Represents a voice region with node ping tracking
22
+ * Used for latency-based node selection
23
+ */
24
+ export class VoiceRegion {
25
+ #pings = new Map<string, VoiceNodePingStats>();
26
+
27
+ readonly id: string;
28
+ readonly player: Player;
29
+
30
+ constructor(player: Player, regionId: string) {
31
+ if (player.voices.regions.has(regionId)) {
32
+ throw new Error("An identical voice region already exists");
33
+ }
34
+
35
+ this.id = regionId;
36
+ this.player = player;
37
+
38
+ // Make properties immutable
39
+ const immutable: PropertyDescriptor = {
40
+ writable: false,
41
+ configurable: false,
42
+ };
43
+
44
+ Object.defineProperties(this, {
45
+ id: immutable,
46
+ player: { ...immutable, enumerable: false },
47
+ } as PropertyDescriptorMap);
48
+ }
49
+
50
+ /**
51
+ * Check if all ready nodes have ping data for this region
52
+ * @returns `true` if all nodes are synced, `false` otherwise
53
+ */
54
+ inSync(): boolean {
55
+ return !Array.from(this.player.nodes.values()).some((n) => n.ready && !this.#pings.has(n.name));
56
+ }
57
+
58
+ /**
59
+ * Remove ping data for a node
60
+ * @param name Node name
61
+ * @returns `true` if data was removed, `false` if it didn't exist
62
+ */
63
+ forgetNode(name: string): boolean {
64
+ return this.#pings.delete(name);
65
+ }
66
+
67
+ /**
68
+ * Get the average ping for a node in this region
69
+ * @param name Node name
70
+ * @returns Average ping in milliseconds, or `null` if no data
71
+ */
72
+ getAveragePing(name: string): number | null {
73
+ const pings = this.#pings.get(name)?.history;
74
+ if (!pings?.length) {
75
+ return null;
76
+ }
77
+
78
+ return Math.round(pings.reduce((total, current) => total + current, 0) / pings.length);
79
+ }
80
+
81
+ /**
82
+ * Get all nodes with their average pings
83
+ * @returns Array of [nodeName, averagePing] tuples
84
+ */
85
+ getAllPings(): Array<[string, number | null]> {
86
+ return Array.from(this.player.nodes.values()).map((node) => [node.name, this.getAveragePing(node.name)]);
87
+ }
88
+
89
+ /**
90
+ * Get the most relevant node for this region
91
+ * Selects based on lowest average ping
92
+ * @returns The node with lowest ping, or first relevant node if no ping data
93
+ */
94
+ getRelevantNode() {
95
+ return this.player.nodes.relevant().sort((a, b) => {
96
+ const pingA = this.getAveragePing(a.name) ?? Number.MAX_SAFE_INTEGER;
97
+ const pingB = this.getAveragePing(b.name) ?? Number.MAX_SAFE_INTEGER;
98
+ return pingA - pingB;
99
+ })[0];
100
+ }
101
+
102
+ /**
103
+ * Internal method to update ping statistics
104
+ * Called by the voice manager when player state updates
105
+ * @param name Node name
106
+ * @param state Player state with ping information
107
+ * @internal
108
+ */
109
+ [OnPingUpdateSymbol](name: string, state: PlayerState): void {
110
+ // Only process if connected and has valid ping data
111
+ if (!state.connected) {
112
+ return;
113
+ }
114
+ if (state.ping <= 0 || state.time <= 0) {
115
+ return;
116
+ }
117
+
118
+ const pings = this.#pings.get(name);
119
+
120
+ // Initialize ping tracking for this node
121
+ if (!pings) {
122
+ this.#pings.set(name, {
123
+ history: [state.ping],
124
+ lastPingTime: state.time,
125
+ });
126
+ return;
127
+ }
128
+
129
+ // Only update if enough time has passed (12 seconds)
130
+ if (state.time - pings.lastPingTime < 12_000) {
131
+ return;
132
+ }
133
+
134
+ pings.lastPingTime = state.time;
135
+ pings.history.push(state.ping);
136
+
137
+ // Keep only last 5 samples
138
+ if (pings.history.length > 5) {
139
+ pings.history.shift();
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Clear all ping data for this region
145
+ */
146
+ clear(): void {
147
+ this.#pings.clear();
148
+ }
149
+
150
+ /**
151
+ * Get the number of nodes with ping data
152
+ */
153
+ get nodeCount(): number {
154
+ return this.#pings.size;
155
+ }
156
+
157
+ /**
158
+ * String representation of the voice region
159
+ */
160
+ toString(): string {
161
+ return `VoiceRegion<${this.id}>`;
162
+ }
163
+
164
+ /**
165
+ * JSON representation of the voice region
166
+ */
167
+ toJSON(): {
168
+ id: string;
169
+ inSync: boolean;
170
+ nodeCount: number;
171
+ pings: Array<{ node: string; averagePing: number | null; samples: number }>;
172
+ } {
173
+ return {
174
+ id: this.id,
175
+ inSync: this.inSync(),
176
+ nodeCount: this.nodeCount,
177
+ pings: Array.from(this.#pings.entries()).map(([node, stats]) => ({
178
+ node,
179
+ averagePing: this.getAveragePing(node),
180
+ samples: stats.history.length,
181
+ })),
182
+ };
183
+ }
184
+ }
@@ -0,0 +1,451 @@
1
+ import { setTimeout } from "node:timers/promises";
2
+ import { SnowflakeRegex, VoiceRegionIdRegex } from "../config";
3
+ import { LookupSymbol, OnVoiceCloseSymbol, UpdateSymbol } from "../config/symbols";
4
+ import { isString, noop } from "../utils";
5
+ import { VoiceRegion } from "./RegionSelector";
6
+ import { VoiceState } from "./VoiceSession";
7
+ import {
8
+ VoiceCloseCodes,
9
+ type BotReadyPayload,
10
+ type BotVoiceState,
11
+ type ConnectOptions,
12
+ type CreateQueueOptions,
13
+ type DiscordDispatchPayload,
14
+ type VoiceServerUpdatePayload,
15
+ type VoiceStateUpdatePayload,
16
+ } from "../types";
17
+ import type { Player } from "../core/RyanlinkPlayer";
18
+
19
+ /**
20
+ * Join request with promise resolvers
21
+ */
22
+ interface JoinRequest extends Pick<CreateQueueOptions, "context" | "node" | "voiceId"> {
23
+ promise: Promise<VoiceState>;
24
+ resolve: (value: VoiceState | PromiseLike<VoiceState>) => void;
25
+ reject: (reason?: unknown) => void;
26
+ config?: Pick<CreateQueueOptions, "filters" | "volume">;
27
+ }
28
+
29
+ /**
30
+ * Manages voice connections for all guilds
31
+ * Handles Discord voice events and Lavalink voice state
32
+ */
33
+ export class VoiceManager implements Partial<Map<string, VoiceState>> {
34
+ #cache = new Map<string, BotVoiceState>();
35
+ #voices = new Map<string, VoiceState>();
36
+
37
+ #joins = new Map<string, JoinRequest>();
38
+ #destroys = new Map<string, Promise<void>>();
39
+
40
+ readonly regions = new Map<string, VoiceRegion>();
41
+ readonly player: Player;
42
+
43
+ constructor(player: Player) {
44
+ if (player.voices === undefined) {
45
+ this.player = player;
46
+ } else {
47
+ throw new Error("Manager already exists for this Player");
48
+ }
49
+
50
+ const immutable: PropertyDescriptor = {
51
+ writable: false,
52
+ configurable: false,
53
+ };
54
+
55
+ Object.defineProperties(this, {
56
+ regions: immutable,
57
+ player: { ...immutable, enumerable: false },
58
+ } as PropertyDescriptorMap);
59
+ }
60
+
61
+ /**
62
+ * Number of voice connections
63
+ */
64
+ get size(): number {
65
+ return this.#voices.size;
66
+ }
67
+
68
+ /**
69
+ * Get voice state for a guild
70
+ */
71
+ get(guildId: string): VoiceState | undefined {
72
+ return this.#voices.get(guildId);
73
+ }
74
+
75
+ /**
76
+ * Check if voice state exists
77
+ */
78
+ has(guildId: string): boolean {
79
+ return this.#voices.has(guildId);
80
+ }
81
+
82
+ /**
83
+ * Get all connected guild IDs
84
+ */
85
+ keys() {
86
+ return this.#voices.keys();
87
+ }
88
+
89
+ /**
90
+ * Get all voice connections
91
+ */
92
+ values() {
93
+ return this.#voices.values();
94
+ }
95
+
96
+ /**
97
+ * Get all voice connections as entries
98
+ */
99
+ entries() {
100
+ return this.#voices.entries();
101
+ }
102
+
103
+ /**
104
+ * Destroy a voice connection
105
+ * @param guildId Guild ID
106
+ * @param reason Reason for destruction
107
+ */
108
+ async destroy(guildId: string, reason = "destroyed"): Promise<void> {
109
+ if (this.player.queues.has(guildId)) {
110
+ return this.player.queues.destroy(guildId, reason);
111
+ }
112
+ if (this.#destroys.has(guildId)) {
113
+ return this.#destroys.get(guildId) ?? Promise.resolve();
114
+ }
115
+
116
+ const voice = this.#voices.get(guildId);
117
+ if (!voice) {
118
+ return;
119
+ }
120
+
121
+ let resolve!: () => void;
122
+ let reject!: (reason?: unknown) => void;
123
+ const promise = new Promise<void>((res, rej) => {
124
+ resolve = res;
125
+ reject = rej;
126
+ });
127
+ const resolver = { promise, resolve, reject };
128
+ this.#destroys.set(guildId, resolver.promise);
129
+
130
+ if (this[LookupSymbol](guildId)?.connected) {
131
+ await voice.disconnect().catch(noop);
132
+ }
133
+
134
+ this.#cache.delete(guildId);
135
+ this.#voices.delete(guildId);
136
+
137
+ this.player.emit("voiceDestroy", voice, reason);
138
+
139
+ resolver.resolve();
140
+ this.#destroys.delete(guildId);
141
+ }
142
+
143
+ /**
144
+ * Connect to a voice channel
145
+ * @param guildId Guild ID
146
+ * @param voiceId Voice channel ID
147
+ * @param options Connection options
148
+ */
149
+ async connect(guildId: string, voiceId: string, options?: ConnectOptions): Promise<VoiceState> {
150
+ if (!isString(guildId, SnowflakeRegex)) {
151
+ throw new Error("Guild Id is not a valid Discord Id");
152
+ }
153
+ if (!isString(voiceId, SnowflakeRegex)) {
154
+ throw new Error("Voice Id is not a valid Discord Id");
155
+ }
156
+
157
+ const currentRequest = this.#joins.get(guildId);
158
+ if (currentRequest) {
159
+ if (currentRequest.voiceId === voiceId) {
160
+ return currentRequest.promise;
161
+ }
162
+ currentRequest.reject(new Error("Connection request was replaced"));
163
+ }
164
+ this.#joins.delete(guildId);
165
+
166
+ let resolve!: (value: VoiceState | PromiseLike<VoiceState>) => void;
167
+ let reject!: (reason?: unknown) => void;
168
+ const promise = new Promise<VoiceState>((res, rej) => {
169
+ resolve = res;
170
+ reject = rej;
171
+ });
172
+ const request: JoinRequest = {
173
+ promise,
174
+ resolve,
175
+ reject,
176
+ voiceId,
177
+ node: options?.node,
178
+ context: options?.context,
179
+ config: options ? { filters: options.filters, volume: options.volume } : undefined,
180
+ };
181
+
182
+ this.#joins.set(guildId, request);
183
+
184
+ // Send voice state update to Discord
185
+ await this.player.options.forwardVoiceUpdate(guildId, {
186
+ op: 4,
187
+ d: {
188
+ guild_id: guildId,
189
+ channel_id: voiceId,
190
+ self_deaf: false,
191
+ self_mute: false,
192
+ },
193
+ });
194
+
195
+ // Wait for voice connection with timeout
196
+ const timeout = setTimeout(15_000, undefined, { ref: false });
197
+ const result = await Promise.race([request.promise, timeout]);
198
+
199
+ if (result === undefined) {
200
+ this.#joins.delete(guildId);
201
+ throw new Error("Voice connection timed out");
202
+ }
203
+
204
+ return result;
205
+ }
206
+
207
+ /**
208
+ * Disconnect from voice channel
209
+ * @param guildId Guild ID
210
+ */
211
+ async disconnect(guildId: string): Promise<void> {
212
+ const state = this.#cache.get(guildId);
213
+ if (!state) {
214
+ return;
215
+ }
216
+
217
+ await this.player.options.forwardVoiceUpdate(guildId, {
218
+ op: 4,
219
+ d: {
220
+ guild_id: guildId,
221
+ channel_id: null,
222
+ self_deaf: false,
223
+ self_mute: false,
224
+ },
225
+ });
226
+
227
+ state.channel_id = "";
228
+ state.connected = false;
229
+ }
230
+
231
+ /**
232
+ * Handle Discord dispatch events
233
+ * @param payload Discord gateway payload
234
+ */
235
+ handleDispatch(payload: DiscordDispatchPayload): void {
236
+ if (payload.t === "READY") {
237
+ this.#handleReady(payload);
238
+ } else if (payload.t === "VOICE_STATE_UPDATE") {
239
+ this.#handleVoiceStateUpdate(payload);
240
+ } else if (payload.t === "VOICE_SERVER_UPDATE") {
241
+ this.#handleVoiceServerUpdate(payload);
242
+ }
243
+ }
244
+
245
+ /**
246
+ * Internal lookup for voice state
247
+ * @internal
248
+ */
249
+ [LookupSymbol](guildId: string): BotVoiceState | undefined {
250
+ return this.#cache.get(guildId);
251
+ }
252
+
253
+ /**
254
+ * Internal update for voice state
255
+ * @internal
256
+ */
257
+ [UpdateSymbol](guildId: string, partial: Partial<BotVoiceState>): void {
258
+ const state = this.#cache.get(guildId);
259
+ if (state) {
260
+ Object.assign(state, partial);
261
+ }
262
+ }
263
+
264
+ /**
265
+ * Internal voice close handler
266
+ * @internal
267
+ */
268
+ [OnVoiceCloseSymbol](guildId: string, code: number, reason: string, byRemote: boolean): void {
269
+ const voice = this.#voices.get(guildId);
270
+ if (!voice) {
271
+ return;
272
+ }
273
+
274
+ this.player.emit("voiceClose", voice, code, reason, byRemote);
275
+
276
+ // Check if we should destroy the connection
277
+ const shouldDestroy =
278
+ (code as VoiceCloseCodes) === VoiceCloseCodes.Disconnected ||
279
+ (code as VoiceCloseCodes) === VoiceCloseCodes.DisconnectedRateLimited ||
280
+ (code as VoiceCloseCodes) === VoiceCloseCodes.DisconnectedCallTerminated;
281
+
282
+ if (shouldDestroy) {
283
+ this.destroy(guildId, `Voice closed: ${reason} (${code})`).catch(noop);
284
+ }
285
+ }
286
+
287
+ /**
288
+ * Handle READY event
289
+ */
290
+ #handleReady(_payload: BotReadyPayload): void {
291
+ // Clear all voice states on ready
292
+ this.#cache.clear();
293
+ this.#voices.clear();
294
+ this.#joins.clear();
295
+ }
296
+
297
+ /**
298
+ * Handle VOICE_STATE_UPDATE event
299
+ */
300
+ #handleVoiceStateUpdate(payload: VoiceStateUpdatePayload): void {
301
+ const { d: data } = payload;
302
+ const guildId = data.guild_id;
303
+
304
+ if (!guildId || data.user_id !== this.player.clientId) {
305
+ return;
306
+ }
307
+
308
+ let state = this.#cache.get(guildId);
309
+
310
+ // User left voice channel
311
+ if (data.channel_id === null) {
312
+ if (state) {
313
+ state.channel_id = "";
314
+ state.connected = false;
315
+ }
316
+ return;
317
+ }
318
+
319
+ // Create or update state
320
+ if (!state) {
321
+ state = {
322
+ channel_id: data.channel_id,
323
+ session_id: data.session_id,
324
+ deaf: data.deaf,
325
+ mute: data.mute,
326
+ self_deaf: data.self_deaf,
327
+ self_mute: data.self_mute,
328
+ suppress: data.suppress,
329
+ token: "",
330
+ endpoint: "",
331
+ connected: false,
332
+ node_session_id: "",
333
+ reconnecting: false,
334
+ region_id: "",
335
+ };
336
+ this.#cache.set(guildId, state);
337
+ } else {
338
+ state.channel_id = data.channel_id;
339
+ state.session_id = data.session_id;
340
+ state.deaf = data.deaf;
341
+ state.mute = data.mute;
342
+ state.self_deaf = data.self_deaf;
343
+ state.self_mute = data.self_mute;
344
+ state.suppress = data.suppress;
345
+ }
346
+
347
+ void this.#tryConnect(guildId);
348
+ }
349
+
350
+ /**
351
+ * Handle VOICE_SERVER_UPDATE event
352
+ */
353
+ #handleVoiceServerUpdate(payload: VoiceServerUpdatePayload): void {
354
+ const { d: data } = payload;
355
+ const guildId = data.guild_id;
356
+
357
+ let state = this.#cache.get(guildId);
358
+
359
+ if (!state) {
360
+ state = {
361
+ channel_id: "",
362
+ session_id: "",
363
+ deaf: false,
364
+ mute: false,
365
+ self_deaf: false,
366
+ self_mute: false,
367
+ suppress: false,
368
+ token: data.token,
369
+ endpoint: data.endpoint ?? "",
370
+ connected: false,
371
+ node_session_id: "",
372
+ reconnecting: false,
373
+ region_id: "",
374
+ };
375
+ this.#cache.set(guildId, state);
376
+ } else {
377
+ state.token = data.token;
378
+ state.endpoint = data.endpoint ?? "";
379
+ }
380
+
381
+ // Extract region from endpoint
382
+ if (state.endpoint) {
383
+ const match = state.endpoint.match(VoiceRegionIdRegex);
384
+ if (match?.[1]) {
385
+ state.region_id = match[1];
386
+
387
+ // Create or get voice region
388
+ if (!this.regions.has(state.region_id)) {
389
+ this.regions.set(state.region_id, new VoiceRegion(this.player, state.region_id));
390
+ }
391
+ }
392
+ }
393
+
394
+ void this.#tryConnect(guildId);
395
+ }
396
+
397
+ /**
398
+ * Try to establish voice connection
399
+ */
400
+ async #tryConnect(guildId: string): Promise<void> {
401
+ const state = this.#cache.get(guildId);
402
+ const request = this.#joins.get(guildId);
403
+
404
+ if (!state || !request) {
405
+ return;
406
+ }
407
+
408
+ // Check if we have all required data
409
+ if (!state.channel_id || !state.session_id || !state.token || !state.endpoint) {
410
+ return;
411
+ }
412
+
413
+ this.#joins.delete(guildId);
414
+
415
+ try {
416
+ // Select node
417
+ const nodeName =
418
+ request.node ??
419
+ (state.region_id && this.regions.get(state.region_id)?.getRelevantNode()?.name) ??
420
+ this.player.nodes.relevant()[0]?.name;
421
+
422
+ if (!nodeName) {
423
+ throw new Error("No nodes available");
424
+ }
425
+
426
+ // Create queue if needed
427
+ let queue = this.player.queues.get(guildId);
428
+ if (!queue) {
429
+ queue = await this.player.queues.create({
430
+ guildId,
431
+ voiceId: state.channel_id,
432
+ node: nodeName,
433
+ context: request.context,
434
+ ...request.config,
435
+ });
436
+ }
437
+
438
+ // Create voice state
439
+ const voice = new VoiceState(this.player, nodeName, guildId);
440
+ this.#voices.set(guildId, voice);
441
+
442
+ state.connected = true;
443
+ state.node_session_id = voice.node.sessionId ?? "";
444
+
445
+ this.player.emit("voiceConnect", voice);
446
+ request.resolve(voice);
447
+ } catch (error) {
448
+ request.reject(error instanceof Error ? error : new Error(String(error)));
449
+ }
450
+ }
451
+ }