@torrent-tv/proxy 2.80.12 → 2.80.14

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,22 @@
1
+ ## 2.80.14
2
+
3
+ - **Fix**: What the master playlist declares a variant carries is MEASURED. It was `height * height * 3.2` — for 1080 exactly the 3 732 480 the field declared for a file carrying 18.4 Mbit/s, five times more — and the comment justifying it said no measurement existed before encoding starts. Both do: the average is the file's own length over its duration, exact at session creation, and the peak is the biggest piece produced over the span it covers, equal to the average until one exists. A re-encoded height is declared at the cap we impose; a smaller height at the pixel share of the source's rate. `BANDWIDTH` and `AVERAGE-BANDWIDTH` are both emitted, as the specification names them. This is not cosmetic: the browser sizes its cushion in BYTES from `BANDWIDTH`, so 120 s asked bought 56 MB — 26 s of that film — and the deepest it ever held was 17.1 s. One expression, no case analysis: absent measurements and absent caps are written as the identity of the operation that consumes them (`services/output/rates.js`, eight checks).
4
+ - **Fix**: How much faster than realtime a step must run is computed from the share of the reading's time that was lost, over whole cycles. The model is unchanged and was always right; the two quantities fed into it were not — the WORST single interruption over the MEDIAN gap between interruptions, a maximum over a median, from populations that need not be the same events. Field 2026-09-08: 0.79 s over 0.01 s gave **158.60x** on a file already downloaded whole, and the quality budget refused every step against it forty times in one session. The same measurements as a share of time lost give 1.00x. The field case the old formula was written for is still caught: 3.50x refuses the 1.5 step that ran at 1.05x and stalled.
5
+ - **Fix**: Only what a read is stopped on reaches MEMORY. `AHEAD` used to, and it is exactly the speculative lead the swarm is told about: the priority map states one claimant per zone, so a film with seven zones arrived at the piece store as five holders — one `NEAR` and four `AHEAD` — on a session with two reads. Field: `5 reader(s) want 24 piece(s) of 25 the store may hold`, so every admission had to evict a piece somebody had declared and 100 of 1395 did, beside 6565 spills and 7575 revivals in 44 minutes at a median 0.0 s on disk. Raising the allowance does not touch it — a lead stated over the rest of the film grows to fill whatever it is given.
6
+ - **New**: The piece store NAMES its holders instead of counting them. Five read as five encoders on a session that had two, and choosing between narrowing the windows and raising the allowance was guesswork without the names.
7
+ - **New**: Whether the page is on screen, and whether the picture was pulled out of it into picture-in-picture, travel with every viewer report and live on the viewer beside where it stands. A hidden tab has its timers throttled — 800 ms of event-loop lag measured — so it asks for nothing and looked exactly like a viewer holding a full cushion; delivery stood still for the last six minutes of the field session and nothing said the tab had gone. Two facts and not one, because picture-in-picture is watching WITH the tab hidden. A viewer who is not on screen consumes nothing, which is what a paused one already meant: no deadline, and their place in the priority map kept.
8
+ - **Chore**: `hls-session-manager.js` is 5 lines shorter: the shape of the film the master declares — which heights splice, how big the source is, how it is packaged, what it carries — moved to `LiveOutputs.masterFactsOf`, which is the layer that holds it.
9
+ - **Chore**: Both architecture documents carry the new arithmetic and how to read it.
10
+
11
+ ## 2.80.13
12
+
13
+ - **New**: Whether the priority map is being served IN ITS OWN ORDER is measured, for both things that read it. Until now the map's ranks were built, published, and then thrown away by everyone who acted on them — a map read backwards would have looked identical in every line this proxy writes. The measure is one wait recorded against the rank the map gave the thing waited for at the moment it was asked for, on one scale for both layers (`services/priority/WaitLedger.js`, `SegmentDemand.rankOf`, eight checks). The encoding's own state line carries `served[now N wait(s) median Xms worst Yms, soon …, later …]` and each `[hold]` line names the band it belonged to; a band that never waited prints `none` rather than a zero, because silence is not a good result. Read: long waits at `now` mean the urgent zone is not being served first, long waits lower down with none at `now` mean the zones are the wrong width.
14
+ - **New**: What the swarm is ACTUALLY TOLD is said out loud, on change, per level: `the swarm is told, for "film.mkv": NEAR 1 zone(s) 42MB, AHEAD 4 zone(s) 310MB, TAIL 1 zone(s) 1900MB (7 band(s) of the map, over 1218s of film)`. `applyPriorityMap` was silent — that the map had been built was visible in the encoding's line, that the download had received it was visible nowhere, and a whole field session carried not one line about it. Per level rather than per zone, because the register has five levels against the map's however-many bands and the fit between them is the one thing there that can be wrong.
15
+ - **Fix**: A read that ends SHORTER than the `Content-Length` already sent says so, loudly. The header is committed before the first byte and the reader's `close()` ends the iteration with no accounting, so only the `fail()` path was ever logged: a client could receive a truncated body with no way to know it, and ffmpeg's mp4 demuxer — which holds the sample table and asks for samples past what arrived — then parses at wrong offsets. That is exactly the `Invalid NAL unit size (negative)` and `missing picture in access unit` seen 2138 times in one field session on a COPIED picture, where no encoder touches a frame. A single clean read of the same file through the same route produced none, and four concurrent ones produced none; what the field session also had was a piece store whose readers wanted every piece it could hold and 100 of 1395 evictions taking a piece a reader had declared.
16
+ - **Fix**: The reads that feed ffmpeg are failed when the torrent thread dies. Both death handlers walked the ordinary reads and left `#fragmentReaders` untouched — which is what `/stream` uses for an encoder — so an encoder's input neither ended nor errored, it went quiet. Field 2026-08-31, three times: the thread died, both ffmpeg runs stayed alive and stopped producing (167 `holding segment-00085.mp4 … encoder alive` lines), and the viewer was left on a spinner that never ended. Recorded as open since then.
17
+ - **Chore**: `hls-session-manager.js` is 2 lines shorter: the zone walk it did by hand is one call to the map that owns ranks, and the explanation of what an empty map means moved to `SegmentDemand.rankOf`, which is the thing it is about.
18
+ - **Chore**: Both architecture documents gain the measurement and how to read it.
19
+
1
20
  ## 2.80.12
