@torrent-tv/proxy 2.9.91 → 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,14 @@
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
+
7
+ ## 2.9.92
8
+
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.
10
+ - **Chore**: Removed `ENCODER_STALL_MS`, declared with a paragraph describing a watchdog that was never wired to anything.
11
+
1
12
  ## 2.9.91
2
13
 
3
14
  - **Fix**: The encoder no longer runs away from the viewer. Nothing bounded how far ahead it produced: measured 2026-08-04, three minutes after a film was opened the encode had reached 00:39:24 of a 01:26:51 source at 12.8x while the viewer was still at the start, and the torrent had pulled 80% of 4.7 GB to feed it — the pool owner's bandwidth and disk spent on a viewer who may watch two minutes, the pieces being read evicted from memory by pieces forty minutes ahead, and the swarm busy with anything but the segment being waited for. An encoder more than two minutes of content ahead of the last segment its viewer asked for is now **suspended**, and released once the viewer is within a minute of it — or at once when a segment is requested. Suspended rather than killed on purpose: restarting costs about nine seconds on this hardware, so a viewer reaching the end of the produced range would stall every time, while suspending keeps the process, its input and its position. POSIX only; where `SIGSTOP` does not exist the attempt fails once, is logged, and that session keeps the old behaviour. Every path that terminates an encoder now releases it first — a suspended process does not act on `SIGTERM` until it is continued, which would have hung the wait a seek performs before starting its replacement.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.9.91",
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": {
@@ -43,26 +43,15 @@ const DEFAULT_SEGMENT_DURATION_SEC = 4;
43
43
  // is allowed to be before we restart ffmpeg at that position (server-side seek).
44
44
  // Requests within the window are served by waiting for the running encode.
45
45
  const MAX_LOOKAHEAD_SEGMENTS = 8;
46
- // After a seek-restart, ignore competing restart requests for this long. The
47
- // synthetic VOD playlist lets the player request distant segments in quick
48
- // succession (stall-recovery seeks); without a cooldown ffmpeg ping-pongs
49
- // between positions, restarting endlessly and producing nothing.
50
- const RESTART_COOLDOWN_MS = 4_000;
51
- // How long a seek restart waits for the CURRENT run to produce its first
52
- // segment before it is allowed to pre-empt it anyway. Generous, because the
53
- // first segment after a seek is the slowest thing this pipeline does (ffmpeg
54
- // restart + torrent pieces for a fresh position); still bounded so a wedged
55
- // run cannot block seeking forever. See #fireSettledSeek.
56
- const RUN_FIRST_SEGMENT_GRACE_MS = 30_000;
57
- // Encoder stall watchdog. A running ffmpeg emits `-progress` output on stdout
58
- // continuously while it encodes; when it hangs mid-file (alive, but producing
59
- // no output and no stderr — a deadlock, e.g. a stalled input read), that output
60
- // stops and `progress.updatedAt` freezes. If a segment INSIDE the look-ahead
61
- // window is being demanded but progress has not advanced for this long, the
62
- // encoder is wedged (observed: the segment 503s forever). Treat it like a seek
63
- // and restart ffmpeg at the demanded segment. Conservative — a slow-but-moving
64
- // encode keeps advancing `updatedAt`, so this only fires on a true freeze.
65
- const ENCODER_STALL_MS = 12_000;
46
+ // Floor between actual restarts. It used to be 4 s, from when a far segment
47
+ // REQUEST could steer the encoder and a playlist scan produced a burst of them.
48
+ // Requests no longer steer anything (see #ensureEncodingFor) every restart
49
+ // now comes from a position the viewer stated — so this is no longer a policy
50
+ // about noise, only a guard against a client that spams the seek endpoint.
51
+ // Measured cost of the old value 2026-08-04: two seeks 1.3 s apart produced two
52
+ // restarts 4.4 s apart, the first encoding 119.5 s of content nobody wanted
53
+ // before the second killed it.
54
+ const RESTART_COOLDOWN_MS = 500;
66
55
  // How far ahead of the viewer the encoder may run before it is stopped, and how
67
56
  // far it must fall back to before it is let go again.
68
57
  //
@@ -101,10 +90,17 @@ const LOOKAHEAD_RESUME_SECONDS = 60;
101
90
  // encoding 125 s of content before reaching the viewer's position. Field
102
91
  // 2026-08-02: a seek took 56 s, of which ~50 s was this backoff.
103
92
  const SEEK_BACKOFF_SEGMENTS = 1;
104
- const SEEK_SETTLE_MS = 1_200;
105
- // Hard cap on the total settle wait, measured from the first far request of a
93
+ // How long to wait for a scrub to stop moving before acting on it. Small,
94
+ // because the browser already collapses a drag into ONE report
95
+ // (`SEEK_REPORT_DEBOUNCE_MS`, 300 ms) and only reports where it settled — this
96
+ // is a second debounce on an already-debounced signal, and every millisecond of
97
+ // it is dead time in front of the viewer. It was 1.2 s when the encoder was
98
+ // also steered by segment requests, which arrive in bursts of dozens; measured
99
+ // 2026-08-04, that cost 1.2 s of every seek.
100
+ const SEEK_SETTLE_MS = 300;
101
+ // Hard cap on the total settle wait, measured from the first request of a
106
102
  // burst, so a still-moving scrubber cannot delay a genuine seek forever.
