@torrent-tv/proxy 2.83.8 → 2.84.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 CHANGED
@@ -1,3 +1,13 @@
1
+ ## 2.84.0
2
+
3
+ - **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.
4
+ - **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.
5
+ - **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.
6
+ - **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.
7
+ - **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.
8
+ - **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.
9
+ - **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.
10
+
1
11
  ## 2.83.8
2
12
 
3
13
  - **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 (HA `docker logs` + `/data/proxy.log`, DO
93
- `infra-server-1` with forwarded frontend `POST /api/client-logs`). Browser
94
- console not needed.
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
- All logs are visible via container `docker logs`, no browser console copy-paste needed.
4
-
5
- ## HA proxy (aarch64, addon `b34a1737_torrent_tv_proxy`)
6
-
7
- Image `b34a1737/aarch64-addon-torrent_tv_proxy:<version>` = `@torrent-tv/proxy` `<version>` (see `ha-addon/torrent_tv_proxy/config.yaml`).
8
-
9
- - 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`).
10
- - **Since 2.71.0 that is true of the torrent thread too, and it was not before.**
11
- A worker thread loads its own instance of every module, so the logger's file
12
- handle — set once, on the main thread — was null in the worker for the life of
13
- the process. Measured over a whole 49 938-line file: zero lines from the piece
14
- reader (`read "…"`, `supply "…"`, every wait and its cause) and zero from the
15
- torrent pool, against 52 and 36 of them in `docker logs`, which every release
16
- destroys. The worker now sends its lines to the main thread, which is the only
17
- writer; a line from there reads `torrent-worker: …`.
18
- - Via container:
19
- ```bash
20
- ssh ha "sudo docker logs app_b34a1737_torrent_tv_proxy --tail 200"
21
- ssh ha "sudo docker exec app_b34a1737_torrent_tv_proxy cat /data/proxy.log | tail -n 300"
22
- ```
23
- - 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_*`.
24
- - Host file `/data/proxy.log` survives `docker logs` rotation; `core dumps: 1 present` line shows kept dump under `/data`.
25
-
26
- 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`.
27
-
28
- ## DO server (webauth.courses, `infra-server-1`)
29
-
30
- - Server is `infra-server-1` (`ghcr.io/torrent-tv/server:latest`) on `do` (`206.189.97.152`).
31
- - Frontend logs are forwarded: browser batches `{sessionId, tag, signalSessionId, lines: [{level,ts,msg}]}` and `POST`s to `https://webauth.courses/api/client-logs` (`server/routes/api/client-logs/post.js:56`), server does `console.log` with prefix `[client <tag> <id> sig=<webrtcSessionId>]`. They appear together with backend logs in the same container.
32
- - Via container:
33
- ```bash
34
- ssh do "docker logs infra-server-1 --tail 200 | cat"
35
- # filter a single viewing:
36
- ssh do "docker logs infra-server-1 --tail 500 | grep 003ed2fd"
37
- ssh do "docker logs infra-server-1 --tail 500 | grep '\[client'"
38
- ```
39
- - No need to open eruda or copy browser console on phone — it is already in `infra-server-1` logs.
40
-
41
- ## Quick triage
42
-
43
- - 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`.
44
- - Cushion / link budget: HA `memory: rss=... anon=...` and `cushion` lines; DO `[eta]` / `[cushion]` from client.
45
- - 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"`.
46
-
47
- ## Related
48
-
49
- - `ha-addon/CLAUDE.md` — addon build/update detour via `hassio_cli`, cache-bust via `config.yaml` version.
50
- - `docs/container-architecture.md` — what container/track classes log and where.
51
- - `server/routes/api/client-logs/post.js:1` — sanitization (control chars → space, `MAX_LINES 50`, `MAX_MSG_LEN 2000`).
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.83.8",
3
+ "version": "2.84.0",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -1,137 +1,139 @@
1
- /**
2
- * Create or return an existing HLS transcode session for a torrent file.
3
- *
4
- * POST /api/transcode-sessions
5
- *
6
- * @param {import("fastify").FastifyRequest} req
7
- * @param {import("fastify").FastifyReply} reply
8
- * @param {{ hlsSessionManager: import("../../../services/hls-session-manager.js").HlsSessionManager, sourceRegistry: object, torrentPool: object }} deps
9
- * @returns {Promise<void>}
10
- */
11
-
12
- import { logger } from "../../../utils/logger.js";
13
-
14
- /**
15
- * Extract a plain object from the request body, guarding against
16
- * non-object payloads (arrays, primitives, null).
17
- *
18
- * @param {unknown} body
19
- * @returns {Record<string, unknown>}
20
- */
21
- function getPayload(body) {
22
- if (body && typeof body === "object" && !Array.isArray(body)) {
23
- return body;
24
- }
25
- return {};
26
- }
27
-
28
-
29
- export async function handleApiTranscodeSessionsPost(req, reply, { hlsSessionManager, sourceRegistry, torrentPool }) {
30
- const payload = getPayload(req.body);
31
- const sourceKey = typeof payload.sourceKey === "string" ? payload.sourceKey.trim() : "";
32
- const fileIndex = Number(payload.fileIndex);
33
- const transcodeVideo = payload.transcodeVideo === true;
34
- const transcodeAudio = payload.transcodeAudio === true;
35
- const consumerId = typeof payload.consumerId === "string" ? payload.consumerId.trim() : "";
36
- const fileName = typeof payload.fileName === "string" ? payload.fileName.trim() : "";
37
- const targetWidth = Number(payload.targetWidth);
38
- const targetHeight = Number(payload.targetHeight);
39
- // Manual quality: the target box is a user-forced resolution, encoded exactly
40
- // (capped to source), with the realtime budget's auto-downscale + runtime
41
- // downswitch disabled for the session.
42
- const manualQuality = payload.manualQuality === true;
43
- // Whether this browser will take its audio from a separate rendition group in
44
- // the master playlist rather than muxed into the picture. It has to say so:
45
- // publishing renditions AND muxing the same audio would play it twice, while
46
- // a browser that does not know about them would get no sound at all.
47
- const audioRenditions = payload.audioRenditions === true;
48
- const startPositionSeconds = Number(payload.startPositionSeconds);
49
- const audioTrackIndex = Number(payload.audioTrackIndex);
50
- // Which container to produce. The browser knows what its media stack will
51
- // accept for the tracks it asked to be copied; an absent or unknown value
52
- // leaves the proxy's own `--segment-format` in charge.
53
- const segmentFormatId =
54
- typeof payload.segmentFormat === "string" ? payload.segmentFormat.trim() : "";
55
-
56
- if (!sourceKey || !Number.isInteger(fileIndex) || fileIndex < 0) {
57
- return reply.code(400).send({ error: "sourceKey and valid fileIndex are required." });
58
- }
59
-
60
- try {
61
- const session = await hlsSessionManager.createOrGetSession({
62
- sourceKey,
63
- fileIndex,
64
- transcodeVideo,
65
- transcodeAudio,
66
- consumerId,
67
- fileName,
68
- targetWidth: Number.isInteger(targetWidth) && targetWidth > 0 ? targetWidth : 0,
69
- targetHeight: Number.isInteger(targetHeight) && targetHeight > 0 ? targetHeight : 0,
70
- manualQuality,
71
- audioRenditions,
72
- startPositionSeconds:
73
- Number.isFinite(startPositionSeconds) && startPositionSeconds > 0
74
- ? startPositionSeconds
75
- : 0,
76
- audioTrackIndex:
77
- Number.isInteger(audioTrackIndex) && audioTrackIndex > 0 ? audioTrackIndex : 0,
78
- segmentFormatId
79
- });
80
- // The index of quality variants, when this session has more than one to
81
- // offer. Its presence is what tells the browser it can change quality
82
- // without a new session: the player switches variants itself, appending the
83
- // new one after what is already buffered. Absent for a copied video, whose
84
- // segments are cut at the source's own keyframes and so cannot be spliced
85
- // with a re-encoded rung.
86
- const hasVariants = hlsSessionManager.buildMasterPlaylist(session.id) !== null;
87
- return reply.send({
88
- sessionId: session.id,
89
- playlistPath: `/transcode/${session.id}/index.m3u8`,
90
- ...(hasVariants
91
- ? {
92
- masterPath: `/transcode/${session.id}/master.m3u8`,
93
- // Which of the master's variants this session IS. The browser pins
94
- // the player to it, so loading the master costs nothing: an encoder
95
- // is already producing that height, and any other rung would be a
96
- // second cold start before the first frame.
97
- variantHeight: hlsSessionManager.liveOutputs.variantHeightOf(session)
98
- }
99
- : {}),
100
- // The heights this host will actually serve this file at, largest first
101
- // — the ladder minus every rung it cannot produce faster than it is
102
- // watched. The browser needs it whether or not a master exists: without
103
- // it, it fell back to a ladder of its own invention and offered rungs the
104
- // proxy had just refused, and picking one re-opened the session at a
105
- // height measured at a third of realtime.
106
- offeredHeights: hlsSessionManager.offeredHeights(session),
107
- // What this session's output will carry, stated rather than left to be
108
- // discovered. The browser checks what it actually got against this: a
109
- // track that never arrives is otherwise noticed only by its absence,
110
- // minutes later, as a black picture with working sound.
111
- tracks: hlsSessionManager.declaredTracks(session),
112
- // How far ahead of the viewer this proxy lets the encoder run, in seconds
113
- // of playback. The browser sizes its own forward buffer from it, so the
114
- // two sides agree by construction instead of each carrying a constant of
115
- // its own — which is how the browser came to hold thirty seconds while
116
- // two minutes stood produced on disk (roadmap item 4).
117
- lookaheadSeconds: hlsSessionManager.lookaheadSeconds
118
- });
119
- } catch (error) {
120
- if (error instanceof Error && error.code === "TRANSCODE_DISABLED") {
121
- return reply.code(409).send({ error: error.message });
122
- }
123
- const message = error instanceof Error ? error.message : String(error);
124
- // Say why on the proxy's own log, not only in the answer. This route
125
- // answered 500 for every viewer of proxy 2.9.101-2.9.102 (an undeclared
126
- // constant) and the addon log carried nothing but the data-channel layer's
127
- // bare "→ 500": the cause had to be recovered by replaying the request
128
- // against the live proxy. The stack is worth the two lines it costs — a
129
- // programming error here is invisible to the viewer, who only sees that
130
- // nothing plays.
131
- logger.error(
132
- `transcode-sessions: ${sourceKey}:${fileIndex} failed to prepare: ${message}\n` +
133
- `${error instanceof Error ? (error.stack ?? "") : ""}`
134
- );
135
- return reply.code(500).send({ error: `Failed to prepare transcode session: ${message}` });
136
- }
137
- }
1
+ /**
2
+ * Create or return an existing HLS transcode session for a torrent file.
3
+ *
4
+ * POST /api/transcode-sessions
5
+ *
6
+ * @param {import("fastify").FastifyRequest} req
7
+ * @param {import("fastify").FastifyReply} reply
8
+ * @param {{ hlsSessionManager: import("../../../services/hls-session-manager.js").HlsSessionManager, sourceRegistry: object, torrentPool: object }} deps
9
+ * @returns {Promise<void>}
10
+ */
11
+
12
+ import { logger } from "../../../utils/logger.js";
13
+
14
+ /**
15
+ * Extract a plain object from the request body, guarding against
16
+ * non-object payloads (arrays, primitives, null).
17
+ *
18
+ * @param {unknown} body
19
+ * @returns {Record<string, unknown>}
20
+ */
21
+ function getPayload(body) {
22
+ if (body && typeof body === "object" && !Array.isArray(body)) {
23
+ return body;
24
+ }
25
+ return {};
26
+ }
27
+
28
+
29
+ export async function handleApiTranscodeSessionsPost(req, reply, { hlsSessionManager, sourceRegistry, torrentPool }) {
30
+ const payload = getPayload(req.body);
31
+ const sourceKey = typeof payload.sourceKey === "string" ? payload.sourceKey.trim() : "";
32
+ const fileIndex = Number(payload.fileIndex);
33
+ const transcodeVideo = payload.transcodeVideo === true;
34
+ const transcodeAudio = payload.transcodeAudio === true;
35
+ const consumerId = typeof payload.consumerId === "string" ? payload.consumerId.trim() : "";
36
+ const fileName = typeof payload.fileName === "string" ? payload.fileName.trim() : "";
37
+ const targetWidth = Number(payload.targetWidth);
38
+ const targetHeight = Number(payload.targetHeight);
39
+ // The target box is produced exactly (capped to source), with the realtime
40
+ // budget's auto-downscale and runtime downswitch off for this session.
41
+ //
42
+ // It says nothing about WHO asked — a rung sets it whether the viewer picked
43
+ // that rung or the player moved itself onto one.
44
+ const exactSize = payload.exactSize === true;
45
+ // Whether this browser will take its audio from a separate rendition group in
46
+ // the master playlist rather than muxed into the picture. It has to say so:
47
+ // publishing renditions AND muxing the same audio would play it twice, while
48
+ // a browser that does not know about them would get no sound at all.
49
+ const audioRenditions = payload.audioRenditions === true;
50
+ const startPositionSeconds = Number(payload.startPositionSeconds);
51
+ const audioTrackIndex = Number(payload.audioTrackIndex);
52
+ // Which container to produce. The browser knows what its media stack will
53
+ // accept for the tracks it asked to be copied; an absent or unknown value
54
+ // leaves the proxy's own `--segment-format` in charge.
55
+ const segmentFormatId =
56
+ typeof payload.segmentFormat === "string" ? payload.segmentFormat.trim() : "";
57
+
58
+ if (!sourceKey || !Number.isInteger(fileIndex) || fileIndex < 0) {
59
+ return reply.code(400).send({ error: "sourceKey and valid fileIndex are required." });
60
+ }
61
+
62
+ try {
63
+ const session = await hlsSessionManager.createOrGetSession({
64
+ sourceKey,
65
+ fileIndex,
66
+ transcodeVideo,
67
+ transcodeAudio,
68
+ consumerId,
69
+ fileName,
70
+ targetWidth: Number.isInteger(targetWidth) && targetWidth > 0 ? targetWidth : 0,
71
+ targetHeight: Number.isInteger(targetHeight) && targetHeight > 0 ? targetHeight : 0,
72
+ exactSize,
73
+ audioRenditions,
74
+ startPositionSeconds:
75
+ Number.isFinite(startPositionSeconds) && startPositionSeconds > 0
76
+ ? startPositionSeconds
77
+ : 0,
78
+ audioTrackIndex:
79
+ Number.isInteger(audioTrackIndex) && audioTrackIndex > 0 ? audioTrackIndex : 0,
80
+ segmentFormatId
81
+ });
82
+ // The index of quality variants, when this session has more than one to
83
+ // offer. Its presence is what tells the browser it can change quality
84
+ // without a new session: the player switches variants itself, appending the
85
+ // new one after what is already buffered. Absent for a copied video, whose
86
+ // segments are cut at the source's own keyframes and so cannot be spliced
87
+ // with a re-encoded rung.
88
+ const hasVariants = hlsSessionManager.buildMasterPlaylist(session.id) !== null;
89
+ return reply.send({
90
+ sessionId: session.id,
91
+ playlistPath: `/transcode/${session.id}/index.m3u8`,
92
+ ...(hasVariants
93
+ ? {
94
+ masterPath: `/transcode/${session.id}/master.m3u8`,
95
+ // Which of the master's variants this session IS. The browser pins
96
+ // the player to it, so loading the master costs nothing: an encoder
97
+ // is already producing that height, and any other rung would be a
98
+ // second cold start before the first frame.
99
+ variantHeight: hlsSessionManager.liveOutputs.variantHeightOf(session)
100
+ }
101
+ : {}),
102
+ // The heights this host will actually serve this file at, largest first
103
+ // — the ladder minus every rung it cannot produce faster than it is
104
+ // watched. The browser needs it whether or not a master exists: without
105
+ // it, it fell back to a ladder of its own invention and offered rungs the
106
+ // proxy had just refused, and picking one re-opened the session at a
107
+ // height measured at a third of realtime.
108
+ offeredHeights: hlsSessionManager.offeredHeights(session),
109
+ // What this session's output will carry, stated rather than left to be
110
+ // discovered. The browser checks what it actually got against this: a
111
+ // track that never arrives is otherwise noticed only by its absence,
112
+ // minutes later, as a black picture with working sound.
113
+ tracks: hlsSessionManager.declaredTracks(session),
114
+ // How far ahead of the viewer this proxy lets the encoder run, in seconds
115
+ // of playback. The browser sizes its own forward buffer from it, so the
116
+ // two sides agree by construction instead of each carrying a constant of
117
+ // its own — which is how the browser came to hold thirty seconds while
118
+ // two minutes stood produced on disk (roadmap item 4).
119
+ lookaheadSeconds: hlsSessionManager.lookaheadSeconds
120
+ });
121
+ } catch (error) {
122
+ if (error instanceof Error && error.code === "TRANSCODE_DISABLED") {
123
+ return reply.code(409).send({ error: error.message });
124
+ }
125
+ const message = error instanceof Error ? error.message : String(error);
126
+ // Say why on the proxy's own log, not only in the answer. This route
127
+ // answered 500 for every viewer of proxy 2.9.101-2.9.102 (an undeclared
128
+ // constant) and the addon log carried nothing but the data-channel layer's
129
+ // bare "→ 500": the cause had to be recovered by replaying the request
130
+ // against the live proxy. The stack is worth the two lines it costs — a
131
+ // programming error here is invisible to the viewer, who only sees that
132
+ // nothing plays.
133
+ logger.error(
134
+ `transcode-sessions: ${sourceKey}:${fileIndex} failed to prepare: ${message}\n` +
135
+ `${error instanceof Error ? (error.stack ?? "") : ""}`
136
+ );
137
+ return reply.code(500).send({ error: `Failed to prepare transcode session: ${message}` });
138
+ }
139
+ }
@@ -362,6 +362,13 @@ function makeSendQueueWatcher({ log, getTransportSnapshot, witness, usrsctpState
362
362
  const snapshot = getTransportSnapshot?.(sessionId) ?? null;
363
363
  connection.unknown = snapshot ? 0 : connection.unknown + 1;
364
364
  if (connection.unknown >= TRANSPORT_UNKNOWN_HEARTBEATS) {
365
+ // THE SECOND DETECTOR, and it is the one that has to work: `onClosed`
366
+ // does not always come — a peer connection can die without it — and
367
+ // presence is the connection and nothing else, so nothing else will
368
+ // ever say these people have gone. Until now this ended the watch and
369
+ // left them registered for the life of the process, holding the
370
+ // outputs they were watching.
371
+ connectionGone(sessionId, "the transport stopped answering");
365
372
  stop();
366
373
  return;
367
374
  }