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