@torrent-tv/proxy 2.80.11 → 2.80.12

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,8 @@
1
+ ## 2.80.12
2
+
3
+ - **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.
4
+ - **New**: `docs/encode-architecture.md` — who decides where encoders go, with the shape drawn: viewers become a map and the map is the whole crossing, the four things the plan decides from, why the map is read at two scopes, and a table of what each of the eight other places used to do against what it now states. `docs/download-architecture.md` gains the priority map as a claimant, which it has been since 2026-09-05 and was never drawn as, and a section saying what each claimant knows — a read states only the piece it is stopped on, which is a fact and not a forecast.
5
+
1
6
  ## 2.80.11
2
7
 
3
8
  - **Fix**: ONE authority over where encoders go, and it is the plan. Eight other places placed or killed them, each by a rule of its own: the first run of a session, a viewer joining it further in, a quality step or a soundtrack being warmed, a step being switched to, a hardware encoder falling back to software, an input coming back, the cut table correcting itself, and a settled seek. The plan is arithmetic over what is made, what is being made and what is wanted; beside eight other answers it was one opinion among nine. Every one of them now states the FACT it knows — this person is here, this output is nobody's, this encoder's arguments are stale — and the plan places what follows. `this.#startEncodeRun` has exactly one caller.
package/CLAUDE.md CHANGED
@@ -72,6 +72,12 @@ Linux-only host (e.g. POSIX-only signals must degrade elsewhere).
72
72
  wanted (`demand/`) against what the swarm is told (`download/`), why urgency
73
73
  is not a number given to the library, and why the speculative levels are
74
74
  withdrawn rather than lowered.
75
+ - `docs/encode-architecture.md` — who decides where encoders go, and the
76
+ answer is one authority: `EncodePlan`, from what is made, what is being
77
+ made, what is wanted and what the host can hold. Why no viewer reaches it,
78
+ why the priority map is read at two scopes (per FILE for the swarm, per
79
+ OUTPUT for the encoders), and what each of the eight other places that used
80
+ to place or kill an encoder now states instead.
75
81
  - `docs/logs.md` — where to find logs (HA `docker logs` + `/data/proxy.log`, DO
76
82
  `infra-server-1` with forwarded frontend `POST /api/client-logs`). Browser
77
83
  console not needed.
@@ -37,10 +37,12 @@ flowchart TB
37
37
  end
38
38
 
39
39
  subgraph Claimants["who states needs"]
40
- PR[piece-reader<br/>four bands per read]
40
+ PM[priority map<br/>where the viewers are, per FILE]
41
+ PR[piece-reader<br/>the piece it is stopped on]
41
42
  BF[torrent-pool<br/>background fill, per file]
42
43
  end
43
44
 
45
+ PM -->|state / withdraw| R
44
46
  PR -->|state / withdraw| R
45
47
  BF -->|state / withdraw| R
46
48
  W --> R
@@ -51,6 +53,25 @@ flowchart TB
51
53
  S -->|the only caller| WT[(WebTorrent)]
