@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
|
@@ -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
|
-
*
|
|
1712
|
-
*
|
|
1713
|
-
*
|
|
1714
|
-
* timestamps
|
|
1715
|
-
* reports
|
|
1716
|
-
*
|
|
1717
|
-
*
|
|
1717
|
+
* ffmpeg's `-progress` output counts from the START OF THIS RUN on BOTH
|
|
1718
|
+
* branches — neither `-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:
|
|
1724
|
-
* `processed=
|
|
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(
|
|
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
|
}
|