@torrent-tv/proxy 2.9.92 → 2.9.93

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.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,9 @@
1
+ ## 2.9.93
2
+
3
+ - **Fix**: A seek could kill playback outright. Restarting at a position that lands exactly on a keyframe leaves a floating-point residue — `seekSeconds - snappedKeyframe` came out as `3.3333333249174757e-7` — and `String()` renders anything below 1e-6 in exponential notation, which ffmpeg's duration parser rejects: `Invalid duration for option ss`. The run died on startup, and from then on every segment request answered 500. Time arguments are now formatted in fixed notation, and a residue under a millisecond is dropped rather than passed on, because it is not a real offset.
4
+ - **Fix**: A session could never recover from a dead encoder. The "already covered by the running encode, not restarting" shortcut did not check that the run was alive, so once one had died `session.ffmpeg` still pointed at the corpse and every later seek was waved through as already covered. One ffmpeg failure therefore became a session that answered 500 for as long as the viewer kept trying.
5
+ - **Fix**: The look-ahead bound held the encoder back but did not keep it there. Any segment request released it, including a request for something produced ten minutes earlier, so it sawtoothed between suspended and running and drifted from 155 s to 922 s ahead of the viewer over three minutes. A request now re-evaluates the same condition the monitor uses instead of resuming outright.
6
+
1
7
  ## 2.9.92
2
8
 
3
9
  - **Fix**: A seek acts on what the viewer asked for, instead of waiting out guards built for a signal that no longer exists. Three delays sat in front of every seek, all of them there because a far segment REQUEST used to steer the encoder and the player's playlist scan produced dozens of them. Requests stopped steering anything when the position became explicit, so what arrives now is only ever a position the viewer stated. The settle window drops from 1.2 s to 300 ms (the browser already collapses a drag into one report at 300 ms — this was a second debounce on an already-debounced signal, and it cost 1.2 s of every measured seek). The floor between restarts drops from 4 s to 500 ms, now a guard against a client spamming the endpoint rather than a policy about noise. And a run in progress is no longer protected for up to 30 s while it reaches its first segment: finishing a segment for a position the viewer has left is work nobody wants, and the hold could delay a genuine second seek by the whole grace. Measured cost of the old behaviour, 2026-08-04: two seeks 1.3 s apart produced two restarts 4.4 s apart, the first encoding 119.5 s of content before the second killed it.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.9.92",
3
+ "version": "2.9.93",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -573,6 +573,28 @@ async function probeVideoKeyframeTimes(ffmpegBin, inputUrl, timeoutMs = 25_000)
573
573
  });
574
574
  }
575
575
 
576
+ /**
577
+ * A number of seconds as ffmpeg will accept it.
578
+ *
579
+ * `String(n)` switches to exponential notation below 1e-6, and ffmpeg's
580
+ * duration parser rejects that outright: a field session died on
581
+ * `Invalid duration for option ss: 3.3333333249174757e-7`, after which the
582
+ * transcode was in state `failed` and every segment request answered 500 for
583
+ * as long as the viewer kept trying. Anything under a millisecond is also not a
584
+ * real offset — it is the residue of subtracting two nearly equal floats — so
585
+ * it is dropped rather than passed on.
586
+ *
587
+ * @param {number} value
588
+ * @returns {string}
589
+ */
590
+ export function ffmpegSeconds(value) {
591
+ if (!Number.isFinite(value) || Math.abs(value) < 0.001) {
592
+ return "0";
593
+ }
594
+ // Microsecond resolution, fixed notation, no trailing zero noise.
595
+ return value.toFixed(6).replace(/\.?0+$/, "");
596
+ }
597
+
576
598
  /**
577
599
  * Compute segment START times (a 0-based timeline) for a session.
578
600
  *
@@ -1588,24 +1610,41 @@ export class HlsSessionManager {
1588
1610
  */
1589
1611
  #enforceLookAhead() {
1590
1612
  for (const session of this.sessionsById.values()) {
1591
- if (!session || session.state === "disposed" || !session.ffmpeg) {
1592
- continue;
1593
- }
1594
- const encodedTo = Number(session.progress?.processedSeconds);
1595
- if (!Number.isFinite(encodedTo)) {
1596
- continue;
1597
- }
1598
- // Where the viewer is. Before the first segment request, the position the
1599
- // run started at so a session nobody has read from yet is bounded too.
1600
- const viewerAt = Number.isInteger(session.lastRequestedSegment)
1601
- ? this.#segmentStartTime(session, session.lastRequestedSegment)
1602
- : this.#segmentStartTime(session, session.encodeStartIndex ?? 0);
1603
- const ahead = encodedTo - viewerAt;
1604
- if (!session.encoderPaused && ahead > LOOKAHEAD_PAUSE_SECONDS) {
1605
- this.#pauseEncoder(session, `${Math.round(ahead)}s ahead of the viewer`);
1606
- } else if (session.encoderPaused && ahead <= LOOKAHEAD_RESUME_SECONDS) {
1607
- this.#resumeEncoder(session, `${Math.round(ahead)}s ahead of the viewer`);
1608
- }
1613
+ this.#enforceLookAheadFor(session);
1614
+ }
1615
+ }
1616
+
1617
+ /**
1618
+ * Decide whether one session's encoder should be running right now.
1619
+ *
1620
+ * Called both on the monitor's interval and the moment a segment is
1621
+ * requested. It must be the SAME decision in both places: an earlier version
1622
+ * simply resumed on any request, which meant a request for a segment produced
1623
+ * ten minutes ago released an encoder that had nothing left to do — measured
1624
+ * 2026-08-04, the encoder sawtoothed between suspended and running and drifted
1625
+ * from 135 s to 702 s ahead of the viewer while doing it.
1626
+ *
1627
+ * @param {HlsSession} session
1628
+ * @returns {void}
1629
+ */
1630
+ #enforceLookAheadFor(session) {
1631
+ if (!session || session.state === "disposed" || !session.ffmpeg) {
1632
+ return;
1633
+ }
1634
+ const encodedTo = Number(session.progress?.processedSeconds);
1635
+ if (!Number.isFinite(encodedTo)) {
1636
+ return;
1637
+ }
1638
+ // Where the viewer is. Before the first segment request, the position the
1639
+ // run started at — so a session nobody has read from yet is bounded too.
1640
+ const viewerAt = Number.isInteger(session.lastRequestedSegment)
1641
+ ? this.#segmentStartTime(session, session.lastRequestedSegment)
1642
+ : this.#segmentStartTime(session, session.encodeStartIndex ?? 0);
1643
+ const ahead = encodedTo - viewerAt;
1644
+ if (!session.encoderPaused && ahead > LOOKAHEAD_PAUSE_SECONDS) {
1645
+ this.#pauseEncoder(session, `${Math.round(ahead)}s ahead of the viewer`);
1646
+ } else if (session.encoderPaused && ahead <= LOOKAHEAD_RESUME_SECONDS) {
1647
+ this.#resumeEncoder(session, `${Math.round(ahead)}s ahead of the viewer`);
1609
1648
  }