2
21
 
3
22
  - **Chore**: The predicate that decides whether an output is the one somebody is consuming asks each layer only for what it owns. Written first as `LiveOutputs.watchedBy(session, viewer)`, it had the output layer reading a viewer's fields — a coupling in the wrong direction, however plain the values. It is `supersededBy(session, stepOnScreen)` now, taking a plain id: which step is on somebody's screen belongs to the person, which output a step supersedes belongs to the film's shape, and the one place that legitimately holds both puts them together.
@@ -72,6 +72,71 @@ wanted, and two thirds of reads came back from disk.
72
72
  **The background fill** states one file at a time — a soundtrack pulled whole
73
73
  once the cushion is full, and no further.
74
74
 
75
+ ## Whether the map reaches the swarm, and in what shape
76
+
77
+ Applied in silence until 2026-09-08: that the map had been BUILT was visible in
78
+ the encoding's own line, that the download had received it was visible nowhere,
79
+ and a whole field session carried not one line about it. Said now on change, per
80
+ LEVEL rather than per zone — the register has five levels and the map has as many
81
+ bands as the film needs, and the fit between the two is the one thing here that
82
+ could be wrong:
83
+
84
+ ```
85
+ torrent-pool: the swarm is told, for "film.mkv": NEAR 1 zone(s) 42MB,
86
+ AHEAD 4 zone(s) 310MB, TAIL 1 zone(s) 1900MB (7 band(s) of the map, over 1218s of film)
87
+ ```
88
+
89
+ In megabytes, because that is what a swarm delivers. `NEAR` absent while
90
+ somebody is watching means the map is not arriving; every band landing in one
91
+ level means the fit has collapsed.
92
+
93
+ Beside it, where the waits fell: the reader records each wait against the level
94
+ the map put its piece in, and the `supply` line carries the table. Long waits at
95
+ `blocked` mean the urgent zone is too narrow; long waits further out mean the
96
+ lead is.
97
+
98
+ ## What reaches MEMORY is not what reaches the swarm
99
+
100
+ `BLOCKED` and `NEAR` only. Memory holds what will be READ soon; the swarm is
101
+ told what will be DOWNLOADED soon, and the map states the second over the whole
102
+ rest of the film.
103
+
104
+ `AHEAD` used to reach memory too, and it is exactly the speculative lead. The
105
+ map states one claimant per zone, so a film with seven zones arrived at the piece
106
+ store as five separate holders — one `NEAR` and four `AHEAD` — each covering tens
107
+ of megabytes. Field 2026-09-08: `5 reader(s) want 24 piece(s) of 25 the store may
108
+ hold (widest window 17)`, on a session with exactly two reads. The union of what
109
+ was declared equalled the whole capacity, so every admission had to evict a piece
110
+ somebody had declared, and 100 of 1395 evictions did; beside that, 6565 spills
111
+ and 7575 revivals in 44 minutes with a median 0.0 s on disk.
112
+
113
+ Raising the allowance does not touch it: a lead stated over the rest of the film
114
+ grows to fill whatever memory it is given, and the ratio is unchanged.
115
+
116
+ The store's own line now NAMES its holders (`[priority-map:22:0 read:…]`) rather
117
+ than counting them, because the count read as five encoders on a session that
118
+ had two, and choosing between "narrow the windows" and "raise the allowance" was
119
+ guesswork without the names.
120
+
121
+ ## How much faster than realtime a step must run
122
+
123
+ `1 / (1 - the share of the reading's time that was lost to waiting)`, over whole
124
+ cycles: from the first interruption's start to the last one's, which holds
125
+ exactly one running stretch per interruption in it.
126
+
127
+ The model is unchanged and was always right — if a fraction `f` of the time
128
+ delivers nothing, producing one second of film takes `1/(1 - f)` seconds. What
129
+ was wrong were the two quantities fed into it: the WORST single interruption
130
+ divided by the MEDIAN gap between interruptions, a maximum over a median, from
131
+ populations that need not be the same events. It asks what would happen if the
132
+ worst interruption recurred at the typical rate — a compound case that never
133
+ occurs — and it divides by a gap that goes to zero whenever interruptions arrive
134
+ in a burst.
135
+
136
+ Field 2026-09-08: 0.79 s over 0.01 s gave **158.60x** on a file already
137
+ downloaded whole, and the quality budget refused every step against it forty
138
+ times in one session. The same measurements as a share of time lost give 1.00x.
139
+
75
140
  ## The five levels
