@torrent-tv/proxy 2.85.1 → 2.86.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,9 @@
1
+ ## 2.86.0
2
+
3
+ - **Fix**: A LINK READING NO LONGER EXPIRES, and the thirty seconds that made it expire were a chosen number holding two facts at once: how fast the link is, and whether we are still hearing from the page. The speed persists — a link does not stop being what it was measured to be because nobody measured it for a minute, which is why the page keeps its own last figure rather than reporting nothing. It is measured once when the connection comes up and corrected by every transfer after that: one estimate, continuously refined, never a fact with a deadline. Whether a reading is still somebody's is PRESENCE, which has its own owner — a viewer who has gone is removed from the output, so the readings weighed for a decision are only those of people still there.
4
+ - **Chore**: The worst reading across the viewers of one output moved to the viewer's own layer (`services/viewer/link-readings.js`), and skips anybody not present. With the expiry gone, `#linkCouldCarry` no longer takes a clock it cannot use.
5
+ - **Chore**: Where a soundtrack must begin moved out too (`services/viewer/audio-start.js`) — it is a question about where the earliest PERSON stands across every rung of a picture, decided one parameter of an output, and had no business being answered inside the session manager. The manager is 199 lines shorter than before this work for both moves, and every line it gained is an import or a call outward.
6
+
1
7
  ## 2.85.1
2
8
 
3
9
  - **Chore**: NOTHING IS ADDED TO THE SESSION MANAGER, and 2.85.0 added a delegation and an import to it. Both are gone, and what they were there for went with them: taking in a viewer's report is the VIEWER layer's own operation now (`services/viewer/report-intake.js`), the route calls it directly with the live sessions and the registry of viewers, and the manager has no method for it at all. Which output a viewer has on screen moved out with it (`services/viewer/active-output.js`) — it is a question about a person, not about an encoder, and eight call sites now ask it outward.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.85.1",
