@torrent-tv/proxy 2.83.8 → 2.85.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -64,20 +64,30 @@ export class Viewer {
64
64
  /** A soundtrack being prepared for the same reason. @type {string | null} */
65
65
  this.warmingAudioId = null;
66
66
  /**
67
- * Where they are.
67
+ * Where their picture stood when they last said so, and when they said it.
68
68
  *
69
- * Set when they arrive, from the position their own request named, and
70
- * moved by the segments they ask for and by the seeks they report. Never
71
- * null for a viewer this process has met: "we do not know where they are"
72
- * is not a state a viewer can be in, because a viewer arrives by asking for
69
+ * ONE FACT WITH ONE WRITER: the viewer. It is set when they arrive — from
70
+ * the position their own create request named — and moved only by what they
71
+ * state afterwards, a seek or a report carrying their playhead. Never null
72
+ * for a viewer this process has met, because a viewer arrives by asking for
73
73
  * a position.
74
74
  *
75
- * `seeked` holds a position they STATED, for as long as they stay there,
76
- * which is the distinction a cold open's soundtrack placement turns on: a
77
- * request is evidence about where their player is reading, a seek is the
78
- * viewer saying where they are.
75
+ * A REQUEST FOR A SEGMENT IS NOT A POSITION and never writes here. It says
76
+ * how far their buffer has reached, which is a different quantity and one
77
+ * nothing in the plan needs: a segment either exists and is served, or does
78
+ * not and is waited for.
79
79
  *
80
- * @type {{ segment: number, seconds: number, at: number, seeked?: number | null } | null}
80
+ * Two writers is what this replaced, and the cost is measured. Until
81
+ * 2026-09-13 a seek wrote `seeked` while a request wrote `seconds`, and
82
+ * `positionSeconds` preferred whichever was set — so the two took turns and
83
+ * the priority map jumped back and forth by one or two segments several
84
+ * times a second. Field that day, one output over one second:
85
+ * `p100:#9..#10`, `p100:#18..#20`, `p100:#16..#18`, `p100:#18..#20`, with
86
+ * the second viewer stopped dead at 1673.6 s throughout. The encoder
87
+ * placement followed the map, as it must, and the machine spent six minutes
88
+ * on 77 starts and 141 stops against one normal end.
89
+ *
90
+ * @type {{ seconds: number, at: number } | null}
81
91
  */
82
92
  this.position = null;
83
93
  /**
@@ -139,13 +149,35 @@ export class Viewer {
139
149
  * @type {Set<string>}
140
150
  */
141
151
  this.wantsCuesFor = new Set();
142
- // Whether the picture is moving. A viewer who has stopped it consumes
143
- // nothing, so nothing in front of them ever becomes due — they have no
144
- // deadline at all, and the work goes to whoever is watching. The page knows
145
- // this exactly and says it outright; inferring it from a position that has
146
- // not moved takes two reports and lies whenever a browser holding a full
147
- // cushion goes quiet between segments, which it does.
148
- this.playing = true;
152
+ // WHETHER THE PICTURE IS MOVING, and separately WHETHER THIS VIEWER IS
153
+ // BLOCKED ON US. Two booleans, because there are three states and one
154
+ // boolean cannot hold them:
155
+ //
156
+ // playing the picture advances, a second of film per second;
157
+ // waiting it does not advance because we have not delivered;
158
+ // neither the viewer stopped it themselves.
159
+ //
160
+ // Collapsed into one, the middle state read as the third: a viewer frozen
161
+ // for want of a segment counted as somebody who had chosen to stop, so
162
+ // nothing in front of them fell due and the work went to whoever was
163
+ // playing. That is backwards — a viewer waiting on us is the most urgent
164
+ // there is. The page tells the two apart exactly (its element says whether
165
+ // the picture advances, and the component owns the viewer's own pause) and
166
+ // states both.
167
+ //
168
+ // `playing` STARTS FALSE, and that is the whole of the fault of
169
+ // 2026-09-14. It used to start true, and the position is carried forward by
170
+ // the clock at that rate, so a viewer who had never said anything walked
171
+ // 146 seconds into a film they had not begun: the tab was hidden, the page
172
+ // deliberately held start-up until it was shown, and this end assumed the
173
+ // film was running the entire time. A statement about somebody else's
174
+ // machine is theirs to make. Until they make it the rate is zero.
175
+ //
176
+ // `waiting` STARTS TRUE, which is what a viewer arriving is: they have
177
+ // asked for a film and hold none of it. So their work is made first from
178
+ // the instant they arrive, without the position ever being invented.
179
+ this.playing = false;
180
+ this.waiting = true;
149
181
  // Whether the page carrying this viewer is ON SCREEN, and whether the
150
182
  // picture has been pulled out of it.
151
183
  //
@@ -161,7 +193,9 @@ export class Viewer {
161
193
  // before it could say otherwise.
162
194
  this.onScreen = true;
163
195
  this.inPictureInPicture = false;
164
- // Seconds of film held ahead of the picture, as the page last said.
196
+ // Seconds of film held ahead of the picture, as the page last said. It is
197
+ // measured at the moment the position beside it was, which is what makes it
198
+ // the bound on carrying that position forward — see `positionSeconds`.
165
199
  this.bufferedSeconds = null;
166
200
  }
167
201
 
@@ -181,7 +215,14 @@ export class Viewer {
181
215
  if (!Number.isFinite(seconds) || seconds < 0) {
182
216
  return;
183
217
  }
184
- this.position = { seconds, at: now, seeked: seconds };
218
+ this.position = { seconds, at: now };
219
+ // WHAT THEY HELD AT THE PLACE THEY LEFT SAYS NOTHING ABOUT THE PLACE THEY
220
+ // ARRIVED AT. A seek empties the buffer by construction — the player
221
+ // discards what it holds and fetches from the new position — so carrying
222
+ // the old cushion over would license the position to run forward again from
223
+ // material that no longer exists. Zero is the truthful floor until they say
224
+ // otherwise, and a viewer who has just seeked says so within a tick.
225
+ this.bufferedSeconds = 0;
185
226
  this.lastSeenAt = now;
186
227
  }
187
228
 
@@ -195,13 +236,18 @@ export class Viewer {
195
236
  * places, two of them on a session shared with other people.
196
237
  *
197
238
  * @param {object} report
198
- * @param {number} report.linkMbps
239
+ * @param {number} [report.linkMbps] - What their link last measured. Absent
240
+ * until something measurable has crossed it, and then the rest of the
241
+ * report still stands: this is a statement about a viewer, not about a link.
199
242
  * @param {number} report.bufferedAheadSec
200
243
  * @param {number | null} [report.positionSeconds] - Null from a page that
201
244
  * does not say; then the position stands as it was.
202
- * @param {boolean} [report.playing] - Absent from a page that does not say;
203
- * then the viewer counts as playing, which is what every page meant before
204
- * it could say otherwise.
245
+ * @param {boolean} [report.playing] - Whether the picture is advancing.
246
+ * Absent means it was not stated, and nothing may be assumed about somebody
247
+ * else's machine: the rate is then zero.
248
+ * @param {boolean} [report.waiting] - Whether this viewer is blocked on
249
+ * material we owe them. Absent from a page one release behind; then it is
250
+ * derived from the two facts that page does state.
205
251
  * @param {boolean} [report.onScreen] - Whether the page is visible, or the
206
252
  * picture is in picture-in-picture. Absent means on screen.
207
253
  * @param {boolean} [report.inPictureInPicture] - Whether the picture has been
@@ -214,87 +260,94 @@ export class Viewer {
214
260
  bufferedAheadSec,
215
261
  positionSeconds = null,
216
262
  playing,
263
+ waiting,
217
264
  onScreen,
218
265
  inPictureInPicture
219
266
  },
220
267
  now = Date.now()
221
268
  ) {
222
- this.netReport = {
223
- linkMbps,
224
- bufferedAheadSec,
225
- positionSeconds:
226
- Number.isFinite(positionSeconds) && positionSeconds >= 0 ? positionSeconds : null,
227
- at: now
228
- };
229
- this.bufferedSeconds = bufferedAheadSec;
230
- this.playing = playing === undefined ? true : Boolean(playing);
231
- this.inPictureInPicture = inPictureInPicture === undefined ? false : Boolean(inPictureInPicture);
232
- this.onScreen = onScreen === undefined ? true : Boolean(onScreen);
269
+ const held = Number.isFinite(bufferedAheadSec) && bufferedAheadSec > 0 ? bufferedAheadSec : 0;
270
+ // A LINK FIGURE IS ONE FIELD OF THIS REPORT AND NOT ITS TICKET. A page that
271
+ // has transferred nothing measurable has nothing to say about its link and
272
+ // everything to say about its viewer, and that is the cold open exactly —
273
+ // the moment the position matters most. Held as a precondition, in three
274
+ // places at once, it silenced every statement of the session of 2026-09-14.
275
+ if (Number.isFinite(linkMbps) && linkMbps > 0) {
276
+ this.netReport = {
277
+ linkMbps,
278
+ bufferedAheadSec: held,
279
+ positionSeconds:
280
+ Number.isFinite(positionSeconds) && positionSeconds >= 0 ? positionSeconds : null,
281
+ at: now
282
+ };
283
+ }
284
+ // The position first, then the cushion: `moveTo` clears what was held at
285
+ // wherever they were before, and this report's own figure is measured at
286
+ // the position this report states.
233
287
  if (Number.isFinite(positionSeconds) && positionSeconds >= 0) {
234
288
  this.moveTo(/** @type {number} */ (positionSeconds), now);
235
289
  }
290
+ this.bufferedSeconds = held;
291
+ this.playing = playing === true;
292
+ // A page that states `playing` and not `waiting` is one release behind this
293
+ // proxy, which is the ordinary state of a rolling pool — the proxy ships
294
+ // first by rule. It says `false` for both a viewer who stopped the picture
295
+ // and a viewer starved of material, so the two are told apart by the one
296
+ // quantity it does state: a stopped viewer holding film chose to stop, a
297
+ // stopped viewer holding nothing is waiting on us. It errs toward making
298
+ // material, which is the direction that costs an encoder rather than a
299
+ // viewer.
300
+ this.waiting = waiting === undefined ? this.playing === false && held === 0 : waiting === true;
301
+ this.inPictureInPicture = inPictureInPicture === undefined ? false : Boolean(inPictureInPicture);
302
+ this.onScreen = onScreen === undefined ? true : Boolean(onScreen);
236
303
  this.seen(now);
237
304
  }
238
305
 
239
306
  /**
240
- * When this viewer runs out of what they hold, in milliseconds.
241
- *
242
- * Film is consumed at one second per second while the picture moves, so the
243
- * moment they run dry is now plus what they hold. Stopped, they consume
244
- * nothing and there is no such moment — which is why a pause needs no rule of
245
- * its own anywhere: it falls out of this as an absent deadline.
246
- *
247
- * @param {number} [now]
248
- * @returns {number | null}
249
- */
250
- deadlineAt(now = Date.now()) {
251
- if (!this.playing) {
252
- return null;
253
- }
254
- const held = Number.isFinite(this.bufferedSeconds) ? Math.max(0, this.bufferedSeconds) : 0;
255
- return now + held * 1000;
256
- }
257
-
258
- /**
259
- * Whether this viewer is still watching.
307
+ * Whether this viewer is here.
260
308
  *
261
- * Two ways to stop being present, and neither of them is "asked for nothing
262
- * recently". Something must have SAID they are gone, or nothing at all must
263
- * have been heard from them for longer than any silence a watching viewer can
264
- * produce.
309
+ * PRESENCE IS THE CONNECTION, AND ONLY THE CONNECTION. They are here from the
310
+ * moment they are known until something SAYS they are gone: the browser
311
+ * releasing the session, or their connection closing. Nothing else, and in
312
+ * particular not silence.
265
313
  *
266
- * The second half exists only because the connection cannot always say: an
267
- * `onClosed` on a data channel does not always come, and a transport that is
268
- * not a data channel at all may have nothing to say. It is a backstop for a
269
- * missing statement, not the ordinary way a viewer leaves.
314
+ * Silence used to end it, after an interval longer than "any silence a
315
+ * watching viewer can produce" — and there is no such interval. A viewer who
316
+ * has paused, whose tab is hidden and whose timers the browser has throttled,
317
+ * or who holds two minutes of cushion, all say nothing for as long as they
318
+ * like and are all still watching. Deciding presence from that made it a
319
+ * guess with a threshold; deciding it from the connection makes it a fact
320
+ * with an owner.
270
321
  *
271
- * @param {number} now
272
- * @param {number} staleAfterMs - Longer than any silence a watching viewer
273
- * can produce. Derived from the cushion, never chosen here.
274
322
  * @returns {boolean}
275
323
  */
276
324
  /**
277
- * Whether this viewer is CONSUMING film.
325
+ * Whether this viewer wants film NOW — either watching it or waiting for it.
278
326
  *
279
- * Two ways of not consuming, and neither is absence: the picture is stopped,
280
- * or the page is not on screen. Both mean nothing in front of them ever falls
281
- * due, so the work goes to whoever is watching — and both leave them a place
282
- * in the priority map, because they are still there and will want it again.
327
+ * The one predicate every reading about urgency asks, and it is a union of
328
+ * two states rather than the negation of a pause:
283
329
  *
330
+ * playing consuming, and will run dry when their cushion does;
331
+ * waiting consuming nothing only because we have delivered nothing;
332
+ * neither they stopped the picture themselves.
333
+ *
334
+ * A viewer who has stopped it keeps their place in the priority map — they
335
+ * are still there and will want the film in front of them again — but nothing
336
+ * of theirs falls due, so the work goes to whoever is watching or waiting.
337
+ *
338
+ * A page that is not on screen is not consuming: its timers are throttled and
339
+ * it asks for nothing, which is indistinguishable from a full cushion.
284
340
  * Picture-in-picture is watching with the tab hidden, and the page folds that
285
- * into `onScreen` before it says it, so it needs no case of its own here.
341
+ * into `onScreen` before saying it, so it needs no case of its own here.
286
342
  *
287
343
  * @returns {boolean}
288
344
  */
289
- consumesFilm() {
290
- return this.playing !== false && this.onScreen !== false;
345
+ wantsFilmNow() {
346
+ return (this.playing === true || this.waiting === true) && this.onScreen !== false;
291
347
  }
292
348
 
293
- isPresent(now, staleAfterMs) {
294
- if (this.gone) {
295
- return false;
296
- }
297
- return now - this.lastSeenAt <= staleAfterMs;
349
+ isPresent() {
350
+ return this.gone !== true;
298
351
  }
299
352
 
300
353
  /**
@@ -308,16 +361,59 @@ export class Viewer {
308
361
  }
309
362
 
310
363
  /**
311
- * Where this viewer is, in seconds, or null when they have somehow never been
312
- * placed — which for a viewer created through `Viewers.of` cannot happen.
364
+ * Where this viewer is NOW, in seconds of film.
365
+ *
366
+ * A FUNCTION OF TIME, not a stored number. A viewer whose picture is moving
367
+ * covers a second of film every second, so between the moments they speak
368
+ * their position is known exactly — it is where they last were plus the time
369
+ * since. A viewer whose picture is stopped covers nothing and stays where
370
+ * they are, which is the same formula with the rate at zero.
313
371
  *
314
- * @returns {number | null}
372
+ * position(now) = stated + rate * min(now - statedAt, held),
373
+ * rate = playing ? 1 : 0
374
+ *
375
+ * **THE SECOND TERM IS BOUNDED BY WHAT THEY HELD**, and the bound is stated
376
+ * by the viewer rather than chosen here: nobody can play past the end of what
377
+ * they have. A viewer who said "I hold thirty seconds" and has said nothing
378
+ * for two minutes has played at most thirty of those seconds; the rest of the
379
+ * silence is a viewer who ran dry, or one we are no longer hearing from, and
380
+ * in both cases they are not where the clock alone would put them.
381
+ *
382
+ * Unbounded, it walks a viewer off the end of a film they never started. On
383
+ * 2026-09-14 the tab was hidden for 145 seconds while the page deliberately
384
+ * held start-up until it was shown, the rate was assumed to be one, and this
385
+ * end placed the viewer at 145.979 s — the exact `-ss` the soundtrack's
386
+ * encoder was given, while the browser was asking for segment #0 and getting
387
+ * 503 for sixty seconds. Their cushion was 0.0 s throughout, so this bound
388
+ * alone would have held them at zero whatever the rate said.
389
+ *
390
+ * The bound binds only where the clock has outrun the cushion — a report lost,
391
+ * or a viewer starving — and there it under-states the position. That is the
392
+ * safe direction and deliberately so: under-stating makes material a viewer
393
+ * has already passed, which costs an encoder; over-stating skips material
394
+ * they are about to need, which costs the viewer the picture.
395
+ *
396
+ * Held as a stored number instead, it was a staircase: flat for the ten
397
+ * seconds between reports and then a jump, so everything derived from it —
398
+ * the priority map, and through the map every encoder — changed in steps for
399
+ * a viewer who was moving smoothly. And because two writers were filling that
400
+ * number in turn, the steps went backwards as often as forwards.
401
+ *
402
+ * There is nothing to tune here and no state to keep: one reading, one clock,
403
+ * one rate the page itself states.
404
+ *
405
+ * @param {number} [now]
406
+ * @returns {number | null} Null only for a viewer never placed, which for one
407
+ * created through `Viewers.of` cannot happen.
315
408
  */
316
- positionSeconds() {
409
+ positionSeconds(now = Date.now()) {
317
410
  if (this.position === null) {
318
411
  return null;
319
412
  }
320
- return Number.isFinite(this.position.seeked) ? this.position.seeked : this.position.seconds;
413
+ const rate = this.playing === true ? 1 : 0;
414
+ const elapsedSec = Math.max(0, (now - this.position.at) / 1000);
415
+ const held = Number.isFinite(this.bufferedSeconds) ? Math.max(0, this.bufferedSeconds) : 0;
416
+ return this.position.seconds + rate * Math.min(elapsedSec, held);
321
417
  }
322
418
  }
323
419
 
@@ -1,48 +1,120 @@
1
- /**
2
- * @file Where the viewers of one output stand.
3
- *
4
- * Asked by whatever has to give disk back: what lies behind every viewer has
5
- * been played and will not be wanted again unless somebody seeks back, and what
6
- * lies ahead of the furthest will be wanted eventually. So an answer of "nobody"
7
- * is the strongest statement there is — everything that output holds is worth
8
- * less than anything anybody is on their way to.
9
- *
10
- * Pure: it is handed the sessions, the clock and a way to turn seconds into a
11
- * segment number, and it holds none of them.
12
- */
13
-
14
- import { viewersOf } from "./Viewer.js";
15
-
16
- /**
17
- * @param {object} params
18
- * @param {Iterable<{ outputKey?: string }>} params.sessions - Every live session.
19
- * @param {string} params.outputKey - The output being asked about.
20
- * @param {(session: object, seconds: number) => number} params.segmentAt - Which
21
- * segment of THAT session's timeline a moment of film falls in.
22
- * @param {number} params.now
23
- * @param {number} params.staleAfterMs - Silence longer than any a watching
24
- * viewer can produce.
25
- * @returns {number[]} A segment number per present viewer, unsorted.
26
- */
27
- export function viewerSegmentsOn({ sessions, outputKey, segmentAt, now, staleAfterMs }) {
28
- const at = [];
29
- for (const session of sessions) {
30
- if (session.outputKey !== outputKey) {
31
- continue;
32
- }
33
- for (const viewer of viewersOf(session).values()) {
34
- if (!viewer.isPresent(now, staleAfterMs)) {
35
- continue;
36
- }
37
- const seconds = viewer.positionSeconds();
38
- if (!Number.isFinite(seconds)) {
39
- continue;
40
- }
41
- const index = segmentAt(session, /** @type {number} */ (seconds));
42
- if (Number.isInteger(index) && index >= 0) {
43
- at.push(index);
44
- }
45
- }
46
- }
47
- return at;
48
- }
1
+ /**
2
+ * @file Where the viewers of one output stand.
3
+ *
4
+ * Asked by whatever has to give disk back: what lies behind every viewer has
5
+ * been played and will not be wanted again unless somebody seeks back, and what
6
+ * lies ahead of the furthest will be wanted eventually. So an answer of "nobody"
7
+ * is the strongest statement there is — everything that output holds is worth
8
+ * less than anything anybody is on their way to.
9
+ *
10
+ * Pure: it is handed the sessions, the clock and a way to turn seconds into a
11
+ * segment number, and it holds none of them.
12
+ */
13
+
14
+ import { viewersOf } from "./Viewer.js";
15
+
16
+ /**
17
+ * @param {object} params
18
+ * @param {Iterable<{ outputKey?: string }>} params.sessions - Every live session.
19
+ * @param {string} params.outputKey - The output being asked about.
20
+ * @param {(session: object, seconds: number) => number} params.segmentAt - Which
21
+ * segment of THAT session's timeline a moment of film falls in.
22
+ * @param {number} params.now
23
+ * @returns {number[]} A segment number per present viewer, unsorted.
24
+ */
25
+ export function viewerSegmentsOn({ sessions, outputKey, segmentAt, now }) {
26
+ const at = [];
27
+ for (const session of sessions) {
28
+ if (session.outputKey !== outputKey) {
29
+ continue;
30
+ }
31
+ for (const viewer of viewersOf(session).values()) {
32
+ if (!viewer.isPresent()) {
33
+ continue;
34
+ }
35
+ const seconds = viewer.positionSeconds(now);
36
+ if (!Number.isFinite(seconds)) {
37
+ continue;
38
+ }
39
+ const index = segmentAt(session, /** @type {number} */ (seconds));
40
+ if (Number.isInteger(index) && index >= 0) {
41
+ at.push(index);
42
+ }
43
+ }
44
+ }
45
+ return at;
46
+ }
47
+
48
+ /**
49
+ * Where a viewer of one output is, in seconds of film, NOW.
50
+ *
51
+ * One reading with one owner, and a function of time rather than a stored
52
+ * number: a viewer states where their picture is, and the clock carries it
53
+ * forward between statements.
54
+ *
55
+ * Three readings ranked by priority used to answer this — a seek, the start of
56
+ * the last segment requested, the position the output was opened at — with a
57
+ * second function saying which had answered. They were three different
58
+ * quantities wearing one name, two of them written at different rates, so the
59
+ * answer alternated between them several times a second and every encoder
60
+ * followed it.
61
+ *
62
+ * Without a name, the FURTHEST viewer: what lies behind them has already been
63
+ * made, so that is what a reading about no particular person wants. With
64
+ * nobody present at all, where the output was opened.
65
+ *
66
+ * @param {object} session
67
+ * @param {string} [consumerId] - Whose position.
68
+ * @param {number} [now]
69
+ * @returns {number} Seconds, never negative.
70
+ */
71
+ export function viewerSecondsOn(session, consumerId = "", now = Date.now()) {
72
+ const named = consumerId ? session?.viewers?.get(consumerId) ?? null : null;
73
+ if (named) {
74
+ return Math.max(0, named.positionSeconds(now) ?? 0);
75
+ }
76
+ let furthest = null;
77
+ for (const viewer of viewersOf(session).values()) {
78
+ if (!viewer.isPresent()) {
79
+ continue;
80
+ }
81
+ const seconds = viewer.positionSeconds(now);
82
+ if (Number.isFinite(seconds)) {
83
+ furthest = furthest === null ? seconds : Math.max(furthest, seconds);
84
+ }
85
+ }
86
+ const opened = Number(session?.progress?.startPositionSeconds);
87
+ return Math.max(0, furthest ?? (Number.isFinite(opened) ? opened : 0));
88
+ }
89
+
90
+ /**
91
+ * The earliest film anybody present is standing on, across several outputs.
92
+ *
93
+ * WHAT IT IS FOR: a soundtrack is created at a position, and a track begun
94
+ * where the leader stands has nothing to give the viewer behind them. So the
95
+ * earliest, and across every rung of the picture, because two viewers of one
96
+ * film may be on different ones.
97
+ *
98
+ * This is a fact about PEOPLE and it decides one parameter of an output. It
99
+ * does not place encoders: which encoder works where is the priority map's
100
+ * answer and nothing here is consulted for it.
101
+ *
102
+ * @param {Iterable<object>} sessions
103
+ * @param {number} [now]
104
+ * @returns {number | null} Seconds, or null when nobody is present.
105
+ */
106
+ export function earliestViewerSecondsOn(sessions, now = Date.now()) {
107
+ let earliest = null;
108
+ for (const session of sessions ?? []) {
109
+ for (const viewer of viewersOf(session).values()) {
110
+ if (!viewer.isPresent()) {
111
+ continue;
112
+ }
113
+ const seconds = viewer.positionSeconds(now);
114
+ if (Number.isFinite(seconds)) {
115
+ earliest = earliest === null ? seconds : Math.min(earliest, seconds);
116
+ }
117
+ }
118
+ }
119
+ return earliest;
120
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * @file A viewer's statement about itself, taken in.
3
+ *
4
+ * The VIEWER layer's own entry point, and the whole of what happens when a page
5
+ * says where it is: the statement reaches the person it is about, and readings
6
+ * that have gone stale stop deciding for the people still here.
7
+ *
8
+ * It lived in the session manager, which is a description of an OUTPUT, and the
9
+ * two questions were tangled there: whether this is a measurement of a LINK,
10
+ * and whether this is a statement by a VIEWER. Required to be both, a page that
11
+ * had measured nothing could say nothing — see `Viewer.report`.
12
+ *
13
+ * Given the session and the registry as plain arguments, so nothing here knows
14
+ * how a session is found, what a variant is, or that there is an encoder.
15
+ */
16
+
17
+ import { viewersOf } from "./Viewer.js";
18
+
19
+ /**
20
+ * Take one report.
21
+ *
22
+ * @param {object} params
23
+ * @param {{ of: (session: object, consumerId: string) => import("./Viewer.js").Viewer }} params.viewers
24
+ * @param {object} params.session - The output this viewer has on screen.
25
+ * @param {string} params.consumerId - Who is reporting; empty when the page
26
+ * does not name itself, and then every such page shares one viewer, which is
27
+ * exactly what one unnamed browser is.
28
+ * @param {object} params.report - What they said about themselves.
29
+ * @param {number} params.now
30
+ * @param {number} params.linkFreshMs - How long a link reading describes the
31
+ * link it was measured on.
32
+ * @returns {true}
33
+ */
34
+ export function takeViewerReport({ viewers, session, consumerId, report, now, linkFreshMs }) {
35
+ viewers.of(session, consumerId).report(report, now);
36
+ forgetStaleLinkReadings({ session, now, linkFreshMs });
37
+ return true;
38
+ }
39
+
40
+ /**
41
+ * Drop link readings older than the time they describe.
42
+ *
43
+ * ONLY THE READING EXPIRES. Whether the person is still watching is a different
44
+ * question with its own answer — their connection — and a viewer who has simply
45
+ * stopped reporting is not thereby gone. Answering both from this one place is
46
+ * what stopped a soundtrack's encoder on 2026-09-05, seconds after it started
47
+ * and before it had made the `init.mp4` the picture could not be played
48
+ * without.
49
+ *
50
+ * @param {object} params
51
+ * @param {object} params.session
52
+ * @param {number} params.now
53
+ * @param {number} params.linkFreshMs
54
+ * @returns {void}
55
+ */
56
+ export function forgetStaleLinkReadings({ session, now, linkFreshMs }) {
57
+ for (const viewer of viewersOf(session).values()) {
58
+ const reading = viewer.netReport;
59
+ if (reading !== null && now - reading.at > linkFreshMs) {
60
+ viewer.netReport = null;
61
+ }
62
+ }
63
+ }
@@ -17,6 +17,7 @@ import os from "node:os";
17
17
  import path from "node:path";
18
18
  import { HlsSessionManager } from "../services/hls-session-manager.js";
19
19
  import { fmp4Format } from "../services/segment-formats/fmp4.js";
20
+ import { viewerOf } from "../services/viewer/Viewer.js";
20
21
 
21
22
  const SESSION_ID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
22
23
  const SEGMENT_SECONDS = 4;
@@ -32,7 +33,7 @@ async function managerWithSession() {
32
33
  localBindHost: "127.0.0.1",
33
34
  localPort: 9090
34
35
  });
35
- manager.sessionsById.set(SESSION_ID, {
36
+ const session = {
36
37
  id: SESSION_ID,
37
38
  dirPath,
38
39
  // Where this file is cut, held by the file. A fixture that stated it
@@ -44,10 +45,12 @@ async function managerWithSession() {
44
45
  state: "ready",
45
46
  segmentFormat: fmp4Format,
46
47
  segmentCount: 200,
47
- useSyntheticPlaylist: true,
48
- // The viewer is at segment #25.
49
- furthestViewerSeconds: 100
50
- });
48
+ useSyntheticPlaylist: true
49
+ };
50
+ manager.sessionsById.set(SESSION_ID, session);
51
+ // The viewer is at segment #25, and says so themselves.
52
+ viewerOf(session, "").moveTo(25 * SEGMENT_SECONDS);
53
+ viewerOf(session, "").playing = false;
51
54
  return { manager, dirPath };
52
55
  }
53
56