@torrent-tv/proxy 2.9.90 → 2.9.92

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,13 @@
1
+ ## 2.9.92
2
+
3
+ - **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.
4
+ - **Chore**: Removed `ENCODER_STALL_MS`, declared with a paragraph describing a watchdog that was never wired to anything.
5
+
6
+ ## 2.9.91
7
+
8
+ - **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.
9
+ - **New**: A reader reports what it waited for. When a read blocks a second or more on a piece, the log names the piece, its position in the read, and the offset the read started at. The first segment after a seek-restart costs 9.2-9.4 s and there was no way to tell whether that is the swarm, the piece picker or ffmpeg; now there is.
10
+
1
11
  ## 2.9.90
2
12
 
3
13
  - **New**: The output container is chosen per session, by the viewer, instead of once per proxy. `POST /api/transcode-sessions` accepts `segmentFormat`; `--segment-format` remains the default for a client that expresses no preference, and an unrecognised value falls back to it rather than to the library default. The browser is the only party that knows what its media stack will accept for the tracks it asked to be copied: a copied MP3 track cannot be appended from fMP4 at all (`audio/mp4; codecs="mp4a.69"` is refused by MediaSource) but works from MPEG-TS, which hls.js demuxes itself and hands to a plain `audio/mpeg` buffer — the same file, the same browser, silent loop one way and normal playback the other. Sessions are keyed by container too, so two viewers wanting different ones do not share an encoder. Nothing branches on the format outside `services/segment-formats/`; the manager now reads it off the session.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.9.90",
3
+ "version": "2.9.92",
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,32 @@ 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;
55
+ // How far ahead of the viewer the encoder may run before it is stopped, and how
56
+ // far it must fall back to before it is let go again.
57
+ //
58
+ // Nothing used to bound this. Measured 2026-08-04 on the copy path: three
59
+ // minutes after a film was opened the encode had reached 00:39:24 of a 01:26:51
60
+ // source at 12.8x while the viewer was still at the start, and the torrent had
61
+ // pulled 80% of 4.7 GB to feed it. That costs the pool owner's bandwidth and
62
+ // disk for a viewer who may watch two minutes, evicts from memory the pieces
63
+ // the viewer is actually reading, and competes for the swarm with the segment
64
+ // being waited on.
65
+ //
66
+ // In seconds of content rather than segments, because a segment is 4 s of
67
+ // re-encoded video but a whole keyframe interval on the copy path. Generous
68
+ // enough that ordinary watching never touches it: the encoder fills two minutes
69
+ // ahead, stops, and is released as soon as the viewer has spent a minute of it.
70
+ const LOOKAHEAD_PAUSE_SECONDS = 120;
71
+ const LOOKAHEAD_RESUME_SECONDS = 60;
66
72
  // Seek debounce. A far (out-of-window) segment request is a server-side seek.
67
73
  // Rather than restart ffmpeg on the first one, wait a short quiet period:
68
74
  // further far requests re-arm it and update the target to the latest index, so
@@ -84,10 +90,17 @@ const ENCODER_STALL_MS = 12_000;
84
90
  // encoding 125 s of content before reaching the viewer's position. Field
85
91
  // 2026-08-02: a seek took 56 s, of which ~50 s was this backoff.
86
92
  const SEEK_BACKOFF_SEGMENTS = 1;
87
- const SEEK_SETTLE_MS = 1_200;
88
- // 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
89
102
  // burst, so a still-moving scrubber cannot delay a genuine seek forever.
90
- const SEEK_SETTLE_MAX_MS = 2_500;
103
+ const SEEK_SETTLE_MAX_MS = 1_000;
91
104
  // Grace period to wait for the PREVIOUS ffmpeg process to exit (per signal
92
105
  // escalation step: SIGTERM, then SIGKILL) before spawning its replacement into
93
106
  // the same session directory. See #startEncodeRun.
