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/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
- # ziplayer
4
-
5
- A modular Discord voice player with plugin system for @discordjs/voice.
6
-
7
- ## Features
8
-
9
- - 🎡 **Plugin-based architecture** - Easy to extend with new sources
10
- - 🎢 **Multiple source support** - YouTube, SoundCloud, Spotify (with fallback)
11
- - πŸ”Š **Queue management** - Add, remove, shuffle, clear
12
- - 🎚️ **Volume control** - 0-200% volume range
13
- - ⏯️ **Playback control** - Play, pause, resume, stop, skip
14
- - πŸ” **Auto play** - Automatically replay the queue when it ends
15
- - πŸ”‚ **Loop control** - Repeat a single track or the entire queue
16
- - πŸ“Š **Progress bar** - Display playback progress with customizable icons
17
- - πŸ”” **Event-driven** - Rich event system for all player actions
18
- - 🎭 **Multi-guild support** - Manage players across multiple Discord servers
19
- - πŸ—ƒοΈ **User data** - Attach custom data to each player for later use
20
- - πŸ”Œ **Lavalink** - Support manage an external Lavalink JVM node
21
- - πŸŽ›οΈ **Audio Filters** - Apply real-time audio effects using FFmpeg (bassboost, nightcore, etc.)
22
-
23
- ## Installation
24
-
25
- ```bash
26
- npm install ziplayer @ziplayer/plugin @ziplayer/extension @discordjs/voice discord.js
27
- ```
28
-
29
- ## Quick Start
30
-
31
- ```typescript
32
- import { PlayerManager } from "ziplayer";
33
- import { SoundCloudPlugin, YouTubePlugin, SpotifyPlugin } from "@ziplayer/plugin";
34
- import { voiceExt } from "@ziplayer/extension";
35
-
36
- const manager = new PlayerManager({
37
- plugins: [new SoundCloudPlugin(), new YouTubePlugin(), new SpotifyPlugin()],
38
- extensions: [new voiceExt()],
39
- });
40
-
41
- // Create player
42
- const player = await manager.create(guildId, {
43
- leaveOnEnd: true,
44
- leaveTimeout: 30000,
45
- userdata: { channel: textChannel }, // store channel for events
46
- // Choose extensions for this player (by name or instances)
47
- // extensions: ["voiceExt"],
48
- // Apply audio filters
49
- // filters: ["bassboost", "normalize"],
50
- });
51
-
52
- // Connect and play
53
- await player.connect(voiceChannel);
54
- await player.play("Never Gonna Give You Up", userId);
55
-
56
- // Play a full YouTube playlist
57
- await player.play("https://www.youtube.com/playlist?list=PL123", userId);
58
-
59
- // Enable autoplay
60
- player.queue.autoPlay(true);
61
-
62
- // Play a full SoundCloud playlist
63
- await player.play("https://soundcloud.com/artist/sets/playlist", userId);
64
-
65
- // Events
66
- player.on("willPlay", (player, track) => {
67
- console.log(`Up next: ${track.title}`);
68
- });
69
- player.on("trackStart", (player, track) => {
70
- console.log(`Now playing: ${track.title}`);
71
- player.userdata?.channel?.send(`Now playing: ${track.title}`);
72
- });
73
-
74
- // Audio Filters
75
- player.filter.applyFilter("bassboost"); // Apply bass boost
76
- player.filter.applyFilter("nightcore"); // Apply nightcore effect
77
- player.filter.removeFilter("bassboost"); // Remove specific filter
78
- player.filter.clearFilters(); // Clear all filters
79
-
80
- // Apply custom filter
81
- player.filter.applyFilter({
82
- name: "custom",
83
- ffmpegFilter: "volume=1.5,treble=g=5",
84
- description: "Volume boost + treble boost",
85
- });
86
-
87
- // Receive transcripts
88
- manager.on("voiceCreate", (player, evt) => {
89
- console.log(`User ${evt.userId} said: ${evt.content}`);
90
- });
91
- ```
92
-
93
- ### TTS (Interrupt Mode)
94
-
95
- Play short text-to-speech messages without losing music progress. The player pauses music, plays TTS on a dedicated AudioPlayer,
96
- then resumes.
97
-
98
- - Requirements: `@ziplayer/plugin` with `TTSPlugin` installed and registered in `PlayerManager`.
99
-
100
- ```ts
101
- import { PlayerManager } from "ziplayer";
102
- import { TTSPlugin, YouTubePlugin, SoundCloudPlugin, SpotifyPlugin } from "@ziplayer/plugin";
103
-
104
- const manager = new PlayerManager({
105
- plugins: [new TTSPlugin({ defaultLang: "vi" }), new YouTubePlugin(), new SoundCloudPlugin(), new SpotifyPlugin()],
106
- });
107
-
108
- // Create a player with TTS interrupt enabled
109
- const player = await manager.create(guildId, {
110
- tts: {
111
- createPlayer: true, // pre-create the internal TTS AudioPlayer
112
- interrupt: true, // pause music, swap to TTS, then resume
113
- volume: 1, // 1 => 100%
114
- },
115
- });
116
-
117
- await player.connect(voiceChannel);
118
-
119
- // Trigger TTS by playing a TTS query (depends on your TTS plugin)
120
- await player.play("tts: xin chào mọi người", userId);
121
-
122
- // Listen to TTS lifecycle events
123
- manager.on("ttsStart", (plr, { track }) => console.log("TTS start", track?.title));
124
- manager.on("ttsEnd", (plr) => console.log("TTS end"));
125
- ```
126
-
127
- Notes
128
-
129
- - The detection uses track.source that includes "tts" or query starting with `tts:`.
130
- - If you need more control, call `player.interruptWithTTSTrack(track)` after building a TTS track via your plugin.
131
-
132
- ### extensions and Lavalink Process
133
-
134
- Use `lavalinkExt` when you need ZiPlayer to manage an external Lavalink JVM node. The extension starts, stops, and optionally
135
- restarts the Lavalink jar and forwards lifecycle events through the manager/player.
136
-
137
- ```ts
138
- import { PlayerManager } from "ziplayer";
139
- import { lavalinkExt, lyricsExt, voiceExt } from "@ziplayer/extension";
140
-
141
- const manager = new PlayerManager({
142
- extensions: [
143
- new lavalinkExt(null, {
144
- nodes: [
145
- {
146
- identifier: "locallavalink",
147
- password: "youshallnotpass",
148
- host: "localhost",
149
- port: 2333,
150
- secure: false,
151
- },
152
- ],
153
- client: client,
154
- searchPrefix: "scsearch",
155
- }),
156
- new voiceExt(null, { lang: "en-US" }),
157
- new lyricsExt(null, { provider: "lrclib" }),
158
- ],
159
- //etc...
160
- });
161
-
162
- //crete player:
163
- const player = await manager.create("id-player", {
164
- extensions: ["lavalinkExt", "voiceExt", "lyricsExt"],
165
- //etc... userdata,
166
- });
167
-
168
- //connec voice
169
- if (!player.connection) await player.connect(interaction?.member?.voice?.channel);
170
-
171
- //play music
172
- await player.play(query, interaction?.user);
173
- ```
174
-
175
- ## Events
176
-
177
- All player events are forwarded through the PlayerManager:
178
-
179
- - `trackStart` - When a track starts playing
180
- - `willPlay` - Before a track begins playing
181
- - `trackEnd` - When a track finishes
182
- - `queueEnd` - When the queue is empty
183
- - `playerError` - When an error occurs
184
- - `queueAdd` - When a track is added
185
- - `volumeChange` - When volume changes
186
- - And more...
187
-
188
- ## Useful Links
189
-
190
- [Example](https://github.com/ZiProject/ZiPlayer/tree/main/examples) | [Repo](https://github.com/ZiProject/ZiPlayer) |
191
- [Package](https://www.npmjs.com/package/ziplayer) | [Plugin](https://www.npmjs.com/package/@ziplayer/plugin) |
192
- [Extension](https://www.npmjs.com/package/@ziplayer/extension)
193
-
194
- ## License
195
-
196
- MIT License
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;AAG1C,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;CAuFtD"}
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"}
@@ -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 preferred = this.findPlugin(track.url) || this.get(track.source);
135
- // ===== THỬ PREFERRED TRƯỚC =====
136
- if (preferred && typeof preferred.getRelatedTracks === "function") {
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] Trying preferred: ${preferred.name}`);
139
- const related = await (0, timeout_1.withTimeout)(preferred.getRelatedTracks(track, {
140
- limit: 10,
141
- history: this.player.queue.previousTracks,
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] ${preferred.name} failed β†’ fallback race`, err);
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
- // ===== FALLBACK: RACE THEO PRIORITY GROUP =====
153
- const plugins = this.getAll()
154
- .filter((p) => p !== preferred && typeof p.getRelatedTracks === "function")
155
- .map((p) => {
156
- p.priority ??= 0;
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
- this.debug(`[RelatedTracks] All plugins failed for: ${track.title}`);
202
- return [];
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;