@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
|
@@ -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).
|
|
832
|
-
//
|
|
833
|
-
//
|
|
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
|
|
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
|
|
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
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
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
|
};
|