ziplayer 0.0.9 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,77 +5,292 @@ const events_1 = require("events");
5
5
  const voice_1 = require("@discordjs/voice");
6
6
  const Queue_1 = require("./Queue");
7
7
  const plugins_1 = require("../plugins");
8
+ const timeout_1 = require("../utils/timeout");
9
+ /**
10
+ * Represents a music player for a specific Discord guild.
11
+ *
12
+ * @example
13
+ * // Create and configure player
14
+ * const player = await manager.create(guildId, {
15
+ * tts: { interrupt: true, volume: 1 },
16
+ * leaveOnEnd: true,
17
+ * leaveTimeout: 30000
18
+ * });
19
+ *
20
+ * // Connect to voice channel
21
+ * await player.connect(voiceChannel);
22
+ *
23
+ * // Play different types of content
24
+ * await player.play("Never Gonna Give You Up", userId); // Search query
25
+ * await player.play("https://youtube.com/watch?v=dQw4w9WgXcQ", userId); // Direct URL
26
+ * await player.play("tts: Hello everyone!", userId); // Text-to-Speech
27
+ *
28
+ * // Player controls
29
+ * player.pause(); // Pause current track
30
+ * player.resume(); // Resume paused track
31
+ * player.skip(); // Skip to next track
32
+ * player.stop(); // Stop and clear queue
33
+ * player.setVolume(0.5); // Set volume to 50%
34
+ *
35
+ * // Event handling
36
+ * player.on("trackStart", (player, track) => {
37
+ * console.log(`Now playing: ${track.title}`);
38
+ * });
39
+ *
40
+ * player.on("queueEnd", (player) => {
41
+ * console.log("Queue finished");
42
+ * });
43
+ *
44
+ */
8
45
  class Player extends events_1.EventEmitter {
46
+ /**
47
+ * Attach an extension to the player
48
+ *
49
+ * @param {BaseExtension} extension - The extension to attach
50
+ * @example
51
+ * player.attachExtension(new MyExtension());
52
+ */
53
+ attachExtension(extension) {
54
+ if (this.extensions.includes(extension))
55
+ return;
56
+ if (!extension.player)
57
+ extension.player = this;
58
+ this.extensions.push(extension);
59
+ this.invokeExtensionLifecycle(extension, "onRegister");
60
+ }
61
+ /**
62
+ * Detach an extension from the player
63
+ *
64
+ * @param {BaseExtension} extension - The extension to detach
65
+ * @example
66
+ * player.detachExtension(new MyExtension());
67
+ */
68
+ detachExtension(extension) {
69
+ const index = this.extensions.indexOf(extension);
70
+ if (index === -1)
71
+ return;
72
+ this.extensions.splice(index, 1);
73
+ this.invokeExtensionLifecycle(extension, "onDestroy");
74
+ if (extension.player === this) {
75
+ extension.player = null;
76
+ }
77
+ }
78
+ /**
79
+ * Get all extensions attached to the player
80
+ *
81
+ * @returns {readonly BaseExtension[]} All attached extensions
82
+ * @example
83
+ * const extensions = player.getExtensions();
84
+ * console.log(`Extensions: ${extensions.length}`);
85
+ */
86
+ getExtensions() {
87
+ return this.extensions;
88
+ }
89
+ invokeExtensionLifecycle(extension, hook) {
90
+ const fn = extension[hook];
91
+ if (typeof fn !== "function")
92
+ return;
93
+ try {
94
+ const result = fn.call(extension, this.extensionContext);
95
+ if (result && typeof result.then === "function") {
96
+ result.catch((err) => this.debug(`[Player] Extension ${extension.name} ${hook} error:`, err));
97
+ }
98
+ }
99
+ catch (err) {
100
+ this.debug(`[Player] Extension ${extension.name} ${hook} error:`, err);
101
+ }
102
+ }
103
+ async runBeforePlayHooks(initial) {
104
+ const request = { ...initial };
105
+ const response = {};
106
+ for (const extension of this.extensions) {
107
+ const hook = extension.beforePlay;
108
+ if (typeof hook !== "function")
109
+ continue;
110
+ try {
111
+ const result = await Promise.resolve(hook.call(extension, this.extensionContext, request));
112
+ if (!result)
113
+ continue;
114
+ if (result.query !== undefined) {
115
+ request.query = result.query;
116
+ response.query = result.query;
117
+ }
118
+ if (result.requestedBy !== undefined) {
119
+ request.requestedBy = result.requestedBy;
120
+ response.requestedBy = result.requestedBy;
121
+ }
122
+ if (Array.isArray(result.tracks)) {
123
+ response.tracks = result.tracks;
124
+ }
125
+ if (typeof result.isPlaylist === "boolean") {
126
+ response.isPlaylist = result.isPlaylist;
127
+ }
128
+ if (typeof result.success === "boolean") {
129
+ response.success = result.success;
130
+ }
131
+ if (result.error instanceof Error) {
132
+ response.error = result.error;
133
+ }
134
+ if (typeof result.handled === "boolean") {
135
+ response.handled = result.handled;
136
+ if (result.handled)
137
+ break;
138
+ }
139
+ }
140
+ catch (err) {
141
+ this.debug(`[Player] Extension ${extension.name} beforePlay error:`, err);
142
+ }
143
+ }
144
+ return { request, response };
145
+ }
146
+ async runAfterPlayHooks(payload) {
147
+ if (this.extensions.length === 0)
148
+ return;
149
+ const safeTracks = payload.tracks ? [...payload.tracks] : undefined;
150
+ if (safeTracks) {
151
+ Object.freeze(safeTracks);
152
+ }
153
+ const immutablePayload = Object.freeze({ ...payload, tracks: safeTracks });
154
+ for (const extension of this.extensions) {
155
+ const hook = extension.afterPlay;
156
+ if (typeof hook !== "function")
157
+ continue;
158
+ try {
159
+ await Promise.resolve(hook.call(extension, this.extensionContext, immutablePayload));
160
+ }
161
+ catch (err) {
162
+ this.debug(`[Player] Extension ${extension.name} afterPlay error:`, err);
163
+ }
164
+ }
165
+ }
166
+ async extensionsProvideSearch(query, requestedBy) {
167
+ const request = { query, requestedBy };
168
+ for (const extension of this.extensions) {
169
+ const hook = extension.provideSearch;
170
+ if (typeof hook !== "function")
171
+ continue;
172
+ try {
173
+ const result = await Promise.resolve(hook.call(extension, this.extensionContext, request));
174
+ if (result && Array.isArray(result.tracks) && result.tracks.length > 0) {
175
+ this.debug(`[Player] Extension ${extension.name} handled search for query: ${query}`);
176
+ return result;
177
+ }
178
+ }
179
+ catch (err) {
180
+ this.debug(`[Player] Extension ${extension.name} provideSearch error:`, err);
181
+ }
182
+ }
183
+ return null;
184
+ }
185
+ async extensionsProvideStream(track) {
186
+ const request = { track };
187
+ for (const extension of this.extensions) {
188
+ const hook = extension.provideStream;
189
+ if (typeof hook !== "function")
190
+ continue;
191
+ try {
192
+ const result = await Promise.resolve(hook.call(extension, this.extensionContext, request));
193
+ if (result && result.stream) {
194
+ this.debug(`[Player] Extension ${extension.name} provided stream for track: ${track.title}`);
195
+ return result;
196
+ }
197
+ }
198
+ catch (err) {
199
+ this.debug(`[Player] Extension ${extension.name} provideStream error:`, err);
200
+ }
201
+ }
202
+ return null;
203
+ }
9
204
  /**
10
205
  * Start playing a specific track immediately, replacing the current resource.
11
206
  */
12
207
  async startTrack(track) {
13
208
  try {
14
- // Find plugin that can handle this track
15
- const plugin = this.pluginManager.findPlugin(track.url) || this.pluginManager.get(track.source);
16
- if (!plugin) {
17
- this.debug(`[Player] No plugin found for track: ${track.title}`);
18
- throw new Error(`No plugin found for track: ${track.title}`);
19
- }
20
- this.debug(`[Player] Getting stream for track: ${track.title}`);
21
- this.debug(`[Player] Using plugin: ${plugin.name}`);
22
- this.debug(`[Track] Track Info:`, track);
23
- let streamInfo;
24
- try {
25
- streamInfo = await this.withTimeout(plugin.getStream(track), "getStream timed out");
26
- }
27
- catch (streamError) {
28
- this.debug(`[Player] getStream failed, trying getFallback:`, streamError);
29
- const allplugs = this.pluginManager.getAll();
30
- for (const p of allplugs) {
31
- if (typeof p.getFallback !== "function") {
32
- continue;
33
- }
34
- try {
35
- streamInfo = await this.withTimeout(p.getFallback(track), `getFallback timed out for plugin ${p.name}`);
36
- if (!streamInfo.stream)
209
+ let streamInfo = await this.extensionsProvideStream(track);
210
+ let plugin;
211
+ if (!streamInfo) {
212
+ plugin = this.pluginManager.findPlugin(track.url) || this.pluginManager.get(track.source);
213
+ if (!plugin) {
214
+ this.debug(`[Player] No plugin found for track: ${track.title}`);
215
+ throw new Error(`No plugin found for track: ${track.title}`);
216
+ }
217
+ this.debug(`[Player] Getting stream for track: ${track.title}`);
218
+ this.debug(`[Player] Using plugin: ${plugin.name}`);
219
+ this.debug(`[Track] Track Info:`, track);
220
+ try {
221
+ streamInfo = await (0, timeout_1.withTimeout)(plugin.getStream(track), this.options.extractorTimeout ?? 15000, "getStream timed out");
222
+ }
223
+ catch (streamError) {
224
+ this.debug(`[Player] getStream failed, trying getFallback:`, streamError);
225
+ const allplugs = this.pluginManager.getAll();
226
+ for (const p of allplugs) {
227
+ if (typeof p.getFallback !== "function") {
37
228
  continue;
38
- this.debug(`[Player] getFallback succeeded with plugin ${p.name} for track: ${track.title}`);
39
- break;
229
+ }
230
+ try {
231
+ streamInfo = await (0, timeout_1.withTimeout)(p.getFallback(track), this.options.extractorTimeout ?? 15000, `getFallback timed out for plugin ${p.name}`);
232
+ if (!streamInfo?.stream)
233
+ continue;
234
+ this.debug(`[Player] getFallback succeeded with plugin ${p.name} for track: ${track.title}`);
235
+ break;
236
+ }
237
+ catch (fallbackError) {
238
+ this.debug(`[Player] getFallback failed with plugin ${p.name}:`, fallbackError);
239
+ }
40
240
  }
41
- catch (fallbackError) {
42
- this.debug(`[Player] getFallback failed with plugin ${p.name}:`, fallbackError);
241
+ if (!streamInfo?.stream) {
242
+ throw new Error(`All getFallback attempts failed for track: ${track.title}`);
43
243
  }
44
244
  }
45
- if (!streamInfo?.stream) {
46
- throw new Error(`All getFallback attempts failed for track: ${track.title}`);
47
- }
245
+ }
246
+ else {
247
+ this.debug(`[Player] Using extension-provided stream for track: ${track.title}`);
248
+ }
249
+ if (plugin) {
48
250
  this.debug(streamInfo);
49
251
  }
50
- function mapToStreamType(type) {
51
- switch (type) {
52
- case "webm/opus":
53
- return voice_1.StreamType.WebmOpus;
54
- case "ogg/opus":
55
- return voice_1.StreamType.OggOpus;
56
- case "arbitrary":
57
- return voice_1.StreamType.Arbitrary;
58
- default:
59
- return voice_1.StreamType.Arbitrary;
252
+ // Kiểm tra nếu có stream thực sự để tạo AudioResource
253
+ if (streamInfo && streamInfo.stream) {
254
+ function mapToStreamType(type) {
255
+ switch (type) {
256
+ case "webm/opus":
257
+ return voice_1.StreamType.WebmOpus;
258
+ case "ogg/opus":
259
+ return voice_1.StreamType.OggOpus;
260
+ case "arbitrary":
261
+ default:
262
+ return voice_1.StreamType.Arbitrary;
263
+ }
264
+ }
265
+ const stream = streamInfo.stream;
266
+ const inputType = mapToStreamType(streamInfo.type);
267
+ this.currentResource = (0, voice_1.createAudioResource)(stream, {
268
+ metadata: track,
269
+ inputType,
270
+ inlineVolume: true,
271
+ });
272
+ // Apply initial volume using the resource's VolumeTransformer
273
+ if (this.volumeInterval) {
274
+ clearInterval(this.volumeInterval);
275
+ this.volumeInterval = null;
60
276
  }
277
+ this.currentResource.volume?.setVolume(this.volume / 100);
278
+ this.debug(`[Player] Playing resource for track: ${track.title}`);
279
+ this.audioPlayer.play(this.currentResource);
280
+ await (0, voice_1.entersState)(this.audioPlayer, voice_1.AudioPlayerStatus.Playing, 5000);
281
+ return true;
61
282
  }
62
- let stream = streamInfo.stream;
63
- let inputType = mapToStreamType(streamInfo.type);
64
- this.currentResource = (0, voice_1.createAudioResource)(stream, {
65
- metadata: track,
66
- inputType,
67
- inlineVolume: true,
68
- });
69
- // Apply initial volume using the resource's VolumeTransformer
70
- if (this.volumeInterval) {
71
- clearInterval(this.volumeInterval);
72
- this.volumeInterval = null;
283
+ else if (streamInfo && !streamInfo.stream) {
284
+ // Extension đang xử lý phát nhạc (như Lavalink) - chỉ đánh dấu đang phát
285
+ this.debug(`[Player] Extension is handling playback for track: ${track.title}`);
286
+ this.isPlaying = true;
287
+ this.isPaused = false;
288
+ this.emit("trackStart", track);
289
+ return true;
290
+ }
291
+ else {
292
+ throw new Error(`No stream available for track: ${track.title}`);
73
293
  }
74
- this.currentResource.volume?.setVolume(this.volume / 100);
75
- this.debug(`[Player] Playing resource for track: ${track.title}`);
76
- this.audioPlayer.play(this.currentResource);
77
- await (0, voice_1.entersState)(this.audioPlayer, voice_1.AudioPlayerStatus.Playing, 5000);
78
- return true;
79
294
  }
80
295
  catch (error) {
81
296
  this.debug(`[Player] startTrack error:`, error);
@@ -90,10 +305,6 @@ class Player extends events_1.EventEmitter {
90
305
  this.debug(`[Player] Cleared leave timeout`);
91
306
  }
92
307
  }
93
- withTimeout(promise, message) {
94
- const timeout = this.options.extractorTimeout ?? 15000;
95
- return Promise.race([promise, new Promise((_, reject) => setTimeout(() => reject(new Error(message)), timeout))]);
96
- }
97
308
  debug(message, ...optionalParams) {
98
309
  if (this.listenerCount("debug") > 0) {
99
310
  this.emit("debug", message, ...optionalParams);
@@ -109,6 +320,11 @@ class Player extends events_1.EventEmitter {
109
320
  this.currentResource = null;
110
321
  this.volumeInterval = null;
111
322
  this.skipLoop = false;
323
+ this.extensions = [];
324
+ // Cache for plugin matching to improve performance
325
+ this.pluginCache = new Map();
326
+ this.PLUGIN_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
327
+ this.pluginCacheTimestamps = new Map();
112
328
  // TTS support
113
329
  this.ttsPlayer = null;
114
330
  this.ttsQueue = [];
@@ -145,6 +361,7 @@ class Player extends events_1.EventEmitter {
145
361
  this.volume = this.options.volume || 100;
146
362
  this.userdata = this.options.userdata;
147
363
  this.setupEventListeners();
364
+ this.extensionContext = Object.freeze({ player: this, manager });
148
365
  // Optionally pre-create the TTS AudioPlayer
149
366
  if (this.options?.tts?.createPlayer) {
150
367
  this.ensureTTSPlayer();
@@ -230,6 +447,14 @@ class Player extends events_1.EventEmitter {
230
447
  this.debug(`[Player] Removing plugin: ${name}`);
231
448
  return this.pluginManager.unregister(name);
232
449
  }
450
+ /**
451
+ * Connect to a voice channel
452
+ *
453
+ * @param {VoiceChannel} channel - Discord voice channel
454
+ * @returns {Promise<VoiceConnection>} The voice connection
455
+ * @example
456
+ * await player.connect(voiceChannel);
457
+ */
233
458
  async connect(channel) {
234
459
  try {
235
460
  this.debug(`[Player] Connecting to voice channel: ${channel.id}`);
@@ -261,14 +486,29 @@ class Player extends events_1.EventEmitter {
261
486
  throw error;
262
487
  }
263
488
  }
489
+ /**
490
+ * Search for tracks using the player's extensions and plugins
491
+ *
492
+ * @param {string} query - The query to search for
493
+ * @param {string} requestedBy - The user ID who requested the search
494
+ * @returns {Promise<SearchResult>} The search result
495
+ * @example
496
+ * const result = await player.search("Never Gonna Give You Up", userId);
497
+ * console.log(`Search result: ${result.tracks.length} tracks`);
498
+ */
264
499
  async search(query, requestedBy) {
265
500
  this.debug(`[Player] Search called with query: ${query}, requestedBy: ${requestedBy}`);
501
+ const extensionResult = await this.extensionsProvideSearch(query, requestedBy);
502
+ if (extensionResult && Array.isArray(extensionResult.tracks) && extensionResult.tracks.length > 0) {
503
+ this.debug(`[Player] Extension handled search for query: ${query}`);
504
+ return extensionResult;
505
+ }
266
506
  const plugins = this.pluginManager.getAll();
267
507
  let lastError = null;
268
508
  for (const p of plugins) {
269
509
  try {
270
510
  this.debug(`[Player] Trying plugin for search: ${p.name}`);
271
- const res = await this.withTimeout(p.search(query, requestedBy), `Search operation timed out for ${p.name}`);
511
+ const res = await (0, timeout_1.withTimeout)(p.search(query, requestedBy), this.options.extractorTimeout ?? 15000, `Search operation timed out for ${p.name}`);
272
512
  if (res && Array.isArray(res.tracks) && res.tracks.length > 0) {
273
513
  this.debug(`[Player] Plugin '${p.name}' returned ${res.tracks.length} tracks`);
274
514
  return res;
@@ -286,29 +526,66 @@ class Player extends events_1.EventEmitter {
286
526
  this.emit("playerError", lastError);
287
527
  throw new Error(`No plugin found to handle: ${query}`);
288
528
  }
529
+ /**
530
+ * Play a track or search query
531
+ *
532
+ * @param {string | Track} query - Track URL, search query, or Track object
533
+ * @param {string} requestedBy - User ID who requested the track
534
+ * @returns {Promise<boolean>} True if playback started successfully
535
+ * @example
536
+ * await player.play("Never Gonna Give You Up", userId);
537
+ * await player.play("https://youtube.com/watch?v=dQw4w9WgXcQ", userId);
538
+ * await player.play("tts: Hello everyone!", userId);
539
+ */
289
540
  async play(query, requestedBy) {
541
+ this.debug(`[Player] Play called with query: ${typeof query === "string" ? query : query?.title}`);
542
+ this.clearLeaveTimeout();
543
+ let tracksToAdd = [];
544
+ let isPlaylist = false;
545
+ let effectiveRequest = { query, requestedBy };
546
+ let hookResponse = {};
290
547
  try {
291
- this.debug(`[Player] Play called with query: ${typeof query === "string" ? query : query?.title}`);
292
- // If a leave was scheduled due to previous idle, cancel it now
293
- this.clearLeaveTimeout();
294
- let tracksToAdd = [];
295
- let isPlaylist = false;
296
- if (typeof query === "string") {
297
- const searchResult = await this.search(query, requestedBy || "Unknown");
548
+ const hookOutcome = await this.runBeforePlayHooks(effectiveRequest);
549
+ effectiveRequest = hookOutcome.request;
550
+ hookResponse = hookOutcome.response;
551
+ if (effectiveRequest.requestedBy === undefined) {
552
+ effectiveRequest.requestedBy = requestedBy;
553
+ }
554
+ const hookTracks = Array.isArray(hookResponse.tracks) ? hookResponse.tracks : undefined;
555
+ if (hookResponse.handled && (!hookTracks || hookTracks.length === 0)) {
556
+ const handledPayload = {
557
+ success: hookResponse.success ?? true,
558
+ query: effectiveRequest.query,
559
+ requestedBy: effectiveRequest.requestedBy,
560
+ tracks: [],
561
+ isPlaylist: hookResponse.isPlaylist ?? false,
562
+ error: hookResponse.error,
563
+ };
564
+ await this.runAfterPlayHooks(handledPayload);
565
+ if (hookResponse.error) {
566
+ this.emit("playerError", hookResponse.error);
567
+ }
568
+ return hookResponse.success ?? true;
569
+ }
570
+ if (hookTracks && hookTracks.length > 0) {
571
+ tracksToAdd = hookTracks;
572
+ isPlaylist = hookResponse.isPlaylist ?? hookTracks.length > 1;
573
+ }
574
+ else if (typeof effectiveRequest.query === "string") {
575
+ const searchResult = await this.search(effectiveRequest.query, effectiveRequest.requestedBy || "Unknown");
298
576
  tracksToAdd = searchResult.tracks;
299
577
  if (searchResult.playlist) {
300
578
  isPlaylist = true;
301
579
  this.debug(`[Player] Added playlist: ${searchResult.playlist.name} (${tracksToAdd.length} tracks)`);
302
580
  }
303
581
  }
304
- else {
305
- tracksToAdd = [query];
582
+ else if (effectiveRequest.query) {
583
+ tracksToAdd = [effectiveRequest.query];
306
584
  }
307
585
  if (tracksToAdd.length === 0) {
308
586
  this.debug(`[Player] No tracks found for play`);
309
587
  throw new Error("No tracks found");
310
588
  }
311
- // If a TTS track is requested and interrupt mode is enabled, handle it separately
312
589
  const isTTS = (t) => {
313
590
  if (!t)
314
591
  return false;
@@ -319,14 +596,20 @@ class Player extends events_1.EventEmitter {
319
596
  return false;
320
597
  }
321
598
  };
322
- const queryLooksTTS = typeof query === "string" && query.trim().toLowerCase().startsWith("tts");
599
+ const queryLooksTTS = typeof effectiveRequest.query === "string" && effectiveRequest.query.trim().toLowerCase().startsWith("tts");
323
600
  if (!isPlaylist &&
324
601
  tracksToAdd.length > 0 &&
325
602
  this.options?.tts?.interrupt !== false &&
326
603
  (isTTS(tracksToAdd[0]) || queryLooksTTS)) {
327
- // Interrupt music playback with TTS (do not modify the music queue)
328
604
  this.debug(`[Player] Interrupting with TTS: ${tracksToAdd[0].title}`);
329
605
  await this.interruptWithTTSTrack(tracksToAdd[0]);
606
+ await this.runAfterPlayHooks({
607
+ success: true,
608
+ query: effectiveRequest.query,
609
+ requestedBy: effectiveRequest.requestedBy,
610
+ tracks: tracksToAdd,
611
+ isPlaylist,
612
+ });
330
613
  return true;
331
614
  }
332
615
  if (isPlaylist) {
@@ -334,16 +617,28 @@ class Player extends events_1.EventEmitter {
334
617
  this.emit("queueAddList", tracksToAdd);
335
618
  }
336
619
  else {
337
- this.queue.add(tracksToAdd?.[0]);
338
- this.emit("queueAdd", tracksToAdd?.[0]);
339
- }
340
- // Start playing if not already playing
341
- if (!this.isPlaying) {
342
- return this.playNext();
620
+ this.queue.add(tracksToAdd[0]);
621
+ this.emit("queueAdd", tracksToAdd[0]);
343
622
  }
344
- return true;
623
+ const started = !this.isPlaying ? await this.playNext() : true;
624
+ await this.runAfterPlayHooks({
625
+ success: started,
626
+ query: effectiveRequest.query,
627
+ requestedBy: effectiveRequest.requestedBy,
628
+ tracks: tracksToAdd,
629
+ isPlaylist,
630
+ });
631
+ return started;
345
632
  }
346
633
  catch (error) {
634
+ await this.runAfterPlayHooks({
635
+ success: false,
636
+ query: effectiveRequest.query,
637
+ requestedBy: effectiveRequest.requestedBy,
638
+ tracks: tracksToAdd,
639
+ isPlaylist,
640
+ error: error,
641
+ });
347
642
  this.debug(`[Player] Play error:`, error);
348
643
  this.emit("playerError", error);
349
644
  return false;
@@ -352,6 +647,11 @@ class Player extends events_1.EventEmitter {
352
647
  /**
353
648
  * Interrupt current music with a TTS track. Pauses music, swaps the
354
649
  * subscription to a dedicated TTS player, plays TTS, then resumes.
650
+ *
651
+ * @param {Track} track - The track to interrupt with
652
+ * @returns {Promise<void>}
653
+ * @example
654
+ * await player.interruptWithTTSTrack(track);
355
655
  */
356
656
  async interruptWithTTSTrack(track) {
357
657
  this.ttsQueue.push(track);
@@ -359,7 +659,13 @@ class Player extends events_1.EventEmitter {
359
659
  void this.playNextTTS();
360
660
  }
361
661
  }
362
- /** Play queued TTS items sequentially */
662
+ /**
663
+ * Play queued TTS items sequentially
664
+ *
665
+ * @returns {Promise<void>}
666
+ * @example
667
+ * await player.playNextTTS();
668
+ */
363
669
  async playNextTTS() {
364
670
  const next = this.ttsQueue.shift();
365
671
  if (!next)
@@ -415,33 +721,160 @@ class Player extends events_1.EventEmitter {
415
721
  }
416
722
  }
417
723
  }
724
+ /**
725
+ * Get cached plugin or find and cache a new one
726
+ * @param track The track to find plugin for
727
+ * @returns The matching plugin or null if not found
728
+ */
729
+ getCachedPlugin(track) {
730
+ const cacheKey = `${track.source}:${track.url}`;
731
+ const now = Date.now();
732
+ // Check if cache is still valid
733
+ const cachedTimestamp = this.pluginCacheTimestamps.get(cacheKey);
734
+ if (cachedTimestamp && now - cachedTimestamp < this.PLUGIN_CACHE_TTL) {
735
+ const cachedPlugin = this.pluginCache.get(cacheKey);
736
+ if (cachedPlugin) {
737
+ this.debug(`[PluginCache] Using cached plugin for ${track.source}: ${cachedPlugin.name}`);
738
+ return cachedPlugin;
739
+ }
740
+ }
741
+ // Find new plugin and cache it
742
+ this.debug(`[PluginCache] Finding plugin for track: ${track.title} (${track.source})`);
743
+ const plugin = this.pluginManager.findPlugin(track.url) || this.pluginManager.get(track.source);
744
+ if (plugin) {
745
+ this.pluginCache.set(cacheKey, plugin);
746
+ this.pluginCacheTimestamps.set(cacheKey, now);
747
+ this.debug(`[PluginCache] Cached plugin: ${plugin.name} for ${track.source}`);
748
+ return plugin;
749
+ }
750
+ return null;
751
+ }
752
+ /**
753
+ * Clear expired cache entries
754
+ */
755
+ clearExpiredCache() {
756
+ const now = Date.now();
757
+ for (const [key, timestamp] of this.pluginCacheTimestamps.entries()) {
758
+ if (now - timestamp >= this.PLUGIN_CACHE_TTL) {
759
+ this.pluginCache.delete(key);
760
+ this.pluginCacheTimestamps.delete(key);
761
+ this.debug(`[PluginCache] Cleared expired cache entry: ${key}`);
762
+ }
763
+ }
764
+ }
765
+ /**
766
+ * Clear all plugin cache entries
767
+ * @example
768
+ * player.clearPluginCache();
769
+ */
770
+ clearPluginCache() {
771
+ const cacheSize = this.pluginCache.size;
772
+ this.pluginCache.clear();
773
+ this.pluginCacheTimestamps.clear();
774
+ this.debug(`[PluginCache] Cleared all ${cacheSize} cache entries`);
775
+ }
776
+ /**
777
+ * Get plugin cache statistics
778
+ * @returns Cache statistics
779
+ * @example
780
+ * const stats = player.getPluginCacheStats();
781
+ * console.log(`Cache size: ${stats.size}, Hit rate: ${stats.hitRate}%`);
782
+ */
783
+ getPluginCacheStats() {
784
+ const now = Date.now();
785
+ let expiredEntries = 0;
786
+ for (const timestamp of this.pluginCacheTimestamps.values()) {
787
+ if (now - timestamp >= this.PLUGIN_CACHE_TTL) {
788
+ expiredEntries++;
789
+ }
790
+ }
791
+ return {
792
+ size: this.pluginCache.size,
793
+ hitRate: 0, // Would need to track hits/misses to calculate this
794
+ expiredEntries,
795
+ };
796
+ }
418
797
  /** Build AudioResource for a given track using the plugin pipeline */
419
798
  async resourceFromTrack(track) {
420
- // Resolve plugin similar to playNext
421
- const plugin = this.pluginManager.findPlugin(track.url) || this.pluginManager.get(track.source);
422
- if (!plugin)
799
+ this.debug(`[ResourceFromTrack] Starting resource creation for track: ${track.title} (${track.source})`);
800
+ // Clear expired cache entries periodically
801
+ if (Math.random() < 0.1) {
802
+ // 10% chance to clean cache
803
+ this.clearExpiredCache();
804
+ }
805
+ // Resolve plugin using cache
806
+ const plugin = this.getCachedPlugin(track);
807
+ if (!plugin) {
808
+ this.debug(`[ResourceFromTrack] No plugin found for track: ${track.title} (${track.source})`);
423
809
  throw new Error(`No plugin found for track: ${track.title}`);
424
- let streamInfo;
810
+ }
811
+ this.debug(`[ResourceFromTrack] Using plugin: ${plugin.name} for track: ${track.title}`);
812
+ let streamInfo = null;
813
+ const timeoutMs = this.options.extractorTimeout ?? 15000;
425
814
  try {
426
- streamInfo = await this.withTimeout(plugin.getStream(track), "getStream timed out");
815
+ this.debug(`[ResourceFromTrack] Attempting getStream with ${plugin.name}, timeout: ${timeoutMs}ms`);
816
+ const startTime = Date.now();
817
+ streamInfo = await (0, timeout_1.withTimeout)(plugin.getStream(track), timeoutMs, "getStream timed out");
818
+ const duration = Date.now() - startTime;
819
+ this.debug(`[ResourceFromTrack] getStream successful with ${plugin.name} in ${duration}ms`);
820
+ if (!streamInfo?.stream) {
821
+ this.debug(`[ResourceFromTrack] getStream returned no stream from ${plugin.name}`);
822
+ throw new Error(`No stream returned from ${plugin.name}`);
823
+ }
427
824
  }
428
825
  catch (streamError) {
826
+ const errorMessage = streamError instanceof Error ? streamError.message : String(streamError);
827
+ this.debug(`[ResourceFromTrack] getStream failed with ${plugin.name}: ${errorMessage}`);
828
+ // Log more details for debugging
829
+ if (streamError instanceof Error && streamError.stack) {
830
+ this.debug(`[ResourceFromTrack] getStream error stack:`, streamError.stack);
831
+ }
429
832
  // try fallbacks
833
+ this.debug(`[ResourceFromTrack] Attempting fallback plugins for track: ${track.title}`);
430
834
  const allplugs = this.pluginManager.getAll();
835
+ let fallbackAttempts = 0;
431
836
  for (const p of allplugs) {
432
- if (typeof p.getFallback !== "function")
837
+ if (typeof p.getFallback !== "function" && typeof p.getStream !== "function") {
838
+ this.debug(`[ResourceFromTrack] Skipping plugin ${p.name} - no getFallback or getStream method`);
433
839
  continue;
840
+ }
841
+ fallbackAttempts++;
842
+ this.debug(`[ResourceFromTrack] Trying fallback plugin ${p.name} (attempt ${fallbackAttempts})`);
434
843
  try {
435
- streamInfo = await this.withTimeout(p.getFallback(track), `getFallback timed out for plugin ${p.name}`);
436
- if (!streamInfo?.stream)
437
- continue;
438
- break;
844
+ // Try getStream first
845
+ const startTime = Date.now();
846
+ streamInfo = await (0, timeout_1.withTimeout)(p.getStream(track), timeoutMs, "getStream timed out");
847
+ const duration = Date.now() - startTime;
848
+ if (streamInfo?.stream) {
849
+ this.debug(`[ResourceFromTrack] Fallback getStream successful with ${p.name} in ${duration}ms`);
850
+ break;
851
+ }
852
+ // Try getFallback if getStream didn't work
853
+ this.debug(`[ResourceFromTrack] Trying getFallback with ${p.name}`);
854
+ const fallbackStartTime = Date.now();
855
+ streamInfo = await (0, timeout_1.withTimeout)(p.getFallback(track), timeoutMs, `getFallback timed out for plugin ${p.name}`);
856
+ const fallbackDuration = Date.now() - fallbackStartTime;
857
+ if (streamInfo?.stream) {
858
+ this.debug(`[ResourceFromTrack] Fallback getFallback successful with ${p.name} in ${fallbackDuration}ms`);
859
+ break;
860
+ }
861
+ this.debug(`[ResourceFromTrack] Fallback plugin ${p.name} returned no stream`);
862
+ }
863
+ catch (fallbackError) {
864
+ const errorMessage = fallbackError instanceof Error ? fallbackError.message : String(fallbackError);
865
+ this.debug(`[ResourceFromTrack] Fallback plugin ${p.name} failed: ${errorMessage}`);
866
+ // Log more details for debugging
867
+ if (fallbackError instanceof Error && fallbackError.stack) {
868
+ this.debug(`[ResourceFromTrack] Fallback error stack:`, fallbackError.stack);
869
+ }
439
870
  }
440
- catch { }
441
871
  }
442
- if (!streamInfo?.stream)
872
+ if (!streamInfo?.stream) {
873
+ this.debug(`[ResourceFromTrack] All ${fallbackAttempts} fallback attempts failed for track: ${track.title}`);
443
874
  throw new Error(`All getFallback attempts failed for track: ${track.title}`);
875
+ }
444
876
  }
877
+ this.debug(`[ResourceFromTrack] Stream obtained, type: ${streamInfo.type}, metadata keys: ${Object.keys(streamInfo.metadata || {}).join(", ")}`);
445
878
  const mapToStreamType = (type) => {
446
879
  switch (type) {
447
880
  case "webm/opus":
@@ -454,15 +887,20 @@ class Player extends events_1.EventEmitter {
454
887
  }
455
888
  };
456
889
  const inputType = mapToStreamType(streamInfo.type);
457
- return (0, voice_1.createAudioResource)(streamInfo.stream, {
890
+ this.debug(`[ResourceFromTrack] Creating AudioResource with inputType: ${inputType}`);
891
+ // Merge metadata safely
892
+ const mergedMetadata = {
893
+ ...track,
894
+ ...(streamInfo.metadata || {}),
895
+ };
896
+ const audioResource = (0, voice_1.createAudioResource)(streamInfo.stream, {
458
897
  // Prefer plugin-provided metadata (e.g., precise duration), fallback to track fields
459
- metadata: {
460
- ...track,
461
- ...(streamInfo?.metadata || {}),
462
- },
898
+ metadata: mergedMetadata,
463
899
  inputType,
464
900
  inlineVolume: true,
465
901
  });
902
+ this.debug(`[ResourceFromTrack] AudioResource created successfully for track: ${track.title}`);
903
+ return audioResource;
466
904
  }
467
905
  async generateWillNext() {
468
906
  const lastTrack = this.queue.previousTracks[this.queue.previousTracks.length - 1] ?? this.queue.currentTrack;
@@ -475,10 +913,10 @@ class Player extends events_1.EventEmitter {
475
913
  for (const p of candidates) {
476
914
  try {
477
915
  this.debug(`[Player] Trying related from plugin: ${p.name}`);
478
- const related = await this.withTimeout(p.getRelatedTracks(lastTrack.url, {
916
+ const related = await (0, timeout_1.withTimeout)(p.getRelatedTracks(lastTrack.url, {
479
917
  limit: 10,
480
918
  history: this.queue.previousTracks,
481
- }), `getRelatedTracks timed out for ${p.name}`);
919
+ }), this.options.extractorTimeout ?? 15000, `getRelatedTracks timed out for ${p.name}`);
482
920
  if (Array.isArray(related) && related.length > 0) {
483
921
  const randomchoice = Math.floor(Math.random() * related.length);
484
922
  const nextTrack = this.queue.nextTrack ? this.queue.nextTrack : related[randomchoice];
@@ -529,6 +967,14 @@ class Player extends events_1.EventEmitter {
529
967
  return this.playNext();
530
968
  }
531
969
  }
970
+ /**
971
+ * Pause the current track
972
+ *
973
+ * @returns {boolean} True if paused successfully
974
+ * @example
975
+ * const paused = player.pause();
976
+ * console.log(`Paused: ${paused}`);
977
+ */
532
978
  pause() {
533
979
  this.debug(`[Player] pause called`);
534
980
  if (this.isPlaying && !this.isPaused) {
@@ -536,6 +982,14 @@ class Player extends events_1.EventEmitter {
536
982
  }
537
983
  return false;
538
984
  }
985
+ /**
986
+ * Resume the current track
987
+ *
988
+ * @returns {boolean} True if resumed successfully
989
+ * @example
990
+ * const resumed = player.resume();
991
+ * console.log(`Resumed: ${resumed}`);
992
+ */
539
993
  resume() {
540
994
  this.debug(`[Player] resume called`);
541
995
  if (this.isPaused) {
@@ -551,6 +1005,14 @@ class Player extends events_1.EventEmitter {
551
1005
  }
552
1006
  return false;
553
1007
  }
1008
+ /**
1009
+ * Stop the current track
1010
+ *
1011
+ * @returns {boolean} True if stopped successfully
1012
+ * @example
1013
+ * const stopped = player.stop();
1014
+ * console.log(`Stopped: ${stopped}`);
1015
+ */
554
1016
  stop() {
555
1017
  this.debug(`[Player] stop called`);
556
1018
  this.queue.clear();
@@ -560,6 +1022,14 @@ class Player extends events_1.EventEmitter {
560
1022
  this.emit("playerStop");
561
1023
  return result;
562
1024
  }
1025
+ /**
1026
+ * Skip to the next track
1027
+ *
1028
+ * @returns {boolean} True if skipped successfully
1029
+ * @example
1030
+ * const skipped = player.skip();
1031
+ * console.log(`Skipped: ${skipped}`);
1032
+ */
563
1033
  skip() {
564
1034
  this.debug(`[Player] skip called`);
565
1035
  if (this.isPlaying || this.isPaused) {
@@ -570,6 +1040,11 @@ class Player extends events_1.EventEmitter {
570
1040
  }
571
1041
  /**
572
1042
  * Go back to the previous track in history and play it.
1043
+ *
1044
+ * @returns {Promise<boolean>} True if previous track was played successfully
1045
+ * @example
1046
+ * const previous = await player.previous();
1047
+ * console.log(`Previous: ${previous}`);
573
1048
  */
574
1049
  async previous() {
575
1050
  this.debug(`[Player] previous called`);
@@ -581,12 +1056,39 @@ class Player extends events_1.EventEmitter {
581
1056
  this.clearLeaveTimeout();
582
1057
  return this.startTrack(track);
583
1058
  }
1059
+ /**
1060
+ * Loop the current track
1061
+ *
1062
+ * @param {LoopMode} mode - The loop mode to set
1063
+ * @returns {LoopMode} The loop mode
1064
+ * @example
1065
+ * const loopMode = player.loop("track");
1066
+ * console.log(`Loop mode: ${loopMode}`);
1067
+ */
584
1068
  loop(mode) {
585
1069
  return this.queue.loop(mode);
586
1070
  }
1071
+ /**
1072
+ * Set the auto-play mode
1073
+ *
1074
+ * @param {boolean} mode - The auto-play mode to set
1075
+ * @returns {boolean} The auto-play mode
1076
+ * @example
1077
+ * const autoPlayMode = player.autoPlay(true);
1078
+ * console.log(`Auto-play mode: ${autoPlayMode}`);
1079
+ */
587
1080
  autoPlay(mode) {
588
1081
  return this.queue.autoPlay(mode);
589
1082
  }
1083
+ /**
1084
+ * Set the volume of the current track
1085
+ *
1086
+ * @param {number} volume - The volume to set
1087
+ * @returns {boolean} True if volume was set successfully
1088
+ * @example
1089
+ * const volumeSet = player.setVolume(50);
1090
+ * console.log(`Volume set: ${volumeSet}`);
1091
+ */
590
1092
  setVolume(volume) {
591
1093
  this.debug(`[Player] setVolume called: ${volume}`);
592
1094
  if (volume < 0 || volume > 200)
@@ -614,10 +1116,24 @@ class Player extends events_1.EventEmitter {
614
1116
  this.emit("volumeChange", oldVolume, volume);
615
1117
  return true;
616
1118
  }
1119
+ /**
1120
+ * Shuffle the queue
1121
+ *
1122
+ * @returns {void}
1123
+ * @example
1124
+ * player.shuffle();
1125
+ */
617
1126
  shuffle() {
618
1127
  this.debug(`[Player] shuffle called`);
619
1128
  this.queue.shuffle();
620
1129
  }
1130
+ /**
1131
+ * Clear the queue
1132
+ *
1133
+ * @returns {void}
1134
+ * @example
1135
+ * player.clearQueue();
1136
+ */
621
1137
  clearQueue() {
622
1138
  this.debug(`[Player] clearQueue called`);
623
1139
  this.queue.clear();
@@ -627,6 +1143,14 @@ class Player extends events_1.EventEmitter {
627
1143
  * - If `query` is a string, performs a search and inserts resulting tracks (playlist supported).
628
1144
  * - If a Track or Track[] is provided, inserts directly.
629
1145
  * Does not auto-start playback; it only modifies the queue.
1146
+ *
1147
+ * @param {string | Track | Track[]} query - The track or tracks to insert
1148
+ * @param {number} index - The index to insert the tracks at
1149
+ * @param {string} requestedBy - The user ID who requested the insert
1150
+ * @returns {Promise<boolean>} True if the tracks were inserted successfully
1151
+ * @example
1152
+ * const inserted = await player.insert("Song Name", 0, userId);
1153
+ * console.log(`Inserted: ${inserted}`);
630
1154
  */
631
1155
  async insert(query, index, requestedBy) {
632
1156
  try {
@@ -667,6 +1191,15 @@ class Player extends events_1.EventEmitter {
667
1191
  return false;
668
1192
  }
669
1193
  }
1194
+ /**
1195
+ * Remove a track from the queue
1196
+ *
1197
+ * @param {number} index - The index of the track to remove
1198
+ * @returns {Track | null} The removed track or null
1199
+ * @example
1200
+ * const removed = player.remove(0);
1201
+ * console.log(`Removed: ${removed?.title}`);
1202
+ */
670
1203
  remove(index) {
671
1204
  this.debug(`[Player] remove called for index: ${index}`);
672
1205
  const track = this.queue.remove(index);
@@ -675,6 +1208,15 @@ class Player extends events_1.EventEmitter {
675
1208
  }
676
1209
  return track;
677
1210
  }
1211
+ /**
1212
+ * Get the progress bar of the current track
1213
+ *
1214
+ * @param {ProgressBarOptions} options - The options for the progress bar
1215
+ * @returns {string} The progress bar
1216
+ * @example
1217
+ * const progressBar = player.getProgressBar();
1218
+ * console.log(`Progress bar: ${progressBar}`);
1219
+ */
678
1220
  getProgressBar(options = {}) {
679
1221
  const { size = 20, barChar = "▬", progressChar = "🔘" } = options;
680
1222
  const track = this.queue.currentTrack;
@@ -690,6 +1232,14 @@ class Player extends events_1.EventEmitter {
690
1232
  const bar = barChar.repeat(progress) + progressChar + barChar.repeat(size - progress);
691
1233
  return `${this.formatTime(current)} | ${bar} | ${this.formatTime(total)}`;
692
1234
  }
1235
+ /**
1236
+ * Get the time of the current track
1237
+ *
1238
+ * @returns {Object} The time of the current track
1239
+ * @example
1240
+ * const time = player.getTime();
1241
+ * console.log(`Time: ${time.current}`);
1242
+ */
693
1243
  getTime() {
694
1244
  const resource = this.currentResource;
695
1245
  const track = this.queue.currentTrack;
@@ -706,6 +1256,15 @@ class Player extends events_1.EventEmitter {
706
1256
  format: this.formatTime(resource.playbackDuration),
707
1257
  };
708
1258
  }
1259
+ /**
1260
+ * Format the time in the format of HH:MM:SS
1261
+ *
1262
+ * @param {number} ms - The time in milliseconds
1263
+ * @returns {string} The formatted time
1264
+ * @example
1265
+ * const formattedTime = player.formatTime(1000);
1266
+ * console.log(`Formatted time: ${formattedTime}`);
1267
+ */
709
1268
  formatTime(ms) {
710
1269
  const totalSeconds = Math.floor(ms / 1000);
711
1270
  const hours = Math.floor(totalSeconds / 3600);
@@ -730,6 +1289,13 @@ class Player extends events_1.EventEmitter {
730
1289
  }, this.options.leaveTimeout);
731
1290
  }
732
1291
  }
1292
+ /**
1293
+ * Destroy the player
1294
+ *
1295
+ * @returns {void}
1296
+ * @example
1297
+ * player.destroy();
1298
+ */
733
1299
  destroy() {
734
1300
  this.debug(`[Player] destroy called`);
735
1301
  if (this.leaveTimeout) {
@@ -750,30 +1316,92 @@ class Player extends events_1.EventEmitter {
750
1316
  }
751
1317
  this.queue.clear();
752
1318
  this.pluginManager.clear();
1319
+ for (const extension of [...this.extensions]) {
1320
+ this.invokeExtensionLifecycle(extension, "onDestroy");
1321
+ if (extension.player === this) {
1322
+ extension.player = null;
1323
+ }
1324
+ }
1325
+ this.extensions = [];
753
1326
  this.isPlaying = false;
754
1327
  this.isPaused = false;
755
1328
  this.emit("playerDestroy");
756
1329
  this.removeAllListeners();
757
1330
  }
758
- // Getters
1331
+ /**
1332
+ * Get the size of the queue
1333
+ *
1334
+ * @returns {number} The size of the queue
1335
+ * @example
1336
+ * const queueSize = player.queueSize;
1337
+ * console.log(`Queue size: ${queueSize}`);
1338
+ */
759
1339
  get queueSize() {
760
1340
  return this.queue.size;
761
1341
  }
1342
+ /**
1343
+ * Get the current track
1344
+ *
1345
+ * @returns {Track | null} The current track or null
1346
+ * @example
1347
+ * const currentTrack = player.currentTrack;
1348
+ * console.log(`Current track: ${currentTrack?.title}`);
1349
+ */
762
1350
  get currentTrack() {
763
1351
  return this.queue.currentTrack;
764
1352
  }
1353
+ /**
1354
+ * Get the previous track
1355
+ *
1356
+ * @returns {Track | null} The previous track or null
1357
+ * @example
1358
+ * const previousTrack = player.previousTrack;
1359
+ * console.log(`Previous track: ${previousTrack?.title}`);
1360
+ */
765
1361
  get previousTrack() {
766
1362
  return this.queue.previousTracks?.at(-1) ?? null;
767
1363
  }
1364
+ /**
1365
+ * Get the upcoming tracks
1366
+ *
1367
+ * @returns {Track[]} The upcoming tracks
1368
+ * @example
1369
+ * const upcomingTracks = player.upcomingTracks;
1370
+ * console.log(`Upcoming tracks: ${upcomingTracks.length}`);
1371
+ */
768
1372
  get upcomingTracks() {
769
1373
  return this.queue.getTracks();
770
1374
  }
1375
+ /**
1376
+ * Get the previous tracks
1377
+ *
1378
+ * @returns {Track[]} The previous tracks
1379
+ * @example
1380
+ * const previousTracks = player.previousTracks;
1381
+ * console.log(`Previous tracks: ${previousTracks.length}`);
1382
+ */
771
1383
  get previousTracks() {
772
1384
  return this.queue.previousTracks;
773
1385
  }
1386
+ /**
1387
+ * Get the available plugins
1388
+ *
1389
+ * @returns {string[]} The available plugins
1390
+ * @example
1391
+ * const availablePlugins = player.availablePlugins;
1392
+ * console.log(`Available plugins: ${availablePlugins.length}`);
1393
+ */
774
1394
  get availablePlugins() {
775
1395
  return this.pluginManager.getAll().map((p) => p.name);
776
1396
  }
1397
+ /**
1398
+ * Get the related tracks
1399
+ *
1400
+ * @returns {Track[] | null} The related tracks or null
1401
+ * @example
1402
+ * const relatedTracks = player.relatedTracks;
1403
+ * console.log(`Related tracks: ${relatedTracks?.length}`);
1404
+ */
777
1405
  get relatedTracks() {
778
1406
  return this.queue.relatedTracks();
779
1407
  }