ziplayer 0.2.7-dev.0 → 0.2.7-dev.2
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.
- package/AI-Guide.md +407 -756
- package/README.md +275 -10
- package/dist/extensions/BaseExtension.d.ts +1 -0
- package/dist/extensions/BaseExtension.d.ts.map +1 -1
- package/dist/extensions/BaseExtension.js.map +1 -1
- package/dist/extensions/index.d.ts +38 -3
- package/dist/extensions/index.d.ts.map +1 -1
- package/dist/extensions/index.js +259 -41
- package/dist/extensions/index.js.map +1 -1
- package/dist/persistence/PersistenceManager.d.ts +95 -0
- package/dist/persistence/PersistenceManager.d.ts.map +1 -0
- package/dist/persistence/PersistenceManager.js +968 -0
- package/dist/persistence/PersistenceManager.js.map +1 -0
- package/dist/plugins/BasePlugin.js +1 -1
- package/dist/plugins/BasePlugin.js.map +1 -1
- package/dist/plugins/index.d.ts +19 -4
- package/dist/plugins/index.d.ts.map +1 -1
- package/dist/plugins/index.js +204 -113
- package/dist/plugins/index.js.map +1 -1
- package/dist/structures/FilterManager.js +3 -3
- package/dist/structures/FilterManager.js.map +1 -1
- package/dist/structures/Player.d.ts +65 -14
- package/dist/structures/Player.d.ts.map +1 -1
- package/dist/structures/Player.js +330 -88
- package/dist/structures/Player.js.map +1 -1
- package/dist/structures/PlayerManager.d.ts +127 -91
- package/dist/structures/PlayerManager.d.ts.map +1 -1
- package/dist/structures/PlayerManager.js +437 -124
- package/dist/structures/PlayerManager.js.map +1 -1
- package/dist/structures/Queue.d.ts +136 -31
- package/dist/structures/Queue.d.ts.map +1 -1
- package/dist/structures/Queue.js +265 -46
- package/dist/structures/Queue.js.map +1 -1
- package/dist/types/index.d.ts +46 -6
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/index.js +1 -0
- package/dist/types/index.js.map +1 -1
- package/dist/types/persistence.d.ts +74 -0
- package/dist/types/persistence.d.ts.map +1 -0
- package/dist/types/persistence.js +3 -0
- package/dist/types/persistence.js.map +1 -0
- package/package.json +3 -2
- package/src/extensions/BaseExtension.ts +1 -0
- package/src/extensions/index.ts +320 -37
- package/src/persistence/PersistenceManager.ts +1073 -0
- package/src/plugins/BasePlugin.ts +1 -1
- package/src/plugins/index.ts +248 -133
- package/src/structures/FilterManager.ts +3 -3
- package/src/structures/Player.ts +358 -94
- package/src/structures/PlayerManager.ts +535 -129
- package/src/structures/Queue.ts +300 -55
- package/src/types/index.ts +52 -10
- package/src/types/persistence.ts +83 -0
- package/src/types/plugin.ts +1 -1
|
@@ -3,7 +3,7 @@ import { SourcePlugin, Track, SearchResult, StreamInfo } from "../types";
|
|
|
3
3
|
export abstract class BasePlugin implements SourcePlugin {
|
|
4
4
|
abstract name: string;
|
|
5
5
|
abstract version: string;
|
|
6
|
-
priority?: number = 0;
|
|
6
|
+
priority?: number = 0; // Higher = run first
|
|
7
7
|
|
|
8
8
|
abstract canHandle(query: string): boolean;
|
|
9
9
|
abstract search(query: string, requestedBy: string): Promise<SearchResult>;
|
package/src/plugins/index.ts
CHANGED
|
@@ -6,6 +6,8 @@ import type { Player } from "../structures/Player";
|
|
|
6
6
|
|
|
7
7
|
type PluginManagerOptions = {
|
|
8
8
|
extractorTimeout: number | undefined;
|
|
9
|
+
maxFallbackAttempts?: number;
|
|
10
|
+
enableCache?: boolean;
|
|
9
11
|
};
|
|
10
12
|
|
|
11
13
|
export { BasePlugin } from "./BasePlugin";
|
|
@@ -28,84 +30,73 @@ function levenshtein(a: string, b: string): number {
|
|
|
28
30
|
|
|
29
31
|
function similarity(a: string, b: string): number {
|
|
30
32
|
if (!a || !b) return 0;
|
|
31
|
-
|
|
32
33
|
const dist = levenshtein(a, b);
|
|
33
34
|
const maxLen = Math.max(a.length, b.length);
|
|
34
|
-
|
|
35
|
-
return 1 - dist / maxLen; // 0 → 1
|
|
35
|
+
return 1 - dist / maxLen;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
function normalize(str: string): string {
|
|
39
39
|
return str
|
|
40
40
|
.toLowerCase()
|
|
41
|
-
.replace(/\(.*?\)|\[.*?\]/g, "")
|
|
41
|
+
.replace(/\(.*?\)|\[.*?\]/g, "")
|
|
42
42
|
.replace(/[^a-z0-9\s]/g, "")
|
|
43
43
|
.replace(/\s+/g, " ")
|
|
44
44
|
.trim();
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
const MUSIC_KEYWORDS = ["official", "mv", "audio", "lyrics", "remix", "cover", "ft", "feat", "prod", "music video"];
|
|
48
|
-
|
|
49
48
|
const NON_MUSIC_KEYWORDS = ["reaction", "review", "podcast", "interview", "vlog", "live stream", "news", "tiktok"];
|
|
50
49
|
|
|
51
50
|
function detectContentType(title: string): number {
|
|
52
51
|
const t = title.toLowerCase();
|
|
53
|
-
|
|
54
52
|
let score = 0;
|
|
55
|
-
|
|
56
|
-
for (const k of
|
|
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
|
-
|
|
53
|
+
for (const k of MUSIC_KEYWORDS) if (t.includes(k)) score += 2;
|
|
54
|
+
for (const k of NON_MUSIC_KEYWORDS) if (t.includes(k)) score -= 3;
|
|
64
55
|
return score;
|
|
65
56
|
}
|
|
66
57
|
|
|
67
58
|
function tokenOverlap(a: string, b: string): number {
|
|
68
59
|
const setA = new Set(a.split(" "));
|
|
69
60
|
const setB = new Set(b.split(" "));
|
|
70
|
-
|
|
71
61
|
let match = 0;
|
|
72
|
-
for (const word of setA)
|
|
73
|
-
if (setB.has(word)) match++;
|
|
74
|
-
}
|
|
75
|
-
|
|
62
|
+
for (const word of setA) if (setB.has(word)) match++;
|
|
76
63
|
return match / Math.max(setA.size, setB.size);
|
|
77
64
|
}
|
|
78
65
|
|
|
79
66
|
function scoreTrack(base: Track, candidate: Track): number {
|
|
80
67
|
const titleA = normalize(base.title);
|
|
81
68
|
const titleB = normalize(candidate.title);
|
|
82
|
-
|
|
83
69
|
let score = 0;
|
|
84
|
-
|
|
85
|
-
// ===== FUZZY =====
|
|
86
|
-
const sim = similarity(titleA, titleB); // 0 → 1
|
|
87
|
-
score += sim * 50;
|
|
88
|
-
|
|
89
|
-
// ===== TOKEN MATCH =====
|
|
70
|
+
score += similarity(titleA, titleB) * 50;
|
|
90
71
|
score += tokenOverlap(titleA, titleB) * 30;
|
|
91
|
-
|
|
92
|
-
// ===== CONTENT TYPE =====
|
|
93
72
|
score += detectContentType(candidate.title);
|
|
94
|
-
|
|
95
73
|
return score;
|
|
96
74
|
}
|
|
97
75
|
|
|
98
|
-
//
|
|
76
|
+
// Cache entry for stream results
|
|
77
|
+
interface StreamCacheEntry {
|
|
78
|
+
streamInfo: StreamInfo;
|
|
79
|
+
timestamp: number;
|
|
80
|
+
expiresAt: number;
|
|
81
|
+
}
|
|
82
|
+
|
|
99
83
|
export class PluginManager {
|
|
100
84
|
private options: PluginManagerOptions;
|
|
101
85
|
private player: Player;
|
|
102
86
|
private manager: PlayerManager;
|
|
103
87
|
private plugins: Map<string, BasePlugin> = new Map();
|
|
88
|
+
private streamCache: Map<string, StreamCacheEntry> = new Map();
|
|
89
|
+
private readonly STREAM_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
|
90
|
+
private pendingStreams: Map<string, Promise<StreamInfo | null>> = new Map(); // Dedupe in-flight requests
|
|
104
91
|
|
|
105
92
|
constructor(player: Player, manager: PlayerManager, options: PluginManagerOptions) {
|
|
106
93
|
this.player = player;
|
|
107
94
|
this.manager = manager;
|
|
108
|
-
this.options =
|
|
95
|
+
this.options = {
|
|
96
|
+
maxFallbackAttempts: 3,
|
|
97
|
+
enableCache: true,
|
|
98
|
+
...options,
|
|
99
|
+
};
|
|
109
100
|
}
|
|
110
101
|
|
|
111
102
|
debug(message?: any, ...optionalParams: any[]): void {
|
|
@@ -115,11 +106,18 @@ export class PluginManager {
|
|
|
115
106
|
}
|
|
116
107
|
|
|
117
108
|
register(plugin: BasePlugin): void {
|
|
109
|
+
if (this.plugins.has(plugin.name)) {
|
|
110
|
+
this.debug(`Overwriting existing plugin: ${plugin.name}`);
|
|
111
|
+
}
|
|
112
|
+
plugin.priority ??= 0;
|
|
118
113
|
this.plugins.set(plugin.name, plugin);
|
|
114
|
+
this.debug(`Registered plugin: ${plugin.name} (priority: ${plugin.priority})`);
|
|
119
115
|
}
|
|
120
116
|
|
|
121
117
|
unregister(name: string): boolean {
|
|
122
|
-
|
|
118
|
+
const removed = this.plugins.delete(name);
|
|
119
|
+
if (removed) this.debug(`Unregistered plugin: ${name}`);
|
|
120
|
+
return removed;
|
|
123
121
|
}
|
|
124
122
|
|
|
125
123
|
get(name: string): BasePlugin | undefined {
|
|
@@ -127,162 +125,279 @@ export class PluginManager {
|
|
|
127
125
|
}
|
|
128
126
|
|
|
129
127
|
getAll(): BasePlugin[] {
|
|
130
|
-
return Array.from(this.plugins.values());
|
|
128
|
+
return Array.from(this.plugins.values()).sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
|
|
131
129
|
}
|
|
132
130
|
|
|
133
131
|
findPlugin(query: string): BasePlugin | undefined {
|
|
134
|
-
|
|
132
|
+
// First try exact match by source
|
|
133
|
+
for (const plugin of this.getAll()) {
|
|
134
|
+
if (plugin.name && query.toLowerCase().includes(plugin.name.toLowerCase())) {
|
|
135
|
+
return plugin;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Then try canHandle
|
|
140
|
+
return this.getAll().find((plugin) => plugin.canHandle?.(query) ?? false);
|
|
135
141
|
}
|
|
136
142
|
|
|
137
143
|
clear(): void {
|
|
138
144
|
this.plugins.clear();
|
|
145
|
+
this.streamCache.clear();
|
|
146
|
+
this.pendingStreams.clear();
|
|
139
147
|
}
|
|
140
148
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
149
|
+
private getStreamCacheKey(track: Track): string {
|
|
150
|
+
return `${track.source}:${track.url}:${track.id || track.title}`;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
private getCachedStream(track: Track): StreamInfo | null {
|
|
154
|
+
if (!this.options.enableCache) return null;
|
|
155
|
+
|
|
156
|
+
const key = this.getStreamCacheKey(track);
|
|
157
|
+
const cached = this.streamCache.get(key);
|
|
158
|
+
|
|
159
|
+
if (cached && Date.now() < cached.expiresAt) {
|
|
160
|
+
this.debug(`[Cache] Hit for track: ${track.title}`);
|
|
161
|
+
return cached.streamInfo;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (cached) {
|
|
165
|
+
this.debug(`[Cache] Expired for track: ${track.title}`);
|
|
166
|
+
this.streamCache.delete(key);
|
|
147
167
|
}
|
|
168
|
+
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
private setCachedStream(track: Track, streamInfo: StreamInfo): void {
|
|
173
|
+
if (!this.options.enableCache) return;
|
|
174
|
+
|
|
175
|
+
const key = this.getStreamCacheKey(track);
|
|
176
|
+
this.streamCache.set(key, {
|
|
177
|
+
streamInfo,
|
|
178
|
+
timestamp: Date.now(),
|
|
179
|
+
expiresAt: Date.now() + this.STREAM_CACHE_TTL,
|
|
180
|
+
});
|
|
181
|
+
this.debug(`[Cache] Stored for track: ${track.title}`);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
private async getStreamWithDedupe(track: Track, primary: BasePlugin): Promise<StreamInfo | null> {
|
|
185
|
+
const key = this.getStreamCacheKey(track);
|
|
186
|
+
|
|
187
|
+
// Check if there's already an in-flight request
|
|
188
|
+
if (this.pendingStreams.has(key)) {
|
|
189
|
+
this.debug(`[Dedupe] Waiting for existing request: ${track.title}`);
|
|
190
|
+
return this.pendingStreams.get(key)!;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Create new request
|
|
194
|
+
const promise = this.getStreamInternal(track, primary);
|
|
195
|
+
this.pendingStreams.set(key, promise);
|
|
196
|
+
|
|
148
197
|
try {
|
|
198
|
+
const result = await promise;
|
|
199
|
+
return result;
|
|
200
|
+
} finally {
|
|
201
|
+
this.pendingStreams.delete(key);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
private async getStreamInternal(track: Track, primary: BasePlugin): Promise<StreamInfo | null> {
|
|
206
|
+
const timeoutMs = this.options.extractorTimeout ?? 50000;
|
|
207
|
+
|
|
208
|
+
// Check cache first
|
|
209
|
+
const cached = this.getCachedStream(track);
|
|
210
|
+
if (cached) return cached;
|
|
211
|
+
|
|
212
|
+
// Try primary plugin first
|
|
213
|
+
try {
|
|
214
|
+
this.debug(`[Primary] Trying ${primary.name} for track: ${track.title}`);
|
|
149
215
|
const controller = new AbortController();
|
|
150
|
-
const result = await withTimeout(
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
216
|
+
const result = await withTimeout(
|
|
217
|
+
primary.getStream(track, controller.signal),
|
|
218
|
+
timeoutMs,
|
|
219
|
+
`Primary timeout: ${primary.name}`,
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
if (result?.stream) {
|
|
223
|
+
this.debug(`[Primary] Success via ${primary.name}`);
|
|
224
|
+
this.setCachedStream(track, result);
|
|
225
|
+
return result;
|
|
226
|
+
}
|
|
227
|
+
throw new Error("Primary plugin returned no stream");
|
|
228
|
+
} catch (error) {
|
|
229
|
+
this.debug(`[Primary] Failed: ${primary.name}`, error);
|
|
155
230
|
}
|
|
156
231
|
|
|
157
|
-
//
|
|
158
|
-
const
|
|
159
|
-
.filter((p) => p !== primary)
|
|
160
|
-
.
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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);
|
|
232
|
+
// Fallback to other plugins
|
|
233
|
+
const fallbackPlugins = this.getAll()
|
|
234
|
+
.filter((p) => p !== primary && p.name !== primary.name)
|
|
235
|
+
.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
|
|
236
|
+
|
|
237
|
+
if (fallbackPlugins.length === 0) {
|
|
238
|
+
this.debug(`[Fallback] No fallback plugins available`);
|
|
239
|
+
return null;
|
|
171
240
|
}
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
241
|
+
|
|
242
|
+
this.debug(`[Fallback] Trying ${fallbackPlugins.length} plugins sequentially`);
|
|
243
|
+
|
|
244
|
+
// Try plugins sequentially to avoid overwhelming sources
|
|
245
|
+
let attempt = 0;
|
|
246
|
+
for (const plugin of fallbackPlugins) {
|
|
247
|
+
attempt++;
|
|
248
|
+
if (attempt > (this.options.maxFallbackAttempts ?? 3)) {
|
|
249
|
+
this.debug(`[Fallback] Max attempts (${this.options.maxFallbackAttempts}) reached`);
|
|
250
|
+
break;
|
|
251
|
+
}
|
|
252
|
+
|
|
175
253
|
try {
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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();
|
|
254
|
+
this.debug(`[Fallback] Attempt ${attempt}/${fallbackPlugins.length}: ${plugin.name}`);
|
|
255
|
+
const controller = new AbortController();
|
|
256
|
+
|
|
257
|
+
let result: StreamInfo | null = null;
|
|
258
|
+
|
|
259
|
+
// Try getStream first
|
|
260
|
+
if (plugin.getStream) {
|
|
261
|
+
result = await withTimeout(plugin.getStream(track, controller.signal), timeoutMs, `Timeout: ${plugin.name}`);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Try fallback method if getStream failed
|
|
265
|
+
if (!result?.stream && plugin.getFallback) {
|
|
266
|
+
this.debug(`[Fallback] Trying fallback method for ${plugin.name}`);
|
|
267
|
+
result = await withTimeout(plugin.getFallback(track, controller.signal), timeoutMs, `Fallback timeout: ${plugin.name}`);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (result?.stream) {
|
|
271
|
+
this.debug(`[Fallback] Success via ${plugin.name}`);
|
|
272
|
+
this.setCachedStream(track, result);
|
|
273
|
+
return result;
|
|
274
|
+
}
|
|
275
|
+
} catch (error) {
|
|
276
|
+
this.debug(`[Fallback] Failed: ${plugin.name}`, error);
|
|
220
277
|
}
|
|
221
278
|
}
|
|
222
279
|
|
|
223
|
-
|
|
280
|
+
this.debug(`[Fallback] All plugins failed for track: ${track.title}`);
|
|
281
|
+
return null;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async getStream(track: Track): Promise<StreamInfo | null> {
|
|
285
|
+
if (!track) {
|
|
286
|
+
this.debug(`[getStream] No track provided`);
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Find the most appropriate plugin
|
|
291
|
+
let primary = this.get(track.source);
|
|
292
|
+
if (!primary) {
|
|
293
|
+
primary = this.findPlugin(track.url);
|
|
294
|
+
}
|
|
295
|
+
if (!primary) {
|
|
296
|
+
this.debug(`[getStream] No plugin found for track: ${track.title} (source: ${track.source})`);
|
|
297
|
+
return null;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return this.getStreamWithDedupe(track, primary);
|
|
224
301
|
}
|
|
225
302
|
|
|
226
303
|
/**
|
|
227
304
|
* Get related tracks for a given track
|
|
228
305
|
* @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`);
|
|
306
|
+
* @returns {Promise<Track[]>} Related tracks or empty array
|
|
233
307
|
*/
|
|
234
308
|
async getRelatedTracks(track: Track): Promise<Track[]> {
|
|
235
309
|
if (!track) return [];
|
|
236
310
|
|
|
237
311
|
const timeoutMs = this.options.extractorTimeout ?? 15000;
|
|
238
312
|
const limit = 20;
|
|
313
|
+
const minSimilarityScore = 10; // Minimum score to consider
|
|
239
314
|
|
|
240
|
-
const
|
|
315
|
+
const relatedPlugins = this.getAll()
|
|
241
316
|
.filter((p) => typeof p.getRelatedTracks === "function")
|
|
242
|
-
.sort((a, b) => (
|
|
317
|
+
.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
|
|
318
|
+
|
|
319
|
+
if (relatedPlugins.length === 0) {
|
|
320
|
+
this.debug(`[RelatedTracks] No plugins support related tracks`);
|
|
321
|
+
return [];
|
|
322
|
+
}
|
|
243
323
|
|
|
244
324
|
const history = this.player.queue.previousTracks;
|
|
325
|
+
const historyUrls = new Set(history.map((t) => t.url));
|
|
326
|
+
const currentTrackUrl = track.url;
|
|
245
327
|
|
|
246
328
|
const results: Track[] = [];
|
|
247
329
|
|
|
248
|
-
//
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
330
|
+
// Try plugins in parallel but with limit
|
|
331
|
+
const batchSize = 3;
|
|
332
|
+
for (let i = 0; i < relatedPlugins.length; i += batchSize) {
|
|
333
|
+
const batch = relatedPlugins.slice(i, i + batchSize);
|
|
334
|
+
const batchResults = await Promise.allSettled(
|
|
335
|
+
batch.map(async (plugin) => {
|
|
336
|
+
try {
|
|
337
|
+
this.debug(`[RelatedTracks] Querying ${plugin.name}`);
|
|
338
|
+
const related = await withTimeout(
|
|
339
|
+
plugin.getRelatedTracks!(track, { limit, history }),
|
|
340
|
+
timeoutMs,
|
|
341
|
+
`Timeout ${plugin.name}`,
|
|
342
|
+
);
|
|
343
|
+
return Array.isArray(related) ? related : [];
|
|
344
|
+
} catch (err) {
|
|
345
|
+
this.debug(`[RelatedTracks] ${plugin.name} failed`, err);
|
|
346
|
+
return [];
|
|
258
347
|
}
|
|
259
|
-
}
|
|
260
|
-
|
|
348
|
+
}),
|
|
349
|
+
);
|
|
350
|
+
|
|
351
|
+
for (const result of batchResults) {
|
|
352
|
+
if (result.status === "fulfilled") {
|
|
353
|
+
results.push(...result.value);
|
|
261
354
|
}
|
|
262
|
-
}
|
|
263
|
-
|
|
355
|
+
}
|
|
356
|
+
}
|
|
264
357
|
|
|
265
358
|
if (results.length === 0) {
|
|
266
|
-
this.debug(`[RelatedTracks] No results`);
|
|
359
|
+
this.debug(`[RelatedTracks] No results from any plugin`);
|
|
267
360
|
return [];
|
|
268
361
|
}
|
|
269
362
|
|
|
270
|
-
//
|
|
363
|
+
// Deduplicate by URL
|
|
271
364
|
const unique = new Map<string, Track>();
|
|
272
365
|
for (const t of results) {
|
|
273
|
-
if (!unique.has(t.url)) {
|
|
366
|
+
if (!unique.has(t.url) && t.url !== currentTrackUrl && !historyUrls.has(t.url)) {
|
|
274
367
|
unique.set(t.url, t);
|
|
275
368
|
}
|
|
276
369
|
}
|
|
277
370
|
|
|
278
|
-
//
|
|
371
|
+
// Score and sort
|
|
279
372
|
const ranked = Array.from(unique.values())
|
|
280
373
|
.map((t) => ({ track: t, score: scoreTrack(track, t) }))
|
|
374
|
+
.filter((item) => item.score >= minSimilarityScore)
|
|
281
375
|
.sort((a, b) => b.score - a.score)
|
|
282
376
|
.slice(0, limit)
|
|
283
377
|
.map((x) => x.track);
|
|
284
378
|
|
|
285
|
-
this.debug(`[RelatedTracks]
|
|
379
|
+
this.debug(`[RelatedTracks] Found ${ranked.length} related tracks (filtered from ${results.length})`);
|
|
286
380
|
return ranked;
|
|
287
381
|
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Clear stream cache
|
|
385
|
+
*/
|
|
386
|
+
clearStreamCache(): void {
|
|
387
|
+
const size = this.streamCache.size;
|
|
388
|
+
this.streamCache.clear();
|
|
389
|
+
this.debug(`[Cache] Cleared ${size} stream cache entries`);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Get plugin statistics
|
|
394
|
+
*/
|
|
395
|
+
getStats(): object {
|
|
396
|
+
return {
|
|
397
|
+
totalPlugins: this.plugins.size,
|
|
398
|
+
pluginNames: Array.from(this.plugins.keys()),
|
|
399
|
+
cacheSize: this.streamCache.size,
|
|
400
|
+
pendingRequests: this.pendingStreams.size,
|
|
401
|
+
};
|
|
402
|
+
}
|
|
288
403
|
}
|
|
@@ -156,7 +156,7 @@ export class FilterManager {
|
|
|
156
156
|
|
|
157
157
|
this.activeFilters.push(audioFilter);
|
|
158
158
|
this.debug(`[FilterManager] Applied filter: ${audioFilter.name} - ${audioFilter.description}`);
|
|
159
|
-
return await this.player.
|
|
159
|
+
return await this.player.refreshPlayerResource();
|
|
160
160
|
}
|
|
161
161
|
|
|
162
162
|
/**
|
|
@@ -195,7 +195,7 @@ export class FilterManager {
|
|
|
195
195
|
}
|
|
196
196
|
const removed = this.activeFilters.splice(index, 1)[0];
|
|
197
197
|
this.debug(`[FilterManager] Removed filter: ${removed.name}`);
|
|
198
|
-
return await this.player.
|
|
198
|
+
return await this.player.refreshPlayerResource();
|
|
199
199
|
}
|
|
200
200
|
|
|
201
201
|
/**
|
|
@@ -209,7 +209,7 @@ export class FilterManager {
|
|
|
209
209
|
const count = this.activeFilters.length;
|
|
210
210
|
this.activeFilters = [];
|
|
211
211
|
this.debug(`[FilterManager] Cleared ${count} filters`);
|
|
212
|
-
return await this.player.
|
|
212
|
+
return await this.player.refreshPlayerResource();
|
|
213
213
|
}
|
|
214
214
|
|
|
215
215
|
/**
|