ziplayer 0.3.5 → 0.3.7

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.
@@ -11,6 +11,12 @@ type PluginManagerOptions = {
11
11
  searchMinScore?: number;
12
12
  };
13
13
  export { BasePlugin } from "./BasePlugin";
14
+ type ExtractedMediaId = {
15
+ platform: "youtube" | "spotify" | "soundcloud" | "unknown";
16
+ id: string;
17
+ url: string;
18
+ };
19
+ export declare function extractMediaId(input: string): ExtractedMediaId | null;
14
20
  export declare class PluginManager {
15
21
  private options;
16
22
  private player;
@@ -41,21 +47,6 @@ export declare class PluginManager {
41
47
  * @returns SearchScore object with score and reason
42
48
  */
43
49
  evaluateTrackMatch(track: Track, query: string): SearchScore;
44
- /**
45
- * Evaluate and rank all search results from a plugin
46
- * @param tracks List of tracks to evaluate
47
- * @param query Original query
48
- * @param isPlaylistResult Whether this is a playlist result (skip per-track evaluation)
49
- * @returns List of tracks with scores, sorted by score
50
- */
51
- private evaluateAndRankResults;
52
- /**
53
- * Select the best result from multiple plugins
54
- * @param allResults Results from various plugins
55
- * @param query Original query
56
- * @returns The best result
57
- */
58
- private selectBestResult;
59
50
  /**
60
51
  * Search with deduplication and evaluation of results
61
52
  * @param query Search query
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/plugins/index.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/plugins/index.ts"],"names":[],"mappings":"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"}
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.PluginManager = exports.BasePlugin = void 0;
4
+ exports.extractMediaId = extractMediaId;
4
5
  const timeout_1 = require("../utils/timeout");
5
6
  var BasePlugin_1 = require("./BasePlugin");
6
7
  Object.defineProperty(exports, "BasePlugin", { enumerable: true, get: function () { return BasePlugin_1.BasePlugin; } });
@@ -33,8 +34,84 @@ function normalize(str) {
33
34
  .replace(/\s+/g, " ")
34
35
  .trim();
35
36
  }
36
- const MUSIC_KEYWORDS = ["official", "mv", "audio", "lyrics", "remix", "cover", "ft", "feat", "prod", "music video"];
37
+ function getContentQualityScore(track) {
38
+ const title = normalize(track.title);
39
+ let score = 0;
40
+ // ưu tiên nhạc official
41
+ for (const k of OFFICIAL_KEYWORDS) {
42
+ if (title.includes(k))
43
+ score += 80;
44
+ }
45
+ // nhạc thường
46
+ for (const k of MUSIC_KEYWORDS) {
47
+ if (title.includes(k))
48
+ score += 10;
49
+ }
50
+ // phạt content rác
51
+ for (const k of BAD_KEYWORDS) {
52
+ if (title.includes(k))
53
+ score -= 120;
54
+ }
55
+ // youtube verified / artist channel
56
+ const author = normalize(track?.author || track?.metadata?.author || "");
57
+ if (author.includes("vevo") || author.includes("official") || author.includes("topic")) {
58
+ score += 20;
59
+ }
60
+ // phạt video quá dài (podcast/review)
61
+ if (track.duration && track.duration > 15 * 60 * 1000) {
62
+ score -= 20;
63
+ }
64
+ return score;
65
+ }
66
+ function dedupeTracks(tracks) {
67
+ const unique = new Map();
68
+ for (const track of tracks) {
69
+ const key = normalize(`${track.title} ${track?.author || track?.metadata?.author || ""}`);
70
+ const existing = unique.get(key);
71
+ if (!existing) {
72
+ unique.set(key, track);
73
+ continue;
74
+ }
75
+ const oldScore = getContentQualityScore(existing);
76
+ const newScore = getContentQualityScore(track);
77
+ if (newScore > oldScore) {
78
+ unique.set(key, track);
79
+ }
80
+ }
81
+ return [...unique.values()];
82
+ }
83
+ // const MUSIC_KEYWORDS = ["official", "mv", "audio", "lyrics", "remix", "cover", "ft", "feat", "prod", "music video"];
37
84
  const NON_MUSIC_KEYWORDS = ["reaction", "review", "podcast", "interview", "vlog", "live stream", "news", "tiktok"];
85
+ const OFFICIAL_KEYWORDS = ["official", "official video", "official audio", "music video", "mv", "audio", "visualizer", "lyrics"];
86
+ const MUSIC_KEYWORDS = [
87
+ "song",
88
+ "track",
89
+ "remix",
90
+ "cover",
91
+ "instrumental",
92
+ "karaoke",
93
+ "nightcore",
94
+ "sped up",
95
+ "slowed",
96
+ "feat",
97
+ "ft",
98
+ ];
99
+ const BAD_KEYWORDS = [
100
+ "reaction",
101
+ "review",
102
+ "podcast",
103
+ "interview",
104
+ "vlog",
105
+ "livestream",
106
+ "live stream",
107
+ "news",
108
+ "analysis",
109
+ "commentary",
110
+ "tiktok",
111
+ "shorts",
112
+ "funny",
113
+ "meme",
114
+ ];
38
115
  function detectContentType(title) {
39
116
  const t = title.toLowerCase();
40
117
  let score = 0;
@@ -64,6 +141,79 @@ function scoreTrack(base, candidate) {
64
141
  score += detectContentType(candidate.title);
65
142
  return score;
66
143
  }
144
+ function extractMediaId(input) {
145
+ try {
146
+ const url = new URL(input);
147
+ const host = url.hostname.replace(/^www\./, "").toLowerCase();
148
+ // =====================================================
149
+ // YOUTUBE
150
+ // =====================================================
151
+ if (host === "youtube.com" || host === "m.youtube.com" || host === "music.youtube.com") {
152
+ const videoId = url.searchParams.get("v");
153
+ if (videoId) {
154
+ return {
155
+ platform: "youtube",
156
+ id: videoId,
157
+ url: `https://www.youtube.com/watch?v=${videoId}`,
158
+ };
159
+ }
160
+ }
161
+ if (host === "youtu.be") {
162
+ const id = url.pathname.slice(1);
163
+ if (id) {
164
+ return {
165
+ platform: "youtube",
166
+ id,
167
+ url: `https://www.youtube.com/watch?v=${id}`,
168
+ };
169
+ }
170
+ }
171
+ // =====================================================
172
+ // SPOTIFY
173
+ // =====================================================
174
+ if (host === "open.spotify.com") {
175
+ const parts = url.pathname.split("/").filter(Boolean);
176
+ // track/playlist/album/episode/show
177
+ if (parts.length >= 2) {
178
+ const [, id] = parts;
179
+ return {
180
+ platform: "spotify",
181
+ id,
182
+ url: `https://open.spotify.com/${parts[0]}/${id}`,
183
+ };
184
+ }
185
+ }
186
+ // spotify uri
187
+ if (input.startsWith("spotify:")) {
188
+ const parts = input.split(":");
189
+ if (parts.length >= 3) {
190
+ return {
191
+ platform: "spotify",
192
+ id: parts[2],
193
+ url: `https://open.spotify.com/${parts[1]}/${parts[2]}`,
194
+ };
195
+ }
196
+ }
197
+ // =====================================================
198
+ // SOUNDCLOUD
199
+ // =====================================================
200
+ if (host === "soundcloud.com") {
201
+ const path = url.pathname.split("/").filter(Boolean);
202
+ if (path.length >= 2) {
203
+ const id = `${path[0]}/${path[1]}`;
204
+ return {
205
+ platform: "soundcloud",
206
+ id,
207
+ url: `https://soundcloud.com/${id}`,
208
+ };
209
+ }
210
+ }
211
+ return null;
212
+ }
213
+ catch {
214
+ return null;
215
+ }
216
+ }
67
217
  class PluginManager {
68
218
  constructor(player, manager, options) {
69
219
  this.plugins = new Map();
@@ -175,10 +325,20 @@ class PluginManager {
175
325
  exactMatch: true,
176
326
  };
177
327
  }
328
+ const queryMedia = extractMediaId(query);
329
+ const trackMedia = extractMediaId(track.url || "");
330
+ if (queryMedia && trackMedia && queryMedia.platform === trackMedia.platform && queryMedia.id === trackMedia.id) {
331
+ return {
332
+ score: 100,
333
+ reason: `${queryMedia.platform} exact ID match`,
334
+ matchedBy: "url",
335
+ exactMatch: true,
336
+ };
337
+ }
178
338
  // 2. Evaluate title match exactly - 100%
179
339
  if (normalizedTitle === normalizedQuery) {
180
340
  return {
181
- score: 100,
341
+ score: 90 + getContentQualityScore(track),
182
342
  reason: "Title matches exactly",
183
343
  matchedBy: "title",
184
344
  exactMatch: true,
@@ -186,11 +346,9 @@ class PluginManager {
186
346
  }
187
347
  // 3. Evaluate title contains query or vice versa - 70-90%
188
348
  if (normalizedTitle.includes(normalizedQuery) && normalizedQuery.length > 5) {
189
- const ratio = normalizedQuery.length / normalizedTitle.length;
190
- const score = 70 + Math.min(20, Math.floor(ratio * 20));
191
349
  return {
192
- score,
193
- reason: `Title contains the query "${query}" (${Math.floor(ratio * 100)}% coverage)`,
350
+ score: 75 + getContentQualityScore(track),
351
+ reason: `Title contains query`,
194
352
  matchedBy: "title",
195
353
  exactMatch: false,
196
354
  };
@@ -209,9 +367,10 @@ class PluginManager {
209
367
  const simScore = similarity(normalizedTitle, normalizedQuery);
210
368
  const tokenScore = tokenOverlap(normalizedTitle, normalizedQuery);
211
369
  const contentTypeBonus = detectContentType(track.title);
370
+ const qualityScore = getContentQualityScore(track);
212
371
  // Tính điểm tổng hợp: similarity 60%, token overlap 30%, content type 10%
213
- let finalScore = simScore * 60 + tokenScore * 30 + (contentTypeBonus / 10) * 10;
214
- finalScore = Math.min(70, Math.max(0, Math.floor(finalScore)));
372
+ let finalScore = simScore * 35 + tokenScore * 25 + qualityScore * 1.5;
373
+ finalScore = Math.max(0, Math.min(100, Math.floor(finalScore)));
215
374
  if (finalScore >= 20) {
216
375
  let reason = `Similarity ${Math.floor(simScore * 100)}%`;
217
376
  if (contentTypeBonus > 0) {
@@ -232,68 +391,6 @@ class PluginManager {
232
391
  exactMatch: false,
233
392
  };
234
393
  }
235
- /**
236
- * Evaluate and rank all search results from a plugin
237
- * @param tracks List of tracks to evaluate
238
- * @param query Original query
239
- * @param isPlaylistResult Whether this is a playlist result (skip per-track evaluation)
240
- * @returns List of tracks with scores, sorted by score
241
- */
242
- evaluateAndRankResults(tracks, query, isPlaylistResult = false) {
243
- // 🔥 Nếu là playlist result, giữ nguyên tất cả tracks với điểm 100%
244
- if (isPlaylistResult && tracks.length > 0) {
245
- this.debug(`[Evaluation] Playlist detected - keeping all ${tracks.length} tracks`);
246
- return tracks.map((track) => ({
247
- track,
248
- score: {
249
- score: 100,
250
- reason: "Part of playlist",
251
- matchedBy: "playlist",
252
- exactMatch: false,
253
- },
254
- }));
255
- }
256
- const evaluated = tracks.map((track) => ({
257
- track,
258
- score: this.evaluateTrackMatch(track, query),
259
- }));
260
- // Sort by score in descending order (cao xuống thấp)
261
- evaluated.sort((a, b) => b.score.score - a.score.score);
262
- // Log evaluation results
263
- for (const item of evaluated.slice(0, 5)) {
264
- this.debug(`[Evaluation] "${item.track.title}" -> ${item.score.score}% (${item.score.reason})`);
265
- }
266
- return evaluated;
267
- }
268
- /**
269
- * Select the best result from multiple plugins
270
- * @param allResults Results from various plugins
271
- * @param query Original query
272
- * @returns The best result
273
- */
274
- selectBestResult(allResults, query) {
275
- let bestResult = null;
276
- let bestScore = -1;
277
- for (const [source, result] of allResults) {
278
- if (!result.tracks || result.tracks.length === 0)
279
- continue;
280
- // Evaluate the first (best) track of this result
281
- const bestTrackScore = this.evaluateTrackMatch(result.tracks[0], query);
282
- if (bestTrackScore.score > bestScore) {
283
- bestScore = bestTrackScore.score;
284
- bestResult = {
285
- ...result,
286
- source,
287
- score: bestTrackScore,
288
- };
289
- }
290
- this.debug(`[Selection] Plugin ${source} -> best match: ${bestTrackScore.score}%`);
291
- }
292
- if (bestResult && bestResult.score) {
293
- this.debug(`[Selection] Selected result from ${bestResult.source} with score ${bestResult.score.score}%`);
294
- }
295
- return bestResult;
296
- }
297
394
  /**
298
395
  * Search with deduplication and evaluation of results
299
396
  * @param query Search query
@@ -332,161 +429,53 @@ class PluginManager {
332
429
  }
333
430
  async searchInternal(query, requestedBy) {
334
431
  const timeoutMs = this.options.extractorTimeout ?? 15000;
335
- const minScore = this.options.searchMinScore ?? 30;
336
- // Get all plugins that support search
337
- const allSearchPlugins = this.getAll()
338
- .filter((p) => typeof p.search === "function")
339
- .sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
340
- if (allSearchPlugins.length === 0) {
341
- this.debug(`[Search] No plugins support search`);
432
+ const plugins = this.getAll().filter((p) => typeof p.search === "function");
433
+ if (!plugins.length)
342
434
  return null;
343
- }
344
- const priorityGroups = new Map();
345
- for (const plugin of allSearchPlugins) {
346
- const priority = plugin.priority ?? 0;
347
- if (!priorityGroups.has(priority)) {
348
- priorityGroups.set(priority, []);
349
- }
350
- priorityGroups.get(priority).push(plugin);
351
- }
352
- const sortedPriorities = Array.from(priorityGroups.keys()).sort((a, b) => b - a);
353
- this.debug(`[Search] Priority groups: ${sortedPriorities.map((p) => `${p} (${priorityGroups.get(p).length} plugins)`).join(", ")}`);
354
- const allResults = new Map();
355
- const errors = [];
356
- let foundHighQualityResult = false;
357
- let bestResultOverall = null;
358
- let bestScoreOverall = -1;
359
- // 🔥 PROCESS EACH PRIORITY GROUP
360
- for (const priority of sortedPriorities) {
361
- const groupPlugins = priorityGroups.get(priority);
362
- this.debug(`[Search] Processing priority group ${priority} with ${groupPlugins.length} plugins`);
363
- const groupResults = new Map();
364
- // Process all plugins in current priority group
365
- for (const plugin of groupPlugins) {
366
- // Skip if we already found a high-quality result (>=90%) from previous group
367
- if (foundHighQualityResult) {
368
- this.debug(`[Search] Skipping plugin ${plugin.name} (priority ${priority}) - already have high-quality result`);
369
- break;
370
- }
371
- try {
372
- this.debug(`[Search] Trying plugin: ${plugin.name} (priority: ${priority})`);
373
- const startTime = Date.now();
374
- const result = await (0, timeout_1.withTimeout)(plugin.search(query, requestedBy), timeoutMs, `Search timeout for ${plugin.name}`);
375
- const duration = Date.now() - startTime;
376
- if (result && Array.isArray(result.tracks) && result.tracks.length > 0) {
377
- // 🔥 KIỂM TRA XEM CÓ PLAYLIST HAY KHÔNG
378
- const hasPlaylist = !!result.playlist;
379
- const isPlaylistResult = hasPlaylist && result.tracks.length > 1;
380
- // Evaluate and rank results from this plugin
381
- const evaluatedTracks = this.evaluateAndRankResults(result.tracks, query, isPlaylistResult);
382
- const bestScore = evaluatedTracks[0]?.score.score || 0;
383
- // Filter tracks below minimum score (chỉ áp dụng cho non-playlist)
384
- let validTracks;
385
- if (isPlaylistResult) {
386
- // 🔥 PLAYLIST: Giữ nguyên tất cả tracks, không lọc theo score
387
- validTracks = evaluatedTracks.map((item) => item.track);
388
- this.debug(`[Search] Plugin ${plugin.name} returned playlist with ${validTracks.length} tracks (keeping all)`);
389
- }
390
- else {
391
- // Single track search: filter by min score
392
- validTracks = evaluatedTracks.filter((item) => item.score.score >= minScore).map((item) => item.track);
393
- this.debug(`[Search] Plugin ${plugin.name} returned ${validTracks.length}/${result.tracks.length} valid tracks in ${duration}ms (best: ${bestScore}%)`);
394
- }
395
- if (validTracks.length > 0) {
396
- const pluginResult = {
397
- tracks: validTracks,
398
- playlist: result.playlist,
399
- query,
400
- score: evaluatedTracks[0].score,
401
- source: plugin.name,
402
- };
403
- groupResults.set(plugin.name, pluginResult);
404
- allResults.set(plugin.name, pluginResult);
405
- // 🔥 Nếu là playlist hoặc kết quả chất lượng cao, dừng search
406
- if (isPlaylistResult || bestScore >= 90) {
407
- foundHighQualityResult = true;
408
- bestResultOverall = pluginResult;
409
- bestScoreOverall = bestScore;
410
- this.debug(`[Search] ${isPlaylistResult ? "Playlist" : "High-quality result"} found, stopping search`);
411
- break;
412
- }
413
- // Track best result overall
414
- if (bestScore > bestScoreOverall) {
415
- bestScoreOverall = bestScore;
416
- bestResultOverall = pluginResult;
417
- }
418
- }
419
- else {
420
- this.debug(`[Search] Plugin ${plugin.name} returned ${result.tracks.length} tracks but none passed min score ${minScore}`);
421
- }
422
- }
423
- else {
424
- this.debug(`[Search] Plugin ${plugin.name} returned no tracks in ${duration}ms`);
425
- }
426
- }
427
- catch (error) {
428
- const err = error instanceof Error ? error : new Error(String(error));
429
- this.debug(`[Search] Plugin ${plugin.name} failed:`, err.message);
430
- errors.push({ plugin: plugin.name, error: err });
435
+ const settled = await Promise.allSettled(plugins.map(async (plugin) => {
436
+ try {
437
+ const result = await (0, timeout_1.withTimeout)(plugin.search(query, requestedBy), timeoutMs, `Search timeout for ${plugin.name}`);
438
+ if (!result?.tracks?.length) {
439
+ return [];
431
440
  }
441
+ return result.tracks.map((track) => ({
442
+ ...track,
443
+ source: plugin.name,
444
+ }));
432
445
  }
433
- // 🔥 AFTER PROCESSING CURRENT GROUP, DECIDE WHETHER TO CONTINUE
434
- if (foundHighQualityResult) {
435
- this.debug(`[Search] Found high-quality result (>=90%) in priority group ${priority}, stopping search`);
436
- break;
437
- }
438
- // If current group has at least one valid result, check if we should continue to lower priority groups
439
- if (groupResults.size > 0) {
440
- // Get best score from this group
441
- const bestGroupScore = Math.max(...Array.from(groupResults.values()).map((r) => r.score?.score || 0));
442
- // If best score in this group is >= 70%, don't try lower priority groups
443
- if (bestGroupScore >= 70) {
444
- this.debug(`[Search] Priority group ${priority} has results with score ${bestGroupScore}% (>=70%), stopping search`);
445
- break;
446
- }
447
- // If best score is between 50-70%, continue to lower groups but log it
448
- if (bestGroupScore >= 50) {
449
- this.debug(`[Search] Priority group ${priority} has results with score ${bestGroupScore}% (50-70%), will try lower priority groups for better results`);
450
- }
451
- else {
452
- this.debug(`[Search] Priority group ${priority} has low quality results (${bestGroupScore}% <50%), trying lower priority groups`);
453
- }
446
+ catch (e) {
447
+ this.debug(`[Search] ${plugin.name} failed`, e);
448
+ return [];
454
449
  }
455
- else {
456
- this.debug(`[Search] Priority group ${priority} produced no valid results, trying lower priority group`);
450
+ }));
451
+ const allTracks = [];
452
+ for (const result of settled) {
453
+ if (result.status === "fulfilled") {
454
+ allTracks.push(...result.value);
457
455
  }
458
456
  }
459
- // 🔥 SELECT BEST RESULT
460
- if (bestResultOverall) {
461
- // Cache the result
462
- this.setCachedSearch(query, requestedBy, bestResultOverall);
463
- // Log summary
464
- const allTracksCount = Array.from(allResults.values()).reduce((sum, r) => sum + r.tracks.length, 0);
465
- this.debug(`[Search] Complete - Best from ${bestResultOverall.source}: ${bestResultOverall.tracks.length} tracks (${bestResultOverall.score?.score || 0}%), Total candidates: ${allTracksCount}`);
466
- return bestResultOverall;
467
- }
468
- // 🔥 FALLBACK: If we have any results that didn't meet min score, return the best one
469
- if (allResults.size === 0 && errors.length > 0) {
470
- // Try to get best result from allResults even if below min score
471
- let fallbackResult = null;
472
- let fallbackScore = -1;
473
- for (const [source, result] of allResults) {
474
- const score = result.score?.score || 0;
475
- if (score > fallbackScore) {
476
- fallbackScore = score;
477
- fallbackResult = result;
478
- }
479
- }
480
- if (fallbackResult) {
481
- this.debug(`[Search] Using fallback result with score ${fallbackScore}% (below minimum ${minScore}%)`);
482
- return fallbackResult;
483
- }
484
- this.debug(`[Search] All ${allSearchPlugins.length} plugins failed for query: ${query}`);
485
- const lastError = errors[errors.length - 1]?.error;
486
- if (lastError)
487
- throw lastError;
457
+ if (!allTracks.length) {
458
+ return null;
488
459
  }
489
- return null;
460
+ // dedupe
461
+ const deduped = dedupeTracks(allTracks);
462
+ // score + sort
463
+ const ranked = deduped
464
+ .map((track) => ({
465
+ track,
466
+ score: this.evaluateTrackMatch(track, query),
467
+ }))
468
+ .sort((a, b) => b.score.score - a.score.score);
469
+ const tracks = ranked.map((x) => x.track);
470
+ const finalResult = {
471
+ query,
472
+ tracks,
473
+ source: "multi-search",
474
+ score: ranked[0]?.score,
475
+ };
476
+ this.setCachedSearch(query, requestedBy, finalResult);
477
+ this.debug(`[Search] Aggregated ${tracks.length} tracks from ${plugins.length} plugins`);
478
+ return finalResult;
490
479
  }
491
480
  /**
492
481
  * Get plugin priority groups info for debugging
@@ -536,6 +525,12 @@ class PluginManager {
536
525
  const key = this.getStreamCacheKey(track);
537
526
  const cached = this.streamCache.get(key);
538
527
  if (cached && Date.now() < cached.expiresAt) {
528
+ const s = cached.streamInfo?.stream;
529
+ if (!s || s.destroyed || s.readable === false) {
530
+ this.debug(`[StreamCache] Dead stream detected, evicting: ${track.title}`);
531
+ this.streamCache.delete(key);
532
+ return null;
533
+ }
539
534
  this.debug(`[StreamCache] Hit for track: ${track.title}`);
540
535
  return cached.streamInfo;
541
536
  }
@@ -604,7 +599,7 @@ class PluginManager {
604
599
  // =========================================================
605
600
  // 1. TRY DIRECT STREAM
606
601
  // =========================================================
607
- if (plugin.getStream) {
602
+ if (plugin?.getStream && plugin.validate?.(track.url ?? "")) {
608
603
  try {
609
604
  this.debug(`[Stream] ${plugin.name} trying direct stream`);
610
605
  result = await (0, timeout_1.withTimeout)(plugin.getStream(track, controller.signal), timeoutMs, `${plugin.name} getStream timeout`);