@torrent-tv/proxy 2.84.0 → 2.85.1
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 +19 -0
- package/package.json +1 -1
- package/routes/api/link-probe/get.js +51 -0
- package/routes/api/transcode-sessions/net-report/post.js +94 -64
- package/routes/transcode/session-file/get.js +268 -240
- package/server.js +10 -1
- package/services/data-channel-handler.js +6 -1
- package/services/delivery-probe.js +842 -828
- package/services/hls-session-manager.js +15 -103
- package/services/priority/PriorityOrchestrator.js +11 -7
- package/services/viewer/Viewer.js +156 -53
- package/services/viewer/active-output.js +54 -0
- package/services/viewer/report-intake.js +44 -0
- package/test/auto-quality-step.test.js +518 -514
- package/test/link-probe.test.js +56 -0
- package/test/plan-is-steady.test.js +225 -223
- package/test/priority-map-emptied.test.js +0 -2
- package/test/viewer-states.test.js +172 -0
- package/test/viewer.test.js +24 -3
|
@@ -23,7 +23,6 @@ import { speedFromReadings } from "./encoder-readings.js";
|
|
|
23
23
|
import { availableShareFrom } from "./available-share.js";
|
|
24
24
|
import { contentionPenalty } from "./encode/contention.js";
|
|
25
25
|
import { minimumBufferFrom } from "./supply-margin.js";
|
|
26
|
-
import { earliestUrgentSecond, runsOf } from "./priority/PriorityMap.js";
|
|
27
26
|
import { PriorityOrchestrator } from "./priority/PriorityOrchestrator.js";
|
|
28
27
|
import { baseDrawFrom, costPerMegabyteFrom } from "./torrent-cost.js";
|
|
29
28
|
import { medianOf, movedBeyondScatter, scatterOf } from "./learned-median.js";
|
|
@@ -89,6 +88,7 @@ import {
|
|
|
89
88
|
// — did not move when the code did.
|
|
90
89
|
export { ffmpegSeconds, onKeyframeGridFor, seekLandingOffsetFor, segmentCutTimesFrom };
|
|
91
90
|
import { viewersOf } from "./viewer/Viewer.js";
|
|
91
|
+
import { activeOutputFor } from "./viewer/active-output.js";
|
|
92
92
|
import { earliestViewerSecondsOn, viewerSecondsOn, viewerSegmentsOn } from "./viewer/positions.js";
|
|
93
93
|
import { Viewers } from "./viewer/Viewers.js";
|
|
94
94
|
import { LiveOutputs } from "./output/LiveOutputs.js";
|
|
@@ -524,7 +524,6 @@ const BUDGET_DOWNLOAD_OK_FACTOR = 1.0;
|
|
|
524
524
|
// instead. Which of the two, and whether a viewer's own pick may be moved at
|
|
525
525
|
// all, is decided where the viewer's choice lives: in the browser, which
|
|
526
526
|
// honours the request only in automatic mode.
|
|
527
|
-
const LINK_REPORT_FRESH_MS = 30_000;
|
|
528
527
|
// Usable share of the reported link (protocol overhead + measurement noise).
|
|
529
528
|
const LINK_SAFETY = 0.8;
|
|
530
529
|
// Deficit must persist this long before acting (absorbs one slow segment).
|
|
@@ -3058,54 +3057,6 @@ export class HlsSessionManager {
|
|
|
3058
3057
|
*
|
|
3059
3058
|
* @returns {Promise<void>}
|
|
3060
3059
|
*/
|
|
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, { linkMbps, bufferedAheadSec, consumerId, positionSeconds, playing, onScreen, inPictureInPicture }) {
|
|
3074
|
-
const named = this.sessionsById.get(sessionId);
|
|
3075
|
-
if (!named || named.state === "disposed") {
|
|
3076
|
-
return false;
|
|
3077
|
-
}
|
|
3078
|
-
// A throughput that is not a positive finite number is not a measurement,
|
|
3079
|
-
// and it reaches an encoder's `-maxrate` from here.
|
|
3080
|
-
if (!Number.isFinite(linkMbps) || !(linkMbps > 0) || !Number.isFinite(bufferedAheadSec)) {
|
|
3081
|
-
return false;
|
|
3082
|
-
}
|
|
3083
|
-
// The link carries the stream on screen, so the report belongs to the
|
|
3084
|
-
// variant producing it — that is the encoder whose bitrate it can bound.
|
|
3085
|
-
// Whose screen, is the reporter's own: with two viewers on two rungs, the
|
|
3086
|
-
// report of one of them says nothing about the other's encoder.
|
|
3087
|
-
const session = this.#activeVariant(named, typeof consumerId === "string" ? consumerId : "");
|
|
3088
|
-
const now = Date.now();
|
|
3089
|
-
this.viewers
|
|
3090
|
-
.of(session, typeof consumerId === "string" && consumerId.length > 0 ? consumerId : "")
|
|
3091
|
-
.report({ linkMbps, bufferedAheadSec, positionSeconds, playing, onScreen, inPictureInPicture }, now);
|
|
3092
|
-
// A stale reading must not go on deciding for the viewers still here: a
|
|
3093
|
-
// report describes a link at a moment, and a viewer who seeked since then
|
|
3094
|
-
// is somewhere else entirely.
|
|
3095
|
-
//
|
|
3096
|
-
// Only the READING expires. Whether the person is still watching is a
|
|
3097
|
-
// different question with its own answer — their connection — and a viewer
|
|
3098
|
-
// who has simply stopped reporting is not thereby gone. Answering both from
|
|
3099
|
-
// this one place is what stopped a soundtrack's encoder on 2026-09-05.
|
|
3100
|
-
for (const viewer of viewersOf(session).values()) {
|
|
3101
|
-
const report = viewer.netReport;
|
|
3102
|
-
if (report !== null && now - report.at > LINK_REPORT_FRESH_MS) {
|
|
3103
|
-
viewer.netReport = null;
|
|
3104
|
-
}
|
|
3105
|
-
}
|
|
3106
|
-
return true;
|
|
3107
|
-
}
|
|
3108
|
-
|
|
3109
3060
|
/**
|
|
3110
3061
|
* Record where one viewer of this session is, and answer with the furthest
|
|
3111
3062
|
* any of them has reached.
|
|
@@ -3153,8 +3104,8 @@ export class HlsSessionManager {
|
|
|
3153
3104
|
#worstNetReport(session, now) {
|
|
3154
3105
|
let worst = null;
|
|
3155
3106
|
for (const viewer of viewersOf(session).values()) {
|
|
3156
|
-
const report = viewer.
|
|
3157
|
-
if (report === null
|
|
3107
|
+
const report = viewer.linkReading(now);
|
|
3108
|
+
if (report === null) {
|
|
3158
3109
|
continue;
|
|
3159
3110
|
}
|
|
3160
3111
|
if (worst === null) {
|
|
@@ -3204,7 +3155,7 @@ export class HlsSessionManager {
|
|
|
3204
3155
|
// run and its own position, and that is exactly the pair this report exists
|
|
3205
3156
|
// to tell apart. Answering an audio report from the picture's records would
|
|
3206
3157
|
// state, confidently, something about the wrong stream.
|
|
3207
|
-
const onScreen =
|
|
3158
|
+
const onScreen = activeOutputFor({ base: named, sessions: this.sessionsById, viewers: this.viewers });
|
|
3208
3159
|
const session = track === "audio"
|
|
3209
3160
|
? ([...this.liveOutputs.familyOf(onScreen)].find((member) => member.audioOnly === true) ?? onScreen)
|
|
3210
3161
|
: onScreen;
|
|
@@ -3293,7 +3244,7 @@ export class HlsSessionManager {
|
|
|
3293
3244
|
*/
|
|
3294
3245
|
async #checkLinkBudget(session, now) {
|
|
3295
3246
|
const report = this.#worstNetReport(session, now);
|
|
3296
|
-
if (!report
|
|
3247
|
+
if (!report) {
|
|
3297
3248
|
session.linkSlowSince = 0; // no fresh data — old clients / stopped reporter
|
|
3298
3249
|
return false;
|
|
3299
3250
|
}
|
|
@@ -4684,7 +4635,7 @@ export class HlsSessionManager {
|
|
|
4684
4635
|
}
|
|
4685
4636
|
return false;
|
|
4686
4637
|
}
|
|
4687
|
-
const playing = this.liveOutputs.variantHeightOf(
|
|
4638
|
+
const playing = this.liveOutputs.variantHeightOf(activeOutputFor({ base, sessions: this.sessionsById, viewers: this.viewers }));
|
|
4688
4639
|
if (height === playing) {
|
|
4689
4640
|
return false;
|
|
4690
4641
|
}
|
|
@@ -4783,7 +4734,7 @@ export class HlsSessionManager {
|
|
|
4783
4734
|
return false;
|
|
4784
4735
|
}
|
|
4785
4736
|
const report = this.#worstNetReport(session, now);
|
|
4786
|
-
if (!report
|
|
4737
|
+
if (!report) {
|
|
4787
4738
|
// Nothing fresh measures the link, so it has no opinion either way — the
|
|
4788
4739
|
// same silence that stops #checkLinkBudget from acting.
|
|
4789
4740
|
return true;
|
|
@@ -4811,7 +4762,7 @@ export class HlsSessionManager {
|
|
|
4811
4762
|
// The SLOWEST link among the viewers: a step up has to be carried by all of
|
|
4812
4763
|
// them, not by whichever reported last.
|
|
4813
4764
|
const report = this.#worstNetReport(session, now);
|
|
4814
|
-
if (!report
|
|
4765
|
+
if (!report) {
|
|
4815
4766
|
return true;
|
|
4816
4767
|
}
|
|
4817
4768
|
return report.linkMbps * LINK_SAFETY >= wantedMbps;
|
|
@@ -7185,7 +7136,7 @@ export class HlsSessionManager {
|
|
|
7185
7136
|
if (!ask) {
|
|
7186
7137
|
return 0;
|
|
7187
7138
|
}
|
|
7188
|
-
if (ask.height === this.liveOutputs.variantHeightOf(
|
|
7139
|
+
if (ask.height === this.liveOutputs.variantHeightOf(activeOutputFor({ base, sessions: this.sessionsById, viewers: this.viewers }))) {
|
|
7189
7140
|
base.qualityAsk = null; // the viewer is there; nothing left to ask for
|
|
7190
7141
|
return 0;
|
|
7191
7142
|
}
|
|
@@ -7200,45 +7151,6 @@ export class HlsSessionManager {
|
|
|
7200
7151
|
return ask.height;
|
|
7201
7152
|
}
|
|
7202
7153
|
|
|
7203
|
-
/**
|
|
7204
|
-
* The variant of a session that the viewer is watching right now.
|
|
7205
|
-
*
|
|
7206
|
-
* Every request that names the base session — seek, progress, link report,
|
|
7207
|
-
* release — means the stream the viewer has on screen, and after a quality
|
|
7208
|
-
* change that is another session. The browser is not told about the swap: it
|
|
7209
|
-
* holds one session id for the whole file, which is what keeps the switch out
|
|
7210
|
-
* of the state machine on that side.
|
|
7211
|
-
*
|
|
7212
|
-
* Kept per viewer, because one picture is shared by everyone watching it and
|
|
7213
|
-
* a quality step is a session of its own: with one answer for the session, a
|
|
7214
|
-
* step taken by one viewer would move the other one's stream, and that
|
|
7215
|
-
* viewer's next seek would be forwarded to a rung they never chose. The
|
|
7216
|
-
* session's own field remains the answer for a viewer who cannot name
|
|
7217
|
-
* themselves, and the last one anybody moved to.
|
|
7218
|
-
*
|
|
7219
|
-
* @param {HlsSession} base
|
|
7220
|
-
* @param {string} [consumerId]
|
|
7221
|
-
* @returns {HlsSession}
|
|
7222
|
-
*/
|
|
7223
|
-
#activeVariant(base, consumerId = "") {
|
|
7224
|
-
const named = consumerId ? base.viewers?.get(consumerId)?.activeVariantId ?? null : null;
|
|
7225
|
-
const activeId = named ?? base.activeVariantId;
|
|
7226
|
-
if (!activeId || activeId === base.id) {
|
|
7227
|
-
return base;
|
|
7228
|
-
}
|
|
7229
|
-
const active = this.sessionsById.get(activeId);
|
|
7230
|
-
if (!active || active.state === "disposed") {
|
|
7231
|
-
if (named) {
|
|
7232
|
-
this.viewers.of(base, consumerId).activeVariantId = null;
|
|
7233
|
-
}
|
|
7234
|
-
if (base.activeVariantId === activeId) {
|
|
7235
|
-
base.activeVariantId = base.id;
|
|
7236
|
-
}
|
|
7237
|
-
return base;
|
|
7238
|
-
}
|
|
7239
|
-
return active;
|
|
7240
|
-
}
|
|
7241
|
-
|
|
7242
7154
|
/**
|
|
7243
7155
|
* Every session of this family that a live viewer has on screen.
|
|
7244
7156
|
*
|
|
@@ -7263,7 +7175,7 @@ export class HlsSessionManager {
|
|
|
7263
7175
|
onScreen.add(viewer.activeVariantId);
|
|
7264
7176
|
}
|
|
7265
7177
|
if (onScreen.size === 0) {
|
|
7266
|
-
onScreen.add(
|
|
7178
|
+
onScreen.add(activeOutputFor({ base, sessions: this.sessionsById, viewers: this.viewers }).id);
|
|
7267
7179
|
}
|
|
7268
7180
|
return onScreen;
|
|
7269
7181
|
}
|
|
@@ -7296,7 +7208,7 @@ export class HlsSessionManager {
|
|
|
7296
7208
|
if (Number.isInteger(wantedIndex) && wantedIndex >= 0) {
|
|
7297
7209
|
return this.#segmentStartTime(base, wantedIndex);
|
|
7298
7210
|
}
|
|
7299
|
-
return viewerSecondsOn(
|
|
7211
|
+
return viewerSecondsOn(activeOutputFor({ base, consumerId, sessions: this.sessionsById, viewers: this.viewers }));
|
|
7300
7212
|
}
|
|
7301
7213
|
|
|
7302
7214
|
/**
|
|
@@ -7377,7 +7289,7 @@ export class HlsSessionManager {
|
|
|
7377
7289
|
* earliest. Reading it off a viewer put a person inside the encoding layer,
|
|
7378
7290
|
* and then every question about WHICH person had to be answered a second time
|
|
7379
7291
|
* here: the earliest of two, the one on this rung, the one who has not
|
|
7380
|
-
* reported yet.
|
|
7292
|
+
* reported yet.
|
|
7381
7293
|
*
|
|
7382
7294
|
* The apparatus this replaced is worth naming so it is not rebuilt: three
|
|
7383
7295
|
* position sources ranked by priority, a function saying which had answered,
|
|
@@ -7901,7 +7813,7 @@ export class HlsSessionManager {
|
|
|
7901
7813
|
// buys it an encoder, and both halves are said here: a warmed rung is one
|
|
7902
7814
|
// this person is watching for as long as the warm-up lasts, which is why
|
|
7903
7815
|
// two encoders run through it.
|
|
7904
|
-
if (variant.id !==
|
|
7816
|
+
if (variant.id !== activeOutputFor({ base, consumerId, sessions: this.sessionsById, viewers: this.viewers }).id) {
|
|
7905
7817
|
this.viewers.of(variant, consumerId).moveTo(this.#segmentStartTime(base, index));
|
|
7906
7818
|
this.planEncodersSoon();
|
|
7907
7819
|
}
|
|
@@ -7926,7 +7838,7 @@ export class HlsSessionManager {
|
|
|
7926
7838
|
* @returns {void}
|
|
7927
7839
|
*/
|
|
7928
7840
|
#noteVariantActive(base, variant, wantedIndex = -1, consumerId = "") {
|
|
7929
|
-
const previous =
|
|
7841
|
+
const previous = activeOutputFor({ base, consumerId, sessions: this.sessionsById, viewers: this.viewers });
|
|
7930
7842
|
if (previous.id === variant.id) {
|
|
7931
7843
|
// The rung on screen asking for more of itself, which it does every few
|
|
7932
7844
|
// seconds. Nothing is being decided here — and deciding anything was the
|
|
@@ -9449,7 +9361,7 @@ export class HlsSessionManager {
|
|
|
9449
9361
|
// change is another session. Touching the named one as well is what keeps
|
|
9450
9362
|
// the family alive: only the ACTIVE variant gets segment requests, so
|
|
9451
9363
|
// without this the base session would idle out from under its own variants.
|
|
9452
|
-
const session =
|
|
9364
|
+
const session = activeOutputFor({ base: named, consumerId, sessions: this.sessionsById, viewers: this.viewers });
|
|
9453
9365
|
session.lastAccessedAt = Date.now();
|
|
9454
9366
|
const warmupTotalSeconds = this.startupWaitMs / 1000;
|
|
9455
9367
|
const warmupElapsedSeconds = Math.max(0, (Date.now() - session.startedAt) / 1000);
|
|
@@ -223,13 +223,17 @@ export class PriorityOrchestrator {
|
|
|
223
223
|
}
|
|
224
224
|
const stated = {
|
|
225
225
|
atSeconds: viewer.positionSeconds(now) ?? 0,
|
|
226
|
-
//
|
|
227
|
-
//
|
|
228
|
-
//
|
|
229
|
-
//
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
226
|
+
// WATCHING OR WAITING, not merely playing. Three states reach this
|
|
227
|
+
// one question: a viewer whose picture is advancing, a viewer
|
|
228
|
+
// blocked on material we owe them, and a viewer who stopped it
|
|
229
|
+
// themselves. The first two want the film in front of them NOW and
|
|
230
|
+
// share the upper band; only the third can wait. A page that is not
|
|
231
|
+
// on screen has its timers throttled and asks for nothing, which is
|
|
232
|
+
// indistinguishable from a full cushion, so it is not consuming
|
|
233
|
+
// either — told apart on the page, folded into one question here.
|
|
234
|
+
playing: typeof viewer.wantsFilmNow === "function"
|
|
235
|
+
? viewer.wantsFilmNow()
|
|
236
|
+
: viewer.playing === true || viewer.waiting === true
|
|
233
237
|
};
|
|
234
238
|
held.viewers.push(stated);
|
|
235
239
|
if (this.#watchedBy(session, viewer)) {
|
|
@@ -40,6 +40,15 @@
|
|
|
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
|
+
|
|
43
52
|
export class Viewer {
|
|
44
53
|
/**
|
|
45
54
|
* @param {string} id - The consumer id the browser sends with every request
|
|
@@ -149,13 +158,35 @@ export class Viewer {
|
|
|
149
158
|
* @type {Set<string>}
|
|
150
159
|
*/
|
|
151
160
|
this.wantsCuesFor = new Set();
|
|
152
|
-
//
|
|
153
|
-
//
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
//
|
|
158
|
-
|
|
161
|
+
// WHETHER THE PICTURE IS MOVING, and separately WHETHER THIS VIEWER IS
|
|
162
|
+
// BLOCKED ON US. Two booleans, because there are three states and one
|
|
163
|
+
// boolean cannot hold them:
|
|
164
|
+
//
|
|
165
|
+
// playing the picture advances, a second of film per second;
|
|
166
|
+
// waiting it does not advance because we have not delivered;
|
|
167
|
+
// neither the viewer stopped it themselves.
|
|
168
|
+
//
|
|
169
|
+
// Collapsed into one, the middle state read as the third: a viewer frozen
|
|
170
|
+
// for want of a segment counted as somebody who had chosen to stop, so
|
|
171
|
+
// nothing in front of them fell due and the work went to whoever was
|
|
172
|
+
// playing. That is backwards — a viewer waiting on us is the most urgent
|
|
173
|
+
// there is. The page tells the two apart exactly (its element says whether
|
|
174
|
+
// the picture advances, and the component owns the viewer's own pause) and
|
|
175
|
+
// states both.
|
|
176
|
+
//
|
|
177
|
+
// `playing` STARTS FALSE, and that is the whole of the fault of
|
|
178
|
+
// 2026-09-14. It used to start true, and the position is carried forward by
|
|
179
|
+
// the clock at that rate, so a viewer who had never said anything walked
|
|
180
|
+
// 146 seconds into a film they had not begun: the tab was hidden, the page
|
|
181
|
+
// deliberately held start-up until it was shown, and this end assumed the
|
|
182
|
+
// film was running the entire time. A statement about somebody else's
|
|
183
|
+
// machine is theirs to make. Until they make it the rate is zero.
|
|
184
|
+
//
|
|
185
|
+
// `waiting` STARTS TRUE, which is what a viewer arriving is: they have
|
|
186
|
+
// asked for a film and hold none of it. So their work is made first from
|
|
187
|
+
// the instant they arrive, without the position ever being invented.
|
|
188
|
+
this.playing = false;
|
|
189
|
+
this.waiting = true;
|
|
159
190
|
// Whether the page carrying this viewer is ON SCREEN, and whether the
|
|
160
191
|
// picture has been pulled out of it.
|
|
161
192
|
//
|
|
@@ -171,7 +202,9 @@ export class Viewer {
|
|
|
171
202
|
// before it could say otherwise.
|
|
172
203
|
this.onScreen = true;
|
|
173
204
|
this.inPictureInPicture = false;
|
|
174
|
-
// Seconds of film held ahead of the picture, as the page last said.
|
|
205
|
+
// Seconds of film held ahead of the picture, as the page last said. It is
|
|
206
|
+
// measured at the moment the position beside it was, which is what makes it
|
|
207
|
+
// the bound on carrying that position forward — see `positionSeconds`.
|
|
175
208
|
this.bufferedSeconds = null;
|
|
176
209
|
}
|
|
177
210
|
|
|
@@ -192,6 +225,13 @@ export class Viewer {
|
|
|
192
225
|
return;
|
|
193
226
|
}
|
|
194
227
|
this.position = { seconds, at: now };
|
|
228
|
+
// WHAT THEY HELD AT THE PLACE THEY LEFT SAYS NOTHING ABOUT THE PLACE THEY
|
|
229
|
+
// ARRIVED AT. A seek empties the buffer by construction — the player
|
|
230
|
+
// discards what it holds and fetches from the new position — so carrying
|
|
231
|
+
// the old cushion over would license the position to run forward again from
|
|
232
|
+
// material that no longer exists. Zero is the truthful floor until they say
|
|
233
|
+
// otherwise, and a viewer who has just seeked says so within a tick.
|
|
234
|
+
this.bufferedSeconds = 0;
|
|
195
235
|
this.lastSeenAt = now;
|
|
196
236
|
}
|
|
197
237
|
|
|
@@ -205,13 +245,18 @@ export class Viewer {
|
|
|
205
245
|
* places, two of them on a session shared with other people.
|
|
206
246
|
*
|
|
207
247
|
* @param {object} report
|
|
208
|
-
* @param {number} report.linkMbps
|
|
248
|
+
* @param {number} [report.linkMbps] - What their link last measured. Absent
|
|
249
|
+
* until something measurable has crossed it, and then the rest of the
|
|
250
|
+
* report still stands: this is a statement about a viewer, not about a link.
|
|
209
251
|
* @param {number} report.bufferedAheadSec
|
|
210
252
|
* @param {number | null} [report.positionSeconds] - Null from a page that
|
|
211
253
|
* does not say; then the position stands as it was.
|
|
212
|
-
* @param {boolean} [report.playing] -
|
|
213
|
-
*
|
|
214
|
-
*
|
|
254
|
+
* @param {boolean} [report.playing] - Whether the picture is advancing.
|
|
255
|
+
* Absent means it was not stated, and nothing may be assumed about somebody
|
|
256
|
+
* else's machine: the rate is then zero.
|
|
257
|
+
* @param {boolean} [report.waiting] - Whether this viewer is blocked on
|
|
258
|
+
* material we owe them. Absent from a page one release behind; then it is
|
|
259
|
+
* derived from the two facts that page does state.
|
|
215
260
|
* @param {boolean} [report.onScreen] - Whether the page is visible, or the
|
|
216
261
|
* picture is in picture-in-picture. Absent means on screen.
|
|
217
262
|
* @param {boolean} [report.inPictureInPicture] - Whether the picture has been
|
|
@@ -224,47 +269,49 @@ export class Viewer {
|
|
|
224
269
|
bufferedAheadSec,
|
|
225
270
|
positionSeconds = null,
|
|
226
271
|
playing,
|
|
272
|
+
waiting,
|
|
227
273
|
onScreen,
|
|
228
274
|
inPictureInPicture
|
|
229
275
|
},
|
|
230
276
|
now = Date.now()
|
|
231
277
|
) {
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
278
|
+
const held = Number.isFinite(bufferedAheadSec) && bufferedAheadSec > 0 ? bufferedAheadSec : 0;
|
|
279
|
+
// A LINK FIGURE IS ONE FIELD OF THIS REPORT AND NOT ITS TICKET. A page that
|
|
280
|
+
// has transferred nothing measurable has nothing to say about its link and
|
|
281
|
+
// everything to say about its viewer, and that is the cold open exactly —
|
|
282
|
+
// the moment the position matters most. Held as a precondition, in three
|
|
283
|
+
// places at once, it silenced every statement of the session of 2026-09-14.
|
|
284
|
+
if (Number.isFinite(linkMbps) && linkMbps > 0) {
|
|
285
|
+
this.netReport = {
|
|
286
|
+
linkMbps,
|
|
287
|
+
bufferedAheadSec: held,
|
|
288
|
+
positionSeconds:
|
|
289
|
+
Number.isFinite(positionSeconds) && positionSeconds >= 0 ? positionSeconds : null,
|
|
290
|
+
at: now
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
// The position first, then the cushion: `moveTo` clears what was held at
|
|
294
|
+
// wherever they were before, and this report's own figure is measured at
|
|
295
|
+
// the position this report states.
|
|
243
296
|
if (Number.isFinite(positionSeconds) && positionSeconds >= 0) {
|
|
244
297
|
this.moveTo(/** @type {number} */ (positionSeconds), now);
|
|
245
298
|
}
|
|
299
|
+
this.bufferedSeconds = held;
|
|
300
|
+
this.playing = playing === true;
|
|
301
|
+
// A page that states `playing` and not `waiting` is one release behind this
|
|
302
|
+
// proxy, which is the ordinary state of a rolling pool — the proxy ships
|
|
303
|
+
// first by rule. It says `false` for both a viewer who stopped the picture
|
|
304
|
+
// and a viewer starved of material, so the two are told apart by the one
|
|
305
|
+
// quantity it does state: a stopped viewer holding film chose to stop, a
|
|
306
|
+
// stopped viewer holding nothing is waiting on us. It errs toward making
|
|
307
|
+
// material, which is the direction that costs an encoder rather than a
|
|
308
|
+
// viewer.
|
|
309
|
+
this.waiting = waiting === undefined ? this.playing === false && held === 0 : waiting === true;
|
|
310
|
+
this.inPictureInPicture = inPictureInPicture === undefined ? false : Boolean(inPictureInPicture);
|
|
311
|
+
this.onScreen = onScreen === undefined ? true : Boolean(onScreen);
|
|
246
312
|
this.seen(now);
|
|
247
313
|
}
|
|
248
314
|
|
|
249
|
-
/**
|
|
250
|
-
* When this viewer runs out of what they hold, in milliseconds.
|
|
251
|
-
*
|
|
252
|
-
* Film is consumed at one second per second while the picture moves, so the
|
|
253
|
-
* moment they run dry is now plus what they hold. Stopped, they consume
|
|
254
|
-
* nothing and there is no such moment — which is why a pause needs no rule of
|
|
255
|
-
* its own anywhere: it falls out of this as an absent deadline.
|
|
256
|
-
*
|
|
257
|
-
* @param {number} [now]
|
|
258
|
-
* @returns {number | null}
|
|
259
|
-
*/
|
|
260
|
-
deadlineAt(now = Date.now()) {
|
|
261
|
-
if (!this.playing) {
|
|
262
|
-
return null;
|
|
263
|
-
}
|
|
264
|
-
const held = Number.isFinite(this.bufferedSeconds) ? Math.max(0, this.bufferedSeconds) : 0;
|
|
265
|
-
return now + held * 1000;
|
|
266
|
-
}
|
|
267
|
-
|
|
268
315
|
/**
|
|
269
316
|
* Whether this viewer is here.
|
|
270
317
|
*
|
|
@@ -284,20 +331,53 @@ export class Viewer {
|
|
|
284
331
|
* @returns {boolean}
|
|
285
332
|
*/
|
|
286
333
|
/**
|
|
287
|
-
*
|
|
334
|
+
* What this viewer's link was last measured to carry, or null when nothing
|
|
335
|
+
* recent measures it.
|
|
336
|
+
*
|
|
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.
|
|
342
|
+
*
|
|
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.
|
|
346
|
+
*
|
|
347
|
+
* @param {number} [now]
|
|
348
|
+
* @returns {{ linkMbps: number, bufferedAheadSec: number, positionSeconds: number | null, at: number } | null}
|
|
349
|
+
*/
|
|
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;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Whether this viewer wants film NOW — either watching it or waiting for it.
|
|
360
|
+
*
|
|
361
|
+
* The one predicate every reading about urgency asks, and it is a union of
|
|
362
|
+
* two states rather than the negation of a pause:
|
|
288
363
|
*
|
|
289
|
-
*
|
|
290
|
-
*
|
|
291
|
-
*
|
|
292
|
-
* in the priority map, because they are still there and will want it again.
|
|
364
|
+
* playing consuming, and will run dry when their cushion does;
|
|
365
|
+
* waiting consuming nothing only because we have delivered nothing;
|
|
366
|
+
* neither they stopped the picture themselves.
|
|
293
367
|
*
|
|
368
|
+
* A viewer who has stopped it keeps their place in the priority map — they
|
|
369
|
+
* are still there and will want the film in front of them again — but nothing
|
|
370
|
+
* of theirs falls due, so the work goes to whoever is watching or waiting.
|
|
371
|
+
*
|
|
372
|
+
* A page that is not on screen is not consuming: its timers are throttled and
|
|
373
|
+
* it asks for nothing, which is indistinguishable from a full cushion.
|
|
294
374
|
* Picture-in-picture is watching with the tab hidden, and the page folds that
|
|
295
|
-
* into `onScreen` before
|
|
375
|
+
* into `onScreen` before saying it, so it needs no case of its own here.
|
|
296
376
|
*
|
|
297
377
|
* @returns {boolean}
|
|
298
378
|
*/
|
|
299
|
-
|
|
300
|
-
return this.playing
|
|
379
|
+
wantsFilmNow() {
|
|
380
|
+
return (this.playing === true || this.waiting === true) && this.onScreen !== false;
|
|
301
381
|
}
|
|
302
382
|
|
|
303
383
|
isPresent() {
|
|
@@ -323,7 +403,29 @@ export class Viewer {
|
|
|
323
403
|
* since. A viewer whose picture is stopped covers nothing and stays where
|
|
324
404
|
* they are, which is the same formula with the rate at zero.
|
|
325
405
|
*
|
|
326
|
-
* position(now) = stated + rate * (now - statedAt),
|
|
406
|
+
* position(now) = stated + rate * min(now - statedAt, held),
|
|
407
|
+
* rate = playing ? 1 : 0
|
|
408
|
+
*
|
|
409
|
+
* **THE SECOND TERM IS BOUNDED BY WHAT THEY HELD**, and the bound is stated
|
|
410
|
+
* by the viewer rather than chosen here: nobody can play past the end of what
|
|
411
|
+
* they have. A viewer who said "I hold thirty seconds" and has said nothing
|
|
412
|
+
* for two minutes has played at most thirty of those seconds; the rest of the
|
|
413
|
+
* silence is a viewer who ran dry, or one we are no longer hearing from, and
|
|
414
|
+
* in both cases they are not where the clock alone would put them.
|
|
415
|
+
*
|
|
416
|
+
* Unbounded, it walks a viewer off the end of a film they never started. On
|
|
417
|
+
* 2026-09-14 the tab was hidden for 145 seconds while the page deliberately
|
|
418
|
+
* held start-up until it was shown, the rate was assumed to be one, and this
|
|
419
|
+
* end placed the viewer at 145.979 s — the exact `-ss` the soundtrack's
|
|
420
|
+
* encoder was given, while the browser was asking for segment #0 and getting
|
|
421
|
+
* 503 for sixty seconds. Their cushion was 0.0 s throughout, so this bound
|
|
422
|
+
* alone would have held them at zero whatever the rate said.
|
|
423
|
+
*
|
|
424
|
+
* The bound binds only where the clock has outrun the cushion — a report lost,
|
|
425
|
+
* or a viewer starving — and there it under-states the position. That is the
|
|
426
|
+
* safe direction and deliberately so: under-stating makes material a viewer
|
|
427
|
+
* has already passed, which costs an encoder; over-stating skips material
|
|
428
|
+
* they are about to need, which costs the viewer the picture.
|
|
327
429
|
*
|
|
328
430
|
* Held as a stored number instead, it was a staircase: flat for the ten
|
|
329
431
|
* seconds between reports and then a jump, so everything derived from it —
|
|
@@ -342,9 +444,10 @@ export class Viewer {
|
|
|
342
444
|
if (this.position === null) {
|
|
343
445
|
return null;
|
|
344
446
|
}
|
|
345
|
-
const rate = this.playing ? 1 : 0;
|
|
447
|
+
const rate = this.playing === true ? 1 : 0;
|
|
346
448
|
const elapsedSec = Math.max(0, (now - this.position.at) / 1000);
|
|
347
|
-
|
|
449
|
+
const held = Number.isFinite(this.bufferedSeconds) ? Math.max(0, this.bufferedSeconds) : 0;
|
|
450
|
+
return this.position.seconds + rate * Math.min(elapsedSec, held);
|
|
348
451
|
}
|
|
349
452
|
}
|
|
350
453
|
|
|
@@ -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,44 @@
|
|
|
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 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.
|
|
8
|
+
*
|
|
9
|
+
* It lived in the session manager, which is a description of an OUTPUT, and the
|
|
10
|
+
* two questions were tangled there: whether this is a measurement of a LINK,
|
|
11
|
+
* and whether this is a statement by a VIEWER. Required to be both, a page that
|
|
12
|
+
* had measured nothing could say nothing — see `Viewer.report`.
|
|
13
|
+
*
|
|
14
|
+
* Given the sessions and the registry as plain values, so nothing here knows
|
|
15
|
+
* how a session is found, what a variant is, or that there is an encoder.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { activeOutputFor } from "./active-output.js";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Take one report from one viewer.
|
|
22
|
+
*
|
|
23
|
+
* @param {object} params
|
|
24
|
+
* @param {{ get: (id: string) => object | undefined }} params.sessions - Every live session, by id.
|
|
25
|
+
* @param {{ of: (session: object, consumerId: string) => import("./Viewer.js").Viewer }} params.viewers
|
|
26
|
+
* @param {string} params.sessionId - The session the page addresses, which is
|
|
27
|
+
* always the picture's: the browser is not told which rung it is on.
|
|
28
|
+
* @param {object} params.report - What they said about themselves.
|
|
29
|
+
* @param {number} [params.now]
|
|
30
|
+
* @returns {boolean} False when no such session is live, which is the page
|
|
31
|
+
* reporting into one that has been disposed.
|
|
32
|
+
*/
|
|
33
|
+
export function recordViewerReport({ sessions, viewers, sessionId, report, now = Date.now() }) {
|
|
34
|
+
const named = sessions.get(sessionId);
|
|
35
|
+
if (!named || named.state === "disposed") {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
const consumerId = typeof report?.consumerId === "string" ? report.consumerId : "";
|
|
39
|
+
// The stream on screen is the reporter's own: with two viewers on two rungs,
|
|
40
|
+
// one report says nothing about the other's encoder.
|
|
41
|
+
const session = activeOutputFor({ base: named, consumerId, sessions, viewers });
|
|
42
|
+
viewers.of(session, consumerId).report(report, now);
|
|
43
|
+
return true;
|
|
44
|
+
}
|