1610
1649
  }
1611
1650
 
@@ -1933,17 +1972,17 @@ export class HlsSessionManager {
1933
1972
  if (snappedKeyframe !== null) {
1934
1973
  const residualSeconds = Math.max(0, seekSeconds - snappedKeyframe);
1935
1974
  if (snappedKeyframe > 0) {
1936
- args.push("-ss", String(snappedKeyframe));
1975
+ args.push("-ss", ffmpegSeconds(snappedKeyframe));
1937
1976
  }
1938
1977
  args.push("-i", session.inputUrl);
1939
1978
  if (residualSeconds > 0) {
1940
- args.push("-ss", String(residualSeconds));
1979
+ args.push("-ss", ffmpegSeconds(residualSeconds));
1941
1980
  }
1942
1981
  } else {
1943
1982
  if (seekSeconds > 0) {
1944
1983
  // No keyframe map (probe failed/timed out) — fall back to the previous
1945
1984
  // behaviour: trust the container's own accurate seek.
1946
- args.push("-accurate_seek", "-ss", String(seekSeconds));
1985
+ args.push("-accurate_seek", "-ss", ffmpegSeconds(seekSeconds));
1947
1986
  }
1948
1987
  args.push("-i", session.inputUrl);
1949
1988
  }
@@ -1952,7 +1991,7 @@ export class HlsSessionManager {
1952
1991
  // segment grid; relabel output onto the original timeline so segment N
1953
1992
  // carries PTS = N × segmentDuration.
1954
1993
  if (startSeconds > 0) {
1955
- args.push("-output_ts_offset", String(startSeconds));
1994
+ args.push("-output_ts_offset", ffmpegSeconds(startSeconds));
1956
1995
  }
1957
1996
  } else {
1958
1997
  // Branch B (video copied — only audio is transcoded): we cannot insert
@@ -1964,7 +2003,7 @@ export class HlsSessionManager {
1964
2003
  // beginning and desyncs audio/video). Audio is transcoded on this timeline.
1965
2004
  args.push("-copyts");
1966
2005
  if (sourceStartTime !== 0) {
1967
- args.push("-output_ts_offset", String(-sourceStartTime));
2006
+ args.push("-output_ts_offset", ffmpegSeconds(-sourceStartTime));
1968
2007
  }
1969
2008
  }
1970
2009
  args.push(
@@ -2389,8 +2428,14 @@ export class HlsSessionManager {
2389
2428
  : this.#segmentStartTime(session, head);
2390
2429
  const currentSeg = Math.max(head, this.#segmentIndexForTime(session, processed));
2391
2430
  // Already covered by the running encode — the data is on its way, so
2392
- // restarting would only destroy work the viewer is waiting for.
2393
- if (index >= head && index <= currentSeg + MAX_LOOKAHEAD_SEGMENTS) {
2431
+ // restarting would only destroy work the viewer is waiting for. The run has
2432
+ // to be ALIVE for that to hold: after a run died, `session.ffmpeg` still
2433
+ // pointed at the dead process and every later seek was waved through as
2434
+ // "already covered", so nothing could ever restart it. Measured 2026-08-04:
2435
+ // one ffmpeg failure turned into a session that answered 500 to every
2436
+ // segment for as long as the viewer kept trying.
2437
+ const runIsAlive = session.ffmpeg != null && !hasChildExited(session.ffmpeg);
2438
+ if (runIsAlive && index >= head && index <= currentSeg + MAX_LOOKAHEAD_SEGMENTS) {
2394
2439
  logger.info(
2395
2440
  `transcode ${session.id} seek to ${positionSeconds.toFixed(1)}s (#${index}) ` +
2396
2441
  `already within the running encode (#${head}..#${currentSeg}) — not restarting`
@@ -2678,8 +2723,10 @@ export class HlsSessionManager {
2678
2723
  const requested = session.segmentFormat.segmentIndexFromName(fileName);
2679
2724
  if (requested >= 0) {
2680
2725
  session.lastRequestedSegment = requested;
2681
- // A viewer who has caught up must not wait out the monitor's interval.
2682
- this.#resumeEncoder(session, "a segment was requested");
2726
+ // A viewer who has caught up must not wait out the monitor's interval
2727
+ // but only if they HAVE caught up, which is why this re-evaluates the
2728
+ // same condition instead of resuming outright.
2729
+ this.#enforceLookAheadFor(session);
2683
2730
  }
2684
2731
  }
2685
2732
  try {