ziplayer 0.1.5 → 0.2.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 +16 -0
- package/dist/extensions/index.d.ts +18 -1
- package/dist/extensions/index.d.ts.map +1 -1
- package/dist/extensions/index.js +146 -3
- package/dist/extensions/index.js.map +1 -1
- package/dist/plugins/index.d.ts +12 -0
- package/dist/plugins/index.d.ts.map +1 -1
- package/dist/plugins/index.js +55 -1
- package/dist/plugins/index.js.map +1 -1
- package/dist/structures/FilterManager.d.ts +126 -0
- package/dist/structures/FilterManager.d.ts.map +1 -0
- package/dist/structures/FilterManager.js +247 -0
- package/dist/structures/FilterManager.js.map +1 -0
- package/dist/structures/Player.d.ts +144 -115
- package/dist/structures/Player.d.ts.map +1 -1
- package/dist/structures/Player.js +683 -743
- package/dist/structures/Player.js.map +1 -1
- package/dist/types/index.d.ts +71 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/index.js +223 -0
- package/dist/types/index.js.map +1 -1
- package/package.json +3 -7
- package/src/extensions/index.ts +165 -3
- package/src/plugins/index.ts +70 -0
- package/src/structures/FilterManager.ts +262 -0
- package/src/structures/Player.ts +759 -803
- package/src/types/index.ts +291 -0
package/src/structures/Player.ts
CHANGED
|
@@ -15,8 +15,8 @@ import {
|
|
|
15
15
|
|
|
16
16
|
import { VoiceChannel } from "discord.js";
|
|
17
17
|
import { Readable } from "stream";
|
|
18
|
-
import { BaseExtension } from "../extensions";
|
|
19
|
-
import {
|
|
18
|
+
import type { BaseExtension } from "../extensions";
|
|
19
|
+
import type {
|
|
20
20
|
Track,
|
|
21
21
|
PlayerOptions,
|
|
22
22
|
PlayerEvents,
|
|
@@ -26,19 +26,18 @@ import {
|
|
|
26
26
|
LoopMode,
|
|
27
27
|
StreamInfo,
|
|
28
28
|
SaveOptions,
|
|
29
|
-
} from "../types";
|
|
30
|
-
import type {
|
|
31
|
-
ExtensionContext,
|
|
32
29
|
ExtensionPlayRequest,
|
|
33
30
|
ExtensionPlayResponse,
|
|
34
31
|
ExtensionAfterPlayPayload,
|
|
35
|
-
ExtensionStreamRequest,
|
|
36
|
-
ExtensionSearchRequest,
|
|
37
32
|
} from "../types";
|
|
33
|
+
import type { PlayerManager } from "./PlayerManager";
|
|
34
|
+
|
|
38
35
|
import { Queue } from "./Queue";
|
|
39
36
|
import { PluginManager } from "../plugins";
|
|
37
|
+
import { ExtensionManager } from "../extensions";
|
|
40
38
|
import { withTimeout } from "../utils/timeout";
|
|
41
|
-
import
|
|
39
|
+
import { FilterManager } from "./FilterManager";
|
|
40
|
+
|
|
42
41
|
export declare interface Player {
|
|
43
42
|
on<K extends keyof PlayerEvents>(event: K, listener: (...args: PlayerEvents[K]) => void): this;
|
|
44
43
|
emit<K extends keyof PlayerEvents>(event: K, ...args: PlayerEvents[K]): boolean;
|
|
@@ -90,313 +89,20 @@ export class Player extends EventEmitter {
|
|
|
90
89
|
public isPaused: boolean = false;
|
|
91
90
|
public options: PlayerOptions;
|
|
92
91
|
public pluginManager: PluginManager;
|
|
92
|
+
public extensionManager: ExtensionManager;
|
|
93
93
|
public userdata?: Record<string, any>;
|
|
94
94
|
private manager: PlayerManager;
|
|
95
95
|
private leaveTimeout: NodeJS.Timeout | null = null;
|
|
96
96
|
private currentResource: AudioResource | null = null;
|
|
97
97
|
private volumeInterval: NodeJS.Timeout | null = null;
|
|
98
98
|
private skipLoop = false;
|
|
99
|
-
private
|
|
100
|
-
private extensionContext!: ExtensionContext;
|
|
99
|
+
private filter!: FilterManager;
|
|
101
100
|
|
|
102
101
|
// Cache for search results to avoid duplicate calls
|
|
103
102
|
private searchCache = new Map<string, SearchResult>();
|
|
104
103
|
private readonly SEARCH_CACHE_TTL = 2 * 60 * 1000; // 2 minutes
|
|
105
104
|
private searchCacheTimestamps = new Map<string, number>();
|
|
106
|
-
// TTS support
|
|
107
105
|
private ttsPlayer: DiscordAudioPlayer | null = null;
|
|
108
|
-
/**
|
|
109
|
-
* Attach an extension to the player
|
|
110
|
-
*
|
|
111
|
-
* @param {BaseExtension} extension - The extension to attach
|
|
112
|
-
* @example
|
|
113
|
-
* player.attachExtension(new MyExtension());
|
|
114
|
-
*/
|
|
115
|
-
public attachExtension(extension: BaseExtension): void {
|
|
116
|
-
if (this.extensions.includes(extension)) return;
|
|
117
|
-
if (!extension.player) extension.player = this;
|
|
118
|
-
this.extensions.push(extension);
|
|
119
|
-
this.invokeExtensionLifecycle(extension, "onRegister");
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
/**
|
|
123
|
-
* Detach an extension from the player
|
|
124
|
-
*
|
|
125
|
-
* @param {BaseExtension} extension - The extension to detach
|
|
126
|
-
* @example
|
|
127
|
-
* player.detachExtension(new MyExtension());
|
|
128
|
-
*/
|
|
129
|
-
public detachExtension(extension: BaseExtension): void {
|
|
130
|
-
const index = this.extensions.indexOf(extension);
|
|
131
|
-
if (index === -1) return;
|
|
132
|
-
this.extensions.splice(index, 1);
|
|
133
|
-
this.invokeExtensionLifecycle(extension, "onDestroy");
|
|
134
|
-
if (extension.player === this) {
|
|
135
|
-
extension.player = null;
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
/**
|
|
140
|
-
* Get all extensions attached to the player
|
|
141
|
-
*
|
|
142
|
-
* @returns {readonly BaseExtension[]} All attached extensions
|
|
143
|
-
* @example
|
|
144
|
-
* const extensions = player.getExtensions();
|
|
145
|
-
* console.log(`Extensions: ${extensions.length}`);
|
|
146
|
-
*/
|
|
147
|
-
public getExtensions(): readonly BaseExtension[] {
|
|
148
|
-
return this.extensions;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
private invokeExtensionLifecycle(extension: BaseExtension, hook: "onRegister" | "onDestroy"): void {
|
|
152
|
-
const fn = (extension as any)[hook];
|
|
153
|
-
if (typeof fn !== "function") return;
|
|
154
|
-
try {
|
|
155
|
-
const result = fn.call(extension, this.extensionContext);
|
|
156
|
-
if (result && typeof (result as Promise<unknown>).then === "function") {
|
|
157
|
-
(result as Promise<unknown>).catch((err) => this.debug(`[Player] Extension ${extension.name} ${hook} error:`, err));
|
|
158
|
-
}
|
|
159
|
-
} catch (err) {
|
|
160
|
-
this.debug(`[Player] Extension ${extension.name} ${hook} error:`, err);
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
private async runBeforePlayHooks(
|
|
165
|
-
initial: ExtensionPlayRequest,
|
|
166
|
-
): Promise<{ request: ExtensionPlayRequest; response: ExtensionPlayResponse }> {
|
|
167
|
-
const request: ExtensionPlayRequest = { ...initial };
|
|
168
|
-
const response: ExtensionPlayResponse = {};
|
|
169
|
-
for (const extension of this.extensions) {
|
|
170
|
-
const hook = (extension as any).beforePlay;
|
|
171
|
-
if (typeof hook !== "function") continue;
|
|
172
|
-
try {
|
|
173
|
-
const result = await Promise.resolve(hook.call(extension, this.extensionContext, request));
|
|
174
|
-
if (!result) continue;
|
|
175
|
-
if (result.query !== undefined) {
|
|
176
|
-
request.query = result.query;
|
|
177
|
-
response.query = result.query;
|
|
178
|
-
}
|
|
179
|
-
if (result.requestedBy !== undefined) {
|
|
180
|
-
request.requestedBy = result.requestedBy;
|
|
181
|
-
response.requestedBy = result.requestedBy;
|
|
182
|
-
}
|
|
183
|
-
if (Array.isArray(result.tracks)) {
|
|
184
|
-
response.tracks = result.tracks;
|
|
185
|
-
}
|
|
186
|
-
if (typeof result.isPlaylist === "boolean") {
|
|
187
|
-
response.isPlaylist = result.isPlaylist;
|
|
188
|
-
}
|
|
189
|
-
if (typeof result.success === "boolean") {
|
|
190
|
-
response.success = result.success;
|
|
191
|
-
}
|
|
192
|
-
if (result.error instanceof Error) {
|
|
193
|
-
response.error = result.error;
|
|
194
|
-
}
|
|
195
|
-
if (typeof result.handled === "boolean") {
|
|
196
|
-
response.handled = result.handled;
|
|
197
|
-
if (result.handled) break;
|
|
198
|
-
}
|
|
199
|
-
} catch (err) {
|
|
200
|
-
this.debug(`[Player] Extension ${extension.name} beforePlay error:`, err);
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
return { request, response };
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
private async runAfterPlayHooks(payload: ExtensionAfterPlayPayload): Promise<void> {
|
|
207
|
-
if (this.extensions.length === 0) return;
|
|
208
|
-
const safeTracks = payload.tracks ? [...payload.tracks] : undefined;
|
|
209
|
-
if (safeTracks) {
|
|
210
|
-
Object.freeze(safeTracks);
|
|
211
|
-
}
|
|
212
|
-
const immutablePayload = Object.freeze({ ...payload, tracks: safeTracks });
|
|
213
|
-
for (const extension of this.extensions) {
|
|
214
|
-
const hook = (extension as any).afterPlay;
|
|
215
|
-
if (typeof hook !== "function") continue;
|
|
216
|
-
try {
|
|
217
|
-
await Promise.resolve(hook.call(extension, this.extensionContext, immutablePayload));
|
|
218
|
-
} catch (err) {
|
|
219
|
-
this.debug(`[Player] Extension ${extension.name} afterPlay error:`, err);
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
private async extensionsProvideSearch(query: string, requestedBy: string): Promise<SearchResult | null> {
|
|
225
|
-
const request: ExtensionSearchRequest = { query, requestedBy };
|
|
226
|
-
for (const extension of this.extensions) {
|
|
227
|
-
const hook = (extension as any).provideSearch;
|
|
228
|
-
if (typeof hook !== "function") continue;
|
|
229
|
-
try {
|
|
230
|
-
const result = await Promise.resolve(hook.call(extension, this.extensionContext, request));
|
|
231
|
-
if (result && Array.isArray(result.tracks) && result.tracks.length > 0) {
|
|
232
|
-
this.debug(`[Player] Extension ${extension.name} handled search for query: ${query}`);
|
|
233
|
-
return result as SearchResult;
|
|
234
|
-
}
|
|
235
|
-
} catch (err) {
|
|
236
|
-
this.debug(`[Player] Extension ${extension.name} provideSearch error:`, err);
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
|
-
return null;
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
private async extensionsProvideStream(track: Track): Promise<StreamInfo | null> {
|
|
243
|
-
const request: ExtensionStreamRequest = { track };
|
|
244
|
-
for (const extension of this.extensions) {
|
|
245
|
-
const hook = (extension as any).provideStream;
|
|
246
|
-
if (typeof hook !== "function") continue;
|
|
247
|
-
try {
|
|
248
|
-
const result = await Promise.resolve(hook.call(extension, this.extensionContext, request));
|
|
249
|
-
if (result && (result as StreamInfo).stream) {
|
|
250
|
-
this.debug(`[Player] Extension ${extension.name} provided stream for track: ${track.title}`);
|
|
251
|
-
return result as StreamInfo;
|
|
252
|
-
}
|
|
253
|
-
} catch (err) {
|
|
254
|
-
this.debug(`[Player] Extension ${extension.name} provideStream error:`, err);
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
return null;
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
private async getStreamFromPlugin(track: Track): Promise<StreamInfo | null> {
|
|
261
|
-
let streamInfo: StreamInfo | null = null;
|
|
262
|
-
const plugin = this.pluginManager.get(track.source) || this.pluginManager.findPlugin(track.url);
|
|
263
|
-
|
|
264
|
-
if (!plugin) {
|
|
265
|
-
this.debug(`[Player] No plugin found for track: ${track.title}`);
|
|
266
|
-
return null;
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
this.debug(`[Player] Getting stream for track: ${track.title}`);
|
|
270
|
-
this.debug(`[Player] Using plugin: ${plugin.name}`);
|
|
271
|
-
this.debug(`[Track] Track Info:`, track);
|
|
272
|
-
const timeoutMs = this.options.extractorTimeout ?? 50000;
|
|
273
|
-
try {
|
|
274
|
-
streamInfo = await withTimeout(plugin.getStream(track), timeoutMs, "getStream timed out");
|
|
275
|
-
if (!(streamInfo as any)?.stream) {
|
|
276
|
-
throw new Error(`No stream returned from ${plugin.name}`);
|
|
277
|
-
}
|
|
278
|
-
} catch (streamError) {
|
|
279
|
-
this.debug(`[Player] getStream failed, trying getFallback:`, streamError);
|
|
280
|
-
const allplugs = this.pluginManager.getAll();
|
|
281
|
-
for (const p of allplugs) {
|
|
282
|
-
if (typeof (p as any).getFallback !== "function" && typeof (p as any).getStream !== "function") {
|
|
283
|
-
continue;
|
|
284
|
-
}
|
|
285
|
-
try {
|
|
286
|
-
streamInfo = await withTimeout((p as any).getStream(track), timeoutMs, `getStream timed out for plugin ${p.name}`);
|
|
287
|
-
if ((streamInfo as any)?.stream) {
|
|
288
|
-
this.debug(`[Player] getStream succeeded with plugin ${p.name} for track: ${track.title}`);
|
|
289
|
-
break;
|
|
290
|
-
}
|
|
291
|
-
streamInfo = await withTimeout((p as any).getFallback(track), timeoutMs, `getFallback timed out for plugin ${p.name}`);
|
|
292
|
-
if (!(streamInfo as any)?.stream) continue;
|
|
293
|
-
break;
|
|
294
|
-
} catch (fallbackError) {
|
|
295
|
-
this.debug(`[Player] getFallback failed with plugin ${p.name}:`, fallbackError);
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
if (!(streamInfo as any)?.stream) {
|
|
299
|
-
throw new Error(`All getFallback attempts failed for track: ${track.title}`);
|
|
300
|
-
}
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
return streamInfo as StreamInfo;
|
|
304
|
-
}
|
|
305
|
-
private async Audioresource(streamInfo: StreamInfo, track?: Track): Promise<AudioResource> {
|
|
306
|
-
function mapToStreamType(type: string | undefined): StreamType {
|
|
307
|
-
switch (type) {
|
|
308
|
-
case "webm/opus":
|
|
309
|
-
return StreamType.WebmOpus;
|
|
310
|
-
case "ogg/opus":
|
|
311
|
-
return StreamType.OggOpus;
|
|
312
|
-
case "arbitrary":
|
|
313
|
-
default:
|
|
314
|
-
return StreamType.Arbitrary;
|
|
315
|
-
}
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
const stream: Readable = (streamInfo as StreamInfo).stream;
|
|
319
|
-
const inputType = mapToStreamType((streamInfo as StreamInfo).type);
|
|
320
|
-
|
|
321
|
-
const resource = createAudioResource(stream, {
|
|
322
|
-
metadata: track ?? {
|
|
323
|
-
title: streamInfo.metadata?.title ?? "",
|
|
324
|
-
duration: streamInfo.metadata?.duration ?? 0,
|
|
325
|
-
source: streamInfo.metadata?.source ?? "",
|
|
326
|
-
requestedBy: streamInfo.metadata?.requestedBy ?? "",
|
|
327
|
-
thumbnail: streamInfo.metadata?.thumbnail ?? "",
|
|
328
|
-
url: streamInfo.metadata?.url ?? "",
|
|
329
|
-
id: streamInfo.metadata?.id ?? "",
|
|
330
|
-
},
|
|
331
|
-
inputType,
|
|
332
|
-
inlineVolume: true,
|
|
333
|
-
});
|
|
334
|
-
|
|
335
|
-
return resource;
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
/**
|
|
339
|
-
* Start playing a specific track immediately, replacing the current resource.
|
|
340
|
-
*/
|
|
341
|
-
private async startTrack(track: Track): Promise<boolean> {
|
|
342
|
-
try {
|
|
343
|
-
let streamInfo: StreamInfo | null = await this.extensionsProvideStream(track);
|
|
344
|
-
let plugin: SourcePlugin | undefined;
|
|
345
|
-
|
|
346
|
-
if (!streamInfo) {
|
|
347
|
-
streamInfo = await this.getStreamFromPlugin(track);
|
|
348
|
-
if (!streamInfo) {
|
|
349
|
-
throw new Error(`No stream available for track: ${track.title}`);
|
|
350
|
-
}
|
|
351
|
-
} else {
|
|
352
|
-
this.debug(`[Player] Using extension-provided stream for track: ${track.title}`);
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
// Kiểm tra nếu có stream thực sự để tạo AudioResource
|
|
356
|
-
if (streamInfo && (streamInfo as any).stream) {
|
|
357
|
-
this.currentResource = await this.Audioresource(streamInfo, track);
|
|
358
|
-
// Apply initial volume using the resource's VolumeTransformer
|
|
359
|
-
if (this.volumeInterval) {
|
|
360
|
-
clearInterval(this.volumeInterval);
|
|
361
|
-
this.volumeInterval = null;
|
|
362
|
-
}
|
|
363
|
-
this.currentResource.volume?.setVolume(this.volume / 100);
|
|
364
|
-
|
|
365
|
-
this.debug(`[Player] Playing resource for track: ${track.title}`);
|
|
366
|
-
this.audioPlayer.play(this.currentResource);
|
|
367
|
-
|
|
368
|
-
await entersState(this.audioPlayer, AudioPlayerStatus.Playing, 5_000);
|
|
369
|
-
return true;
|
|
370
|
-
} else if (streamInfo && !(streamInfo as any).stream) {
|
|
371
|
-
// Extension đang xử lý phát nhạc (như Lavalink) - chỉ đánh dấu đang phát
|
|
372
|
-
this.debug(`[Player] Extension is handling playback for track: ${track.title}`);
|
|
373
|
-
this.isPlaying = true;
|
|
374
|
-
this.isPaused = false;
|
|
375
|
-
this.emit("trackStart", track);
|
|
376
|
-
return true;
|
|
377
|
-
} else {
|
|
378
|
-
throw new Error(`No stream available for track: ${track.title}`);
|
|
379
|
-
}
|
|
380
|
-
} catch (error) {
|
|
381
|
-
this.debug(`[Player] startTrack error:`, error);
|
|
382
|
-
this.emit("playerError", error as Error, track);
|
|
383
|
-
return false;
|
|
384
|
-
}
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
private clearLeaveTimeout(): void {
|
|
388
|
-
if (this.leaveTimeout) {
|
|
389
|
-
clearTimeout(this.leaveTimeout);
|
|
390
|
-
this.leaveTimeout = null;
|
|
391
|
-
this.debug(`[Player] Cleared leave timeoutMs`);
|
|
392
|
-
}
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
private debug(message?: any, ...optionalParams: any[]): void {
|
|
396
|
-
if (this.listenerCount("debug") > 0) {
|
|
397
|
-
this.emit("debug", message, ...optionalParams);
|
|
398
|
-
}
|
|
399
|
-
}
|
|
400
106
|
|
|
401
107
|
constructor(guildId: string, options: PlayerOptions = {}, manager: PlayerManager) {
|
|
402
108
|
super();
|
|
@@ -411,8 +117,6 @@ export class Player extends EventEmitter {
|
|
|
411
117
|
},
|
|
412
118
|
});
|
|
413
119
|
|
|
414
|
-
this.pluginManager = new PluginManager();
|
|
415
|
-
|
|
416
120
|
this.options = {
|
|
417
121
|
leaveOnEnd: true,
|
|
418
122
|
leaveOnEmpty: true,
|
|
@@ -431,11 +135,24 @@ export class Player extends EventEmitter {
|
|
|
431
135
|
...(options?.tts || {}),
|
|
432
136
|
},
|
|
433
137
|
};
|
|
138
|
+
this.filter = new FilterManager(this, this.manager);
|
|
139
|
+
this.extensionManager = new ExtensionManager(this, this.manager);
|
|
140
|
+
this.pluginManager = new PluginManager(this, this.manager, {
|
|
141
|
+
extractorTimeout: this.options.extractorTimeout,
|
|
142
|
+
});
|
|
434
143
|
|
|
435
144
|
this.volume = this.options.volume || 100;
|
|
436
145
|
this.userdata = this.options.userdata;
|
|
437
146
|
this.setupEventListeners();
|
|
438
|
-
|
|
147
|
+
|
|
148
|
+
// Initialize filters from options
|
|
149
|
+
if (this.options.filters && this.options.filters.length > 0) {
|
|
150
|
+
this.debug(`[Player] Initializing ${this.options.filters.length} filters from options`);
|
|
151
|
+
// Use async version but don't await in constructor
|
|
152
|
+
this.filter.applyFilters(this.options.filters).catch((error: any) => {
|
|
153
|
+
this.debug(`[Player] Error initializing filters:`, error);
|
|
154
|
+
});
|
|
155
|
+
}
|
|
439
156
|
|
|
440
157
|
// Optionally pre-create the TTS AudioPlayer
|
|
441
158
|
if (this.options?.tts?.createPlayer) {
|
|
@@ -443,129 +160,7 @@ export class Player extends EventEmitter {
|
|
|
443
160
|
}
|
|
444
161
|
}
|
|
445
162
|
|
|
446
|
-
|
|
447
|
-
this.audioPlayer.on("stateChange", (oldState, newState) => {
|
|
448
|
-
this.debug(`[Player] AudioPlayer stateChange from ${oldState.status} to ${newState.status}`);
|
|
449
|
-
if (newState.status === AudioPlayerStatus.Idle && oldState.status !== AudioPlayerStatus.Idle) {
|
|
450
|
-
// Track ended
|
|
451
|
-
const track = this.queue.currentTrack;
|
|
452
|
-
if (track) {
|
|
453
|
-
this.debug(`[Player] Track ended: ${track.title}`);
|
|
454
|
-
this.emit("trackEnd", track);
|
|
455
|
-
}
|
|
456
|
-
this.playNext();
|
|
457
|
-
} else if (
|
|
458
|
-
newState.status === AudioPlayerStatus.Playing &&
|
|
459
|
-
(oldState.status === AudioPlayerStatus.Idle || oldState.status === AudioPlayerStatus.Buffering)
|
|
460
|
-
) {
|
|
461
|
-
// Track started
|
|
462
|
-
this.clearLeaveTimeout();
|
|
463
|
-
this.isPlaying = true;
|
|
464
|
-
this.isPaused = false;
|
|
465
|
-
const track = this.queue.currentTrack;
|
|
466
|
-
if (track) {
|
|
467
|
-
this.debug(`[Player] Track started: ${track.title}`);
|
|
468
|
-
this.emit("trackStart", track);
|
|
469
|
-
}
|
|
470
|
-
} else if (newState.status === AudioPlayerStatus.Paused && oldState.status !== AudioPlayerStatus.Paused) {
|
|
471
|
-
// Track paused
|
|
472
|
-
this.isPaused = true;
|
|
473
|
-
const track = this.queue.currentTrack;
|
|
474
|
-
if (track) {
|
|
475
|
-
this.debug(`[Player] Player paused on track: ${track.title}`);
|
|
476
|
-
this.emit("playerPause", track);
|
|
477
|
-
}
|
|
478
|
-
} else if (newState.status !== AudioPlayerStatus.Paused && oldState.status === AudioPlayerStatus.Paused) {
|
|
479
|
-
// Track resumed
|
|
480
|
-
this.isPaused = false;
|
|
481
|
-
const track = this.queue.currentTrack;
|
|
482
|
-
if (track) {
|
|
483
|
-
this.debug(`[Player] Player resumed on track: ${track.title}`);
|
|
484
|
-
this.emit("playerResume", track);
|
|
485
|
-
}
|
|
486
|
-
} else if (newState.status === AudioPlayerStatus.AutoPaused) {
|
|
487
|
-
this.debug(`[Player] AudioPlayerStatus.AutoPaused`);
|
|
488
|
-
} else if (newState.status === AudioPlayerStatus.Buffering) {
|
|
489
|
-
this.debug(`[Player] AudioPlayerStatus.Buffering`);
|
|
490
|
-
}
|
|
491
|
-
});
|
|
492
|
-
this.audioPlayer.on("error", (error) => {
|
|
493
|
-
this.debug(`[Player] AudioPlayer error:`, error);
|
|
494
|
-
this.emit("playerError", error, this.queue.currentTrack || undefined);
|
|
495
|
-
this.playNext();
|
|
496
|
-
});
|
|
497
|
-
|
|
498
|
-
this.audioPlayer.on("debug", (...args) => {
|
|
499
|
-
if (this.manager.debugEnabled) {
|
|
500
|
-
this.emit("debug", ...args);
|
|
501
|
-
}
|
|
502
|
-
});
|
|
503
|
-
}
|
|
504
|
-
|
|
505
|
-
private ensureTTSPlayer(): DiscordAudioPlayer {
|
|
506
|
-
if (this.ttsPlayer) return this.ttsPlayer;
|
|
507
|
-
this.ttsPlayer = createAudioPlayer({
|
|
508
|
-
behaviors: {
|
|
509
|
-
noSubscriber: NoSubscriberBehavior.Pause,
|
|
510
|
-
maxMissedFrames: 100,
|
|
511
|
-
},
|
|
512
|
-
});
|
|
513
|
-
this.ttsPlayer.on("error", (e) => this.debug("[TTS] error:", e));
|
|
514
|
-
return this.ttsPlayer;
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
addPlugin(plugin: SourcePlugin): void {
|
|
518
|
-
this.debug(`[Player] Adding plugin: ${plugin.name}`);
|
|
519
|
-
this.pluginManager.register(plugin);
|
|
520
|
-
}
|
|
521
|
-
|
|
522
|
-
removePlugin(name: string): boolean {
|
|
523
|
-
this.debug(`[Player] Removing plugin: ${name}`);
|
|
524
|
-
return this.pluginManager.unregister(name);
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
/**
|
|
528
|
-
* Connect to a voice channel
|
|
529
|
-
*
|
|
530
|
-
* @param {VoiceChannel} channel - Discord voice channel
|
|
531
|
-
* @returns {Promise<VoiceConnection>} The voice connection
|
|
532
|
-
* @example
|
|
533
|
-
* await player.connect(voiceChannel);
|
|
534
|
-
*/
|
|
535
|
-
async connect(channel: VoiceChannel): Promise<VoiceConnection> {
|
|
536
|
-
try {
|
|
537
|
-
this.debug(`[Player] Connecting to voice channel: ${channel.id}`);
|
|
538
|
-
const connection = joinVoiceChannel({
|
|
539
|
-
channelId: channel.id,
|
|
540
|
-
guildId: channel.guildId,
|
|
541
|
-
adapterCreator: channel.guild.voiceAdapterCreator as any,
|
|
542
|
-
selfDeaf: this.options.selfDeaf ?? true,
|
|
543
|
-
selfMute: this.options.selfMute ?? false,
|
|
544
|
-
});
|
|
545
|
-
|
|
546
|
-
await entersState(connection, VoiceConnectionStatus.Ready, 50_000);
|
|
547
|
-
this.connection = connection;
|
|
548
|
-
|
|
549
|
-
connection.on(VoiceConnectionStatus.Disconnected, () => {
|
|
550
|
-
this.debug(`[Player] VoiceConnectionStatus.Disconnected`);
|
|
551
|
-
this.destroy();
|
|
552
|
-
});
|
|
553
|
-
|
|
554
|
-
connection.on("error", (error) => {
|
|
555
|
-
this.debug(`[Player] Voice connection error:`, error);
|
|
556
|
-
this.emit("connectionError", error);
|
|
557
|
-
});
|
|
558
|
-
connection.subscribe(this.audioPlayer);
|
|
559
|
-
|
|
560
|
-
this.clearLeaveTimeout();
|
|
561
|
-
return this.connection;
|
|
562
|
-
} catch (error) {
|
|
563
|
-
this.debug(`[Player] Connection error:`, error);
|
|
564
|
-
this.emit("connectionError", error as Error);
|
|
565
|
-
this.connection?.destroy();
|
|
566
|
-
throw error;
|
|
567
|
-
}
|
|
568
|
-
}
|
|
163
|
+
//#region Search
|
|
569
164
|
|
|
570
165
|
/**
|
|
571
166
|
* Search for tracks using the player's extensions and plugins
|
|
@@ -592,60 +187,195 @@ export class Player extends EventEmitter {
|
|
|
592
187
|
return cachedResult;
|
|
593
188
|
}
|
|
594
189
|
|
|
595
|
-
// Try extensions first
|
|
596
|
-
const extensionResult = await this.
|
|
597
|
-
if (extensionResult && Array.isArray(extensionResult.tracks) && extensionResult.tracks.length > 0) {
|
|
598
|
-
this.debug(`[Player] Extension handled search for query: ${query}`);
|
|
599
|
-
this.cacheSearchResult(query, extensionResult);
|
|
600
|
-
return extensionResult;
|
|
601
|
-
}
|
|
190
|
+
// Try extensions first
|
|
191
|
+
const extensionResult = await this.extensionManager.provideSearch(query, requestedBy);
|
|
192
|
+
if (extensionResult && Array.isArray(extensionResult.tracks) && extensionResult.tracks.length > 0) {
|
|
193
|
+
this.debug(`[Player] Extension handled search for query: ${query}`);
|
|
194
|
+
this.cacheSearchResult(query, extensionResult);
|
|
195
|
+
return extensionResult;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Get plugins and filter out TTS for regular searches
|
|
199
|
+
const allPlugins = this.pluginManager.getAll();
|
|
200
|
+
const plugins = allPlugins.filter((p) => {
|
|
201
|
+
// Skip TTS plugin for regular searches (unless query starts with "tts:")
|
|
202
|
+
if (p.name.toLowerCase() === "tts" && !query.toLowerCase().startsWith("tts:")) {
|
|
203
|
+
this.debug(`[Player] Skipping TTS plugin for regular search: ${query}`);
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
return true;
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
this.debug(`[Player] Using ${plugins.length} plugins for search (filtered from ${allPlugins.length})`);
|
|
210
|
+
|
|
211
|
+
let lastError: any = null;
|
|
212
|
+
let searchAttempts = 0;
|
|
213
|
+
|
|
214
|
+
for (const p of plugins) {
|
|
215
|
+
searchAttempts++;
|
|
216
|
+
try {
|
|
217
|
+
this.debug(`[Player] Trying plugin for search: ${p.name} (attempt ${searchAttempts}/${plugins.length})`);
|
|
218
|
+
const startTime = Date.now();
|
|
219
|
+
const res = await withTimeout(
|
|
220
|
+
p.search(query, requestedBy),
|
|
221
|
+
this.options.extractorTimeout ?? 15000,
|
|
222
|
+
`Search operation timed out for ${p.name}`,
|
|
223
|
+
);
|
|
224
|
+
const duration = Date.now() - startTime;
|
|
225
|
+
|
|
226
|
+
if (res && Array.isArray(res.tracks) && res.tracks.length > 0) {
|
|
227
|
+
this.debug(`[Player] Plugin '${p.name}' returned ${res.tracks.length} tracks in ${duration}ms`);
|
|
228
|
+
this.cacheSearchResult(query, res);
|
|
229
|
+
return res;
|
|
230
|
+
}
|
|
231
|
+
this.debug(`[Player] Plugin '${p.name}' returned no tracks in ${duration}ms`);
|
|
232
|
+
} catch (error) {
|
|
233
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
234
|
+
this.debug(`[Player] Search via plugin '${p.name}' failed: ${errorMessage}`);
|
|
235
|
+
lastError = error;
|
|
236
|
+
// Continue to next plugin
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
this.debug(`[Player] No plugins returned results for query: ${query} (tried ${searchAttempts} plugins)`);
|
|
241
|
+
if (lastError) this.emit("playerError", lastError as Error);
|
|
242
|
+
throw new Error(`No plugin found to handle: ${query}`);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Get cached search result or null if not found/expired
|
|
247
|
+
* @param query The search query
|
|
248
|
+
* @returns Cached search result or null
|
|
249
|
+
*/
|
|
250
|
+
private getCachedSearchResult(query: string): SearchResult | null {
|
|
251
|
+
const cacheKey = query.toLowerCase().trim();
|
|
252
|
+
const now = Date.now();
|
|
253
|
+
|
|
254
|
+
const cachedTimestamp = this.searchCacheTimestamps.get(cacheKey);
|
|
255
|
+
if (cachedTimestamp && now - cachedTimestamp < this.SEARCH_CACHE_TTL) {
|
|
256
|
+
const cachedResult = this.searchCache.get(cacheKey);
|
|
257
|
+
if (cachedResult) {
|
|
258
|
+
this.debug(`[SearchCache] Using cached search result for: ${query}`);
|
|
259
|
+
return cachedResult;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Cache search result
|
|
268
|
+
* @param query The search query
|
|
269
|
+
* @param result The search result to cache
|
|
270
|
+
*/
|
|
271
|
+
private cacheSearchResult(query: string, result: SearchResult): void {
|
|
272
|
+
const cacheKey = query.toLowerCase().trim();
|
|
273
|
+
const now = Date.now();
|
|
274
|
+
|
|
275
|
+
this.searchCache.set(cacheKey, result);
|
|
276
|
+
this.searchCacheTimestamps.set(cacheKey, now);
|
|
277
|
+
this.debug(`[SearchCache] Cached search result for: ${query} (${result.tracks.length} tracks)`);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Clear expired search cache entries
|
|
282
|
+
*/
|
|
283
|
+
private clearExpiredSearchCache(): void {
|
|
284
|
+
const now = Date.now();
|
|
285
|
+
for (const [key, timestamp] of this.searchCacheTimestamps.entries()) {
|
|
286
|
+
if (now - timestamp >= this.SEARCH_CACHE_TTL) {
|
|
287
|
+
this.searchCache.delete(key);
|
|
288
|
+
this.searchCacheTimestamps.delete(key);
|
|
289
|
+
this.debug(`[SearchCache] Cleared expired cache entry: ${key}`);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Clear all search cache entries
|
|
296
|
+
* @example
|
|
297
|
+
* player.clearSearchCache();
|
|
298
|
+
*/
|
|
299
|
+
public clearSearchCache(): void {
|
|
300
|
+
const cacheSize = this.searchCache.size;
|
|
301
|
+
this.searchCache.clear();
|
|
302
|
+
this.searchCacheTimestamps.clear();
|
|
303
|
+
this.debug(`[SearchCache] Cleared all ${cacheSize} search cache entries`);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Debug method to check for duplicate search calls
|
|
308
|
+
* @param query The search query to check
|
|
309
|
+
* @returns Debug information about the query
|
|
310
|
+
*/
|
|
311
|
+
public debugSearchQuery(query: string): {
|
|
312
|
+
isCached: boolean;
|
|
313
|
+
cacheAge?: number;
|
|
314
|
+
pluginCount: number;
|
|
315
|
+
ttsFiltered: boolean;
|
|
316
|
+
} {
|
|
317
|
+
const cacheKey = query.toLowerCase().trim();
|
|
318
|
+
const now = Date.now();
|
|
319
|
+
const cachedTimestamp = this.searchCacheTimestamps.get(cacheKey);
|
|
320
|
+
const isCached = cachedTimestamp && now - cachedTimestamp < this.SEARCH_CACHE_TTL;
|
|
602
321
|
|
|
603
|
-
// Get plugins and filter out TTS for regular searches
|
|
604
322
|
const allPlugins = this.pluginManager.getAll();
|
|
605
323
|
const plugins = allPlugins.filter((p) => {
|
|
606
|
-
// Skip TTS plugin for regular searches (unless query starts with "tts:")
|
|
607
324
|
if (p.name.toLowerCase() === "tts" && !query.toLowerCase().startsWith("tts:")) {
|
|
608
|
-
this.debug(`[Player] Skipping TTS plugin for regular search: ${query}`);
|
|
609
325
|
return false;
|
|
610
326
|
}
|
|
611
327
|
return true;
|
|
612
328
|
});
|
|
613
329
|
|
|
614
|
-
|
|
330
|
+
return {
|
|
331
|
+
isCached: !!isCached,
|
|
332
|
+
cacheAge: cachedTimestamp ? now - cachedTimestamp : undefined,
|
|
333
|
+
pluginCount: plugins.length,
|
|
334
|
+
ttsFiltered: allPlugins.length > plugins.length,
|
|
335
|
+
};
|
|
336
|
+
}
|
|
615
337
|
|
|
616
|
-
|
|
617
|
-
|
|
338
|
+
private async generateWillNext(): Promise<void> {
|
|
339
|
+
const lastTrack = this.queue.previousTracks[this.queue.previousTracks.length - 1] ?? this.queue.currentTrack;
|
|
340
|
+
if (!lastTrack) return;
|
|
618
341
|
|
|
619
|
-
|
|
620
|
-
|
|
342
|
+
// Build list of candidate plugins: preferred first, then others with getRelatedTracks
|
|
343
|
+
const preferred = this.pluginManager.findPlugin(lastTrack.url) || this.pluginManager.get(lastTrack.source);
|
|
344
|
+
const all = this.pluginManager.getAll();
|
|
345
|
+
const candidates = [...(preferred ? [preferred] : []), ...all.filter((p) => p !== preferred)].filter(
|
|
346
|
+
(p) => typeof (p as any).getRelatedTracks === "function",
|
|
347
|
+
);
|
|
348
|
+
|
|
349
|
+
for (const p of candidates) {
|
|
621
350
|
try {
|
|
622
|
-
this.debug(`[Player] Trying
|
|
623
|
-
const
|
|
624
|
-
|
|
625
|
-
|
|
351
|
+
this.debug(`[Player] Trying related from plugin: ${p.name}`);
|
|
352
|
+
const related = await withTimeout(
|
|
353
|
+
(p as any).getRelatedTracks(lastTrack.url, {
|
|
354
|
+
limit: 10,
|
|
355
|
+
history: this.queue.previousTracks,
|
|
356
|
+
}),
|
|
626
357
|
this.options.extractorTimeout ?? 15000,
|
|
627
|
-
`
|
|
358
|
+
`getRelatedTracks timed out for ${p.name}`,
|
|
628
359
|
);
|
|
629
|
-
const duration = Date.now() - startTime;
|
|
630
360
|
|
|
631
|
-
if (
|
|
632
|
-
|
|
633
|
-
this.
|
|
634
|
-
|
|
361
|
+
if (Array.isArray(related) && related.length > 0) {
|
|
362
|
+
const randomchoice = Math.floor(Math.random() * related.length);
|
|
363
|
+
const nextTrack = this.queue.nextTrack ? this.queue.nextTrack : related[randomchoice];
|
|
364
|
+
this.queue.willNextTrack(nextTrack);
|
|
365
|
+
this.queue.relatedTracks(related);
|
|
366
|
+
this.debug(`[Player] Will next track if autoplay: ${nextTrack?.title} (via ${p.name})`);
|
|
367
|
+
this.emit("willPlay", nextTrack, related);
|
|
368
|
+
return; // success
|
|
635
369
|
}
|
|
636
|
-
this.debug(`[Player]
|
|
637
|
-
} catch (
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
lastError = error;
|
|
641
|
-
// Continue to next plugin
|
|
370
|
+
this.debug(`[Player] ${p.name} returned no related tracks`);
|
|
371
|
+
} catch (err) {
|
|
372
|
+
this.debug(`[Player] getRelatedTracks error from ${p.name}:`, err);
|
|
373
|
+
// try next candidate
|
|
642
374
|
}
|
|
643
375
|
}
|
|
644
|
-
|
|
645
|
-
this.debug(`[Player] No plugins returned results for query: ${query} (tried ${searchAttempts} plugins)`);
|
|
646
|
-
if (lastError) this.emit("playerError", lastError as Error);
|
|
647
|
-
throw new Error(`No plugin found to handle: ${query}`);
|
|
648
376
|
}
|
|
377
|
+
//#endregion
|
|
378
|
+
//#region Play
|
|
649
379
|
|
|
650
380
|
/**
|
|
651
381
|
* Play a track, search query, search result, or play from queue
|
|
@@ -663,10 +393,13 @@ export class Player extends EventEmitter {
|
|
|
663
393
|
*/
|
|
664
394
|
async play(query: string | Track | SearchResult | null, requestedBy?: string): Promise<boolean> {
|
|
665
395
|
const debugInfo =
|
|
666
|
-
query === null
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
396
|
+
query === null
|
|
397
|
+
? "null"
|
|
398
|
+
: typeof query === "string"
|
|
399
|
+
? query
|
|
400
|
+
: "tracks" in query
|
|
401
|
+
? `${query.tracks.length} tracks`
|
|
402
|
+
: query.title || "unknown";
|
|
670
403
|
this.debug(`[Player] Play called with query: ${debugInfo}`);
|
|
671
404
|
this.clearLeaveTimeout();
|
|
672
405
|
let tracksToAdd: Track[] = [];
|
|
@@ -700,7 +433,7 @@ export class Player extends EventEmitter {
|
|
|
700
433
|
}
|
|
701
434
|
} else {
|
|
702
435
|
// Handle other types (string, Track)
|
|
703
|
-
const hookOutcome = await this.
|
|
436
|
+
const hookOutcome = await this.extensionManager.BeforePlayHooks(effectiveRequest);
|
|
704
437
|
effectiveRequest = hookOutcome.request;
|
|
705
438
|
hookResponse = hookOutcome.response;
|
|
706
439
|
if (effectiveRequest.requestedBy === undefined) {
|
|
@@ -718,7 +451,7 @@ export class Player extends EventEmitter {
|
|
|
718
451
|
isPlaylist: hookResponse.isPlaylist ?? false,
|
|
719
452
|
error: hookResponse.error,
|
|
720
453
|
};
|
|
721
|
-
await this.
|
|
454
|
+
await this.extensionManager.AfterPlayHooks(handledPayload);
|
|
722
455
|
if (hookResponse.error) {
|
|
723
456
|
this.emit("playerError", hookResponse.error);
|
|
724
457
|
}
|
|
@@ -765,7 +498,7 @@ export class Player extends EventEmitter {
|
|
|
765
498
|
) {
|
|
766
499
|
this.debug(`[Player] Interrupting with TTS: ${tracksToAdd[0].title}`);
|
|
767
500
|
await this.interruptWithTTSTrack(tracksToAdd[0]);
|
|
768
|
-
await this.
|
|
501
|
+
await this.extensionManager.AfterPlayHooks({
|
|
769
502
|
success: true,
|
|
770
503
|
query: effectiveRequest.query,
|
|
771
504
|
requestedBy: effectiveRequest.requestedBy,
|
|
@@ -785,7 +518,7 @@ export class Player extends EventEmitter {
|
|
|
785
518
|
|
|
786
519
|
const started = !this.isPlaying ? await this.playNext() : true;
|
|
787
520
|
|
|
788
|
-
await this.
|
|
521
|
+
await this.extensionManager.AfterPlayHooks({
|
|
789
522
|
success: started,
|
|
790
523
|
query: effectiveRequest.query,
|
|
791
524
|
requestedBy: effectiveRequest.requestedBy,
|
|
@@ -795,7 +528,7 @@ export class Player extends EventEmitter {
|
|
|
795
528
|
|
|
796
529
|
return started;
|
|
797
530
|
} catch (error) {
|
|
798
|
-
await this.
|
|
531
|
+
await this.extensionManager.AfterPlayHooks({
|
|
799
532
|
success: false,
|
|
800
533
|
query: effectiveRequest.query,
|
|
801
534
|
requestedBy: effectiveRequest.requestedBy,
|
|
@@ -810,209 +543,130 @@ export class Player extends EventEmitter {
|
|
|
810
543
|
}
|
|
811
544
|
|
|
812
545
|
/**
|
|
813
|
-
*
|
|
814
|
-
* subscription to a dedicated TTS player, plays TTS, then resumes.
|
|
546
|
+
* Create AudioResource with filters and seek applied
|
|
815
547
|
*
|
|
816
|
-
* @param {
|
|
817
|
-
* @
|
|
818
|
-
* @
|
|
819
|
-
*
|
|
820
|
-
*/
|
|
821
|
-
public async interruptWithTTSTrack(track: Track): Promise<void> {
|
|
822
|
-
const wasPlaying =
|
|
823
|
-
this.audioPlayer.state.status === AudioPlayerStatus.Playing ||
|
|
824
|
-
this.audioPlayer.state.status === AudioPlayerStatus.Buffering;
|
|
825
|
-
|
|
826
|
-
try {
|
|
827
|
-
if (!this.connection) throw new Error("No voice connection for TTS");
|
|
828
|
-
const ttsPlayer = this.ensureTTSPlayer();
|
|
829
|
-
|
|
830
|
-
// Build resource from plugin stream
|
|
831
|
-
const streamInfo = await this.getStreamFromPlugin(track);
|
|
832
|
-
if (!streamInfo) {
|
|
833
|
-
throw new Error("No stream available for track: ${track.title}");
|
|
834
|
-
}
|
|
835
|
-
const resource = await this.Audioresource(streamInfo as StreamInfo, track);
|
|
836
|
-
if (!resource) {
|
|
837
|
-
throw new Error("No resource available for track: ${track.title}");
|
|
838
|
-
}
|
|
839
|
-
if (resource.volume) {
|
|
840
|
-
resource.volume.setVolume((this.options?.tts?.volume ?? this?.volume ?? 100) / 100);
|
|
841
|
-
}
|
|
842
|
-
|
|
843
|
-
// Pause current music if any
|
|
844
|
-
try {
|
|
845
|
-
this.pause();
|
|
846
|
-
} catch {}
|
|
847
|
-
|
|
848
|
-
// Swap subscription and play TTS
|
|
849
|
-
this.connection.subscribe(ttsPlayer);
|
|
850
|
-
this.emit("ttsStart", { track });
|
|
851
|
-
ttsPlayer.play(resource);
|
|
852
|
-
|
|
853
|
-
// Wait until TTS starts then finishes
|
|
854
|
-
await entersState(ttsPlayer, AudioPlayerStatus.Playing, 5_000).catch(() => null);
|
|
855
|
-
// Derive timeoutMs from resource/track duration when available, with a sensible cap
|
|
856
|
-
const md: any = (resource as any)?.metadata ?? {};
|
|
857
|
-
const declared =
|
|
858
|
-
typeof md.duration === "number" ? md.duration
|
|
859
|
-
: typeof track?.duration === "number" ? track.duration
|
|
860
|
-
: undefined;
|
|
861
|
-
const declaredMs =
|
|
862
|
-
declared ?
|
|
863
|
-
declared > 1000 ?
|
|
864
|
-
declared
|
|
865
|
-
: declared * 1000
|
|
866
|
-
: undefined;
|
|
867
|
-
const cap = this.options?.tts?.Max_Time_TTS ?? 60_000;
|
|
868
|
-
const idleTimeout = declaredMs ? Math.min(cap, Math.max(1_000, declaredMs + 1_500)) : cap;
|
|
869
|
-
await entersState(ttsPlayer, AudioPlayerStatus.Idle, idleTimeout).catch(() => null);
|
|
870
|
-
|
|
871
|
-
// Swap back and resume if needed
|
|
872
|
-
this.connection.subscribe(this.audioPlayer);
|
|
873
|
-
} catch (err) {
|
|
874
|
-
this.debug("[TTS] error while playing:", err);
|
|
875
|
-
this.emit("playerError", err as Error);
|
|
876
|
-
} finally {
|
|
877
|
-
if (wasPlaying) {
|
|
878
|
-
try {
|
|
879
|
-
this.resume();
|
|
880
|
-
} catch {}
|
|
881
|
-
}
|
|
882
|
-
this.emit("ttsEnd");
|
|
883
|
-
}
|
|
884
|
-
}
|
|
885
|
-
|
|
886
|
-
/**
|
|
887
|
-
* Get cached search result or null if not found/expired
|
|
888
|
-
* @param query The search query
|
|
889
|
-
* @returns Cached search result or null
|
|
890
|
-
*/
|
|
891
|
-
private getCachedSearchResult(query: string): SearchResult | null {
|
|
892
|
-
const cacheKey = query.toLowerCase().trim();
|
|
893
|
-
const now = Date.now();
|
|
894
|
-
|
|
895
|
-
const cachedTimestamp = this.searchCacheTimestamps.get(cacheKey);
|
|
896
|
-
if (cachedTimestamp && now - cachedTimestamp < this.SEARCH_CACHE_TTL) {
|
|
897
|
-
const cachedResult = this.searchCache.get(cacheKey);
|
|
898
|
-
if (cachedResult) {
|
|
899
|
-
this.debug(`[SearchCache] Using cached search result for: ${query}`);
|
|
900
|
-
return cachedResult;
|
|
901
|
-
}
|
|
902
|
-
}
|
|
903
|
-
|
|
904
|
-
return null;
|
|
905
|
-
}
|
|
906
|
-
|
|
907
|
-
/**
|
|
908
|
-
* Cache search result
|
|
909
|
-
* @param query The search query
|
|
910
|
-
* @param result The search result to cache
|
|
911
|
-
*/
|
|
912
|
-
private cacheSearchResult(query: string, result: SearchResult): void {
|
|
913
|
-
const cacheKey = query.toLowerCase().trim();
|
|
914
|
-
const now = Date.now();
|
|
915
|
-
|
|
916
|
-
this.searchCache.set(cacheKey, result);
|
|
917
|
-
this.searchCacheTimestamps.set(cacheKey, now);
|
|
918
|
-
this.debug(`[SearchCache] Cached search result for: ${query} (${result.tracks.length} tracks)`);
|
|
919
|
-
}
|
|
920
|
-
|
|
921
|
-
/**
|
|
922
|
-
* Clear expired search cache entries
|
|
923
|
-
*/
|
|
924
|
-
private clearExpiredSearchCache(): void {
|
|
925
|
-
const now = Date.now();
|
|
926
|
-
for (const [key, timestamp] of this.searchCacheTimestamps.entries()) {
|
|
927
|
-
if (now - timestamp >= this.SEARCH_CACHE_TTL) {
|
|
928
|
-
this.searchCache.delete(key);
|
|
929
|
-
this.searchCacheTimestamps.delete(key);
|
|
930
|
-
this.debug(`[SearchCache] Cleared expired cache entry: ${key}`);
|
|
931
|
-
}
|
|
932
|
-
}
|
|
933
|
-
}
|
|
934
|
-
|
|
935
|
-
/**
|
|
936
|
-
* Clear all search cache entries
|
|
937
|
-
* @example
|
|
938
|
-
* player.clearSearchCache();
|
|
548
|
+
* @param {StreamInfo} streamInfo - The stream information
|
|
549
|
+
* @param {Track} track - The track being processed
|
|
550
|
+
* @param {number} position - Position in milliseconds to seek to (0 = no seek)
|
|
551
|
+
* @returns {Promise<AudioResource>} The AudioResource with filters and seek applied
|
|
939
552
|
*/
|
|
940
|
-
|
|
941
|
-
const
|
|
942
|
-
this.searchCache.clear();
|
|
943
|
-
this.searchCacheTimestamps.clear();
|
|
944
|
-
this.debug(`[SearchCache] Cleared all ${cacheSize} search cache entries`);
|
|
945
|
-
}
|
|
553
|
+
private async createResource(streamInfo: StreamInfo, track: Track, position: number = 0): Promise<AudioResource> {
|
|
554
|
+
const filterString = this.filter.getFilterString();
|
|
946
555
|
|
|
947
|
-
|
|
948
|
-
* Debug method to check for duplicate search calls
|
|
949
|
-
* @param query The search query to check
|
|
950
|
-
* @returns Debug information about the query
|
|
951
|
-
*/
|
|
952
|
-
public debugSearchQuery(query: string): {
|
|
953
|
-
isCached: boolean;
|
|
954
|
-
cacheAge?: number;
|
|
955
|
-
pluginCount: number;
|
|
956
|
-
ttsFiltered: boolean;
|
|
957
|
-
} {
|
|
958
|
-
const cacheKey = query.toLowerCase().trim();
|
|
959
|
-
const now = Date.now();
|
|
960
|
-
const cachedTimestamp = this.searchCacheTimestamps.get(cacheKey);
|
|
961
|
-
const isCached = cachedTimestamp && now - cachedTimestamp < this.SEARCH_CACHE_TTL;
|
|
556
|
+
this.debug(`[Player] Creating AudioResource with filters: ${filterString || "none"}, seek: ${position}ms`);
|
|
962
557
|
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
558
|
+
try {
|
|
559
|
+
let stream: Readable = streamInfo.stream;
|
|
560
|
+
// Apply filters and seek if needed
|
|
561
|
+
if (filterString || position > 0) {
|
|
562
|
+
stream = await this.filter.applyFiltersAndSeek(streamInfo.stream, position);
|
|
563
|
+
streamInfo.type = StreamType.Arbitrary;
|
|
967
564
|
}
|
|
968
|
-
return true;
|
|
969
|
-
});
|
|
970
|
-
|
|
971
|
-
return {
|
|
972
|
-
isCached: !!isCached,
|
|
973
|
-
cacheAge: cachedTimestamp ? now - cachedTimestamp : undefined,
|
|
974
|
-
pluginCount: plugins.length,
|
|
975
|
-
ttsFiltered: allPlugins.length > plugins.length,
|
|
976
|
-
};
|
|
977
|
-
}
|
|
978
|
-
|
|
979
|
-
private async generateWillNext(): Promise<void> {
|
|
980
|
-
const lastTrack = this.queue.previousTracks[this.queue.previousTracks.length - 1] ?? this.queue.currentTrack;
|
|
981
|
-
if (!lastTrack) return;
|
|
982
565
|
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
566
|
+
// Create AudioResource with better error handling
|
|
567
|
+
const resource = createAudioResource(stream, {
|
|
568
|
+
metadata: track,
|
|
569
|
+
inputType:
|
|
570
|
+
streamInfo.type === "webm/opus"
|
|
571
|
+
? StreamType.WebmOpus
|
|
572
|
+
: streamInfo.type === "ogg/opus"
|
|
573
|
+
? StreamType.OggOpus
|
|
574
|
+
: StreamType.Arbitrary,
|
|
575
|
+
inlineVolume: true,
|
|
576
|
+
});
|
|
989
577
|
|
|
990
|
-
|
|
578
|
+
return resource;
|
|
579
|
+
} catch (error) {
|
|
580
|
+
this.debug(`[Player] Error creating AudioResource with filters+seek:`, error);
|
|
581
|
+
// Fallback to basic AudioResource
|
|
991
582
|
try {
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
583
|
+
const resource = createAudioResource(streamInfo.stream, {
|
|
584
|
+
metadata: track,
|
|
585
|
+
inputType:
|
|
586
|
+
streamInfo.type === "webm/opus"
|
|
587
|
+
? StreamType.WebmOpus
|
|
588
|
+
: streamInfo.type === "ogg/opus"
|
|
589
|
+
? StreamType.OggOpus
|
|
590
|
+
: StreamType.Arbitrary,
|
|
591
|
+
inlineVolume: true,
|
|
592
|
+
});
|
|
593
|
+
return resource;
|
|
594
|
+
} catch (fallbackError) {
|
|
595
|
+
this.debug(`[Player] Fallback AudioResource creation failed:`, fallbackError);
|
|
596
|
+
throw fallbackError;
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
private async getStream(track: Track): Promise<StreamInfo | null> {
|
|
601
|
+
let stream = await this.extensionManager.provideStream(track);
|
|
602
|
+
if (stream?.stream) return stream;
|
|
603
|
+
stream = await this.pluginManager.getStream(track);
|
|
604
|
+
if (stream?.stream) return stream;
|
|
605
|
+
throw new Error(`No stream available for track: ${track.title}`);
|
|
606
|
+
}
|
|
1001
607
|
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
608
|
+
/**
|
|
609
|
+
* Start playing a specific track immediately, replacing the current resource.
|
|
610
|
+
*/
|
|
611
|
+
private async startTrack(track: Track): Promise<boolean> {
|
|
612
|
+
try {
|
|
613
|
+
let streamInfo: StreamInfo | null = await this.getStream(track);
|
|
614
|
+
this.debug(`[Player] Using stream for track: ${track.title}`);
|
|
615
|
+
// Kiểm tra nếu có stream thực sự để tạo AudioResource
|
|
616
|
+
if (streamInfo && (streamInfo as any).stream) {
|
|
617
|
+
try {
|
|
618
|
+
this.currentResource = await this.createResource(streamInfo, track, 0);
|
|
619
|
+
if (this.volumeInterval) {
|
|
620
|
+
clearInterval(this.volumeInterval);
|
|
621
|
+
this.volumeInterval = null;
|
|
622
|
+
}
|
|
623
|
+
this.currentResource.volume?.setVolume(this.volume / 100);
|
|
624
|
+
|
|
625
|
+
this.debug(`[Player] Playing resource for track: ${track.title}`);
|
|
626
|
+
this.audioPlayer.play(this.currentResource);
|
|
627
|
+
|
|
628
|
+
await entersState(this.audioPlayer, AudioPlayerStatus.Playing, 5_000);
|
|
629
|
+
return true;
|
|
630
|
+
} catch (resourceError) {
|
|
631
|
+
this.debug(`[Player] Error creating/playing resource:`, resourceError);
|
|
632
|
+
// Try fallback without filters
|
|
633
|
+
try {
|
|
634
|
+
this.debug(`[Player] Attempting fallback without filters`);
|
|
635
|
+
const fallbackResource = createAudioResource(streamInfo.stream, {
|
|
636
|
+
metadata: track,
|
|
637
|
+
inputType:
|
|
638
|
+
streamInfo.type === "webm/opus"
|
|
639
|
+
? StreamType.WebmOpus
|
|
640
|
+
: streamInfo.type === "ogg/opus"
|
|
641
|
+
? StreamType.OggOpus
|
|
642
|
+
: StreamType.Arbitrary,
|
|
643
|
+
inlineVolume: true,
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
this.currentResource = fallbackResource;
|
|
647
|
+
this.currentResource.volume?.setVolume(this.volume / 100);
|
|
648
|
+
this.audioPlayer.play(this.currentResource);
|
|
649
|
+
await entersState(this.audioPlayer, AudioPlayerStatus.Playing, 5_000);
|
|
650
|
+
return true;
|
|
651
|
+
} catch (fallbackError) {
|
|
652
|
+
this.debug(`[Player] Fallback also failed:`, fallbackError);
|
|
653
|
+
throw fallbackError;
|
|
654
|
+
}
|
|
1010
655
|
}
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
this.debug(`[Player]
|
|
1014
|
-
|
|
656
|
+
} else if (streamInfo && !(streamInfo as any).stream) {
|
|
657
|
+
// Extension đang xử lý phát nhạc (như Lavalink) - chỉ đánh dấu đang phát
|
|
658
|
+
this.debug(`[Player] Extension is handling playback for track: ${track.title}`);
|
|
659
|
+
this.isPlaying = true;
|
|
660
|
+
this.isPaused = false;
|
|
661
|
+
this.emit("trackStart", track);
|
|
662
|
+
return true;
|
|
663
|
+
} else {
|
|
664
|
+
throw new Error(`No stream available for track: ${track.title}`);
|
|
1015
665
|
}
|
|
666
|
+
} catch (error) {
|
|
667
|
+
this.debug(`[Player] startTrack error:`, error);
|
|
668
|
+
this.emit("playerError", error as Error, track);
|
|
669
|
+
return false;
|
|
1016
670
|
}
|
|
1017
671
|
}
|
|
1018
672
|
|
|
@@ -1053,6 +707,133 @@ export class Player extends EventEmitter {
|
|
|
1053
707
|
}
|
|
1054
708
|
}
|
|
1055
709
|
|
|
710
|
+
//#endregion
|
|
711
|
+
//#region TTS
|
|
712
|
+
|
|
713
|
+
private ensureTTSPlayer(): DiscordAudioPlayer {
|
|
714
|
+
if (this.ttsPlayer) return this.ttsPlayer;
|
|
715
|
+
this.ttsPlayer = createAudioPlayer({
|
|
716
|
+
behaviors: {
|
|
717
|
+
noSubscriber: NoSubscriberBehavior.Pause,
|
|
718
|
+
maxMissedFrames: 100,
|
|
719
|
+
},
|
|
720
|
+
});
|
|
721
|
+
this.ttsPlayer.on("error", (e) => this.debug("[TTS] error:", e));
|
|
722
|
+
return this.ttsPlayer;
|
|
723
|
+
}
|
|
724
|
+
/**
|
|
725
|
+
* Interrupt current music with a TTS track. Pauses music, swaps the
|
|
726
|
+
* subscription to a dedicated TTS player, plays TTS, then resumes.
|
|
727
|
+
*
|
|
728
|
+
* @param {Track} track - The track to interrupt with
|
|
729
|
+
* @returns {Promise<void>}
|
|
730
|
+
* @example
|
|
731
|
+
* await player.interruptWithTTSTrack(track);
|
|
732
|
+
*/
|
|
733
|
+
public async interruptWithTTSTrack(track: Track): Promise<void> {
|
|
734
|
+
const wasPlaying =
|
|
735
|
+
this.audioPlayer.state.status === AudioPlayerStatus.Playing ||
|
|
736
|
+
this.audioPlayer.state.status === AudioPlayerStatus.Buffering;
|
|
737
|
+
|
|
738
|
+
try {
|
|
739
|
+
if (!this.connection) throw new Error("No voice connection for TTS");
|
|
740
|
+
const ttsPlayer = this.ensureTTSPlayer();
|
|
741
|
+
|
|
742
|
+
// Build resource from plugin stream
|
|
743
|
+
const streamInfo = await this.pluginManager.getStream(track);
|
|
744
|
+
if (!streamInfo) {
|
|
745
|
+
throw new Error("No stream available for track: ${track.title}");
|
|
746
|
+
}
|
|
747
|
+
const resource = await this.createResource(streamInfo as StreamInfo, track);
|
|
748
|
+
if (!resource) {
|
|
749
|
+
throw new Error("No resource available for track: ${track.title}");
|
|
750
|
+
}
|
|
751
|
+
if (resource.volume) {
|
|
752
|
+
resource.volume.setVolume((this.options?.tts?.volume ?? this?.volume ?? 100) / 100);
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
// Pause current music if any
|
|
756
|
+
try {
|
|
757
|
+
this.pause();
|
|
758
|
+
} catch {}
|
|
759
|
+
|
|
760
|
+
// Swap subscription and play TTS
|
|
761
|
+
this.connection.subscribe(ttsPlayer);
|
|
762
|
+
this.emit("ttsStart", { track });
|
|
763
|
+
ttsPlayer.play(resource);
|
|
764
|
+
|
|
765
|
+
// Wait until TTS starts then finishes
|
|
766
|
+
await entersState(ttsPlayer, AudioPlayerStatus.Playing, 5_000).catch(() => null);
|
|
767
|
+
// Derive timeoutMs from resource/track duration when available, with a sensible cap
|
|
768
|
+
const md: any = (resource as any)?.metadata ?? {};
|
|
769
|
+
const declared =
|
|
770
|
+
typeof md.duration === "number" ? md.duration : typeof track?.duration === "number" ? track.duration : undefined;
|
|
771
|
+
const declaredMs = declared ? (declared > 1000 ? declared : declared * 1000) : undefined;
|
|
772
|
+
const cap = this.options?.tts?.Max_Time_TTS ?? 60_000;
|
|
773
|
+
const idleTimeout = declaredMs ? Math.min(cap, Math.max(1_000, declaredMs + 1_500)) : cap;
|
|
774
|
+
await entersState(ttsPlayer, AudioPlayerStatus.Idle, idleTimeout).catch(() => null);
|
|
775
|
+
|
|
776
|
+
// Swap back and resume if needed
|
|
777
|
+
this.connection.subscribe(this.audioPlayer);
|
|
778
|
+
} catch (err) {
|
|
779
|
+
this.debug("[TTS] error while playing:", err);
|
|
780
|
+
this.emit("playerError", err as Error);
|
|
781
|
+
} finally {
|
|
782
|
+
if (wasPlaying) {
|
|
783
|
+
try {
|
|
784
|
+
this.resume();
|
|
785
|
+
} catch {}
|
|
786
|
+
}
|
|
787
|
+
this.emit("ttsEnd");
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
//#endregion
|
|
792
|
+
//#region Player Function
|
|
793
|
+
|
|
794
|
+
/**
|
|
795
|
+
* Connect to a voice channel
|
|
796
|
+
*
|
|
797
|
+
* @param {VoiceChannel} channel - Discord voice channel
|
|
798
|
+
* @returns {Promise<VoiceConnection>} The voice connection
|
|
799
|
+
* @example
|
|
800
|
+
* await player.connect(voiceChannel);
|
|
801
|
+
*/
|
|
802
|
+
async connect(channel: VoiceChannel): Promise<VoiceConnection> {
|
|
803
|
+
try {
|
|
804
|
+
this.debug(`[Player] Connecting to voice channel: ${channel.id}`);
|
|
805
|
+
const connection = joinVoiceChannel({
|
|
806
|
+
channelId: channel.id,
|
|
807
|
+
guildId: channel.guildId,
|
|
808
|
+
adapterCreator: channel.guild.voiceAdapterCreator as any,
|
|
809
|
+
selfDeaf: this.options.selfDeaf ?? true,
|
|
810
|
+
selfMute: this.options.selfMute ?? false,
|
|
811
|
+
});
|
|
812
|
+
|
|
813
|
+
await entersState(connection, VoiceConnectionStatus.Ready, 50_000);
|
|
814
|
+
this.connection = connection;
|
|
815
|
+
|
|
816
|
+
connection.on(VoiceConnectionStatus.Disconnected, () => {
|
|
817
|
+
this.debug(`[Player] VoiceConnectionStatus.Disconnected`);
|
|
818
|
+
this.destroy();
|
|
819
|
+
});
|
|
820
|
+
|
|
821
|
+
connection.on("error", (error) => {
|
|
822
|
+
this.debug(`[Player] Voice connection error:`, error);
|
|
823
|
+
this.emit("connectionError", error);
|
|
824
|
+
});
|
|
825
|
+
connection.subscribe(this.audioPlayer);
|
|
826
|
+
|
|
827
|
+
this.clearLeaveTimeout();
|
|
828
|
+
return this.connection;
|
|
829
|
+
} catch (error) {
|
|
830
|
+
this.debug(`[Player] Connection error:`, error);
|
|
831
|
+
this.emit("connectionError", error as Error);
|
|
832
|
+
this.connection?.destroy();
|
|
833
|
+
throw error;
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
|
|
1056
837
|
/**
|
|
1057
838
|
* Pause the current track
|
|
1058
839
|
*
|
|
@@ -1111,6 +892,45 @@ export class Player extends EventEmitter {
|
|
|
1111
892
|
return result;
|
|
1112
893
|
}
|
|
1113
894
|
|
|
895
|
+
/**
|
|
896
|
+
* Seek to a specific position in the current track
|
|
897
|
+
*
|
|
898
|
+
* @param {number} position - Position in milliseconds to seek to
|
|
899
|
+
* @returns {Promise<boolean>} True if seek was successful
|
|
900
|
+
* @example
|
|
901
|
+
* // Seek to 30 seconds (30000ms)
|
|
902
|
+
* const success = await player.seek(30000);
|
|
903
|
+
* console.log(`Seek successful: ${success}`);
|
|
904
|
+
*
|
|
905
|
+
* // Seek to 1 minute 30 seconds (90000ms)
|
|
906
|
+
* await player.seek(90000);
|
|
907
|
+
*/
|
|
908
|
+
async seek(position: number): Promise<boolean> {
|
|
909
|
+
this.debug(`[Player] seek called with position: ${position}ms`);
|
|
910
|
+
|
|
911
|
+
const track = this.queue.currentTrack;
|
|
912
|
+
if (!track) {
|
|
913
|
+
this.debug(`[Player] No current track to seek`);
|
|
914
|
+
return false;
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
const totalDuration = track.duration > 1000 ? track.duration : track.duration * 1000;
|
|
918
|
+
if (position < 0 || position > totalDuration) {
|
|
919
|
+
this.debug(`[Player] Invalid seek position: ${position}ms (track duration: ${totalDuration}ms)`);
|
|
920
|
+
return false;
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
const streaminfo = await this.getStream(track);
|
|
924
|
+
if (!streaminfo?.stream) {
|
|
925
|
+
this.debug(`[Player] No stream to seek`);
|
|
926
|
+
return false;
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
await this.refeshPlayerResource(true, position);
|
|
930
|
+
|
|
931
|
+
return true;
|
|
932
|
+
}
|
|
933
|
+
|
|
1114
934
|
/**
|
|
1115
935
|
* Skip to the next track or skip to a specific index
|
|
1116
936
|
*
|
|
@@ -1174,6 +994,78 @@ export class Player extends EventEmitter {
|
|
|
1174
994
|
return this.startTrack(track);
|
|
1175
995
|
}
|
|
1176
996
|
|
|
997
|
+
/**
|
|
998
|
+
* Save a track's stream to a file and return a Readable stream
|
|
999
|
+
*
|
|
1000
|
+
* @param {Track} track - The track to save
|
|
1001
|
+
* @param {SaveOptions | string} options - Save options or filename string (for backward compatibility)
|
|
1002
|
+
* @returns {Promise<Readable>} A Readable stream containing the audio data
|
|
1003
|
+
* @example
|
|
1004
|
+
* // Save current track to file
|
|
1005
|
+
* const track = player.currentTrack;
|
|
1006
|
+
* if (track) {
|
|
1007
|
+
* const stream = await player.save(track);
|
|
1008
|
+
*
|
|
1009
|
+
* // Use fs to write the stream to file
|
|
1010
|
+
* const fs = require('fs');
|
|
1011
|
+
* const writeStream = fs.createWriteStream('saved-song.mp3');
|
|
1012
|
+
* stream.pipe(writeStream);
|
|
1013
|
+
*
|
|
1014
|
+
* writeStream.on('finish', () => {
|
|
1015
|
+
* console.log('File saved successfully!');
|
|
1016
|
+
* });
|
|
1017
|
+
* }
|
|
1018
|
+
*
|
|
1019
|
+
* // Save any track by URL
|
|
1020
|
+
* const searchResult = await player.search("Never Gonna Give You Up", userId);
|
|
1021
|
+
* if (searchResult.tracks.length > 0) {
|
|
1022
|
+
* const stream = await player.save(searchResult.tracks[0]);
|
|
1023
|
+
* // Handle the stream...
|
|
1024
|
+
* }
|
|
1025
|
+
*
|
|
1026
|
+
* // Backward compatibility - filename as string
|
|
1027
|
+
* const stream = await player.save(track, "my-song.mp3");
|
|
1028
|
+
*/
|
|
1029
|
+
async save(track: Track, options?: SaveOptions | string): Promise<Readable> {
|
|
1030
|
+
this.debug(`[Player] save called for track: ${track.title}`);
|
|
1031
|
+
|
|
1032
|
+
// Parse options - support both SaveOptions object and filename string (backward compatibility)
|
|
1033
|
+
let saveOptions: SaveOptions = {};
|
|
1034
|
+
if (typeof options === "string") {
|
|
1035
|
+
saveOptions = { filename: options };
|
|
1036
|
+
} else if (options) {
|
|
1037
|
+
saveOptions = options;
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
try {
|
|
1041
|
+
// Try extensions first
|
|
1042
|
+
let streamInfo: StreamInfo | null = await this.pluginManager.getStream(track);
|
|
1043
|
+
|
|
1044
|
+
if (!streamInfo || !streamInfo.stream) {
|
|
1045
|
+
throw new Error(`No save stream available for track: ${track.title}`);
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
this.debug(`[Player] Save stream obtained for track: ${track.title}`);
|
|
1049
|
+
if (saveOptions.filename) {
|
|
1050
|
+
this.debug(`[Player] Save options - filename: ${saveOptions.filename}, quality: ${saveOptions.quality || "default"}`);
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
// Apply filters if any are active
|
|
1054
|
+
let finalStream = streamInfo.stream;
|
|
1055
|
+
if (this.filter.getActiveFilters().length > 0) {
|
|
1056
|
+
this.debug(`[Player] Applying filters to save stream: ${this.filter.getFilterString() || "none"}`);
|
|
1057
|
+
finalStream = await this.filter.applyFiltersAndSeek(streamInfo.stream);
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
// Return the stream directly - caller can pipe it to fs.createWriteStream()
|
|
1061
|
+
return finalStream;
|
|
1062
|
+
} catch (error) {
|
|
1063
|
+
this.debug(`[Player] save error:`, error);
|
|
1064
|
+
this.emit("playerError", error as Error, track);
|
|
1065
|
+
throw error;
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1177
1069
|
/**
|
|
1178
1070
|
* Loop the current track or queue
|
|
1179
1071
|
*
|
|
@@ -1432,20 +1324,6 @@ export class Player extends EventEmitter {
|
|
|
1432
1324
|
return parts.join(":");
|
|
1433
1325
|
}
|
|
1434
1326
|
|
|
1435
|
-
private scheduleLeave(): void {
|
|
1436
|
-
this.debug(`[Player] scheduleLeave called`);
|
|
1437
|
-
if (this.leaveTimeout) {
|
|
1438
|
-
clearTimeout(this.leaveTimeout);
|
|
1439
|
-
}
|
|
1440
|
-
|
|
1441
|
-
if (this.options.leaveOnEmpty && this.options.leaveTimeout) {
|
|
1442
|
-
this.leaveTimeout = setTimeout(() => {
|
|
1443
|
-
this.debug(`[Player] Leaving voice channel after timeoutMs`);
|
|
1444
|
-
this.destroy();
|
|
1445
|
-
}, this.options.leaveTimeout);
|
|
1446
|
-
}
|
|
1447
|
-
}
|
|
1448
|
-
|
|
1449
1327
|
/**
|
|
1450
1328
|
* Destroy the player
|
|
1451
1329
|
*
|
|
@@ -1476,19 +1354,213 @@ export class Player extends EventEmitter {
|
|
|
1476
1354
|
|
|
1477
1355
|
this.queue.clear();
|
|
1478
1356
|
this.pluginManager.clear();
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
if (extension.player === this) {
|
|
1482
|
-
extension.player = null;
|
|
1483
|
-
}
|
|
1484
|
-
}
|
|
1485
|
-
this.extensions = [];
|
|
1357
|
+
this.filter.destroy();
|
|
1358
|
+
this.extensionManager.destroy();
|
|
1486
1359
|
this.isPlaying = false;
|
|
1487
1360
|
this.isPaused = false;
|
|
1488
1361
|
this.emit("playerDestroy");
|
|
1489
1362
|
this.removeAllListeners();
|
|
1490
1363
|
}
|
|
1491
1364
|
|
|
1365
|
+
//#endregion
|
|
1366
|
+
//#region utils
|
|
1367
|
+
private scheduleLeave(): void {
|
|
1368
|
+
this.debug(`[Player] scheduleLeave called`);
|
|
1369
|
+
if (this.leaveTimeout) {
|
|
1370
|
+
clearTimeout(this.leaveTimeout);
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
if (this.options.leaveOnEmpty && this.options.leaveTimeout) {
|
|
1374
|
+
this.leaveTimeout = setTimeout(() => {
|
|
1375
|
+
this.debug(`[Player] Leaving voice channel after timeoutMs`);
|
|
1376
|
+
this.destroy();
|
|
1377
|
+
}, this.options.leaveTimeout);
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
/**
|
|
1382
|
+
* Refesh player resource (apply filter)
|
|
1383
|
+
*
|
|
1384
|
+
* @param {boolean} applyToCurrent - Apply filter for curent track
|
|
1385
|
+
* @param {number} position - Position to seek to in milliseconds
|
|
1386
|
+
* @returns {Promise<boolean>}
|
|
1387
|
+
* @example
|
|
1388
|
+
* const refreshed = await player.refeshPlayerResource(true, 1000);
|
|
1389
|
+
* console.log(`Refreshed: ${refreshed}`);
|
|
1390
|
+
*/
|
|
1391
|
+
public async refeshPlayerResource(applyToCurrent: boolean = true, position: number = -1): Promise<boolean> {
|
|
1392
|
+
if (!applyToCurrent || !this.queue.currentTrack || !(this.isPlaying || this.isPaused)) {
|
|
1393
|
+
return false;
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
try {
|
|
1397
|
+
const track = this.queue.currentTrack;
|
|
1398
|
+
this.debug(`[Player] Refreshing player resource for track: ${track.title}`);
|
|
1399
|
+
|
|
1400
|
+
// Get current position for seeking
|
|
1401
|
+
const currentPosition = position > 0 ? position : this.currentResource?.playbackDuration || 0;
|
|
1402
|
+
|
|
1403
|
+
const streaminfo = await this.getStream(track);
|
|
1404
|
+
if (!streaminfo?.stream) {
|
|
1405
|
+
this.debug(`[Player] No stream to refresh`);
|
|
1406
|
+
return false;
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
// Create AudioResource with filters and seek to current position
|
|
1410
|
+
const resource = await this.createResource(streaminfo, track, currentPosition);
|
|
1411
|
+
|
|
1412
|
+
// Stop current playback and start new one
|
|
1413
|
+
const wasPlaying = this.isPlaying;
|
|
1414
|
+
const wasPaused = this.isPaused;
|
|
1415
|
+
|
|
1416
|
+
this.audioPlayer.stop();
|
|
1417
|
+
this.currentResource = resource;
|
|
1418
|
+
|
|
1419
|
+
// Subscribe to new resource
|
|
1420
|
+
if (this.connection) {
|
|
1421
|
+
this.connection.subscribe(this.audioPlayer);
|
|
1422
|
+
this.audioPlayer.play(resource);
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
// Restore playing state
|
|
1426
|
+
if (wasPlaying && !wasPaused) {
|
|
1427
|
+
this.isPlaying = true;
|
|
1428
|
+
this.isPaused = false;
|
|
1429
|
+
} else if (wasPaused) {
|
|
1430
|
+
this.isPlaying = false;
|
|
1431
|
+
this.isPaused = true;
|
|
1432
|
+
this.audioPlayer.pause();
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
this.debug(`[Player] Successfully applied filter to current track at position ${currentPosition}ms`);
|
|
1436
|
+
return true;
|
|
1437
|
+
} catch (error) {
|
|
1438
|
+
this.debug(`[Player] Error applying filter to current track:`, error);
|
|
1439
|
+
// Filter was still added to active filters, so return true
|
|
1440
|
+
return true;
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
/**
|
|
1445
|
+
* Attach an extension to the player
|
|
1446
|
+
*
|
|
1447
|
+
* @param {BaseExtension} extension - The extension to attach
|
|
1448
|
+
* @example
|
|
1449
|
+
* player.attachExtension(new MyExtension());
|
|
1450
|
+
*/
|
|
1451
|
+
public attachExtension(extension: BaseExtension): void {
|
|
1452
|
+
this.extensionManager.register(extension);
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
/**
|
|
1456
|
+
* Detach an extension from the player
|
|
1457
|
+
*
|
|
1458
|
+
* @param {BaseExtension} extension - The extension to detach
|
|
1459
|
+
* @example
|
|
1460
|
+
* player.detachExtension(new MyExtension());
|
|
1461
|
+
*/
|
|
1462
|
+
public detachExtension(extension: BaseExtension): void {
|
|
1463
|
+
this.extensionManager.unregister(extension);
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
/**
|
|
1467
|
+
* Get all extensions attached to the player
|
|
1468
|
+
*
|
|
1469
|
+
* @returns {readonly BaseExtension[]} All attached extensions
|
|
1470
|
+
* @example
|
|
1471
|
+
* const extensions = player.getExtensions();
|
|
1472
|
+
* console.log(`Extensions: ${extensions.length}`);
|
|
1473
|
+
*/
|
|
1474
|
+
public getExtensions(): readonly BaseExtension[] {
|
|
1475
|
+
return this.extensionManager.getAll();
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
private clearLeaveTimeout(): void {
|
|
1479
|
+
if (this.leaveTimeout) {
|
|
1480
|
+
clearTimeout(this.leaveTimeout);
|
|
1481
|
+
this.leaveTimeout = null;
|
|
1482
|
+
this.debug(`[Player] Cleared leave timeoutMs`);
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
private debug(message?: any, ...optionalParams: any[]): void {
|
|
1487
|
+
if (this.listenerCount("debug") > 0) {
|
|
1488
|
+
this.emit("debug", message, ...optionalParams);
|
|
1489
|
+
}
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
private setupEventListeners(): void {
|
|
1493
|
+
this.audioPlayer.on("stateChange", (oldState, newState) => {
|
|
1494
|
+
this.debug(`[Player] AudioPlayer stateChange from ${oldState.status} to ${newState.status}`);
|
|
1495
|
+
if (newState.status === AudioPlayerStatus.Idle && oldState.status !== AudioPlayerStatus.Idle) {
|
|
1496
|
+
// Track ended
|
|
1497
|
+
const track = this.queue.currentTrack;
|
|
1498
|
+
if (track) {
|
|
1499
|
+
this.debug(`[Player] Track ended: ${track.title}`);
|
|
1500
|
+
this.emit("trackEnd", track);
|
|
1501
|
+
}
|
|
1502
|
+
this.playNext();
|
|
1503
|
+
} else if (
|
|
1504
|
+
newState.status === AudioPlayerStatus.Playing &&
|
|
1505
|
+
(oldState.status === AudioPlayerStatus.Idle || oldState.status === AudioPlayerStatus.Buffering)
|
|
1506
|
+
) {
|
|
1507
|
+
// Track started
|
|
1508
|
+
this.clearLeaveTimeout();
|
|
1509
|
+
this.isPlaying = true;
|
|
1510
|
+
this.isPaused = false;
|
|
1511
|
+
const track = this.queue.currentTrack;
|
|
1512
|
+
if (track) {
|
|
1513
|
+
this.debug(`[Player] Track started: ${track.title}`);
|
|
1514
|
+
this.emit("trackStart", track);
|
|
1515
|
+
}
|
|
1516
|
+
} else if (newState.status === AudioPlayerStatus.Paused && oldState.status !== AudioPlayerStatus.Paused) {
|
|
1517
|
+
// Track paused
|
|
1518
|
+
this.isPaused = true;
|
|
1519
|
+
const track = this.queue.currentTrack;
|
|
1520
|
+
if (track) {
|
|
1521
|
+
this.debug(`[Player] Player paused on track: ${track.title}`);
|
|
1522
|
+
this.emit("playerPause", track);
|
|
1523
|
+
}
|
|
1524
|
+
} else if (newState.status !== AudioPlayerStatus.Paused && oldState.status === AudioPlayerStatus.Paused) {
|
|
1525
|
+
// Track resumed
|
|
1526
|
+
this.isPaused = false;
|
|
1527
|
+
const track = this.queue.currentTrack;
|
|
1528
|
+
if (track) {
|
|
1529
|
+
this.debug(`[Player] Player resumed on track: ${track.title}`);
|
|
1530
|
+
this.emit("playerResume", track);
|
|
1531
|
+
}
|
|
1532
|
+
} else if (newState.status === AudioPlayerStatus.AutoPaused) {
|
|
1533
|
+
this.debug(`[Player] AudioPlayerStatus.AutoPaused`);
|
|
1534
|
+
} else if (newState.status === AudioPlayerStatus.Buffering) {
|
|
1535
|
+
this.debug(`[Player] AudioPlayerStatus.Buffering`);
|
|
1536
|
+
}
|
|
1537
|
+
});
|
|
1538
|
+
this.audioPlayer.on("error", (error) => {
|
|
1539
|
+
this.debug(`[Player] AudioPlayer error:`, error);
|
|
1540
|
+
this.emit("playerError", error, this.queue.currentTrack || undefined);
|
|
1541
|
+
this.playNext();
|
|
1542
|
+
});
|
|
1543
|
+
|
|
1544
|
+
this.audioPlayer.on("debug", (...args) => {
|
|
1545
|
+
if (this.manager.debugEnabled) {
|
|
1546
|
+
this.emit("debug", ...args);
|
|
1547
|
+
}
|
|
1548
|
+
});
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
addPlugin(plugin: SourcePlugin): void {
|
|
1552
|
+
this.debug(`[Player] Adding plugin: ${plugin.name}`);
|
|
1553
|
+
this.pluginManager.register(plugin);
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
removePlugin(name: string): boolean {
|
|
1557
|
+
this.debug(`[Player] Removing plugin: ${name}`);
|
|
1558
|
+
return this.pluginManager.unregister(name);
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
//#endregion
|
|
1562
|
+
//#region Getters
|
|
1563
|
+
|
|
1492
1564
|
/**
|
|
1493
1565
|
* Get the size of the queue
|
|
1494
1566
|
*
|
|
@@ -1573,121 +1645,5 @@ export class Player extends EventEmitter {
|
|
|
1573
1645
|
return this.queue.relatedTracks();
|
|
1574
1646
|
}
|
|
1575
1647
|
|
|
1576
|
-
|
|
1577
|
-
* Save a track's stream to a file and return a Readable stream
|
|
1578
|
-
*
|
|
1579
|
-
* @param {Track} track - The track to save
|
|
1580
|
-
* @param {SaveOptions | string} options - Save options or filename string (for backward compatibility)
|
|
1581
|
-
* @returns {Promise<Readable>} A Readable stream containing the audio data
|
|
1582
|
-
* @example
|
|
1583
|
-
* // Save current track to file
|
|
1584
|
-
* const track = player.currentTrack;
|
|
1585
|
-
* if (track) {
|
|
1586
|
-
* const stream = await player.save(track);
|
|
1587
|
-
*
|
|
1588
|
-
* // Use fs to write the stream to file
|
|
1589
|
-
* const fs = require('fs');
|
|
1590
|
-
* const writeStream = fs.createWriteStream('saved-song.mp3');
|
|
1591
|
-
* stream.pipe(writeStream);
|
|
1592
|
-
*
|
|
1593
|
-
* writeStream.on('finish', () => {
|
|
1594
|
-
* console.log('File saved successfully!');
|
|
1595
|
-
* });
|
|
1596
|
-
* }
|
|
1597
|
-
*
|
|
1598
|
-
* // Save any track by URL
|
|
1599
|
-
* const searchResult = await player.search("Never Gonna Give You Up", userId);
|
|
1600
|
-
* if (searchResult.tracks.length > 0) {
|
|
1601
|
-
* const stream = await player.save(searchResult.tracks[0]);
|
|
1602
|
-
* // Handle the stream...
|
|
1603
|
-
* }
|
|
1604
|
-
*
|
|
1605
|
-
* // Backward compatibility - filename as string
|
|
1606
|
-
* const stream = await player.save(track, "my-song.mp3");
|
|
1607
|
-
*/
|
|
1608
|
-
async save(track: Track, options?: SaveOptions | string): Promise<Readable> {
|
|
1609
|
-
this.debug(`[Player] save called for track: ${track.title}`);
|
|
1610
|
-
|
|
1611
|
-
// Parse options - support both SaveOptions object and filename string (backward compatibility)
|
|
1612
|
-
let saveOptions: SaveOptions = {};
|
|
1613
|
-
if (typeof options === "string") {
|
|
1614
|
-
saveOptions = { filename: options };
|
|
1615
|
-
} else if (options) {
|
|
1616
|
-
saveOptions = options;
|
|
1617
|
-
}
|
|
1618
|
-
|
|
1619
|
-
// Use timeout from options or fallback to player's extractorTimeout
|
|
1620
|
-
const timeout = saveOptions.timeout ?? this.options.extractorTimeout ?? 15000;
|
|
1621
|
-
|
|
1622
|
-
try {
|
|
1623
|
-
// Try extensions first
|
|
1624
|
-
let streamInfo: StreamInfo | null = await this.extensionsProvideStream(track);
|
|
1625
|
-
let plugin: SourcePlugin | undefined;
|
|
1626
|
-
|
|
1627
|
-
if (!streamInfo) {
|
|
1628
|
-
plugin = this.pluginManager.findPlugin(track.url) || this.pluginManager.get(track.source);
|
|
1629
|
-
|
|
1630
|
-
if (!plugin) {
|
|
1631
|
-
this.debug(`[Player] No plugin found for track: ${track.title}`);
|
|
1632
|
-
throw new Error(`No plugin found for track: ${track.title}`);
|
|
1633
|
-
}
|
|
1634
|
-
|
|
1635
|
-
this.debug(`[Player] Getting save stream for track: ${track.title}`);
|
|
1636
|
-
this.debug(`[Player] Using save plugin: ${plugin.name}`);
|
|
1637
|
-
|
|
1638
|
-
try {
|
|
1639
|
-
streamInfo = await withTimeout(plugin.getStream(track), timeout, "getSaveStream timed out");
|
|
1640
|
-
} catch (streamError) {
|
|
1641
|
-
this.debug(`[Player] getSaveStream failed, trying getFallback:`, streamError);
|
|
1642
|
-
const allplugs = this.pluginManager.getAll();
|
|
1643
|
-
for (const p of allplugs) {
|
|
1644
|
-
if (typeof (p as any).getFallback !== "function" && typeof (p as any).getStream !== "function") {
|
|
1645
|
-
continue;
|
|
1646
|
-
}
|
|
1647
|
-
try {
|
|
1648
|
-
streamInfo = await withTimeout(
|
|
1649
|
-
(p as any).getStream(track),
|
|
1650
|
-
timeout,
|
|
1651
|
-
`getSaveStream timed out for plugin ${p.name}`,
|
|
1652
|
-
);
|
|
1653
|
-
if ((streamInfo as any)?.stream) {
|
|
1654
|
-
this.debug(`[Player] getSaveStream succeeded with plugin ${p.name} for track: ${track.title}`);
|
|
1655
|
-
break;
|
|
1656
|
-
}
|
|
1657
|
-
streamInfo = await withTimeout(
|
|
1658
|
-
(p as any).getFallback(track),
|
|
1659
|
-
timeout,
|
|
1660
|
-
`getSaveFallback timed out for plugin ${p.name}`,
|
|
1661
|
-
);
|
|
1662
|
-
if (!(streamInfo as any)?.stream) continue;
|
|
1663
|
-
break;
|
|
1664
|
-
} catch (fallbackError) {
|
|
1665
|
-
this.debug(`[Player] getSaveFallback failed with plugin ${p.name}:`, fallbackError);
|
|
1666
|
-
}
|
|
1667
|
-
}
|
|
1668
|
-
if (!(streamInfo as any)?.stream) {
|
|
1669
|
-
throw new Error(`All getSaveFallback attempts failed for track: ${track.title}`);
|
|
1670
|
-
}
|
|
1671
|
-
}
|
|
1672
|
-
} else {
|
|
1673
|
-
this.debug(`[Player] Using extension-provided save stream for track: ${track.title}`);
|
|
1674
|
-
}
|
|
1675
|
-
|
|
1676
|
-
if (!streamInfo || !streamInfo.stream) {
|
|
1677
|
-
throw new Error(`No save stream available for track: ${track.title}`);
|
|
1678
|
-
}
|
|
1679
|
-
|
|
1680
|
-
this.debug(`[Player] Save stream obtained for track: ${track.title}`);
|
|
1681
|
-
if (saveOptions.filename) {
|
|
1682
|
-
this.debug(`[Player] Save options - filename: ${saveOptions.filename}, quality: ${saveOptions.quality || "default"}`);
|
|
1683
|
-
}
|
|
1684
|
-
|
|
1685
|
-
// Return the stream directly - caller can pipe it to fs.createWriteStream()
|
|
1686
|
-
return streamInfo.stream;
|
|
1687
|
-
} catch (error) {
|
|
1688
|
-
this.debug(`[Player] save error:`, error);
|
|
1689
|
-
this.emit("playerError", error as Error, track);
|
|
1690
|
-
throw error;
|
|
1691
|
-
}
|
|
1692
|
-
}
|
|
1648
|
+
//#endregion
|
|
1693
1649
|
}
|