@torrent-tv/proxy 2.83.8 → 2.85.0
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 +23 -0
- package/CLAUDE.md +5 -3
- package/docs/encode-architecture.md +26 -1
- package/docs/logs.md +97 -51
- package/package.json +1 -1
- package/routes/api/link-probe/get.js +51 -0
- package/routes/api/transcode-sessions/net-report/post.js +84 -64
- package/routes/api/transcode-sessions/post.js +139 -137
- package/routes/transcode/session-file/get.js +268 -240
- package/server.js +3 -0
- package/services/data-channel-handler.js +12 -0
- package/services/delivery-probe.js +842 -828
- package/services/hls-session-manager.js +9759 -10006
- package/services/output/OutputSpec.js +202 -195
- package/services/priority/PriorityMap.js +390 -362
- package/services/priority/PriorityOrchestrator.js +320 -318
- package/services/viewer/Viewer.js +179 -83
- package/services/viewer/positions.js +120 -48
- package/services/viewer/report-intake.js +63 -0
- package/test/held-request-width.test.js +8 -5
- package/test/link-probe.test.js +56 -0
- package/test/orchestrator-wired.test.js +14 -8
- package/test/output-spec.test.js +157 -157
- package/test/plan-is-steady.test.js +225 -0
- package/test/priority-map-emptied.test.js +205 -207
- package/test/priority-map-per-output.test.js +209 -211
- package/test/quality-variants.test.js +1167 -1222
- package/test/seek-target-not-superseded.test.js +14 -12
- package/test/stale-request-after-seek.test.js +2 -3
- package/test/viewer-states.test.js +151 -0
- package/test/viewer.test.js +136 -108
- package/test/viewer-position-on-open.test.js +0 -92
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,26 @@
|
|
|
1
|
+
## 2.85.0
|
|
2
|
+
|
|
3
|
+
- **Fix**: A VIEWER WHO HAS SAID NOTHING IS NOT ASSUMED TO BE WATCHING. `playing` started true on a viewer this proxy had never heard from, and the position is carried forward by the clock at that rate — so a page whose tab was hidden for 145 seconds, while it deliberately held start-up until it was shown, was placed **145.979 s** into a film it had not begun. Field 2026-09-14: that is the exact `-ss` the soundtrack's encoder was given, the browser was asking for segment #0, the map ranked #0 last of a hundred (`p1:#0..#13`), and the request was held 60 145 ms and answered 503. The moment the viewer left and the invented clock stopped, the plan asked for exactly the segment they had been asking for all along. A statement about somebody else's machine is theirs to make: until they make it the rate is zero.
|
|
4
|
+
- **Fix**: THE POSITION IS BOUNDED BY WHAT THE VIEWER SAID THEY HELD — `position(now) = stated + rate × min(now − statedAt, held)`. Nobody can play past the end of what they have; a viewer who said "thirty seconds" and has said nothing for two minutes has played at most thirty of them. The bound binds only where the clock has outrun the cushion (a report lost, or a viewer starving) and there it under-states, which is the direction that costs an encoder rather than a viewer. In the field session above the cushion was 0.0 s throughout, so this alone would have held that viewer at zero whatever the rate said.
|
|
5
|
+
- **Fix**: THREE STATES, NOT TWO. A picture that is not advancing was stopped either by the viewer — who then consumes nothing and can wait — or by us having delivered nothing, and that viewer is the most urgent there is. One boolean held two of the three and read the second as the first, so a viewer frozen for want of a segment counted as somebody who had chosen to stop and nothing of theirs fell due. `waiting` is stated beside `playing`, a viewer arrives waiting, and the priority map's upper band is for those watching OR waiting. A page one release behind states only `playing`, and its two states are told apart by the quantity it does state: stopped while holding film is a pause, stopped holding nothing is starvation.
|
|
6
|
+
- **Fix**: A LINK MEASUREMENT IS NO LONGER THE TICKET FOR A VIEWER'S OWN REPORT. The same condition stood in three places — the page, this route, and the session manager — and a page measures its link from completed transfers, so a page that has not been delivered a segment has no figure at all. Field 2026-09-14: one transfer completed in the whole session, the median needs two, and every report the page tried to send — the pauses, the starvation, the tab going hidden — was dropped. The link figure is one field now, kept only when it is a measurement.
|
|
7
|
+
- **Fix**: The second presence detector threw instead of working. `makeSendQueueWatcher` called `connectionGone`, a function of the factory that is not in its scope, so the watch that exists because `onClosed` does not always come raised a `ReferenceError` inside its own timer and left the viewer registered for the life of the process.
|
|
8
|
+
- **New**: `GET /api/link-probe?bytes=N` — bytes a browser can time its link with before any film has been chosen. Bounded at 2 MiB, which is 336 Mbit/s inside the browser's minimum measurable transfer and twenty-eight times the top rung this product offers. Not `delivery-sink`, which is the load stand's and answers any size asked for.
|
|
9
|
+
- **New**: A refusal says WHY, in the viewer's terms and not only in this proxy's log: the 503 for a segment that is not ready carries a `reason`, and where the priority map is working further along the film it says so. The reason existed sixty seconds before the failure of 2026-09-14 and travelled nowhere.
|
|
10
|
+
- **New**: `peerWaiting=` on the delivery-probe line — how many requests the far end says it is still waiting for. The browser has sent it twice a second since the probe echo was built and nothing read it. Evidence, not a verdict: the receiver declares its own transport lost, because it knows first — on 2026-09-06 it said so sixteen minutes before this end did.
|
|
11
|
+
- **Chore**: Taking in a viewer's report leaves the session manager for `services/viewer/report-intake.js`, and `Viewer.deadlineAt` is deleted — nothing consumed it, and when a second of film falls due is the priority map's answer. The manager is 18 lines shorter.
|
|
12
|
+
- **New**: `test/viewer-states.test.js` — twelve checks over the three states, the bound, the cold open of 2026-09-14 with its own 146 seconds, and a report carrying no link measurement. `test/link-probe.test.js` covers this end of the probe; how big an ask has to be is checked where that function lives, in the page's own repository.
|
|
13
|
+
|
|
14
|
+
## 2.84.0
|
|
15
|
+
|
|
16
|
+
- **Fix**: WHERE A VIEWER IS HAS ONE WRITER AND IS A FUNCTION OF TIME. It had two: a seek wrote `position.seeked`, a segment request overwrote `position.seconds`, and the reading preferred whichever was set — so the two took turns and the priority map jumped backwards and forwards several times a second. Field 2026-09-13, one output over one second: `p100:#9..#10`, `p100:#18..#20`, `p100:#16..#18`, `p100:#18..#20`, with the second viewer stopped dead at 1673.6 s throughout. Encoder placement follows the map, as it must, so the machine spent six minutes on **77 starts and 141 stops against one normal end**, produced almost nothing, and both browsers sat frozen with empty buffers while the swarm delivered 3 MB/s from 129 peers. A segment request now says only that the viewer is still here; where they are is what they state, and between statements it is carried forward by the clock at the rate the film moves — `position(now) = stated + (playing ? 1 : 0) × (now − statedAt)`. Continuous and monotone by construction, so nothing derived from it can dither.
|
|
17
|
+
- **Fix**: A picture starved of data is no longer reported as moving. `paused` is false while an element waits for bytes, so a frozen player told the proxy it was playing and the position walked away from where the viewer actually stood — the exact case a frozen player is in. It is `readyState >= HAVE_FUTURE_DATA` as well now, and `waiting` joins `pause`/`playing`/`ended` in the events that report at once rather than waiting out the ten-second interval.
|
|
18
|
+
- **Fix**: PRESENCE IS THE CONNECTION AND ONLY THE CONNECTION. Silence longer than "any silence a watching viewer can produce" used to end it, and there is no such interval: a paused viewer, a hidden tab whose timers the browser has throttled, and one holding two minutes of cushion all say nothing and are all still watching. A viewer is here until something says otherwise — the browser releasing the session, or the connection closing. `presenceStaleAfterMs` and every threshold it fed are gone.
|
|
19
|
+
- **Fix**: A soundtrack begins where the earliest viewer of the picture stands, with nothing subtracted. What that replaced was an apparatus for turning a request edge back into a playhead: three position sources ranked by priority, a second function naming which had answered, a subtraction of the deepest reported buffer, and a special case for a session nobody had asked anything of yet. Each repaired a quantity that meant two things. Field 2026-08-31, what it cost: a page opened at 588 s started its sound at 460 s — 131 seconds of film nobody would hear, and the segment the viewer needed took 38.8 s against the picture's 8.4 s.
|
|
20
|
+
- **Chore**: `resolveViewerPosition`, `viewerPositionSource`, `#viewerPositionSourceOf`, `#consumerPositionOf`, `#noteConsumerHead`, `session.lastRequestedSegment`, `session.furthestViewerSeconds` and `headsOf` are deleted. Where a viewer is now lives in the viewer's own layer (`viewer/positions.js`), and the session manager is 250 lines shorter for it.
|
|
21
|
+
- **Chore**: The flag that pins a rung's size is `exactSize`, not `manualQuality`, and the key it writes reads `enc:1280x720:exact` rather than `:manual`. It says nothing about who asked — every rung of a master sets it, whether the viewer picked that rung or the player moved itself onto one — and the old name was read as "the viewer did this" four times in a few weeks, in field logs where the viewer had touched nothing. The log line says `size=exact` for the same reason. Requires server 0.27.14 or newer in the page.
|
|
22
|
+
- **New**: `test/plan-is-steady.test.js` — five checks that the placement does not move while the facts do not, over the whole chain from what a viewer states to what the plan answers, with the field's own two positions. Confirmed to go red on the reading this release replaced.
|
|
23
|
+
|
|
1
24
|
## 2.83.8
|
|
2
25
|
|
|
3
26
|
- **New**: THE BROWSER'S OWN LOG IS KEPT BESIDE THE PROXY'S, ON A DISK THAT SURVIVES. Explaining any failure here needs both halves — what the proxy did and what the page saw — and the second half went to the registry server's standard output on the droplet, which every release of it destroys. On 2026-09-13 a viewer reported a desync and a frozen picture and the analysis ran on half the evidence for exactly that reason. `POST /api/client-logs` on the proxy writes them into the same directory as its own log (`/data` on the addon host), one file per viewing session: `client-<start>-<session>-<film>-<infohash>.log`. The start time and the session id are what join the two halves without guessing, and the film says which one it is without opening the file. Seven checks over the writer, one confirmed to go red.
|
package/CLAUDE.md
CHANGED
|
@@ -89,9 +89,11 @@ Linux-only host (e.g. POSIX-only signals must degrade elsewhere).
|
|
|
89
89
|
why the priority map is read at two scopes (per FILE for the swarm, per
|
|
90
90
|
OUTPUT for the encoders), and what each of the eight other places that used
|
|
91
91
|
to place or kill an encoder now states instead.
|
|
92
|
-
- `docs/logs.md` — where to find logs
|
|
93
|
-
|
|
94
|
-
|
|
92
|
+
- `docs/logs.md` — where to find logs, and the fact that the BROWSER's own log
|
|
93
|
+
is in TWO places split by phase: on the droplet while a transport is being
|
|
94
|
+
acquired and at unload, on THIS proxy for the whole of a viewing
|
|
95
|
+
(`/data/client-<start>-<session>-<torrent>.log`, one file per session).
|
|
96
|
+
Read both before concluding anything from a half-session.
|
|
95
97
|
- `playback-planner.js` — single ffmpeg probe returns audioCodec, videoCodec,
|
|
96
98
|
container, durationSeconds. `mode` is advisory; the browser decides.
|
|
97
99
|
- `hls-session-manager.js` — one ffmpeg per (source, file, settings). Serves a
|
|
@@ -28,12 +28,37 @@ It decides from four things and no others:
|
|
|
28
28
|
name a consumer id, or hold a person. What crosses is a priority map: zones of
|
|
29
29
|
segment numbers with a rank and a real time, and nobody's name on it.
|
|
30
30
|
|
|
31
|
+
## Where a viewer is, and how it reaches the encoders
|
|
32
|
+
|
|
33
|
+
A viewer's position has ONE writer — the viewer — and is a function of time:
|
|
34
|
+
|
|
35
|
+
```
|
|
36
|
+
position(now) = stated + (playing ? 1 : 0) × (now − statedAt)
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
A viewer whose picture moves covers a second of film every second, so between
|
|
40
|
+
the moments they speak their position is known exactly; one whose picture is
|
|
41
|
+
stopped covers nothing. Nothing else writes it, and in particular a request for
|
|
42
|
+
a segment does not: a request says the viewer is still here, and the segment
|
|
43
|
+
either exists and is served or does not and is waited for.
|
|
44
|
+
|
|
45
|
+
Held as a stored number instead it was a staircase, flat for the ten seconds
|
|
46
|
+
between reports and then a jump — and with two writers filling it in turn the
|
|
47
|
+
steps went backwards as often as forwards. Field 2026-09-13, one output over one
|
|
48
|
+
second: `p100:#9..#10`, `p100:#18..#20`, `p100:#16..#18`, `p100:#18..#20`, while
|
|
49
|
+
the second viewer sat frozen at 1673.6 s. The placement follows the map, so the
|
|
50
|
+
machine spent six minutes on 77 encoder starts and 141 stops.
|
|
51
|
+
|
|
52
|
+
PRESENCE IS A SEPARATE FACT and it is the connection: a viewer is here until
|
|
53
|
+
something says otherwise. Silence never says it — a pause, a hidden tab whose
|
|
54
|
+
timers are throttled, and a full cushion are all silent and all still watching.
|
|
55
|
+
|
|
31
56
|
## Viewers become a map, and that is the whole crossing
|
|
32
57
|
|
|
33
58
|
```mermaid
|
|
34
59
|
flowchart TB
|
|
35
60
|
subgraph V["services/viewer — where people are"]
|
|
36
|
-
VW[Viewer<br/>position, playing, buffered, chosen track]
|
|
61
|
+
VW[Viewer<br/>position as a function of time, playing, buffered, chosen track]
|
|
37
62
|
VS[Viewers<br/>the relation, indexed from both ends]
|
|
38
63
|
end
|
|
39
64
|
|
package/docs/logs.md
CHANGED
|
@@ -1,51 +1,97 @@
|
|
|
1
|
-
# Logs — where to look
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
```
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
ssh do "docker logs infra-server-1 --tail
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
1
|
+
# Logs — where to look
|
|
2
|
+
|
|
3
|
+
No browser console copy-paste is needed: the page forwards its own log. But it
|
|
4
|
+
lands in TWO places depending on the phase of the session, and `docker logs` is
|
|
5
|
+
only one of them — see "Browser logs are in TWO places" below before concluding
|
|
6
|
+
anything from a half-session.
|
|
7
|
+
|
|
8
|
+
## HA proxy (aarch64, addon `b34a1737_torrent_tv_proxy`)
|
|
9
|
+
|
|
10
|
+
Image `b34a1737/aarch64-addon-torrent_tv_proxy:<version>` = `@torrent-tv/proxy` `<version>` (see `ha-addon/torrent_tv_proxy/config.yaml`).
|
|
11
|
+
|
|
12
|
+
- Console + file are the same: the proxy logs to both `stdout` and `/data/proxy.log` on start (`logging to /data/proxy.log as well as the console`).
|
|
13
|
+
- **Since 2.71.0 that is true of the torrent thread too, and it was not before.**
|
|
14
|
+
A worker thread loads its own instance of every module, so the logger's file
|
|
15
|
+
handle — set once, on the main thread — was null in the worker for the life of
|
|
16
|
+
the process. Measured over a whole 49 938-line file: zero lines from the piece
|
|
17
|
+
reader (`read "…"`, `supply "…"`, every wait and its cause) and zero from the
|
|
18
|
+
torrent pool, against 52 and 36 of them in `docker logs`, which every release
|
|
19
|
+
destroys. The worker now sends its lines to the main thread, which is the only
|
|
20
|
+
writer; a line from there reads `torrent-worker: …`.
|
|
21
|
+
- Via container:
|
|
22
|
+
```bash
|
|
23
|
+
ssh ha "sudo docker logs app_b34a1737_torrent_tv_proxy --tail 200"
|
|
24
|
+
ssh ha "sudo docker exec app_b34a1737_torrent_tv_proxy cat /data/proxy.log | tail -n 300"
|
|
25
|
+
```
|
|
26
|
+
- Filter by session: every transcode session logs its id, e.g. `003ed2fd-7c9b-4cd5-9d05-ff875ff2be23`, `hold segment-00068.mp4 failed`, `encode-run failed`, `EXITED_*`.
|
|
27
|
+
- Host file `/data/proxy.log` survives `docker logs` rotation; `core dumps: 1 present` line shows kept dump under `/data`.
|
|
28
|
+
|
|
29
|
+
SSH to HA requires `MACs hmac-sha2-256-etm@openssh.com,hmac-sha2-512-etm@openssh.com,umac-128-etm@openssh.com` — server `OpenSSH_10.3` on `homeassistant.local` offers only `*-etm` (`Unable to negotiate` / `Corrupted MAC` otherwise). Already in `~/.ssh/config` `Host ha`.
|
|
30
|
+
|
|
31
|
+
## DO server (webauth.courses, `infra-server-1`)
|
|
32
|
+
|
|
33
|
+
- Server is `infra-server-1` (`ghcr.io/torrent-tv/server:latest`) on `do` (`206.189.97.152`).
|
|
34
|
+
- Frontend logs reach here only in the phases the proxy cannot be reached in — see "Browser logs are in TWO places" below. When they do, the server `console.log`s them with the prefix `[client <tag> <id> sig=<webrtcSessionId>]` (`server/routes/api/client-logs/post.js`), in the same container as its own lines.
|
|
35
|
+
- Via container:
|
|
36
|
+
```bash
|
|
37
|
+
ssh do "docker logs infra-server-1 --tail 200 | cat"
|
|
38
|
+
# filter a single viewing:
|
|
39
|
+
ssh do "docker logs infra-server-1 --tail 500 | grep 003ed2fd"
|
|
40
|
+
ssh do "docker logs infra-server-1 --tail 500 | grep '\[client'"
|
|
41
|
+
```
|
|
42
|
+
- No need to open eruda or copy the browser console on a phone — it is forwarded.
|
|
43
|
+
|
|
44
|
+
## Browser logs are in TWO places, split by phase — read both
|
|
45
|
+
|
|
46
|
+
Until 2026-09-13 the page's whole log went to the droplet's standard output,
|
|
47
|
+
which every release of the server destroys. A viewer reported a desync and a
|
|
48
|
+
frozen picture that day and the analysis ran on half the evidence, so the log
|
|
49
|
+
moved to the proxy — beside the proxy's own, on the same durable directory.
|
|
50
|
+
|
|
51
|
+
The split is by PHASE, and neither half is the whole session:
|
|
52
|
+
|
|
53
|
+
1. **Before a transport exists** — the page opening, the proxy being chosen, a
|
|
54
|
+
connection failing — there is nowhere else to send it, so it goes to the
|
|
55
|
+
SERVER (`infra-server-1`);
|
|
56
|
+
2. **From the moment the data channel is up**, the page's logger is given a
|
|
57
|
+
proxy sink (`loading.js`, `setProxySink`) and every batch goes to the PROXY
|
|
58
|
+
over that channel. This is the bulk of a viewing;
|
|
59
|
+
3. **At unload** it goes to the server again: `navigator.sendBeacon` is the only
|
|
60
|
+
thing a page that is going away can use, and a data channel is not;
|
|
61
|
+
4. **A batch that cannot reach the proxy** falls back to the server.
|
|
62
|
+
|
|
63
|
+
So a session that failed while connecting is on the droplet, a session that
|
|
64
|
+
failed while playing is on the addon host, and a complete reading of a long
|
|
65
|
+
failure needs both.
|
|
66
|
+
|
|
67
|
+
### The files on the proxy
|
|
68
|
+
|
|
69
|
+
One file per viewing session, in the proxy log's own directory (`/data` on the
|
|
70
|
+
addon host), named so the two halves join without guessing:
|
|
71
|
+
|
|
72
|
+
```
|
|
73
|
+
client-<YYYYMMDD-HHMMSS UTC of the session start>-<sessionId:8>-<torrent name:60>-<infoHash:8>.log
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
A session that has not chosen a torrent yet writes `no-torrent-yet` in that
|
|
77
|
+
last part — the file is keyed by the session id, not by the name, so choosing a
|
|
78
|
+
torrent later does NOT start a second file. Rotated at 16 MB to `<name>.log.1`.
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
ssh ha "sudo docker exec app_b34a1737_torrent_tv_proxy sh -c 'ls -la /data/client-*.log'"
|
|
82
|
+
ssh ha "sudo docker exec app_b34a1737_torrent_tv_proxy sh -c 'cat /data/client-20260913-*.log'"
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Implementation: `utils/client-log-file.js`, route `routes/api/client-logs/post.js`.
|
|
86
|
+
|
|
87
|
+
## Quick triage
|
|
88
|
+
|
|
89
|
+
- Rewind/seek bug (hold 0ms → 500): HA `hold ... failed after 0ms → 500` + `encode-run` lines for the same `<sessionId>`, and DO `[client ...] fragLoadError / levelLoadError 500` for same `sn`.
|
|
90
|
+
- Cushion / link budget: HA `memory: rss=... anon=...` and `cushion` lines; DO `[eta]` / `[cushion]` from client.
|
|
91
|
+
- Update check: `ssh ha "sudo docker exec hassio_cli ha apps info b34a1737_torrent_tv_proxy | grep version"` and `ssh ha "sudo docker ps --filter name=app_b34a1737_torrent_tv_proxy"`.
|
|
92
|
+
|
|
93
|
+
## Related
|
|
94
|
+
|
|
95
|
+
- `ha-addon/CLAUDE.md` — addon build/update detour via `hassio_cli`, cache-bust via `config.yaml` version.
|
|
96
|
+
- `docs/container-architecture.md` — what container/track classes log and where.
|
|
97
|
+
- `routes/api/client-logs/post.js` and `server/routes/api/client-logs/post.js` — the two receivers; both sanitize the same way (control chars → space, `MAX_LINES`, `MAX_MSG_LEN`).
|
package/package.json
CHANGED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file `GET /api/link-probe?bytes=N` — bytes to measure the link with.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS. A viewer's link is measured from completed transfers, and
|
|
5
|
+
* the only transfers that counted were segments of the film. So the figure came
|
|
6
|
+
* into being after playback had begun — after the quality offer had been made,
|
|
7
|
+
* and after the start-up decisions that want it. Worse, the page's report of
|
|
8
|
+
* its OWN facts was skipped when there was no figure, so at a cold open — the
|
|
9
|
+
* moment where where-the-viewer-is matters most — the page said nothing at all
|
|
10
|
+
* (2026-09-14).
|
|
11
|
+
*
|
|
12
|
+
* There is nothing to wait for: a proxy can send bytes the moment its channel
|
|
13
|
+
* is up, which is when the page opens rather than when a film is chosen.
|
|
14
|
+
*
|
|
15
|
+
* NOT `delivery-sink`, which is the load stand's and answers any size asked
|
|
16
|
+
* for. This is part of the product, always on, and bounded — the browser's own
|
|
17
|
+
* sizing stops at 2 MiB and this refuses more, so a caller cannot turn it into
|
|
18
|
+
* a way of drawing a proxy owner's uplink.
|
|
19
|
+
*
|
|
20
|
+
* The body is zeros. What is being measured is the wire, and zeros cross it at
|
|
21
|
+
* the same speed as anything else; producing them costs one allocation.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The most this route will send for one ask.
|
|
26
|
+
*
|
|
27
|
+
* The figure is only ever used to decide what a link can carry, and the most
|
|
28
|
+
* anything this product offers is a 1080p rung at around 12 Mbit/s. Two
|
|
29
|
+
* mebibytes inside the browser's minimum measurable transfer (50 ms) is
|
|
30
|
+
* 336 Mbit/s, twenty-eight times that rung: a link too fast to time at this
|
|
31
|
+
* size is beyond anything a decision here distinguishes.
|
|
32
|
+
*/
|
|
33
|
+
export const MAX_LINK_PROBE_BYTES = 2 * 1024 * 1024;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @param {import("fastify").FastifyRequest} req
|
|
37
|
+
* @param {import("fastify").FastifyReply} reply
|
|
38
|
+
* @returns {Promise<void>}
|
|
39
|
+
*/
|
|
40
|
+
export async function handleApiLinkProbeGet(req, reply) {
|
|
41
|
+
const asked = Number(/** @type {{ bytes?: string }} */ (req.query)?.bytes);
|
|
42
|
+
if (!Number.isFinite(asked) || asked <= 0) {
|
|
43
|
+
return reply.code(400).send({ error: "bytes (>0) is required." });
|
|
44
|
+
}
|
|
45
|
+
const bytes = Math.min(Math.floor(asked), MAX_LINK_PROBE_BYTES);
|
|
46
|
+
return reply
|
|
47
|
+
.code(200)
|
|
48
|
+
.header("Content-Type", "application/octet-stream")
|
|
49
|
+
.header("Cache-Control", "no-store")
|
|
50
|
+
.send(Buffer.alloc(bytes));
|
|
51
|
+
}
|
|
@@ -1,64 +1,84 @@
|
|
|
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
|
-
* consumerId?: string, positionSeconds?: number }
|
|
10
|
-
*
|
|
11
|
-
* `consumerId` and `positionSeconds` say WHO is reporting and WHERE they are.
|
|
12
|
-
* A copied picture is one session shared by every viewer of it, so without them
|
|
13
|
-
* the proxy could only act on whichever viewer reported last. Both are
|
|
14
|
-
* optional: a browser that sends neither is treated exactly as before.
|
|
15
|
-
*
|
|
16
|
-
* Best-effort telemetry: invalid body → 400, unknown session → 404, ok → 204.
|
|
17
|
-
*
|
|
18
|
-
* @param {import("fastify").FastifyRequest} req
|
|
19
|
-
* @param {import("fastify").FastifyReply} reply
|
|
20
|
-
* @param {{ hlsSessionManager: import("../../../../services/hls-session-manager.js").HlsSessionManager }} deps
|
|
21
|
-
* @returns {Promise<void>}
|
|
22
|
-
*/
|
|
23
|
-
export async function handleApiTranscodeSessionNetReportPost(req, reply, { hlsSessionManager }) {
|
|
24
|
-
const sessionId = typeof req.params.sessionId === "string" ? req.params.sessionId : "";
|
|
25
|
-
const body = req.body && typeof req.body === "object" && !Array.isArray(req.body) ? req.body : {};
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
//
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
+
* consumerId?: string, positionSeconds?: number }
|
|
10
|
+
*
|
|
11
|
+
* `consumerId` and `positionSeconds` say WHO is reporting and WHERE they are.
|
|
12
|
+
* A copied picture is one session shared by every viewer of it, so without them
|
|
13
|
+
* the proxy could only act on whichever viewer reported last. Both are
|
|
14
|
+
* optional: a browser that sends neither is treated exactly as before.
|
|
15
|
+
*
|
|
16
|
+
* Best-effort telemetry: invalid body → 400, unknown session → 404, ok → 204.
|
|
17
|
+
*
|
|
18
|
+
* @param {import("fastify").FastifyRequest} req
|
|
19
|
+
* @param {import("fastify").FastifyReply} reply
|
|
20
|
+
* @param {{ hlsSessionManager: import("../../../../services/hls-session-manager.js").HlsSessionManager }} deps
|
|
21
|
+
* @returns {Promise<void>}
|
|
22
|
+
*/
|
|
23
|
+
export async function handleApiTranscodeSessionNetReportPost(req, reply, { hlsSessionManager }) {
|
|
24
|
+
const sessionId = typeof req.params.sessionId === "string" ? req.params.sessionId : "";
|
|
25
|
+
const body = req.body && typeof req.body === "object" && !Array.isArray(req.body) ? req.body : {};
|
|
26
|
+
// WHAT THIS REPORT IS. A statement by one viewer about itself: where the
|
|
27
|
+
// picture is, how much film it holds, whether it is moving, whether it is
|
|
28
|
+
// blocked on us, whether the page is on screen — and, when there has been
|
|
29
|
+
// anything to measure, how fast the link carried it.
|
|
30
|
+
//
|
|
31
|
+
// THE LINK FIGURE IS NOT REQUIRED, and requiring it is the fault of
|
|
32
|
+
// 2026-09-14. A page measures its link from completed transfers, so a page
|
|
33
|
+
// that has not yet been delivered a segment has no figure; at a cold open
|
|
34
|
+
// that is every page. Rejected here with 400, the page's own account of
|
|
35
|
+
// itself never arrived, this proxy filled the silence by assuming the film
|
|
36
|
+
// was running, and a viewer who had not seen a frame was placed 146 seconds
|
|
37
|
+
// into it — the soundtrack's encoder went there, and the segment the browser
|
|
38
|
+
// was actually asking for was ranked last of a hundred and answered 503.
|
|
39
|
+
const linkMbps = Number(body.linkMbps);
|
|
40
|
+
const bufferedAheadSec = Number(body.bufferedAheadSec);
|
|
41
|
+
if (!sessionId || !Number.isFinite(bufferedAheadSec) || bufferedAheadSec < 0) {
|
|
42
|
+
return reply.code(400).send({ error: "bufferedAheadSec (>=0) is required." });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Neither is required, and neither can make a report invalid: they are what
|
|
46
|
+
// the proxy uses to tell the viewers of one session apart, and a report
|
|
47
|
+
// without them is still a truthful reading of somebody's link.
|
|
48
|
+
const consumerId = typeof body.consumerId === "string" ? body.consumerId.trim() : "";
|
|
49
|
+
// Whether the picture is moving. Absent from a page that does not say, and
|
|
50
|
+
// then nothing is assumed: a statement about somebody else's machine is
|
|
51
|
+
// theirs to make, and assuming it walked a viewer 146 seconds into a film
|
|
52
|
+
// they had not begun (2026-09-14).
|
|
53
|
+
const playing = typeof body.playing === "boolean" ? body.playing : undefined;
|
|
54
|
+
// Whether this viewer is BLOCKED on material we owe them, which is a
|
|
55
|
+
// different state from having stopped the picture: the first is the most
|
|
56
|
+
// urgent viewer there is, the second consumes nothing and can wait. One
|
|
57
|
+
// boolean could not hold three states and read the first as the second.
|
|
58
|
+
const waiting = typeof body.waiting === "boolean" ? body.waiting : undefined;
|
|
59
|
+
// Whether the page is on screen, and whether the picture was pulled out of
|
|
60
|
+
// it. A hidden tab has its timers throttled, so it asks for nothing and looks
|
|
61
|
+
// exactly like a viewer holding a full cushion; picture-in-picture is the case
|
|
62
|
+
// that makes the distinction necessary, because there the tab is hidden and
|
|
63
|
+
// the viewer is watching. Absent from a page that does not say, and then the
|
|
64
|
+
// viewer is on screen, as every page meant before it could say otherwise.
|
|
65
|
+
const onScreen = typeof body.onScreen === "boolean" ? body.onScreen : undefined;
|
|
66
|
+
const inPictureInPicture =
|
|
67
|
+
typeof body.inPictureInPicture === "boolean" ? body.inPictureInPicture : undefined;
|
|
68
|
+
const positionSeconds = Number(body.positionSeconds);
|
|
69
|
+
const recorded = hlsSessionManager.recordNetReport(sessionId, {
|
|
70
|
+
linkMbps: Number.isFinite(linkMbps) && linkMbps > 0 ? linkMbps : undefined,
|
|
71
|
+
waiting,
|
|
72
|
+
bufferedAheadSec,
|
|
73
|
+
consumerId,
|
|
74
|
+
playing,
|
|
75
|
+
onScreen,
|
|
76
|
+
inPictureInPicture,
|
|
77
|
+
positionSeconds:
|
|
78
|
+
Number.isFinite(positionSeconds) && positionSeconds >= 0 ? positionSeconds : undefined
|
|
79
|
+
});
|
|
80
|
+
if (!recorded) {
|
|
81
|
+
return reply.code(404).send({ error: "Transcode session was not found." });
|
|
82
|
+
}
|
|
83
|
+
return reply.code(204).send();
|
|
84
|
+
}
|