ziplayer 0.3.7 → 0.3.9

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.
@@ -173,6 +173,7 @@ export class Player extends EventEmitter {
173
173
  private ttsPlayer: DiscordAudioPlayer | null = null;
174
174
  private lastDuration: number = 0;
175
175
  private seekOffset: number = 0;
176
+ private recoveryInProgress = false;
176
177
 
177
178
  constructor(guildId: string, options: PlayerOptions = {}, manager: PlayerManager) {
178
179
  super();
@@ -269,7 +270,7 @@ export class Player extends EventEmitter {
269
270
  extractorTimeout: this.options.extractorTimeout,
270
271
  });
271
272
  this.streamManager = new StreamManager({
272
- maxConcurrentStreams: 2,
273
+ maxConcurrentStreams: this.options?.maxStreamStore ?? 4,
273
274
  streamTimeout: 5 * 60 * 1000,
274
275
  maxListenersPerStream: 15,
275
276
  enableMetrics: true,
@@ -787,6 +788,7 @@ export class Player extends EventEmitter {
787
788
 
788
789
  private async attemptTrackRecovery(track: Track, reason: unknown): Promise<boolean> {
789
790
  if (!this.antiStuckEnabled) return false;
791
+ this.recoveryInProgress = true;
790
792
  this.debug(`[AntiStuck] Recovery started for: ${track.title}`, reason);
791
793
 
792
794
  const originalQuality = this.options.quality;
@@ -808,6 +810,7 @@ export class Player extends EventEmitter {
808
810
  if (startedFromPreload) {
809
811
  this.antiStuckConsecutiveFailures = 0;
810
812
  this.options.quality = originalQuality;
813
+ this.recoveryInProgress = false;
811
814
  return true;
812
815
  }
813
816
  }
@@ -816,6 +819,7 @@ export class Player extends EventEmitter {
816
819
  if (started) {
817
820
  this.antiStuckConsecutiveFailures = 0;
818
821
  this.options.quality = originalQuality;
822
+ this.recoveryInProgress = false;
819
823
  return true;
820
824
  }
821
825
  } catch (error) {
@@ -827,9 +831,10 @@ export class Player extends EventEmitter {
827
831
  this.antiStuckConsecutiveFailures++;
828
832
  if (this.antiStuckConsecutiveFailures >= this.antiStuckControlledSkipThreshold) {
829
833
  this.debug(`[AntiStuck] Controlled skip threshold reached for ${track.title}`);
834
+ this.recoveryInProgress = false;
830
835
  return false;
831
836
  }
832
-
837
+ this.recoveryInProgress = false;
833
838
  // Avoid hard skip storm by leaving track for next natural retry window.
834
839
  this.debug(`[AntiStuck] Keeping track for controlled retry window: ${track.title}`);
835
840
  return false;
@@ -887,26 +892,32 @@ export class Player extends EventEmitter {
887
892
  * @param {number} position - Position in milliseconds to seek to (0 = no seek)
888
893
  * @returns {Promise<AudioResource>} The AudioResource with filters and seek applied
889
894
  */
890
- private async createResource(streamInfo: StreamInfo, track: Track, position: number = 0): Promise<AudioResource> {
895
+ private async createResource(
896
+ streamInfo: StreamInfo,
897
+ track: Track,
898
+ position: number = 0,
899
+ ): Promise<{ resource: AudioResource; processedStream: import("stream").Readable | null }> {
891
900
  const filterString = this.filter.getFilterString();
892
901
  this.debug(`[Player] Creating AudioResource — filters: ${filterString || "none"}, seek: ${position}ms`);
893
902
 
894
- // -1 = sentinel "no seek requested"
903
+ this.filter.setSourceStreamType(streamInfo.type);
904
+
895
905
  const seekArg = position > 0 ? position : -1;
896
906
 
897
907
  if (filterString || position > 0) {
898
- // throws on failure — do NOT fall back to the already-piped stream
899
908
  const processedStream = await this.filter.applyFiltersAndSeek(streamInfo.stream, seekArg);
900
- streamInfo.type = StreamType.Arbitrary as any;
901
909
 
902
- return createAudioResource(processedStream, {
910
+ // rawStream. Always use Arbitrary so discordjs/voice doesn't re-encode.
911
+ const resource = createAudioResource(processedStream, {
903
912
  metadata: track,
904
913
  inputType: StreamType.Arbitrary,
905
914
  inlineVolume: true,
906
915
  });
916
+
917
+ return { resource, processedStream };
907
918
  }
908
919
 
909
- return createAudioResource(streamInfo.stream, {
920
+ const resource = createAudioResource(streamInfo.stream, {
910
921
  metadata: track,
911
922
  inputType:
912
923
  streamInfo.type === "webm/opus" ? StreamType.WebmOpus
@@ -914,6 +925,8 @@ export class Player extends EventEmitter {
914
925
  : StreamType.Arbitrary,
915
926
  inlineVolume: true,
916
927
  });
928
+
929
+ return { resource, processedStream: null };
917
930
  }
918
931
 
919
932
  private mergeTrackPreserveRef(target: Track, source: Track): void {
@@ -1027,9 +1040,16 @@ export class Player extends EventEmitter {
1027
1040
  private async startTrack(track: Track): Promise<boolean> {
1028
1041
  if (this.destroyed) return false;
1029
1042
 
1030
- // First, get stream info (this will handle remote detection)
1031
- let streamInfo: StreamInfo | null = null;
1043
+ // Check preload BEFORE calling getStream so we never fetch a
1044
+ // stream we're about to throw away. The original code called getStream()
1045
+ // unconditionally at the top, then used the preload if available — leaking
1046
+ // the just-fetched stream and running middleware twice.
1047
+ if (this.preloadManager.hasValidPreload(track)) {
1048
+ return await this.startFromPreload(track);
1049
+ }
1032
1050
 
1051
+ // Only fetch a stream when there is no usable preload.
1052
+ let streamInfo: StreamInfo | null = null;
1033
1053
  try {
1034
1054
  streamInfo = await this.getStream(track);
1035
1055
  } catch (error) {
@@ -1037,78 +1057,70 @@ export class Player extends EventEmitter {
1037
1057
  throw error;
1038
1058
  }
1039
1059
 
1040
- // Handle remote playback
1060
+ // Remote playback
1041
1061
  if (streamInfo?.remote && streamInfo.handle) {
1042
1062
  return await this.playRemote(track, streamInfo);
1043
1063
  }
1044
1064
 
1045
- // Handle native playback
1046
- try {
1047
- // Try to use preloaded resource
1048
- if (this.preloadManager.hasValidPreload(track)) {
1049
- this.debug(`[Player] Using preloaded stream for: ${track.title}`);
1050
-
1051
- const oldStreamId = this.currentSlot.streamId;
1052
- if (oldStreamId && this.streamManager) {
1053
- setTimeout(() => {
1054
- if (this.currentSlot.streamId === oldStreamId) {
1055
- this.streamManager.unregisterStream(oldStreamId, true);
1056
- }
1057
- }, 3000);
1058
- }
1065
+ // Native playback — pass the already-fetched streamInfo to avoid a second fetch
1066
+ return await this.loadFreshStream(track, streamInfo);
1067
+ }
1068
+ private async startFromPreload(track: Track): Promise<boolean> {
1069
+ if (this.destroyed) return false;
1059
1070
 
1060
- this.promotePreloadToCurrent(track);
1061
- const currentResource = this.currentSlot.resource;
1062
- if (!currentResource) {
1063
- return false;
1064
- }
1065
- this.seekOffset = 0;
1066
- const targetVolume = this.getTrackTargetVolume(track);
1071
+ this.debug(`[Player] Using preloaded stream for: ${track.title}`);
1067
1072
 
1068
- if (currentResource.volume) {
1069
- currentResource.volume.setVolume(this.crossfadeEnabled ? 0 : targetVolume);
1070
- }
1073
+ const oldStreamId = this.currentSlot.streamId;
1071
1074
 
1072
- await this.maybeAlignToBeatBoundary();
1073
- this.refreshLock = true;
1074
- try {
1075
- this.audioPlayer.stop(true);
1076
- this.audioPlayer.play(currentResource);
1077
- await entersState(this.audioPlayer, AudioPlayerStatus.Playing, 10_000);
1078
- } finally {
1079
- this.refreshLock = false;
1080
- }
1081
- await this.applyCrossfadeIn(currentResource, track);
1075
+ this.promotePreloadToCurrent(track);
1082
1076
 
1083
- this.preloadNextTrack().catch((err) => {
1084
- this.debug(`[Player] Preload error:`, err);
1085
- });
1077
+ if (oldStreamId && oldStreamId !== this.currentSlot.streamId) {
1078
+ this.streamManager.unregisterStream(oldStreamId, true);
1079
+ this.debug(`[Player] Released old stream ${oldStreamId} after preload promotion`);
1080
+ }
1086
1081
 
1087
- return true;
1088
- }
1082
+ const currentResource = this.currentSlot.resource;
1083
+ if (!currentResource) return false;
1089
1084
 
1090
- this.debug(`[Player] No preload available, loading fresh: ${track.title}`);
1091
- return await this.loadFreshStream(track);
1092
- } catch (error) {
1093
- this.debug(`[Player] startTrack error:`, error);
1094
- this.emit("playerError", error as Error, track);
1095
- return false;
1085
+ this.seekOffset = 0;
1086
+ const targetVolume = this.getTrackTargetVolume(track);
1087
+
1088
+ if (currentResource.volume) {
1089
+ currentResource.volume.setVolume(this.crossfadeEnabled ? 0 : targetVolume);
1096
1090
  }
1091
+
1092
+ await this.maybeAlignToBeatBoundary();
1093
+ this.refreshLock = true;
1094
+ try {
1095
+ this.audioPlayer.stop(true);
1096
+ this.audioPlayer.play(currentResource);
1097
+ await entersState(this.audioPlayer, AudioPlayerStatus.Playing, 10_000);
1098
+ } finally {
1099
+ this.refreshLock = false;
1100
+ }
1101
+
1102
+ await this.applyCrossfadeIn(currentResource, track);
1103
+
1104
+ this.preloadNextTrack().catch((err: any) => {
1105
+ this.debug(`[Player] Preload error:`, err);
1106
+ });
1107
+
1108
+ return true;
1097
1109
  }
1098
1110
 
1099
1111
  /**
1100
1112
  * Load fresh stream when no preload available
1101
1113
  */
1102
- private async loadFreshStream(track: Track): Promise<boolean> {
1114
+ private async loadFreshStream(track: Track, preloadedStreamInfo?: StreamInfo | null): Promise<boolean> {
1103
1115
  if (this.destroyed) return false;
1104
1116
 
1105
- // Cancel preload to free resources
1106
1117
  await this.safeCancelPreload();
1107
1118
 
1108
1119
  try {
1109
- const streamInfo = await this.getStream(track);
1120
+ // use caller-supplied streamInfo when available so we don't
1121
+ // call getStream() a second time and run middleware twice.
1122
+ const streamInfo = preloadedStreamInfo ?? (await this.getStream(track));
1110
1123
 
1111
- // Handle remote playback
1112
1124
  if (streamInfo?.remote && streamInfo.handle) {
1113
1125
  return await this.playRemote(track, streamInfo);
1114
1126
  }
@@ -1117,37 +1129,50 @@ export class Player extends EventEmitter {
1117
1129
  throw new Error(`No stream available`);
1118
1130
  }
1119
1131
 
1120
- // Register with StreamManager
1121
- const streamId = this.streamManager.registerStream(streamInfo.stream, track, {
1132
+ // Register the RAW source stream — this is what we can reuse on seek
1133
+ const rawStreamId = this.streamManager.registerStream(streamInfo.stream, track, {
1122
1134
  source: track.source || "stream",
1123
1135
  isPreload: false,
1124
1136
  isRemote: !!streamInfo?.remote,
1125
1137
  priority: 10,
1126
1138
  });
1127
1139
 
1128
- // Create resource
1129
- const resource = await this.createResource(streamInfo, track, 0);
1130
-
1131
- // Clean up old current
1132
- if (this.currentSlot.streamId && this.currentSlot.streamId !== streamId) {
1140
+ // createResource now returns both the AudioResource
1141
+ // AND the processedStream (ffmpeg stdout) when filters/seek are involved.
1142
+ const { resource, processedStream } = await this.createResource(streamInfo, track, 0);
1143
+
1144
+ // when a processedStream exists, register it too so its
1145
+ // lifecycle is tracked. Store its id separately in currentSlot so
1146
+ // destroyCurrentStream() and refreshPlayerResource() clean the right object.
1147
+ let playStreamId = rawStreamId;
1148
+ if (processedStream && processedStream !== streamInfo.stream) {
1149
+ playStreamId = this.streamManager.registerStream(processedStream, track, {
1150
+ source: track.source || "stream-processed",
1151
+ isPreload: false,
1152
+ priority: 10,
1153
+ });
1154
+ this.debug(`[Player] Registered processedStream ${playStreamId} (rawStream: ${rawStreamId})`);
1155
+ }
1156
+ if (this.currentSlot.streamId && this.currentSlot.streamId !== rawStreamId) {
1133
1157
  this.streamManager.unregisterStream(this.currentSlot.streamId, true);
1134
1158
  }
1159
+ if ((this.currentSlot as any).processedStreamId && (this.currentSlot as any).processedStreamId !== playStreamId) {
1160
+ this.streamManager.unregisterStream((this.currentSlot as any).processedStreamId, true);
1161
+ }
1135
1162
 
1136
- // Set current slot
1137
1163
  this.currentSlot.resource = resource;
1138
1164
  this.currentSlot.track = track;
1139
- this.currentSlot.streamId = streamId;
1165
+ this.currentSlot.streamId = rawStreamId;
1166
+ (this.currentSlot as any).processedStreamId = processedStream ? playStreamId : null;
1140
1167
  this.currentSlot.isValid = true;
1141
1168
  this.currentResource = resource;
1142
- this.seekOffset = 0; // new track — reset seek baseline
1169
+ this.seekOffset = 0;
1143
1170
 
1144
- // Apply volume
1145
1171
  const targetVolume = this.getTrackTargetVolume(track);
1146
1172
  if (resource.volume) {
1147
1173
  resource.volume.setVolume(this.crossfadeEnabled ? 0 : targetVolume);
1148
1174
  }
1149
1175
 
1150
- // Play — lock refresh so Idle event doesn't spawn duplicate playNext
1151
1176
  await this.maybeAlignToBeatBoundary();
1152
1177
  this.refreshLock = true;
1153
1178
  try {
@@ -1157,11 +1182,11 @@ export class Player extends EventEmitter {
1157
1182
  } finally {
1158
1183
  this.refreshLock = false;
1159
1184
  }
1185
+
1160
1186
  await this.applyCrossfadeIn(resource, track);
1161
1187
 
1162
- // Preload next (async)
1163
1188
  if (!this.destroyed) {
1164
- this.preloadNextTrack().catch((err) => {
1189
+ this.preloadNextTrack().catch((err: any) => {
1165
1190
  this.debug(`[Player] Preload error:`, err);
1166
1191
  });
1167
1192
  }
@@ -1347,7 +1372,7 @@ export class Player extends EventEmitter {
1347
1372
  throw new Error(`No stream available for track: ${track.title}`);
1348
1373
  }
1349
1374
  ttsStream = streamInfo.stream;
1350
- const resource = await this.createResource(streamInfo as StreamInfo, track);
1375
+ const { resource, processedStream } = await this.createResource(streamInfo as StreamInfo, track);
1351
1376
  if (!resource) {
1352
1377
  throw new Error(`No resource available for track: ${track.title}`);
1353
1378
  }
@@ -1720,6 +1745,7 @@ export class Player extends EventEmitter {
1720
1745
  this.debug("[Player] Cannot stop while subscribed to another player");
1721
1746
  return false;
1722
1747
  }
1748
+ this.recoveryInProgress = false;
1723
1749
  this.debug(`[Player] stop called`);
1724
1750
  if (this.playbackMode === PlaybackMode.REMOTE) {
1725
1751
  this.cancelPreload();
@@ -1811,7 +1837,7 @@ export class Player extends EventEmitter {
1811
1837
  return false;
1812
1838
  }
1813
1839
  this.debug(`[Player] skip called with index: ${index}`);
1814
-
1840
+ this.recoveryInProgress = false;
1815
1841
  if (this.playbackMode === PlaybackMode.REMOTE) {
1816
1842
  if (typeof index === "number" && index >= 0) {
1817
1843
  for (let i = 0; i < index; i++) this.queue.remove(0);
@@ -2434,11 +2460,8 @@ export class Player extends EventEmitter {
2434
2460
  return false;
2435
2461
  }
2436
2462
 
2437
- // Lock before anything so stateChange idle sees it when stop() fires.
2438
2463
  this.refreshLock = true;
2439
2464
 
2440
- // Clear any existing stuckTimer from the previous playback cycle so it
2441
- // cannot fire while we are mid-refresh.
2442
2465
  if (this.stuckTimer) {
2443
2466
  clearTimeout(this.stuckTimer);
2444
2467
  this.stuckTimer = null;
@@ -2453,13 +2476,11 @@ export class Player extends EventEmitter {
2453
2476
  const wasPaused = this.isPaused;
2454
2477
  const playbackDuration = this.currentResource?.playbackDuration ?? 0;
2455
2478
 
2456
- // Reuse is only viable for forward seeks (stream is sequential).
2457
2479
  const isForwardSeek = position < 0 || position >= playbackDuration;
2458
2480
  const currentStreamId = this.currentSlot.streamId;
2459
2481
 
2460
- // Try to grab the live source stream for reuse.
2461
- // getRawStream accepts "paused" streams (discordjs/voice pauses source streams on NoSubscriberBehavior); getStream would reject them.
2462
- let reuseStream: Readable | null = null;
2482
+ // Try to grab the raw source stream for reuse (forward seeks only)
2483
+ let reuseStream: import("stream").Readable | null = null;
2463
2484
  if (isForwardSeek && currentStreamId) {
2464
2485
  reuseStream = this.streamManager.getRawStream(currentStreamId);
2465
2486
  if (reuseStream) {
@@ -2467,31 +2488,29 @@ export class Player extends EventEmitter {
2467
2488
  }
2468
2489
  }
2469
2490
 
2470
- // ── CRITICAL: unpipe BEFORE stop ──────────────────────────────────────
2471
- // stop() kills discordjs/voice internal FFmpeg → EPIPE on source stream.
2472
- // unpipe() first disconnects our stream cleanly before that happens.
2473
2491
  if (reuseStream) {
2474
2492
  reuseStream.unpipe();
2475
2493
  }
2476
2494
 
2477
- // Remove StreamManager listeners.
2478
- // forceDestroy=false when reusing so the Readable object stays alive.
2495
+ // Clean up processedStream first (it's what AudioResource reads)
2496
+ const processedStreamId = (this.currentSlot as any).processedStreamId;
2497
+ if (processedStreamId && processedStreamId !== currentStreamId) {
2498
+ this.streamManager.unregisterStream(processedStreamId, true);
2499
+ (this.currentSlot as any).processedStreamId = null;
2500
+ }
2501
+
2479
2502
  if (currentStreamId) {
2480
2503
  this.streamManager.unregisterStream(currentStreamId, !reuseStream);
2481
2504
  this.currentSlot.streamId = null;
2482
2505
  }
2483
2506
 
2484
- // Stop the AudioPlayer.
2485
- // stateChange (playing→idle) fires; refreshLock=true guards it (v4 fix).
2486
2507
  this.audioPlayer.stop(true);
2487
2508
  this.currentResource = null;
2488
2509
  this.currentSlot.resource = null;
2489
2510
  this.currentSlot.isValid = false;
2490
2511
 
2491
- // One event-loop tick: lets deferred stream events settle.
2492
2512
  await new Promise<void>((resolve) => setImmediate(resolve));
2493
2513
 
2494
- // Verify the reuse stream survived stop().
2495
2514
  if (reuseStream) {
2496
2515
  if (reuseStream.destroyed || (reuseStream as any).readable === false) {
2497
2516
  this.debug(`[Player] Source stream did not survive stop — falling back to fresh stream`);
@@ -2504,7 +2523,6 @@ export class Player extends EventEmitter {
2504
2523
  if (reuseStream) {
2505
2524
  streaminfo = { stream: reuseStream, type: "arbitrary" };
2506
2525
  } else {
2507
- // Clear caches so we don't get the dead Readable back.
2508
2526
  this.pluginManager.clearStreamCache();
2509
2527
  this.extensionManager.clearCache("stream");
2510
2528
  this.debug(`[Player] Fetching fresh stream${!isForwardSeek ? " (backward seek)" : " (reuse failed)"}`);
@@ -2516,22 +2534,33 @@ export class Player extends EventEmitter {
2516
2534
  return false;
2517
2535
  }
2518
2536
 
2519
- // Build AudioResource (input-side FFmpeg seek via FilterManager).
2520
- const resource = await this.createResource(streaminfo, track, currentPosition);
2537
+ const createPosition = reuseStream ? -1 : currentPosition;
2538
+
2539
+ const { resource, processedStream } = await this.createResource(streaminfo, track, createPosition);
2521
2540
 
2522
- // Register the source stream.
2541
+ // Register raw source stream
2523
2542
  const newStreamId = this.streamManager.registerStream(streaminfo.stream, track, {
2524
2543
  source: track.source || "stream",
2525
2544
  isPreload: false,
2526
2545
  priority: 10,
2527
2546
  });
2547
+
2548
+ let newProcessedStreamId: string | null = null;
2549
+ if (processedStream && processedStream !== streaminfo.stream) {
2550
+ newProcessedStreamId = this.streamManager.registerStream(processedStream, track, {
2551
+ source: track.source || "stream-processed",
2552
+ isPreload: false,
2553
+ priority: 10,
2554
+ });
2555
+ }
2556
+
2528
2557
  this.currentSlot.resource = resource;
2529
2558
  this.currentSlot.track = track;
2530
2559
  this.currentSlot.streamId = newStreamId;
2560
+ (this.currentSlot as any).processedStreamId = newProcessedStreamId;
2531
2561
  this.currentSlot.isValid = true;
2532
2562
  this.currentResource = resource;
2533
2563
 
2534
- // ── Set seek flag BEFORE play so the Buffering handler sees it ────────
2535
2564
  if (position >= 0) {
2536
2565
  this.seekInProgress = true;
2537
2566
  }
@@ -2546,11 +2575,11 @@ export class Player extends EventEmitter {
2546
2575
  return true;
2547
2576
  } catch (error) {
2548
2577
  this.debug(`[Player] refreshPlayerResource error:`, error);
2549
- this.seekInProgress = false; // ensure flag is cleared on failure
2578
+ this.seekInProgress = false;
2550
2579
  this.emit("playerError", error as Error, this.queue.currentTrack ?? undefined);
2551
2580
  return false;
2552
2581
  } finally {
2553
- this.refreshLock = false; // always released
2582
+ this.refreshLock = false;
2554
2583
  }
2555
2584
  }
2556
2585
 
@@ -2613,6 +2642,10 @@ export class Player extends EventEmitter {
2613
2642
  this.debug(`[Player] AudioPlayer went idle during resource refresh — skipping trackEnd/playNext`);
2614
2643
  return;
2615
2644
  }
2645
+ if (this.recoveryInProgress) {
2646
+ this.debug(`[Player] AudioPlayer went idle during recovery — skipping playNext`);
2647
+ return;
2648
+ }
2616
2649
  // Track ended
2617
2650
  const track = this.queue.currentTrack;
2618
2651
  if (track) {
@@ -2708,6 +2741,7 @@ export class Player extends EventEmitter {
2708
2741
  });
2709
2742
  this.audioPlayer.on("error", (error) => {
2710
2743
  if (this.destroyed) return;
2744
+ if (this.recoveryInProgress) return;
2711
2745
  this.debug(`[Player] AudioPlayer error:`, error);
2712
2746
  this.emit("playerError", error, this.queue.currentTrack || undefined);
2713
2747
  const track = this.queue.currentTrack;