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.
@@ -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
- plugin = this.pluginManager.findPlugin(track.url) || this.pluginManager.get(track.source);
273
-
274
- if (!plugin) {
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
- function mapToStreamType(type: string | undefined): StreamType {
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 timeout`);
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
- this.ttsQueue.push(track);
813
- if (!this.ttsActive) {
814
- void this.playNextTTS();
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 resource = await this.resourceFromTrack(next);
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.audioPlayer.pause(true);
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: next });
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 timeout from resource/track duration when available, with a sensible cap
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 next?.duration === "number" ? next.duration
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
- this.ttsActive = false;
885
- if (this.ttsQueue.length > 0) {
886
- await this.playNextTTS();
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 timeout`);
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
  }