ziplayer 0.3.7 → 0.3.8

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.
@@ -193,7 +193,7 @@ class Player extends events_1.EventEmitter {
193
193
  extractorTimeout: this.options.extractorTimeout,
194
194
  });
195
195
  this.streamManager = new StreamManager_1.StreamManager({
196
- maxConcurrentStreams: 2,
196
+ maxConcurrentStreams: 4,
197
197
  streamTimeout: 5 * 60 * 1000,
198
198
  maxListenersPerStream: 15,
199
199
  enableMetrics: true,
@@ -751,25 +751,26 @@ class Player extends events_1.EventEmitter {
751
751
  async createResource(streamInfo, track, position = 0) {
752
752
  const filterString = this.filter.getFilterString();
753
753
  this.debug(`[Player] Creating AudioResource — filters: ${filterString || "none"}, seek: ${position}ms`);
754
- // -1 = sentinel "no seek requested"
754
+ this.filter.setSourceStreamType(streamInfo.type);
755
755
  const seekArg = position > 0 ? position : -1;
756
756
  if (filterString || position > 0) {
757
- // throws on failure — do NOT fall back to the already-piped stream
758
757
  const processedStream = await this.filter.applyFiltersAndSeek(streamInfo.stream, seekArg);
759
- streamInfo.type = voice_1.StreamType.Arbitrary;
760
- return (0, voice_1.createAudioResource)(processedStream, {
758
+ // rawStream. Always use Arbitrary so discordjs/voice doesn't re-encode.
759
+ const resource = (0, voice_1.createAudioResource)(processedStream, {
761
760
  metadata: track,
762
761
  inputType: voice_1.StreamType.Arbitrary,
763
762
  inlineVolume: true,
764
763
  });
764
+ return { resource, processedStream };
765
765
  }
766
- return (0, voice_1.createAudioResource)(streamInfo.stream, {
766
+ const resource = (0, voice_1.createAudioResource)(streamInfo.stream, {
767
767
  metadata: track,
768
768
  inputType: streamInfo.type === "webm/opus" ? voice_1.StreamType.WebmOpus
769
769
  : streamInfo.type === "ogg/opus" ? voice_1.StreamType.OggOpus
770
770
  : voice_1.StreamType.Arbitrary,
771
771
  inlineVolume: true,
772
772
  });
773
+ return { resource, processedStream: null };
773
774
  }
774
775
  mergeTrackPreserveRef(target, source) {
775
776
  if (source === target)
@@ -870,7 +871,14 @@ class Player extends events_1.EventEmitter {
870
871
  async startTrack(track) {
871
872
  if (this.destroyed)
872
873
  return false;
873
- // First, get stream info (this will handle remote detection)
874
+ // Check preload BEFORE calling getStream so we never fetch a
875
+ // stream we're about to throw away. The original code called getStream()
876
+ // unconditionally at the top, then used the preload if available — leaking
877
+ // the just-fetched stream and running middleware twice.
878
+ if (this.preloadManager.hasValidPreload(track)) {
879
+ return await this.startFromPreload(track);
880
+ }
881
+ // Only fetch a stream when there is no usable preload.
874
882
  let streamInfo = null;
875
883
  try {
876
884
  streamInfo = await this.getStream(track);
@@ -879,101 +887,103 @@ class Player extends events_1.EventEmitter {
879
887
  this.debug(`[Player] Failed to get stream for track: ${track.title}`, error);
880
888
  throw error;
881
889
  }
882
- // Handle remote playback
890
+ // Remote playback
883
891
  if (streamInfo?.remote && streamInfo.handle) {
884
892
  return await this.playRemote(track, streamInfo);
885
893
  }
886
- // Handle native playback
894
+ // Native playback — pass the already-fetched streamInfo to avoid a second fetch
895
+ return await this.loadFreshStream(track, streamInfo);
896
+ }
897
+ async startFromPreload(track) {
898
+ if (this.destroyed)
899
+ return false;
900
+ this.debug(`[Player] Using preloaded stream for: ${track.title}`);
901
+ const oldStreamId = this.currentSlot.streamId;
902
+ this.promotePreloadToCurrent(track);
903
+ if (oldStreamId && oldStreamId !== this.currentSlot.streamId) {
904
+ this.streamManager.unregisterStream(oldStreamId, true);
905
+ this.debug(`[Player] Released old stream ${oldStreamId} after preload promotion`);
906
+ }
907
+ const currentResource = this.currentSlot.resource;
908
+ if (!currentResource)
909
+ return false;
910
+ this.seekOffset = 0;
911
+ const targetVolume = this.getTrackTargetVolume(track);
912
+ if (currentResource.volume) {
913
+ currentResource.volume.setVolume(this.crossfadeEnabled ? 0 : targetVolume);
914
+ }
915
+ await this.maybeAlignToBeatBoundary();
916
+ this.refreshLock = true;
887
917
  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);
918
+ this.audioPlayer.stop(true);
919
+ this.audioPlayer.play(currentResource);
920
+ await (0, voice_1.entersState)(this.audioPlayer, voice_1.AudioPlayerStatus.Playing, 10_000);
927
921
  }
928
- catch (error) {
929
- this.debug(`[Player] startTrack error:`, error);
930
- this.emit("playerError", error, track);
931
- return false;
922
+ finally {
923
+ this.refreshLock = false;
932
924
  }
925
+ await this.applyCrossfadeIn(currentResource, track);
926
+ this.preloadNextTrack().catch((err) => {
927
+ this.debug(`[Player] Preload error:`, err);
928
+ });
929
+ return true;
933
930
  }
934
931
  /**
935
932
  * Load fresh stream when no preload available
936
933
  */
937
- async loadFreshStream(track) {
934
+ async loadFreshStream(track, preloadedStreamInfo) {
938
935
  if (this.destroyed)
939
936
  return false;
940
- // Cancel preload to free resources
941
937
  await this.safeCancelPreload();
942
938
  try {
943
- const streamInfo = await this.getStream(track);
944
- // Handle remote playback
939
+ // use caller-supplied streamInfo when available so we don't
940
+ // call getStream() a second time and run middleware twice.
941
+ const streamInfo = preloadedStreamInfo ?? (await this.getStream(track));
945
942
  if (streamInfo?.remote && streamInfo.handle) {
946
943
  return await this.playRemote(track, streamInfo);
947
944
  }
948
945
  if (!streamInfo?.stream) {
949
946
  throw new Error(`No stream available`);
950
947
  }
951
- // Register with StreamManager
952
- const streamId = this.streamManager.registerStream(streamInfo.stream, track, {
948
+ // Register the RAW source stream — this is what we can reuse on seek
949
+ const rawStreamId = this.streamManager.registerStream(streamInfo.stream, track, {
953
950
  source: track.source || "stream",
954
951
  isPreload: false,
955
952
  isRemote: !!streamInfo?.remote,
956
953
  priority: 10,
957
954
  });
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) {
955
+ // createResource now returns both the AudioResource
956
+ // AND the processedStream (ffmpeg stdout) when filters/seek are involved.
957
+ const { resource, processedStream } = await this.createResource(streamInfo, track, 0);
958
+ // when a processedStream exists, register it too so its
959
+ // lifecycle is tracked. Store its id separately in currentSlot so
960
+ // destroyCurrentStream() and refreshPlayerResource() clean the right object.
961
+ let playStreamId = rawStreamId;
962
+ if (processedStream && processedStream !== streamInfo.stream) {
963
+ playStreamId = this.streamManager.registerStream(processedStream, track, {
964
+ source: track.source || "stream-processed",
965
+ isPreload: false,
966
+ priority: 10,
967
+ });
968
+ this.debug(`[Player] Registered processedStream ${playStreamId} (rawStream: ${rawStreamId})`);
969
+ }
970
+ if (this.currentSlot.streamId && this.currentSlot.streamId !== rawStreamId) {
962
971
  this.streamManager.unregisterStream(this.currentSlot.streamId, true);
963
972
  }
964
- // Set current slot
973
+ if (this.currentSlot.processedStreamId && this.currentSlot.processedStreamId !== playStreamId) {
974
+ this.streamManager.unregisterStream(this.currentSlot.processedStreamId, true);
975
+ }
965
976
  this.currentSlot.resource = resource;
966
977
  this.currentSlot.track = track;
967
- this.currentSlot.streamId = streamId;
978
+ this.currentSlot.streamId = rawStreamId;
979
+ this.currentSlot.processedStreamId = processedStream ? playStreamId : null;
968
980
  this.currentSlot.isValid = true;
969
981
  this.currentResource = resource;
970
- this.seekOffset = 0; // new track — reset seek baseline
971
- // Apply volume
982
+ this.seekOffset = 0;
972
983
  const targetVolume = this.getTrackTargetVolume(track);
973
984
  if (resource.volume) {
974
985
  resource.volume.setVolume(this.crossfadeEnabled ? 0 : targetVolume);
975
986
  }
976
- // Play — lock refresh so Idle event doesn't spawn duplicate playNext
977
987
  await this.maybeAlignToBeatBoundary();
978
988
  this.refreshLock = true;
979
989
  try {
@@ -985,7 +995,6 @@ class Player extends events_1.EventEmitter {
985
995
  this.refreshLock = false;
986
996
  }
987
997
  await this.applyCrossfadeIn(resource, track);
988
- // Preload next (async)
989
998
  if (!this.destroyed) {
990
999
  this.preloadNextTrack().catch((err) => {
991
1000
  this.debug(`[Player] Preload error:`, err);
@@ -1153,7 +1162,7 @@ class Player extends events_1.EventEmitter {
1153
1162
  throw new Error(`No stream available for track: ${track.title}`);
1154
1163
  }
1155
1164
  ttsStream = streamInfo.stream;
1156
- const resource = await this.createResource(streamInfo, track);
1165
+ const { resource, processedStream } = await this.createResource(streamInfo, track);
1157
1166
  if (!resource) {
1158
1167
  throw new Error(`No resource available for track: ${track.title}`);
1159
1168
  }
@@ -2134,10 +2143,7 @@ class Player extends events_1.EventEmitter {
2134
2143
  this.debug(`[Player] refreshPlayerResource skipped — lock held`);
2135
2144
  return false;
2136
2145
  }
2137
- // Lock before anything so stateChange idle sees it when stop() fires.
2138
2146
  this.refreshLock = true;
2139
- // Clear any existing stuckTimer from the previous playback cycle so it
2140
- // cannot fire while we are mid-refresh.
2141
2147
  if (this.stuckTimer) {
2142
2148
  clearTimeout(this.stuckTimer);
2143
2149
  this.stuckTimer = null;
@@ -2149,11 +2155,9 @@ class Player extends events_1.EventEmitter {
2149
2155
  this.seekOffset = currentPosition;
2150
2156
  const wasPaused = this.isPaused;
2151
2157
  const playbackDuration = this.currentResource?.playbackDuration ?? 0;
2152
- // Reuse is only viable for forward seeks (stream is sequential).
2153
2158
  const isForwardSeek = position < 0 || position >= playbackDuration;
2154
2159
  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.
2160
+ // Try to grab the raw source stream for reuse (forward seeks only)
2157
2161
  let reuseStream = null;
2158
2162
  if (isForwardSeek && currentStreamId) {
2159
2163
  reuseStream = this.streamManager.getRawStream(currentStreamId);
@@ -2161,27 +2165,24 @@ class Player extends events_1.EventEmitter {
2161
2165
  this.debug(`[Player] Will reuse source stream for seek (pos: ${currentPosition}ms)`);
2162
2166
  }
2163
2167
  }
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
2168
  if (reuseStream) {
2168
2169
  reuseStream.unpipe();
2169
2170
  }
2170
- // Remove StreamManager listeners.
2171
- // forceDestroy=false when reusing so the Readable object stays alive.
2171
+ // Clean up processedStream first (it's what AudioResource reads)
2172
+ const processedStreamId = this.currentSlot.processedStreamId;
2173
+ if (processedStreamId && processedStreamId !== currentStreamId) {
2174
+ this.streamManager.unregisterStream(processedStreamId, true);
2175
+ this.currentSlot.processedStreamId = null;
2176
+ }
2172
2177
  if (currentStreamId) {
2173
2178
  this.streamManager.unregisterStream(currentStreamId, !reuseStream);
2174
2179
  this.currentSlot.streamId = null;
2175
2180
  }
2176
- // Stop the AudioPlayer.
2177
- // stateChange (playing→idle) fires; refreshLock=true guards it (v4 fix).
2178
2181
  this.audioPlayer.stop(true);
2179
2182
  this.currentResource = null;
2180
2183
  this.currentSlot.resource = null;
2181
2184
  this.currentSlot.isValid = false;
2182
- // One event-loop tick: lets deferred stream events settle.
2183
2185
  await new Promise((resolve) => setImmediate(resolve));
2184
- // Verify the reuse stream survived stop().
2185
2186
  if (reuseStream) {
2186
2187
  if (reuseStream.destroyed || reuseStream.readable === false) {
2187
2188
  this.debug(`[Player] Source stream did not survive stop — falling back to fresh stream`);
@@ -2193,7 +2194,6 @@ class Player extends events_1.EventEmitter {
2193
2194
  streaminfo = { stream: reuseStream, type: "arbitrary" };
2194
2195
  }
2195
2196
  else {
2196
- // Clear caches so we don't get the dead Readable back.
2197
2197
  this.pluginManager.clearStreamCache();
2198
2198
  this.extensionManager.clearCache("stream");
2199
2199
  this.debug(`[Player] Fetching fresh stream${!isForwardSeek ? " (backward seek)" : " (reuse failed)"}`);
@@ -2203,20 +2203,28 @@ class Player extends events_1.EventEmitter {
2203
2203
  this.debug(`[Player] No stream available for refresh`);
2204
2204
  return false;
2205
2205
  }
2206
- // Build AudioResource (input-side FFmpeg seek via FilterManager).
2207
- const resource = await this.createResource(streaminfo, track, currentPosition);
2208
- // Register the source stream.
2206
+ const createPosition = reuseStream ? -1 : currentPosition;
2207
+ const { resource, processedStream } = await this.createResource(streaminfo, track, createPosition);
2208
+ // Register raw source stream
2209
2209
  const newStreamId = this.streamManager.registerStream(streaminfo.stream, track, {
2210
2210
  source: track.source || "stream",
2211
2211
  isPreload: false,
2212
2212
  priority: 10,
2213
2213
  });
2214
+ let newProcessedStreamId = null;
2215
+ if (processedStream && processedStream !== streaminfo.stream) {
2216
+ newProcessedStreamId = this.streamManager.registerStream(processedStream, track, {
2217
+ source: track.source || "stream-processed",
2218
+ isPreload: false,
2219
+ priority: 10,
2220
+ });
2221
+ }
2214
2222
  this.currentSlot.resource = resource;
2215
2223
  this.currentSlot.track = track;
2216
2224
  this.currentSlot.streamId = newStreamId;
2225
+ this.currentSlot.processedStreamId = newProcessedStreamId;
2217
2226
  this.currentSlot.isValid = true;
2218
2227
  this.currentResource = resource;
2219
- // ── Set seek flag BEFORE play so the Buffering handler sees it ────────
2220
2228
  if (position >= 0) {
2221
2229
  this.seekInProgress = true;
2222
2230
  }
@@ -2231,12 +2239,12 @@ class Player extends events_1.EventEmitter {
2231
2239
  }
2232
2240
  catch (error) {
2233
2241
  this.debug(`[Player] refreshPlayerResource error:`, error);
2234
- this.seekInProgress = false; // ensure flag is cleared on failure
2242
+ this.seekInProgress = false;
2235
2243
  this.emit("playerError", error, this.queue.currentTrack ?? undefined);
2236
2244
  return false;
2237
2245
  }
2238
2246
  finally {
2239
- this.refreshLock = false; // always released
2247
+ this.refreshLock = false;
2240
2248
  }
2241
2249
  }
2242
2250
  /**