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
@@ -68,13 +68,17 @@ class PluginManager {
68
68
  constructor(player, manager, options) {
69
69
  this.plugins = new Map();
70
70
  this.streamCache = new Map();
71
+ this.searchCache = new Map();
71
72
  this.STREAM_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
72
73
  this.pendingStreams = new Map(); // Dedupe in-flight requests
74
+ this.pendingSearches = new Map(); // Dedupe search requests
73
75
  this.player = player;
74
76
  this.manager = manager;
75
77
  this.options = {
76
78
  maxFallbackAttempts: 3,
77
79
  enableCache: true,
80
+ searchMinScore: 30,
81
+ searchCacheTTL: 2 * 60 * 1000, // 2 minutes
78
82
  ...options,
79
83
  };
80
84
  }
@@ -104,20 +108,425 @@ class PluginManager {
104
108
  return Array.from(this.plugins.values()).sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
105
109
  }
106
110
  findPlugin(query) {
107
- // First try exact match by source
108
111
  for (const plugin of this.getAll()) {
109
112
  if (plugin.name && query.toLowerCase().includes(plugin.name.toLowerCase())) {
110
113
  return plugin;
111
114
  }
112
115
  }
113
- // Then try canHandle
114
116
  return this.getAll().find((plugin) => plugin.canHandle?.(query) ?? false);
115
117
  }
116
118
  clear() {
117
119
  this.plugins.clear();
118
120
  this.streamCache.clear();
121
+ this.searchCache.clear();
119
122
  this.pendingStreams.clear();
123
+ this.pendingSearches.clear();
120
124
  }
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)
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}%`);
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);
325
+ try {
326
+ const result = await searchPromise;
327
+ return result;
328
+ }
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;
412
+ }
413
+ // Track best result overall
414
+ if (bestScore > bestScoreOverall) {
415
+ bestScoreOverall = bestScore;
416
+ bestResultOverall = pluginResult;
417
+ }
418
+ }
419
+ else {
420
+ this.debug(`[Search] Plugin ${plugin.name} returned ${result.tracks.length} tracks but none passed min score ${minScore}`);
421
+ }
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
+ }
479
+ }
480
+ if (fallbackResult) {
481
+ this.debug(`[Search] Using fallback result with score ${fallbackScore}% (below minimum ${minScore}%)`);
482
+ return fallbackResult;
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;
488
+ }
489
+ return null;
490
+ }
491
+ /**
492
+ * Get plugin priority groups info for debugging
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)
121
530
  getStreamCacheKey(track) {
122
531
  return `${track.source}:${track.url}:${track.id || track.title}`;
123
532
  }
@@ -127,11 +536,11 @@ class PluginManager {
127
536
  const key = this.getStreamCacheKey(track);
128
537
  const cached = this.streamCache.get(key);
129
538
  if (cached && Date.now() < cached.expiresAt) {
130
- this.debug(`[Cache] Hit for track: ${track.title}`);
539
+ this.debug(`[StreamCache] Hit for track: ${track.title}`);
131
540
  return cached.streamInfo;
132
541
  }
133
542
  if (cached) {
134
- this.debug(`[Cache] Expired for track: ${track.title}`);
543
+ this.debug(`[StreamCache] Expired for track: ${track.title}`);
135
544
  this.streamCache.delete(key);
136
545
  }
137
546
  return null;
@@ -145,16 +554,14 @@ class PluginManager {
145
554
  timestamp: Date.now(),
146
555
  expiresAt: Date.now() + this.STREAM_CACHE_TTL,
147
556
  });
148
- this.debug(`[Cache] Stored for track: ${track.title}`);
557
+ this.debug(`[StreamCache] Stored for track: ${track.title}`);
149
558
  }
150
559
  async getStreamWithDedupe(track, primary) {
151
560
  const key = this.getStreamCacheKey(track);
152
- // Check if there's already an in-flight request
153
561
  if (this.pendingStreams.has(key)) {
154
- this.debug(`[Dedupe] Waiting for existing request: ${track.title}`);
562
+ this.debug(`[StreamDedupe] Waiting for existing request: ${track.title}`);
155
563
  return this.pendingStreams.get(key);
156
564
  }
157
- // Create new request
158
565
  const promise = this.getStreamInternal(track, primary);
159
566
  this.pendingStreams.set(key, promise);
160
567
  try {
@@ -166,67 +573,89 @@ class PluginManager {
166
573
  }
167
574
  }
168
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
+ }
169
583
  const timeoutMs = this.options.extractorTimeout ?? 50000;
170
- // Check cache first
171
584
  const cached = this.getCachedStream(track);
172
585
  if (cached)
173
586
  return cached;
174
- // Try primary plugin first
175
587
  try {
176
- this.debug(`[Primary] Trying ${primary.name} for track: ${track.title}`);
588
+ this.debug(`[Stream] Trying ${primary.name} for track: ${track.title}`);
177
589
  const controller = new AbortController();
178
590
  const result = await (0, timeout_1.withTimeout)(primary.getStream(track, controller.signal), timeoutMs, `Primary timeout: ${primary.name}`);
179
591
  if (result?.stream) {
180
- this.debug(`[Primary] Success via ${primary.name}`);
181
- this.setCachedStream(track, result);
182
- return result;
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`);
183
599
  }
