ziplayer 0.1.4 → 0.1.5
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/dist/structures/Player.d.ts +38 -57
- package/dist/structures/Player.d.ts.map +1 -1
- package/dist/structures/Player.js +205 -303
- package/dist/structures/Player.js.map +1 -1
- package/dist/structures/PlayerManager.d.ts +5 -1
- package/dist/structures/PlayerManager.d.ts.map +1 -1
- package/dist/structures/PlayerManager.js.map +1 -1
- package/dist/types/index.d.ts +58 -16
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/structures/Player.ts +225 -360
- package/src/structures/PlayerManager.ts +16 -11
- package/src/types/index.ts +56 -16
package/src/structures/Player.ts
CHANGED
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
ProgressBarOptions,
|
|
26
26
|
LoopMode,
|
|
27
27
|
StreamInfo,
|
|
28
|
+
SaveOptions,
|
|
28
29
|
} from "../types";
|
|
29
30
|
import type {
|
|
30
31
|
ExtensionContext,
|
|
@@ -98,16 +99,12 @@ export class Player extends EventEmitter {
|
|
|
98
99
|
private extensions: BaseExtension[] = [];
|
|
99
100
|
private extensionContext!: ExtensionContext;
|
|
100
101
|
|
|
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>();
|
|
105
|
-
|
|
106
102
|
// Cache for search results to avoid duplicate calls
|
|
107
103
|
private searchCache = new Map<string, SearchResult>();
|
|
108
104
|
private readonly SEARCH_CACHE_TTL = 2 * 60 * 1000; // 2 minutes
|
|
109
105
|
private searchCacheTimestamps = new Map<string, number>();
|
|
110
|
-
|
|
106
|
+
// TTS support
|
|
107
|
+
private ttsPlayer: DiscordAudioPlayer | null = null;
|
|
111
108
|
/**
|
|
112
109
|
* Attach an extension to the player
|
|
113
110
|
*
|
|
@@ -260,6 +257,84 @@ export class Player extends EventEmitter {
|
|
|
260
257
|
return null;
|
|
261
258
|
}
|
|
262
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
|
+
|
|
263
338
|
/**
|
|
264
339
|
* Start playing a specific track immediately, replacing the current resource.
|
|
265
340
|
*/
|
|
@@ -269,78 +344,17 @@ export class Player extends EventEmitter {
|
|
|
269
344
|
let plugin: SourcePlugin | undefined;
|
|
270
345
|
|
|
271
346
|
if (!streamInfo) {
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
this.debug(`[Player] No plugin found for track: ${track.title}`);
|
|
276
|
-
throw new Error(`No plugin found for track: ${track.title}`);
|
|
347
|
+
streamInfo = await this.getStreamFromPlugin(track);
|
|
348
|
+
if (!streamInfo) {
|
|
349
|
+
throw new Error(`No stream available for track: ${track.title}`);
|
|
277
350
|
}
|
|
278
|
-
|
|
279
|
-
this.debug(`[Player] Getting stream for track: ${track.title}`);
|
|
280
|
-
this.debug(`[Player] Using plugin: ${plugin.name}`);
|
|
281
|
-
this.debug(`[Track] Track Info:`, track);
|
|
282
|
-
try {
|
|
283
|
-
streamInfo = await withTimeout(plugin.getStream(track), this.options.extractorTimeout ?? 15000, "getStream timed out");
|
|
284
|
-
} catch (streamError) {
|
|
285
|
-
this.debug(`[Player] getStream failed, trying getFallback:`, streamError);
|
|
286
|
-
const allplugs = this.pluginManager.getAll();
|
|
287
|
-
for (const p of allplugs) {
|
|
288
|
-
if (typeof (p as any).getFallback !== "function" && typeof (p as any).getStream !== "function") {
|
|
289
|
-
continue;
|
|
290
|
-
}
|
|
291
|
-
try {
|
|
292
|
-
streamInfo = await withTimeout(
|
|
293
|
-
(p as any).getStream(track),
|
|
294
|
-
this.options.extractorTimeout ?? 15000,
|
|
295
|
-
`getStream timed out for plugin ${p.name}`,
|
|
296
|
-
);
|
|
297
|
-
if ((streamInfo as any)?.stream) {
|
|
298
|
-
this.debug(`[Player] getStream succeeded with plugin ${p.name} for track: ${track.title}`);
|
|
299
|
-
break;
|
|
300
|
-
}
|
|
301
|
-
streamInfo = await withTimeout(
|
|
302
|
-
(p as any).getFallback(track),
|
|
303
|
-
this.options.extractorTimeout ?? 15000,
|
|
304
|
-
`getFallback timed out for plugin ${p.name}`,
|
|
305
|
-
);
|
|
306
|
-
if (!(streamInfo as any)?.stream) continue;
|
|
307
|
-
break;
|
|
308
|
-
} catch (fallbackError) {
|
|
309
|
-
this.debug(`[Player] getFallback failed with plugin ${p.name}:`, fallbackError);
|
|
310
|
-
}
|
|
311
|
-
}
|
|
312
|
-
if (!(streamInfo as any)?.stream) {
|
|
313
|
-
throw new Error(`All getFallback attempts failed for track: ${track.title}`);
|
|
314
|
-
}
|
|
315
|
-
}
|
|
316
|
-
this.debug(streamInfo);
|
|
317
351
|
} else {
|
|
318
352
|
this.debug(`[Player] Using extension-provided stream for track: ${track.title}`);
|
|
319
353
|
}
|
|
320
354
|
|
|
321
355
|
// Kiểm tra nếu có stream thực sự để tạo AudioResource
|
|
322
356
|
if (streamInfo && (streamInfo as any).stream) {
|
|
323
|
-
|
|
324
|
-
switch (type) {
|
|
325
|
-
case "webm/opus":
|
|
326
|
-
return StreamType.WebmOpus;
|
|
327
|
-
case "ogg/opus":
|
|
328
|
-
return StreamType.OggOpus;
|
|
329
|
-
case "arbitrary":
|
|
330
|
-
default:
|
|
331
|
-
return StreamType.Arbitrary;
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
const stream: Readable = (streamInfo as StreamInfo).stream;
|
|
336
|
-
const inputType = mapToStreamType((streamInfo as StreamInfo).type);
|
|
337
|
-
|
|
338
|
-
this.currentResource = createAudioResource(stream, {
|
|
339
|
-
metadata: track,
|
|
340
|
-
inputType,
|
|
341
|
-
inlineVolume: true,
|
|
342
|
-
});
|
|
343
|
-
|
|
357
|
+
this.currentResource = await this.Audioresource(streamInfo, track);
|
|
344
358
|
// Apply initial volume using the resource's VolumeTransformer
|
|
345
359
|
if (this.volumeInterval) {
|
|
346
360
|
clearInterval(this.volumeInterval);
|
|
@@ -370,15 +384,11 @@ export class Player extends EventEmitter {
|
|
|
370
384
|
}
|
|
371
385
|
}
|
|
372
386
|
|
|
373
|
-
// TTS support
|
|
374
|
-
private ttsPlayer: DiscordAudioPlayer | null = null;
|
|
375
|
-
private ttsQueue: Array<Track> = [];
|
|
376
|
-
private ttsActive = false;
|
|
377
387
|
private clearLeaveTimeout(): void {
|
|
378
388
|
if (this.leaveTimeout) {
|
|
379
389
|
clearTimeout(this.leaveTimeout);
|
|
380
390
|
this.leaveTimeout = null;
|
|
381
|
-
this.debug(`[Player] Cleared leave
|
|
391
|
+
this.debug(`[Player] Cleared leave timeoutMs`);
|
|
382
392
|
}
|
|
383
393
|
}
|
|
384
394
|
|
|
@@ -809,55 +819,44 @@ export class Player extends EventEmitter {
|
|
|
809
819
|
* await player.interruptWithTTSTrack(track);
|
|
810
820
|
*/
|
|
811
821
|
public async interruptWithTTSTrack(track: Track): Promise<void> {
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
}
|
|
816
|
-
}
|
|
817
|
-
|
|
818
|
-
/**
|
|
819
|
-
* Play queued TTS items sequentially
|
|
820
|
-
*
|
|
821
|
-
* @returns {Promise<void>}
|
|
822
|
-
* @example
|
|
823
|
-
* await player.playNextTTS();
|
|
824
|
-
*/
|
|
825
|
-
private async playNextTTS(): Promise<void> {
|
|
826
|
-
const next = this.ttsQueue.shift();
|
|
827
|
-
if (!next) return;
|
|
828
|
-
this.ttsActive = true;
|
|
822
|
+
const wasPlaying =
|
|
823
|
+
this.audioPlayer.state.status === AudioPlayerStatus.Playing ||
|
|
824
|
+
this.audioPlayer.state.status === AudioPlayerStatus.Buffering;
|
|
829
825
|
|
|
830
826
|
try {
|
|
831
827
|
if (!this.connection) throw new Error("No voice connection for TTS");
|
|
832
828
|
const ttsPlayer = this.ensureTTSPlayer();
|
|
833
829
|
|
|
834
830
|
// Build resource from plugin stream
|
|
835
|
-
const
|
|
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
|
+
}
|
|
836
839
|
if (resource.volume) {
|
|
837
840
|
resource.volume.setVolume((this.options?.tts?.volume ?? this?.volume ?? 100) / 100);
|
|
838
841
|
}
|
|
839
842
|
|
|
840
|
-
const wasPlaying =
|
|
841
|
-
this.audioPlayer.state.status === AudioPlayerStatus.Playing ||
|
|
842
|
-
this.audioPlayer.state.status === AudioPlayerStatus.Buffering;
|
|
843
|
-
|
|
844
843
|
// Pause current music if any
|
|
845
844
|
try {
|
|
846
|
-
this.
|
|
845
|
+
this.pause();
|
|
847
846
|
} catch {}
|
|
848
847
|
|
|
849
848
|
// Swap subscription and play TTS
|
|
850
849
|
this.connection.subscribe(ttsPlayer);
|
|
851
|
-
this.emit("ttsStart", { track
|
|
850
|
+
this.emit("ttsStart", { track });
|
|
852
851
|
ttsPlayer.play(resource);
|
|
853
852
|
|
|
854
853
|
// Wait until TTS starts then finishes
|
|
855
854
|
await entersState(ttsPlayer, AudioPlayerStatus.Playing, 5_000).catch(() => null);
|
|
856
|
-
// Derive
|
|
855
|
+
// Derive timeoutMs from resource/track duration when available, with a sensible cap
|
|
857
856
|
const md: any = (resource as any)?.metadata ?? {};
|
|
858
857
|
const declared =
|
|
859
858
|
typeof md.duration === "number" ? md.duration
|
|
860
|
-
: typeof
|
|
859
|
+
: typeof track?.duration === "number" ? track.duration
|
|
861
860
|
: undefined;
|
|
862
861
|
const declaredMs =
|
|
863
862
|
declared ?
|
|
@@ -871,104 +870,17 @@ export class Player extends EventEmitter {
|
|
|
871
870
|
|
|
872
871
|
// Swap back and resume if needed
|
|
873
872
|
this.connection.subscribe(this.audioPlayer);
|
|
874
|
-
if (wasPlaying) {
|
|
875
|
-
try {
|
|
876
|
-
this.audioPlayer.unpause();
|
|
877
|
-
} catch {}
|
|
878
|
-
}
|
|
879
|
-
this.emit("ttsEnd");
|
|
880
873
|
} catch (err) {
|
|
881
874
|
this.debug("[TTS] error while playing:", err);
|
|
882
875
|
this.emit("playerError", err as Error);
|
|
883
876
|
} finally {
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
}
|
|
889
|
-
}
|
|
890
|
-
|
|
891
|
-
/**
|
|
892
|
-
* Get cached plugin or find and cache a new one
|
|
893
|
-
* @param track The track to find plugin for
|
|
894
|
-
* @returns The matching plugin or null if not found
|
|
895
|
-
*/
|
|
896
|
-
private getCachedPlugin(track: Track): SourcePlugin | null {
|
|
897
|
-
const cacheKey = `${track.source}:${track.url}`;
|
|
898
|
-
const now = Date.now();
|
|
899
|
-
|
|
900
|
-
// Check if cache is still valid
|
|
901
|
-
const cachedTimestamp = this.pluginCacheTimestamps.get(cacheKey);
|
|
902
|
-
if (cachedTimestamp && now - cachedTimestamp < this.PLUGIN_CACHE_TTL) {
|
|
903
|
-
const cachedPlugin = this.pluginCache.get(cacheKey);
|
|
904
|
-
if (cachedPlugin) {
|
|
905
|
-
this.debug(`[PluginCache] Using cached plugin for ${track.source}: ${cachedPlugin.name}`);
|
|
906
|
-
return cachedPlugin;
|
|
907
|
-
}
|
|
908
|
-
}
|
|
909
|
-
|
|
910
|
-
// Find new plugin and cache it
|
|
911
|
-
this.debug(`[PluginCache] Finding plugin for track: ${track.title} (${track.source})`);
|
|
912
|
-
const plugin = this.pluginManager.findPlugin(track.url) || this.pluginManager.get(track.source);
|
|
913
|
-
|
|
914
|
-
if (plugin) {
|
|
915
|
-
this.pluginCache.set(cacheKey, plugin);
|
|
916
|
-
this.pluginCacheTimestamps.set(cacheKey, now);
|
|
917
|
-
this.debug(`[PluginCache] Cached plugin: ${plugin.name} for ${track.source}`);
|
|
918
|
-
return plugin;
|
|
919
|
-
}
|
|
920
|
-
|
|
921
|
-
return null;
|
|
922
|
-
}
|
|
923
|
-
|
|
924
|
-
/**
|
|
925
|
-
* Clear expired cache entries
|
|
926
|
-
*/
|
|
927
|
-
private clearExpiredCache(): void {
|
|
928
|
-
const now = Date.now();
|
|
929
|
-
for (const [key, timestamp] of this.pluginCacheTimestamps.entries()) {
|
|
930
|
-
if (now - timestamp >= this.PLUGIN_CACHE_TTL) {
|
|
931
|
-
this.pluginCache.delete(key);
|
|
932
|
-
this.pluginCacheTimestamps.delete(key);
|
|
933
|
-
this.debug(`[PluginCache] Cleared expired cache entry: ${key}`);
|
|
934
|
-
}
|
|
935
|
-
}
|
|
936
|
-
}
|
|
937
|
-
|
|
938
|
-
/**
|
|
939
|
-
* Clear all plugin cache entries
|
|
940
|
-
* @example
|
|
941
|
-
* player.clearPluginCache();
|
|
942
|
-
*/
|
|
943
|
-
public clearPluginCache(): void {
|
|
944
|
-
const cacheSize = this.pluginCache.size;
|
|
945
|
-
this.pluginCache.clear();
|
|
946
|
-
this.pluginCacheTimestamps.clear();
|
|
947
|
-
this.debug(`[PluginCache] Cleared all ${cacheSize} cache entries`);
|
|
948
|
-
}
|
|
949
|
-
|
|
950
|
-
/**
|
|
951
|
-
* Get plugin cache statistics
|
|
952
|
-
* @returns Cache statistics
|
|
953
|
-
* @example
|
|
954
|
-
* const stats = player.getPluginCacheStats();
|
|
955
|
-
* console.log(`Cache size: ${stats.size}, Hit rate: ${stats.hitRate}%`);
|
|
956
|
-
*/
|
|
957
|
-
public getPluginCacheStats(): { size: number; hitRate: number; expiredEntries: number } {
|
|
958
|
-
const now = Date.now();
|
|
959
|
-
let expiredEntries = 0;
|
|
960
|
-
|
|
961
|
-
for (const timestamp of this.pluginCacheTimestamps.values()) {
|
|
962
|
-
if (now - timestamp >= this.PLUGIN_CACHE_TTL) {
|
|
963
|
-
expiredEntries++;
|
|
877
|
+
if (wasPlaying) {
|
|
878
|
+
try {
|
|
879
|
+
this.resume();
|
|
880
|
+
} catch {}
|
|
964
881
|
}
|
|
882
|
+
this.emit("ttsEnd");
|
|
965
883
|
}
|
|
966
|
-
|
|
967
|
-
return {
|
|
968
|
-
size: this.pluginCache.size,
|
|
969
|
-
hitRate: 0, // Would need to track hits/misses to calculate this
|
|
970
|
-
expiredEntries,
|
|
971
|
-
};
|
|
972
884
|
}
|
|
973
885
|
|
|
974
886
|
/**
|
|
@@ -1032,33 +944,6 @@ export class Player extends EventEmitter {
|
|
|
1032
944
|
this.debug(`[SearchCache] Cleared all ${cacheSize} search cache entries`);
|
|
1033
945
|
}
|
|
1034
946
|
|
|
1035
|
-
/**
|
|
1036
|
-
* Get search cache statistics
|
|
1037
|
-
* @returns Search cache statistics
|
|
1038
|
-
* @example
|
|
1039
|
-
* const stats = player.getSearchCacheStats();
|
|
1040
|
-
* console.log(`Search cache size: ${stats.size}, Expired: ${stats.expiredEntries}`);
|
|
1041
|
-
*/
|
|
1042
|
-
public getSearchCacheStats(): { size: number; expiredEntries: number; queries: string[] } {
|
|
1043
|
-
const now = Date.now();
|
|
1044
|
-
let expiredEntries = 0;
|
|
1045
|
-
const queries: string[] = [];
|
|
1046
|
-
|
|
1047
|
-
for (const [key, timestamp] of this.searchCacheTimestamps.entries()) {
|
|
1048
|
-
if (now - timestamp >= this.SEARCH_CACHE_TTL) {
|
|
1049
|
-
expiredEntries++;
|
|
1050
|
-
} else {
|
|
1051
|
-
queries.push(key);
|
|
1052
|
-
}
|
|
1053
|
-
}
|
|
1054
|
-
|
|
1055
|
-
return {
|
|
1056
|
-
size: this.searchCache.size,
|
|
1057
|
-
expiredEntries,
|
|
1058
|
-
queries,
|
|
1059
|
-
};
|
|
1060
|
-
}
|
|
1061
|
-
|
|
1062
947
|
/**
|
|
1063
948
|
* Debug method to check for duplicate search calls
|
|
1064
949
|
* @param query The search query to check
|
|
@@ -1091,144 +976,6 @@ export class Player extends EventEmitter {
|
|
|
1091
976
|
};
|
|
1092
977
|
}
|
|
1093
978
|
|
|
1094
|
-
/** Build AudioResource for a given track using the plugin pipeline */
|
|
1095
|
-
private async resourceFromTrack(track: Track): Promise<AudioResource> {
|
|
1096
|
-
this.debug(`[ResourceFromTrack] Starting resource creation for track: ${track.title} (${track.source})`);
|
|
1097
|
-
|
|
1098
|
-
// Clear expired cache entries periodically
|
|
1099
|
-
if (Math.random() < 0.1) {
|
|
1100
|
-
// 10% chance to clean cache
|
|
1101
|
-
this.clearExpiredCache();
|
|
1102
|
-
}
|
|
1103
|
-
|
|
1104
|
-
// Resolve plugin using cache
|
|
1105
|
-
const plugin = this.getCachedPlugin(track);
|
|
1106
|
-
if (!plugin) {
|
|
1107
|
-
this.debug(`[ResourceFromTrack] No plugin found for track: ${track.title} (${track.source})`);
|
|
1108
|
-
throw new Error(`No plugin found for track: ${track.title}`);
|
|
1109
|
-
}
|
|
1110
|
-
|
|
1111
|
-
this.debug(`[ResourceFromTrack] Using plugin: ${plugin.name} for track: ${track.title}`);
|
|
1112
|
-
|
|
1113
|
-
let streamInfo: StreamInfo | null = null;
|
|
1114
|
-
const timeoutMs = this.options.extractorTimeout ?? 15000;
|
|
1115
|
-
|
|
1116
|
-
try {
|
|
1117
|
-
this.debug(`[ResourceFromTrack] Attempting getStream with ${plugin.name}, timeout: ${timeoutMs}ms`);
|
|
1118
|
-
const startTime = Date.now();
|
|
1119
|
-
streamInfo = await withTimeout(plugin.getStream(track), timeoutMs, "getStream timed out");
|
|
1120
|
-
const duration = Date.now() - startTime;
|
|
1121
|
-
this.debug(`[ResourceFromTrack] getStream successful with ${plugin.name} in ${duration}ms`);
|
|
1122
|
-
|
|
1123
|
-
if (!streamInfo?.stream) {
|
|
1124
|
-
this.debug(`[ResourceFromTrack] getStream returned no stream from ${plugin.name}`);
|
|
1125
|
-
throw new Error(`No stream returned from ${plugin.name}`);
|
|
1126
|
-
}
|
|
1127
|
-
} catch (streamError) {
|
|
1128
|
-
const errorMessage = streamError instanceof Error ? streamError.message : String(streamError);
|
|
1129
|
-
this.debug(`[ResourceFromTrack] getStream failed with ${plugin.name}: ${errorMessage}`);
|
|
1130
|
-
|
|
1131
|
-
// Log more details for debugging
|
|
1132
|
-
if (streamError instanceof Error && streamError.stack) {
|
|
1133
|
-
this.debug(`[ResourceFromTrack] getStream error stack:`, streamError.stack);
|
|
1134
|
-
}
|
|
1135
|
-
|
|
1136
|
-
// try fallbacks
|
|
1137
|
-
this.debug(`[ResourceFromTrack] Attempting fallback plugins for track: ${track.title}`);
|
|
1138
|
-
const allplugs = this.pluginManager.getAll();
|
|
1139
|
-
let fallbackAttempts = 0;
|
|
1140
|
-
|
|
1141
|
-
for (const p of allplugs) {
|
|
1142
|
-
if (typeof (p as any).getFallback !== "function" && typeof (p as any).getStream !== "function") {
|
|
1143
|
-
this.debug(`[ResourceFromTrack] Skipping plugin ${(p as any).name} - no getFallback or getStream method`);
|
|
1144
|
-
continue;
|
|
1145
|
-
}
|
|
1146
|
-
|
|
1147
|
-
fallbackAttempts++;
|
|
1148
|
-
this.debug(`[ResourceFromTrack] Trying fallback plugin ${(p as any).name} (attempt ${fallbackAttempts})`);
|
|
1149
|
-
|
|
1150
|
-
try {
|
|
1151
|
-
// Try getStream first
|
|
1152
|
-
const startTime = Date.now();
|
|
1153
|
-
streamInfo = await withTimeout(p.getStream(track), timeoutMs, "getStream timed out");
|
|
1154
|
-
const duration = Date.now() - startTime;
|
|
1155
|
-
|
|
1156
|
-
if (streamInfo?.stream) {
|
|
1157
|
-
this.debug(`[ResourceFromTrack] Fallback getStream successful with ${(p as any).name} in ${duration}ms`);
|
|
1158
|
-
break;
|
|
1159
|
-
}
|
|
1160
|
-
|
|
1161
|
-
// Try getFallback if getStream didn't work
|
|
1162
|
-
this.debug(`[ResourceFromTrack] Trying getFallback with ${(p as any).name}`);
|
|
1163
|
-
const fallbackStartTime = Date.now();
|
|
1164
|
-
streamInfo = await withTimeout(
|
|
1165
|
-
(p as any).getFallback(track),
|
|
1166
|
-
timeoutMs,
|
|
1167
|
-
`getFallback timed out for plugin ${(p as any).name}`,
|
|
1168
|
-
);
|
|
1169
|
-
const fallbackDuration = Date.now() - fallbackStartTime;
|
|
1170
|
-
|
|
1171
|
-
if (streamInfo?.stream) {
|
|
1172
|
-
this.debug(`[ResourceFromTrack] Fallback getFallback successful with ${(p as any).name} in ${fallbackDuration}ms`);
|
|
1173
|
-
break;
|
|
1174
|
-
}
|
|
1175
|
-
|
|
1176
|
-
this.debug(`[ResourceFromTrack] Fallback plugin ${(p as any).name} returned no stream`);
|
|
1177
|
-
} catch (fallbackError) {
|
|
1178
|
-
const errorMessage = fallbackError instanceof Error ? fallbackError.message : String(fallbackError);
|
|
1179
|
-
this.debug(`[ResourceFromTrack] Fallback plugin ${(p as any).name} failed: ${errorMessage}`);
|
|
1180
|
-
|
|
1181
|
-
// Log more details for debugging
|
|
1182
|
-
if (fallbackError instanceof Error && fallbackError.stack) {
|
|
1183
|
-
this.debug(`[ResourceFromTrack] Fallback error stack:`, fallbackError.stack);
|
|
1184
|
-
}
|
|
1185
|
-
}
|
|
1186
|
-
}
|
|
1187
|
-
|
|
1188
|
-
if (!streamInfo?.stream) {
|
|
1189
|
-
this.debug(`[ResourceFromTrack] All ${fallbackAttempts} fallback attempts failed for track: ${track.title}`);
|
|
1190
|
-
throw new Error(`All getFallback attempts failed for track: ${track.title}`);
|
|
1191
|
-
}
|
|
1192
|
-
}
|
|
1193
|
-
|
|
1194
|
-
this.debug(
|
|
1195
|
-
`[ResourceFromTrack] Stream obtained, type: ${streamInfo.type}, metadata keys: ${Object.keys(
|
|
1196
|
-
streamInfo.metadata || {},
|
|
1197
|
-
).join(", ")}`,
|
|
1198
|
-
);
|
|
1199
|
-
|
|
1200
|
-
const mapToStreamType = (type: string): StreamType => {
|
|
1201
|
-
switch (type) {
|
|
1202
|
-
case "webm/opus":
|
|
1203
|
-
return StreamType.WebmOpus;
|
|
1204
|
-
case "ogg/opus":
|
|
1205
|
-
return StreamType.OggOpus;
|
|
1206
|
-
case "arbitrary":
|
|
1207
|
-
default:
|
|
1208
|
-
return StreamType.Arbitrary;
|
|
1209
|
-
}
|
|
1210
|
-
};
|
|
1211
|
-
|
|
1212
|
-
const inputType = mapToStreamType(streamInfo.type);
|
|
1213
|
-
this.debug(`[ResourceFromTrack] Creating AudioResource with inputType: ${inputType}`);
|
|
1214
|
-
|
|
1215
|
-
// Merge metadata safely
|
|
1216
|
-
const mergedMetadata = {
|
|
1217
|
-
...track,
|
|
1218
|
-
...(streamInfo.metadata || {}),
|
|
1219
|
-
};
|
|
1220
|
-
|
|
1221
|
-
const audioResource = createAudioResource(streamInfo.stream, {
|
|
1222
|
-
// Prefer plugin-provided metadata (e.g., precise duration), fallback to track fields
|
|
1223
|
-
metadata: mergedMetadata,
|
|
1224
|
-
inputType,
|
|
1225
|
-
inlineVolume: true,
|
|
1226
|
-
});
|
|
1227
|
-
|
|
1228
|
-
this.debug(`[ResourceFromTrack] AudioResource created successfully for track: ${track.title}`);
|
|
1229
|
-
return audioResource;
|
|
1230
|
-
}
|
|
1231
|
-
|
|
1232
979
|
private async generateWillNext(): Promise<void> {
|
|
1233
980
|
const lastTrack = this.queue.previousTracks[this.queue.previousTracks.length - 1] ?? this.queue.currentTrack;
|
|
1234
981
|
if (!lastTrack) return;
|
|
@@ -1693,7 +1440,7 @@ export class Player extends EventEmitter {
|
|
|
1693
1440
|
|
|
1694
1441
|
if (this.options.leaveOnEmpty && this.options.leaveTimeout) {
|
|
1695
1442
|
this.leaveTimeout = setTimeout(() => {
|
|
1696
|
-
this.debug(`[Player] Leaving voice channel after
|
|
1443
|
+
this.debug(`[Player] Leaving voice channel after timeoutMs`);
|
|
1697
1444
|
this.destroy();
|
|
1698
1445
|
}, this.options.leaveTimeout);
|
|
1699
1446
|
}
|
|
@@ -1825,4 +1572,122 @@ export class Player extends EventEmitter {
|
|
|
1825
1572
|
get relatedTracks(): Track[] | null {
|
|
1826
1573
|
return this.queue.relatedTracks();
|
|
1827
1574
|
}
|
|
1575
|
+
|
|
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
|
+
}
|
|
1828
1693
|
}
|