ziplayer 0.2.5 → 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,109 +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
- type DebugFn = (message?: any, ...optionalParams: any[]) => void;
7
-
8
- type PluginManagerOptions = {
9
- extractorTimeout: number | undefined;
10
- };
11
-
12
- export { BasePlugin } from "./BasePlugin";
13
-
14
- // Plugin factory
15
- export class PluginManager {
16
- private debug: DebugFn;
17
- private options: PluginManagerOptions;
18
- private player: Player;
19
- private manager: PlayerManager;
20
- private plugins: Map<string, BasePlugin> = new Map();
21
-
22
- constructor(player: Player, manager: PlayerManager, options: PluginManagerOptions) {
23
- this.player = player;
24
- this.manager = manager;
25
- this.options = options;
26
- this.debug = (message?: any, ...optionalParams: any[]) => {
27
- if (manager.debugEnabled) {
28
- manager.emit("debug", `[ExtensionManager] ${message}`, ...optionalParams);
29
- }
30
- };
31
- }
32
-
33
- register(plugin: BasePlugin): void {
34
- this.plugins.set(plugin.name, plugin);
35
- }
36
-
37
- unregister(name: string): boolean {
38
- return this.plugins.delete(name);
39
- }
40
-
41
- get(name: string): BasePlugin | undefined {
42
- return this.plugins.get(name);
43
- }
44
-
45
- getAll(): BasePlugin[] {
46
- return Array.from(this.plugins.values());
47
- }
48
-
49
- findPlugin(query: string): BasePlugin | undefined {
50
- return this.getAll().find((plugin) => plugin.canHandle(query));
51
- }
52
-
53
- clear(): void {
54
- this.plugins.clear();
55
- }
56
-
57
- async getStream(track: Track): Promise<StreamInfo | null> {
58
- let streamInfo: StreamInfo | null = null;
59
- const plugin = this.get(track.source) || this.findPlugin(track.url);
60
-
61
- if (!plugin) {
62
- this.debug(`[Player] No plugin found for track: ${track.title}`);
63
- return null;
64
- }
65
-
66
- this.debug(`[Player] Getting stream for track: ${track.title}`);
67
- this.debug(`[Player] Using plugin: ${plugin.name}`);
68
- this.debug(`[Track] Track Info:`, track);
69
- const timeoutMs = this.options.extractorTimeout ?? 50000;
70
- try {
71
- streamInfo = await withTimeout(plugin.getStream(track), timeoutMs, "getStream timed out");
72
- if (!(streamInfo as any)?.stream) {
73
- throw new Error(`No stream returned from ${plugin.name}`);
74
- }
75
- } catch (streamError) {
76
- this.debug(`[Player] getStream failed, trying getFallback:`, streamError);
77
- const allplugs = this.getAll();
78
- for (const p of allplugs) {
79
- try {
80
- if (typeof p.getStream == "function") {
81
- streamInfo = await withTimeout((p as any).getStream(track), timeoutMs, `getStream timed out for plugin ${p.name}`);
82
- if ((streamInfo as any)?.stream) {
83
- this.debug(`[Player] getStream succeeded with plugin ${p.name} for track: ${track.title}`);
84
- return streamInfo as StreamInfo;
85
- }
86
- }
87
- if (typeof p.getFallback == "function") {
88
- streamInfo = await withTimeout(
89
- (p as any).getFallback(track),
90
- timeoutMs,
91
- `getFallback timed out for plugin ${p.name}`,
92
- );
93
- if ((streamInfo as any)?.stream) {
94
- this.debug(`[Player] getFallback succeeded with plugin ${p.name} for track: ${track.title}`);
95
- return streamInfo as StreamInfo;
96
- }
97
- }
98
- } catch (fallbackError) {
99
- this.debug(`[Player] getFallback failed with plugin ${p.name}:`, fallbackError);
100
- }
101
- }
102
- if (!(streamInfo as any)?.stream) {
103
- throw new Error(`All getFallback attempts failed for track: ${track.title}`);
104
- }
105
- }
106
-
107
- return streamInfo as StreamInfo;
108
- }
109
- }
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
+ }