ziplayer 0.2.7-dev.3 → 0.3.0
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/AI-Guide.md +624 -607
- package/README.md +526 -524
- package/dist/plugins/index.d.ts +62 -12
- package/dist/plugins/index.d.ts.map +1 -1
- package/dist/plugins/index.js +497 -57
- package/dist/plugins/index.js.map +1 -1
- package/dist/structures/PersistenceManager.d.ts +96 -0
- package/dist/structures/PersistenceManager.d.ts.map +1 -0
- package/dist/structures/PersistenceManager.js +1008 -0
- package/dist/structures/PersistenceManager.js.map +1 -0
- package/dist/structures/Player.d.ts +109 -18
- package/dist/structures/Player.d.ts.map +1 -1
- package/dist/structures/Player.js +902 -182
- package/dist/structures/Player.js.map +1 -1
- package/dist/structures/PlayerManager.d.ts +1 -22
- package/dist/structures/PlayerManager.d.ts.map +1 -1
- package/dist/structures/PlayerManager.js +1 -73
- package/dist/structures/PlayerManager.js.map +1 -1
- package/dist/structures/StreamManager.d.ts +137 -0
- package/dist/structures/StreamManager.d.ts.map +1 -0
- package/dist/structures/StreamManager.js +420 -0
- package/dist/structures/StreamManager.js.map +1 -0
- package/dist/types/index.d.ts +149 -16
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/index.js +0 -1
- package/dist/types/index.js.map +1 -1
- package/dist/types/persistence.d.ts +3 -2
- package/dist/types/persistence.d.ts.map +1 -1
- package/package.json +47 -47
- package/src/extensions/BaseExtension.ts +36 -36
- package/src/extensions/index.ts +473 -473
- package/src/index.ts +16 -16
- package/src/plugins/BasePlugin.ts +27 -27
- package/src/plugins/index.ts +950 -403
- package/src/structures/FilterManager.ts +303 -303
- package/src/structures/Player.ts +2797 -1970
- package/src/structures/PlayerManager.ts +725 -822
- package/src/structures/Queue.ts +599 -599
- package/src/structures/StreamManager.ts +524 -0
- package/src/types/extension.ts +129 -129
- package/src/types/fillter.ts +264 -264
- package/src/types/index.ts +548 -415
- package/src/types/plugin.ts +59 -59
- package/src/utils/timeout.ts +10 -10
- package/tsconfig.json +22 -22
- package/src/persistence/PersistenceManager.ts +0 -1077
- package/src/types/persistence.ts +0 -85
package/src/extensions/index.ts
CHANGED
|
@@ -1,473 +1,473 @@
|
|
|
1
|
-
import type { Player } from "../structures/Player";
|
|
2
|
-
import type { PlayerManager } from "../structures/PlayerManager";
|
|
3
|
-
import type {
|
|
4
|
-
ExtensionSearchRequest,
|
|
5
|
-
SearchResult,
|
|
6
|
-
StreamInfo,
|
|
7
|
-
Track,
|
|
8
|
-
ExtensionContext,
|
|
9
|
-
ExtensionPlayRequest,
|
|
10
|
-
ExtensionPlayResponse,
|
|
11
|
-
ExtensionAfterPlayPayload,
|
|
12
|
-
ExtensionStreamRequest,
|
|
13
|
-
} from "../types";
|
|
14
|
-
|
|
15
|
-
import { BaseExtension } from "./BaseExtension";
|
|
16
|
-
|
|
17
|
-
export { BaseExtension } from "./BaseExtension";
|
|
18
|
-
|
|
19
|
-
interface ExtensionCacheEntry<T> {
|
|
20
|
-
data: T;
|
|
21
|
-
timestamp: number;
|
|
22
|
-
expiresAt: number;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
interface ExtensionMetadata {
|
|
26
|
-
name: string;
|
|
27
|
-
priority: number;
|
|
28
|
-
registeredAt: number;
|
|
29
|
-
hasSearch: boolean;
|
|
30
|
-
hasStream: boolean;
|
|
31
|
-
hasBeforePlay: boolean;
|
|
32
|
-
hasAfterPlay: boolean;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export class ExtensionManager {
|
|
36
|
-
private extensions: Map<string, BaseExtension>;
|
|
37
|
-
private extensionMetadata: Map<string, ExtensionMetadata>;
|
|
38
|
-
private player: Player;
|
|
39
|
-
private manager: PlayerManager;
|
|
40
|
-
private extensionContext: ExtensionContext;
|
|
41
|
-
|
|
42
|
-
// Caches for different operations
|
|
43
|
-
private searchCache: Map<string, ExtensionCacheEntry<SearchResult>>;
|
|
44
|
-
private streamCache: Map<string, ExtensionCacheEntry<StreamInfo>>;
|
|
45
|
-
|
|
46
|
-
// Cache TTLs
|
|
47
|
-
private readonly SEARCH_CACHE_TTL = 60 * 1000; // 1 minute
|
|
48
|
-
private readonly STREAM_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
|
49
|
-
private readonly MAX_CACHE_SIZE = 100;
|
|
50
|
-
|
|
51
|
-
// Pending requests for deduplication
|
|
52
|
-
private pendingSearches: Map<string, Promise<SearchResult | null>>;
|
|
53
|
-
private pendingStreams: Map<string, Promise<StreamInfo | null>>;
|
|
54
|
-
|
|
55
|
-
constructor(player: Player, manager: PlayerManager) {
|
|
56
|
-
this.player = player;
|
|
57
|
-
this.manager = manager;
|
|
58
|
-
this.extensions = new Map();
|
|
59
|
-
this.extensionMetadata = new Map();
|
|
60
|
-
this.searchCache = new Map();
|
|
61
|
-
this.streamCache = new Map();
|
|
62
|
-
this.pendingSearches = new Map();
|
|
63
|
-
this.pendingStreams = new Map();
|
|
64
|
-
this.extensionContext = Object.freeze({ player, manager });
|
|
65
|
-
|
|
66
|
-
// Auto-cleanup caches periodically
|
|
67
|
-
setInterval(() => this.cleanupCaches(), 5 * 60 * 1000);
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
debug(message?: any, ...optionalParams: any[]): void {
|
|
71
|
-
if (this.manager.debugEnabled) {
|
|
72
|
-
this.manager.emit("debug", `[ExtensionManager] ${message}`, ...optionalParams);
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
register(extension: BaseExtension): void {
|
|
77
|
-
if (this.extensions.has(extension.name)) {
|
|
78
|
-
this.debug(`Extension ${extension.name} already registered, skipping`);
|
|
79
|
-
return;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
if (!extension.player) {
|
|
83
|
-
extension.player = this.player;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
// Set default priority if not set
|
|
87
|
-
extension.priority ??= 0;
|
|
88
|
-
|
|
89
|
-
// Store metadata for optimization
|
|
90
|
-
const metadata: ExtensionMetadata = {
|
|
91
|
-
name: extension.name,
|
|
92
|
-
priority: extension.priority,
|
|
93
|
-
registeredAt: Date.now(),
|
|
94
|
-
hasSearch: typeof (extension as any).provideSearch === "function",
|
|
95
|
-
hasStream: typeof (extension as any).provideStream === "function",
|
|
96
|
-
hasBeforePlay: typeof (extension as any).beforePlay === "function",
|
|
97
|
-
hasAfterPlay: typeof (extension as any).afterPlay === "function",
|
|
98
|
-
};
|
|
99
|
-
|
|
100
|
-
this.extensions.set(extension.name, extension);
|
|
101
|
-
this.extensionMetadata.set(extension.name, metadata);
|
|
102
|
-
this.invokeExtensionLifecycle(extension, "onRegister");
|
|
103
|
-
this.debug(`Registered extension: ${extension.name} (priority: ${extension.priority})`);
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
unregister(extension: BaseExtension): boolean {
|
|
107
|
-
const name = extension.name;
|
|
108
|
-
const result = this.extensions.delete(name);
|
|
109
|
-
if (result) {
|
|
110
|
-
this.extensionMetadata.delete(name);
|
|
111
|
-
this.invokeExtensionLifecycle(extension, "onDestroy");
|
|
112
|
-
this.debug(`Unregistered extension: ${name}`);
|
|
113
|
-
}
|
|
114
|
-
return result;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
destroy(): void {
|
|
118
|
-
this.debug(`Destroying all extensions`);
|
|
119
|
-
for (const extension of this.extensions.values()) {
|
|
120
|
-
this.unregister(extension);
|
|
121
|
-
}
|
|
122
|
-
this.extensions.clear();
|
|
123
|
-
this.extensionMetadata.clear();
|
|
124
|
-
this.clearAllCaches();
|
|
125
|
-
this.pendingSearches.clear();
|
|
126
|
-
this.pendingStreams.clear();
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
get(name: string): BaseExtension | undefined {
|
|
130
|
-
return this.extensions.get(name);
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
getAll(): BaseExtension[] {
|
|
134
|
-
// Sort by priority (higher first)
|
|
135
|
-
return Array.from(this.extensions.values()).sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
getMetadata(name: string): ExtensionMetadata | undefined {
|
|
139
|
-
return this.extensionMetadata.get(name);
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
getAllMetadata(): ExtensionMetadata[] {
|
|
143
|
-
return Array.from(this.extensionMetadata.values()).sort((a, b) => b.priority - a.priority);
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
findExtension(query: unknown): BaseExtension | undefined {
|
|
147
|
-
return this.getAll().find((extension) => extension.active?.(query) ?? false);
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
findExtensionsByCapability(capability: "search" | "stream" | "beforePlay" | "afterPlay"): BaseExtension[] {
|
|
151
|
-
const capabilityMap = {
|
|
152
|
-
search: "hasSearch",
|
|
153
|
-
stream: "hasStream",
|
|
154
|
-
beforePlay: "hasBeforePlay",
|
|
155
|
-
afterPlay: "hasAfterPlay",
|
|
156
|
-
};
|
|
157
|
-
|
|
158
|
-
const metaKey = capabilityMap[capability];
|
|
159
|
-
return this.getAll().filter((ext) => {
|
|
160
|
-
const meta = this.extensionMetadata.get(ext.name);
|
|
161
|
-
return meta?.[metaKey as keyof ExtensionMetadata] ?? false;
|
|
162
|
-
});
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
clear(): void {
|
|
166
|
-
this.extensions.clear();
|
|
167
|
-
this.extensionMetadata.clear();
|
|
168
|
-
this.clearAllCaches();
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
private invokeExtensionLifecycle(extension: BaseExtension | undefined, hook: "onRegister" | "onDestroy"): void {
|
|
172
|
-
if (!extension) return;
|
|
173
|
-
const fn = (extension as any)[hook];
|
|
174
|
-
if (typeof fn !== "function") return;
|
|
175
|
-
try {
|
|
176
|
-
const result = fn.call(extension, this.extensionContext);
|
|
177
|
-
if (result && typeof (result as Promise<unknown>).then === "function") {
|
|
178
|
-
(result as Promise<unknown>).catch((err) => this.debug(`Extension ${extension.name} ${hook} error:`, err));
|
|
179
|
-
}
|
|
180
|
-
} catch (err) {
|
|
181
|
-
this.debug(`Extension ${extension.name} ${hook} error:`, err);
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
private getCacheKey(prefix: string, ...parts: string[]): string {
|
|
186
|
-
return `${prefix}:${parts.join(":")}`;
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
private cleanupCaches(): void {
|
|
190
|
-
const now = Date.now();
|
|
191
|
-
|
|
192
|
-
// Clean search cache
|
|
193
|
-
for (const [key, entry] of this.searchCache) {
|
|
194
|
-
if (now >= entry.expiresAt) {
|
|
195
|
-
this.searchCache.delete(key);
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
// Clean stream cache
|
|
200
|
-
for (const [key, entry] of this.streamCache) {
|
|
201
|
-
if (now >= entry.expiresAt) {
|
|
202
|
-
this.streamCache.delete(key);
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
this.debug(`Cache cleanup completed - Search: ${this.searchCache.size}, Stream: ${this.streamCache.size}`);
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
private clearAllCaches(): void {
|
|
210
|
-
this.searchCache.clear();
|
|
211
|
-
this.streamCache.clear();
|
|
212
|
-
this.debug("All caches cleared");
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
private getCachedSearch(query: string): SearchResult | null {
|
|
216
|
-
const key = this.getCacheKey("search", query.toLowerCase().trim());
|
|
217
|
-
const cached = this.searchCache.get(key);
|
|
218
|
-
if (cached && Date.now() < cached.expiresAt) {
|
|
219
|
-
this.debug(`[Cache] Search hit for: ${query}`);
|
|
220
|
-
return cached.data;
|
|
221
|
-
}
|
|
222
|
-
return null;
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
private setCachedSearch(query: string, result: SearchResult): void {
|
|
226
|
-
if (this.searchCache.size >= this.MAX_CACHE_SIZE) {
|
|
227
|
-
// Remove oldest entries (LRU approximation)
|
|
228
|
-
const oldest = Array.from(this.searchCache.entries()).sort((a, b) => a[1].timestamp - b[1].timestamp)[0];
|
|
229
|
-
if (oldest) this.searchCache.delete(oldest[0]);
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
const key = this.getCacheKey("search", query.toLowerCase().trim());
|
|
233
|
-
this.searchCache.set(key, {
|
|
234
|
-
data: result,
|
|
235
|
-
timestamp: Date.now(),
|
|
236
|
-
expiresAt: Date.now() + this.SEARCH_CACHE_TTL,
|
|
237
|
-
});
|
|
238
|
-
this.debug(`[Cache] Search stored for: ${query}`);
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
private getCachedStream(track: Track): StreamInfo | null {
|
|
242
|
-
const key = this.getCacheKey("stream", track.url || track.id || track.title);
|
|
243
|
-
const cached = this.streamCache.get(key);
|
|
244
|
-
if (cached && Date.now() < cached.expiresAt) {
|
|
245
|
-
this.debug(`[Cache] Stream hit for: ${track.title}`);
|
|
246
|
-
return cached.data;
|
|
247
|
-
}
|
|
248
|
-
return null;
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
private setCachedStream(track: Track, stream: StreamInfo): void {
|
|
252
|
-
if (this.streamCache.size >= this.MAX_CACHE_SIZE) {
|
|
253
|
-
const oldest = Array.from(this.streamCache.entries()).sort((a, b) => a[1].timestamp - b[1].timestamp)[0];
|
|
254
|
-
if (oldest) this.streamCache.delete(oldest[0]);
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
const key = this.getCacheKey("stream", track.url || track.id || track.title);
|
|
258
|
-
this.streamCache.set(key, {
|
|
259
|
-
data: stream,
|
|
260
|
-
timestamp: Date.now(),
|
|
261
|
-
expiresAt: Date.now() + this.STREAM_CACHE_TTL,
|
|
262
|
-
});
|
|
263
|
-
this.debug(`[Cache] Stream stored for: ${track.title}`);
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
async provideSearch(query: string, requestedBy: string): Promise<SearchResult | null> {
|
|
267
|
-
if (!query) return null;
|
|
268
|
-
|
|
269
|
-
// Check cache first
|
|
270
|
-
const cached = this.getCachedSearch(query);
|
|
271
|
-
if (cached) return cached;
|
|
272
|
-
|
|
273
|
-
// Deduplicate concurrent requests
|
|
274
|
-
const cacheKey = this.getCacheKey("search", query.toLowerCase().trim());
|
|
275
|
-
if (this.pendingSearches.has(cacheKey)) {
|
|
276
|
-
this.debug(`[Dedupe] Waiting for pending search: ${query}`);
|
|
277
|
-
return this.pendingSearches.get(cacheKey)!;
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
const request: ExtensionSearchRequest = { query, requestedBy };
|
|
281
|
-
const searchPromise = (async () => {
|
|
282
|
-
// Only query extensions that have provideSearch capability
|
|
283
|
-
const searchExtensions = this.findExtensionsByCapability("search");
|
|
284
|
-
|
|
285
|
-
for (const extension of searchExtensions) {
|
|
286
|
-
const hook = (extension as any).provideSearch;
|
|
287
|
-
if (typeof hook !== "function") continue;
|
|
288
|
-
|
|
289
|
-
try {
|
|
290
|
-
const result = await Promise.resolve(hook.call(extension, this.extensionContext, request));
|
|
291
|
-
if (result && Array.isArray(result.tracks) && result.tracks.length > 0) {
|
|
292
|
-
this.debug(`Extension ${extension.name} handled search for: ${query}`);
|
|
293
|
-
this.setCachedSearch(query, result as SearchResult);
|
|
294
|
-
return result as SearchResult;
|
|
295
|
-
}
|
|
296
|
-
} catch (err) {
|
|
297
|
-
this.debug(`Extension ${extension.name} provideSearch error:`, err);
|
|
298
|
-
}
|
|
299
|
-
}
|
|
300
|
-
return null;
|
|
301
|
-
})();
|
|
302
|
-
|
|
303
|
-
this.pendingSearches.set(cacheKey, searchPromise);
|
|
304
|
-
|
|
305
|
-
try {
|
|
306
|
-
return await searchPromise;
|
|
307
|
-
} finally {
|
|
308
|
-
this.pendingSearches.delete(cacheKey);
|
|
309
|
-
}
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
async provideStream(track: Track): Promise<StreamInfo | null> {
|
|
313
|
-
if (!track) return null;
|
|
314
|
-
|
|
315
|
-
// Check cache first
|
|
316
|
-
const cached = this.getCachedStream(track);
|
|
317
|
-
if (cached) return cached;
|
|
318
|
-
|
|
319
|
-
// Deduplicate concurrent requests
|
|
320
|
-
const cacheKey = this.getCacheKey("stream", track.url || track.id || track.title);
|
|
321
|
-
if (this.pendingStreams.has(cacheKey)) {
|
|
322
|
-
this.debug(`[Dedupe] Waiting for pending stream: ${track.title}`);
|
|
323
|
-
return this.pendingStreams.get(cacheKey)!;
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
const request: ExtensionStreamRequest = { track };
|
|
327
|
-
const streamPromise = (async () => {
|
|
328
|
-
// Only query extensions that have provideStream capability
|
|
329
|
-
const streamExtensions = this.findExtensionsByCapability("stream");
|
|
330
|
-
|
|
331
|
-
for (const extension of streamExtensions) {
|
|
332
|
-
const hook = (extension as any).provideStream;
|
|
333
|
-
if (typeof hook !== "function") continue;
|
|
334
|
-
|
|
335
|
-
try {
|
|
336
|
-
const result = await Promise.resolve(hook.call(extension, this.extensionContext, request));
|
|
337
|
-
if (result && (result as StreamInfo).stream) {
|
|
338
|
-
this.debug(`Extension ${extension.name} provided stream for: ${track.title}`);
|
|
339
|
-
this.setCachedStream(track, result as StreamInfo);
|
|
340
|
-
return result as StreamInfo;
|
|
341
|
-
}
|
|
342
|
-
} catch (err) {
|
|
343
|
-
this.debug(`Extension ${extension.name} provideStream error:`, err);
|
|
344
|
-
}
|
|
345
|
-
}
|
|
346
|
-
return null;
|
|
347
|
-
})();
|
|
348
|
-
|
|
349
|
-
this.pendingStreams.set(cacheKey, streamPromise);
|
|
350
|
-
|
|
351
|
-
try {
|
|
352
|
-
return await streamPromise;
|
|
353
|
-
} finally {
|
|
354
|
-
this.pendingStreams.delete(cacheKey);
|
|
355
|
-
}
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
async beforePlayHooks(
|
|
359
|
-
initial: ExtensionPlayRequest,
|
|
360
|
-
): Promise<{ request: ExtensionPlayRequest; response: ExtensionPlayResponse }> {
|
|
361
|
-
const request: ExtensionPlayRequest = { ...initial };
|
|
362
|
-
const response: ExtensionPlayResponse = {};
|
|
363
|
-
|
|
364
|
-
// Only query extensions that have beforePlay capability
|
|
365
|
-
const beforePlayExtensions = this.findExtensionsByCapability("beforePlay");
|
|
366
|
-
|
|
367
|
-
for (const extension of beforePlayExtensions) {
|
|
368
|
-
const hook = (extension as any).beforePlay;
|
|
369
|
-
if (typeof hook !== "function") continue;
|
|
370
|
-
|
|
371
|
-
try {
|
|
372
|
-
const result = await Promise.resolve(hook.call(extension, this.extensionContext, request));
|
|
373
|
-
if (!result) continue;
|
|
374
|
-
|
|
375
|
-
// Merge results
|
|
376
|
-
if (result.query !== undefined) {
|
|
377
|
-
request.query = result.query;
|
|
378
|
-
response.query = result.query;
|
|
379
|
-
}
|
|
380
|
-
if (result.requestedBy !== undefined) {
|
|
381
|
-
request.requestedBy = result.requestedBy;
|
|
382
|
-
response.requestedBy = result.requestedBy;
|
|
383
|
-
}
|
|
384
|
-
if (Array.isArray(result.tracks)) {
|
|
385
|
-
response.tracks = result.tracks;
|
|
386
|
-
}
|
|
387
|
-
if (typeof result.isPlaylist === "boolean") {
|
|
388
|
-
response.isPlaylist = result.isPlaylist;
|
|
389
|
-
}
|
|
390
|
-
if (typeof result.success === "boolean") {
|
|
391
|
-
response.success = result.success;
|
|
392
|
-
}
|
|
393
|
-
if (result.error instanceof Error) {
|
|
394
|
-
response.error = result.error;
|
|
395
|
-
}
|
|
396
|
-
if (typeof result.handled === "boolean") {
|
|
397
|
-
response.handled = result.handled;
|
|
398
|
-
if (result.handled) break;
|
|
399
|
-
}
|
|
400
|
-
} catch (err) {
|
|
401
|
-
this.debug(`Extension ${extension.name} beforePlay error:`, err);
|
|
402
|
-
}
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
return { request, response };
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
async afterPlayHooks(payload: ExtensionAfterPlayPayload): Promise<void> {
|
|
409
|
-
const afterPlayExtensions = this.findExtensionsByCapability("afterPlay");
|
|
410
|
-
if (afterPlayExtensions.length === 0) return;
|
|
411
|
-
|
|
412
|
-
// Create immutable payload
|
|
413
|
-
const safeTracks = payload.tracks ? [...payload.tracks] : undefined;
|
|
414
|
-
if (safeTracks) {
|
|
415
|
-
Object.freeze(safeTracks);
|
|
416
|
-
}
|
|
417
|
-
const immutablePayload = Object.freeze({ ...payload, tracks: safeTracks });
|
|
418
|
-
|
|
419
|
-
// Execute hooks in parallel for better performance
|
|
420
|
-
const hooks = afterPlayExtensions.map(async (extension) => {
|
|
421
|
-
const hook = (extension as any).afterPlay;
|
|
422
|
-
if (typeof hook !== "function") return;
|
|
423
|
-
|
|
424
|
-
try {
|
|
425
|
-
await Promise.resolve(hook.call(extension, this.extensionContext, immutablePayload));
|
|
426
|
-
} catch (err) {
|
|
427
|
-
this.debug(`Extension ${extension.name} afterPlay error:`, err);
|
|
428
|
-
}
|
|
429
|
-
});
|
|
430
|
-
|
|
431
|
-
await Promise.allSettled(hooks);
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
-
/**
|
|
435
|
-
* Get extension statistics
|
|
436
|
-
*/
|
|
437
|
-
getStats(): object {
|
|
438
|
-
const metadata = this.getAllMetadata();
|
|
439
|
-
return {
|
|
440
|
-
totalExtensions: this.extensions.size,
|
|
441
|
-
extensions: metadata.map((m) => ({
|
|
442
|
-
name: m.name,
|
|
443
|
-
priority: m.priority,
|
|
444
|
-
capabilities: {
|
|
445
|
-
search: m.hasSearch,
|
|
446
|
-
stream: m.hasStream,
|
|
447
|
-
beforePlay: m.hasBeforePlay,
|
|
448
|
-
afterPlay: m.hasAfterPlay,
|
|
449
|
-
},
|
|
450
|
-
})),
|
|
451
|
-
cacheStats: {
|
|
452
|
-
searchCacheSize: this.searchCache.size,
|
|
453
|
-
streamCacheSize: this.streamCache.size,
|
|
454
|
-
pendingSearches: this.pendingSearches.size,
|
|
455
|
-
pendingStreams: this.pendingStreams.size,
|
|
456
|
-
},
|
|
457
|
-
};
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
/**
|
|
461
|
-
* Clear specific cache
|
|
462
|
-
*/
|
|
463
|
-
clearCache(type?: "search" | "stream"): void {
|
|
464
|
-
if (!type || type === "search") {
|
|
465
|
-
this.searchCache.clear();
|
|
466
|
-
this.debug("Search cache cleared");
|
|
467
|
-
}
|
|
468
|
-
if (!type || type === "stream") {
|
|
469
|
-
this.streamCache.clear();
|
|
470
|
-
this.debug("Stream cache cleared");
|
|
471
|
-
}
|
|
472
|
-
}
|
|
473
|
-
}
|
|
1
|
+
import type { Player } from "../structures/Player";
|
|
2
|
+
import type { PlayerManager } from "../structures/PlayerManager";
|
|
3
|
+
import type {
|
|
4
|
+
ExtensionSearchRequest,
|
|
5
|
+
SearchResult,
|
|
6
|
+
StreamInfo,
|
|
7
|
+
Track,
|
|
8
|
+
ExtensionContext,
|
|
9
|
+
ExtensionPlayRequest,
|
|
10
|
+
ExtensionPlayResponse,
|
|
11
|
+
ExtensionAfterPlayPayload,
|
|
12
|
+
ExtensionStreamRequest,
|
|
13
|
+
} from "../types";
|
|
14
|
+
|
|
15
|
+
import { BaseExtension } from "./BaseExtension";
|
|
16
|
+
|
|
17
|
+
export { BaseExtension } from "./BaseExtension";
|
|
18
|
+
|
|
19
|
+
interface ExtensionCacheEntry<T> {
|
|
20
|
+
data: T;
|
|
21
|
+
timestamp: number;
|
|
22
|
+
expiresAt: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface ExtensionMetadata {
|
|
26
|
+
name: string;
|
|
27
|
+
priority: number;
|
|
28
|
+
registeredAt: number;
|
|
29
|
+
hasSearch: boolean;
|
|
30
|
+
hasStream: boolean;
|
|
31
|
+
hasBeforePlay: boolean;
|
|
32
|
+
hasAfterPlay: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export class ExtensionManager {
|
|
36
|
+
private extensions: Map<string, BaseExtension>;
|
|
37
|
+
private extensionMetadata: Map<string, ExtensionMetadata>;
|
|
38
|
+
private player: Player;
|
|
39
|
+
private manager: PlayerManager;
|
|
40
|
+
private extensionContext: ExtensionContext;
|
|
41
|
+
|
|
42
|
+
// Caches for different operations
|
|
43
|
+
private searchCache: Map<string, ExtensionCacheEntry<SearchResult>>;
|
|
44
|
+
private streamCache: Map<string, ExtensionCacheEntry<StreamInfo>>;
|
|
45
|
+
|
|
46
|
+
// Cache TTLs
|
|
47
|
+
private readonly SEARCH_CACHE_TTL = 60 * 1000; // 1 minute
|
|
48
|
+
private readonly STREAM_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
|
49
|
+
private readonly MAX_CACHE_SIZE = 100;
|
|
50
|
+
|
|
51
|
+
// Pending requests for deduplication
|
|
52
|
+
private pendingSearches: Map<string, Promise<SearchResult | null>>;
|
|
53
|
+
private pendingStreams: Map<string, Promise<StreamInfo | null>>;
|
|
54
|
+
|
|
55
|
+
constructor(player: Player, manager: PlayerManager) {
|
|
56
|
+
this.player = player;
|
|
57
|
+
this.manager = manager;
|
|
58
|
+
this.extensions = new Map();
|
|
59
|
+
this.extensionMetadata = new Map();
|
|
60
|
+
this.searchCache = new Map();
|
|
61
|
+
this.streamCache = new Map();
|
|
62
|
+
this.pendingSearches = new Map();
|
|
63
|
+
this.pendingStreams = new Map();
|
|
64
|
+
this.extensionContext = Object.freeze({ player, manager });
|
|
65
|
+
|
|
66
|
+
// Auto-cleanup caches periodically
|
|
67
|
+
setInterval(() => this.cleanupCaches(), 5 * 60 * 1000);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
debug(message?: any, ...optionalParams: any[]): void {
|
|
71
|
+
if (this.manager.debugEnabled) {
|
|
72
|
+
this.manager.emit("debug", `[ExtensionManager] ${message}`, ...optionalParams);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
register(extension: BaseExtension): void {
|
|
77
|
+
if (this.extensions.has(extension.name)) {
|
|
78
|
+
this.debug(`Extension ${extension.name} already registered, skipping`);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (!extension.player) {
|
|
83
|
+
extension.player = this.player;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Set default priority if not set
|
|
87
|
+
extension.priority ??= 0;
|
|
88
|
+
|
|
89
|
+
// Store metadata for optimization
|
|
90
|
+
const metadata: ExtensionMetadata = {
|
|
91
|
+
name: extension.name,
|
|
92
|
+
priority: extension.priority,
|
|
93
|
+
registeredAt: Date.now(),
|
|
94
|
+
hasSearch: typeof (extension as any).provideSearch === "function",
|
|
95
|
+
hasStream: typeof (extension as any).provideStream === "function",
|
|
96
|
+
hasBeforePlay: typeof (extension as any).beforePlay === "function",
|
|
97
|
+
hasAfterPlay: typeof (extension as any).afterPlay === "function",
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
this.extensions.set(extension.name, extension);
|
|
101
|
+
this.extensionMetadata.set(extension.name, metadata);
|
|
102
|
+
this.invokeExtensionLifecycle(extension, "onRegister");
|
|
103
|
+
this.debug(`Registered extension: ${extension.name} (priority: ${extension.priority})`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
unregister(extension: BaseExtension): boolean {
|
|
107
|
+
const name = extension.name;
|
|
108
|
+
const result = this.extensions.delete(name);
|
|
109
|
+
if (result) {
|
|
110
|
+
this.extensionMetadata.delete(name);
|
|
111
|
+
this.invokeExtensionLifecycle(extension, "onDestroy");
|
|
112
|
+
this.debug(`Unregistered extension: ${name}`);
|
|
113
|
+
}
|
|
114
|
+
return result;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
destroy(): void {
|
|
118
|
+
this.debug(`Destroying all extensions`);
|
|
119
|
+
for (const extension of this.extensions.values()) {
|
|
120
|
+
this.unregister(extension);
|
|
121
|
+
}
|
|
122
|
+
this.extensions.clear();
|
|
123
|
+
this.extensionMetadata.clear();
|
|
124
|
+
this.clearAllCaches();
|
|
125
|
+
this.pendingSearches.clear();
|
|
126
|
+
this.pendingStreams.clear();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
get(name: string): BaseExtension | undefined {
|
|
130
|
+
return this.extensions.get(name);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
getAll(): BaseExtension[] {
|
|
134
|
+
// Sort by priority (higher first)
|
|
135
|
+
return Array.from(this.extensions.values()).sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
getMetadata(name: string): ExtensionMetadata | undefined {
|
|
139
|
+
return this.extensionMetadata.get(name);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
getAllMetadata(): ExtensionMetadata[] {
|
|
143
|
+
return Array.from(this.extensionMetadata.values()).sort((a, b) => b.priority - a.priority);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
findExtension(query: unknown): BaseExtension | undefined {
|
|
147
|
+
return this.getAll().find((extension) => extension.active?.(query) ?? false);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
findExtensionsByCapability(capability: "search" | "stream" | "beforePlay" | "afterPlay"): BaseExtension[] {
|
|
151
|
+
const capabilityMap = {
|
|
152
|
+
search: "hasSearch",
|
|
153
|
+
stream: "hasStream",
|
|
154
|
+
beforePlay: "hasBeforePlay",
|
|
155
|
+
afterPlay: "hasAfterPlay",
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const metaKey = capabilityMap[capability];
|
|
159
|
+
return this.getAll().filter((ext) => {
|
|
160
|
+
const meta = this.extensionMetadata.get(ext.name);
|
|
161
|
+
return meta?.[metaKey as keyof ExtensionMetadata] ?? false;
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
clear(): void {
|
|
166
|
+
this.extensions.clear();
|
|
167
|
+
this.extensionMetadata.clear();
|
|
168
|
+
this.clearAllCaches();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
private invokeExtensionLifecycle(extension: BaseExtension | undefined, hook: "onRegister" | "onDestroy"): void {
|
|
172
|
+
if (!extension) return;
|
|
173
|
+
const fn = (extension as any)[hook];
|
|
174
|
+
if (typeof fn !== "function") return;
|
|
175
|
+
try {
|
|
176
|
+
const result = fn.call(extension, this.extensionContext);
|
|
177
|
+
if (result && typeof (result as Promise<unknown>).then === "function") {
|
|
178
|
+
(result as Promise<unknown>).catch((err) => this.debug(`Extension ${extension.name} ${hook} error:`, err));
|
|
179
|
+
}
|
|
180
|
+
} catch (err) {
|
|
181
|
+
this.debug(`Extension ${extension.name} ${hook} error:`, err);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
private getCacheKey(prefix: string, ...parts: string[]): string {
|
|
186
|
+
return `${prefix}:${parts.join(":")}`;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
private cleanupCaches(): void {
|
|
190
|
+
const now = Date.now();
|
|
191
|
+
|
|
192
|
+
// Clean search cache
|
|
193
|
+
for (const [key, entry] of this.searchCache) {
|
|
194
|
+
if (now >= entry.expiresAt) {
|
|
195
|
+
this.searchCache.delete(key);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Clean stream cache
|
|
200
|
+
for (const [key, entry] of this.streamCache) {
|
|
201
|
+
if (now >= entry.expiresAt) {
|
|
202
|
+
this.streamCache.delete(key);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
this.debug(`Cache cleanup completed - Search: ${this.searchCache.size}, Stream: ${this.streamCache.size}`);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
private clearAllCaches(): void {
|
|
210
|
+
this.searchCache.clear();
|
|
211
|
+
this.streamCache.clear();
|
|
212
|
+
this.debug("All caches cleared");
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
private getCachedSearch(query: string): SearchResult | null {
|
|
216
|
+
const key = this.getCacheKey("search", query.toLowerCase().trim());
|
|
217
|
+
const cached = this.searchCache.get(key);
|
|
218
|
+
if (cached && Date.now() < cached.expiresAt) {
|
|
219
|
+
this.debug(`[Cache] Search hit for: ${query}`);
|
|
220
|
+
return cached.data;
|
|
221
|
+
}
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
private setCachedSearch(query: string, result: SearchResult): void {
|
|
226
|
+
if (this.searchCache.size >= this.MAX_CACHE_SIZE) {
|
|
227
|
+
// Remove oldest entries (LRU approximation)
|
|
228
|
+
const oldest = Array.from(this.searchCache.entries()).sort((a, b) => a[1].timestamp - b[1].timestamp)[0];
|
|
229
|
+
if (oldest) this.searchCache.delete(oldest[0]);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const key = this.getCacheKey("search", query.toLowerCase().trim());
|
|
233
|
+
this.searchCache.set(key, {
|
|
234
|
+
data: result,
|
|
235
|
+
timestamp: Date.now(),
|
|
236
|
+
expiresAt: Date.now() + this.SEARCH_CACHE_TTL,
|
|
237
|
+
});
|
|
238
|
+
this.debug(`[Cache] Search stored for: ${query}`);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
private getCachedStream(track: Track): StreamInfo | null {
|
|
242
|
+
const key = this.getCacheKey("stream", track.url || track.id || track.title);
|
|
243
|
+
const cached = this.streamCache.get(key);
|
|
244
|
+
if (cached && Date.now() < cached.expiresAt) {
|
|
245
|
+
this.debug(`[Cache] Stream hit for: ${track.title}`);
|
|
246
|
+
return cached.data;
|
|
247
|
+
}
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
private setCachedStream(track: Track, stream: StreamInfo): void {
|
|
252
|
+
if (this.streamCache.size >= this.MAX_CACHE_SIZE) {
|
|
253
|
+
const oldest = Array.from(this.streamCache.entries()).sort((a, b) => a[1].timestamp - b[1].timestamp)[0];
|
|
254
|
+
if (oldest) this.streamCache.delete(oldest[0]);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const key = this.getCacheKey("stream", track.url || track.id || track.title);
|
|
258
|
+
this.streamCache.set(key, {
|
|
259
|
+
data: stream,
|
|
260
|
+
timestamp: Date.now(),
|
|
261
|
+
expiresAt: Date.now() + this.STREAM_CACHE_TTL,
|
|
262
|
+
});
|
|
263
|
+
this.debug(`[Cache] Stream stored for: ${track.title}`);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async provideSearch(query: string, requestedBy: string): Promise<SearchResult | null> {
|
|
267
|
+
if (!query) return null;
|
|
268
|
+
|
|
269
|
+
// Check cache first
|
|
270
|
+
const cached = this.getCachedSearch(query);
|
|
271
|
+
if (cached) return cached;
|
|
272
|
+
|
|
273
|
+
// Deduplicate concurrent requests
|
|
274
|
+
const cacheKey = this.getCacheKey("search", query.toLowerCase().trim());
|
|
275
|
+
if (this.pendingSearches.has(cacheKey)) {
|
|
276
|
+
this.debug(`[Dedupe] Waiting for pending search: ${query}`);
|
|
277
|
+
return this.pendingSearches.get(cacheKey)!;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const request: ExtensionSearchRequest = { query, requestedBy };
|
|
281
|
+
const searchPromise = (async () => {
|
|
282
|
+
// Only query extensions that have provideSearch capability
|
|
283
|
+
const searchExtensions = this.findExtensionsByCapability("search");
|
|
284
|
+
|
|
285
|
+
for (const extension of searchExtensions) {
|
|
286
|
+
const hook = (extension as any).provideSearch;
|
|
287
|
+
if (typeof hook !== "function") continue;
|
|
288
|
+
|
|
289
|
+
try {
|
|
290
|
+
const result = await Promise.resolve(hook.call(extension, this.extensionContext, request));
|
|
291
|
+
if (result && Array.isArray(result.tracks) && result.tracks.length > 0) {
|
|
292
|
+
this.debug(`Extension ${extension.name} handled search for: ${query}`);
|
|
293
|
+
this.setCachedSearch(query, result as SearchResult);
|
|
294
|
+
return result as SearchResult;
|
|
295
|
+
}
|
|
296
|
+
} catch (err) {
|
|
297
|
+
this.debug(`Extension ${extension.name} provideSearch error:`, err);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
return null;
|
|
301
|
+
})();
|
|
302
|
+
|
|
303
|
+
this.pendingSearches.set(cacheKey, searchPromise);
|
|
304
|
+
|
|
305
|
+
try {
|
|
306
|
+
return await searchPromise;
|
|
307
|
+
} finally {
|
|
308
|
+
this.pendingSearches.delete(cacheKey);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
async provideStream(track: Track): Promise<StreamInfo | null> {
|
|
313
|
+
if (!track) return null;
|
|
314
|
+
|
|
315
|
+
// Check cache first
|
|
316
|
+
const cached = this.getCachedStream(track);
|
|
317
|
+
if (cached) return cached;
|
|
318
|
+
|
|
319
|
+
// Deduplicate concurrent requests
|
|
320
|
+
const cacheKey = this.getCacheKey("stream", track.url || track.id || track.title);
|
|
321
|
+
if (this.pendingStreams.has(cacheKey)) {
|
|
322
|
+
this.debug(`[Dedupe] Waiting for pending stream: ${track.title}`);
|
|
323
|
+
return this.pendingStreams.get(cacheKey)!;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const request: ExtensionStreamRequest = { track };
|
|
327
|
+
const streamPromise = (async () => {
|
|
328
|
+
// Only query extensions that have provideStream capability
|
|
329
|
+
const streamExtensions = this.findExtensionsByCapability("stream");
|
|
330
|
+
|
|
331
|
+
for (const extension of streamExtensions) {
|
|
332
|
+
const hook = (extension as any).provideStream;
|
|
333
|
+
if (typeof hook !== "function") continue;
|
|
334
|
+
|
|
335
|
+
try {
|
|
336
|
+
const result = await Promise.resolve(hook.call(extension, this.extensionContext, request));
|
|
337
|
+
if (result && (result as StreamInfo).stream) {
|
|
338
|
+
this.debug(`Extension ${extension.name} provided stream for: ${track.title}`);
|
|
339
|
+
this.setCachedStream(track, result as StreamInfo);
|
|
340
|
+
return result as StreamInfo;
|
|
341
|
+
}
|
|
342
|
+
} catch (err) {
|
|
343
|
+
this.debug(`Extension ${extension.name} provideStream error:`, err);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
return null;
|
|
347
|
+
})();
|
|
348
|
+
|
|
349
|
+
this.pendingStreams.set(cacheKey, streamPromise);
|
|
350
|
+
|
|
351
|
+
try {
|
|
352
|
+
return await streamPromise;
|
|
353
|
+
} finally {
|
|
354
|
+
this.pendingStreams.delete(cacheKey);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
async beforePlayHooks(
|
|
359
|
+
initial: ExtensionPlayRequest,
|
|
360
|
+
): Promise<{ request: ExtensionPlayRequest; response: ExtensionPlayResponse }> {
|
|
361
|
+
const request: ExtensionPlayRequest = { ...initial };
|
|
362
|
+
const response: ExtensionPlayResponse = {};
|
|
363
|
+
|
|
364
|
+
// Only query extensions that have beforePlay capability
|
|
365
|
+
const beforePlayExtensions = this.findExtensionsByCapability("beforePlay");
|
|
366
|
+
|
|
367
|
+
for (const extension of beforePlayExtensions) {
|
|
368
|
+
const hook = (extension as any).beforePlay;
|
|
369
|
+
if (typeof hook !== "function") continue;
|
|
370
|
+
|
|
371
|
+
try {
|
|
372
|
+
const result = await Promise.resolve(hook.call(extension, this.extensionContext, request));
|
|
373
|
+
if (!result) continue;
|
|
374
|
+
|
|
375
|
+
// Merge results
|
|
376
|
+
if (result.query !== undefined) {
|
|
377
|
+
request.query = result.query;
|
|
378
|
+
response.query = result.query;
|
|
379
|
+
}
|
|
380
|
+
if (result.requestedBy !== undefined) {
|
|
381
|
+
request.requestedBy = result.requestedBy;
|
|
382
|
+
response.requestedBy = result.requestedBy;
|
|
383
|
+
}
|
|
384
|
+
if (Array.isArray(result.tracks)) {
|
|
385
|
+
response.tracks = result.tracks;
|
|
386
|
+
}
|
|
387
|
+
if (typeof result.isPlaylist === "boolean") {
|
|
388
|
+
response.isPlaylist = result.isPlaylist;
|
|
389
|
+
}
|
|
390
|
+
if (typeof result.success === "boolean") {
|
|
391
|
+
response.success = result.success;
|
|
392
|
+
}
|
|
393
|
+
if (result.error instanceof Error) {
|
|
394
|
+
response.error = result.error;
|
|
395
|
+
}
|
|
396
|
+
if (typeof result.handled === "boolean") {
|
|
397
|
+
response.handled = result.handled;
|
|
398
|
+
if (result.handled) break;
|
|
399
|
+
}
|
|
400
|
+
} catch (err) {
|
|
401
|
+
this.debug(`Extension ${extension.name} beforePlay error:`, err);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
return { request, response };
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
async afterPlayHooks(payload: ExtensionAfterPlayPayload): Promise<void> {
|
|
409
|
+
const afterPlayExtensions = this.findExtensionsByCapability("afterPlay");
|
|
410
|
+
if (afterPlayExtensions.length === 0) return;
|
|
411
|
+
|
|
412
|
+
// Create immutable payload
|
|
413
|
+
const safeTracks = payload.tracks ? [...payload.tracks] : undefined;
|
|
414
|
+
if (safeTracks) {
|
|
415
|
+
Object.freeze(safeTracks);
|
|
416
|
+
}
|
|
417
|
+
const immutablePayload = Object.freeze({ ...payload, tracks: safeTracks });
|
|
418
|
+
|
|
419
|
+
// Execute hooks in parallel for better performance
|
|
420
|
+
const hooks = afterPlayExtensions.map(async (extension) => {
|
|
421
|
+
const hook = (extension as any).afterPlay;
|
|
422
|
+
if (typeof hook !== "function") return;
|
|
423
|
+
|
|
424
|
+
try {
|
|
425
|
+
await Promise.resolve(hook.call(extension, this.extensionContext, immutablePayload));
|
|
426
|
+
} catch (err) {
|
|
427
|
+
this.debug(`Extension ${extension.name} afterPlay error:`, err);
|
|
428
|
+
}
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
await Promise.allSettled(hooks);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Get extension statistics
|
|
436
|
+
*/
|
|
437
|
+
getStats(): object {
|
|
438
|
+
const metadata = this.getAllMetadata();
|
|
439
|
+
return {
|
|
440
|
+
totalExtensions: this.extensions.size,
|
|
441
|
+
extensions: metadata.map((m) => ({
|
|
442
|
+
name: m.name,
|
|
443
|
+
priority: m.priority,
|
|
444
|
+
capabilities: {
|
|
445
|
+
search: m.hasSearch,
|
|
446
|
+
stream: m.hasStream,
|
|
447
|
+
beforePlay: m.hasBeforePlay,
|
|
448
|
+
afterPlay: m.hasAfterPlay,
|
|
449
|
+
},
|
|
450
|
+
})),
|
|
451
|
+
cacheStats: {
|
|
452
|
+
searchCacheSize: this.searchCache.size,
|
|
453
|
+
streamCacheSize: this.streamCache.size,
|
|
454
|
+
pendingSearches: this.pendingSearches.size,
|
|
455
|
+
pendingStreams: this.pendingStreams.size,
|
|
456
|
+
},
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Clear specific cache
|
|
462
|
+
*/
|
|
463
|
+
clearCache(type?: "search" | "stream"): void {
|
|
464
|
+
if (!type || type === "search") {
|
|
465
|
+
this.searchCache.clear();
|
|
466
|
+
this.debug("Search cache cleared");
|
|
467
|
+
}
|
|
468
|
+
if (!type || type === "stream") {
|
|
469
|
+
this.streamCache.clear();
|
|
470
|
+
this.debug("Stream cache cleared");
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
}
|