76
141
 
77
142
  | level | what it is | stated |
@@ -136,6 +136,58 @@ one-segment backoff are gone — a second debounce on a signal the browser had
136
136
  already debounced, and every millisecond of it was dead time in front of the
137
137
  viewer.
138
138
 
139
+ ## Whether the map is being served in its own order
140
+
141
+ The zones say what matters most. What the viewer actually waited for is measured
142
+ where a viewer measurably waits — the one place in the proxy that holds a
143
+ request for a named segment — and recorded against the rank the map gave that
144
+ segment **at the moment it was asked for**. It reaches the `encode:` state line
145
+ as `served[...]`:
146
+
147
+ ```
148
+ served[now 42 wait(s) median 180ms worst 1900ms, soon 6 wait(s) median 90ms worst 240ms, later none]
149
+ ```
150
+
151
+ Read it like this:
152
+
153
+ | what it says | what is wrong |
154
+ |---|---|
155
+ | long waits at `now` | the urgent zone is not being served first — a fault in whoever acts on the map |
156
+ | long waits at `soon`/`later`, none at `now` | the zones are the wrong width: the urgent one too narrow, so the viewer reaches material only ranked "soon" |
157
+ | `now none` while the viewer is watching | nothing was ever urgent — the map is not reaching this output |
158
+ | a band reading `none` | silence, not a zero, and it is printed as `none` so it cannot be read as "no waits, all good" |
159
+
160
+ Ranks are collapsed into three bands because the map's own scale is as long as
161
+ the film needs — a hundred ranks on a long file — and a hundred-row table says
162
+ nothing a reader can hold. The width of `soon` is a tenth of the top rank, which
163
+ is the map's own shape (its zones widen geometrically) rather than a threshold
164
+ chosen for the table.
165
+
166
+ The download half is measured the same way and on the same scale, so the two are
167
+ comparable: `download-architecture.md`.
168
+
169
+ ## What the master playlist declares, and why it is not cosmetic
170
+
171
+ `BANDWIDTH` and `AVERAGE-BANDWIDTH` per variant, both measured
172
+ (`services/output/rates.js`):
173
+
174
+ - the average is the file's own length over its duration, exact and known when
175
+ the session is created;
176
+ - the peak is the biggest piece produced over the span it covers, and equals the
177
+ average until a piece exists — a ratio invented meanwhile would be the
178
+ fabrication this replaced;
179
+ - a re-encoded height is declared at the cap we impose, which is exact;
180
+ - a smaller height at the pixel share of the source's rate, which errs high, and
181
+ high is the safe direction.
182
+
183
+ It used to be `height * height * 3.2`. **The browser sizes its cushion in BYTES
184
+ from `BANDWIDTH`**, so a figure five times low makes the cushion five times
185
+ shallow: field 2026-09-08, 3.73 Mbit/s declared for a file carrying 18.4, 120 s
186
+ asked bought 56 MB — 26 s of that film — and the deepest the browser ever held
187
+ was 17.1 s. Inflating it is not the answer either: hls.js compares it against
188
+ its own estimate of the link to decide a level is unplayable, and its recovery
189
+ then moves level by itself, which does not honour our pinning.
190
+
139
191
  ## What is checked
