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,403 @@
1
+ import { EventEmitter } from "node:events";
2
+ import { LoadType, type APITrack } from "../types/api/Rest";
3
+ import { DefaultPlayerOptions } from "../config";
4
+ import { isString } from "../utils";
5
+ import { NodeManager } from "../lavalink/ConnectionPool";
6
+ import { VoiceManager } from "../voice/VoiceConnection";
7
+ import { Playlist, Queue, QueueManager, Track } from "../audio";
8
+ import { PlayerPlugin } from "./PluginSystem";
9
+ import type {
10
+ CreateQueueOptions,
11
+ PlayerEventMap,
12
+ PlayerOptions,
13
+ PlayOptions,
14
+ PluginRecord,
15
+ RepeatMode,
16
+ SearchOptions,
17
+ SearchResult,
18
+ CreateNodeOptions,
19
+ PlayerInstanceOptions,
20
+ QueueContext,
21
+ PluginEventMap,
22
+ MergeUnionType,
23
+ } from "../types";
24
+
25
+ /**
26
+ * Constrain event map to ensure all values are arrays
27
+ */
28
+ type ConstrainEventMap<T> = {
29
+ [K in keyof T]: T[K] extends unknown[] ? T[K] : never;
30
+ };
31
+
32
+ /**
33
+ * Main Player class - entry point for Ryanlink
34
+ * Manages nodes, voices, queues, and plugins
35
+ */
36
+ export class Player<
37
+ Context extends Record<string, unknown> = QueueContext,
38
+ Plugins extends PlayerPlugin[] = [],
39
+ > extends EventEmitter<ConstrainEventMap<PlayerEventMap & MergeUnionType<PluginEventMap<Plugins[number]>>>> {
40
+ #initialized = false;
41
+ #initPromise: Promise<void> | null = null;
42
+
43
+ #clientId: string | null = null;
44
+ #nodes: CreateNodeOptions[] | null = null;
45
+
46
+ readonly options: PlayerInstanceOptions;
47
+ readonly plugins: PluginRecord<Plugins>;
48
+
49
+ readonly nodes: NodeManager;
50
+ readonly voices: VoiceManager;
51
+ readonly queues: QueueManager<Context>;
52
+
53
+ constructor(options: PlayerOptions<Plugins>) {
54
+ super({ captureRejections: false });
55
+
56
+ const _options = { ...DefaultPlayerOptions, ...options };
57
+
58
+ if (_options.nodes.length === 0) {
59
+ throw new Error("Missing node create options");
60
+ }
61
+ if (typeof _options.forwardVoiceUpdate !== "function") {
62
+ throw new Error("Missing voice update function");
63
+ }
64
+
65
+ this.#nodes = _options.nodes;
66
+ delete (_options as Partial<typeof _options>).nodes;
67
+
68
+ this.options = _options;
69
+ this.plugins = {} as PluginRecord<Plugins>;
70
+
71
+ if (_options.plugins !== undefined) {
72
+ for (const plugin of _options.plugins) {
73
+ if (!(plugin instanceof PlayerPlugin)) {
74
+ throw new Error("Invalid plugin(s)");
75
+ }
76
+ (this.plugins as { [x: string]: PlayerPlugin })[plugin.name] = plugin;
77
+ }
78
+ delete _options.plugins;
79
+ }
80
+
81
+ this.nodes = new NodeManager(this as unknown as Player);
82
+ this.voices = new VoiceManager(this as unknown as Player);
83
+ this.queues = new QueueManager(this as unknown as Player);
84
+
85
+ const immutable: PropertyDescriptor = {
86
+ writable: false,
87
+ configurable: false,
88
+ };
89
+
90
+ Object.defineProperties(this, {
91
+ options: immutable,
92
+ plugins: immutable,
93
+ nodes: immutable,
94
+ voices: immutable,
95
+ queues: immutable,
96
+ } satisfies { [k in keyof Player]?: PropertyDescriptor });
97
+ }
98
+
99
+ /**
100
+ * Whether the player is initialized and ready
101
+ */
102
+ get ready(): boolean {
103
+ return this.#initialized;
104
+ }
105
+
106
+ /**
107
+ * The bot's client ID
108
+ */
109
+ get clientId(): string | null {
110
+ return this.#clientId;
111
+ }
112
+
113
+ /**
114
+ * Initialize the player
115
+ * @param clientId Bot client ID
116
+ */
117
+ async init(clientId: string): Promise<void> {
118
+ if (this.#initialized) {
119
+ return;
120
+ }
121
+
122
+ if (this.#initPromise !== null) {
123
+ return this.#initPromise;
124
+ }
125
+
126
+ let resolve!: (value: void | PromiseLike<void>) => void;
127
+ let reject!: (reason?: unknown) => void;
128
+ const promise = new Promise<void>((res, rej) => {
129
+ resolve = res;
130
+ reject = rej;
131
+ });
132
+
133
+ this.#initPromise = promise;
134
+ this.#clientId = clientId;
135
+
136
+ try {
137
+ const nodes = this.#nodes ?? [];
138
+ for (const node of nodes) {
139
+ this.nodes.create({ ...node, clientId });
140
+ }
141
+
142
+ // Initialize all plugins
143
+ for (const name in this.plugins) {
144
+ (this.plugins as Record<string, PlayerPlugin>)[name].init(this as unknown as Player);
145
+ }
146
+
147
+ // Connect to all nodes
148
+ await this.nodes.connect();
149
+
150
+ this.#initialized = true;
151
+ this.#nodes = null;
152
+ (this as EventEmitter).emit("init");
153
+ resolve();
154
+ } catch (err) {
155
+ reject(err);
156
+ throw err;
157
+ } finally {
158
+ this.#initPromise = null;
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Returns the queue of a guild
164
+ * @param guildId Id of the guild
165
+ */
166
+ getQueue(guildId: string): Queue<Context> | undefined {
167
+ return this.queues.get(guildId);
168
+ }
169
+
170
+ /**
171
+ * Creates a queue from options
172
+ * @param options Options to create from
173
+ */
174
+ async createQueue(options: CreateQueueOptions<Context>): Promise<Queue<Context>> {
175
+ return this.queues.create(options);
176
+ }
177
+
178
+ /**
179
+ * Destroys the queue of a guild
180
+ * @param guildId Id of the guild
181
+ * @param reason Reason for destroying
182
+ */
183
+ async destroyQueue(guildId: string, reason?: string): Promise<void> {
184
+ return this.queues.destroy(guildId, reason);
185
+ }
186
+
187
+ /**
188
+ * Searches for results based on query and options
189
+ * @param query Query (or URL as well)
190
+ * @param options Options for customization
191
+ */
192
+ async search(query: string, options?: SearchOptions): Promise<SearchResult> {
193
+ if (!isString(query, "non-empty")) {
194
+ throw new Error("Query must be a non-empty string");
195
+ }
196
+
197
+ const node = options?.node !== undefined ? this.nodes.get(options.node) : this.nodes.relevant()[0];
198
+ if (!node) {
199
+ if (options?.node === undefined) {
200
+ throw new Error("No nodes available");
201
+ }
202
+ throw new Error(`Node '${options.node}' not found`);
203
+ }
204
+
205
+ const prefix = options?.prefix ?? this.options.queryPrefix;
206
+ query = isString(query, "url") ? query : `${String(prefix)}:${String(query)} `;
207
+ const result = await node.rest.loadTracks(query);
208
+
209
+ switch (result.loadType) {
210
+ case LoadType.Empty:
211
+ return { type: "empty", data: [] };
212
+ case LoadType.Error:
213
+ return { type: "error", data: result.data };
214
+ case LoadType.Playlist:
215
+ return { type: "playlist", data: new Playlist(result.data) };
216
+ case LoadType.Search:
217
+ return { type: "query", data: result.data.map((t: APITrack) => new Track(t)) };
218
+ case LoadType.Track:
219
+ return { type: "track", data: new Track(result.data) };
220
+ default:
221
+ throw new Error(`Unexpected load result type from node '${node.name}'`);
222
+ }
223
+ }
224
+
225
+ /**
226
+ * Adds or searches if source is query and resumes the queue if stopped
227
+ * @param source Source to play from
228
+ * @param options Options for customization
229
+ */
230
+ async play(source: string | Parameters<Queue["add"]>[0], options: PlayOptions<Context>): Promise<Queue<Context>> {
231
+ let queue = this.queues.get(options.guildId);
232
+
233
+ if (typeof source === "string") {
234
+ let result: SearchResult;
235
+ if (!queue) {
236
+ result = await this.search(source, options);
237
+ } else {
238
+ result = await queue.search(source, options.prefix);
239
+ }
240
+
241
+ if (result.type === "empty") {
242
+ throw new Error(`No results found for '${source}'`);
243
+ }
244
+ if (result.type === "error") {
245
+ throw new Error(result.data.message ?? result.data.cause, { cause: result.data });
246
+ }
247
+
248
+ source = result.type === "query" ? result.data[0] : result.data;
249
+ }
250
+
251
+ queue ??= await this.queues.create(options);
252
+
253
+ if (options.context !== undefined) {
254
+ Object.assign(queue.context, options.context);
255
+ }
256
+
257
+ queue.add(source, options.userData);
258
+
259
+ if (queue.stopped) {
260
+ await queue.resume();
261
+ }
262
+
263
+ return queue;
264
+ }
265
+
266
+ /**
267
+ * Jumps to the specified index in queue of a guild
268
+ * @param guildId Id of the guild
269
+ * @param index Index to jump to
270
+ */
271
+ async jump(guildId: string, index: number): Promise<Track> {
272
+ const queue = this.queues.get(guildId);
273
+ if (!queue) {
274
+ throw new Error(`No queue found for guild '${guildId}'`);
275
+ }
276
+ return queue.jump(index);
277
+ }
278
+
279
+ /**
280
+ * Pauses the queue of a guild
281
+ * @param guildId Id of the guild
282
+ */
283
+ async pause(guildId: string): Promise<boolean> {
284
+ const queue = this.queues.get(guildId);
285
+ if (!queue) {
286
+ throw new Error(`No queue found for guild '${guildId}'`);
287
+ }
288
+ return queue.pause();
289
+ }
290
+
291
+ /**
292
+ * Plays the previous track in queue of a guild
293
+ * @param guildId Id of the guild
294
+ */
295
+ async previous(guildId: string): Promise<Track | null> {
296
+ const queue = this.queues.get(guildId);
297
+ if (!queue) {
298
+ throw new Error(`No queue found for guild '${guildId}'`);
299
+ }
300
+ return queue.previous();
301
+ }
302
+
303
+ /**
304
+ * Resumes the queue of a guild
305
+ * @param guildId Id of the guild
306
+ */
307
+ async resume(guildId: string): Promise<boolean> {
308
+ const queue = this.queues.get(guildId);
309
+ if (!queue) {
310
+ throw new Error(`No queue found for guild '${guildId}'`);
311
+ }
312
+ return queue.resume();
313
+ }
314
+
315
+ /**
316
+ * Seeks to a position in the current track of a guild
317
+ * @param guildId Id of the guild
318
+ * @param ms Position in milliseconds
319
+ */
320
+ async seek(guildId: string, ms: number): Promise<number> {
321
+ const queue = this.queues.get(guildId);
322
+ if (!queue) {
323
+ throw new Error(`No queue found for guild '${guildId}'`);
324
+ }
325
+ return queue.seek(ms);
326
+ }
327
+
328
+ /**
329
+ * Enables or disables autoplay for the queue of a guild
330
+ * @param guildId Id of the guild
331
+ * @param autoplay Whether to enable autoplay
332
+ */
333
+ setAutoplay(guildId: string, autoplay?: boolean): boolean {
334
+ const queue = this.queues.get(guildId);
335
+ if (!queue) {
336
+ throw new Error(`No queue found for guild '${guildId}'`);
337
+ }
338
+ return queue.setAutoplay(autoplay);
339
+ }
340
+
341
+ /**
342
+ * Sets the repeat mode for the queue of a guild
343
+ * @param guildId Id of the guild
344
+ * @param repeatMode The repeat mode
345
+ */
346
+ setRepeatMode(guildId: string, repeatMode: RepeatMode): RepeatMode {
347
+ const queue = this.queues.get(guildId);
348
+ if (!queue) {
349
+ throw new Error(`No queue found for guild '${guildId}'`);
350
+ }
351
+ return queue.setRepeatMode(repeatMode);
352
+ }
353
+
354
+ /**
355
+ * Sets the volume of the queue of a guild
356
+ * @param guildId Id of the guild
357
+ * @param volume The volume to set
358
+ */
359
+ async setVolume(guildId: string, volume: number): Promise<number> {
360
+ const queue = this.queues.get(guildId);
361
+ if (!queue) {
362
+ throw new Error(`No queue found for guild '${guildId}'`);
363
+ }
364
+ return queue.setVolume(volume);
365
+ }
366
+
367
+ /**
368
+ * Shuffles tracks for the queue of a guild
369
+ * @param guildId Id of the guild
370
+ * @param includePrevious Whether to pull previous tracks to current
371
+ */
372
+ shuffle(guildId: string, includePrevious?: boolean): Queue<Context> {
373
+ const queue = this.queues.get(guildId);
374
+ if (!queue) {
375
+ throw new Error(`No queue found for guild '${guildId}'`);
376
+ }
377
+ return queue.shuffle(includePrevious);
378
+ }
379
+
380
+ /**
381
+ * Plays the next track in queue of a guild
382
+ * @param guildId Id of the guild
383
+ */
384
+ async next(guildId: string): Promise<Track | null> {
385
+ const queue = this.queues.get(guildId);
386
+ if (!queue) {
387
+ throw new Error(`No queue found for guild '${guildId}'`);
388
+ }
389
+ return queue.next();
390
+ }
391
+
392
+ /**
393
+ * Stops the queue of a guild
394
+ * @param guildId Id of the guild
395
+ */
396
+ async stop(guildId: string): Promise<void> {
397
+ const queue = this.queues.get(guildId);
398
+ if (!queue) {
399
+ throw new Error(`No queue found for guild '${guildId}'`);
400
+ }
401
+ return queue.stop();
402
+ }
403
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Core exports - RyanlinkPlayer and PluginSystem
3
+ */
4
+
5
+ export * from "./PluginSystem";
6
+ export * from "./RyanlinkPlayer";
@@ -0,0 +1,283 @@
1
+ import { PlayerPlugin } from "../core/PluginSystem";
2
+ import type { Player } from "../core/RyanlinkPlayer";
3
+ import type { Track } from "../audio/AudioTrack";
4
+ import { Queue } from "../audio/AudioQueue";
5
+ import { LoadType, type LoadResult } from "../types/api/Rest";
6
+
7
+ interface PlayerWithGet extends Player {
8
+ get?<T>(key: string): T;
9
+ }
10
+
11
+ export interface AutoplayConfig {
12
+ enabled: boolean;
13
+ minPlayTime: number;
14
+ sources: {
15
+ spotify: boolean;
16
+ youtube: boolean;
17
+ youtubemusic: boolean;
18
+ soundcloud: boolean;
19
+ };
20
+ limit: number;
21
+ /** Filter out tracks shorter than this (ms) */
22
+ minDuration: number;
23
+ /** Filter out tracks longer than this (ms) */
24
+ maxDuration: number;
25
+ /** Filter out specific keywords in titles */
26
+ excludeKeywords: string[];
27
+ }
28
+
29
+ /**
30
+ * Autoplay Plugin - Automatically adds related tracks when queue ends
31
+ * Uses Player's existing events - no separate event system
32
+ */
33
+ export class AutoplayPlugin extends PlayerPlugin {
34
+ readonly name = "autoplay" as const;
35
+
36
+ #player!: Player;
37
+ #adding = new Set<string>(); // Track which guilds are currently adding
38
+
39
+ init(player: Player): void {
40
+ this.#player = player;
41
+
42
+ // Listen to queue finish event
43
+ player.on("queueFinish", (queue) => {
44
+ void this.#handleQueueFinish(queue);
45
+ });
46
+ }
47
+
48
+ async #handleQueueFinish(queue: Queue): Promise<void> {
49
+ const lastTrack = queue.previousTrack;
50
+ if (!lastTrack) {
51
+ return;
52
+ }
53
+
54
+ const config = (queue.player as PlayerWithGet).get?.<AutoplayConfig>(`autoplay_config_${queue.guildId}`) ?? {
55
+ enabled: true,
56
+ minPlayTime: 10000,
57
+ sources: {
58
+ spotify: true,
59
+ youtube: true,
60
+ youtubemusic: true,
61
+ soundcloud: false,
62
+ },
63
+ limit: 5,
64
+ minDuration: 20000,
65
+ maxDuration: 900000,
66
+ excludeKeywords: ["nightcore", "bass boosted", "8d audio", "slowed", "reverb"],
67
+ };
68
+
69
+ if (!config.enabled) {
70
+ return;
71
+ }
72
+
73
+ // Prevent concurrent autoplay
74
+ if (this.#adding.has(queue.guildId)) {
75
+ return;
76
+ }
77
+ this.#adding.add(queue.guildId);
78
+
79
+ try {
80
+ const playedData = this.#buildPlayedData(queue);
81
+ const relatedTracks = await this.#fetchRelatedTracks(queue, lastTrack, config, playedData);
82
+
83
+ if (relatedTracks.length > 0) {
84
+ for (const relatedTrack of relatedTracks) {
85
+ if (!relatedTrack.pluginInfo) {
86
+ relatedTrack.pluginInfo = {};
87
+ }
88
+ relatedTrack.pluginInfo.fromAutoplay = true;
89
+
90
+ relatedTrack.userData.requester = {
91
+ id: this.#player.clientId ?? "autoplay",
92
+ username: "Autoplay",
93
+ };
94
+
95
+ queue.add(relatedTrack);
96
+ }
97
+
98
+ if (queue.stopped && queue.tracks.length > 0) {
99
+ await queue.resume();
100
+ }
101
+ }
102
+ } catch (error) {
103
+ this.#player.emit("debug", "autoplay", {
104
+ message: `Autoplay failed: ${(error as Error).message}`,
105
+ state: "error",
106
+ error: error as Error,
107
+ functionLayer: "AutoplayPlugin",
108
+ });
109
+ } finally {
110
+ this.#adding.delete(queue.guildId);
111
+ }
112
+ }
113
+
114
+ #buildPlayedData(queue: Queue): {
115
+ playedIds: Set<string>;
116
+ playedTracks: Set<string>;
117
+ } {
118
+ const playedIds = new Set<string>();
119
+ const playedTracks = new Set<string>();
120
+
121
+ const addTrack = (track: Track) => {
122
+ if (track.info.identifier) {
123
+ playedIds.add(track.info.identifier);
124
+ }
125
+ if (track.info.isrc) {
126
+ playedIds.add(track.info.isrc);
127
+ }
128
+ if (track.info.title && track.info.author) {
129
+ const key = `${track.info.title.toLowerCase()}|${track.info.author.toLowerCase()}`;
130
+ playedTracks.add(key);
131
+ }
132
+ };
133
+
134
+ if (queue.track) {
135
+ addTrack(queue.track);
136
+ }
137
+ queue.previousTracks.forEach(addTrack);
138
+ queue.tracks.forEach(addTrack);
139
+
140
+ return { playedIds, playedTracks };
141
+ }
142
+
143
+ async #fetchRelatedTracks(
144
+ queue: Queue,
145
+ lastTrack: Track,
146
+ config: AutoplayConfig,
147
+ playedData: { playedIds: Set<string>; playedTracks: Set<string> },
148
+ ): Promise<Track[]> {
149
+ const tracks: Track[] = [];
150
+ const source = lastTrack.info.sourceName?.toLowerCase();
151
+
152
+ if (config.sources.spotify && source?.includes("spotify")) {
153
+ const spotifyTracks = await this.#getSpotifyRecommendations(queue, lastTrack);
154
+ tracks.push(...spotifyTracks);
155
+
156
+ if (tracks.length < config.limit) {
157
+ const artistTracks = await this.#getSpotifyArtistSearch(queue, lastTrack);
158
+ tracks.push(...artistTracks);
159
+ }
160
+ }
161
+
162
+ if (tracks.length < config.limit && config.sources.youtube && source?.includes("youtube")) {
163
+ const youtubeTracks = await this.#getYouTubeSimilar(queue, lastTrack);
164
+ tracks.push(...youtubeTracks);
165
+
166
+ if (tracks.length < config.limit) {
167
+ const artistTracks = await this.#getYouTubeArtist(queue, lastTrack);
168
+ tracks.push(...artistTracks);
169
+ }
170
+ }
171
+
172
+ if (tracks.length === 0 && config.sources.youtube) {
173
+ const youtubeTracks = await this.#getYouTubeSimilar(queue, lastTrack);
174
+ tracks.push(...youtubeTracks);
175
+ }
176
+
177
+ return this.#filterAutoplayTracks(tracks, playedData, config);
178
+ }
179
+
180
+ #filterAutoplayTracks(
181
+ tracks: Track[],
182
+ playedData: { playedIds: Set<string>; playedTracks: Set<string> },
183
+ config: AutoplayConfig,
184
+ ): Track[] {
185
+ return tracks
186
+ .filter((track) => {
187
+ if (!track.info) {
188
+ return false;
189
+ }
190
+
191
+ if (playedData.playedIds.has(track.info.identifier)) {
192
+ return false;
193
+ }
194
+ if (track.info.isrc && playedData.playedIds.has(track.info.isrc)) {
195
+ return false;
196
+ }
197
+
198
+ const key = `${track.info.title.toLowerCase()}|${track.info.author.toLowerCase()}`;
199
+ if (playedData.playedTracks.has(key)) {
200
+ return false;
201
+ }
202
+
203
+ if (track.info.length) {
204
+ if (track.info.length < config.minDuration) {
205
+ return false;
206
+ }
207
+ if (track.info.length > config.maxDuration) {
208
+ return false;
209
+ }
210
+ }
211
+
212
+ const title = track.info.title.toLowerCase();
213
+ for (const keyword of config.excludeKeywords) {
214
+ if (title.includes(keyword.toLowerCase())) {
215
+ return false;
216
+ }
217
+ }
218
+
219
+ return true;
220
+ })
221
+ .sort(() => Math.random() - 0.5)
222
+ .slice(0, config.limit);
223
+ }
224
+
225
+ async #getSpotifyRecommendations(queue: Queue, track: Track): Promise<Track[]> {
226
+ try {
227
+ const query = `sprec:seed_tracks=${track.info.identifier}`;
228
+ const result = (await queue.search(query)) as unknown as LoadResult;
229
+
230
+ if (result.loadType === LoadType.Track || result.loadType === LoadType.Playlist) {
231
+ if (result.loadType === LoadType.Track) {
232
+ return [result.data as unknown as Track];
233
+ }
234
+ return result.data.tracks as unknown as Track[];
235
+ }
236
+ } catch (error) {
237
+ // Silent fail
238
+ }
239
+ return [];
240
+ }
241
+
242
+ async #getSpotifyArtistSearch(queue: Queue, track: Track): Promise<Track[]> {
243
+ try {
244
+ const query = `spsearch:${track.info.author}`;
245
+ const result = (await queue.search(query)) as unknown as LoadResult;
246
+
247
+ if (result.loadType === LoadType.Search && Array.isArray(result.data)) {
248
+ return (result.data as unknown as Track[]).slice(0, 5);
249
+ }
250
+ } catch (error) {
251
+ // Silent fail
252
+ }
253
+ return [];
254
+ }
255
+
256
+ async #getYouTubeSimilar(queue: Queue, track: Track): Promise<Track[]> {
257
+ try {
258
+ const query = `https://www.youtube.com/watch?v=${track.info.identifier}&list=RD${track.info.identifier}`;
259
+ const result = (await queue.search(query)) as unknown as LoadResult;
260
+
261
+ if (result.loadType === LoadType.Playlist && result.data && "tracks" in result.data) {
262
+ return (result.data.tracks as unknown as Track[]).slice(0, 10);
263
+ }
264
+ } catch (error) {
265
+ // Silent fail
266
+ }
267
+ return [];
268
+ }
269
+
270
+ async #getYouTubeArtist(queue: Queue, track: Track): Promise<Track[]> {
271
+ try {
272
+ const query = `ytsearch:${track.info.author}`;
273
+ const result = (await queue.search(query)) as unknown as LoadResult;
274
+
275
+ if (result.loadType === LoadType.Search && Array.isArray(result.data)) {
276
+ return (result.data as unknown as Track[]).slice(0, 5);
277
+ }
278
+ } catch (error) {
279
+ // Silent fail
280
+ }
281
+ return [];
282
+ }
283
+ }