52
54
  ```
53
55
 
56
+ ## Who the claimants are, and what each of them knows
57
+
58
+ **The priority map** states what should be downloaded AHEAD of the viewers, once
59
+ per file, built in `services/priority/` from where they are and from nothing
60
+ else. It is the same map the encoding reads — the encoding reads it per OUTPUT
61
+ and in segment numbers, this layer reads it per FILE and in bytes, and both
62
+ scopes are right for what asks them (`encode-architecture.md`).
63
+
64
+ **A read** states only the piece it is STOPPED ON. That is not a forecast but the
65
+ fact that somebody is waiting there, and it is what keeps working for the reads
66
+ the viewers' map does not cover: a container header at open, the subtitle walk, a
67
+ soundtrack being fetched whole. Until 2026-09-02 each read built four bands
68
+ around its own head instead, so fifteen reads were fifteen forecasts on a store
69
+ holding sixteen pieces, half of all evictions took a piece a reader had said it
70
+ wanted, and two thirds of reads came back from disk.
71
+
72
+ **The background fill** states one file at a time — a soundtrack pulled whole
73
+ once the cushion is full, and no further.
74
+
54
75
  ## The five levels
55
76
 
56
77
  | level | what it is | stated |
@@ -0,0 +1,150 @@
1
+ # Encode architecture — who decides where encoders go
2
+
3
+ One question, one answer, and the answer is arithmetic.
4
+
5
+ > Viewers are always independent and always reuse what can be reused. The
6
+ > number of encoders is however many are needed; how many are needed follows
7
+ > from which sets of output parameters are wanted and where the viewers stand
8
+ > inside each. Segments produced by ANY encoder are available to ANY viewer, and
9
+ > which viewer asked never enters the question.
10
+
11
+ ## The one authority
12
+
13
+ `services/encode/EncodePlan.js` decides. Nothing else places an encoder,
14
+ nothing else takes one away for scheduling reasons, and there is exactly one
15
+ call to `#startEncodeRun` in the whole proxy — the one the plan asks through.
16
+
17
+ It decides from four things and no others:
18
+
19
+ | what | where it comes from |
20
+ |---|---|
21
+ | what is already made | the segment store, asked afresh before every decision |
22
+ | what is being made | the coverage map's live claims |
23
+ | what is wanted | the priority map of that output |
24
+ | how many the machine can hold | `run-budget.js`, from measurements of this host |
25
+
26
+ **No viewer reaches it.** `services/encode/` and
27
+ `services/orchestrators/EncodeOrchestrator.js` do not import the viewer layer,
28
+ name a consumer id, or hold a person. What crosses is a priority map: zones of
29
+ segment numbers with a rank and a real time, and nobody's name on it.
30
+
31
+ ## Viewers become a map, and that is the whole crossing
32
+
33
+ ```mermaid
34
+ flowchart TB
35
+ subgraph V["services/viewer — where people are"]
36
+ VW[Viewer<br/>position, playing, buffered, chosen track]
37
+ VS[Viewers<br/>the relation, indexed from both ends]
38
+ end
39
+
40
+ subgraph P["services/priority — one fact, two scopes"]
41
+ PM[PriorityMap<br/>seconds of film to a rank]
42
+ PO[PriorityOrchestrator<br/>merge, publish, keep]
43
+ end
44
+
45
+ subgraph E["services/encode — where encoders go"]
46
+ CM[CoverageMap<br/>ready / making / free]
47
+ EP[EncodePlan<br/>argmin over arrangements]
48
+ RB[run-budget<br/>what this host can hold]
49
+ ER[EncodeRun<br/>one process, one interval]
50
+ end
51
+
52
+ subgraph D["services/demand + download — what the swarm is told"]
53
+ DR[DemandRegister]
54
+ SS[SwarmSelection]
55
+ end
56
+
57
+ VW --> VS
58
+ VS -->|"positions, presence"| PO
59
+ PM --> PO
60
+ PO -->|"per FILE, in bytes"| DR
61
+ DR --> SS
62
+ PO -->|"per OUTPUT, in segments"| EO[EncodeOrchestrator]
63
+ EO --> EP
64
+ CM --> EP
65
+ RB --> EP
66
+ EP -->|"start / stop / move"| ER
67
+ ST[(SegmentStore<br/>the disk)] -->|"what is ready"| CM
68
+ ```
69
+
70
+ ## Two scopes of one map, and both are right
71
+
72
+ The map is built from where the viewers are, once, and read at two scopes.
73
+
74
+ **Per FILE, for the swarm.** The picture, a quality step and a soundtrack of one
75
+ film read the same bytes, so every viewer of any of them wants that file's
76
+ bytes. This is what `PriorityOrchestrator.mapFor(sourceKey, fileIndex)`
77
+ answers, and what is published to the download layer.
78
+
79
+ **Per OUTPUT, for the encoders.** A person watching 480p wants nothing of the
80
+ 1080p output at all. This is `mapForOutput(address)`.
81
+
82
+ One map for both was the second authority over encoders. Handed the film's map,
83
+ the plan wanted an encoder on every output of the film; what actually stopped
84
+ the ones nobody was watching was the session manager killing them by its own
85
+ judgement — and since a viewer moving between steps announces itself, the plan
86
+ started them again on the very next pass. Two parties answering "should this
87
+ encoder exist" by different rules, several times a second.
88
+
89
+ An output nobody is on gets a map with **nothing in it**, which is a statement
90
+ and not an absence: the walk writes one for every output a session exists for,
91
+ including the ones everybody has left. That is how the plan is told to stop what
92
+ is on it.
93
+
94
+ ## Which output a person is consuming
95
+
96
+ A person holds a record on more outputs than they are consuming. The picture is
97
+ where their record lives — the browser addresses it, their chosen soundtrack is
98
+ written on it, their position is read from it — so they are never let go of it;
99
+ but the moment they step down to 480p, the 1080p output is producing for nobody.
100
+
101
+ That distinction is answered where the two facts meet, and neither layer is
102
+ handed the other:
103
+
104
+ - **which step is on their screen** is a fact about a PERSON, read off the
105
+ viewer as one field;
106
+ - **which output a step supersedes** is a fact about the FILM'S SHAPE, answered
107
+ by `LiveOutputs.supersededBy(session, stepOnScreen)`, which takes a plain id
108
+ and has never seen a viewer.
109
+
110
+ A step, a soundtrack, and a step being warmed are consumed by whoever is
111
+ registered on them — everywhere but the picture, a person who stops watching is
112
+ let go of, so being known to an output is consuming it. Through a warm-up both
113
+ the step on screen and the step being made ready are genuinely produced, which
114
+ is the price of the switch not being visible.
115
+
116
+ ## What each of the eight other places used to do
117
+
118
+ Before 2026-09-08 the plan was one opinion among nine. Each of these placed or
119
+ killed encoders by a rule of its own; each now states the fact it knows.
120
+
121
+ | it knows | it used to do | it now says |
122
+ |---|---|---|
123
+ | a session was created | start a run at the viewer's position, worked out again | the viewer is placed; the plan reads that |
124
+ | a viewer joined further in | start a run there if nothing was being made | as above |
125
+ | a step or soundtrack is being warmed | point that session at the switch and start it | this person is at N seconds on it |
126
+ | a step was switched to | stop the one left, point the new one | this person is on this step now |
127
+ | a step or track was abandoned | stop its encoder | this person has left that output |
128
+ | the hardware encoder failed | start a run at the dead one's start | what this host encodes with has changed |
129
+ | the input came back | start a run at the last requested segment | decide again |
130
+ | the cut table was corrected | restart the members at the measured instant | this run is producing in the wrong place; the instant is in the file's table |
131
+ | the bitrate cap changed | restart at where the encoder had got to | this run's arguments are stale |
132
+
133
+ A **seek** is not in that list because it was already reduced to one thing: it
134
+ puts the viewer where they are. The settle timer behind it, its cooldown and its
135
+ one-segment backoff are gone — a second debounce on a signal the browser had
136
+ already debounced, and every millisecond of it was dead time in front of the
137
+ viewer.
138
+
139
+ ## What is checked
140
+
141
+ `test/one-authority.test.js` holds the shape: one caller of `#startEncodeRun`,
142
+ no encoder stopped for being unwatched, the settle machinery absent, each output
143
+ reading its own map, and the soundtrack's start instant read off the table
144
+ rather than handed in.
145
+
146
+ `test/priority-map-per-output.test.js` holds the two scopes, over the real
147
+ viewer registry, the real `LiveOutputs` and the real `PriorityOrchestrator`.
148
+
149
+ `test/encode-plan.test.js` holds the arithmetic, including that every encoder
150
+ stops when nobody is watching the output.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.80.11",
3
+ "version": "2.80.12",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -1563,7 +1563,11 @@ export class HlsSessionManager {
1563
1563
  ).catch(() => {});
