ziplayer 0.2.6 → 0.2.7-dev.1

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 (57) hide show
  1. package/AI-Guide.md +607 -0
  2. package/README.md +513 -196
  3. package/dist/extensions/BaseExtension.d.ts +1 -0
  4. package/dist/extensions/BaseExtension.d.ts.map +1 -1
  5. package/dist/extensions/BaseExtension.js.map +1 -1
  6. package/dist/extensions/index.d.ts +38 -3
  7. package/dist/extensions/index.d.ts.map +1 -1
  8. package/dist/extensions/index.js +259 -41
  9. package/dist/extensions/index.js.map +1 -1
  10. package/dist/persistence/PersistenceManager.d.ts +61 -0
  11. package/dist/persistence/PersistenceManager.d.ts.map +1 -0
  12. package/dist/persistence/PersistenceManager.js +551 -0
  13. package/dist/persistence/PersistenceManager.js.map +1 -0
  14. package/dist/plugins/BasePlugin.js +1 -1
  15. package/dist/plugins/BasePlugin.js.map +1 -1
  16. package/dist/plugins/index.d.ts +19 -4
  17. package/dist/plugins/index.d.ts.map +1 -1
  18. package/dist/plugins/index.js +273 -146
  19. package/dist/plugins/index.js.map +1 -1
  20. package/dist/structures/FilterManager.js +3 -3
  21. package/dist/structures/FilterManager.js.map +1 -1
  22. package/dist/structures/Player.d.ts +64 -14
  23. package/dist/structures/Player.d.ts.map +1 -1
  24. package/dist/structures/Player.js +344 -91
  25. package/dist/structures/Player.js.map +1 -1
  26. package/dist/structures/PlayerManager.d.ts +125 -91
  27. package/dist/structures/PlayerManager.d.ts.map +1 -1
  28. package/dist/structures/PlayerManager.js +406 -111
  29. package/dist/structures/PlayerManager.js.map +1 -1
  30. package/dist/structures/Queue.d.ts +136 -31
  31. package/dist/structures/Queue.d.ts.map +1 -1
  32. package/dist/structures/Queue.js +265 -46
  33. package/dist/structures/Queue.js.map +1 -1
  34. package/dist/types/index.d.ts +39 -6
  35. package/dist/types/index.d.ts.map +1 -1
  36. package/dist/types/index.js +1 -0
  37. package/dist/types/index.js.map +1 -1
  38. package/dist/types/persistence.d.ts +55 -0
  39. package/dist/types/persistence.d.ts.map +1 -0
  40. package/dist/types/persistence.js +3 -0
  41. package/dist/types/persistence.js.map +1 -0
  42. package/package.json +47 -46
  43. package/src/extensions/BaseExtension.ts +36 -35
  44. package/src/extensions/index.ts +473 -190
  45. package/src/index.ts +16 -16
  46. package/src/persistence/PersistenceManager.ts +572 -0
  47. package/src/plugins/BasePlugin.ts +27 -27
  48. package/src/plugins/index.ts +403 -236
  49. package/src/structures/FilterManager.ts +303 -303
  50. package/src/structures/Player.ts +1962 -1689
  51. package/src/structures/PlayerManager.ts +788 -416
  52. package/src/structures/Queue.ts +599 -354
  53. package/src/types/index.ts +406 -373
  54. package/src/types/persistence.ts +65 -0
  55. package/src/types/plugin.ts +1 -1
  56. package/src/utils/timeout.ts +10 -10
  57. package/tsconfig.json +22 -23
