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