@torrent-tv/proxy 2.9.90 → 2.9.91

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,8 @@
1
+ ## 2.9.91
2
+
3
+ - **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.
4
+ - **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.
5
+
1
6
  ## 2.9.90
2
7
 
3
8
  - **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.91",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -63,6 +63,23 @@ const RUN_FIRST_SEGMENT_GRACE_MS = 30_000;
63
63
  // and restart ffmpeg at the demanded segment. Conservative — a slow-but-moving
64
64
  // encode keeps advancing `updatedAt`, so this only fires on a true freeze.
65
65
  const ENCODER_STALL_MS = 12_000;
66
+ // How far ahead of the viewer the encoder may run before it is stopped, and how
67
+ // far it must fall back to before it is let go again.
68
+ //
69
+ // Nothing used to bound this. Measured 2026-08-04 on the copy path: three
70
+ // minutes after a film was opened the encode had reached 00:39:24 of a 01:26:51
71
+ // source at 12.8x while the viewer was still at the start, and the torrent had
72
+ // pulled 80% of 4.7 GB to feed it. That costs the pool owner's bandwidth and
73
+ // disk for a viewer who may watch two minutes, evicts from memory the pieces
74
+ // the viewer is actually reading, and competes for the swarm with the segment
75
+ // being waited on.
76
+ //
77
+ // In seconds of content rather than segments, because a segment is 4 s of
78
+ // re-encoded video but a whole keyframe interval on the copy path. Generous
79
+ // enough that ordinary watching never touches it: the encoder fills two minutes
80
+ // ahead, stops, and is released as soon as the viewer has spent a minute of it.
81
+ const LOOKAHEAD_PAUSE_SECONDS = 120;
82
+ const LOOKAHEAD_RESUME_SECONDS = 60;
66
83
  // Seek debounce. A far (out-of-window) segment request is a server-side seek.
67
84
  // Rather than restart ffmpeg on the first one, wait a short quiet period:
68
85
  // further far requests re-arm it and update the target to the latest index, so
@@ -787,6 +804,7 @@ export class HlsSessionManager {
787
804
  // benchmark (the only path that can pick/step resolution). Cheap no-op scan
788
805
  // otherwise.
789
806
  this.budgetTimer = setInterval(() => {
807
+ this.#enforceLookAhead();
790
808
  void this.#enforceRealtimeBudget();
791
809
  }, BUDGET_CHECK_INTERVAL_MS);
792
810
  this.budgetTimer.unref();
@@ -1160,6 +1178,12 @@ export class HlsSessionManager {
1160
1178
  // Bumped by every viewer seek; a held segment request that started under
1161
1179
  // an older value gives up at once. See requestSeek.
1162
1180
  waitEpoch: 0,
1181
+ // Highest segment the viewer has actually asked for, and whether the
1182
+ // encoder is currently suspended for running too far past it.
1183
+ // See #enforceLookAhead.
1184
+ lastRequestedSegment: null,
1185
+ encoderPaused: false,
1186
+ encoderPauseUnsupported: false,
1163
1187
  seekFirstFarAt: 0,
1164
1188
  // Circuit breaker: consecutive FAST failures (see SEEK_FAST_FAIL_MS) at
1165
1189
  // seekFailureTarget. Reset whenever a run starts at a DIFFERENT target or
@@ -1549,6 +1573,94 @@ export class HlsSessionManager {
1549
1573
  return true;
1550
1574
  }
1551
1575
 
1576
+ /**
1577
+ * Stop encoders that have run too far ahead of their viewer, and release
1578
+ * those the viewer has caught up with.
1579
+ *
1580
+ * The encoder is SUSPENDED, not killed. Killing would be simpler, but
1581
+ * restarting it costs about nine seconds on this hardware — the torrent has
1582
+ * to serve a fresh position and ffmpeg has to reach its first keyframe — so a
1583
+ * viewer reaching the end of the produced range would stall every time.
1584
+ * Suspending keeps the process, its open input and its position, and costs
1585
+ * nothing to undo.
1586
+ *
1587
+ * POSIX only. `SIGSTOP` does not exist on Windows, where `process.kill`
1588
+ * throws; the attempt is made once per session and, if it fails, that session
1589
+ * simply keeps its old unbounded behaviour rather than breaking.
1590
+ *
1591
+ * @returns {void}
1592
+ */
1593
+ #enforceLookAhead() {
1594
+ 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
+ }
1614
+ }
1615
+
1616
+ /**
1617
+ * Suspend a session's encoder. No-op when already paused or unsupported here.
1618
+ *
1619
+ * @param {HlsSession} session
1620
+ * @param {string} reason
1621
+ * @returns {void}
1622
+ */
1623
+ #pauseEncoder(session, reason) {
1624
+ if (session.encoderPaused || session.encoderPauseUnsupported || !session.ffmpeg?.pid) {
1625
+ return;
1626
+ }
1627
+ try {
1628
+ process.kill(session.ffmpeg.pid, "SIGSTOP");
1629
+ } catch (error) {
1630
+ session.encoderPauseUnsupported = true;
1631
+ logger.info(
1632
+ `transcode ${session.id} cannot suspend the encoder on this platform ` +
1633
+ `(${error instanceof Error ? error.message : String(error)}); look-ahead stays unbounded`
1634
+ );
1635
+ return;
1636
+ }
1637
+ session.encoderPaused = true;
1638
+ logger.info(
1639
+ `transcode ${session.id} encoder suspended — ${reason} ` +
1640
+ `"${session.fileName}"`
1641
+ );
1642
+ }
1643
+
1644
+ /**
1645
+ * Let a suspended encoder run again.
1646
+ *
1647
+ * @param {HlsSession} session
1648
+ * @param {string} reason
1649
+ * @returns {void}
1650
+ */
1651
+ #resumeEncoder(session, reason) {
1652
+ if (!session.encoderPaused || !session.ffmpeg?.pid) {
1653
+ return;
1654
+ }
1655
+ try {
1656
+ process.kill(session.ffmpeg.pid, "SIGCONT");
1657
+ } catch {
1658
+ // The process is gone; the exit handler will deal with it.
1659
+ }
1660
+ session.encoderPaused = false;
1661
+ logger.info(`transcode ${session.id} encoder resumed — ${reason} "${session.fileName}"`);
1662
+ }
1663
+
1552
1664
  async #enforceRealtimeBudget() {
