ziplayer 0.2.6 → 0.2.7-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.
@@ -1,236 +1,288 @@
1
- import { BasePlugin } from "./BasePlugin";
2
- import { withTimeout } from "../utils/timeout";
3
- import type { Track, StreamInfo } from "../types";
4
- import type { PlayerManager } from "../structures/PlayerManager";
5
- import type { Player } from "../structures/Player";
6
-
7
- type PluginManagerOptions = {
8
- extractorTimeout: number | undefined;
9
- };
10
-
11
- export { BasePlugin } from "./BasePlugin";
12
-
13
- // Plugin factory
14
- export class PluginManager {
15
- private options: PluginManagerOptions;
16
- private player: Player;
17
- private manager: PlayerManager;
18
- private plugins: Map<string, BasePlugin> = new Map();
19
-
20
- constructor(player: Player, manager: PlayerManager, options: PluginManagerOptions) {
21
- this.player = player;
22
- this.manager = manager;
23
- this.options = options;
24
- }
25
-
26
- debug(message?: any, ...optionalParams: any[]): void {
27
- if (this.manager.debugEnabled) {
28
- this.manager.emit("debug", `[Plugins] ${message}`, ...optionalParams);
29
- }
30
- }
31
-
32
- register(plugin: BasePlugin): void {
33
- this.plugins.set(plugin.name, plugin);
34
- }
35
-
36
- unregister(name: string): boolean {
37
- return this.plugins.delete(name);
38
- }
39
-
40
- get(name: string): BasePlugin | undefined {
41
- return this.plugins.get(name);
42
- }
43
-
44
- getAll(): BasePlugin[] {
45
- return Array.from(this.plugins.values());
46
- }
47
-
48
- findPlugin(query: string): BasePlugin | undefined {
49
- return this.getAll().find((plugin) => plugin.canHandle(query));
50
- }
51
-
52
- clear(): void {
53
- this.plugins.clear();
54
- }
55
-
56
- async getStream(track: Track): Promise<StreamInfo | null> {
57
- const timeoutMs = this.options.extractorTimeout ?? 50000;
58
- const primary = this.get(track.source) || this.findPlugin(track.url);
59
- if (!primary) {
60
- this.debug(`No plugin found for track: ${track.title}`);
61
- return null;
62
- }
63
- try {
64
- const controller = new AbortController();
65
- const result = await withTimeout(primary.getStream(track, controller.signal), timeoutMs, "Primary timeout");
66
- if (result?.stream) return result;
67
- throw new Error("Primary failed");
68
- } catch {
69
- this.debug("Primary failed fallback parallel");
70
- }
71
-
72
- // ===== FALLBACK PARALLEL =====
73
- const plugins = this.getAll()
74
- .filter((p) => p !== primary)
75
- .map((p) => {
76
- p.priority ??= 0;
77
- return p;
78
- })
79
- .sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0));
80
-
81
- // group by priority
82
- const groups = new Map<number, BasePlugin[]>();
83
- for (const p of plugins) {
84
- if (!groups.has(p.priority ?? 0)) groups.set(p.priority ?? 0, []);
85
- groups.get(p.priority ?? 0)!.push(p);
86
- }
87
- for (const [priority, group] of groups) {
88
- this.debug(`Running group priority=${priority}`);
89
- const controller = new AbortController();
90
- try {
91
- const promises = group.map((p) => {
92
- const run = async () => {
93
- try {
94
- let result: StreamInfo | null = null;
95
-
96
- if (p.getStream) {
97
- try {
98
- result = await withTimeout(p.getStream(track, controller.signal), timeoutMs, `Timeout ${p.name}`);
99
- } catch (err) {
100
- // getStream thất bại → log rồi thử getFallback
101
- this.debug(`getStream failed for ${p.name}, trying getFallback`, err);
102
- }
103
-
104
- if (result?.stream) {
105
- this.debug(`Success via ${p.name}`);
106
- controller.abort();
107
- return result;
108
- }
109
- }
110
-
111
- if (p.getFallback) {
112
- result = await withTimeout(p.getFallback(track, controller.signal), timeoutMs, `Fallback timeout ${p.name}`);
113
- if (result?.stream) {
114
- this.debug(`Fallback via ${p.name}`);
115
- controller.abort();
116
- return result;
117
- }
118
- }
119
-
120
- throw new Error("No stream");
121
- } catch (err) {
122
- if (controller.signal.aborted) throw new Error("Aborted");
123
- this.debug(`Failed ${p.name}`, err);
124
- throw err;
125
- }
126
- };
127
- return run();
128
- });
129
-
130
- const result = await Promise.any(promises);
131
- if (result?.stream) return result;
132
- } catch {
133
- this.debug(`Priority group ${priority} failed`);
134
- controller.abort();
135
- }
136
- }
137
-
138
- throw new Error(`All plugins failed for track: ${track.title}`);
139
- }
140
-
141
- /**
142
- * Get related tracks for a given track
143
- * @param {Track} track Track to find related tracks for
144
- * @returns {Track[]} Related tracks or empty array
145
- * @example
146
- * const related = await player.getRelatedTracks(track);
147
- * console.log(`Found ${related.length} related tracks`);
148
- */
149
- async getRelatedTracks(track: Track): Promise<Track[]> {
150
- if (!track) return [];
151
-
152
- const timeoutMs = this.options.extractorTimeout ?? 15000;
153
- const preferred = this.findPlugin(track.url) || this.get(track.source);
154
-
155
- // ===== THỬ PREFERRED TRƯỚC =====
156
- if (preferred && typeof preferred.getRelatedTracks === "function") {
157
- try {
158
- this.debug(`[RelatedTracks] Trying preferred: ${preferred.name}`);
159
- const related = await withTimeout(
160
- preferred.getRelatedTracks(track, {
161
- limit: 10,
162
- history: this.player.queue.previousTracks,
163
- }),
164
- timeoutMs,
165
- `getRelatedTracks timed out for ${preferred.name}`,
166
- );
167
-
168
- if (Array.isArray(related) && related.length > 0) {
169
- return related;
170
- }
171
- this.debug(`[RelatedTracks] ${preferred.name} returned no results → fallback race`);
172
- } catch (err) {
173
- this.debug(`[RelatedTracks] ${preferred.name} failed → fallback race`, err);
174
- }
175
- }
176
-
177
- // ===== FALLBACK: RACE THEO PRIORITY GROUP =====
178
- const plugins = this.getAll()
179
- .filter((p) => p !== preferred && typeof p.getRelatedTracks === "function")
180
- .map((p) => {
181
- p.priority ??= 0;
182
- return p;
183
- })
184
- .sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0));
185
-
186
- // group by priority
187
- const groups = new Map<number, BasePlugin[]>();
188
- for (const p of plugins) {
189
- const key = p.priority ?? 0;
190
- if (!groups.has(key)) groups.set(key, []);
191
- groups.get(key)!.push(p);
192
- }
193
-
194
- for (const [priority, group] of groups) {
195
- this.debug(`[RelatedTracks] Racing priority=${priority} (${group.map((p) => p.name).join(", ")})`);
196
- const controller = new AbortController();
197
-
198
- try {
199
- const promises = group.map((p) =>
200
- (async () => {
201
- try {
202
- const related = await withTimeout(
203
- p.getRelatedTracks!(track, {
204
- limit: 10,
205
- history: this.player.queue.previousTracks,
206
- }),
207
- timeoutMs,
208
- `getRelatedTracks timed out for ${p.name}`,
209
- );
210
-
211
- if (Array.isArray(related) && related.length > 0) {
212
- this.debug(`[RelatedTracks] Success via ${p.name}`);
213
- controller.abort();
214
- return related;
215
- }
216
- throw new Error(`${p.name} returned no results`);
217
- } catch (err) {
218
- if (controller.signal.aborted) throw new Error("Aborted");
219
- this.debug(`[RelatedTracks] ${p.name} failed`, err);
220
- throw err;
221
- }
222
- })(),
223
- );
224
-
225
- const result = await Promise.any(promises);
226
- if (result) return result;
227
- } catch {
228
- this.debug(`[RelatedTracks] Priority group ${priority} all failed`);
229
- controller.abort();
230
- }
231
- }
232
-
233
- this.debug(`[RelatedTracks] All plugins failed for: ${track.title}`);
234
- return [];
235
- }
236
- }
1
+ import { BasePlugin } from "./BasePlugin";
2
+ import { withTimeout } from "../utils/timeout";
3
+ import type { Track, StreamInfo } from "../types";
4
+ import type { PlayerManager } from "../structures/PlayerManager";
5
+ import type { Player } from "../structures/Player";
6
+
7
+ type PluginManagerOptions = {
8
+ extractorTimeout: number | undefined;
9
+ };
10
+
11
+ export { BasePlugin } from "./BasePlugin";
12
+
13
+ function levenshtein(a: string, b: string): number {
14
+ const matrix = Array.from({ length: a.length + 1 }, () => new Array(b.length + 1).fill(0));
15
+
16
+ for (let i = 0; i <= a.length; i++) matrix[i][0] = i;
17
+ for (let j = 0; j <= b.length; j++) matrix[0][j] = j;
18
+
19
+ for (let i = 1; i <= a.length; i++) {
20
+ for (let j = 1; j <= b.length; j++) {
21
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
22
+ matrix[i][j] = Math.min(matrix[i - 1][j] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j - 1] + cost);
23
+ }
24
+ }
25
+
26
+ return matrix[a.length][b.length];
27
+ }
28
+
29
+ function similarity(a: string, b: string): number {
30
+ if (!a || !b) return 0;
31
+
32
+ const dist = levenshtein(a, b);
33
+ const maxLen = Math.max(a.length, b.length);
34
+
35
+ return 1 - dist / maxLen; // 0 → 1
36
+ }
37
+
38
+ function normalize(str: string): string {
39
+ return str
40
+ .toLowerCase()
41
+ .replace(/\(.*?\)|\[.*?\]/g, "") // remove (remix), [lyrics]
42
+ .replace(/[^a-z0-9\s]/g, "")
43
+ .replace(/\s+/g, " ")
44
+ .trim();
45
+ }
46
+
47
+ const MUSIC_KEYWORDS = ["official", "mv", "audio", "lyrics", "remix", "cover", "ft", "feat", "prod", "music video"];
48
+
49
+ const NON_MUSIC_KEYWORDS = ["reaction", "review", "podcast", "interview", "vlog", "live stream", "news", "tiktok"];
50
+
51
+ function detectContentType(title: string): number {
52
+ const t = title.toLowerCase();
53
+
54
+ let score = 0;
55
+
56
+ for (const k of MUSIC_KEYWORDS) {
57
+ if (t.includes(k)) score += 2;
58
+ }
59
+
60
+ for (const k of NON_MUSIC_KEYWORDS) {
61
+ if (t.includes(k)) score -= 3;
62
+ }
63
+
64
+ return score;
65
+ }
66
+
67
+ function tokenOverlap(a: string, b: string): number {
68
+ const setA = new Set(a.split(" "));
69
+ const setB = new Set(b.split(" "));
70
+
71
+ let match = 0;
72
+ for (const word of setA) {
73
+ if (setB.has(word)) match++;
74
+ }
75
+
76
+ return match / Math.max(setA.size, setB.size);
77
+ }
78
+
79
+ function scoreTrack(base: Track, candidate: Track): number {
80
+ const titleA = normalize(base.title);
81
+ const titleB = normalize(candidate.title);
82
+
83
+ let score = 0;
84
+
85
+ // ===== FUZZY =====
86
+ const sim = similarity(titleA, titleB); // 0 → 1
87
+ score += sim * 50;
88
+
89
+ // ===== TOKEN MATCH =====
90
+ score += tokenOverlap(titleA, titleB) * 30;
91
+
92
+ // ===== CONTENT TYPE =====
93
+ score += detectContentType(candidate.title);
94
+
95
+ return score;
96
+ }
97
+
98
+ // Plugin factory
99
+ export class PluginManager {
100
+ private options: PluginManagerOptions;
101
+ private player: Player;
102
+ private manager: PlayerManager;
103
+ private plugins: Map<string, BasePlugin> = new Map();
104
+
105
+ constructor(player: Player, manager: PlayerManager, options: PluginManagerOptions) {
106
+ this.player = player;
107
+ this.manager = manager;
108
+ this.options = options;
109
+ }
110
+
111
+ debug(message?: any, ...optionalParams: any[]): void {
112
+ if (this.manager.debugEnabled) {
113
+ this.manager.emit("debug", `[Plugins] ${message}`, ...optionalParams);
114
+ }
115
+ }
116
+
117
+ register(plugin: BasePlugin): void {
118
+ this.plugins.set(plugin.name, plugin);
119
+ }
120
+
121
+ unregister(name: string): boolean {
122
+ return this.plugins.delete(name);
123
+ }
124
+
125
+ get(name: string): BasePlugin | undefined {
126
+ return this.plugins.get(name);
127
+ }
128
+
129
+ getAll(): BasePlugin[] {
130
+ return Array.from(this.plugins.values());
131
+ }
132
+
133
+ findPlugin(query: string): BasePlugin | undefined {
134
+ return this.getAll().find((plugin) => plugin.canHandle(query));
135
+ }
136
+
137
+ clear(): void {
138
+ this.plugins.clear();
139
+ }
140
+
141
+ async getStream(track: Track): Promise<StreamInfo | null> {
142
+ const timeoutMs = this.options.extractorTimeout ?? 50000;
143
+ const primary = this.get(track.source) || this.findPlugin(track.url);
144
+ if (!primary) {
145
+ this.debug(`No plugin found for track: ${track.title}`);
146
+ return null;
147
+ }
148
+ try {
149
+ const controller = new AbortController();
150
+ const result = await withTimeout(primary.getStream(track, controller.signal), timeoutMs, "Primary timeout");
151
+ if (result?.stream) return result;
152
+ throw new Error("Primary failed");
153
+ } catch {
154
+ this.debug("Primary failed → fallback parallel");
155
+ }
156
+
157
+ // ===== FALLBACK PARALLEL =====
158
+ const plugins = this.getAll()
159
+ .filter((p) => p !== primary)
160
+ .map((p) => {
161
+ p.priority ??= 0;
162
+ return p;
163
+ })
164
+ .sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0));
165
+
166
+ // group by priority
167
+ const groups = new Map<number, BasePlugin[]>();
168
+ for (const p of plugins) {
169
+ if (!groups.has(p.priority ?? 0)) groups.set(p.priority ?? 0, []);
170
+ groups.get(p.priority ?? 0)!.push(p);
171
+ }
172
+ for (const [priority, group] of groups) {
173
+ this.debug(`Running group priority=${priority}`);
174
+ const controller = new AbortController();
175
+ try {
176
+ const promises = group.map((p) => {
177
+ const run = async () => {
178
+ try {
179
+ let result: StreamInfo | null = null;
180
+
181
+ if (p.getStream) {
182
+ try {
183
+ result = await withTimeout(p.getStream(track, controller.signal), timeoutMs, `Timeout ${p.name}`);
184
+ } catch (err) {
185
+ // getStream thất bại → log rồi thử getFallback
186
+ this.debug(`getStream failed for ${p.name}, trying getFallback`, err);
187
+ }
188
+
189
+ if (result?.stream) {
190
+ this.debug(`Success via ${p.name}`);
191
+ controller.abort();
192
+ return result;
193
+ }
194
+ }
195
+
196
+ if (p.getFallback) {
197
+ result = await withTimeout(p.getFallback(track, controller.signal), timeoutMs, `Fallback timeout ${p.name}`);
198
+ if (result?.stream) {
199
+ this.debug(`Fallback via ${p.name}`);
200
+ controller.abort();
201
+ return result;
202
+ }
203
+ }
204
+
205
+ throw new Error("No stream");
206
+ } catch (err) {
207
+ if (controller.signal.aborted) throw new Error("Aborted");
208
+ this.debug(`Failed ${p.name}`, err);
209
+ throw err;
210
+ }
211
+ };
212
+ return run();
213
+ });
214
+
215
+ const result = await Promise.any(promises);
216
+ if (result?.stream) return result;
217
+ } catch {
218
+ this.debug(`Priority group ${priority} failed`);
219
+ controller.abort();
220
+ }
221
+ }
222
+
223
+ throw new Error(`All plugins failed for track: ${track.title}`);
224
+ }
225
+
226
+ /**
227
+ * Get related tracks for a given track
228
+ * @param {Track} track Track to find related tracks for
229
+ * @returns {Track[]} Related tracks or empty array
230
+ * @example
231
+ * const related = await player.getRelatedTracks(track);
232
+ * console.log(`Found ${related.length} related tracks`);
233
+ */
234
+ async getRelatedTracks(track: Track): Promise<Track[]> {
235
+ if (!track) return [];
236
+
237
+ const timeoutMs = this.options.extractorTimeout ?? 15000;
238
+ const limit = 20;
239
+
240
+ const allPlugins = this.getAll()
241
+ .filter((p) => typeof p.getRelatedTracks === "function")
242
+ .sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0));
243
+
244
+ const history = this.player.queue.previousTracks;
245
+
246
+ const results: Track[] = [];
247
+
248
+ // ===== TRY ALL PLUGINS (NOT JUST FIRST SUCCESS) =====
249
+ await Promise.allSettled(
250
+ allPlugins.map(async (p) => {
251
+ try {
252
+ this.debug(`[RelatedTracks] Querying ${p.name}`);
253
+
254
+ const related = await withTimeout(p.getRelatedTracks!(track, { limit, history }), timeoutMs, `Timeout ${p.name}`);
255
+
256
+ if (Array.isArray(related)) {
257
+ results.push(...related);
258
+ }
259
+ } catch (err) {
260
+ this.debug(`[RelatedTracks] ${p.name} failed`, err);
261
+ }
262
+ }),
263
+ );
264
+
265
+ if (results.length === 0) {
266
+ this.debug(`[RelatedTracks] No results`);
267
+ return [];
268
+ }
269
+
270
+ // ===== DEDUPE =====
271
+ const unique = new Map<string, Track>();
272
+ for (const t of results) {
273
+ if (!unique.has(t.url)) {
274
+ unique.set(t.url, t);
275
+ }
276
+ }
277
+
278
+ // ===== SCORE + SORT =====
279
+ const ranked = Array.from(unique.values())
280
+ .map((t) => ({ track: t, score: scoreTrack(track, t) }))
281
+ .sort((a, b) => b.score - a.score)
282
+ .slice(0, limit)
283
+ .map((x) => x.track);
284
+
285
+ this.debug(`[RelatedTracks] Final ${ranked.length} tracks`);
286
+ return ranked;
287
+ }
288
+ }