ziplayer 0.0.9 → 0.1.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/README.md +61 -30
- package/dist/extensions/BaseExtension.d.ts +9 -3
- package/dist/extensions/BaseExtension.d.ts.map +1 -1
- package/dist/extensions/BaseExtension.js.map +1 -1
- package/dist/structures/Player.d.ts +325 -2
- package/dist/structures/Player.d.ts.map +1 -1
- package/dist/structures/Player.js +729 -101
- package/dist/structures/Player.js.map +1 -1
- package/dist/structures/PlayerManager.d.ts +166 -2
- package/dist/structures/PlayerManager.d.ts.map +1 -1
- package/dist/structures/PlayerManager.js +182 -8
- package/dist/structures/PlayerManager.js.map +1 -1
- package/dist/structures/Queue.d.ts +193 -2
- package/dist/structures/Queue.d.ts.map +1 -1
- package/dist/structures/Queue.js +193 -2
- package/dist/structures/Queue.js.map +1 -1
- package/dist/types/index.d.ts +327 -1
- package/dist/types/index.d.ts.map +1 -1
- package/dist/utils/timeout.d.ts +9 -0
- package/dist/utils/timeout.d.ts.map +1 -0
- package/dist/utils/timeout.js +14 -0
- package/dist/utils/timeout.js.map +1 -0
- package/package.json +1 -1
- package/src/extensions/BaseExtension.ts +35 -10
- package/src/structures/Player.ts +795 -102
- package/src/structures/PlayerManager.ts +189 -8
- package/src/structures/Queue.ts +196 -2
- package/src/types/index.ts +343 -4
- package/src/utils/timeout.ts +10 -0
package/src/structures/Player.ts
CHANGED
|
@@ -15,15 +15,70 @@ import {
|
|
|
15
15
|
|
|
16
16
|
import { VoiceChannel } from "discord.js";
|
|
17
17
|
import { Readable } from "stream";
|
|
18
|
-
import {
|
|
18
|
+
import { BaseExtension } from "../extensions";
|
|
19
|
+
import {
|
|
20
|
+
Track,
|
|
21
|
+
PlayerOptions,
|
|
22
|
+
PlayerEvents,
|
|
23
|
+
SourcePlugin,
|
|
24
|
+
SearchResult,
|
|
25
|
+
ProgressBarOptions,
|
|
26
|
+
LoopMode,
|
|
27
|
+
StreamInfo,
|
|
28
|
+
} from "../types";
|
|
29
|
+
import type {
|
|
30
|
+
ExtensionContext,
|
|
31
|
+
ExtensionPlayRequest,
|
|
32
|
+
ExtensionPlayResponse,
|
|
33
|
+
ExtensionAfterPlayPayload,
|
|
34
|
+
ExtensionStreamRequest,
|
|
35
|
+
ExtensionSearchRequest,
|
|
36
|
+
} from "../types";
|
|
19
37
|
import { Queue } from "./Queue";
|
|
20
38
|
import { PluginManager } from "../plugins";
|
|
39
|
+
import { withTimeout } from "../utils/timeout";
|
|
21
40
|
import type { PlayerManager } from "./PlayerManager";
|
|
22
41
|
export declare interface Player {
|
|
23
42
|
on<K extends keyof PlayerEvents>(event: K, listener: (...args: PlayerEvents[K]) => void): this;
|
|
24
43
|
emit<K extends keyof PlayerEvents>(event: K, ...args: PlayerEvents[K]): boolean;
|
|
25
44
|
}
|
|
26
45
|
|
|
46
|
+
/**
|
|
47
|
+
* Represents a music player for a specific Discord guild.
|
|
48
|
+
*
|
|
49
|
+
* @example
|
|
50
|
+
* // Create and configure player
|
|
51
|
+
* const player = await manager.create(guildId, {
|
|
52
|
+
* tts: { interrupt: true, volume: 1 },
|
|
53
|
+
* leaveOnEnd: true,
|
|
54
|
+
* leaveTimeout: 30000
|
|
55
|
+
* });
|
|
56
|
+
*
|
|
57
|
+
* // Connect to voice channel
|
|
58
|
+
* await player.connect(voiceChannel);
|
|
59
|
+
*
|
|
60
|
+
* // Play different types of content
|
|
61
|
+
* await player.play("Never Gonna Give You Up", userId); // Search query
|
|
62
|
+
* await player.play("https://youtube.com/watch?v=dQw4w9WgXcQ", userId); // Direct URL
|
|
63
|
+
* await player.play("tts: Hello everyone!", userId); // Text-to-Speech
|
|
64
|
+
*
|
|
65
|
+
* // Player controls
|
|
66
|
+
* player.pause(); // Pause current track
|
|
67
|
+
* player.resume(); // Resume paused track
|
|
68
|
+
* player.skip(); // Skip to next track
|
|
69
|
+
* player.stop(); // Stop and clear queue
|
|
70
|
+
* player.setVolume(0.5); // Set volume to 50%
|
|
71
|
+
*
|
|
72
|
+
* // Event handling
|
|
73
|
+
* player.on("trackStart", (player, track) => {
|
|
74
|
+
* console.log(`Now playing: ${track.title}`);
|
|
75
|
+
* });
|
|
76
|
+
*
|
|
77
|
+
* player.on("queueEnd", (player) => {
|
|
78
|
+
* console.log("Queue finished");
|
|
79
|
+
* });
|
|
80
|
+
*
|
|
81
|
+
*/
|
|
27
82
|
export class Player extends EventEmitter {
|
|
28
83
|
public readonly guildId: string;
|
|
29
84
|
public connection: VoiceConnection | null = null;
|
|
@@ -40,83 +95,264 @@ export class Player extends EventEmitter {
|
|
|
40
95
|
private currentResource: AudioResource | null = null;
|
|
41
96
|
private volumeInterval: NodeJS.Timeout | null = null;
|
|
42
97
|
private skipLoop = false;
|
|
98
|
+
private extensions: BaseExtension[] = [];
|
|
99
|
+
private extensionContext!: ExtensionContext;
|
|
100
|
+
|
|
101
|
+
// Cache for plugin matching to improve performance
|
|
102
|
+
private pluginCache = new Map<string, SourcePlugin>();
|
|
103
|
+
private readonly PLUGIN_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
|
104
|
+
private pluginCacheTimestamps = new Map<string, number>();
|
|
43
105
|
|
|
44
106
|
/**
|
|
45
|
-
*
|
|
107
|
+
* Attach an extension to the player
|
|
108
|
+
*
|
|
109
|
+
* @param {BaseExtension} extension - The extension to attach
|
|
110
|
+
* @example
|
|
111
|
+
* player.attachExtension(new MyExtension());
|
|
46
112
|
*/
|
|
47
|
-
|
|
113
|
+
public attachExtension(extension: BaseExtension): void {
|
|
114
|
+
if (this.extensions.includes(extension)) return;
|
|
115
|
+
if (!extension.player) extension.player = this;
|
|
116
|
+
this.extensions.push(extension);
|
|
117
|
+
this.invokeExtensionLifecycle(extension, "onRegister");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Detach an extension from the player
|
|
122
|
+
*
|
|
123
|
+
* @param {BaseExtension} extension - The extension to detach
|
|
124
|
+
* @example
|
|
125
|
+
* player.detachExtension(new MyExtension());
|
|
126
|
+
*/
|
|
127
|
+
public detachExtension(extension: BaseExtension): void {
|
|
128
|
+
const index = this.extensions.indexOf(extension);
|
|
129
|
+
if (index === -1) return;
|
|
130
|
+
this.extensions.splice(index, 1);
|
|
131
|
+
this.invokeExtensionLifecycle(extension, "onDestroy");
|
|
132
|
+
if (extension.player === this) {
|
|
133
|
+
extension.player = null;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Get all extensions attached to the player
|
|
139
|
+
*
|
|
140
|
+
* @returns {readonly BaseExtension[]} All attached extensions
|
|
141
|
+
* @example
|
|
142
|
+
* const extensions = player.getExtensions();
|
|
143
|
+
* console.log(`Extensions: ${extensions.length}`);
|
|
144
|
+
*/
|
|
145
|
+
public getExtensions(): readonly BaseExtension[] {
|
|
146
|
+
return this.extensions;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private invokeExtensionLifecycle(extension: BaseExtension, hook: "onRegister" | "onDestroy"): void {
|
|
150
|
+
const fn = (extension as any)[hook];
|
|
151
|
+
if (typeof fn !== "function") return;
|
|
48
152
|
try {
|
|
49
|
-
|
|
50
|
-
|
|
153
|
+
const result = fn.call(extension, this.extensionContext);
|
|
154
|
+
if (result && typeof (result as Promise<unknown>).then === "function") {
|
|
155
|
+
(result as Promise<unknown>).catch((err) => this.debug(`[Player] Extension ${extension.name} ${hook} error:`, err));
|
|
156
|
+
}
|
|
157
|
+
} catch (err) {
|
|
158
|
+
this.debug(`[Player] Extension ${extension.name} ${hook} error:`, err);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
private async runBeforePlayHooks(
|
|
163
|
+
initial: ExtensionPlayRequest,
|
|
164
|
+
): Promise<{ request: ExtensionPlayRequest; response: ExtensionPlayResponse }> {
|
|
165
|
+
const request: ExtensionPlayRequest = { ...initial };
|
|
166
|
+
const response: ExtensionPlayResponse = {};
|
|
167
|
+
for (const extension of this.extensions) {
|
|
168
|
+
const hook = (extension as any).beforePlay;
|
|
169
|
+
if (typeof hook !== "function") continue;
|
|
170
|
+
try {
|
|
171
|
+
const result = await Promise.resolve(hook.call(extension, this.extensionContext, request));
|
|
172
|
+
if (!result) continue;
|
|
173
|
+
if (result.query !== undefined) {
|
|
174
|
+
request.query = result.query;
|
|
175
|
+
response.query = result.query;
|
|
176
|
+
}
|
|
177
|
+
if (result.requestedBy !== undefined) {
|
|
178
|
+
request.requestedBy = result.requestedBy;
|
|
179
|
+
response.requestedBy = result.requestedBy;
|
|
180
|
+
}
|
|
181
|
+
if (Array.isArray(result.tracks)) {
|
|
182
|
+
response.tracks = result.tracks;
|
|
183
|
+
}
|
|
184
|
+
if (typeof result.isPlaylist === "boolean") {
|
|
185
|
+
response.isPlaylist = result.isPlaylist;
|
|
186
|
+
}
|
|
187
|
+
if (typeof result.success === "boolean") {
|
|
188
|
+
response.success = result.success;
|
|
189
|
+
}
|
|
190
|
+
if (result.error instanceof Error) {
|
|
191
|
+
response.error = result.error;
|
|
192
|
+
}
|
|
193
|
+
if (typeof result.handled === "boolean") {
|
|
194
|
+
response.handled = result.handled;
|
|
195
|
+
if (result.handled) break;
|
|
196
|
+
}
|
|
197
|
+
} catch (err) {
|
|
198
|
+
this.debug(`[Player] Extension ${extension.name} beforePlay error:`, err);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return { request, response };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
private async runAfterPlayHooks(payload: ExtensionAfterPlayPayload): Promise<void> {
|
|
205
|
+
if (this.extensions.length === 0) return;
|
|
206
|
+
const safeTracks = payload.tracks ? [...payload.tracks] : undefined;
|
|
207
|
+
if (safeTracks) {
|
|
208
|
+
Object.freeze(safeTracks);
|
|
209
|
+
}
|
|
210
|
+
const immutablePayload = Object.freeze({ ...payload, tracks: safeTracks });
|
|
211
|
+
for (const extension of this.extensions) {
|
|
212
|
+
const hook = (extension as any).afterPlay;
|
|
213
|
+
if (typeof hook !== "function") continue;
|
|
214
|
+
try {
|
|
215
|
+
await Promise.resolve(hook.call(extension, this.extensionContext, immutablePayload));
|
|
216
|
+
} catch (err) {
|
|
217
|
+
this.debug(`[Player] Extension ${extension.name} afterPlay error:`, err);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
51
221
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
222
|
+
private async extensionsProvideSearch(query: string, requestedBy: string): Promise<SearchResult | null> {
|
|
223
|
+
const request: ExtensionSearchRequest = { query, requestedBy };
|
|
224
|
+
for (const extension of this.extensions) {
|
|
225
|
+
const hook = (extension as any).provideSearch;
|
|
226
|
+
if (typeof hook !== "function") continue;
|
|
227
|
+
try {
|
|
228
|
+
const result = await Promise.resolve(hook.call(extension, this.extensionContext, request));
|
|
229
|
+
if (result && Array.isArray(result.tracks) && result.tracks.length > 0) {
|
|
230
|
+
this.debug(`[Player] Extension ${extension.name} handled search for query: ${query}`);
|
|
231
|
+
return result as SearchResult;
|
|
232
|
+
}
|
|
233
|
+
} catch (err) {
|
|
234
|
+
this.debug(`[Player] Extension ${extension.name} provideSearch error:`, err);
|
|
55
235
|
}
|
|
236
|
+
}
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
56
239
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
240
|
+
private async extensionsProvideStream(track: Track): Promise<StreamInfo | null> {
|
|
241
|
+
const request: ExtensionStreamRequest = { track };
|
|
242
|
+
for (const extension of this.extensions) {
|
|
243
|
+
const hook = (extension as any).provideStream;
|
|
244
|
+
if (typeof hook !== "function") continue;
|
|
61
245
|
try {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
246
|
+
const result = await Promise.resolve(hook.call(extension, this.extensionContext, request));
|
|
247
|
+
if (result && (result as StreamInfo).stream) {
|
|
248
|
+
this.debug(`[Player] Extension ${extension.name} provided stream for track: ${track.title}`);
|
|
249
|
+
return result as StreamInfo;
|
|
250
|
+
}
|
|
251
|
+
} catch (err) {
|
|
252
|
+
this.debug(`[Player] Extension ${extension.name} provideStream error:`, err);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Start playing a specific track immediately, replacing the current resource.
|
|
260
|
+
*/
|
|
261
|
+
private async startTrack(track: Track): Promise<boolean> {
|
|
262
|
+
try {
|
|
263
|
+
let streamInfo: StreamInfo | null = await this.extensionsProvideStream(track);
|
|
264
|
+
let plugin: SourcePlugin | undefined;
|
|
265
|
+
|
|
266
|
+
if (!streamInfo) {
|
|
267
|
+
plugin = this.pluginManager.findPlugin(track.url) || this.pluginManager.get(track.source);
|
|
268
|
+
|
|
269
|
+
if (!plugin) {
|
|
270
|
+
this.debug(`[Player] No plugin found for track: ${track.title}`);
|
|
271
|
+
throw new Error(`No plugin found for track: ${track.title}`);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
this.debug(`[Player] Getting stream for track: ${track.title}`);
|
|
275
|
+
this.debug(`[Player] Using plugin: ${plugin.name}`);
|
|
276
|
+
this.debug(`[Track] Track Info:`, track);
|
|
277
|
+
try {
|
|
278
|
+
streamInfo = await withTimeout(plugin.getStream(track), this.options.extractorTimeout ?? 15000, "getStream timed out");
|
|
279
|
+
} catch (streamError) {
|
|
280
|
+
this.debug(`[Player] getStream failed, trying getFallback:`, streamError);
|
|
281
|
+
const allplugs = this.pluginManager.getAll();
|
|
282
|
+
for (const p of allplugs) {
|
|
283
|
+
if (typeof (p as any).getFallback !== "function") {
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
try {
|
|
287
|
+
streamInfo = await withTimeout(
|
|
288
|
+
(p as any).getFallback(track),
|
|
289
|
+
this.options.extractorTimeout ?? 15000,
|
|
290
|
+
`getFallback timed out for plugin ${p.name}`,
|
|
291
|
+
);
|
|
292
|
+
if (!(streamInfo as any)?.stream) continue;
|
|
293
|
+
this.debug(`[Player] getFallback succeeded with plugin ${p.name} for track: ${track.title}`);
|
|
294
|
+
break;
|
|
295
|
+
} catch (fallbackError) {
|
|
296
|
+
this.debug(`[Player] getFallback failed with plugin ${p.name}:`, fallbackError);
|
|
297
|
+
}
|
|
69
298
|
}
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
if (!(streamInfo as any).stream) continue;
|
|
73
|
-
this.debug(`[Player] getFallback succeeded with plugin ${p.name} for track: ${track.title}`);
|
|
74
|
-
break;
|
|
75
|
-
} catch (fallbackError) {
|
|
76
|
-
this.debug(`[Player] getFallback failed with plugin ${p.name}:`, fallbackError);
|
|
299
|
+
if (!(streamInfo as any)?.stream) {
|
|
300
|
+
throw new Error(`All getFallback attempts failed for track: ${track.title}`);
|
|
77
301
|
}
|
|
78
302
|
}
|
|
303
|
+
} else {
|
|
304
|
+
this.debug(`[Player] Using extension-provided stream for track: ${track.title}`);
|
|
305
|
+
}
|
|
79
306
|
|
|
80
|
-
|
|
81
|
-
throw new Error(`All getFallback attempts failed for track: ${track.title}`);
|
|
82
|
-
}
|
|
307
|
+
if (plugin) {
|
|
83
308
|
this.debug(streamInfo);
|
|
84
309
|
}
|
|
85
310
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
311
|
+
// Kiểm tra nếu có stream thực sự để tạo AudioResource
|
|
312
|
+
if (streamInfo && (streamInfo as any).stream) {
|
|
313
|
+
function mapToStreamType(type: string | undefined): StreamType {
|
|
314
|
+
switch (type) {
|
|
315
|
+
case "webm/opus":
|
|
316
|
+
return StreamType.WebmOpus;
|
|
317
|
+
case "ogg/opus":
|
|
318
|
+
return StreamType.OggOpus;
|
|
319
|
+
case "arbitrary":
|
|
320
|
+
default:
|
|
321
|
+
return StreamType.Arbitrary;
|
|
322
|
+
}
|
|
96
323
|
}
|
|
97
|
-
}
|
|
98
324
|
|
|
99
|
-
|
|
100
|
-
|
|
325
|
+
const stream: Readable = (streamInfo as StreamInfo).stream;
|
|
326
|
+
const inputType = mapToStreamType((streamInfo as StreamInfo).type);
|
|
101
327
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
328
|
+
this.currentResource = createAudioResource(stream, {
|
|
329
|
+
metadata: track,
|
|
330
|
+
inputType,
|
|
331
|
+
inlineVolume: true,
|
|
332
|
+
});
|
|
107
333
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
334
|
+
// Apply initial volume using the resource's VolumeTransformer
|
|
335
|
+
if (this.volumeInterval) {
|
|
336
|
+
clearInterval(this.volumeInterval);
|
|
337
|
+
this.volumeInterval = null;
|
|
338
|
+
}
|
|
339
|
+
this.currentResource.volume?.setVolume(this.volume / 100);
|
|
114
340
|
|
|
115
|
-
|
|
116
|
-
|
|
341
|
+
this.debug(`[Player] Playing resource for track: ${track.title}`);
|
|
342
|
+
this.audioPlayer.play(this.currentResource);
|
|
117
343
|
|
|
118
|
-
|
|
119
|
-
|
|
344
|
+
await entersState(this.audioPlayer, AudioPlayerStatus.Playing, 5_000);
|
|
345
|
+
return true;
|
|
346
|
+
} else if (streamInfo && !(streamInfo as any).stream) {
|
|
347
|
+
// Extension đang xử lý phát nhạc (như Lavalink) - chỉ đánh dấu đang phát
|
|
348
|
+
this.debug(`[Player] Extension is handling playback for track: ${track.title}`);
|
|
349
|
+
this.isPlaying = true;
|
|
350
|
+
this.isPaused = false;
|
|
351
|
+
this.emit("trackStart", track);
|
|
352
|
+
return true;
|
|
353
|
+
} else {
|
|
354
|
+
throw new Error(`No stream available for track: ${track.title}`);
|
|
355
|
+
}
|
|
120
356
|
} catch (error) {
|
|
121
357
|
this.debug(`[Player] startTrack error:`, error);
|
|
122
358
|
this.emit("playerError", error as Error, track);
|
|
@@ -136,11 +372,6 @@ export class Player extends EventEmitter {
|
|
|
136
372
|
}
|
|
137
373
|
}
|
|
138
374
|
|
|
139
|
-
private withTimeout<T>(promise: Promise<T>, message: string): Promise<T> {
|
|
140
|
-
const timeout = this.options.extractorTimeout ?? 15000;
|
|
141
|
-
return Promise.race([promise, new Promise<never>((_, reject) => setTimeout(() => reject(new Error(message)), timeout))]);
|
|
142
|
-
}
|
|
143
|
-
|
|
144
375
|
private debug(message?: any, ...optionalParams: any[]): void {
|
|
145
376
|
if (this.listenerCount("debug") > 0) {
|
|
146
377
|
this.emit("debug", message, ...optionalParams);
|
|
@@ -184,6 +415,7 @@ export class Player extends EventEmitter {
|
|
|
184
415
|
this.volume = this.options.volume || 100;
|
|
185
416
|
this.userdata = this.options.userdata;
|
|
186
417
|
this.setupEventListeners();
|
|
418
|
+
this.extensionContext = Object.freeze({ player: this, manager });
|
|
187
419
|
|
|
188
420
|
// Optionally pre-create the TTS AudioPlayer
|
|
189
421
|
if (this.options?.tts?.createPlayer) {
|
|
@@ -272,6 +504,14 @@ export class Player extends EventEmitter {
|
|
|
272
504
|
return this.pluginManager.unregister(name);
|
|
273
505
|
}
|
|
274
506
|
|
|
507
|
+
/**
|
|
508
|
+
* Connect to a voice channel
|
|
509
|
+
*
|
|
510
|
+
* @param {VoiceChannel} channel - Discord voice channel
|
|
511
|
+
* @returns {Promise<VoiceConnection>} The voice connection
|
|
512
|
+
* @example
|
|
513
|
+
* await player.connect(voiceChannel);
|
|
514
|
+
*/
|
|
275
515
|
async connect(channel: VoiceChannel): Promise<VoiceConnection> {
|
|
276
516
|
try {
|
|
277
517
|
this.debug(`[Player] Connecting to voice channel: ${channel.id}`);
|
|
@@ -307,15 +547,34 @@ export class Player extends EventEmitter {
|
|
|
307
547
|
}
|
|
308
548
|
}
|
|
309
549
|
|
|
550
|
+
/**
|
|
551
|
+
* Search for tracks using the player's extensions and plugins
|
|
552
|
+
*
|
|
553
|
+
* @param {string} query - The query to search for
|
|
554
|
+
* @param {string} requestedBy - The user ID who requested the search
|
|
555
|
+
* @returns {Promise<SearchResult>} The search result
|
|
556
|
+
* @example
|
|
557
|
+
* const result = await player.search("Never Gonna Give You Up", userId);
|
|
558
|
+
* console.log(`Search result: ${result.tracks.length} tracks`);
|
|
559
|
+
*/
|
|
310
560
|
async search(query: string, requestedBy: string): Promise<SearchResult> {
|
|
311
561
|
this.debug(`[Player] Search called with query: ${query}, requestedBy: ${requestedBy}`);
|
|
562
|
+
const extensionResult = await this.extensionsProvideSearch(query, requestedBy);
|
|
563
|
+
if (extensionResult && Array.isArray(extensionResult.tracks) && extensionResult.tracks.length > 0) {
|
|
564
|
+
this.debug(`[Player] Extension handled search for query: ${query}`);
|
|
565
|
+
return extensionResult;
|
|
566
|
+
}
|
|
312
567
|
const plugins = this.pluginManager.getAll();
|
|
313
568
|
let lastError: any = null;
|
|
314
569
|
|
|
315
570
|
for (const p of plugins) {
|
|
316
571
|
try {
|
|
317
572
|
this.debug(`[Player] Trying plugin for search: ${p.name}`);
|
|
318
|
-
const res = await
|
|
573
|
+
const res = await withTimeout(
|
|
574
|
+
p.search(query, requestedBy),
|
|
575
|
+
this.options.extractorTimeout ?? 15000,
|
|
576
|
+
`Search operation timed out for ${p.name}`,
|
|
577
|
+
);
|
|
319
578
|
if (res && Array.isArray(res.tracks) && res.tracks.length > 0) {
|
|
320
579
|
this.debug(`[Player] Plugin '${p.name}' returned ${res.tracks.length} tracks`);
|
|
321
580
|
return res;
|
|
@@ -333,23 +592,63 @@ export class Player extends EventEmitter {
|
|
|
333
592
|
throw new Error(`No plugin found to handle: ${query}`);
|
|
334
593
|
}
|
|
335
594
|
|
|
595
|
+
/**
|
|
596
|
+
* Play a track or search query
|
|
597
|
+
*
|
|
598
|
+
* @param {string | Track} query - Track URL, search query, or Track object
|
|
599
|
+
* @param {string} requestedBy - User ID who requested the track
|
|
600
|
+
* @returns {Promise<boolean>} True if playback started successfully
|
|
601
|
+
* @example
|
|
602
|
+
* await player.play("Never Gonna Give You Up", userId);
|
|
603
|
+
* await player.play("https://youtube.com/watch?v=dQw4w9WgXcQ", userId);
|
|
604
|
+
* await player.play("tts: Hello everyone!", userId);
|
|
605
|
+
*/
|
|
336
606
|
async play(query: string | Track, requestedBy?: string): Promise<boolean> {
|
|
607
|
+
this.debug(`[Player] Play called with query: ${typeof query === "string" ? query : query?.title}`);
|
|
608
|
+
this.clearLeaveTimeout();
|
|
609
|
+
let tracksToAdd: Track[] = [];
|
|
610
|
+
let isPlaylist = false;
|
|
611
|
+
let effectiveRequest: ExtensionPlayRequest = { query, requestedBy };
|
|
612
|
+
let hookResponse: ExtensionPlayResponse = {};
|
|
613
|
+
|
|
337
614
|
try {
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
615
|
+
const hookOutcome = await this.runBeforePlayHooks(effectiveRequest);
|
|
616
|
+
effectiveRequest = hookOutcome.request;
|
|
617
|
+
hookResponse = hookOutcome.response;
|
|
618
|
+
if (effectiveRequest.requestedBy === undefined) {
|
|
619
|
+
effectiveRequest.requestedBy = requestedBy;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
const hookTracks = Array.isArray(hookResponse.tracks) ? hookResponse.tracks : undefined;
|
|
623
|
+
|
|
624
|
+
if (hookResponse.handled && (!hookTracks || hookTracks.length === 0)) {
|
|
625
|
+
const handledPayload: ExtensionAfterPlayPayload = {
|
|
626
|
+
success: hookResponse.success ?? true,
|
|
627
|
+
query: effectiveRequest.query,
|
|
628
|
+
requestedBy: effectiveRequest.requestedBy,
|
|
629
|
+
tracks: [],
|
|
630
|
+
isPlaylist: hookResponse.isPlaylist ?? false,
|
|
631
|
+
error: hookResponse.error,
|
|
632
|
+
};
|
|
633
|
+
await this.runAfterPlayHooks(handledPayload);
|
|
634
|
+
if (hookResponse.error) {
|
|
635
|
+
this.emit("playerError", hookResponse.error);
|
|
636
|
+
}
|
|
637
|
+
return hookResponse.success ?? true;
|
|
638
|
+
}
|
|
346
639
|
|
|
640
|
+
if (hookTracks && hookTracks.length > 0) {
|
|
641
|
+
tracksToAdd = hookTracks;
|
|
642
|
+
isPlaylist = hookResponse.isPlaylist ?? hookTracks.length > 1;
|
|
643
|
+
} else if (typeof effectiveRequest.query === "string") {
|
|
644
|
+
const searchResult = await this.search(effectiveRequest.query, effectiveRequest.requestedBy || "Unknown");
|
|
645
|
+
tracksToAdd = searchResult.tracks;
|
|
347
646
|
if (searchResult.playlist) {
|
|
348
647
|
isPlaylist = true;
|
|
349
648
|
this.debug(`[Player] Added playlist: ${searchResult.playlist.name} (${tracksToAdd.length} tracks)`);
|
|
350
649
|
}
|
|
351
|
-
} else {
|
|
352
|
-
tracksToAdd = [query];
|
|
650
|
+
} else if (effectiveRequest.query) {
|
|
651
|
+
tracksToAdd = [effectiveRequest.query as Track];
|
|
353
652
|
}
|
|
354
653
|
|
|
355
654
|
if (tracksToAdd.length === 0) {
|
|
@@ -357,7 +656,6 @@ export class Player extends EventEmitter {
|
|
|
357
656
|
throw new Error("No tracks found");
|
|
358
657
|
}
|
|
359
658
|
|
|
360
|
-
// If a TTS track is requested and interrupt mode is enabled, handle it separately
|
|
361
659
|
const isTTS = (t: Track | undefined) => {
|
|
362
660
|
if (!t) return false;
|
|
363
661
|
try {
|
|
@@ -367,7 +665,8 @@ export class Player extends EventEmitter {
|
|
|
367
665
|
}
|
|
368
666
|
};
|
|
369
667
|
|
|
370
|
-
const queryLooksTTS =
|
|
668
|
+
const queryLooksTTS =
|
|
669
|
+
typeof effectiveRequest.query === "string" && effectiveRequest.query.trim().toLowerCase().startsWith("tts");
|
|
371
670
|
|
|
372
671
|
if (
|
|
373
672
|
!isPlaylist &&
|
|
@@ -375,9 +674,15 @@ export class Player extends EventEmitter {
|
|
|
375
674
|
this.options?.tts?.interrupt !== false &&
|
|
376
675
|
(isTTS(tracksToAdd[0]) || queryLooksTTS)
|
|
377
676
|
) {
|
|
378
|
-
// Interrupt music playback with TTS (do not modify the music queue)
|
|
379
677
|
this.debug(`[Player] Interrupting with TTS: ${tracksToAdd[0].title}`);
|
|
380
678
|
await this.interruptWithTTSTrack(tracksToAdd[0]);
|
|
679
|
+
await this.runAfterPlayHooks({
|
|
680
|
+
success: true,
|
|
681
|
+
query: effectiveRequest.query,
|
|
682
|
+
requestedBy: effectiveRequest.requestedBy,
|
|
683
|
+
tracks: tracksToAdd,
|
|
684
|
+
isPlaylist,
|
|
685
|
+
});
|
|
381
686
|
return true;
|
|
382
687
|
}
|
|
383
688
|
|
|
@@ -385,17 +690,30 @@ export class Player extends EventEmitter {
|
|
|
385
690
|
this.queue.addMultiple(tracksToAdd);
|
|
386
691
|
this.emit("queueAddList", tracksToAdd);
|
|
387
692
|
} else {
|
|
388
|
-
this.queue.add(tracksToAdd
|
|
389
|
-
this.emit("queueAdd", tracksToAdd
|
|
693
|
+
this.queue.add(tracksToAdd[0]);
|
|
694
|
+
this.emit("queueAdd", tracksToAdd[0]);
|
|
390
695
|
}
|
|
391
696
|
|
|
392
|
-
|
|
393
|
-
if (!this.isPlaying) {
|
|
394
|
-
return this.playNext();
|
|
395
|
-
}
|
|
697
|
+
const started = !this.isPlaying ? await this.playNext() : true;
|
|
396
698
|
|
|
397
|
-
|
|
699
|
+
await this.runAfterPlayHooks({
|
|
700
|
+
success: started,
|
|
701
|
+
query: effectiveRequest.query,
|
|
702
|
+
requestedBy: effectiveRequest.requestedBy,
|
|
703
|
+
tracks: tracksToAdd,
|
|
704
|
+
isPlaylist,
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
return started;
|
|
398
708
|
} catch (error) {
|
|
709
|
+
await this.runAfterPlayHooks({
|
|
710
|
+
success: false,
|
|
711
|
+
query: effectiveRequest.query,
|
|
712
|
+
requestedBy: effectiveRequest.requestedBy,
|
|
713
|
+
tracks: tracksToAdd,
|
|
714
|
+
isPlaylist,
|
|
715
|
+
error: error as Error,
|
|
716
|
+
});
|
|
399
717
|
this.debug(`[Player] Play error:`, error);
|
|
400
718
|
this.emit("playerError", error as Error);
|
|
401
719
|
return false;
|
|
@@ -405,6 +723,11 @@ export class Player extends EventEmitter {
|
|
|
405
723
|
/**
|
|
406
724
|
* Interrupt current music with a TTS track. Pauses music, swaps the
|
|
407
725
|
* subscription to a dedicated TTS player, plays TTS, then resumes.
|
|
726
|
+
*
|
|
727
|
+
* @param {Track} track - The track to interrupt with
|
|
728
|
+
* @returns {Promise<void>}
|
|
729
|
+
* @example
|
|
730
|
+
* await player.interruptWithTTSTrack(track);
|
|
408
731
|
*/
|
|
409
732
|
public async interruptWithTTSTrack(track: Track): Promise<void> {
|
|
410
733
|
this.ttsQueue.push(track);
|
|
@@ -413,7 +736,13 @@ export class Player extends EventEmitter {
|
|
|
413
736
|
}
|
|
414
737
|
}
|
|
415
738
|
|
|
416
|
-
/**
|
|
739
|
+
/**
|
|
740
|
+
* Play queued TTS items sequentially
|
|
741
|
+
*
|
|
742
|
+
* @returns {Promise<void>}
|
|
743
|
+
* @example
|
|
744
|
+
* await player.playNextTTS();
|
|
745
|
+
*/
|
|
417
746
|
private async playNextTTS(): Promise<void> {
|
|
418
747
|
const next = this.ttsQueue.shift();
|
|
419
748
|
if (!next) return;
|
|
@@ -473,32 +802,195 @@ export class Player extends EventEmitter {
|
|
|
473
802
|
}
|
|
474
803
|
}
|
|
475
804
|
|
|
805
|
+
/**
|
|
806
|
+
* Get cached plugin or find and cache a new one
|
|
807
|
+
* @param track The track to find plugin for
|
|
808
|
+
* @returns The matching plugin or null if not found
|
|
809
|
+
*/
|
|
810
|
+
private getCachedPlugin(track: Track): SourcePlugin | null {
|
|
811
|
+
const cacheKey = `${track.source}:${track.url}`;
|
|
812
|
+
const now = Date.now();
|
|
813
|
+
|
|
814
|
+
// Check if cache is still valid
|
|
815
|
+
const cachedTimestamp = this.pluginCacheTimestamps.get(cacheKey);
|
|
816
|
+
if (cachedTimestamp && now - cachedTimestamp < this.PLUGIN_CACHE_TTL) {
|
|
817
|
+
const cachedPlugin = this.pluginCache.get(cacheKey);
|
|
818
|
+
if (cachedPlugin) {
|
|
819
|
+
this.debug(`[PluginCache] Using cached plugin for ${track.source}: ${cachedPlugin.name}`);
|
|
820
|
+
return cachedPlugin;
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
// Find new plugin and cache it
|
|
825
|
+
this.debug(`[PluginCache] Finding plugin for track: ${track.title} (${track.source})`);
|
|
826
|
+
const plugin = this.pluginManager.findPlugin(track.url) || this.pluginManager.get(track.source);
|
|
827
|
+
|
|
828
|
+
if (plugin) {
|
|
829
|
+
this.pluginCache.set(cacheKey, plugin);
|
|
830
|
+
this.pluginCacheTimestamps.set(cacheKey, now);
|
|
831
|
+
this.debug(`[PluginCache] Cached plugin: ${plugin.name} for ${track.source}`);
|
|
832
|
+
return plugin;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
return null;
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
/**
|
|
839
|
+
* Clear expired cache entries
|
|
840
|
+
*/
|
|
841
|
+
private clearExpiredCache(): void {
|
|
842
|
+
const now = Date.now();
|
|
843
|
+
for (const [key, timestamp] of this.pluginCacheTimestamps.entries()) {
|
|
844
|
+
if (now - timestamp >= this.PLUGIN_CACHE_TTL) {
|
|
845
|
+
this.pluginCache.delete(key);
|
|
846
|
+
this.pluginCacheTimestamps.delete(key);
|
|
847
|
+
this.debug(`[PluginCache] Cleared expired cache entry: ${key}`);
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
/**
|
|
853
|
+
* Clear all plugin cache entries
|
|
854
|
+
* @example
|
|
855
|
+
* player.clearPluginCache();
|
|
856
|
+
*/
|
|
857
|
+
public clearPluginCache(): void {
|
|
858
|
+
const cacheSize = this.pluginCache.size;
|
|
859
|
+
this.pluginCache.clear();
|
|
860
|
+
this.pluginCacheTimestamps.clear();
|
|
861
|
+
this.debug(`[PluginCache] Cleared all ${cacheSize} cache entries`);
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
/**
|
|
865
|
+
* Get plugin cache statistics
|
|
866
|
+
* @returns Cache statistics
|
|
867
|
+
* @example
|
|
868
|
+
* const stats = player.getPluginCacheStats();
|
|
869
|
+
* console.log(`Cache size: ${stats.size}, Hit rate: ${stats.hitRate}%`);
|
|
870
|
+
*/
|
|
871
|
+
public getPluginCacheStats(): { size: number; hitRate: number; expiredEntries: number } {
|
|
872
|
+
const now = Date.now();
|
|
873
|
+
let expiredEntries = 0;
|
|
874
|
+
|
|
875
|
+
for (const timestamp of this.pluginCacheTimestamps.values()) {
|
|
876
|
+
if (now - timestamp >= this.PLUGIN_CACHE_TTL) {
|
|
877
|
+
expiredEntries++;
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
return {
|
|
882
|
+
size: this.pluginCache.size,
|
|
883
|
+
hitRate: 0, // Would need to track hits/misses to calculate this
|
|
884
|
+
expiredEntries,
|
|
885
|
+
};
|
|
886
|
+
}
|
|
887
|
+
|
|
476
888
|
/** Build AudioResource for a given track using the plugin pipeline */
|
|
477
889
|
private async resourceFromTrack(track: Track): Promise<AudioResource> {
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
890
|
+
this.debug(`[ResourceFromTrack] Starting resource creation for track: ${track.title} (${track.source})`);
|
|
891
|
+
|
|
892
|
+
// Clear expired cache entries periodically
|
|
893
|
+
if (Math.random() < 0.1) {
|
|
894
|
+
// 10% chance to clean cache
|
|
895
|
+
this.clearExpiredCache();
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
// Resolve plugin using cache
|
|
899
|
+
const plugin = this.getCachedPlugin(track);
|
|
900
|
+
if (!plugin) {
|
|
901
|
+
this.debug(`[ResourceFromTrack] No plugin found for track: ${track.title} (${track.source})`);
|
|
902
|
+
throw new Error(`No plugin found for track: ${track.title}`);
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
this.debug(`[ResourceFromTrack] Using plugin: ${plugin.name} for track: ${track.title}`);
|
|
906
|
+
|
|
907
|
+
let streamInfo: StreamInfo | null = null;
|
|
908
|
+
const timeoutMs = this.options.extractorTimeout ?? 15000;
|
|
481
909
|
|
|
482
|
-
let streamInfo: any;
|
|
483
910
|
try {
|
|
484
|
-
|
|
911
|
+
this.debug(`[ResourceFromTrack] Attempting getStream with ${plugin.name}, timeout: ${timeoutMs}ms`);
|
|
912
|
+
const startTime = Date.now();
|
|
913
|
+
streamInfo = await withTimeout(plugin.getStream(track), timeoutMs, "getStream timed out");
|
|
914
|
+
const duration = Date.now() - startTime;
|
|
915
|
+
this.debug(`[ResourceFromTrack] getStream successful with ${plugin.name} in ${duration}ms`);
|
|
916
|
+
|
|
917
|
+
if (!streamInfo?.stream) {
|
|
918
|
+
this.debug(`[ResourceFromTrack] getStream returned no stream from ${plugin.name}`);
|
|
919
|
+
throw new Error(`No stream returned from ${plugin.name}`);
|
|
920
|
+
}
|
|
485
921
|
} catch (streamError) {
|
|
922
|
+
const errorMessage = streamError instanceof Error ? streamError.message : String(streamError);
|
|
923
|
+
this.debug(`[ResourceFromTrack] getStream failed with ${plugin.name}: ${errorMessage}`);
|
|
924
|
+
|
|
925
|
+
// Log more details for debugging
|
|
926
|
+
if (streamError instanceof Error && streamError.stack) {
|
|
927
|
+
this.debug(`[ResourceFromTrack] getStream error stack:`, streamError.stack);
|
|
928
|
+
}
|
|
929
|
+
|
|
486
930
|
// try fallbacks
|
|
931
|
+
this.debug(`[ResourceFromTrack] Attempting fallback plugins for track: ${track.title}`);
|
|
487
932
|
const allplugs = this.pluginManager.getAll();
|
|
933
|
+
let fallbackAttempts = 0;
|
|
934
|
+
|
|
488
935
|
for (const p of allplugs) {
|
|
489
|
-
if (typeof (p as any).getFallback !== "function")
|
|
936
|
+
if (typeof (p as any).getFallback !== "function" && typeof (p as any).getStream !== "function") {
|
|
937
|
+
this.debug(`[ResourceFromTrack] Skipping plugin ${(p as any).name} - no getFallback or getStream method`);
|
|
938
|
+
continue;
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
fallbackAttempts++;
|
|
942
|
+
this.debug(`[ResourceFromTrack] Trying fallback plugin ${(p as any).name} (attempt ${fallbackAttempts})`);
|
|
943
|
+
|
|
490
944
|
try {
|
|
491
|
-
|
|
945
|
+
// Try getStream first
|
|
946
|
+
const startTime = Date.now();
|
|
947
|
+
streamInfo = await withTimeout(p.getStream(track), timeoutMs, "getStream timed out");
|
|
948
|
+
const duration = Date.now() - startTime;
|
|
949
|
+
|
|
950
|
+
if (streamInfo?.stream) {
|
|
951
|
+
this.debug(`[ResourceFromTrack] Fallback getStream successful with ${(p as any).name} in ${duration}ms`);
|
|
952
|
+
break;
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
// Try getFallback if getStream didn't work
|
|
956
|
+
this.debug(`[ResourceFromTrack] Trying getFallback with ${(p as any).name}`);
|
|
957
|
+
const fallbackStartTime = Date.now();
|
|
958
|
+
streamInfo = await withTimeout(
|
|
492
959
|
(p as any).getFallback(track),
|
|
960
|
+
timeoutMs,
|
|
493
961
|
`getFallback timed out for plugin ${(p as any).name}`,
|
|
494
962
|
);
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
963
|
+
const fallbackDuration = Date.now() - fallbackStartTime;
|
|
964
|
+
|
|
965
|
+
if (streamInfo?.stream) {
|
|
966
|
+
this.debug(`[ResourceFromTrack] Fallback getFallback successful with ${(p as any).name} in ${fallbackDuration}ms`);
|
|
967
|
+
break;
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
this.debug(`[ResourceFromTrack] Fallback plugin ${(p as any).name} returned no stream`);
|
|
971
|
+
} catch (fallbackError) {
|
|
972
|
+
const errorMessage = fallbackError instanceof Error ? fallbackError.message : String(fallbackError);
|
|
973
|
+
this.debug(`[ResourceFromTrack] Fallback plugin ${(p as any).name} failed: ${errorMessage}`);
|
|
974
|
+
|
|
975
|
+
// Log more details for debugging
|
|
976
|
+
if (fallbackError instanceof Error && fallbackError.stack) {
|
|
977
|
+
this.debug(`[ResourceFromTrack] Fallback error stack:`, fallbackError.stack);
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
if (!streamInfo?.stream) {
|
|
983
|
+
this.debug(`[ResourceFromTrack] All ${fallbackAttempts} fallback attempts failed for track: ${track.title}`);
|
|
984
|
+
throw new Error(`All getFallback attempts failed for track: ${track.title}`);
|
|
498
985
|
}
|
|
499
|
-
if (!streamInfo?.stream) throw new Error(`All getFallback attempts failed for track: ${track.title}`);
|
|
500
986
|
}
|
|
501
987
|
|
|
988
|
+
this.debug(
|
|
989
|
+
`[ResourceFromTrack] Stream obtained, type: ${streamInfo.type}, metadata keys: ${Object.keys(
|
|
990
|
+
streamInfo.metadata || {},
|
|
991
|
+
).join(", ")}`,
|
|
992
|
+
);
|
|
993
|
+
|
|
502
994
|
const mapToStreamType = (type: string): StreamType => {
|
|
503
995
|
switch (type) {
|
|
504
996
|
case "webm/opus":
|
|
@@ -512,15 +1004,23 @@ export class Player extends EventEmitter {
|
|
|
512
1004
|
};
|
|
513
1005
|
|
|
514
1006
|
const inputType = mapToStreamType(streamInfo.type);
|
|
515
|
-
|
|
1007
|
+
this.debug(`[ResourceFromTrack] Creating AudioResource with inputType: ${inputType}`);
|
|
1008
|
+
|
|
1009
|
+
// Merge metadata safely
|
|
1010
|
+
const mergedMetadata = {
|
|
1011
|
+
...track,
|
|
1012
|
+
...(streamInfo.metadata || {}),
|
|
1013
|
+
};
|
|
1014
|
+
|
|
1015
|
+
const audioResource = createAudioResource(streamInfo.stream, {
|
|
516
1016
|
// Prefer plugin-provided metadata (e.g., precise duration), fallback to track fields
|
|
517
|
-
metadata:
|
|
518
|
-
...(track as any),
|
|
519
|
-
...((streamInfo as any)?.metadata || {}),
|
|
520
|
-
},
|
|
1017
|
+
metadata: mergedMetadata,
|
|
521
1018
|
inputType,
|
|
522
1019
|
inlineVolume: true,
|
|
523
1020
|
});
|
|
1021
|
+
|
|
1022
|
+
this.debug(`[ResourceFromTrack] AudioResource created successfully for track: ${track.title}`);
|
|
1023
|
+
return audioResource;
|
|
524
1024
|
}
|
|
525
1025
|
|
|
526
1026
|
private async generateWillNext(): Promise<void> {
|
|
@@ -537,11 +1037,12 @@ export class Player extends EventEmitter {
|
|
|
537
1037
|
for (const p of candidates) {
|
|
538
1038
|
try {
|
|
539
1039
|
this.debug(`[Player] Trying related from plugin: ${p.name}`);
|
|
540
|
-
const related = await
|
|
1040
|
+
const related = await withTimeout(
|
|
541
1041
|
(p as any).getRelatedTracks(lastTrack.url, {
|
|
542
1042
|
limit: 10,
|
|
543
1043
|
history: this.queue.previousTracks,
|
|
544
1044
|
}),
|
|
1045
|
+
this.options.extractorTimeout ?? 15000,
|
|
545
1046
|
`getRelatedTracks timed out for ${p.name}`,
|
|
546
1047
|
);
|
|
547
1048
|
|
|
@@ -599,6 +1100,14 @@ export class Player extends EventEmitter {
|
|
|
599
1100
|
}
|
|
600
1101
|
}
|
|
601
1102
|
|
|
1103
|
+
/**
|
|
1104
|
+
* Pause the current track
|
|
1105
|
+
*
|
|
1106
|
+
* @returns {boolean} True if paused successfully
|
|
1107
|
+
* @example
|
|
1108
|
+
* const paused = player.pause();
|
|
1109
|
+
* console.log(`Paused: ${paused}`);
|
|
1110
|
+
*/
|
|
602
1111
|
pause(): boolean {
|
|
603
1112
|
this.debug(`[Player] pause called`);
|
|
604
1113
|
if (this.isPlaying && !this.isPaused) {
|
|
@@ -607,6 +1116,14 @@ export class Player extends EventEmitter {
|
|
|
607
1116
|
return false;
|
|
608
1117
|
}
|
|
609
1118
|
|
|
1119
|
+
/**
|
|
1120
|
+
* Resume the current track
|
|
1121
|
+
*
|
|
1122
|
+
* @returns {boolean} True if resumed successfully
|
|
1123
|
+
* @example
|
|
1124
|
+
* const resumed = player.resume();
|
|
1125
|
+
* console.log(`Resumed: ${resumed}`);
|
|
1126
|
+
*/
|
|
610
1127
|
resume(): boolean {
|
|
611
1128
|
this.debug(`[Player] resume called`);
|
|
612
1129
|
if (this.isPaused) {
|
|
@@ -623,6 +1140,14 @@ export class Player extends EventEmitter {
|
|
|
623
1140
|
return false;
|
|
624
1141
|
}
|
|
625
1142
|
|
|
1143
|
+
/**
|
|
1144
|
+
* Stop the current track
|
|
1145
|
+
*
|
|
1146
|
+
* @returns {boolean} True if stopped successfully
|
|
1147
|
+
* @example
|
|
1148
|
+
* const stopped = player.stop();
|
|
1149
|
+
* console.log(`Stopped: ${stopped}`);
|
|
1150
|
+
*/
|
|
626
1151
|
stop(): boolean {
|
|
627
1152
|
this.debug(`[Player] stop called`);
|
|
628
1153
|
this.queue.clear();
|
|
@@ -633,6 +1158,15 @@ export class Player extends EventEmitter {
|
|
|
633
1158
|
return result;
|
|
634
1159
|
}
|
|
635
1160
|
|
|
1161
|
+
/**
|
|
1162
|
+
* Skip to the next track
|
|
1163
|
+
*
|
|
1164
|
+
* @returns {boolean} True if skipped successfully
|
|
1165
|
+
* @example
|
|
1166
|
+
* const skipped = player.skip();
|
|
1167
|
+
* console.log(`Skipped: ${skipped}`);
|
|
1168
|
+
*/
|
|
1169
|
+
|
|
636
1170
|
skip(): boolean {
|
|
637
1171
|
this.debug(`[Player] skip called`);
|
|
638
1172
|
if (this.isPlaying || this.isPaused) {
|
|
@@ -644,6 +1178,11 @@ export class Player extends EventEmitter {
|
|
|
644
1178
|
|
|
645
1179
|
/**
|
|
646
1180
|
* Go back to the previous track in history and play it.
|
|
1181
|
+
*
|
|
1182
|
+
* @returns {Promise<boolean>} True if previous track was played successfully
|
|
1183
|
+
* @example
|
|
1184
|
+
* const previous = await player.previous();
|
|
1185
|
+
* console.log(`Previous: ${previous}`);
|
|
647
1186
|
*/
|
|
648
1187
|
async previous(): Promise<boolean> {
|
|
649
1188
|
this.debug(`[Player] previous called`);
|
|
@@ -654,14 +1193,41 @@ export class Player extends EventEmitter {
|
|
|
654
1193
|
return this.startTrack(track);
|
|
655
1194
|
}
|
|
656
1195
|
|
|
1196
|
+
/**
|
|
1197
|
+
* Loop the current track
|
|
1198
|
+
*
|
|
1199
|
+
* @param {LoopMode} mode - The loop mode to set
|
|
1200
|
+
* @returns {LoopMode} The loop mode
|
|
1201
|
+
* @example
|
|
1202
|
+
* const loopMode = player.loop("track");
|
|
1203
|
+
* console.log(`Loop mode: ${loopMode}`);
|
|
1204
|
+
*/
|
|
657
1205
|
loop(mode?: LoopMode): LoopMode {
|
|
658
1206
|
return this.queue.loop(mode);
|
|
659
1207
|
}
|
|
660
1208
|
|
|
1209
|
+
/**
|
|
1210
|
+
* Set the auto-play mode
|
|
1211
|
+
*
|
|
1212
|
+
* @param {boolean} mode - The auto-play mode to set
|
|
1213
|
+
* @returns {boolean} The auto-play mode
|
|
1214
|
+
* @example
|
|
1215
|
+
* const autoPlayMode = player.autoPlay(true);
|
|
1216
|
+
* console.log(`Auto-play mode: ${autoPlayMode}`);
|
|
1217
|
+
*/
|
|
661
1218
|
autoPlay(mode?: boolean): boolean {
|
|
662
1219
|
return this.queue.autoPlay(mode);
|
|
663
1220
|
}
|
|
664
1221
|
|
|
1222
|
+
/**
|
|
1223
|
+
* Set the volume of the current track
|
|
1224
|
+
*
|
|
1225
|
+
* @param {number} volume - The volume to set
|
|
1226
|
+
* @returns {boolean} True if volume was set successfully
|
|
1227
|
+
* @example
|
|
1228
|
+
* const volumeSet = player.setVolume(50);
|
|
1229
|
+
* console.log(`Volume set: ${volumeSet}`);
|
|
1230
|
+
*/
|
|
665
1231
|
setVolume(volume: number): boolean {
|
|
666
1232
|
this.debug(`[Player] setVolume called: ${volume}`);
|
|
667
1233
|
if (volume < 0 || volume > 200) return false;
|
|
@@ -693,11 +1259,25 @@ export class Player extends EventEmitter {
|
|
|
693
1259
|
return true;
|
|
694
1260
|
}
|
|
695
1261
|
|
|
1262
|
+
/**
|
|
1263
|
+
* Shuffle the queue
|
|
1264
|
+
*
|
|
1265
|
+
* @returns {void}
|
|
1266
|
+
* @example
|
|
1267
|
+
* player.shuffle();
|
|
1268
|
+
*/
|
|
696
1269
|
shuffle(): void {
|
|
697
1270
|
this.debug(`[Player] shuffle called`);
|
|
698
1271
|
this.queue.shuffle();
|
|
699
1272
|
}
|
|
700
1273
|
|
|
1274
|
+
/**
|
|
1275
|
+
* Clear the queue
|
|
1276
|
+
*
|
|
1277
|
+
* @returns {void}
|
|
1278
|
+
* @example
|
|
1279
|
+
* player.clearQueue();
|
|
1280
|
+
*/
|
|
701
1281
|
clearQueue(): void {
|
|
702
1282
|
this.debug(`[Player] clearQueue called`);
|
|
703
1283
|
this.queue.clear();
|
|
@@ -708,6 +1288,14 @@ export class Player extends EventEmitter {
|
|
|
708
1288
|
* - If `query` is a string, performs a search and inserts resulting tracks (playlist supported).
|
|
709
1289
|
* - If a Track or Track[] is provided, inserts directly.
|
|
710
1290
|
* Does not auto-start playback; it only modifies the queue.
|
|
1291
|
+
*
|
|
1292
|
+
* @param {string | Track | Track[]} query - The track or tracks to insert
|
|
1293
|
+
* @param {number} index - The index to insert the tracks at
|
|
1294
|
+
* @param {string} requestedBy - The user ID who requested the insert
|
|
1295
|
+
* @returns {Promise<boolean>} True if the tracks were inserted successfully
|
|
1296
|
+
* @example
|
|
1297
|
+
* const inserted = await player.insert("Song Name", 0, userId);
|
|
1298
|
+
* console.log(`Inserted: ${inserted}`);
|
|
711
1299
|
*/
|
|
712
1300
|
async insert(query: string | Track | Track[], index: number, requestedBy?: string): Promise<boolean> {
|
|
713
1301
|
try {
|
|
@@ -749,6 +1337,15 @@ export class Player extends EventEmitter {
|
|
|
749
1337
|
}
|
|
750
1338
|
}
|
|
751
1339
|
|
|
1340
|
+
/**
|
|
1341
|
+
* Remove a track from the queue
|
|
1342
|
+
*
|
|
1343
|
+
* @param {number} index - The index of the track to remove
|
|
1344
|
+
* @returns {Track | null} The removed track or null
|
|
1345
|
+
* @example
|
|
1346
|
+
* const removed = player.remove(0);
|
|
1347
|
+
* console.log(`Removed: ${removed?.title}`);
|
|
1348
|
+
*/
|
|
752
1349
|
remove(index: number): Track | null {
|
|
753
1350
|
this.debug(`[Player] remove called for index: ${index}`);
|
|
754
1351
|
const track = this.queue.remove(index);
|
|
@@ -758,6 +1355,15 @@ export class Player extends EventEmitter {
|
|
|
758
1355
|
return track;
|
|
759
1356
|
}
|
|
760
1357
|
|
|
1358
|
+
/**
|
|
1359
|
+
* Get the progress bar of the current track
|
|
1360
|
+
*
|
|
1361
|
+
* @param {ProgressBarOptions} options - The options for the progress bar
|
|
1362
|
+
* @returns {string} The progress bar
|
|
1363
|
+
* @example
|
|
1364
|
+
* const progressBar = player.getProgressBar();
|
|
1365
|
+
* console.log(`Progress bar: ${progressBar}`);
|
|
1366
|
+
*/
|
|
761
1367
|
getProgressBar(options: ProgressBarOptions = {}): string {
|
|
762
1368
|
const { size = 20, barChar = "▬", progressChar = "🔘" } = options;
|
|
763
1369
|
const track = this.queue.currentTrack;
|
|
@@ -775,6 +1381,14 @@ export class Player extends EventEmitter {
|
|
|
775
1381
|
return `${this.formatTime(current)} | ${bar} | ${this.formatTime(total)}`;
|
|
776
1382
|
}
|
|
777
1383
|
|
|
1384
|
+
/**
|
|
1385
|
+
* Get the time of the current track
|
|
1386
|
+
*
|
|
1387
|
+
* @returns {Object} The time of the current track
|
|
1388
|
+
* @example
|
|
1389
|
+
* const time = player.getTime();
|
|
1390
|
+
* console.log(`Time: ${time.current}`);
|
|
1391
|
+
*/
|
|
778
1392
|
getTime() {
|
|
779
1393
|
const resource = this.currentResource;
|
|
780
1394
|
const track = this.queue.currentTrack;
|
|
@@ -794,6 +1408,15 @@ export class Player extends EventEmitter {
|
|
|
794
1408
|
};
|
|
795
1409
|
}
|
|
796
1410
|
|
|
1411
|
+
/**
|
|
1412
|
+
* Format the time in the format of HH:MM:SS
|
|
1413
|
+
*
|
|
1414
|
+
* @param {number} ms - The time in milliseconds
|
|
1415
|
+
* @returns {string} The formatted time
|
|
1416
|
+
* @example
|
|
1417
|
+
* const formattedTime = player.formatTime(1000);
|
|
1418
|
+
* console.log(`Formatted time: ${formattedTime}`);
|
|
1419
|
+
*/
|
|
797
1420
|
formatTime(ms: number): string {
|
|
798
1421
|
const totalSeconds = Math.floor(ms / 1000);
|
|
799
1422
|
const hours = Math.floor(totalSeconds / 3600);
|
|
@@ -820,6 +1443,13 @@ export class Player extends EventEmitter {
|
|
|
820
1443
|
}
|
|
821
1444
|
}
|
|
822
1445
|
|
|
1446
|
+
/**
|
|
1447
|
+
* Destroy the player
|
|
1448
|
+
*
|
|
1449
|
+
* @returns {void}
|
|
1450
|
+
* @example
|
|
1451
|
+
* player.destroy();
|
|
1452
|
+
*/
|
|
823
1453
|
destroy(): void {
|
|
824
1454
|
this.debug(`[Player] destroy called`);
|
|
825
1455
|
if (this.leaveTimeout) {
|
|
@@ -843,36 +1473,99 @@ export class Player extends EventEmitter {
|
|
|
843
1473
|
|
|
844
1474
|
this.queue.clear();
|
|
845
1475
|
this.pluginManager.clear();
|
|
1476
|
+
for (const extension of [...this.extensions]) {
|
|
1477
|
+
this.invokeExtensionLifecycle(extension, "onDestroy");
|
|
1478
|
+
if (extension.player === this) {
|
|
1479
|
+
extension.player = null;
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1482
|
+
this.extensions = [];
|
|
846
1483
|
this.isPlaying = false;
|
|
847
1484
|
this.isPaused = false;
|
|
848
1485
|
this.emit("playerDestroy");
|
|
849
1486
|
this.removeAllListeners();
|
|
850
1487
|
}
|
|
851
1488
|
|
|
852
|
-
|
|
1489
|
+
/**
|
|
1490
|
+
* Get the size of the queue
|
|
1491
|
+
*
|
|
1492
|
+
* @returns {number} The size of the queue
|
|
1493
|
+
* @example
|
|
1494
|
+
* const queueSize = player.queueSize;
|
|
1495
|
+
* console.log(`Queue size: ${queueSize}`);
|
|
1496
|
+
*/
|
|
853
1497
|
get queueSize(): number {
|
|
854
1498
|
return this.queue.size;
|
|
855
1499
|
}
|
|
856
1500
|
|
|
1501
|
+
/**
|
|
1502
|
+
* Get the current track
|
|
1503
|
+
*
|
|
1504
|
+
* @returns {Track | null} The current track or null
|
|
1505
|
+
* @example
|
|
1506
|
+
* const currentTrack = player.currentTrack;
|
|
1507
|
+
* console.log(`Current track: ${currentTrack?.title}`);
|
|
1508
|
+
*/
|
|
857
1509
|
get currentTrack(): Track | null {
|
|
858
1510
|
return this.queue.currentTrack;
|
|
859
1511
|
}
|
|
860
1512
|
|
|
1513
|
+
/**
|
|
1514
|
+
* Get the previous track
|
|
1515
|
+
*
|
|
1516
|
+
* @returns {Track | null} The previous track or null
|
|
1517
|
+
* @example
|
|
1518
|
+
* const previousTrack = player.previousTrack;
|
|
1519
|
+
* console.log(`Previous track: ${previousTrack?.title}`);
|
|
1520
|
+
*/
|
|
861
1521
|
get previousTrack(): Track | null {
|
|
862
1522
|
return this.queue.previousTracks?.at(-1) ?? null;
|
|
863
1523
|
}
|
|
864
1524
|
|
|
1525
|
+
/**
|
|
1526
|
+
* Get the upcoming tracks
|
|
1527
|
+
*
|
|
1528
|
+
* @returns {Track[]} The upcoming tracks
|
|
1529
|
+
* @example
|
|
1530
|
+
* const upcomingTracks = player.upcomingTracks;
|
|
1531
|
+
* console.log(`Upcoming tracks: ${upcomingTracks.length}`);
|
|
1532
|
+
*/
|
|
865
1533
|
get upcomingTracks(): Track[] {
|
|
866
1534
|
return this.queue.getTracks();
|
|
867
1535
|
}
|
|
868
1536
|
|
|
1537
|
+
/**
|
|
1538
|
+
* Get the previous tracks
|
|
1539
|
+
*
|
|
1540
|
+
* @returns {Track[]} The previous tracks
|
|
1541
|
+
* @example
|
|
1542
|
+
* const previousTracks = player.previousTracks;
|
|
1543
|
+
* console.log(`Previous tracks: ${previousTracks.length}`);
|
|
1544
|
+
*/
|
|
869
1545
|
get previousTracks(): Track[] {
|
|
870
1546
|
return this.queue.previousTracks;
|
|
871
1547
|
}
|
|
872
1548
|
|
|
1549
|
+
/**
|
|
1550
|
+
* Get the available plugins
|
|
1551
|
+
*
|
|
1552
|
+
* @returns {string[]} The available plugins
|
|
1553
|
+
* @example
|
|
1554
|
+
* const availablePlugins = player.availablePlugins;
|
|
1555
|
+
* console.log(`Available plugins: ${availablePlugins.length}`);
|
|
1556
|
+
*/
|
|
873
1557
|
get availablePlugins(): string[] {
|
|
874
1558
|
return this.pluginManager.getAll().map((p) => p.name);
|
|
875
1559
|
}
|
|
1560
|
+
|
|
1561
|
+
/**
|
|
1562
|
+
* Get the related tracks
|
|
1563
|
+
*
|
|
1564
|
+
* @returns {Track[] | null} The related tracks or null
|
|
1565
|
+
* @example
|
|
1566
|
+
* const relatedTracks = player.relatedTracks;
|
|
1567
|
+
* console.log(`Related tracks: ${relatedTracks?.length}`);
|
|
1568
|
+
*/
|
|
876
1569
|
get relatedTracks(): Track[] | null {
|
|
877
1570
|
return this.queue.relatedTracks();
|
|
878
1571
|
}
|