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,242 @@
1
+ import { formatDuration, isNumber, isRecord, isString } from "../utils";
2
+ import type { APITrack, CommonPluginInfo, CommonUserData, JsonObject } from "../types";
3
+
4
+ /**
5
+ * Represents a music track with metadata and playback information
6
+ */
7
+ export class Track<UserData extends JsonObject = CommonUserData, PluginInfo extends JsonObject = CommonPluginInfo> {
8
+ /**
9
+ * Unique identifier of the track
10
+ */
11
+ id: string;
12
+
13
+ /**
14
+ * Title of the track
15
+ */
16
+ title = "Unknown Track";
17
+
18
+ /**
19
+ * Author/artist of the track
20
+ */
21
+ author = "Unknown Author";
22
+
23
+ /**
24
+ * Whether the track is a live stream
25
+ */
26
+ isLive = false;
27
+
28
+ /**
29
+ * Whether the track is seekable
30
+ */
31
+ isSeekable = false;
32
+
33
+ /**
34
+ * Duration of the track in milliseconds
35
+ */
36
+ duration = 0;
37
+
38
+ /**
39
+ * Formatted duration string (hh:mm:ss or mm:ss)
40
+ */
41
+ formattedDuration = "00:00";
42
+
43
+ /**
44
+ * Uniform Resource Identifier of the track
45
+ */
46
+ uri: string | null = null;
47
+
48
+ /**
49
+ * International Standard Recording Code
50
+ */
51
+ isrc: string | null = null;
52
+
53
+ /**
54
+ * URL of the track (validated URI)
55
+ */
56
+ url: string | null = null;
57
+
58
+ /**
59
+ * Artwork/thumbnail URL
60
+ */
61
+ artworkUrl: string | null = null;
62
+
63
+ /**
64
+ * Custom user data attached to the track
65
+ */
66
+ userData = {} as UserData;
67
+
68
+ /**
69
+ * Additional info from plugins
70
+ */
71
+ pluginInfo = {} as PluginInfo;
72
+
73
+ /**
74
+ * Encoded string representation (Lavalink format)
75
+ */
76
+ encoded: string;
77
+
78
+ /**
79
+ * Source name (youtube, spotify, soundcloud, etc.)
80
+ */
81
+ sourceName = "unknown";
82
+
83
+ get identifier(): string {
84
+ return this.id;
85
+ }
86
+
87
+ get stream(): boolean {
88
+ return this.isLive;
89
+ }
90
+
91
+ get seekable(): boolean {
92
+ return this.isSeekable;
93
+ }
94
+
95
+ get durationFormatted(): string {
96
+ return this.formattedDuration;
97
+ }
98
+
99
+ get source(): string {
100
+ return this.sourceName;
101
+ }
102
+
103
+ get thumbnail(): string | null {
104
+ return this.artworkUrl;
105
+ }
106
+
107
+ get info() {
108
+ return {
109
+ identifier: this.id,
110
+ position: 0,
111
+ title: this.title,
112
+ author: this.author,
113
+ length: this.duration,
114
+ isStream: this.isLive,
115
+ isSeekable: this.isSeekable,
116
+ uri: this.uri,
117
+ isrc: this.isrc,
118
+ artworkUrl: this.artworkUrl,
119
+ sourceName: this.sourceName,
120
+ };
121
+ }
122
+
123
+ constructor(data: APITrack<UserData, PluginInfo>) {
124
+ if (!isRecord(data)) {
125
+ throw new Error("Track data must be an object");
126
+ }
127
+ if (!isRecord(data.info)) {
128
+ throw new Error("Track info is not an object");
129
+ }
130
+
131
+ // Validate and set identifier
132
+ if (isString(data.info.identifier, "non-empty")) {
133
+ this.id = data.info.identifier;
134
+ } else {
135
+ throw new Error("Track does not have an identifier");
136
+ }
137
+
138
+ // Validate and set encoded data
139
+ if (isString(data.encoded, "non-empty")) {
140
+ this.encoded = data.encoded;
141
+ } else {
142
+ throw new Error("Track does not have an encoded data string");
143
+ }
144
+
145
+ // Set title and author
146
+ if (isString(data.info.title, "non-empty")) {
147
+ this.title = data.info.title;
148
+ }
149
+ if (isString(data.info.author, "non-empty")) {
150
+ this.author = data.info.author;
151
+ }
152
+
153
+ // Set stream and seekable flags
154
+ if (data.info.isStream) {
155
+ this.isLive = true;
156
+ }
157
+ if (data.info.isSeekable) {
158
+ this.isSeekable = true;
159
+ }
160
+
161
+ // Set duration
162
+ if (this.isLive) {
163
+ this.duration = Number.POSITIVE_INFINITY;
164
+ this.formattedDuration = "Live";
165
+ } else if (isNumber(data.info.length, "whole")) {
166
+ this.duration = data.info.length;
167
+ this.formattedDuration = formatDuration(this.duration);
168
+ }
169
+
170
+ // Set URIs
171
+ if (isString(data.info.uri, "non-empty")) {
172
+ this.uri = data.info.uri;
173
+ }
174
+ if (isString(data.info.isrc, "non-empty")) {
175
+ this.isrc = data.info.isrc;
176
+ }
177
+
178
+ // Validate URL
179
+ if (isString(this.uri, "url")) {
180
+ this.url = this.uri;
181
+ }
182
+ if (isString(data.info.artworkUrl, "url")) {
183
+ this.artworkUrl = data.info.artworkUrl;
184
+ }
185
+
186
+ // Set user data and plugin info
187
+ if (isRecord(data.userData, "non-empty")) {
188
+ this.userData = data.userData;
189
+ }
190
+ if (isRecord(data.pluginInfo, "non-empty")) {
191
+ this.pluginInfo = data.pluginInfo;
192
+ }
193
+
194
+ // Set source name
195
+ if (isString(data.info.sourceName, "non-empty")) {
196
+ this.sourceName = data.info.sourceName;
197
+ }
198
+ }
199
+
200
+ /**
201
+ * String representation of the track
202
+ */
203
+ toString(): string {
204
+ return this.title;
205
+ }
206
+
207
+ /**
208
+ * JSON representation of the track
209
+ */
210
+ toJSON(): Record<string, unknown> {
211
+ return {
212
+ identifier: this.id,
213
+ title: this.title,
214
+ author: this.author,
215
+ duration: this.duration,
216
+ uri: this.uri,
217
+ thumbnail: this.artworkUrl,
218
+ source: this.sourceName,
219
+ isSeekable: this.isSeekable,
220
+ isStream: this.isLive,
221
+ encoded: this.encoded,
222
+ userData: this.userData,
223
+ pluginInfo: this.pluginInfo,
224
+ };
225
+ }
226
+
227
+ /**
228
+ * Create a clone of the track
229
+ */
230
+ clone(): Track<UserData, PluginInfo> {
231
+ return new Track({
232
+ encoded: this.encoded,
233
+ info: {
234
+ ...this.info,
235
+ length: this.duration,
236
+ isStream: this.isLive,
237
+ },
238
+ userData: this.userData,
239
+ pluginInfo: this.pluginInfo,
240
+ } as APITrack<UserData, PluginInfo>);
241
+ }
242
+ }
@@ -0,0 +1,252 @@
1
+ import { EventEmitter } from "node:events";
2
+ import { Queue } from "../audio/AudioQueue";
3
+ import { Track } from "./AudioTrack";
4
+ import {
5
+ LookupSymbol,
6
+ OnStateUpdateSymbol,
7
+ OnEventUpdateSymbol,
8
+ OnPingUpdateSymbol,
9
+ OnVoiceCloseSymbol,
10
+ } from "../config/symbols";
11
+ import { noop } from "../utils";
12
+ import { EventType, TrackEndReason } from "../types/api/Websocket";
13
+ import type { Player } from "../core/RyanlinkPlayer";
14
+ import type {
15
+ CreateQueueOptions,
16
+ QueueContext,
17
+ APIPlayer,
18
+ PlayerState,
19
+ EventPayload,
20
+ TrackStartEventPayload,
21
+ TrackEndEventPayload,
22
+ TrackExceptionEventPayload,
23
+ TrackStuckEventPayload,
24
+ WebSocketClosedEventPayload,
25
+ } from "../types";
26
+
27
+ /**
28
+ * Manages all queues across guilds
29
+ * Handles queue creation, destruction, synchronization, and event forwarding
30
+ */
31
+ export class QueueManager<Context extends Record<string, unknown> = QueueContext> extends EventEmitter {
32
+ #player: Player;
33
+ #queues = new Map<string, Queue<Context>>();
34
+ #players = new Map<string, APIPlayer>();
35
+
36
+ constructor(player: Player) {
37
+ super({ captureRejections: false });
38
+ this.#player = player;
39
+ }
40
+
41
+ get(guildId: string): Queue<Context> | undefined {
42
+ return this.#queues.get(guildId);
43
+ }
44
+
45
+ has(guildId: string): boolean {
46
+ return this.#queues.has(guildId);
47
+ }
48
+
49
+ get all(): Queue<Context>[] {
50
+ return Array.from(this.#queues.values());
51
+ }
52
+
53
+ get size(): number {
54
+ return this.#queues.size;
55
+ }
56
+
57
+ keys(): IterableIterator<string> {
58
+ return this.#queues.keys();
59
+ }
60
+
61
+ values(): IterableIterator<Queue<Context>> {
62
+ return this.#queues.values();
63
+ }
64
+
65
+ entries(): IterableIterator<[string, Queue<Context>]> {
66
+ return this.#queues.entries();
67
+ }
68
+
69
+ async create(options: CreateQueueOptions<Context>): Promise<Queue<Context>> {
70
+ if (this.#queues.has(options.guildId)) {
71
+ throw new Error(`Queue already exists for guild '${options.guildId}'`);
72
+ }
73
+
74
+ let voice = this.#player.voices.get(options.guildId);
75
+ if (!voice) {
76
+ voice = await this.#player.voices.connect(options.guildId, options.voiceId, {
77
+ node: options.node,
78
+ context: options.context,
79
+ filters: options.filters,
80
+ volume: options.volume,
81
+ });
82
+ }
83
+
84
+ const playerState: APIPlayer = {
85
+ guildId: options.guildId,
86
+ track: null,
87
+ volume: options.volume ?? 100,
88
+ paused: false,
89
+ state: {
90
+ time: Date.now(),
91
+ position: 0,
92
+ connected: false,
93
+ ping: -1,
94
+ },
95
+ voice: {
96
+ token: "",
97
+ endpoint: "",
98
+ sessionId: "",
99
+ channelId: options.voiceId,
100
+ },
101
+ filters: options.filters ?? {},
102
+ };
103
+
104
+ this.#players.set(options.guildId, playerState);
105
+
106
+ const queue = new Queue(this.#player, options.guildId, options.context);
107
+ this.#queues.set(options.guildId, queue);
108
+
109
+ this.#player.emit("queueCreate", queue);
110
+
111
+ return queue;
112
+ }
113
+
114
+ async destroy(guildId: string, reason = "destroyed"): Promise<void> {
115
+ const queue = this.#queues.get(guildId);
116
+ if (!queue) {
117
+ return;
118
+ }
119
+
120
+ const voice = this.#player.voices.get(guildId);
121
+ if (voice?.node) {
122
+ await voice.node.rest.destroyPlayer(guildId).catch(noop);
123
+ }
124
+
125
+ await this.#player.voices.destroy(guildId, reason);
126
+
127
+ this.#queues.delete(guildId);
128
+ this.#players.delete(guildId);
129
+
130
+ this.#player.emit("queueDestroy", queue, reason);
131
+ }
132
+
133
+ async relocate(guildId: string, nodeName: string): Promise<void> {
134
+ const queue = this.#queues.get(guildId);
135
+ if (!queue) {
136
+ throw new Error(`No queue found for guild '${guildId}'`);
137
+ }
138
+
139
+ const voice = this.#player.voices.get(guildId);
140
+ if (!voice) {
141
+ throw new Error(`No voice connection found for guild '${guildId}'`);
142
+ }
143
+
144
+ await voice.changeNode(nodeName);
145
+ }
146
+
147
+ async syncAll(): Promise<void> {
148
+ const promises: Promise<void>[] = [];
149
+
150
+ for (const queue of this.#queues.values()) {
151
+ promises.push(queue.sync("remote").catch(noop));
152
+ }
153
+
154
+ await Promise.all(promises);
155
+ }
156
+
157
+ [LookupSymbol](guildId: string): APIPlayer | undefined {
158
+ return this.#players.get(guildId);
159
+ }
160
+
161
+ [OnStateUpdateSymbol](guildId: string, state: PlayerState): void {
162
+ const player = this.#players.get(guildId);
163
+ if (!player) {
164
+ return;
165
+ }
166
+
167
+ player.state = state;
168
+
169
+ const queue = this.#queues.get(guildId);
170
+ if (queue) {
171
+ this.#player.emit("queueUpdate", queue, state);
172
+ }
173
+
174
+ const voice = this.#player.voices.get(guildId);
175
+ if (voice?.regionId) {
176
+ const region = this.#player.voices.regions.get(voice.regionId);
177
+ if (region) {
178
+ (region as unknown as { [OnPingUpdateSymbol]?(nodeName: string, state: PlayerState): void })[
179
+ OnPingUpdateSymbol
180
+ ]?.(voice.node.name, state);
181
+ }
182
+ }
183
+ }
184
+
185
+ [OnEventUpdateSymbol](guildId: string, event: EventPayload): void {
186
+ const queue = this.#queues.get(guildId);
187
+ if (!queue) {
188
+ return;
189
+ }
190
+
191
+ switch (event.type) {
192
+ case EventType.TrackStart:
193
+ this.#handleTrackStart(queue, event);
194
+ break;
195
+ case EventType.TrackEnd:
196
+ void this.#handleTrackEnd(queue, event);
197
+ break;
198
+ case EventType.TrackException:
199
+ this.#handleTrackException(queue, event);
200
+ break;
201
+ case EventType.TrackStuck:
202
+ this.#handleTrackStuck(queue, event);
203
+ break;
204
+ case EventType.WebSocketClosed:
205
+ this.#handleWebSocketClosed(queue, event);
206
+ break;
207
+ }
208
+ }
209
+
210
+ #handleTrackStart(queue: Queue<Context>, event: TrackStartEventPayload): void {
211
+ const track = new Track(event.track);
212
+ this.#player.emit("trackStart", queue, track);
213
+ }
214
+
215
+ async #handleTrackEnd(queue: Queue<Context>, event: TrackEndEventPayload): Promise<void> {
216
+ const track = new Track(event.track);
217
+ const reason = event.reason;
218
+
219
+ this.#player.emit("trackFinish", queue, track, reason);
220
+
221
+ const shouldAdvance = reason === TrackEndReason.Finished || reason === TrackEndReason.LoadFailed;
222
+
223
+ if (shouldAdvance) {
224
+ const nextTrack = await queue.next().catch(noop);
225
+
226
+ if (!nextTrack && queue.finished) {
227
+ this.#player.emit("queueFinish", queue);
228
+ }
229
+ }
230
+ }
231
+
232
+ #handleTrackException(queue: Queue<Context>, event: TrackExceptionEventPayload): void {
233
+ const track = new Track(event.track);
234
+ this.#player.emit("trackError", queue, track, event.exception);
235
+ }
236
+
237
+ #handleTrackStuck(queue: Queue<Context>, event: TrackStuckEventPayload): void {
238
+ const track = new Track(event.track);
239
+ this.#player.emit("trackStuck", queue, track, event.thresholdMs);
240
+ }
241
+
242
+ #handleWebSocketClosed(queue: Queue<Context>, event: WebSocketClosedEventPayload): void {
243
+ const voice = this.#player.voices.get(queue.guildId);
244
+ if (voice) {
245
+ (
246
+ this.#player.voices as unknown as {
247
+ [OnVoiceCloseSymbol]?(guildId: string, code: number, reason: string, byRemote: boolean): void;
248
+ }
249
+ )[OnVoiceCloseSymbol]?.(queue.guildId, event.code, event.reason, event.byRemote);
250
+ }
251
+ }
252
+ }
@@ -0,0 +1,138 @@
1
+ import { formatDuration, isArray, isNumber, isRecord, isString } from "../utils";
2
+ import { Track } from "./AudioTrack";
3
+ import type { APIPlaylist, CommonPluginInfo, JsonObject } from "../types";
4
+
5
+ /**
6
+ * Represents a playlist containing multiple tracks
7
+ */
8
+ export class Playlist<PluginInfo extends JsonObject = CommonPluginInfo> {
9
+ /**
10
+ * Name of the playlist
11
+ */
12
+ name = "Unknown Playlist";
13
+
14
+ /**
15
+ * Index of the track that was selected (from URL)
16
+ */
17
+ selectedTrack = -1;
18
+
19
+ /**
20
+ * List of tracks in the playlist
21
+ */
22
+ tracks: Track[] = [];
23
+
24
+ /**
25
+ * Additional info from plugins
26
+ */
27
+ pluginInfo = {} as PluginInfo;
28
+
29
+ /**
30
+ * Total duration of all tracks in milliseconds
31
+ */
32
+ duration = 0;
33
+
34
+ /**
35
+ * Formatted total duration string
36
+ */
37
+ formattedDuration = "00:00";
38
+
39
+ constructor(data: APIPlaylist<PluginInfo>) {
40
+ if (!isRecord(data)) {
41
+ throw new Error("Playlist data must be an object");
42
+ }
43
+ if (!isRecord(data.info)) {
44
+ throw new Error("Playlist info is not an object");
45
+ }
46
+ if (!isArray(data.tracks)) {
47
+ throw new Error("Playlist tracks must be an array");
48
+ }
49
+
50
+ // Process tracks and calculate duration
51
+ for (let i = 0; i < data.tracks.length; i++) {
52
+ const track = new Track(data.tracks[i]);
53
+ if (!track.isLive) {
54
+ this.duration += track.duration;
55
+ }
56
+ this.tracks.push(track);
57
+ }
58
+
59
+ // Set playlist name
60
+ if (isString(data.info.name, "non-empty")) {
61
+ this.name = data.info.name;
62
+ } else if (data.info.name === "") {
63
+ this.name = "";
64
+ }
65
+
66
+ // Set selected track index
67
+ if (isNumber(data.info.selectedTrack, "whole")) {
68
+ this.selectedTrack = data.info.selectedTrack;
69
+ }
70
+
71
+ // Set plugin info
72
+ if (isRecord(data.pluginInfo, "non-empty")) {
73
+ this.pluginInfo = data.pluginInfo;
74
+ }
75
+
76
+ // Format duration
77
+ if (this.duration > 0) {
78
+ this.formattedDuration = formatDuration(this.duration);
79
+ }
80
+ }
81
+
82
+ get selected(): Track | null {
83
+ if (this.selectedTrack < 0 || this.selectedTrack >= this.tracks.length) {
84
+ return null;
85
+ }
86
+ return this.tracks[this.selectedTrack] ?? null;
87
+ }
88
+
89
+ /**
90
+ * Get the selected track (if any)
91
+ * @returns The selected track or undefined if none/invalid
92
+ */
93
+ getSelectedTrack(): Track | undefined {
94
+ return this.selected ?? undefined;
95
+ }
96
+
97
+ /**
98
+ * Get the number of tracks
99
+ */
100
+ get length(): number {
101
+ return this.tracks.length;
102
+ }
103
+
104
+ /**
105
+ * Get the number of tracks (alias for length)
106
+ */
107
+ get trackCount(): number {
108
+ return this.tracks.length;
109
+ }
110
+
111
+ /**
112
+ * Formatted total duration (alias for formattedDuration)
113
+ */
114
+ get durationFormatted(): string {
115
+ return this.formattedDuration;
116
+ }
117
+
118
+ /**
119
+ * String representation of the playlist
120
+ */
121
+ toString(): string {
122
+ return this.name;
123
+ }
124
+
125
+ /**
126
+ * JSON representation of the playlist
127
+ */
128
+ toJSON(): Record<string, unknown> {
129
+ return {
130
+ name: this.name,
131
+ selectedTrack: this.selectedTrack,
132
+ tracks: this.tracks.map((t) => t.toJSON()),
133
+ duration: this.duration,
134
+ formattedDuration: this.formattedDuration,
135
+ pluginInfo: this.pluginInfo,
136
+ };
137
+ }
138
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Audio exports - AudioTrack, TrackCollection, AudioFilters, AudioQueue, QueueController
3
+ */
4
+
5
+ export * from "./AudioTrack";
6
+ export * from "./TrackCollection";
7
+ export * from "./AudioFilters";
8
+ export * from "./AudioQueue";
9
+ export * from "./QueueController";