@torrent-tv/proxy 2.85.0 → 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,15 @@
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
+
7
+ ## 2.85.1
8
+
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.
10
+ - **Chore**: HOW LONG A LINK READING DESCRIBES A LINK is answered by the reading, once (`Viewer.linkReading`). It was a constant in the session manager consulted in four places, and three of those were conditions over a figure that had already passed that very filter — unreachable by construction. Nothing nulls a stale reading any more either: expiry is answered when it is read, so the write that existed only to make a later read correct is gone, and a viewer who has simply stopped reporting is untouched.
11
+ - **Chore**: The session manager is 103 lines shorter for it, and gained one import and eight calls outward.
12
+
1
13
  ## 2.85.0
2
14
 
3
15
  - **Fix**: A VIEWER WHO HAS SAID NOTHING IS NOT ASSUMED TO BE WATCHING. `playing` started true on a viewer this proxy had never heard from, and the position is carried forward by the clock at that rate — so a page whose tab was hidden for 145 seconds, while it deliberately held start-up until it was shown, was placed **145.979 s** into a film it had not begun. Field 2026-09-14: that is the exact `-ss` the soundtrack's encoder was given, the browser was asking for segment #0, the map ranked #0 last of a hundred (`p1:#0..#13`), and the request was held 60 145 ms and answered 503. The moment the viewer left and the invented clock stopped, the plan asked for exactly the segment they had been asking for all along. A statement about somebody else's machine is theirs to make: until they make it the rate is zero.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.85.0",
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": {
@@ -1,3 +1,5 @@
1
+ import { recordViewerReport } from "../../../../services/viewer/report-intake.js";
2
+
1
3
  /**
2
4
  * Accept a viewer link report for a transcode session (adaptive bitrate).
3
5
  * The browser measures its own data-channel throughput per segment fetch and
@@ -17,10 +19,13 @@
17
19
  *
18
20
  * @param {import("fastify").FastifyRequest} req
19
21
  * @param {import("fastify").FastifyReply} reply
20
- * @param {{ hlsSessionManager: import("../../../../services/hls-session-manager.js").HlsSessionManager }} deps
22
+ * @param {{ sessions: { get: (id: string) => object | undefined }, viewers: object }} deps -
23
+ * The live sessions and the registry of viewers. The VIEWER layer takes the
24
+ * statement from here; this route's whole job is turning a request into that
25
+ * one call and its answer into a status code.
21
26
  * @returns {Promise<void>}
22
27
  */
