ziplayer 0.3.1 → 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.
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/structures/Player.d.ts +3 -16
- package/dist/structures/Player.d.ts.map +1 -1
- package/dist/structures/Player.js +74 -377
- package/dist/structures/Player.js.map +1 -1
- package/dist/structures/PreloadManager.d.ts +32 -0
- package/dist/structures/PreloadManager.d.ts.map +1 -0
- package/dist/structures/PreloadManager.js +230 -0
- package/dist/structures/PreloadManager.js.map +1 -0
- package/dist/structures/StreamManager.d.ts +1 -0
- package/dist/structures/StreamManager.d.ts.map +1 -1
- package/dist/structures/StreamManager.js +37 -3
- package/dist/structures/StreamManager.js.map +1 -1
- package/dist/types/index.d.ts +1 -1
- package/package.json +1 -1
- package/src/index.ts +1 -0
- package/src/structures/Player.ts +71 -424
- package/src/structures/PreloadManager.ts +274 -0
- package/src/structures/StreamManager.ts +41 -4
- package/src/types/index.ts +1 -1
|
@@ -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
|
-
|
|
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():
|
|
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
|
-
|
|
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
|
/**
|