ziplayer 0.3.0 → 0.3.2

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.
@@ -0,0 +1,274 @@
1
+ import { createAudioResource, AudioResource } from "@discordjs/voice";
2
+ import type { Track, StreamInfo, StreamSlot } from "../types";
3
+ import type { StreamManager } from "./StreamManager";
4
+
5
+ interface PreloadManagerDeps {
6
+ streamManager: StreamManager;
7
+ debug: (message?: any, ...optionalParams: any[]) => void;
8
+ getNextTrack: () => Track | null;
9
+ getStream: (track: Track) => Promise<StreamInfo | null>;
10
+ isDestroyed: () => boolean;
11
+ isEnabled: () => boolean;
12
+ }
13
+
14
+ export class PreloadManager {
15
+ private readonly streamManager: StreamManager;
16
+ private readonly debugLog: (message?: any, ...optionalParams: any[]) => void;
17
+ private readonly getNextTrack: () => Track | null;
18
+ private readonly getStream: (track: Track) => Promise<StreamInfo | null>;
19
+ private readonly isDestroyed: () => boolean;
20
+ private readonly isEnabled: () => boolean;
21
+
22
+ private preloadLock = false;
23
+ private readonly preloadSlot: StreamSlot = {
24
+ resource: null,
25
+ track: null,
26
+ streamId: null,
27
+ abortController: null,
28
+ isValid: false,
29
+ isLoading: false,
30
+ loadPromise: null,
31
+ };
32
+
33
+ constructor(deps: PreloadManagerDeps) {
34
+ this.streamManager = deps.streamManager;
35
+ this.debugLog = deps.debug;
36
+ this.getNextTrack = deps.getNextTrack;
37
+ this.getStream = deps.getStream;
38
+ this.isDestroyed = deps.isDestroyed;
39
+ this.isEnabled = deps.isEnabled;
40
+ }
41
+
42
+ public hasValidPreload(track: Track): boolean {
43
+ return !!(
44
+ this.preloadSlot.isValid &&
45
+ this.preloadSlot.track?.id === track.id &&
46
+ this.preloadSlot.resource &&
47
+ this.preloadSlot.resource.playStream?.readable !== false
48
+ );
49
+ }
50
+
51
+ public promoteToCurrent(track: Track, currentSlot: StreamSlot): AudioResource | null {
52
+ const promotedResource = this.preloadSlot.resource;
53
+ const promotedStreamId = this.preloadSlot.streamId;
54
+ if (!promotedResource) return null;
55
+
56
+ currentSlot.resource = promotedResource;
57
+ currentSlot.track = track;
58
+ currentSlot.streamId = promotedStreamId;
59
+ currentSlot.abortController = null;
60
+ currentSlot.isValid = true;
61
+ currentSlot.isLoading = false;
62
+ currentSlot.loadPromise = null;
63
+
64
+ this.preloadSlot.resource = null;
65
+ this.preloadSlot.track = null;
66
+ this.preloadSlot.streamId = null;
67
+ this.preloadSlot.abortController = null;
68
+ this.preloadSlot.isValid = false;
69
+ this.preloadSlot.isLoading = false;
70
+ this.preloadSlot.loadPromise = null;
71
+
72
+ return promotedResource;
73
+ }
74
+
75
+ public async preloadNextTrack(): Promise<void> {
76
+ if (this.isDestroyed()) return;
77
+ if (!this.isEnabled()) {
78
+ this.debugLog(`[Preload] Disabled by options/runtime profile`);
79
+ return;
80
+ }
81
+
82
+ if (this.preloadLock) {
83
+ this.debugLog(`[Preload] Already preloading, skipping`);
84
+ return;
85
+ }
86
+
87
+ const nextTrack = this.getNextTrack();
88
+ if (!nextTrack) {
89
+ this.debugLog(`[Preload] No next track to preload`);
90
+ return;
91
+ }
92
+
93
+ if (this.preloadSlot.isValid && this.preloadSlot.track?.id === nextTrack.id && this.preloadSlot.resource) {
94
+ this.debugLog(`[Preload] Already have valid preload for: ${nextTrack.title}`);
95
+ return;
96
+ }
97
+
98
+ if (this.preloadSlot.isLoading && this.preloadSlot.track?.id === nextTrack.id) {
99
+ this.debugLog(`[Preload] Currently loading same track, waiting...`);
100
+ if (this.preloadSlot.loadPromise) {
101
+ await this.preloadSlot.loadPromise;
102
+ }
103
+ return;
104
+ }
105
+
106
+ if (this.preloadSlot.isValid && this.preloadSlot.track?.id !== nextTrack.id) {
107
+ this.debugLog(`[Preload] Cancelling old preload for different track: ${this.preloadSlot.track?.title}`);
108
+ await this.safeCancelPreload();
109
+ }
110
+
111
+ this.preloadLock = true;
112
+ const abortController = new AbortController();
113
+ this.preloadSlot.track = nextTrack;
114
+ this.preloadSlot.abortController = abortController;
115
+ this.preloadSlot.isLoading = true;
116
+
117
+ const loadPromise = this.executePreload(nextTrack, abortController);
118
+ this.preloadSlot.loadPromise = loadPromise;
119
+
120
+ try {
121
+ await loadPromise;
122
+ } catch (err) {
123
+ if (err instanceof Error && err.message === "PRELOAD_CANCELLED") {
124
+ this.debugLog(`[Preload] Cancelled for ${nextTrack.title}`);
125
+ } else {
126
+ this.debugLog(`[Preload] Failed for ${nextTrack.title}:`, err);
127
+ }
128
+ this.clearPreloadSlot();
129
+ } finally {
130
+ this.preloadLock = false;
131
+ this.preloadSlot.isLoading = false;
132
+ this.preloadSlot.loadPromise = null;
133
+ }
134
+ }
135
+
136
+ public async safeCancelPreload(): Promise<void> {
137
+ if (!this.preloadSlot.abortController && !this.preloadSlot.resource) {
138
+ return;
139
+ }
140
+
141
+ this.debugLog(`[Preload] Safely cancelling preload for: ${this.preloadSlot.track?.title || "unknown"}`);
142
+
143
+ if (this.preloadSlot.abortController) {
144
+ this.preloadSlot.abortController.abort();
145
+ this.preloadSlot.abortController = null;
146
+ }
147
+
148
+ if (this.preloadSlot.streamId) {
149
+ this.streamManager.unregisterStream(this.preloadSlot.streamId, true);
150
+ }
151
+
152
+ if (this.preloadSlot.resource) {
153
+ try {
154
+ const stream = this.preloadSlot.resource.playStream;
155
+ if (stream && typeof stream.destroy === "function" && !stream.destroyed) {
156
+ stream.destroy();
157
+ }
158
+ } catch {
159
+ // ignore
160
+ }
161
+ }
162
+
163
+ this.clearPreloadSlot();
164
+ }
165
+
166
+ public cancelPreload(): void {
167
+ if (this.preloadSlot.abortController) {
168
+ this.debugLog(`[Preload] Cancelling preload for: ${this.preloadSlot.track?.title}`);
169
+ this.preloadSlot.abortController.abort();
170
+ }
171
+ if (this.preloadSlot.streamId) {
172
+ this.streamManager.unregisterStream(this.preloadSlot.streamId, true);
173
+ }
174
+ this.clearPreloadSlot();
175
+ }
176
+
177
+ public clearPreloadSlot(): void {
178
+ if (this.preloadSlot.resource) {
179
+ try {
180
+ const stream = this.preloadSlot.resource.playStream;
181
+ if (stream && typeof stream.destroy === "function" && !stream.destroyed) {
182
+ stream.destroy();
183
+ }
184
+ } catch {
185
+ // ignore
186
+ }
187
+ }
188
+
189
+ if (this.preloadSlot.streamId) {
190
+ this.streamManager.unregisterStream(this.preloadSlot.streamId, true);
191
+ }
192
+
193
+ this.preloadSlot.resource = null;
194
+ this.preloadSlot.track = null;
195
+ this.preloadSlot.streamId = null;
196
+ this.preloadSlot.abortController = null;
197
+ this.preloadSlot.isValid = false;
198
+ this.preloadSlot.isLoading = false;
199
+ this.preloadSlot.loadPromise = null;
200
+ }
201
+
202
+ private async executePreload(track: Track, abortController: AbortController): Promise<void> {
203
+ if (this.isDestroyed()) throw new Error("PLAYER_DESTROYED");
204
+ this.debugLog(`[Preload] Starting preload for: ${track.title}`);
205
+
206
+ if (abortController.signal.aborted) {
207
+ throw new Error("PRELOAD_CANCELLED");
208
+ }
209
+
210
+ if (this.getNextTrack()?.id !== track.id) {
211
+ this.debugLog(`[Preload] Track changed, cancelling`);
212
+ throw new Error("PRELOAD_CANCELLED");
213
+ }
214
+
215
+ const streamInfo = await this.getStreamWithCancel(track, abortController.signal);
216
+ if (abortController.signal.aborted) {
217
+ throw new Error("PRELOAD_CANCELLED");
218
+ }
219
+ if (this.getNextTrack()?.id !== track.id) {
220
+ this.debugLog(`[Preload] Track changed after stream fetch`);
221
+ throw new Error("PRELOAD_CANCELLED");
222
+ }
223
+ if (!streamInfo?.stream) {
224
+ throw new Error(`No stream available`);
225
+ }
226
+
227
+ const streamId = this.streamManager.registerStream(streamInfo.stream, track, {
228
+ source: track.source || "preload",
229
+ isPreload: true,
230
+ priority: 5,
231
+ });
232
+
233
+ const resource = createAudioResource(streamInfo.stream, {
234
+ inlineVolume: true,
235
+ metadata: { ...track, preloaded: true },
236
+ });
237
+
238
+ if (!resource.playStream || resource.playStream.readable === false) {
239
+ throw new Error("Resource not readable");
240
+ }
241
+
242
+ this.preloadSlot.resource = resource;
243
+ this.preloadSlot.streamId = streamId;
244
+ this.preloadSlot.isValid = true;
245
+ this.preloadSlot.track = track;
246
+
247
+ this.debugLog(`[Preload] Successfully preloaded: ${track.title} (Stream ID: ${streamId})`);
248
+ }
249
+
250
+ private async getStreamWithCancel(track: Track, signal: AbortSignal): Promise<StreamInfo | null> {
251
+ if (this.isDestroyed()) throw new Error("PLAYER_DESTROYED");
252
+ const abortPromise = new Promise<never>((_, reject) => {
253
+ if (signal.aborted) {
254
+ reject(new Error("PRELOAD_CANCELLED"));
255
+ return;
256
+ }
257
+ const handler = () => {
258
+ signal.removeEventListener("abort", handler);
259
+ reject(new Error("PRELOAD_CANCELLED"));
260
+ };
261
+ signal.addEventListener("abort", handler);
262
+ });
263
+
264
+ const existingStream = this.streamManager.getStreamByTrack(track.id || track.title);
265
+ if (existingStream && !existingStream.destroyed && existingStream.readable !== false) {
266
+ this.debugLog(`[Stream] Using existing stream for preload: ${track.title}`);
267
+ return { stream: existingStream, type: "arbitrary" };
268
+ }
269
+
270
+ const streamPromise = this.getStream(track);
271
+ const result = await Promise.race([streamPromise, abortPromise]);
272
+ return result as StreamInfo | null;
273
+ }
274
+ }
@@ -37,6 +37,7 @@ export interface StreamManagerOptions {
37
37
 
38
38
  export class StreamManager extends EventEmitter {
39
39
  private streams = new Map<string, ManagedStream>();
40
+ private suppressPrematureCloseErrors = new Set<string>();
40
41
  private options: Required<StreamManagerOptions>;
41
42
  private cleanupTimer: NodeJS.Timeout | null = null;
42
43
  private metrics = {
@@ -72,6 +73,20 @@ export class StreamManager extends EventEmitter {
72
73
  * Register a new stream
73
74
  */
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
+ existing.lastAccessed = Date.now();
79
+ existing.track = track;
80
+ existing.metadata = {
81
+ ...existing.metadata,
82
+ source: track.source || existing.metadata.source || "unknown",
83
+ ...metadata,
84
+ };
85
+ this.debug(`Stream already managed, reusing ID: ${existing.id}`);
86
+ return existing.id;
87
+ }
88
+ }
89
+
75
90
  const streamId = this.generateStreamId(track);
76
91
 
77
92
  // Check if stream already exists
@@ -81,8 +96,9 @@ export class StreamManager extends EventEmitter {
81
96
  }
82
97
 
83
98
  // Check concurrent limit
84
- if (this.streams.size >= this.options.maxConcurrentStreams) {
85
- this.evictOldestStream();
99
+ while (this.streams.size >= this.options.maxConcurrentStreams) {
100
+ const evicted = this.evictOldestStream();
101
+ if (!evicted) break;
86
102
  }
87
103
 
88
104
  // Configure stream
@@ -141,6 +157,14 @@ export class StreamManager extends EventEmitter {
141
157
  private createStreamListeners(streamId: string): ManagedStream["listeners"] {
142
158
  return {
143
159
  error: (err: Error) => {
160
+ const isPrematureClose = err?.message?.toLowerCase().includes("premature close");
161
+ if (isPrematureClose && this.suppressPrematureCloseErrors.has(streamId)) {
162
+ this.debug(`Ignored expected premature close [${streamId}] during controlled destroy`);
163
+ this.suppressPrematureCloseErrors.delete(streamId);
164
+ this.unregisterStream(streamId, false);
165
+ return;
166
+ }
167
+
144
168
  this.debug(`Stream error [${streamId}]:`, err);
145
169
  if (this.options.enableMetrics) {
146
170
  this.metrics.totalErrors++;
@@ -232,6 +256,7 @@ export class StreamManager extends EventEmitter {
232
256
  unregisterStream(streamId: string, forceDestroy: boolean = true): boolean {
233
257
  const managed = this.streams.get(streamId);
234
258
  if (!managed) {
259
+ this.suppressPrematureCloseErrors.delete(streamId);
235
260
  return false;
236
261
  }
237
262
 
@@ -258,9 +283,11 @@ export class StreamManager extends EventEmitter {
258
283
  // Force destroy if needed
259
284
  if (forceDestroy && !stream.destroyed && typeof stream.destroy === "function") {
260
285
  try {
286
+ this.suppressPrematureCloseErrors.add(streamId);
261
287
  stream.destroy();
262
288
  managed.status = "destroyed";
263
289
  } catch (err) {
290
+ this.suppressPrematureCloseErrors.delete(streamId);
264
291
  this.debug(`Error destroying stream:`, err);
265
292
  }
266
293
  }
@@ -274,6 +301,7 @@ export class StreamManager extends EventEmitter {
274
301
  }
275
302
 
276
303
  this.emit("streamUnregistered", { streamId, track: managed.track, reason: forceDestroy ? "destroyed" : "natural" });
304
+ this.suppressPrematureCloseErrors.delete(streamId);
277
305
 
278
306
  return true;
279
307
  }
@@ -336,7 +364,7 @@ export class StreamManager extends EventEmitter {
336
364
  /**
337
365
  * Evict oldest stream when limit reached
338
366
  */
339
- private evictOldestStream(): void {
367
+ private evictOldestStream(): boolean {
340
368
  // Evict lowest priority streams first
341
369
  const sorted = Array.from(this.streams.values()).sort((a, b) => a.metadata.priority - b.metadata.priority);
342
370
 
@@ -344,9 +372,18 @@ export class StreamManager extends EventEmitter {
344
372
  if (managed.metadata.isPreload && managed.metadata.priority < 5) {
345
373
  this.debug(`Evicting low priority preload stream: ${managed.track.title}`);
346
374
  this.unregisterStream(managed.id, true);
347
- break;
375
+ return true;
348
376
  }
349
377
  }
378
+
379
+ if (sorted.length > 0) {
380
+ const fallback = sorted[0];
381
+ this.debug(`Evicting fallback stream to enforce limit: ${fallback.track.title}`);
382
+ this.unregisterStream(fallback.id, true);
383
+ return true;
384
+ }
385
+
386
+ return false;
350
387
  }
351
388
 
352
389
  /**
@@ -228,7 +228,7 @@ export interface PlayerOptions {
228
228
  autoDisableInLowPerformance?: boolean;
229
229
  /**
230
230
  * Target crossfade duration in milliseconds.
231
- * Default: 5000
231
+ * Default: 500
232
232
  */
233
233
  durationMs?: number;
234
234
  };