1564
1564
  },
1565
1565
  viewersOf: (session) => viewersOf(session),
1566
- watchedBy: (session, viewer) => this.liveOutputs.watchedBy(session, viewer),
1566
+ // WHERE THE TWO FACTS MEET, and this is the only place that holds both.
1567
+ // Which step is on somebody's screen belongs to the person; which output
1568
+ // a step supersedes belongs to the film's shape. Neither layer is handed
1569
+ // the other — one gets a plain id, the other is read for one field.
1570
+ watchedBy: (session, viewer) => !this.liveOutputs.supersededBy(session, viewer.activeVariantId ?? null),
1567
1571
  allowanceFor: (session) => minimumBufferFrom({
1568
1572
  segmentSeconds: this.segmentDurationSec,
1569
1573
  worstSupplyWaitSec: session.supplyFigures?.worstWaitSec
@@ -1,282 +1,267 @@
1
- /**
2
- * @file Which outputs of one file exist right now, and what each of them is.
3
- *
4
- * Every question here is answered by walking the live sessions and looking at
5
- * what they ARE — the same file, a step, a soundtrack, this height — and not by
6
- * following a list of ids anybody keeps. A list of ids is a link between
7
- * sessions: it ties their lifetimes together, it goes stale when one of them is
8
- * disposed, and it has to be cleaned from the other side. What an output is is
9
- * enough to find it, which is the rule `OutputSpec` exists for.
10
- *
11
- * Nothing here writes anything except the two answers a session memoizes about
12
- * itself, and nothing here knows about encoders, viewers, the torrent or the
13
- * disk. It is the layer the quality budget and the serving path both stand on,
14
- * and it is separated first for that reason.
15
- */
16
-
17
- import { variantHeightsFor } from "./ladder.js";
18
-
19
- export class LiveOutputs {
20
- /**
21
- * @param {object} params
22
- * @param {Map<string, object>} params.sessionsById - The live sessions. Read,
23
- * never written.
24
- */
25
- constructor({ sessionsById }) {
26
- this.sessionsById = sessionsById;
27
- }
28
-
29
- /**
30
- * Every live session producing ONE output.
31
- *
32
- * The output is the address the encoding layer works in: two sessions whose
33
- * output parameters agree ARE the same output, so what one of them measured
34
- * about the machine is true of the other.
35
- *
36
- * @param {string} address
37
- * @returns {object[]}
38
- */
39
- sessionsOn(address) {
40
- const found = [];
41
- for (const session of this.sessionsById.values()) {
42
- if (session.outputKey === address && session.state !== "disposed") {
43
- found.push(session);
44
- }
45
- }
46
- return found;
47
- }
48
-
49
- /**
50
- * Every live session of one file: the picture, its quality steps, and the
51
- * soundtracks published separately.
52
- *
53
- * The file is what they share, so the file is what this asks about. Where the
54
- * sums this feeds are concerned that is also the right question: two pictures
55
- * of one file are two encoders on one machine whether or not anybody thinks
56
- * of them as one film.
57
- *
58
- * @param {object} session
59
- * @returns {object[]}
60
- */
61
- familyOf(session) {
62
- const family = [session];
63
- const key = session?.file?.key;
64
- for (const other of this.sessionsById.values()) {
65
- if (other === session || other.state === "disposed" || other.file?.key !== key) {
66
- continue;
67
- }
68
- family.push(other);
69
- }
70
- return family;
71
- }
72
-
73
- /**
74
- * The soundtracks published separately for this picture, live ones only.
75
- *
76
- * @param {object} base
77
- * @returns {object[]}
78
- */
79
- renditionsOf(base) {
80
- return this.familyOf(base).filter((session) => session !== base && session.audioOnly === true);
81
- }
82
-
83
- /**
84
- * The quality steps of this picture, live ones only.
85
- *
86
- * A step is a session made as one — `isStep`, written where it is created —
87
- * and not merely "another session of this file that carries a picture". The
88
- * difference is the second picture: one file can hold two — a browser that
89
- * understands rendition groups and one that needs the sound muxed in produce
90
- * two — and calling one of them a step of the other would let a switch away
91
- * from a step stop the encoder of somebody else's picture.
92
- *
93
- * @param {object} base
94
- * @returns {object[]}
95
- */
96
- stepsOf(base) {
97
- return this.familyOf(base).filter((session) => session !== base && session.isStep === true);
98
- }
99
-
100
- /**
101
- * The picture a step belongs to, or the session itself when it is not a step.
102
- *
103
- * How a session came to be is a fact about it, not a link to another session
104
- * — so a step whose picture has gone answers for itself rather than following
105
- * a dead reference, and nothing has to be cleaned from the other side when
106
- * one of them ends.
107
- *
108
- * One file can carry two pictures at once, and then this returns whichever
109
- * was made first. Everything asked of the answer is a fact of the FILE and of
110
- * this host: what heights can be offered, what a step costs, when the budget
111
- * last acted. Two pictures of one file answer all of those alike.
112
- *
113
- * @param {object} session
114
- * @returns {object}
115
- */
116
- pictureOf(session) {
117
- if (!session || session.isStep !== true) {
118
- return session;
119
- }
120
- for (const other of this.familyOf(session)) {
121
- if (other.isStep !== true && other.audioOnly !== true) {
122
- return other;
123
- }
124
- }
125
- return session;
126
- }
127
-
128
- /**
129
- * Whether this output is the one that person is CONSUMING.
130
- *
131
- * A person holds a record on more outputs than they are watching, and the two
132
- * are different facts. The picture is where their record lives the browser
133
- * addresses it, their chosen soundtrack is written on it, their position is
134
- * read from it — so they never stop being known to it; but the moment they
135
- * step down to 480p, the 1080p output is producing for nobody.
136
- *
137
- * Which is why this exists and why it is here. It is asked when the priority
138
- * map of one output is built, and it decides whether an encoder on that output
139
- * is wanted at all. Without it every output of a film was handed the film's
140
- * whole map: the plan wanted an encoder on each, the session manager killed
141
- * the ones it judged unwatched, and the viewer's own move announced itself and
142
- * started them again the two authorities of 2026-09-08.
143
- *
144
- * The viewer arrives as PLAIN FIELDS. This layer knows the shape of a film a
145
- * picture, its steps, its soundtracks and the viewer layer knows where a
146
- * person stands; neither holds the other.
147
- *
148
- * STATED AS A REFUSAL, and deliberately only where the refusal is certain.
149
- * Being known to an output is watching it everywhere except one case, because
150
- * everywhere else a person who stops watching is let go of: a soundtrack
151
- * nobody chose, a step nobody is on. The exception is the picture itself,
152
- * which they are never let go of — and which therefore had no way at all of
153
- * knowing it was producing for nobody.
154
- *
155
- * So a step, a soundtrack, a step being warmed, a step whose init has just
156
- * been asked for: all watched, as before. The picture: watched unless this
157
- * person is on a step of it.
158
- *
159
- * @param {object} session
160
- * @param {{ activeVariantId?: string | null, warmingVariantId?: string | null }} viewer
161
- * @returns {boolean}
162
- */
163
- watchedBy(session, viewer) {
164
- if (!session || !viewer) {
165
- return false;
166
- }
167
- if (session.isStep === true || session.audioOnly === true) {
168
- return true;
169
- }
170
- // The picture. A step of it on their screen is a statement that they are
171
- // not looking at this; a step merely being made ready for them is not, and
172
- // during a warm-up both are genuinely being produced.
173
- const step = viewer.activeVariantId ?? null;
174
- return step === null || step === session.id;
175
- }
176
-
177
- /**
178
- * Which variant a session IS, as a height. Zero encode height means "keep the
179
- * source", so the source's own height is the answer.
180
- *
181
- * Settled once and then kept, because it is a NAME — the player addresses the
182
- * variant by it for the whole session, having fetched the master exactly
183
- * once. The height a session encodes at is not stable: the realtime budget
184
- * steps it down when the host cannot keep up. Deriving the name afresh each
185
- * time would mean a downshift silently renames the variant the viewer is
186
- * watching, and the next segment request under the old name would build a
187
- * SECOND session at the height the host had just proved it could not manage.
188
- * A downshift changes the picture inside the variant instead, which is what
189
- * it has always done.
190
- *
191
- * @param {object} session
192
- * @returns {number}
193
- */
194
- variantHeightOf(session) {
195
- if (Number.isInteger(session.variantHeight) && session.variantHeight > 0) {
196
- return session.variantHeight;
197
- }
198
- const encodeHeight = Number(session.output.encodeHeight) || 0;
199
- session.variantHeight = encodeHeight > 0
200
- ? encodeHeight
201
- : Math.round(Number(session.file.height) || 0);
202
- return session.variantHeight;
203
- }
204
-
205
- /**
206
- * The height a session's encoder is actually producing, or 0 when it produces
207
- * no encoded picture of its own (a copy, or a soundtrack).
208
- *
209
- * A COPY must never be adopted: it costs no encoder at all, so handing it to
210
- * a request for a re-encoded rung would give away the one thing this host can
211
- * always serve.
212
- *
213
- * @param {object} session
214
- * @returns {number}
215
- */
216
- producedHeightOf(session) {
217
- if (!session || session.transcodeVideo !== true || session.audioOnly === true) {
218
- return 0;
219
- }
220
- return Math.round(Number(session.output.encodeHeight) || 0);
221
- }
222
-
223
- /**
224
- * The heights this file's variants CAN be spliced at — a fact about the
225
- * source and the cut grid, settled once and never moved.
226
- *
227
- * Separate from which of them are worth OFFERING to the viewer right now, on
228
- * a machine whose load moves every five seconds. Both were the same list
229
- * until 2026-08-18, and that is what broke playback outright: the browser is
230
- * told at session creation that a master playlist exists, and 192 ms later —
231
- * after the session's own encoder had started and the first supply reading
232
- * had arrived — the live list had fallen from five rungs to one,
233
- * `buildMasterPlaylist` returned null for having fewer than two, and the
234
- * master answered 404 to the very session that had just published it. hls.js
235
- * treats that as fatal and unrecoverable, so nothing played at all (session
236
- * `4ef731d8`, "Moana (2016).mkv", 17:43:01).
237
- *
238
- * A live figure may decide what to offer. It may not decide whether a
239
- * published document exists.
240
- *
241
- * @param {object} session
242
- * @returns {number[]} Largest first.
243
- */
244
- splicableHeights(session) {
245
- const owner = this.pictureOf(session);
246
- if (Array.isArray(owner.splicableHeights)) {
247
- return owner.splicableHeights;
248
- }
249
- const heights = new Set(variantHeightsFor(Number(owner.file.height) || 0));
250
- const own = this.variantHeightOf(owner);
251
- if (own > 0) {
252
- heights.add(own);
253
- }
254
- owner.splicableHeights = [...heights].sort((left, right) => right - left);
255
- return owner.splicableHeights;
256
- }
257
-
258
- /**
259
- * Whether this stream publishes a master playlist at all that is, whether
260
- * there is anything for a player to move BETWEEN.
261
- *
262
- * Asked in one place because two callers depend on the same answer and used
263
- * to compute it differently: the builder refused a copied stream whose cut
264
- * grid is a fiction, while the budget looked only at how many heights could
265
- * in principle be spliced. A copy with no readable keyframe index therefore
266
- * had requests recorded against it — asking a player with no variants to
267
- * change variant, once every window, for the whole film.
268
- *
269
- * @param {object} session
270
- * @returns {boolean}
271
- */
272
- publishesVariants(session) {
273
- const owner = this.pictureOf(session);
274
- // A copy can only be cut where the source already has a keyframe, so a rung
275
- // meant to splice into it has to be cut at exactly those times. A copy that
276
- // fell back to an even grid ffmpeg does not cut on has nothing to align to.
277
- if (!owner.transcodeVideo && owner.timeline.cutGrid !== "keyframe") {
278
- return false;
279
- }
280
- return this.splicableHeights(owner).length >= 2;
281
- }
282
- }
1
+ /**
2
+ * @file Which outputs of one file exist right now, and what each of them is.
3
+ *
4
+ * Every question here is answered by walking the live sessions and looking at
5
+ * what they ARE — the same file, a step, a soundtrack, this height — and not by
6
+ * following a list of ids anybody keeps. A list of ids is a link between
7
+ * sessions: it ties their lifetimes together, it goes stale when one of them is
8
+ * disposed, and it has to be cleaned from the other side. What an output is is
9
+ * enough to find it, which is the rule `OutputSpec` exists for.
10
+ *
11
+ * Nothing here writes anything except the two answers a session memoizes about
12
+ * itself, and nothing here knows about encoders, viewers, the torrent or the
13
+ * disk. It is the layer the quality budget and the serving path both stand on,
14
+ * and it is separated first for that reason.
15
+ */
16
+
17
+ import { variantHeightsFor } from "./ladder.js";
18
+
19
+ export class LiveOutputs {
20
+ /**
21
+ * @param {object} params
22
+ * @param {Map<string, object>} params.sessionsById - The live sessions. Read,
23
+ * never written.
24
+ */
25
+ constructor({ sessionsById }) {
26
+ this.sessionsById = sessionsById;
27
+ }
28
+
29
+ /**
30
+ * Every live session producing ONE output.
31
+ *
32
+ * The output is the address the encoding layer works in: two sessions whose
33
+ * output parameters agree ARE the same output, so what one of them measured
34
+ * about the machine is true of the other.
35
+ *
36
+ * @param {string} address
37
+ * @returns {object[]}
38
+ */
39
+ sessionsOn(address) {
40
+ const found = [];
41
+ for (const session of this.sessionsById.values()) {
42
+ if (session.outputKey === address && session.state !== "disposed") {
43
+ found.push(session);
44
+ }
45
+ }
46
+ return found;
47
+ }
48
+
49
+ /**
50
+ * Every live session of one file: the picture, its quality steps, and the
51
+ * soundtracks published separately.
52
+ *
53
+ * The file is what they share, so the file is what this asks about. Where the
54
+ * sums this feeds are concerned that is also the right question: two pictures
55
+ * of one file are two encoders on one machine whether or not anybody thinks
56
+ * of them as one film.
57
+ *
58
+ * @param {object} session
59
+ * @returns {object[]}
60
+ */
61
+ familyOf(session) {
62
+ const family = [session];
63
+ const key = session?.file?.key;
64
+ for (const other of this.sessionsById.values()) {
65
+ if (other === session || other.state === "disposed" || other.file?.key !== key) {
66
+ continue;
67
+ }
68
+ family.push(other);
69
+ }
70
+ return family;
71
+ }
72
+
73
+ /**
74
+ * The soundtracks published separately for this picture, live ones only.
75
+ *
76
+ * @param {object} base
77
+ * @returns {object[]}
78
+ */
79
+ renditionsOf(base) {
80
+ return this.familyOf(base).filter((session) => session !== base && session.audioOnly === true);
81
+ }
82
+
83
+ /**
84
+ * The quality steps of this picture, live ones only.
85
+ *
86
+ * A step is a session made as one — `isStep`, written where it is created —
87
+ * and not merely "another session of this file that carries a picture". The
88
+ * difference is the second picture: one file can hold two — a browser that
89
+ * understands rendition groups and one that needs the sound muxed in produce
90
+ * two — and calling one of them a step of the other would let a switch away
91
+ * from a step stop the encoder of somebody else's picture.
92
+ *
93
+ * @param {object} base
94
+ * @returns {object[]}
95
+ */
96
+ stepsOf(base) {
97
+ return this.familyOf(base).filter((session) => session !== base && session.isStep === true);
98
+ }
99
+
100
+ /**
101
+ * The picture a step belongs to, or the session itself when it is not a step.
102
+ *
103
+ * How a session came to be is a fact about it, not a link to another session
104
+ * — so a step whose picture has gone answers for itself rather than following
105
+ * a dead reference, and nothing has to be cleaned from the other side when
106
+ * one of them ends.
107
+ *
108
+ * One file can carry two pictures at once, and then this returns whichever
109
+ * was made first. Everything asked of the answer is a fact of the FILE and of
110
+ * this host: what heights can be offered, what a step costs, when the budget
111
+ * last acted. Two pictures of one file answer all of those alike.
112
+ *
113
+ * @param {object} session
114
+ * @returns {object}
115
+ */
116
+ pictureOf(session) {
117
+ if (!session || session.isStep !== true) {
118
+ return session;
119
+ }
120
+ for (const other of this.familyOf(session)) {
121
+ if (other.isStep !== true && other.audioOnly !== true) {
122
+ return other;
123
+ }
124
+ }
125
+ return session;
126
+ }
127
+
128
+ /**
129
+ * Whether this output has been SUPERSEDED by the step named here.
130
+ *
131
+ * A question about the shape of a film and nothing else: one picture, its
132
+ * steps, its soundtracks. A step supersedes only the picture it is a step of
133
+ * never another step, never a soundtrack, which are produced or not
134
+ * according to whether anybody asked for them.
135
+ *
136
+ * The step arrives as a plain id. Which step is on somebody's screen is a fact
137
+ * about a PERSON, and this layer neither holds a person nor knows what one
138
+ * looks like; whoever asks holds both and states the id.
139
+ *
140
+ * It is asked while the priority map of one output is built, and it is what
141
+ * makes the difference between a picture somebody is watching and a picture
142
+ * they stepped off. Without it every output of a film was handed the film's
143
+ * whole map, the plan wanted an encoder on each, the session manager killed
144
+ * the ones it judged unwatched, and the viewer's own move started them again
145
+ * — the two authorities of 2026-09-08.
146
+ *
147
+ * @param {object} session
148
+ * @param {string | null} [stepOnScreen] - The step being watched, or null for
149
+ * "the height the film was opened at", which supersedes nothing.
150
+ * @returns {boolean}
151
+ */
152
+ supersededBy(session, stepOnScreen = null) {
153
+ if (!session || session.isStep === true || session.audioOnly === true) {
154
+ return false;
155
+ }
156
+ // The picture. A step of it on screen says outright that this is not what
157
+ // is being looked at; a step merely being made READY supersedes nothing,
158
+ // because through a warm-up both are genuinely being produced.
159
+ return typeof stepOnScreen === "string" && stepOnScreen !== session.id;
160
+ }
161
+
162
+ /**
163
+ * Which variant a session IS, as a height. Zero encode height means "keep the
164
+ * source", so the source's own height is the answer.
165
+ *
166
+ * Settled once and then kept, because it is a NAME — the player addresses the
167
+ * variant by it for the whole session, having fetched the master exactly
168
+ * once. The height a session encodes at is not stable: the realtime budget
169
+ * steps it down when the host cannot keep up. Deriving the name afresh each
170
+ * time would mean a downshift silently renames the variant the viewer is
171
+ * watching, and the next segment request under the old name would build a
172
+ * SECOND session at the height the host had just proved it could not manage.
173
+ * A downshift changes the picture inside the variant instead, which is what
174
+ * it has always done.
175
+ *
176
+ * @param {object} session
177
+ * @returns {number}
178
+ */
179
+ variantHeightOf(session) {
180
+ if (Number.isInteger(session.variantHeight) && session.variantHeight > 0) {
181
+ return session.variantHeight;
182
+ }
183
+ const encodeHeight = Number(session.output.encodeHeight) || 0;
184
+ session.variantHeight = encodeHeight > 0
185
+ ? encodeHeight
186
+ : Math.round(Number(session.file.height) || 0);
187
+ return session.variantHeight;
188
+ }
189
+
190
+ /**
191
+ * The height a session's encoder is actually producing, or 0 when it produces
192
+ * no encoded picture of its own (a copy, or a soundtrack).
193
+ *
194
+ * A COPY must never be adopted: it costs no encoder at all, so handing it to
195
+ * a request for a re-encoded rung would give away the one thing this host can
196
+ * always serve.
197
+ *
198
+ * @param {object} session
199
+ * @returns {number}
200
+ */
201
+ producedHeightOf(session) {
202
+ if (!session || session.transcodeVideo !== true || session.audioOnly === true) {
203
+ return 0;
204
+ }
205
+ return Math.round(Number(session.output.encodeHeight) || 0);
206
+ }
207
+
208
+ /**
209
+ * The heights this file's variants CAN be spliced at a fact about the
210
+ * source and the cut grid, settled once and never moved.
211
+ *
212
+ * Separate from which of them are worth OFFERING to the viewer right now, on
213
+ * a machine whose load moves every five seconds. Both were the same list
214
+ * until 2026-08-18, and that is what broke playback outright: the browser is
215
+ * told at session creation that a master playlist exists, and 192 ms later —
216
+ * after the session's own encoder had started and the first supply reading
217
+ * had arrived the live list had fallen from five rungs to one,
218
+ * `buildMasterPlaylist` returned null for having fewer than two, and the
219
+ * master answered 404 to the very session that had just published it. hls.js
220
+ * treats that as fatal and unrecoverable, so nothing played at all (session
221
+ * `4ef731d8`, "Moana (2016).mkv", 17:43:01).
222
+ *
223
+ * A live figure may decide what to offer. It may not decide whether a
224
+ * published document exists.
225
+ *
226
+ * @param {object} session
227
+ * @returns {number[]} Largest first.
228
+ */
229
+ splicableHeights(session) {
230
+ const owner = this.pictureOf(session);
231
+ if (Array.isArray(owner.splicableHeights)) {
232
+ return owner.splicableHeights;
233
+ }
234
+ const heights = new Set(variantHeightsFor(Number(owner.file.height) || 0));
235
+ const own = this.variantHeightOf(owner);
236
+ if (own > 0) {
237
+ heights.add(own);
238
+ }
239
+ owner.splicableHeights = [...heights].sort((left, right) => right - left);
240
+ return owner.splicableHeights;
241
+ }
242
+
243
+ /**
244
+ * Whether this stream publishes a master playlist at all — that is, whether
245
+ * there is anything for a player to move BETWEEN.
246
+ *
247
+ * Asked in one place because two callers depend on the same answer and used
248
+ * to compute it differently: the builder refused a copied stream whose cut
249
+ * grid is a fiction, while the budget looked only at how many heights could
250
+ * in principle be spliced. A copy with no readable keyframe index therefore
251
+ * had requests recorded against it — asking a player with no variants to
252
+ * change variant, once every window, for the whole film.
253
+ *
254
+ * @param {object} session
255
+ * @returns {boolean}
256
+ */
257
+ publishesVariants(session) {
258
+ const owner = this.pictureOf(session);
259
+ // A copy can only be cut where the source already has a keyframe, so a rung
260
+ // meant to splice into it has to be cut at exactly those times. A copy that
261
+ // fell back to an even grid ffmpeg does not cut on has nothing to align to.
262
+ if (!owner.transcodeVideo && owner.timeline.cutGrid !== "keyframe") {
263
+ return false;
264
+ }
265
+ return this.splicableHeights(owner).length >= 2;
266
+ }
267
+ }
@@ -66,7 +66,7 @@ function over(sessions) {
66
66
  publish: () => {},
67
67
  viewersOf: (session) => viewersOf(session),
68
68
  allowanceFor: () => 10,
69
- watchedBy: (session, viewer) => live.watchedBy(session, viewer)
69
+ watchedBy: (session, viewer) => !live.supersededBy(session, viewer.activeVariantId ?? null)
70
70
  });
71
71
  return {
72
72
  priority,
@@ -162,7 +162,7 @@ test("a viewer nothing has been heard from is not watching anything", () => {
162
162
  publish: () => {},
163
163
  viewersOf: (session) => viewersOf(session),
164
164
  allowanceFor: () => 10,
165
- watchedBy: (session, viewer) => live.watchedBy(session, viewer)
165
+ watchedBy: (session, viewer) => !live.supersededBy(session, viewer.activeVariantId ?? null)
166
166
  });
167
167
  const person = viewers.of(picture, "p");
168
168
  person.moveTo(300);
@@ -201,11 +201,11 @@ test("the picture is watched by a person who never moved off it", () => {
201
201
  const { viewers, live } = over([picture]);
202
202
  const person = viewers.of(picture, "p");
203
203
 
204
- assert.equal(live.watchedBy(picture, person), true, "no step is active");
204
+ assert.equal(live.supersededBy(picture, null), false, "no step is active");
205
205
  person.activeVariantId = "pic";
206
206
  assert.equal(
207
- live.watchedBy(picture, person),
208
- true,
207
+ live.supersededBy(picture, person.activeVariantId),
208
+ false,
209
209
  "and naming the picture itself as the step is the same statement"
210
210
  );
211
211
  });
@@ -302,13 +302,13 @@ test("a segment request hands the encoder to the variant the viewer moved to", a
302
302
  // handed the whole film's priority map — started it again on the very next
303
303
  // pass, which this viewer's own move had just triggered.
304
304
  assert.equal(
305
- manager.liveOutputs.watchedBy(base, viewerOf(base, "")),
306
- false,
305
+ manager.liveOutputs.supersededBy(base, viewerOf(base, "").activeVariantId),
306
+ true,
307
307
  "the picture they stepped off is nobody's now"
308
308
  );
309
309
  assert.equal(
310
- manager.liveOutputs.watchedBy(variant, viewerOf(variant, "")),
311
- true,
310
+ manager.liveOutputs.supersededBy(variant, viewerOf(variant, "").activeVariantId),
311
+ false,
312
312
  "and the rung they moved to is theirs"
313
313
  );
314
314
  assert.deepEqual(encoder.signals, [], "stopping it is the plan's, from that fact, and not this path's");