ziplayer 0.3.6 → 0.3.7

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