ziplayer 0.3.6 → 0.3.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/dist/plugins/index.d.ts +1 -8
  2. package/dist/plugins/index.d.ts.map +1 -1
  3. package/dist/plugins/index.js +59 -107
  4. package/dist/plugins/index.js.map +1 -1
  5. package/dist/structures/FilterManager.d.ts +9 -24
  6. package/dist/structures/FilterManager.d.ts.map +1 -1
  7. package/dist/structures/FilterManager.js +182 -93
  8. package/dist/structures/FilterManager.js.map +1 -1
  9. package/dist/structures/Player.d.ts +8 -1
  10. package/dist/structures/Player.d.ts.map +1 -1
  11. package/dist/structures/Player.js +233 -133
  12. package/dist/structures/Player.js.map +1 -1
  13. package/dist/structures/PreloadManager.d.ts +1 -0
  14. package/dist/structures/PreloadManager.d.ts.map +1 -1
  15. package/dist/structures/PreloadManager.js +26 -6
  16. package/dist/structures/PreloadManager.js.map +1 -1
  17. package/dist/structures/Queue.d.ts.map +1 -1
  18. package/dist/structures/Queue.js +4 -0
  19. package/dist/structures/Queue.js.map +1 -1
  20. package/dist/structures/StreamManager.d.ts +8 -0
  21. package/dist/structures/StreamManager.d.ts.map +1 -1
  22. package/dist/structures/StreamManager.js +23 -0
  23. package/dist/structures/StreamManager.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/package.json +1 -1
  28. package/src/plugins/index.ts +70 -120
  29. package/src/structures/FilterManager.ts +439 -303
  30. package/src/structures/Player.ts +268 -140
  31. package/src/structures/PreloadManager.ts +293 -274
  32. package/src/structures/Queue.ts +5 -0
  33. package/src/structures/StreamManager.ts +585 -563
  34. package/src/types/index.ts +1 -0
