ziplayer 0.1.3 → 0.1.5

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