@torrent-tv/proxy 2.9.54 → 2.9.55

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.55
2
+
3
+ - **Fix**: One scrub of the seek bar could leave the encoder ping-ponging between positions with an empty player buffer for over a minute (field-diagnosed 2026-08-01). A single scrub makes the player fire SEVERAL segment requests within a few hundred milliseconds — field log: `#534`, `#694`, `#817`, `#828` within 361 ms — and each of them long-polls `getFileStream` every 300 ms until served. Every poll called `#ensureEncodingFor`, so the four in-flight requests took turns overwriting the seek target and restarting ffmpeg at each other's positions (`534→828→694→828→817→828`, six restarts), none surviving long enough to produce a segment: three of the four eventually timed out after 34-36 s and the fourth was served after 25 s, with the browser buffer at 0.0 s throughout. Fixed by giving each INCOMING request one sequence number (`nextRequestSeq`) that it keeps for all of its long-poll iterations, and letting only the newest request steer the encoder — an older request may still be served if its segment gets produced, but can no longer move the encode head. Verified by replaying the exact field sequence: 44 target switches before, 4 after (the initial burst, which the existing settle-debounce then collapses into a single restart), settling on the last-requested segment.
4
+ - **Fix**: The transcode percent read 0% for a whole run on **video-copy** sessions (`transcodeVideo:false`), the other half of the 2.9.53 timeline bug. That fix rebased ffmpeg's `-progress` `out_time` onto the absolute timeline only for the re-encode branch, on the assumption that `-copyts` already made the copy branch absolute. The assumption was never measured and is wrong: on the field host, `-ss 600 … -copyts -c:v copy` reports `out_time` = 0, 40.7, 54.9, 90.9 — relative to the run, exactly like the re-encode branch (field log: `processed=12.638` against `startPos=3312` at 12.6x speed). The rebase now applies to both branches, and both measurements are recorded in the code so neither can be exempted again without a fresh one.
5
+
1
6
  ## 2.9.54
2
7
 
3
8
  - **Fix**: Seeking left playback permanently frozen — the root cause behind the field reports of "seeking does nothing" / "100% • starting now on a dead player". After a seek the player fetched the target segment successfully, over and over (field log: segments 402 and 403 re-requested in a loop for more than two minutes at full link speed, ~250-340 KB each time, browser buffer stuck at 0.0 s) while the transcode itself was healthy. Cause: ffmpeg's HLS/fMP4 output writes `tfdt` (the box that says WHERE a fragment sits on the timeline) as **0** in every seek-restart run, and records the run's start offset in an `elst` edit list inside **that run's** init segment instead. That is self-consistent only while init and segments come from the same run — but the player fetches `#EXT-X-MAP` exactly once, so we must serve one init for the whole session. Read against that cached init, a post-seek segment loses its offset completely and claims to start at ~0 s; the player finds nothing at the position it seeked to, discards the segment and re-requests it, forever. **No ffmpeg configuration avoids this** — measured on the shipping build: HLS *and* DASH muxers, `-copyts`, `-output_ts_offset`, `-itsoffset`, `-avoid_negative_ts disabled`, `-movflags -use_edts/+dash/+frag_discont/+global_sidx`, `-video_track_timescale`; all emit `tfdt = 0`. Fixed by stamping each fragment's `tfdt` with the segment's true start time as it is served, which is what CMAF (ISO/IEC 23000-19) requires of an independently-addressable segment in the first place: the segment then carries its own position and is valid against any init for the same tracks. Verified against a reproduction of the field scenario (several consecutive seek-restarts, video+audio): a post-seek segment read with the session-cached init reports its true timestamp (80.1 s) instead of 0.083 s, and decodes cleanly.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.9.54",