107
- const SEEK_SETTLE_MAX_MS = 2_500;
103
+ const SEEK_SETTLE_MAX_MS = 1_000;
108
104
  // Grace period to wait for the PREVIOUS ffmpeg process to exit (per signal
109
105
  // escalation step: SIGTERM, then SIGKILL) before spawning its replacement into
110
106
  // the same session directory. See #startEncodeRun.
@@ -577,6 +573,28 @@ async function probeVideoKeyframeTimes(ffmpegBin, inputUrl, timeoutMs = 25_000)
577
573
  });
578
574
  }
579
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
+
580
598
  /**
581
599
  * Compute segment START times (a 0-based timeline) for a session.
582
600
  *
@@ -1592,24 +1610,41 @@ export class HlsSessionManager {
1592
1610
  */
1593
1611
  #enforceLookAhead() {
1594
1612
  for (const session of this.sessionsById.values()) {
1595
- if (!session || session.state === "disposed" || !session.ffmpeg) {
1596
- continue;
1597
- }
1598
- const encodedTo = Number(session.progress?.processedSeconds);
1599
- if (!Number.isFinite(encodedTo)) {
1600
- continue;
1601
- }
1602
- // Where the viewer is. Before the first segment request, the position the
1603
- // run started at so a session nobody has read from yet is bounded too.
1604
- const viewerAt = Number.isInteger(session.lastRequestedSegment)
1605
- ? this.#segmentStartTime(session, session.lastRequestedSegment)
1606
- : this.#segmentStartTime(session, session.encodeStartIndex ?? 0);
1607
- const ahead = encodedTo - viewerAt;
1608
- if (!session.encoderPaused && ahead > LOOKAHEAD_PAUSE_SECONDS) {
1609
- this.#pauseEncoder(session, `${Math.round(ahead)}s ahead of the viewer`);
1610
- } else if (session.encoderPaused && ahead <= LOOKAHEAD_RESUME_SECONDS) {
1611
- this.#resumeEncoder(session, `${Math.round(ahead)}s ahead of the viewer`);
1612
- }
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`);
1613
1648
  }
1614
1649
  }
1615
1650
 
@@ -1937,17 +1972,17 @@ export class HlsSessionManager {
1937
1972
  if (snappedKeyframe !== null) {
1938
1973
  const residualSeconds = Math.max(0, seekSeconds - snappedKeyframe);
1939
1974
  if (snappedKeyframe > 0) {
1940
- args.push("-ss", String(snappedKeyframe));
1975
+ args.push("-ss", ffmpegSeconds(snappedKeyframe));
1941
1976
  }
1942
1977
  args.push("-i", session.inputUrl);
1943
1978
  if (residualSeconds > 0) {
1944
- args.push("-ss", String(residualSeconds));
1979
+ args.push("-ss", ffmpegSeconds(residualSeconds));
1945
1980
  }
1946
1981
  } else {
1947
1982
  if (seekSeconds > 0) {
1948
1983
  // No keyframe map (probe failed/timed out) — fall back to the previous
1949
1984
  // behaviour: trust the container's own accurate seek.
1950
- args.push("-accurate_seek", "-ss", String(seekSeconds));
1985
+ args.push("-accurate_seek", "-ss", ffmpegSeconds(seekSeconds));
1951
1986
  }
1952
1987
  args.push("-i", session.inputUrl);
1953
1988
  }
@@ -1956,7 +1991,7 @@ export class HlsSessionManager {
1956
1991
  // segment grid; relabel output onto the original timeline so segment N
1957
1992
  // carries PTS = N × segmentDuration.
1958
1993
  if (startSeconds > 0) {
1959
- args.push("-output_ts_offset", String(startSeconds));
1994
+ args.push("-output_ts_offset", ffmpegSeconds(startSeconds));
1960
1995
  }
1961
1996
  } else {
1962
1997
  // Branch B (video copied — only audio is transcoded): we cannot insert
@@ -1968,7 +2003,7 @@ export class HlsSessionManager {
1968
2003
  // beginning and desyncs audio/video). Audio is transcoded on this timeline.
1969
2004
  args.push("-copyts");
1970
2005
  if (sourceStartTime !== 0) {
1971
- args.push("-output_ts_offset", String(-sourceStartTime));
2006
+ args.push("-output_ts_offset", ffmpegSeconds(-sourceStartTime));
1972
2007
  }
1973
2008
  }
1974
2009
  args.push(
@@ -2393,8 +2428,14 @@ export class HlsSessionManager {
2393
2428
  : this.#segmentStartTime(session, head);
2394
2429
  const currentSeg = Math.max(head, this.#segmentIndexForTime(session, processed));
2395
2430
  // Already covered by the running encode — the data is on its way, so
2396
- // restarting would only destroy work the viewer is waiting for.
2397
- 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) {
2398
2439
  logger.info(
2399
2440
  `transcode ${session.id} seek to ${positionSeconds.toFixed(1)}s (#${index}) ` +
2400
2441
  `already within the running encode (#${head}..#${currentSeg}) — not restarting`
@@ -2464,48 +2505,21 @@ export class HlsSessionManager {
2464
2505
  session.seekSettleTimer.unref?.();
2465
2506
  return;
2466
2507
  }
2467
- // Let the CURRENT run finish what it started. Restarting a run that has not
2468
- // yet produced a single segment destroys all its work and starts the wait
2469
- // over and after a seek the first segment is always the slowest, so this
2470
- // is self-perpetuating: field log (2026-08-02, one user seek) shows
2471
- // restarts at #617 → #717 → #732 → #732 every 5-7 s, none of which ever
2472
- // produced anything, leaving the viewer with a flickering loading pill and
2473
- // no playback at all.
2474
- //
2475
- // These extra targets are NOT further user seeks: when the player cannot
2476
- // get its segment it SCANS the playlist (every segment is listed in our
2477
- // synthetic VOD playlist, so from its point of view they all exist), and
2478
- // each far-enough probe looked like a fresh seek to us. Waiting for the
2479
- // first segment makes the scan harmless — it can no longer steer the
2480
- // encoder — and one genuine seek now reliably completes.
2481
- //
2482
- // Bounded by RUN_FIRST_SEGMENT_GRACE_MS so a wedged run cannot block seeks
2483
- // forever; the encoder-stall watchdog and the exit handler cover a run that
2484
- // dies outright.
2508
+ // A run in progress is NOT protected any more. It used to be: a restart was
2509
+ // held for up to 30 s while the current run reached its first segment,
2510
+ // because a far segment REQUEST could steer the encoder and the player's
2511
+ // playlist scan produced dozens of them restarts at #617 → #717 → #732 →
2512
+ // #732 every 5-7 s, none producing anything (field 2026-08-02). Requests
2513
+ // stopped steering anything when the position became explicit, so the only
2514
+ // thing that can arrive here is a position the viewer has stated, and
2515
+ // finishing a segment for where they no longer are is work nobody wants.
2516
+ // Holding it was also expensive in the other direction: a genuine second
2517
+ // seek could be delayed by the whole grace.
2485
2518
  const producedThisRun = this.#producedSecondsThisRun(session);
2486
2519
  const runIsAlive = session.ffmpeg != null && !hasChildExited(session.ffmpeg);
2487
- if (
2488
- runIsAlive &&
2489
- producedThisRun < this.segmentDurationSec &&
2490
- sinceLastRestart < RUN_FIRST_SEGMENT_GRACE_MS
2491
- ) {
2492
- logger.info(
2493
- `transcode ${session.id} seek #${target} HELD — current run has produced ` +
2494
- `${producedThisRun.toFixed(1)}s of the ${this.segmentDurationSec}s first segment ` +
2495
- `(${(sinceLastRestart / 1000).toFixed(1)}s into a ${RUN_FIRST_SEGMENT_GRACE_MS / 1000}s grace)`
2496
- );
2497
- session.seekSettleTimer = setTimeout(() => this.#fireSettledSeek(session), SEEK_SETTLE_MS);
2498
- session.seekSettleTimer.unref?.();
2499
- return;
2500
- }
2501
- // Why the restart was allowed — the counterpart of the HELD line above.
2502
- // Without it a restart is indistinguishable from the runaway ping-pong this
2503
- // guard exists to stop, and diagnosing a field report becomes guesswork.
2504
2520
  const allowedBecause = !runIsAlive
2505
2521
  ? "run is dead"
2506
- : producedThisRun >= this.segmentDurationSec
2507
- ? `run produced ${producedThisRun.toFixed(1)}s (first segment done)`
2508
- : `grace of ${RUN_FIRST_SEGMENT_GRACE_MS / 1000}s expired`;
2522
+ : `viewer moved; run had produced ${producedThisRun.toFixed(1)}s`;
2509
2523
  // The start is exactly what requestSeek computed — one segment before the
2510
2524
  // viewer's position — and nothing else may move it.
2511
2525
  //
@@ -2709,8 +2723,10 @@ export class HlsSessionManager {
2709
2723
  const requested = session.segmentFormat.segmentIndexFromName(fileName);
2710
2724
  if (requested >= 0) {
2711
2725
  session.lastRequestedSegment = requested;
2712
- // A viewer who has caught up must not wait out the monitor's interval.
2713
- 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);
2714
2730
  }
2715
2731
  }
2716
2732
  try {