184
- throw new Error("Primary plugin returned no stream");
600
+ throw new Error("Primary plugin returned no stream or invalid stream");
185
601
  }
186
602
  catch (error) {
187
- this.debug(`[Primary] Failed: ${primary.name}`, error);
603
+ this.debug(`[Stream] Primary failed: ${primary.name}`, error);
188
604
  }
189
- // Fallback to other plugins
605
+ // Fallback logic...
190
606
  const fallbackPlugins = this.getAll()
191
607
  .filter((p) => p !== primary && p.name !== primary.name)
192
608
  .sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
193
609
  if (fallbackPlugins.length === 0) {
194
- this.debug(`[Fallback] No fallback plugins available`);
610
+ this.debug(`[Stream] No fallback plugins available`);
195
611
  return null;
196
612
  }
197
- this.debug(`[Fallback] Trying ${fallbackPlugins.length} plugins sequentially`);
198
- // Try plugins sequentially to avoid overwhelming sources
613
+ this.debug(`[Stream] Trying ${fallbackPlugins.length} fallback plugins`);
614
+ const validResults = [];
199
615
  let attempt = 0;
200
616
  for (const plugin of fallbackPlugins) {
201
617
  attempt++;
202
618
  if (attempt > (this.options.maxFallbackAttempts ?? 3)) {
203
- this.debug(`[Fallback] Max attempts (${this.options.maxFallbackAttempts}) reached`);
619
+ this.debug(`[Stream] Max attempts reached`);
204
620
  break;
205
621
  }
206
622
  try {
207
- this.debug(`[Fallback] Attempt ${attempt}/${fallbackPlugins.length}: ${plugin.name}`);
623
+ this.debug(`[Stream] Fallback ${attempt}: ${plugin.name}`);
208
624
  const controller = new AbortController();
209
625
  let result = null;
210
- // Try getStream first
211
626
  if (plugin.getStream) {
212
627
  result = await (0, timeout_1.withTimeout)(plugin.getStream(track, controller.signal), timeoutMs, `Timeout: ${plugin.name}`);
213
628
  }
214
- // Try fallback method if getStream failed
215
629
  if (!result?.stream && plugin.getFallback) {
216
- this.debug(`[Fallback] Trying fallback method for ${plugin.name}`);
217
630
  result = await (0, timeout_1.withTimeout)(plugin.getFallback(track, controller.signal), timeoutMs, `Fallback timeout: ${plugin.name}`);
218
631
  }
219
632
  if (result?.stream) {
220
- this.debug(`[Fallback] Success via ${plugin.name}`);
221
- this.setCachedStream(track, result);
222
- return result;
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
+ }
223
647
  }
224
648
  }
225
649
  catch (error) {
226
- this.debug(`[Fallback] Failed: ${plugin.name}`, error);
650
+ this.debug(`[Stream] Fallback failed: ${plugin.name}`, error);
227
651
  }
228
652
  }
