@torrent-tv/proxy 2.9.49 → 2.9.51

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,15 @@
1
+ ## 2.9.52
2
+
3
+ - **Chore**: `npm audit` fixes. `@fastify/static` 9.1.3 → 10.1.2 (fixes GHSA-83w8-p2f5-377r route-guard path-traversal bypass and GHSA-8pvw-jcv7-9cmj non-canonical-path authorization bypass — no API change to our usage, verified with a live smoke test: healthz, tunnel connect, and static registration all still work). `brace-expansion`/`fast-uri`/`find-my-way` bumped via `npm audit fix` (transitive, no direct dependency change). Residual: `ip` (via `webtorrent@2.8.5` → `torrent-discovery` → `bittorrent-tracker`) stays flagged high (GHSA-2p57-rm9w-gvfp / CVE-2024-29415, SSRF via `isPublic()` misclassification) — investigated and left as an accepted risk, not an oversight: the advisory has no upstream fix (`first_patched_version: null`, every published version of `ip` is flagged) and `npm audit fix --force`'s only offered fix is downgrading `webtorrent` to 0.7.3, which would reintroduce the exact download-freeze regressions 2.9.44 rolled back from 3.x to avoid. The only actual call site in our dependency tree (`bittorrent-tracker/lib/server/parse-udp.js`) uses `ip.toString()` for UDP-integer→string formatting in the tracker-SERVER's request parser — code we never execute (WebTorrent only uses `bittorrent-tracker` as a tracker CLIENT) — and the vulnerable function itself, `isPublic()`, is not called anywhere in the chain. Revisit if/when a maintained `ip` replacement lands upstream in `bittorrent-tracker`.
4
+
5
+ ## 2.9.51
6
+
7
+ - **Fix**: The 2.9.50 keyframe-snap seek fix did not reliably apply on the re-encode path for containers needing a full packet scan (observed: AVI). The probe ran with a 6 s cap shared with the video-copy path (there it is fast, moov-index based); on a container needing a full scan, 6 s was not enough, the probe returned null, and the seek fell back to the raw (unsnapped) target — the exact case the circuit breaker exists to catch, not prevent. Split the two paths: video-copy keeps the blocking 6 s probe (segment boundaries need it before the first segment can be produced); video re-encode now runs the probe in the BACKGROUND with a full 25 s budget, since segment boundaries there are the uniform grid and never depend on it — only a later seek benefits from the snap. `#startEncodeRun` already reads `session.keyframeTimes` fresh on every call, so a seek arriving after the background probe resolves picks up the snap automatically; one arriving before still falls back to the existing circuit breaker (no regression). Verified live on the field AVI: far seek to the previously-hanging segment now returns in ~12 s instead of the ~90 s stall.
8
+
9
+ ## 2.9.50
10
+
11
+ - **Fix**: Seeking could get stuck in an infinite restart loop on some containers (observed: AVI with VBR MP3 audio), producing nothing for ~90 s until the whole WebRTC session died — the on-screen symptom of "seeking does nothing." Root cause, two parts: (1) `-accurate_seek -ss X` before `-i` trusts the container's own on-the-fly seek/index to land near X; for this AVI it pointed at a position with no valid frame boundary at all, so ffmpeg failed outright ("Seek failed" / "Header missing") — not just imprecisely — and every retry re-tried the SAME bad container-computed position. (2) `#ensureEncodingFor`/`#fireSettledSeek` never checked for a `"failed"` session state, and `#startEncodeRun` unconditionally resets state back to `"starting"` on every call — so a failed run's next client poll silently re-armed and re-ran the identical failing seek, forever. Fixed both: the video-keyframe probe (previously only used for the copy path's segment boundaries) now also feeds a two-step seek — jump to the nearest REAL keyframe (a position ffmpeg has already proven it can decode, read directly from the packet list, not the container's live index) before `-i`, then trim the short residual precisely after `-i` (always frame-accurate, no reliance on `-accurate_seek`'s trust in the container). A circuit breaker caps consecutive fast failures (exits within 2 s — never did real work) at the SAME target to 3 before the session is left in its terminal `failed` state instead of looping — a different seek target still gets a fresh attempt budget. Verified: the keyframe-snap helper against synthetic data, and the breaker's state machine (3 attempts at one target → blocked, a different target → fresh budget, only 4 real ffmpeg spawns instead of an unbounded loop).
12
+
1
13
  ## 2.9.49
2
14
 
3
15
  - **New**: `getSessionProgress` (the transcode-session progress endpoint) now also reports `outputMbps` — the observed produced bitrate from recently completed segment files (already computed internally for the viewer-link budget check, `#checkLinkBudget`), so the browser can turn its OWN measured link throughput into a delivery-speed multiplier for the unified download/transcode/delivery playback-start ETA, the same way the transcode's own `speed` already is one.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.9.49",
3
+ "version": "2.9.51",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -20,7 +20,7 @@
20
20
  "dependencies": {
21
21
  "@fastify/cors": "^11.2.0",
22
22
  "@fastify/helmet": "^13.0.2",
23
- "@fastify/static": "^9.1.3",
23
+ "@fastify/static": "^10.1.2",
24
24
  "@silentbot1/nat-api": "^0.4.9",
25
25
  "chalk": "^5.4.1",
26
26
  "commander": "^12.1.0",
@@ -72,6 +72,19 @@ const SEEK_SETTLE_MAX_MS = 2_500;
72
72
  // escalation step: SIGTERM, then SIGKILL) before spawning its replacement into
73
73
  // the same session directory. See #startEncodeRun.
74
74
  const ENCODE_RUN_TERMINATE_GRACE_MS = 2_000;
75
+ // A seek-restart run that exits this fast never did real work — it failed at
76
+ // the seek/open step itself (container demux error, bad audio frame boundary,
77
+ // etc.), not mid-stream. Used to tell a genuine seek failure apart from a
78
+ // later, unrelated crash so the circuit breaker below only counts the former.
79
+ const SEEK_FAST_FAIL_MS = 2_000;
80
+ // Circuit breaker: consecutive fast failures AT THE SAME target before we stop
81
+ // auto-retrying and leave the session in its terminal "failed" state (surfaced
82
+ // to the client as a clean, retryable error) instead of looping forever. The
83
+ // keyframe-snap seek (see #startEncodeRun) already fixes the dominant failure
84
+ // mode (an unreliable container-computed seek position); this is a safety net
85
+ // for whatever residual case still fails — not a second competing "fix" that
86
+ // blindly retries the identical command hoping for a different result.
87
+ const MAX_SEEK_FAILURES = 3;
75
88
  // Idle TTL: a session is disposed this long after the last segment/playlist
76
89
  // access. Long enough that a viewer who pauses, backgrounds the tab, or briefly
77
90
  // turns the phone off can resume WITHOUT a cold ffmpeg restart (the warm session
@@ -575,6 +588,27 @@ function computeSegmentBoundaries({ transcodeVideo, durationSeconds, segDur, key
575
588
  return boundaries.length >= 2 ? boundaries : uniform();
576
589
  }
577
590
 
591
+ /**
592
+ * The largest keyframe time that does not exceed `target`, from a SORTED
593
+ * (ascending) array of keyframe times such as {@link probeVideoKeyframeTimes}
594
+ * returns. Null when `target` is before the first keyframe or the array is
595
+ * empty — the caller then falls back to its unsnapped target.
596
+ *
597
+ * @param {number[]} keyframeTimes - Sorted ascending.
598
+ * @param {number} target
599
+ * @returns {number | null}
600
+ */
601
+ function nearestKeyframeAtOrBefore(keyframeTimes, target) {
602
+ let result = null;
603
+ for (const time of keyframeTimes) {
604
+ if (time > target) {
605
+ break;
606
+ }
607
+ result = time;
608
+ }
609
+ return result;
610
+ }
611
+
578
612
  function isWarmupTimeoutError(error) {
579
613
  if (!(error instanceof Error)) {
580
614
  return false;
@@ -621,6 +655,12 @@ function normalizeLogFileName(fileName, fileIndex) {
621
655
  * @property {number} encodeRunGeneration - Bumped on every #startEncodeRun call;
622
656
  * lets a call that awaited the previous ffmpeg's exit detect it was superseded
623
657
  * by a newer restart request and abort instead of spawning a second process.
658
+ * @property {number[] | null} keyframeTimes - Real source keyframe times
659
+ * (sorted seconds), or null when the probe failed/timed out. Used to snap a
660
+ * source seek onto a known-valid position (see #startEncodeRun).
661
+ * @property {number} seekFailureTarget - Segment index of the last fast seek
662
+ * failure, for the consecutive-failure circuit breaker (see MAX_SEEK_FAILURES).
663
+ * @property {number} seekFailureCount - Consecutive fast failures at seekFailureTarget.
624
664
  */
625
665
 
626
666
  /**
@@ -828,28 +868,71 @@ export class HlsSessionManager {
828
868
 
829
869
  // For the video-copy path we cannot insert keyframes, so the playlist's
830
870
  // segment boundaries must match the source's real keyframes (otherwise the
831
- // player sees gaps on seek). Probe them; on failure we fall back to a
832
- // uniform grid (current behaviour). Re-encoded video uses a uniform grid
833
- // (its fixed GOP makes the cuts land there).
871
+ // player sees gaps on seek). Re-encoded video uses a uniform grid for
872
+ // segment boundaries instead (its fixed GOP makes the cuts land there —
873
+ // computeSegmentBoundaries ignores keyframeTimes when transcodeVideo).
874
+ //
875
+ // But the probe is ALSO used for something both branches need: choosing a
876
+ // SOURCE seek position ffmpeg can actually land on. `-ss` before `-i` trusts
877
+ // the container's own on-the-fly seek/index, which for some containers
878
+ // (observed: AVI with VBR MP3 audio) can point at a position with no valid
879
+ // frame boundary at all — ffmpeg then fails outright ("Seek failed" /
880
+ // "Header missing"), not just imprecisely. Snapping the seek to the nearest
881
+ // KNOWN real keyframe (see #startEncodeRun) avoids that. So probe for both
882
+ // branches; on failure both fall back to their current behaviour (uniform
883
+ // grid for boundaries, raw target for seeking) — no regression.
834
884
  let keyframeTimes = null;
835
- let keyframeMs = -1; // -1 = not run (skipped)
885
+ let keyframeMs = -1; // -1 = not run (skipped), -2 = running in the background
836
886
  if (hasDuration && !transcodeVideo) {
837
- // Short timeout: mp4 keyframes come from the moov index (fast); containers
838
- // that force a full packet scan time out and fall back to a uniform grid,
839
- // so this never adds more than ~6 s to session start.
887
+ // Video-COPY path: keyframeTimes are REQUIRED to build correct segment
888
+ // boundaries (the playlist itself), so this MUST block session creation
889
+ // an incorrect playlist is worse than a slower start. Short timeout: mp4
890
+ // keyframes come from the moov index (fast); containers that force a full
891
+ // packet scan time out and fall back to a uniform grid, so this never adds
892
+ // more than ~6 s to session start.
840
893
  const keyframeStartMs = Date.now();
841
894
  keyframeTimes = await probeVideoKeyframeTimes(this.ffmpegBin, inputUrl.toString(), 6_000);
842
895
  keyframeMs = Date.now() - keyframeStartMs;
843
896
  if (!keyframeTimes) {
844
897
  logger.warn(
845
898
  `transcode ${sessionId}: keyframe probe unavailable; using uniform grid ` +
846
- `for copied video "${logName}" (seek precision may be reduced)`
899
+ `for "${logName}" (seek precision may be reduced)`
847
900
  );
848
901
  }
902
+ } else if (hasDuration && transcodeVideo) {
903
+ // Re-encode path: keyframeTimes are ONLY used to snap a LATER seek (see
904
+ // #startEncodeRun) — segment boundaries stay on the uniform grid either
905
+ // way. So this does NOT need to block session creation / the first
906
+ // segment's start. Run it in the background with a FULL budget instead of
907
+ // the 6 s cap: AVI-class containers need a full packet scan, which 6 s can
908
+ // never afford without delaying playback start — that starved budget is
909
+ // exactly why the probe kept missing on the container where the seek bug
910
+ // was field-diagnosed. #startEncodeRun reads session.keyframeTimes fresh
911
+ // on every call, so a seek that happens AFTER this finishes picks it up
912
+ // automatically; one that happens before falls back to the existing
913
+ // circuit breaker as a safety net (no regression either way).
914
+ keyframeMs = -2;
915
+ const backgroundStartedAt = Date.now();
916
+ void probeVideoKeyframeTimes(this.ffmpegBin, inputUrl.toString(), 25_000).then((times) => {
917
+ const liveSession = this.sessionsById.get(sessionId);
918
+ if (!liveSession || liveSession.state === "disposed") {
919
+ return; // Session gone before the probe finished — nothing to update.
920
+ }
921
+ liveSession.keyframeTimes = times;
922
+ const elapsedMs = Date.now() - backgroundStartedAt;
923
+ logger.info(
924
+ times
925
+ ? `transcode ${sessionId}: background keyframe probe found ${times.length} keyframes ` +
926
+ `(${elapsedMs}ms) for "${logName}" — later seeks will snap to them`
927
+ : `transcode ${sessionId}: background keyframe probe unavailable (${elapsedMs}ms) for "${logName}" ` +
928
+ `— seeks keep using the raw target (falls back to the circuit breaker on failure)`
929
+ );
930
+ });
849
931
  }
850
932
  logger.info(
851
933
  `cold-start ${sessionId.slice(0, 8)}: media-info=${mediaInfoMs}ms (${mediaInfoSource}) ` +
852
- `keyframes=${keyframeMs < 0 ? "skipped" : `${keyframeMs}ms`} create-total=${Date.now() - createEntryMs}ms`
934
+ `keyframes=${keyframeMs === -1 ? "skipped" : keyframeMs === -2 ? "background" : `${keyframeMs}ms`} ` +
935
+ `create-total=${Date.now() - createEntryMs}ms`
853
936
  );
854
937
  const segmentBoundaries = hasDuration
855
938
  ? computeSegmentBoundaries({
@@ -953,6 +1036,11 @@ export class HlsSessionManager {
953
1036
  // keyframe positions for copied video. Drives the playlist and seeking.
954
1037
  segmentBoundaries,
955
1038
  segmentCount,
1039
+ // Real source keyframe times (sorted seconds), or null when the probe
1040
+ // failed/timed out. Used by #startEncodeRun to snap a source seek onto a
1041
+ // KNOWN valid position instead of trusting the container's own on-the-fly
1042
+ // seek at an arbitrary target — see the probe call above for why.
1043
+ keyframeTimes,
956
1044
  playlistText: hasDuration ? this.#buildVodPlaylist(segmentBoundaries) : "",
957
1045
  // Segment index the current ffmpeg run started producing from.
958
1046
  encodeStartIndex: 0,
@@ -966,6 +1054,12 @@ export class HlsSessionManager {
966
1054
  seekSettleTimer: null,
967
1055
  seekTarget: null,
968
1056
  seekFirstFarAt: 0,
1057
+ // Circuit breaker: consecutive FAST failures (see SEEK_FAST_FAIL_MS) at
1058
+ // seekFailureTarget. Reset whenever a run starts at a DIFFERENT target or
1059
+ // survives past the fast-fail window. See the exit handler in
1060
+ // #wireEncodeProcess and MAX_SEEK_FAILURES.
1061
+ seekFailureTarget: -1,
1062
+ seekFailureCount: 0,
969
1063
  progress: {
970
1064
  state: "starting",
971
1065
  processedSeconds: 0,
@@ -1512,12 +1606,40 @@ export class HlsSessionManager {
1512
1606
  // (startSeconds is already a real-keyframe offset from 0, so add back the
1513
1607
  // container start time); for re-encode startSeconds is a plain grid offset.
1514
1608
  const seekSeconds = session.transcodeVideo ? startSeconds : startSeconds + sourceStartTime;
1515
- if (seekSeconds > 0) {
1516
- // Accurate seek before -i (decodes from the preceding keyframe and trims
1517
- // to the exact point), so the first output frame is exactly at the target.
1518
- args.push("-accurate_seek", "-ss", String(seekSeconds));
1609
+ // Two-step seek when we have a real keyframe map: jump to a KNOWN-valid
1610
+ // keyframe (coarse, before -i safe because WE sourced it from ffprobe,
1611
+ // not the container's own on-the-fly seek/index) and trim the short
1612
+ // residual (bounded by the keyframe interval) precisely AFTER -i, which is
1613
+ // always frame-accurate regardless of -accurate_seek.
1614
+ //
1615
+ // Root cause this works around: `-accurate_seek -ss X` before -i trusts the
1616
+ // CONTAINER's own seek to land near X. For some containers (observed: AVI
1617
+ // with VBR MP3 audio) that on-the-fly seek can point at a position with no
1618
+ // valid frame boundary at all — ffmpeg fails outright ("Seek failed" /
1619
+ // "Header missing"), not just imprecisely, and repeatedly so since every
1620
+ // retry re-tries the SAME bad container-computed position. A keyframe we
1621
+ // read directly from the packet list is a position ffmpeg has already
1622
+ // proven it can decode.
1623
+ const snappedKeyframe = Array.isArray(session.keyframeTimes) && session.keyframeTimes.length > 0
1624
+ ? nearestKeyframeAtOrBefore(session.keyframeTimes, seekSeconds)
1625
+ : null;
1626
+ if (snappedKeyframe !== null) {
1627
+ const residualSeconds = Math.max(0, seekSeconds - snappedKeyframe);
1628
+ if (snappedKeyframe > 0) {
1629
+ args.push("-ss", String(snappedKeyframe));
1630
+ }
1631
+ args.push("-i", session.inputUrl);
1632
+ if (residualSeconds > 0) {
1633
+ args.push("-ss", String(residualSeconds));
1634
+ }
1635
+ } else {
1636
+ if (seekSeconds > 0) {
1637
+ // No keyframe map (probe failed/timed out) — fall back to the previous
1638
+ // behaviour: trust the container's own accurate seek.
1639
+ args.push("-accurate_seek", "-ss", String(seekSeconds));
1640
+ }
1641
+ args.push("-i", session.inputUrl);
1519
1642
  }
1520
- args.push("-i", session.inputUrl);
1521
1643
  if (session.transcodeVideo) {
1522
1644
  // Branch A (re-encode): fixed GOP makes keyframes land exactly on the
1523
1645
  // segment grid; relabel output onto the original timeline so segment N
@@ -1709,6 +1831,30 @@ export class HlsSessionManager {
1709
1831
  void this.#startEncodeRun(session, session.encodeStartIndex);
1710
1832
  return;
1711
1833
  }
1834
+ // Circuit-breaker bookkeeping: a seek-restart run that exits THIS fast
1835
+ // never did real work — it failed at the seek/open step itself, not
1836
+ // mid-stream (see SEEK_FAST_FAIL_MS). Track consecutive fast failures at
1837
+ // the SAME target so #ensureEncodingFor/#fireSettledSeek (which check
1838
+ // this below) can stop retrying instead of looping forever on a position
1839
+ // that keeps failing even with the keyframe-snapped seek.
1840
+ const elapsedMs = Date.now() - session.lastRestartAt;
1841
+ if (elapsedMs < SEEK_FAST_FAIL_MS && session.encodeStartIndex > 0) {
1842
+ if (session.seekFailureTarget === session.encodeStartIndex) {
1843
+ session.seekFailureCount += 1;
1844
+ } else {
1845
+ session.seekFailureTarget = session.encodeStartIndex;
1846
+ session.seekFailureCount = 1;
1847
+ }
1848
+ logger.warn(
1849
+ `transcode ${session.id} fast failure at segment #${session.encodeStartIndex} ` +
1850
+ `(${elapsedMs}ms) — ${session.seekFailureCount}/${MAX_SEEK_FAILURES} consecutive`
1851
+ );
1852
+ } else {
1853
+ // Real progress was made (or this was the very first run) — not a
1854
+ // repeating seek failure. Reset the breaker.
1855
+ session.seekFailureTarget = -1;
1856
+ session.seekFailureCount = 0;
1857
+ }
1712
1858
  session.state = "failed";
1713
1859
  session.progress.state = "failed";
1714
1860
  session.progress.updatedAt = Date.now();
@@ -1743,6 +1889,15 @@ export class HlsSessionManager {
1743
1889
  if (withinWindow) {
1744
1890
  return;
1745
1891
  }
1892
+ // Circuit breaker: this exact target has already failed MAX_SEEK_FAILURES
1893
+ // times in a row (fast failures — see #wireEncodeProcess's exit handler).
1894
+ // Stop auto-retrying it; session.state stays "failed" so getFileStream
1895
+ // reports a clean, retryable error instead of looping forever. A DIFFERENT
1896
+ // target (the viewer seeking elsewhere) is unaffected — it gets its own
1897
+ // fresh attempt budget.
1898
+ if (index === session.seekFailureTarget && session.seekFailureCount >= MAX_SEEK_FAILURES) {
1899
+ return;
1900
+ }
1746
1901
  // Far request = a server-side seek. Do NOT restart on the first one:
1747
1902
  // debounce a burst of scattered requests into a single restart at the
1748
1903
  // position the player ended on. Record the latest target and (re)arm the
@@ -1776,6 +1931,14 @@ export class HlsSessionManager {
1776
1931
  session.seekFirstFarAt = 0;
1777
1932
  return;
1778
1933
  }
1934
+ // Circuit breaker (defense in depth): a timer armed before the cap was hit
1935
+ // could still be pending when it was reached — do not fire the restart it
1936
+ // was going to make. See the matching check in #ensureEncodingFor.
1937
+ if (target === session.seekFailureTarget && session.seekFailureCount >= MAX_SEEK_FAILURES) {
1938
+ session.seekTarget = null;
1939
+ session.seekFirstFarAt = 0;
1940
+ return;
1941
+ }
1779
1942
  // Minimum gap between actual restarts (the settle already collapses bursts;
1780
1943
  // this only guards back-to-back seeks). If still cooling down, re-arm once
1781
1944
  // for the remaining cooldown instead of restarting now.