@torrent-tv/proxy 2.9.91 → 2.9.93
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 +11 -0
- package/package.json +1 -1
- package/services/hls-session-manager.js +104 -88
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,14 @@
|
|
|
1
|
+
## 2.9.93
|
|
2
|
+
|
|
3
|
+
- **Fix**: A seek could kill playback outright. Restarting at a position that lands exactly on a keyframe leaves a floating-point residue — `seekSeconds - snappedKeyframe` came out as `3.3333333249174757e-7` — and `String()` renders anything below 1e-6 in exponential notation, which ffmpeg's duration parser rejects: `Invalid duration for option ss`. The run died on startup, and from then on every segment request answered 500. Time arguments are now formatted in fixed notation, and a residue under a millisecond is dropped rather than passed on, because it is not a real offset.
|
|
4
|
+
- **Fix**: A session could never recover from a dead encoder. The "already covered by the running encode, not restarting" shortcut did not check that the run was alive, so once one had died `session.ffmpeg` still pointed at the corpse and every later seek was waved through as already covered. One ffmpeg failure therefore became a session that answered 500 for as long as the viewer kept trying.
|
|
5
|
+
- **Fix**: The look-ahead bound held the encoder back but did not keep it there. Any segment request released it, including a request for something produced ten minutes earlier, so it sawtoothed between suspended and running and drifted from 155 s to 922 s ahead of the viewer over three minutes. A request now re-evaluates the same condition the monitor uses instead of resuming outright.
|
|
6
|
+
|
|
7
|
+
## 2.9.92
|
|
8
|
+
|
|
9
|
+
- **Fix**: A seek acts on what the viewer asked for, instead of waiting out guards built for a signal that no longer exists. Three delays sat in front of every seek, all of them there because a far segment REQUEST used to steer the encoder and the player's playlist scan produced dozens of them. Requests stopped steering anything when the position became explicit, so what arrives now is only ever a position the viewer stated. The settle window drops from 1.2 s to 300 ms (the browser already collapses a drag into one report at 300 ms — this was a second debounce on an already-debounced signal, and it cost 1.2 s of every measured seek). The floor between restarts drops from 4 s to 500 ms, now a guard against a client spamming the endpoint rather than a policy about noise. And a run in progress is no longer protected for up to 30 s while it reaches its first segment: finishing a segment for a position the viewer has left is work nobody wants, and the hold could delay a genuine second seek by the whole grace. Measured cost of the old behaviour, 2026-08-04: two seeks 1.3 s apart produced two restarts 4.4 s apart, the first encoding 119.5 s of content before the second killed it.
|
|
10
|
+
- **Chore**: Removed `ENCODER_STALL_MS`, declared with a paragraph describing a watchdog that was never wired to anything.
|
|
11
|
+
|
|
1
12
|
## 2.9.91
|
|
2
13
|
|
|
3
14
|
- **Fix**: The encoder no longer runs away from the viewer. Nothing bounded how far ahead it produced: measured 2026-08-04, three minutes after a film was opened the encode had reached 00:39:24 of a 01:26:51 source at 12.8x while the viewer was still at the start, and the torrent had pulled 80% of 4.7 GB to feed it — the pool owner's bandwidth and disk spent on a viewer who may watch two minutes, the pieces being read evicted from memory by pieces forty minutes ahead, and the swarm busy with anything but the segment being waited for. An encoder more than two minutes of content ahead of the last segment its viewer asked for is now **suspended**, and released once the viewer is within a minute of it — or at once when a segment is requested. Suspended rather than killed on purpose: restarting costs about nine seconds on this hardware, so a viewer reaching the end of the produced range would stall every time, while suspending keeps the process, its input and its position. POSIX only; where `SIGSTOP` does not exist the attempt fails once, is logged, and that session keeps the old behaviour. Every path that terminates an encoder now releases it first — a suspended process does not act on `SIGTERM` until it is continued, which would have hung the wait a seek performs before starting its replacement.
|
package/package.json
CHANGED
|
@@ -43,26 +43,15 @@ const DEFAULT_SEGMENT_DURATION_SEC = 4;
|
|
|
43
43
|
// is allowed to be before we restart ffmpeg at that position (server-side seek).
|
|
44
44
|
// Requests within the window are served by waiting for the running encode.
|
|
45
45
|
const MAX_LOOKAHEAD_SEGMENTS = 8;
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
|
|
55
|
-
// run cannot block seeking forever. See #fireSettledSeek.
|
|
56
|
-
const RUN_FIRST_SEGMENT_GRACE_MS = 30_000;
|
|
57
|
-
// Encoder stall watchdog. A running ffmpeg emits `-progress` output on stdout
|
|
58
|
-
// continuously while it encodes; when it hangs mid-file (alive, but producing
|
|
59
|
-
// no output and no stderr — a deadlock, e.g. a stalled input read), that output
|
|
60
|
-
// stops and `progress.updatedAt` freezes. If a segment INSIDE the look-ahead
|
|
61
|
-
// window is being demanded but progress has not advanced for this long, the
|
|
62
|
-
// encoder is wedged (observed: the segment 503s forever). Treat it like a seek
|
|
63
|
-
// and restart ffmpeg at the demanded segment. Conservative — a slow-but-moving
|
|
64
|
-
// encode keeps advancing `updatedAt`, so this only fires on a true freeze.
|
|
65
|
-
const ENCODER_STALL_MS = 12_000;
|
|
46
|
+
// Floor between actual restarts. It used to be 4 s, from when a far segment
|
|
47
|
+
// REQUEST could steer the encoder and a playlist scan produced a burst of them.
|
|
48
|
+
// Requests no longer steer anything (see #ensureEncodingFor) — every restart
|
|
49
|
+
// now comes from a position the viewer stated — so this is no longer a policy
|
|
50
|
+
// about noise, only a guard against a client that spams the seek endpoint.
|
|
51
|
+
// Measured cost of the old value 2026-08-04: two seeks 1.3 s apart produced two
|
|
52
|
+
// restarts 4.4 s apart, the first encoding 119.5 s of content nobody wanted
|
|
53
|
+
// before the second killed it.
|
|
54
|
+
const RESTART_COOLDOWN_MS = 500;
|
|
66
55
|
// How far ahead of the viewer the encoder may run before it is stopped, and how
|
|
67
56
|
// far it must fall back to before it is let go again.
|
|
68
57
|
//
|
|
@@ -101,10 +90,17 @@ const LOOKAHEAD_RESUME_SECONDS = 60;
|
|
|
101
90
|
// encoding 125 s of content before reaching the viewer's position. Field
|
|
102
91
|
// 2026-08-02: a seek took 56 s, of which ~50 s was this backoff.
|
|
103
92
|
const SEEK_BACKOFF_SEGMENTS = 1;
|
|
104
|
-
|
|
105
|
-
//
|
|
93
|
+
// How long to wait for a scrub to stop moving before acting on it. Small,
|
|
94
|
+
// because the browser already collapses a drag into ONE report
|
|
95
|
+
// (`SEEK_REPORT_DEBOUNCE_MS`, 300 ms) and only reports where it settled — this
|
|
96
|
+
// is a second debounce on an already-debounced signal, and every millisecond of
|
|
97
|
+
// it is dead time in front of the viewer. It was 1.2 s when the encoder was
|
|
98
|
+
// also steered by segment requests, which arrive in bursts of dozens; measured
|
|
99
|
+
// 2026-08-04, that cost 1.2 s of every seek.
|
|
100
|
+
const SEEK_SETTLE_MS = 300;
|
|
101
|
+
// Hard cap on the total settle wait, measured from the first request of a
|
|
106
102
|
// burst, so a still-moving scrubber cannot delay a genuine seek forever.
|
|
107
|
-
const SEEK_SETTLE_MAX_MS =
|
|
103
|
+
const SEEK_SETTLE_MAX_MS = 1_000;
|
|
108
104
|
// Grace period to wait for the PREVIOUS ffmpeg process to exit (per signal
|
|
109
105
|
// escalation step: SIGTERM, then SIGKILL) before spawning its replacement into
|
|
110
106
|
// the same session directory. See #startEncodeRun.
|
|
@@ -577,6 +573,28 @@ async function probeVideoKeyframeTimes(ffmpegBin, inputUrl, timeoutMs = 25_000)
|
|
|
577
573
|
});
|
|
578
574
|
}
|
|
579
575
|
|
|
576
|
+
/**
|
|
577
|
+
* A number of seconds as ffmpeg will accept it.
|
|
578
|
+
*
|
|
579
|
+
* `String(n)` switches to exponential notation below 1e-6, and ffmpeg's
|
|
580
|
+
* duration parser rejects that outright: a field session died on
|
|
581
|
+
* `Invalid duration for option ss: 3.3333333249174757e-7`, after which the
|
|
582
|
+
* transcode was in state `failed` and every segment request answered 500 for
|
|
583
|
+
* as long as the viewer kept trying. Anything under a millisecond is also not a
|
|
584
|
+
* real offset — it is the residue of subtracting two nearly equal floats — so
|
|
585
|
+
* it is dropped rather than passed on.
|
|
586
|
+
*
|
|
587
|
+
* @param {number} value
|
|
588
|
+
* @returns {string}
|
|
589
|
+
*/
|
|
590
|
+
export function ffmpegSeconds(value) {
|
|
591
|
+
if (!Number.isFinite(value) || Math.abs(value) < 0.001) {
|
|
592
|
+
return "0";
|
|
593
|
+
}
|
|
594
|
+
// Microsecond resolution, fixed notation, no trailing zero noise.
|
|
595
|
+
return value.toFixed(6).replace(/\.?0+$/, "");
|
|
596
|
+
}
|
|
597
|
+
|
|
580
598
|
/**
|
|
581
599
|
* Compute segment START times (a 0-based timeline) for a session.
|
|
582
600
|
*
|
|
@@ -1592,24 +1610,41 @@ export class HlsSessionManager {
|
|
|
1592
1610
|
*/
|
|
1593
1611
|
#enforceLookAhead() {
|
|
1594
1612
|
for (const session of this.sessionsById.values()) {
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
+
this.#enforceLookAheadFor(session);
|
|
1614
|
+
}
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
/**
|
|
1618
|
+
* Decide whether one session's encoder should be running right now.
|
|
1619
|
+
*
|
|
1620
|
+
* Called both on the monitor's interval and the moment a segment is
|
|
1621
|
+
* requested. It must be the SAME decision in both places: an earlier version
|
|
1622
|
+
* simply resumed on any request, which meant a request for a segment produced
|
|
1623
|
+
* ten minutes ago released an encoder that had nothing left to do — measured
|
|
1624
|
+
* 2026-08-04, the encoder sawtoothed between suspended and running and drifted
|
|
1625
|
+
* from 135 s to 702 s ahead of the viewer while doing it.
|
|
1626
|
+
*
|
|
1627
|
+
* @param {HlsSession} session
|
|
1628
|
+
* @returns {void}
|
|
1629
|
+
*/
|
|
1630
|
+
#enforceLookAheadFor(session) {
|
|
1631
|
+
if (!session || session.state === "disposed" || !session.ffmpeg) {
|
|
1632
|
+
return;
|
|
1633
|
+
}
|
|
1634
|
+
const encodedTo = Number(session.progress?.processedSeconds);
|
|
1635
|
+
if (!Number.isFinite(encodedTo)) {
|
|
1636
|
+
return;
|
|
1637
|
+
}
|
|
1638
|
+
// Where the viewer is. Before the first segment request, the position the
|
|
1639
|
+
// run started at — so a session nobody has read from yet is bounded too.
|
|
1640
|
+
const viewerAt = Number.isInteger(session.lastRequestedSegment)
|
|
1641
|
+
? this.#segmentStartTime(session, session.lastRequestedSegment)
|
|
1642
|
+
: this.#segmentStartTime(session, session.encodeStartIndex ?? 0);
|
|
1643
|
+
const ahead = encodedTo - viewerAt;
|
|
1644
|
+
if (!session.encoderPaused && ahead > LOOKAHEAD_PAUSE_SECONDS) {
|
|
1645
|
+
this.#pauseEncoder(session, `${Math.round(ahead)}s ahead of the viewer`);
|
|
1646
|
+
} else if (session.encoderPaused && ahead <= LOOKAHEAD_RESUME_SECONDS) {
|
|
1647
|
+
this.#resumeEncoder(session, `${Math.round(ahead)}s ahead of the viewer`);
|
|
1613
1648
|
}
|
|
1614
1649
|
}
|
|
1615
1650
|
|
|
@@ -1937,17 +1972,17 @@ export class HlsSessionManager {
|
|
|
1937
1972
|
if (snappedKeyframe !== null) {
|
|
1938
1973
|
const residualSeconds = Math.max(0, seekSeconds - snappedKeyframe);
|
|
1939
1974
|
if (snappedKeyframe > 0) {
|
|
1940
|
-
args.push("-ss",
|
|
1975
|
+
args.push("-ss", ffmpegSeconds(snappedKeyframe));
|
|
1941
1976
|
}
|
|
1942
1977
|
args.push("-i", session.inputUrl);
|
|
1943
1978
|
if (residualSeconds > 0) {
|
|
1944
|
-
args.push("-ss",
|
|
1979
|
+
args.push("-ss", ffmpegSeconds(residualSeconds));
|
|
1945
1980
|
}
|
|
1946
1981
|
} else {
|
|
1947
1982
|
if (seekSeconds > 0) {
|
|
1948
1983
|
// No keyframe map (probe failed/timed out) — fall back to the previous
|
|
1949
1984
|
// behaviour: trust the container's own accurate seek.
|
|
1950
|
-
args.push("-accurate_seek", "-ss",
|
|
1985
|
+
args.push("-accurate_seek", "-ss", ffmpegSeconds(seekSeconds));
|
|
1951
1986
|
}
|
|
1952
1987
|
args.push("-i", session.inputUrl);
|
|
1953
1988
|
}
|
|
@@ -1956,7 +1991,7 @@ export class HlsSessionManager {
|
|
|
1956
1991
|
// segment grid; relabel output onto the original timeline so segment N
|
|
1957
1992
|
// carries PTS = N × segmentDuration.
|
|
1958
1993
|
if (startSeconds > 0) {
|
|
1959
|
-
args.push("-output_ts_offset",
|
|
1994
|
+
args.push("-output_ts_offset", ffmpegSeconds(startSeconds));
|
|
1960
1995
|
}
|
|
1961
1996
|
} else {
|
|
1962
1997
|
// Branch B (video copied — only audio is transcoded): we cannot insert
|
|
@@ -1968,7 +2003,7 @@ export class HlsSessionManager {
|
|
|
1968
2003
|
// beginning and desyncs audio/video). Audio is transcoded on this timeline.
|
|
1969
2004
|
args.push("-copyts");
|
|
1970
2005
|
if (sourceStartTime !== 0) {
|
|
1971
|
-
args.push("-output_ts_offset",
|
|
2006
|
+
args.push("-output_ts_offset", ffmpegSeconds(-sourceStartTime));
|
|
1972
2007
|
}
|
|
1973
2008
|
}
|
|
1974
2009
|
args.push(
|
|
@@ -2393,8 +2428,14 @@ export class HlsSessionManager {
|
|
|
2393
2428
|
: this.#segmentStartTime(session, head);
|
|
2394
2429
|
const currentSeg = Math.max(head, this.#segmentIndexForTime(session, processed));
|
|
2395
2430
|
// Already covered by the running encode — the data is on its way, so
|
|
2396
|
-
// restarting would only destroy work the viewer is waiting for.
|
|
2397
|
-
|
|
2431
|
+
// restarting would only destroy work the viewer is waiting for. The run has
|
|
2432
|
+
// to be ALIVE for that to hold: after a run died, `session.ffmpeg` still
|
|
2433
|
+
// pointed at the dead process and every later seek was waved through as
|
|
2434
|
+
// "already covered", so nothing could ever restart it. Measured 2026-08-04:
|
|
2435
|
+
// one ffmpeg failure turned into a session that answered 500 to every
|
|
2436
|
+
// segment for as long as the viewer kept trying.
|
|
2437
|
+
const runIsAlive = session.ffmpeg != null && !hasChildExited(session.ffmpeg);
|
|
2438
|
+
if (runIsAlive && index >= head && index <= currentSeg + MAX_LOOKAHEAD_SEGMENTS) {
|
|
2398
2439
|
logger.info(
|
|
2399
2440
|
`transcode ${session.id} seek to ${positionSeconds.toFixed(1)}s (#${index}) ` +
|
|
2400
2441
|
`already within the running encode (#${head}..#${currentSeg}) — not restarting`
|
|
@@ -2464,48 +2505,21 @@ export class HlsSessionManager {
|
|
|
2464
2505
|
session.seekSettleTimer.unref?.();
|
|
2465
2506
|
return;
|
|
2466
2507
|
}
|
|
2467
|
-
//
|
|
2468
|
-
//
|
|
2469
|
-
//
|
|
2470
|
-
//
|
|
2471
|
-
//
|
|
2472
|
-
//
|
|
2473
|
-
//
|
|
2474
|
-
//
|
|
2475
|
-
//
|
|
2476
|
-
//
|
|
2477
|
-
// synthetic VOD playlist, so from its point of view they all exist), and
|
|
2478
|
-
// each far-enough probe looked like a fresh seek to us. Waiting for the
|
|
2479
|
-
// first segment makes the scan harmless — it can no longer steer the
|
|
2480
|
-
// encoder — and one genuine seek now reliably completes.
|
|
2481
|
-
//
|
|
2482
|
-
// Bounded by RUN_FIRST_SEGMENT_GRACE_MS so a wedged run cannot block seeks
|
|
2483
|
-
// forever; the encoder-stall watchdog and the exit handler cover a run that
|
|
2484
|
-
// dies outright.
|
|
2508
|
+
// A run in progress is NOT protected any more. It used to be: a restart was
|
|
2509
|
+
// held for up to 30 s while the current run reached its first segment,
|
|
2510
|
+
// because a far segment REQUEST could steer the encoder and the player's
|
|
2511
|
+
// playlist scan produced dozens of them — restarts at #617 → #717 → #732 →
|
|
2512
|
+
// #732 every 5-7 s, none producing anything (field 2026-08-02). Requests
|
|
2513
|
+
// stopped steering anything when the position became explicit, so the only
|
|
2514
|
+
// thing that can arrive here is a position the viewer has stated, and
|
|
2515
|
+
// finishing a segment for where they no longer are is work nobody wants.
|
|
2516
|
+
// Holding it was also expensive in the other direction: a genuine second
|
|
2517
|
+
// seek could be delayed by the whole grace.
|
|
2485
2518
|
const producedThisRun = this.#producedSecondsThisRun(session);
|
|
2486
2519
|
const runIsAlive = session.ffmpeg != null && !hasChildExited(session.ffmpeg);
|
|
2487
|
-
if (
|
|
2488
|
-
runIsAlive &&
|
|
2489
|
-
producedThisRun < this.segmentDurationSec &&
|
|
2490
|
-
sinceLastRestart < RUN_FIRST_SEGMENT_GRACE_MS
|
|
2491
|
-
) {
|
|
2492
|
-
logger.info(
|
|
2493
|
-
`transcode ${session.id} seek #${target} HELD — current run has produced ` +
|
|
2494
|
-
`${producedThisRun.toFixed(1)}s of the ${this.segmentDurationSec}s first segment ` +
|
|
2495
|
-
`(${(sinceLastRestart / 1000).toFixed(1)}s into a ${RUN_FIRST_SEGMENT_GRACE_MS / 1000}s grace)`
|
|
2496
|
-
);
|
|
2497
|
-
session.seekSettleTimer = setTimeout(() => this.#fireSettledSeek(session), SEEK_SETTLE_MS);
|
|
2498
|
-
session.seekSettleTimer.unref?.();
|
|
2499
|
-
return;
|
|
2500
|
-
}
|
|
2501
|
-
// Why the restart was allowed — the counterpart of the HELD line above.
|
|
2502
|
-
// Without it a restart is indistinguishable from the runaway ping-pong this
|
|
2503
|
-
// guard exists to stop, and diagnosing a field report becomes guesswork.
|
|
2504
2520
|
const allowedBecause = !runIsAlive
|
|
2505
2521
|
? "run is dead"
|
|
2506
|
-
:
|
|
2507
|
-
? `run produced ${producedThisRun.toFixed(1)}s (first segment done)`
|
|
2508
|
-
: `grace of ${RUN_FIRST_SEGMENT_GRACE_MS / 1000}s expired`;
|
|
2522
|
+
: `viewer moved; run had produced ${producedThisRun.toFixed(1)}s`;
|
|
2509
2523
|
// The start is exactly what requestSeek computed — one segment before the
|
|
2510
2524
|
// viewer's position — and nothing else may move it.
|
|
2511
2525
|
//
|
|
@@ -2709,8 +2723,10 @@ export class HlsSessionManager {
|
|
|
2709
2723
|
const requested = session.segmentFormat.segmentIndexFromName(fileName);
|
|
2710
2724
|
if (requested >= 0) {
|
|
2711
2725
|
session.lastRequestedSegment = requested;
|
|
2712
|
-
// A viewer who has caught up must not wait out the monitor's interval
|
|
2713
|
-
|
|
2726
|
+
// A viewer who has caught up must not wait out the monitor's interval —
|
|
2727
|
+
// but only if they HAVE caught up, which is why this re-evaluates the
|
|
2728
|
+
// same condition instead of resuming outright.
|
|
2729
|
+
this.#enforceLookAheadFor(session);
|
|
2714
2730
|
}
|
|
2715
2731
|
}
|
|
2716
2732
|
try {
|