ziplayer 0.3.11 → 0.3.12-dev.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/dist/extensions/index.d.ts +1 -0
  2. package/dist/extensions/index.d.ts.map +1 -1
  3. package/dist/extensions/index.js +9 -1
  4. package/dist/extensions/index.js.map +1 -1
  5. package/dist/plugins/index.d.ts.map +1 -1
  6. package/dist/plugins/index.js +106 -61
  7. package/dist/plugins/index.js.map +1 -1
  8. package/dist/structures/FilterManager.d.ts.map +1 -1
  9. package/dist/structures/FilterManager.js +8 -4
  10. package/dist/structures/FilterManager.js.map +1 -1
  11. package/dist/structures/Player.d.ts +1 -1
  12. package/dist/structures/Player.d.ts.map +1 -1
  13. package/dist/structures/Player.js +43 -18
  14. package/dist/structures/Player.js.map +1 -1
  15. package/dist/structures/PlayerManager.d.ts +12 -7
  16. package/dist/structures/PlayerManager.d.ts.map +1 -1
  17. package/dist/structures/PlayerManager.js +113 -79
  18. package/dist/structures/PlayerManager.js.map +1 -1
  19. package/dist/structures/PreloadManager.d.ts.map +1 -1
  20. package/dist/structures/PreloadManager.js +11 -8
  21. package/dist/structures/PreloadManager.js.map +1 -1
  22. package/dist/structures/Queue.js +2 -2
  23. package/dist/structures/Queue.js.map +1 -1
  24. package/dist/types/index.d.ts +1 -0
  25. package/dist/types/index.d.ts.map +1 -1
  26. package/dist/types/index.js.map +1 -1
  27. package/dist/utils/timeout.d.ts.map +1 -1
  28. package/dist/utils/timeout.js +8 -1
  29. package/dist/utils/timeout.js.map +1 -1
  30. package/package.json +1 -1
  31. package/src/extensions/index.ts +9 -1
  32. package/src/plugins/index.ts +1027 -975
  33. package/src/structures/FilterManager.ts +8 -4
  34. package/src/structures/Player.ts +54 -24
  35. package/src/structures/PlayerManager.ts +125 -84
  36. package/src/structures/PreloadManager.ts +12 -8
  37. package/src/structures/Queue.ts +2 -2
  38. package/src/types/index.ts +2 -0
  39. package/src/utils/timeout.ts +8 -1
@@ -244,8 +244,7 @@ export class FilterManager {
244
244
  wasRecreated = true;
245
245
 
246
246
  position = -1;
247
- streamInfo.type = "arbitrary";
248
- if (!filterString) return { ...streamInfo, stream: sourceStream, wasRecreated };
247
+ if (!filterString) return { ...streamInfo, type: "arbitrary", stream: sourceStream, wasRecreated };
249
248
  }
250
249
 
251
250
  this.debug(`Applying filters and seek — filters: ${filterString || "none"}, seek: ${position}ms`);