3
+ "version": "2.86.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": {
@@ -89,7 +89,9 @@ import {
89
89
  export { ffmpegSeconds, onKeyframeGridFor, seekLandingOffsetFor, segmentCutTimesFrom };
90
90
  import { viewersOf } from "./viewer/Viewer.js";
91
91
  import { activeOutputFor } from "./viewer/active-output.js";
92
- import { earliestViewerSecondsOn, viewerSecondsOn, viewerSegmentsOn } from "./viewer/positions.js";
92
+ import { audioStartSecondsFor } from "./viewer/audio-start.js";
93
+ import { worstLinkReading } from "./viewer/link-readings.js";
94
+ import { viewerSecondsOn, viewerSegmentsOn } from "./viewer/positions.js";
93
95
  import { Viewers } from "./viewer/Viewers.js";
94
96
  import { LiveOutputs } from "./output/LiveOutputs.js";
95
97
  import { variantHeightsFor } from "./output/ladder.js";
@@ -3086,44 +3088,6 @@ export class HlsSessionManager {
3086
3088
  this.viewers.of(session, consumerId).seen();
3087
3089
  }
3088
3090
 
3089
- /**
3090
- * The worst of what the viewers of this session report, as one reading.
3091
- *
3092
- * The budget asks one question — is anybody failing to keep up — so both
3093
- * terms are the worst case: the slowest link and the emptiest buffer, which
3094
- * may belong to different people. That is deliberate. Taking the last report
3095
- * instead meant a session with two viewers acted on whichever of them
3096
- * happened to report most recently.
3097
- *
3098
- * @param {HlsSession} session
3099
- * @param {number} now
3100
- * @returns {{ linkMbps: number, bufferedAheadSec: number, at: number, viewers: number } | null}
3101
- * Null when nothing fresh measures the link, which is the silence every
3102
- * caller already treats as "no opinion".
3103
- */
3104
- #worstNetReport(session, now) {
3105
- let worst = null;
3106
- for (const viewer of viewersOf(session).values()) {
3107
- const report = viewer.linkReading(now);
3108
- if (report === null) {
3109
- continue;
3110
- }
3111
- if (worst === null) {
3112
- worst = {
3113
- linkMbps: report.linkMbps,
3114
- bufferedAheadSec: report.bufferedAheadSec,
3115
- at: report.at,
3116
- viewers: 1
3117
- };
3118
- continue;
3119
- }
3120
- worst.linkMbps = Math.min(worst.linkMbps, report.linkMbps);
3121
- worst.bufferedAheadSec = Math.min(worst.bufferedAheadSec, report.bufferedAheadSec);
3122
- worst.at = Math.max(worst.at, report.at);
3123
- worst.viewers += 1;
3124
- }
3125
- return worst;
3126
- }
3127
3091
 
3128
3092
  /**
3129
3093
  * Answer the player's report that a delivered fragment sits far from the edge
@@ -3243,9 +3207,9 @@ export class HlsSessionManager {
3243
3207
  * @returns {Promise<boolean>}
3244
3208
  */
3245
3209
  async #checkLinkBudget(session, now) {
3246
- const report = this.#worstNetReport(session, now);
3210
+ const report = worstLinkReading(session);
3247
3211
  if (!report) {
3248
- session.linkSlowSince = 0; // no fresh data — old clients / stopped reporter
3212
+ session.linkSlowSince = 0; // nobody present has measured their link
3249
3213
  return false;
3250
3214
  }
3251
3215
  if (report.bufferedAheadSec >= LINK_LOW_BUFFER_SEC) {
@@ -4689,7 +4653,7 @@ export class HlsSessionManager {
4689
4653
  // top offered height has no next rung at all, and answering "nothing to
4690
4654
  // step to, so yes" is how a cap came off a link measured at a fifth of
4691
4655
  // what the picture needs.
4692
- if (!this.#linkCouldCarry(session, this.#peakMbpsForHeight(this.liveOutputs.pictureOf(session), current), now)) {
4656
+ if (!this.#linkCouldCarry(session, this.#peakMbpsForHeight(this.liveOutputs.pictureOf(session), current))) {
4693
4657
  return;
4694
4658
  }
4695
4659
  session.budgetUpSince = 0;
@@ -4733,10 +4697,10 @@ export class HlsSessionManager {
4733
4697
  if (session.linkSlowSince !== 0 || session.budgetSlowSince !== 0) {
4734
4698
  return false;
4735
4699
  }
4736
- const report = this.#worstNetReport(session, now);
4700
+ const report = worstLinkReading(session);
4737
4701
  if (!report) {
4738
- // Nothing fresh measures the link, so it has no opinion either way — the
4739
- // same silence that stops #checkLinkBudget from acting.
4702
+ // Nothing measures the link, so it has no opinion either way — the same
4703
+ // silence that stops #checkLinkBudget from acting.
4740
4704
  return true;
4741
4705
  }
4742
4706
  const base = this.liveOutputs.pictureOf(session);
@@ -4744,24 +4708,23 @@ export class HlsSessionManager {
4744
4708
  if (next === undefined) {
4745
4709
  return true; // nothing to step to; only the cap decision is left
4746
4710
  }
4747
- return this.#linkCouldCarry(session, this.#peakMbpsForHeight(base, next), now);
4711
+ return this.#linkCouldCarry(session, this.#peakMbpsForHeight(base, next));
4748
4712
  }
4749
4713
 
4750
4714
  /**
4751
4715
  * Whether the viewer's measured link can carry a given number of Mbit/s.
4752
4716
  *
4753
- * A link nobody has measured recently has no opinion either way — the same
4717
+ * A link nobody has ever measured has no opinion either way — the same
4754
4718
  * silence that stops `#checkLinkBudget` from acting — so it answers yes.
4755
4719
  *
4756
4720
  * @param {HlsSession} session
4757
4721
  * @param {number} wantedMbps
4758
- * @param {number} now
4759
4722
  * @returns {boolean}
4760
4723
  */
4761
- #linkCouldCarry(session, wantedMbps, now) {
4724
+ #linkCouldCarry(session, wantedMbps) {
4762
4725
  // The SLOWEST link among the viewers: a step up has to be carried by all of
4763
4726
  // them, not by whichever reported last.
4764
- const report = this.#worstNetReport(session, now);
4727
+ const report = worstLinkReading(session);
4765
4728
  if (!report) {
4766
4729
  return true;
4767
4730
  }
@@ -7280,54 +7243,6 @@ export class HlsSessionManager {
7280
7243
  return { earliestPosition, deepestBuffer, viewers };
7281
7244
  }
7282
7245
 
7283
- /**
7284
- * Where a soundtrack must begin: at the earliest film anybody is waiting on.
7285
- *
7286
- * ASKED OF THE MAP, NOT OF A VIEWER. Sound is produced for the same reason a
7287
- * picture is, and the map already states it — in seconds of film, merged over
7288
- * everybody watching, with the map's own order saying which of them is
7289
- * earliest. Reading it off a viewer put a person inside the encoding layer,
7290
- * and then every question about WHICH person had to be answered a second time
7291
- * here: the earliest of two, the one on this rung, the one who has not
7292
- * reported yet.
7293
- *
7294
- * The apparatus this replaced is worth naming so it is not rebuilt: three
7295
- * position sources ranked by priority, a function saying which had answered,
7296
- * a subtraction of the deepest reported buffer, and a special case for a
7297
- * session nobody had asked anything of. Each repaired a quantity that meant
7298
- * two things. It means one thing now.
7299
- *
7300
- * One segment back, and that is not a margin: a cut grid places the
7301
- * soundtrack's own boundaries where it will, so the piece holding a moment of
7302
- * film begins at or before it.
7303
- *
7304
- * @param {HlsSession} base
7305
- * @returns {number} Seconds.
7306
- */
7307
- #audioStartSecondsFor(base) {
7308
- // WHERE THE EARLIEST OF THEM STANDS, across every rung of this picture. A
7309
- // track begun at the leader has nothing to give the viewer behind them, and
7310
- // two viewers of one film may be on different rungs.
7311
- //
7312
- // NO SUBTRACTION, and that is the whole of the change. A viewer's position
7313
- // is where their PICTURE is, so the sound belongs exactly there — while the
7314
- // apparatus this replaced turned a request edge back into a playhead with
7315
- // three ranked sources, a name for which had answered, a subtraction of the
7316
- // deepest reported buffer, and a special case for a session nobody had
7317
- // asked anything of. Field 2026-08-31: a page opened at 588 s started its
7318
- // sound at 460 s, 131 seconds nobody would hear
7319
- // (`research/cold-open-audio-start-2026-08-31.md`).
7320
- //
7321
- // A fact about people, deciding one parameter of an output. It places no
7322
- // encoder: where an encoder works is the priority map's answer.
7323
- const earliest = earliestViewerSecondsOn(this.liveOutputs.familyOf(base));
7324
- const opened = Number(base.progress?.startPositionSeconds);
7325
- const from = earliest ?? (Number.isFinite(opened) ? opened : 0);
7326
- // One segment back, and that is not a margin: a cut grid places the
7327
- // soundtrack's own boundaries where it will, so the piece holding a moment
7328
- // of film begins at or before it.
7329
- return Math.max(0, from - this.segmentDurationSec);
7330
- }
7331
7246
 
7332
7247
 
7333
7248
  /**
@@ -8263,7 +8178,11 @@ export class HlsSessionManager {
8263
8178
  // who seeked since then is somewhere else entirely — so an old one is
8264
8179
  // ignored and the whole look-ahead is subtracted instead, which cannot
8265
8180
  // leave the run ahead of them.
8266
- startPositionSeconds: this.#audioStartSecondsFor(base),
8181
+ startPositionSeconds: audioStartSecondsFor({
8182
+ family: this.liveOutputs.familyOf(base),
8183
+ openedAtSeconds: base.progress?.startPositionSeconds,
8184
+ segmentSeconds: this.segmentDurationSec
8185
+ }),
8267
8186
  segmentFormatId: base.segmentFormat.id,
8268
8187
  // Cut where the picture is cut. Two streams meant to be played together
8269
8188
  // have to be divided at the same times, and the grid is the base's — the
@@ -40,15 +40,6 @@
40
40
  * question, and it reads a union of viewers rather than any one of them.
41
41
  */
42
42
 
43
- /**
44
- * How long a link measurement describes the link it was taken on.
45
- *
46
- * A reading older than this says nothing about now: the viewer may have seeked,
47
- * changed network, or stopped. Thirty seconds is three of the page's own
48
- * reporting intervals, so a live viewer always has a fresh one.
49
- */
50
- export const LINK_READING_FRESH_MS = 30_000;
51
-
52
43
  export class Viewer {
53
44
  /**
54
45
  * @param {string} id - The consumer id the browser sends with every request
@@ -331,28 +322,31 @@ export class Viewer {
331
322
  * @returns {boolean}
332
323
  */
333
324
  /**
334
- * What this viewer's link was last measured to carry, or null when nothing
335
- * recent measures it.
325
+ * What this viewer's link was last measured to carry, or null when it has
326
+ * never been measured.
336
327
  *
337
- * HOW LONG A READING DESCRIBES A LINK is a fact about the reading, so it is
338
- * answered here and nowhere else. It used to be a constant in the session
339
- * manager, consulted in four places — once where a reading is chosen, and
340
- * three more times over a figure that had already passed that very filter,
341
- * which made those three conditions unreachable.
328
+ * IT DOES NOT EXPIRE, and the reasoning is the correction of 2026-09-14. A
329
+ * reading older than a chosen thirty seconds used to be discarded here, which
330
+ * put two facts under one number: how fast the link is, and whether we are
331
+ * still hearing from this page.
342
332
  *
343
- * A viewer who has stopped reporting is NOT thereby gone: only the reading
344
- * expires. Answering both from one place is what stopped a soundtrack's
345
- * encoder on 2026-09-05.
333
+ * The speed persists. A link does not stop being what it was measured to be
334
+ * because nobody measured it for a minute, and the page says as much by
335
+ * keeping its own last figure for ever rather than reporting nothing. It is
336
+ * measured once when the connection comes up and corrected by every transfer
337
+ * afterwards — one estimate, continuously refined, never a fact with a
338
+ * deadline.
339
+ *
340
+ * Whether the reading is still somebody's is PRESENCE, which has its own
341
+ * owner: a viewer who has gone is removed from the output, and the readings
342
+ * walked for a decision are only those of people still there. Answering both
343
+ * questions in one place is what stopped a soundtrack's encoder on
344
+ * 2026-09-05.
346
345
  *
347
- * @param {number} [now]
348
346
  * @returns {{ linkMbps: number, bufferedAheadSec: number, positionSeconds: number | null, at: number } | null}
349
347
  */
350
- linkReading(now = Date.now()) {
351
- const reading = this.netReport;
352
- if (reading === null || now - reading.at > LINK_READING_FRESH_MS) {
353
- return null;
354
- }
355
- return reading;
348
+ linkReading() {
349
+ return this.netReport;
356
350
  }
357
351
 
358
352
  /**
@@ -0,0 +1,45 @@
1
+ /**
2
+ * @file Where a soundtrack must begin.
3
+ *
4
+ * ASKED OF THE PEOPLE, NOT OF A SESSION. Sound is produced for the same reason
5
+ * a picture is — somebody is going to hear it — so the question is where the
6
+ * earliest of them stands, across every rung of that picture: a track begun at
7
+ * the leader has nothing to give the viewer behind them, and two viewers of one
8
+ * film may be watching at different qualities.
9
+ *
10
+ * NO SUBTRACTION, and that is the whole of what this replaced: three position
11
+ * sources ranked by priority, a second function naming which had answered, a
12
+ * subtraction of the deepest reported buffer, and a special case for a session
13
+ * nobody had asked anything of. Each repaired a quantity that meant two things.
14
+ * A viewer's position is where their PICTURE is, so the sound belongs exactly
15
+ * there. Field 2026-08-31, what the subtraction cost: a page opened at 588 s
16
+ * started its sound at 460 s — 131 seconds nobody would hear
17
+ * (`research/cold-open-audio-start-2026-08-31.md`).
18
+ *
19
+ * It decides ONE PARAMETER of an output and places no encoder: where an encoder
20
+ * works is the priority map's answer. Pure over plain values — the outputs of
21
+ * one picture, where it was opened, and how long a piece is — so it knows
22
+ * nothing of ffmpeg, cut grids or the disk.
23
+ */
24
+
25
+ import { earliestViewerSecondsOn } from "./positions.js";
26
+
27
+ /**
28
+ * @param {object} params
29
+ * @param {Iterable<object>} params.family - Every output of one picture: the
30
+ * picture itself and its quality steps. The viewers are spread across them.
31
+ * @param {number} params.openedAtSeconds - Where the picture was opened, which
32
+ * is the answer while nobody has stated a position yet.
33
+ * @param {number} params.segmentSeconds - How long one piece is.
34
+ * @returns {number} Seconds, never negative.
35
+ */
36
+ export function audioStartSecondsFor({ family, openedAtSeconds, segmentSeconds }) {
37
+ const earliest = earliestViewerSecondsOn(family);
38
+ const opened = Number(openedAtSeconds);
39
+ const from = earliest ?? (Number.isFinite(opened) ? opened : 0);
40
+ // ONE PIECE BACK, and that is not a margin: a cut grid places the
41
+ // soundtrack's own boundaries where it will, so the piece holding a moment of
42
+ // film begins at or before it.
43
+ const back = Number.isFinite(segmentSeconds) && segmentSeconds > 0 ? segmentSeconds : 0;
44
+ return Math.max(0, from - back);
45
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * @file What the viewers of one output say about their links, as one reading.
3
+ *
4
+ * The budget asks one question — is anybody failing to keep up — so both terms
5
+ * are the worst case: the slowest link and the emptiest buffer, which may
6
+ * belong to different people. Taking the last report instead meant a session
7
+ * with two viewers acted on whichever of them happened to report most recently.
8
+ *
9
+ * NOTHING HERE EXPIRES, and that is the correction of 2026-09-14. A reading
10
+ * used to be discarded once it was older than a chosen thirty seconds, which
11
+ * conflated two facts under one number: how fast the link is, and whether we
12
+ * are still hearing from the page. The first persists — a link does not stop
13
+ * being what it was measured to be because nobody measured it for a minute,
14
+ * which is why the page itself keeps its last figure for ever. The second is
15
+ * PRESENCE, and presence has its own owner: a viewer who has gone is removed
16
+ * from the output, so what is walked here is only people who are still here.
17
+ *
18
+ * The reading is corrected continuously while material crosses the link, which
19
+ * is the whole shape: measured once when the connection comes up, corrected by
20
+ * every transfer after it.
21
+ */
22
+
23
+ import { viewersOf } from "./Viewer.js";
24
+
25
+ /**
26
+ * @param {object} output - The session whose viewers are asked.
27
+ * @returns {{ linkMbps: number, bufferedAheadSec: number, at: number, viewers: number } | null}
28
+ * Null when nobody present has ever measured their link, which every caller
29
+ * already treats as "no opinion either way".
30
+ */
31
+ export function worstLinkReading(output) {
32
+ let worst = null;
33
+ for (const viewer of viewersOf(output).values()) {
34
+ if (!viewer.isPresent()) {
35
+ continue;
36
+ }
37
+ const reading = viewer.linkReading();
38
+ if (reading === null) {
39
+ continue;
40
+ }
41
+ if (worst === null) {
42
+ worst = {
43
+ linkMbps: reading.linkMbps,
44
+ bufferedAheadSec: reading.bufferedAheadSec,
45
+ at: reading.at,
46
+ viewers: 1
47
+ };
48
+ continue;
49
+ }
50
+ worst.linkMbps = Math.min(worst.linkMbps, reading.linkMbps);
51
+ worst.bufferedAheadSec = Math.min(worst.bufferedAheadSec, reading.bufferedAheadSec);
52
+ worst.at = Math.max(worst.at, reading.at);
53
+ worst.viewers += 1;
54
+ }
55
+ return worst;
56
+ }
@@ -2,9 +2,10 @@
2
2
  * @file A viewer's statement about itself, taken in.
3
3
  *
4
4
  * The VIEWER layer's own entry point, and the whole of what happens when a page
5
- * says where it is: the output the reporter has on screen is resolved, the
6
- * statement reaches the person it is about, and readings that have gone stale
7
- * stop deciding for the people still here.
5
+ * says where it is: the output the reporter has on screen is resolved, and the
6
+ * statement reaches the person it is about. Nothing expires here — a link
7
+ * reading is the last thing known about that link, and whether it is still
8
+ * somebody's is presence's answer (`Viewer.linkReading`).
8
9
  *
9
10
  * It lived in the session manager, which is a description of an OUTPUT, and the
10
11
  * two questions were tangled there: whether this is a measurement of a LINK,
@@ -345,7 +345,7 @@ test("with two viewers the budget acts on the WORST link, not on whoever reporte
345
345
  );
346
346
  });
347
347
 
348
- test("a report from a viewer who has left stops counting", async (t) => {
348
+ test("a reading stops counting when the person leaves, not when it gets old", async (t) => {
349
349
  const { manager, session, dirPath } = await managerWithSession({ transcodeVideo: false });
350
350
  t.after(async () => {
351
351
  await manager.disposeAll();
@@ -353,17 +353,15 @@ test("a report from a viewer who has left stops counting", async (t) => {
353
353
  });
354
354
 
355
355
  await produceSegments(session, 2_000_000);
356
- // A reading describes a link at a moment, and one this old cannot decide for
357
- // the viewers still here. ONLY THE READING EXPIRES: whether the person is
358
- // still watching is a different question with its own answer — their
359
- // connection — and answering both from this one place is what stopped a
360
- // soundtrack's encoder on 2026-09-05, so silence no longer removes anybody.
361
- viewerOf(session, "gone").netReport = {
362
- linkMbps: 1.0,
363
- bufferedAheadSec: 1.5,
364
- positionSeconds: 40,
365
- at: Date.now() - 120_000
366
- };
356
+ // A LINK READING DOES NOT EXPIRE. It is the last thing known about that link,
357
+ // and how fast a link is does not change because nobody measured it for a
358
+ // minute — the page keeps its own last figure for the same reason. What ends
359
+ // a reading is the person leaving, which is presence, and presence is the
360
+ // connection: silence removes nobody (2026-09-05, a soundtrack's encoder
361
+ // stopped because those two questions had one answer).
362
+ const gone = viewerOf(session, "gone");
363
+ gone.report({ linkMbps: 1.0, bufferedAheadSec: 1.5, positionSeconds: 40, playing: true }, Date.now() - 120_000);
364
+ gone.gone = true;
367
365
  recordViewerReport({
368
366
  sessions: manager.sessionsById,
369
367
  viewers: manager.viewers,
@@ -375,13 +373,12 @@ test("a report from a viewer who has left stops counting", async (t) => {
375
373
  await manager.runQualityBudgetOnce();
376
374
 
377
375
  assert.equal(session.viewers.size, 2, "the viewer is still known — silence is not leaving");
378
- // The reading is not deleted anywhere: how long one describes a link is a
379
- // fact about the reading, answered when it is read. Nulling it on somebody
380
- // else's report was a second owner of the same fact.
376
+ // Their reading is not deleted anywhere: it is simply not theirs to give any
377
+ // more, because they are not here. Nothing walks it for a decision.
381
378
  assert.equal(
382
- viewerOf(session, "gone").linkReading(Date.now()),
383
- null,
384
- "and their reading has expired, so it decides nothing"
379
+ viewerOf(session, "gone").linkReading()?.linkMbps,
380
+ 1.0,
381
+ "their last reading stands as the last thing known about that link"
385
382
  );
386
383
  assert.equal(session.qualityAsk, null, "the viewer who is here can carry the picture");
387
384
  });
@@ -144,16 +144,46 @@ test("the intake hands the statement to the viewer on the output they are watchi
144
144
  assert.equal(picture.viewers?.has("one") ?? false, false);
145
145
  });
146
146
 
147
- test("a reading decides nothing once it is older than the link it describes", () => {
147
+ test("a link reading does not expire — presence is what decides whose it is", () => {
148
148
  const viewer = new Viewer("one", AT);
149
149
  viewer.report({ linkMbps: 8, bufferedAheadSec: 10, positionSeconds: 5, playing: true }, AT);
150
150
 
151
- assert.equal(viewer.linkReading(AT)?.linkMbps, 8);
152
- assert.equal(viewer.linkReading(AT + 29_000)?.linkMbps, 8);
153
- assert.equal(viewer.linkReading(AT + 31_000), null);
154
- // Nothing was deleted: the viewer is still here and still where they said.
151
+ // A link does not stop being what it was measured to be because nobody
152
+ // measured it for a while. The page says as much by keeping its own last
153
+ // figure rather than reporting nothing.
154
+ assert.equal(viewer.linkReading()?.linkMbps, 8);
155
+ assert.equal(viewer.linkReading()?.linkMbps, 8, "and an hour later it is still the last thing known");
156
+
157
+ // What ends it is the person leaving, which is a different fact with a
158
+ // different owner.
155
159
  assert.equal(viewer.isPresent(), true);
156
- assert.equal(viewer.positionSeconds(AT + 31_000), 15);
160
+ });
161
+
162
+ test("the worst reading is taken across the people who are still here", async () => {
163
+ const { worstLinkReading } = await import("../services/viewer/link-readings.js");
164
+ const output = {};
165
+ const slow = new Viewer("slow", AT);
166
+ slow.outputs.add("out");
167
+ const fast = new Viewer("fast", AT);
168
+ fast.outputs.add("out");
169
+ output.viewers = new Map([
170
+ ["slow", slow],
171
+ ["fast", fast]
172
+ ]);
173
+ slow.report({ linkMbps: 2, bufferedAheadSec: 3, playing: true }, AT);
174
+ fast.report({ linkMbps: 40, bufferedAheadSec: 90, playing: true }, AT);
175
+
176
+ // The slowest link and the emptiest buffer, which may belong to two people.
177
+ const worst = worstLinkReading(output);
178
+ assert.equal(worst?.linkMbps, 2);
179
+ assert.equal(worst?.bufferedAheadSec, 3);
180
+ assert.equal(worst?.viewers, 2);
181
+
182
+ // The slow one leaves. Their reading goes with them, however recent it was.
183
+ slow.gone = true;
184
+ const left = worstLinkReading(output);
185
+ assert.equal(left?.linkMbps, 40);
186
+ assert.equal(left?.viewers, 1);
157
187
  });
158
188
 
159
189
  test("a report into a session that is gone is refused rather than invented", async () => {