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.
@@ -269,7 +269,7 @@ export class Player extends EventEmitter {
269
269
  extractorTimeout: this.options.extractorTimeout,
270
270
  });
271
271
  this.streamManager = new StreamManager({
272
- maxConcurrentStreams: 2,
272
+ maxConcurrentStreams: 4,
273
273
  streamTimeout: 5 * 60 * 1000,
274
274
  maxListenersPerStream: 15,
275
275
  enableMetrics: true,
@@ -887,26 +887,32 @@ export class Player extends EventEmitter {
887
887
  * @param {number} position - Position in milliseconds to seek to (0 = no seek)
888
888
  * @returns {Promise<AudioResource>} The AudioResource with filters and seek applied
889
889
  */
890
- private async createResource(streamInfo: StreamInfo, track: Track, position: number = 0): Promise<AudioResource> {
890
+ private async createResource(
891
+ streamInfo: StreamInfo,
892
+ track: Track,
893
+ position: number = 0,
894
+ ): Promise<{ resource: AudioResource; processedStream: import("stream").Readable | null }> {
891
895
  const filterString = this.filter.getFilterString();
892
896
  this.debug(`[Player] Creating AudioResource — filters: ${filterString || "none"}, seek: ${position}ms`);
893
897
 
894
- // -1 = sentinel "no seek requested"
898
+ this.filter.setSourceStreamType(streamInfo.type);
899
+
895
900
  const seekArg = position > 0 ? position : -1;
896
901
 
897
902
  if (filterString || position > 0) {
898
- // throws on failure — do NOT fall back to the already-piped stream
899
903
  const processedStream = await this.filter.applyFiltersAndSeek(streamInfo.stream, seekArg);
900
- streamInfo.type = StreamType.Arbitrary as any;
901
904
 
902
- return createAudioResource(processedStream, {
905
+ // rawStream. Always use Arbitrary so discordjs/voice doesn't re-encode.
906
+ const resource = createAudioResource(processedStream, {
903
907
  metadata: track,
904
908
  inputType: StreamType.Arbitrary,
905
909
  inlineVolume: true,
906
910
  });
911
+
912
+ return { resource, processedStream };
907
913
  }
908
914
 
909
- return createAudioResource(streamInfo.stream, {
915
+ const resource = createAudioResource(streamInfo.stream, {
910
916
  metadata: track,
911
917
  inputType:
912
918
  streamInfo.type === "webm/opus" ? StreamType.WebmOpus
@@ -914,6 +920,8 @@ export class Player extends EventEmitter {
914
920
  : StreamType.Arbitrary,
915
921
  inlineVolume: true,
916
922
  });
923
+
924
+ return { resource, processedStream: null };
917
925
  }
918
926
 
919
927
  private mergeTrackPreserveRef(target: Track, source: Track): void {
@@ -1027,9 +1035,16 @@ export class Player extends EventEmitter {
1027
1035
  private async startTrack(track: Track): Promise<boolean> {
1028
1036
  if (this.destroyed) return false;
1029
1037
 
1030
- // First, get stream info (this will handle remote detection)
1031
- let streamInfo: StreamInfo | null = null;
1038
+ // Check preload BEFORE calling getStream so we never fetch a
1039
+ // stream we're about to throw away. The original code called getStream()
1040
+ // unconditionally at the top, then used the preload if available — leaking
1041
+ // the just-fetched stream and running middleware twice.
1042
+ if (this.preloadManager.hasValidPreload(track)) {
1043
+ return await this.startFromPreload(track);
1044
+ }
1032
1045
 
1046
+ // Only fetch a stream when there is no usable preload.
1047
+ let streamInfo: StreamInfo | null = null;
1033
1048
  try {
1034
1049
  streamInfo = await this.getStream(track);
1035
1050
  } catch (error) {
@@ -1037,78 +1052,70 @@ export class Player extends EventEmitter {
1037
1052
  throw error;
1038
1053
  }
1039
1054
 
1040
- // Handle remote playback
1055
+ // Remote playback
1041
1056
  if (streamInfo?.remote && streamInfo.handle) {
1042
1057
  return await this.playRemote(track, streamInfo);
1043
1058
  }
1044
1059
 
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
- }
1060
+ // Native playback — pass the already-fetched streamInfo to avoid a second fetch
1061
+ return await this.loadFreshStream(track, streamInfo);
1062
+ }
1063
+ private async startFromPreload(track: Track): Promise<boolean> {
1064
+ if (this.destroyed) return false;
1059
1065
 
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);
1066
+ this.debug(`[Player] Using preloaded stream for: ${track.title}`);
1067
1067
 
1068
- if (currentResource.volume) {
1069
- currentResource.volume.setVolume(this.crossfadeEnabled ? 0 : targetVolume);
1070
- }
1068
+ const oldStreamId = this.currentSlot.streamId;
1071
1069
 
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);
1070
+ this.promotePreloadToCurrent(track);
1082
1071
 
1083
- this.preloadNextTrack().catch((err) => {
1084
- this.debug(`[Player] Preload error:`, err);
1085
- });
1072
+ if (oldStreamId && oldStreamId !== this.currentSlot.streamId) {
1073
+ this.streamManager.unregisterStream(oldStreamId, true);
1074
+ this.debug(`[Player] Released old stream ${oldStreamId} after preload promotion`);
1075
+ }
1086
1076
 
1087
- return true;
1088
- }
1077
+ const currentResource = this.currentSlot.resource;
1078
+ if (!currentResource) return false;
1089
1079
 
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;
1080
+ this.seekOffset = 0;
1081
+ const targetVolume = this.getTrackTargetVolume(track);
1082
+
1083
+ if (currentResource.volume) {
1084
+ currentResource.volume.setVolume(this.crossfadeEnabled ? 0 : targetVolume);
1085
+ }
1086
+
1087
+ await this.maybeAlignToBeatBoundary();
1088
+ this.refreshLock = true;
1089
+ try {
1090
+ this.audioPlayer.stop(true);
1091
+ this.audioPlayer.play(currentResource);
1092
+ await entersState(this.audioPlayer, AudioPlayerStatus.Playing, 10_000);
1093
+ } finally {
1094
+ this.refreshLock = false;
1096
1095
  }
1096
+
1097
+ await this.applyCrossfadeIn(currentResource, track);
1098
+
1099
+ this.preloadNextTrack().catch((err: any) => {
1100
+ this.debug(`[Player] Preload error:`, err);
1101
+ });
1102
+
1103
+ return true;
1097
1104
  }
1098
1105
 
1099
1106
  /**
1100
1107
  * Load fresh stream when no preload available
1101
1108
  */
1102
- private async loadFreshStream(track: Track): Promise<boolean> {
1109
+ private async loadFreshStream(track: Track, preloadedStreamInfo?: StreamInfo | null): Promise<boolean> {
1103
1110
  if (this.destroyed) return false;
1104
1111
 
1105
- // Cancel preload to free resources
1106
1112
  await this.safeCancelPreload();
1107
1113
 
1108
1114
  try {
1109
- const streamInfo = await this.getStream(track);
1115
+ // use caller-supplied streamInfo when available so we don't
1116
+ // call getStream() a second time and run middleware twice.
1117
+ const streamInfo = preloadedStreamInfo ?? (await this.getStream(track));
1110
1118
 
1111
- // Handle remote playback
1112
1119
  if (streamInfo?.remote && streamInfo.handle) {
1113
1120
  return await this.playRemote(track, streamInfo);
1114
1121
  }
@@ -1117,37 +1124,50 @@ export class Player extends EventEmitter {
1117
1124
  throw new Error(`No stream available`);
1118
1125
  }
1119
1126
 
1120
- // Register with StreamManager
1121
- const streamId = this.streamManager.registerStream(streamInfo.stream, track, {
1127
+ // Register the RAW source stream — this is what we can reuse on seek
1128
+ const rawStreamId = this.streamManager.registerStream(streamInfo.stream, track, {
1122
1129
  source: track.source || "stream",
1123
1130
  isPreload: false,
1124
1131
  isRemote: !!streamInfo?.remote,
1125
1132
  priority: 10,
1126
1133
  });
1127
1134
 
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) {
1135
+ // createResource now returns both the AudioResource
1136
+ // AND the processedStream (ffmpeg stdout) when filters/seek are involved.
1137
+ const { resource, processedStream } = await this.createResource(streamInfo, track, 0);
1138
+
1139
+ // when a processedStream exists, register it too so its
1140
+ // lifecycle is tracked. Store its id separately in currentSlot so
1141
+ // destroyCurrentStream() and refreshPlayerResource() clean the right object.
1142
+ let playStreamId = rawStreamId;
1143
+ if (processedStream && processedStream !== streamInfo.stream) {
1144
+ playStreamId = this.streamManager.registerStream(processedStream, track, {
1145
+ source: track.source || "stream-processed",
1146
+ isPreload: false,
1147
+ priority: 10,
1148
+ });
1149
+ this.debug(`[Player] Registered processedStream ${playStreamId} (rawStream: ${rawStreamId})`);
1150
+ }
1151
+ if (this.currentSlot.streamId && this.currentSlot.streamId !== rawStreamId) {
1133
1152
  this.streamManager.unregisterStream(this.currentSlot.streamId, true);
1134
1153
  }
1154
+ if ((this.currentSlot as any).processedStreamId && (this.currentSlot as any).processedStreamId !== playStreamId) {
1155
+ this.streamManager.unregisterStream((this.currentSlot as any).processedStreamId, true);
1156
+ }
1135
1157
 
1136
- // Set current slot
1137
1158
  this.currentSlot.resource = resource;
1138
1159
  this.currentSlot.track = track;
1139
- this.currentSlot.streamId = streamId;
1160
+ this.currentSlot.streamId = rawStreamId;
1161
+ (this.currentSlot as any).processedStreamId = processedStream ? playStreamId : null;
1140
1162
  this.currentSlot.isValid = true;
1141
1163
  this.currentResource = resource;
1142
- this.seekOffset = 0; // new track — reset seek baseline
1164
+ this.seekOffset = 0;
1143
1165
 
1144
- // Apply volume
1145
1166
  const targetVolume = this.getTrackTargetVolume(track);
1146
1167
  if (resource.volume) {
1147
1168
  resource.volume.setVolume(this.crossfadeEnabled ? 0 : targetVolume);
1148
1169
  }
1149
1170
 
1150
- // Play — lock refresh so Idle event doesn't spawn duplicate playNext
1151
1171
  await this.maybeAlignToBeatBoundary();
1152
1172
  this.refreshLock = true;
1153
1173
  try {
@@ -1157,11 +1177,11 @@ export class Player extends EventEmitter {
1157
1177
  } finally {
1158
1178
  this.refreshLock = false;
1159
1179
  }
1180
+
1160
1181
  await this.applyCrossfadeIn(resource, track);
1161
1182
 
1162
- // Preload next (async)
1163
1183
  if (!this.destroyed) {
1164
- this.preloadNextTrack().catch((err) => {
1184
+ this.preloadNextTrack().catch((err: any) => {
1165
1185
  this.debug(`[Player] Preload error:`, err);
1166
1186
  });
1167
1187
  }
@@ -1347,7 +1367,7 @@ export class Player extends EventEmitter {
1347
1367
  throw new Error(`No stream available for track: ${track.title}`);
1348
1368
  }
1349
1369
  ttsStream = streamInfo.stream;
1350
- const resource = await this.createResource(streamInfo as StreamInfo, track);
1370
+ const { resource, processedStream } = await this.createResource(streamInfo as StreamInfo, track);
1351
1371
  if (!resource) {
1352
1372
  throw new Error(`No resource available for track: ${track.title}`);
1353
1373
  }
@@ -2434,11 +2454,8 @@ export class Player extends EventEmitter {
2434
2454
  return false;
2435
2455
  }
2436
2456
 
2437
- // Lock before anything so stateChange idle sees it when stop() fires.
2438
2457
  this.refreshLock = true;
2439
2458
 
2440
- // Clear any existing stuckTimer from the previous playback cycle so it
2441
- // cannot fire while we are mid-refresh.
2442
2459
  if (this.stuckTimer) {
2443
2460
  clearTimeout(this.stuckTimer);
2444
2461
  this.stuckTimer = null;
@@ -2453,13 +2470,11 @@ export class Player extends EventEmitter {
2453
2470
  const wasPaused = this.isPaused;
2454
2471
  const playbackDuration = this.currentResource?.playbackDuration ?? 0;
2455
2472
 
2456
- // Reuse is only viable for forward seeks (stream is sequential).
2457
2473
  const isForwardSeek = position < 0 || position >= playbackDuration;
2458
2474
  const currentStreamId = this.currentSlot.streamId;
2459
2475
 
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;
2476
+ // Try to grab the raw source stream for reuse (forward seeks only)
2477
+ let reuseStream: import("stream").Readable | null = null;
2463
2478
  if (isForwardSeek && currentStreamId) {
2464
2479
  reuseStream = this.streamManager.getRawStream(currentStreamId);
2465
2480
  if (reuseStream) {
@@ -2467,31 +2482,29 @@ export class Player extends EventEmitter {
2467
2482
  }
2468
2483
  }
2469
2484
 
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
2485
  if (reuseStream) {
2474
2486
  reuseStream.unpipe();
2475
2487
  }
2476
2488
 
2477
- // Remove StreamManager listeners.
2478
- // forceDestroy=false when reusing so the Readable object stays alive.
2489
+ // Clean up processedStream first (it's what AudioResource reads)
2490
+ const processedStreamId = (this.currentSlot as any).processedStreamId;
2491
+ if (processedStreamId && processedStreamId !== currentStreamId) {
2492
+ this.streamManager.unregisterStream(processedStreamId, true);
2493
+ (this.currentSlot as any).processedStreamId = null;
2494
+ }
2495
+
2479
2496
  if (currentStreamId) {
2480
2497
  this.streamManager.unregisterStream(currentStreamId, !reuseStream);
2481
2498
  this.currentSlot.streamId = null;
2482
2499
  }
2483
2500
 
2484
- // Stop the AudioPlayer.
2485
- // stateChange (playing→idle) fires; refreshLock=true guards it (v4 fix).
2486
2501
  this.audioPlayer.stop(true);
2487
2502
  this.currentResource = null;
2488
2503
  this.currentSlot.resource = null;
2489
2504
  this.currentSlot.isValid = false;
2490
2505
 
2491
- // One event-loop tick: lets deferred stream events settle.
2492
2506
  await new Promise<void>((resolve) => setImmediate(resolve));
2493
2507
 
2494
- // Verify the reuse stream survived stop().
2495
2508
  if (reuseStream) {
2496
2509
  if (reuseStream.destroyed || (reuseStream as any).readable === false) {
2497
2510
  this.debug(`[Player] Source stream did not survive stop — falling back to fresh stream`);
@@ -2504,7 +2517,6 @@ export class Player extends EventEmitter {
2504
2517
  if (reuseStream) {
2505
2518
  streaminfo = { stream: reuseStream, type: "arbitrary" };
2506
2519
  } else {
2507
- // Clear caches so we don't get the dead Readable back.
2508
2520
  this.pluginManager.clearStreamCache();
2509
2521
  this.extensionManager.clearCache("stream");
2510
2522
  this.debug(`[Player] Fetching fresh stream${!isForwardSeek ? " (backward seek)" : " (reuse failed)"}`);
@@ -2516,22 +2528,33 @@ export class Player extends EventEmitter {
2516
2528
  return false;
2517
2529
  }
2518
2530
 
2519
- // Build AudioResource (input-side FFmpeg seek via FilterManager).
2520
- const resource = await this.createResource(streaminfo, track, currentPosition);
2531
+ const createPosition = reuseStream ? -1 : currentPosition;
2532
+
2533
+ const { resource, processedStream } = await this.createResource(streaminfo, track, createPosition);
2521
2534
 
2522
- // Register the source stream.
2535
+ // Register raw source stream
2523
2536
  const newStreamId = this.streamManager.registerStream(streaminfo.stream, track, {
2524
2537
  source: track.source || "stream",
2525
2538
  isPreload: false,
2526
2539
  priority: 10,
2527
2540
  });
2541
+
2542
+ let newProcessedStreamId: string | null = null;
2543
+ if (processedStream && processedStream !== streaminfo.stream) {
2544
+ newProcessedStreamId = this.streamManager.registerStream(processedStream, track, {
2545
+ source: track.source || "stream-processed",
2546
+ isPreload: false,
2547
+ priority: 10,
2548
+ });
2549
+ }
2550
+
2528
2551
  this.currentSlot.resource = resource;
2529
2552
  this.currentSlot.track = track;
2530
2553
  this.currentSlot.streamId = newStreamId;
2554
+ (this.currentSlot as any).processedStreamId = newProcessedStreamId;
2531
2555
  this.currentSlot.isValid = true;
2532
2556
  this.currentResource = resource;
2533
2557
 
2534
- // ── Set seek flag BEFORE play so the Buffering handler sees it ────────
2535
2558
  if (position >= 0) {
2536
2559
  this.seekInProgress = true;
2537
2560
  }
@@ -2546,11 +2569,11 @@ export class Player extends EventEmitter {
2546
2569
  return true;
2547
2570
  } catch (error) {
2548
2571
  this.debug(`[Player] refreshPlayerResource error:`, error);
2549
- this.seekInProgress = false; // ensure flag is cleared on failure
2572
+ this.seekInProgress = false;
2550
2573
  this.emit("playerError", error as Error, this.queue.currentTrack ?? undefined);
2551
2574
  return false;
2552
2575
  } finally {
2553
- this.refreshLock = false; // always released
2576
+ this.refreshLock = false;
2554
2577
  }
2555
2578
  }
2556
2579