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.
@@ -112,6 +112,7 @@ class Player extends events_1.EventEmitter {
112
112
  this.ttsPlayer = null;
113
113
  this.lastDuration = 0;
114
114
  this.seekOffset = 0;
115
+ this.recoveryInProgress = false;
115
116
  this.debug(`[Player] Constructor called for guildId: ${guildId}`);
116
117
  this.guildId = guildId;
117
118
  this.queue = new Queue_1.Queue();
@@ -193,7 +194,7 @@ class Player extends events_1.EventEmitter {
193
194
  extractorTimeout: this.options.extractorTimeout,
194
195
  });
195
196
  this.streamManager = new StreamManager_1.StreamManager({
196
- maxConcurrentStreams: 2,
197
+ maxConcurrentStreams: this.options?.maxStreamStore ?? 4,
197
198
  streamTimeout: 5 * 60 * 1000,
198
199
  maxListenersPerStream: 15,
199
200
  enableMetrics: true,
@@ -659,6 +660,7 @@ class Player extends events_1.EventEmitter {
659
660
  async attemptTrackRecovery(track, reason) {
660
661
  if (!this.antiStuckEnabled)
661
662
  return false;
663
+ this.recoveryInProgress = true;
662
664
  this.debug(`[AntiStuck] Recovery started for: ${track.title}`, reason);
663
665
  const originalQuality = this.options.quality;
664
666
  let attempted = 0;
@@ -676,6 +678,7 @@ class Player extends events_1.EventEmitter {
676
678
  if (startedFromPreload) {
677
679
  this.antiStuckConsecutiveFailures = 0;
678
680
  this.options.quality = originalQuality;
681
+ this.recoveryInProgress = false;
679
682
  return true;
680
683
  }
681
684
  }
@@ -683,6 +686,7 @@ class Player extends events_1.EventEmitter {
683
686
  if (started) {
684
687
  this.antiStuckConsecutiveFailures = 0;
685
688
  this.options.quality = originalQuality;
689
+ this.recoveryInProgress = false;
686
690
  return true;
687
691
  }
688
692
  }
@@ -694,8 +698,10 @@ class Player extends events_1.EventEmitter {
694
698
  this.antiStuckConsecutiveFailures++;
695
699
  if (this.antiStuckConsecutiveFailures >= this.antiStuckControlledSkipThreshold) {
696
700
  this.debug(`[AntiStuck] Controlled skip threshold reached for ${track.title}`);
701
+ this.recoveryInProgress = false;
697
702
  return false;
698
703
  }
704
+ this.recoveryInProgress = false;
699
705
  // Avoid hard skip storm by leaving track for next natural retry window.
700
706
  this.debug(`[AntiStuck] Keeping track for controlled retry window: ${track.title}`);
701
707
  return false;
@@ -751,25 +757,26 @@ class Player extends events_1.EventEmitter {
751
757
  async createResource(streamInfo, track, position = 0) {
752
758
  const filterString = this.filter.getFilterString();
753
759
  this.debug(`[Player] Creating AudioResource — filters: ${filterString || "none"}, seek: ${position}ms`);
754
- // -1 = sentinel "no seek requested"
760
+ this.filter.setSourceStreamType(streamInfo.type);
755
761
  const seekArg = position > 0 ? position : -1;
756
762
  if (filterString || position > 0) {
757
- // throws on failure — do NOT fall back to the already-piped stream
758
763
  const processedStream = await this.filter.applyFiltersAndSeek(streamInfo.stream, seekArg);
759
- streamInfo.type = voice_1.StreamType.Arbitrary;
760
- return (0, voice_1.createAudioResource)(processedStream, {
764
+ // rawStream. Always use Arbitrary so discordjs/voice doesn't re-encode.
765
+ const resource = (0, voice_1.createAudioResource)(processedStream, {
761
766
  metadata: track,
762
767
  inputType: voice_1.StreamType.Arbitrary,
763
768
  inlineVolume: true,
764
769
  });
770
+ return { resource, processedStream };
765
771
  }
766
- return (0, voice_1.createAudioResource)(streamInfo.stream, {
772
+ const resource = (0, voice_1.createAudioResource)(streamInfo.stream, {
767
773
  metadata: track,
768
774
  inputType: streamInfo.type === "webm/opus" ? voice_1.StreamType.WebmOpus
769
775
  : streamInfo.type === "ogg/opus" ? voice_1.StreamType.OggOpus
770
776
  : voice_1.StreamType.Arbitrary,
771
777
  inlineVolume: true,
772
778
  });
779
+ return { resource, processedStream: null };
773
780
  }
774
781
  mergeTrackPreserveRef(target, source) {
775
782
  if (source === target)
@@ -870,7 +877,14 @@ class Player extends events_1.EventEmitter {
870
877
  async startTrack(track) {
871
878
  if (this.destroyed)
872
879
  return false;
873
- // First, get stream info (this will handle remote detection)
880
+ // Check preload BEFORE calling getStream so we never fetch a
881
+ // stream we're about to throw away. The original code called getStream()
882
+ // unconditionally at the top, then used the preload if available — leaking
883
+ // the just-fetched stream and running middleware twice.
884
+ if (this.preloadManager.hasValidPreload(track)) {
885
+ return await this.startFromPreload(track);
886
+ }
887
+ // Only fetch a stream when there is no usable preload.
874
888
  let streamInfo = null;
875
889
  try {
876
890
  streamInfo = await this.getStream(track);
@@ -879,101 +893,103 @@ class Player extends events_1.EventEmitter {
879
893
  this.debug(`[Player] Failed to get stream for track: ${track.title}`, error);
880
894
  throw error;
881
895
  }
882
- // Handle remote playback
896
+ // Remote playback
883
897
  if (streamInfo?.remote && streamInfo.handle) {
884
898
  return await this.playRemote(track, streamInfo);
885
899
  }
886
- // Handle native playback
900
+ // Native playback — pass the already-fetched streamInfo to avoid a second fetch
901
+ return await this.loadFreshStream(track, streamInfo);
902
+ }
903
+ async startFromPreload(track) {
904
+ if (this.destroyed)
905
+ return false;
906
+ this.debug(`[Player] Using preloaded stream for: ${track.title}`);
907
+ const oldStreamId = this.currentSlot.streamId;
908
+ this.promotePreloadToCurrent(track);
909
+ if (oldStreamId && oldStreamId !== this.currentSlot.streamId) {
910
+ this.streamManager.unregisterStream(oldStreamId, true);
911
+ this.debug(`[Player] Released old stream ${oldStreamId} after preload promotion`);
912
+ }
913
+ const currentResource = this.currentSlot.resource;
914
+ if (!currentResource)
915
+ return false;
916
+ this.seekOffset = 0;
917
+ const targetVolume = this.getTrackTargetVolume(track);
918
+ if (currentResource.volume) {
919
+ currentResource.volume.setVolume(this.crossfadeEnabled ? 0 : targetVolume);
920
+ }
921
+ await this.maybeAlignToBeatBoundary();
922
+ this.refreshLock = true;
887
923
  try {
888
- // Try to use preloaded resource
889
- if (this.preloadManager.hasValidPreload(track)) {
890
- this.debug(`[Player] Using preloaded stream for: ${track.title}`);
891
- const oldStreamId = this.currentSlot.streamId;
892
- if (oldStreamId && this.streamManager) {
893
- setTimeout(() => {
894
- if (this.currentSlot.streamId === oldStreamId) {
895
- this.streamManager.unregisterStream(oldStreamId, true);
896
- }
897
- }, 3000);
898
- }
899
- this.promotePreloadToCurrent(track);
900
- const currentResource = this.currentSlot.resource;
901
- if (!currentResource) {
902
- return false;
903
- }
904
- this.seekOffset = 0;
905
- const targetVolume = this.getTrackTargetVolume(track);
906
- if (currentResource.volume) {
907
- currentResource.volume.setVolume(this.crossfadeEnabled ? 0 : targetVolume);
908
- }
909
- await this.maybeAlignToBeatBoundary();
910
- this.refreshLock = true;
911
- try {
912
- this.audioPlayer.stop(true);
913
- this.audioPlayer.play(currentResource);
914
- await (0, voice_1.entersState)(this.audioPlayer, voice_1.AudioPlayerStatus.Playing, 10_000);
915
- }
916
- finally {
917
- this.refreshLock = false;
918
- }
919
- await this.applyCrossfadeIn(currentResource, track);
920
- this.preloadNextTrack().catch((err) => {
921
- this.debug(`[Player] Preload error:`, err);
922
- });
923
- return true;
924
- }
925
- this.debug(`[Player] No preload available, loading fresh: ${track.title}`);
926
- return await this.loadFreshStream(track);
924
+ this.audioPlayer.stop(true);
925
+ this.audioPlayer.play(currentResource);
926
+ await (0, voice_1.entersState)(this.audioPlayer, voice_1.AudioPlayerStatus.Playing, 10_000);
927
927
  }
928
- catch (error) {
929
- this.debug(`[Player] startTrack error:`, error);
930
- this.emit("playerError", error, track);
931
- return false;
928
+ finally {
929
+ this.refreshLock = false;
932
930
  }
931
+ await this.applyCrossfadeIn(currentResource, track);
932
+ this.preloadNextTrack().catch((err) => {
933
+ this.debug(`[Player] Preload error:`, err);
934
+ });
935
+ return true;
933
936
  }
934
937
  /**
935
938
  * Load fresh stream when no preload available
936
939
  */
937
- async loadFreshStream(track) {
940
+ async loadFreshStream(track, preloadedStreamInfo) {
938
941
  if (this.destroyed)
939
942
  return false;
940
- // Cancel preload to free resources
941
943
  await this.safeCancelPreload();
942
944
  try {
943
- const streamInfo = await this.getStream(track);
944
- // Handle remote playback
945
+ // use caller-supplied streamInfo when available so we don't
946
+ // call getStream() a second time and run middleware twice.
947
+ const streamInfo = preloadedStreamInfo ?? (await this.getStream(track));
945
948
  if (streamInfo?.remote && streamInfo.handle) {
946
949
  return await this.playRemote(track, streamInfo);
947
950
  }
948
951
  if (!streamInfo?.stream) {
949
952
  throw new Error(`No stream available`);
950
953
  }
951
- // Register with StreamManager
952
- const streamId = this.streamManager.registerStream(streamInfo.stream, track, {
954
+ // Register the RAW source stream — this is what we can reuse on seek
955
+ const rawStreamId = this.streamManager.registerStream(streamInfo.stream, track, {
953
956
  source: track.source || "stream",
954
957
  isPreload: false,
955
958
  isRemote: !!streamInfo?.remote,
956
959
  priority: 10,
957
960
  });
958
- // Create resource
959
- const resource = await this.createResource(streamInfo, track, 0);
960
- // Clean up old current
961
- if (this.currentSlot.streamId && this.currentSlot.streamId !== streamId) {
961
+ // createResource now returns both the AudioResource
962
+ // AND the processedStream (ffmpeg stdout) when filters/seek are involved.
963
+ const { resource, processedStream } = await this.createResource(streamInfo, track, 0);
964
+ // when a processedStream exists, register it too so its
965
+ // lifecycle is tracked. Store its id separately in currentSlot so
966
+ // destroyCurrentStream() and refreshPlayerResource() clean the right object.
967
+ let playStreamId = rawStreamId;
968
+ if (processedStream && processedStream !== streamInfo.stream) {
969
+ playStreamId = this.streamManager.registerStream(processedStream, track, {
970
+ source: track.source || "stream-processed",
971
+ isPreload: false,
972
+ priority: 10,
973
+ });
974
+ this.debug(`[Player] Registered processedStream ${playStreamId} (rawStream: ${rawStreamId})`);
975
+ }
976
+ if (this.currentSlot.streamId && this.currentSlot.streamId !== rawStreamId) {
962
977
  this.streamManager.unregisterStream(this.currentSlot.streamId, true);
963
978
  }
964
- // Set current slot
979
+ if (this.currentSlot.processedStreamId && this.currentSlot.processedStreamId !== playStreamId) {
980
+ this.streamManager.unregisterStream(this.currentSlot.processedStreamId, true);
981
+ }
965
982
  this.currentSlot.resource = resource;
966
983
  this.currentSlot.track = track;
967
- this.currentSlot.streamId = streamId;
984
+ this.currentSlot.streamId = rawStreamId;
985
+ this.currentSlot.processedStreamId = processedStream ? playStreamId : null;
968
986
  this.currentSlot.isValid = true;
969
987
  this.currentResource = resource;
970
- this.seekOffset = 0; // new track — reset seek baseline
971
- // Apply volume
988
+ this.seekOffset = 0;
972
989
  const targetVolume = this.getTrackTargetVolume(track);
973
990
  if (resource.volume) {
974
991
  resource.volume.setVolume(this.crossfadeEnabled ? 0 : targetVolume);
975
992
  }
976
- // Play — lock refresh so Idle event doesn't spawn duplicate playNext
977
993
  await this.maybeAlignToBeatBoundary();
978
994
  this.refreshLock = true;
979
995
  try {
@@ -985,7 +1001,6 @@ class Player extends events_1.EventEmitter {
985
1001
  this.refreshLock = false;
986
1002
  }
987
1003
  await this.applyCrossfadeIn(resource, track);
988
- // Preload next (async)
989
1004
  if (!this.destroyed) {
990
1005
  this.preloadNextTrack().catch((err) => {
991
1006
  this.debug(`[Player] Preload error:`, err);
@@ -1153,7 +1168,7 @@ class Player extends events_1.EventEmitter {
1153
1168
  throw new Error(`No stream available for track: ${track.title}`);
1154
1169
  }
1155
1170
  ttsStream = streamInfo.stream;
1156
- const resource = await this.createResource(streamInfo, track);
1171
+ const { resource, processedStream } = await this.createResource(streamInfo, track);
1157
1172
  if (!resource) {
1158
1173
  throw new Error(`No resource available for track: ${track.title}`);
1159
1174
  }
@@ -1481,6 +1496,7 @@ class Player extends events_1.EventEmitter {
1481
1496
  this.debug("[Player] Cannot stop while subscribed to another player");
1482
1497
  return false;
1483
1498
  }
1499
+ this.recoveryInProgress = false;
1484
1500
  this.debug(`[Player] stop called`);
1485
1501
  if (this.playbackMode === types_1.PlaybackMode.REMOTE) {
1486
1502
  this.cancelPreload();
@@ -1564,6 +1580,7 @@ class Player extends events_1.EventEmitter {
1564
1580
  return false;
1565
1581
  }
1566
1582
  this.debug(`[Player] skip called with index: ${index}`);
1583
+ this.recoveryInProgress = false;
1567
1584
  if (this.playbackMode === types_1.PlaybackMode.REMOTE) {
1568
1585
  if (typeof index === "number" && index >= 0) {
1569
1586
  for (let i = 0; i < index; i++)
@@ -2134,10 +2151,7 @@ class Player extends events_1.EventEmitter {
2134
2151
  this.debug(`[Player] refreshPlayerResource skipped — lock held`);
2135
2152
  return false;
2136
2153
  }
2137
- // Lock before anything so stateChange idle sees it when stop() fires.
2138
2154
  this.refreshLock = true;
2139
- // Clear any existing stuckTimer from the previous playback cycle so it
2140
- // cannot fire while we are mid-refresh.
2141
2155
  if (this.stuckTimer) {
2142
2156
  clearTimeout(this.stuckTimer);
2143
2157
  this.stuckTimer = null;
@@ -2149,11 +2163,9 @@ class Player extends events_1.EventEmitter {
2149
2163
  this.seekOffset = currentPosition;
2150
2164
  const wasPaused = this.isPaused;
2151
2165
  const playbackDuration = this.currentResource?.playbackDuration ?? 0;
2152
- // Reuse is only viable for forward seeks (stream is sequential).
2153
2166
  const isForwardSeek = position < 0 || position >= playbackDuration;
2154
2167
  const currentStreamId = this.currentSlot.streamId;
2155
- // Try to grab the live source stream for reuse.
2156
- // getRawStream accepts "paused" streams (discordjs/voice pauses source streams on NoSubscriberBehavior); getStream would reject them.
2168
+ // Try to grab the raw source stream for reuse (forward seeks only)
2157
2169
  let reuseStream = null;
2158
2170
  if (isForwardSeek && currentStreamId) {
2159
2171
  reuseStream = this.streamManager.getRawStream(currentStreamId);
@@ -2161,27 +2173,24 @@ class Player extends events_1.EventEmitter {
2161
2173
  this.debug(`[Player] Will reuse source stream for seek (pos: ${currentPosition}ms)`);
2162
2174
  }
2163
2175
  }
2164
- // ── CRITICAL: unpipe BEFORE stop ──────────────────────────────────────
2165
- // stop() kills discordjs/voice internal FFmpeg → EPIPE on source stream.
2166
- // unpipe() first disconnects our stream cleanly before that happens.
2167
2176
  if (reuseStream) {
2168
2177
  reuseStream.unpipe();
2169
2178
  }
2170
- // Remove StreamManager listeners.
2171
- // forceDestroy=false when reusing so the Readable object stays alive.
2179
+ // Clean up processedStream first (it's what AudioResource reads)
2180
+ const processedStreamId = this.currentSlot.processedStreamId;
2181
+ if (processedStreamId && processedStreamId !== currentStreamId) {
2182
+ this.streamManager.unregisterStream(processedStreamId, true);
2183
+ this.currentSlot.processedStreamId = null;
2184
+ }
2172
2185
  if (currentStreamId) {
2173
2186
  this.streamManager.unregisterStream(currentStreamId, !reuseStream);
2174
2187
  this.currentSlot.streamId = null;
2175
2188
  }
2176
- // Stop the AudioPlayer.
2177
- // stateChange (playing→idle) fires; refreshLock=true guards it (v4 fix).
2178
2189
  this.audioPlayer.stop(true);
2179
2190
  this.currentResource = null;
2180
2191
  this.currentSlot.resource = null;
2181
2192
  this.currentSlot.isValid = false;
2182
- // One event-loop tick: lets deferred stream events settle.
2183
2193
  await new Promise((resolve) => setImmediate(resolve));
2184
- // Verify the reuse stream survived stop().
2185
2194
  if (reuseStream) {
2186
2195
  if (reuseStream.destroyed || reuseStream.readable === false) {
2187
2196
  this.debug(`[Player] Source stream did not survive stop — falling back to fresh stream`);
@@ -2193,7 +2202,6 @@ class Player extends events_1.EventEmitter {
2193
2202
  streaminfo = { stream: reuseStream, type: "arbitrary" };
2194
2203
  }
2195
2204
  else {
2196
- // Clear caches so we don't get the dead Readable back.
2197
2205
  this.pluginManager.clearStreamCache();
2198
2206
  this.extensionManager.clearCache("stream");
2199
2207
  this.debug(`[Player] Fetching fresh stream${!isForwardSeek ? " (backward seek)" : " (reuse failed)"}`);
@@ -2203,20 +2211,28 @@ class Player extends events_1.EventEmitter {
2203
2211
  this.debug(`[Player] No stream available for refresh`);
2204
2212
  return false;
2205
2213
  }
2206
- // Build AudioResource (input-side FFmpeg seek via FilterManager).
2207
- const resource = await this.createResource(streaminfo, track, currentPosition);
2208
- // Register the source stream.
2214
+ const createPosition = reuseStream ? -1 : currentPosition;
2215
+ const { resource, processedStream } = await this.createResource(streaminfo, track, createPosition);
2216
+ // Register raw source stream
2209
2217
  const newStreamId = this.streamManager.registerStream(streaminfo.stream, track, {
2210
2218
  source: track.source || "stream",
2211
2219
  isPreload: false,
2212
2220
  priority: 10,
2213
2221
  });
2222
+ let newProcessedStreamId = null;
2223
+ if (processedStream && processedStream !== streaminfo.stream) {
2224
+ newProcessedStreamId = this.streamManager.registerStream(processedStream, track, {
2225
+ source: track.source || "stream-processed",
2226
+ isPreload: false,
2227
+ priority: 10,
2228
+ });
2229
+ }
2214
2230
  this.currentSlot.resource = resource;
2215
2231
  this.currentSlot.track = track;
2216
2232
  this.currentSlot.streamId = newStreamId;
2233
+ this.currentSlot.processedStreamId = newProcessedStreamId;
2217
2234
  this.currentSlot.isValid = true;
2218
2235
  this.currentResource = resource;
2219
- // ── Set seek flag BEFORE play so the Buffering handler sees it ────────
2220
2236
  if (position >= 0) {
2221
2237
  this.seekInProgress = true;
2222
2238
  }
@@ -2231,12 +2247,12 @@ class Player extends events_1.EventEmitter {
2231
2247
  }
2232
2248
  catch (error) {
2233
2249
  this.debug(`[Player] refreshPlayerResource error:`, error);
2234
- this.seekInProgress = false; // ensure flag is cleared on failure
2250
+ this.seekInProgress = false;
2235
2251
  this.emit("playerError", error, this.queue.currentTrack ?? undefined);
2236
2252
  return false;
2237
2253
  }
2238
2254
  finally {
2239
- this.refreshLock = false; // always released
2255
+ this.refreshLock = false;
2240
2256
  }
2241
2257
  }
2242
2258
  /**
@@ -2293,6 +2309,10 @@ class Player extends events_1.EventEmitter {
2293
2309
  this.debug(`[Player] AudioPlayer went idle during resource refresh — skipping trackEnd/playNext`);
2294
2310
  return;
2295
2311
  }
2312
+ if (this.recoveryInProgress) {
2313
+ this.debug(`[Player] AudioPlayer went idle during recovery — skipping playNext`);
2314
+ return;
2315
+ }
2296
2316
  // Track ended
2297
2317
  const track = this.queue.currentTrack;
2298
2318
  if (track) {
@@ -2389,6 +2409,8 @@ class Player extends events_1.EventEmitter {
2389
2409
  this.audioPlayer.on("error", (error) => {
2390
2410
  if (this.destroyed)
2391
2411
  return;
2412
+ if (this.recoveryInProgress)
2413
+ return;
2392
2414
  this.debug(`[Player] AudioPlayer error:`, error);
2393
2415
  this.emit("playerError", error, this.queue.currentTrack || undefined);
2394
2416
  const track = this.queue.currentTrack;