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,782 @@
1
+ import { LookupSymbol, UpdateSymbol } from "../config/symbols";
2
+ import { FilterManager } from "./AudioFilters";
3
+ import { Track } from "./AudioTrack";
4
+ import { Playlist } from "./TrackCollection";
5
+ import type { Player } from "../core/RyanlinkPlayer";
6
+ import type { VoiceState } from "../voice/VoiceSession";
7
+ import type {
8
+ APIPlayer,
9
+ QueueContext,
10
+ RepeatMode,
11
+ SearchResult,
12
+ PlayerUpdateRequestBody,
13
+ PlayerUpdateQueryParams,
14
+ JsonObject,
15
+ Severity,
16
+ } from "../types";
17
+
18
+ /**
19
+ * Represents a music queue for a guild
20
+ * Manages tracks, playback state, and filters
21
+ */
22
+ export class Queue<Context extends Record<string, unknown> = QueueContext> {
23
+ #player: APIPlayer;
24
+
25
+ #autoplay = false;
26
+ #repeatMode: RepeatMode = "none";
27
+
28
+ #tracks: Track[] = [];
29
+ #previousTracks: Track[] = [];
30
+
31
+ context = {} as Context;
32
+
33
+ readonly voice: VoiceState;
34
+ readonly filters: FilterManager;
35
+ readonly player: Player;
36
+
37
+ constructor(player: Player, guildId: string, context?: Context) {
38
+ if (player.queues.has(guildId)) {
39
+ throw new Error("An identical queue already exists");
40
+ }
41
+
42
+ const _player = player.queues[LookupSymbol](guildId);
43
+ if (!_player) {
44
+ throw new Error(`No player found for guild '${guildId}'`);
45
+ }
46
+
47
+ const voice = player.voices.get(guildId);
48
+ if (!voice) {
49
+ throw new Error(`No connection found for guild '${guildId}'`);
50
+ }
51
+
52
+ this.#player = _player;
53
+ if (context !== undefined) {
54
+ this.context = context;
55
+ }
56
+
57
+ this.voice = voice;
58
+ this.filters = new FilterManager(player, guildId);
59
+ this.player = player;
60
+
61
+ // Make properties immutable
62
+ const immutable: PropertyDescriptor = {
63
+ writable: false,
64
+ configurable: false,
65
+ };
66
+
67
+ Object.defineProperties(this, {
68
+ voice: immutable,
69
+ filters: immutable,
70
+ player: { ...immutable, enumerable: false },
71
+ });
72
+ }
73
+
74
+ get node() {
75
+ return this.voice.nodeSessionId
76
+ ? this.player.nodes.all.find((n) => n.sessionId === this.voice.nodeSessionId)
77
+ : this.player.nodes.relevant()[0];
78
+ }
79
+
80
+ get rest() {
81
+ return this.node?.rest;
82
+ }
83
+
84
+ get guildId(): string {
85
+ return this.voice.guildId;
86
+ }
87
+
88
+ get volume(): number {
89
+ return this.#player.volume;
90
+ }
91
+
92
+ get paused(): boolean {
93
+ return this.#player.paused;
94
+ }
95
+
96
+ get stopped(): boolean {
97
+ return this.track !== null && this.#player.track === null;
98
+ }
99
+
100
+ get empty(): boolean {
101
+ return this.finished && !this.hasPrevious;
102
+ }
103
+
104
+ get playing(): boolean {
105
+ return !this.paused && this.track !== null && this.#player.track !== null;
106
+ }
107
+
108
+ get autoplay(): boolean {
109
+ return this.#autoplay;
110
+ }
111
+
112
+ get finished(): boolean {
113
+ return this.#tracks.length === 0;
114
+ }
115
+
116
+ get destroyed(): boolean {
117
+ return this.player.queues.get(this.guildId) !== this;
118
+ }
119
+
120
+ get repeatMode(): RepeatMode {
121
+ return this.#repeatMode;
122
+ }
123
+
124
+ get hasNext(): boolean {
125
+ return this.#tracks.length > 1;
126
+ }
127
+
128
+ get hasPrevious(): boolean {
129
+ return this.#previousTracks.length !== 0;
130
+ }
131
+
132
+ get track(): Track | null {
133
+ return this.#tracks[0] ?? null;
134
+ }
135
+
136
+ get previousTrack(): Track | null {
137
+ return this.#previousTracks[this.#previousTracks.length - 1] ?? null;
138
+ }
139
+
140
+ get tracks(): Track[] {
141
+ return this.#tracks;
142
+ }
143
+
144
+ get previousTracks(): Track[] {
145
+ return this.#previousTracks;
146
+ }
147
+
148
+ get length(): number {
149
+ return this.#tracks.length;
150
+ }
151
+
152
+ get totalLength(): number {
153
+ return this.length + this.#previousTracks.length;
154
+ }
155
+
156
+ get duration(): number {
157
+ return this.#tracks.reduce((time, track) => time + (track.isLive ? 0 : track.duration), 0);
158
+ }
159
+
160
+ get formattedDuration(): string {
161
+ return this.#formatDuration(this.duration);
162
+ }
163
+
164
+ get currentTime(): number {
165
+ if (this.#player.paused || !this.#player.state.connected) {
166
+ return this.#player.state.position;
167
+ }
168
+ if (this.#player.state.position === 0) {
169
+ return 0;
170
+ }
171
+ return this.#player.state.position + (Date.now() - this.#player.state.time);
172
+ }
173
+
174
+ get formattedCurrentTime(): string {
175
+ return this.#formatDuration(this.currentTime);
176
+ }
177
+
178
+ #formatDuration(ms: number): string {
179
+ const seconds = Math.floor(ms / 1000);
180
+ const minutes = Math.floor(seconds / 60);
181
+ const hours = Math.floor(minutes / 60);
182
+
183
+ if (hours > 0) {
184
+ return `${hours}:${(minutes % 60).toString().padStart(2, "0")}:${(seconds % 60).toString().padStart(2, "0")}`;
185
+ }
186
+ return `${minutes}:${(seconds % 60).toString().padStart(2, "0")}`;
187
+ }
188
+
189
+ #error(data: string | { message?: string; cause?: string; severity?: Severity }): Error {
190
+ const explicit = typeof data === "string";
191
+ const message = explicit ? data : (data.message ?? data.cause ?? "Unknown error");
192
+ const error = new Error(message) as Error & { severity?: Severity };
193
+ error.name = `Error [${this.constructor.name}]`;
194
+ if (!explicit && data.severity) {
195
+ error.severity = data.severity;
196
+ }
197
+ return error;
198
+ }
199
+
200
+ async #update(data: PlayerUpdateRequestBody, params?: PlayerUpdateQueryParams): Promise<void> {
201
+ const node = this.node;
202
+ if (!node) {
203
+ throw this.#error("No node available");
204
+ }
205
+
206
+ const player = await node.rest.updatePlayer(this.guildId, data, params);
207
+ Object.assign(this.#player, player);
208
+ }
209
+
210
+ /**
211
+ * Sync queue state with Lavalink
212
+ * @param target - "local" to pull from Lavalink, "remote" to push to Lavalink
213
+ */
214
+ async sync(target: "local" | "remote" = "local"): Promise<void> {
215
+ const node = this.node;
216
+ if (!node) {
217
+ throw this.#error("No node available");
218
+ }
219
+
220
+ if (target === "local") {
221
+ const player = await node.rest.fetchPlayer(this.guildId);
222
+ Object.assign(this.#player, player);
223
+ return;
224
+ }
225
+
226
+ if (target !== "remote") {
227
+ throw this.#error("Target must be 'local' or 'remote'");
228
+ }
229
+
230
+ const voice = this.player.voices[LookupSymbol](this.guildId);
231
+ if (!voice) {
232
+ return;
233
+ }
234
+
235
+ const request: PlayerUpdateRequestBody = {
236
+ voice: {
237
+ token: voice.token,
238
+ endpoint: voice.endpoint,
239
+ sessionId: voice.session_id,
240
+ channelId: voice.channel_id,
241
+ },
242
+ filters: this.#player.filters,
243
+ paused: this.#player.paused,
244
+ volume: this.#player.volume,
245
+ };
246
+
247
+ if (this.#player.track !== null) {
248
+ request.track = {
249
+ encoded: this.#player.track.encoded,
250
+ userData: this.#player.track.userData,
251
+ };
252
+ request.position = this.#player.state.position;
253
+ }
254
+
255
+ await this.#update(request);
256
+ const nodeSessionId = this.node?.sessionId ?? "";
257
+ this.player.voices[UpdateSymbol](this.guildId, {
258
+ node_session_id: nodeSessionId,
259
+ });
260
+ }
261
+
262
+ /**
263
+ * Search for tracks
264
+ */
265
+ async search(query: string, prefix = this.player.options.queryPrefix): Promise<SearchResult> {
266
+ return this.player.search(query, { prefix, node: this.node?.name });
267
+ }
268
+
269
+ /**
270
+ * Add tracks to the queue
271
+ */
272
+ add(source: Track | Track[] | Playlist, userData?: JsonObject): this {
273
+ const added: Track[] = [];
274
+
275
+ if (source instanceof Track) {
276
+ Object.assign(source.userData, userData);
277
+ this.#tracks.push(source);
278
+ added.push(source);
279
+ } else if (source instanceof Playlist) {
280
+ for (const track of source.tracks) {
281
+ Object.assign(track.userData, userData);
282
+ this.#tracks.push(track);
283
+ added.push(track);
284
+ }
285
+ } else if (Array.isArray(source)) {
286
+ for (const track of source) {
287
+ if (track instanceof Track) {
288
+ Object.assign(track.userData, userData);
289
+ this.#tracks.push(track);
290
+ added.push(track);
291
+ }
292
+ }
293
+ } else {
294
+ throw this.#error("Source must be a track, playlist, or array of tracks");
295
+ }
296
+
297
+ this.player.emit("trackAdd", this.player, this.guildId, added);
298
+ return this;
299
+ }
300
+
301
+ /**
302
+ * Add related tracks (for autoplay)
303
+ */
304
+ async addRelated(refTrack?: Track): Promise<Track[]> {
305
+ refTrack ??= this.track ?? this.previousTrack ?? undefined;
306
+ if (!refTrack) {
307
+ throw this.#error("The queue is empty and there is no track to refer");
308
+ }
309
+
310
+ if (!this.node) {
311
+ return [];
312
+ }
313
+ const relatedTracks = await this.player.options.fetchRelatedTracks?.(this, refTrack);
314
+ this.add(relatedTracks);
315
+ return relatedTracks;
316
+ }
317
+
318
+ /**
319
+ * Remove tracks from the queue
320
+ */
321
+ remove(index: number): Track | undefined;
322
+ remove(indices: number[]): Track[];
323
+ remove(input: number | number[]): Track | Track[] | undefined {
324
+ if (typeof input === "number") {
325
+ if (input === 0 && !this.stopped) {
326
+ return;
327
+ }
328
+ if (input < 0) {
329
+ return this.#previousTracks.splice(input, 1)[0];
330
+ }
331
+ return this.#tracks.splice(input, 1)[0];
332
+ }
333
+
334
+ if (Array.isArray(input)) {
335
+ if (input.length === 0) {
336
+ return [];
337
+ }
338
+ const tracks: Track[] = [];
339
+
340
+ const indices = input.toSorted((a, b) => a - b);
341
+ for (let i = 0; i < indices.length; i++) {
342
+ const index = indices[i] - i;
343
+ if (index === 0 && !this.stopped) {
344
+ continue;
345
+ }
346
+ if (index < 0) {
347
+ tracks.push(...this.#previousTracks.splice(index, 1));
348
+ } else if (index < this.#tracks.length) {
349
+ tracks.push(...this.#tracks.splice(index, 1));
350
+ }
351
+ }
352
+ return tracks;
353
+ }
354
+
355
+ throw this.#error("Input must be an index or array of indices");
356
+ }
357
+
358
+ /**
359
+ * Clear tracks from the queue
360
+ */
361
+ clear(type?: "current" | "previous"): void {
362
+ switch (type) {
363
+ case "current":
364
+ if (!this.finished) {
365
+ this.#tracks.length = this.stopped ? 0 : 1;
366
+ }
367
+ break;
368
+ case "previous":
369
+ this.#previousTracks.length = 0;
370
+ break;
371
+ default:
372
+ if (!this.finished) {
373
+ this.#tracks.length = this.stopped ? 0 : 1;
374
+ }
375
+ this.#previousTracks.length = 0;
376
+ }
377
+ }
378
+
379
+ /**
380
+ * Jump to a specific track
381
+ */
382
+ async jump(index: number): Promise<Track> {
383
+ if (this.empty) {
384
+ throw this.#error("The queue is empty at the moment");
385
+ }
386
+ if (!Number.isInteger(index)) {
387
+ throw this.#error("Index must be an integer");
388
+ }
389
+
390
+ const track = index < 0 ? this.#previousTracks[this.#previousTracks.length + index] : this.#tracks[index];
391
+
392
+ if (!track) {
393
+ throw this.#error("Specified index is out of range");
394
+ }
395
+
396
+ if (index < 0) {
397
+ this.#tracks.unshift(...this.#previousTracks.splice(index));
398
+ } else {
399
+ this.#previousTracks.push(...this.#tracks.splice(0, index));
400
+ }
401
+
402
+ await this.#update({
403
+ paused: false,
404
+ track: { encoded: track.encoded, userData: track.userData },
405
+ });
406
+
407
+ return track;
408
+ }
409
+
410
+ /**
411
+ * Pause playback
412
+ */
413
+ async pause(): Promise<boolean> {
414
+ await this.#update({ paused: true });
415
+ return this.#player.paused;
416
+ }
417
+
418
+ /**
419
+ * Resume playback
420
+ */
421
+ async resume(): Promise<boolean> {
422
+ if (this.stopped) {
423
+ await this.jump(0);
424
+ } else {
425
+ await this.#update({ paused: false });
426
+ }
427
+ return !this.#player.paused;
428
+ }
429
+
430
+ /**
431
+ * Seek to a position
432
+ */
433
+ async seek(ms: number): Promise<number> {
434
+ if (this.track === null) {
435
+ throw this.#error("No track is playing at the moment");
436
+ }
437
+ if (!this.track.isSeekable) {
438
+ throw this.#error("Current track is not seekable");
439
+ }
440
+ if (!Number.isInteger(ms) || ms < 0) {
441
+ throw this.#error("Seek time must be a positive integer");
442
+ }
443
+ if (ms > this.track.duration) {
444
+ throw this.#error("Specified time to seek is out of range");
445
+ }
446
+
447
+ const body: PlayerUpdateRequestBody = { paused: false, position: ms };
448
+
449
+ if (this.#player.track?.info.identifier !== this.track.id) {
450
+ body.track = { encoded: this.track.encoded, userData: this.track.userData };
451
+ }
452
+
453
+ await this.#update(body);
454
+ return this.#player.state.position;
455
+ }
456
+
457
+ /**
458
+ * Play next track
459
+ */
460
+ async next(): Promise<Track | null> {
461
+ if (this.hasNext) {
462
+ return this.jump(1);
463
+ }
464
+
465
+ if (this.hasPrevious && this.#repeatMode === "queue") {
466
+ const track = this.#previousTracks.shift();
467
+ if (track) {
468
+ this.#tracks.push(track);
469
+ }
470
+ return this.jump(this.hasNext ? 1 : 0);
471
+ }
472
+
473
+ if (!this.empty && this.#autoplay) {
474
+ const related = await this.addRelated();
475
+ if (related.length > 0) {
476
+ return this.jump(this.length - related.length);
477
+ }
478
+ }
479
+
480
+ if (!this.finished) {
481
+ const track = this.#tracks.shift();
482
+ if (track) {
483
+ this.#previousTracks.push(track);
484
+ }
485
+ await this.stop();
486
+ }
487
+
488
+ return null;
489
+ }
490
+
491
+ /**
492
+ * Play previous track
493
+ */
494
+ async previous(): Promise<Track | null> {
495
+ if (this.hasPrevious) {
496
+ return this.jump(-1);
497
+ }
498
+ return null;
499
+ }
500
+
501
+ /**
502
+ * Shuffle tracks
503
+ */
504
+ shuffle(includePrevious = false): this {
505
+ if (includePrevious === true) {
506
+ this.#tracks.push(...this.#previousTracks.splice(0));
507
+ }
508
+
509
+ if (this.#tracks.length < 3) {
510
+ return this;
511
+ }
512
+
513
+ for (let i = this.#tracks.length - 1; i > 1; --i) {
514
+ const j = Math.floor(Math.random() * i) + 1;
515
+ [this.#tracks[i], this.#tracks[j]] = [this.#tracks[j], this.#tracks[i]];
516
+ }
517
+
518
+ return this;
519
+ }
520
+
521
+ /**
522
+ * Set volume
523
+ */
524
+ async setVolume(volume: number): Promise<number> {
525
+ if (!Number.isInteger(volume) || volume < 0) {
526
+ throw this.#error("Volume must be a positive integer");
527
+ }
528
+ if (volume > 1000) {
529
+ throw this.#error("Volume cannot be more than 1000");
530
+ }
531
+
532
+ await this.#update({ volume });
533
+ return this.#player.volume;
534
+ }
535
+
536
+ /**
537
+ * Set autoplay
538
+ */
539
+ setAutoplay(autoplay = false): boolean {
540
+ if (typeof autoplay !== "boolean") {
541
+ throw this.#error("Autoplay must be a boolean value");
542
+ }
543
+ this.#autoplay = autoplay;
544
+ return this.#autoplay;
545
+ }
546
+
547
+ /**
548
+ * Set repeat mode
549
+ */
550
+ setRepeatMode(repeatMode: RepeatMode = "none"): RepeatMode {
551
+ if (repeatMode !== "track" && repeatMode !== "queue" && repeatMode !== "none") {
552
+ throw this.#error("Repeat mode can only be set to track, queue, or none");
553
+ }
554
+ this.#repeatMode = repeatMode;
555
+ return this.#repeatMode;
556
+ }
557
+
558
+ /**
559
+ * Stop playback
560
+ */
561
+ async stop(): Promise<void> {
562
+ return this.#update({ track: { encoded: null } });
563
+ }
564
+
565
+ /**
566
+ * Destroy the queue
567
+ */
568
+ async destroy(reason?: string): Promise<void> {
569
+ return this.player.queues.destroy(this.guildId, reason);
570
+ }
571
+
572
+ /**
573
+ * Move a track from one position to another
574
+ * @param from - Current position of the track
575
+ * @param to - New position for the track
576
+ */
577
+ move(from: number, to: number): Track | null {
578
+ if (from < 0 || from >= this.#tracks.length || to < 0 || to >= this.#tracks.length || from === to) {
579
+ return null;
580
+ }
581
+
582
+ const track = this.#tracks[from];
583
+ if (!track) {
584
+ return null;
585
+ }
586
+
587
+ this.#tracks.splice(from, 1);
588
+ this.#tracks.splice(to, 0, track);
589
+
590
+ return track;
591
+ }
592
+
593
+ /**
594
+ * Splice tracks - remove and/or add tracks at a specific position
595
+ * @param index - Position to start
596
+ * @param amount - Number of tracks to remove
597
+ * @param tracks - Tracks to add at the position
598
+ */
599
+ splice(index: number, amount: number, tracks?: Track | Track[]): Track[] {
600
+ if (!this.#tracks.length && tracks) {
601
+ void this.add(tracks);
602
+ return [];
603
+ }
604
+
605
+ const removed = tracks
606
+ ? this.#tracks.splice(index, amount, ...(Array.isArray(tracks) ? tracks : [tracks]))
607
+ : this.#tracks.splice(index, amount);
608
+
609
+ return removed;
610
+ }
611
+
612
+ /**
613
+ * Sort tracks by a property or custom function
614
+ * @param sortBy - Property name or comparator function
615
+ * @param order - Sort order (asc/desc)
616
+ */
617
+ sortBy(
618
+ sortBy: "duration" | "title" | "author" | ((a: Track, b: Track) => number),
619
+ order: "asc" | "desc" = "asc",
620
+ ): this {
621
+ if (typeof sortBy === "function") {
622
+ this.#tracks.sort(sortBy);
623
+ } else {
624
+ this.#tracks.sort((a, b) => {
625
+ let comparison = 0;
626
+
627
+ switch (sortBy) {
628
+ case "duration":
629
+ comparison = a.duration - b.duration;
630
+ break;
631
+ case "title":
632
+ comparison = a.info.title.localeCompare(b.info.title);
633
+ break;
634
+ case "author":
635
+ comparison = a.info.author.localeCompare(b.info.author);
636
+ break;
637
+ default:
638
+ return 0;
639
+ }
640
+
641
+ return order === "desc" ? -comparison : comparison;
642
+ });
643
+ }
644
+
645
+ return this;
646
+ }
647
+
648
+ /**
649
+ * Get a sorted copy without modifying the original queue
650
+ */
651
+ toSortedBy(
652
+ sortBy: "duration" | "title" | "author" | ((a: Track, b: Track) => number),
653
+ order: "asc" | "desc" = "asc",
654
+ ): Track[] {
655
+ const copy = [...this.#tracks];
656
+
657
+ if (typeof sortBy === "function") {
658
+ return copy.sort(sortBy);
659
+ }
660
+
661
+ return copy.sort((a, b) => {
662
+ let comparison = 0;
663
+
664
+ switch (sortBy) {
665
+ case "duration":
666
+ comparison = a.duration - b.duration;
667
+ break;
668
+ case "title":
669
+ comparison = a.info.title.localeCompare(b.info.title);
670
+ break;
671
+ case "author":
672
+ comparison = a.info.author.localeCompare(b.info.author);
673
+ break;
674
+ default:
675
+ return 0;
676
+ }
677
+
678
+ return order === "desc" ? -comparison : comparison;
679
+ });
680
+ }
681
+
682
+ /**
683
+ * Filter tracks by predicate or criteria
684
+ */
685
+ filterTracks(
686
+ predicate:
687
+ | ((track: Track, index: number) => boolean)
688
+ | {
689
+ title?: string;
690
+ author?: string;
691
+ duration?: number | { min?: number; max?: number };
692
+ uri?: string;
693
+ identifier?: string;
694
+ sourceName?: string;
695
+ isStream?: boolean;
696
+ isSeekable?: boolean;
697
+ },
698
+ ): Array<{ track: Track; index: number }> {
699
+ if (typeof predicate === "function") {
700
+ return this.#tracks
701
+ .map((track, index) => ({ track, index }))
702
+ .filter(({ track, index }) => predicate(track, index));
703
+ }
704
+
705
+ return this.#tracks
706
+ .map((track, index) => ({ track, index }))
707
+ .filter(({ track }) => {
708
+ if (predicate.title && !track.info.title.toLowerCase().includes(predicate.title.toLowerCase())) {
709
+ return false;
710
+ }
711
+ if (predicate.author && !track.info.author.toLowerCase().includes(predicate.author.toLowerCase())) {
712
+ return false;
713
+ }
714
+ if (predicate.duration) {
715
+ if (typeof predicate.duration === "number") {
716
+ if (track.duration !== predicate.duration) {
717
+ return false;
718
+ }
719
+ } else {
720
+ if (predicate.duration.min && track.duration < predicate.duration.min) {
721
+ return false;
722
+ }
723
+ if (predicate.duration.max && track.duration > predicate.duration.max) {
724
+ return false;
725
+ }
726
+ }
727
+ }
728
+ if (predicate.uri && track.info.uri !== predicate.uri) {
729
+ return false;
730
+ }
731
+ if (predicate.identifier && track.info.identifier !== predicate.identifier) {
732
+ return false;
733
+ }
734
+ if (predicate.sourceName && track.info.sourceName !== predicate.sourceName) {
735
+ return false;
736
+ }
737
+ if (predicate.isStream !== undefined && track.isLive !== predicate.isStream) {
738
+ return false;
739
+ }
740
+ if (predicate.isSeekable !== undefined && track.isSeekable !== predicate.isSeekable) {
741
+ return false;
742
+ }
743
+
744
+ return true;
745
+ });
746
+ }
747
+
748
+ /**
749
+ * Find a track by predicate or criteria
750
+ */
751
+ findTrack(
752
+ predicate:
753
+ | ((track: Track, index: number) => boolean)
754
+ | {
755
+ title?: string;
756
+ author?: string;
757
+ duration?: number | { min?: number; max?: number };
758
+ uri?: string;
759
+ identifier?: string;
760
+ sourceName?: string;
761
+ isStream?: boolean;
762
+ isSeekable?: boolean;
763
+ },
764
+ ): { track: Track; index: number } | null {
765
+ const results = this.filterTracks(predicate);
766
+ return results[0] ?? null;
767
+ }
768
+
769
+ /**
770
+ * Get a range of tracks
771
+ */
772
+ getTracks(start: number, end?: number): Track[] {
773
+ return this.#tracks.slice(start, end);
774
+ }
775
+
776
+ /**
777
+ * Shift from previous tracks
778
+ */
779
+ shiftPrevious(): Track | null {
780
+ return this.#previousTracks.shift() ?? null;
781
+ }
782
+ }