ziplayer 0.1.2 → 0.1.4
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/README.md +212 -212
- package/dist/plugins/SoundCloudPlugin.d.ts +22 -0
- package/dist/plugins/SoundCloudPlugin.d.ts.map +1 -0
- package/dist/plugins/SoundCloudPlugin.js +171 -0
- package/dist/plugins/SoundCloudPlugin.js.map +1 -0
- package/dist/plugins/SpotifyPlugin.d.ts +26 -0
- package/dist/plugins/SpotifyPlugin.d.ts.map +1 -0
- package/dist/plugins/SpotifyPlugin.js +183 -0
- package/dist/plugins/SpotifyPlugin.js.map +1 -0
- package/dist/plugins/YouTubePlugin.d.ts +25 -0
- package/dist/plugins/YouTubePlugin.d.ts.map +1 -0
- package/dist/plugins/YouTubePlugin.js +314 -0
- package/dist/plugins/YouTubePlugin.js.map +1 -0
- package/dist/structures/Player.d.ts +23 -13
- package/dist/structures/Player.d.ts.map +1 -1
- package/dist/structures/Player.js +143 -58
- package/dist/structures/Player.js.map +1 -1
- package/package.json +1 -1
- package/src/extensions/BaseExtension.ts +35 -35
- package/src/structures/Player.ts +1828 -1732
- package/src/structures/PlayerManager.ts +411 -411
- package/src/structures/Queue.ts +354 -354
- package/src/types/index.ts +470 -470
- package/src/utils/timeout.ts +10 -10
package/src/structures/Player.ts
CHANGED
|
@@ -1,1732 +1,1828 @@
|
|
|
1
|
-
import { EventEmitter } from "events";
|
|
2
|
-
import {
|
|
3
|
-
createAudioPlayer,
|
|
4
|
-
createAudioResource,
|
|
5
|
-
entersState,
|
|
6
|
-
AudioPlayerStatus,
|
|
7
|
-
VoiceConnection,
|
|
8
|
-
AudioPlayer as DiscordAudioPlayer,
|
|
9
|
-
VoiceConnectionStatus,
|
|
10
|
-
NoSubscriberBehavior,
|
|
11
|
-
joinVoiceChannel,
|
|
12
|
-
AudioResource,
|
|
13
|
-
StreamType,
|
|
14
|
-
} from "@discordjs/voice";
|
|
15
|
-
|
|
16
|
-
import { VoiceChannel } from "discord.js";
|
|
17
|
-
import { Readable } from "stream";
|
|
18
|
-
import { BaseExtension } from "../extensions";
|
|
19
|
-
import {
|
|
20
|
-
Track,
|
|
21
|
-
PlayerOptions,
|
|
22
|
-
PlayerEvents,
|
|
23
|
-
SourcePlugin,
|
|
24
|
-
SearchResult,
|
|
25
|
-
ProgressBarOptions,
|
|
26
|
-
LoopMode,
|
|
27
|
-
StreamInfo,
|
|
28
|
-
} from "../types";
|
|
29
|
-
import type {
|
|
30
|
-
ExtensionContext,
|
|
31
|
-
ExtensionPlayRequest,
|
|
32
|
-
ExtensionPlayResponse,
|
|
33
|
-
ExtensionAfterPlayPayload,
|
|
34
|
-
ExtensionStreamRequest,
|
|
35
|
-
ExtensionSearchRequest,
|
|
36
|
-
} from "../types";
|
|
37
|
-
import { Queue } from "./Queue";
|
|
38
|
-
import { PluginManager } from "../plugins";
|
|
39
|
-
import { withTimeout } from "../utils/timeout";
|
|
40
|
-
import type { PlayerManager } from "./PlayerManager";
|
|
41
|
-
export declare interface Player {
|
|
42
|
-
on<K extends keyof PlayerEvents>(event: K, listener: (...args: PlayerEvents[K]) => void): this;
|
|
43
|
-
emit<K extends keyof PlayerEvents>(event: K, ...args: PlayerEvents[K]): boolean;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Represents a music player for a specific Discord guild.
|
|
48
|
-
*
|
|
49
|
-
* @example
|
|
50
|
-
* // Create and configure player
|
|
51
|
-
* const player = await manager.create(guildId, {
|
|
52
|
-
* tts: { interrupt: true, volume: 1 },
|
|
53
|
-
* leaveOnEnd: true,
|
|
54
|
-
* leaveTimeout: 30000
|
|
55
|
-
* });
|
|
56
|
-
*
|
|
57
|
-
* // Connect to voice channel
|
|
58
|
-
* await player.connect(voiceChannel);
|
|
59
|
-
*
|
|
60
|
-
* // Play different types of content
|
|
61
|
-
* await player.play("Never Gonna Give You Up", userId); // Search query
|
|
62
|
-
* await player.play("https://youtube.com/watch?v=dQw4w9WgXcQ", userId); // Direct URL
|
|
63
|
-
* await player.play("tts: Hello everyone!", userId); // Text-to-Speech
|
|
64
|
-
*
|
|
65
|
-
* // Player controls
|
|
66
|
-
* player.pause(); // Pause current track
|
|
67
|
-
* player.resume(); // Resume paused track
|
|
68
|
-
* player.skip(); // Skip to next track
|
|
69
|
-
* player.stop(); // Stop and clear queue
|
|
70
|
-
* player.setVolume(0.5); // Set volume to 50%
|
|
71
|
-
*
|
|
72
|
-
* // Event handling
|
|
73
|
-
* player.on("trackStart", (player, track) => {
|
|
74
|
-
* console.log(`Now playing: ${track.title}`);
|
|
75
|
-
* });
|
|
76
|
-
*
|
|
77
|
-
* player.on("queueEnd", (player) => {
|
|
78
|
-
* console.log("Queue finished");
|
|
79
|
-
* });
|
|
80
|
-
*
|
|
81
|
-
*/
|
|
82
|
-
export class Player extends EventEmitter {
|
|
83
|
-
public readonly guildId: string;
|
|
84
|
-
public connection: VoiceConnection | null = null;
|
|
85
|
-
public audioPlayer: DiscordAudioPlayer;
|
|
86
|
-
public queue: Queue;
|
|
87
|
-
public volume: number = 100;
|
|
88
|
-
public isPlaying: boolean = false;
|
|
89
|
-
public isPaused: boolean = false;
|
|
90
|
-
public options: PlayerOptions;
|
|
91
|
-
public pluginManager: PluginManager;
|
|
92
|
-
public userdata?: Record<string, any>;
|
|
93
|
-
private manager: PlayerManager;
|
|
94
|
-
private leaveTimeout: NodeJS.Timeout | null = null;
|
|
95
|
-
private currentResource: AudioResource | null = null;
|
|
96
|
-
private volumeInterval: NodeJS.Timeout | null = null;
|
|
97
|
-
private skipLoop = false;
|
|
98
|
-
private extensions: BaseExtension[] = [];
|
|
99
|
-
private extensionContext!: ExtensionContext;
|
|
100
|
-
|
|
101
|
-
// Cache for plugin matching to improve performance
|
|
102
|
-
private pluginCache = new Map<string, SourcePlugin>();
|
|
103
|
-
private readonly PLUGIN_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
|
104
|
-
private pluginCacheTimestamps = new Map<string, number>();
|
|
105
|
-
|
|
106
|
-
// Cache for search results to avoid duplicate calls
|
|
107
|
-
private searchCache = new Map<string, SearchResult>();
|
|
108
|
-
private readonly SEARCH_CACHE_TTL = 2 * 60 * 1000; // 2 minutes
|
|
109
|
-
private searchCacheTimestamps = new Map<string, number>();
|
|
110
|
-
|
|
111
|
-
/**
|
|
112
|
-
* Attach an extension to the player
|
|
113
|
-
*
|
|
114
|
-
* @param {BaseExtension} extension - The extension to attach
|
|
115
|
-
* @example
|
|
116
|
-
* player.attachExtension(new MyExtension());
|
|
117
|
-
*/
|
|
118
|
-
public attachExtension(extension: BaseExtension): void {
|
|
119
|
-
if (this.extensions.includes(extension)) return;
|
|
120
|
-
if (!extension.player) extension.player = this;
|
|
121
|
-
this.extensions.push(extension);
|
|
122
|
-
this.invokeExtensionLifecycle(extension, "onRegister");
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
/**
|
|
126
|
-
* Detach an extension from the player
|
|
127
|
-
*
|
|
128
|
-
* @param {BaseExtension} extension - The extension to detach
|
|
129
|
-
* @example
|
|
130
|
-
* player.detachExtension(new MyExtension());
|
|
131
|
-
*/
|
|
132
|
-
public detachExtension(extension: BaseExtension): void {
|
|
133
|
-
const index = this.extensions.indexOf(extension);
|
|
134
|
-
if (index === -1) return;
|
|
135
|
-
this.extensions.splice(index, 1);
|
|
136
|
-
this.invokeExtensionLifecycle(extension, "onDestroy");
|
|
137
|
-
if (extension.player === this) {
|
|
138
|
-
extension.player = null;
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
/**
|
|
143
|
-
* Get all extensions attached to the player
|
|
144
|
-
*
|
|
145
|
-
* @returns {readonly BaseExtension[]} All attached extensions
|
|
146
|
-
* @example
|
|
147
|
-
* const extensions = player.getExtensions();
|
|
148
|
-
* console.log(`Extensions: ${extensions.length}`);
|
|
149
|
-
*/
|
|
150
|
-
public getExtensions(): readonly BaseExtension[] {
|
|
151
|
-
return this.extensions;
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
private invokeExtensionLifecycle(extension: BaseExtension, hook: "onRegister" | "onDestroy"): void {
|
|
155
|
-
const fn = (extension as any)[hook];
|
|
156
|
-
if (typeof fn !== "function") return;
|
|
157
|
-
try {
|
|
158
|
-
const result = fn.call(extension, this.extensionContext);
|
|
159
|
-
if (result && typeof (result as Promise<unknown>).then === "function") {
|
|
160
|
-
(result as Promise<unknown>).catch((err) => this.debug(`[Player] Extension ${extension.name} ${hook} error:`, err));
|
|
161
|
-
}
|
|
162
|
-
} catch (err) {
|
|
163
|
-
this.debug(`[Player] Extension ${extension.name} ${hook} error:`, err);
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
private async runBeforePlayHooks(
|
|
168
|
-
initial: ExtensionPlayRequest,
|
|
169
|
-
): Promise<{ request: ExtensionPlayRequest; response: ExtensionPlayResponse }> {
|
|
170
|
-
const request: ExtensionPlayRequest = { ...initial };
|
|
171
|
-
const response: ExtensionPlayResponse = {};
|
|
172
|
-
for (const extension of this.extensions) {
|
|
173
|
-
const hook = (extension as any).beforePlay;
|
|
174
|
-
if (typeof hook !== "function") continue;
|
|
175
|
-
try {
|
|
176
|
-
const result = await Promise.resolve(hook.call(extension, this.extensionContext, request));
|
|
177
|
-
if (!result) continue;
|
|
178
|
-
if (result.query !== undefined) {
|
|
179
|
-
request.query = result.query;
|
|
180
|
-
response.query = result.query;
|
|
181
|
-
}
|
|
182
|
-
if (result.requestedBy !== undefined) {
|
|
183
|
-
request.requestedBy = result.requestedBy;
|
|
184
|
-
response.requestedBy = result.requestedBy;
|
|
185
|
-
}
|
|
186
|
-
if (Array.isArray(result.tracks)) {
|
|
187
|
-
response.tracks = result.tracks;
|
|
188
|
-
}
|
|
189
|
-
if (typeof result.isPlaylist === "boolean") {
|
|
190
|
-
response.isPlaylist = result.isPlaylist;
|
|
191
|
-
}
|
|
192
|
-
if (typeof result.success === "boolean") {
|
|
193
|
-
response.success = result.success;
|
|
194
|
-
}
|
|
195
|
-
if (result.error instanceof Error) {
|
|
196
|
-
response.error = result.error;
|
|
197
|
-
}
|
|
198
|
-
if (typeof result.handled === "boolean") {
|
|
199
|
-
response.handled = result.handled;
|
|
200
|
-
if (result.handled) break;
|
|
201
|
-
}
|
|
202
|
-
} catch (err) {
|
|
203
|
-
this.debug(`[Player] Extension ${extension.name} beforePlay error:`, err);
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
return { request, response };
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
private async runAfterPlayHooks(payload: ExtensionAfterPlayPayload): Promise<void> {
|
|
210
|
-
if (this.extensions.length === 0) return;
|
|
211
|
-
const safeTracks = payload.tracks ? [...payload.tracks] : undefined;
|
|
212
|
-
if (safeTracks) {
|
|
213
|
-
Object.freeze(safeTracks);
|
|
214
|
-
}
|
|
215
|
-
const immutablePayload = Object.freeze({ ...payload, tracks: safeTracks });
|
|
216
|
-
for (const extension of this.extensions) {
|
|
217
|
-
const hook = (extension as any).afterPlay;
|
|
218
|
-
if (typeof hook !== "function") continue;
|
|
219
|
-
try {
|
|
220
|
-
await Promise.resolve(hook.call(extension, this.extensionContext, immutablePayload));
|
|
221
|
-
} catch (err) {
|
|
222
|
-
this.debug(`[Player] Extension ${extension.name} afterPlay error:`, err);
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
private async extensionsProvideSearch(query: string, requestedBy: string): Promise<SearchResult | null> {
|
|
228
|
-
const request: ExtensionSearchRequest = { query, requestedBy };
|
|
229
|
-
for (const extension of this.extensions) {
|
|
230
|
-
const hook = (extension as any).provideSearch;
|
|
231
|
-
if (typeof hook !== "function") continue;
|
|
232
|
-
try {
|
|
233
|
-
const result = await Promise.resolve(hook.call(extension, this.extensionContext, request));
|
|
234
|
-
if (result && Array.isArray(result.tracks) && result.tracks.length > 0) {
|
|
235
|
-
this.debug(`[Player] Extension ${extension.name} handled search for query: ${query}`);
|
|
236
|
-
return result as SearchResult;
|
|
237
|
-
}
|
|
238
|
-
} catch (err) {
|
|
239
|
-
this.debug(`[Player] Extension ${extension.name} provideSearch error:`, err);
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
return null;
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
private async extensionsProvideStream(track: Track): Promise<StreamInfo | null> {
|
|
246
|
-
const request: ExtensionStreamRequest = { track };
|
|
247
|
-
for (const extension of this.extensions) {
|
|
248
|
-
const hook = (extension as any).provideStream;
|
|
249
|
-
if (typeof hook !== "function") continue;
|
|
250
|
-
try {
|
|
251
|
-
const result = await Promise.resolve(hook.call(extension, this.extensionContext, request));
|
|
252
|
-
if (result && (result as StreamInfo).stream) {
|
|
253
|
-
this.debug(`[Player] Extension ${extension.name} provided stream for track: ${track.title}`);
|
|
254
|
-
return result as StreamInfo;
|
|
255
|
-
}
|
|
256
|
-
} catch (err) {
|
|
257
|
-
this.debug(`[Player] Extension ${extension.name} provideStream error:`, err);
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
return null;
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
/**
|
|
264
|
-
* Start playing a specific track immediately, replacing the current resource.
|
|
265
|
-
*/
|
|
266
|
-
private async startTrack(track: Track): Promise<boolean> {
|
|
267
|
-
try {
|
|
268
|
-
let streamInfo: StreamInfo | null = await this.extensionsProvideStream(track);
|
|
269
|
-
let plugin: SourcePlugin | undefined;
|
|
270
|
-
|
|
271
|
-
if (!streamInfo) {
|
|
272
|
-
plugin = this.pluginManager.findPlugin(track.url) || this.pluginManager.get(track.source);
|
|
273
|
-
|
|
274
|
-
if (!plugin) {
|
|
275
|
-
this.debug(`[Player] No plugin found for track: ${track.title}`);
|
|
276
|
-
throw new Error(`No plugin found for track: ${track.title}`);
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
this.debug(`[Player] Getting stream for track: ${track.title}`);
|
|
280
|
-
this.debug(`[Player] Using plugin: ${plugin.name}`);
|
|
281
|
-
this.debug(`[Track] Track Info:`, track);
|
|
282
|
-
try {
|
|
283
|
-
streamInfo = await withTimeout(plugin.getStream(track), this.options.extractorTimeout ?? 15000, "getStream timed out");
|
|
284
|
-
} catch (streamError) {
|
|
285
|
-
this.debug(`[Player] getStream failed, trying getFallback:`, streamError);
|
|
286
|
-
const allplugs = this.pluginManager.getAll();
|
|
287
|
-
for (const p of allplugs) {
|
|
288
|
-
if (typeof (p as any).getFallback !== "function") {
|
|
289
|
-
continue;
|
|
290
|
-
}
|
|
291
|
-
try {
|
|
292
|
-
streamInfo = await withTimeout(
|
|
293
|
-
(p as any).
|
|
294
|
-
this.options.extractorTimeout ?? 15000,
|
|
295
|
-
`
|
|
296
|
-
);
|
|
297
|
-
if (
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
return StreamType.
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
this.
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
this.
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
});
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
this.debug(
|
|
504
|
-
this.
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
this.debug(`[Player]
|
|
509
|
-
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
*
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
connection.on(
|
|
540
|
-
this.debug(`[Player]
|
|
541
|
-
this.
|
|
542
|
-
});
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
this.
|
|
549
|
-
|
|
550
|
-
this.
|
|
551
|
-
|
|
552
|
-
}
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
*
|
|
562
|
-
*
|
|
563
|
-
*
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
);
|
|
614
|
-
const
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
}
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
*
|
|
642
|
-
*
|
|
643
|
-
*
|
|
644
|
-
*
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
tracksToAdd =
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
tracks
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
this.
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
}
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
const
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
this.
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
*
|
|
894
|
-
* @
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
const
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
this.
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
const
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
}
|
|
920
|
-
|
|
921
|
-
return
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
*
|
|
952
|
-
* @
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
const
|
|
984
|
-
this.
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
const
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
this.
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
*
|
|
1327
|
-
*
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
*
|
|
1371
|
-
*
|
|
1372
|
-
* @
|
|
1373
|
-
*
|
|
1374
|
-
*
|
|
1375
|
-
*
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
*
|
|
1436
|
-
*
|
|
1437
|
-
*
|
|
1438
|
-
*
|
|
1439
|
-
* player.
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
return
|
|
1516
|
-
}
|
|
1517
|
-
|
|
1518
|
-
/**
|
|
1519
|
-
*
|
|
1520
|
-
*
|
|
1521
|
-
* @
|
|
1522
|
-
* @
|
|
1523
|
-
*
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
*
|
|
1546
|
-
*
|
|
1547
|
-
*
|
|
1548
|
-
* @
|
|
1549
|
-
*
|
|
1550
|
-
*
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
*
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
*
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1
|
+
import { EventEmitter } from "events";
|
|
2
|
+
import {
|
|
3
|
+
createAudioPlayer,
|
|
4
|
+
createAudioResource,
|
|
5
|
+
entersState,
|
|
6
|
+
AudioPlayerStatus,
|
|
7
|
+
VoiceConnection,
|
|
8
|
+
AudioPlayer as DiscordAudioPlayer,
|
|
9
|
+
VoiceConnectionStatus,
|
|
10
|
+
NoSubscriberBehavior,
|
|
11
|
+
joinVoiceChannel,
|
|
12
|
+
AudioResource,
|
|
13
|
+
StreamType,
|
|
14
|
+
} from "@discordjs/voice";
|
|
15
|
+
|
|
16
|
+
import { VoiceChannel } from "discord.js";
|
|
17
|
+
import { Readable } from "stream";
|
|
18
|
+
import { BaseExtension } from "../extensions";
|
|
19
|
+
import {
|
|
20
|
+
Track,
|
|
21
|
+
PlayerOptions,
|
|
22
|
+
PlayerEvents,
|
|
23
|
+
SourcePlugin,
|
|
24
|
+
SearchResult,
|
|
25
|
+
ProgressBarOptions,
|
|
26
|
+
LoopMode,
|
|
27
|
+
StreamInfo,
|
|
28
|
+
} from "../types";
|
|
29
|
+
import type {
|
|
30
|
+
ExtensionContext,
|
|
31
|
+
ExtensionPlayRequest,
|
|
32
|
+
ExtensionPlayResponse,
|
|
33
|
+
ExtensionAfterPlayPayload,
|
|
34
|
+
ExtensionStreamRequest,
|
|
35
|
+
ExtensionSearchRequest,
|
|
36
|
+
} from "../types";
|
|
37
|
+
import { Queue } from "./Queue";
|
|
38
|
+
import { PluginManager } from "../plugins";
|
|
39
|
+
import { withTimeout } from "../utils/timeout";
|
|
40
|
+
import type { PlayerManager } from "./PlayerManager";
|
|
41
|
+
export declare interface Player {
|
|
42
|
+
on<K extends keyof PlayerEvents>(event: K, listener: (...args: PlayerEvents[K]) => void): this;
|
|
43
|
+
emit<K extends keyof PlayerEvents>(event: K, ...args: PlayerEvents[K]): boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Represents a music player for a specific Discord guild.
|
|
48
|
+
*
|
|
49
|
+
* @example
|
|
50
|
+
* // Create and configure player
|
|
51
|
+
* const player = await manager.create(guildId, {
|
|
52
|
+
* tts: { interrupt: true, volume: 1 },
|
|
53
|
+
* leaveOnEnd: true,
|
|
54
|
+
* leaveTimeout: 30000
|
|
55
|
+
* });
|
|
56
|
+
*
|
|
57
|
+
* // Connect to voice channel
|
|
58
|
+
* await player.connect(voiceChannel);
|
|
59
|
+
*
|
|
60
|
+
* // Play different types of content
|
|
61
|
+
* await player.play("Never Gonna Give You Up", userId); // Search query
|
|
62
|
+
* await player.play("https://youtube.com/watch?v=dQw4w9WgXcQ", userId); // Direct URL
|
|
63
|
+
* await player.play("tts: Hello everyone!", userId); // Text-to-Speech
|
|
64
|
+
*
|
|
65
|
+
* // Player controls
|
|
66
|
+
* player.pause(); // Pause current track
|
|
67
|
+
* player.resume(); // Resume paused track
|
|
68
|
+
* player.skip(); // Skip to next track
|
|
69
|
+
* player.stop(); // Stop and clear queue
|
|
70
|
+
* player.setVolume(0.5); // Set volume to 50%
|
|
71
|
+
*
|
|
72
|
+
* // Event handling
|
|
73
|
+
* player.on("trackStart", (player, track) => {
|
|
74
|
+
* console.log(`Now playing: ${track.title}`);
|
|
75
|
+
* });
|
|
76
|
+
*
|
|
77
|
+
* player.on("queueEnd", (player) => {
|
|
78
|
+
* console.log("Queue finished");
|
|
79
|
+
* });
|
|
80
|
+
*
|
|
81
|
+
*/
|
|
82
|
+
export class Player extends EventEmitter {
|
|
83
|
+
public readonly guildId: string;
|
|
84
|
+
public connection: VoiceConnection | null = null;
|
|
85
|
+
public audioPlayer: DiscordAudioPlayer;
|
|
86
|
+
public queue: Queue;
|
|
87
|
+
public volume: number = 100;
|
|
88
|
+
public isPlaying: boolean = false;
|
|
89
|
+
public isPaused: boolean = false;
|
|
90
|
+
public options: PlayerOptions;
|
|
91
|
+
public pluginManager: PluginManager;
|
|
92
|
+
public userdata?: Record<string, any>;
|
|
93
|
+
private manager: PlayerManager;
|
|
94
|
+
private leaveTimeout: NodeJS.Timeout | null = null;
|
|
95
|
+
private currentResource: AudioResource | null = null;
|
|
96
|
+
private volumeInterval: NodeJS.Timeout | null = null;
|
|
97
|
+
private skipLoop = false;
|
|
98
|
+
private extensions: BaseExtension[] = [];
|
|
99
|
+
private extensionContext!: ExtensionContext;
|
|
100
|
+
|
|
101
|
+
// Cache for plugin matching to improve performance
|
|
102
|
+
private pluginCache = new Map<string, SourcePlugin>();
|
|
103
|
+
private readonly PLUGIN_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
|
104
|
+
private pluginCacheTimestamps = new Map<string, number>();
|
|
105
|
+
|
|
106
|
+
// Cache for search results to avoid duplicate calls
|
|
107
|
+
private searchCache = new Map<string, SearchResult>();
|
|
108
|
+
private readonly SEARCH_CACHE_TTL = 2 * 60 * 1000; // 2 minutes
|
|
109
|
+
private searchCacheTimestamps = new Map<string, number>();
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Attach an extension to the player
|
|
113
|
+
*
|
|
114
|
+
* @param {BaseExtension} extension - The extension to attach
|
|
115
|
+
* @example
|
|
116
|
+
* player.attachExtension(new MyExtension());
|
|
117
|
+
*/
|
|
118
|
+
public attachExtension(extension: BaseExtension): void {
|
|
119
|
+
if (this.extensions.includes(extension)) return;
|
|
120
|
+
if (!extension.player) extension.player = this;
|
|
121
|
+
this.extensions.push(extension);
|
|
122
|
+
this.invokeExtensionLifecycle(extension, "onRegister");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Detach an extension from the player
|
|
127
|
+
*
|
|
128
|
+
* @param {BaseExtension} extension - The extension to detach
|
|
129
|
+
* @example
|
|
130
|
+
* player.detachExtension(new MyExtension());
|
|
131
|
+
*/
|
|
132
|
+
public detachExtension(extension: BaseExtension): void {
|
|
133
|
+
const index = this.extensions.indexOf(extension);
|
|
134
|
+
if (index === -1) return;
|
|
135
|
+
this.extensions.splice(index, 1);
|
|
136
|
+
this.invokeExtensionLifecycle(extension, "onDestroy");
|
|
137
|
+
if (extension.player === this) {
|
|
138
|
+
extension.player = null;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Get all extensions attached to the player
|
|
144
|
+
*
|
|
145
|
+
* @returns {readonly BaseExtension[]} All attached extensions
|
|
146
|
+
* @example
|
|
147
|
+
* const extensions = player.getExtensions();
|
|
148
|
+
* console.log(`Extensions: ${extensions.length}`);
|
|
149
|
+
*/
|
|
150
|
+
public getExtensions(): readonly BaseExtension[] {
|
|
151
|
+
return this.extensions;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
private invokeExtensionLifecycle(extension: BaseExtension, hook: "onRegister" | "onDestroy"): void {
|
|
155
|
+
const fn = (extension as any)[hook];
|
|
156
|
+
if (typeof fn !== "function") return;
|
|
157
|
+
try {
|
|
158
|
+
const result = fn.call(extension, this.extensionContext);
|
|
159
|
+
if (result && typeof (result as Promise<unknown>).then === "function") {
|
|
160
|
+
(result as Promise<unknown>).catch((err) => this.debug(`[Player] Extension ${extension.name} ${hook} error:`, err));
|
|
161
|
+
}
|
|
162
|
+
} catch (err) {
|
|
163
|
+
this.debug(`[Player] Extension ${extension.name} ${hook} error:`, err);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
private async runBeforePlayHooks(
|
|
168
|
+
initial: ExtensionPlayRequest,
|
|
169
|
+
): Promise<{ request: ExtensionPlayRequest; response: ExtensionPlayResponse }> {
|
|
170
|
+
const request: ExtensionPlayRequest = { ...initial };
|
|
171
|
+
const response: ExtensionPlayResponse = {};
|
|
172
|
+
for (const extension of this.extensions) {
|
|
173
|
+
const hook = (extension as any).beforePlay;
|
|
174
|
+
if (typeof hook !== "function") continue;
|
|
175
|
+
try {
|
|
176
|
+
const result = await Promise.resolve(hook.call(extension, this.extensionContext, request));
|
|
177
|
+
if (!result) continue;
|
|
178
|
+
if (result.query !== undefined) {
|
|
179
|
+
request.query = result.query;
|
|
180
|
+
response.query = result.query;
|
|
181
|
+
}
|
|
182
|
+
if (result.requestedBy !== undefined) {
|
|
183
|
+
request.requestedBy = result.requestedBy;
|
|
184
|
+
response.requestedBy = result.requestedBy;
|
|
185
|
+
}
|
|
186
|
+
if (Array.isArray(result.tracks)) {
|
|
187
|
+
response.tracks = result.tracks;
|
|
188
|
+
}
|
|
189
|
+
if (typeof result.isPlaylist === "boolean") {
|
|
190
|
+
response.isPlaylist = result.isPlaylist;
|
|
191
|
+
}
|
|
192
|
+
if (typeof result.success === "boolean") {
|
|
193
|
+
response.success = result.success;
|
|
194
|
+
}
|
|
195
|
+
if (result.error instanceof Error) {
|
|
196
|
+
response.error = result.error;
|
|
197
|
+
}
|
|
198
|
+
if (typeof result.handled === "boolean") {
|
|
199
|
+
response.handled = result.handled;
|
|
200
|
+
if (result.handled) break;
|
|
201
|
+
}
|
|
202
|
+
} catch (err) {
|
|
203
|
+
this.debug(`[Player] Extension ${extension.name} beforePlay error:`, err);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return { request, response };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
private async runAfterPlayHooks(payload: ExtensionAfterPlayPayload): Promise<void> {
|
|
210
|
+
if (this.extensions.length === 0) return;
|
|
211
|
+
const safeTracks = payload.tracks ? [...payload.tracks] : undefined;
|
|
212
|
+
if (safeTracks) {
|
|
213
|
+
Object.freeze(safeTracks);
|
|
214
|
+
}
|
|
215
|
+
const immutablePayload = Object.freeze({ ...payload, tracks: safeTracks });
|
|
216
|
+
for (const extension of this.extensions) {
|
|
217
|
+
const hook = (extension as any).afterPlay;
|
|
218
|
+
if (typeof hook !== "function") continue;
|
|
219
|
+
try {
|
|
220
|
+
await Promise.resolve(hook.call(extension, this.extensionContext, immutablePayload));
|
|
221
|
+
} catch (err) {
|
|
222
|
+
this.debug(`[Player] Extension ${extension.name} afterPlay error:`, err);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
private async extensionsProvideSearch(query: string, requestedBy: string): Promise<SearchResult | null> {
|
|
228
|
+
const request: ExtensionSearchRequest = { query, requestedBy };
|
|
229
|
+
for (const extension of this.extensions) {
|
|
230
|
+
const hook = (extension as any).provideSearch;
|
|
231
|
+
if (typeof hook !== "function") continue;
|
|
232
|
+
try {
|
|
233
|
+
const result = await Promise.resolve(hook.call(extension, this.extensionContext, request));
|
|
234
|
+
if (result && Array.isArray(result.tracks) && result.tracks.length > 0) {
|
|
235
|
+
this.debug(`[Player] Extension ${extension.name} handled search for query: ${query}`);
|
|
236
|
+
return result as SearchResult;
|
|
237
|
+
}
|
|
238
|
+
} catch (err) {
|
|
239
|
+
this.debug(`[Player] Extension ${extension.name} provideSearch error:`, err);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
private async extensionsProvideStream(track: Track): Promise<StreamInfo | null> {
|
|
246
|
+
const request: ExtensionStreamRequest = { track };
|
|
247
|
+
for (const extension of this.extensions) {
|
|
248
|
+
const hook = (extension as any).provideStream;
|
|
249
|
+
if (typeof hook !== "function") continue;
|
|
250
|
+
try {
|
|
251
|
+
const result = await Promise.resolve(hook.call(extension, this.extensionContext, request));
|
|
252
|
+
if (result && (result as StreamInfo).stream) {
|
|
253
|
+
this.debug(`[Player] Extension ${extension.name} provided stream for track: ${track.title}`);
|
|
254
|
+
return result as StreamInfo;
|
|
255
|
+
}
|
|
256
|
+
} catch (err) {
|
|
257
|
+
this.debug(`[Player] Extension ${extension.name} provideStream error:`, err);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return null;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Start playing a specific track immediately, replacing the current resource.
|
|
265
|
+
*/
|
|
266
|
+
private async startTrack(track: Track): Promise<boolean> {
|
|
267
|
+
try {
|
|
268
|
+
let streamInfo: StreamInfo | null = await this.extensionsProvideStream(track);
|
|
269
|
+
let plugin: SourcePlugin | undefined;
|
|
270
|
+
|
|
271
|
+
if (!streamInfo) {
|
|
272
|
+
plugin = this.pluginManager.findPlugin(track.url) || this.pluginManager.get(track.source);
|
|
273
|
+
|
|
274
|
+
if (!plugin) {
|
|
275
|
+
this.debug(`[Player] No plugin found for track: ${track.title}`);
|
|
276
|
+
throw new Error(`No plugin found for track: ${track.title}`);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
this.debug(`[Player] Getting stream for track: ${track.title}`);
|
|
280
|
+
this.debug(`[Player] Using plugin: ${plugin.name}`);
|
|
281
|
+
this.debug(`[Track] Track Info:`, track);
|
|
282
|
+
try {
|
|
283
|
+
streamInfo = await withTimeout(plugin.getStream(track), this.options.extractorTimeout ?? 15000, "getStream timed out");
|
|
284
|
+
} catch (streamError) {
|
|
285
|
+
this.debug(`[Player] getStream failed, trying getFallback:`, streamError);
|
|
286
|
+
const allplugs = this.pluginManager.getAll();
|
|
287
|
+
for (const p of allplugs) {
|
|
288
|
+
if (typeof (p as any).getFallback !== "function" && typeof (p as any).getStream !== "function") {
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
try {
|
|
292
|
+
streamInfo = await withTimeout(
|
|
293
|
+
(p as any).getStream(track),
|
|
294
|
+
this.options.extractorTimeout ?? 15000,
|
|
295
|
+
`getStream timed out for plugin ${p.name}`,
|
|
296
|
+
);
|
|
297
|
+
if ((streamInfo as any)?.stream) {
|
|
298
|
+
this.debug(`[Player] getStream succeeded with plugin ${p.name} for track: ${track.title}`);
|
|
299
|
+
break;
|
|
300
|
+
}
|
|
301
|
+
streamInfo = await withTimeout(
|
|
302
|
+
(p as any).getFallback(track),
|
|
303
|
+
this.options.extractorTimeout ?? 15000,
|
|
304
|
+
`getFallback timed out for plugin ${p.name}`,
|
|
305
|
+
);
|
|
306
|
+
if (!(streamInfo as any)?.stream) continue;
|
|
307
|
+
break;
|
|
308
|
+
} catch (fallbackError) {
|
|
309
|
+
this.debug(`[Player] getFallback failed with plugin ${p.name}:`, fallbackError);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
if (!(streamInfo as any)?.stream) {
|
|
313
|
+
throw new Error(`All getFallback attempts failed for track: ${track.title}`);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
this.debug(streamInfo);
|
|
317
|
+
} else {
|
|
318
|
+
this.debug(`[Player] Using extension-provided stream for track: ${track.title}`);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Kiểm tra nếu có stream thực sự để tạo AudioResource
|
|
322
|
+
if (streamInfo && (streamInfo as any).stream) {
|
|
323
|
+
function mapToStreamType(type: string | undefined): StreamType {
|
|
324
|
+
switch (type) {
|
|
325
|
+
case "webm/opus":
|
|
326
|
+
return StreamType.WebmOpus;
|
|
327
|
+
case "ogg/opus":
|
|
328
|
+
return StreamType.OggOpus;
|
|
329
|
+
case "arbitrary":
|
|
330
|
+
default:
|
|
331
|
+
return StreamType.Arbitrary;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const stream: Readable = (streamInfo as StreamInfo).stream;
|
|
336
|
+
const inputType = mapToStreamType((streamInfo as StreamInfo).type);
|
|
337
|
+
|
|
338
|
+
this.currentResource = createAudioResource(stream, {
|
|
339
|
+
metadata: track,
|
|
340
|
+
inputType,
|
|
341
|
+
inlineVolume: true,
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
// Apply initial volume using the resource's VolumeTransformer
|
|
345
|
+
if (this.volumeInterval) {
|
|
346
|
+
clearInterval(this.volumeInterval);
|
|
347
|
+
this.volumeInterval = null;
|
|
348
|
+
}
|
|
349
|
+
this.currentResource.volume?.setVolume(this.volume / 100);
|
|
350
|
+
|
|
351
|
+
this.debug(`[Player] Playing resource for track: ${track.title}`);
|
|
352
|
+
this.audioPlayer.play(this.currentResource);
|
|
353
|
+
|
|
354
|
+
await entersState(this.audioPlayer, AudioPlayerStatus.Playing, 5_000);
|
|
355
|
+
return true;
|
|
356
|
+
} else if (streamInfo && !(streamInfo as any).stream) {
|
|
357
|
+
// Extension đang xử lý phát nhạc (như Lavalink) - chỉ đánh dấu đang phát
|
|
358
|
+
this.debug(`[Player] Extension is handling playback for track: ${track.title}`);
|
|
359
|
+
this.isPlaying = true;
|
|
360
|
+
this.isPaused = false;
|
|
361
|
+
this.emit("trackStart", track);
|
|
362
|
+
return true;
|
|
363
|
+
} else {
|
|
364
|
+
throw new Error(`No stream available for track: ${track.title}`);
|
|
365
|
+
}
|
|
366
|
+
} catch (error) {
|
|
367
|
+
this.debug(`[Player] startTrack error:`, error);
|
|
368
|
+
this.emit("playerError", error as Error, track);
|
|
369
|
+
return false;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// TTS support
|
|
374
|
+
private ttsPlayer: DiscordAudioPlayer | null = null;
|
|
375
|
+
private ttsQueue: Array<Track> = [];
|
|
376
|
+
private ttsActive = false;
|
|
377
|
+
private clearLeaveTimeout(): void {
|
|
378
|
+
if (this.leaveTimeout) {
|
|
379
|
+
clearTimeout(this.leaveTimeout);
|
|
380
|
+
this.leaveTimeout = null;
|
|
381
|
+
this.debug(`[Player] Cleared leave timeout`);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
private debug(message?: any, ...optionalParams: any[]): void {
|
|
386
|
+
if (this.listenerCount("debug") > 0) {
|
|
387
|
+
this.emit("debug", message, ...optionalParams);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
constructor(guildId: string, options: PlayerOptions = {}, manager: PlayerManager) {
|
|
392
|
+
super();
|
|
393
|
+
this.debug(`[Player] Constructor called for guildId: ${guildId}`);
|
|
394
|
+
this.guildId = guildId;
|
|
395
|
+
this.queue = new Queue();
|
|
396
|
+
this.manager = manager;
|
|
397
|
+
this.audioPlayer = createAudioPlayer({
|
|
398
|
+
behaviors: {
|
|
399
|
+
noSubscriber: NoSubscriberBehavior.Pause,
|
|
400
|
+
maxMissedFrames: 100,
|
|
401
|
+
},
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
this.pluginManager = new PluginManager();
|
|
405
|
+
|
|
406
|
+
this.options = {
|
|
407
|
+
leaveOnEnd: true,
|
|
408
|
+
leaveOnEmpty: true,
|
|
409
|
+
leaveTimeout: 100000,
|
|
410
|
+
volume: 100,
|
|
411
|
+
quality: "high",
|
|
412
|
+
extractorTimeout: 50000,
|
|
413
|
+
selfDeaf: true,
|
|
414
|
+
selfMute: false,
|
|
415
|
+
...options,
|
|
416
|
+
tts: {
|
|
417
|
+
createPlayer: false,
|
|
418
|
+
interrupt: true,
|
|
419
|
+
volume: 100,
|
|
420
|
+
Max_Time_TTS: 60_000,
|
|
421
|
+
...(options?.tts || {}),
|
|
422
|
+
},
|
|
423
|
+
};
|
|
424
|
+
|
|
425
|
+
this.volume = this.options.volume || 100;
|
|
426
|
+
this.userdata = this.options.userdata;
|
|
427
|
+
this.setupEventListeners();
|
|
428
|
+
this.extensionContext = Object.freeze({ player: this, manager });
|
|
429
|
+
|
|
430
|
+
// Optionally pre-create the TTS AudioPlayer
|
|
431
|
+
if (this.options?.tts?.createPlayer) {
|
|
432
|
+
this.ensureTTSPlayer();
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
private setupEventListeners(): void {
|
|
437
|
+
this.audioPlayer.on("stateChange", (oldState, newState) => {
|
|
438
|
+
this.debug(`[Player] AudioPlayer stateChange from ${oldState.status} to ${newState.status}`);
|
|
439
|
+
if (newState.status === AudioPlayerStatus.Idle && oldState.status !== AudioPlayerStatus.Idle) {
|
|
440
|
+
// Track ended
|
|
441
|
+
const track = this.queue.currentTrack;
|
|
442
|
+
if (track) {
|
|
443
|
+
this.debug(`[Player] Track ended: ${track.title}`);
|
|
444
|
+
this.emit("trackEnd", track);
|
|
445
|
+
}
|
|
446
|
+
this.playNext();
|
|
447
|
+
} else if (
|
|
448
|
+
newState.status === AudioPlayerStatus.Playing &&
|
|
449
|
+
(oldState.status === AudioPlayerStatus.Idle || oldState.status === AudioPlayerStatus.Buffering)
|
|
450
|
+
) {
|
|
451
|
+
// Track started
|
|
452
|
+
this.clearLeaveTimeout();
|
|
453
|
+
this.isPlaying = true;
|
|
454
|
+
this.isPaused = false;
|
|
455
|
+
const track = this.queue.currentTrack;
|
|
456
|
+
if (track) {
|
|
457
|
+
this.debug(`[Player] Track started: ${track.title}`);
|
|
458
|
+
this.emit("trackStart", track);
|
|
459
|
+
}
|
|
460
|
+
} else if (newState.status === AudioPlayerStatus.Paused && oldState.status !== AudioPlayerStatus.Paused) {
|
|
461
|
+
// Track paused
|
|
462
|
+
this.isPaused = true;
|
|
463
|
+
const track = this.queue.currentTrack;
|
|
464
|
+
if (track) {
|
|
465
|
+
this.debug(`[Player] Player paused on track: ${track.title}`);
|
|
466
|
+
this.emit("playerPause", track);
|
|
467
|
+
}
|
|
468
|
+
} else if (newState.status !== AudioPlayerStatus.Paused && oldState.status === AudioPlayerStatus.Paused) {
|
|
469
|
+
// Track resumed
|
|
470
|
+
this.isPaused = false;
|
|
471
|
+
const track = this.queue.currentTrack;
|
|
472
|
+
if (track) {
|
|
473
|
+
this.debug(`[Player] Player resumed on track: ${track.title}`);
|
|
474
|
+
this.emit("playerResume", track);
|
|
475
|
+
}
|
|
476
|
+
} else if (newState.status === AudioPlayerStatus.AutoPaused) {
|
|
477
|
+
this.debug(`[Player] AudioPlayerStatus.AutoPaused`);
|
|
478
|
+
} else if (newState.status === AudioPlayerStatus.Buffering) {
|
|
479
|
+
this.debug(`[Player] AudioPlayerStatus.Buffering`);
|
|
480
|
+
}
|
|
481
|
+
});
|
|
482
|
+
this.audioPlayer.on("error", (error) => {
|
|
483
|
+
this.debug(`[Player] AudioPlayer error:`, error);
|
|
484
|
+
this.emit("playerError", error, this.queue.currentTrack || undefined);
|
|
485
|
+
this.playNext();
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
this.audioPlayer.on("debug", (...args) => {
|
|
489
|
+
if (this.manager.debugEnabled) {
|
|
490
|
+
this.emit("debug", ...args);
|
|
491
|
+
}
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
private ensureTTSPlayer(): DiscordAudioPlayer {
|
|
496
|
+
if (this.ttsPlayer) return this.ttsPlayer;
|
|
497
|
+
this.ttsPlayer = createAudioPlayer({
|
|
498
|
+
behaviors: {
|
|
499
|
+
noSubscriber: NoSubscriberBehavior.Pause,
|
|
500
|
+
maxMissedFrames: 100,
|
|
501
|
+
},
|
|
502
|
+
});
|
|
503
|
+
this.ttsPlayer.on("error", (e) => this.debug("[TTS] error:", e));
|
|
504
|
+
return this.ttsPlayer;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
addPlugin(plugin: SourcePlugin): void {
|
|
508
|
+
this.debug(`[Player] Adding plugin: ${plugin.name}`);
|
|
509
|
+
this.pluginManager.register(plugin);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
removePlugin(name: string): boolean {
|
|
513
|
+
this.debug(`[Player] Removing plugin: ${name}`);
|
|
514
|
+
return this.pluginManager.unregister(name);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* Connect to a voice channel
|
|
519
|
+
*
|
|
520
|
+
* @param {VoiceChannel} channel - Discord voice channel
|
|
521
|
+
* @returns {Promise<VoiceConnection>} The voice connection
|
|
522
|
+
* @example
|
|
523
|
+
* await player.connect(voiceChannel);
|
|
524
|
+
*/
|
|
525
|
+
async connect(channel: VoiceChannel): Promise<VoiceConnection> {
|
|
526
|
+
try {
|
|
527
|
+
this.debug(`[Player] Connecting to voice channel: ${channel.id}`);
|
|
528
|
+
const connection = joinVoiceChannel({
|
|
529
|
+
channelId: channel.id,
|
|
530
|
+
guildId: channel.guildId,
|
|
531
|
+
adapterCreator: channel.guild.voiceAdapterCreator as any,
|
|
532
|
+
selfDeaf: this.options.selfDeaf ?? true,
|
|
533
|
+
selfMute: this.options.selfMute ?? false,
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
await entersState(connection, VoiceConnectionStatus.Ready, 50_000);
|
|
537
|
+
this.connection = connection;
|
|
538
|
+
|
|
539
|
+
connection.on(VoiceConnectionStatus.Disconnected, () => {
|
|
540
|
+
this.debug(`[Player] VoiceConnectionStatus.Disconnected`);
|
|
541
|
+
this.destroy();
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
connection.on("error", (error) => {
|
|
545
|
+
this.debug(`[Player] Voice connection error:`, error);
|
|
546
|
+
this.emit("connectionError", error);
|
|
547
|
+
});
|
|
548
|
+
connection.subscribe(this.audioPlayer);
|
|
549
|
+
|
|
550
|
+
this.clearLeaveTimeout();
|
|
551
|
+
return this.connection;
|
|
552
|
+
} catch (error) {
|
|
553
|
+
this.debug(`[Player] Connection error:`, error);
|
|
554
|
+
this.emit("connectionError", error as Error);
|
|
555
|
+
this.connection?.destroy();
|
|
556
|
+
throw error;
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* Search for tracks using the player's extensions and plugins
|
|
562
|
+
*
|
|
563
|
+
* @param {string} query - The query to search for
|
|
564
|
+
* @param {string} requestedBy - The user ID who requested the search
|
|
565
|
+
* @returns {Promise<SearchResult>} The search result
|
|
566
|
+
* @example
|
|
567
|
+
* const result = await player.search("Never Gonna Give You Up", userId);
|
|
568
|
+
* console.log(`Search result: ${result.tracks.length} tracks`);
|
|
569
|
+
*/
|
|
570
|
+
async search(query: string, requestedBy: string): Promise<SearchResult> {
|
|
571
|
+
this.debug(`[Player] Search called with query: ${query}, requestedBy: ${requestedBy}`);
|
|
572
|
+
|
|
573
|
+
// Clear expired search cache periodically
|
|
574
|
+
if (Math.random() < 0.1) {
|
|
575
|
+
// 10% chance to clean cache
|
|
576
|
+
this.clearExpiredSearchCache();
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// Check cache first
|
|
580
|
+
const cachedResult = this.getCachedSearchResult(query);
|
|
581
|
+
if (cachedResult) {
|
|
582
|
+
return cachedResult;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// Try extensions first
|
|
586
|
+
const extensionResult = await this.extensionsProvideSearch(query, requestedBy);
|
|
587
|
+
if (extensionResult && Array.isArray(extensionResult.tracks) && extensionResult.tracks.length > 0) {
|
|
588
|
+
this.debug(`[Player] Extension handled search for query: ${query}`);
|
|
589
|
+
this.cacheSearchResult(query, extensionResult);
|
|
590
|
+
return extensionResult;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// Get plugins and filter out TTS for regular searches
|
|
594
|
+
const allPlugins = this.pluginManager.getAll();
|
|
595
|
+
const plugins = allPlugins.filter((p) => {
|
|
596
|
+
// Skip TTS plugin for regular searches (unless query starts with "tts:")
|
|
597
|
+
if (p.name.toLowerCase() === "tts" && !query.toLowerCase().startsWith("tts:")) {
|
|
598
|
+
this.debug(`[Player] Skipping TTS plugin for regular search: ${query}`);
|
|
599
|
+
return false;
|
|
600
|
+
}
|
|
601
|
+
return true;
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
this.debug(`[Player] Using ${plugins.length} plugins for search (filtered from ${allPlugins.length})`);
|
|
605
|
+
|
|
606
|
+
let lastError: any = null;
|
|
607
|
+
let searchAttempts = 0;
|
|
608
|
+
|
|
609
|
+
for (const p of plugins) {
|
|
610
|
+
searchAttempts++;
|
|
611
|
+
try {
|
|
612
|
+
this.debug(`[Player] Trying plugin for search: ${p.name} (attempt ${searchAttempts}/${plugins.length})`);
|
|
613
|
+
const startTime = Date.now();
|
|
614
|
+
const res = await withTimeout(
|
|
615
|
+
p.search(query, requestedBy),
|
|
616
|
+
this.options.extractorTimeout ?? 15000,
|
|
617
|
+
`Search operation timed out for ${p.name}`,
|
|
618
|
+
);
|
|
619
|
+
const duration = Date.now() - startTime;
|
|
620
|
+
|
|
621
|
+
if (res && Array.isArray(res.tracks) && res.tracks.length > 0) {
|
|
622
|
+
this.debug(`[Player] Plugin '${p.name}' returned ${res.tracks.length} tracks in ${duration}ms`);
|
|
623
|
+
this.cacheSearchResult(query, res);
|
|
624
|
+
return res;
|
|
625
|
+
}
|
|
626
|
+
this.debug(`[Player] Plugin '${p.name}' returned no tracks in ${duration}ms`);
|
|
627
|
+
} catch (error) {
|
|
628
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
629
|
+
this.debug(`[Player] Search via plugin '${p.name}' failed: ${errorMessage}`);
|
|
630
|
+
lastError = error;
|
|
631
|
+
// Continue to next plugin
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
this.debug(`[Player] No plugins returned results for query: ${query} (tried ${searchAttempts} plugins)`);
|
|
636
|
+
if (lastError) this.emit("playerError", lastError as Error);
|
|
637
|
+
throw new Error(`No plugin found to handle: ${query}`);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* Play a track, search query, search result, or play from queue
|
|
642
|
+
*
|
|
643
|
+
* @param {string | Track | SearchResult | null} query - Track URL, search query, Track object, SearchResult, or null for play
|
|
644
|
+
* @param {string} requestedBy - User ID who requested the track
|
|
645
|
+
* @returns {Promise<boolean>} True if playback started successfully
|
|
646
|
+
* @example
|
|
647
|
+
* await player.play("Never Gonna Give You Up", userId); // Search query
|
|
648
|
+
* await player.play("https://youtube.com/watch?v=dQw4w9WgXcQ", userId); // Direct URL
|
|
649
|
+
* await player.play("tts: Hello everyone!", userId); // Text-to-Speech
|
|
650
|
+
* await player.play(trackObject, userId); // Track object
|
|
651
|
+
* await player.play(searchResult, userId); // SearchResult object
|
|
652
|
+
* await player.play(null); // play from queue
|
|
653
|
+
*/
|
|
654
|
+
async play(query: string | Track | SearchResult | null, requestedBy?: string): Promise<boolean> {
|
|
655
|
+
const debugInfo =
|
|
656
|
+
query === null ? "null"
|
|
657
|
+
: typeof query === "string" ? query
|
|
658
|
+
: "tracks" in query ? `${query.tracks.length} tracks`
|
|
659
|
+
: query.title || "unknown";
|
|
660
|
+
this.debug(`[Player] Play called with query: ${debugInfo}`);
|
|
661
|
+
this.clearLeaveTimeout();
|
|
662
|
+
let tracksToAdd: Track[] = [];
|
|
663
|
+
let isPlaylist = false;
|
|
664
|
+
let effectiveRequest: ExtensionPlayRequest = { query: query as string | Track, requestedBy };
|
|
665
|
+
let hookResponse: ExtensionPlayResponse = {};
|
|
666
|
+
|
|
667
|
+
try {
|
|
668
|
+
// Handle null query - play from queue
|
|
669
|
+
if (query === null) {
|
|
670
|
+
this.debug(`[Player] Play from queue requested`);
|
|
671
|
+
if (this.queue.isEmpty) {
|
|
672
|
+
this.debug(`[Player] Queue is empty, nothing to play`);
|
|
673
|
+
return false;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
if (!this.isPlaying) {
|
|
677
|
+
return await this.playNext();
|
|
678
|
+
}
|
|
679
|
+
return true;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
// Handle SearchResult
|
|
683
|
+
if (query && typeof query === "object" && "tracks" in query && Array.isArray(query.tracks)) {
|
|
684
|
+
this.debug(`[Player] Playing SearchResult with ${query.tracks.length} tracks`);
|
|
685
|
+
tracksToAdd = query.tracks;
|
|
686
|
+
isPlaylist = !!query.playlist || query.tracks.length > 1;
|
|
687
|
+
|
|
688
|
+
if (query.playlist) {
|
|
689
|
+
this.debug(`[Player] Added playlist: ${query.playlist.name} (${tracksToAdd.length} tracks)`);
|
|
690
|
+
}
|
|
691
|
+
} else {
|
|
692
|
+
// Handle other types (string, Track)
|
|
693
|
+
const hookOutcome = await this.runBeforePlayHooks(effectiveRequest);
|
|
694
|
+
effectiveRequest = hookOutcome.request;
|
|
695
|
+
hookResponse = hookOutcome.response;
|
|
696
|
+
if (effectiveRequest.requestedBy === undefined) {
|
|
697
|
+
effectiveRequest.requestedBy = requestedBy;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
const hookTracks = Array.isArray(hookResponse.tracks) ? hookResponse.tracks : undefined;
|
|
701
|
+
|
|
702
|
+
if (hookResponse.handled && (!hookTracks || hookTracks.length === 0)) {
|
|
703
|
+
const handledPayload: ExtensionAfterPlayPayload = {
|
|
704
|
+
success: hookResponse.success ?? true,
|
|
705
|
+
query: effectiveRequest.query,
|
|
706
|
+
requestedBy: effectiveRequest.requestedBy,
|
|
707
|
+
tracks: [],
|
|
708
|
+
isPlaylist: hookResponse.isPlaylist ?? false,
|
|
709
|
+
error: hookResponse.error,
|
|
710
|
+
};
|
|
711
|
+
await this.runAfterPlayHooks(handledPayload);
|
|
712
|
+
if (hookResponse.error) {
|
|
713
|
+
this.emit("playerError", hookResponse.error);
|
|
714
|
+
}
|
|
715
|
+
return hookResponse.success ?? true;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
if (hookTracks && hookTracks.length > 0) {
|
|
719
|
+
tracksToAdd = hookTracks;
|
|
720
|
+
isPlaylist = hookResponse.isPlaylist ?? hookTracks.length > 1;
|
|
721
|
+
} else if (typeof effectiveRequest.query === "string") {
|
|
722
|
+
const searchResult = await this.search(effectiveRequest.query, effectiveRequest.requestedBy || "Unknown");
|
|
723
|
+
tracksToAdd = searchResult.tracks;
|
|
724
|
+
if (searchResult.playlist) {
|
|
725
|
+
isPlaylist = true;
|
|
726
|
+
this.debug(`[Player] Added playlist: ${searchResult.playlist.name} (${tracksToAdd.length} tracks)`);
|
|
727
|
+
}
|
|
728
|
+
} else if (effectiveRequest.query) {
|
|
729
|
+
tracksToAdd = [effectiveRequest.query as Track];
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
if (tracksToAdd.length === 0) {
|
|
734
|
+
this.debug(`[Player] No tracks found for play`);
|
|
735
|
+
throw new Error("No tracks found");
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
const isTTS = (t: Track | undefined) => {
|
|
739
|
+
if (!t) return false;
|
|
740
|
+
try {
|
|
741
|
+
return typeof t.source === "string" && t.source.toLowerCase().includes("tts");
|
|
742
|
+
} catch {
|
|
743
|
+
return false;
|
|
744
|
+
}
|
|
745
|
+
};
|
|
746
|
+
|
|
747
|
+
const queryLooksTTS =
|
|
748
|
+
typeof effectiveRequest.query === "string" && effectiveRequest.query.trim().toLowerCase().startsWith("tts");
|
|
749
|
+
|
|
750
|
+
if (
|
|
751
|
+
!isPlaylist &&
|
|
752
|
+
tracksToAdd.length > 0 &&
|
|
753
|
+
this.options?.tts?.interrupt !== false &&
|
|
754
|
+
(isTTS(tracksToAdd[0]) || queryLooksTTS)
|
|
755
|
+
) {
|
|
756
|
+
this.debug(`[Player] Interrupting with TTS: ${tracksToAdd[0].title}`);
|
|
757
|
+
await this.interruptWithTTSTrack(tracksToAdd[0]);
|
|
758
|
+
await this.runAfterPlayHooks({
|
|
759
|
+
success: true,
|
|
760
|
+
query: effectiveRequest.query,
|
|
761
|
+
requestedBy: effectiveRequest.requestedBy,
|
|
762
|
+
tracks: tracksToAdd,
|
|
763
|
+
isPlaylist,
|
|
764
|
+
});
|
|
765
|
+
return true;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
if (isPlaylist) {
|
|
769
|
+
this.queue.addMultiple(tracksToAdd);
|
|
770
|
+
this.emit("queueAddList", tracksToAdd);
|
|
771
|
+
} else {
|
|
772
|
+
this.queue.add(tracksToAdd[0]);
|
|
773
|
+
this.emit("queueAdd", tracksToAdd[0]);
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
const started = !this.isPlaying ? await this.playNext() : true;
|
|
777
|
+
|
|
778
|
+
await this.runAfterPlayHooks({
|
|
779
|
+
success: started,
|
|
780
|
+
query: effectiveRequest.query,
|
|
781
|
+
requestedBy: effectiveRequest.requestedBy,
|
|
782
|
+
tracks: tracksToAdd,
|
|
783
|
+
isPlaylist,
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
return started;
|
|
787
|
+
} catch (error) {
|
|
788
|
+
await this.runAfterPlayHooks({
|
|
789
|
+
success: false,
|
|
790
|
+
query: effectiveRequest.query,
|
|
791
|
+
requestedBy: effectiveRequest.requestedBy,
|
|
792
|
+
tracks: tracksToAdd,
|
|
793
|
+
isPlaylist,
|
|
794
|
+
error: error as Error,
|
|
795
|
+
});
|
|
796
|
+
this.debug(`[Player] Play error:`, error);
|
|
797
|
+
this.emit("playerError", error as Error);
|
|
798
|
+
return false;
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
/**
|
|
803
|
+
* Interrupt current music with a TTS track. Pauses music, swaps the
|
|
804
|
+
* subscription to a dedicated TTS player, plays TTS, then resumes.
|
|
805
|
+
*
|
|
806
|
+
* @param {Track} track - The track to interrupt with
|
|
807
|
+
* @returns {Promise<void>}
|
|
808
|
+
* @example
|
|
809
|
+
* await player.interruptWithTTSTrack(track);
|
|
810
|
+
*/
|
|
811
|
+
public async interruptWithTTSTrack(track: Track): Promise<void> {
|
|
812
|
+
this.ttsQueue.push(track);
|
|
813
|
+
if (!this.ttsActive) {
|
|
814
|
+
void this.playNextTTS();
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
/**
|
|
819
|
+
* Play queued TTS items sequentially
|
|
820
|
+
*
|
|
821
|
+
* @returns {Promise<void>}
|
|
822
|
+
* @example
|
|
823
|
+
* await player.playNextTTS();
|
|
824
|
+
*/
|
|
825
|
+
private async playNextTTS(): Promise<void> {
|
|
826
|
+
const next = this.ttsQueue.shift();
|
|
827
|
+
if (!next) return;
|
|
828
|
+
this.ttsActive = true;
|
|
829
|
+
|
|
830
|
+
try {
|
|
831
|
+
if (!this.connection) throw new Error("No voice connection for TTS");
|
|
832
|
+
const ttsPlayer = this.ensureTTSPlayer();
|
|
833
|
+
|
|
834
|
+
// Build resource from plugin stream
|
|
835
|
+
const resource = await this.resourceFromTrack(next);
|
|
836
|
+
if (resource.volume) {
|
|
837
|
+
resource.volume.setVolume((this.options?.tts?.volume ?? this?.volume ?? 100) / 100);
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
const wasPlaying =
|
|
841
|
+
this.audioPlayer.state.status === AudioPlayerStatus.Playing ||
|
|
842
|
+
this.audioPlayer.state.status === AudioPlayerStatus.Buffering;
|
|
843
|
+
|
|
844
|
+
// Pause current music if any
|
|
845
|
+
try {
|
|
846
|
+
this.audioPlayer.pause(true);
|
|
847
|
+
} catch {}
|
|
848
|
+
|
|
849
|
+
// Swap subscription and play TTS
|
|
850
|
+
this.connection.subscribe(ttsPlayer);
|
|
851
|
+
this.emit("ttsStart", { track: next });
|
|
852
|
+
ttsPlayer.play(resource);
|
|
853
|
+
|
|
854
|
+
// Wait until TTS starts then finishes
|
|
855
|
+
await entersState(ttsPlayer, AudioPlayerStatus.Playing, 5_000).catch(() => null);
|
|
856
|
+
// Derive timeout from resource/track duration when available, with a sensible cap
|
|
857
|
+
const md: any = (resource as any)?.metadata ?? {};
|
|
858
|
+
const declared =
|
|
859
|
+
typeof md.duration === "number" ? md.duration
|
|
860
|
+
: typeof next?.duration === "number" ? next.duration
|
|
861
|
+
: undefined;
|
|
862
|
+
const declaredMs =
|
|
863
|
+
declared ?
|
|
864
|
+
declared > 1000 ?
|
|
865
|
+
declared
|
|
866
|
+
: declared * 1000
|
|
867
|
+
: undefined;
|
|
868
|
+
const cap = this.options?.tts?.Max_Time_TTS ?? 60_000;
|
|
869
|
+
const idleTimeout = declaredMs ? Math.min(cap, Math.max(1_000, declaredMs + 1_500)) : cap;
|
|
870
|
+
await entersState(ttsPlayer, AudioPlayerStatus.Idle, idleTimeout).catch(() => null);
|
|
871
|
+
|
|
872
|
+
// Swap back and resume if needed
|
|
873
|
+
this.connection.subscribe(this.audioPlayer);
|
|
874
|
+
if (wasPlaying) {
|
|
875
|
+
try {
|
|
876
|
+
this.audioPlayer.unpause();
|
|
877
|
+
} catch {}
|
|
878
|
+
}
|
|
879
|
+
this.emit("ttsEnd");
|
|
880
|
+
} catch (err) {
|
|
881
|
+
this.debug("[TTS] error while playing:", err);
|
|
882
|
+
this.emit("playerError", err as Error);
|
|
883
|
+
} finally {
|
|
884
|
+
this.ttsActive = false;
|
|
885
|
+
if (this.ttsQueue.length > 0) {
|
|
886
|
+
await this.playNextTTS();
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
/**
|
|
892
|
+
* Get cached plugin or find and cache a new one
|
|
893
|
+
* @param track The track to find plugin for
|
|
894
|
+
* @returns The matching plugin or null if not found
|
|
895
|
+
*/
|
|
896
|
+
private getCachedPlugin(track: Track): SourcePlugin | null {
|
|
897
|
+
const cacheKey = `${track.source}:${track.url}`;
|
|
898
|
+
const now = Date.now();
|
|
899
|
+
|
|
900
|
+
// Check if cache is still valid
|
|
901
|
+
const cachedTimestamp = this.pluginCacheTimestamps.get(cacheKey);
|
|
902
|
+
if (cachedTimestamp && now - cachedTimestamp < this.PLUGIN_CACHE_TTL) {
|
|
903
|
+
const cachedPlugin = this.pluginCache.get(cacheKey);
|
|
904
|
+
if (cachedPlugin) {
|
|
905
|
+
this.debug(`[PluginCache] Using cached plugin for ${track.source}: ${cachedPlugin.name}`);
|
|
906
|
+
return cachedPlugin;
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
// Find new plugin and cache it
|
|
911
|
+
this.debug(`[PluginCache] Finding plugin for track: ${track.title} (${track.source})`);
|
|
912
|
+
const plugin = this.pluginManager.findPlugin(track.url) || this.pluginManager.get(track.source);
|
|
913
|
+
|
|
914
|
+
if (plugin) {
|
|
915
|
+
this.pluginCache.set(cacheKey, plugin);
|
|
916
|
+
this.pluginCacheTimestamps.set(cacheKey, now);
|
|
917
|
+
this.debug(`[PluginCache] Cached plugin: ${plugin.name} for ${track.source}`);
|
|
918
|
+
return plugin;
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
return null;
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
/**
|
|
925
|
+
* Clear expired cache entries
|
|
926
|
+
*/
|
|
927
|
+
private clearExpiredCache(): void {
|
|
928
|
+
const now = Date.now();
|
|
929
|
+
for (const [key, timestamp] of this.pluginCacheTimestamps.entries()) {
|
|
930
|
+
if (now - timestamp >= this.PLUGIN_CACHE_TTL) {
|
|
931
|
+
this.pluginCache.delete(key);
|
|
932
|
+
this.pluginCacheTimestamps.delete(key);
|
|
933
|
+
this.debug(`[PluginCache] Cleared expired cache entry: ${key}`);
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
/**
|
|
939
|
+
* Clear all plugin cache entries
|
|
940
|
+
* @example
|
|
941
|
+
* player.clearPluginCache();
|
|
942
|
+
*/
|
|
943
|
+
public clearPluginCache(): void {
|
|
944
|
+
const cacheSize = this.pluginCache.size;
|
|
945
|
+
this.pluginCache.clear();
|
|
946
|
+
this.pluginCacheTimestamps.clear();
|
|
947
|
+
this.debug(`[PluginCache] Cleared all ${cacheSize} cache entries`);
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
/**
|
|
951
|
+
* Get plugin cache statistics
|
|
952
|
+
* @returns Cache statistics
|
|
953
|
+
* @example
|
|
954
|
+
* const stats = player.getPluginCacheStats();
|
|
955
|
+
* console.log(`Cache size: ${stats.size}, Hit rate: ${stats.hitRate}%`);
|
|
956
|
+
*/
|
|
957
|
+
public getPluginCacheStats(): { size: number; hitRate: number; expiredEntries: number } {
|
|
958
|
+
const now = Date.now();
|
|
959
|
+
let expiredEntries = 0;
|
|
960
|
+
|
|
961
|
+
for (const timestamp of this.pluginCacheTimestamps.values()) {
|
|
962
|
+
if (now - timestamp >= this.PLUGIN_CACHE_TTL) {
|
|
963
|
+
expiredEntries++;
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
return {
|
|
968
|
+
size: this.pluginCache.size,
|
|
969
|
+
hitRate: 0, // Would need to track hits/misses to calculate this
|
|
970
|
+
expiredEntries,
|
|
971
|
+
};
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
/**
|
|
975
|
+
* Get cached search result or null if not found/expired
|
|
976
|
+
* @param query The search query
|
|
977
|
+
* @returns Cached search result or null
|
|
978
|
+
*/
|
|
979
|
+
private getCachedSearchResult(query: string): SearchResult | null {
|
|
980
|
+
const cacheKey = query.toLowerCase().trim();
|
|
981
|
+
const now = Date.now();
|
|
982
|
+
|
|
983
|
+
const cachedTimestamp = this.searchCacheTimestamps.get(cacheKey);
|
|
984
|
+
if (cachedTimestamp && now - cachedTimestamp < this.SEARCH_CACHE_TTL) {
|
|
985
|
+
const cachedResult = this.searchCache.get(cacheKey);
|
|
986
|
+
if (cachedResult) {
|
|
987
|
+
this.debug(`[SearchCache] Using cached search result for: ${query}`);
|
|
988
|
+
return cachedResult;
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
return null;
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
/**
|
|
996
|
+
* Cache search result
|
|
997
|
+
* @param query The search query
|
|
998
|
+
* @param result The search result to cache
|
|
999
|
+
*/
|
|
1000
|
+
private cacheSearchResult(query: string, result: SearchResult): void {
|
|
1001
|
+
const cacheKey = query.toLowerCase().trim();
|
|
1002
|
+
const now = Date.now();
|
|
1003
|
+
|
|
1004
|
+
this.searchCache.set(cacheKey, result);
|
|
1005
|
+
this.searchCacheTimestamps.set(cacheKey, now);
|
|
1006
|
+
this.debug(`[SearchCache] Cached search result for: ${query} (${result.tracks.length} tracks)`);
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
/**
|
|
1010
|
+
* Clear expired search cache entries
|
|
1011
|
+
*/
|
|
1012
|
+
private clearExpiredSearchCache(): void {
|
|
1013
|
+
const now = Date.now();
|
|
1014
|
+
for (const [key, timestamp] of this.searchCacheTimestamps.entries()) {
|
|
1015
|
+
if (now - timestamp >= this.SEARCH_CACHE_TTL) {
|
|
1016
|
+
this.searchCache.delete(key);
|
|
1017
|
+
this.searchCacheTimestamps.delete(key);
|
|
1018
|
+
this.debug(`[SearchCache] Cleared expired cache entry: ${key}`);
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
/**
|
|
1024
|
+
* Clear all search cache entries
|
|
1025
|
+
* @example
|
|
1026
|
+
* player.clearSearchCache();
|
|
1027
|
+
*/
|
|
1028
|
+
public clearSearchCache(): void {
|
|
1029
|
+
const cacheSize = this.searchCache.size;
|
|
1030
|
+
this.searchCache.clear();
|
|
1031
|
+
this.searchCacheTimestamps.clear();
|
|
1032
|
+
this.debug(`[SearchCache] Cleared all ${cacheSize} search cache entries`);
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
/**
|
|
1036
|
+
* Get search cache statistics
|
|
1037
|
+
* @returns Search cache statistics
|
|
1038
|
+
* @example
|
|
1039
|
+
* const stats = player.getSearchCacheStats();
|
|
1040
|
+
* console.log(`Search cache size: ${stats.size}, Expired: ${stats.expiredEntries}`);
|
|
1041
|
+
*/
|
|
1042
|
+
public getSearchCacheStats(): { size: number; expiredEntries: number; queries: string[] } {
|
|
1043
|
+
const now = Date.now();
|
|
1044
|
+
let expiredEntries = 0;
|
|
1045
|
+
const queries: string[] = [];
|
|
1046
|
+
|
|
1047
|
+
for (const [key, timestamp] of this.searchCacheTimestamps.entries()) {
|
|
1048
|
+
if (now - timestamp >= this.SEARCH_CACHE_TTL) {
|
|
1049
|
+
expiredEntries++;
|
|
1050
|
+
} else {
|
|
1051
|
+
queries.push(key);
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
return {
|
|
1056
|
+
size: this.searchCache.size,
|
|
1057
|
+
expiredEntries,
|
|
1058
|
+
queries,
|
|
1059
|
+
};
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
/**
|
|
1063
|
+
* Debug method to check for duplicate search calls
|
|
1064
|
+
* @param query The search query to check
|
|
1065
|
+
* @returns Debug information about the query
|
|
1066
|
+
*/
|
|
1067
|
+
public debugSearchQuery(query: string): {
|
|
1068
|
+
isCached: boolean;
|
|
1069
|
+
cacheAge?: number;
|
|
1070
|
+
pluginCount: number;
|
|
1071
|
+
ttsFiltered: boolean;
|
|
1072
|
+
} {
|
|
1073
|
+
const cacheKey = query.toLowerCase().trim();
|
|
1074
|
+
const now = Date.now();
|
|
1075
|
+
const cachedTimestamp = this.searchCacheTimestamps.get(cacheKey);
|
|
1076
|
+
const isCached = cachedTimestamp && now - cachedTimestamp < this.SEARCH_CACHE_TTL;
|
|
1077
|
+
|
|
1078
|
+
const allPlugins = this.pluginManager.getAll();
|
|
1079
|
+
const plugins = allPlugins.filter((p) => {
|
|
1080
|
+
if (p.name.toLowerCase() === "tts" && !query.toLowerCase().startsWith("tts:")) {
|
|
1081
|
+
return false;
|
|
1082
|
+
}
|
|
1083
|
+
return true;
|
|
1084
|
+
});
|
|
1085
|
+
|
|
1086
|
+
return {
|
|
1087
|
+
isCached: !!isCached,
|
|
1088
|
+
cacheAge: cachedTimestamp ? now - cachedTimestamp : undefined,
|
|
1089
|
+
pluginCount: plugins.length,
|
|
1090
|
+
ttsFiltered: allPlugins.length > plugins.length,
|
|
1091
|
+
};
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
/** Build AudioResource for a given track using the plugin pipeline */
|
|
1095
|
+
private async resourceFromTrack(track: Track): Promise<AudioResource> {
|
|
1096
|
+
this.debug(`[ResourceFromTrack] Starting resource creation for track: ${track.title} (${track.source})`);
|
|
1097
|
+
|
|
1098
|
+
// Clear expired cache entries periodically
|
|
1099
|
+
if (Math.random() < 0.1) {
|
|
1100
|
+
// 10% chance to clean cache
|
|
1101
|
+
this.clearExpiredCache();
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
// Resolve plugin using cache
|
|
1105
|
+
const plugin = this.getCachedPlugin(track);
|
|
1106
|
+
if (!plugin) {
|
|
1107
|
+
this.debug(`[ResourceFromTrack] No plugin found for track: ${track.title} (${track.source})`);
|
|
1108
|
+
throw new Error(`No plugin found for track: ${track.title}`);
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
this.debug(`[ResourceFromTrack] Using plugin: ${plugin.name} for track: ${track.title}`);
|
|
1112
|
+
|
|
1113
|
+
let streamInfo: StreamInfo | null = null;
|
|
1114
|
+
const timeoutMs = this.options.extractorTimeout ?? 15000;
|
|
1115
|
+
|
|
1116
|
+
try {
|
|
1117
|
+
this.debug(`[ResourceFromTrack] Attempting getStream with ${plugin.name}, timeout: ${timeoutMs}ms`);
|
|
1118
|
+
const startTime = Date.now();
|
|
1119
|
+
streamInfo = await withTimeout(plugin.getStream(track), timeoutMs, "getStream timed out");
|
|
1120
|
+
const duration = Date.now() - startTime;
|
|
1121
|
+
this.debug(`[ResourceFromTrack] getStream successful with ${plugin.name} in ${duration}ms`);
|
|
1122
|
+
|
|
1123
|
+
if (!streamInfo?.stream) {
|
|
1124
|
+
this.debug(`[ResourceFromTrack] getStream returned no stream from ${plugin.name}`);
|
|
1125
|
+
throw new Error(`No stream returned from ${plugin.name}`);
|
|
1126
|
+
}
|
|
1127
|
+
} catch (streamError) {
|
|
1128
|
+
const errorMessage = streamError instanceof Error ? streamError.message : String(streamError);
|
|
1129
|
+
this.debug(`[ResourceFromTrack] getStream failed with ${plugin.name}: ${errorMessage}`);
|
|
1130
|
+
|
|
1131
|
+
// Log more details for debugging
|
|
1132
|
+
if (streamError instanceof Error && streamError.stack) {
|
|
1133
|
+
this.debug(`[ResourceFromTrack] getStream error stack:`, streamError.stack);
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
// try fallbacks
|
|
1137
|
+
this.debug(`[ResourceFromTrack] Attempting fallback plugins for track: ${track.title}`);
|
|
1138
|
+
const allplugs = this.pluginManager.getAll();
|
|
1139
|
+
let fallbackAttempts = 0;
|
|
1140
|
+
|
|
1141
|
+
for (const p of allplugs) {
|
|
1142
|
+
if (typeof (p as any).getFallback !== "function" && typeof (p as any).getStream !== "function") {
|
|
1143
|
+
this.debug(`[ResourceFromTrack] Skipping plugin ${(p as any).name} - no getFallback or getStream method`);
|
|
1144
|
+
continue;
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
fallbackAttempts++;
|
|
1148
|
+
this.debug(`[ResourceFromTrack] Trying fallback plugin ${(p as any).name} (attempt ${fallbackAttempts})`);
|
|
1149
|
+
|
|
1150
|
+
try {
|
|
1151
|
+
// Try getStream first
|
|
1152
|
+
const startTime = Date.now();
|
|
1153
|
+
streamInfo = await withTimeout(p.getStream(track), timeoutMs, "getStream timed out");
|
|
1154
|
+
const duration = Date.now() - startTime;
|
|
1155
|
+
|
|
1156
|
+
if (streamInfo?.stream) {
|
|
1157
|
+
this.debug(`[ResourceFromTrack] Fallback getStream successful with ${(p as any).name} in ${duration}ms`);
|
|
1158
|
+
break;
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
// Try getFallback if getStream didn't work
|
|
1162
|
+
this.debug(`[ResourceFromTrack] Trying getFallback with ${(p as any).name}`);
|
|
1163
|
+
const fallbackStartTime = Date.now();
|
|
1164
|
+
streamInfo = await withTimeout(
|
|
1165
|
+
(p as any).getFallback(track),
|
|
1166
|
+
timeoutMs,
|
|
1167
|
+
`getFallback timed out for plugin ${(p as any).name}`,
|
|
1168
|
+
);
|
|
1169
|
+
const fallbackDuration = Date.now() - fallbackStartTime;
|
|
1170
|
+
|
|
1171
|
+
if (streamInfo?.stream) {
|
|
1172
|
+
this.debug(`[ResourceFromTrack] Fallback getFallback successful with ${(p as any).name} in ${fallbackDuration}ms`);
|
|
1173
|
+
break;
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
this.debug(`[ResourceFromTrack] Fallback plugin ${(p as any).name} returned no stream`);
|
|
1177
|
+
} catch (fallbackError) {
|
|
1178
|
+
const errorMessage = fallbackError instanceof Error ? fallbackError.message : String(fallbackError);
|
|
1179
|
+
this.debug(`[ResourceFromTrack] Fallback plugin ${(p as any).name} failed: ${errorMessage}`);
|
|
1180
|
+
|
|
1181
|
+
// Log more details for debugging
|
|
1182
|
+
if (fallbackError instanceof Error && fallbackError.stack) {
|
|
1183
|
+
this.debug(`[ResourceFromTrack] Fallback error stack:`, fallbackError.stack);
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
if (!streamInfo?.stream) {
|
|
1189
|
+
this.debug(`[ResourceFromTrack] All ${fallbackAttempts} fallback attempts failed for track: ${track.title}`);
|
|
1190
|
+
throw new Error(`All getFallback attempts failed for track: ${track.title}`);
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
this.debug(
|
|
1195
|
+
`[ResourceFromTrack] Stream obtained, type: ${streamInfo.type}, metadata keys: ${Object.keys(
|
|
1196
|
+
streamInfo.metadata || {},
|
|
1197
|
+
).join(", ")}`,
|
|
1198
|
+
);
|
|
1199
|
+
|
|
1200
|
+
const mapToStreamType = (type: string): StreamType => {
|
|
1201
|
+
switch (type) {
|
|
1202
|
+
case "webm/opus":
|
|
1203
|
+
return StreamType.WebmOpus;
|
|
1204
|
+
case "ogg/opus":
|
|
1205
|
+
return StreamType.OggOpus;
|
|
1206
|
+
case "arbitrary":
|
|
1207
|
+
default:
|
|
1208
|
+
return StreamType.Arbitrary;
|
|
1209
|
+
}
|
|
1210
|
+
};
|
|
1211
|
+
|
|
1212
|
+
const inputType = mapToStreamType(streamInfo.type);
|
|
1213
|
+
this.debug(`[ResourceFromTrack] Creating AudioResource with inputType: ${inputType}`);
|
|
1214
|
+
|
|
1215
|
+
// Merge metadata safely
|
|
1216
|
+
const mergedMetadata = {
|
|
1217
|
+
...track,
|
|
1218
|
+
...(streamInfo.metadata || {}),
|
|
1219
|
+
};
|
|
1220
|
+
|
|
1221
|
+
const audioResource = createAudioResource(streamInfo.stream, {
|
|
1222
|
+
// Prefer plugin-provided metadata (e.g., precise duration), fallback to track fields
|
|
1223
|
+
metadata: mergedMetadata,
|
|
1224
|
+
inputType,
|
|
1225
|
+
inlineVolume: true,
|
|
1226
|
+
});
|
|
1227
|
+
|
|
1228
|
+
this.debug(`[ResourceFromTrack] AudioResource created successfully for track: ${track.title}`);
|
|
1229
|
+
return audioResource;
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
private async generateWillNext(): Promise<void> {
|
|
1233
|
+
const lastTrack = this.queue.previousTracks[this.queue.previousTracks.length - 1] ?? this.queue.currentTrack;
|
|
1234
|
+
if (!lastTrack) return;
|
|
1235
|
+
|
|
1236
|
+
// Build list of candidate plugins: preferred first, then others with getRelatedTracks
|
|
1237
|
+
const preferred = this.pluginManager.findPlugin(lastTrack.url) || this.pluginManager.get(lastTrack.source);
|
|
1238
|
+
const all = this.pluginManager.getAll();
|
|
1239
|
+
const candidates = [...(preferred ? [preferred] : []), ...all.filter((p) => p !== preferred)].filter(
|
|
1240
|
+
(p) => typeof (p as any).getRelatedTracks === "function",
|
|
1241
|
+
);
|
|
1242
|
+
|
|
1243
|
+
for (const p of candidates) {
|
|
1244
|
+
try {
|
|
1245
|
+
this.debug(`[Player] Trying related from plugin: ${p.name}`);
|
|
1246
|
+
const related = await withTimeout(
|
|
1247
|
+
(p as any).getRelatedTracks(lastTrack.url, {
|
|
1248
|
+
limit: 10,
|
|
1249
|
+
history: this.queue.previousTracks,
|
|
1250
|
+
}),
|
|
1251
|
+
this.options.extractorTimeout ?? 15000,
|
|
1252
|
+
`getRelatedTracks timed out for ${p.name}`,
|
|
1253
|
+
);
|
|
1254
|
+
|
|
1255
|
+
if (Array.isArray(related) && related.length > 0) {
|
|
1256
|
+
const randomchoice = Math.floor(Math.random() * related.length);
|
|
1257
|
+
const nextTrack = this.queue.nextTrack ? this.queue.nextTrack : related[randomchoice];
|
|
1258
|
+
this.queue.willNextTrack(nextTrack);
|
|
1259
|
+
this.queue.relatedTracks(related);
|
|
1260
|
+
this.debug(`[Player] Will next track if autoplay: ${nextTrack?.title} (via ${p.name})`);
|
|
1261
|
+
this.emit("willPlay", nextTrack, related);
|
|
1262
|
+
return; // success
|
|
1263
|
+
}
|
|
1264
|
+
this.debug(`[Player] ${p.name} returned no related tracks`);
|
|
1265
|
+
} catch (err) {
|
|
1266
|
+
this.debug(`[Player] getRelatedTracks error from ${p.name}:`, err);
|
|
1267
|
+
// try next candidate
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
private async playNext(): Promise<boolean> {
|
|
1273
|
+
this.debug(`[Player] playNext called`);
|
|
1274
|
+
const track = this.queue.next(this.skipLoop);
|
|
1275
|
+
this.skipLoop = false;
|
|
1276
|
+
if (!track) {
|
|
1277
|
+
if (this.queue.autoPlay()) {
|
|
1278
|
+
const willnext = this.queue.willNextTrack();
|
|
1279
|
+
if (willnext) {
|
|
1280
|
+
this.debug(`[Player] Auto-playing next track: ${willnext.title}`);
|
|
1281
|
+
this.queue.addMultiple([willnext]);
|
|
1282
|
+
return this.playNext();
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
this.debug(`[Player] No next track in queue`);
|
|
1287
|
+
this.isPlaying = false;
|
|
1288
|
+
this.emit("queueEnd");
|
|
1289
|
+
|
|
1290
|
+
if (this.options.leaveOnEnd) {
|
|
1291
|
+
this.scheduleLeave();
|
|
1292
|
+
}
|
|
1293
|
+
return false;
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
this.generateWillNext();
|
|
1297
|
+
// A new track is about to play; ensure we don't leave mid-playback
|
|
1298
|
+
this.clearLeaveTimeout();
|
|
1299
|
+
|
|
1300
|
+
try {
|
|
1301
|
+
return await this.startTrack(track);
|
|
1302
|
+
} catch (error) {
|
|
1303
|
+
this.debug(`[Player] playNext error:`, error);
|
|
1304
|
+
this.emit("playerError", error as Error, track);
|
|
1305
|
+
return this.playNext();
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
/**
|
|
1310
|
+
* Pause the current track
|
|
1311
|
+
*
|
|
1312
|
+
* @returns {boolean} True if paused successfully
|
|
1313
|
+
* @example
|
|
1314
|
+
* const paused = player.pause();
|
|
1315
|
+
* console.log(`Paused: ${paused}`);
|
|
1316
|
+
*/
|
|
1317
|
+
pause(): boolean {
|
|
1318
|
+
this.debug(`[Player] pause called`);
|
|
1319
|
+
if (this.isPlaying && !this.isPaused) {
|
|
1320
|
+
return this.audioPlayer.pause();
|
|
1321
|
+
}
|
|
1322
|
+
return false;
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
/**
|
|
1326
|
+
* Resume the current track
|
|
1327
|
+
*
|
|
1328
|
+
* @returns {boolean} True if resumed successfully
|
|
1329
|
+
* @example
|
|
1330
|
+
* const resumed = player.resume();
|
|
1331
|
+
* console.log(`Resumed: ${resumed}`);
|
|
1332
|
+
*/
|
|
1333
|
+
resume(): boolean {
|
|
1334
|
+
this.debug(`[Player] resume called`);
|
|
1335
|
+
if (this.isPaused) {
|
|
1336
|
+
const result = this.audioPlayer.unpause();
|
|
1337
|
+
if (result) {
|
|
1338
|
+
const track = this.queue.currentTrack;
|
|
1339
|
+
if (track) {
|
|
1340
|
+
this.debug(`[Player] Player resumed on track: ${track.title}`);
|
|
1341
|
+
this.emit("playerResume", track);
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
return result;
|
|
1345
|
+
}
|
|
1346
|
+
return false;
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
/**
|
|
1350
|
+
* Stop the current track
|
|
1351
|
+
*
|
|
1352
|
+
* @returns {boolean} True if stopped successfully
|
|
1353
|
+
* @example
|
|
1354
|
+
* const stopped = player.stop();
|
|
1355
|
+
* console.log(`Stopped: ${stopped}`);
|
|
1356
|
+
*/
|
|
1357
|
+
stop(): boolean {
|
|
1358
|
+
this.debug(`[Player] stop called`);
|
|
1359
|
+
this.queue.clear();
|
|
1360
|
+
const result = this.audioPlayer.stop();
|
|
1361
|
+
this.isPlaying = false;
|
|
1362
|
+
this.isPaused = false;
|
|
1363
|
+
this.emit("playerStop");
|
|
1364
|
+
return result;
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
/**
|
|
1368
|
+
* Skip to the next track or skip to a specific index
|
|
1369
|
+
*
|
|
1370
|
+
* @param {number} index - Optional index to skip to (0 = next track)
|
|
1371
|
+
* @returns {boolean} True if skipped successfully
|
|
1372
|
+
* @example
|
|
1373
|
+
* const skipped = player.skip(); // Skip to next track
|
|
1374
|
+
* const skippedToIndex = player.skip(2); // Skip to track at index 2
|
|
1375
|
+
* console.log(`Skipped: ${skipped}`);
|
|
1376
|
+
*/
|
|
1377
|
+
skip(index?: number): boolean {
|
|
1378
|
+
this.debug(`[Player] skip called with index: ${index}`);
|
|
1379
|
+
try {
|
|
1380
|
+
if (typeof index === "number" && index >= 0) {
|
|
1381
|
+
// Skip to specific index
|
|
1382
|
+
const targetTrack = this.queue.getTrack(index);
|
|
1383
|
+
if (!targetTrack) {
|
|
1384
|
+
this.debug(`[Player] No track found at index ${index}`);
|
|
1385
|
+
return false;
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
// Remove tracks from 0 to index-1
|
|
1389
|
+
for (let i = 0; i < index; i++) {
|
|
1390
|
+
this.queue.remove(0);
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
this.debug(`[Player] Skipped to track at index ${index}: ${targetTrack.title}`);
|
|
1394
|
+
if (this.isPlaying || this.isPaused) {
|
|
1395
|
+
this.skipLoop = true;
|
|
1396
|
+
return this.audioPlayer.stop();
|
|
1397
|
+
}
|
|
1398
|
+
return true;
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
if (this.isPlaying || this.isPaused) {
|
|
1402
|
+
this.skipLoop = true;
|
|
1403
|
+
return this.audioPlayer.stop();
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
return true;
|
|
1407
|
+
} catch (error) {
|
|
1408
|
+
this.debug(`[Player] skip error:`, error);
|
|
1409
|
+
return false;
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
/**
|
|
1414
|
+
* Go back to the previous track in history and play it.
|
|
1415
|
+
*
|
|
1416
|
+
* @returns {Promise<boolean>} True if previous track was played successfully
|
|
1417
|
+
* @example
|
|
1418
|
+
* const previous = await player.previous();
|
|
1419
|
+
* console.log(`Previous: ${previous}`);
|
|
1420
|
+
*/
|
|
1421
|
+
async previous(): Promise<boolean> {
|
|
1422
|
+
this.debug(`[Player] previous called`);
|
|
1423
|
+
const track = this.queue.previous();
|
|
1424
|
+
if (!track) return false;
|
|
1425
|
+
if (this.queue.currentTrack) this.insert(this.queue.currentTrack, 0);
|
|
1426
|
+
this.clearLeaveTimeout();
|
|
1427
|
+
return this.startTrack(track);
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
/**
|
|
1431
|
+
* Loop the current track or queue
|
|
1432
|
+
*
|
|
1433
|
+
* @param {LoopMode | number} mode - The loop mode to set ("off", "track", "queue") or number (0=off, 1=track, 2=queue)
|
|
1434
|
+
* @returns {LoopMode} The loop mode
|
|
1435
|
+
* @example
|
|
1436
|
+
* const loopMode = player.loop("track"); // Loop current track
|
|
1437
|
+
* const loopQueue = player.loop("queue"); // Loop entire queue
|
|
1438
|
+
* const loopTrack = player.loop(1); // Loop current track (same as "track")
|
|
1439
|
+
* const loopQueueNum = player.loop(2); // Loop entire queue (same as "queue")
|
|
1440
|
+
* const noLoop = player.loop("off"); // No loop
|
|
1441
|
+
* const noLoopNum = player.loop(0); // No loop (same as "off")
|
|
1442
|
+
* console.log(`Loop mode: ${loopMode}`);
|
|
1443
|
+
*/
|
|
1444
|
+
loop(mode?: LoopMode | number): LoopMode {
|
|
1445
|
+
this.debug(`[Player] loop called with mode: ${mode}`);
|
|
1446
|
+
|
|
1447
|
+
if (typeof mode === "number") {
|
|
1448
|
+
// Number mode: convert to text mode
|
|
1449
|
+
switch (mode) {
|
|
1450
|
+
case 0:
|
|
1451
|
+
return this.queue.loop("off");
|
|
1452
|
+
case 1:
|
|
1453
|
+
return this.queue.loop("track");
|
|
1454
|
+
case 2:
|
|
1455
|
+
return this.queue.loop("queue");
|
|
1456
|
+
default:
|
|
1457
|
+
this.debug(`[Player] Invalid loop number: ${mode}, using "off"`);
|
|
1458
|
+
return this.queue.loop("off");
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
return this.queue.loop(mode as LoopMode);
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
/**
|
|
1466
|
+
* Set the auto-play mode
|
|
1467
|
+
*
|
|
1468
|
+
* @param {boolean} mode - The auto-play mode to set
|
|
1469
|
+
* @returns {boolean} The auto-play mode
|
|
1470
|
+
* @example
|
|
1471
|
+
* const autoPlayMode = player.autoPlay(true);
|
|
1472
|
+
* console.log(`Auto-play mode: ${autoPlayMode}`);
|
|
1473
|
+
*/
|
|
1474
|
+
autoPlay(mode?: boolean): boolean {
|
|
1475
|
+
return this.queue.autoPlay(mode);
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
/**
|
|
1479
|
+
* Set the volume of the current track
|
|
1480
|
+
*
|
|
1481
|
+
* @param {number} volume - The volume to set
|
|
1482
|
+
* @returns {boolean} True if volume was set successfully
|
|
1483
|
+
* @example
|
|
1484
|
+
* const volumeSet = player.setVolume(50);
|
|
1485
|
+
* console.log(`Volume set: ${volumeSet}`);
|
|
1486
|
+
*/
|
|
1487
|
+
setVolume(volume: number): boolean {
|
|
1488
|
+
this.debug(`[Player] setVolume called: ${volume}`);
|
|
1489
|
+
if (volume < 0 || volume > 200) return false;
|
|
1490
|
+
|
|
1491
|
+
const oldVolume = this.volume;
|
|
1492
|
+
this.volume = volume;
|
|
1493
|
+
const resourceVolume = this.currentResource?.volume;
|
|
1494
|
+
|
|
1495
|
+
if (resourceVolume) {
|
|
1496
|
+
if (this.volumeInterval) clearInterval(this.volumeInterval);
|
|
1497
|
+
|
|
1498
|
+
const start = resourceVolume.volume;
|
|
1499
|
+
const target = this.volume / 100;
|
|
1500
|
+
const steps = 10;
|
|
1501
|
+
let currentStep = 0;
|
|
1502
|
+
|
|
1503
|
+
this.volumeInterval = setInterval(() => {
|
|
1504
|
+
currentStep++;
|
|
1505
|
+
const value = start + ((target - start) * currentStep) / steps;
|
|
1506
|
+
resourceVolume.setVolume(value);
|
|
1507
|
+
if (currentStep >= steps) {
|
|
1508
|
+
clearInterval(this.volumeInterval!);
|
|
1509
|
+
this.volumeInterval = null;
|
|
1510
|
+
}
|
|
1511
|
+
}, 300);
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
this.emit("volumeChange", oldVolume, volume);
|
|
1515
|
+
return true;
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
/**
|
|
1519
|
+
* Shuffle the queue
|
|
1520
|
+
*
|
|
1521
|
+
* @returns {void}
|
|
1522
|
+
* @example
|
|
1523
|
+
* player.shuffle();
|
|
1524
|
+
*/
|
|
1525
|
+
shuffle(): void {
|
|
1526
|
+
this.debug(`[Player] shuffle called`);
|
|
1527
|
+
this.queue.shuffle();
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
/**
|
|
1531
|
+
* Clear the queue
|
|
1532
|
+
*
|
|
1533
|
+
* @returns {void}
|
|
1534
|
+
* @example
|
|
1535
|
+
* player.clearQueue();
|
|
1536
|
+
*/
|
|
1537
|
+
clearQueue(): void {
|
|
1538
|
+
this.debug(`[Player] clearQueue called`);
|
|
1539
|
+
this.queue.clear();
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
/**
|
|
1543
|
+
* Insert a track or list of tracks into the upcoming queue at a specific position (0 = play after current).
|
|
1544
|
+
* - If `query` is a string, performs a search and inserts resulting tracks (playlist supported).
|
|
1545
|
+
* - If a Track or Track[] is provided, inserts directly.
|
|
1546
|
+
* Does not auto-start playback; it only modifies the queue.
|
|
1547
|
+
*
|
|
1548
|
+
* @param {string | Track | Track[]} query - The track or tracks to insert
|
|
1549
|
+
* @param {number} index - The index to insert the tracks at
|
|
1550
|
+
* @param {string} requestedBy - The user ID who requested the insert
|
|
1551
|
+
* @returns {Promise<boolean>} True if the tracks were inserted successfully
|
|
1552
|
+
* @example
|
|
1553
|
+
* const inserted = await player.insert("Song Name", 0, userId);
|
|
1554
|
+
* console.log(`Inserted: ${inserted}`);
|
|
1555
|
+
*/
|
|
1556
|
+
async insert(query: string | Track | Track[], index: number, requestedBy?: string): Promise<boolean> {
|
|
1557
|
+
try {
|
|
1558
|
+
this.debug(`[Player] insert called at index ${index} with type: ${typeof query}`);
|
|
1559
|
+
let tracksToAdd: Track[] = [];
|
|
1560
|
+
let isPlaylist = false;
|
|
1561
|
+
|
|
1562
|
+
if (typeof query === "string") {
|
|
1563
|
+
const searchResult = await this.search(query, requestedBy || "Unknown");
|
|
1564
|
+
tracksToAdd = searchResult.tracks || [];
|
|
1565
|
+
isPlaylist = !!searchResult.playlist;
|
|
1566
|
+
} else if (Array.isArray(query)) {
|
|
1567
|
+
tracksToAdd = query;
|
|
1568
|
+
isPlaylist = query.length > 1;
|
|
1569
|
+
} else if (query) {
|
|
1570
|
+
tracksToAdd = [query];
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
if (!tracksToAdd || tracksToAdd.length === 0) {
|
|
1574
|
+
this.debug(`[Player] insert: no tracks resolved`);
|
|
1575
|
+
throw new Error("No tracks to insert");
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
if (tracksToAdd.length === 1) {
|
|
1579
|
+
this.queue.insert(tracksToAdd[0], index);
|
|
1580
|
+
this.emit("queueAdd", tracksToAdd[0]);
|
|
1581
|
+
this.debug(`[Player] Inserted track at index ${index}: ${tracksToAdd[0].title}`);
|
|
1582
|
+
} else {
|
|
1583
|
+
this.queue.insertMultiple(tracksToAdd, index);
|
|
1584
|
+
this.emit("queueAddList", tracksToAdd);
|
|
1585
|
+
this.debug(`[Player] Inserted ${tracksToAdd.length} ${isPlaylist ? "playlist " : ""}tracks at index ${index}`);
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
return true;
|
|
1589
|
+
} catch (error) {
|
|
1590
|
+
this.debug(`[Player] insert error:`, error);
|
|
1591
|
+
this.emit("playerError", error as Error);
|
|
1592
|
+
return false;
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
/**
|
|
1597
|
+
* Remove a track from the queue
|
|
1598
|
+
*
|
|
1599
|
+
* @param {number} index - The index of the track to remove
|
|
1600
|
+
* @returns {Track | null} The removed track or null
|
|
1601
|
+
* @example
|
|
1602
|
+
* const removed = player.remove(0);
|
|
1603
|
+
* console.log(`Removed: ${removed?.title}`);
|
|
1604
|
+
*/
|
|
1605
|
+
remove(index: number): Track | null {
|
|
1606
|
+
this.debug(`[Player] remove called for index: ${index}`);
|
|
1607
|
+
const track = this.queue.remove(index);
|
|
1608
|
+
if (track) {
|
|
1609
|
+
this.emit("queueRemove", track, index);
|
|
1610
|
+
}
|
|
1611
|
+
return track;
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
/**
|
|
1615
|
+
* Get the progress bar of the current track
|
|
1616
|
+
*
|
|
1617
|
+
* @param {ProgressBarOptions} options - The options for the progress bar
|
|
1618
|
+
* @returns {string} The progress bar
|
|
1619
|
+
* @example
|
|
1620
|
+
* const progressBar = player.getProgressBar();
|
|
1621
|
+
* console.log(`Progress bar: ${progressBar}`);
|
|
1622
|
+
*/
|
|
1623
|
+
getProgressBar(options: ProgressBarOptions = {}): string {
|
|
1624
|
+
const { size = 20, barChar = "▬", progressChar = "🔘" } = options;
|
|
1625
|
+
const track = this.queue.currentTrack;
|
|
1626
|
+
const resource = this.currentResource;
|
|
1627
|
+
if (!track || !resource) return "";
|
|
1628
|
+
|
|
1629
|
+
const total = track.duration > 1000 ? track.duration : track.duration * 1000;
|
|
1630
|
+
if (!total) return this.formatTime(resource.playbackDuration);
|
|
1631
|
+
|
|
1632
|
+
const current = resource.playbackDuration;
|
|
1633
|
+
const ratio = Math.min(current / total, 1);
|
|
1634
|
+
const progress = Math.round(ratio * size);
|
|
1635
|
+
const bar = barChar.repeat(progress) + progressChar + barChar.repeat(size - progress);
|
|
1636
|
+
|
|
1637
|
+
return `${this.formatTime(current)} | ${bar} | ${this.formatTime(total)}`;
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
/**
|
|
1641
|
+
* Get the time of the current track
|
|
1642
|
+
*
|
|
1643
|
+
* @returns {Object} The time of the current track
|
|
1644
|
+
* @example
|
|
1645
|
+
* const time = player.getTime();
|
|
1646
|
+
* console.log(`Time: ${time.current}`);
|
|
1647
|
+
*/
|
|
1648
|
+
getTime() {
|
|
1649
|
+
const resource = this.currentResource;
|
|
1650
|
+
const track = this.queue.currentTrack;
|
|
1651
|
+
if (!track || !resource)
|
|
1652
|
+
return {
|
|
1653
|
+
current: 0,
|
|
1654
|
+
total: 0,
|
|
1655
|
+
format: "00:00",
|
|
1656
|
+
};
|
|
1657
|
+
|
|
1658
|
+
const total = track.duration > 1000 ? track.duration : track.duration * 1000;
|
|
1659
|
+
|
|
1660
|
+
return {
|
|
1661
|
+
current: resource?.playbackDuration,
|
|
1662
|
+
total: total,
|
|
1663
|
+
format: this.formatTime(resource.playbackDuration),
|
|
1664
|
+
};
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
/**
|
|
1668
|
+
* Format the time in the format of HH:MM:SS
|
|
1669
|
+
*
|
|
1670
|
+
* @param {number} ms - The time in milliseconds
|
|
1671
|
+
* @returns {string} The formatted time
|
|
1672
|
+
* @example
|
|
1673
|
+
* const formattedTime = player.formatTime(1000);
|
|
1674
|
+
* console.log(`Formatted time: ${formattedTime}`);
|
|
1675
|
+
*/
|
|
1676
|
+
formatTime(ms: number): string {
|
|
1677
|
+
const totalSeconds = Math.floor(ms / 1000);
|
|
1678
|
+
const hours = Math.floor(totalSeconds / 3600);
|
|
1679
|
+
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
1680
|
+
const seconds = totalSeconds % 60;
|
|
1681
|
+
const parts: string[] = [];
|
|
1682
|
+
if (hours > 0) parts.push(String(hours).padStart(2, "0"));
|
|
1683
|
+
parts.push(String(minutes).padStart(2, "0"));
|
|
1684
|
+
parts.push(String(seconds).padStart(2, "0"));
|
|
1685
|
+
return parts.join(":");
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
private scheduleLeave(): void {
|
|
1689
|
+
this.debug(`[Player] scheduleLeave called`);
|
|
1690
|
+
if (this.leaveTimeout) {
|
|
1691
|
+
clearTimeout(this.leaveTimeout);
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
if (this.options.leaveOnEmpty && this.options.leaveTimeout) {
|
|
1695
|
+
this.leaveTimeout = setTimeout(() => {
|
|
1696
|
+
this.debug(`[Player] Leaving voice channel after timeout`);
|
|
1697
|
+
this.destroy();
|
|
1698
|
+
}, this.options.leaveTimeout);
|
|
1699
|
+
}
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
/**
|
|
1703
|
+
* Destroy the player
|
|
1704
|
+
*
|
|
1705
|
+
* @returns {void}
|
|
1706
|
+
* @example
|
|
1707
|
+
* player.destroy();
|
|
1708
|
+
*/
|
|
1709
|
+
destroy(): void {
|
|
1710
|
+
this.debug(`[Player] destroy called`);
|
|
1711
|
+
if (this.leaveTimeout) {
|
|
1712
|
+
clearTimeout(this.leaveTimeout);
|
|
1713
|
+
this.leaveTimeout = null;
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
this.audioPlayer.stop(true);
|
|
1717
|
+
|
|
1718
|
+
if (this.ttsPlayer) {
|
|
1719
|
+
try {
|
|
1720
|
+
this.ttsPlayer.stop(true);
|
|
1721
|
+
} catch {}
|
|
1722
|
+
this.ttsPlayer = null;
|
|
1723
|
+
}
|
|
1724
|
+
|
|
1725
|
+
if (this.connection) {
|
|
1726
|
+
this.connection.destroy();
|
|
1727
|
+
this.connection = null;
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
this.queue.clear();
|
|
1731
|
+
this.pluginManager.clear();
|
|
1732
|
+
for (const extension of [...this.extensions]) {
|
|
1733
|
+
this.invokeExtensionLifecycle(extension, "onDestroy");
|
|
1734
|
+
if (extension.player === this) {
|
|
1735
|
+
extension.player = null;
|
|
1736
|
+
}
|
|
1737
|
+
}
|
|
1738
|
+
this.extensions = [];
|
|
1739
|
+
this.isPlaying = false;
|
|
1740
|
+
this.isPaused = false;
|
|
1741
|
+
this.emit("playerDestroy");
|
|
1742
|
+
this.removeAllListeners();
|
|
1743
|
+
}
|
|
1744
|
+
|
|
1745
|
+
/**
|
|
1746
|
+
* Get the size of the queue
|
|
1747
|
+
*
|
|
1748
|
+
* @returns {number} The size of the queue
|
|
1749
|
+
* @example
|
|
1750
|
+
* const queueSize = player.queueSize;
|
|
1751
|
+
* console.log(`Queue size: ${queueSize}`);
|
|
1752
|
+
*/
|
|
1753
|
+
get queueSize(): number {
|
|
1754
|
+
return this.queue.size;
|
|
1755
|
+
}
|
|
1756
|
+
|
|
1757
|
+
/**
|
|
1758
|
+
* Get the current track
|
|
1759
|
+
*
|
|
1760
|
+
* @returns {Track | null} The current track or null
|
|
1761
|
+
* @example
|
|
1762
|
+
* const currentTrack = player.currentTrack;
|
|
1763
|
+
* console.log(`Current track: ${currentTrack?.title}`);
|
|
1764
|
+
*/
|
|
1765
|
+
get currentTrack(): Track | null {
|
|
1766
|
+
return this.queue.currentTrack;
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
/**
|
|
1770
|
+
* Get the previous track
|
|
1771
|
+
*
|
|
1772
|
+
* @returns {Track | null} The previous track or null
|
|
1773
|
+
* @example
|
|
1774
|
+
* const previousTrack = player.previousTrack;
|
|
1775
|
+
* console.log(`Previous track: ${previousTrack?.title}`);
|
|
1776
|
+
*/
|
|
1777
|
+
get previousTrack(): Track | null {
|
|
1778
|
+
return this.queue.previousTracks?.at(-1) ?? null;
|
|
1779
|
+
}
|
|
1780
|
+
|
|
1781
|
+
/**
|
|
1782
|
+
* Get the upcoming tracks
|
|
1783
|
+
*
|
|
1784
|
+
* @returns {Track[]} The upcoming tracks
|
|
1785
|
+
* @example
|
|
1786
|
+
* const upcomingTracks = player.upcomingTracks;
|
|
1787
|
+
* console.log(`Upcoming tracks: ${upcomingTracks.length}`);
|
|
1788
|
+
*/
|
|
1789
|
+
get upcomingTracks(): Track[] {
|
|
1790
|
+
return this.queue.getTracks();
|
|
1791
|
+
}
|
|
1792
|
+
|
|
1793
|
+
/**
|
|
1794
|
+
* Get the previous tracks
|
|
1795
|
+
*
|
|
1796
|
+
* @returns {Track[]} The previous tracks
|
|
1797
|
+
* @example
|
|
1798
|
+
* const previousTracks = player.previousTracks;
|
|
1799
|
+
* console.log(`Previous tracks: ${previousTracks.length}`);
|
|
1800
|
+
*/
|
|
1801
|
+
get previousTracks(): Track[] {
|
|
1802
|
+
return this.queue.previousTracks;
|
|
1803
|
+
}
|
|
1804
|
+
|
|
1805
|
+
/**
|
|
1806
|
+
* Get the available plugins
|
|
1807
|
+
*
|
|
1808
|
+
* @returns {string[]} The available plugins
|
|
1809
|
+
* @example
|
|
1810
|
+
* const availablePlugins = player.availablePlugins;
|
|
1811
|
+
* console.log(`Available plugins: ${availablePlugins.length}`);
|
|
1812
|
+
*/
|
|
1813
|
+
get availablePlugins(): string[] {
|
|
1814
|
+
return this.pluginManager.getAll().map((p) => p.name);
|
|
1815
|
+
}
|
|
1816
|
+
|
|
1817
|
+
/**
|
|
1818
|
+
* Get the related tracks
|
|
1819
|
+
*
|
|
1820
|
+
* @returns {Track[] | null} The related tracks or null
|
|
1821
|
+
* @example
|
|
1822
|
+
* const relatedTracks = player.relatedTracks;
|
|
1823
|
+
* console.log(`Related tracks: ${relatedTracks?.length}`);
|
|
1824
|
+
*/
|
|
1825
|
+
get relatedTracks(): Track[] | null {
|
|
1826
|
+
return this.queue.relatedTracks();
|
|
1827
|
+
}
|
|
1828
|
+
}
|