1553
1665
  if (this.videoEncoder?.kind !== "software") {
1554
1666
  return;
@@ -1728,6 +1840,9 @@ export class HlsSessionManager {
1728
1840
  async #startEncodeRun(session, startIndex) {
1729
1841
  const generation = ++session.encodeRunGeneration;
1730
1842
  const previousFfmpeg = session.ffmpeg;
1843
+ // A suspended process does not act on SIGTERM until it is continued, so the
1844
+ // wait below would never end. Let it run before asking it to stop.
1845
+ this.#resumeEncoder(session, "terminating for a new run");
1731
1846
  if (previousFfmpeg && !hasChildExited(previousFfmpeg)) {
1732
1847
  try {
1733
1848
  previousFfmpeg.kill("SIGTERM");
@@ -1760,6 +1875,7 @@ export class HlsSessionManager {
1760
1875
  // Terminate any existing encode process before starting a new one. The
1761
1876
  // old process's exit handler no-ops because session.ffmpeg is reassigned
1762
1877
  // below (it checks identity).
1878
+ this.#resumeEncoder(session, "terminating");
1763
1879
  if (session.ffmpeg && !session.ffmpeg.killed) {
1764
1880
  try {
1765
1881
  session.ffmpeg.kill("SIGTERM");
@@ -1933,6 +2049,7 @@ export class HlsSessionManager {
1933
2049
  // judged finished — see getFileStream.
1934
2050
  session.usesExplicitCuts = Boolean(cutTimes && cutTimes.length > 0);
1935
2051
  session.encodeStartIndex = safeIndex;
2052
+ session.encoderPaused = false;
1936
2053
  session.pendingRestartIndex = -1;
1937
2054
  session.lastRestartAt = Date.now();
1938
2055
  session.state = session.state === "disposed" ? "disposed" : "starting";
@@ -2585,6 +2702,17 @@ export class HlsSessionManager {
2585
2702
 
2586
2703
  const filePath = path.join(session.dirPath, fileName);
2587
2704
  const isPlaylist = fileName === PLAYLIST_FILE_NAME;
2705
+ if (!isPlaylist) {
2706
+ // Where the viewer actually is. Recorded for every segment request,
2707
+ // served or not, because it is what bounds how far ahead the encoder is
2708
+ // allowed to run — see #enforceLookAhead.
2709
+ const requested = session.segmentFormat.segmentIndexFromName(fileName);
2710
+ if (requested >= 0) {
2711
+ 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");
2714
+ }
2715
+ }
2588
2716
  try {
2589
2717
  await access(filePath);
2590
2718
 
@@ -2793,6 +2921,7 @@ export class HlsSessionManager {
2793
2921
  session.seekSettleTimer = null;
2794
2922
  }
2795
2923
 
2924
+ this.#resumeEncoder(session, "session disposed");
2796
2925
  if (session.ffmpeg && !session.ffmpeg.killed) {
2797
2926
  session.ffmpeg.kill("SIGTERM");
2798
2927
  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.