3
+ "version": "2.9.55",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -51,8 +51,13 @@ export async function handleTranscodeSessionFileGet(req, reply, { hlsSessionMana
51
51
  */
52
52
  async function waitForSessionFile(hlsSessionManager, sessionId, fileName, timeoutMs) {
53
53
  const startedAt = Date.now();
54
+ // One sequence number for THIS request, reused by every poll below, so the
55
+ // session can tell a newly-arrived request apart from an old one polling
56
+ // again — see HlsSessionManager#ensureEncodingFor for the encoder ping-pong
57
+ // this prevents when one seek-bar scrub fires several segment requests.
58
+ const requestSeq = hlsSessionManager.nextRequestSeq(sessionId);
54
59
  while (Date.now() - startedAt < timeoutMs) {
55
- const result = await hlsSessionManager.getFileStream(sessionId, fileName);
60
+ const result = await hlsSessionManager.getFileStream(sessionId, fileName, { requestSeq });
56
61
  if (result.kind !== "warming-up") {
57
62
  return result;
58
63
  }
@@ -1042,6 +1042,12 @@ export class HlsSessionManager {
1042
1042
  // request (for the SEEK_SETTLE_MAX_MS cap).
1043
1043
  seekSettleTimer: null,
1044
1044
  seekTarget: null,
1045
+ // Monotonic sequence of INCOMING segment requests (see #ensureEncodingFor
1046
+ // and nextRequestSeq): a request is issued one number when it arrives and
1047
+ // keeps it across all its long-poll iterations, so a burst of requests
1048
+ // from one scrub cannot take turns steering the encoder.
1049
+ requestSeqCounter: 0,
1050
+ latestRequestSeq: 0,
1045
1051
  seekFirstFarAt: 0,
1046
1052
  // Circuit breaker: consecutive FAST failures (see SEEK_FAST_FAIL_MS) at
1047
1053
  // seekFailureTarget. Reset whenever a run starts at a DIFFERENT target or
@@ -1708,29 +1714,36 @@ export class HlsSessionManager {
1708
1714
  * `computeProgressMetrics` and the client's own cushion-percent/ETA math
1709
1715
  * both assume.
1710
1716
  *
1711
- * Branch B (video copy, `-copyts`) already reports `out_time` on the
1712
- * source's absolute timeline no rebase needed. Branch A (video re-encode)
1713
- * does NOT: `-output_ts_offset` (used there to relabel the MUXED output's
1714
- * timestamps onto the absolute grid) does not affect what `-progress`
1715
- * reports verified empirically (a 5s clip encoded with
1716
- * `-output_ts_offset 100` still reports `out_time` counting 0→5, not
1717
- * 100→105). Left unrebased, `processedSeconds` jumps from the post-restart
1717
+ * ffmpeg's `-progress` output counts from the START OF THIS RUN on BOTH
1718
+ * branchesneither `-output_ts_offset` (branch A, re-encode) nor
1719
+ * `-copyts` (branch B, video copy) changes it: both relabel the MUXED
1720
+ * output's timestamps, which is a different thing from what `-progress`
1721
+ * reports. Verified empirically on each branch separately against a real
1722
+ * file on the field host:
1723
+ * - branch A: a clip encoded with `-output_ts_offset 100` reports
1724
+ * `out_time` counting 0→5, not 100→105;
1725
+ * - branch B: `-ss 600 … -copyts -c:v copy` reports `out_time` =
1726
+ * 0, 40.7, 54.9, 90.9 — relative, NOT 600, 640.7, …
1727
+ * The branch-B half was originally ASSUMED to be absolute (because of
1728
+ * `-copyts`) and left unrebased in 2.9.53; that assumption was wrong and
1729
+ * cost a field session — hence both measurements above are recorded here,
1730
+ * and neither branch may be exempted again without a fresh measurement.
1731
+ *
1732
+ * Left unrebased, `processedSeconds` jumps from the post-restart
1718
1733
  * placeholder (`session.progress.startPositionSeconds`, absolute) down to a
1719
1734
  * near-zero RELATIVE value the moment real ffmpeg progress starts flowing —
1720
1735
  * `processedSeconds - startPositionSeconds` then goes deeply negative,
1721
1736
  * clamps to 0, and the client's cushion percent/ETA reads as permanently
1722
1737
  * stuck at 0% for the whole run even while the encode is actively
1723
- * producing (field-diagnosed 2026-08-01: a re-encode session logged
1724
- * `processed=39.5 startPos=1824` at a healthy 6x realtime speed).
1738
+ * producing (field-diagnosed 2026-08-01: `processed=39.5 startPos=1824` at
1739
+ * a healthy 6x speed on branch A; `processed=12.638 startPos=3312` at 12.6x
1740
+ * on branch B).
1725
1741
  *
1726
1742
  * @param {HlsSession} session
1727
1743
  * @param {number} rawSeconds - As parsed from `out_time`/`out_time_ms`.
1728
1744
  * @returns {number}
1729
1745
  */
1730
1746
  #toAbsoluteProcessedSeconds(session, rawSeconds) {
1731
- if (!session.transcodeVideo) {
1732
- return rawSeconds;
1733
- }
1734
1747
  const offset = Number.isFinite(session.progress?.startPositionSeconds)
1735
1748
  ? session.progress.startPositionSeconds
1736
1749
  : 0;
@@ -1889,10 +1902,24 @@ export class HlsSessionManager {
1889
1902
  * @param {number} index
1890
1903
  * @returns {void}
1891
1904
  */
1892
- #ensureEncodingFor(session, index) {
1905
+ #ensureEncodingFor(session, index, requestSeq = Number.MAX_SAFE_INTEGER) {
1893
1906
  if (!session || session.state === "disposed" || index < 0) {
1894
1907
  return;
1895
1908
  }
1909
+ // A stale in-flight request must not steer the encoder. One scrub of the
1910
+ // seek bar makes the player fire SEVERAL segment requests within a few
1911
+ // hundred ms (field-observed 2026-08-01: #534, #694, #817, #828 within
1912
+ // 361 ms), and each one long-polls this method every 300 ms until it is
1913
+ // served or times out. Without this guard they take turns overwriting
1914
+ // `seekTarget`, so the encoder ping-pongs between their positions
1915
+ // (534→828→694→828→817→828) and none of them ever completes — the buffer
1916
+ // stayed empty for over a minute while ffmpeg restarted six times. Only
1917
+ // the NEWEST request may set the target: older ones keep polling (their
1918
+ // segment may still be produced) but no longer move the encoder.
1919
+ if (requestSeq < session.latestRequestSeq) {
1920
+ return;
1921
+ }
1922
+ session.latestRequestSeq = requestSeq;
1896
1923
  const head = session.encodeStartIndex;
1897
1924
  // Anchor the look-ahead window on the CURRENT encode position (start index +
1898
1925
  // seconds already processed), not the run's start index. Otherwise a long
@@ -2015,11 +2042,32 @@ export class HlsSessionManager {
2015
2042
  throw new Error("HLS playlist is still warming up.");
2016
2043
  }
2017
2044
 
2045
+ /**
2046
+ * Issue the sequence number an incoming segment request keeps for all of its
2047
+ * long-poll iterations. The caller (the route) takes ONE number when the
2048
+ * request arrives and passes it back on every poll, which is what lets
2049
+ * #ensureEncodingFor tell "a newer request arrived" apart from "the same
2050
+ * request polled again" — see the ping-pong it prevents there.
2051
+ *
2052
+ * @param {string} sessionId
2053
+ * @returns {number} 0 when the session is unknown (treated as newest).
2054
+ */
2055
+ nextRequestSeq(sessionId) {
2056
+ const session = isSafeSessionId(sessionId) ? this.sessionsById.get(sessionId) : null;
2057
+ if (!session) {
2058
+ return 0;
2059
+ }
2060
+ session.requestSeqCounter += 1;
2061
+ return session.requestSeqCounter;
2062
+ }
2063
+
2018
2064
  /**
2019
2065
  * Open a read stream for an HLS segment or playlist file from a session.
2020
2066
  *
2021
2067
  * @param {string} sessionId
2022
2068
  * @param {string} fileName - Must match the playlist or segment name pattern.
2069
+ * @param {{ requestSeq?: number }} [options] - `requestSeq` from
2070
+ * {@link nextRequestSeq}, constant across one request's long-poll loop.
2023
2071
  * @returns {Promise<
2024
2072
  * | { kind: "not-found" }
2025
2073
  * | { kind: "warming-up" }
@@ -2027,7 +2075,7 @@ export class HlsSessionManager {
2027
2075
  * | { kind: "file"; stream: import("node:fs").ReadStream; contentType: string; isPlaylist: boolean }
2028
2076
  * >}
2029
2077
  */
2030
- async getFileStream(sessionId, fileName) {
2078
+ async getFileStream(sessionId, fileName, options = {}) {
2031
2079
  if (!isSafeSessionId(sessionId) || !isSafeFileName(fileName, this.segmentFormat)) {
2032
2080
  return { kind: "not-found" };
2033
2081
  }
@@ -2150,7 +2198,11 @@ export class HlsSessionManager {
2150
2198
  // to wait for the current encode run to reach it or to restart the encoder
2151
2199
  // at this position (server-side seeking). The caller long-polls.
2152
2200
  if (!isPlaylist) {
2153
- this.#ensureEncodingFor(session, this.segmentFormat.segmentIndexFromName(fileName));
2201
+ this.#ensureEncodingFor(
2202
+ session,
2203
+ this.segmentFormat.segmentIndexFromName(fileName),
2204
+ Number.isFinite(options?.requestSeq) ? options.requestSeq : Number.MAX_SAFE_INTEGER
2205
+ );
2154
2206
  }
2155
2207
  return { kind: "warming-up" };
2156
2208
  }