ziplayer 0.3.11 → 0.3.12-dev.0

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