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