ziplayer 0.3.11 → 0.3.12
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/dist/extensions/index.d.ts +1 -0
- package/dist/extensions/index.d.ts.map +1 -1
- package/dist/extensions/index.js +9 -1
- package/dist/extensions/index.js.map +1 -1
- package/dist/structures/FilterManager.d.ts.map +1 -1
- package/dist/structures/FilterManager.js +1 -2
- package/dist/structures/FilterManager.js.map +1 -1
- package/dist/structures/Player.d.ts.map +1 -1
- package/dist/structures/Player.js +24 -14
- package/dist/structures/Player.js.map +1 -1
- package/dist/structures/PlayerManager.d.ts +11 -7
- package/dist/structures/PlayerManager.d.ts.map +1 -1
- package/dist/structures/PlayerManager.js +39 -20
- package/dist/structures/PlayerManager.js.map +1 -1
- package/dist/structures/PreloadManager.d.ts.map +1 -1
- package/dist/structures/PreloadManager.js +10 -7
- package/dist/structures/PreloadManager.js.map +1 -1
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/index.js.map +1 -1
- package/package.json +1 -1
- package/src/extensions/index.ts +9 -1
- package/src/plugins/index.ts +975 -975
- package/src/structures/FilterManager.ts +1 -2
- package/src/structures/Player.ts +31 -20
- package/src/structures/PlayerManager.ts +46 -21
- package/src/structures/PreloadManager.ts +11 -7
- package/src/types/index.ts +2 -0
package/src/plugins/index.ts
CHANGED
|
@@ -1,975 +1,975 @@
|
|
|
1
|
-
import { BasePlugin } from "./BasePlugin";
|
|
2
|
-
import { withTimeout } from "../utils/timeout";
|
|
3
|
-
import type { Track, StreamInfo, SearchResult, SearchScore } from "../types";
|
|
4
|
-
import type { PlayerManager } from "../structures/PlayerManager";
|
|
5
|
-
import type { Player } from "../structures/Player";
|
|
6
|
-
import { StreamManager } from "../structures/StreamManager";
|
|
7
|
-
|
|
8
|
-
type PluginManagerOptions = {
|
|
9
|
-
extractorTimeout: number | undefined;
|
|
10
|
-
maxFallbackAttempts?: number;
|
|
11
|
-
enableCache?: boolean;
|
|
12
|
-
searchCacheTTL?: number;
|
|
13
|
-
searchMinScore?: number;
|
|
14
|
-
};
|
|
15
|
-
|
|
16
|
-
export { BasePlugin } from "./BasePlugin";
|
|
17
|
-
|
|
18
|
-
function levenshtein(a: string, b: string): number {
|
|
19
|
-
const matrix = Array.from({ length: a.length + 1 }, () => new Array(b.length + 1).fill(0));
|
|
20
|
-
|
|
21
|
-
for (let i = 0; i <= a.length; i++) matrix[i][0] = i;
|
|
22
|
-
for (let j = 0; j <= b.length; j++) matrix[0][j] = j;
|
|
23
|
-
|
|
24
|
-
for (let i = 1; i <= a.length; i++) {
|
|
25
|
-
for (let j = 1; j <= b.length; j++) {
|
|
26
|
-
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
27
|
-
matrix[i][j] = Math.min(matrix[i - 1][j] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j - 1] + cost);
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
return matrix[a.length][b.length];
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
function similarity(a: string, b: string): number {
|
|
35
|
-
if (!a || !b) return 0;
|
|
36
|
-
const dist = levenshtein(a, b);
|
|
37
|
-
const maxLen = Math.max(a.length, b.length);
|
|
38
|
-
return 1 - dist / maxLen;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function normalize(str: string): string {
|
|
42
|
-
return str
|
|
43
|
-
.toLowerCase()
|
|
44
|
-
.replace(/\(.*?\)|\[.*?\]/g, "")
|
|
45
|
-
.replace(/[^a-z0-9\s]/g, "")
|
|
46
|
-
.replace(/\s+/g, " ")
|
|
47
|
-
.trim();
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
function getContentQualityScore(track: Track): number {
|
|
51
|
-
const title = normalize(track.title);
|
|
52
|
-
|
|
53
|
-
let score = 0;
|
|
54
|
-
|
|
55
|
-
// ưu tiên nhạc official
|
|
56
|
-
for (const k of OFFICIAL_KEYWORDS) {
|
|
57
|
-
if (title.includes(k)) score += 80;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
// nhạc thường
|
|
61
|
-
for (const k of MUSIC_KEYWORDS) {
|
|
62
|
-
if (title.includes(k)) score += 10;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
// phạt content rác
|
|
66
|
-
for (const k of BAD_KEYWORDS) {
|
|
67
|
-
if (title.includes(k)) score -= 120;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
// youtube verified / artist channel
|
|
71
|
-
const author = normalize(track?.author || track?.metadata?.author || "");
|
|
72
|
-
|
|
73
|
-
if (author.includes("vevo") || author.includes("official") || author.includes("topic")) {
|
|
74
|
-
score += 20;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
// phạt video quá dài (podcast/review)
|
|
78
|
-
if (track.duration && track.duration > 15 * 60 * 1000) {
|
|
79
|
-
score -= 20;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
return score;
|
|
83
|
-
}
|
|
84
|
-
function dedupeTracks(tracks: Track[]): Track[] {
|
|
85
|
-
const unique = new Map<string, Track>();
|
|
86
|
-
|
|
87
|
-
for (const track of tracks) {
|
|
88
|
-
const key = normalize(`${track.title} ${track?.author || track?.metadata?.author || ""}`);
|
|
89
|
-
|
|
90
|
-
const existing = unique.get(key);
|
|
91
|
-
|
|
92
|
-
if (!existing) {
|
|
93
|
-
unique.set(key, track);
|
|
94
|
-
continue;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
const oldScore = getContentQualityScore(existing);
|
|
98
|
-
const newScore = getContentQualityScore(track);
|
|
99
|
-
|
|
100
|
-
if (newScore > oldScore) {
|
|
101
|
-
unique.set(key, track);
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
return [...unique.values()];
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
// const MUSIC_KEYWORDS = ["official", "mv", "audio", "lyrics", "remix", "cover", "ft", "feat", "prod", "music video"];
|
|
109
|
-
const NON_MUSIC_KEYWORDS = ["reaction", "review", "podcast", "interview", "vlog", "live stream", "news", "tiktok"];
|
|
110
|
-
|
|
111
|
-
const OFFICIAL_KEYWORDS = ["official", "official video", "official audio", "music video", "mv", "audio", "visualizer", "lyrics"];
|
|
112
|
-
|
|
113
|
-
const MUSIC_KEYWORDS = [
|
|
114
|
-
"song",
|
|
115
|
-
"track",
|
|
116
|
-
"remix",
|
|
117
|
-
"cover",
|
|
118
|
-
"instrumental",
|
|
119
|
-
"karaoke",
|
|
120
|
-
"nightcore",
|
|
121
|
-
"sped up",
|
|
122
|
-
"slowed",
|
|
123
|
-
"feat",
|
|
124
|
-
"ft",
|
|
125
|
-
];
|
|
126
|
-
|
|
127
|
-
const BAD_KEYWORDS = [
|
|
128
|
-
"reaction",
|
|
129
|
-
"review",
|
|
130
|
-
"podcast",
|
|
131
|
-
"interview",
|
|
132
|
-
"vlog",
|
|
133
|
-
"livestream",
|
|
134
|
-
"live stream",
|
|
135
|
-
"news",
|
|
136
|
-
"analysis",
|
|
137
|
-
"commentary",
|
|
138
|
-
"tiktok",
|
|
139
|
-
"shorts",
|
|
140
|
-
"funny",
|
|
141
|
-
"meme",
|
|
142
|
-
];
|
|
143
|
-
|
|
144
|
-
function detectContentType(title: string): number {
|
|
145
|
-
const t = title.toLowerCase();
|
|
146
|
-
let score = 0;
|
|
147
|
-
for (const k of MUSIC_KEYWORDS) if (t.includes(k)) score += 2;
|
|
148
|
-
for (const k of NON_MUSIC_KEYWORDS) if (t.includes(k)) score -= 3;
|
|
149
|
-
return score;
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
function tokenOverlap(a: string, b: string): number {
|
|
153
|
-
const setA = new Set(a.split(" "));
|
|
154
|
-
const setB = new Set(b.split(" "));
|
|
155
|
-
let match = 0;
|
|
156
|
-
for (const word of setA) if (setB.has(word)) match++;
|
|
157
|
-
return match / Math.max(setA.size, setB.size);
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
function scoreTrack(base: Track, candidate: Track): number {
|
|
161
|
-
const titleA = normalize(base.title);
|
|
162
|
-
const titleB = normalize(candidate.title);
|
|
163
|
-
let score = 0;
|
|
164
|
-
score += similarity(titleA, titleB) * 50;
|
|
165
|
-
score += tokenOverlap(titleA, titleB) * 30;
|
|
166
|
-
score += detectContentType(candidate.title);
|
|
167
|
-
return score;
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
type ExtractedMediaId = {
|
|
171
|
-
platform: "youtube" | "spotify" | "soundcloud" | "unknown";
|
|
172
|
-
id: string;
|
|
173
|
-
url: string;
|
|
174
|
-
};
|
|
175
|
-
|
|
176
|
-
export function extractMediaId(input: string): ExtractedMediaId | null {
|
|
177
|
-
try {
|
|
178
|
-
const url = new URL(input);
|
|
179
|
-
|
|
180
|
-
const host = url.hostname.replace(/^www\./, "").toLowerCase();
|
|
181
|
-
|
|
182
|
-
// =====================================================
|
|
183
|
-
// YOUTUBE
|
|
184
|
-
// =====================================================
|
|
185
|
-
if (host === "youtube.com" || host === "m.youtube.com" || host === "music.youtube.com") {
|
|
186
|
-
const videoId = url.searchParams.get("v");
|
|
187
|
-
|
|
188
|
-
if (videoId) {
|
|
189
|
-
return {
|
|
190
|
-
platform: "youtube",
|
|
191
|
-
id: videoId,
|
|
192
|
-
url: `https://www.youtube.com/watch?v=${videoId}`,
|
|
193
|
-
};
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
if (host === "youtu.be") {
|
|
198
|
-
const id = url.pathname.slice(1);
|
|
199
|
-
|
|
200
|
-
if (id) {
|
|
201
|
-
return {
|
|
202
|
-
platform: "youtube",
|
|
203
|
-
id,
|
|
204
|
-
url: `https://www.youtube.com/watch?v=${id}`,
|
|
205
|
-
};
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
// =====================================================
|
|
210
|
-
// SPOTIFY
|
|
211
|
-
// =====================================================
|
|
212
|
-
if (host === "open.spotify.com") {
|
|
213
|
-
const parts = url.pathname.split("/").filter(Boolean);
|
|
214
|
-
|
|
215
|
-
// track/playlist/album/episode/show
|
|
216
|
-
if (parts.length >= 2) {
|
|
217
|
-
const [, id] = parts;
|
|
218
|
-
|
|
219
|
-
return {
|
|
220
|
-
platform: "spotify",
|
|
221
|
-
id,
|
|
222
|
-
url: `https://open.spotify.com/${parts[0]}/${id}`,
|
|
223
|
-
};
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
// spotify uri
|
|
228
|
-
if (input.startsWith("spotify:")) {
|
|
229
|
-
const parts = input.split(":");
|
|
230
|
-
|
|
231
|
-
if (parts.length >= 3) {
|
|
232
|
-
return {
|
|
233
|
-
platform: "spotify",
|
|
234
|
-
id: parts[2],
|
|
235
|
-
url: `https://open.spotify.com/${parts[1]}/${parts[2]}`,
|
|
236
|
-
};
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
// =====================================================
|
|
241
|
-
// SOUNDCLOUD
|
|
242
|
-
// =====================================================
|
|
243
|
-
if (host === "soundcloud.com") {
|
|
244
|
-
const path = url.pathname.split("/").filter(Boolean);
|
|
245
|
-
|
|
246
|
-
if (path.length >= 2) {
|
|
247
|
-
const id = `${path[0]}/${path[1]}`;
|
|
248
|
-
|
|
249
|
-
return {
|
|
250
|
-
platform: "soundcloud",
|
|
251
|
-
id,
|
|
252
|
-
url: `https://soundcloud.com/${id}`,
|
|
253
|
-
};
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
return null;
|
|
258
|
-
} catch {
|
|
259
|
-
return null;
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
interface SearchCacheEntry {
|
|
264
|
-
result: SearchResult;
|
|
265
|
-
timestamp: number;
|
|
266
|
-
expiresAt: number;
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
interface StreamCacheEntry {
|
|
270
|
-
streamInfo: StreamInfo;
|
|
271
|
-
timestamp: number;
|
|
272
|
-
expiresAt: number;
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
export class PluginManager {
|
|
276
|
-
private options: PluginManagerOptions;
|
|
277
|
-
private player: Player;
|
|
278
|
-
private manager: PlayerManager;
|
|
279
|
-
private plugins: Map<string, BasePlugin> = new Map();
|
|
280
|
-
private streamCache: Map<string, StreamCacheEntry> = new Map();
|
|
281
|
-
private searchCache: Map<string, SearchCacheEntry> = new Map();
|
|
282
|
-
private readonly STREAM_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
|
283
|
-
private pendingStreams: Map<string, Promise<StreamInfo | null>> = new Map(); // Dedupe in-flight requests
|
|
284
|
-
private pendingSearches: Map<string, Promise<SearchResult | null>> = new Map(); // Dedupe search requests
|
|
285
|
-
private streamManager?: StreamManager;
|
|
286
|
-
|
|
287
|
-
constructor(player: Player, manager: PlayerManager, options: PluginManagerOptions) {
|
|
288
|
-
this.player = player;
|
|
289
|
-
this.manager = manager;
|
|
290
|
-
this.options = {
|
|
291
|
-
maxFallbackAttempts: 3,
|
|
292
|
-
enableCache: true,
|
|
293
|
-
searchMinScore: 30,
|
|
294
|
-
searchCacheTTL: 2 * 60 * 1000, // 2 minutes
|
|
295
|
-
...options,
|
|
296
|
-
};
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
debug(message?: any, ...optionalParams: any[]): void {
|
|
300
|
-
if (this.manager.debugEnabled) {
|
|
301
|
-
this.manager.emit("debug", `[Plugins] ${message}`, ...optionalParams);
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
register(plugin: BasePlugin): void {
|
|
306
|
-
if (this.plugins.has(plugin.name)) {
|
|
307
|
-
this.debug(`Overwriting existing plugin: ${plugin.name}`);
|
|
308
|
-
}
|
|
309
|
-
plugin.priority ??= 0;
|
|
310
|
-
this.plugins.set(plugin.name, plugin);
|
|
311
|
-
this.debug(`Registered plugin: ${plugin.name} (priority: ${plugin.priority})`);
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
unregister(name: string): boolean {
|
|
315
|
-
const removed = this.plugins.delete(name);
|
|
316
|
-
if (removed) this.debug(`Unregistered plugin: ${name}`);
|
|
317
|
-
return removed;
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
get(name: string): BasePlugin | undefined {
|
|
321
|
-
return this.plugins.get(name);
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
getAll(): BasePlugin[] {
|
|
325
|
-
return Array.from(this.plugins.values()).sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
findPlugin(query: string): BasePlugin | undefined {
|
|
329
|
-
for (const plugin of this.getAll()) {
|
|
330
|
-
if (plugin.name && query.toLowerCase().includes(plugin.name.toLowerCase())) {
|
|
331
|
-
return plugin;
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
return this.getAll().find((plugin) => plugin.canHandle?.(query) ?? false);
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
clear(): void {
|
|
338
|
-
this.plugins.clear();
|
|
339
|
-
this.streamCache.clear();
|
|
340
|
-
this.searchCache.clear();
|
|
341
|
-
this.pendingStreams.clear();
|
|
342
|
-
this.pendingSearches.clear();
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
setStreamManager(manager: StreamManager): void {
|
|
346
|
-
this.streamManager = manager;
|
|
347
|
-
}
|
|
348
|
-
//#region Search advanced scoring
|
|
349
|
-
|
|
350
|
-
private getSearchCacheKey(query: string, requestedBy: string): string {
|
|
351
|
-
return `${query.toLowerCase().trim()}:${requestedBy}`;
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
private getCachedSearch(query: string, requestedBy: string): SearchResult | null {
|
|
355
|
-
if (!this.options.enableCache) return null;
|
|
356
|
-
|
|
357
|
-
const key = this.getSearchCacheKey(query, requestedBy);
|
|
358
|
-
const cached = this.searchCache.get(key);
|
|
359
|
-
|
|
360
|
-
if (cached && Date.now() < cached.expiresAt) {
|
|
361
|
-
this.debug(`[SearchCache] Hit for query: ${query}`);
|
|
362
|
-
return cached.result;
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
if (cached) {
|
|
366
|
-
this.debug(`[SearchCache] Expired for query: ${query}`);
|
|
367
|
-
this.searchCache.delete(key);
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
return null;
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
private setCachedSearch(query: string, requestedBy: string, result: SearchResult): void {
|
|
374
|
-
if (!this.options.enableCache) return;
|
|
375
|
-
|
|
376
|
-
const key = this.getSearchCacheKey(query, requestedBy);
|
|
377
|
-
this.searchCache.set(key, {
|
|
378
|
-
result,
|
|
379
|
-
timestamp: Date.now(),
|
|
380
|
-
expiresAt: Date.now() + (this.options.searchCacheTTL ?? 2 * 60 * 1000),
|
|
381
|
-
});
|
|
382
|
-
this.debug(`[SearchCache] Stored for query: ${query}, tracks: ${result.tracks.length}`);
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
/**
|
|
386
|
-
* Search with deduplication and evaluation of results
|
|
387
|
-
* @param query Search query
|
|
388
|
-
* @param requestedBy User who requested the search
|
|
389
|
-
* @returns Evaluated search result
|
|
390
|
-
*/
|
|
391
|
-
async search(query: string, requestedBy: string): Promise<SearchResult | null> {
|
|
392
|
-
if (!query || !query.trim()) {
|
|
393
|
-
this.debug(`[Search] Empty query provided`);
|
|
394
|
-
return null;
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
const trimmedQuery = query.trim();
|
|
398
|
-
this.debug(`[Search] Called with query: "${trimmedQuery}", requestedBy: ${requestedBy}`);
|
|
399
|
-
|
|
400
|
-
// Check cache
|
|
401
|
-
const cached = this.getCachedSearch(trimmedQuery, requestedBy);
|
|
402
|
-
if (cached) {
|
|
403
|
-
this.debug(`[Search] Returning cached result for: ${trimmedQuery}`);
|
|
404
|
-
return cached;
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
// Check in-flight request
|
|
408
|
-
const dedupeKey = this.getSearchCacheKey(trimmedQuery, requestedBy);
|
|
409
|
-
if (this.pendingSearches.has(dedupeKey)) {
|
|
410
|
-
this.debug(`[Search] Waiting for in-flight request: ${trimmedQuery}`);
|
|
411
|
-
return this.pendingSearches.get(dedupeKey)!;
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
// Create new search request
|
|
415
|
-
const searchPromise = this.searchInternal(trimmedQuery, requestedBy);
|
|
416
|
-
this.pendingSearches.set(dedupeKey, searchPromise);
|
|
417
|
-
|
|
418
|
-
try {
|
|
419
|
-
const result = await searchPromise;
|
|
420
|
-
|
|
421
|
-
return result;
|
|
422
|
-
} finally {
|
|
423
|
-
this.pendingSearches.delete(dedupeKey);
|
|
424
|
-
}
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
private async searchInternal(query: string, requestedBy: string): Promise<SearchResult | null> {
|
|
428
|
-
const timeoutMs = this.options.extractorTimeout ?? 15000;
|
|
429
|
-
|
|
430
|
-
const plugins = this.getAll().filter((p) => typeof p.search === "function");
|
|
431
|
-
|
|
432
|
-
if (!plugins.length) return null;
|
|
433
|
-
|
|
434
|
-
const settled = await Promise.allSettled(
|
|
435
|
-
plugins.map(async (plugin) => {
|
|
436
|
-
try {
|
|
437
|
-
const result = await withTimeout(plugin.search(query, requestedBy), timeoutMs, `Search timeout for ${plugin.name}`);
|
|
438
|
-
|
|
439
|
-
if (!result?.tracks?.length) {
|
|
440
|
-
return null;
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
// giữ nguyên playlist
|
|
444
|
-
if (result.playlist) {
|
|
445
|
-
return {
|
|
446
|
-
...result,
|
|
447
|
-
tracks: result.tracks.map((track) => ({
|
|
448
|
-
...track,
|
|
449
|
-
source: plugin.name,
|
|
450
|
-
})),
|
|
451
|
-
};
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
return {
|
|
455
|
-
...result,
|
|
456
|
-
tracks: result.tracks.map((track) => ({
|
|
457
|
-
...track,
|
|
458
|
-
source: plugin.name,
|
|
459
|
-
})),
|
|
460
|
-
};
|
|
461
|
-
} catch (e) {
|
|
462
|
-
this.debug(`[Search] ${plugin.name} failed`, e);
|
|
463
|
-
return null;
|
|
464
|
-
}
|
|
465
|
-
}),
|
|
466
|
-
);
|
|
467
|
-
|
|
468
|
-
const results: SearchResult[] = [];
|
|
469
|
-
|
|
470
|
-
for (const result of settled) {
|
|
471
|
-
if (result.status === "fulfilled" && result.value) {
|
|
472
|
-
results.push(result.value);
|
|
473
|
-
}
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
if (!results.length) {
|
|
477
|
-
return null;
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
const playlistResult = results.find((r) => r.playlist);
|
|
481
|
-
|
|
482
|
-
if (playlistResult) {
|
|
483
|
-
this.setCachedSearch(query, requestedBy, playlistResult);
|
|
484
|
-
|
|
485
|
-
this.debug(`[Search] Returning playlist: ${playlistResult.playlist?.name} (${playlistResult.tracks.length} tracks)`);
|
|
486
|
-
|
|
487
|
-
return playlistResult;
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
const allTracks = results.flatMap((r) => r.tracks);
|
|
491
|
-
|
|
492
|
-
const deduped = dedupeTracks(allTracks);
|
|
493
|
-
|
|
494
|
-
const queryMedia = extractMediaId(query);
|
|
495
|
-
|
|
496
|
-
const prioritized: Track[] = [];
|
|
497
|
-
const normal: Track[] = [];
|
|
498
|
-
|
|
499
|
-
for (const track of deduped) {
|
|
500
|
-
let shouldPrioritize = false;
|
|
501
|
-
|
|
502
|
-
// exact url
|
|
503
|
-
if (track.url?.toLowerCase() === query.toLowerCase()) {
|
|
504
|
-
shouldPrioritize = true;
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
// exact media id
|
|
508
|
-
const media = extractMediaId(track.url || "");
|
|
509
|
-
|
|
510
|
-
if (!shouldPrioritize && queryMedia && media && queryMedia.platform === media.platform && queryMedia.id === media.id) {
|
|
511
|
-
shouldPrioritize = true;
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
if (shouldPrioritize) {
|
|
515
|
-
prioritized.push(track);
|
|
516
|
-
} else {
|
|
517
|
-
normal.push(track);
|
|
518
|
-
}
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
const tracks = [...prioritized, ...normal];
|
|
522
|
-
|
|
523
|
-
const finalResult: SearchResult = {
|
|
524
|
-
query,
|
|
525
|
-
tracks,
|
|
526
|
-
source: "multi-search",
|
|
527
|
-
};
|
|
528
|
-
|
|
529
|
-
this.setCachedSearch(query, requestedBy, finalResult);
|
|
530
|
-
|
|
531
|
-
this.debug(`[Search] Aggregated ${tracks.length} tracks from ${plugins.length} plugins`);
|
|
532
|
-
return finalResult;
|
|
533
|
-
}
|
|
534
|
-
/**
|
|
535
|
-
* Get plugin priority groups info for debugging
|
|
536
|
-
*/
|
|
537
|
-
getPriorityGroupsInfo(): { priority: number; plugins: string[]; count: number }[] {
|
|
538
|
-
const groups = new Map<number, string[]>();
|
|
539
|
-
|
|
540
|
-
for (const plugin of this.getAll()) {
|
|
541
|
-
const priority = plugin.priority ?? 0;
|
|
542
|
-
if (!groups.has(priority)) {
|
|
543
|
-
groups.set(priority, []);
|
|
544
|
-
}
|
|
545
|
-
groups.get(priority)!.push(plugin.name);
|
|
546
|
-
}
|
|
547
|
-
|
|
548
|
-
return Array.from(groups.entries())
|
|
549
|
-
.map(([priority, plugins]) => ({
|
|
550
|
-
priority,
|
|
551
|
-
plugins,
|
|
552
|
-
count: plugins.length,
|
|
553
|
-
}))
|
|
554
|
-
.sort((a, b) => b.priority - a.priority);
|
|
555
|
-
}
|
|
556
|
-
|
|
557
|
-
/**
|
|
558
|
-
* Clear search cache
|
|
559
|
-
*/
|
|
560
|
-
clearSearchCache(): void {
|
|
561
|
-
const size = this.searchCache.size;
|
|
562
|
-
this.searchCache.clear();
|
|
563
|
-
this.debug(`[SearchCache] Cleared ${size} entries`);
|
|
564
|
-
}
|
|
565
|
-
|
|
566
|
-
/**
|
|
567
|
-
* Get search cache stats
|
|
568
|
-
*/
|
|
569
|
-
getSearchCacheStats(): { size: number; keys: string[] } {
|
|
570
|
-
return {
|
|
571
|
-
size: this.searchCache.size,
|
|
572
|
-
keys: Array.from(this.searchCache.keys()),
|
|
573
|
-
};
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
//#endregion
|
|
577
|
-
|
|
578
|
-
//#region Stream methods (giữ nguyên)
|
|
579
|
-
|
|
580
|
-
private getStreamCacheKey(track: Track): string {
|
|
581
|
-
return `${track.source}:${track.url}:${track.id || track.title}`;
|
|
582
|
-
}
|
|
583
|
-
|
|
584
|
-
private getCachedStream(track: Track): StreamInfo | null {
|
|
585
|
-
if (!this.options.enableCache) return null;
|
|
586
|
-
|
|
587
|
-
const key = this.getStreamCacheKey(track);
|
|
588
|
-
const cached = this.streamCache.get(key);
|
|
589
|
-
|
|
590
|
-
if (cached && Date.now() < cached.expiresAt) {
|
|
591
|
-
const s = cached.streamInfo?.stream;
|
|
592
|
-
if (!s || s.destroyed || (s as any).readable === false) {
|
|
593
|
-
this.debug(`[StreamCache] Dead stream detected, evicting: ${track.title}`);
|
|
594
|
-
this.streamCache.delete(key);
|
|
595
|
-
return null;
|
|
596
|
-
}
|
|
597
|
-
this.debug(`[StreamCache] Hit for track: ${track.title}`);
|
|
598
|
-
return cached.streamInfo;
|
|
599
|
-
}
|
|
600
|
-
|
|
601
|
-
if (cached) {
|
|
602
|
-
this.debug(`[StreamCache] Expired for track: ${track.title}`);
|
|
603
|
-
this.streamCache.delete(key);
|
|
604
|
-
}
|
|
605
|
-
|
|
606
|
-
return null;
|
|
607
|
-
}
|
|
608
|
-
|
|
609
|
-
private setCachedStream(track: Track, streamInfo: StreamInfo): void {
|
|
610
|
-
if (!this.options.enableCache) return;
|
|
611
|
-
|
|
612
|
-
const key = this.getStreamCacheKey(track);
|
|
613
|
-
this.streamCache.set(key, {
|
|
614
|
-
streamInfo,
|
|
615
|
-
timestamp: Date.now(),
|
|
616
|
-
expiresAt: Date.now() + this.STREAM_CACHE_TTL,
|
|
617
|
-
});
|
|
618
|
-
this.debug(`[StreamCache] Stored for track: ${track.title}`);
|
|
619
|
-
}
|
|
620
|
-
|
|
621
|
-
private async getStreamWithDedupe(track: Track, primary: BasePlugin): Promise<StreamInfo | null> {
|
|
622
|
-
const key = this.getStreamCacheKey(track);
|
|
623
|
-
|
|
624
|
-
if (this.pendingStreams.has(key)) {
|
|
625
|
-
this.debug(`[StreamDedupe] Waiting for existing request: ${track.title}`);
|
|
626
|
-
return this.pendingStreams.get(key)!;
|
|
627
|
-
}
|
|
628
|
-
|
|
629
|
-
const promise = this.getStreamInternal(track, primary);
|
|
630
|
-
this.pendingStreams.set(key, promise);
|
|
631
|
-
|
|
632
|
-
try {
|
|
633
|
-
const result = await promise;
|
|
634
|
-
return result;
|
|
635
|
-
} finally {
|
|
636
|
-
this.pendingStreams.delete(key);
|
|
637
|
-
}
|
|
638
|
-
}
|
|
639
|
-
|
|
640
|
-
private async getStreamInternal(track: Track, primary: BasePlugin): Promise<StreamInfo | null> {
|
|
641
|
-
// Reuse existing stream from StreamManager
|
|
642
|
-
if (this.streamManager) {
|
|
643
|
-
const existingStream = this.streamManager.getStreamByTrack(track.id || track.title);
|
|
644
|
-
|
|
645
|
-
if (existingStream) {
|
|
646
|
-
this.debug(`[Stream] Using existing stream from manager`);
|
|
647
|
-
|
|
648
|
-
return {
|
|
649
|
-
stream: existingStream,
|
|
650
|
-
type: "arbitrary",
|
|
651
|
-
};
|
|
652
|
-
}
|
|
653
|
-
}
|
|
654
|
-
|
|
655
|
-
const timeoutMs = this.options.extractorTimeout ?? 50000;
|
|
656
|
-
|
|
657
|
-
// Cache
|
|
658
|
-
const cached = this.getCachedStream(track);
|
|
659
|
-
|
|
660
|
-
if (cached) {
|
|
661
|
-
this.debug(`[Stream] Using cached stream for: ${track.title}`);
|
|
662
|
-
return cached;
|
|
663
|
-
}
|
|
664
|
-
|
|
665
|
-
/**
|
|
666
|
-
* Try resolve stream from plugin
|
|
667
|
-
* Flow:
|
|
668
|
-
* 1. plugin.getStream()
|
|
669
|
-
* 2. validate stream
|
|
670
|
-
* 3. if failed -> plugin.getFallback()
|
|
671
|
-
*/
|
|
672
|
-
const tryPlugin = async (
|
|
673
|
-
plugin: BasePlugin,
|
|
674
|
-
isPrimary: boolean = false,
|
|
675
|
-
): Promise<{ result: StreamInfo | null; similarity: number }> => {
|
|
676
|
-
const controller = new AbortController();
|
|
677
|
-
|
|
678
|
-
let result: StreamInfo | null = null;
|
|
679
|
-
|
|
680
|
-
// =========================================================
|
|
681
|
-
// 1. TRY DIRECT STREAM
|
|
682
|
-
// =========================================================
|
|
683
|
-
if (plugin?.getStream && plugin.validate?.(track.url ?? "")) {
|
|
684
|
-
try {
|
|
685
|
-
this.debug(`[Stream] ${plugin.name} trying direct stream`);
|
|
686
|
-
|
|
687
|
-
result = await withTimeout(plugin.getStream(track, controller.signal), timeoutMs, `${plugin.name} getStream timeout`);
|
|
688
|
-
|
|
689
|
-
if (result?.stream) {
|
|
690
|
-
const valid = await this.validateStreamMatchesTrack(result, track);
|
|
691
|
-
|
|
692
|
-
if (valid) {
|
|
693
|
-
this.debug(`[Stream] ${plugin.name} direct stream success`);
|
|
694
|
-
|
|
695
|
-
return {
|
|
696
|
-
result,
|
|
697
|
-
similarity: 1,
|
|
698
|
-
};
|
|
699
|
-
}
|
|
700
|
-
|
|
701
|
-
this.debug(`[Stream] ${plugin.name} returned invalid stream`);
|
|
702
|
-
} else {
|
|
703
|
-
this.debug(`[Stream] ${plugin.name} no direct stream returned`);
|
|
704
|
-
}
|
|
705
|
-
} catch (error) {
|
|
706
|
-
this.debug(`[Stream] ${plugin.name} getStream failed:`, error instanceof Error ? error.message : error);
|
|
707
|
-
}
|
|
708
|
-
}
|
|
709
|
-
|
|
710
|
-
// =========================================================
|
|
711
|
-
// 2. TRY FALLBACK SEARCH
|
|
712
|
-
// =========================================================
|
|
713
|
-
if (plugin.getFallback) {
|
|
714
|
-
try {
|
|
715
|
-
this.debug(`[Stream] ${plugin.name} trying fallback resolver`);
|
|
716
|
-
|
|
717
|
-
result = await withTimeout(plugin.getFallback(track, controller.signal), timeoutMs, `${plugin.name} fallback timeout`);
|
|
718
|
-
|
|
719
|
-
if (result?.stream) {
|
|
720
|
-
const similarity = this.calculateTrackSimilarity(track, {
|
|
721
|
-
title: result.metadata?.title || result.metadata?.originalTitle || track.title,
|
|
722
|
-
});
|
|
723
|
-
|
|
724
|
-
this.debug(`[Stream] ${plugin.name} fallback success (${similarity})`);
|
|
725
|
-
|
|
726
|
-
return {
|
|
727
|
-
result,
|
|
728
|
-
similarity,
|
|
729
|
-
};
|
|
730
|
-
}
|
|
731
|
-
|
|
732
|
-
this.debug(`[Stream] ${plugin.name} fallback returned no stream`);
|
|
733
|
-
} catch (error) {
|
|
734
|
-
this.debug(`[Stream] ${plugin.name} fallback failed:`, error instanceof Error ? error.message : error);
|
|
735
|
-
}
|
|
736
|
-
}
|
|
737
|
-
|
|
738
|
-
return {
|
|
739
|
-
result: null,
|
|
740
|
-
similarity: 0,
|
|
741
|
-
};
|
|
742
|
-
};
|
|
743
|
-
|
|
744
|
-
// =========================================================
|
|
745
|
-
// PRIMARY PLUGIN
|
|
746
|
-
// =========================================================
|
|
747
|
-
const primaryResult = await tryPlugin(primary, true);
|
|
748
|
-
|
|
749
|
-
if (primaryResult.result?.stream) {
|
|
750
|
-
this.setCachedStream(track, primaryResult.result);
|
|
751
|
-
|
|
752
|
-
return primaryResult.result;
|
|
753
|
-
}
|
|
754
|
-
|
|
755
|
-
// =========================================================
|
|
756
|
-
// FALLBACK PLUGINS
|
|
757
|
-
// =========================================================
|
|
758
|
-
const fallbackPlugins = this.getAll()
|
|
759
|
-
.filter((p) => p !== primary && p.name !== primary.name)
|
|
760
|
-
.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
|
|
761
|
-
|
|
762
|
-
if (fallbackPlugins.length === 0) {
|
|
763
|
-
this.debug(`[Stream] No fallback plugins available`);
|
|
764
|
-
return null;
|
|
765
|
-
}
|
|
766
|
-
|
|
767
|
-
this.debug(`[Stream] Trying ${fallbackPlugins.length} fallback plugins`);
|
|
768
|
-
|
|
769
|
-
const validResults: Array<{
|
|
770
|
-
plugin: string;
|
|
771
|
-
streamInfo: StreamInfo;
|
|
772
|
-
score: number;
|
|
773
|
-
}> = [];
|
|
774
|
-
|
|
775
|
-
let attempt = 0;
|
|
776
|
-
|
|
777
|
-
for (const plugin of fallbackPlugins) {
|
|
778
|
-
attempt++;
|
|
779
|
-
|
|
780
|
-
if (attempt > (this.options.maxFallbackAttempts ?? 3)) {
|
|
781
|
-
this.debug(`[Stream] Max fallback attempts reached`);
|
|
782
|
-
break;
|
|
783
|
-
}
|
|
784
|
-
|
|
785
|
-
const { result, similarity } = await tryPlugin(plugin);
|
|
786
|
-
|
|
787
|
-
if (!result?.stream) {
|
|
788
|
-
continue;
|
|
789
|
-
}
|
|
790
|
-
|
|
791
|
-
// Perfect / good match
|
|
792
|
-
if (similarity >= 0.7) {
|
|
793
|
-
this.debug(`[Stream] Success via fallback ${plugin.name} (score: ${similarity})`);
|
|
794
|
-
|
|
795
|
-
this.setCachedStream(track, result);
|
|
796
|
-
|
|
797
|
-
return result;
|
|
798
|
-
}
|
|
799
|
-
|
|
800
|
-
// Keep low similarity result as backup
|
|
801
|
-
validResults.push({
|
|
802
|
-
plugin: plugin.name,
|
|
803
|
-
streamInfo: result,
|
|
804
|
-
score: similarity,
|
|
805
|
-
});
|
|
806
|
-
|
|
807
|
-
this.debug(`[Stream] ${plugin.name} low similarity match (${similarity})`);
|
|
808
|
-
}
|
|
809
|
-
|
|
810
|
-
// =========================================================
|
|
811
|
-
// BEST AVAILABLE MATCH
|
|
812
|
-
// =========================================================
|
|
813
|
-
if (validResults.length > 0) {
|
|
814
|
-
const bestMatch = validResults.sort((a, b) => b.score - a.score)[0];
|
|
815
|
-
|
|
816
|
-
this.debug(`[Stream] Using best available match from ${bestMatch.plugin} (${bestMatch.score})`);
|
|
817
|
-
|
|
818
|
-
this.setCachedStream(track, bestMatch.streamInfo);
|
|
819
|
-
|
|
820
|
-
return bestMatch.streamInfo;
|
|
821
|
-
}
|
|
822
|
-
|
|
823
|
-
this.debug(`[Stream] All plugins failed for: ${track.title}`);
|
|
824
|
-
|
|
825
|
-
return null;
|
|
826
|
-
}
|
|
827
|
-
async getStream(track: Track): Promise<StreamInfo | null> {
|
|
828
|
-
if (!track) {
|
|
829
|
-
this.debug(`[getStream] No track provided`);
|
|
830
|
-
return null;
|
|
831
|
-
}
|
|
832
|
-
|
|
833
|
-
let primary = this.get(track.source);
|
|
834
|
-
if (!primary) {
|
|
835
|
-
primary = this.findPlugin(track.url);
|
|
836
|
-
}
|
|
837
|
-
if (!primary) {
|
|
838
|
-
this.debug(`[getStream] No plugin found for track: ${track.title}`);
|
|
839
|
-
return null;
|
|
840
|
-
}
|
|
841
|
-
|
|
842
|
-
return this.getStreamWithDedupe(track, primary);
|
|
843
|
-
}
|
|
844
|
-
|
|
845
|
-
hasStreamCandidate(track: Track): boolean {
|
|
846
|
-
if (!track) return false;
|
|
847
|
-
if (this.get(track.source)) return true;
|
|
848
|
-
const query = track.url || track.title || track.source;
|
|
849
|
-
if (!query) return false;
|
|
850
|
-
return !!this.findPlugin(query);
|
|
851
|
-
}
|
|
852
|
-
|
|
853
|
-
async getRelatedTracks(track: Track): Promise<Track[]> {
|
|
854
|
-
if (!track) return [];
|
|
855
|
-
|
|
856
|
-
const timeoutMs = this.options.extractorTimeout ?? 15000;
|
|
857
|
-
const limit = 20;
|
|
858
|
-
const minSimilarityScore = 10;
|
|
859
|
-
|
|
860
|
-
const relatedPlugins = this.getAll()
|
|
861
|
-
.filter((p) => typeof p.getRelatedTracks === "function")
|
|
862
|
-
.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
|
|
863
|
-
|
|
864
|
-
if (relatedPlugins.length === 0) {
|
|
865
|
-
return [];
|
|
866
|
-
}
|
|
867
|
-
|
|
868
|
-
const history = this.player?.queue?.previousTracks || [];
|
|
869
|
-
const historyUrls = new Set(history.map((t) => t.url));
|
|
870
|
-
const currentTrackUrl = track.url;
|
|
871
|
-
|
|
872
|
-
const results: Track[] = [];
|
|
873
|
-
|
|
874
|
-
const batchSize = 3;
|
|
875
|
-
for (let i = 0; i < relatedPlugins.length; i += batchSize) {
|
|
876
|
-
const batch = relatedPlugins.slice(i, i + batchSize);
|
|
877
|
-
const batchResults = await Promise.allSettled(
|
|
878
|
-
batch.map(async (plugin) => {
|
|
879
|
-
try {
|
|
880
|
-
const related = await withTimeout(
|
|
881
|
-
plugin.getRelatedTracks!(track, { limit, history }),
|
|
882
|
-
timeoutMs,
|
|
883
|
-
`Timeout ${plugin.name}`,
|
|
884
|
-
);
|
|
885
|
-
return Array.isArray(related) ? related : [];
|
|
886
|
-
} catch (err) {
|
|
887
|
-
return [];
|
|
888
|
-
}
|
|
889
|
-
}),
|
|
890
|
-
);
|
|
891
|
-
|
|
892
|
-
for (const result of batchResults) {
|
|
893
|
-
if (result.status === "fulfilled") {
|
|
894
|
-
results.push(...result.value);
|
|
895
|
-
}
|
|
896
|
-
}
|
|
897
|
-
}
|
|
898
|
-
|
|
899
|
-
if (results.length === 0) return [];
|
|
900
|
-
|
|
901
|
-
const unique = new Map<string, Track>();
|
|
902
|
-
for (const t of results) {
|
|
903
|
-
if (!unique.has(t.url) && t.url !== currentTrackUrl && !historyUrls.has(t.url)) {
|
|
904
|
-
unique.set(t.url, t);
|
|
905
|
-
}
|
|
906
|
-
}
|
|
907
|
-
|
|
908
|
-
const ranked = Array.from(unique.values())
|
|
909
|
-
.map((t) => ({ track: t, score: scoreTrack(track, t) }))
|
|
910
|
-
.filter((item) => item.score >= minSimilarityScore)
|
|
911
|
-
.sort((a, b) => b.score - a.score)
|
|
912
|
-
.slice(0, limit)
|
|
913
|
-
.map((x) => x.track);
|
|
914
|
-
|
|
915
|
-
this.debug(`[RelatedTracks] Found ${ranked.length} related tracks`);
|
|
916
|
-
return ranked;
|
|
917
|
-
}
|
|
918
|
-
|
|
919
|
-
//#endregion
|
|
920
|
-
|
|
921
|
-
//#region Utility methods
|
|
922
|
-
|
|
923
|
-
clearStreamCache(): void {
|
|
924
|
-
const size = this.streamCache.size;
|
|
925
|
-
this.streamCache.clear();
|
|
926
|
-
this.debug(`[StreamCache] Cleared ${size} entries`);
|
|
927
|
-
}
|
|
928
|
-
|
|
929
|
-
getStats(): object {
|
|
930
|
-
return {
|
|
931
|
-
totalPlugins: this.plugins.size,
|
|
932
|
-
pluginNames: Array.from(this.plugins.keys()),
|
|
933
|
-
streamCacheSize: this.streamCache.size,
|
|
934
|
-
searchCacheSize: this.searchCache.size,
|
|
935
|
-
pendingStreams: this.pendingStreams.size,
|
|
936
|
-
pendingSearches: this.pendingSearches.size,
|
|
937
|
-
};
|
|
938
|
-
}
|
|
939
|
-
|
|
940
|
-
private async validateStreamMatchesTrack(streamInfo: StreamInfo, expectedTrack: Track): Promise<boolean> {
|
|
941
|
-
const actualTitle = streamInfo.metadata?.title || streamInfo.metadata?.originalTitle;
|
|
942
|
-
|
|
943
|
-
if (!actualTitle) {
|
|
944
|
-
return true;
|
|
945
|
-
}
|
|
946
|
-
|
|
947
|
-
const similarity = this.calculateTrackSimilarity(expectedTrack, { title: actualTitle } as Track);
|
|
948
|
-
return similarity > 0.6;
|
|
949
|
-
}
|
|
950
|
-
|
|
951
|
-
private calculateTrackSimilarity(track1: Track, track2: Partial<Track>): number {
|
|
952
|
-
const normalize = (str: string) =>
|
|
953
|
-
str
|
|
954
|
-
.toLowerCase()
|
|
955
|
-
.replace(/\(.*?\)|\[.*?\]/g, "")
|
|
956
|
-
.replace(/[^a-z0-9\s]/g, "")
|
|
957
|
-
.replace(/\s+/g, " ")
|
|
958
|
-
.trim();
|
|
959
|
-
|
|
960
|
-
const title1 = normalize(track1.title);
|
|
961
|
-
const title2 = normalize(track2.title || "");
|
|
962
|
-
|
|
963
|
-
if (title1 === title2) return 1.0;
|
|
964
|
-
if (title1.includes(title2) || title2.includes(title1)) return 0.8;
|
|
965
|
-
|
|
966
|
-
const words1 = new Set(title1.split(" "));
|
|
967
|
-
const words2 = new Set(title2.split(" "));
|
|
968
|
-
const intersection = new Set([...words1].filter((x) => words2.has(x)));
|
|
969
|
-
const union = new Set([...words1, ...words2]);
|
|
970
|
-
|
|
971
|
-
return intersection.size / union.size;
|
|
972
|
-
}
|
|
973
|
-
|
|
974
|
-
//#endregion
|
|
975
|
-
}
|
|
1
|
+
import { BasePlugin } from "./BasePlugin";
|
|
2
|
+
import { withTimeout } from "../utils/timeout";
|
|
3
|
+
import type { Track, StreamInfo, SearchResult, SearchScore } from "../types";
|
|
4
|
+
import type { PlayerManager } from "../structures/PlayerManager";
|
|
5
|
+
import type { Player } from "../structures/Player";
|
|
6
|
+
import { StreamManager } from "../structures/StreamManager";
|
|
7
|
+
|
|
8
|
+
type PluginManagerOptions = {
|
|
9
|
+
extractorTimeout: number | undefined;
|
|
10
|
+
maxFallbackAttempts?: number;
|
|
11
|
+
enableCache?: boolean;
|
|
12
|
+
searchCacheTTL?: number;
|
|
13
|
+
searchMinScore?: number;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export { BasePlugin } from "./BasePlugin";
|
|
17
|
+
|
|
18
|
+
function levenshtein(a: string, b: string): number {
|
|
19
|
+
const matrix = Array.from({ length: a.length + 1 }, () => new Array(b.length + 1).fill(0));
|
|
20
|
+
|
|
21
|
+
for (let i = 0; i <= a.length; i++) matrix[i][0] = i;
|
|
22
|
+
for (let j = 0; j <= b.length; j++) matrix[0][j] = j;
|
|
23
|
+
|
|
24
|
+
for (let i = 1; i <= a.length; i++) {
|
|
25
|
+
for (let j = 1; j <= b.length; j++) {
|
|
26
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
27
|
+
matrix[i][j] = Math.min(matrix[i - 1][j] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j - 1] + cost);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return matrix[a.length][b.length];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function similarity(a: string, b: string): number {
|
|
35
|
+
if (!a || !b) return 0;
|
|
36
|
+
const dist = levenshtein(a, b);
|
|
37
|
+
const maxLen = Math.max(a.length, b.length);
|
|
38
|
+
return 1 - dist / maxLen;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function normalize(str: string): string {
|
|
42
|
+
return str
|
|
43
|
+
.toLowerCase()
|
|
44
|
+
.replace(/\(.*?\)|\[.*?\]/g, "")
|
|
45
|
+
.replace(/[^a-z0-9\s]/g, "")
|
|
46
|
+
.replace(/\s+/g, " ")
|
|
47
|
+
.trim();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function getContentQualityScore(track: Track): number {
|
|
51
|
+
const title = normalize(track.title);
|
|
52
|
+
|
|
53
|
+
let score = 0;
|
|
54
|
+
|
|
55
|
+
// ưu tiên nhạc official
|
|
56
|
+
for (const k of OFFICIAL_KEYWORDS) {
|
|
57
|
+
if (title.includes(k)) score += 80;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// nhạc thường
|
|
61
|
+
for (const k of MUSIC_KEYWORDS) {
|
|
62
|
+
if (title.includes(k)) score += 10;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// phạt content rác
|
|
66
|
+
for (const k of BAD_KEYWORDS) {
|
|
67
|
+
if (title.includes(k)) score -= 120;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// youtube verified / artist channel
|
|
71
|
+
const author = normalize(track?.author || track?.metadata?.author || "");
|
|
72
|
+
|
|
73
|
+
if (author.includes("vevo") || author.includes("official") || author.includes("topic")) {
|
|
74
|
+
score += 20;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// phạt video quá dài (podcast/review)
|
|
78
|
+
if (track.duration && track.duration > 15 * 60 * 1000) {
|
|
79
|
+
score -= 20;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return score;
|
|
83
|
+
}
|
|
84
|
+
function dedupeTracks(tracks: Track[]): Track[] {
|
|
85
|
+
const unique = new Map<string, Track>();
|
|
86
|
+
|
|
87
|
+
for (const track of tracks) {
|
|
88
|
+
const key = normalize(`${track.title} ${track?.author || track?.metadata?.author || ""}`);
|
|
89
|
+
|
|
90
|
+
const existing = unique.get(key);
|
|
91
|
+
|
|
92
|
+
if (!existing) {
|
|
93
|
+
unique.set(key, track);
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const oldScore = getContentQualityScore(existing);
|
|
98
|
+
const newScore = getContentQualityScore(track);
|
|
99
|
+
|
|
100
|
+
if (newScore > oldScore) {
|
|
101
|
+
unique.set(key, track);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return [...unique.values()];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// const MUSIC_KEYWORDS = ["official", "mv", "audio", "lyrics", "remix", "cover", "ft", "feat", "prod", "music video"];
|
|
109
|
+
const NON_MUSIC_KEYWORDS = ["reaction", "review", "podcast", "interview", "vlog", "live stream", "news", "tiktok"];
|
|
110
|
+
|
|
111
|
+
const OFFICIAL_KEYWORDS = ["official", "official video", "official audio", "music video", "mv", "audio", "visualizer", "lyrics"];
|
|
112
|
+
|
|
113
|
+
const MUSIC_KEYWORDS = [
|
|
114
|
+
"song",
|
|
115
|
+
"track",
|
|
116
|
+
"remix",
|
|
117
|
+
"cover",
|
|
118
|
+
"instrumental",
|
|
119
|
+
"karaoke",
|
|
120
|
+
"nightcore",
|
|
121
|
+
"sped up",
|
|
122
|
+
"slowed",
|
|
123
|
+
"feat",
|
|
124
|
+
"ft",
|
|
125
|
+
];
|
|
126
|
+
|
|
127
|
+
const BAD_KEYWORDS = [
|
|
128
|
+
"reaction",
|
|
129
|
+
"review",
|
|
130
|
+
"podcast",
|
|
131
|
+
"interview",
|
|
132
|
+
"vlog",
|
|
133
|
+
"livestream",
|
|
134
|
+
"live stream",
|
|
135
|
+
"news",
|
|
136
|
+
"analysis",
|
|
137
|
+
"commentary",
|
|
138
|
+
"tiktok",
|
|
139
|
+
"shorts",
|
|
140
|
+
"funny",
|
|
141
|
+
"meme",
|
|
142
|
+
];
|
|
143
|
+
|
|
144
|
+
function detectContentType(title: string): number {
|
|
145
|
+
const t = title.toLowerCase();
|
|
146
|
+
let score = 0;
|
|
147
|
+
for (const k of MUSIC_KEYWORDS) if (t.includes(k)) score += 2;
|
|
148
|
+
for (const k of NON_MUSIC_KEYWORDS) if (t.includes(k)) score -= 3;
|
|
149
|
+
return score;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function tokenOverlap(a: string, b: string): number {
|
|
153
|
+
const setA = new Set(a.split(" "));
|
|
154
|
+
const setB = new Set(b.split(" "));
|
|
155
|
+
let match = 0;
|
|
156
|
+
for (const word of setA) if (setB.has(word)) match++;
|
|
157
|
+
return match / Math.max(setA.size, setB.size);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function scoreTrack(base: Track, candidate: Track): number {
|
|
161
|
+
const titleA = normalize(base.title);
|
|
162
|
+
const titleB = normalize(candidate.title);
|
|
163
|
+
let score = 0;
|
|
164
|
+
score += similarity(titleA, titleB) * 50;
|
|
165
|
+
score += tokenOverlap(titleA, titleB) * 30;
|
|
166
|
+
score += detectContentType(candidate.title);
|
|
167
|
+
return score;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
type ExtractedMediaId = {
|
|
171
|
+
platform: "youtube" | "spotify" | "soundcloud" | "unknown";
|
|
172
|
+
id: string;
|
|
173
|
+
url: string;
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
export function extractMediaId(input: string): ExtractedMediaId | null {
|
|
177
|
+
try {
|
|
178
|
+
const url = new URL(input);
|
|
179
|
+
|
|
180
|
+
const host = url.hostname.replace(/^www\./, "").toLowerCase();
|
|
181
|
+
|
|
182
|
+
// =====================================================
|
|
183
|
+
// YOUTUBE
|
|
184
|
+
// =====================================================
|
|
185
|
+
if (host === "youtube.com" || host === "m.youtube.com" || host === "music.youtube.com") {
|
|
186
|
+
const videoId = url.searchParams.get("v");
|
|
187
|
+
|
|
188
|
+
if (videoId) {
|
|
189
|
+
return {
|
|
190
|
+
platform: "youtube",
|
|
191
|
+
id: videoId,
|
|
192
|
+
url: `https://www.youtube.com/watch?v=${videoId}`,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (host === "youtu.be") {
|
|
198
|
+
const id = url.pathname.slice(1);
|
|
199
|
+
|
|
200
|
+
if (id) {
|
|
201
|
+
return {
|
|
202
|
+
platform: "youtube",
|
|
203
|
+
id,
|
|
204
|
+
url: `https://www.youtube.com/watch?v=${id}`,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// =====================================================
|
|
210
|
+
// SPOTIFY
|
|
211
|
+
// =====================================================
|
|
212
|
+
if (host === "open.spotify.com") {
|
|
213
|
+
const parts = url.pathname.split("/").filter(Boolean);
|
|
214
|
+
|
|
215
|
+
// track/playlist/album/episode/show
|
|
216
|
+
if (parts.length >= 2) {
|
|
217
|
+
const [, id] = parts;
|
|
218
|
+
|
|
219
|
+
return {
|
|
220
|
+
platform: "spotify",
|
|
221
|
+
id,
|
|
222
|
+
url: `https://open.spotify.com/${parts[0]}/${id}`,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// spotify uri
|
|
228
|
+
if (input.startsWith("spotify:")) {
|
|
229
|
+
const parts = input.split(":");
|
|
230
|
+
|
|
231
|
+
if (parts.length >= 3) {
|
|
232
|
+
return {
|
|
233
|
+
platform: "spotify",
|
|
234
|
+
id: parts[2],
|
|
235
|
+
url: `https://open.spotify.com/${parts[1]}/${parts[2]}`,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// =====================================================
|
|
241
|
+
// SOUNDCLOUD
|
|
242
|
+
// =====================================================
|
|
243
|
+
if (host === "soundcloud.com") {
|
|
244
|
+
const path = url.pathname.split("/").filter(Boolean);
|
|
245
|
+
|
|
246
|
+
if (path.length >= 2) {
|
|
247
|
+
const id = `${path[0]}/${path[1]}`;
|
|
248
|
+
|
|
249
|
+
return {
|
|
250
|
+
platform: "soundcloud",
|
|
251
|
+
id,
|
|
252
|
+
url: `https://soundcloud.com/${id}`,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return null;
|
|
258
|
+
} catch {
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
interface SearchCacheEntry {
|
|
264
|
+
result: SearchResult;
|
|
265
|
+
timestamp: number;
|
|
266
|
+
expiresAt: number;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
interface StreamCacheEntry {
|
|
270
|
+
streamInfo: StreamInfo;
|
|
271
|
+
timestamp: number;
|
|
272
|
+
expiresAt: number;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export class PluginManager {
|
|
276
|
+
private options: PluginManagerOptions;
|
|
277
|
+
private player: Player;
|
|
278
|
+
private manager: PlayerManager;
|
|
279
|
+
private plugins: Map<string, BasePlugin> = new Map();
|
|
280
|
+
private streamCache: Map<string, StreamCacheEntry> = new Map();
|
|
281
|
+
private searchCache: Map<string, SearchCacheEntry> = new Map();
|
|
282
|
+
private readonly STREAM_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
|
283
|
+
private pendingStreams: Map<string, Promise<StreamInfo | null>> = new Map(); // Dedupe in-flight requests
|
|
284
|
+
private pendingSearches: Map<string, Promise<SearchResult | null>> = new Map(); // Dedupe search requests
|
|
285
|
+
private streamManager?: StreamManager;
|
|
286
|
+
|
|
287
|
+
constructor(player: Player, manager: PlayerManager, options: PluginManagerOptions) {
|
|
288
|
+
this.player = player;
|
|
289
|
+
this.manager = manager;
|
|
290
|
+
this.options = {
|
|
291
|
+
maxFallbackAttempts: 3,
|
|
292
|
+
enableCache: true,
|
|
293
|
+
searchMinScore: 30,
|
|
294
|
+
searchCacheTTL: 2 * 60 * 1000, // 2 minutes
|
|
295
|
+
...options,
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
debug(message?: any, ...optionalParams: any[]): void {
|
|
300
|
+
if (this.manager.debugEnabled) {
|
|
301
|
+
this.manager.emit("debug", `[Plugins] ${message}`, ...optionalParams);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
register(plugin: BasePlugin): void {
|
|
306
|
+
if (this.plugins.has(plugin.name)) {
|
|
307
|
+
this.debug(`Overwriting existing plugin: ${plugin.name}`);
|
|
308
|
+
}
|
|
309
|
+
plugin.priority ??= 0;
|
|
310
|
+
this.plugins.set(plugin.name, plugin);
|
|
311
|
+
this.debug(`Registered plugin: ${plugin.name} (priority: ${plugin.priority})`);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
unregister(name: string): boolean {
|
|
315
|
+
const removed = this.plugins.delete(name);
|
|
316
|
+
if (removed) this.debug(`Unregistered plugin: ${name}`);
|
|
317
|
+
return removed;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
get(name: string): BasePlugin | undefined {
|
|
321
|
+
return this.plugins.get(name);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
getAll(): BasePlugin[] {
|
|
325
|
+
return Array.from(this.plugins.values()).sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
findPlugin(query: string): BasePlugin | undefined {
|
|
329
|
+
for (const plugin of this.getAll()) {
|
|
330
|
+
if (plugin.name && query.toLowerCase().includes(plugin.name.toLowerCase())) {
|
|
331
|
+
return plugin;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
return this.getAll().find((plugin) => plugin.canHandle?.(query) ?? false);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
clear(): void {
|
|
338
|
+
this.plugins.clear();
|
|
339
|
+
this.streamCache.clear();
|
|
340
|
+
this.searchCache.clear();
|
|
341
|
+
this.pendingStreams.clear();
|
|
342
|
+
this.pendingSearches.clear();
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
setStreamManager(manager: StreamManager): void {
|
|
346
|
+
this.streamManager = manager;
|
|
347
|
+
}
|
|
348
|
+
//#region Search advanced scoring
|
|
349
|
+
|
|
350
|
+
private getSearchCacheKey(query: string, requestedBy: string): string {
|
|
351
|
+
return `${query.toLowerCase().trim()}:${requestedBy}`;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
private getCachedSearch(query: string, requestedBy: string): SearchResult | null {
|
|
355
|
+
if (!this.options.enableCache) return null;
|
|
356
|
+
|
|
357
|
+
const key = this.getSearchCacheKey(query, requestedBy);
|
|
358
|
+
const cached = this.searchCache.get(key);
|
|
359
|
+
|
|
360
|
+
if (cached && Date.now() < cached.expiresAt) {
|
|
361
|
+
this.debug(`[SearchCache] Hit for query: ${query}`);
|
|
362
|
+
return cached.result;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
if (cached) {
|
|
366
|
+
this.debug(`[SearchCache] Expired for query: ${query}`);
|
|
367
|
+
this.searchCache.delete(key);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
return null;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
private setCachedSearch(query: string, requestedBy: string, result: SearchResult): void {
|
|
374
|
+
if (!this.options.enableCache) return;
|
|
375
|
+
|
|
376
|
+
const key = this.getSearchCacheKey(query, requestedBy);
|
|
377
|
+
this.searchCache.set(key, {
|
|
378
|
+
result,
|
|
379
|
+
timestamp: Date.now(),
|
|
380
|
+
expiresAt: Date.now() + (this.options.searchCacheTTL ?? 2 * 60 * 1000),
|
|
381
|
+
});
|
|
382
|
+
this.debug(`[SearchCache] Stored for query: ${query}, tracks: ${result.tracks.length}`);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Search with deduplication and evaluation of results
|
|
387
|
+
* @param query Search query
|
|
388
|
+
* @param requestedBy User who requested the search
|
|
389
|
+
* @returns Evaluated search result
|
|
390
|
+
*/
|
|
391
|
+
async search(query: string, requestedBy: string): Promise<SearchResult | null> {
|
|
392
|
+
if (!query || !query.trim()) {
|
|
393
|
+
this.debug(`[Search] Empty query provided`);
|
|
394
|
+
return null;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const trimmedQuery = query.trim();
|
|
398
|
+
this.debug(`[Search] Called with query: "${trimmedQuery}", requestedBy: ${requestedBy}`);
|
|
399
|
+
|
|
400
|
+
// Check cache
|
|
401
|
+
const cached = this.getCachedSearch(trimmedQuery, requestedBy);
|
|
402
|
+
if (cached) {
|
|
403
|
+
this.debug(`[Search] Returning cached result for: ${trimmedQuery}`);
|
|
404
|
+
return cached;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// Check in-flight request
|
|
408
|
+
const dedupeKey = this.getSearchCacheKey(trimmedQuery, requestedBy);
|
|
409
|
+
if (this.pendingSearches.has(dedupeKey)) {
|
|
410
|
+
this.debug(`[Search] Waiting for in-flight request: ${trimmedQuery}`);
|
|
411
|
+
return this.pendingSearches.get(dedupeKey)!;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// Create new search request
|
|
415
|
+
const searchPromise = this.searchInternal(trimmedQuery, requestedBy);
|
|
416
|
+
this.pendingSearches.set(dedupeKey, searchPromise);
|
|
417
|
+
|
|
418
|
+
try {
|
|
419
|
+
const result = await searchPromise;
|
|
420
|
+
|
|
421
|
+
return result;
|
|
422
|
+
} finally {
|
|
423
|
+
this.pendingSearches.delete(dedupeKey);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
private async searchInternal(query: string, requestedBy: string): Promise<SearchResult | null> {
|
|
428
|
+
const timeoutMs = this.options.extractorTimeout ?? 15000;
|
|
429
|
+
|
|
430
|
+
const plugins = this.getAll().filter((p) => typeof p.search === "function");
|
|
431
|
+
|
|
432
|
+
if (!plugins.length) return null;
|
|
433
|
+
|
|
434
|
+
const settled = await Promise.allSettled(
|
|
435
|
+
plugins.map(async (plugin) => {
|
|
436
|
+
try {
|
|
437
|
+
const result = await withTimeout(plugin.search(query, requestedBy), timeoutMs, `Search timeout for ${plugin.name}`);
|
|
438
|
+
|
|
439
|
+
if (!result?.tracks?.length) {
|
|
440
|
+
return null;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// giữ nguyên playlist
|
|
444
|
+
if (result.playlist) {
|
|
445
|
+
return {
|
|
446
|
+
...result,
|
|
447
|
+
tracks: result.tracks.map((track) => ({
|
|
448
|
+
...track,
|
|
449
|
+
source: plugin.name,
|
|
450
|
+
})),
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
return {
|
|
455
|
+
...result,
|
|
456
|
+
tracks: result.tracks.map((track) => ({
|
|
457
|
+
...track,
|
|
458
|
+
source: plugin.name,
|
|
459
|
+
})),
|
|
460
|
+
};
|
|
461
|
+
} catch (e) {
|
|
462
|
+
this.debug(`[Search] ${plugin.name} failed`, e);
|
|
463
|
+
return null;
|
|
464
|
+
}
|
|
465
|
+
}),
|
|
466
|
+
);
|
|
467
|
+
|
|
468
|
+
const results: SearchResult[] = [];
|
|
469
|
+
|
|
470
|
+
for (const result of settled) {
|
|
471
|
+
if (result.status === "fulfilled" && result.value) {
|
|
472
|
+
results.push(result.value);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
if (!results.length) {
|
|
477
|
+
return null;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const playlistResult = results.find((r) => r.playlist);
|
|
481
|
+
|
|
482
|
+
if (playlistResult) {
|
|
483
|
+
this.setCachedSearch(query, requestedBy, playlistResult);
|
|
484
|
+
|
|
485
|
+
this.debug(`[Search] Returning playlist: ${playlistResult.playlist?.name} (${playlistResult.tracks.length} tracks)`);
|
|
486
|
+
|
|
487
|
+
return playlistResult;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
const allTracks = results.flatMap((r) => r.tracks);
|
|
491
|
+
|
|
492
|
+
const deduped = dedupeTracks(allTracks);
|
|
493
|
+
|
|
494
|
+
const queryMedia = extractMediaId(query);
|
|
495
|
+
|
|
496
|
+
const prioritized: Track[] = [];
|
|
497
|
+
const normal: Track[] = [];
|
|
498
|
+
|
|
499
|
+
for (const track of deduped) {
|
|
500
|
+
let shouldPrioritize = false;
|
|
501
|
+
|
|
502
|
+
// exact url
|
|
503
|
+
if (track.url?.toLowerCase() === query.toLowerCase()) {
|
|
504
|
+
shouldPrioritize = true;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// exact media id
|
|
508
|
+
const media = extractMediaId(track.url || "");
|
|
509
|
+
|
|
510
|
+
if (!shouldPrioritize && queryMedia && media && queryMedia.platform === media.platform && queryMedia.id === media.id) {
|
|
511
|
+
shouldPrioritize = true;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
if (shouldPrioritize) {
|
|
515
|
+
prioritized.push(track);
|
|
516
|
+
} else {
|
|
517
|
+
normal.push(track);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const tracks = [...prioritized, ...normal];
|
|
522
|
+
|
|
523
|
+
const finalResult: SearchResult = {
|
|
524
|
+
query,
|
|
525
|
+
tracks,
|
|
526
|
+
source: "multi-search",
|
|
527
|
+
};
|
|
528
|
+
|
|
529
|
+
this.setCachedSearch(query, requestedBy, finalResult);
|
|
530
|
+
|
|
531
|
+
this.debug(`[Search] Aggregated ${tracks.length} tracks from ${plugins.length} plugins`);
|
|
532
|
+
return finalResult;
|
|
533
|
+
}
|
|
534
|
+
/**
|
|
535
|
+
* Get plugin priority groups info for debugging
|
|
536
|
+
*/
|
|
537
|
+
getPriorityGroupsInfo(): { priority: number; plugins: string[]; count: number }[] {
|
|
538
|
+
const groups = new Map<number, string[]>();
|
|
539
|
+
|
|
540
|
+
for (const plugin of this.getAll()) {
|
|
541
|
+
const priority = plugin.priority ?? 0;
|
|
542
|
+
if (!groups.has(priority)) {
|
|
543
|
+
groups.set(priority, []);
|
|
544
|
+
}
|
|
545
|
+
groups.get(priority)!.push(plugin.name);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
return Array.from(groups.entries())
|
|
549
|
+
.map(([priority, plugins]) => ({
|
|
550
|
+
priority,
|
|
551
|
+
plugins,
|
|
552
|
+
count: plugins.length,
|
|
553
|
+
}))
|
|
554
|
+
.sort((a, b) => b.priority - a.priority);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* Clear search cache
|
|
559
|
+
*/
|
|
560
|
+
clearSearchCache(): void {
|
|
561
|
+
const size = this.searchCache.size;
|
|
562
|
+
this.searchCache.clear();
|
|
563
|
+
this.debug(`[SearchCache] Cleared ${size} entries`);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* Get search cache stats
|
|
568
|
+
*/
|
|
569
|
+
getSearchCacheStats(): { size: number; keys: string[] } {
|
|
570
|
+
return {
|
|
571
|
+
size: this.searchCache.size,
|
|
572
|
+
keys: Array.from(this.searchCache.keys()),
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
//#endregion
|
|
577
|
+
|
|
578
|
+
//#region Stream methods (giữ nguyên)
|
|
579
|
+
|
|
580
|
+
private getStreamCacheKey(track: Track): string {
|
|
581
|
+
return `${track.source}:${track.url}:${track.id || track.title}`;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
private getCachedStream(track: Track): StreamInfo | null {
|
|
585
|
+
if (!this.options.enableCache) return null;
|
|
586
|
+
|
|
587
|
+
const key = this.getStreamCacheKey(track);
|
|
588
|
+
const cached = this.streamCache.get(key);
|
|
589
|
+
|
|
590
|
+
if (cached && Date.now() < cached.expiresAt) {
|
|
591
|
+
const s = cached.streamInfo?.stream;
|
|
592
|
+
if (!s || s.destroyed || (s as any).readable === false) {
|
|
593
|
+
this.debug(`[StreamCache] Dead stream detected, evicting: ${track.title}`);
|
|
594
|
+
this.streamCache.delete(key);
|
|
595
|
+
return null;
|
|
596
|
+
}
|
|
597
|
+
this.debug(`[StreamCache] Hit for track: ${track.title}`);
|
|
598
|
+
return cached.streamInfo;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
if (cached) {
|
|
602
|
+
this.debug(`[StreamCache] Expired for track: ${track.title}`);
|
|
603
|
+
this.streamCache.delete(key);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
return null;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
private setCachedStream(track: Track, streamInfo: StreamInfo): void {
|
|
610
|
+
if (!this.options.enableCache) return;
|
|
611
|
+
|
|
612
|
+
const key = this.getStreamCacheKey(track);
|
|
613
|
+
this.streamCache.set(key, {
|
|
614
|
+
streamInfo,
|
|
615
|
+
timestamp: Date.now(),
|
|
616
|
+
expiresAt: Date.now() + this.STREAM_CACHE_TTL,
|
|
617
|
+
});
|
|
618
|
+
this.debug(`[StreamCache] Stored for track: ${track.title}`);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
private async getStreamWithDedupe(track: Track, primary: BasePlugin): Promise<StreamInfo | null> {
|
|
622
|
+
const key = this.getStreamCacheKey(track);
|
|
623
|
+
|
|
624
|
+
if (this.pendingStreams.has(key)) {
|
|
625
|
+
this.debug(`[StreamDedupe] Waiting for existing request: ${track.title}`);
|
|
626
|
+
return this.pendingStreams.get(key)!;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
const promise = this.getStreamInternal(track, primary);
|
|
630
|
+
this.pendingStreams.set(key, promise);
|
|
631
|
+
|
|
632
|
+
try {
|
|
633
|
+
const result = await promise;
|
|
634
|
+
return result;
|
|
635
|
+
} finally {
|
|
636
|
+
this.pendingStreams.delete(key);
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
private async getStreamInternal(track: Track, primary: BasePlugin): Promise<StreamInfo | null> {
|
|
641
|
+
// Reuse existing stream from StreamManager
|
|
642
|
+
if (this.streamManager) {
|
|
643
|
+
const existingStream = this.streamManager.getStreamByTrack(track.id || track.title);
|
|
644
|
+
|
|
645
|
+
if (existingStream) {
|
|
646
|
+
this.debug(`[Stream] Using existing stream from manager`);
|
|
647
|
+
|
|
648
|
+
return {
|
|
649
|
+
stream: existingStream,
|
|
650
|
+
type: "arbitrary",
|
|
651
|
+
};
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
const timeoutMs = this.options.extractorTimeout ?? 50000;
|
|
656
|
+
|
|
657
|
+
// Cache
|
|
658
|
+
const cached = this.getCachedStream(track);
|
|
659
|
+
|
|
660
|
+
if (cached) {
|
|
661
|
+
this.debug(`[Stream] Using cached stream for: ${track.title}`);
|
|
662
|
+
return cached;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/**
|
|
666
|
+
* Try resolve stream from plugin
|
|
667
|
+
* Flow:
|
|
668
|
+
* 1. plugin.getStream()
|
|
669
|
+
* 2. validate stream
|
|
670
|
+
* 3. if failed -> plugin.getFallback()
|
|
671
|
+
*/
|
|
672
|
+
const tryPlugin = async (
|
|
673
|
+
plugin: BasePlugin,
|
|
674
|
+
isPrimary: boolean = false,
|
|
675
|
+
): Promise<{ result: StreamInfo | null; similarity: number }> => {
|
|
676
|
+
const controller = new AbortController();
|
|
677
|
+
|
|
678
|
+
let result: StreamInfo | null = null;
|
|
679
|
+
|
|
680
|
+
// =========================================================
|
|
681
|
+
// 1. TRY DIRECT STREAM
|
|
682
|
+
// =========================================================
|
|
683
|
+
if (plugin?.getStream && plugin.validate?.(track.url ?? "")) {
|
|
684
|
+
try {
|
|
685
|
+
this.debug(`[Stream] ${plugin.name} trying direct stream`);
|
|
686
|
+
|
|
687
|
+
result = await withTimeout(plugin.getStream(track, controller.signal), timeoutMs, `${plugin.name} getStream timeout`);
|
|
688
|
+
|
|
689
|
+
if (result?.stream) {
|
|
690
|
+
const valid = await this.validateStreamMatchesTrack(result, track);
|
|
691
|
+
|
|
692
|
+
if (valid) {
|
|
693
|
+
this.debug(`[Stream] ${plugin.name} direct stream success`);
|
|
694
|
+
|
|
695
|
+
return {
|
|
696
|
+
result,
|
|
697
|
+
similarity: 1,
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
this.debug(`[Stream] ${plugin.name} returned invalid stream`);
|
|
702
|
+
} else {
|
|
703
|
+
this.debug(`[Stream] ${plugin.name} no direct stream returned`);
|
|
704
|
+
}
|
|
705
|
+
} catch (error) {
|
|
706
|
+
this.debug(`[Stream] ${plugin.name} getStream failed:`, error instanceof Error ? error.message : error);
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
// =========================================================
|
|
711
|
+
// 2. TRY FALLBACK SEARCH
|
|
712
|
+
// =========================================================
|
|
713
|
+
if (plugin.getFallback) {
|
|
714
|
+
try {
|
|
715
|
+
this.debug(`[Stream] ${plugin.name} trying fallback resolver`);
|
|
716
|
+
|
|
717
|
+
result = await withTimeout(plugin.getFallback(track, controller.signal), timeoutMs, `${plugin.name} fallback timeout`);
|
|
718
|
+
|
|
719
|
+
if (result?.stream) {
|
|
720
|
+
const similarity = this.calculateTrackSimilarity(track, {
|
|
721
|
+
title: result.metadata?.title || result.metadata?.originalTitle || track.title,
|
|
722
|
+
});
|
|
723
|
+
|
|
724
|
+
this.debug(`[Stream] ${plugin.name} fallback success (${similarity})`);
|
|
725
|
+
|
|
726
|
+
return {
|
|
727
|
+
result,
|
|
728
|
+
similarity,
|
|
729
|
+
};
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
this.debug(`[Stream] ${plugin.name} fallback returned no stream`);
|
|
733
|
+
} catch (error) {
|
|
734
|
+
this.debug(`[Stream] ${plugin.name} fallback failed:`, error instanceof Error ? error.message : error);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
return {
|
|
739
|
+
result: null,
|
|
740
|
+
similarity: 0,
|
|
741
|
+
};
|
|
742
|
+
};
|
|
743
|
+
|
|
744
|
+
// =========================================================
|
|
745
|
+
// PRIMARY PLUGIN
|
|
746
|
+
// =========================================================
|
|
747
|
+
const primaryResult = await tryPlugin(primary, true);
|
|
748
|
+
|
|
749
|
+
if (primaryResult.result?.stream) {
|
|
750
|
+
this.setCachedStream(track, primaryResult.result);
|
|
751
|
+
|
|
752
|
+
return primaryResult.result;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
// =========================================================
|
|
756
|
+
// FALLBACK PLUGINS
|
|
757
|
+
// =========================================================
|
|
758
|
+
const fallbackPlugins = this.getAll()
|
|
759
|
+
.filter((p) => p !== primary && p.name !== primary.name)
|
|
760
|
+
.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
|
|
761
|
+
|
|
762
|
+
if (fallbackPlugins.length === 0) {
|
|
763
|
+
this.debug(`[Stream] No fallback plugins available`);
|
|
764
|
+
return null;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
this.debug(`[Stream] Trying ${fallbackPlugins.length} fallback plugins`);
|
|
768
|
+
|
|
769
|
+
const validResults: Array<{
|
|
770
|
+
plugin: string;
|
|
771
|
+
streamInfo: StreamInfo;
|
|
772
|
+
score: number;
|
|
773
|
+
}> = [];
|
|
774
|
+
|
|
775
|
+
let attempt = 0;
|
|
776
|
+
|
|
777
|
+
for (const plugin of fallbackPlugins) {
|
|
778
|
+
attempt++;
|
|
779
|
+
|
|
780
|
+
if (attempt > (this.options.maxFallbackAttempts ?? 3)) {
|
|
781
|
+
this.debug(`[Stream] Max fallback attempts reached`);
|
|
782
|
+
break;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
const { result, similarity } = await tryPlugin(plugin);
|
|
786
|
+
|
|
787
|
+
if (!result?.stream) {
|
|
788
|
+
continue;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
// Perfect / good match
|
|
792
|
+
if (similarity >= 0.7) {
|
|
793
|
+
this.debug(`[Stream] Success via fallback ${plugin.name} (score: ${similarity})`);
|
|
794
|
+
|
|
795
|
+
this.setCachedStream(track, result);
|
|
796
|
+
|
|
797
|
+
return result;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
// Keep low similarity result as backup
|
|
801
|
+
validResults.push({
|
|
802
|
+
plugin: plugin.name,
|
|
803
|
+
streamInfo: result,
|
|
804
|
+
score: similarity,
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
this.debug(`[Stream] ${plugin.name} low similarity match (${similarity})`);
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
// =========================================================
|
|
811
|
+
// BEST AVAILABLE MATCH
|
|
812
|
+
// =========================================================
|
|
813
|
+
if (validResults.length > 0) {
|
|
814
|
+
const bestMatch = validResults.sort((a, b) => b.score - a.score)[0];
|
|
815
|
+
|
|
816
|
+
this.debug(`[Stream] Using best available match from ${bestMatch.plugin} (${bestMatch.score})`);
|
|
817
|
+
|
|
818
|
+
this.setCachedStream(track, bestMatch.streamInfo);
|
|
819
|
+
|
|
820
|
+
return bestMatch.streamInfo;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
this.debug(`[Stream] All plugins failed for: ${track.title}`);
|
|
824
|
+
|
|
825
|
+
return null;
|
|
826
|
+
}
|
|
827
|
+
async getStream(track: Track): Promise<StreamInfo | null> {
|
|
828
|
+
if (!track) {
|
|
829
|
+
this.debug(`[getStream] No track provided`);
|
|
830
|
+
return null;
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
let primary = this.get(track.source);
|
|
834
|
+
if (!primary) {
|
|
835
|
+
primary = this.findPlugin(track.url);
|
|
836
|
+
}
|
|
837
|
+
if (!primary) {
|
|
838
|
+
this.debug(`[getStream] No plugin found for track: ${track.title}`);
|
|
839
|
+
return null;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
return this.getStreamWithDedupe(track, primary);
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
hasStreamCandidate(track: Track): boolean {
|
|
846
|
+
if (!track) return false;
|
|
847
|
+
if (this.get(track.source)) return true;
|
|
848
|
+
const query = track.url || track.title || track.source;
|
|
849
|
+
if (!query) return false;
|
|
850
|
+
return !!this.findPlugin(query);
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
async getRelatedTracks(track: Track): Promise<Track[]> {
|
|
854
|
+
if (!track) return [];
|
|
855
|
+
|
|
856
|
+
const timeoutMs = this.options.extractorTimeout ?? 15000;
|
|
857
|
+
const limit = 20;
|
|
858
|
+
const minSimilarityScore = 10;
|
|
859
|
+
|
|
860
|
+
const relatedPlugins = this.getAll()
|
|
861
|
+
.filter((p) => typeof p.getRelatedTracks === "function")
|
|
862
|
+
.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
|
|
863
|
+
|
|
864
|
+
if (relatedPlugins.length === 0) {
|
|
865
|
+
return [];
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
const history = this.player?.queue?.previousTracks || [];
|
|
869
|
+
const historyUrls = new Set(history.map((t) => t.url));
|
|
870
|
+
const currentTrackUrl = track.url;
|
|
871
|
+
|
|
872
|
+
const results: Track[] = [];
|
|
873
|
+
|
|
874
|
+
const batchSize = 3;
|
|
875
|
+
for (let i = 0; i < relatedPlugins.length; i += batchSize) {
|
|
876
|
+
const batch = relatedPlugins.slice(i, i + batchSize);
|
|
877
|
+
const batchResults = await Promise.allSettled(
|
|
878
|
+
batch.map(async (plugin) => {
|
|
879
|
+
try {
|
|
880
|
+
const related = await withTimeout(
|
|
881
|
+
plugin.getRelatedTracks!(track, { limit, history }),
|
|
882
|
+
timeoutMs,
|
|
883
|
+
`Timeout ${plugin.name}`,
|
|
884
|
+
);
|
|
885
|
+
return Array.isArray(related) ? related : [];
|
|
886
|
+
} catch (err) {
|
|
887
|
+
return [];
|
|
888
|
+
}
|
|
889
|
+
}),
|
|
890
|
+
);
|
|
891
|
+
|
|
892
|
+
for (const result of batchResults) {
|
|
893
|
+
if (result.status === "fulfilled") {
|
|
894
|
+
results.push(...result.value);
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
if (results.length === 0) return [];
|
|
900
|
+
|
|
901
|
+
const unique = new Map<string, Track>();
|
|
902
|
+
for (const t of results) {
|
|
903
|
+
if (!unique.has(t.url) && t.url !== currentTrackUrl && !historyUrls.has(t.url)) {
|
|
904
|
+
unique.set(t.url, t);
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
const ranked = Array.from(unique.values())
|
|
909
|
+
.map((t) => ({ track: t, score: scoreTrack(track, t) }))
|
|
910
|
+
.filter((item) => item.score >= minSimilarityScore)
|
|
911
|
+
.sort((a, b) => b.score - a.score)
|
|
912
|
+
.slice(0, limit)
|
|
913
|
+
.map((x) => x.track);
|
|
914
|
+
|
|
915
|
+
this.debug(`[RelatedTracks] Found ${ranked.length} related tracks`);
|
|
916
|
+
return ranked;
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
//#endregion
|
|
920
|
+
|
|
921
|
+
//#region Utility methods
|
|
922
|
+
|
|
923
|
+
clearStreamCache(): void {
|
|
924
|
+
const size = this.streamCache.size;
|
|
925
|
+
this.streamCache.clear();
|
|
926
|
+
this.debug(`[StreamCache] Cleared ${size} entries`);
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
getStats(): object {
|
|
930
|
+
return {
|
|
931
|
+
totalPlugins: this.plugins.size,
|
|
932
|
+
pluginNames: Array.from(this.plugins.keys()),
|
|
933
|
+
streamCacheSize: this.streamCache.size,
|
|
934
|
+
searchCacheSize: this.searchCache.size,
|
|
935
|
+
pendingStreams: this.pendingStreams.size,
|
|
936
|
+
pendingSearches: this.pendingSearches.size,
|
|
937
|
+
};
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
private async validateStreamMatchesTrack(streamInfo: StreamInfo, expectedTrack: Track): Promise<boolean> {
|
|
941
|
+
const actualTitle = streamInfo.metadata?.title || streamInfo.metadata?.originalTitle;
|
|
942
|
+
|
|
943
|
+
if (!actualTitle) {
|
|
944
|
+
return true;
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
const similarity = this.calculateTrackSimilarity(expectedTrack, { title: actualTitle } as Track);
|
|
948
|
+
return similarity > 0.6;
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
private calculateTrackSimilarity(track1: Track, track2: Partial<Track>): number {
|
|
952
|
+
const normalize = (str: string) =>
|
|
953
|
+
str
|
|
954
|
+
.toLowerCase()
|
|
955
|
+
.replace(/\(.*?\)|\[.*?\]/g, "")
|
|
956
|
+
.replace(/[^a-z0-9\s]/g, "")
|
|
957
|
+
.replace(/\s+/g, " ")
|
|
958
|
+
.trim();
|
|
959
|
+
|
|
960
|
+
const title1 = normalize(track1.title);
|
|
961
|
+
const title2 = normalize(track2.title || "");
|
|
962
|
+
|
|
963
|
+
if (title1 === title2) return 1.0;
|
|
964
|
+
if (title1.includes(title2) || title2.includes(title1)) return 0.8;
|
|
965
|
+
|
|
966
|
+
const words1 = new Set(title1.split(" "));
|
|
967
|
+
const words2 = new Set(title2.split(" "));
|
|
968
|
+
const intersection = new Set([...words1].filter((x) => words2.has(x)));
|
|
969
|
+
const union = new Set([...words1, ...words2]);
|
|
970
|
+
|
|
971
|
+
return intersection.size / union.size;
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
//#endregion
|
|
975
|
+
}
|