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.
Files changed (47) hide show
  1. package/AI-Guide.md +624 -607
  2. package/README.md +526 -524
  3. package/dist/plugins/index.d.ts +62 -12
  4. package/dist/plugins/index.d.ts.map +1 -1
  5. package/dist/plugins/index.js +497 -57
  6. package/dist/plugins/index.js.map +1 -1
  7. package/dist/structures/PersistenceManager.d.ts +96 -0
  8. package/dist/structures/PersistenceManager.d.ts.map +1 -0
  9. package/dist/structures/PersistenceManager.js +1008 -0
  10. package/dist/structures/PersistenceManager.js.map +1 -0
  11. package/dist/structures/Player.d.ts +109 -18
  12. package/dist/structures/Player.d.ts.map +1 -1
  13. package/dist/structures/Player.js +902 -182
  14. package/dist/structures/Player.js.map +1 -1
  15. package/dist/structures/PlayerManager.d.ts +1 -22
  16. package/dist/structures/PlayerManager.d.ts.map +1 -1
  17. package/dist/structures/PlayerManager.js +1 -73
  18. package/dist/structures/PlayerManager.js.map +1 -1
  19. package/dist/structures/StreamManager.d.ts +137 -0
  20. package/dist/structures/StreamManager.d.ts.map +1 -0
  21. package/dist/structures/StreamManager.js +420 -0
  22. package/dist/structures/StreamManager.js.map +1 -0
  23. package/dist/types/index.d.ts +149 -16
  24. package/dist/types/index.d.ts.map +1 -1
  25. package/dist/types/index.js +0 -1
  26. package/dist/types/index.js.map +1 -1
  27. package/dist/types/persistence.d.ts +3 -2
  28. package/dist/types/persistence.d.ts.map +1 -1
  29. package/package.json +47 -47
  30. package/src/extensions/BaseExtension.ts +36 -36
  31. package/src/extensions/index.ts +473 -473
  32. package/src/index.ts +16 -16
  33. package/src/plugins/BasePlugin.ts +27 -27
  34. package/src/plugins/index.ts +950 -403
  35. package/src/structures/FilterManager.ts +303 -303
  36. package/src/structures/Player.ts +2797 -1970
  37. package/src/structures/PlayerManager.ts +725 -822
  38. package/src/structures/Queue.ts +599 -599
  39. package/src/structures/StreamManager.ts +524 -0
  40. package/src/types/extension.ts +129 -129
  41. package/src/types/fillter.ts +264 -264
  42. package/src/types/index.ts +548 -415
  43. package/src/types/plugin.ts +59 -59
  44. package/src/utils/timeout.ts +10 -10
  45. package/tsconfig.json +22 -22
  46. package/src/persistence/PersistenceManager.ts +0 -1077
  47. package/src/types/persistence.ts +0 -85
@@ -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
+ }