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.
- package/AI-Guide.md +624 -607
- package/README.md +526 -524
- package/dist/plugins/index.d.ts +62 -12
- package/dist/plugins/index.d.ts.map +1 -1
- package/dist/plugins/index.js +497 -57
- package/dist/plugins/index.js.map +1 -1
- package/dist/structures/PersistenceManager.d.ts +96 -0
- package/dist/structures/PersistenceManager.d.ts.map +1 -0
- package/dist/structures/PersistenceManager.js +1008 -0
- package/dist/structures/PersistenceManager.js.map +1 -0
- package/dist/structures/Player.d.ts +109 -18
- package/dist/structures/Player.d.ts.map +1 -1
- package/dist/structures/Player.js +902 -182
- package/dist/structures/Player.js.map +1 -1
- package/dist/structures/PlayerManager.d.ts +1 -22
- package/dist/structures/PlayerManager.d.ts.map +1 -1
- package/dist/structures/PlayerManager.js +1 -73
- package/dist/structures/PlayerManager.js.map +1 -1
- package/dist/structures/StreamManager.d.ts +137 -0
- package/dist/structures/StreamManager.d.ts.map +1 -0
- package/dist/structures/StreamManager.js +420 -0
- package/dist/structures/StreamManager.js.map +1 -0
- package/dist/types/index.d.ts +149 -16
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/index.js +0 -1
- package/dist/types/index.js.map +1 -1
- package/dist/types/persistence.d.ts +3 -2
- package/dist/types/persistence.d.ts.map +1 -1
- package/package.json +47 -47
- package/src/extensions/BaseExtension.ts +36 -36
- package/src/extensions/index.ts +473 -473
- package/src/index.ts +16 -16
- package/src/plugins/BasePlugin.ts +27 -27
- package/src/plugins/index.ts +950 -403
- package/src/structures/FilterManager.ts +303 -303
- package/src/structures/Player.ts +2797 -1970
- package/src/structures/PlayerManager.ts +725 -822
- package/src/structures/Queue.ts +599 -599
- package/src/structures/StreamManager.ts +524 -0
- package/src/types/extension.ts +129 -129
- package/src/types/fillter.ts +264 -264
- package/src/types/index.ts +548 -415
- package/src/types/plugin.ts +59 -59
- package/src/utils/timeout.ts +10 -10
- package/tsconfig.json +22 -22
- package/src/persistence/PersistenceManager.ts +0 -1077
- package/src/types/persistence.ts +0 -85
package/dist/plugins/index.js
CHANGED
|
@@ -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(`[
|
|
539
|
+
this.debug(`[StreamCache] Hit for track: ${track.title}`);
|
|
131
540
|
return cached.streamInfo;
|
|
132
541
|
}
|
|
133
542
|
if (cached) {
|
|
134
|
-
this.debug(`[
|
|
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(`[
|
|
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(`[
|
|
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(`[
|
|
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.
|
|
181
|
-
|
|
182
|
-
|
|
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(`[
|
|
603
|
+
this.debug(`[Stream] Primary failed: ${primary.name}`, error);
|
|
188
604
|
}
|
|
189
|
-
// Fallback
|
|
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(`[
|
|
610
|
+
this.debug(`[Stream] No fallback plugins available`);
|
|
195
611
|
return null;
|
|
196
612
|
}
|
|
197
|
-
this.debug(`[
|
|
198
|
-
|
|
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(`[
|
|
619
|
+
this.debug(`[Stream] Max attempts reached`);
|
|
204
620
|
break;
|
|
205
621
|
}
|
|
206
622
|
try {
|
|
207
|
-
this.debug(`[
|
|
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.
|
|
221
|
-
|
|
222
|
-
|
|
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(`[
|
|
650
|
+
this.debug(`[Stream] Fallback failed: ${plugin.name}`, error);
|
|
227
651
|
}
|
|
228
652
|
}
|
|
229
|
-
|
|
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}
|
|
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;
|
|
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
|
|
724
|
+
this.debug(`[RelatedTracks] Found ${ranked.length} related tracks`);
|
|
310
725
|
return ranked;
|
|
311
726
|
}
|
|
312
|
-
|
|
313
|
-
|
|
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(`[
|
|
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
|
-
|
|
328
|
-
|
|
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
|