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,236 +1,403 @@
1
- import { BasePlugin } from "./BasePlugin";
2
- import { withTimeout } from "../utils/timeout";
3
- import type { Track, StreamInfo } from "../types";
4
- import type { PlayerManager } from "../structures/PlayerManager";
5
- import type { Player } from "../structures/Player";
6
-
7
- type PluginManagerOptions = {
8
- extractorTimeout: number | undefined;
9
- };
10
-
11
- export { BasePlugin } from "./BasePlugin";
12
-
13
- // Plugin factory
14
- export class PluginManager {
15
- private options: PluginManagerOptions;
16
- private player: Player;
17
- private manager: PlayerManager;
18
- private plugins: Map<string, BasePlugin> = new Map();
19
-
20
- constructor(player: Player, manager: PlayerManager, options: PluginManagerOptions) {
21
- this.player = player;
22
- this.manager = manager;
23
- this.options = options;
24
- }
25
-
26
- debug(message?: any, ...optionalParams: any[]): void {
27
- if (this.manager.debugEnabled) {
28
- this.manager.emit("debug", `[Plugins] ${message}`, ...optionalParams);
29
- }
30
- }
31
-
32
- register(plugin: BasePlugin): void {
33
- this.plugins.set(plugin.name, plugin);
34
- }
35
-
36
- unregister(name: string): boolean {
37
- return this.plugins.delete(name);
38
- }
39
-
40
- get(name: string): BasePlugin | undefined {
41
- return this.plugins.get(name);
42
- }
43
-
44
- getAll(): BasePlugin[] {
45
- return Array.from(this.plugins.values());
46
- }
47
-
48
- findPlugin(query: string): BasePlugin | undefined {
49
- return this.getAll().find((plugin) => plugin.canHandle(query));
50
- }
51
-
52
- clear(): void {
53
- this.plugins.clear();
54
- }
55
-
56
- async getStream(track: Track): Promise<StreamInfo | null> {
57
- const timeoutMs = this.options.extractorTimeout ?? 50000;
58
- const primary = this.get(track.source) || this.findPlugin(track.url);
59
- if (!primary) {
60
- this.debug(`No plugin found for track: ${track.title}`);
61
- return null;
62
- }
63
- try {
64
- const controller = new AbortController();
65
- const result = await withTimeout(primary.getStream(track, controller.signal), timeoutMs, "Primary timeout");
66
- if (result?.stream) return result;
67
- throw new Error("Primary failed");
68
- } catch {
69
- this.debug("Primary failed fallback parallel");
70
- }
71
-
72
- // ===== FALLBACK PARALLEL =====
73
- const plugins = this.getAll()
74
- .filter((p) => p !== primary)
75
- .map((p) => {
76
- p.priority ??= 0;
77
- return p;
78
- })
79
- .sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0));
80
-
81
- // group by priority
82
- const groups = new Map<number, BasePlugin[]>();
83
- for (const p of plugins) {
84
- if (!groups.has(p.priority ?? 0)) groups.set(p.priority ?? 0, []);
85
- groups.get(p.priority ?? 0)!.push(p);
86
- }
87
- for (const [priority, group] of groups) {
88
- this.debug(`Running group priority=${priority}`);
89
- const controller = new AbortController();
90
- try {
91
- const promises = group.map((p) => {
92
- const run = async () => {
93
- try {
94
- let result: StreamInfo | null = null;
95
-
96
- if (p.getStream) {
97
- try {
98
- result = await withTimeout(p.getStream(track, controller.signal), timeoutMs, `Timeout ${p.name}`);
99
- } catch (err) {
100
- // getStream thất bại → log rồi thử getFallback
101
- this.debug(`getStream failed for ${p.name}, trying getFallback`, err);
102
- }
103
-
104
- if (result?.stream) {
105
- this.debug(`Success via ${p.name}`);
106
- controller.abort();
107
- return result;
108
- }
109
- }
110
-
111
- if (p.getFallback) {
112
- result = await withTimeout(p.getFallback(track, controller.signal), timeoutMs, `Fallback timeout ${p.name}`);
113
- if (result?.stream) {
114
- this.debug(`Fallback via ${p.name}`);
115
- controller.abort();
116
- return result;
117
- }
118
- }
119
-
120
- throw new Error("No stream");
121
- } catch (err) {
122
- if (controller.signal.aborted) throw new Error("Aborted");
123
- this.debug(`Failed ${p.name}`, err);
124
- throw err;
125
- }
126
- };
127
- return run();
128
- });
129
-
130
- const result = await Promise.any(promises);
131
- if (result?.stream) return result;
132
- } catch {
133
- this.debug(`Priority group ${priority} failed`);
134
- controller.abort();
135
- }
136
- }
137
-
138
- throw new Error(`All plugins failed for track: ${track.title}`);
139
- }
140
-
141
- /**
142
- * Get related tracks for a given track
143
- * @param {Track} track Track to find related tracks for
144
- * @returns {Track[]} Related tracks or empty array
145
- * @example
146
- * const related = await player.getRelatedTracks(track);
147
- * console.log(`Found ${related.length} related tracks`);
148
- */
149
- async getRelatedTracks(track: Track): Promise<Track[]> {
150
- if (!track) return [];
151
-
152
- const timeoutMs = this.options.extractorTimeout ?? 15000;
153
- const preferred = this.findPlugin(track.url) || this.get(track.source);
154
-
155
- // ===== THỬ PREFERRED TRƯỚC =====
156
- if (preferred && typeof preferred.getRelatedTracks === "function") {
157
- try {
158
- this.debug(`[RelatedTracks] Trying preferred: ${preferred.name}`);
159
- const related = await withTimeout(
160
- preferred.getRelatedTracks(track, {
161
- limit: 10,
162
- history: this.player.queue.previousTracks,
163
- }),
164
- timeoutMs,
165
- `getRelatedTracks timed out for ${preferred.name}`,
166
- );
167
-
168
- if (Array.isArray(related) && related.length > 0) {
169
- return related;
170
- }
171
- this.debug(`[RelatedTracks] ${preferred.name} returned no results → fallback race`);
172
- } catch (err) {
173
- this.debug(`[RelatedTracks] ${preferred.name} failed → fallback race`, err);
174
- }
175
- }
176
-
177
- // ===== FALLBACK: RACE THEO PRIORITY GROUP =====
178
- const plugins = this.getAll()
179
- .filter((p) => p !== preferred && typeof p.getRelatedTracks === "function")
180
- .map((p) => {
181
- p.priority ??= 0;
182
- return p;
183
- })
184
- .sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0));
185
-
186
- // group by priority
187
- const groups = new Map<number, BasePlugin[]>();
188
- for (const p of plugins) {
189
- const key = p.priority ?? 0;
190
- if (!groups.has(key)) groups.set(key, []);
191
- groups.get(key)!.push(p);
192
- }
193
-
194
- for (const [priority, group] of groups) {
195
- this.debug(`[RelatedTracks] Racing priority=${priority} (${group.map((p) => p.name).join(", ")})`);
196
- const controller = new AbortController();
197
-
198
- try {
199
- const promises = group.map((p) =>
200
- (async () => {
201
- try {
202
- const related = await withTimeout(
203
- p.getRelatedTracks!(track, {
204
- limit: 10,
205
- history: this.player.queue.previousTracks,
206
- }),
207
- timeoutMs,
208
- `getRelatedTracks timed out for ${p.name}`,
209
- );
210
-
211
- if (Array.isArray(related) && related.length > 0) {
212
- this.debug(`[RelatedTracks] Success via ${p.name}`);
213
- controller.abort();
214
- return related;
215
- }
216
- throw new Error(`${p.name} returned no results`);
217
- } catch (err) {
218
- if (controller.signal.aborted) throw new Error("Aborted");
219
- this.debug(`[RelatedTracks] ${p.name} failed`, err);
220
- throw err;
221
- }
222
- })(),
223
- );
224
-
225
- const result = await Promise.any(promises);
226
- if (result) return result;
227
- } catch {
228
- this.debug(`[RelatedTracks] Priority group ${priority} all failed`);
229
- controller.abort();
230
- }
231
- }
232
-
233
- this.debug(`[RelatedTracks] All plugins failed for: ${track.title}`);
234
- return [];
235
- }
236
- }
1
+ import { BasePlugin } from "./BasePlugin";
2
+ import { withTimeout } from "../utils/timeout";
3
+ import type { Track, StreamInfo } from "../types";
4
+ import type { PlayerManager } from "../structures/PlayerManager";
5
+ import type { Player } from "../structures/Player";
6
+
7
+ type PluginManagerOptions = {
8
+ extractorTimeout: number | undefined;
9
+ maxFallbackAttempts?: number;
10
+ enableCache?: boolean;
11
+ };
12
+
13
+ export { BasePlugin } from "./BasePlugin";
14
+
15
+ function levenshtein(a: string, b: string): number {
16
+ const matrix = Array.from({ length: a.length + 1 }, () => new Array(b.length + 1).fill(0));
17
+
18
+ for (let i = 0; i <= a.length; i++) matrix[i][0] = i;
19
+ for (let j = 0; j <= b.length; j++) matrix[0][j] = j;
20
+
21
+ for (let i = 1; i <= a.length; i++) {
22
+ for (let j = 1; j <= b.length; j++) {
23
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
24
+ matrix[i][j] = Math.min(matrix[i - 1][j] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j - 1] + cost);
25
+ }
26
+ }
27
+
28
+ return matrix[a.length][b.length];
29
+ }
30
+
31
+ function similarity(a: string, b: string): number {
32
+ if (!a || !b) return 0;
33
+ const dist = levenshtein(a, b);
34
+ const maxLen = Math.max(a.length, b.length);
35
+ return 1 - dist / maxLen;
36
+ }
37
+
38
+ function normalize(str: string): string {
39
+ return str
40
+ .toLowerCase()
41
+ .replace(/\(.*?\)|\[.*?\]/g, "")
42
+ .replace(/[^a-z0-9\s]/g, "")
43
+ .replace(/\s+/g, " ")
44
+ .trim();
45
+ }
46
+
47
+ const MUSIC_KEYWORDS = ["official", "mv", "audio", "lyrics", "remix", "cover", "ft", "feat", "prod", "music video"];
48
+ const NON_MUSIC_KEYWORDS = ["reaction", "review", "podcast", "interview", "vlog", "live stream", "news", "tiktok"];
49
+
50
+ function detectContentType(title: string): number {
51
+ const t = title.toLowerCase();
52
+ let score = 0;
53
+ for (const k of MUSIC_KEYWORDS) if (t.includes(k)) score += 2;
54
+ for (const k of NON_MUSIC_KEYWORDS) if (t.includes(k)) score -= 3;
55
+ return score;
56
+ }
57
+
58
+ function tokenOverlap(a: string, b: string): number {
59
+ const setA = new Set(a.split(" "));
60
+ const setB = new Set(b.split(" "));
61
+ let match = 0;
62
+ for (const word of setA) if (setB.has(word)) match++;
63
+ return match / Math.max(setA.size, setB.size);
64
+ }
65
+
66
+ function scoreTrack(base: Track, candidate: Track): number {
67
+ const titleA = normalize(base.title);
68
+ const titleB = normalize(candidate.title);
69
+ let score = 0;
70
+ score += similarity(titleA, titleB) * 50;
71
+ score += tokenOverlap(titleA, titleB) * 30;
72
+ score += detectContentType(candidate.title);
73
+ return score;
74
+ }
75
+
76
+ // Cache entry for stream results
77
+ interface StreamCacheEntry {
78
+ streamInfo: StreamInfo;
79
+ timestamp: number;
80
+ expiresAt: number;
81
+ }
82
+
83
+ export class PluginManager {
84
+ private options: PluginManagerOptions;
85
+ private player: Player;
86
+ private manager: PlayerManager;
87
+ private plugins: Map<string, BasePlugin> = new Map();
88
+ private streamCache: Map<string, StreamCacheEntry> = new Map();
89
+ private readonly STREAM_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
90
+ private pendingStreams: Map<string, Promise<StreamInfo | null>> = new Map(); // Dedupe in-flight requests
91
+
92
+ constructor(player: Player, manager: PlayerManager, options: PluginManagerOptions) {
93
+ this.player = player;
94
+ this.manager = manager;
95
+ this.options = {
96
+ maxFallbackAttempts: 3,
97
+ enableCache: true,
98
+ ...options,
99
+ };
100
+ }
101
+
102
+ debug(message?: any, ...optionalParams: any[]): void {
103
+ if (this.manager.debugEnabled) {
104
+ this.manager.emit("debug", `[Plugins] ${message}`, ...optionalParams);
105
+ }
106
+ }
107
+
108
+ register(plugin: BasePlugin): void {
109
+ if (this.plugins.has(plugin.name)) {
110
+ this.debug(`Overwriting existing plugin: ${plugin.name}`);
111
+ }
112
+ plugin.priority ??= 0;
113
+ this.plugins.set(plugin.name, plugin);
114
+ this.debug(`Registered plugin: ${plugin.name} (priority: ${plugin.priority})`);
115
+ }
116
+
117
+ unregister(name: string): boolean {
118
+ const removed = this.plugins.delete(name);
119
+ if (removed) this.debug(`Unregistered plugin: ${name}`);
120
+ return removed;
121
+ }
122
+
123
+ get(name: string): BasePlugin | undefined {
124
+ return this.plugins.get(name);
125
+ }
126
+
127
+ getAll(): BasePlugin[] {
128
+ return Array.from(this.plugins.values()).sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
129
+ }
130
+
131
+ findPlugin(query: string): BasePlugin | undefined {
132
+ // First try exact match by source
133
+ for (const plugin of this.getAll()) {
134
+ if (plugin.name && query.toLowerCase().includes(plugin.name.toLowerCase())) {
135
+ return plugin;
136
+ }
137
+ }
138
+
139
+ // Then try canHandle
140
+ return this.getAll().find((plugin) => plugin.canHandle?.(query) ?? false);
141
+ }
142
+
143
+ clear(): void {
144
+ this.plugins.clear();
145
+ this.streamCache.clear();
146
+ this.pendingStreams.clear();
147
+ }
148
+
149
+ private getStreamCacheKey(track: Track): string {
150
+ return `${track.source}:${track.url}:${track.id || track.title}`;
151
+ }
152
+
153
+ private getCachedStream(track: Track): StreamInfo | null {
154
+ if (!this.options.enableCache) return null;
155
+
156
+ const key = this.getStreamCacheKey(track);
157
+ const cached = this.streamCache.get(key);
158
+
159
+ if (cached && Date.now() < cached.expiresAt) {
160
+ this.debug(`[Cache] Hit for track: ${track.title}`);
161
+ return cached.streamInfo;
162
+ }
163
+
164
+ if (cached) {
165
+ this.debug(`[Cache] Expired for track: ${track.title}`);
166
+ this.streamCache.delete(key);
167
+ }
168
+
169
+ return null;
170
+ }
171
+
172
+ private setCachedStream(track: Track, streamInfo: StreamInfo): void {
173
+ if (!this.options.enableCache) return;
174
+
175
+ const key = this.getStreamCacheKey(track);
176
+ this.streamCache.set(key, {
177
+ streamInfo,
178
+ timestamp: Date.now(),
179
+ expiresAt: Date.now() + this.STREAM_CACHE_TTL,
180
+ });
181
+ this.debug(`[Cache] Stored for track: ${track.title}`);
182
+ }
183
+
184
+ private async getStreamWithDedupe(track: Track, primary: BasePlugin): Promise<StreamInfo | null> {
185
+ const key = this.getStreamCacheKey(track);
186
+
187
+ // Check if there's already an in-flight request
188
+ if (this.pendingStreams.has(key)) {
189
+ this.debug(`[Dedupe] Waiting for existing request: ${track.title}`);
190
+ return this.pendingStreams.get(key)!;
191
+ }
192
+
193
+ // Create new request
194
+ const promise = this.getStreamInternal(track, primary);
195
+ this.pendingStreams.set(key, promise);
196
+
197
+ try {
198
+ const result = await promise;
199
+ return result;
200
+ } finally {
201
+ this.pendingStreams.delete(key);
202
+ }
203
+ }
204
+
205
+ private async getStreamInternal(track: Track, primary: BasePlugin): Promise<StreamInfo | null> {
206
+ const timeoutMs = this.options.extractorTimeout ?? 50000;
207
+
208
+ // Check cache first
209
+ const cached = this.getCachedStream(track);
210
+ if (cached) return cached;
211
+
212
+ // Try primary plugin first
213
+ try {
214
+ this.debug(`[Primary] Trying ${primary.name} for track: ${track.title}`);
215
+ const controller = new AbortController();
216
+ const result = await withTimeout(
217
+ primary.getStream(track, controller.signal),
218
+ timeoutMs,
219
+ `Primary timeout: ${primary.name}`,
220
+ );
221
+
222
+ if (result?.stream) {
223
+ this.debug(`[Primary] Success via ${primary.name}`);
224
+ this.setCachedStream(track, result);
225
+ return result;
226
+ }
227
+ throw new Error("Primary plugin returned no stream");
228
+ } catch (error) {
229
+ this.debug(`[Primary] Failed: ${primary.name}`, error);
230
+ }
231
+
232
+ // Fallback to other plugins
233
+ const fallbackPlugins = this.getAll()
234
+ .filter((p) => p !== primary && p.name !== primary.name)
235
+ .sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
236
+
237
+ if (fallbackPlugins.length === 0) {
238
+ this.debug(`[Fallback] No fallback plugins available`);
239
+ return null;
240
+ }
241
+
242
+ this.debug(`[Fallback] Trying ${fallbackPlugins.length} plugins sequentially`);
243
+
244
+ // Try plugins sequentially to avoid overwhelming sources
245
+ let attempt = 0;
246
+ for (const plugin of fallbackPlugins) {
247
+ attempt++;
248
+ if (attempt > (this.options.maxFallbackAttempts ?? 3)) {
249
+ this.debug(`[Fallback] Max attempts (${this.options.maxFallbackAttempts}) reached`);
250
+ break;
251
+ }
252
+
253
+ try {
254
+ this.debug(`[Fallback] Attempt ${attempt}/${fallbackPlugins.length}: ${plugin.name}`);
255
+ const controller = new AbortController();
256
+
257
+ let result: StreamInfo | null = null;
258
+
259
+ // Try getStream first
260
+ if (plugin.getStream) {
261
+ result = await withTimeout(plugin.getStream(track, controller.signal), timeoutMs, `Timeout: ${plugin.name}`);
262
+ }
263
+
264
+ // Try fallback method if getStream failed
265
+ if (!result?.stream && plugin.getFallback) {
266
+ this.debug(`[Fallback] Trying fallback method for ${plugin.name}`);
267
+ result = await withTimeout(plugin.getFallback(track, controller.signal), timeoutMs, `Fallback timeout: ${plugin.name}`);
268
+ }
269
+
270
+ if (result?.stream) {
271
+ this.debug(`[Fallback] Success via ${plugin.name}`);
272
+ this.setCachedStream(track, result);
273
+ return result;
274
+ }
275
+ } catch (error) {
276
+ this.debug(`[Fallback] Failed: ${plugin.name}`, error);
277
+ }
278
+ }
279
+
280
+ this.debug(`[Fallback] All plugins failed for track: ${track.title}`);
281
+ return null;
282
+ }
283
+
284
+ async getStream(track: Track): Promise<StreamInfo | null> {
285
+ if (!track) {
286
+ this.debug(`[getStream] No track provided`);
287
+ return null;
288
+ }
289
+
290
+ // Find the most appropriate plugin
291
+ let primary = this.get(track.source);
292
+ if (!primary) {
293
+ primary = this.findPlugin(track.url);
294
+ }
295
+ if (!primary) {
296
+ this.debug(`[getStream] No plugin found for track: ${track.title} (source: ${track.source})`);
297
+ return null;
298
+ }
299
+
300
+ return this.getStreamWithDedupe(track, primary);
301
+ }
302
+
303
+ /**
304
+ * Get related tracks for a given track
305
+ * @param {Track} track Track to find related tracks for
306
+ * @returns {Promise<Track[]>} Related tracks or empty array
307
+ */
308
+ async getRelatedTracks(track: Track): Promise<Track[]> {
309
+ if (!track) return [];
310
+
311
+ const timeoutMs = this.options.extractorTimeout ?? 15000;
312
+ const limit = 20;
313
+ const minSimilarityScore = 10; // Minimum score to consider
314
+
315
+ const relatedPlugins = this.getAll()
316
+ .filter((p) => typeof p.getRelatedTracks === "function")
317
+ .sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
318
+
319
+ if (relatedPlugins.length === 0) {
320
+ this.debug(`[RelatedTracks] No plugins support related tracks`);
321
+ return [];
322
+ }
323
+
324
+ const history = this.player.queue.previousTracks;
325
+ const historyUrls = new Set(history.map((t) => t.url));
326
+ const currentTrackUrl = track.url;
327
+
328
+ const results: Track[] = [];
329
+
330
+ // Try plugins in parallel but with limit
331
+ const batchSize = 3;
332
+ for (let i = 0; i < relatedPlugins.length; i += batchSize) {
333
+ const batch = relatedPlugins.slice(i, i + batchSize);
334
+ const batchResults = await Promise.allSettled(
335
+ batch.map(async (plugin) => {
336
+ try {
337
+ this.debug(`[RelatedTracks] Querying ${plugin.name}`);
338
+ const related = await withTimeout(
339
+ plugin.getRelatedTracks!(track, { limit, history }),
340
+ timeoutMs,
341
+ `Timeout ${plugin.name}`,
342
+ );
343
+ return Array.isArray(related) ? related : [];
344
+ } catch (err) {
345
+ this.debug(`[RelatedTracks] ${plugin.name} failed`, err);
346
+ return [];
347
+ }
348
+ }),
349
+ );
350
+
351
+ for (const result of batchResults) {
352
+ if (result.status === "fulfilled") {
353
+ results.push(...result.value);
354
+ }
355
+ }
356
+ }
357
+
358
+ if (results.length === 0) {
359
+ this.debug(`[RelatedTracks] No results from any plugin`);
360
+ return [];
361
+ }
362
+
363
+ // Deduplicate by URL
364
+ const unique = new Map<string, Track>();
365
+ for (const t of results) {
366
+ if (!unique.has(t.url) && t.url !== currentTrackUrl && !historyUrls.has(t.url)) {
367
+ unique.set(t.url, t);
368
+ }
369
+ }
370
+
371
+ // Score and sort
372
+ const ranked = Array.from(unique.values())
373
+ .map((t) => ({ track: t, score: scoreTrack(track, t) }))
374
+ .filter((item) => item.score >= minSimilarityScore)
375
+ .sort((a, b) => b.score - a.score)
376
+ .slice(0, limit)
377
+ .map((x) => x.track);
378
+
379
+ this.debug(`[RelatedTracks] Found ${ranked.length} related tracks (filtered from ${results.length})`);
380
+ return ranked;
381
+ }
382
+
383
+ /**
384
+ * Clear stream cache
385
+ */
386
+ clearStreamCache(): void {
387
+ const size = this.streamCache.size;
388
+ this.streamCache.clear();
389
+ this.debug(`[Cache] Cleared ${size} stream cache entries`);
390
+ }
391
+
392
+ /**
393
+ * Get plugin statistics
394
+ */
395
+ getStats(): object {
396
+ return {
397
+ totalPlugins: this.plugins.size,
398
+ pluginNames: Array.from(this.plugins.keys()),
399
+ cacheSize: this.streamCache.size,
400
+ pendingRequests: this.pendingStreams.size,
401
+ };
402
+ }
403
+ }