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.
- package/AI-Guide.md +956 -0
- package/README.md +259 -196
- package/dist/plugins/index.d.ts.map +1 -1
- package/dist/plugins/index.js +97 -61
- package/dist/plugins/index.js.map +1 -1
- package/dist/structures/Player.d.ts.map +1 -1
- package/dist/structures/Player.js +18 -3
- package/dist/structures/Player.js.map +1 -1
- package/package.json +46 -46
- package/src/extensions/BaseExtension.ts +35 -35
- package/src/extensions/index.ts +190 -190
- package/src/index.ts +16 -16
- package/src/plugins/BasePlugin.ts +27 -27
- package/src/plugins/index.ts +288 -236
- package/src/structures/FilterManager.ts +303 -303
- package/src/structures/Player.ts +1704 -1689
- package/src/structures/PlayerManager.ts +416 -416
- package/src/structures/Queue.ts +354 -354
- package/src/types/index.ts +373 -373
- package/src/utils/timeout.ts +10 -10
- package/tsconfig.json +22 -23
package/README.md
CHANGED
|
@@ -1,196 +1,259 @@
|
|
|
1
|
-
<img width="1175" height="305" alt="logo" src="https://raw.githubusercontent.com/ZiProject/ZiPlayer/refs/heads/main/publish/logo.png" />
|
|
2
|
-
|
|
3
|
-
#
|
|
4
|
-
|
|
5
|
-
A
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
const
|
|
164
|
-
extensions: [
|
|
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
|
-
|
|
1
|
+
<img width="1175" height="305" alt="logo" src="https://raw.githubusercontent.com/ZiProject/ZiPlayer/refs/heads/main/publish/logo.png" />
|
|
2
|
+
|
|
3
|
+
# ZiPlayer
|
|
4
|
+
|
|
5
|
+
A powerful, extensible Discord music engine built on top of `@discordjs/voice`, designed for scalability, flexibility, and
|
|
6
|
+
developer experience.
|
|
7
|
+
|
|
8
|
+
ZiPlayer is not just a player β it's a **full ecosystem** with plugins, extensions, and a modular architecture that lets you build
|
|
9
|
+
advanced music bots quickly.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## β¨ Highlights
|
|
14
|
+
|
|
15
|
+
- π **Plugin-driven architecture** β Easily support new audio sources
|
|
16
|
+
- π **Multi-source playback** β YouTube, SoundCloud, Spotify (with fallback), TTS, and more
|
|
17
|
+
- π§ **Smart fallback system** β Automatically resolves streams across plugins
|
|
18
|
+
- ποΈ **Advanced audio filters** β Real-time FFmpeg effects (bassboost, nightcore, etc.)
|
|
19
|
+
- π **Autoplay & looping** β Seamless listening experience
|
|
20
|
+
- π§© **Extension system** β Add STT, lyrics, Lavalink, and custom logic
|
|
21
|
+
- ποΈ **Per-guild player system** β Scales across multiple Discord servers
|
|
22
|
+
- π‘ **Event-driven core** β Full lifecycle hooks for customization
|
|
23
|
+
- πΎ **Custom userdata** β Attach context to each player
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## π¦ Installation
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
npm install ziplayer @ziplayer/plugin @ziplayer/extension @ziplayer/infinity @discordjs/voice discord.js opusscript
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## π Quick Start
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
import { Client, GatewayIntentBits } from "discord.js";
|
|
39
|
+
import { PlayerManager } from "ziplayer";
|
|
40
|
+
import { YouTubePlugin, SoundCloudPlugin, SpotifyPlugin } from "@ziplayer/plugin";
|
|
41
|
+
import { InfinityPlugin } from "@ziplayer/infinity";
|
|
42
|
+
|
|
43
|
+
const client = new Client({
|
|
44
|
+
intents: [
|
|
45
|
+
GatewayIntentBits.Guilds,
|
|
46
|
+
GatewayIntentBits.GuildVoiceStates,
|
|
47
|
+
GatewayIntentBits.GuildMessages,
|
|
48
|
+
GatewayIntentBits.MessageContent,
|
|
49
|
+
],
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const manager = new PlayerManager({
|
|
53
|
+
plugins: [new YouTubePlugin(), new SoundCloudPlugin(), new SpotifyPlugin(), new InfinityPlugin()],
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
client.on("messageCreate", async (msg) => {
|
|
57
|
+
if (!msg.content.startsWith("!play ") || !msg.guildId) return;
|
|
58
|
+
|
|
59
|
+
const voiceChannel = msg.member?.voice?.channel;
|
|
60
|
+
if (!voiceChannel) return msg.reply("Join a voice channel first!");
|
|
61
|
+
|
|
62
|
+
const player = await manager.create(msg.guildId, {
|
|
63
|
+
leaveOnEnd: true,
|
|
64
|
+
userdata: { channel: msg.channel },
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
if (!player.connection) await player.connect(voiceChannel);
|
|
68
|
+
await player.play(msg.content.slice(6), msg.author.id);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
client.login(process.env.DISCORD_TOKEN);
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
---
|
|
75
|
+
|
|
76
|
+
## π§± Architecture Overview
|
|
77
|
+
|
|
78
|
+
```
|
|
79
|
+
PlayerManager (global)
|
|
80
|
+
βββ Player (per guild)
|
|
81
|
+
βββ Queue
|
|
82
|
+
βββ PluginManager
|
|
83
|
+
βββ ExtensionManager
|
|
84
|
+
βββ FilterManager
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### Flow
|
|
88
|
+
|
|
89
|
+
```
|
|
90
|
+
create β connect β play β stream β events β destroy
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
---
|
|
94
|
+
|
|
95
|
+
## π΅ Core Usage
|
|
96
|
+
|
|
97
|
+
### Play music
|
|
98
|
+
|
|
99
|
+
```ts
|
|
100
|
+
await player.play("Never Gonna Give You Up", userId);
|
|
101
|
+
await player.play("https://youtube.com/watch?v=...", userId);
|
|
102
|
+
await player.play("tts: Hello world", userId);
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### Controls
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
player.pause();
|
|
109
|
+
player.resume();
|
|
110
|
+
player.skip();
|
|
111
|
+
player.stop();
|
|
112
|
+
player.setVolume(100);
|
|
113
|
+
player.loop("track");
|
|
114
|
+
player.shuffle();
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
### Queue
|
|
118
|
+
|
|
119
|
+
```ts
|
|
120
|
+
player.queue.add(track);
|
|
121
|
+
player.queue.remove(0);
|
|
122
|
+
player.queue.shuffle();
|
|
123
|
+
player.queue.clear();
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
---
|
|
127
|
+
|
|
128
|
+
## π Plugins
|
|
129
|
+
|
|
130
|
+
Install via `@ziplayer/plugin`:
|
|
131
|
+
|
|
132
|
+
- **YouTubePlugin** β YouTube + search
|
|
133
|
+
- **SoundCloudPlugin** β SoundCloud streaming
|
|
134
|
+
- **SpotifyPlugin** β Metadata (uses fallback)
|
|
135
|
+
- **TTSPlugin** β Text-to-speech
|
|
136
|
+
- **AttachmentsPlugin** β Local/URL audio files
|
|
137
|
+
|
|
138
|
+
### Example
|
|
139
|
+
|
|
140
|
+
```ts
|
|
141
|
+
import { TTSPlugin } from "@ziplayer/plugin";
|
|
142
|
+
|
|
143
|
+
new PlayerManager({
|
|
144
|
+
plugins: [new TTSPlugin({ defaultLang: "en" })],
|
|
145
|
+
});
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
---
|
|
149
|
+
|
|
150
|
+
## π§© Extensions
|
|
151
|
+
|
|
152
|
+
Enhance player behavior:
|
|
153
|
+
|
|
154
|
+
- π€ `voiceExt` β Speech-to-text commands
|
|
155
|
+
- π€ `lyricsExt` β Auto lyrics (synced support)
|
|
156
|
+
- β‘ `lavalinkExt` β External Lavalink node
|
|
157
|
+
|
|
158
|
+
### Example
|
|
159
|
+
|
|
160
|
+
```ts
|
|
161
|
+
import { voiceExt, lyricsExt } from "@ziplayer/extension";
|
|
162
|
+
|
|
163
|
+
const manager = new PlayerManager({
|
|
164
|
+
extensions: [new voiceExt(null, { lang: "en-US" }), new lyricsExt(null, { provider: "lrclib" })],
|
|
165
|
+
});
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
---
|
|
169
|
+
|
|
170
|
+
## ποΈ Audio Filters
|
|
171
|
+
|
|
172
|
+
Apply FFmpeg filters in real-time:
|
|
173
|
+
|
|
174
|
+
```ts
|
|
175
|
+
await player.filter.applyFilter("bassboost");
|
|
176
|
+
await player.filter.applyFilter("nightcore");
|
|
177
|
+
await player.filter.clearAll();
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
### Available filters
|
|
181
|
+
|
|
182
|
+
- bassboost, trebleboost
|
|
183
|
+
- nightcore, lofi, vaporwave
|
|
184
|
+
- echo, reverb, chorus
|
|
185
|
+
- karaoke
|
|
186
|
+
- normalize, compressor, limiter
|
|
187
|
+
|
|
188
|
+
---
|
|
189
|
+
|
|
190
|
+
## π TTS (Interrupt Mode)
|
|
191
|
+
|
|
192
|
+
```ts
|
|
193
|
+
const player = await manager.create(guildId, {
|
|
194
|
+
tts: {
|
|
195
|
+
createPlayer: true,
|
|
196
|
+
interrupt: true,
|
|
197
|
+
},
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
await player.play("tts: Hello everyone", userId);
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
---
|
|
204
|
+
|
|
205
|
+
## π‘ Events
|
|
206
|
+
|
|
207
|
+
Listen globally via manager:
|
|
208
|
+
|
|
209
|
+
```ts
|
|
210
|
+
manager.on("trackStart", (player, track) => {});
|
|
211
|
+
manager.on("queueEnd", (player) => {});
|
|
212
|
+
manager.on("playerError", (player, error) => {});
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
---
|
|
216
|
+
|
|
217
|
+
## π§ Advanced Features
|
|
218
|
+
|
|
219
|
+
### Autoplay
|
|
220
|
+
|
|
221
|
+
```ts
|
|
222
|
+
player.queue.autoPlay(true);
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
### Insert next track
|
|
226
|
+
|
|
227
|
+
```ts
|
|
228
|
+
await player.insert("song", 0);
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
### Save stream
|
|
232
|
+
|
|
233
|
+
```ts
|
|
234
|
+
const stream = await player.save(track);
|
|
235
|
+
stream.pipe(fs.createWriteStream("song.mp3"));
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
---
|
|
239
|
+
|
|
240
|
+
## β οΈ Best Practices
|
|
241
|
+
|
|
242
|
+
- Use **one PlayerManager** per bot
|
|
243
|
+
- Always `await player.connect()` before playing
|
|
244
|
+
- Handle `playerError` events
|
|
245
|
+
- Do not reuse a destroyed player
|
|
246
|
+
|
|
247
|
+
---
|
|
248
|
+
|
|
249
|
+
## π Resources
|
|
250
|
+
|
|
251
|
+
- Examples: [https://github.com/ZiProject/ZiPlayer/tree/main/examples](https://github.com/ZiProject/ZiPlayer/tree/main/examples)
|
|
252
|
+
- GitHub: [https://github.com/ZiProject/ZiPlayer](https://github.com/ZiProject/ZiPlayer)
|
|
253
|
+
- npm: [https://www.npmjs.com/package/ziplayer](https://www.npmjs.com/package/ziplayer)
|
|
254
|
+
|
|
255
|
+
---
|
|
256
|
+
|
|
257
|
+
## π License
|
|
258
|
+
|
|
259
|
+
MIT License
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/plugins/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAE1C,OAAO,KAAK,EAAE,KAAK,EAAE,UAAU,EAAE,MAAM,UAAU,CAAC;AAClD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,6BAA6B,CAAC;AACjE,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,sBAAsB,CAAC;AAEnD,KAAK,oBAAoB,GAAG;IAC3B,gBAAgB,EAAE,MAAM,GAAG,SAAS,CAAC;CACrC,CAAC;AAEF,OAAO,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/plugins/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAE1C,OAAO,KAAK,EAAE,KAAK,EAAE,UAAU,EAAE,MAAM,UAAU,CAAC;AAClD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,6BAA6B,CAAC;AACjE,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,sBAAsB,CAAC;AAEnD,KAAK,oBAAoB,GAAG;IAC3B,gBAAgB,EAAE,MAAM,GAAG,SAAS,CAAC;CACrC,CAAC;AAEF,OAAO,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAwF1C,qBAAa,aAAa;IACzB,OAAO,CAAC,OAAO,CAAuB;IACtC,OAAO,CAAC,MAAM,CAAS;IACvB,OAAO,CAAC,OAAO,CAAgB;IAC/B,OAAO,CAAC,OAAO,CAAsC;gBAEzC,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,aAAa,EAAE,OAAO,EAAE,oBAAoB;IAMjF,KAAK,CAAC,OAAO,CAAC,EAAE,GAAG,EAAE,GAAG,cAAc,EAAE,GAAG,EAAE,GAAG,IAAI;IAMpD,QAAQ,CAAC,MAAM,EAAE,UAAU,GAAG,IAAI;IAIlC,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO;IAIjC,GAAG,CAAC,IAAI,EAAE,MAAM,GAAG,UAAU,GAAG,SAAS;IAIzC,MAAM,IAAI,UAAU,EAAE;IAItB,UAAU,CAAC,KAAK,EAAE,MAAM,GAAG,UAAU,GAAG,SAAS;IAIjD,KAAK,IAAI,IAAI;IAIP,SAAS,CAAC,KAAK,EAAE,KAAK,GAAG,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC;IAqFzD;;;;;;;OAOG;IACG,gBAAgB,CAAC,KAAK,EAAE,KAAK,GAAG,OAAO,CAAC,KAAK,EAAE,CAAC;CAsDtD"}
|
package/dist/plugins/index.js
CHANGED
|
@@ -4,6 +4,73 @@ exports.PluginManager = exports.BasePlugin = void 0;
|
|
|
4
4
|
const timeout_1 = require("../utils/timeout");
|
|
5
5
|
var BasePlugin_1 = require("./BasePlugin");
|
|
6
6
|
Object.defineProperty(exports, "BasePlugin", { enumerable: true, get: function () { return BasePlugin_1.BasePlugin; } });
|
|
7
|
+
function levenshtein(a, b) {
|
|
8
|
+
const matrix = Array.from({ length: a.length + 1 }, () => new Array(b.length + 1).fill(0));
|
|
9
|
+
for (let i = 0; i <= a.length; i++)
|
|
10
|
+
matrix[i][0] = i;
|
|
11
|
+
for (let j = 0; j <= b.length; j++)
|
|
12
|
+
matrix[0][j] = j;
|
|
13
|
+
for (let i = 1; i <= a.length; i++) {
|
|
14
|
+
for (let j = 1; j <= b.length; j++) {
|
|
15
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
16
|
+
matrix[i][j] = Math.min(matrix[i - 1][j] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j - 1] + cost);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return matrix[a.length][b.length];
|
|
20
|
+
}
|
|
21
|
+
function similarity(a, b) {
|
|
22
|
+
if (!a || !b)
|
|
23
|
+
return 0;
|
|
24
|
+
const dist = levenshtein(a, b);
|
|
25
|
+
const maxLen = Math.max(a.length, b.length);
|
|
26
|
+
return 1 - dist / maxLen; // 0 β 1
|
|
27
|
+
}
|
|
28
|
+
function normalize(str) {
|
|
29
|
+
return str
|
|
30
|
+
.toLowerCase()
|
|
31
|
+
.replace(/\(.*?\)|\[.*?\]/g, "") // remove (remix), [lyrics]
|
|
32
|
+
.replace(/[^a-z0-9\s]/g, "")
|
|
33
|
+
.replace(/\s+/g, " ")
|
|
34
|
+
.trim();
|
|
35
|
+
}
|
|
36
|
+
const MUSIC_KEYWORDS = ["official", "mv", "audio", "lyrics", "remix", "cover", "ft", "feat", "prod", "music video"];
|
|
37
|
+
const NON_MUSIC_KEYWORDS = ["reaction", "review", "podcast", "interview", "vlog", "live stream", "news", "tiktok"];
|
|
38
|
+
function detectContentType(title) {
|
|
39
|
+
const t = title.toLowerCase();
|
|
40
|
+
let score = 0;
|
|
41
|
+
for (const k of MUSIC_KEYWORDS) {
|
|
42
|
+
if (t.includes(k))
|
|
43
|
+
score += 2;
|
|
44
|
+
}
|
|
45
|
+
for (const k of NON_MUSIC_KEYWORDS) {
|
|
46
|
+
if (t.includes(k))
|
|
47
|
+
score -= 3;
|
|
48
|
+
}
|
|
49
|
+
return score;
|
|
50
|
+
}
|
|
51
|
+
function tokenOverlap(a, b) {
|
|
52
|
+
const setA = new Set(a.split(" "));
|
|
53
|
+
const setB = new Set(b.split(" "));
|
|
54
|
+
let match = 0;
|
|
55
|
+
for (const word of setA) {
|
|
56
|
+
if (setB.has(word))
|
|
57
|
+
match++;
|
|
58
|
+
}
|
|
59
|
+
return match / Math.max(setA.size, setB.size);
|
|
60
|
+
}
|
|
61
|
+
function scoreTrack(base, candidate) {
|
|
62
|
+
const titleA = normalize(base.title);
|
|
63
|
+
const titleB = normalize(candidate.title);
|
|
64
|
+
let score = 0;
|
|
65
|
+
// ===== FUZZY =====
|
|
66
|
+
const sim = similarity(titleA, titleB); // 0 β 1
|
|
67
|
+
score += sim * 50;
|
|
68
|
+
// ===== TOKEN MATCH =====
|
|
69
|
+
score += tokenOverlap(titleA, titleB) * 30;
|
|
70
|
+
// ===== CONTENT TYPE =====
|
|
71
|
+
score += detectContentType(candidate.title);
|
|
72
|
+
return score;
|
|
73
|
+
}
|
|
7
74
|
// Plugin factory
|
|
8
75
|
class PluginManager {
|
|
9
76
|
constructor(player, manager, options) {
|
|
@@ -131,75 +198,44 @@ class PluginManager {
|
|
|
131
198
|
if (!track)
|
|
132
199
|
return [];
|
|
133
200
|
const timeoutMs = this.options.extractorTimeout ?? 15000;
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
|
|
201
|
+
const limit = 20;
|
|
202
|
+
const allPlugins = this.getAll()
|
|
203
|
+
.filter((p) => typeof p.getRelatedTracks === "function")
|
|
204
|
+
.sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0));
|
|
205
|
+
const history = this.player.queue.previousTracks;
|
|
206
|
+
const results = [];
|
|
207
|
+
// ===== TRY ALL PLUGINS (NOT JUST FIRST SUCCESS) =====
|
|
208
|
+
await Promise.allSettled(allPlugins.map(async (p) => {
|
|
137
209
|
try {
|
|
138
|
-
this.debug(`[RelatedTracks]
|
|
139
|
-
const related = await (0, timeout_1.withTimeout)(
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
}), timeoutMs, `getRelatedTracks timed out for ${preferred.name}`);
|
|
143
|
-
if (Array.isArray(related) && related.length > 0) {
|
|
144
|
-
return related;
|
|
210
|
+
this.debug(`[RelatedTracks] Querying ${p.name}`);
|
|
211
|
+
const related = await (0, timeout_1.withTimeout)(p.getRelatedTracks(track, { limit, history }), timeoutMs, `Timeout ${p.name}`);
|
|
212
|
+
if (Array.isArray(related)) {
|
|
213
|
+
results.push(...related);
|
|
145
214
|
}
|
|
146
|
-
this.debug(`[RelatedTracks] ${preferred.name} returned no results β fallback race`);
|
|
147
215
|
}
|
|
148
216
|
catch (err) {
|
|
149
|
-
this.debug(`[RelatedTracks] ${
|
|
217
|
+
this.debug(`[RelatedTracks] ${p.name} failed`, err);
|
|
150
218
|
}
|
|
219
|
+
}));
|
|
220
|
+
if (results.length === 0) {
|
|
221
|
+
this.debug(`[RelatedTracks] No results`);
|
|
222
|
+
return [];
|
|
151
223
|
}
|
|
152
|
-
// =====
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
.
|
|
156
|
-
|
|
157
|
-
return p;
|
|
158
|
-
})
|
|
159
|
-
.sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0));
|
|
160
|
-
// group by priority
|
|
161
|
-
const groups = new Map();
|
|
162
|
-
for (const p of plugins) {
|
|
163
|
-
const key = p.priority ?? 0;
|
|
164
|
-
if (!groups.has(key))
|
|
165
|
-
groups.set(key, []);
|
|
166
|
-
groups.get(key).push(p);
|
|
167
|
-
}
|
|
168
|
-
for (const [priority, group] of groups) {
|
|
169
|
-
this.debug(`[RelatedTracks] Racing priority=${priority} (${group.map((p) => p.name).join(", ")})`);
|
|
170
|
-
const controller = new AbortController();
|
|
171
|
-
try {
|
|
172
|
-
const promises = group.map((p) => (async () => {
|
|
173
|
-
try {
|
|
174
|
-
const related = await (0, timeout_1.withTimeout)(p.getRelatedTracks(track, {
|
|
175
|
-
limit: 10,
|
|
176
|
-
history: this.player.queue.previousTracks,
|
|
177
|
-
}), timeoutMs, `getRelatedTracks timed out for ${p.name}`);
|
|
178
|
-
if (Array.isArray(related) && related.length > 0) {
|
|
179
|
-
this.debug(`[RelatedTracks] Success via ${p.name}`);
|
|
180
|
-
controller.abort();
|
|
181
|
-
return related;
|
|
182
|
-
}
|
|
183
|
-
throw new Error(`${p.name} returned no results`);
|
|
184
|
-
}
|
|
185
|
-
catch (err) {
|
|
186
|
-
if (controller.signal.aborted)
|
|
187
|
-
throw new Error("Aborted");
|
|
188
|
-
this.debug(`[RelatedTracks] ${p.name} failed`, err);
|
|
189
|
-
throw err;
|
|
190
|
-
}
|
|
191
|
-
})());
|
|
192
|
-
const result = await Promise.any(promises);
|
|
193
|
-
if (result)
|
|
194
|
-
return result;
|
|
195
|
-
}
|
|
196
|
-
catch {
|
|
197
|
-
this.debug(`[RelatedTracks] Priority group ${priority} all failed`);
|
|
198
|
-
controller.abort();
|
|
224
|
+
// ===== DEDUPE =====
|
|
225
|
+
const unique = new Map();
|
|
226
|
+
for (const t of results) {
|
|
227
|
+
if (!unique.has(t.url)) {
|
|
228
|
+
unique.set(t.url, t);
|
|
199
229
|
}
|
|
200
230
|
}
|
|
201
|
-
|
|
202
|
-
|
|
231
|
+
// ===== SCORE + SORT =====
|
|
232
|
+
const ranked = Array.from(unique.values())
|
|
233
|
+
.map((t) => ({ track: t, score: scoreTrack(track, t) }))
|
|
234
|
+
.sort((a, b) => b.score - a.score)
|
|
235
|
+
.slice(0, limit)
|
|
236
|
+
.map((x) => x.track);
|
|
237
|
+
this.debug(`[RelatedTracks] Final ${ranked.length} tracks`);
|
|
238
|
+
return ranked;
|
|
203
239
|
}
|
|
204
240
|
}
|
|
205
241
|
exports.PluginManager = PluginManager;
|