140
192
 
141
193
  `test/one-authority.test.js` holds the shape: one caller of `#startEncodeRun`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.80.12",
3
+ "version": "2.80.14",
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,53 +1,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
- const linkMbps = Number(body.linkMbps);
27
- const bufferedAheadSec = Number(body.bufferedAheadSec);
28
- if (!sessionId || !Number.isFinite(linkMbps) || linkMbps <= 0 || !Number.isFinite(bufferedAheadSec) || bufferedAheadSec < 0) {
29
- return reply.code(400).send({ error: "linkMbps (>0) and bufferedAheadSec (>=0) are required." });
30
- }
31
-
32
- // Neither is required, and neither can make a report invalid: they are what
33
- // the proxy uses to tell the viewers of one session apart, and a report
34
- // without them is still a truthful reading of somebody's link.
35
- const consumerId = typeof body.consumerId === "string" ? body.consumerId.trim() : "";
36
- // Whether the picture is moving. Absent from a page that does not say, and
37
- // then the viewer counts as playing, which is what every page meant before it
38
- // could say otherwise.
39
- const playing = typeof body.playing === "boolean" ? body.playing : undefined;
40
- const positionSeconds = Number(body.positionSeconds);
41
- const recorded = hlsSessionManager.recordNetReport(sessionId, {
42
- linkMbps,
43
- bufferedAheadSec,
44
- consumerId,
45
- playing,
46
- positionSeconds:
47
- Number.isFinite(positionSeconds) && positionSeconds >= 0 ? positionSeconds : undefined
48
- });
49
- if (!recorded) {
50
- return reply.code(404).send({ error: "Transcode session was not found." });
51
- }
52
- return reply.code(204).send();
53
- }
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
+ const linkMbps = Number(body.linkMbps);
27
+ const bufferedAheadSec = Number(body.bufferedAheadSec);
28
+ if (!sessionId || !Number.isFinite(linkMbps) || linkMbps <= 0 || !Number.isFinite(bufferedAheadSec) || bufferedAheadSec < 0) {
29
+ return reply.code(400).send({ error: "linkMbps (>0) and bufferedAheadSec (>=0) are required." });
30
+ }
31
+
32
+ // Neither is required, and neither can make a report invalid: they are what
33
+ // the proxy uses to tell the viewers of one session apart, and a report
34
+ // without them is still a truthful reading of somebody's link.
35
+ const consumerId = typeof body.consumerId === "string" ? body.consumerId.trim() : "";
36
+ // Whether the picture is moving. Absent from a page that does not say, and
37
+ // then the viewer counts as playing, which is what every page meant before it
38
+ // could say otherwise.
39
+ const playing = typeof body.playing === "boolean" ? body.playing : undefined;
40
+ // Whether the page is on screen, and whether the picture was pulled out of
41
+ // it. A hidden tab has its timers throttled, so it asks for nothing and looks
42
+ // exactly like a viewer holding a full cushion; picture-in-picture is the case
43
+ // that makes the distinction necessary, because there the tab is hidden and
44
+ // the viewer is watching. Absent from a page that does not say, and then the
45
+ // viewer is on screen, as every page meant before it could say otherwise.
46
+ const onScreen = typeof body.onScreen === "boolean" ? body.onScreen : undefined;
47
+ const inPictureInPicture =
48
+ typeof body.inPictureInPicture === "boolean" ? body.inPictureInPicture : undefined;
49
+ const positionSeconds = Number(body.positionSeconds);
50
+ const recorded = hlsSessionManager.recordNetReport(sessionId, {
51
+ linkMbps,
52
+ bufferedAheadSec,
53
+ consumerId,
54
+ playing,
55
+ onScreen,
56
+ inPictureInPicture,
57
+ positionSeconds:
58
+ Number.isFinite(positionSeconds) && positionSeconds >= 0 ? positionSeconds : undefined
59
+ });
60
+ if (!recorded) {
61
+ return reply.code(404).send({ error: "Transcode session was not found." });
62
+ }
63
+ return reply.code(204).send();
64
+ }