@@ -1,190 +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
- // Extension factory
20
- export class ExtensionManager {
21
- private extensions: Map<string, BaseExtension>;
22
- private player: Player;
23
- private manager: PlayerManager;
24
- private extensionContext: ExtensionContext;
25
-
26
- constructor(player: Player, manager: PlayerManager) {
27
- this.player = player;
28
- this.manager = manager;
29
- this.extensions = new Map();
30
- this.extensionContext = Object.freeze({ player, manager });
31
- }
32
- debug(message?: any, ...optionalParams: any[]): void {
33
- if (this.manager.debugEnabled) {
34
- this.manager.emit("debug", `[ExtensionManager] ${message}`, ...optionalParams);
35
- }
36
- }
37
-
38
- register(extension: BaseExtension): void {
39
- if (this.extensions.has(extension.name)) {
40
- return;
41
- }
42
- if (!extension.player) {
43
- extension.player = this.player;
44
- }
45
- this.extensions.set(extension.name, extension);
46
- }
47
-
48
- unregister(extension: BaseExtension): boolean {
49
- const name = extension.name;
50
- const result = this.extensions.delete(name);
51
- if (result) {
52
- this.invokeExtensionLifecycle(extension, "onDestroy");
53
- }
54
- return result;
55
- }
56
-
57
- destroy(): void {
58
- this.debug(`[ExtensionManager] destroying all extensions`);
59
- for (const extension of this.extensions.values()) {
60
- this.unregister(extension);
61
- }
62
- this.extensions.clear();
63
- }
64
-
65
- get(name: string): BaseExtension | undefined {
66
- return this.extensions.get(name);
67
- }
68
-
69
- getAll(): BaseExtension[] {
70
- return Array.from(this.extensions.values());
71
- }
72
-
73
- findExtension(alas: any): BaseExtension | undefined {
74
- return this.getAll().find((extension) => extension.active(alas));
75
- }
76
-
77
- clear(): void {
78
- this.extensions.clear();
79
- }
80
-
81
- private invokeExtensionLifecycle(extension: BaseExtension | undefined, hook: "onRegister" | "onDestroy"): void {
82
- if (!extension) return;
83
- const fn = (extension as any)[hook];
84
- if (typeof fn !== "function") return;
85
- try {
86
- const result = fn.call(extension, this.extensionContext);
87
- if (result && typeof (result as Promise<unknown>).then === "function") {
88
- (result as Promise<unknown>).catch((err) => this.debug(`[Player] Extension ${extension.name} ${hook} error:`, err));
89
- }
90
- } catch (err) {
91
- this.debug(`[Player] Extension ${extension.name} ${hook} error:`, err);
92
- }
93
- }
94
-
95
- async provideSearch(query: string, requestedBy: string): Promise<SearchResult | null> {
96
- const request: ExtensionSearchRequest = { query, requestedBy };
97
- for (const extension of this.getAll()) {
98
- const hook = (extension as any).provideSearch;
99
- if (typeof hook !== "function") continue;
100
- try {
101
- const result = await Promise.resolve(hook.call(extension, this.extensionContext, request));
102
- if (result && Array.isArray(result.tracks) && result.tracks.length > 0) {
103
- this.debug(`[Player] Extension ${extension.name} handled search for query: ${query}`);
104
- return result as SearchResult;
105
- }
106
- } catch (err) {
107
- this.debug(`[Player] Extension ${extension.name} provideSearch error:`, err);
108
- }
109
- }
110
- return null;
111
- }
112
-
113
- async provideStream(track: Track): Promise<StreamInfo | null> {
114
- const request: ExtensionStreamRequest = { track };
115
- for (const extension of this.getAll()) {
116
- const hook = (extension as any).provideStream;
117
- if (typeof hook !== "function") continue;
118
- try {
119
- const result = await Promise.resolve(hook.call(extension, this.extensionContext, request));
120
- if (result && (result as StreamInfo).stream) {
121
- this.debug(`[Player] Extension ${extension.name} provided stream for track: ${track.title}`);
122
- return result as StreamInfo;
123
- }
124
- } catch (err) {
125
- this.debug(`[Player] Extension ${extension.name} provideStream error:`, err);
126
- }
127
- }
128
- return null;
129
- }
130
-
131
- async BeforePlayHooks(
132
- initial: ExtensionPlayRequest,
133
- ): Promise<{ request: ExtensionPlayRequest; response: ExtensionPlayResponse }> {
134
- const request: ExtensionPlayRequest = { ...initial };
135
- const response: ExtensionPlayResponse = {};
136
- for (const extension of this.getAll()) {
137
- const hook = (extension as any).beforePlay;
138
- if (typeof hook !== "function") continue;
139
- try {
140
- const result = await Promise.resolve(hook.call(extension, this.extensionContext, request));
141
- if (!result) continue;
142
- if (result.query !== undefined) {
143
- request.query = result.query;
144
- response.query = result.query;
145
- }
146
- if (result.requestedBy !== undefined) {
147
- request.requestedBy = result.requestedBy;
148
- response.requestedBy = result.requestedBy;
149
- }
150
- if (Array.isArray(result.tracks)) {
151
- response.tracks = result.tracks;
152
- }
153
- if (typeof result.isPlaylist === "boolean") {
154
- response.isPlaylist = result.isPlaylist;
155
- }
156
- if (typeof result.success === "boolean") {
157
- response.success = result.success;
158
- }
159
- if (result.error instanceof Error) {
160
- response.error = result.error;
161
- }
162
- if (typeof result.handled === "boolean") {
163
- response.handled = result.handled;
164
- if (result.handled) break;
165
- }
166
- } catch (err) {
167
- this.debug(`[Player] Extension ${extension.name} beforePlay error:`, err);
168
- }
169
- }
170
- return { request, response };
171
- }
172
-
173
- async AfterPlayHooks(payload: ExtensionAfterPlayPayload): Promise<void> {
174
- if (this.getAll().length === 0) return;
175
- const safeTracks = payload.tracks ? [...payload.tracks] : undefined;
176
- if (safeTracks) {
177
- Object.freeze(safeTracks);
178
- }
179
- const immutablePayload = Object.freeze({ ...payload, tracks: safeTracks });
180
- for (const extension of this.getAll()) {
181
- const hook = (extension as any).afterPlay;
182
- if (typeof hook !== "function") continue;
183
- try {
184
- await Promise.resolve(hook.call(extension, this.extensionContext, immutablePayload));
185
- } catch (err) {
186
- this.debug(`[Player] Extension ${extension.name} afterPlay error:`, err);
187
- }
188
- }
189
- }
190
- }
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
+ }