229
- this.debug(`[Fallback] All plugins failed for track: ${track.title}`);
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}`);
230
659
  return null;
231
660
  }
232
661
  async getStream(track) {
@@ -234,51 +663,41 @@ class PluginManager {
234
663
  this.debug(`[getStream] No track provided`);
235
664
  return null;
236
665
  }
237
- // Find the most appropriate plugin
238
666
  let primary = this.get(track.source);
239
667
  if (!primary) {
240
668
  primary = this.findPlugin(track.url);
241
669
  }
242
670
  if (!primary) {
243
- this.debug(`[getStream] No plugin found for track: ${track.title} (source: ${track.source})`);
671
+ this.debug(`[getStream] No plugin found for track: ${track.title}`);
244
672
  return null;
245
673
  }
246
674
  return this.getStreamWithDedupe(track, primary);
247
675
  }
248
- /**
249
- * Get related tracks for a given track
250
- * @param {Track} track Track to find related tracks for
251
- * @returns {Promise<Track[]>} Related tracks or empty array
252
- */
253
676
  async getRelatedTracks(track) {
254
677
  if (!track)
255
678
  return [];
256
679
  const timeoutMs = this.options.extractorTimeout ?? 15000;
257
680
  const limit = 20;
258
- const minSimilarityScore = 10; // Minimum score to consider
681
+ const minSimilarityScore = 10;
259
682
  const relatedPlugins = this.getAll()
260
683
  .filter((p) => typeof p.getRelatedTracks === "function")
261
684
  .sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
262
685
  if (relatedPlugins.length === 0) {
263
- this.debug(`[RelatedTracks] No plugins support related tracks`);
264
686
  return [];
265
687
  }
266
688
  const history = this.player.queue.previousTracks;
267
689
  const historyUrls = new Set(history.map((t) => t.url));
268
690
  const currentTrackUrl = track.url;
269
691
  const results = [];
270
- // Try plugins in parallel but with limit
271
692
  const batchSize = 3;
272
693
  for (let i = 0; i < relatedPlugins.length; i += batchSize) {
273
694
  const batch = relatedPlugins.slice(i, i + batchSize);
274
695
  const batchResults = await Promise.allSettled(batch.map(async (plugin) => {
275
696
  try {
276
- this.debug(`[RelatedTracks] Querying ${plugin.name}`);
277
697
  const related = await (0, timeout_1.withTimeout)(plugin.getRelatedTracks(track, { limit, history }), timeoutMs, `Timeout ${plugin.name}`);
278
698
  return Array.isArray(related) ? related : [];
279
699
  }
280
700
  catch (err) {
281
- this.debug(`[RelatedTracks] ${plugin.name} failed`, err);
282
701
  return [];
283
702
  }
284
703
  }));
@@ -288,46 +707,67 @@ class PluginManager {
288
707
  }
289
708
  }
290
709
  }
291
- if (results.length === 0) {
292
- this.debug(`[RelatedTracks] No results from any plugin`);
710
+ if (results.length === 0)
293
711
  return [];
294
- }
295
- // Deduplicate by URL
296
712
  const unique = new Map();
297
713
  for (const t of results) {
298
714
  if (!unique.has(t.url) && t.url !== currentTrackUrl && !historyUrls.has(t.url)) {
299
715
  unique.set(t.url, t);
300
716
  }
301
717
  }
302
- // Score and sort
303
718
  const ranked = Array.from(unique.values())
304
719
  .map((t) => ({ track: t, score: scoreTrack(track, t) }))
305
720
  .filter((item) => item.score >= minSimilarityScore)
306
721
  .sort((a, b) => b.score - a.score)
307
722
  .slice(0, limit)
308
723
  .map((x) => x.track);
309
- this.debug(`[RelatedTracks] Found ${ranked.length} related tracks (filtered from ${results.length})`);
724
+ this.debug(`[RelatedTracks] Found ${ranked.length} related tracks`);
310
725
  return ranked;
311
726
  }
312
- /**
313
- * Clear stream cache
314
- */
727
+ //#endregion
728
+ //#region Utility methods
315
729
  clearStreamCache() {
316
730
  const size = this.streamCache.size;
317
731
  this.streamCache.clear();
318
- this.debug(`[Cache] Cleared ${size} stream cache entries`);
732
+ this.debug(`[StreamCache] Cleared ${size} entries`);
319
733
  }
320
- /**
321
- * Get plugin statistics
322
- */
323
734
  getStats() {
324
735
  return {
325
736
  totalPlugins: this.plugins.size,
326
737
  pluginNames: Array.from(this.plugins.keys()),
327
- cacheSize: this.streamCache.size,
328
- pendingRequests: this.pendingStreams.size,
738
+ streamCacheSize: this.streamCache.size,
739
+ searchCacheSize: this.searchCache.size,
740
+ pendingStreams: this.pendingStreams.size,
741
+ pendingSearches: this.pendingSearches.size,
329
742
  };
330
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
+ }
331
771
  }
332
772
  exports.PluginManager = PluginManager;
333
773
  //# sourceMappingURL=index.js.map