@torrent-tv/proxy 2.9.36 → 2.9.38
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 +12 -1
- package/openspec/changes/adaptive-bitrate/.openspec.yaml +0 -0
- package/openspec/changes/adaptive-bitrate/design.md +92 -0
- package/openspec/changes/adaptive-bitrate/proposal.md +56 -0
- package/openspec/changes/adaptive-bitrate/specs/adaptive-bitrate/spec.md +51 -0
- package/openspec/changes/adaptive-bitrate/tasks.md +40 -0
- package/openspec/changes/seek-debounce/.openspec.yaml +0 -0
- package/openspec/changes/seek-debounce/design.md +118 -0
- package/openspec/changes/seek-debounce/proposal.md +59 -0
- package/openspec/changes/seek-debounce/specs/seek-debounce/spec.md +41 -0
- package/openspec/changes/seek-debounce/tasks.md +33 -0
- package/package.json +1 -1
- package/routes/api/transcode-sessions/net-report/post.js +31 -0
- package/server.js +4 -0
- package/services/hls-session-manager.js +206 -17
- package/services/hwaccel.js +53 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,17 @@
|
|
|
1
|
-
## 2.9.
|
|
1
|
+
## 2.9.38
|
|
2
|
+
|
|
3
|
+
- **New**: Adaptive bitrate for thin viewer links (OpenSpec change `adaptive-bitrate`). Field evidence (iPhone on cellular): uncapped complex scenes produced 4 s segments of ~18 Mbit/s against a 1–6 Mbit/s link — 45 s prebuffer and a draining buffer. Two parts. (a) Software encodes are now constrained-CRF: `-maxrate`/`-bufsize` per resolution rung (1080p→5000K, 720p→2800K, 480p→1400K, 360p→800K, 240p→400K nominal; ×1.3/×1.5 — webtor's production multipliers), so peaks stay bounded. (b) New data-channel route `POST /api/transcode-sessions/:id/net-report` accepts the browser's measured link throughput + buffered seconds; the realtime-budget loop gains a second downshift trigger — a FRESH report showing the usable link (×0.8 safety) sustainedly (15 s) below the observed produced bitrate while the viewer's buffer is low (<10 s) steps the encode one rung down via the existing machinery (shared 30 s cooldown, step cap, no upswitch). Log reason `viewer-link-bound` distinguishes it from CPU downshifts. Manual-quality sessions are exempt (no budget ladder); old clients that never report simply keep today's behaviour plus the caps.
|
|
4
|
+
|
|
5
|
+
## 2.9.37
|
|
6
|
+
|
|
7
|
+
- **Fix**: Scrubbing (server-side seek) no longer hangs the player. A far segment request restarts ffmpeg at that position; native players (notably iOS HLS) issue a burst of scattered far requests after a scrub (observed: `367 → 732 → 369 → 368 → 370`, tens of seconds apart), and the old fixed 4 s cooldown only suppressed restarts within 4 s of the last — so each scattered request restarted ffmpeg and it ping-ponged between positions, producing nothing and stalling playback. Far requests are now **debounced**: the target index is recorded and a short settle timer armed (1.2 s quiet period, 2.5 s hard cap from the burst's first request); further far requests re-arm it and update the target to the latest index; when it settles, ffmpeg restarts once at that index. "Last index wins" self-corrects — a wrong target costs at most one extra settle, never the old infinite loop. The settle timer is cleared on session disposal. (OpenSpec change `seek-debounce`.)
|
|
8
|
+
|
|
9
|
+
## 2.9.36
|
|
2
10
|
|
|
3
11
|
- **New**: Chunked request bodies over the data channel (OpenSpec change `chunked-request-bodies`). Large request bodies — notably the source registration, whose body is the base64 `.torrent` (hundreds of KB for a multi-season pack) — now arrive as bounded binary frames (the response-frame layout) announced by a `request-start` message, and are reassembled and run through the same path as a single-message request. Bounded: 32 MB per-body cap, a 60 s TTL for incomplete bodies, an abort frame that drops partial state at once, and all per-channel state freed on channel close. This removes the single-message size ceiling symmetrically with responses (which already stream in chunks). Logged as `body=<bytes> bytes (chunked)`.
|
|
12
|
+
|
|
13
|
+
## 2.9.35
|
|
14
|
+
|
|
4
15
|
- **Fix**: Large torrents (many files / seasons) no longer fail with "Trying to send message larger than max-message-size" when a file is picked. The browser sends the source registration body — the base64-encoded `.torrent` — in a single data-channel message; a big multi-season pack's `.torrent` carries thousands of piece hashes (e.g. Poirot, 13 seasons: 420 KB → ~560 KB base64), exceeding libdatachannel's default advertised limit of 256 KB, so the browser's `channel.send()` threw. The proxy now advertises a 16 MB `a=max-message-size`, so a large single send still works while already-open tabs run the old bundle. Verified the SDP now carries `a=max-message-size:16777216` (was `262144`).
|
|
5
16
|
|
|
6
17
|
## 2.9.34
|
|
File without changes
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# Design: Adaptive bitrate (proxy)
|
|
2
|
+
|
|
3
|
+
Read before coding: `services/hwaccel.js` (`chooseSoftwareEncodeSettings`,
|
|
4
|
+
the software descriptor's `buildVideoArgs`), `services/hls-session-manager.js`
|
|
5
|
+
(the `BUDGET_*` constants, `#chooseEncodeBudget`, the budget interval check
|
|
6
|
+
`#enforceRealtimeBudget`, `#classifyTranscodeBound`, `#applyBudgetDownshift`,
|
|
7
|
+
session fields around `budgetLadder`), `routes/api/transcode-sessions/`
|
|
8
|
+
(route file conventions), `server.js` (route wiring, deps injection).
|
|
9
|
+
|
|
10
|
+
## (a) Caps — constrained CRF
|
|
11
|
+
|
|
12
|
+
Nominal H.264 rates by rung height (nearest rung wins for odd heights):
|
|
13
|
+
|
|
14
|
+
RUNG_NOMINAL_KBPS = { 1080: 5000, 720: 2800, 480: 1400, 360: 800, 240: 400 }
|
|
15
|
+
CAP_MAXRATE_FACTOR = 1.3
|
|
16
|
+
CAP_BUFSIZE_FACTOR = 1.5
|
|
17
|
+
|
|
18
|
+
In the software descriptor's video args (where `-crf`/`-preset` are emitted),
|
|
19
|
+
add for encode height H (the actual encode height after budget/manual
|
|
20
|
+
selection, not the source height):
|
|
21
|
+
|
|
22
|
+
-maxrate <round(nominal(H) * 1.3)>k
|
|
23
|
+
-bufsize <round(nominal(H) * 1.5)>k
|
|
24
|
+
|
|
25
|
+
CRF stays the quality driver; the caps only bound peaks (standard
|
|
26
|
+
constrained-CRF). Do NOT touch hardware descriptors in this change.
|
|
27
|
+
|
|
28
|
+
## (b) Net report intake
|
|
29
|
+
|
|
30
|
+
Route: `POST /api/transcode-sessions/:id/net-report` (data-channel path, so
|
|
31
|
+
add the prefix to the allowlist regex in `data-channel-handler.js` if not
|
|
32
|
+
covered by the existing `/api/` rule — verify). Body:
|
|
33
|
+
|
|
34
|
+
{ linkMbps: number, bufferedAheadSec: number }
|
|
35
|
+
|
|
36
|
+
Validation: both finite numbers, `linkMbps > 0`, else 400. Unknown session →
|
|
37
|
+
404. On success 204. Handler stores on the session:
|
|
38
|
+
|
|
39
|
+
session.netReport = { linkMbps, bufferedAheadSec, at: Date.now() }
|
|
40
|
+
|
|
41
|
+
## (b) Budget-loop trigger
|
|
42
|
+
|
|
43
|
+
Constants (near the other BUDGET_ constants):
|
|
44
|
+
|
|
45
|
+
LINK_REPORT_FRESH_MS = 30_000 // ignore stale reports
|
|
46
|
+
LINK_SAFETY = 0.8 // usable share of reported link
|
|
47
|
+
LINK_SLOW_WINDOW_MS = 15_000 // sustained deficit before acting
|
|
48
|
+
LINK_LOW_BUFFER_SEC = 10 // only act while the viewer runs dry
|
|
49
|
+
|
|
50
|
+
In the periodic budget check, for each active software-transcode session,
|
|
51
|
+
alongside the CPU check:
|
|
52
|
+
|
|
53
|
+
report fresh (now - at < LINK_REPORT_FRESH_MS)?
|
|
54
|
+
manualQuality not set?
|
|
55
|
+
observed = observed stream bitrate, Mbit/s — recent produced segment
|
|
56
|
+
bytes / segment duration (reuse/extend whatever the CPU path
|
|
57
|
+
reads; a rolling average over the last ~5 segments)
|
|
58
|
+
deficit = report.linkMbps * LINK_SAFETY < observed
|
|
59
|
+
if deficit AND report.bufferedAheadSec < LINK_LOW_BUFFER_SEC:
|
|
60
|
+
accumulate slow-time (same pattern as the CPU slow window)
|
|
61
|
+
if slow ≥ LINK_SLOW_WINDOW_MS → #applyBudgetDownshift(session,
|
|
62
|
+
reason "link") // existing cooldown + step cap + floor apply
|
|
63
|
+
else: reset the link slow window
|
|
64
|
+
|
|
65
|
+
`#applyBudgetDownshift` is reused as-is except the log line carries the
|
|
66
|
+
reason: `budget downshift (link) …` vs the current CPU wording, so the
|
|
67
|
+
client-log pipeline can tell them apart in the field.
|
|
68
|
+
|
|
69
|
+
No upswitch in v1 (mirrors the CPU budget's conservatism). A downshifted
|
|
70
|
+
session stays down until re-opened.
|
|
71
|
+
|
|
72
|
+
## Interactions
|
|
73
|
+
|
|
74
|
+
- Caps make `observed` honest: without (a) a complex scene can spike far
|
|
75
|
+
above nominal and flap the trigger; with caps observed ≤ maxrate.
|
|
76
|
+
- CPU trigger and link trigger share the cooldown inside
|
|
77
|
+
`#applyBudgetDownshift` — they cannot double-fire.
|
|
78
|
+
- `manualQuality` set → link trigger skipped entirely (user pinned quality
|
|
79
|
+
explicitly; the proxy respects it, matching the manual-quality contract).
|
|
80
|
+
- Old browsers never POST reports → trigger never fires → behaviour
|
|
81
|
+
identical to today plus caps.
|
|
82
|
+
|
|
83
|
+
## Verification
|
|
84
|
+
|
|
85
|
+
- Unit: nominal table lookup (exact rungs + nearest for odd heights); ffmpeg
|
|
86
|
+
arg assembly contains the caps; trigger logic with a fake clock (fresh vs
|
|
87
|
+
stale report, deficit accumulating to a downshift, buffer-high suppresses,
|
|
88
|
+
manualQuality suppresses).
|
|
89
|
+
- `node --check` on touched files.
|
|
90
|
+
- Field (after the server change ships): cellular session shows
|
|
91
|
+
`budget downshift (link)` and the stream settles at a rung whose bitrate
|
|
92
|
+
fits the link; segment sizes bounded (~maxrate × segDur / 8 max).
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# Proposal: Adaptive bitrate for thin viewer links (proxy side)
|
|
2
|
+
|
|
3
|
+
## Why
|
|
4
|
+
|
|
5
|
+
Field evidence (iPhone on cellular, 2026-07-10, session `14cc1017`): encode
|
|
6
|
+
bitrate is unbounded — transcoded segments reached 9 MB per 4 s (~18 Mbit/s)
|
|
7
|
+
against a measured 1–5.8 Mbit/s cellular link. Result: 45 s prebuffer, buffer
|
|
8
|
+
draining during playback (`bottleneck delta=-7.1s`), "было так себе". The
|
|
9
|
+
realtime budget protects the PROXY's CPU but nothing protects the VIEWER's
|
|
10
|
+
link: quality never adapts to how fast the viewer can actually download.
|
|
11
|
+
|
|
12
|
+
## What Changes
|
|
13
|
+
|
|
14
|
+
Two parts, one release:
|
|
15
|
+
|
|
16
|
+
- **(a) Bitrate caps (constrained CRF).** Software video encodes gain
|
|
17
|
+
`-maxrate`/`-bufsize` sized per resolution rung from a nominal-rate table
|
|
18
|
+
(H.264 ladder: 1080p→5000K, 720p→2800K, 480p→1400K, 360p→800K, 240p→400K;
|
|
19
|
+
nearest rung by encode height). Multipliers from webtor's production
|
|
20
|
+
ladder: `maxrate = 1.3×` nominal, `bufsize = 1.5×` nominal. CRF/preset
|
|
21
|
+
selection stays — the caps only bound the peaks that killed the cellular
|
|
22
|
+
session. Applies to the software (libx264) path; hardware encoders keep
|
|
23
|
+
their current args (follow-up — their rate-control flags differ and any
|
|
24
|
+
change must pass the strict startup test on real hardware).
|
|
25
|
+
- **(b) Viewer-link downshift trigger.** A new data-channel route lets the
|
|
26
|
+
browser report its measured link state (`linkMbps` — rolling median of
|
|
27
|
+
per-segment transfer throughput; `bufferedAheadSec`) every ~10 s. The
|
|
28
|
+
existing realtime-budget loop gains a SECOND downshift trigger: when
|
|
29
|
+
reports are fresh, `manualQuality` is not set, the viewer's link is
|
|
30
|
+
sustainedly slower than the produced stream (`linkMbps × 0.8 <` observed
|
|
31
|
+
segment bitrate for ≥ 15 s) AND the viewer's buffer is low (< 10 s), step
|
|
32
|
+
one rung down via the existing `#applyBudgetDownshift` (same cooldown,
|
|
33
|
+
step cap, no upswitch v1). Distinct log reason (`reason=link`) so field
|
|
34
|
+
logs distinguish CPU-bound from link-bound downshifts.
|
|
35
|
+
|
|
36
|
+
Missing/stale reports change nothing (old clients keep working — the trigger
|
|
37
|
+
simply never fires). Manual quality pins win: when `manualQuality` is set the
|
|
38
|
+
trigger is skipped.
|
|
39
|
+
|
|
40
|
+
## Capabilities
|
|
41
|
+
|
|
42
|
+
### Modified Capabilities
|
|
43
|
+
|
|
44
|
+
- `transcode-quality`: encodes are bitrate-capped per rung; the budget loop
|
|
45
|
+
adapts to the viewer's link, not only to the proxy's CPU.
|
|
46
|
+
|
|
47
|
+
## Impact
|
|
48
|
+
|
|
49
|
+
- `services/hwaccel.js` — nominal-rate table + caps in the software
|
|
50
|
+
descriptor's video args.
|
|
51
|
+
- `services/hls-session-manager.js` — accept/store net reports per session;
|
|
52
|
+
link trigger in the budget check; log line.
|
|
53
|
+
- `routes/api/transcode-sessions/net-report/post.js` (new) + `server.js`
|
|
54
|
+
wiring — report intake.
|
|
55
|
+
- Proxy release + ha-addon bump. The server-side reporter is a separate
|
|
56
|
+
change (`server/viewer-net-report`); release proxy+addon FIRST.
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# adaptive-bitrate — delta spec (proxy)
|
|
2
|
+
|
|
3
|
+
## ADDED Requirements
|
|
4
|
+
|
|
5
|
+
### Requirement: Software encodes are bitrate-capped per resolution rung
|
|
6
|
+
|
|
7
|
+
Software video encodes SHALL carry `-maxrate`/`-bufsize` derived from a
|
|
8
|
+
per-rung nominal-rate table (1.3× / 1.5× of the rung's nominal), keeping the
|
|
9
|
+
existing CRF/preset quality selection (constrained CRF). The cap follows the
|
|
10
|
+
ACTUAL encode height after budget or manual selection.
|
|
11
|
+
|
|
12
|
+
#### Scenario: Complex scene on a capped encode
|
|
13
|
+
- **WHEN** a transcoded scene would spike far above the rung's nominal rate
|
|
14
|
+
- **THEN** the produced segments stay bounded by the cap instead of reaching
|
|
15
|
+
multi-megabyte sizes that a thin viewer link cannot download in time
|
|
16
|
+
|
|
17
|
+
### Requirement: The proxy accepts viewer link reports
|
|
18
|
+
|
|
19
|
+
A data-channel route SHALL accept periodic reports
|
|
20
|
+
`{ linkMbps, bufferedAheadSec }` for an active transcode session and store
|
|
21
|
+
the latest report with its arrival time. Invalid bodies are rejected;
|
|
22
|
+
unknown sessions get 404; missing reports are not an error condition.
|
|
23
|
+
|
|
24
|
+
#### Scenario: Old client
|
|
25
|
+
- **WHEN** a browser never sends net reports
|
|
26
|
+
- **THEN** the session behaves exactly as before this change (plus caps)
|
|
27
|
+
|
|
28
|
+
### Requirement: The budget loop downshifts on a sustained link deficit
|
|
29
|
+
|
|
30
|
+
When a fresh report shows the viewer's usable link (reported × safety
|
|
31
|
+
margin) sustainedly below the observed produced bitrate AND the viewer's
|
|
32
|
+
buffer is low, the existing budget downshift SHALL step the encode one rung
|
|
33
|
+
down (same cooldown, step cap and floor as the CPU trigger; no upswitch).
|
|
34
|
+
The trigger SHALL be skipped when `manualQuality` is set, when reports are
|
|
35
|
+
stale, or when the viewer's buffer is comfortable. The downshift log line
|
|
36
|
+
SHALL name the reason (`link`) distinctly from the CPU reason.
|
|
37
|
+
|
|
38
|
+
#### Scenario: Cellular viewer, stream too fat
|
|
39
|
+
- **WHEN** reports show 3 Mbit/s usable link against 6 Mbit/s produced
|
|
40
|
+
bitrate for longer than the slow window, with a draining buffer
|
|
41
|
+
- **THEN** the encode steps down one rung and the log shows a link-reason
|
|
42
|
+
downshift
|
|
43
|
+
|
|
44
|
+
#### Scenario: Slow link but full buffer
|
|
45
|
+
- **WHEN** the link is slower than the stream but the viewer's buffer stays
|
|
46
|
+
comfortable (e.g. paused playback filling ahead)
|
|
47
|
+
- **THEN** no downshift happens
|
|
48
|
+
|
|
49
|
+
#### Scenario: Manual quality pinned
|
|
50
|
+
- **WHEN** the viewer picked a forced quality
|
|
51
|
+
- **THEN** link reports never trigger a downshift
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Tasks: Adaptive bitrate (proxy)
|
|
2
|
+
|
|
3
|
+
## 1. Caps
|
|
4
|
+
|
|
5
|
+
- [x] 1.1 `hwaccel.js`: `RUNG_NOMINAL_KBPS` table + nearest-rung lookup;
|
|
6
|
+
software descriptor's video args emit `-maxrate`/`-bufsize`
|
|
7
|
+
(1.3× / 1.5× nominal for the actual encode height). Hardware
|
|
8
|
+
descriptors untouched.
|
|
9
|
+
|
|
10
|
+
## 2. Net report intake
|
|
11
|
+
|
|
12
|
+
- [x] 2.1 `routes/api/transcode-sessions/net-report/post.js`: validate
|
|
13
|
+
`{ linkMbps, bufferedAheadSec }`, store
|
|
14
|
+
`session.netReport = { linkMbps, bufferedAheadSec, at }`, 204/400/404.
|
|
15
|
+
- [x] 2.2 Wire in `server.js`; verify the data-channel path allowlist covers
|
|
16
|
+
the route.
|
|
17
|
+
|
|
18
|
+
## 3. Budget trigger
|
|
19
|
+
|
|
20
|
+
- [x] 3.1 Constants `LINK_REPORT_FRESH_MS`, `LINK_SAFETY`,
|
|
21
|
+
`LINK_SLOW_WINDOW_MS`, `LINK_LOW_BUFFER_SEC`.
|
|
22
|
+
- [x] 3.2 Observed produced bitrate (rolling, last ~5 segments) available to
|
|
23
|
+
the budget check.
|
|
24
|
+
- [x] 3.3 Link deficit accumulation + `#applyBudgetDownshift(session,
|
|
25
|
+
reason "link")`; skip on manualQuality/stale/comfortable buffer; log
|
|
26
|
+
reason distinctly.
|
|
27
|
+
|
|
28
|
+
## 4. Verification
|
|
29
|
+
|
|
30
|
+
- [x] 4.1 Unit: table lookup, arg assembly, trigger logic (fake clock):
|
|
31
|
+
deficit→downshift, stale report→no-op, high buffer→no-op,
|
|
32
|
+
manualQuality→no-op.
|
|
33
|
+
- [x] 4.2 `node --check` on touched files.
|
|
34
|
+
|
|
35
|
+
## 5. Release
|
|
36
|
+
|
|
37
|
+
- [ ] 5.1 CHANGELOG (next patch) + `npm run patch` (user OTP), then addon
|
|
38
|
+
bump + push + HA update. Release BEFORE the server reporter change.
|
|
39
|
+
- [ ] 5.2 Field: cellular Poirot/Mavka run shows `budget downshift (link)`
|
|
40
|
+
and bounded segment sizes; correlate via client-log pipeline.
|
|
File without changes
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# Design: Debounce server-side seek restarts
|
|
2
|
+
|
|
3
|
+
Normative — member names, constants, and edge cases as written. Read before
|
|
4
|
+
coding:
|
|
5
|
+
|
|
6
|
+
- `services/hls-session-manager.js`: `#ensureEncodingFor(session, index)`
|
|
7
|
+
(line ~1478 — the restart decision), the session object shape where
|
|
8
|
+
`lastRestartAt` / `pendingRestartIndex` / `encodeStartIndex` live (~883),
|
|
9
|
+
`#startEncodeRun`, `disposeSession` (must clear timers), and the constants
|
|
10
|
+
block (~36–58).
|
|
11
|
+
|
|
12
|
+
## Current behaviour (what we are replacing)
|
|
13
|
+
|
|
14
|
+
`#ensureEncodingFor` runs per segment request:
|
|
15
|
+
|
|
16
|
+
1. Compute the look-ahead window `[head, currentSeg + MAX_LOOKAHEAD_SEGMENTS]`.
|
|
17
|
+
2. In window → return (the running encode will reach it).
|
|
18
|
+
3. Out of window (a seek) → if `now - lastRestartAt < RESTART_COOLDOWN_MS`,
|
|
19
|
+
skip; else `#startEncodeRun(session, index)` immediately.
|
|
20
|
+
|
|
21
|
+
The cooldown is a fixed 4 s gap. Requests spaced wider than 4 s each restart
|
|
22
|
+
→ ping-pong. That is the bug.
|
|
23
|
+
|
|
24
|
+
## New behaviour: settle window (debounce)
|
|
25
|
+
|
|
26
|
+
Keep steps 1–2. Replace step 3 with a debounce:
|
|
27
|
+
|
|
28
|
+
far request (index outside window):
|
|
29
|
+
session.seekTarget = index // last far index wins
|
|
30
|
+
if (!session.seekSettleTimer):
|
|
31
|
+
session.seekFirstFarAt = now()
|
|
32
|
+
else:
|
|
33
|
+
clearTimeout(session.seekSettleTimer)
|
|
34
|
+
const waited = now() - session.seekFirstFarAt
|
|
35
|
+
const delay = waited >= SEEK_SETTLE_MAX_MS ? 0
|
|
36
|
+
: Math.min(SEEK_SETTLE_MS, SEEK_SETTLE_MAX_MS - waited)
|
|
37
|
+
session.seekSettleTimer = setTimeout(() => fireSettledSeek(session), delay)
|
|
38
|
+
return // do NOT restart now
|
|
39
|
+
|
|
40
|
+
fireSettledSeek(session):
|
|
41
|
+
const target = session.seekTarget
|
|
42
|
+
session.seekSettleTimer = null
|
|
43
|
+
session.seekTarget = null
|
|
44
|
+
session.seekFirstFarAt = 0
|
|
45
|
+
if (session disposed or target == null) return
|
|
46
|
+
// Minimum gap between actual restarts (defensive; the settle already
|
|
47
|
+
// collapses bursts). If still cooling down, re-arm once for the
|
|
48
|
+
// remaining cooldown instead of restarting.
|
|
49
|
+
const sinceRestart = now() - (session.lastRestartAt ?? 0)
|
|
50
|
+
if (sinceRestart < RESTART_COOLDOWN_MS):
|
|
51
|
+
session.seekFirstFarAt = now()
|
|
52
|
+
session.seekTarget = target
|
|
53
|
+
session.seekSettleTimer = setTimeout(() => fireSettledSeek(session),
|
|
54
|
+
RESTART_COOLDOWN_MS - sinceRestart)
|
|
55
|
+
return
|
|
56
|
+
log(`transcode ${id} seek settle → restart at #${target}`)
|
|
57
|
+
this.#startEncodeRun(session, target) // sets lastRestartAt
|
|
58
|
+
|
|
59
|
+
Notes:
|
|
60
|
+
- `timer.unref?.()` on each `setTimeout` so a pending settle never keeps the
|
|
61
|
+
process alive (mirror the codebase's other timers).
|
|
62
|
+
- A far request whose `index` equals the current `seekTarget` still re-arms
|
|
63
|
+
the timer (the player is still asking for the same place — that is fine, it
|
|
64
|
+
just extends the quiet period up to the cap).
|
|
65
|
+
- If, while a settle is pending, the running encode advances so a later
|
|
66
|
+
request falls back INSIDE the window, that request returns at step 2 and
|
|
67
|
+
does not touch the settle. The pending settle still fires for the recorded
|
|
68
|
+
target; that is acceptable (it restarts at a position the player recently
|
|
69
|
+
wanted). Simplicity over cleverness.
|
|
70
|
+
|
|
71
|
+
## Constants (add to the constants block)
|
|
72
|
+
|
|
73
|
+
// Quiet period after a far (seek) segment request before ffmpeg is
|
|
74
|
+
// restarted at it. Further far requests within the period re-arm it, so a
|
|
75
|
+
// scrub that emits a burst of scattered requests collapses to ONE restart
|
|
76
|
+
// at the position the player ended on.
|
|
77
|
+
SEEK_SETTLE_MS = 1200
|
|
78
|
+
// Hard cap on the total settle wait measured from the first far request of
|
|
79
|
+
// a burst, so a still-moving scrubber cannot delay a real seek forever.
|
|
80
|
+
SEEK_SETTLE_MAX_MS = 2500
|
|
81
|
+
|
|
82
|
+
`MAX_LOOKAHEAD_SEGMENTS` and `RESTART_COOLDOWN_MS` keep their current values;
|
|
83
|
+
`RESTART_COOLDOWN_MS` is now only the floor between actual restarts.
|
|
84
|
+
|
|
85
|
+
## Session state (add where lastRestartAt is initialised, ~883)
|
|
86
|
+
|
|
87
|
+
seekSettleTimer: null, // pending settle timer handle or null
|
|
88
|
+
seekTarget: null, // pending far segment index to restart at
|
|
89
|
+
seekFirstFarAt: 0, // timestamp of the first far request in the burst
|
|
90
|
+
|
|
91
|
+
## Disposal
|
|
92
|
+
|
|
93
|
+
`disposeSession` (and any teardown that abandons a session) MUST
|
|
94
|
+
`clearTimeout(session.seekSettleTimer)` and null it, so a settle cannot fire
|
|
95
|
+
after disposal and restart a dead session.
|
|
96
|
+
|
|
97
|
+
## Why proxy-side, not client-side (the user's original framing)
|
|
98
|
+
|
|
99
|
+
The user asked for a scrubber-release debounce. On the in-page media-chrome
|
|
100
|
+
control that is possible but fragile, and — decisively — it cannot cover the
|
|
101
|
+
iOS **native fullscreen** player, whose scrubber the web app does not control;
|
|
102
|
+
the failing session was exactly iOS. The proxy sees the segment requests from
|
|
103
|
+
EVERY client (native iOS, hls.js, direct `<video>`), so debouncing the
|
|
104
|
+
restart here is the one place that fixes all of them. An in-page scrubber
|
|
105
|
+
debounce may still be added later as a responsiveness nicety; it is not a
|
|
106
|
+
substitute and is out of scope here.
|
|
107
|
+
|
|
108
|
+
## Verification
|
|
109
|
+
|
|
110
|
+
- Unit-test the timing/target logic in isolation (a fake session + a fake
|
|
111
|
+
clock/`setTimeout`): a burst of far requests `367,732,369,368,370` within
|
|
112
|
+
the window collapses to a single `#startEncodeRun` at the LAST index (370),
|
|
113
|
+
and a lone later far request triggers exactly one more restart.
|
|
114
|
+
- `node --check`.
|
|
115
|
+
- Field: the earlier hang scenario (scrub Poirot on iOS) should now produce a
|
|
116
|
+
single `seek settle → restart` log line per scrub instead of a train of
|
|
117
|
+
`seek → restart` lines, and playback should resume after one settle + the
|
|
118
|
+
(separate, unavoidable) cold-segment wait.
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# Proposal: Debounce server-side seek restarts
|
|
2
|
+
|
|
3
|
+
## Why
|
|
4
|
+
|
|
5
|
+
Field evidence (iPhone/Safari, Poirot, 2026-07-09): after a scrub the player
|
|
6
|
+
hung indefinitely. The proxy restarts ffmpeg at a requested segment when the
|
|
7
|
+
request lands outside the look-ahead window (server-side seek). The native
|
|
8
|
+
iOS player issued scattered segment requests after the seek —
|
|
9
|
+
`367 → 732 → 369 → 368 → 370` — each 25–35 s apart. The existing guard
|
|
10
|
+
(`RESTART_COOLDOWN_MS = 4000`) only suppresses restarts within 4 s of the
|
|
11
|
+
last one, so with requests spaced far wider than that, EVERY scattered
|
|
12
|
+
request restarted ffmpeg at a new position. ffmpeg ping-ponged between
|
|
13
|
+
positions and never finished a single segment, so playback stalled at
|
|
14
|
+
`currentTime` with `bufferedAhead=0` — "заглохло наглухо".
|
|
15
|
+
|
|
16
|
+
The fix is the user's idea placed where every client is covered (including
|
|
17
|
+
the iOS native fullscreen player, whose scrubber the web app cannot control):
|
|
18
|
+
don't act on the first far request — wait for the burst to settle, then
|
|
19
|
+
restart ONCE at the position the player ended on.
|
|
20
|
+
|
|
21
|
+
## What Changes
|
|
22
|
+
|
|
23
|
+
- Replace the fixed post-restart cooldown as the anti-thrash mechanism with a
|
|
24
|
+
**settle window**. When a segment request lands outside the look-ahead
|
|
25
|
+
window (a seek), the proxy does NOT restart immediately: it records the
|
|
26
|
+
requested index as the pending seek target and arms a short quiet-period
|
|
27
|
+
timer. Each further far request updates the target to the latest index and
|
|
28
|
+
re-arms the timer (debounce). When the quiet period elapses with no new far
|
|
29
|
+
request, ffmpeg restarts ONCE at the pending target. A hard cap bounds the
|
|
30
|
+
total wait so a genuine seek is never delayed more than a fixed budget even
|
|
31
|
+
while the scrubber is still moving.
|
|
32
|
+
- Meanwhile the segment route behaves exactly as today — it long-polls and
|
|
33
|
+
the client retries — so the player simply waits out the (short) settle
|
|
34
|
+
instead of driving restarts.
|
|
35
|
+
- "Last far index wins" self-corrects: if the settle resolves on the wrong
|
|
36
|
+
position (e.g. a lone probe request), the player's next request re-arms one
|
|
37
|
+
more settle — at most one extra cycle, never the old infinite ping-pong.
|
|
38
|
+
|
|
39
|
+
Out of scope (documented in design.md): an optional client-side scrubber
|
|
40
|
+
debounce for the in-page media-chrome control (helps responsiveness but does
|
|
41
|
+
NOT cover iOS native fullscreen, so the proxy-side settle is the must-have);
|
|
42
|
+
the cold-torrent piece-download and weak-host encode latency that make the
|
|
43
|
+
FIRST post-seek segment slow regardless — the settle removes the infinite
|
|
44
|
+
thrash, not the one-time seek latency.
|
|
45
|
+
|
|
46
|
+
## Capabilities
|
|
47
|
+
|
|
48
|
+
### Modified Capabilities
|
|
49
|
+
|
|
50
|
+
- `seek-debounce` (server-side HLS seeking): far-segment requests are
|
|
51
|
+
debounced into a single ffmpeg restart at the settled position.
|
|
52
|
+
|
|
53
|
+
## Impact
|
|
54
|
+
|
|
55
|
+
- `services/hls-session-manager.js` — `#ensureEncodingFor` gains the settle
|
|
56
|
+
window; per-session settle state; `disposeSession` clears the timer;
|
|
57
|
+
constants. `RESTART_COOLDOWN_MS` is retained only as a minimum gap between
|
|
58
|
+
actual restarts.
|
|
59
|
+
- Proxy release + ha-addon version bump (per release rules).
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# seek-debounce — delta spec (proxy)
|
|
2
|
+
|
|
3
|
+
## ADDED Requirements
|
|
4
|
+
|
|
5
|
+
### Requirement: Scattered post-seek segment requests collapse to one restart
|
|
6
|
+
|
|
7
|
+
When segment requests land outside the running encode's look-ahead window
|
|
8
|
+
(server-side seeks), the proxy SHALL NOT restart ffmpeg on each one. It SHALL
|
|
9
|
+
wait a short settle period, treating further out-of-window requests as
|
|
10
|
+
re-arming the period and updating the target to the most recently requested
|
|
11
|
+
index, and then restart the encoder exactly once at the settled target. A
|
|
12
|
+
fixed cap SHALL bound the total settle wait so a genuine seek is not delayed
|
|
13
|
+
indefinitely while the scrubber is still moving. During the settle the
|
|
14
|
+
segment route behaves as before (long-poll / client retry).
|
|
15
|
+
|
|
16
|
+
#### Scenario: Scrub emits a burst of scattered requests
|
|
17
|
+
- **WHEN** a player, after a seek, requests several far-apart segments in
|
|
18
|
+
quick succession (e.g. 367, 732, 369, 368, 370)
|
|
19
|
+
- **THEN** ffmpeg is restarted only once, at the last requested index, and
|
|
20
|
+
produces a continuous run from there — no ping-pong between positions
|
|
21
|
+
|
|
22
|
+
#### Scenario: Settle resolves on the wrong position
|
|
23
|
+
- **WHEN** the settled restart target turns out not to be where the player
|
|
24
|
+
ultimately needs to play (e.g. it was a lone probe request)
|
|
25
|
+
- **THEN** the player's next out-of-window request arms exactly one more
|
|
26
|
+
settle and one more restart — never an unbounded restart loop
|
|
27
|
+
|
|
28
|
+
#### Scenario: Request falls back inside the window
|
|
29
|
+
- **WHEN** a requested segment is within the current run's look-ahead window
|
|
30
|
+
- **THEN** it is served by the running encode with no restart and without
|
|
31
|
+
affecting any pending settle
|
|
32
|
+
|
|
33
|
+
### Requirement: A pending settle never outlives its session
|
|
34
|
+
|
|
35
|
+
When a session is disposed, any pending settle timer SHALL be cleared so it
|
|
36
|
+
cannot fire and restart a disposed session.
|
|
37
|
+
|
|
38
|
+
#### Scenario: Session disposed mid-settle
|
|
39
|
+
- **WHEN** a session with a pending seek-settle timer is disposed (idle TTL,
|
|
40
|
+
shutdown, or teardown)
|
|
41
|
+
- **THEN** the timer is cleared and no encode restart occurs afterwards
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Tasks: Debounce server-side seek restarts
|
|
2
|
+
|
|
3
|
+
## 1. Implementation (proxy)
|
|
4
|
+
|
|
5
|
+
- [x] 1.1 Add `SEEK_SETTLE_MS = 1200` and `SEEK_SETTLE_MAX_MS = 2500` to the
|
|
6
|
+
constants block in `hls-session-manager.js`.
|
|
7
|
+
- [x] 1.2 Add session state `seekSettleTimer: null`, `seekTarget: null`,
|
|
8
|
+
`seekFirstFarAt: 0` where `lastRestartAt` is initialised.
|
|
9
|
+
- [x] 1.3 Rewrite the out-of-window branch of `#ensureEncodingFor` as the
|
|
10
|
+
settle/debounce (design.md): record target, arm/re-arm the timer with
|
|
11
|
+
the capped delay, restart once on fire (`#fireSettledSeek`), re-arm for
|
|
12
|
+
the cooldown remainder if still cooling down. `timer.unref?.()`.
|
|
13
|
+
- [x] 1.4 `disposeSession`: clear `seekSettleTimer` and null it.
|
|
14
|
+
- [x] 1.5 Restart log line: `transcode <id> seek settle → restart at segment #<target>`.
|
|
15
|
+
|
|
16
|
+
## 2. Verification
|
|
17
|
+
|
|
18
|
+
- [x] 2.1 Timing/target logic verified with a fake-clock replica: burst
|
|
19
|
+
`367,732,369,368,370` → one restart at 370; lone later far request →
|
|
20
|
+
exactly one more restart (900); disposal mid-settle → no restart; the
|
|
21
|
+
2.5 s cap forces a fire while the scrubber keeps moving. (Replica, not
|
|
22
|
+
the wired private method — the methods are private and `#startEncodeRun`
|
|
23
|
+
spawns ffmpeg; the wired code mirrors the replica.)
|
|
24
|
+
- [x] 2.2 `node --check services/hls-session-manager.js`.
|
|
25
|
+
|
|
26
|
+
## 3. Release
|
|
27
|
+
|
|
28
|
+
- [ ] 3.1 CHANGELOG (proxy, next patch) + `npm run patch` (user OTP).
|
|
29
|
+
- [ ] 3.2 Bump `ha-addon/torrent_tv_proxy/config.yaml` + CHANGELOG; push;
|
|
30
|
+
update the addon in HA.
|
|
31
|
+
- [ ] 3.3 Field: scrub Poirot on iOS → a single `seek settle → restart` per
|
|
32
|
+
scrub (not a train of `seek → restart`), playback resumes after the
|
|
33
|
+
settle + the unavoidable cold-segment wait.
|
package/package.json
CHANGED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Accept a viewer link report for a transcode session (adaptive bitrate).
|
|
3
|
+
* The browser measures its own data-channel throughput per segment fetch and
|
|
4
|
+
* posts a rolling median + its buffered seconds; the session manager's budget
|
|
5
|
+
* loop uses the latest report as the link-deficit downshift trigger.
|
|
6
|
+
*
|
|
7
|
+
* POST /api/transcode-sessions/:sessionId/net-report
|
|
8
|
+
* Body: { linkMbps: number, bufferedAheadSec: number }
|
|
9
|
+
*
|
|
10
|
+
* Best-effort telemetry: invalid body → 400, unknown session → 404, ok → 204.
|
|
11
|
+
*
|
|
12
|
+
* @param {import("fastify").FastifyRequest} req
|
|
13
|
+
* @param {import("fastify").FastifyReply} reply
|
|
14
|
+
* @param {{ hlsSessionManager: import("../../../../services/hls-session-manager.js").HlsSessionManager }} deps
|
|
15
|
+
* @returns {Promise<void>}
|
|
16
|
+
*/
|
|
17
|
+
export async function handleApiTranscodeSessionNetReportPost(req, reply, { hlsSessionManager }) {
|
|
18
|
+
const sessionId = typeof req.params.sessionId === "string" ? req.params.sessionId : "";
|
|
19
|
+
const body = req.body && typeof req.body === "object" && !Array.isArray(req.body) ? req.body : {};
|
|
20
|
+
const linkMbps = Number(body.linkMbps);
|
|
21
|
+
const bufferedAheadSec = Number(body.bufferedAheadSec);
|
|
22
|
+
if (!sessionId || !Number.isFinite(linkMbps) || linkMbps <= 0 || !Number.isFinite(bufferedAheadSec) || bufferedAheadSec < 0) {
|
|
23
|
+
return reply.code(400).send({ error: "linkMbps (>0) and bufferedAheadSec (>=0) are required." });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const recorded = hlsSessionManager.recordNetReport(sessionId, { linkMbps, bufferedAheadSec });
|
|
27
|
+
if (!recorded) {
|
|
28
|
+
return reply.code(404).send({ error: "Transcode session was not found." });
|
|
29
|
+
}
|
|
30
|
+
return reply.code(204).send();
|
|
31
|
+
}
|
package/server.js
CHANGED
|
@@ -24,6 +24,7 @@ import { handleApiSubtitlesGet } from "./routes/api/subtitles/get.js";
|
|
|
24
24
|
import { handleApiTranscodeSessionsPost } from "./routes/api/transcode-sessions/post.js";
|
|
25
25
|
import { handleApiTranscodeSessionsProgressGet } from "./routes/api/transcode-sessions/progress/get.js";
|
|
26
26
|
import { handleApiTranscodeSessionReleasePost } from "./routes/api/transcode-sessions/release/post.js";
|
|
27
|
+
import { handleApiTranscodeSessionNetReportPost } from "./routes/api/transcode-sessions/net-report/post.js";
|
|
27
28
|
import { handleStreamGet } from "./routes/stream/get.js";
|
|
28
29
|
import { handleTranscodeSessionFileGet } from "./routes/transcode/session-file/get.js";
|
|
29
30
|
import { createSourceRegistry } from "./store/source-registry.js";
|
|
@@ -186,6 +187,9 @@ export async function startProxyServer({ host, port, transcodeAudio, ffmpegBin,
|
|
|
186
187
|
app.get("/api/transcode-sessions/:sessionId/progress", async (req, reply) =>
|
|
187
188
|
handleApiTranscodeSessionsProgressGet(req, reply, { hlsSessionManager })
|
|
188
189
|
);
|
|
190
|
+
app.post("/api/transcode-sessions/:sessionId/net-report", async (req, reply) =>
|
|
191
|
+
handleApiTranscodeSessionNetReportPost(req, reply, { hlsSessionManager })
|
|
192
|
+
);
|
|
189
193
|
app.get("/transcode/:sessionId/:fileName", async (req, reply) =>
|
|
190
194
|
handleTranscodeSessionFileGet(req, reply, { hlsSessionManager })
|
|
191
195
|
);
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { createReadStream } from "node:fs";
|
|
11
|
-
import { access, mkdir, readdir, readFile, rm } from "node:fs/promises";
|
|
11
|
+
import { access, mkdir, readdir, readFile, rm, stat } from "node:fs/promises";
|
|
12
12
|
import { Readable } from "node:stream";
|
|
13
13
|
import os from "node:os";
|
|
14
14
|
import path from "node:path";
|
|
@@ -43,6 +43,17 @@ const MAX_LOOKAHEAD_SEGMENTS = 8;
|
|
|
43
43
|
// succession (stall-recovery seeks); without a cooldown ffmpeg ping-pongs
|
|
44
44
|
// between positions, restarting endlessly and producing nothing.
|
|
45
45
|
const RESTART_COOLDOWN_MS = 4_000;
|
|
46
|
+
// Seek debounce. A far (out-of-window) segment request is a server-side seek.
|
|
47
|
+
// Rather than restart ffmpeg on the first one, wait a short quiet period:
|
|
48
|
+
// further far requests re-arm it and update the target to the latest index, so
|
|
49
|
+
// a scrub that emits a burst of scattered requests (e.g. iOS native HLS firing
|
|
50
|
+
// 367,732,369,368,370 seconds apart) collapses to ONE restart at the position
|
|
51
|
+
// the player ended on, instead of ping-ponging ffmpeg between positions and
|
|
52
|
+
// producing nothing.
|
|
53
|
+
const SEEK_SETTLE_MS = 1_200;
|
|
54
|
+
// Hard cap on the total settle wait, measured from the first far request of a
|
|
55
|
+
// burst, so a still-moving scrubber cannot delay a genuine seek forever.
|
|
56
|
+
const SEEK_SETTLE_MAX_MS = 2_500;
|
|
46
57
|
// Idle TTL: a session is disposed this long after the last segment/playlist
|
|
47
58
|
// access. Kept short so an ffmpeg process does not keep burning CPU after the
|
|
48
59
|
// viewer stops or navigates away. Active playback refreshes the timer on every
|
|
@@ -73,6 +84,24 @@ const BUDGET_MAX_DOWNSHIFTS = 3;
|
|
|
73
84
|
// multiple of the source's average byte rate. Below it (and not yet fully
|
|
74
85
|
// downloaded), a low speed is download-bound, not CPU-bound → do NOT downscale.
|
|
75
86
|
const BUDGET_DOWNLOAD_OK_FACTOR = 1.0;
|
|
87
|
+
// Viewer-link adaptation (adaptive bitrate, part b). The browser reports its
|
|
88
|
+
// measured data-channel throughput + buffered seconds every ~10 s; when a
|
|
89
|
+
// FRESH report shows the usable link (reported × safety margin) sustainedly
|
|
90
|
+
// below the observed produced bitrate AND the viewer's buffer is low, the
|
|
91
|
+
// budget loop steps the encode one rung down — same machinery, cooldown and
|
|
92
|
+
// floor as the CPU trigger. Manual-quality sessions are inherently exempt
|
|
93
|
+
// (their budgetLadder is null).
|
|
94
|
+
const LINK_REPORT_FRESH_MS = 30_000;
|
|
95
|
+
// Usable share of the reported link (protocol overhead + measurement noise).
|
|
96
|
+
const LINK_SAFETY = 0.8;
|
|
97
|
+
// Deficit must persist this long before acting (absorbs one slow segment).
|
|
98
|
+
const LINK_SLOW_WINDOW_MS = 15_000;
|
|
99
|
+
// Only act while the viewer is actually running dry; a comfortable buffer
|
|
100
|
+
// (e.g. paused playback filling ahead) suppresses the trigger.
|
|
101
|
+
const LINK_LOW_BUFFER_SEC = 10;
|
|
102
|
+
// Observed produced bitrate: average over this many recently completed
|
|
103
|
+
// segments (the newest file on disk may still be written and is excluded).
|
|
104
|
+
const LINK_OBSERVED_SEGMENTS = 5;
|
|
76
105
|
const MICROSECONDS_PER_SECOND = 1_000_000;
|
|
77
106
|
const PROGRESS_LOG_INTERVAL_MS = 5_000;
|
|
78
107
|
// Read segment files in large blocks so the body is delivered to the data
|
|
@@ -862,6 +891,10 @@ export class HlsSessionManager {
|
|
|
862
891
|
budgetDownshifts: 0,
|
|
863
892
|
budgetSlowSince: 0,
|
|
864
893
|
budgetLastActionAt: 0,
|
|
894
|
+
// Latest viewer link report ({ linkMbps, bufferedAheadSec, at }) and the
|
|
895
|
+
// link-deficit slow window (mirrors budgetSlowSince for the CPU path).
|
|
896
|
+
netReport: null,
|
|
897
|
+
linkSlowSince: 0,
|
|
865
898
|
sourceWidth,
|
|
866
899
|
sourceHeight,
|
|
867
900
|
// Container start time (seconds); subtracted on the copy path so the
|
|
@@ -884,6 +917,12 @@ export class HlsSessionManager {
|
|
|
884
917
|
pendingRestartIndex: -1,
|
|
885
918
|
// Timestamp of the last encode (re)start, for the restart cooldown.
|
|
886
919
|
lastRestartAt: 0,
|
|
920
|
+
// Seek debounce: pending settle timer, the far segment index to restart
|
|
921
|
+
// at once the burst settles, and the timestamp of the burst's first far
|
|
922
|
+
// request (for the SEEK_SETTLE_MAX_MS cap).
|
|
923
|
+
seekSettleTimer: null,
|
|
924
|
+
seekTarget: null,
|
|
925
|
+
seekFirstFarAt: 0,
|
|
887
926
|
progress: {
|
|
888
927
|
state: "starting",
|
|
889
928
|
processedSeconds: 0,
|
|
@@ -1070,6 +1109,110 @@ export class HlsSessionManager {
|
|
|
1070
1109
|
*
|
|
1071
1110
|
* @returns {Promise<void>}
|
|
1072
1111
|
*/
|
|
1112
|
+
/**
|
|
1113
|
+
* Record the latest viewer link report for a session (adaptive bitrate).
|
|
1114
|
+
* Returns false for an unknown/disposed session.
|
|
1115
|
+
*
|
|
1116
|
+
* @param {string} sessionId
|
|
1117
|
+
* @param {{ linkMbps: number, bufferedAheadSec: number }} report
|
|
1118
|
+
* @returns {boolean}
|
|
1119
|
+
*/
|
|
1120
|
+
recordNetReport(sessionId, { linkMbps, bufferedAheadSec }) {
|
|
1121
|
+
const session = this.sessionsById.get(sessionId);
|
|
1122
|
+
if (!session || session.state === "disposed") {
|
|
1123
|
+
return false;
|
|
1124
|
+
}
|
|
1125
|
+
session.netReport = { linkMbps, bufferedAheadSec, at: Date.now() };
|
|
1126
|
+
return true;
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
/**
|
|
1130
|
+
* Observed produced bitrate (Mbit/s) averaged over the last few COMPLETED
|
|
1131
|
+
* segment files (the newest file may still be being written and is
|
|
1132
|
+
* excluded). Transcode sessions only — their segment grid is uniform, so
|
|
1133
|
+
* bytes / (count × segDur) is exact. Returns null when there is not enough
|
|
1134
|
+
* material to measure.
|
|
1135
|
+
*
|
|
1136
|
+
* @param {HlsSession} session
|
|
1137
|
+
* @returns {Promise<number | null>}
|
|
1138
|
+
*/
|
|
1139
|
+
async #observedStreamMbps(session) {
|
|
1140
|
+
let names;
|
|
1141
|
+
try {
|
|
1142
|
+
names = await readdir(session.dirPath);
|
|
1143
|
+
} catch {
|
|
1144
|
+
return null;
|
|
1145
|
+
}
|
|
1146
|
+
const indices = [];
|
|
1147
|
+
for (const name of names) {
|
|
1148
|
+
const match = /^segment-(\d{5})\.ts$/.exec(name);
|
|
1149
|
+
if (match) {
|
|
1150
|
+
indices.push(parseInt(match[1], 10));
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
if (indices.length < 3) {
|
|
1154
|
+
return null; // need ≥2 completed segments after dropping the newest
|
|
1155
|
+
}
|
|
1156
|
+
indices.sort((a, b) => a - b);
|
|
1157
|
+
const completed = indices.slice(0, -1).slice(-LINK_OBSERVED_SEGMENTS);
|
|
1158
|
+
let bytes = 0;
|
|
1159
|
+
try {
|
|
1160
|
+
for (const index of completed) {
|
|
1161
|
+
const st = await stat(path.join(session.dirPath, `segment-${String(index).padStart(5, "0")}.ts`));
|
|
1162
|
+
bytes += st.size;
|
|
1163
|
+
}
|
|
1164
|
+
} catch {
|
|
1165
|
+
return null; // a segment vanished mid-measure (seek-restart cleanup)
|
|
1166
|
+
}
|
|
1167
|
+
return (bytes * 8) / (completed.length * this.segmentDurationSec) / 1e6;
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
/**
|
|
1171
|
+
* Viewer-link deficit check for one session (adaptive bitrate, part b).
|
|
1172
|
+
* Mirrors the CPU slow-window pattern; shares the action cooldown and the
|
|
1173
|
+
* downshift machinery. Returns true when a downshift was applied this tick.
|
|
1174
|
+
*
|
|
1175
|
+
* @param {HlsSession} session
|
|
1176
|
+
* @param {number} now
|
|
1177
|
+
* @returns {Promise<boolean>}
|
|
1178
|
+
*/
|
|
1179
|
+
async #checkLinkBudget(session, now) {
|
|
1180
|
+
const report = session.netReport;
|
|
1181
|
+
if (!report || now - report.at > LINK_REPORT_FRESH_MS) {
|
|
1182
|
+
session.linkSlowSince = 0; // no fresh data — old clients / stopped reporter
|
|
1183
|
+
return false;
|
|
1184
|
+
}
|
|
1185
|
+
if (report.bufferedAheadSec >= LINK_LOW_BUFFER_SEC) {
|
|
1186
|
+
session.linkSlowSince = 0; // viewer is comfortable — nothing to fix
|
|
1187
|
+
return false;
|
|
1188
|
+
}
|
|
1189
|
+
const observed = await this.#observedStreamMbps(session);
|
|
1190
|
+
if (observed === null) {
|
|
1191
|
+
return false; // not enough produced material to compare against
|
|
1192
|
+
}
|
|
1193
|
+
if (report.linkMbps * LINK_SAFETY >= observed) {
|
|
1194
|
+
session.linkSlowSince = 0; // link keeps up
|
|
1195
|
+
return false;
|
|
1196
|
+
}
|
|
1197
|
+
if (session.linkSlowSince === 0) {
|
|
1198
|
+
session.linkSlowSince = now;
|
|
1199
|
+
return false;
|
|
1200
|
+
}
|
|
1201
|
+
if (now - session.linkSlowSince < LINK_SLOW_WINDOW_MS) {
|
|
1202
|
+
return false; // not sustained yet
|
|
1203
|
+
}
|
|
1204
|
+
if (now - session.budgetLastActionAt < BUDGET_ACTION_COOLDOWN_MS) {
|
|
1205
|
+
return false; // let the previous action settle
|
|
1206
|
+
}
|
|
1207
|
+
this.#applyBudgetDownshift(
|
|
1208
|
+
session,
|
|
1209
|
+
`link=${report.linkMbps.toFixed(2)}Mbps stream=${observed.toFixed(2)}Mbps buffer=${report.bufferedAheadSec.toFixed(1)}s`,
|
|
1210
|
+
"link"
|
|
1211
|
+
);
|
|
1212
|
+
session.linkSlowSince = 0;
|
|
1213
|
+
return true;
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1073
1216
|
async #enforceRealtimeBudget() {
|
|
1074
1217
|
if (this.videoEncoder?.kind !== "software") {
|
|
1075
1218
|
return;
|
|
@@ -1093,6 +1236,13 @@ export class HlsSessionManager {
|
|
|
1093
1236
|
) {
|
|
1094
1237
|
continue;
|
|
1095
1238
|
}
|
|
1239
|
+
// Viewer-link deficit first (adaptive bitrate): independent of encoder
|
|
1240
|
+
// speed — a thin cellular link starves even a faster-than-realtime
|
|
1241
|
+
// encode. When it acts, skip the CPU check this tick (shared cooldown
|
|
1242
|
+
// guards double-firing anyway).
|
|
1243
|
+
if (await this.#checkLinkBudget(session, now)) {
|
|
1244
|
+
continue;
|
|
1245
|
+
}
|
|
1096
1246
|
const speed = this.#parseSpeed(session.progress?.speed);
|
|
1097
1247
|
if (speed === null) {
|
|
1098
1248
|
continue; // no measurement yet
|
|
@@ -1126,7 +1276,7 @@ export class HlsSessionManager {
|
|
|
1126
1276
|
session.budgetSlowSince = 0; // re-evaluate fresh; don't thrash on this
|
|
1127
1277
|
continue;
|
|
1128
1278
|
}
|
|
1129
|
-
this.#applyBudgetDownshift(session, speed
|
|
1279
|
+
this.#applyBudgetDownshift(session, `speed=${speed.toFixed(2)}x`, bound);
|
|
1130
1280
|
}
|
|
1131
1281
|
}
|
|
1132
1282
|
|
|
@@ -1172,11 +1322,11 @@ export class HlsSessionManager {
|
|
|
1172
1322
|
* encode at the current segment with the lighter profile.
|
|
1173
1323
|
*
|
|
1174
1324
|
* @param {HlsSession} session
|
|
1175
|
-
* @param {
|
|
1176
|
-
* @param {"cpu" | "unknown"} bound
|
|
1325
|
+
* @param {string} reasonText - Measurement summary for the log line.
|
|
1326
|
+
* @param {"cpu" | "unknown" | "link"} bound
|
|
1177
1327
|
* @returns {void}
|
|
1178
1328
|
*/
|
|
1179
|
-
#applyBudgetDownshift(session,
|
|
1329
|
+
#applyBudgetDownshift(session, reasonText, bound) {
|
|
1180
1330
|
const nextIndex = session.budgetRungIndex + 1;
|
|
1181
1331
|
const rung = session.budgetLadder[nextIndex];
|
|
1182
1332
|
if (!rung) {
|
|
@@ -1197,9 +1347,11 @@ export class HlsSessionManager {
|
|
|
1197
1347
|
? session.progress.processedSeconds
|
|
1198
1348
|
: this.#segmentStartTime(session, head);
|
|
1199
1349
|
const currentSeg = Math.max(head, this.#segmentIndexForTime(session, processed));
|
|
1350
|
+
const boundLabel =
|
|
1351
|
+
bound === "link" ? "viewer-link-bound" : bound === "unknown" ? "assuming CPU-bound" : "CPU-bound";
|
|
1200
1352
|
logger.info(
|
|
1201
|
-
`[budget] transcode ${session.id} ${
|
|
1202
|
-
|
|
1353
|
+
`[budget] transcode ${session.id} ${boundLabel} ` +
|
|
1354
|
+
`${reasonText} → downscale to ${rung.width}x${rung.height}/${session.softwarePreset} ` +
|
|
1203
1355
|
`(rung ${nextIndex + 1}/${session.budgetLadder.length}, downshift ${session.budgetDownshifts}/${BUDGET_MAX_DOWNSHIFTS}), ` +
|
|
1204
1356
|
`restart at segment #${currentSeg} "${session.fileName}"`
|
|
1205
1357
|
);
|
|
@@ -1492,22 +1644,52 @@ export class HlsSessionManager {
|
|
|
1492
1644
|
if (withinWindow) {
|
|
1493
1645
|
return;
|
|
1494
1646
|
}
|
|
1495
|
-
|
|
1647
|
+
// Far request = a server-side seek. Do NOT restart on the first one:
|
|
1648
|
+
// debounce a burst of scattered requests into a single restart at the
|
|
1649
|
+
// position the player ended on. Record the latest target and (re)arm the
|
|
1650
|
+
// settle timer; the caller long-polls / the client retries meanwhile.
|
|
1651
|
+
session.seekTarget = index;
|
|
1652
|
+
if (session.seekSettleTimer) {
|
|
1653
|
+
clearTimeout(session.seekSettleTimer);
|
|
1654
|
+
} else {
|
|
1655
|
+
session.seekFirstFarAt = Date.now();
|
|
1656
|
+
}
|
|
1657
|
+
const waited = Date.now() - session.seekFirstFarAt;
|
|
1658
|
+
const delay = waited >= SEEK_SETTLE_MAX_MS ? 0 : Math.min(SEEK_SETTLE_MS, SEEK_SETTLE_MAX_MS - waited);
|
|
1659
|
+
session.seekSettleTimer = setTimeout(() => this.#fireSettledSeek(session), delay);
|
|
1660
|
+
session.seekSettleTimer.unref?.();
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
/**
|
|
1664
|
+
* Fire a settled server-side seek: restart the encoder once at the target
|
|
1665
|
+
* recorded during the settle window. Enforces the restart cooldown as a
|
|
1666
|
+
* floor between actual restarts (re-arming for the remainder if still
|
|
1667
|
+
* cooling down). No-op for a disposed session or a cleared target.
|
|
1668
|
+
*
|
|
1669
|
+
* @param {HlsSession} session
|
|
1670
|
+
* @returns {void}
|
|
1671
|
+
*/
|
|
1672
|
+
#fireSettledSeek(session) {
|
|
1673
|
+
const target = session.seekTarget;
|
|
1674
|
+
session.seekSettleTimer = null;
|
|
1675
|
+
if (!session || session.state === "disposed" || target == null) {
|
|
1676
|
+
session.seekTarget = null;
|
|
1677
|
+
session.seekFirstFarAt = 0;
|
|
1496
1678
|
return;
|
|
1497
1679
|
}
|
|
1498
|
-
//
|
|
1499
|
-
//
|
|
1500
|
-
//
|
|
1501
|
-
// long-polls / the client retries, and a genuine seek is honored once the
|
|
1502
|
-
// cooldown elapses.
|
|
1680
|
+
// Minimum gap between actual restarts (the settle already collapses bursts;
|
|
1681
|
+
// this only guards back-to-back seeks). If still cooling down, re-arm once
|
|
1682
|
+
// for the remaining cooldown instead of restarting now.
|
|
1503
1683
|
const sinceLastRestart = Date.now() - (session.lastRestartAt ?? 0);
|
|
1504
1684
|
if (sinceLastRestart < RESTART_COOLDOWN_MS) {
|
|
1685
|
+
session.seekSettleTimer = setTimeout(() => this.#fireSettledSeek(session), RESTART_COOLDOWN_MS - sinceLastRestart);
|
|
1686
|
+
session.seekSettleTimer.unref?.();
|
|
1505
1687
|
return;
|
|
1506
1688
|
}
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
);
|
|
1510
|
-
this.#startEncodeRun(session,
|
|
1689
|
+
session.seekTarget = null;
|
|
1690
|
+
session.seekFirstFarAt = 0;
|
|
1691
|
+
logger.info(`transcode ${session.id} seek settle → restart at segment #${target}`);
|
|
1692
|
+
this.#startEncodeRun(session, target);
|
|
1511
1693
|
}
|
|
1512
1694
|
|
|
1513
1695
|
/**
|
|
@@ -1743,6 +1925,13 @@ export class HlsSessionManager {
|
|
|
1743
1925
|
this.sessionsById.delete(sessionId);
|
|
1744
1926
|
this.sessionIdBySource.delete(session.sourceMapKey);
|
|
1745
1927
|
|
|
1928
|
+
// Clear any pending seek-settle timer so it cannot fire and restart a
|
|
1929
|
+
// disposed session.
|
|
1930
|
+
if (session.seekSettleTimer) {
|
|
1931
|
+
clearTimeout(session.seekSettleTimer);
|
|
1932
|
+
session.seekSettleTimer = null;
|
|
1933
|
+
}
|
|
1934
|
+
|
|
1746
1935
|
if (session.ffmpeg && !session.ffmpeg.killed) {
|
|
1747
1936
|
session.ffmpeg.kill("SIGTERM");
|
|
1748
1937
|
await waitForChildExit(session.ffmpeg);
|
package/services/hwaccel.js
CHANGED
|
@@ -75,6 +75,54 @@ export function chooseOutputFps(sourceFps, cap = MAX_OUTPUT_FPS) {
|
|
|
75
75
|
// Software x264 on weak ARM hosts is the transcode bottleneck — use all cores.
|
|
76
76
|
const CPU_THREADS = Math.max(1, os.cpus().length);
|
|
77
77
|
|
|
78
|
+
// Bitrate caps (constrained CRF). CRF stays the quality driver; -maxrate/
|
|
79
|
+
// -bufsize only bound the peaks. Field evidence (iPhone on cellular,
|
|
80
|
+
// 2026-07-10): uncapped complex scenes produced 4 s segments of ~18 Mbit/s
|
|
81
|
+
// against a 1-6 Mbit/s viewer link — 45 s prebuffer, draining buffer.
|
|
82
|
+
// Nominal H.264 rates per rung height; multipliers from webtor's production
|
|
83
|
+
// ladder (content-transcoder): maxrate = 1.3x nominal, bufsize = 1.5x.
|
|
84
|
+
const RUNG_NOMINAL_KBPS = [
|
|
85
|
+
[1080, 5000],
|
|
86
|
+
[720, 2800],
|
|
87
|
+
[480, 1400],
|
|
88
|
+
[360, 800],
|
|
89
|
+
[240, 400]
|
|
90
|
+
];
|
|
91
|
+
const CAP_MAXRATE_FACTOR = 1.3;
|
|
92
|
+
const CAP_BUFSIZE_FACTOR = 1.5;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Nominal kbps for an encode height: nearest rung wins (odd heights snap to
|
|
96
|
+
* the closest standard rung; anything above the top rung uses the top one).
|
|
97
|
+
*
|
|
98
|
+
* @param {number} height
|
|
99
|
+
* @returns {number}
|
|
100
|
+
*/
|
|
101
|
+
export function nominalKbpsForHeight(height) {
|
|
102
|
+
const h = Number.isFinite(height) && height > 0 ? height : 720;
|
|
103
|
+
let best = RUNG_NOMINAL_KBPS[0];
|
|
104
|
+
for (const rung of RUNG_NOMINAL_KBPS) {
|
|
105
|
+
if (Math.abs(rung[0] - h) < Math.abs(best[0] - h)) {
|
|
106
|
+
best = rung;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return best[1];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* `-maxrate`/`-bufsize` args for an encode height (constrained CRF).
|
|
114
|
+
*
|
|
115
|
+
* @param {number} height
|
|
116
|
+
* @returns {string[]}
|
|
117
|
+
*/
|
|
118
|
+
function bitrateCapArgs(height) {
|
|
119
|
+
const nominal = nominalKbpsForHeight(height);
|
|
120
|
+
return [
|
|
121
|
+
"-maxrate", `${Math.round(nominal * CAP_MAXRATE_FACTOR)}k`,
|
|
122
|
+
"-bufsize", `${Math.round(nominal * CAP_BUFSIZE_FACTOR)}k`
|
|
123
|
+
];
|
|
124
|
+
}
|
|
125
|
+
|
|
78
126
|
// libx264 presets to benchmark, ordered slowest/highest-quality → fastest.
|
|
79
127
|
const BENCHMARK_PRESETS = ["fast", "faster", "veryfast", "superfast", "ultrafast"];
|
|
80
128
|
const BENCHMARK_REF_W = 640;
|
|
@@ -140,6 +188,11 @@ export function softwareDescriptor() {
|
|
|
140
188
|
// faster than realtime); falls back to the static default.
|
|
141
189
|
"-preset", chosenPreset,
|
|
142
190
|
"-crf", SOFTWARE_CRF,
|
|
191
|
+
// Constrained CRF: bound peak bitrate per rung so a complex scene
|
|
192
|
+
// cannot produce segments a thin viewer link (cellular) can't
|
|
193
|
+
// download in time. Sized by the TARGET box height (the rung the
|
|
194
|
+
// budget/manual selection chose).
|
|
195
|
+
...bitrateCapArgs(h),
|
|
143
196
|
"-threads", String(CPU_THREADS),
|
|
144
197
|
"-pix_fmt", "yuv420p",
|
|
145
198
|
// Fixed GOP: a keyframe exactly every (segmentDurationSec × fps) frames,
|