@@ -787,6 +800,7 @@ export class HlsSessionManager {
787
800
  // benchmark (the only path that can pick/step resolution). Cheap no-op scan
788
801
  // otherwise.
789
802
  this.budgetTimer = setInterval(() => {
803
+ this.#enforceLookAhead();
790
804
  void this.#enforceRealtimeBudget();
791
805
  }, BUDGET_CHECK_INTERVAL_MS);
792
806
  this.budgetTimer.unref();
@@ -1160,6 +1174,12 @@ export class HlsSessionManager {
1160
1174
  // Bumped by every viewer seek; a held segment request that started under
1161
1175
  // an older value gives up at once. See requestSeek.
1162
1176
  waitEpoch: 0,
1177
+ // Highest segment the viewer has actually asked for, and whether the
1178
+ // encoder is currently suspended for running too far past it.
1179
+ // See #enforceLookAhead.
1180
+ lastRequestedSegment: null,
1181
+ encoderPaused: false,
1182
+ encoderPauseUnsupported: false,
1163
1183
  seekFirstFarAt: 0,
1164
1184
  // Circuit breaker: consecutive FAST failures (see SEEK_FAST_FAIL_MS) at
1165
1185
  // seekFailureTarget. Reset whenever a run starts at a DIFFERENT target or
@@ -1549,6 +1569,94 @@ export class HlsSessionManager {
1549
1569
  return true;
1550
1570
  }
1551
1571
 
1572
+ /**
1573
+ * Stop encoders that have run too far ahead of their viewer, and release
1574
+ * those the viewer has caught up with.
1575
+ *
1576
+ * The encoder is SUSPENDED, not killed. Killing would be simpler, but
1577
+ * restarting it costs about nine seconds on this hardware — the torrent has
1578
+ * to serve a fresh position and ffmpeg has to reach its first keyframe — so a
1579
+ * viewer reaching the end of the produced range would stall every time.
1580
+ * Suspending keeps the process, its open input and its position, and costs
1581
+ * nothing to undo.
1582
+ *
1583
+ * POSIX only. `SIGSTOP` does not exist on Windows, where `process.kill`
1584
+ * throws; the attempt is made once per session and, if it fails, that session
1585
+ * simply keeps its old unbounded behaviour rather than breaking.
1586
+ *
1587
+ * @returns {void}
1588
+ */
1589
+ #enforceLookAhead() {
1590
+ 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
+ }
1609
+ }
1610
+ }
1611
+
1612
+ /**
1613
+ * Suspend a session's encoder. No-op when already paused or unsupported here.
1614
+ *
1615
+ * @param {HlsSession} session
1616
+ * @param {string} reason
1617
+ * @returns {void}
1618
+ */
1619
+ #pauseEncoder(session, reason) {
1620
+ if (session.encoderPaused || session.encoderPauseUnsupported || !session.ffmpeg?.pid) {
1621
+ return;
1622
+ }
1623
+ try {
1624
+ process.kill(session.ffmpeg.pid, "SIGSTOP");
1625
+ } catch (error) {
1626
+ session.encoderPauseUnsupported = true;
1627
+ logger.info(
1628
+ `transcode ${session.id} cannot suspend the encoder on this platform ` +
1629
+ `(${error instanceof Error ? error.message : String(error)}); look-ahead stays unbounded`
1630
+ );
1631
+ return;
1632
+ }
1633
+ session.encoderPaused = true;
1634
+ logger.info(
1635
+ `transcode ${session.id} encoder suspended — ${reason} ` +
1636
+ `"${session.fileName}"`
1637
+ );
1638
+ }
1639
+
1640
+ /**
1641
+ * Let a suspended encoder run again.
1642
+ *
1643
+ * @param {HlsSession} session
1644
+ * @param {string} reason
1645
+ * @returns {void}
1646
+ */
1647
+ #resumeEncoder(session, reason) {
1648
+ if (!session.encoderPaused || !session.ffmpeg?.pid) {
1649
+ return;
1650
+ }
1651
+ try {
1652
+ process.kill(session.ffmpeg.pid, "SIGCONT");
1653
+ } catch {
1654
+ // The process is gone; the exit handler will deal with it.
1655
+ }
1656
+ session.encoderPaused = false;
1657
+ logger.info(`transcode ${session.id} encoder resumed — ${reason} "${session.fileName}"`);
1658
+ }
1659
+
1552
1660
  async #enforceRealtimeBudget() {
