@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 +12 -0
- package/package.json +1 -1
- package/routes/api/transcode-sessions/net-report/post.js +22 -12
- package/server.js +7 -1
- package/services/hls-session-manager.js +30 -181
- package/services/viewer/Viewer.js +28 -0
- package/services/viewer/active-output.js +54 -0
- package/services/viewer/audio-start.js +45 -0
- package/services/viewer/link-readings.js +56 -0
- package/services/viewer/report-intake.js +45 -63
- package/test/auto-quality-step.test.js +515 -514
- package/test/viewer-states.test.js +75 -24
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,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 {{
|
|
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, {
|
|
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 =
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
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 {
|
|
92
|
-
import {
|
|
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 =
|
|
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 =
|
|
3278
|
-
if (!report
|
|
3279
|
-
session.linkSlowSince = 0; //
|
|
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(
|
|
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)
|
|
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 =
|
|
4768
|
-
if (!report
|
|
4769
|
-
// Nothing
|
|
4770
|
-
//
|
|
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)
|
|
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
|
|
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
|
|
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 =
|
|
4796
|
-
if (!report
|
|
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(
|
|
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(
|
|
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(
|
|
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 !==
|
|
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 =
|
|
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:
|
|
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 =
|
|
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
|
+
}
|