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