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,403 +1,950 @@
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
- }
1
+ import { BasePlugin } from "./BasePlugin";
2
+ import { withTimeout } from "../utils/timeout";
3
+ import type { Track, StreamInfo, SearchResult, SearchScore } from "../types";
4
+ import type { PlayerManager } from "../structures/PlayerManager";
5
+ import type { Player } from "../structures/Player";
6
+ import { StreamManager } from "../structures/StreamManager";
7
+
8
+ type PluginManagerOptions = {
9
+ extractorTimeout: number | undefined;
10
+ maxFallbackAttempts?: number;
11
+ enableCache?: boolean;
12
+ searchCacheTTL?: number;
13
+ searchMinScore?: number;
14
+ };
15
+
16
+ export { BasePlugin } from "./BasePlugin";
17
+
18
+ function levenshtein(a: string, b: string): number {
19
+ const matrix = Array.from({ length: a.length + 1 }, () => new Array(b.length + 1).fill(0));
20
+
21
+ for (let i = 0; i <= a.length; i++) matrix[i][0] = i;
22
+ for (let j = 0; j <= b.length; j++) matrix[0][j] = j;
23
+
24
+ for (let i = 1; i <= a.length; i++) {
25
+ for (let j = 1; j <= b.length; j++) {
26
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
27
+ matrix[i][j] = Math.min(matrix[i - 1][j] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j - 1] + cost);
28
+ }
29
+ }
30
+
31
+ return matrix[a.length][b.length];
32
+ }
33
+
34
+ function similarity(a: string, b: string): number {
35
+ if (!a || !b) return 0;
36
+ const dist = levenshtein(a, b);
37
+ const maxLen = Math.max(a.length, b.length);
38
+ return 1 - dist / maxLen;
39
+ }
40
+
41
+ function normalize(str: string): string {
42
+ return str
43
+ .toLowerCase()
44
+ .replace(/\(.*?\)|\[.*?\]/g, "")
45
+ .replace(/[^a-z0-9\s]/g, "")
46
+ .replace(/\s+/g, " ")
47
+ .trim();
48
+ }
49
+
50
+ const MUSIC_KEYWORDS = ["official", "mv", "audio", "lyrics", "remix", "cover", "ft", "feat", "prod", "music video"];
51
+ const NON_MUSIC_KEYWORDS = ["reaction", "review", "podcast", "interview", "vlog", "live stream", "news", "tiktok"];
52
+
53
+ function detectContentType(title: string): number {
54
+ const t = title.toLowerCase();
55
+ let score = 0;
56
+ for (const k of MUSIC_KEYWORDS) if (t.includes(k)) score += 2;
57
+ for (const k of NON_MUSIC_KEYWORDS) if (t.includes(k)) score -= 3;
58
+ return score;
59
+ }
60
+
61
+ function tokenOverlap(a: string, b: string): number {
62
+ const setA = new Set(a.split(" "));
63
+ const setB = new Set(b.split(" "));
64
+ let match = 0;
65
+ for (const word of setA) if (setB.has(word)) match++;
66
+ return match / Math.max(setA.size, setB.size);
67
+ }
68
+
69
+ function scoreTrack(base: Track, candidate: Track): number {
70
+ const titleA = normalize(base.title);
71
+ const titleB = normalize(candidate.title);
72
+ let score = 0;
73
+ score += similarity(titleA, titleB) * 50;
74
+ score += tokenOverlap(titleA, titleB) * 30;
75
+ score += detectContentType(candidate.title);
76
+ return score;
77
+ }
78
+
79
+ interface SearchCacheEntry {
80
+ result: SearchResult;
81
+ timestamp: number;
82
+ expiresAt: number;
83
+ }
84
+
85
+ interface StreamCacheEntry {
86
+ streamInfo: StreamInfo;
87
+ timestamp: number;
88
+ expiresAt: number;
89
+ }
90
+
91
+ export class PluginManager {
92
+ private options: PluginManagerOptions;
93
+ private player: Player;
94
+ private manager: PlayerManager;
95
+ private plugins: Map<string, BasePlugin> = new Map();
96
+ private streamCache: Map<string, StreamCacheEntry> = new Map();
97
+ private searchCache: Map<string, SearchCacheEntry> = new Map();
98
+ private readonly STREAM_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
99
+ private pendingStreams: Map<string, Promise<StreamInfo | null>> = new Map(); // Dedupe in-flight requests
100
+ private pendingSearches: Map<string, Promise<SearchResult | null>> = new Map(); // Dedupe search requests
101
+ private streamManager?: StreamManager;
102
+
103
+ constructor(player: Player, manager: PlayerManager, options: PluginManagerOptions) {
104
+ this.player = player;
105
+ this.manager = manager;
106
+ this.options = {
107
+ maxFallbackAttempts: 3,
108
+ enableCache: true,
109
+ searchMinScore: 30,
110
+ searchCacheTTL: 2 * 60 * 1000, // 2 minutes
111
+ ...options,
112
+ };
113
+ }
114
+
115
+ debug(message?: any, ...optionalParams: any[]): void {
116
+ if (this.manager.debugEnabled) {
117
+ this.manager.emit("debug", `[Plugins] ${message}`, ...optionalParams);
118
+ }
119
+ }
120
+
121
+ register(plugin: BasePlugin): void {
122
+ if (this.plugins.has(plugin.name)) {
123
+ this.debug(`Overwriting existing plugin: ${plugin.name}`);
124
+ }
125
+ plugin.priority ??= 0;
126
+ this.plugins.set(plugin.name, plugin);
127
+ this.debug(`Registered plugin: ${plugin.name} (priority: ${plugin.priority})`);
128
+ }
129
+
130
+ unregister(name: string): boolean {
131
+ const removed = this.plugins.delete(name);
132
+ if (removed) this.debug(`Unregistered plugin: ${name}`);
133
+ return removed;
134
+ }
135
+
136
+ get(name: string): BasePlugin | undefined {
137
+ return this.plugins.get(name);
138
+ }
139
+
140
+ getAll(): BasePlugin[] {
141
+ return Array.from(this.plugins.values()).sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
142
+ }
143
+
144
+ findPlugin(query: string): BasePlugin | undefined {
145
+ for (const plugin of this.getAll()) {
146
+ if (plugin.name && query.toLowerCase().includes(plugin.name.toLowerCase())) {
147
+ return plugin;
148
+ }
149
+ }
150
+ return this.getAll().find((plugin) => plugin.canHandle?.(query) ?? false);
151
+ }
152
+
153
+ clear(): void {
154
+ this.plugins.clear();
155
+ this.streamCache.clear();
156
+ this.searchCache.clear();
157
+ this.pendingStreams.clear();
158
+ this.pendingSearches.clear();
159
+ }
160
+
161
+ setStreamManager(manager: StreamManager): void {
162
+ this.streamManager = manager;
163
+ }
164
+ //#region Search advanced scoring
165
+
166
+ private getSearchCacheKey(query: string, requestedBy: string): string {
167
+ return `${query.toLowerCase().trim()}:${requestedBy}`;
168
+ }
169
+
170
+ private getCachedSearch(query: string, requestedBy: string): SearchResult | null {
171
+ if (!this.options.enableCache) return null;
172
+
173
+ const key = this.getSearchCacheKey(query, requestedBy);
174
+ const cached = this.searchCache.get(key);
175
+
176
+ if (cached && Date.now() < cached.expiresAt) {
177
+ this.debug(`[SearchCache] Hit for query: ${query}`);
178
+ return cached.result;
179
+ }
180
+
181
+ if (cached) {
182
+ this.debug(`[SearchCache] Expired for query: ${query}`);
183
+ this.searchCache.delete(key);
184
+ }
185
+
186
+ return null;
187
+ }
188
+
189
+ private setCachedSearch(query: string, requestedBy: string, result: SearchResult): void {
190
+ if (!this.options.enableCache) return;
191
+
192
+ const key = this.getSearchCacheKey(query, requestedBy);
193
+ this.searchCache.set(key, {
194
+ result,
195
+ timestamp: Date.now(),
196
+ expiresAt: Date.now() + (this.options.searchCacheTTL ?? 2 * 60 * 1000),
197
+ });
198
+ this.debug(`[SearchCache] Stored for query: ${query}, tracks: ${result.tracks.length}`);
199
+ }
200
+
201
+ /**
202
+ * Evaluate how well a track matches the search query
203
+ * @param track Evaluated track
204
+ * @param query Query default
205
+ * @returns SearchScore object with score and reason
206
+ */
207
+ public evaluateTrackMatch(track: Track, query: string): SearchScore {
208
+ const normalizedQuery = normalize(query);
209
+ const normalizedTitle = normalize(track.title);
210
+ const queryLower = query.toLowerCase();
211
+ const urlLower = track.url?.toLowerCase() || "";
212
+
213
+ // 1. Evaluate URL match - 100%
214
+ if (urlLower === queryLower || (queryLower.includes(urlLower) && urlLower.length > 10)) {
215
+ return {
216
+ score: 100,
217
+ reason: "URL matches exactly",
218
+ matchedBy: "url",
219
+ exactMatch: true,
220
+ };
221
+ }
222
+
223
+ // 2. Evaluate title match exactly - 100%
224
+ if (normalizedTitle === normalizedQuery) {
225
+ return {
226
+ score: 100,
227
+ reason: "Title matches exactly",
228
+ matchedBy: "title",
229
+ exactMatch: true,
230
+ };
231
+ }
232
+
233
+ // 3. Evaluate title contains query or vice versa - 70-90%
234
+ if (normalizedTitle.includes(normalizedQuery) && normalizedQuery.length > 5) {
235
+ const ratio = normalizedQuery.length / normalizedTitle.length;
236
+ const score = 70 + Math.min(20, Math.floor(ratio * 20));
237
+ return {
238
+ score,
239
+ reason: `Title contains the query "${query}" (${Math.floor(ratio * 100)}% coverage)`,
240
+ matchedBy: "title",
241
+ exactMatch: false,
242
+ };
243
+ }
244
+
245
+ if (normalizedQuery.includes(normalizedTitle) && normalizedTitle.length > 5) {
246
+ const ratio = normalizedTitle.length / normalizedQuery.length;
247
+ const score = 70 + Math.min(20, Math.floor(ratio * 20));
248
+ return {
249
+ score,
250
+ reason: `Query contains the title "${query}" (${Math.floor(ratio * 100)}% overlap)`,
251
+ matchedBy: "title",
252
+ exactMatch: false,
253
+ };
254
+ }
255
+
256
+ // 4. Evaluate similarity algorithm - 0-70%
257
+ const simScore = similarity(normalizedTitle, normalizedQuery);
258
+ const tokenScore = tokenOverlap(normalizedTitle, normalizedQuery);
259
+ const contentTypeBonus = detectContentType(track.title);
260
+
261
+ // Tính điểm tổng hợp: similarity 60%, token overlap 30%, content type 10%
262
+ let finalScore = simScore * 60 + tokenScore * 30 + (contentTypeBonus / 10) * 10;
263
+ finalScore = Math.min(70, Math.max(0, Math.floor(finalScore)));
264
+
265
+ if (finalScore >= 20) {
266
+ let reason = `Similarity ${Math.floor(simScore * 100)}%`;
267
+
268
+ if (contentTypeBonus > 0) {
269
+ reason += `, recognized as music content`;
270
+ }
271
+
272
+ return {
273
+ score: finalScore,
274
+ reason,
275
+ matchedBy: "partial",
276
+ exactMatch: false,
277
+ };
278
+ }
279
+
280
+ // 5. Không match
281
+ return {
282
+ score: 0,
283
+ reason: "No matching results found",
284
+ matchedBy: "none",
285
+ exactMatch: false,
286
+ };
287
+ }
288
+
289
+ /**
290
+ * Evaluate and rank all search results from a plugin
291
+ * @param tracks List of tracks to evaluate
292
+ * @param query Original query
293
+ * @param isPlaylistResult Whether this is a playlist result (skip per-track evaluation)
294
+ * @returns List of tracks with scores, sorted by score
295
+ */
296
+ private evaluateAndRankResults(
297
+ tracks: Track[],
298
+ query: string,
299
+ isPlaylistResult: boolean = false,
300
+ ): { track: Track; score: SearchScore }[] {
301
+ // 🔥 Nếu là playlist result, giữ nguyên tất cả tracks với điểm 100%
302
+ if (isPlaylistResult && tracks.length > 0) {
303
+ this.debug(`[Evaluation] Playlist detected - keeping all ${tracks.length} tracks`);
304
+
305
+ return tracks.map((track) => ({
306
+ track,
307
+ score: {
308
+ score: 100,
309
+ reason: "Part of playlist",
310
+ matchedBy: "playlist",
311
+ exactMatch: false,
312
+ },
313
+ }));
314
+ }
315
+
316
+ const evaluated = tracks.map((track) => ({
317
+ track,
318
+ score: this.evaluateTrackMatch(track, query),
319
+ }));
320
+
321
+ // Sort by score in descending order (cao xuống thấp)
322
+ evaluated.sort((a, b) => b.score.score - a.score.score);
323
+
324
+ // Log evaluation results
325
+ for (const item of evaluated.slice(0, 5)) {
326
+ this.debug(`[Evaluation] "${item.track.title}" -> ${item.score.score}% (${item.score.reason})`);
327
+ }
328
+
329
+ return evaluated;
330
+ }
331
+ /**
332
+ * Select the best result from multiple plugins
333
+ * @param allResults Results from various plugins
334
+ * @param query Original query
335
+ * @returns The best result
336
+ */
337
+ private selectBestResult(allResults: Map<string, SearchResult>, query: string): SearchResult | null {
338
+ let bestResult: SearchResult | null = null;
339
+ let bestScore = -1;
340
+
341
+ for (const [source, result] of allResults) {
342
+ if (!result.tracks || result.tracks.length === 0) continue;
343
+
344
+ // Evaluate the first (best) track of this result
345
+ const bestTrackScore = this.evaluateTrackMatch(result.tracks[0], query);
346
+
347
+ if (bestTrackScore.score > bestScore) {
348
+ bestScore = bestTrackScore.score;
349
+ bestResult = {
350
+ ...result,
351
+ source,
352
+ score: bestTrackScore,
353
+ };
354
+ }
355
+
356
+ this.debug(`[Selection] Plugin ${source} -> best match: ${bestTrackScore.score}%`);
357
+ }
358
+
359
+ if (bestResult && bestResult.score) {
360
+ this.debug(`[Selection] Selected result from ${bestResult.source} with score ${bestResult.score.score}%`);
361
+ }
362
+
363
+ return bestResult;
364
+ }
365
+
366
+ /**
367
+ * Search with deduplication and evaluation of results
368
+ * @param query Search query
369
+ * @param requestedBy User who requested the search
370
+ * @returns Evaluated search result
371
+ */
372
+ async search(query: string, requestedBy: string): Promise<SearchResult | null> {
373
+ if (!query || !query.trim()) {
374
+ this.debug(`[Search] Empty query provided`);
375
+ return null;
376
+ }
377
+
378
+ const trimmedQuery = query.trim();
379
+ this.debug(`[Search] Called with query: "${trimmedQuery}", requestedBy: ${requestedBy}`);
380
+
381
+ // Check cache
382
+ const cached = this.getCachedSearch(trimmedQuery, requestedBy);
383
+ if (cached) {
384
+ this.debug(`[Search] Returning cached result for: ${trimmedQuery}`);
385
+ return cached;
386
+ }
387
+
388
+ // Check in-flight request
389
+ const dedupeKey = this.getSearchCacheKey(trimmedQuery, requestedBy);
390
+ if (this.pendingSearches.has(dedupeKey)) {
391
+ this.debug(`[Search] Waiting for in-flight request: ${trimmedQuery}`);
392
+ return this.pendingSearches.get(dedupeKey)!;
393
+ }
394
+
395
+ // Create new search request
396
+ const searchPromise = this.searchInternal(trimmedQuery, requestedBy);
397
+ this.pendingSearches.set(dedupeKey, searchPromise);
398
+
399
+ try {
400
+ const result = await searchPromise;
401
+
402
+ return result;
403
+ } finally {
404
+ this.pendingSearches.delete(dedupeKey);
405
+ }
406
+ }
407
+
408
+ private async searchInternal(query: string, requestedBy: string): Promise<SearchResult | null> {
409
+ const timeoutMs = this.options.extractorTimeout ?? 15000;
410
+ const minScore = this.options.searchMinScore ?? 30;
411
+
412
+ // Get all plugins that support search
413
+ const allSearchPlugins = this.getAll()
414
+ .filter((p) => typeof p.search === "function")
415
+ .sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
416
+
417
+ if (allSearchPlugins.length === 0) {
418
+ this.debug(`[Search] No plugins support search`);
419
+ return null;
420
+ }
421
+
422
+ const priorityGroups = new Map<number, BasePlugin[]>();
423
+ for (const plugin of allSearchPlugins) {
424
+ const priority = plugin.priority ?? 0;
425
+ if (!priorityGroups.has(priority)) {
426
+ priorityGroups.set(priority, []);
427
+ }
428
+ priorityGroups.get(priority)!.push(plugin);
429
+ }
430
+
431
+ const sortedPriorities = Array.from(priorityGroups.keys()).sort((a, b) => b - a);
432
+
433
+ this.debug(
434
+ `[Search] Priority groups: ${sortedPriorities.map((p) => `${p} (${priorityGroups.get(p)!.length} plugins)`).join(", ")}`,
435
+ );
436
+
437
+ const allResults = new Map<string, SearchResult>();
438
+ const errors: Array<{ plugin: string; error: Error }> = [];
439
+
440
+ let foundHighQualityResult = false;
441
+ let bestResultOverall: SearchResult | null = null;
442
+ let bestScoreOverall = -1;
443
+
444
+ // 🔥 PROCESS EACH PRIORITY GROUP
445
+ for (const priority of sortedPriorities) {
446
+ const groupPlugins = priorityGroups.get(priority)!;
447
+ this.debug(`[Search] Processing priority group ${priority} with ${groupPlugins.length} plugins`);
448
+
449
+ const groupResults = new Map<string, SearchResult>();
450
+
451
+ // Process all plugins in current priority group
452
+ for (const plugin of groupPlugins) {
453
+ // Skip if we already found a high-quality result (>=90%) from previous group
454
+ if (foundHighQualityResult) {
455
+ this.debug(`[Search] Skipping plugin ${plugin.name} (priority ${priority}) - already have high-quality result`);
456
+ break;
457
+ }
458
+
459
+ try {
460
+ this.debug(`[Search] Trying plugin: ${plugin.name} (priority: ${priority})`);
461
+
462
+ const startTime = Date.now();
463
+ const result = await withTimeout(plugin.search(query, requestedBy), timeoutMs, `Search timeout for ${plugin.name}`);
464
+
465
+ const duration = Date.now() - startTime;
466
+
467
+ if (result && Array.isArray(result.tracks) && result.tracks.length > 0) {
468
+ // 🔥 KIỂM TRA XEM CÓ PLAYLIST HAY KHÔNG
469
+ const hasPlaylist = !!result.playlist;
470
+ const isPlaylistResult = hasPlaylist && result.tracks.length > 1;
471
+
472
+ // Evaluate and rank results from this plugin
473
+ const evaluatedTracks = this.evaluateAndRankResults(result.tracks, query, isPlaylistResult);
474
+
475
+ const bestScore = evaluatedTracks[0]?.score.score || 0;
476
+
477
+ // Filter tracks below minimum score (chỉ áp dụng cho non-playlist)
478
+ let validTracks: Track[];
479
+
480
+ if (isPlaylistResult) {
481
+ // 🔥 PLAYLIST: Giữ nguyên tất cả tracks, không lọc theo score
482
+ validTracks = evaluatedTracks.map((item) => item.track);
483
+ this.debug(`[Search] Plugin ${plugin.name} returned playlist with ${validTracks.length} tracks (keeping all)`);
484
+ } else {
485
+ // Single track search: filter by min score
486
+ validTracks = evaluatedTracks.filter((item) => item.score.score >= minScore).map((item) => item.track);
487
+ this.debug(
488
+ `[Search] Plugin ${plugin.name} returned ${validTracks.length}/${result.tracks.length} valid tracks in ${duration}ms (best: ${bestScore}%)`,
489
+ );
490
+ }
491
+
492
+ if (validTracks.length > 0) {
493
+ const pluginResult: SearchResult = {
494
+ tracks: validTracks,
495
+ playlist: result.playlist,
496
+ query,
497
+ score: evaluatedTracks[0].score,
498
+ source: plugin.name,
499
+ };
500
+
501
+ groupResults.set(plugin.name, pluginResult);
502
+ allResults.set(plugin.name, pluginResult);
503
+
504
+ // 🔥 Nếu là playlist hoặc kết quả chất lượng cao, dừng search
505
+ if (isPlaylistResult || bestScore >= 90) {
506
+ foundHighQualityResult = true;
507
+ bestResultOverall = pluginResult;
508
+ bestScoreOverall = bestScore;
509
+ this.debug(`[Search] ${isPlaylistResult ? "Playlist" : "High-quality result"} found, stopping search`);
510
+ break;
511
+ }
512
+
513
+ // Track best result overall
514
+ if (bestScore > bestScoreOverall) {
515
+ bestScoreOverall = bestScore;
516
+ bestResultOverall = pluginResult;
517
+ }
518
+ } else {
519
+ this.debug(
520
+ `[Search] Plugin ${plugin.name} returned ${result.tracks.length} tracks but none passed min score ${minScore}`,
521
+ );
522
+ }
523
+ } else {
524
+ this.debug(`[Search] Plugin ${plugin.name} returned no tracks in ${duration}ms`);
525
+ }
526
+ } catch (error) {
527
+ const err = error instanceof Error ? error : new Error(String(error));
528
+ this.debug(`[Search] Plugin ${plugin.name} failed:`, err.message);
529
+ errors.push({ plugin: plugin.name, error: err });
530
+ }
531
+ }
532
+
533
+ // 🔥 AFTER PROCESSING CURRENT GROUP, DECIDE WHETHER TO CONTINUE
534
+ if (foundHighQualityResult) {
535
+ this.debug(`[Search] Found high-quality result (>=90%) in priority group ${priority}, stopping search`);
536
+ break;
537
+ }
538
+
539
+ // If current group has at least one valid result, check if we should continue to lower priority groups
540
+ if (groupResults.size > 0) {
541
+ // Get best score from this group
542
+ const bestGroupScore = Math.max(...Array.from(groupResults.values()).map((r) => r.score?.score || 0));
543
+
544
+ // If best score in this group is >= 70%, don't try lower priority groups
545
+ if (bestGroupScore >= 70) {
546
+ this.debug(`[Search] Priority group ${priority} has results with score ${bestGroupScore}% (>=70%), stopping search`);
547
+ break;
548
+ }
549
+
550
+ // If best score is between 50-70%, continue to lower groups but log it
551
+ if (bestGroupScore >= 50) {
552
+ this.debug(
553
+ `[Search] Priority group ${priority} has results with score ${bestGroupScore}% (50-70%), will try lower priority groups for better results`,
554
+ );
555
+ } else {
556
+ this.debug(
557
+ `[Search] Priority group ${priority} has low quality results (${bestGroupScore}% <50%), trying lower priority groups`,
558
+ );
559
+ }
560
+ } else {
561
+ this.debug(`[Search] Priority group ${priority} produced no valid results, trying lower priority group`);
562
+ }
563
+ }
564
+
565
+ // 🔥 SELECT BEST RESULT
566
+ if (bestResultOverall) {
567
+ // Cache the result
568
+ this.setCachedSearch(query, requestedBy, bestResultOverall);
569
+
570
+ // Log summary
571
+ const allTracksCount = Array.from(allResults.values()).reduce((sum, r) => sum + r.tracks.length, 0);
572
+ this.debug(
573
+ `[Search] Complete - Best from ${bestResultOverall.source}: ${bestResultOverall.tracks.length} tracks (${bestResultOverall.score?.score || 0}%), Total candidates: ${allTracksCount}`,
574
+ );
575
+
576
+ return bestResultOverall;
577
+ }
578
+
579
+ // 🔥 FALLBACK: If we have any results that didn't meet min score, return the best one
580
+ if (allResults.size === 0 && errors.length > 0) {
581
+ // Try to get best result from allResults even if below min score
582
+ let fallbackResult: SearchResult | null = null;
583
+ let fallbackScore = -1;
584
+
585
+ for (const [source, result] of allResults) {
586
+ const score = result.score?.score || 0;
587
+ if (score > fallbackScore) {
588
+ fallbackScore = score;
589
+ fallbackResult = result;
590
+ }
591
+ }
592
+
593
+ if (fallbackResult) {
594
+ this.debug(`[Search] Using fallback result with score ${fallbackScore}% (below minimum ${minScore}%)`);
595
+ return fallbackResult;
596
+ }
597
+
598
+ this.debug(`[Search] All ${allSearchPlugins.length} plugins failed for query: ${query}`);
599
+ const lastError = errors[errors.length - 1]?.error;
600
+ if (lastError) throw lastError;
601
+ }
602
+
603
+ return null;
604
+ }
605
+
606
+ /**
607
+ * Get plugin priority groups info for debugging
608
+ */
609
+ getPriorityGroupsInfo(): { priority: number; plugins: string[]; count: number }[] {
610
+ const groups = new Map<number, string[]>();
611
+
612
+ for (const plugin of this.getAll()) {
613
+ const priority = plugin.priority ?? 0;
614
+ if (!groups.has(priority)) {
615
+ groups.set(priority, []);
616
+ }
617
+ groups.get(priority)!.push(plugin.name);
618
+ }
619
+
620
+ return Array.from(groups.entries())
621
+ .map(([priority, plugins]) => ({
622
+ priority,
623
+ plugins,
624
+ count: plugins.length,
625
+ }))
626
+ .sort((a, b) => b.priority - a.priority);
627
+ }
628
+
629
+ /**
630
+ * Clear search cache
631
+ */
632
+ clearSearchCache(): void {
633
+ const size = this.searchCache.size;
634
+ this.searchCache.clear();
635
+ this.debug(`[SearchCache] Cleared ${size} entries`);
636
+ }
637
+
638
+ /**
639
+ * Get search cache stats
640
+ */
641
+ getSearchCacheStats(): { size: number; keys: string[] } {
642
+ return {
643
+ size: this.searchCache.size,
644
+ keys: Array.from(this.searchCache.keys()),
645
+ };
646
+ }
647
+
648
+ //#endregion
649
+
650
+ //#region Stream methods (giữ nguyên)
651
+
652
+ private getStreamCacheKey(track: Track): string {
653
+ return `${track.source}:${track.url}:${track.id || track.title}`;
654
+ }
655
+
656
+ private getCachedStream(track: Track): StreamInfo | null {
657
+ if (!this.options.enableCache) return null;
658
+
659
+ const key = this.getStreamCacheKey(track);
660
+ const cached = this.streamCache.get(key);
661
+
662
+ if (cached && Date.now() < cached.expiresAt) {
663
+ this.debug(`[StreamCache] Hit for track: ${track.title}`);
664
+ return cached.streamInfo;
665
+ }
666
+
667
+ if (cached) {
668
+ this.debug(`[StreamCache] Expired for track: ${track.title}`);
669
+ this.streamCache.delete(key);
670
+ }
671
+
672
+ return null;
673
+ }
674
+
675
+ private setCachedStream(track: Track, streamInfo: StreamInfo): void {
676
+ if (!this.options.enableCache) return;
677
+
678
+ const key = this.getStreamCacheKey(track);
679
+ this.streamCache.set(key, {
680
+ streamInfo,
681
+ timestamp: Date.now(),
682
+ expiresAt: Date.now() + this.STREAM_CACHE_TTL,
683
+ });
684
+ this.debug(`[StreamCache] Stored for track: ${track.title}`);
685
+ }
686
+
687
+ private async getStreamWithDedupe(track: Track, primary: BasePlugin): Promise<StreamInfo | null> {
688
+ const key = this.getStreamCacheKey(track);
689
+
690
+ if (this.pendingStreams.has(key)) {
691
+ this.debug(`[StreamDedupe] Waiting for existing request: ${track.title}`);
692
+ return this.pendingStreams.get(key)!;
693
+ }
694
+
695
+ const promise = this.getStreamInternal(track, primary);
696
+ this.pendingStreams.set(key, promise);
697
+
698
+ try {
699
+ const result = await promise;
700
+ return result;
701
+ } finally {
702
+ this.pendingStreams.delete(key);
703
+ }
704
+ }
705
+
706
+ private async getStreamInternal(track: Track, primary: BasePlugin): Promise<StreamInfo | null> {
707
+ if (this.streamManager) {
708
+ const existingStream = this.streamManager.getStreamByTrack(track.id || track.title);
709
+ if (existingStream) {
710
+ this.debug(`[Stream] Using existing stream from manager`);
711
+ return { stream: existingStream, type: "arbitrary" };
712
+ }
713
+ }
714
+
715
+ const timeoutMs = this.options.extractorTimeout ?? 50000;
716
+
717
+ const cached = this.getCachedStream(track);
718
+ if (cached) return cached;
719
+
720
+ try {
721
+ this.debug(`[Stream] Trying ${primary.name} for track: ${track.title}`);
722
+ const controller = new AbortController();
723
+ const result = await withTimeout(
724
+ primary.getStream(track, controller.signal),
725
+ timeoutMs,
726
+ `Primary timeout: ${primary.name}`,
727
+ );
728
+
729
+ if (result?.stream) {
730
+ const isValid = await this.validateStreamMatchesTrack(result, track);
731
+ if (isValid) {
732
+ this.debug(`[Stream] Success via ${primary.name}`);
733
+ this.setCachedStream(track, result);
734
+ return result;
735
+ }
736
+ this.debug(`[Stream] Stream validation failed - wrong track returned`);
737
+ }
738
+ throw new Error("Primary plugin returned no stream or invalid stream");
739
+ } catch (error) {
740
+ this.debug(`[Stream] Primary failed: ${primary.name}`, error);
741
+ }
742
+
743
+ // Fallback logic...
744
+ const fallbackPlugins = this.getAll()
745
+ .filter((p) => p !== primary && p.name !== primary.name)
746
+ .sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
747
+
748
+ if (fallbackPlugins.length === 0) {
749
+ this.debug(`[Stream] No fallback plugins available`);
750
+ return null;
751
+ }
752
+
753
+ this.debug(`[Stream] Trying ${fallbackPlugins.length} fallback plugins`);
754
+
755
+ const validResults: Array<{ plugin: string; streamInfo: StreamInfo; score: number }> = [];
756
+
757
+ let attempt = 0;
758
+ for (const plugin of fallbackPlugins) {
759
+ attempt++;
760
+ if (attempt > (this.options.maxFallbackAttempts ?? 3)) {
761
+ this.debug(`[Stream] Max attempts reached`);
762
+ break;
763
+ }
764
+
765
+ try {
766
+ this.debug(`[Stream] Fallback ${attempt}: ${plugin.name}`);
767
+ const controller = new AbortController();
768
+
769
+ let result: StreamInfo | null = null;
770
+
771
+ if (plugin.getStream) {
772
+ result = await withTimeout(plugin.getStream(track, controller.signal), timeoutMs, `Timeout: ${plugin.name}`);
773
+ }
774
+
775
+ if (!result?.stream && plugin.getFallback) {
776
+ result = await withTimeout(plugin.getFallback(track, controller.signal), timeoutMs, `Fallback timeout: ${plugin.name}`);
777
+ }
778
+
779
+ if (result?.stream) {
780
+ const similarityScore = this.calculateTrackSimilarity(track, result);
781
+
782
+ if (similarityScore > 0.7) {
783
+ this.debug(`[Stream] Fallback success via ${plugin.name} (score: ${similarityScore})`);
784
+ this.setCachedStream(track, result);
785
+ return result;
786
+ } else {
787
+ validResults.push({
788
+ plugin: plugin.name,
789
+ streamInfo: result,
790
+ score: similarityScore,
791
+ });
792
+ this.debug(`[Stream] Fallback ${plugin.name} returned low similarity (${similarityScore})`);
793
+ }
794
+ }
795
+ } catch (error) {
796
+ this.debug(`[Stream] Fallback failed: ${plugin.name}`, error);
797
+ }
798
+ }
799
+
800
+ if (validResults.length > 0) {
801
+ const bestMatch = validResults.sort((a, b) => b.score - a.score)[0];
802
+ this.debug(`[Stream] Using best available match from ${bestMatch.plugin} (score: ${bestMatch.score})`);
803
+ return bestMatch.streamInfo;
804
+ }
805
+
806
+ this.debug(`[Stream] All plugins failed for track: ${track.title}`);
807
+ return null;
808
+ }
809
+
810
+ async getStream(track: Track): Promise<StreamInfo | null> {
811
+ if (!track) {
812
+ this.debug(`[getStream] No track provided`);
813
+ return null;
814
+ }
815
+
816
+ let primary = this.get(track.source);
817
+ if (!primary) {
818
+ primary = this.findPlugin(track.url);
819
+ }
820
+ if (!primary) {
821
+ this.debug(`[getStream] No plugin found for track: ${track.title}`);
822
+ return null;
823
+ }
824
+
825
+ return this.getStreamWithDedupe(track, primary);
826
+ }
827
+
828
+ async getRelatedTracks(track: Track): Promise<Track[]> {
829
+ if (!track) return [];
830
+
831
+ const timeoutMs = this.options.extractorTimeout ?? 15000;
832
+ const limit = 20;
833
+ const minSimilarityScore = 10;
834
+
835
+ const relatedPlugins = this.getAll()
836
+ .filter((p) => typeof p.getRelatedTracks === "function")
837
+ .sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
838
+
839
+ if (relatedPlugins.length === 0) {
840
+ return [];
841
+ }
842
+
843
+ const history = this.player.queue.previousTracks;
844
+ const historyUrls = new Set(history.map((t) => t.url));
845
+ const currentTrackUrl = track.url;
846
+
847
+ const results: Track[] = [];
848
+
849
+ const batchSize = 3;
850
+ for (let i = 0; i < relatedPlugins.length; i += batchSize) {
851
+ const batch = relatedPlugins.slice(i, i + batchSize);
852
+ const batchResults = await Promise.allSettled(
853
+ batch.map(async (plugin) => {
854
+ try {
855
+ const related = await withTimeout(
856
+ plugin.getRelatedTracks!(track, { limit, history }),
857
+ timeoutMs,
858
+ `Timeout ${plugin.name}`,
859
+ );
860
+ return Array.isArray(related) ? related : [];
861
+ } catch (err) {
862
+ return [];
863
+ }
864
+ }),
865
+ );
866
+
867
+ for (const result of batchResults) {
868
+ if (result.status === "fulfilled") {
869
+ results.push(...result.value);
870
+ }
871
+ }
872
+ }
873
+
874
+ if (results.length === 0) return [];
875
+
876
+ const unique = new Map<string, Track>();
877
+ for (const t of results) {
878
+ if (!unique.has(t.url) && t.url !== currentTrackUrl && !historyUrls.has(t.url)) {
879
+ unique.set(t.url, t);
880
+ }
881
+ }
882
+
883
+ const ranked = Array.from(unique.values())
884
+ .map((t) => ({ track: t, score: scoreTrack(track, t) }))
885
+ .filter((item) => item.score >= minSimilarityScore)
886
+ .sort((a, b) => b.score - a.score)
887
+ .slice(0, limit)
888
+ .map((x) => x.track);
889
+
890
+ this.debug(`[RelatedTracks] Found ${ranked.length} related tracks`);
891
+ return ranked;
892
+ }
893
+
894
+ //#endregion
895
+
896
+ //#region Utility methods
897
+
898
+ clearStreamCache(): void {
899
+ const size = this.streamCache.size;
900
+ this.streamCache.clear();
901
+ this.debug(`[StreamCache] Cleared ${size} entries`);
902
+ }
903
+
904
+ getStats(): object {
905
+ return {
906
+ totalPlugins: this.plugins.size,
907
+ pluginNames: Array.from(this.plugins.keys()),
908
+ streamCacheSize: this.streamCache.size,
909
+ searchCacheSize: this.searchCache.size,
910
+ pendingStreams: this.pendingStreams.size,
911
+ pendingSearches: this.pendingSearches.size,
912
+ };
913
+ }
914
+
915
+ private async validateStreamMatchesTrack(streamInfo: StreamInfo, expectedTrack: Track): Promise<boolean> {
916
+ const actualTitle = streamInfo.metadata?.title || streamInfo.metadata?.originalTitle;
917
+
918
+ if (!actualTitle) {
919
+ return true;
920
+ }
921
+
922
+ const similarity = this.calculateTrackSimilarity(expectedTrack, { title: actualTitle } as Track);
923
+ return similarity > 0.6;
924
+ }
925
+
926
+ private calculateTrackSimilarity(track1: Track, track2: Partial<Track>): number {
927
+ const normalize = (str: string) =>
928
+ str
929
+ .toLowerCase()
930
+ .replace(/\(.*?\)|\[.*?\]/g, "")
931
+ .replace(/[^a-z0-9\s]/g, "")
932
+ .replace(/\s+/g, " ")
933
+ .trim();
934
+
935
+ const title1 = normalize(track1.title);
936
+ const title2 = normalize(track2.title || "");
937
+
938
+ if (title1 === title2) return 1.0;
939
+ if (title1.includes(title2) || title2.includes(title1)) return 0.8;
940
+
941
+ const words1 = new Set(title1.split(" "));
942
+ const words2 = new Set(title2.split(" "));
943
+ const intersection = new Set([...words1].filter((x) => words2.has(x)));
944
+ const union = new Set([...words1, ...words2]);
945
+
946
+ return intersection.size / union.size;
947
+ }
948
+
949
+ //#endregion
950
+ }