ziplayer 0.3.10 → 0.3.12

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.
@@ -16,7 +16,7 @@ export class FilterManager {
16
16
  private debug: DebugFn;
17
17
  private player: Player;
18
18
  private ffmpeg: FFmpeg | null = null;
19
- private currentInputStream: Readable | null = null;
19
+ private currentInputStream: Readable | null | undefined | string = null;
20
20
  public StreamType: FilterManagerStreamType = "arbitrary";
21
21
  private ffmpegProcess: ChildProcess | null = null;
22
22
  private ffmpegAbortController: AbortController | null = null;
@@ -236,7 +236,7 @@ export class FilterManager {
236
236
  const generation = ++this.ffmpegGeneration;
237
237
  const filterString = this.getFilterString();
238
238
 
239
- let sourceStream = streamInfo.stream;
239
+ let sourceStream: Readable | string = streamInfo.stream || streamInfo.url!;
240
240
  let wasRecreated = false;
241
241
 
242
242
  if (position >= 0 && streamInfo.recreate) {
@@ -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`);
@@ -264,7 +263,13 @@ export class FilterManager {
264
263
  return { ...streamInfo, stream };
265
264
  }
266
265
 
267
- // Trường hợp chỉ apply filter mà không seek (position < 0)
266
+ if (typeof sourceStream === "string") {
267
+ return {
268
+ ...streamInfo,
269
+ stream: await this.createFFmpegFromUrl(sourceStream, filterString),
270
+ };
271
+ }
272
+
268
273
  const args = [
269
274
  "-analyzeduration",
270
275
  "0",
@@ -287,7 +292,6 @@ export class FilterManager {
287
292
  }
288
293
 
289
294
  try {
290
- // Sử dụng prism.FFmpeg cho trường hợp không seek
291
295
  this.ffmpeg = sourceStream.pipe(new prism.FFmpeg({ args }));
292
296
  return { ...streamInfo, stream: this.ffmpeg };
293
297
  } catch (spawnError) {
@@ -297,7 +301,7 @@ export class FilterManager {
297
301
  }
298
302
 
299
303
  private spawnFFmpegInputSeek(
300
- stream: Readable,
304
+ stream: Readable | string,
301
305
  position: number,
302
306
  filterString: string,
303
307
  signal: AbortSignal,
@@ -308,7 +312,11 @@ export class FilterManager {
308
312
 
309
313
  // Chuyển sang dùng s16le (Raw PCM) để Discord.js dễ xử lý nhất khi có filter
310
314
  // NOTE: -ss MUST come BEFORE -i for proper seeking and timing
311
- const args: string[] = ["-ss", seekSeconds, "-i", "pipe:0", "-analyzeduration", "0", "-loglevel", "0"];
315
+ //if url is provided, we can let ffmpeg handle it directly without piping, which is more efficient and less error-prone
316
+ const args: string[] =
317
+ typeof stream === "string" ?
318
+ ["-ss", seekSeconds, "-i", stream, "-analyzeduration", "0", "-loglevel", "0"]
319
+ : ["-ss", seekSeconds, "-i", "pipe:0", "-analyzeduration", "0", "-loglevel", "0"];
312
320
 
313
321
  if (filterString) {
314
322
  args.push("-af", filterString);
@@ -337,7 +345,11 @@ export class FilterManager {
337
345
  signal.removeEventListener("abort", onAbort);
338
346
 
339
347
  try {
340
- stream.unpipe(proc.stdin!);
348
+ if (typeof stream === "string") {
349
+ // If stream is a URL, no need to unpipe
350
+ } else {
351
+ stream.unpipe(proc.stdin!);
352
+ }
341
353
  } catch {}
342
354
 
343
355
  try {
@@ -361,7 +373,11 @@ export class FilterManager {
361
373
  }
362
374
 
363
375
  // Pipe source → ffmpeg stdin
364
- stream.pipe(proc.stdin!);
376
+ if (typeof stream === "string") {
377
+ // If stream is a URL, no need to pipe
378
+ } else {
379
+ stream.pipe(proc.stdin!);
380
+ }
365
381
 
366
382
  // Suppress EPIPE on stdin when the process exits early
367
383
  proc.stdin!.on("error", (err: Error) => {
@@ -409,4 +425,59 @@ export class FilterManager {
409
425
 
410
426
  return proc.stdout as Readable;
411
427
  }
428
+ private createFFmpegFromUrl(url: string, filterString: string): Readable {
429
+ const args = ["-analyzeduration", "0", "-loglevel", "0", "-i", url];
430
+
431
+ if (filterString) {
432
+ args.push("-af", filterString);
433
+ }
434
+
435
+ args.push("-acodec", "libopus", "-f", "opus", "-ar", "48000", "-ac", "2", "pipe:1");
436
+
437
+ const proc = spawn(ffmpegPath!, args, {
438
+ stdio: ["ignore", "pipe", "ignore"],
439
+ });
440
+
441
+ const oldProcess = this.ffmpegProcess;
442
+ this.ffmpegProcess = proc;
443
+
444
+ proc.on("close", (code) => {
445
+ this.debug(`FFmpeg URL process exited (code: ${code})`);
446
+
447
+ if (this.ffmpegProcess === proc) {
448
+ this.ffmpegProcess = null;
449
+ }
450
+ });
451
+
452
+ proc.on("error", (err) => {
453
+ this.debug(`FFmpeg URL process error: ${err.message}`);
454
+
455
+ if (this.ffmpegProcess === proc) {
456
+ this.ffmpegProcess = null;
457
+ }
458
+ });
459
+
460
+ // kill process cũ sau khi process mới đã sẵn sàng
461
+ if (oldProcess && oldProcess !== proc) {
462
+ try {
463
+ oldProcess.kill("SIGKILL");
464
+ } catch {}
465
+ }
466
+
467
+ const output = proc.stdout as Readable;
468
+
469
+ output.once("close", () => {
470
+ try {
471
+ proc.kill("SIGKILL");
472
+ } catch {}
473
+ });
474
+
475
+ output.once("end", () => {
476
+ try {
477
+ proc.kill("SIGKILL");
478
+ } catch {}
479
+ });
480
+
481
+ return output;
482
+ }
412
483
  }
@@ -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;
@@ -907,7 +912,7 @@ export class Player extends EventEmitter {
907
912
  if (filterString || position > 0) {
908
913
  const processedStream = await this.filter.applyFiltersAndSeek(streamInfo, seekArg);
909
914
 
910
- const resource = createAudioResource(processedStream.stream, {
915
+ const resource = createAudioResource(processedStream.stream || processedStream.url!, {
911
916
  metadata: track,
912
917
  inputType:
913
918
  processedStream.wasRecreated && !filterString ? StreamType.Arbitrary
@@ -916,10 +921,10 @@ export class Player extends EventEmitter {
916
921
  inlineVolume: true,
917
922
  });
918
923
 
919
- return { resource, processedStream: processedStream.stream };
924
+ return { resource, processedStream: processedStream.stream! };
920
925
  }
921
926
 
922
- const resource = createAudioResource(streamInfo.stream, {
927
+ const resource = createAudioResource(streamInfo.stream || streamInfo.url!, {
923
928
  metadata: track,
924
929
  inputType:
925
930
  streamInfo.type === "webm/opus" ? StreamType.WebmOpus
@@ -1128,17 +1133,20 @@ export class Player extends EventEmitter {
1128
1133
  return await this.playRemote(track, streamInfo);
1129
1134
  }
1130
1135
 
1131
- if (!streamInfo?.stream) {
1136
+ if (!streamInfo?.stream && !streamInfo?.url) {
1132
1137
  throw new Error(`No stream available`);
1133
1138
  }
1134
1139
 
1135
1140
  // 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
- });
1141
+ const rawStreamId =
1142
+ streamInfo.stream ?
1143
+ this.streamManager.registerStream(streamInfo.stream, track, {
1144
+ source: track.source || "stream",
1145
+ isPreload: false,
1146
+ isRemote: !!streamInfo?.remote,
1147
+ priority: 10,
1148
+ })
1149
+ : null;
1142
1150
 
1143
1151
  // createResource now returns both the AudioResource
1144
1152
  // AND the processedStream (ffmpeg stdout) when filters/seek are involved.
@@ -1159,14 +1167,14 @@ export class Player extends EventEmitter {
1159
1167
  if (this.currentSlot.streamId && this.currentSlot.streamId !== rawStreamId) {
1160
1168
  this.streamManager.unregisterStream(this.currentSlot.streamId, true);
1161
1169
  }
1162
- if ((this.currentSlot as any).processedStreamId && (this.currentSlot as any).processedStreamId !== playStreamId) {
1163
- this.streamManager.unregisterStream((this.currentSlot as any).processedStreamId, true);
1170
+ if (this.currentSlot.processedStreamId && this.currentSlot.processedStreamId !== playStreamId) {
1171
+ this.streamManager.unregisterStream(this.currentSlot.processedStreamId, true);
1164
1172
  }
1165
1173
 
1166
1174
  this.currentSlot.resource = resource;
1167
1175
  this.currentSlot.track = track;
1168
1176
  this.currentSlot.streamId = rawStreamId;
1169
- (this.currentSlot as any).processedStreamId = processedStream ? playStreamId : null;
1177
+ this.currentSlot.processedStreamId = processedStream ? playStreamId : null;
1170
1178
  this.currentSlot.isValid = true;
1171
1179
  this.currentResource = resource;
1172
1180
  this.seekOffset = 0;
@@ -1980,7 +1988,7 @@ export class Player extends EventEmitter {
1980
1988
  }
1981
1989
 
1982
1990
  // Return the stream directly - caller can pipe it to fs.createWriteStream()
1983
- return finalStream.stream;
1991
+ return finalStream.stream!;
1984
1992
  } catch (error) {
1985
1993
  this.debug(`[Player] save error:`, error);
1986
1994
  this.emit("playerError", error as Error, track);
@@ -2508,10 +2516,10 @@ export class Player extends EventEmitter {
2508
2516
  }
2509
2517
 
2510
2518
  // Clean up processedStream first (it's what AudioResource reads)
2511
- const processedStreamId = (this.currentSlot as any).processedStreamId;
2519
+ const processedStreamId = this.currentSlot.processedStreamId;
2512
2520
  if (processedStreamId && processedStreamId !== currentStreamId) {
2513
2521
  this.streamManager.unregisterStream(processedStreamId, true);
2514
- (this.currentSlot as any).processedStreamId = null;
2522
+ this.currentSlot.processedStreamId = null;
2515
2523
  }
2516
2524
 
2517
2525
  if (currentStreamId) {
@@ -2544,7 +2552,7 @@ export class Player extends EventEmitter {
2544
2552
  streaminfo = await this.getStream(track);
2545
2553
  }
2546
2554
 
2547
- if (!streaminfo?.stream) {
2555
+ if (!streaminfo?.stream && !streaminfo?.url) {
2548
2556
  this.debug(`[Player] No stream available for refresh`);
2549
2557
  return false;
2550
2558
  }
@@ -2554,11 +2562,14 @@ export class Player extends EventEmitter {
2554
2562
  const { resource, processedStream } = await this.createResource(streaminfo, track, createPosition);
2555
2563
 
2556
2564
  // 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
- });
2565
+ const newStreamId =
2566
+ streaminfo.stream ?
2567
+ this.streamManager.registerStream(streaminfo.stream, track, {
2568
+ source: track.source || "stream",
2569
+ isPreload: false,
2570
+ priority: 10,
2571
+ })
2572
+ : null;
2562
2573
 
2563
2574
  let newProcessedStreamId: string | null = null;
2564
2575
  if (processedStream && processedStream !== streaminfo.stream) {
@@ -2572,7 +2583,7 @@ export class Player extends EventEmitter {
2572
2583
  this.currentSlot.resource = resource;
2573
2584
  this.currentSlot.track = track;
2574
2585
  this.currentSlot.streamId = newStreamId;
2575
- (this.currentSlot as any).processedStreamId = newProcessedStreamId;
2586
+ this.currentSlot.processedStreamId = newProcessedStreamId;
2576
2587
  this.currentSlot.isValid = true;
2577
2588
  this.currentResource = resource;
2578
2589
 
@@ -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 {
@@ -105,7 +106,8 @@ export class PlayerManager extends EventEmitter {
105
106
  }
106
107
 
107
108
  private plugins: SourcePlugin[];
108
- private pluginManager: PluginManager;
109
+ /** Reused player for {@link search}; not registered in {@link players}. */
110
+ private searchPlayer: Player | null = null;
109
111
  private extensions: any[];
110
112
  private B_debug: boolean = false;
111
113
  private extractorTimeout: number = 10000;
@@ -126,9 +128,6 @@ export class PlayerManager extends EventEmitter {
126
128
  constructor(options: PlayerManagerOptions = {}) {
127
129
  super();
128
130
  this.plugins = [];
129
- this.pluginManager = new PluginManager(null as any, this, {
130
- extractorTimeout: this.extractorTimeout,
131
- });
132
131
  this.searchCache = new Map();
133
132
 
134
133
  // Initialize plugins
@@ -145,7 +144,6 @@ export class PlayerManager extends EventEmitter {
145
144
 
146
145
  if (instance) {
147
146
  this.plugins.push(instance);
148
- this.pluginManager.register(instance);
149
147
  }
150
148
  this.debug(`Registered plugin: ${p.name || "unnamed"}`);
151
149
  } catch (e) {
@@ -249,6 +247,25 @@ export class PlayerManager extends EventEmitter {
249
247
  this.debug(`Auto-cleanup started with interval: ${this.cleanupTimeout}ms`);
250
248
  }
251
249
 
250
+ /**
251
+ * Lazy internal player used only for {@link search}.
252
+ * Not added to {@link players} and does not forward manager events.
253
+ */
254
+ private getSearchPlayer(): Player {
255
+ if (this.searchPlayer && !this.searchPlayer.destroyed) {
256
+ return this.searchPlayer;
257
+ }
258
+
259
+ const player = new Player(SEARCH_PLAYER_GUILD_ID, { extractorTimeout: this.extractorTimeout }, this);
260
+ for (const plugin of this.plugins) {
261
+ player.addPlugin(plugin);
262
+ }
263
+
264
+ this.searchPlayer = player;
265
+ this.debug(`Created internal search player (not stored in players map)`);
266
+ return player;
267
+ }
268
+
252
269
  private startStatsCollection(): void {
253
270
  if (this.statsInterval) {
254
271
  clearInterval(this.statsInterval);
@@ -291,6 +308,10 @@ export class PlayerManager extends EventEmitter {
291
308
  async create(guildOrId: string | { id: string }, options?: PlayerOptions): Promise<Player> {
292
309
  const guildId = this.resolveGuildId(guildOrId);
293
310
 
311
+ if (guildId === SEARCH_PLAYER_GUILD_ID) {
312
+ throw new Error(`Guild id "${SEARCH_PLAYER_GUILD_ID}" is reserved for internal search.`);
313
+ }
314
+
294
315
  if (this.players.has(guildId)) {
295
316
  this.debug(`Player already exists for guildId: ${guildId}, returning existing`);
296
317
  return this.players.get(guildId)!;
@@ -770,6 +791,11 @@ export class PlayerManager extends EventEmitter {
770
791
  player.destroy();
771
792
  }
772
793
 
794
+ if (this.searchPlayer && !this.searchPlayer.destroyed) {
795
+ this.searchPlayer.destroy();
796
+ }
797
+ this.searchPlayer = null;
798
+
773
799
  this.players.clear();
774
800
  this.searchCache.clear();
775
801
  this.removeAllListeners();
@@ -777,13 +803,11 @@ export class PlayerManager extends EventEmitter {
777
803
  }
778
804
 
779
805
  /**
780
- * Search using PluginManager without creating a Player.
806
+ * Search via an internal Player instance (all registered plugins) without
807
+ * storing it in {@link players}.
781
808
  *
782
- * Uses the same search pipeline as Player.search():
783
- * - cache
784
- * - plugin deduplication
785
- * - plugin scoring/evaluation
786
- * - fallback handling
809
+ * Uses the same search pipeline as {@link Player.search}:
810
+ * extension hooks, plugin deduplication, scoring, and fallback handling.
787
811
  *
788
812
  * @param {string} query
789
813
  * @param {string} requestedBy
@@ -792,20 +816,15 @@ export class PlayerManager extends EventEmitter {
792
816
  async search(query: string, requestedBy: string): Promise<SearchResult> {
793
817
  this.debug(`Search called with query: ${query}, requestedBy: ${requestedBy}`);
794
818
 
795
- // Cache
796
819
  const cached = this.getCachedSearch(query);
797
820
  if (cached) {
798
821
  return cached;
799
822
  }
800
823
 
801
824
  try {
802
- const result = await this.pluginManager.search(query, requestedBy);
825
+ const result = await this.getSearchPlayer().search(query, requestedBy);
803
826
 
804
- if (!result || !Array.isArray(result.tracks) || result.tracks.length === 0) {
805
- throw new Error(`No results found for: ${query}`);
806
- }
807
-
808
- this.debug(`Plugin search returned ${result.tracks.length} tracks (score: ${result.score?.score ?? "unknown"}%)`);
827
+ this.debug(`Search returned ${result.tracks.length} tracks (score: ${result.score?.score ?? "unknown"}%)`);
809
828
 
810
829
  if (result.score) {
811
830
  this.debug(`Search evaluation - ${result.score.reason}`);
@@ -836,10 +855,13 @@ export class PlayerManager extends EventEmitter {
836
855
  */
837
856
  registerPlugin(plugin: SourcePlugin): void {
838
857
  this.plugins.push(plugin);
839
- this.pluginManager.register(plugin);
840
858
 
841
859
  this.debug(`Registered plugin: ${plugin.name}`);
842
860
 
861
+ if (this.searchPlayer && !this.searchPlayer.destroyed) {
862
+ this.searchPlayer.addPlugin(plugin);
863
+ }
864
+
843
865
  for (const player of this.players.values()) {
844
866
  player.addPlugin(plugin);
845
867
  }
@@ -856,7 +878,10 @@ export class PlayerManager extends EventEmitter {
856
878
  if (index === -1) return false;
857
879
 
858
880
  this.plugins.splice(index, 1);
859
- this.pluginManager.unregister(name);
881
+
882
+ if (this.searchPlayer && !this.searchPlayer.destroyed) {
883
+ this.searchPlayer.removePlugin(name);
884
+ }
860
885
 
861
886
  this.debug(`Unregistered plugin: ${name}`);
862
887
 
@@ -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
  });
@@ -124,8 +124,9 @@ export interface SearchScore {
124
124
  * };
125
125
  */
126
126
  export interface StreamInfo {
127
- stream: Readable;
128
- type: "webm/opus" | "ogg/opus" | "arbitrary" | string;
127
+ stream?: Readable;
128
+ url?: string;
129
+ type: "webm/opus" | "ogg/opus" | "arbitrary" | "url" | string;
129
130
  metadata?: Record<string, any>;
130
131
  position?: number;
131
132
  recreate?: (position: number) => Promise<Readable>;
@@ -419,6 +420,7 @@ export interface SaveOptions {
419
420
  /** Seek position in milliseconds to start saving from */
420
421
  seek?: number;
421
422
  }
423
+
422
424
  export interface PlayerSession {
423
425
  guildId: string;
424
426
  queue: Track[];
@@ -470,6 +472,7 @@ export interface StreamSlot {
470
472
  resource: AudioResource | null;
471
473
  track: Track | null;
472
474
  streamId: string | null;
475
+ processedStreamId: string | null;
473
476
  abortController: AbortController | null;
474
477
  isValid: boolean;
475
478
  isLoading: boolean;