23
- export async function handleApiTranscodeSessionNetReportPost(req, reply, { hlsSessionManager }) {
28
+ export async function handleApiTranscodeSessionNetReportPost(req, reply, { sessions, viewers }) {
24
29
  const sessionId = typeof req.params.sessionId === "string" ? req.params.sessionId : "";
25
30
  const body = req.body && typeof req.body === "object" && !Array.isArray(req.body) ? req.body : {};
26
31
  // WHAT THIS REPORT IS. A statement by one viewer about itself: where the
@@ -66,16 +71,21 @@ export async function handleApiTranscodeSessionNetReportPost(req, reply, { hlsSe
66
71
  const inPictureInPicture =
67
72
  typeof body.inPictureInPicture === "boolean" ? body.inPictureInPicture : undefined;
68
73
  const positionSeconds = Number(body.positionSeconds);
69
- const recorded = hlsSessionManager.recordNetReport(sessionId, {
70
- linkMbps: Number.isFinite(linkMbps) && linkMbps > 0 ? linkMbps : undefined,
71
- waiting,
72
- bufferedAheadSec,
73
- consumerId,
74
- playing,
75
- onScreen,
76
- inPictureInPicture,
77
- positionSeconds:
78
- Number.isFinite(positionSeconds) && positionSeconds >= 0 ? positionSeconds : undefined
74
+ const recorded = recordViewerReport({
75
+ sessions,
76
+ viewers,
77
+ sessionId,
78
+ report: {
79
+ linkMbps: Number.isFinite(linkMbps) && linkMbps > 0 ? linkMbps : undefined,
80
+ bufferedAheadSec,
81
+ consumerId,
82
+ playing,
83
+ waiting,
84
+ onScreen,
85
+ inPictureInPicture,
86
+ positionSeconds:
87
+ Number.isFinite(positionSeconds) && positionSeconds >= 0 ? positionSeconds : undefined
88
+ }
79
89
  });
80
90
  if (!recorded) {
81
91
  return reply.code(404).send({ error: "Transcode session was not found." });
package/server.js CHANGED
@@ -393,7 +393,13 @@ export async function startProxyServer({
393
393
  handleApiTranscodeSessionsProgressGet(req, reply, { hlsSessionManager })
394
394
  );
395
395
  app.post("/api/transcode-sessions/:sessionId/net-report", async (req, reply) =>
396
- handleApiTranscodeSessionNetReportPost(req, reply, { hlsSessionManager })
396
+ // A viewer's statement about itself goes to the VIEWER layer, not through
397
+ // the session manager: what it needs is the live sessions and the registry
398
+ // of viewers, and nothing about encoding.
399
+ handleApiTranscodeSessionNetReportPost(req, reply, {
400
+ sessions: hlsSessionManager.sessionsById,
401
+ viewers: hlsSessionManager.viewers
402
+ })
397
403
  );
398
404
  app.post("/api/transcode-sessions/:sessionId/fragment-far", async (req, reply) =>
399
405
  handleApiTranscodeSessionFragmentFarPost(req, reply, { hlsSessionManager })
@@ -88,8 +88,10 @@ import {
88
88
  // — did not move when the code did.
89
89
  export { ffmpegSeconds, onKeyframeGridFor, seekLandingOffsetFor, segmentCutTimesFrom };
90
90
  import { viewersOf } from "./viewer/Viewer.js";
91
- import { earliestViewerSecondsOn, viewerSecondsOn, viewerSegmentsOn } from "./viewer/positions.js";
92
- import { takeViewerReport } from "./viewer/report-intake.js";
91
+ import { activeOutputFor } from "./viewer/active-output.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";
@@ -524,7 +526,6 @@ const BUDGET_DOWNLOAD_OK_FACTOR = 1.0;
524
526
  // instead. Which of the two, and whether a viewer's own pick may be moved at
525
527
  // all, is decided where the viewer's choice lives: in the browser, which
526
528
  // honours the request only in automatic mode.
527
- const LINK_REPORT_FRESH_MS = 30_000;
528
529
  // Usable share of the reported link (protocol overhead + measurement noise).
529
530
  const LINK_SAFETY = 0.8;
530
531
  // Deficit must persist this long before acting (absorbs one slow segment).
@@ -3058,36 +3059,6 @@ export class HlsSessionManager {
3058
3059
  *
3059
3060
  * @returns {Promise<void>}
3060
3061
  */
3061
- /**
3062
- * Record the latest viewer link report for a session (adaptive bitrate).
3063
- * Returns false for an unknown/disposed session.
3064
- *
3065
- * Kept per viewer. A browser that does not say who it is lands under one
3066
- * shared key, which is exactly the old behaviour for that browser and no
3067
- * worse: with one viewer the two are the same thing.
3068
- *
3069
- * @param {string} sessionId
3070
- * @param {{ linkMbps: number, bufferedAheadSec: number, consumerId?: string, positionSeconds?: number }} report
3071
- * @returns {boolean}
3072
- */
3073
- recordNetReport(sessionId, report) {
3074
- const named = this.sessionsById.get(sessionId);
3075
- if (!named || named.state === "disposed") {
3076
- return false;
3077
- }
3078
- const consumerId = typeof report?.consumerId === "string" ? report.consumerId : "";
3079
- return takeViewerReport({
3080
- viewers: this.viewers,
3081
- // The stream on screen is the reporter's own: with two viewers on two
3082
- // rungs, one report says nothing about the other's encoder.
3083
- session: this.#activeVariant(named, consumerId),
3084
- consumerId,
3085
- report,
3086
- now: Date.now(),
3087
- linkFreshMs: LINK_REPORT_FRESH_MS
3088
- });
3089
- }
3090
-
3091
3062
  /**
3092
3063
  * Record where one viewer of this session is, and answer with the furthest
3093
3064
  * any of them has reached.
@@ -3117,44 +3088,6 @@ export class HlsSessionManager {
3117
3088
  this.viewers.of(session, consumerId).seen();
3118
3089
  }
3119
3090
 
3120
- /**
3121
- * The worst of what the viewers of this session report, as one reading.
3122
- *
3123
- * The budget asks one question — is anybody failing to keep up — so both
3124
- * terms are the worst case: the slowest link and the emptiest buffer, which
3125
- * may belong to different people. That is deliberate. Taking the last report
3126
- * instead meant a session with two viewers acted on whichever of them
3127
- * happened to report most recently.
3128
- *
3129
- * @param {HlsSession} session
3130
- * @param {number} now
3131
- * @returns {{ linkMbps: number, bufferedAheadSec: number, at: number, viewers: number } | null}
3132
- * Null when nothing fresh measures the link, which is the silence every
3133
- * caller already treats as "no opinion".
3134
- */
3135
- #worstNetReport(session, now) {
3136
- let worst = null;
3137
- for (const viewer of viewersOf(session).values()) {
3138
- const report = viewer.netReport;
3139
- if (report === null || now - report.at > LINK_REPORT_FRESH_MS) {
3140
- continue;
3141
- }
3142
- if (worst === null) {
3143
- worst = {
3144
- linkMbps: report.linkMbps,
3145
- bufferedAheadSec: report.bufferedAheadSec,
3146
- at: report.at,
3147
- viewers: 1
3148
- };
3149
- continue;
3150
- }
3151
- worst.linkMbps = Math.min(worst.linkMbps, report.linkMbps);
3152
- worst.bufferedAheadSec = Math.min(worst.bufferedAheadSec, report.bufferedAheadSec);
3153
- worst.at = Math.max(worst.at, report.at);
3154
- worst.viewers += 1;
3155
- }
3156
- return worst;
3157
- }
3158
3091
 
3159
3092
  /**
3160
3093
  * Answer the player's report that a delivered fragment sits far from the edge
@@ -3186,7 +3119,7 @@ export class HlsSessionManager {
3186
3119
  // run and its own position, and that is exactly the pair this report exists
3187
3120
  // to tell apart. Answering an audio report from the picture's records would
3188
3121
  // state, confidently, something about the wrong stream.
3189
- const onScreen = this.#activeVariant(named);
3122
+ const onScreen = activeOutputFor({ base: named, sessions: this.sessionsById, viewers: this.viewers });
3190
3123
  const session = track === "audio"
3191
3124
  ? ([...this.liveOutputs.familyOf(onScreen)].find((member) => member.audioOnly === true) ?? onScreen)
3192
3125
  : onScreen;
@@ -3274,9 +3207,9 @@ export class HlsSessionManager {
3274
3207
  * @returns {Promise<boolean>}
3275
3208
  */
3276
3209
  async #checkLinkBudget(session, now) {
3277
- const report = this.#worstNetReport(session, now);
3278
- if (!report || now - report.at > LINK_REPORT_FRESH_MS) {
3279
- session.linkSlowSince = 0; // no fresh data — old clients / stopped reporter
3210
+ const report = worstLinkReading(session);
3211
+ if (!report) {
3212
+ session.linkSlowSince = 0; // nobody present has measured their link
3280
3213
  return false;
3281
3214
  }
3282
3215
  if (report.bufferedAheadSec >= LINK_LOW_BUFFER_SEC) {
@@ -4666,7 +4599,7 @@ export class HlsSessionManager {
4666
4599
  }
4667
4600
  return false;
4668
4601
  }
4669
- const playing = this.liveOutputs.variantHeightOf(this.#activeVariant(base));
4602
+ const playing = this.liveOutputs.variantHeightOf(activeOutputFor({ base, sessions: this.sessionsById, viewers: this.viewers }));
4670
4603
  if (height === playing) {
4671
4604
  return false;
4672
4605
  }
@@ -4720,7 +4653,7 @@ export class HlsSessionManager {
4720
4653
  // top offered height has no next rung at all, and answering "nothing to
4721
4654
  // step to, so yes" is how a cap came off a link measured at a fifth of
4722
4655
  // what the picture needs.
4723
- if (!this.#linkCouldCarry(session, this.#peakMbpsForHeight(this.liveOutputs.pictureOf(session), current), now)) {
4656
+ if (!this.#linkCouldCarry(session, this.#peakMbpsForHeight(this.liveOutputs.pictureOf(session), current))) {
4724
4657
  return;
4725
4658
  }
4726
4659
  session.budgetUpSince = 0;
@@ -4764,10 +4697,10 @@ export class HlsSessionManager {
4764
4697
  if (session.linkSlowSince !== 0 || session.budgetSlowSince !== 0) {
4765
4698
  return false;
4766
4699
  }
4767
- const report = this.#worstNetReport(session, now);
4768
- if (!report || now - report.at > LINK_REPORT_FRESH_MS) {
4769
- // Nothing fresh measures the link, so it has no opinion either way — the
4770
- // same silence that stops #checkLinkBudget from acting.
4700
+ const report = worstLinkReading(session);
4701
+ if (!report) {
4702
+ // Nothing measures the link, so it has no opinion either way — the same
4703
+ // silence that stops #checkLinkBudget from acting.
4771
4704
  return true;
4772
4705
  }
4773
4706
  const base = this.liveOutputs.pictureOf(session);
@@ -4775,25 +4708,24 @@ export class HlsSessionManager {
4775
4708
  if (next === undefined) {
4776
4709
  return true; // nothing to step to; only the cap decision is left
4777
4710
  }
4778
- return this.#linkCouldCarry(session, this.#peakMbpsForHeight(base, next), now);
4711
+ return this.#linkCouldCarry(session, this.#peakMbpsForHeight(base, next));
4779
4712
  }
4780
4713
 
4781
4714
  /**
4782
4715
  * Whether the viewer's measured link can carry a given number of Mbit/s.
4783
4716
  *
4784
- * 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
4785
4718
  * silence that stops `#checkLinkBudget` from acting — so it answers yes.
4786
4719
  *
4787
4720
  * @param {HlsSession} session
4788
4721
  * @param {number} wantedMbps
4789
- * @param {number} now
4790
4722
  * @returns {boolean}
4791
4723
  */
4792
- #linkCouldCarry(session, wantedMbps, now) {
4724
+ #linkCouldCarry(session, wantedMbps) {
4793
4725
  // The SLOWEST link among the viewers: a step up has to be carried by all of
4794
4726
  // them, not by whichever reported last.
4795
- const report = this.#worstNetReport(session, now);
4796
- if (!report || now - report.at > LINK_REPORT_FRESH_MS) {
4727
+ const report = worstLinkReading(session);
4728
+ if (!report) {
4797
4729
  return true;
4798
4730
  }
4799
4731
  return report.linkMbps * LINK_SAFETY >= wantedMbps;
@@ -7167,7 +7099,7 @@ export class HlsSessionManager {
7167
7099
  if (!ask) {
7168
7100
  return 0;
7169
7101
  }
7170
- if (ask.height === this.liveOutputs.variantHeightOf(this.#activeVariant(base))) {
7102
+ if (ask.height === this.liveOutputs.variantHeightOf(activeOutputFor({ base, sessions: this.sessionsById, viewers: this.viewers }))) {
7171
7103
  base.qualityAsk = null; // the viewer is there; nothing left to ask for
7172
7104
  return 0;
7173
7105
  }
@@ -7182,45 +7114,6 @@ export class HlsSessionManager {
7182
7114
  return ask.height;
7183
7115
  }
7184
7116
 
7185
- /**
7186
- * The variant of a session that the viewer is watching right now.
7187
- *
7188
- * Every request that names the base session — seek, progress, link report,
7189
- * release — means the stream the viewer has on screen, and after a quality
7190
- * change that is another session. The browser is not told about the swap: it
7191
- * holds one session id for the whole file, which is what keeps the switch out
7192
- * of the state machine on that side.
7193
- *
7194
- * Kept per viewer, because one picture is shared by everyone watching it and
7195
- * a quality step is a session of its own: with one answer for the session, a
7196
- * step taken by one viewer would move the other one's stream, and that
7197
- * viewer's next seek would be forwarded to a rung they never chose. The
7198
- * session's own field remains the answer for a viewer who cannot name
7199
- * themselves, and the last one anybody moved to.
7200
- *
7201
- * @param {HlsSession} base
7202
- * @param {string} [consumerId]
7203
- * @returns {HlsSession}
7204
- */
7205
- #activeVariant(base, consumerId = "") {
7206
- const named = consumerId ? base.viewers?.get(consumerId)?.activeVariantId ?? null : null;
7207
- const activeId = named ?? base.activeVariantId;
7208
- if (!activeId || activeId === base.id) {
7209
- return base;
7210
- }
7211
- const active = this.sessionsById.get(activeId);
7212
- if (!active || active.state === "disposed") {
7213
- if (named) {
7214
- this.viewers.of(base, consumerId).activeVariantId = null;
7215
- }
7216
- if (base.activeVariantId === activeId) {
7217
- base.activeVariantId = base.id;
7218
- }
7219
- return base;
7220
- }
7221
- return active;
7222
- }
7223
-
7224
7117
  /**
7225
7118
  * Every session of this family that a live viewer has on screen.
7226
7119
  *
@@ -7245,7 +7138,7 @@ export class HlsSessionManager {
7245
7138
  onScreen.add(viewer.activeVariantId);
7246
7139
  }
7247
7140
  if (onScreen.size === 0) {
7248
- onScreen.add(this.#activeVariant(base).id);
7141
+ onScreen.add(activeOutputFor({ base, sessions: this.sessionsById, viewers: this.viewers }).id);
7249
7142
  }
7250
7143
  return onScreen;
7251
7144
  }
@@ -7278,7 +7171,7 @@ export class HlsSessionManager {
7278
7171
  if (Number.isInteger(wantedIndex) && wantedIndex >= 0) {
7279
7172
  return this.#segmentStartTime(base, wantedIndex);
7280
7173
  }
7281
- return viewerSecondsOn(this.#activeVariant(base, consumerId));
7174
+ return viewerSecondsOn(activeOutputFor({ base, consumerId, sessions: this.sessionsById, viewers: this.viewers }));
7282
7175
  }
7283
7176
 
7284
7177
  /**
@@ -7350,54 +7243,6 @@ export class HlsSessionManager {
7350
7243
  return { earliestPosition, deepestBuffer, viewers };
7351
7244
  }
7352
7245
 
7353
- /**
7354
- * Where a soundtrack must begin: at the earliest film anybody is waiting on.
7355
- *
7356
- * ASKED OF THE MAP, NOT OF A VIEWER. Sound is produced for the same reason a
7357
- * picture is, and the map already states it — in seconds of film, merged over
7358
- * everybody watching, with the map's own order saying which of them is
7359
- * earliest. Reading it off a viewer put a person inside the encoding layer,
7360
- * and then every question about WHICH person had to be answered a second time
7361
- * here: the earliest of two, the one on this rung, the one who has not
7362
- * reported yet.
7363
- *
7364
- * The apparatus this replaced is worth naming so it is not rebuilt: three
7365
- * position sources ranked by priority, a function saying which had answered,
7366
- * a subtraction of the deepest reported buffer, and a special case for a
7367
- * session nobody had asked anything of. Each repaired a quantity that meant
7368
- * two things. It means one thing now.
7369
- *
7370
- * One segment back, and that is not a margin: a cut grid places the
7371
- * soundtrack's own boundaries where it will, so the piece holding a moment of
7372
- * film begins at or before it.
7373
- *
7374
- * @param {HlsSession} base
7375
- * @returns {number} Seconds.
7376
- */
7377
- #audioStartSecondsFor(base) {
7378
- // WHERE THE EARLIEST OF THEM STANDS, across every rung of this picture. A
7379
- // track begun at the leader has nothing to give the viewer behind them, and
7380
- // two viewers of one film may be on different rungs.
7381
- //
7382
- // NO SUBTRACTION, and that is the whole of the change. A viewer's position
7383
- // is where their PICTURE is, so the sound belongs exactly there — while the
7384
- // apparatus this replaced turned a request edge back into a playhead with
7385
- // three ranked sources, a name for which had answered, a subtraction of the
7386
- // deepest reported buffer, and a special case for a session nobody had
7387
- // asked anything of. Field 2026-08-31: a page opened at 588 s started its
7388
- // sound at 460 s, 131 seconds nobody would hear
7389
- // (`research/cold-open-audio-start-2026-08-31.md`).
7390
- //
7391
- // A fact about people, deciding one parameter of an output. It places no
7392
- // encoder: where an encoder works is the priority map's answer.
7393
- const earliest = earliestViewerSecondsOn(this.liveOutputs.familyOf(base));
7394
- const opened = Number(base.progress?.startPositionSeconds);
7395
- const from = earliest ?? (Number.isFinite(opened) ? opened : 0);
7396
- // One segment back, and that is not a margin: a cut grid places the
7397
- // soundtrack's own boundaries where it will, so the piece holding a moment
7398
- // of film begins at or before it.
7399
- return Math.max(0, from - this.segmentDurationSec);
7400
- }
7401
7246
 
7402
7247
 
7403
7248
  /**
@@ -7883,7 +7728,7 @@ export class HlsSessionManager {
7883
7728
  // buys it an encoder, and both halves are said here: a warmed rung is one
7884
7729
  // this person is watching for as long as the warm-up lasts, which is why
7885
7730
  // two encoders run through it.
7886
- if (variant.id !== this.#activeVariant(base, consumerId).id) {
7731
+ if (variant.id !== activeOutputFor({ base, consumerId, sessions: this.sessionsById, viewers: this.viewers }).id) {
7887
7732
  this.viewers.of(variant, consumerId).moveTo(this.#segmentStartTime(base, index));
7888
7733
  this.planEncodersSoon();
7889
7734
  }
@@ -7908,7 +7753,7 @@ export class HlsSessionManager {
7908
7753
  * @returns {void}
7909
7754
  */
7910
7755
  #noteVariantActive(base, variant, wantedIndex = -1, consumerId = "") {
7911
- const previous = this.#activeVariant(base, consumerId);
7756
+ const previous = activeOutputFor({ base, consumerId, sessions: this.sessionsById, viewers: this.viewers });
7912
7757
  if (previous.id === variant.id) {
7913
7758
  // The rung on screen asking for more of itself, which it does every few
7914
7759
  // seconds. Nothing is being decided here — and deciding anything was the
@@ -8333,7 +8178,11 @@ export class HlsSessionManager {
8333
8178
  // who seeked since then is somewhere else entirely — so an old one is
8334
8179
  // ignored and the whole look-ahead is subtracted instead, which cannot
8335
8180
  // leave the run ahead of them.
8336
- startPositionSeconds: this.#audioStartSecondsFor(base),
8181
+ startPositionSeconds: audioStartSecondsFor({
8182
+ family: this.liveOutputs.familyOf(base),
8183
+ openedAtSeconds: base.progress?.startPositionSeconds,
8184
+ segmentSeconds: this.segmentDurationSec
8185
+ }),
8337
8186
  segmentFormatId: base.segmentFormat.id,
8338
8187
  // Cut where the picture is cut. Two streams meant to be played together
8339
8188
  // have to be divided at the same times, and the grid is the base's — the
@@ -9431,7 +9280,7 @@ export class HlsSessionManager {
9431
9280
  // change is another session. Touching the named one as well is what keeps
9432
9281
  // the family alive: only the ACTIVE variant gets segment requests, so
9433
9282
  // without this the base session would idle out from under its own variants.
9434
- const session = this.#activeVariant(named, consumerId);
9283
+ const session = activeOutputFor({ base: named, consumerId, sessions: this.sessionsById, viewers: this.viewers });
9435
9284
  session.lastAccessedAt = Date.now();
9436
9285
  const warmupTotalSeconds = this.startupWaitMs / 1000;
9437
9286
  const warmupElapsedSeconds = Math.max(0, (Date.now() - session.startedAt) / 1000);
@@ -321,6 +321,34 @@ export class Viewer {
321
321
  *
322
322
  * @returns {boolean}
323
323
  */
324
+ /**
325
+ * What this viewer's link was last measured to carry, or null when it has
326
+ * never been measured.
327
+ *
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.
332
+ *
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.
345
+ *
346
+ * @returns {{ linkMbps: number, bufferedAheadSec: number, positionSeconds: number | null, at: number } | null}
347
+ */
348
+ linkReading() {
349
+ return this.netReport;
350
+ }
351
+
324
352
  /**
325
353
  * Whether this viewer wants film NOW — either watching it or waiting for it.
326
354
  *
@@ -0,0 +1,54 @@
1
+ /**
2
+ * @file Which output a viewer has on screen.
3
+ *
4
+ * Every request that names the picture's session — a seek, a progress poll, a
5
+ * link report, a release — means the stream the viewer is actually watching,
6
+ * and after a quality change that is another session. The browser is never told
7
+ * about the swap: it holds one session id for the whole file, which is what
8
+ * keeps the switch out of the state machine on that side.
9
+ *
10
+ * KEPT PER VIEWER, because one picture is shared by everyone watching it and a
11
+ * quality step is a session of its own. With one answer for the session, a step
12
+ * taken by one viewer moved the other one's stream, and that viewer's next seek
13
+ * was forwarded to a rung they never chose. The session's own field remains the
14
+ * answer for a viewer who cannot name themselves, and is the last rung anybody
15
+ * moved to.
16
+ *
17
+ * It is handed the picture, a name, the live sessions and the registry of
18
+ * viewers, and it HOLDS none of them — but it is not read-only: finding a rung
19
+ * that has been disposed, it puts both remembers back on the picture, or the
20
+ * next question gets the same dead answer. It lives in the VIEWER layer because
21
+ * the question is about a person — which of several equally live outputs is
22
+ * theirs — and it knows nothing of encoders, files or heights.
23
+ */
24
+
25
+ /**
26
+ * @param {object} params
27
+ * @param {object} params.base - The picture's own session.
28
+ * @param {string} [params.consumerId] - Whose screen. Empty asks about the
29
+ * session's own last answer, which is what an unnamed browser gets.
30
+ * @param {{ get: (id: string) => object | undefined }} params.sessions - Every live session, by id.
31
+ * @param {{ of: (session: object, consumerId: string) => { activeVariantId: string | null } }} params.viewers
32
+ * @returns {object} A live session, never a disposed one.
33
+ */
34
+ export function activeOutputFor({ base, consumerId = "", sessions, viewers }) {
35
+ const named = consumerId ? base.viewers?.get(consumerId)?.activeVariantId ?? null : null;
36
+ const activeId = named ?? base.activeVariantId;
37
+ if (!activeId || activeId === base.id) {
38
+ return base;
39
+ }
40
+ const active = sessions.get(activeId);
41
+ if (!active || active.state === "disposed") {
42
+ // The rung is gone, so the viewer is watching the picture again. Said in
43
+ // both places that remember it, or the next question gets the same dead
44
+ // answer.
45
+ if (named) {
46
+ viewers.of(base, consumerId).activeVariantId = null;
47
+ }
48
+ if (base.activeVariantId === activeId) {
49
+ base.activeVariantId = base.id;
50
+ }
51
+ return base;
52
+ }
53
+ return active;
54
+ }
@@ -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
+ }