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.
- package/LICENSE +37 -0
- package/README.md +455 -0
- package/dist/index.d.mts +1335 -0
- package/dist/index.d.ts +1335 -0
- package/dist/index.js +4694 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +4604 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +82 -0
- package/src/audio/AudioFilters.ts +316 -0
- package/src/audio/AudioQueue.ts +782 -0
- package/src/audio/AudioTrack.ts +242 -0
- package/src/audio/QueueController.ts +252 -0
- package/src/audio/TrackCollection.ts +138 -0
- package/src/audio/index.ts +9 -0
- package/src/config/defaults.ts +223 -0
- package/src/config/endpoints.ts +99 -0
- package/src/config/index.ts +9 -0
- package/src/config/patterns.ts +55 -0
- package/src/config/presets.ts +400 -0
- package/src/config/symbols.ts +31 -0
- package/src/core/PluginSystem.ts +50 -0
- package/src/core/RyanlinkPlayer.ts +403 -0
- package/src/core/index.ts +6 -0
- package/src/extensions/AutoplayExtension.ts +283 -0
- package/src/extensions/FairPlayExtension.ts +154 -0
- package/src/extensions/LyricsExtension.ts +187 -0
- package/src/extensions/PersistenceExtension.ts +182 -0
- package/src/extensions/SponsorBlockExtension.ts +81 -0
- package/src/extensions/index.ts +9 -0
- package/src/index.ts +19 -0
- package/src/lavalink/ConnectionPool.ts +326 -0
- package/src/lavalink/HttpClient.ts +316 -0
- package/src/lavalink/LavalinkConnection.ts +409 -0
- package/src/lavalink/index.ts +7 -0
- package/src/metadata.ts +88 -0
- package/src/types/api/Rest.ts +949 -0
- package/src/types/api/Websocket.ts +463 -0
- package/src/types/api/index.ts +6 -0
- package/src/types/audio/FilterManager.ts +29 -0
- package/src/types/audio/Queue.ts +4 -0
- package/src/types/audio/QueueManager.ts +30 -0
- package/src/types/audio/index.ts +7 -0
- package/src/types/common.ts +63 -0
- package/src/types/core/Player.ts +322 -0
- package/src/types/core/index.ts +5 -0
- package/src/types/index.ts +6 -0
- package/src/types/lavalink/Node.ts +173 -0
- package/src/types/lavalink/NodeManager.ts +34 -0
- package/src/types/lavalink/REST.ts +144 -0
- package/src/types/lavalink/index.ts +32 -0
- package/src/types/voice/VoiceManager.ts +176 -0
- package/src/types/voice/index.ts +5 -0
- package/src/utils/helpers.ts +169 -0
- package/src/utils/index.ts +6 -0
- package/src/utils/validators.ts +184 -0
- package/src/voice/RegionSelector.ts +184 -0
- package/src/voice/VoiceConnection.ts +451 -0
- package/src/voice/VoiceSession.ts +297 -0
- 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,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
|
+
}
|