ziplayer 0.1.5 → 0.2.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.
@@ -15,8 +15,8 @@ import {
15
15
 
16
16
  import { VoiceChannel } from "discord.js";
17
17
  import { Readable } from "stream";
18
- import { BaseExtension } from "../extensions";
19
- import {
18
+ import type { BaseExtension } from "../extensions";
19
+ import type {
20
20
  Track,
21
21
  PlayerOptions,
22
22
  PlayerEvents,
@@ -26,19 +26,18 @@ import {
26
26
  LoopMode,
27
27
  StreamInfo,
28
28
  SaveOptions,
29
- } from "../types";
30
- import type {
31
- ExtensionContext,
32
29
  ExtensionPlayRequest,
33
30
  ExtensionPlayResponse,
34
31
  ExtensionAfterPlayPayload,
35
- ExtensionStreamRequest,
36
- ExtensionSearchRequest,
37
32
  } from "../types";
33
+ import type { PlayerManager } from "./PlayerManager";
34
+
38
35
  import { Queue } from "./Queue";
39
36
  import { PluginManager } from "../plugins";
37
+ import { ExtensionManager } from "../extensions";
40
38
  import { withTimeout } from "../utils/timeout";
41
- import type { PlayerManager } from "./PlayerManager";
39
+ import { FilterManager } from "./FilterManager";
40
+
42
41
  export declare interface Player {
43
42
  on<K extends keyof PlayerEvents>(event: K, listener: (...args: PlayerEvents[K]) => void): this;
44
43
  emit<K extends keyof PlayerEvents>(event: K, ...args: PlayerEvents[K]): boolean;
@@ -90,313 +89,20 @@ export class Player extends EventEmitter {
90
89
  public isPaused: boolean = false;
91
90
  public options: PlayerOptions;
92
91
  public pluginManager: PluginManager;
92
+ public extensionManager: ExtensionManager;
93
93
  public userdata?: Record<string, any>;
94
94
  private manager: PlayerManager;
95
95
  private leaveTimeout: NodeJS.Timeout | null = null;
96
96
  private currentResource: AudioResource | null = null;
97
97
  private volumeInterval: NodeJS.Timeout | null = null;
98
98
  private skipLoop = false;
99
- private extensions: BaseExtension[] = [];
100
- private extensionContext!: ExtensionContext;
99
+ private filter!: FilterManager;
101
100
 
102
101
  // Cache for search results to avoid duplicate calls
103
102
  private searchCache = new Map<string, SearchResult>();
104
103
  private readonly SEARCH_CACHE_TTL = 2 * 60 * 1000; // 2 minutes
105
104
  private searchCacheTimestamps = new Map<string, number>();
106
- // TTS support
107
105
  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
106
 
401
107
  constructor(guildId: string, options: PlayerOptions = {}, manager: PlayerManager) {
402
108
  super();
@@ -411,8 +117,6 @@ export class Player extends EventEmitter {
411
117
  },
412
118
  });
413
119
 
414
- this.pluginManager = new PluginManager();
415
-
416
120
  this.options = {
417
121
  leaveOnEnd: true,
418
122
  leaveOnEmpty: true,
@@ -431,11 +135,24 @@ export class Player extends EventEmitter {
431
135
  ...(options?.tts || {}),
432
136
  },
433
137
  };
138
+ this.filter = new FilterManager(this, this.manager);
139
+ this.extensionManager = new ExtensionManager(this, this.manager);
140
+ this.pluginManager = new PluginManager(this, this.manager, {
141
+ extractorTimeout: this.options.extractorTimeout,
142
+ });
434
143
 
435
144
  this.volume = this.options.volume || 100;
436
145
  this.userdata = this.options.userdata;
437
146
  this.setupEventListeners();
438
- this.extensionContext = Object.freeze({ player: this, manager });
147
+
148
+ // Initialize filters from options
149
+ if (this.options.filters && this.options.filters.length > 0) {
150
+ this.debug(`[Player] Initializing ${this.options.filters.length} filters from options`);
151
+ // Use async version but don't await in constructor
152
+ this.filter.applyFilters(this.options.filters).catch((error: any) => {
153
+ this.debug(`[Player] Error initializing filters:`, error);
154
+ });
155
+ }
439
156
 
440
157
  // Optionally pre-create the TTS AudioPlayer
441
158
  if (this.options?.tts?.createPlayer) {
@@ -443,129 +160,7 @@ export class Player extends EventEmitter {
443
160
  }
444
161
  }
445
162
 
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
- }
163
+ //#region Search
569
164
 
570
165
  /**
571
166
  * Search for tracks using the player's extensions and plugins
@@ -592,60 +187,195 @@ export class Player extends EventEmitter {
592
187
  return cachedResult;
593
188
  }
594
189
 
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
- }
190
+ // Try extensions first
191
+ const extensionResult = await this.extensionManager.provideSearch(query, requestedBy);
192
+ if (extensionResult && Array.isArray(extensionResult.tracks) && extensionResult.tracks.length > 0) {
193
+ this.debug(`[Player] Extension handled search for query: ${query}`);
194
+ this.cacheSearchResult(query, extensionResult);
195
+ return extensionResult;
196
+ }
197
+
198
+ // Get plugins and filter out TTS for regular searches
199
+ const allPlugins = this.pluginManager.getAll();
200
+ const plugins = allPlugins.filter((p) => {
201
+ // Skip TTS plugin for regular searches (unless query starts with "tts:")
202
+ if (p.name.toLowerCase() === "tts" && !query.toLowerCase().startsWith("tts:")) {
203
+ this.debug(`[Player] Skipping TTS plugin for regular search: ${query}`);
204
+ return false;
205
+ }
206
+ return true;
207
+ });
208
+
209
+ this.debug(`[Player] Using ${plugins.length} plugins for search (filtered from ${allPlugins.length})`);
210
+
211
+ let lastError: any = null;
212
+ let searchAttempts = 0;
213
+
214
+ for (const p of plugins) {
215
+ searchAttempts++;
216
+ try {
217
+ this.debug(`[Player] Trying plugin for search: ${p.name} (attempt ${searchAttempts}/${plugins.length})`);
218
+ const startTime = Date.now();
219
+ const res = await withTimeout(
220
+ p.search(query, requestedBy),
221
+ this.options.extractorTimeout ?? 15000,
222
+ `Search operation timed out for ${p.name}`,
223
+ );
224
+ const duration = Date.now() - startTime;
225
+
226
+ if (res && Array.isArray(res.tracks) && res.tracks.length > 0) {
227
+ this.debug(`[Player] Plugin '${p.name}' returned ${res.tracks.length} tracks in ${duration}ms`);
228
+ this.cacheSearchResult(query, res);
229
+ return res;
230
+ }
231
+ this.debug(`[Player] Plugin '${p.name}' returned no tracks in ${duration}ms`);
232
+ } catch (error) {
233
+ const errorMessage = error instanceof Error ? error.message : String(error);
234
+ this.debug(`[Player] Search via plugin '${p.name}' failed: ${errorMessage}`);
235
+ lastError = error;
236
+ // Continue to next plugin
237
+ }
238
+ }
239
+
240
+ this.debug(`[Player] No plugins returned results for query: ${query} (tried ${searchAttempts} plugins)`);
241
+ if (lastError) this.emit("playerError", lastError as Error);
242
+ throw new Error(`No plugin found to handle: ${query}`);
243
+ }
244
+
245
+ /**
246
+ * Get cached search result or null if not found/expired
247
+ * @param query The search query
248
+ * @returns Cached search result or null
249
+ */
250
+ private getCachedSearchResult(query: string): SearchResult | null {
251
+ const cacheKey = query.toLowerCase().trim();
252
+ const now = Date.now();
253
+
254
+ const cachedTimestamp = this.searchCacheTimestamps.get(cacheKey);
255
+ if (cachedTimestamp && now - cachedTimestamp < this.SEARCH_CACHE_TTL) {
256
+ const cachedResult = this.searchCache.get(cacheKey);
257
+ if (cachedResult) {
258
+ this.debug(`[SearchCache] Using cached search result for: ${query}`);
259
+ return cachedResult;
260
+ }
261
+ }
262
+
263
+ return null;
264
+ }
265
+
266
+ /**
267
+ * Cache search result
268
+ * @param query The search query
269
+ * @param result The search result to cache
270
+ */
271
+ private cacheSearchResult(query: string, result: SearchResult): void {
272
+ const cacheKey = query.toLowerCase().trim();
273
+ const now = Date.now();
274
+
275
+ this.searchCache.set(cacheKey, result);
276
+ this.searchCacheTimestamps.set(cacheKey, now);
277
+ this.debug(`[SearchCache] Cached search result for: ${query} (${result.tracks.length} tracks)`);
278
+ }
279
+
280
+ /**
281
+ * Clear expired search cache entries
282
+ */
283
+ private clearExpiredSearchCache(): void {
284
+ const now = Date.now();
285
+ for (const [key, timestamp] of this.searchCacheTimestamps.entries()) {
286
+ if (now - timestamp >= this.SEARCH_CACHE_TTL) {
287
+ this.searchCache.delete(key);
288
+ this.searchCacheTimestamps.delete(key);
289
+ this.debug(`[SearchCache] Cleared expired cache entry: ${key}`);
290
+ }
291
+ }
292
+ }
293
+
294
+ /**
295
+ * Clear all search cache entries
296
+ * @example
297
+ * player.clearSearchCache();
298
+ */
299
+ public clearSearchCache(): void {
300
+ const cacheSize = this.searchCache.size;
301
+ this.searchCache.clear();
302
+ this.searchCacheTimestamps.clear();
303
+ this.debug(`[SearchCache] Cleared all ${cacheSize} search cache entries`);
304
+ }
305
+
306
+ /**
307
+ * Debug method to check for duplicate search calls
308
+ * @param query The search query to check
309
+ * @returns Debug information about the query
310
+ */
311
+ public debugSearchQuery(query: string): {
312
+ isCached: boolean;
313
+ cacheAge?: number;
314
+ pluginCount: number;
315
+ ttsFiltered: boolean;
316
+ } {
317
+ const cacheKey = query.toLowerCase().trim();
318
+ const now = Date.now();
319
+ const cachedTimestamp = this.searchCacheTimestamps.get(cacheKey);
320
+ const isCached = cachedTimestamp && now - cachedTimestamp < this.SEARCH_CACHE_TTL;
602
321
 
603
- // Get plugins and filter out TTS for regular searches
604
322
  const allPlugins = this.pluginManager.getAll();
605
323
  const plugins = allPlugins.filter((p) => {
606
- // Skip TTS plugin for regular searches (unless query starts with "tts:")
607
324
  if (p.name.toLowerCase() === "tts" && !query.toLowerCase().startsWith("tts:")) {
608
- this.debug(`[Player] Skipping TTS plugin for regular search: ${query}`);
609
325
  return false;
610
326
  }
611
327
  return true;
612
328
  });
613
329
 
614
- this.debug(`[Player] Using ${plugins.length} plugins for search (filtered from ${allPlugins.length})`);
330
+ return {
331
+ isCached: !!isCached,
332
+ cacheAge: cachedTimestamp ? now - cachedTimestamp : undefined,
333
+ pluginCount: plugins.length,
334
+ ttsFiltered: allPlugins.length > plugins.length,
335
+ };
336
+ }
615
337
 
616
- let lastError: any = null;
617
- let searchAttempts = 0;
338
+ private async generateWillNext(): Promise<void> {
339
+ const lastTrack = this.queue.previousTracks[this.queue.previousTracks.length - 1] ?? this.queue.currentTrack;
340
+ if (!lastTrack) return;
618
341
 
619
- for (const p of plugins) {
620
- searchAttempts++;
342
+ // Build list of candidate plugins: preferred first, then others with getRelatedTracks
343
+ const preferred = this.pluginManager.findPlugin(lastTrack.url) || this.pluginManager.get(lastTrack.source);
344
+ const all = this.pluginManager.getAll();
345
+ const candidates = [...(preferred ? [preferred] : []), ...all.filter((p) => p !== preferred)].filter(
346
+ (p) => typeof (p as any).getRelatedTracks === "function",
347
+ );
348
+
349
+ for (const p of candidates) {
621
350
  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),
351
+ this.debug(`[Player] Trying related from plugin: ${p.name}`);
352
+ const related = await withTimeout(
353
+ (p as any).getRelatedTracks(lastTrack.url, {
354
+ limit: 10,
355
+ history: this.queue.previousTracks,
356
+ }),
626
357
  this.options.extractorTimeout ?? 15000,
627
- `Search operation timed out for ${p.name}`,
358
+ `getRelatedTracks timed out for ${p.name}`,
628
359
  );
629
- const duration = Date.now() - startTime;
630
360
 
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;
361
+ if (Array.isArray(related) && related.length > 0) {
362
+ const randomchoice = Math.floor(Math.random() * related.length);
363
+ const nextTrack = this.queue.nextTrack ? this.queue.nextTrack : related[randomchoice];
364
+ this.queue.willNextTrack(nextTrack);
365
+ this.queue.relatedTracks(related);
366
+ this.debug(`[Player] Will next track if autoplay: ${nextTrack?.title} (via ${p.name})`);
367
+ this.emit("willPlay", nextTrack, related);
368
+ return; // success
635
369
  }
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
370
+ this.debug(`[Player] ${p.name} returned no related tracks`);
371
+ } catch (err) {
372
+ this.debug(`[Player] getRelatedTracks error from ${p.name}:`, err);
373
+ // try next candidate
642
374
  }
643
375
  }
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
376
  }
377
+ //#endregion
378
+ //#region Play
649
379
 
650
380
  /**
651
381
  * Play a track, search query, search result, or play from queue
@@ -663,10 +393,13 @@ export class Player extends EventEmitter {
663
393
  */
664
394
  async play(query: string | Track | SearchResult | null, requestedBy?: string): Promise<boolean> {
665
395
  const debugInfo =
666
- query === null ? "null"
667
- : typeof query === "string" ? query
668
- : "tracks" in query ? `${query.tracks.length} tracks`
669
- : query.title || "unknown";
396
+ query === null
397
+ ? "null"
398
+ : typeof query === "string"
399
+ ? query
400
+ : "tracks" in query
401
+ ? `${query.tracks.length} tracks`
402
+ : query.title || "unknown";
670
403
  this.debug(`[Player] Play called with query: ${debugInfo}`);
671
404
  this.clearLeaveTimeout();
672
405
  let tracksToAdd: Track[] = [];
@@ -700,7 +433,7 @@ export class Player extends EventEmitter {
700
433
  }
701
434
  } else {
702
435
  // Handle other types (string, Track)
703
- const hookOutcome = await this.runBeforePlayHooks(effectiveRequest);
436
+ const hookOutcome = await this.extensionManager.BeforePlayHooks(effectiveRequest);
704
437
  effectiveRequest = hookOutcome.request;
705
438
  hookResponse = hookOutcome.response;
706
439
  if (effectiveRequest.requestedBy === undefined) {
@@ -718,7 +451,7 @@ export class Player extends EventEmitter {
718
451
  isPlaylist: hookResponse.isPlaylist ?? false,
719
452
  error: hookResponse.error,
720
453
  };
721
- await this.runAfterPlayHooks(handledPayload);
454
+ await this.extensionManager.AfterPlayHooks(handledPayload);
722
455
  if (hookResponse.error) {
723
456
  this.emit("playerError", hookResponse.error);
724
457
  }
@@ -765,7 +498,7 @@ export class Player extends EventEmitter {
765
498
  ) {
766
499
  this.debug(`[Player] Interrupting with TTS: ${tracksToAdd[0].title}`);
767
500
  await this.interruptWithTTSTrack(tracksToAdd[0]);
768
- await this.runAfterPlayHooks({
501
+ await this.extensionManager.AfterPlayHooks({
769
502
  success: true,
770
503
  query: effectiveRequest.query,
771
504
  requestedBy: effectiveRequest.requestedBy,
@@ -785,7 +518,7 @@ export class Player extends EventEmitter {
785
518
 
786
519
  const started = !this.isPlaying ? await this.playNext() : true;
787
520
 
788
- await this.runAfterPlayHooks({
521
+ await this.extensionManager.AfterPlayHooks({
789
522
  success: started,
790
523
  query: effectiveRequest.query,
791
524
  requestedBy: effectiveRequest.requestedBy,
@@ -795,7 +528,7 @@ export class Player extends EventEmitter {
795
528
 
796
529
  return started;
797
530
  } catch (error) {
798
- await this.runAfterPlayHooks({
531
+ await this.extensionManager.AfterPlayHooks({
799
532
  success: false,
800
533
  query: effectiveRequest.query,
801
534
  requestedBy: effectiveRequest.requestedBy,
@@ -810,209 +543,130 @@ export class Player extends EventEmitter {
810
543
  }
811
544
 
812
545
  /**
813
- * Interrupt current music with a TTS track. Pauses music, swaps the
814
- * subscription to a dedicated TTS player, plays TTS, then resumes.
546
+ * Create AudioResource with filters and seek applied
815
547
  *
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();
548
+ * @param {StreamInfo} streamInfo - The stream information
549
+ * @param {Track} track - The track being processed
550
+ * @param {number} position - Position in milliseconds to seek to (0 = no seek)
551
+ * @returns {Promise<AudioResource>} The AudioResource with filters and seek applied
939
552
  */
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
- }
553
+ private async createResource(streamInfo: StreamInfo, track: Track, position: number = 0): Promise<AudioResource> {
554
+ const filterString = this.filter.getFilterString();
946
555
 
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;
556
+ this.debug(`[Player] Creating AudioResource with filters: ${filterString || "none"}, seek: ${position}ms`);
962
557
 
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;
558
+ try {
559
+ let stream: Readable = streamInfo.stream;
560
+ // Apply filters and seek if needed
561
+ if (filterString || position > 0) {
562
+ stream = await this.filter.applyFiltersAndSeek(streamInfo.stream, position);
563
+ streamInfo.type = StreamType.Arbitrary;
967
564
  }
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
565
 
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
- );
566
+ // Create AudioResource with better error handling
567
+ const resource = createAudioResource(stream, {
568
+ metadata: track,
569
+ inputType:
570
+ streamInfo.type === "webm/opus"
571
+ ? StreamType.WebmOpus
572
+ : streamInfo.type === "ogg/opus"
573
+ ? StreamType.OggOpus
574
+ : StreamType.Arbitrary,
575
+ inlineVolume: true,
576
+ });
989
577
 
990
- for (const p of candidates) {
578
+ return resource;
579
+ } catch (error) {
580
+ this.debug(`[Player] Error creating AudioResource with filters+seek:`, error);
581
+ // Fallback to basic AudioResource
991
582
  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
- );
583
+ const resource = createAudioResource(streamInfo.stream, {
584
+ metadata: track,
585
+ inputType:
586
+ streamInfo.type === "webm/opus"
587
+ ? StreamType.WebmOpus
588
+ : streamInfo.type === "ogg/opus"
589
+ ? StreamType.OggOpus
590
+ : StreamType.Arbitrary,
591
+ inlineVolume: true,
592
+ });
593
+ return resource;
594
+ } catch (fallbackError) {
595
+ this.debug(`[Player] Fallback AudioResource creation failed:`, fallbackError);
596
+ throw fallbackError;
597
+ }
598
+ }
599
+ }
600
+ private async getStream(track: Track): Promise<StreamInfo | null> {
601
+ let stream = await this.extensionManager.provideStream(track);
602
+ if (stream?.stream) return stream;
603
+ stream = await this.pluginManager.getStream(track);
604
+ if (stream?.stream) return stream;
605
+ throw new Error(`No stream available for track: ${track.title}`);
606
+ }
1001
607
 
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
608
+ /**
609
+ * Start playing a specific track immediately, replacing the current resource.
610
+ */
611
+ private async startTrack(track: Track): Promise<boolean> {
612
+ try {
613
+ let streamInfo: StreamInfo | null = await this.getStream(track);
614
+ this.debug(`[Player] Using stream for track: ${track.title}`);
615
+ // Kiểm tra nếu có stream thực sự để tạo AudioResource
616
+ if (streamInfo && (streamInfo as any).stream) {
617
+ try {
618
+ this.currentResource = await this.createResource(streamInfo, track, 0);
619
+ if (this.volumeInterval) {
620
+ clearInterval(this.volumeInterval);
621
+ this.volumeInterval = null;
622
+ }
623
+ this.currentResource.volume?.setVolume(this.volume / 100);
624
+
625
+ this.debug(`[Player] Playing resource for track: ${track.title}`);
626
+ this.audioPlayer.play(this.currentResource);
627
+
628
+ await entersState(this.audioPlayer, AudioPlayerStatus.Playing, 5_000);
629
+ return true;
630
+ } catch (resourceError) {
631
+ this.debug(`[Player] Error creating/playing resource:`, resourceError);
632
+ // Try fallback without filters
633
+ try {
634
+ this.debug(`[Player] Attempting fallback without filters`);
635
+ const fallbackResource = createAudioResource(streamInfo.stream, {
636
+ metadata: track,
637
+ inputType:
638
+ streamInfo.type === "webm/opus"
639
+ ? StreamType.WebmOpus
640
+ : streamInfo.type === "ogg/opus"
641
+ ? StreamType.OggOpus
642
+ : StreamType.Arbitrary,
643
+ inlineVolume: true,
644
+ });
645
+
646
+ this.currentResource = fallbackResource;
647
+ this.currentResource.volume?.setVolume(this.volume / 100);
648
+ this.audioPlayer.play(this.currentResource);
649
+ await entersState(this.audioPlayer, AudioPlayerStatus.Playing, 5_000);
650
+ return true;
651
+ } catch (fallbackError) {
652
+ this.debug(`[Player] Fallback also failed:`, fallbackError);
653
+ throw fallbackError;
654
+ }
1010
655
  }
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
656
+ } else if (streamInfo && !(streamInfo as any).stream) {
657
+ // Extension đang xử lý phát nhạc (như Lavalink) - chỉ đánh dấu đang phát
658
+ this.debug(`[Player] Extension is handling playback for track: ${track.title}`);
659
+ this.isPlaying = true;
660
+ this.isPaused = false;
661
+ this.emit("trackStart", track);
662
+ return true;
663
+ } else {
664
+ throw new Error(`No stream available for track: ${track.title}`);
1015
665
  }
666
+ } catch (error) {
667
+ this.debug(`[Player] startTrack error:`, error);
668
+ this.emit("playerError", error as Error, track);
669
+ return false;
1016
670
  }
1017
671
  }
1018
672
 
@@ -1053,6 +707,133 @@ export class Player extends EventEmitter {
1053
707
  }
1054
708
  }
1055
709
 
710
+ //#endregion
711
+ //#region TTS
712
+
713
+ private ensureTTSPlayer(): DiscordAudioPlayer {
714
+ if (this.ttsPlayer) return this.ttsPlayer;
715
+ this.ttsPlayer = createAudioPlayer({
716
+ behaviors: {
717
+ noSubscriber: NoSubscriberBehavior.Pause,
718
+ maxMissedFrames: 100,
719
+ },
720
+ });
721
+ this.ttsPlayer.on("error", (e) => this.debug("[TTS] error:", e));
722
+ return this.ttsPlayer;
723
+ }
724
+ /**
725
+ * Interrupt current music with a TTS track. Pauses music, swaps the
726
+ * subscription to a dedicated TTS player, plays TTS, then resumes.
727
+ *
728
+ * @param {Track} track - The track to interrupt with
729
+ * @returns {Promise<void>}
730
+ * @example
731
+ * await player.interruptWithTTSTrack(track);
732
+ */
733
+ public async interruptWithTTSTrack(track: Track): Promise<void> {
734
+ const wasPlaying =
735
+ this.audioPlayer.state.status === AudioPlayerStatus.Playing ||
736
+ this.audioPlayer.state.status === AudioPlayerStatus.Buffering;
737
+
738
+ try {
739
+ if (!this.connection) throw new Error("No voice connection for TTS");
740
+ const ttsPlayer = this.ensureTTSPlayer();
741
+
742
+ // Build resource from plugin stream
743
+ const streamInfo = await this.pluginManager.getStream(track);
744
+ if (!streamInfo) {
745
+ throw new Error("No stream available for track: ${track.title}");
746
+ }
747
+ const resource = await this.createResource(streamInfo as StreamInfo, track);
748
+ if (!resource) {
749
+ throw new Error("No resource available for track: ${track.title}");
750
+ }
751
+ if (resource.volume) {
752
+ resource.volume.setVolume((this.options?.tts?.volume ?? this?.volume ?? 100) / 100);
753
+ }
754
+
755
+ // Pause current music if any
756
+ try {
757
+ this.pause();
758
+ } catch {}
759
+
760
+ // Swap subscription and play TTS
761
+ this.connection.subscribe(ttsPlayer);
762
+ this.emit("ttsStart", { track });
763
+ ttsPlayer.play(resource);
764
+
765
+ // Wait until TTS starts then finishes
766
+ await entersState(ttsPlayer, AudioPlayerStatus.Playing, 5_000).catch(() => null);
767
+ // Derive timeoutMs from resource/track duration when available, with a sensible cap
768
+ const md: any = (resource as any)?.metadata ?? {};
769
+ const declared =
770
+ typeof md.duration === "number" ? md.duration : typeof track?.duration === "number" ? track.duration : undefined;
771
+ const declaredMs = declared ? (declared > 1000 ? declared : declared * 1000) : undefined;
772
+ const cap = this.options?.tts?.Max_Time_TTS ?? 60_000;
773
+ const idleTimeout = declaredMs ? Math.min(cap, Math.max(1_000, declaredMs + 1_500)) : cap;
774
+ await entersState(ttsPlayer, AudioPlayerStatus.Idle, idleTimeout).catch(() => null);
775
+
776
+ // Swap back and resume if needed
777
+ this.connection.subscribe(this.audioPlayer);
778
+ } catch (err) {
779
+ this.debug("[TTS] error while playing:", err);
780
+ this.emit("playerError", err as Error);
781
+ } finally {
782
+ if (wasPlaying) {
783
+ try {
784
+ this.resume();
785
+ } catch {}
786
+ }
787
+ this.emit("ttsEnd");
788
+ }
789
+ }
790
+
791
+ //#endregion
792
+ //#region Player Function
793
+
794
+ /**
795
+ * Connect to a voice channel
796
+ *
797
+ * @param {VoiceChannel} channel - Discord voice channel
798
+ * @returns {Promise<VoiceConnection>} The voice connection
799
+ * @example
800
+ * await player.connect(voiceChannel);
801
+ */
802
+ async connect(channel: VoiceChannel): Promise<VoiceConnection> {
803
+ try {
804
+ this.debug(`[Player] Connecting to voice channel: ${channel.id}`);
805
+ const connection = joinVoiceChannel({
806
+ channelId: channel.id,
807
+ guildId: channel.guildId,
808
+ adapterCreator: channel.guild.voiceAdapterCreator as any,
809
+ selfDeaf: this.options.selfDeaf ?? true,
810
+ selfMute: this.options.selfMute ?? false,
811
+ });
812
+
813
+ await entersState(connection, VoiceConnectionStatus.Ready, 50_000);
814
+ this.connection = connection;
815
+
816
+ connection.on(VoiceConnectionStatus.Disconnected, () => {
817
+ this.debug(`[Player] VoiceConnectionStatus.Disconnected`);
818
+ this.destroy();
819
+ });
820
+
821
+ connection.on("error", (error) => {
822
+ this.debug(`[Player] Voice connection error:`, error);
823
+ this.emit("connectionError", error);
824
+ });
825
+ connection.subscribe(this.audioPlayer);
826
+
827
+ this.clearLeaveTimeout();
828
+ return this.connection;
829
+ } catch (error) {
830
+ this.debug(`[Player] Connection error:`, error);
831
+ this.emit("connectionError", error as Error);
832
+ this.connection?.destroy();
833
+ throw error;
834
+ }
835
+ }
836
+
1056
837
  /**
1057
838
  * Pause the current track
1058
839
  *
@@ -1111,6 +892,45 @@ export class Player extends EventEmitter {
1111
892
  return result;
1112
893
  }
1113
894
 
895
+ /**
896
+ * Seek to a specific position in the current track
897
+ *
898
+ * @param {number} position - Position in milliseconds to seek to
899
+ * @returns {Promise<boolean>} True if seek was successful
900
+ * @example
901
+ * // Seek to 30 seconds (30000ms)
902
+ * const success = await player.seek(30000);
903
+ * console.log(`Seek successful: ${success}`);
904
+ *
905
+ * // Seek to 1 minute 30 seconds (90000ms)
906
+ * await player.seek(90000);
907
+ */
908
+ async seek(position: number): Promise<boolean> {
909
+ this.debug(`[Player] seek called with position: ${position}ms`);
910
+
911
+ const track = this.queue.currentTrack;
912
+ if (!track) {
913
+ this.debug(`[Player] No current track to seek`);
914
+ return false;
915
+ }
916
+
917
+ const totalDuration = track.duration > 1000 ? track.duration : track.duration * 1000;
918
+ if (position < 0 || position > totalDuration) {
919
+ this.debug(`[Player] Invalid seek position: ${position}ms (track duration: ${totalDuration}ms)`);
920
+ return false;
921
+ }
922
+
923
+ const streaminfo = await this.getStream(track);
924
+ if (!streaminfo?.stream) {
925
+ this.debug(`[Player] No stream to seek`);
926
+ return false;
927
+ }
928
+
929
+ await this.refeshPlayerResource(true, position);
930
+
931
+ return true;
932
+ }
933
+
1114
934
  /**
1115
935
  * Skip to the next track or skip to a specific index
1116
936
  *
@@ -1174,6 +994,78 @@ export class Player extends EventEmitter {
1174
994
  return this.startTrack(track);
1175
995
  }
1176
996
 
997
+ /**
998
+ * Save a track's stream to a file and return a Readable stream
999
+ *
1000
+ * @param {Track} track - The track to save
1001
+ * @param {SaveOptions | string} options - Save options or filename string (for backward compatibility)
1002
+ * @returns {Promise<Readable>} A Readable stream containing the audio data
1003
+ * @example
1004
+ * // Save current track to file
1005
+ * const track = player.currentTrack;
1006
+ * if (track) {
1007
+ * const stream = await player.save(track);
1008
+ *
1009
+ * // Use fs to write the stream to file
1010
+ * const fs = require('fs');
1011
+ * const writeStream = fs.createWriteStream('saved-song.mp3');
1012
+ * stream.pipe(writeStream);
1013
+ *
1014
+ * writeStream.on('finish', () => {
1015
+ * console.log('File saved successfully!');
1016
+ * });
1017
+ * }
1018
+ *
1019
+ * // Save any track by URL
1020
+ * const searchResult = await player.search("Never Gonna Give You Up", userId);
1021
+ * if (searchResult.tracks.length > 0) {
1022
+ * const stream = await player.save(searchResult.tracks[0]);
1023
+ * // Handle the stream...
1024
+ * }
1025
+ *
1026
+ * // Backward compatibility - filename as string
1027
+ * const stream = await player.save(track, "my-song.mp3");
1028
+ */
1029
+ async save(track: Track, options?: SaveOptions | string): Promise<Readable> {
1030
+ this.debug(`[Player] save called for track: ${track.title}`);
1031
+
1032
+ // Parse options - support both SaveOptions object and filename string (backward compatibility)
1033
+ let saveOptions: SaveOptions = {};
1034
+ if (typeof options === "string") {
1035
+ saveOptions = { filename: options };
1036
+ } else if (options) {
1037
+ saveOptions = options;
1038
+ }
1039
+
1040
+ try {
1041
+ // Try extensions first
1042
+ let streamInfo: StreamInfo | null = await this.pluginManager.getStream(track);
1043
+
1044
+ if (!streamInfo || !streamInfo.stream) {
1045
+ throw new Error(`No save stream available for track: ${track.title}`);
1046
+ }
1047
+
1048
+ this.debug(`[Player] Save stream obtained for track: ${track.title}`);
1049
+ if (saveOptions.filename) {
1050
+ this.debug(`[Player] Save options - filename: ${saveOptions.filename}, quality: ${saveOptions.quality || "default"}`);
1051
+ }
1052
+
1053
+ // Apply filters if any are active
1054
+ let finalStream = streamInfo.stream;
1055
+ if (this.filter.getActiveFilters().length > 0) {
1056
+ this.debug(`[Player] Applying filters to save stream: ${this.filter.getFilterString() || "none"}`);
1057
+ finalStream = await this.filter.applyFiltersAndSeek(streamInfo.stream);
1058
+ }
1059
+
1060
+ // Return the stream directly - caller can pipe it to fs.createWriteStream()
1061
+ return finalStream;
1062
+ } catch (error) {
1063
+ this.debug(`[Player] save error:`, error);
1064
+ this.emit("playerError", error as Error, track);
1065
+ throw error;
1066
+ }
1067
+ }
1068
+
1177
1069
  /**
1178
1070
  * Loop the current track or queue
1179
1071
  *
@@ -1432,20 +1324,6 @@ export class Player extends EventEmitter {
1432
1324
  return parts.join(":");
1433
1325
  }
1434
1326
 
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
1327
  /**
1450
1328
  * Destroy the player
1451
1329
  *
@@ -1476,19 +1354,213 @@ export class Player extends EventEmitter {
1476
1354
 
1477
1355
  this.queue.clear();
1478
1356
  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 = [];
1357
+ this.filter.destroy();
1358
+ this.extensionManager.destroy();
1486
1359
  this.isPlaying = false;
1487
1360
  this.isPaused = false;
1488
1361
  this.emit("playerDestroy");
1489
1362
  this.removeAllListeners();
1490
1363
  }
1491
1364
 
1365
+ //#endregion
1366
+ //#region utils
1367
+ private scheduleLeave(): void {
1368
+ this.debug(`[Player] scheduleLeave called`);
1369
+ if (this.leaveTimeout) {
1370
+ clearTimeout(this.leaveTimeout);
1371
+ }
1372
+
1373
+ if (this.options.leaveOnEmpty && this.options.leaveTimeout) {
1374
+ this.leaveTimeout = setTimeout(() => {
1375
+ this.debug(`[Player] Leaving voice channel after timeoutMs`);
1376
+ this.destroy();
1377
+ }, this.options.leaveTimeout);
1378
+ }
1379
+ }
1380
+
1381
+ /**
1382
+ * Refesh player resource (apply filter)
1383
+ *
1384
+ * @param {boolean} applyToCurrent - Apply filter for curent track
1385
+ * @param {number} position - Position to seek to in milliseconds
1386
+ * @returns {Promise<boolean>}
1387
+ * @example
1388
+ * const refreshed = await player.refeshPlayerResource(true, 1000);
1389
+ * console.log(`Refreshed: ${refreshed}`);
1390
+ */
1391
+ public async refeshPlayerResource(applyToCurrent: boolean = true, position: number = -1): Promise<boolean> {
1392
+ if (!applyToCurrent || !this.queue.currentTrack || !(this.isPlaying || this.isPaused)) {
1393
+ return false;
1394
+ }
1395
+
1396
+ try {
1397
+ const track = this.queue.currentTrack;
1398
+ this.debug(`[Player] Refreshing player resource for track: ${track.title}`);
1399
+
1400
+ // Get current position for seeking
1401
+ const currentPosition = position > 0 ? position : this.currentResource?.playbackDuration || 0;
1402
+
1403
+ const streaminfo = await this.getStream(track);
1404
+ if (!streaminfo?.stream) {
1405
+ this.debug(`[Player] No stream to refresh`);
1406
+ return false;
1407
+ }
1408
+
1409
+ // Create AudioResource with filters and seek to current position
1410
+ const resource = await this.createResource(streaminfo, track, currentPosition);
1411
+
1412
+ // Stop current playback and start new one
1413
+ const wasPlaying = this.isPlaying;
1414
+ const wasPaused = this.isPaused;
1415
+
1416
+ this.audioPlayer.stop();
1417
+ this.currentResource = resource;
1418
+
1419
+ // Subscribe to new resource
1420
+ if (this.connection) {
1421
+ this.connection.subscribe(this.audioPlayer);
1422
+ this.audioPlayer.play(resource);
1423
+ }
1424
+
1425
+ // Restore playing state
1426
+ if (wasPlaying && !wasPaused) {
1427
+ this.isPlaying = true;
1428
+ this.isPaused = false;
1429
+ } else if (wasPaused) {
1430
+ this.isPlaying = false;
1431
+ this.isPaused = true;
1432
+ this.audioPlayer.pause();
1433
+ }
1434
+
1435
+ this.debug(`[Player] Successfully applied filter to current track at position ${currentPosition}ms`);
1436
+ return true;
1437
+ } catch (error) {
1438
+ this.debug(`[Player] Error applying filter to current track:`, error);
1439
+ // Filter was still added to active filters, so return true
1440
+ return true;
1441
+ }
1442
+ }
1443
+
1444
+ /**
1445
+ * Attach an extension to the player
1446
+ *
1447
+ * @param {BaseExtension} extension - The extension to attach
1448
+ * @example
1449
+ * player.attachExtension(new MyExtension());
1450
+ */
1451
+ public attachExtension(extension: BaseExtension): void {
1452
+ this.extensionManager.register(extension);
1453
+ }
1454
+
1455
+ /**
1456
+ * Detach an extension from the player
1457
+ *
1458
+ * @param {BaseExtension} extension - The extension to detach
1459
+ * @example
1460
+ * player.detachExtension(new MyExtension());
1461
+ */
1462
+ public detachExtension(extension: BaseExtension): void {
1463
+ this.extensionManager.unregister(extension);
1464
+ }
1465
+
1466
+ /**
1467
+ * Get all extensions attached to the player
1468
+ *
1469
+ * @returns {readonly BaseExtension[]} All attached extensions
1470
+ * @example
1471
+ * const extensions = player.getExtensions();
1472
+ * console.log(`Extensions: ${extensions.length}`);
1473
+ */
1474
+ public getExtensions(): readonly BaseExtension[] {
1475
+ return this.extensionManager.getAll();
1476
+ }
1477
+
1478
+ private clearLeaveTimeout(): void {
1479
+ if (this.leaveTimeout) {
1480
+ clearTimeout(this.leaveTimeout);
1481
+ this.leaveTimeout = null;
1482
+ this.debug(`[Player] Cleared leave timeoutMs`);
1483
+ }
1484
+ }
1485
+
1486
+ private debug(message?: any, ...optionalParams: any[]): void {
1487
+ if (this.listenerCount("debug") > 0) {
1488
+ this.emit("debug", message, ...optionalParams);
1489
+ }
1490
+ }
1491
+
1492
+ private setupEventListeners(): void {
1493
+ this.audioPlayer.on("stateChange", (oldState, newState) => {
1494
+ this.debug(`[Player] AudioPlayer stateChange from ${oldState.status} to ${newState.status}`);
1495
+ if (newState.status === AudioPlayerStatus.Idle && oldState.status !== AudioPlayerStatus.Idle) {
1496
+ // Track ended
1497
+ const track = this.queue.currentTrack;
1498
+ if (track) {
1499
+ this.debug(`[Player] Track ended: ${track.title}`);
1500
+ this.emit("trackEnd", track);
1501
+ }
1502
+ this.playNext();
1503
+ } else if (
1504
+ newState.status === AudioPlayerStatus.Playing &&
1505
+ (oldState.status === AudioPlayerStatus.Idle || oldState.status === AudioPlayerStatus.Buffering)
1506
+ ) {
1507
+ // Track started
1508
+ this.clearLeaveTimeout();
1509
+ this.isPlaying = true;
1510
+ this.isPaused = false;
1511
+ const track = this.queue.currentTrack;
1512
+ if (track) {
1513
+ this.debug(`[Player] Track started: ${track.title}`);
1514
+ this.emit("trackStart", track);
1515
+ }
1516
+ } else if (newState.status === AudioPlayerStatus.Paused && oldState.status !== AudioPlayerStatus.Paused) {
1517
+ // Track paused
1518
+ this.isPaused = true;
1519
+ const track = this.queue.currentTrack;
1520
+ if (track) {
1521
+ this.debug(`[Player] Player paused on track: ${track.title}`);
1522
+ this.emit("playerPause", track);
1523
+ }
1524
+ } else if (newState.status !== AudioPlayerStatus.Paused && oldState.status === AudioPlayerStatus.Paused) {
1525
+ // Track resumed
1526
+ this.isPaused = false;
1527
+ const track = this.queue.currentTrack;
1528
+ if (track) {
1529
+ this.debug(`[Player] Player resumed on track: ${track.title}`);
1530
+ this.emit("playerResume", track);
1531
+ }
1532
+ } else if (newState.status === AudioPlayerStatus.AutoPaused) {
1533
+ this.debug(`[Player] AudioPlayerStatus.AutoPaused`);
1534
+ } else if (newState.status === AudioPlayerStatus.Buffering) {
1535
+ this.debug(`[Player] AudioPlayerStatus.Buffering`);
1536
+ }
1537
+ });
1538
+ this.audioPlayer.on("error", (error) => {
1539
+ this.debug(`[Player] AudioPlayer error:`, error);
1540
+ this.emit("playerError", error, this.queue.currentTrack || undefined);
1541
+ this.playNext();
1542
+ });
1543
+
1544
+ this.audioPlayer.on("debug", (...args) => {
1545
+ if (this.manager.debugEnabled) {
1546
+ this.emit("debug", ...args);
1547
+ }
1548
+ });
1549
+ }
1550
+
1551
+ addPlugin(plugin: SourcePlugin): void {
1552
+ this.debug(`[Player] Adding plugin: ${plugin.name}`);
1553
+ this.pluginManager.register(plugin);
1554
+ }
1555
+
1556
+ removePlugin(name: string): boolean {
1557
+ this.debug(`[Player] Removing plugin: ${name}`);
1558
+ return this.pluginManager.unregister(name);
1559
+ }
1560
+
1561
+ //#endregion
1562
+ //#region Getters
1563
+
1492
1564
  /**
1493
1565
  * Get the size of the queue
1494
1566
  *
@@ -1573,121 +1645,5 @@ export class Player extends EventEmitter {
1573
1645
  return this.queue.relatedTracks();
1574
1646
  }
1575
1647
 
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
- }
1648
+ //#endregion
1693
1649
  }