@@ -1,563 +1,585 @@
1
- // src/managers/StreamManager.ts
2
- import { Readable } from "stream";
3
- import { EventEmitter } from "events";
4
- import type { Track } from "../types";
5
-
6
- export interface ManagedStream {
7
- id: string;
8
- stream: Readable;
9
- track: Track;
10
- createdAt: number;
11
- lastAccessed: number;
12
- metadata: {
13
- source: string;
14
- isPreload: boolean;
15
- isRemote: boolean;
16
- priority: number;
17
- };
18
- listeners: {
19
- error: (err: Error) => void;
20
- close: () => void;
21
- end: () => void;
22
- drain?: () => void;
23
- pause?: () => void;
24
- resume?: () => void;
25
- };
26
- status: "active" | "paused" | "ended" | "error" | "destroyed";
27
- byteCount: number;
28
- }
29
-
30
- export interface StreamManagerOptions {
31
- maxConcurrentStreams?: number;
32
- streamTimeout?: number;
33
- maxListenersPerStream?: number;
34
- cleanupInterval?: number;
35
- enableMetrics?: boolean;
36
- autoDestroy?: boolean;
37
- }
38
-
39
- export class StreamManager extends EventEmitter {
40
- private streams = new Map<string, ManagedStream>();
41
- private suppressPrematureCloseErrors = new Set<string>();
42
- private options: Required<StreamManagerOptions>;
43
- private cleanupTimer: NodeJS.Timeout | null = null;
44
- private metrics = {
45
- totalStreamsCreated: 0,
46
- totalStreamsDestroyed: 0,
47
- activeStreams: 0,
48
- totalErrors: 0,
49
- totalBytesProcessed: 0,
50
- };
51
-
52
- constructor(options: StreamManagerOptions = {}) {
53
- super();
54
- this.setMaxListeners(50);
55
-
56
- this.options = {
57
- maxConcurrentStreams: 20,
58
- streamTimeout: 5 * 60 * 1000, // 5 minutes
59
- maxListenersPerStream: 15,
60
- cleanupInterval: 60000, // 1 minute
61
- enableMetrics: true,
62
- autoDestroy: true,
63
- ...options,
64
- };
65
-
66
- if (this.options.cleanupInterval > 0) {
67
- this.startCleanupInterval();
68
- }
69
-
70
- this.debug("StreamManager initialized");
71
- }
72
-
73
- /**
74
- * Register a new stream
75
- */
76
- registerStream(stream: Readable, track: Track, metadata: Partial<ManagedStream["metadata"]> = {}): string {
77
- for (const existing of this.streams.values()) {
78
- if (existing.stream === stream) {
79
- existing.lastAccessed = Date.now();
80
- existing.track = track;
81
- existing.metadata = {
82
- ...existing.metadata,
83
- source: track.source || existing.metadata.source || "unknown",
84
- ...metadata,
85
- };
86
- this.debug(`Stream already managed, reusing ID: ${existing.id}`);
87
- return existing.id;
88
- }
89
- }
90
-
91
- const streamId = this.generateStreamId(track);
92
-
93
- // Check if stream already exists
94
- if (this.streams.has(streamId)) {
95
- this.debug(`Stream already exists for track: ${track.title}, destroying old one`);
96
- this.unregisterStream(streamId, true);
97
- }
98
-
99
- // Check concurrent limit
100
- while (this.streams.size >= this.options.maxConcurrentStreams) {
101
- const evicted = this.evictOldestStream();
102
- if (!evicted) break;
103
- }
104
-
105
- // Configure stream
106
- if (stream.setMaxListeners) {
107
- stream.setMaxListeners(this.options.maxListenersPerStream);
108
- }
109
-
110
- // Create listeners
111
- const listeners = this.createStreamListeners(streamId);
112
-
113
- // Apply listeners
114
- stream.on("error", listeners.error);
115
- stream.on("close", listeners.close);
116
- stream.on("end", listeners.end);
117
- stream.on("pause", listeners.pause!);
118
- stream.on("resume", listeners.resume!);
119
- stream.on("drain", listeners.drain!);
120
-
121
- // Create managed stream
122
- const managedStream: ManagedStream = {
123
- id: streamId,
124
- stream,
125
- track,
126
- createdAt: Date.now(),
127
- lastAccessed: Date.now(),
128
- metadata: {
129
- source: track.source || "unknown",
130
- isPreload: metadata.isPreload || false,
131
- priority: metadata.priority || 0,
132
- isRemote: metadata.isRemote || false,
133
- ...metadata,
134
- },
135
- listeners,
136
- status: "active",
137
- byteCount: 0,
138
- };
139
-
140
- this.streams.set(streamId, managedStream);
141
-
142
- if (this.options.enableMetrics) {
143
- this.metrics.totalStreamsCreated++;
144
- this.metrics.activeStreams = this.streams.size;
145
- }
146
-
147
- // Setup data counter
148
- this.setupDataCounter(managedStream);
149
-
150
- this.debug(`Stream registered: ${track.title} (ID: ${streamId}), Total: ${this.streams.size}`);
151
- this.emit("streamRegistered", { streamId, track, metadata: managedStream.metadata });
152
-
153
- return streamId;
154
- }
155
-
156
- /**
157
- * Create stream listeners
158
- */
159
- private createStreamListeners(streamId: string): ManagedStream["listeners"] {
160
- return {
161
- error: (err: Error) => {
162
- const isPrematureClose = err?.message?.toLowerCase().includes("premature close");
163
- if (isPrematureClose && this.suppressPrematureCloseErrors.has(streamId)) {
164
- this.debug(`Ignored expected premature close [${streamId}] during controlled destroy`);
165
- this.suppressPrematureCloseErrors.delete(streamId);
166
- this.unregisterStream(streamId, false);
167
- return;
168
- }
169
-
170
- this.debug(`Stream error [${streamId}]:`, err);
171
- if (this.options.enableMetrics) {
172
- this.metrics.totalErrors++;
173
- }
174
- this.emit("streamError", { streamId, error: err });
175
- this.unregisterStream(streamId, true);
176
- },
177
-
178
- close: () => {
179
- this.debug(`Stream closed [${streamId}]`);
180
- const managed = this.streams.get(streamId);
181
- if (managed) {
182
- managed.status = "ended";
183
- }
184
- this.emit("streamClose", { streamId });
185
- this.unregisterStream(streamId, false);
186
- },
187
-
188
- end: () => {
189
- this.debug(`Stream ended [${streamId}]`);
190
- const managed = this.streams.get(streamId);
191
- if (managed) {
192
- managed.status = "ended";
193
- }
194
- this.emit("streamEnd", { streamId });
195
- this.unregisterStream(streamId, false);
196
- },
197
-
198
- pause: () => {
199
- const managed = this.streams.get(streamId);
200
- if (managed) {
201
- managed.status = "paused";
202
- this.emit("streamPaused", { streamId, track: managed.track });
203
- }
204
- },
205
-
206
- resume: () => {
207
- const managed = this.streams.get(streamId);
208
- if (managed) {
209
- managed.status = "active";
210
- managed.lastAccessed = Date.now();
211
- this.emit("streamResumed", { streamId, track: managed.track });
212
- }
213
- },
214
-
215
- drain: () => {
216
- const managed = this.streams.get(streamId);
217
- if (managed) {
218
- this.emit("streamDrained", { streamId, track: managed.track });
219
- }
220
- },
221
- };
222
- }
223
-
224
- /**
225
- * Setup data counter for stream
226
- */
227
- private setupDataCounter(managed: ManagedStream): void {
228
- let dataListener: (chunk: Buffer) => void;
229
-
230
- if (managed.stream.readable) {
231
- dataListener = (chunk: Buffer) => {
232
- managed.byteCount += chunk.length;
233
- if (this.options.enableMetrics) {
234
- this.metrics.totalBytesProcessed += chunk.length;
235
- }
236
-
237
- // Emit progress every ~1MB
238
- if (managed.byteCount % (1024 * 1024) < chunk.length) {
239
- this.emit("streamProgress", {
240
- streamId: managed.id,
241
- track: managed.track,
242
- bytes: managed.byteCount,
243
- megabytes: Math.floor(managed.byteCount / (1024 * 1024)),
244
- });
245
- }
246
- };
247
-
248
- managed.stream.on("data", dataListener);
249
-
250
- // Store data listener for cleanup
251
- (managed as any).dataListener = dataListener;
252
- }
253
- }
254
-
255
- /**
256
- * Unregister a stream
257
- */
258
- unregisterStream(streamId: string, forceDestroy: boolean = true): boolean {
259
- const managed = this.streams.get(streamId);
260
- if (!managed) {
261
- this.suppressPrematureCloseErrors.delete(streamId);
262
- return false;
263
- }
264
-
265
- this.debug(`Unregistering stream: ${managed.track.title} (${streamId})`);
266
-
267
- // Remove data listener
268
- const dataListener = (managed as any).dataListener;
269
- if (dataListener && managed.stream) {
270
- managed.stream.removeListener("data", dataListener);
271
- }
272
-
273
- // Remove all listeners
274
- const { listeners } = managed;
275
- const stream = managed.stream;
276
-
277
- if (stream) {
278
- stream.removeListener("error", listeners.error);
279
- stream.removeListener("close", listeners.close);
280
- stream.removeListener("end", listeners.end);
281
- stream.removeListener("pause", listeners.pause!);
282
- stream.removeListener("resume", listeners.resume!);
283
- stream.removeListener("drain", listeners.drain!);
284
-
285
- // Force destroy if needed
286
- if (forceDestroy && !stream.destroyed && typeof stream.destroy === "function") {
287
- try {
288
- this.suppressPrematureCloseErrors.add(streamId);
289
- stream.destroy();
290
- managed.status = "destroyed";
291
- } catch (err) {
292
- this.suppressPrematureCloseErrors.delete(streamId);
293
- this.debug(`Error destroying stream:`, err);
294
- }
295
- }
296
- }
297
-
298
- this.streams.delete(streamId);
299
-
300
- if (this.options.enableMetrics) {
301
- this.metrics.totalStreamsDestroyed++;
302
- this.metrics.activeStreams = this.streams.size;
303
- }
304
-
305
- this.emit("streamUnregistered", { streamId, track: managed.track, reason: forceDestroy ? "destroyed" : "natural" });
306
- this.suppressPrematureCloseErrors.delete(streamId);
307
-
308
- return true;
309
- }
310
-
311
- /**
312
- * Get a stream by ID
313
- */
314
- getStream(streamId: string): Readable | null {
315
- const managed = this.streams.get(streamId);
316
- if (managed && managed.status === "active") {
317
- managed.lastAccessed = Date.now();
318
- return managed.stream;
319
- }
320
- return null;
321
- }
322
-
323
- /**
324
- * Update stream metadata
325
- */
326
- updateMetadata(streamId: string, metadata: Partial<ManagedStream["metadata"]>): boolean {
327
- const managed = this.streams.get(streamId);
328
- if (managed) {
329
- managed.metadata = { ...managed.metadata, ...metadata };
330
- managed.lastAccessed = Date.now();
331
- this.emit("streamMetadataUpdated", { streamId, metadata });
332
- return true;
333
- }
334
- return false;
335
- }
336
-
337
- /**
338
- * Pause a stream
339
- */
340
- pauseStream(streamId: string): boolean {
341
- const managed = this.streams.get(streamId);
342
- if (managed && managed.status === "active" && !managed.stream.isPaused()) {
343
- managed.stream.pause();
344
- managed.status = "paused";
345
- this.emit("streamPaused", { streamId, track: managed.track });
346
- return true;
347
- }
348
- return false;
349
- }
350
-
351
- /**
352
- * Resume a stream
353
- */
354
- resumeStream(streamId: string): boolean {
355
- const managed = this.streams.get(streamId);
356
- if (managed && managed.status === "paused") {
357
- managed.stream.resume();
358
- managed.status = "active";
359
- managed.lastAccessed = Date.now();
360
- this.emit("streamResumed", { streamId, track: managed.track });
361
- return true;
362
- }
363
- return false;
364
- }
365
-
366
- /**
367
- * Evict oldest stream when limit reached
368
- */
369
- private evictOldestStream(): boolean {
370
- // Evict lowest priority streams first
371
- const sorted = Array.from(this.streams.values()).sort((a, b) => a.metadata.priority - b.metadata.priority);
372
-
373
- for (const managed of sorted) {
374
- if (managed.metadata.isPreload && managed.metadata.priority < 5) {
375
- this.debug(`Evicting low priority preload stream: ${managed.track.title}`);
376
- this.unregisterStream(managed.id, true);
377
- return true;
378
- }
379
- }
380
-
381
- if (sorted.length > 0) {
382
- const fallback = sorted[0];
383
- this.debug(`Evicting fallback stream to enforce limit: ${fallback.track.title}`);
384
- this.unregisterStream(fallback.id, true);
385
- return true;
386
- }
387
-
388
- return false;
389
- }
390
-
391
- /**
392
- * Cleanup expired streams
393
- */
394
- private cleanupExpiredStreams(): void {
395
- const now = Date.now();
396
- let cleaned = 0;
397
-
398
- for (const [streamId, managed] of this.streams) {
399
- const age = now - managed.lastAccessed;
400
-
401
- if (age > this.options.streamTimeout) {
402
- this.debug(`Cleaning up expired stream: ${managed.track.title} (age: ${age}ms)`);
403
- this.unregisterStream(streamId, this.options.autoDestroy);
404
- cleaned++;
405
- }
406
- }
407
-
408
- if (cleaned > 0) {
409
- this.emit("cleanupCompleted", { cleaned, remaining: this.streams.size });
410
- }
411
- }
412
-
413
- /**
414
- * Start automatic cleanup interval
415
- */
416
- private startCleanupInterval(): void {
417
- if (this.cleanupTimer) {
418
- clearInterval(this.cleanupTimer);
419
- }
420
-
421
- this.cleanupTimer = setInterval(() => {
422
- this.cleanupExpiredStreams();
423
- }, this.options.cleanupInterval);
424
-
425
- this.cleanupTimer.unref(); // Don't keep process alive
426
- }
427
-
428
- /**
429
- * Stop cleanup interval
430
- */
431
- stopCleanupInterval(): void {
432
- if (this.cleanupTimer) {
433
- clearInterval(this.cleanupTimer);
434
- this.cleanupTimer = null;
435
- }
436
- }
437
-
438
- /**
439
- * Get all active streams
440
- */
441
- getAllStreams(): ManagedStream[] {
442
- return Array.from(this.streams.values());
443
- }
444
-
445
- /**
446
- * Get streams by status
447
- */
448
- getStreamsByStatus(status: ManagedStream["status"]): ManagedStream[] {
449
- return Array.from(this.streams.values()).filter((s) => s.status === status);
450
- }
451
-
452
- /**
453
- * Get stream by track ID (using track.id, track.url, or track.title as identifier)
454
- */
455
- getStreamByTrack(trackId: string): Readable | null {
456
- for (const managed of this.streams.values()) {
457
- const managedTrackId = managed.track.id || managed.track.url || managed.track.title;
458
- if (managedTrackId === trackId && managed.status === "active") {
459
- managed.lastAccessed = Date.now();
460
- return managed.stream;
461
- }
462
- }
463
- return null;
464
- }
465
-
466
- /**
467
- * Check if a stream exists for a given track ID
468
- */
469
- hasStream(trackId: string): boolean {
470
- for (const managed of this.streams.values()) {
471
- const managedTrackId = managed.track.id || managed.track.url || managed.track.title;
472
- if (managedTrackId === trackId && managed.status === "active") {
473
- return true;
474
- }
475
- }
476
- return false;
477
- }
478
- /**
479
- * Get stream count
480
- */
481
- getStreamCount(): number {
482
- return this.streams.size;
483
- }
484
-
485
- /**
486
- * Get metrics
487
- */
488
- getMetrics(): typeof this.metrics {
489
- if (!this.options.enableMetrics) {
490
- return {
491
- totalStreamsCreated: 0,
492
- totalStreamsDestroyed: 0,
493
- activeStreams: 0,
494
- totalErrors: 0,
495
- totalBytesProcessed: 0,
496
- };
497
- }
498
- return { ...this.metrics };
499
- }
500
-
501
- /**
502
- * Get statistics
503
- */
504
- getStats(): {
505
- active: number;
506
- paused: number;
507
- ended: number;
508
- error: number;
509
- destroyed: number;
510
- total: number;
511
- bySource: Record<string, number>;
512
- } {
513
- const stats = {
514
- active: 0,
515
- paused: 0,
516
- ended: 0,
517
- error: 0,
518
- destroyed: 0,
519
- total: 0,
520
- bySource: {} as Record<string, number>,
521
- };
522
-
523
- for (const managed of this.streams.values()) {
524
- stats[managed.status]++;
525
- stats.total++;
526
-
527
- const source = managed.metadata.source;
528
- stats.bySource[source] = (stats.bySource[source] || 0) + 1;
529
- }
530
-
531
- return stats;
532
- }
533
-
534
- /**
535
- * Destroy all streams
536
- */
537
- destroyAll(force: boolean = true): void {
538
- this.debug(`Destroying all streams (${this.streams.size})`);
539
-
540
- for (const streamId of Array.from(this.streams.keys())) {
541
- this.unregisterStream(streamId, force);
542
- }
543
-
544
- this.stopCleanupInterval();
545
- this.emit("destroyed", { totalDestroyed: this.metrics.totalStreamsDestroyed });
546
- }
547
-
548
- /**
549
- * Generate unique stream ID
550
- */
551
- private generateStreamId(track: Track): string {
552
- return `${track.source || "unknown"}:${track.id || track.url || track.title}:${Date.now()}:${Math.random().toString(36).substr(2, 9)}`;
553
- }
554
-
555
- /**
556
- * Debug logging
557
- */
558
- private debug(message: string, ...args: any[]): void {
559
- if (this.listenerCount("debug") > 0) {
560
- this.emit("debug", `[StreamManager] ${message}`, ...args);
561
- }
562
- }
563
- }
1
+ import { Readable } from "stream";
2
+ import { EventEmitter } from "events";
3
+ import type { Track } from "../types";
4
+
5
+ export interface ManagedStream {
6
+ id: string;
7
+ stream: Readable;
8
+ track: Track;
9
+ createdAt: number;
10
+ lastAccessed: number;
11
+ playStream?: Readable;
12
+ metadata: {
13
+ source: string;
14
+ isPreload: boolean;
15
+ isRemote: boolean;
16
+ priority: number;
17
+ };
18
+ listeners: {
19
+ error: (err: Error) => void;
20
+ close: () => void;
21
+ end: () => void;
22
+ drain?: () => void;
23
+ pause?: () => void;
24
+ resume?: () => void;
25
+ };
26
+ status: "active" | "paused" | "ended" | "error" | "destroyed";
27
+ byteCount: number;
28
+ }
29
+
30
+ export interface StreamManagerOptions {
31
+ maxConcurrentStreams?: number;
32
+ streamTimeout?: number;
33
+ maxListenersPerStream?: number;
34
+ cleanupInterval?: number;
35
+ enableMetrics?: boolean;
36
+ autoDestroy?: boolean;
37
+ }
38
+
39
+ export class StreamManager extends EventEmitter {
40
+ private streams = new Map<string, ManagedStream>();
41
+ private suppressPrematureCloseErrors = new Set<string>();
42
+ private options: Required<StreamManagerOptions>;
43
+ private cleanupTimer: NodeJS.Timeout | null = null;
44
+
45
+ private metrics = {
46
+ totalStreamsCreated: 0,
47
+ totalStreamsDestroyed: 0,
48
+ activeStreams: 0,
49
+ totalErrors: 0,
50
+ totalBytesProcessed: 0,
51
+ };
52
+
53
+ constructor(options: StreamManagerOptions = {}) {
54
+ super();
55
+ this.setMaxListeners(50);
56
+
57
+ this.options = {
58
+ maxConcurrentStreams: 20,
59
+ streamTimeout: 5 * 60 * 1000, // 5 minutes
60
+ maxListenersPerStream: 15,
61
+ cleanupInterval: 60000, // 1 minute
62
+ enableMetrics: true,
63
+ autoDestroy: true,
64
+ ...options,
65
+ };
66
+
67
+ if (this.options.cleanupInterval > 0) {
68
+ this.startCleanupInterval();
69
+ }
70
+
71
+ this.debug("StreamManager initialized");
72
+ }
73
+
74
+ /**
75
+ * Register a new stream
76
+ */
77
+ registerStream(stream: Readable, track: Track, metadata: Partial<ManagedStream["metadata"]> = {}): string {
78
+ for (const existing of this.streams.values()) {
79
+ if (existing.stream === stream) {
80
+ if (stream.destroyed || (stream as any).readable === false) {
81
+ this.debug(`Stream object is dead, removing stale entry: ${existing.id}`);
82
+ this.streams.delete(existing.id);
83
+ break;
84
+ }
85
+ existing.lastAccessed = Date.now();
86
+ existing.track = track;
87
+ existing.metadata = {
88
+ ...existing.metadata,
89
+ source: track.source || existing.metadata.source || "unknown",
90
+ ...metadata,
91
+ };
92
+ this.debug(`Stream already managed, reusing ID: ${existing.id}`);
93
+ return existing.id;
94
+ }
95
+ }
96
+
97
+ const streamId = this.generateStreamId(track);
98
+
99
+ // Check if stream already exists
100
+ if (this.streams.has(streamId)) {
101
+ this.debug(`Stream already exists for track: ${track.title}, destroying old one`);
102
+ this.unregisterStream(streamId, true);
103
+ }
104
+
105
+ // Check concurrent limit
106
+ while (this.streams.size >= this.options.maxConcurrentStreams) {
107
+ const evicted = this.evictOldestStream();
108
+ if (!evicted) break;
109
+ }
110
+
111
+ // Configure stream
112
+ if (stream.setMaxListeners) {
113
+ stream.setMaxListeners(this.options.maxListenersPerStream);
114
+ }
115
+
116
+ // Create listeners
117
+ const listeners = this.createStreamListeners(streamId);
118
+
119
+ // Apply listeners
120
+ stream.on("error", listeners.error);
121
+ stream.on("close", listeners.close);
122
+ stream.on("end", listeners.end);
123
+ stream.on("pause", listeners.pause!);
124
+ stream.on("resume", listeners.resume!);
125
+ stream.on("drain", listeners.drain!);
126
+
127
+ // Create managed stream
128
+ const managedStream: ManagedStream = {
129
+ id: streamId,
130
+ stream,
131
+ track,
132
+ createdAt: Date.now(),
133
+ lastAccessed: Date.now(),
134
+ metadata: {
135
+ source: track.source || "unknown",
136
+ isPreload: metadata.isPreload || false,
137
+ priority: metadata.priority || 0,
138
+ isRemote: metadata.isRemote || false,
139
+ ...metadata,
140
+ },
141
+ listeners,
142
+ status: "active",
143
+ byteCount: 0,
144
+ };
145
+
146
+ this.streams.set(streamId, managedStream);
147
+
148
+ if (this.options.enableMetrics) {
149
+ this.metrics.totalStreamsCreated++;
150
+ this.metrics.activeStreams = this.streams.size;
151
+ }
152
+
153
+ // Setup data counter
154
+ this.setupDataCounter(managedStream);
155
+
156
+ this.debug(`Stream registered: ${track.title} (ID: ${streamId}), Total: ${this.streams.size}`);
157
+ this.emit("streamRegistered", { streamId, track, metadata: managedStream.metadata });
158
+
159
+ return streamId;
160
+ }
161
+
162
+ /**
163
+ * Create stream listeners
164
+ */
165
+ private createStreamListeners(streamId: string): ManagedStream["listeners"] {
166
+ return {
167
+ error: (err: Error) => {
168
+ const isPrematureClose = err?.message?.toLowerCase().includes("premature close");
169
+ if (isPrematureClose && this.suppressPrematureCloseErrors.has(streamId)) {
170
+ this.debug(`Ignored expected premature close [${streamId}] during controlled destroy`);
171
+ this.suppressPrematureCloseErrors.delete(streamId);
172
+ this.unregisterStream(streamId, false);
173
+ return;
174
+ }
175
+
176
+ this.debug(`Stream error [${streamId}]:`, err);
177
+ if (this.options.enableMetrics) {
178
+ this.metrics.totalErrors++;
179
+ }
180
+ this.emit("streamError", { streamId, error: err });
181
+ this.unregisterStream(streamId, true);
182
+ },
183
+
184
+ close: () => {
185
+ this.debug(`Stream closed [${streamId}]`);
186
+ const managed = this.streams.get(streamId);
187
+ if (managed) {
188
+ managed.status = "ended";
189
+ }
190
+ this.emit("streamClose", { streamId });
191
+ this.unregisterStream(streamId, false);
192
+ },
193
+
194
+ end: () => {
195
+ this.debug(`Stream ended [${streamId}]`);
196
+ const managed = this.streams.get(streamId);
197
+ if (managed) {
198
+ managed.status = "ended";
199
+ }
200
+ this.emit("streamEnd", { streamId });
201
+ this.unregisterStream(streamId, false);
202
+ },
203
+
204
+ pause: () => {
205
+ const managed = this.streams.get(streamId);
206
+ if (managed) {
207
+ managed.status = "paused";
208
+ this.emit("streamPaused", { streamId, track: managed.track });
209
+ }
210
+ },
211
+
212
+ resume: () => {
213
+ const managed = this.streams.get(streamId);
214
+ if (managed) {
215
+ managed.status = "active";
216
+ managed.lastAccessed = Date.now();
217
+ this.emit("streamResumed", { streamId, track: managed.track });
218
+ }
219
+ },
220
+
221
+ drain: () => {
222
+ const managed = this.streams.get(streamId);
223
+ if (managed) {
224
+ this.emit("streamDrained", { streamId, track: managed.track });
225
+ }
226
+ },
227
+ };
228
+ }
229
+
230
+ /**
231
+ * Setup data counter for stream
232
+ */
233
+ private setupDataCounter(managed: ManagedStream): void {
234
+ let dataListener: (chunk: Buffer) => void;
235
+
236
+ if (managed.stream.readable) {
237
+ dataListener = (chunk: Buffer) => {
238
+ managed.byteCount += chunk.length;
239
+ if (this.options.enableMetrics) {
240
+ this.metrics.totalBytesProcessed += chunk.length;
241
+ }
242
+
243
+ // Emit progress every ~1MB
244
+ if (managed.byteCount % (1024 * 1024) < chunk.length) {
245
+ this.emit("streamProgress", {
246
+ streamId: managed.id,
247
+ track: managed.track,
248
+ bytes: managed.byteCount,
249
+ megabytes: Math.floor(managed.byteCount / (1024 * 1024)),
250
+ });
251
+ }
252
+ };
253
+
254
+ managed.stream.on("data", dataListener);
255
+
256
+ // Store data listener for cleanup
257
+ (managed as any).dataListener = dataListener;
258
+ }
259
+ }
260
+
261
+ /**
262
+ * Unregister a stream
263
+ */
264
+ unregisterStream(streamId: string, forceDestroy: boolean = true): boolean {
265
+ const managed = this.streams.get(streamId);
266
+ if (!managed) {
267
+ this.suppressPrematureCloseErrors.delete(streamId);
268
+ return false;
269
+ }
270
+
271
+ this.debug(`Unregistering stream: ${managed.track.title} (${streamId})`);
272
+
273
+ // Remove data listener
274
+ const dataListener = (managed as any).dataListener;
275
+ if (dataListener && managed.stream) {
276
+ managed.stream.removeListener("data", dataListener);
277
+ }
278
+
279
+ // Remove all listeners
280
+ const { listeners } = managed;
281
+ const stream = managed.stream;
282
+
283
+ if (stream) {
284
+ stream.removeListener("error", listeners.error);
285
+ stream.removeListener("close", listeners.close);
286
+ stream.removeListener("end", listeners.end);
287
+ stream.removeListener("pause", listeners.pause!);
288
+ stream.removeListener("resume", listeners.resume!);
289
+ stream.removeListener("drain", listeners.drain!);
290
+
291
+ // Force destroy if needed
292
+ if (forceDestroy && !stream.destroyed && typeof stream.destroy === "function") {
293
+ try {
294
+ this.suppressPrematureCloseErrors.add(streamId);
295
+ stream.destroy();
296
+ managed.status = "destroyed";
297
+ } catch (err) {
298
+ this.suppressPrematureCloseErrors.delete(streamId);
299
+ this.debug(`Error destroying stream:`, err);
300
+ }
301
+ }
302
+ }
303
+
304
+ this.streams.delete(streamId);
305
+
306
+ if (this.options.enableMetrics) {
307
+ this.metrics.totalStreamsDestroyed++;
308
+ this.metrics.activeStreams = this.streams.size;
309
+ }
310
+
311
+ this.emit("streamUnregistered", { streamId, track: managed.track, reason: forceDestroy ? "destroyed" : "natural" });
312
+ this.suppressPrematureCloseErrors.delete(streamId);
313
+
314
+ return true;
315
+ }
316
+
317
+ /**
318
+ * Get a stream by ID
319
+ */
320
+ getStream(streamId: string): Readable | null {
321
+ const managed = this.streams.get(streamId);
322
+ if (managed && managed.status === "active") {
323
+ managed.lastAccessed = Date.now();
324
+ return managed.stream;
325
+ }
326
+ return null;
327
+ }
328
+
329
+ /**
330
+ * Like getStream() but accepts "paused" streams too.
331
+ * Used by refreshPlayerResource to reuse a source stream during seek.
332
+ * discordjs/voice pauses source streams on NoSubscriberBehavior which would
333
+ * make getStream() return null and force an unnecessary network fetch.
334
+ */
335
+ getRawStream(streamId: string): Readable | null {
336
+ const managed = this.streams.get(streamId);
337
+ if (!managed) return null;
338
+ // Only reject truly terminal states.
339
+ if (managed.status === "destroyed" || managed.status === "ended" || managed.status === "error") return null;
340
+ if (managed.stream.destroyed) return null;
341
+ managed.lastAccessed = Date.now();
342
+ return managed.stream;
343
+ }
344
+
345
+ /**
346
+ * Update stream metadata
347
+ */
348
+ updateMetadata(streamId: string, metadata: Partial<ManagedStream["metadata"]>): boolean {
349
+ const managed = this.streams.get(streamId);
350
+ if (managed) {
351
+ managed.metadata = { ...managed.metadata, ...metadata };
352
+ managed.lastAccessed = Date.now();
353
+ this.emit("streamMetadataUpdated", { streamId, metadata });
354
+ return true;
355
+ }
356
+ return false;
357
+ }
358
+
359
+ /**
360
+ * Pause a stream
361
+ */
362
+ pauseStream(streamId: string): boolean {
363
+ const managed = this.streams.get(streamId);
364
+ if (managed && managed.status === "active" && !managed.stream.isPaused()) {
365
+ managed.stream.pause();
366
+ managed.status = "paused";
367
+ this.emit("streamPaused", { streamId, track: managed.track });
368
+ return true;
369
+ }
370
+ return false;
371
+ }
372
+
373
+ /**
374
+ * Resume a stream
375
+ */
376
+ resumeStream(streamId: string): boolean {
377
+ const managed = this.streams.get(streamId);
378
+ if (managed && managed.status === "paused") {
379
+ managed.stream.resume();
380
+ managed.status = "active";
381
+ managed.lastAccessed = Date.now();
382
+ this.emit("streamResumed", { streamId, track: managed.track });
383
+ return true;
384
+ }
385
+ return false;
386
+ }
387
+
388
+ /**
389
+ * Evict oldest stream when limit reached
390
+ */
391
+ private evictOldestStream(): boolean {
392
+ // Evict lowest priority streams first
393
+ const sorted = Array.from(this.streams.values()).sort((a, b) => a.metadata.priority - b.metadata.priority);
394
+
395
+ for (const managed of sorted) {
396
+ if (managed.metadata.isPreload && managed.metadata.priority < 5) {
397
+ this.debug(`Evicting low priority preload stream: ${managed.track.title}`);
398
+ this.unregisterStream(managed.id, true);
399
+ return true;
400
+ }
401
+ }
402
+
403
+ if (sorted.length > 0) {
404
+ const fallback = sorted[0];
405
+ this.debug(`Evicting fallback stream to enforce limit: ${fallback.track.title}`);
406
+ this.unregisterStream(fallback.id, true);
407
+ return true;
408
+ }
409
+
410
+ return false;
411
+ }
412
+
413
+ /**
414
+ * Cleanup expired streams
415
+ */
416
+ private cleanupExpiredStreams(): void {
417
+ const now = Date.now();
418
+ let cleaned = 0;
419
+
420
+ for (const [streamId, managed] of this.streams) {
421
+ const age = now - managed.lastAccessed;
422
+
423
+ if (age > this.options.streamTimeout) {
424
+ this.debug(`Cleaning up expired stream: ${managed.track.title} (age: ${age}ms)`);
425
+ this.unregisterStream(streamId, this.options.autoDestroy);
426
+ cleaned++;
427
+ }
428
+ }
429
+
430
+ if (cleaned > 0) {
431
+ this.emit("cleanupCompleted", { cleaned, remaining: this.streams.size });
432
+ }
433
+ }
434
+
435
+ /**
436
+ * Start automatic cleanup interval
437
+ */
438
+ private startCleanupInterval(): void {
439
+ if (this.cleanupTimer) {
440
+ clearInterval(this.cleanupTimer);
441
+ }
442
+
443
+ this.cleanupTimer = setInterval(() => {
444
+ this.cleanupExpiredStreams();
445
+ }, this.options.cleanupInterval);
446
+
447
+ this.cleanupTimer.unref(); // Don't keep process alive
448
+ }
449
+
450
+ /**
451
+ * Stop cleanup interval
452
+ */
453
+ stopCleanupInterval(): void {
454
+ if (this.cleanupTimer) {
455
+ clearInterval(this.cleanupTimer);
456
+ this.cleanupTimer = null;
457
+ }
458
+ }
459
+
460
+ /**
461
+ * Get all active streams
462
+ */
463
+ getAllStreams(): ManagedStream[] {
464
+ return Array.from(this.streams.values());
465
+ }
466
+
467
+ /**
468
+ * Get streams by status
469
+ */
470
+ getStreamsByStatus(status: ManagedStream["status"]): ManagedStream[] {
471
+ return Array.from(this.streams.values()).filter((s) => s.status === status);
472
+ }
473
+
474
+ /**
475
+ * Get stream by track ID (using track.id, track.url, or track.title as identifier)
476
+ */
477
+ getStreamByTrack(trackId: string): Readable | null {
478
+ for (const managed of this.streams.values()) {
479
+ const managedTrackId = managed.track.id || managed.track.url || managed.track.title;
480
+ if (managedTrackId === trackId && managed.status === "active") {
481
+ managed.lastAccessed = Date.now();
482
+ return managed.stream;
483
+ }
484
+ }
485
+ return null;
486
+ }
487
+
488
+ /**
489
+ * Check if a stream exists for a given track ID
490
+ */
491
+ hasStream(trackId: string): boolean {
492
+ for (const managed of this.streams.values()) {
493
+ const managedTrackId = managed.track.id || managed.track.url || managed.track.title;
494
+ if (managedTrackId === trackId && managed.status === "active") {
495
+ return true;
496
+ }
497
+ }
498
+ return false;
499
+ }
500
+ /**
501
+ * Get stream count
502
+ */
503
+ getStreamCount(): number {
504
+ return this.streams.size;
505
+ }
506
+
507
+ /**
508
+ * Get metrics
509
+ */
510
+ getMetrics(): typeof this.metrics {
511
+ if (!this.options.enableMetrics) {
512
+ return {
513
+ totalStreamsCreated: 0,
514
+ totalStreamsDestroyed: 0,
515
+ activeStreams: 0,
516
+ totalErrors: 0,
517
+ totalBytesProcessed: 0,
518
+ };
519
+ }
520
+ return { ...this.metrics };
521
+ }
522
+
523
+ /**
524
+ * Get statistics
525
+ */
526
+ getStats(): {
527
+ active: number;
528
+ paused: number;
529
+ ended: number;
530
+ error: number;
531
+ destroyed: number;
532
+ total: number;
533
+ bySource: Record<string, number>;
534
+ } {
535
+ const stats = {
536
+ active: 0,
537
+ paused: 0,
538
+ ended: 0,
539
+ error: 0,
540
+ destroyed: 0,
541
+ total: 0,
542
+ bySource: {} as Record<string, number>,
543
+ };
544
+
545
+ for (const managed of this.streams.values()) {
546
+ stats[managed.status]++;
547
+ stats.total++;
548
+
549
+ const source = managed.metadata.source;
550
+ stats.bySource[source] = (stats.bySource[source] || 0) + 1;
551
+ }
552
+
553
+ return stats;
554
+ }
555
+
556
+ /**
557
+ * Destroy all streams
558
+ */
559
+ destroyAll(force: boolean = true): void {
560
+ this.debug(`Destroying all streams (${this.streams.size})`);
561
+
562
+ for (const streamId of Array.from(this.streams.keys())) {
563
+ this.unregisterStream(streamId, force);
564
+ }
565
+
566
+ this.stopCleanupInterval();
567
+ this.emit("destroyed", { totalDestroyed: this.metrics.totalStreamsDestroyed });
568
+ }
569
+
570
+ /**
571
+ * Generate unique stream ID
572
+ */
573
+ private generateStreamId(track: Track): string {
574
+ return `${track.source || "unknown"}:${track.id || track.url || track.title}:${Date.now()}:${Math.random().toString(36).substr(2, 9)}`;
575
+ }
576
+
577
+ /**
578
+ * Debug logging
579
+ */
580
+ private debug(message: string, ...args: any[]): void {
581
+ if (this.listenerCount("debug") > 0) {
582
+ this.emit("debug", `[StreamManager] ${message}`, ...args);
583
+ }
584
+ }
585
+ }