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.
- package/AI-Guide.md +624 -956
- package/README.md +277 -10
- package/dist/extensions/BaseExtension.d.ts +1 -0
- package/dist/extensions/BaseExtension.d.ts.map +1 -1
- package/dist/extensions/BaseExtension.js.map +1 -1
- package/dist/extensions/index.d.ts +38 -3
- package/dist/extensions/index.d.ts.map +1 -1
- package/dist/extensions/index.js +259 -41
- package/dist/extensions/index.js.map +1 -1
- package/dist/persistence/PersistenceManager.d.ts +95 -0
- package/dist/persistence/PersistenceManager.d.ts.map +1 -0
- package/dist/persistence/PersistenceManager.js +975 -0
- package/dist/persistence/PersistenceManager.js.map +1 -0
- package/dist/plugins/BasePlugin.js +1 -1
- package/dist/plugins/BasePlugin.js.map +1 -1
- package/dist/plugins/index.d.ts +73 -8
- package/dist/plugins/index.d.ts.map +1 -1
- package/dist/plugins/index.js +647 -116
- package/dist/plugins/index.js.map +1 -1
- package/dist/structures/FilterManager.js +3 -3
- package/dist/structures/FilterManager.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 +157 -14
- package/dist/structures/Player.d.ts.map +1 -1
- package/dist/structures/Player.js +1163 -188
- package/dist/structures/Player.js.map +1 -1
- package/dist/structures/PlayerManager.d.ts +106 -91
- package/dist/structures/PlayerManager.d.ts.map +1 -1
- package/dist/structures/PlayerManager.js +365 -124
- package/dist/structures/PlayerManager.js.map +1 -1
- package/dist/structures/Queue.d.ts +136 -31
- package/dist/structures/Queue.d.ts.map +1 -1
- package/dist/structures/Queue.js +265 -46
- package/dist/structures/Queue.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 +181 -8
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/index.js.map +1 -1
- package/dist/types/persistence.d.ts +77 -0
- package/dist/types/persistence.d.ts.map +1 -0
- package/dist/types/persistence.js +3 -0
- package/dist/types/persistence.js.map +1 -0
- package/package.json +3 -2
- package/src/extensions/BaseExtension.ts +1 -0
- package/src/extensions/index.ts +320 -37
- package/src/plugins/BasePlugin.ts +1 -1
- package/src/plugins/index.ts +801 -139
- package/src/structures/FilterManager.ts +3 -3
- package/src/structures/Player.ts +2797 -1693
- package/src/structures/PlayerManager.ts +438 -129
- package/src/structures/Queue.ts +300 -55
- 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 +187 -12
- package/src/types/plugin.ts +59 -59
- package/tsconfig.json +0 -1
package/dist/plugins/index.js
CHANGED
|
@@ -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;
|
|
26
|
+
return 1 - dist / maxLen;
|
|
27
27
|
}
|
|
28
28
|
function normalize(str) {
|
|
29
29
|
return str
|
|
30
30
|
.toLowerCase()
|
|
31
|
-
.replace(/\(.*?\)|\[.*?\]/g, "")
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
|
114
|
-
|
|
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
|
-
|
|
138
|
-
this.
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
170
|
-
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
183
|
-
this.debug(`
|
|
184
|
-
|
|
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
|
-
|
|
489
|
+
return null;
|
|
188
490
|
}
|
|
189
491
|
/**
|
|
190
|
-
* Get
|
|
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
|
|
681
|
+
const minSimilarityScore = 10;
|
|
682
|
+
const relatedPlugins = this.getAll()
|
|
203
683
|
.filter((p) => typeof p.getRelatedTracks === "function")
|
|
204
|
-
.sort((a, b) => (
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
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]
|
|
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
|