ziplayer 0.3.5 → 0.3.6

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.
@@ -47,9 +47,100 @@ function normalize(str: string): string {
47
47
  .trim();
48
48
  }
49
49
 
50
- const MUSIC_KEYWORDS = ["official", "mv", "audio", "lyrics", "remix", "cover", "ft", "feat", "prod", "music video"];
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"];
51
109
  const NON_MUSIC_KEYWORDS = ["reaction", "review", "podcast", "interview", "vlog", "live stream", "news", "tiktok"];
52
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
+
53
144
  function detectContentType(title: string): number {
54
145
  const t = title.toLowerCase();
55
146
  let score = 0;
@@ -76,6 +167,99 @@ function scoreTrack(base: Track, candidate: Track): number {
76
167
  return score;
77
168
  }
78
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
+
79
263
  interface SearchCacheEntry {
80
264
  result: SearchResult;
81
265
  timestamp: number;
@@ -219,11 +403,21 @@ export class PluginManager {
219
403
  exactMatch: true,
220
404
  };
221
405
  }
406
+ const queryMedia = extractMediaId(query);
407
+ const trackMedia = extractMediaId(track.url || "");
222
408
 
409
+ if (queryMedia && trackMedia && queryMedia.platform === trackMedia.platform && queryMedia.id === trackMedia.id) {
410
+ return {
411
+ score: 100,
412
+ reason: `${queryMedia.platform} exact ID match`,
413
+ matchedBy: "url",
414
+ exactMatch: true,
415
+ };
416
+ }
223
417
  // 2. Evaluate title match exactly - 100%
224
418
  if (normalizedTitle === normalizedQuery) {
225
419
  return {
226
- score: 100,
420
+ score: 90 + getContentQualityScore(track),
227
421
  reason: "Title matches exactly",
228
422
  matchedBy: "title",
229
423
  exactMatch: true,
@@ -232,11 +426,9 @@ export class PluginManager {
232
426
 
233
427
  // 3. Evaluate title contains query or vice versa - 70-90%
234
428
  if (normalizedTitle.includes(normalizedQuery) && normalizedQuery.length > 5) {
235
- const ratio = normalizedQuery.length / normalizedTitle.length;
236
- const score = 70 + Math.min(20, Math.floor(ratio * 20));
237
429
  return {
238
- score,
239
- reason: `Title contains the query "${query}" (${Math.floor(ratio * 100)}% coverage)`,
430
+ score: 75 + getContentQualityScore(track),
431
+ reason: `Title contains query`,
240
432
  matchedBy: "title",
241
433
  exactMatch: false,
242
434
  };
@@ -257,10 +449,12 @@ export class PluginManager {
257
449
  const simScore = similarity(normalizedTitle, normalizedQuery);
258
450
  const tokenScore = tokenOverlap(normalizedTitle, normalizedQuery);
259
451
  const contentTypeBonus = detectContentType(track.title);
260
-
452
+ const qualityScore = getContentQualityScore(track);
261
453
  // Tính điểm tổng hợp: similarity 60%, token overlap 30%, content type 10%
262
- let finalScore = simScore * 60 + tokenScore * 30 + (contentTypeBonus / 10) * 10;
263
- finalScore = Math.min(70, Math.max(0, Math.floor(finalScore)));
454
+
455
+ let finalScore = simScore * 35 + tokenScore * 25 + qualityScore * 1.5;
456
+
457
+ finalScore = Math.max(0, Math.min(100, Math.floor(finalScore)));
264
458
 
265
459
  if (finalScore >= 20) {
266
460
  let reason = `Similarity ${Math.floor(simScore * 100)}%`;
@@ -286,83 +480,6 @@ export class PluginManager {
286
480
  };
287
481
  }
288
482
 
289
- /**
290
- * Evaluate and rank all search results from a plugin
291
- * @param tracks List of tracks to evaluate
292
- * @param query Original query
293
- * @param isPlaylistResult Whether this is a playlist result (skip per-track evaluation)
294
- * @returns List of tracks with scores, sorted by score
295
- */
296
- private evaluateAndRankResults(
297
- tracks: Track[],
298
- query: string,
299
- isPlaylistResult: boolean = false,
300
- ): { track: Track; score: SearchScore }[] {
301
- // 🔥 Nếu là playlist result, giữ nguyên tất cả tracks với điểm 100%
302
- if (isPlaylistResult && tracks.length > 0) {
303
- this.debug(`[Evaluation] Playlist detected - keeping all ${tracks.length} tracks`);
304
-
305
- return tracks.map((track) => ({
306
- track,
307
- score: {
308
- score: 100,
309
- reason: "Part of playlist",
310
- matchedBy: "playlist",
311
- exactMatch: false,
312
- },
313
- }));
314
- }
315
-
316
- const evaluated = tracks.map((track) => ({
317
- track,
318
- score: this.evaluateTrackMatch(track, query),
319
- }));
320
-
321
- // Sort by score in descending order (cao xuống thấp)
322
- evaluated.sort((a, b) => b.score.score - a.score.score);
323
-
324
- // Log evaluation results
325
- for (const item of evaluated.slice(0, 5)) {
326
- this.debug(`[Evaluation] "${item.track.title}" -> ${item.score.score}% (${item.score.reason})`);
327
- }
328
-
329
- return evaluated;
330
- }
331
- /**
332
- * Select the best result from multiple plugins
333
- * @param allResults Results from various plugins
334
- * @param query Original query
335
- * @returns The best result
336
- */
337
- private selectBestResult(allResults: Map<string, SearchResult>, query: string): SearchResult | null {
338
- let bestResult: SearchResult | null = null;
339
- let bestScore = -1;
340
-
341
- for (const [source, result] of allResults) {
342
- if (!result.tracks || result.tracks.length === 0) continue;
343
-
344
- // Evaluate the first (best) track of this result
345
- const bestTrackScore = this.evaluateTrackMatch(result.tracks[0], query);
346
-
347
- if (bestTrackScore.score > bestScore) {
348
- bestScore = bestTrackScore.score;
349
- bestResult = {
350
- ...result,
351
- source,
352
- score: bestTrackScore,
353
- };
354
- }
355
-
356
- this.debug(`[Selection] Plugin ${source} -> best match: ${bestTrackScore.score}%`);
357
- }
358
-
359
- if (bestResult && bestResult.score) {
360
- this.debug(`[Selection] Selected result from ${bestResult.source} with score ${bestResult.score.score}%`);
361
- }
362
-
363
- return bestResult;
364
- }
365
-
366
483
  /**
367
484
  * Search with deduplication and evaluation of results
368
485
  * @param query Search query
@@ -407,202 +524,69 @@ export class PluginManager {
407
524
 
408
525
  private async searchInternal(query: string, requestedBy: string): Promise<SearchResult | null> {
409
526
  const timeoutMs = this.options.extractorTimeout ?? 15000;
410
- const minScore = this.options.searchMinScore ?? 30;
411
-
412
- // Get all plugins that support search
413
- const allSearchPlugins = this.getAll()
414
- .filter((p) => typeof p.search === "function")
415
- .sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
416
-
417
- if (allSearchPlugins.length === 0) {
418
- this.debug(`[Search] No plugins support search`);
419
- return null;
420
- }
421
-
422
- const priorityGroups = new Map<number, BasePlugin[]>();
423
- for (const plugin of allSearchPlugins) {
424
- const priority = plugin.priority ?? 0;
425
- if (!priorityGroups.has(priority)) {
426
- priorityGroups.set(priority, []);
427
- }
428
- priorityGroups.get(priority)!.push(plugin);
429
- }
430
-
431
- const sortedPriorities = Array.from(priorityGroups.keys()).sort((a, b) => b - a);
432
-
433
- this.debug(
434
- `[Search] Priority groups: ${sortedPriorities.map((p) => `${p} (${priorityGroups.get(p)!.length} plugins)`).join(", ")}`,
435
- );
436
-
437
- const allResults = new Map<string, SearchResult>();
438
- const errors: Array<{ plugin: string; error: Error }> = [];
439
527
 
440
- let foundHighQualityResult = false;
441
- let bestResultOverall: SearchResult | null = null;
442
- let bestScoreOverall = -1;
528
+ const plugins = this.getAll().filter((p) => typeof p.search === "function");
443
529
 
444
- // 🔥 PROCESS EACH PRIORITY GROUP
445
- for (const priority of sortedPriorities) {
446
- const groupPlugins = priorityGroups.get(priority)!;
447
- this.debug(`[Search] Processing priority group ${priority} with ${groupPlugins.length} plugins`);
448
-
449
- const groupResults = new Map<string, SearchResult>();
450
-
451
- // Process all plugins in current priority group
452
- for (const plugin of groupPlugins) {
453
- // Skip if we already found a high-quality result (>=90%) from previous group
454
- if (foundHighQualityResult) {
455
- this.debug(`[Search] Skipping plugin ${plugin.name} (priority ${priority}) - already have high-quality result`);
456
- break;
457
- }
530
+ if (!plugins.length) return null;
458
531
 
532
+ const settled = await Promise.allSettled(
533
+ plugins.map(async (plugin) => {
459
534
  try {
460
- this.debug(`[Search] Trying plugin: ${plugin.name} (priority: ${priority})`);
461
-
462
- const startTime = Date.now();
463
535
  const result = await withTimeout(plugin.search(query, requestedBy), timeoutMs, `Search timeout for ${plugin.name}`);
464
536
 
465
- const duration = Date.now() - startTime;
466
-
467
- if (result && Array.isArray(result.tracks) && result.tracks.length > 0) {
468
- // 🔥 KIỂM TRA XEM CÓ PLAYLIST HAY KHÔNG
469
- const hasPlaylist = !!result.playlist;
470
- const isPlaylistResult = hasPlaylist && result.tracks.length > 1;
471
-
472
- // Evaluate and rank results from this plugin
473
- const evaluatedTracks = this.evaluateAndRankResults(result.tracks, query, isPlaylistResult);
474
-
475
- const bestScore = evaluatedTracks[0]?.score.score || 0;
476
-
477
- // Filter tracks below minimum score (chỉ áp dụng cho non-playlist)
478
- let validTracks: Track[];
479
-
480
- if (isPlaylistResult) {
481
- // 🔥 PLAYLIST: Giữ nguyên tất cả tracks, không lọc theo score
482
- validTracks = evaluatedTracks.map((item) => item.track);
483
- this.debug(`[Search] Plugin ${plugin.name} returned playlist with ${validTracks.length} tracks (keeping all)`);
484
- } else {
485
- // Single track search: filter by min score
486
- validTracks = evaluatedTracks.filter((item) => item.score.score >= minScore).map((item) => item.track);
487
- this.debug(
488
- `[Search] Plugin ${plugin.name} returned ${validTracks.length}/${result.tracks.length} valid tracks in ${duration}ms (best: ${bestScore}%)`,
489
- );
490
- }
491
-
492
- if (validTracks.length > 0) {
493
- const pluginResult: SearchResult = {
494
- tracks: validTracks,
495
- playlist: result.playlist,
496
- query,
497
- score: evaluatedTracks[0].score,
498
- source: plugin.name,
499
- };
500
-
501
- groupResults.set(plugin.name, pluginResult);
502
- allResults.set(plugin.name, pluginResult);
503
-
504
- // 🔥 Nếu là playlist hoặc kết quả chất lượng cao, dừng search
505
- if (isPlaylistResult || bestScore >= 90) {
506
- foundHighQualityResult = true;
507
- bestResultOverall = pluginResult;
508
- bestScoreOverall = bestScore;
509
- this.debug(`[Search] ${isPlaylistResult ? "Playlist" : "High-quality result"} found, stopping search`);
510
- break;
511
- }
512
-
513
- // Track best result overall
514
- if (bestScore > bestScoreOverall) {
515
- bestScoreOverall = bestScore;
516
- bestResultOverall = pluginResult;
517
- }
518
- } else {
519
- this.debug(
520
- `[Search] Plugin ${plugin.name} returned ${result.tracks.length} tracks but none passed min score ${minScore}`,
521
- );
522
- }
523
- } else {
524
- this.debug(`[Search] Plugin ${plugin.name} returned no tracks in ${duration}ms`);
537
+ if (!result?.tracks?.length) {
538
+ return [];
525
539
  }
526
- } catch (error) {
527
- const err = error instanceof Error ? error : new Error(String(error));
528
- this.debug(`[Search] Plugin ${plugin.name} failed:`, err.message);
529
- errors.push({ plugin: plugin.name, error: err });
530
- }
531
- }
532
540
 
533
- // 🔥 AFTER PROCESSING CURRENT GROUP, DECIDE WHETHER TO CONTINUE
534
- if (foundHighQualityResult) {
535
- this.debug(`[Search] Found high-quality result (>=90%) in priority group ${priority}, stopping search`);
536
- break;
537
- }
538
-
539
- // If current group has at least one valid result, check if we should continue to lower priority groups
540
- if (groupResults.size > 0) {
541
- // Get best score from this group
542
- const bestGroupScore = Math.max(...Array.from(groupResults.values()).map((r) => r.score?.score || 0));
541
+ return result.tracks.map((track) => ({
542
+ ...track,
543
+ source: plugin.name,
544
+ }));
545
+ } catch (e) {
546
+ this.debug(`[Search] ${plugin.name} failed`, e);
543
547
 
544
- // If best score in this group is >= 70%, don't try lower priority groups
545
- if (bestGroupScore >= 70) {
546
- this.debug(`[Search] Priority group ${priority} has results with score ${bestGroupScore}% (>=70%), stopping search`);
547
- break;
548
+ return [];
548
549
  }
550
+ }),
551
+ );
549
552
 
550
- // If best score is between 50-70%, continue to lower groups but log it
551
- if (bestGroupScore >= 50) {
552
- this.debug(
553
- `[Search] Priority group ${priority} has results with score ${bestGroupScore}% (50-70%), will try lower priority groups for better results`,
554
- );
555
- } else {
556
- this.debug(
557
- `[Search] Priority group ${priority} has low quality results (${bestGroupScore}% <50%), trying lower priority groups`,
558
- );
559
- }
560
- } else {
561
- this.debug(`[Search] Priority group ${priority} produced no valid results, trying lower priority group`);
553
+ const allTracks: Track[] = [];
554
+
555
+ for (const result of settled) {
556
+ if (result.status === "fulfilled") {
557
+ allTracks.push(...result.value);
562
558
  }
563
559
  }
564
560
 
565
- // 🔥 SELECT BEST RESULT
566
- if (bestResultOverall) {
567
- // Cache the result
568
- this.setCachedSearch(query, requestedBy, bestResultOverall);
561
+ if (!allTracks.length) {
562
+ return null;
563
+ }
569
564
 
570
- // Log summary
571
- const allTracksCount = Array.from(allResults.values()).reduce((sum, r) => sum + r.tracks.length, 0);
572
- this.debug(
573
- `[Search] Complete - Best from ${bestResultOverall.source}: ${bestResultOverall.tracks.length} tracks (${bestResultOverall.score?.score || 0}%), Total candidates: ${allTracksCount}`,
574
- );
565
+ // dedupe
566
+ const deduped = dedupeTracks(allTracks);
575
567
 
576
- return bestResultOverall;
577
- }
568
+ // score + sort
569
+ const ranked = deduped
570
+ .map((track) => ({
571
+ track,
572
+ score: this.evaluateTrackMatch(track, query),
573
+ }))
574
+ .sort((a, b) => b.score.score - a.score.score);
578
575
 
579
- // 🔥 FALLBACK: If we have any results that didn't meet min score, return the best one
580
- if (allResults.size === 0 && errors.length > 0) {
581
- // Try to get best result from allResults even if below min score
582
- let fallbackResult: SearchResult | null = null;
583
- let fallbackScore = -1;
584
-
585
- for (const [source, result] of allResults) {
586
- const score = result.score?.score || 0;
587
- if (score > fallbackScore) {
588
- fallbackScore = score;
589
- fallbackResult = result;
590
- }
591
- }
576
+ const tracks = ranked.map((x) => x.track);
592
577
 
593
- if (fallbackResult) {
594
- this.debug(`[Search] Using fallback result with score ${fallbackScore}% (below minimum ${minScore}%)`);
595
- return fallbackResult;
596
- }
578
+ const finalResult: SearchResult = {
579
+ query,
580
+ tracks,
581
+ source: "multi-search",
582
+ score: ranked[0]?.score,
583
+ };
597
584
 
598
- this.debug(`[Search] All ${allSearchPlugins.length} plugins failed for query: ${query}`);
599
- const lastError = errors[errors.length - 1]?.error;
600
- if (lastError) throw lastError;
601
- }
585
+ this.setCachedSearch(query, requestedBy, finalResult);
602
586
 
603
- return null;
587
+ this.debug(`[Search] Aggregated ${tracks.length} tracks from ${plugins.length} plugins`);
588
+ return finalResult;
604
589
  }
605
-
606
590
  /**
607
591
  * Get plugin priority groups info for debugging
608
592
  */
@@ -67,6 +67,7 @@ export interface Track {
67
67
  source: string;
68
68
  metadata?: Record<string, any>;
69
69
  isLive?: boolean;
70
+ author?: string;
70
71
  }
71
72
 
72
73
  /**