@@ -259,7 +258,12 @@ export class FilterManager {
259
258
  this.ffmpegAbortController = abortController;
260
259
 
261
260
  // Nếu có vị trí seek, ưu tiên dùng spawnFFmpegInputSeek
262
- if (position >= 0 && ffmpegPath) {
261
+ if (position >= 0) {
262
+ if (!ffmpegPath) {
263
+ this.debug("[FilterManager] ffmpeg-static path not found, seeking may fail");
264
+ // Fallback or throw based on preference, here we try to proceed or throw
265
+ throw new Error("FFmpeg binary not found. Seeking is unavailable.");
266
+ }
263
267
  const stream = await this.spawnFFmpegInputSeek(sourceStream, position, filterString, abortController.signal, generation);
264
268
  return { ...streamInfo, stream };
265
269
  }
@@ -370,7 +374,7 @@ export class FilterManager {
370
374
  // Already aborted before we even spawned
371
375
  onAbort();
372
376
  } else {
373
- signal.addEventListener("abort", onAbort);
377
+ signal.addEventListener("abort", onAbort, { once: true });
374
378
  }
375
379
 
376
380
  // Pipe source → ffmpeg stdin
@@ -125,6 +125,7 @@ export class Player extends EventEmitter {
125
125
  resource: null,
126
126
  track: null,
127
127
  streamId: null,
128
+ processedStreamId: null,
128
129
  abortController: null,
129
130
  isValid: false,
130
131
  isLoading: false,
@@ -331,7 +332,11 @@ export class Player extends EventEmitter {
331
332
  const stream = (this.currentResource as any)?.metadata?.stream ?? (this.currentResource as any)?.stream;
332
333
 
333
334
  if (stream && typeof stream.destroy === "function") {
334
- stream.destroy().catch((e: any) => this.debug("Stream destroy error:", e));
335
+ try {
336
+ stream.destroy();
337
+ } catch (e: any) {
338
+ this.debug("Stream destroy error:", e);
339
+ }
335
340
  }
336
341
 
337
342
  this.currentResource = null;
@@ -464,13 +469,22 @@ export class Player extends EventEmitter {
464
469
  private async generateWillNext(): Promise<void> {
465
470
  const lastTrack = this.queue.previousTracks[this.queue.previousTracks.length - 1] ?? this.queue.currentTrack;
466
471
  if (!lastTrack) return;
467
- const related = await this.pluginManager.getRelatedTracks(lastTrack);
472
+ let related = await this.pluginManager.getRelatedTracks(lastTrack);
468
473
  if (!related || related.length === 0) return;
469
- const randomchoice = Math.floor(Math.random() * related.length);
474
+
475
+ // Lọc bỏ các bài đã có trong hàng đợi sắp tới
476
+ const upcomingUrls = new Set(this.queue.getTracks().map((t) => t.url));
477
+ related = related.filter((t) => !upcomingUrls.has(t.url));
478
+
479
+ if (related.length === 0) return;
480
+
481
+ // Ưu tiên chọn trong top 5 để đảm bảo chất lượng cao nhất
482
+ const poolSize = Math.min(5, related.length);
483
+ const randomchoice = Math.floor(Math.random() * poolSize);
470
484
  const nextTrack = this.queue.nextTrack ? this.queue.nextTrack : related[randomchoice];
471
485
  this.queue.willNextTrack(nextTrack);
472
486
  this.queue.relatedTracks(related);
473
- this.debug(`[Player] Will next track if autoplay: ${nextTrack?.title}]`);
487
+ this.debug(`[Player] Will next track if autoplay: ${nextTrack?.title}`);
474
488
  this.emit("willPlay", nextTrack, related);
475
489
  }
476
490
  //#endregion
@@ -1128,17 +1142,20 @@ export class Player extends EventEmitter {
1128
1142
  return await this.playRemote(track, streamInfo);
1129
1143
  }
1130
1144
 
1131
- if (!streamInfo?.stream) {
1145
+ if (!streamInfo?.stream && !streamInfo?.url) {
1132
1146
  throw new Error(`No stream available`);
1133
1147
  }
1134
1148
 
1135
1149
  // Register the RAW source stream — this is what we can reuse on seek
1136
- const rawStreamId = this.streamManager.registerStream(streamInfo.stream, track, {
1137
- source: track.source || "stream",
1138
- isPreload: false,
1139
- isRemote: !!streamInfo?.remote,
1140
- priority: 10,
1141
- });
1150
+ const rawStreamId =
1151
+ streamInfo.stream ?
1152
+ this.streamManager.registerStream(streamInfo.stream, track, {
1153
+ source: track.source || "stream",
1154
+ isPreload: false,
1155
+ isRemote: !!streamInfo?.remote,
1156
+ priority: 10,
1157
+ })
1158
+ : null;
1142
1159
 
1143
1160
  // createResource now returns both the AudioResource
1144
1161
  // AND the processedStream (ffmpeg stdout) when filters/seek are involved.
@@ -1159,14 +1176,14 @@ export class Player extends EventEmitter {
1159
1176
  if (this.currentSlot.streamId && this.currentSlot.streamId !== rawStreamId) {
1160
1177
  this.streamManager.unregisterStream(this.currentSlot.streamId, true);
1161
1178
  }
1162
- if ((this.currentSlot as any).processedStreamId && (this.currentSlot as any).processedStreamId !== playStreamId) {
1163
- this.streamManager.unregisterStream((this.currentSlot as any).processedStreamId, true);
1179
+ if (this.currentSlot.processedStreamId && this.currentSlot.processedStreamId !== playStreamId) {
1180
+ this.streamManager.unregisterStream(this.currentSlot.processedStreamId, true);
1164
1181
  }
1165
1182
 
1166
1183
  this.currentSlot.resource = resource;
1167
1184
  this.currentSlot.track = track;
1168
1185
  this.currentSlot.streamId = rawStreamId;
1169
- (this.currentSlot as any).processedStreamId = processedStream ? playStreamId : null;
1186
+ this.currentSlot.processedStreamId = processedStream ? playStreamId : null;
1170
1187
  this.currentSlot.isValid = true;
1171
1188
  this.currentResource = resource;
1172
1189
  this.seekOffset = 0;
@@ -1449,7 +1466,7 @@ export class Player extends EventEmitter {
1449
1466
  */
1450
1467
  async connect(
1451
1468
  channel: VoiceChannel,
1452
- options: { group: string; selfDeaf: boolean; selfMute: boolean },
1469
+ options: { group: string; selfDeaf: boolean; selfMute: boolean } = {} as any,
1453
1470
  ): Promise<VoiceConnection> {
1454
1471
  try {
1455
1472
  this.debug(`[Player] Connecting to voice channel: ${channel.id}`);
@@ -2508,10 +2525,10 @@ export class Player extends EventEmitter {
2508
2525
  }
2509
2526
 
2510
2527
  // Clean up processedStream first (it's what AudioResource reads)
2511
- const processedStreamId = (this.currentSlot as any).processedStreamId;
2528
+ const processedStreamId = this.currentSlot.processedStreamId;
2512
2529
  if (processedStreamId && processedStreamId !== currentStreamId) {
2513
2530
  this.streamManager.unregisterStream(processedStreamId, true);
2514
- (this.currentSlot as any).processedStreamId = null;
2531
+ this.currentSlot.processedStreamId = null;
2515
2532
  }
2516
2533
 
2517
2534
  if (currentStreamId) {
@@ -2542,9 +2559,19 @@ export class Player extends EventEmitter {
2542
2559
  this.extensionManager.clearCache("stream");
2543
2560
  this.debug(`[Player] Fetching fresh stream${!isForwardSeek ? " (backward seek)" : " (reuse failed)"}`);
2544
2561
  streaminfo = await this.getStream(track);
2562
+
2563
+ if (this.destroyed) {
2564
+ this.debug(`[Player] refreshPlayerResource: Player destroyed during stream fetch`);
2565
+ return false;
2566
+ }
2567
+
2568
+ if (!applyToCurrent || !this.queue.currentTrack || !(this.isPlaying || this.isPaused)) {
2569
+ this.debug(`[Player] refreshPlayerResource: Player state changed during stream fetch, aborting`);
2570
+ return false;
2571
+ }
2545
2572
  }
2546
2573
 
2547
- if (!streaminfo?.stream) {
2574
+ if (!streaminfo?.stream && !streaminfo?.url) {
2548
2575
  this.debug(`[Player] No stream available for refresh`);
2549
2576
  return false;
2550
2577
  }
@@ -2554,11 +2581,14 @@ export class Player extends EventEmitter {
2554
2581
  const { resource, processedStream } = await this.createResource(streaminfo, track, createPosition);
2555
2582
 
2556
2583
  // Register raw source stream
2557
- const newStreamId = this.streamManager.registerStream(streaminfo.stream, track, {
2558
- source: track.source || "stream",
2559
- isPreload: false,
2560
- priority: 10,
2561
- });
2584
+ const newStreamId =
2585
+ streaminfo.stream ?
2586
+ this.streamManager.registerStream(streaminfo.stream, track, {
2587
+ source: track.source || "stream",
2588
+ isPreload: false,
2589
+ priority: 10,
2590
+ })
2591
+ : null;
2562
2592
 
2563
2593
  let newProcessedStreamId: string | null = null;
2564
2594
  if (processedStream && processedStream !== streaminfo.stream) {
@@ -2572,7 +2602,7 @@ export class Player extends EventEmitter {
2572
2602
  this.currentSlot.resource = resource;
2573
2603
  this.currentSlot.track = track;
2574
2604
  this.currentSlot.streamId = newStreamId;
2575
- (this.currentSlot as any).processedStreamId = newProcessedStreamId;
2605
+ this.currentSlot.processedStreamId = newProcessedStreamId;
2576
2606
  this.currentSlot.isValid = true;
2577
2607
  this.currentResource = resource;
2578
2608
 
@@ -15,9 +15,10 @@ import {
15
15
  } from "../types";
16
16
  import type { BaseExtension } from "../extensions";
17
17
  import { withTimeout } from "../utils/timeout";
18
- import { PluginManager } from "../plugins";
19
18
 
20
19
  const GLOBAL_MANAGER_KEY: symbol = Symbol.for("ziplayer.PlayerManager.instance");
20
+ /** Guild id for the internal search-only player (never stored in {@link PlayerManager.players}). */
21
+ const SEARCH_PLAYER_GUILD_ID = "__ziplayer_search__";
21
22
 
22
23
  export const getGlobalManager = (): PlayerManager | null => {
23
24
  try {
@@ -90,6 +91,7 @@ interface ManagerCacheEntry<T> {
90
91
  export class PlayerManager extends EventEmitter {
91
92
  private static instance: PlayerManager | null = null;
92
93
  private players: Map<string, Player> = new Map();
94
+ private pendingPlayers: Map<string, Promise<Player>> = new Map();
93
95
  private searchCache: Map<string, ManagerCacheEntry<SearchResult>>;
94
96
  private readonly SEARCH_CACHE_TTL = 60 * 1000; // 1 minute
95
97
  private readonly MAX_CACHE_SIZE = 100;
@@ -105,7 +107,8 @@ export class PlayerManager extends EventEmitter {
105
107
  }
106
108
 
107
109
  private plugins: SourcePlugin[];
108
- private pluginManager: PluginManager;
110
+ /** Reused player for {@link search}; not registered in {@link players}. */
111
+ private searchPlayer: Player | null = null;
109
112
  private extensions: any[];
110
113
  private B_debug: boolean = false;
111
114
  private extractorTimeout: number = 10000;
@@ -126,9 +129,6 @@ export class PlayerManager extends EventEmitter {
126
129
  constructor(options: PlayerManagerOptions = {}) {
127
130
  super();
128
131
  this.plugins = [];
129
- this.pluginManager = new PluginManager(null as any, this, {
130
- extractorTimeout: this.extractorTimeout,
131
- });
132
132
  this.searchCache = new Map();
133
133
 
134
134
  // Initialize plugins
@@ -145,7 +145,6 @@ export class PlayerManager extends EventEmitter {
145
145
 
146
146
  if (instance) {
147
147
  this.plugins.push(instance);
148
- this.pluginManager.register(instance);
149
148
  }
150
149
  this.debug(`Registered plugin: ${p.name || "unnamed"}`);
151
150
  } catch (e) {
@@ -249,6 +248,25 @@ export class PlayerManager extends EventEmitter {
249
248
  this.debug(`Auto-cleanup started with interval: ${this.cleanupTimeout}ms`);
250
249
  }
251
250
 
251
+ /**
252
+ * Lazy internal player used only for {@link search}.
253
+ * Not added to {@link players} and does not forward manager events.
254
+ */
255
+ private getSearchPlayer(): Player {
256
+ if (this.searchPlayer && !this.searchPlayer.destroyed) {
257
+ return this.searchPlayer;
258
+ }
259
+
260
+ const player = new Player(SEARCH_PLAYER_GUILD_ID, { extractorTimeout: this.extractorTimeout }, this);
261
+ for (const plugin of this.plugins) {
262
+ player.addPlugin(plugin);
263
+ }
264
+
265
+ this.searchPlayer = player;
266
+ this.debug(`Created internal search player (not stored in players map)`);
267
+ return player;
268
+ }
269
+
252
270
  private startStatsCollection(): void {
253
271
  if (this.statsInterval) {
254
272
  clearInterval(this.statsInterval);
@@ -291,85 +309,104 @@ export class PlayerManager extends EventEmitter {
291
309
  async create(guildOrId: string | { id: string }, options?: PlayerOptions): Promise<Player> {
292
310
  const guildId = this.resolveGuildId(guildOrId);
293
311
 
312
+ if (guildId === SEARCH_PLAYER_GUILD_ID) {
313
+ throw new Error(`Guild id "${SEARCH_PLAYER_GUILD_ID}" is reserved for internal search.`);
314
+ }
315
+
294
316
  if (this.players.has(guildId)) {
295
317
  this.debug(`Player already exists for guildId: ${guildId}, returning existing`);
296
318
  return this.players.get(guildId)!;
297
319
  }
298
320
 
299
- this.debug(`Creating player for guildId: ${guildId}`);
300
- const player = new Player(guildId, options, this);
301
-
302
- // Add all registered plugins
303
- this.plugins.forEach((plugin) => player.addPlugin(plugin));
304
-
305
- // Activate extensions
306
- let extsToActivate: any[] = [];
307
- const optExts = (options as any)?.extensions as any[] | string[] | undefined;
308
-
309
- if (Array.isArray(optExts)) {
310
- if (optExts.length === 0) {
311
- extsToActivate = [];
312
- } else if (typeof optExts[0] === "string") {
313
- const wanted = new Set(optExts as string[]);
314
- extsToActivate = this.extensions.filter((ext) => {
315
- const name = typeof ext === "function" ? ext.name : ext?.name;
316
- return !!name && wanted.has(name);
317
- });
318
- } else {
319
- extsToActivate = optExts;
320
- }
321
- } else {
322
- // Use all extensions by default
323
- extsToActivate = this.extensions;
321
+ // Check if a player is already being created for this guild
322
+ if (this.pendingPlayers.has(guildId)) {
323
+ this.debug(`Player creation already in progress for guildId: ${guildId}, awaiting...`);
324
+ return this.pendingPlayers.get(guildId)!;
324
325
  }
325
326
 
326
- for (const ext of extsToActivate) {
327
- let instance = ext;
328
- if (typeof ext === "function") {
329
- try {
330
- instance = new ext(player);
331
- } catch (e) {
332
- this.debug(`Extension constructor error for ${ext.name}:`, e);
333
- continue;
327
+ const creationPromise = (async () => {
328
+ try {
329
+ this.debug(`Creating player for guildId: ${guildId}`);
330
+ const player = new Player(guildId, options, this);
331
+
332
+ // Add all registered plugins
333
+ this.plugins.forEach((plugin) => player.addPlugin(plugin));
334
+
335
+ // Activate extensions
336
+ let extsToActivate: any[] = [];
337
+ const optExts = (options as any)?.extensions as any[] | string[] | undefined;
338
+
339
+ if (Array.isArray(optExts)) {
340
+ if (optExts.length === 0) {
341
+ extsToActivate = [];
342
+ } else if (typeof optExts[0] === "string") {
343
+ const wanted = new Set(optExts as string[]);
344
+ extsToActivate = this.extensions.filter((ext) => {
345
+ const name = typeof ext === "function" ? ext.name : ext?.name;
346
+ return !!name && wanted.has(name);
347
+ });
348
+ } else {
349
+ extsToActivate = optExts;
350
+ }
351
+ } else {
352
+ // Use all extensions by default
353
+ extsToActivate = this.extensions;
334
354
  }
335
- }
336
355
 
337
- if (instance && typeof instance === "object") {
338
- const extInstance = instance as BaseExtension;
339
- if ("player" in extInstance && !extInstance.player) extInstance.player = player;
340
- player.attachExtension(extInstance);
341
-
342
- if (typeof extInstance.active === "function") {
343
- let activated: boolean | void = true;
344
- try {
345
- activated = await withTimeout(
346
- Promise.resolve(extInstance.active({ manager: this, player })),
347
- player.options.extractorTimeout ?? 15000,
348
- `Extension ${extInstance?.name} activation timed out`,
349
- );
350
- this.debug(`Extension ${extInstance?.name} active check returned: ${activated}`);
351
- } catch (e) {
352
- activated = false;
353
- this.debug(`Extension activation error for ${extInstance?.name}:`, e);
356
+ for (const ext of extsToActivate) {
357
+ let instance = ext;
358
+ if (typeof ext === "function") {
359
+ try {
360
+ instance = new ext(player);
361
+ } catch (e) {
362
+ this.debug(`Extension constructor error for ${ext.name}:`, e);
363
+ continue;
364
+ }
354
365
  }
355
366
 
356
- if (activated === false) {
357
- player.detachExtension(extInstance);
358
- continue;
367
+ if (instance && typeof instance === "object") {
368
+ const extInstance = instance as BaseExtension;
369
+ if ("player" in extInstance && !extInstance.player) extInstance.player = player;
370
+ player.attachExtension(extInstance);
371
+
372
+ if (typeof extInstance.active === "function") {
373
+ let activated: boolean | void = true;
374
+ try {
375
+ activated = await withTimeout(
376
+ Promise.resolve(extInstance.active({ manager: this, player })),
377
+ player.options.extractorTimeout ?? 15000,
378
+ `Extension ${extInstance?.name} activation timed out`,
379
+ );
380
+ this.debug(`Extension ${extInstance?.name} active check returned: ${activated}`);
381
+ } catch (e) {
382
+ activated = false;
383
+ this.debug(`Extension activation error for ${extInstance?.name}:`, e);
384
+ }
385
+
386
+ if (activated === false) {
387
+ player.detachExtension(extInstance);
388
+ continue;
389
+ }
390
+ }
359
391
  }
360
392
  }
361
- }
362
- }
363
393
 
364
- // Forward all player events to manager
365
- this.setupEventForwarding(player, guildId);
394
+ // Forward all player events to manager
395
+ this.setupEventForwarding(player, guildId);
366
396
 
367
- // Mark last activity
368
- (player as any)._lastActivity = Date.now();
397
+ // Mark last activity
398
+ (player as any)._lastActivity = Date.now();
369
399
 
370
- this.players.set(guildId, player);
371
- this.debug(`Player created for guildId: ${guildId}`);
372
- return player;
400
+ this.players.set(guildId, player);
401
+ this.debug(`Player created for guildId: ${guildId}`);
402
+ return player;
403
+ } finally {
404
+ this.pendingPlayers.delete(guildId);
405
+ }
406
+ })();
407
+
408
+ this.pendingPlayers.set(guildId, creationPromise);
409
+ return creationPromise;
373
410
  }
374
411
 
375
412
  private setupEventForwarding(player: Player, guildId: string): void {
@@ -770,6 +807,11 @@ export class PlayerManager extends EventEmitter {
770
807
  player.destroy();
771
808
  }
772
809
 
810
+ if (this.searchPlayer && !this.searchPlayer.destroyed) {
811
+ this.searchPlayer.destroy();
812
+ }
813
+ this.searchPlayer = null;
814
+
773
815
  this.players.clear();
774
816
  this.searchCache.clear();
775
817
  this.removeAllListeners();
@@ -777,13 +819,11 @@ export class PlayerManager extends EventEmitter {
777
819
  }
778
820
 
779
821
  /**
780
- * Search using PluginManager without creating a Player.
822
+ * Search via an internal Player instance (all registered plugins) without
823
+ * storing it in {@link players}.
781
824
  *
782
- * Uses the same search pipeline as Player.search():
783
- * - cache
784
- * - plugin deduplication
785
- * - plugin scoring/evaluation
786
- * - fallback handling
825
+ * Uses the same search pipeline as {@link Player.search}:
826
+ * extension hooks, plugin deduplication, scoring, and fallback handling.
787
827
  *
788
828
  * @param {string} query
789
829
  * @param {string} requestedBy
@@ -792,20 +832,15 @@ export class PlayerManager extends EventEmitter {
792
832
  async search(query: string, requestedBy: string): Promise<SearchResult> {
793
833
  this.debug(`Search called with query: ${query}, requestedBy: ${requestedBy}`);
794
834
 
795
- // Cache
796
835
  const cached = this.getCachedSearch(query);
797
836
  if (cached) {
798
837
  return cached;
799
838
  }
800
839
 
801
840
  try {
802
- const result = await this.pluginManager.search(query, requestedBy);
803
-
804
- if (!result || !Array.isArray(result.tracks) || result.tracks.length === 0) {
805
- throw new Error(`No results found for: ${query}`);
806
- }
841
+ const result = await this.getSearchPlayer().search(query, requestedBy);
807
842
 
808
- this.debug(`Plugin search returned ${result.tracks.length} tracks (score: ${result.score?.score ?? "unknown"}%)`);
843
+ this.debug(`Search returned ${result.tracks.length} tracks (score: ${result.score?.score ?? "unknown"}%)`);
809
844
 
810
845
  if (result.score) {
811
846
  this.debug(`Search evaluation - ${result.score.reason}`);
@@ -836,10 +871,13 @@ export class PlayerManager extends EventEmitter {
836
871
  */
837
872
  registerPlugin(plugin: SourcePlugin): void {
838
873
  this.plugins.push(plugin);
839
- this.pluginManager.register(plugin);
840
874
 
841
875
  this.debug(`Registered plugin: ${plugin.name}`);
842
876
 
877
+ if (this.searchPlayer && !this.searchPlayer.destroyed) {
878
+ this.searchPlayer.addPlugin(plugin);
879
+ }
880
+
843
881
  for (const player of this.players.values()) {
844
882
  player.addPlugin(plugin);
845
883
  }
@@ -856,7 +894,10 @@ export class PlayerManager extends EventEmitter {
856
894
  if (index === -1) return false;
857
895
 
858
896
  this.plugins.splice(index, 1);
859
- this.pluginManager.unregister(name);
897
+
898
+ if (this.searchPlayer && !this.searchPlayer.destroyed) {
899
+ this.searchPlayer.removePlugin(name);
900
+ }
860
901
 
861
902
  this.debug(`Unregistered plugin: ${name}`);
862
903
 
@@ -24,6 +24,7 @@ export class PreloadManager {
24
24
  resource: null,
25
25
  track: null,
26
26
  streamId: null,
27
+ processedStreamId: null,
27
28
  abortController: null,
28
29
  isValid: false,
29
30
  isLoading: false,
@@ -239,17 +240,20 @@ export class PreloadManager {
239
240
  this.debugLog(`[Preload] Track changed after stream fetch`);
240
241
  throw new Error("PRELOAD_CANCELLED");
241
242
  }
242
- if (!streamInfo?.stream) {
243
+ if (!streamInfo?.stream && !streamInfo?.url) {
243
244
  throw new Error(`No stream available`);
244
245
  }
245
246
 
246
- const streamId = this.streamManager.registerStream(streamInfo.stream, track, {
247
- source: track.source || "preload",
248
- isPreload: true,
249
- priority: 5,
250
- });
247
+ const streamId =
248
+ streamInfo.stream ?
249
+ this.streamManager.registerStream(streamInfo.stream, track, {
250
+ source: track.source || "preload",
251
+ isPreload: true,
252
+ priority: 5,
253
+ })
254
+ : null;
251
255
 
252
- const resource = createAudioResource(streamInfo.stream, {
256
+ const resource = createAudioResource(streamInfo.stream || streamInfo.url!, {
253
257
  inlineVolume: true,
254
258
  metadata: { ...track, preloaded: true },
255
259
  });
@@ -277,7 +281,7 @@ export class PreloadManager {
277
281
  signal.removeEventListener("abort", handler);
278
282
  reject(new Error("PRELOAD_CANCELLED"));
279
283
  };
280
- signal.addEventListener("abort", handler);
284
+ signal.addEventListener("abort", handler, { once: true });
281
285
  });
282
286
 
283
287
  const existingStream = this.streamManager.getStreamByTrack(track.id || track.title);
@@ -231,9 +231,9 @@ export class Queue {
231
231
  this.current = this.tracks.shift() || null;
232
232
  }
233
233
 
234
- // Skip bypassed track loop but no other track exists → restore current from history
234
+ // Skip bypassed track loop but no other track exists → trigger queue end
235
235
  if (!this.current && this._loop === "track" && ignoreLoop && this.history.length > 0) {
236
- this.current = this.history.pop() || null;
236
+ return null;
237
237
  }
238
238
 
239
239
  return this.current;
@@ -420,6 +420,7 @@ export interface SaveOptions {
420
420
  /** Seek position in milliseconds to start saving from */
421
421
  seek?: number;
422
422
  }
423
+
423
424
  export interface PlayerSession {
424
425
  guildId: string;
425
426
  queue: Track[];
@@ -471,6 +472,7 @@ export interface StreamSlot {
471
472
  resource: AudioResource | null;
472
473
  track: Track | null;
473
474
  streamId: string | null;
475
+ processedStreamId: string | null;
474
476
  abortController: AbortController | null;
475
477
  isValid: boolean;
476
478
  isLoading: boolean;
@@ -6,5 +6,12 @@
6
6
  * @returns Promise that rejects if timeout is reached
7
7
  */
8
8
  export function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
9
- return Promise.race([promise, new Promise<never>((_, reject) => setTimeout(() => reject(new Error(message)), timeoutMs))]);
9
+ let timeoutId: NodeJS.Timeout;
10
+ const timeoutPromise = new Promise<never>((_, reject) => {
11
+ timeoutId = setTimeout(() => reject(new Error(message)), timeoutMs);
12
+ });
13
+
14
+ return Promise.race([promise, timeoutPromise]).finally(() => {
15
+ if (timeoutId) clearTimeout(timeoutId);
16
+ });
10
17
  }