1553
1661
  if (this.videoEncoder?.kind !== "software") {
1554
1662
  return;
@@ -1728,6 +1836,9 @@ export class HlsSessionManager {
1728
1836
  async #startEncodeRun(session, startIndex) {
1729
1837
  const generation = ++session.encodeRunGeneration;
1730
1838
  const previousFfmpeg = session.ffmpeg;
1839
+ // A suspended process does not act on SIGTERM until it is continued, so the
1840
+ // wait below would never end. Let it run before asking it to stop.
1841
+ this.#resumeEncoder(session, "terminating for a new run");
1731
1842
  if (previousFfmpeg && !hasChildExited(previousFfmpeg)) {
1732
1843
  try {
1733
1844
  previousFfmpeg.kill("SIGTERM");
@@ -1760,6 +1871,7 @@ export class HlsSessionManager {
1760
1871
  // Terminate any existing encode process before starting a new one. The
1761
1872
  // old process's exit handler no-ops because session.ffmpeg is reassigned
1762
1873
  // below (it checks identity).
1874
+ this.#resumeEncoder(session, "terminating");
1763
1875
  if (session.ffmpeg && !session.ffmpeg.killed) {
1764
1876
  try {
1765
1877
  session.ffmpeg.kill("SIGTERM");
@@ -1933,6 +2045,7 @@ export class HlsSessionManager {
1933
2045
  // judged finished — see getFileStream.
1934
2046
  session.usesExplicitCuts = Boolean(cutTimes && cutTimes.length > 0);
1935
2047
  session.encodeStartIndex = safeIndex;
2048
+ session.encoderPaused = false;
1936
2049
  session.pendingRestartIndex = -1;
1937
2050
  session.lastRestartAt = Date.now();
1938
2051
  session.state = session.state === "disposed" ? "disposed" : "starting";
@@ -2347,48 +2460,21 @@ export class HlsSessionManager {
2347
2460
  session.seekSettleTimer.unref?.();
2348
2461
  return;
2349
2462
  }
2350
- // Let the CURRENT run finish what it started. Restarting a run that has not
2351
- // yet produced a single segment destroys all its work and starts the wait
2352
- // over and after a seek the first segment is always the slowest, so this
2353
- // is self-perpetuating: field log (2026-08-02, one user seek) shows
2354
- // restarts at #617 → #717 → #732 → #732 every 5-7 s, none of which ever
2355
- // produced anything, leaving the viewer with a flickering loading pill and
2356
- // no playback at all.
2357
- //
2358
- // These extra targets are NOT further user seeks: when the player cannot
2359
- // get its segment it SCANS the playlist (every segment is listed in our
2360
- // synthetic VOD playlist, so from its point of view they all exist), and
2361
- // each far-enough probe looked like a fresh seek to us. Waiting for the
2362
- // first segment makes the scan harmless — it can no longer steer the
2363
- // encoder — and one genuine seek now reliably completes.
2364
- //
2365
- // Bounded by RUN_FIRST_SEGMENT_GRACE_MS so a wedged run cannot block seeks
2366
- // forever; the encoder-stall watchdog and the exit handler cover a run that
2367
- // dies outright.
2463
+ // A run in progress is NOT protected any more. It used to be: a restart was
2464
+ // held for up to 30 s while the current run reached its first segment,
2465
+ // because a far segment REQUEST could steer the encoder and the player's
2466
+ // playlist scan produced dozens of them restarts at #617 → #717 → #732 →
2467
+ // #732 every 5-7 s, none producing anything (field 2026-08-02). Requests
2468
+ // stopped steering anything when the position became explicit, so the only
2469
+ // thing that can arrive here is a position the viewer has stated, and
2470
+ // finishing a segment for where they no longer are is work nobody wants.
2471
+ // Holding it was also expensive in the other direction: a genuine second
2472
+ // seek could be delayed by the whole grace.
2368
2473
  const producedThisRun = this.#producedSecondsThisRun(session);
2369
2474
  const runIsAlive = session.ffmpeg != null && !hasChildExited(session.ffmpeg);
2370
- if (
2371
- runIsAlive &&
2372
- producedThisRun < this.segmentDurationSec &&
2373
- sinceLastRestart < RUN_FIRST_SEGMENT_GRACE_MS
2374
- ) {
2375
- logger.info(
2376
- `transcode ${session.id} seek #${target} HELD — current run has produced ` +
2377
- `${producedThisRun.toFixed(1)}s of the ${this.segmentDurationSec}s first segment ` +
2378
- `(${(sinceLastRestart / 1000).toFixed(1)}s into a ${RUN_FIRST_SEGMENT_GRACE_MS / 1000}s grace)`
2379
- );
2380
- session.seekSettleTimer = setTimeout(() => this.#fireSettledSeek(session), SEEK_SETTLE_MS);
2381
- session.seekSettleTimer.unref?.();
2382
- return;
2383
- }
2384
- // Why the restart was allowed — the counterpart of the HELD line above.
2385
- // Without it a restart is indistinguishable from the runaway ping-pong this
2386
- // guard exists to stop, and diagnosing a field report becomes guesswork.
2387
2475
  const allowedBecause = !runIsAlive
2388
2476
  ? "run is dead"
2389
- : producedThisRun >= this.segmentDurationSec
2390
- ? `run produced ${producedThisRun.toFixed(1)}s (first segment done)`
2391
- : `grace of ${RUN_FIRST_SEGMENT_GRACE_MS / 1000}s expired`;
2477
+ : `viewer moved; run had produced ${producedThisRun.toFixed(1)}s`;
2392
2478
  // The start is exactly what requestSeek computed — one segment before the
2393
2479
  // viewer's position — and nothing else may move it.
2394
2480
  //
@@ -2585,6 +2671,17 @@ export class HlsSessionManager {
2585
2671
 
2586
2672
  const filePath = path.join(session.dirPath, fileName);
2587
2673
  const isPlaylist = fileName === PLAYLIST_FILE_NAME;
2674
+ if (!isPlaylist) {
2675
+ // Where the viewer actually is. Recorded for every segment request,
2676
+ // served or not, because it is what bounds how far ahead the encoder is
2677
+ // allowed to run — see #enforceLookAhead.
2678
+ const requested = session.segmentFormat.segmentIndexFromName(fileName);
2679
+ if (requested >= 0) {
2680
+ 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");
2683
+ }
2684
+ }
2588
2685
  try {
2589
2686
  await access(filePath);
2590
2687
 
@@ -2793,6 +2890,7 @@ export class HlsSessionManager {
2793
2890
  session.seekSettleTimer = null;
2794
2891
  }
2795
2892
 
2893
+ this.#resumeEncoder(session, "session disposed");
2796
2894
  if (session.ffmpeg && !session.ffmpeg.killed) {
2797
2895
  session.ffmpeg.kill("SIGTERM");
2798
2896
  await waitForChildExit(session.ffmpeg);
@@ -21,6 +21,10 @@
21
21
  */
22
22
 
23
23
  import { findSharedStore } from "../piece-store/shared-piece-store.js";
24
+ import { logger } from "../../utils/logger.js";
25
+
26
+ /** Only waits at least this long are reported; sequential reading stays silent. */
27
+ const PIECE_WAIT_LOG_MS = 1_000;
24
28
 
25
29
  /**
26
30
  * How far ahead of the read head pieces are asked for.
@@ -318,7 +322,21 @@ export async function* readFragments({
318
322
  criticalMark = markCritical(torrent, pieceIndex, Math.min(lastPiece, pieceIndex + criticalRun), criticalMark);
319
323
  }
320
324
 
325
+ const waitStartedAt = Date.now();
321
326
  await whenPieceReady(torrent, pieceIndex, cancellation);
327
+ // What a reader spent waiting for data, attributed to the exact piece. A
328
+ // seek's cost is dominated by the first segment after the encoder
329
+ // restarts (measured 9.2-9.4 s), and without this there is no way to say
330
+ // whether that is the swarm, the picker, or ffmpeg. Logged only when the
331
+ // wait is long enough to matter, so ordinary sequential reading is silent.
332
+ const waitedMs = Date.now() - waitStartedAt;
333
+ if (waitedMs >= PIECE_WAIT_LOG_MS) {
334
+ logger.info(
335
+ `piece-reader: waited ${waitedMs}ms for piece ${pieceIndex} ` +
336
+ `(${pieceIndex - firstPiece + 1} of ${lastPiece - firstPiece + 1} in a read from ` +
337
+ `${(start / 1024 / 1024).toFixed(0)}MB of "${file.name}")`
338
+ );
339
+ }
322
340
 
323
341
  // Pinned BEFORE it is located, and before any await that could let an
324
342
  // eviction run: the offset is only meaningful while the piece is held.