@torrent-tv/proxy 2.9.48 → 2.9.50

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,11 @@
1
+ ## 2.9.50
2
+
3
+ - **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).
4
+
5
+ ## 2.9.49
6
+
7
+ - **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.
8
+
1
9
  ## 2.9.48
2
10
 
3
11
  - **Fix**: The "bytes still needed to resume" figure shown while buffering could jump UP mid-poll even though nothing regressed, which read as confusing/broken. Root cause: the resume-window progress (`resumeNeededBytes`/`resumeDownloadedBytes`) was always computed against the LIVE read position, which slides forward as the file is read/transcoded further — so when the window moved past an already-downloaded piece into a fresh, never-touched one, "bytes needed" jumped up (a moving reference frame, not a real setback). `getFileStats` now accepts an optional `resumeAnchorByteStart` and always returns the byte offset the window was computed against; the browser client captures that offset on the FIRST poll of a buffering episode and sends it back on every subsequent poll of the SAME episode, so the window stays pinned to a fixed target and the figure only ever decreases as real download progress happens. Verified: with the anchor pinned, repeated polls report the same "needed" while the live read position moves with no new data, and a real download of a piece inside the frozen window correctly decreases it.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.9.48",
3
+ "version": "2.9.50",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -14,7 +14,7 @@ export async function handleApiTranscodeSessionsProgressGet(req, reply, { hlsSes
14
14
  return reply.code(400).send({ error: "sessionId is required." });
15
15
  }
16
16
 
17
- const progress = hlsSessionManager.getSessionProgress(sessionId);
17
+ const progress = await hlsSessionManager.getSessionProgress(sessionId);
18
18
  if (!progress) {
19
19
  return reply.code(404).send({ error: "Transcode session was not found." });
20
20
  }
@@ -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,12 +868,22 @@ 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
885
  let keyframeMs = -1; // -1 = not run (skipped)
836
- if (hasDuration && !transcodeVideo) {
886
+ if (hasDuration) {
837
887
  // Short timeout: mp4 keyframes come from the moov index (fast); containers
838
888
  // that force a full packet scan time out and fall back to a uniform grid,
839
889
  // so this never adds more than ~6 s to session start.
@@ -842,8 +892,8 @@ export class HlsSessionManager {
842
892
  keyframeMs = Date.now() - keyframeStartMs;
843
893
  if (!keyframeTimes) {
844
894
  logger.warn(
845
- `transcode ${sessionId}: keyframe probe unavailable; using uniform grid ` +
846
- `for copied video "${logName}" (seek precision may be reduced)`
895
+ `transcode ${sessionId}: keyframe probe unavailable; using uniform grid / raw seek targets ` +
896
+ `for "${logName}" (seek precision may be reduced)`
847
897
  );
848
898
  }
849
899
  }
@@ -953,6 +1003,11 @@ export class HlsSessionManager {
953
1003
  // keyframe positions for copied video. Drives the playlist and seeking.
954
1004
  segmentBoundaries,
955
1005
  segmentCount,
1006
+ // Real source keyframe times (sorted seconds), or null when the probe
1007
+ // failed/timed out. Used by #startEncodeRun to snap a source seek onto a
1008
+ // KNOWN valid position instead of trusting the container's own on-the-fly
1009
+ // seek at an arbitrary target — see the probe call above for why.
1010
+ keyframeTimes,
956
1011
  playlistText: hasDuration ? this.#buildVodPlaylist(segmentBoundaries) : "",
957
1012
  // Segment index the current ffmpeg run started producing from.
958
1013
  encodeStartIndex: 0,
@@ -966,6 +1021,12 @@ export class HlsSessionManager {
966
1021
  seekSettleTimer: null,
967
1022
  seekTarget: null,
968
1023
  seekFirstFarAt: 0,
1024
+ // Circuit breaker: consecutive FAST failures (see SEEK_FAST_FAIL_MS) at
1025
+ // seekFailureTarget. Reset whenever a run starts at a DIFFERENT target or
1026
+ // survives past the fast-fail window. See the exit handler in
1027
+ // #wireEncodeProcess and MAX_SEEK_FAILURES.
1028
+ seekFailureTarget: -1,
1029
+ seekFailureCount: 0,
969
1030
  progress: {
970
1031
  state: "starting",
971
1032
  processedSeconds: 0,
@@ -1512,12 +1573,40 @@ export class HlsSessionManager {
1512
1573
  // (startSeconds is already a real-keyframe offset from 0, so add back the
1513
1574
  // container start time); for re-encode startSeconds is a plain grid offset.
1514
1575
  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));
1576
+ // Two-step seek when we have a real keyframe map: jump to a KNOWN-valid
1577
+ // keyframe (coarse, before -i safe because WE sourced it from ffprobe,
1578
+ // not the container's own on-the-fly seek/index) and trim the short
1579
+ // residual (bounded by the keyframe interval) precisely AFTER -i, which is
1580
+ // always frame-accurate regardless of -accurate_seek.
1581
+ //
1582
+ // Root cause this works around: `-accurate_seek -ss X` before -i trusts the
1583
+ // CONTAINER's own seek to land near X. For some containers (observed: AVI
1584
+ // with VBR MP3 audio) that on-the-fly seek can point at a position with no
1585
+ // valid frame boundary at all — ffmpeg fails outright ("Seek failed" /
1586
+ // "Header missing"), not just imprecisely, and repeatedly so since every
1587
+ // retry re-tries the SAME bad container-computed position. A keyframe we
1588
+ // read directly from the packet list is a position ffmpeg has already
1589
+ // proven it can decode.
1590
+ const snappedKeyframe = Array.isArray(session.keyframeTimes) && session.keyframeTimes.length > 0
1591
+ ? nearestKeyframeAtOrBefore(session.keyframeTimes, seekSeconds)
1592
+ : null;
1593
+ if (snappedKeyframe !== null) {
1594
+ const residualSeconds = Math.max(0, seekSeconds - snappedKeyframe);
1595
+ if (snappedKeyframe > 0) {
1596
+ args.push("-ss", String(snappedKeyframe));
1597
+ }
1598
+ args.push("-i", session.inputUrl);
1599
+ if (residualSeconds > 0) {
1600
+ args.push("-ss", String(residualSeconds));
1601
+ }
1602
+ } else {
1603
+ if (seekSeconds > 0) {
1604
+ // No keyframe map (probe failed/timed out) — fall back to the previous
1605
+ // behaviour: trust the container's own accurate seek.
1606
+ args.push("-accurate_seek", "-ss", String(seekSeconds));
1607
+ }
1608
+ args.push("-i", session.inputUrl);
1519
1609
  }
1520
- args.push("-i", session.inputUrl);
1521
1610
  if (session.transcodeVideo) {
1522
1611
  // Branch A (re-encode): fixed GOP makes keyframes land exactly on the
1523
1612
  // segment grid; relabel output onto the original timeline so segment N
@@ -1709,6 +1798,30 @@ export class HlsSessionManager {
1709
1798
  void this.#startEncodeRun(session, session.encodeStartIndex);
1710
1799
  return;
1711
1800
  }
1801
+ // Circuit-breaker bookkeeping: a seek-restart run that exits THIS fast
1802
+ // never did real work — it failed at the seek/open step itself, not
1803
+ // mid-stream (see SEEK_FAST_FAIL_MS). Track consecutive fast failures at
1804
+ // the SAME target so #ensureEncodingFor/#fireSettledSeek (which check
1805
+ // this below) can stop retrying instead of looping forever on a position
1806
+ // that keeps failing even with the keyframe-snapped seek.
1807
+ const elapsedMs = Date.now() - session.lastRestartAt;
1808
+ if (elapsedMs < SEEK_FAST_FAIL_MS && session.encodeStartIndex > 0) {
1809
+ if (session.seekFailureTarget === session.encodeStartIndex) {
1810
+ session.seekFailureCount += 1;
1811
+ } else {
1812
+ session.seekFailureTarget = session.encodeStartIndex;
1813
+ session.seekFailureCount = 1;
1814
+ }
1815
+ logger.warn(
1816
+ `transcode ${session.id} fast failure at segment #${session.encodeStartIndex} ` +
1817
+ `(${elapsedMs}ms) — ${session.seekFailureCount}/${MAX_SEEK_FAILURES} consecutive`
1818
+ );
1819
+ } else {
1820
+ // Real progress was made (or this was the very first run) — not a
1821
+ // repeating seek failure. Reset the breaker.
1822
+ session.seekFailureTarget = -1;
1823
+ session.seekFailureCount = 0;
1824
+ }
1712
1825
  session.state = "failed";
1713
1826
  session.progress.state = "failed";
1714
1827
  session.progress.updatedAt = Date.now();
@@ -1743,6 +1856,15 @@ export class HlsSessionManager {
1743
1856
  if (withinWindow) {
1744
1857
  return;
1745
1858
  }
1859
+ // Circuit breaker: this exact target has already failed MAX_SEEK_FAILURES
1860
+ // times in a row (fast failures — see #wireEncodeProcess's exit handler).
1861
+ // Stop auto-retrying it; session.state stays "failed" so getFileStream
1862
+ // reports a clean, retryable error instead of looping forever. A DIFFERENT
1863
+ // target (the viewer seeking elsewhere) is unaffected — it gets its own
1864
+ // fresh attempt budget.
1865
+ if (index === session.seekFailureTarget && session.seekFailureCount >= MAX_SEEK_FAILURES) {
1866
+ return;
1867
+ }
1746
1868
  // Far request = a server-side seek. Do NOT restart on the first one:
1747
1869
  // debounce a burst of scattered requests into a single restart at the
1748
1870
  // position the player ended on. Record the latest target and (re)arm the
@@ -1776,6 +1898,14 @@ export class HlsSessionManager {
1776
1898
  session.seekFirstFarAt = 0;
1777
1899
  return;
1778
1900
  }
1901
+ // Circuit breaker (defense in depth): a timer armed before the cap was hit
1902
+ // could still be pending when it was reached — do not fire the restart it
1903
+ // was going to make. See the matching check in #ensureEncodingFor.
1904
+ if (target === session.seekFailureTarget && session.seekFailureCount >= MAX_SEEK_FAILURES) {
1905
+ session.seekTarget = null;
1906
+ session.seekFirstFarAt = 0;
1907
+ return;
1908
+ }
1779
1909
  // Minimum gap between actual restarts (the settle already collapses bursts;
1780
1910
  // this only guards back-to-back seeks). If still cooling down, re-arm once
1781
1911
  // for the remaining cooldown instead of restarting now.
@@ -1970,9 +2100,9 @@ export class HlsSessionManager {
1970
2100
  * Also refreshes `lastAccessedAt` to prevent the session from expiring.
1971
2101
  *
1972
2102
  * @param {string} sessionId
1973
- * @returns {object | null}
2103
+ * @returns {Promise<object | null>}
1974
2104
  */
1975
- getSessionProgress(sessionId) {
2105
+ async getSessionProgress(sessionId) {
1976
2106
  if (!isSafeSessionId(sessionId)) {
1977
2107
  return null;
1978
2108
  }
@@ -1990,6 +2120,13 @@ export class HlsSessionManager {
1990
2120
  const warmupRemainingSeconds = isWarmupPhase
1991
2121
  ? Math.max(0, warmupTotalSeconds - warmupElapsedSeconds)
1992
2122
  : null;
2123
+ // Observed OUTPUT bitrate (Mbit/s) from recently completed segment sizes —
2124
+ // already computed for the viewer-link budget check (#checkLinkBudget); also
2125
+ // exposed here so the browser can turn its OWN measured link throughput into
2126
+ // a "content-seconds delivered per wall-clock second" rate for the unified
2127
+ // three-stage ETA (download / transcode / delivery), the same way the
2128
+ // transcode's own `speed` already is one. Null when not enough segments yet.
2129
+ const outputMbps = await this.#observedStreamMbps(session);
1993
2130
  return {
1994
2131
  sessionId: session.id,
1995
2132
  state: session.progress.state,
@@ -2005,6 +2142,7 @@ export class HlsSessionManager {
2005
2142
  // of a percentage of the whole-file transcode.
2006
2143
  segmentDurationSec: this.segmentDurationSec,
2007
2144
  speed: session.progress.speed,
2145
+ outputMbps,
2008
2146
  updatedAt: session.progress.updatedAt,
2009
2147
  error: session.state === "failed" ? session.lastError : ""
2010
2148
  };