ziplayer 0.2.6 → 0.2.7-dev.1
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 +607 -0
- package/README.md +513 -196
- 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 +61 -0
- package/dist/persistence/PersistenceManager.d.ts.map +1 -0
- package/dist/persistence/PersistenceManager.js +551 -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 +273 -146
- 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 +64 -14
- package/dist/structures/Player.d.ts.map +1 -1
- package/dist/structures/Player.js +344 -91
- package/dist/structures/Player.js.map +1 -1
- package/dist/structures/PlayerManager.d.ts +125 -91
- package/dist/structures/PlayerManager.d.ts.map +1 -1
- package/dist/structures/PlayerManager.js +406 -111
- 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 +39 -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 +55 -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 +47 -46
- package/src/extensions/BaseExtension.ts +36 -35
- package/src/extensions/index.ts +473 -190
- package/src/index.ts +16 -16
- package/src/persistence/PersistenceManager.ts +572 -0
- package/src/plugins/BasePlugin.ts +27 -27
- package/src/plugins/index.ts +403 -236
- package/src/structures/FilterManager.ts +303 -303
- package/src/structures/Player.ts +1962 -1689
- package/src/structures/PlayerManager.ts +788 -416
- package/src/structures/Queue.ts +599 -354
- package/src/types/index.ts +406 -373
- package/src/types/persistence.ts +65 -0
- package/src/types/plugin.ts +1 -1
- package/src/utils/timeout.ts +10 -10
- package/tsconfig.json +22 -23
package/src/plugins/index.ts
CHANGED
|
@@ -1,236 +1,403 @@
|
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
.
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
);
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
+
maxFallbackAttempts?: number;
|
|
10
|
+
enableCache?: boolean;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export { BasePlugin } from "./BasePlugin";
|
|
14
|
+
|
|
15
|
+
function levenshtein(a: string, b: string): number {
|
|
16
|
+
const matrix = Array.from({ length: a.length + 1 }, () => new Array(b.length + 1).fill(0));
|
|
17
|
+
|
|
18
|
+
for (let i = 0; i <= a.length; i++) matrix[i][0] = i;
|
|
19
|
+
for (let j = 0; j <= b.length; j++) matrix[0][j] = j;
|
|
20
|
+
|
|
21
|
+
for (let i = 1; i <= a.length; i++) {
|
|
22
|
+
for (let j = 1; j <= b.length; j++) {
|
|
23
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
24
|
+
matrix[i][j] = Math.min(matrix[i - 1][j] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j - 1] + cost);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return matrix[a.length][b.length];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function similarity(a: string, b: string): number {
|
|
32
|
+
if (!a || !b) return 0;
|
|
33
|
+
const dist = levenshtein(a, b);
|
|
34
|
+
const maxLen = Math.max(a.length, b.length);
|
|
35
|
+
return 1 - dist / maxLen;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function normalize(str: string): string {
|
|
39
|
+
return str
|
|
40
|
+
.toLowerCase()
|
|
41
|
+
.replace(/\(.*?\)|\[.*?\]/g, "")
|
|
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
|
+
const NON_MUSIC_KEYWORDS = ["reaction", "review", "podcast", "interview", "vlog", "live stream", "news", "tiktok"];
|
|
49
|
+
|
|
50
|
+
function detectContentType(title: string): number {
|
|
51
|
+
const t = title.toLowerCase();
|
|
52
|
+
let score = 0;
|
|
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;
|
|
55
|
+
return score;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function tokenOverlap(a: string, b: string): number {
|
|
59
|
+
const setA = new Set(a.split(" "));
|
|
60
|
+
const setB = new Set(b.split(" "));
|
|
61
|
+
let match = 0;
|
|
62
|
+
for (const word of setA) if (setB.has(word)) match++;
|
|
63
|
+
return match / Math.max(setA.size, setB.size);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function scoreTrack(base: Track, candidate: Track): number {
|
|
67
|
+
const titleA = normalize(base.title);
|
|
68
|
+
const titleB = normalize(candidate.title);
|
|
69
|
+
let score = 0;
|
|
70
|
+
score += similarity(titleA, titleB) * 50;
|
|
71
|
+
score += tokenOverlap(titleA, titleB) * 30;
|
|
72
|
+
score += detectContentType(candidate.title);
|
|
73
|
+
return score;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Cache entry for stream results
|
|
77
|
+
interface StreamCacheEntry {
|
|
78
|
+
streamInfo: StreamInfo;
|
|
79
|
+
timestamp: number;
|
|
80
|
+
expiresAt: number;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export class PluginManager {
|
|
84
|
+
private options: PluginManagerOptions;
|
|
85
|
+
private player: Player;
|
|
86
|
+
private manager: PlayerManager;
|
|
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
|
|
91
|
+
|
|
92
|
+
constructor(player: Player, manager: PlayerManager, options: PluginManagerOptions) {
|
|
93
|
+
this.player = player;
|
|
94
|
+
this.manager = manager;
|
|
95
|
+
this.options = {
|
|
96
|
+
maxFallbackAttempts: 3,
|
|
97
|
+
enableCache: true,
|
|
98
|
+
...options,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
debug(message?: any, ...optionalParams: any[]): void {
|
|
103
|
+
if (this.manager.debugEnabled) {
|
|
104
|
+
this.manager.emit("debug", `[Plugins] ${message}`, ...optionalParams);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
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;
|
|
113
|
+
this.plugins.set(plugin.name, plugin);
|
|
114
|
+
this.debug(`Registered plugin: ${plugin.name} (priority: ${plugin.priority})`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
unregister(name: string): boolean {
|
|
118
|
+
const removed = this.plugins.delete(name);
|
|
119
|
+
if (removed) this.debug(`Unregistered plugin: ${name}`);
|
|
120
|
+
return removed;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
get(name: string): BasePlugin | undefined {
|
|
124
|
+
return this.plugins.get(name);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
getAll(): BasePlugin[] {
|
|
128
|
+
return Array.from(this.plugins.values()).sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
findPlugin(query: string): BasePlugin | undefined {
|
|
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);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
clear(): void {
|
|
144
|
+
this.plugins.clear();
|
|
145
|
+
this.streamCache.clear();
|
|
146
|
+
this.pendingStreams.clear();
|
|
147
|
+
}
|
|
148
|
+
|
|
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);
|
|
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
|
+
|
|
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}`);
|
|
215
|
+
const controller = new AbortController();
|
|
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);
|
|
230
|
+
}
|
|
231
|
+
|
|
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;
|
|
240
|
+
}
|
|
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
|
+
|
|
253
|
+
try {
|
|
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);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
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);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Get related tracks for a given track
|
|
305
|
+
* @param {Track} track Track to find related tracks for
|
|
306
|
+
* @returns {Promise<Track[]>} Related tracks or empty array
|
|
307
|
+
*/
|
|
308
|
+
async getRelatedTracks(track: Track): Promise<Track[]> {
|
|
309
|
+
if (!track) return [];
|
|
310
|
+
|
|
311
|
+
const timeoutMs = this.options.extractorTimeout ?? 15000;
|
|
312
|
+
const limit = 20;
|
|
313
|
+
const minSimilarityScore = 10; // Minimum score to consider
|
|
314
|
+
|
|
315
|
+
const relatedPlugins = this.getAll()
|
|
316
|
+
.filter((p) => typeof p.getRelatedTracks === "function")
|
|
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
|
+
}
|
|
323
|
+
|
|
324
|
+
const history = this.player.queue.previousTracks;
|
|
325
|
+
const historyUrls = new Set(history.map((t) => t.url));
|
|
326
|
+
const currentTrackUrl = track.url;
|
|
327
|
+
|
|
328
|
+
const results: Track[] = [];
|
|
329
|
+
|
|
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 [];
|
|
347
|
+
}
|
|
348
|
+
}),
|
|
349
|
+
);
|
|
350
|
+
|
|
351
|
+
for (const result of batchResults) {
|
|
352
|
+
if (result.status === "fulfilled") {
|
|
353
|
+
results.push(...result.value);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
if (results.length === 0) {
|
|
359
|
+
this.debug(`[RelatedTracks] No results from any plugin`);
|
|
360
|
+
return [];
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Deduplicate by URL
|
|
364
|
+
const unique = new Map<string, Track>();
|
|
365
|
+
for (const t of results) {
|
|
366
|
+
if (!unique.has(t.url) && t.url !== currentTrackUrl && !historyUrls.has(t.url)) {
|
|
367
|
+
unique.set(t.url, t);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Score and sort
|
|
372
|
+
const ranked = Array.from(unique.values())
|
|
373
|
+
.map((t) => ({ track: t, score: scoreTrack(track, t) }))
|
|
374
|
+
.filter((item) => item.score >= minSimilarityScore)
|
|
375
|
+
.sort((a, b) => b.score - a.score)
|
|
376
|
+
.slice(0, limit)
|
|
377
|
+
.map((x) => x.track);
|
|
378
|
+
|
|
379
|
+
this.debug(`[RelatedTracks] Found ${ranked.length} related tracks (filtered from ${results.length})`);
|
|
380
|
+
return ranked;
|
|
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
|
+
}
|
|
403
|
+
}
|