@torrent-tv/proxy 2.84.0 → 2.85.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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";
@@ -90,6 +89,7 @@ import {
90
89
  export { ffmpegSeconds, onKeyframeGridFor, seekLandingOffsetFor, segmentCutTimesFrom };
91
90
  import { viewersOf } from "./viewer/Viewer.js";
92
91
  import { earliestViewerSecondsOn, viewerSecondsOn, viewerSegmentsOn } from "./viewer/positions.js";
92
+ import { takeViewerReport } from "./viewer/report-intake.js";
93
93
  import { Viewers } from "./viewer/Viewers.js";
94
94
  import { LiveOutputs } from "./output/LiveOutputs.js";
95
95
  import { variantHeightsFor } from "./output/ladder.js";
@@ -3070,40 +3070,22 @@ export class HlsSessionManager {
3070
3070
  * @param {{ linkMbps: number, bufferedAheadSec: number, consumerId?: string, positionSeconds?: number }} report
3071
3071
  * @returns {boolean}
3072
3072
  */
3073
- recordNetReport(sessionId, { linkMbps, bufferedAheadSec, consumerId, positionSeconds, playing, onScreen, inPictureInPicture }) {
3073
+ recordNetReport(sessionId, report) {
3074
3074
  const named = this.sessionsById.get(sessionId);
3075
3075
  if (!named || named.state === "disposed") {
3076
3076
  return false;
3077
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;
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
+ });
3107
3089
  }
3108
3090
 
3109
3091
  /**
@@ -7377,7 +7359,7 @@ export class HlsSessionManager {
7377
7359
  * earliest. Reading it off a viewer put a person inside the encoding layer,
7378
7360
  * and then every question about WHICH person had to be answered a second time
7379
7361
  * here: the earliest of two, the one on this rung, the one who has not
7380
- * reported yet. `earliestUrgentSecond` answers it once, for everything.
7362
+ * reported yet.
7381
7363
  *
7382
7364
  * The apparatus this replaced is worth naming so it is not rebuilt: three
7383
7365
  * position sources ranked by priority, a function saying which had answered,
@@ -223,13 +223,17 @@ export class PriorityOrchestrator {
223
223
  }
224
224
  const stated = {
225
225
  atSeconds: viewer.positionSeconds(now) ?? 0,
226
- // CONSUMING, not merely playing. A page that is not on screen has
227
- // its timers throttled and asks for nothing, which is exactly what
228
- // a viewer holding a full cushion looks like; the two are told
229
- // apart on the page and folded into one question here.
230
- playing: typeof viewer.consumesFilm === "function"
231
- ? viewer.consumesFilm()
232
- : viewer.playing !== false
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)) {
@@ -149,13 +149,35 @@ export class Viewer {
149
149
  * @type {Set<string>}
150
150
  */
151
151
  this.wantsCuesFor = new Set();
152
- // Whether the picture is moving. A viewer who has stopped it consumes
153
- // nothing, so nothing in front of them ever becomes due — they have no
154
- // deadline at all, and the work goes to whoever is watching. The page knows
155
- // this exactly and says it outright; inferring it from a position that has
156
- // not moved takes two reports and lies whenever a browser holding a full
157
- // cushion goes quiet between segments, which it does.
158
- this.playing = true;
152
+ // WHETHER THE PICTURE IS MOVING, and separately WHETHER THIS VIEWER IS
153
+ // BLOCKED ON US. Two booleans, because there are three states and one
154
+ // boolean cannot hold them:
155
+ //
156
+ // playing the picture advances, a second of film per second;
157
+ // waiting it does not advance because we have not delivered;
158
+ // neither the viewer stopped it themselves.
159
+ //
160
+ // Collapsed into one, the middle state read as the third: a viewer frozen
161
+ // for want of a segment counted as somebody who had chosen to stop, so
162
+ // nothing in front of them fell due and the work went to whoever was
163
+ // playing. That is backwards — a viewer waiting on us is the most urgent
164
+ // there is. The page tells the two apart exactly (its element says whether
165
+ // the picture advances, and the component owns the viewer's own pause) and
166
+ // states both.
167
+ //
168
+ // `playing` STARTS FALSE, and that is the whole of the fault of
169
+ // 2026-09-14. It used to start true, and the position is carried forward by
170
+ // the clock at that rate, so a viewer who had never said anything walked
171
+ // 146 seconds into a film they had not begun: the tab was hidden, the page
172
+ // deliberately held start-up until it was shown, and this end assumed the
173
+ // film was running the entire time. A statement about somebody else's
174
+ // machine is theirs to make. Until they make it the rate is zero.
175
+ //
176
+ // `waiting` STARTS TRUE, which is what a viewer arriving is: they have
177
+ // asked for a film and hold none of it. So their work is made first from
178
+ // the instant they arrive, without the position ever being invented.
179
+ this.playing = false;
180
+ this.waiting = true;
159
181
  // Whether the page carrying this viewer is ON SCREEN, and whether the
160
182
  // picture has been pulled out of it.
161
183
  //
@@ -171,7 +193,9 @@ export class Viewer {
171
193
  // before it could say otherwise.
172
194
  this.onScreen = true;
173
195
  this.inPictureInPicture = false;
174
- // Seconds of film held ahead of the picture, as the page last said.
196
+ // Seconds of film held ahead of the picture, as the page last said. It is
197
+ // measured at the moment the position beside it was, which is what makes it
198
+ // the bound on carrying that position forward — see `positionSeconds`.
175
199
  this.bufferedSeconds = null;
176
200
  }
177
201
 
@@ -192,6 +216,13 @@ export class Viewer {
192
216
  return;
193
217
  }
194
218
  this.position = { seconds, at: now };
219
+ // WHAT THEY HELD AT THE PLACE THEY LEFT SAYS NOTHING ABOUT THE PLACE THEY
220
+ // ARRIVED AT. A seek empties the buffer by construction — the player
221
+ // discards what it holds and fetches from the new position — so carrying
222
+ // the old cushion over would license the position to run forward again from
223
+ // material that no longer exists. Zero is the truthful floor until they say
224
+ // otherwise, and a viewer who has just seeked says so within a tick.
225
+ this.bufferedSeconds = 0;
195
226
  this.lastSeenAt = now;
196
227
  }
197
228
 
@@ -205,13 +236,18 @@ export class Viewer {
205
236
  * places, two of them on a session shared with other people.
206
237
  *
207
238
  * @param {object} report
208
- * @param {number} report.linkMbps
239
+ * @param {number} [report.linkMbps] - What their link last measured. Absent
240
+ * until something measurable has crossed it, and then the rest of the
241
+ * report still stands: this is a statement about a viewer, not about a link.
209
242
  * @param {number} report.bufferedAheadSec
210
243
  * @param {number | null} [report.positionSeconds] - Null from a page that
211
244
  * does not say; then the position stands as it was.
212
- * @param {boolean} [report.playing] - Absent from a page that does not say;
213
- * then the viewer counts as playing, which is what every page meant before
214
- * it could say otherwise.
245
+ * @param {boolean} [report.playing] - Whether the picture is advancing.
246
+ * Absent means it was not stated, and nothing may be assumed about somebody
247
+ * else's machine: the rate is then zero.
248
+ * @param {boolean} [report.waiting] - Whether this viewer is blocked on
249
+ * material we owe them. Absent from a page one release behind; then it is
250
+ * derived from the two facts that page does state.
215
251
  * @param {boolean} [report.onScreen] - Whether the page is visible, or the
216
252
  * picture is in picture-in-picture. Absent means on screen.
217
253
  * @param {boolean} [report.inPictureInPicture] - Whether the picture has been
@@ -224,47 +260,49 @@ export class Viewer {
224
260
  bufferedAheadSec,
225
261
  positionSeconds = null,
226
262
  playing,
263
+ waiting,
227
264
  onScreen,
228
265
  inPictureInPicture
229
266
  },
230
267
  now = Date.now()
231
268
  ) {
232
- this.netReport = {
233
- linkMbps,
234
- bufferedAheadSec,
235
- positionSeconds:
236
- Number.isFinite(positionSeconds) && positionSeconds >= 0 ? positionSeconds : null,
237
- at: now
238
- };
239
- this.bufferedSeconds = bufferedAheadSec;
240
- this.playing = playing === undefined ? true : Boolean(playing);
241
- this.inPictureInPicture = inPictureInPicture === undefined ? false : Boolean(inPictureInPicture);
242
- this.onScreen = onScreen === undefined ? true : Boolean(onScreen);
269
+ const held = Number.isFinite(bufferedAheadSec) && bufferedAheadSec > 0 ? bufferedAheadSec : 0;
270
+ // A LINK FIGURE IS ONE FIELD OF THIS REPORT AND NOT ITS TICKET. A page that
271
+ // has transferred nothing measurable has nothing to say about its link and
272
+ // everything to say about its viewer, and that is the cold open exactly —
273
+ // the moment the position matters most. Held as a precondition, in three
274
+ // places at once, it silenced every statement of the session of 2026-09-14.
275
+ if (Number.isFinite(linkMbps) && linkMbps > 0) {
276
+ this.netReport = {
277
+ linkMbps,
278
+ bufferedAheadSec: held,
279
+ positionSeconds:
280
+ Number.isFinite(positionSeconds) && positionSeconds >= 0 ? positionSeconds : null,
281
+ at: now
282
+ };
283
+ }
284
+ // The position first, then the cushion: `moveTo` clears what was held at
285
+ // wherever they were before, and this report's own figure is measured at
286
+ // the position this report states.
243
287
  if (Number.isFinite(positionSeconds) && positionSeconds >= 0) {
244
288
  this.moveTo(/** @type {number} */ (positionSeconds), now);
245
289
  }
290
+ this.bufferedSeconds = held;
291
+ this.playing = playing === true;
292
+ // A page that states `playing` and not `waiting` is one release behind this
293
+ // proxy, which is the ordinary state of a rolling pool — the proxy ships
294
+ // first by rule. It says `false` for both a viewer who stopped the picture
295
+ // and a viewer starved of material, so the two are told apart by the one
296
+ // quantity it does state: a stopped viewer holding film chose to stop, a
297
+ // stopped viewer holding nothing is waiting on us. It errs toward making
298
+ // material, which is the direction that costs an encoder rather than a
299
+ // viewer.
300
+ this.waiting = waiting === undefined ? this.playing === false && held === 0 : waiting === true;
301
+ this.inPictureInPicture = inPictureInPicture === undefined ? false : Boolean(inPictureInPicture);
302
+ this.onScreen = onScreen === undefined ? true : Boolean(onScreen);
246
303
  this.seen(now);
247
304
  }
248
305
 
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
306
  /**
269
307
  * Whether this viewer is here.
270
308
  *
@@ -284,20 +322,28 @@ export class Viewer {
284
322
  * @returns {boolean}
285
323
  */
286
324
  /**
287
- * Whether this viewer is CONSUMING film.
325
+ * Whether this viewer wants film NOW — either watching it or waiting for it.
288
326
  *
289
- * Two ways of not consuming, and neither is absence: the picture is stopped,
290
- * or the page is not on screen. Both mean nothing in front of them ever falls
291
- * due, so the work goes to whoever is watching — and both leave them a place
292
- * in the priority map, because they are still there and will want it again.
327
+ * The one predicate every reading about urgency asks, and it is a union of
328
+ * two states rather than the negation of a pause:
293
329
  *
330
+ * playing consuming, and will run dry when their cushion does;
331
+ * waiting consuming nothing only because we have delivered nothing;
332
+ * neither they stopped the picture themselves.
333
+ *
334
+ * A viewer who has stopped it keeps their place in the priority map — they
335
+ * are still there and will want the film in front of them again — but nothing
336
+ * of theirs falls due, so the work goes to whoever is watching or waiting.
337
+ *
338
+ * A page that is not on screen is not consuming: its timers are throttled and
339
+ * it asks for nothing, which is indistinguishable from a full cushion.
294
340
  * Picture-in-picture is watching with the tab hidden, and the page folds that
295
- * into `onScreen` before it says it, so it needs no case of its own here.
341
+ * into `onScreen` before saying it, so it needs no case of its own here.
296
342
  *
297
343
  * @returns {boolean}
298
344
  */
299
- consumesFilm() {
300
- return this.playing !== false && this.onScreen !== false;
345
+ wantsFilmNow() {
346
+ return (this.playing === true || this.waiting === true) && this.onScreen !== false;
301
347
  }
302
348
 
303
349
  isPresent() {
@@ -323,7 +369,29 @@ export class Viewer {
323
369
  * since. A viewer whose picture is stopped covers nothing and stays where
324
370
  * they are, which is the same formula with the rate at zero.
325
371
  *
326
- * position(now) = stated + rate * (now - statedAt), rate = playing ? 1 : 0
372
+ * position(now) = stated + rate * min(now - statedAt, held),
373
+ * rate = playing ? 1 : 0
374
+ *
375
+ * **THE SECOND TERM IS BOUNDED BY WHAT THEY HELD**, and the bound is stated
376
+ * by the viewer rather than chosen here: nobody can play past the end of what
377
+ * they have. A viewer who said "I hold thirty seconds" and has said nothing
378
+ * for two minutes has played at most thirty of those seconds; the rest of the
379
+ * silence is a viewer who ran dry, or one we are no longer hearing from, and
380
+ * in both cases they are not where the clock alone would put them.
381
+ *
382
+ * Unbounded, it walks a viewer off the end of a film they never started. On
383
+ * 2026-09-14 the tab was hidden for 145 seconds while the page deliberately
384
+ * held start-up until it was shown, the rate was assumed to be one, and this
385
+ * end placed the viewer at 145.979 s — the exact `-ss` the soundtrack's
386
+ * encoder was given, while the browser was asking for segment #0 and getting
387
+ * 503 for sixty seconds. Their cushion was 0.0 s throughout, so this bound
388
+ * alone would have held them at zero whatever the rate said.
389
+ *
390
+ * The bound binds only where the clock has outrun the cushion — a report lost,
391
+ * or a viewer starving — and there it under-states the position. That is the
392
+ * safe direction and deliberately so: under-stating makes material a viewer
393
+ * has already passed, which costs an encoder; over-stating skips material
394
+ * they are about to need, which costs the viewer the picture.
327
395
  *
328
396
  * Held as a stored number instead, it was a staircase: flat for the ten
329
397
  * seconds between reports and then a jump, so everything derived from it —
@@ -342,9 +410,10 @@ export class Viewer {
342
410
  if (this.position === null) {
343
411
  return null;
344
412
  }
345
- const rate = this.playing ? 1 : 0;
413
+ const rate = this.playing === true ? 1 : 0;
346
414
  const elapsedSec = Math.max(0, (now - this.position.at) / 1000);
347
- return this.position.seconds + rate * elapsedSec;
415
+ const held = Number.isFinite(this.bufferedSeconds) ? Math.max(0, this.bufferedSeconds) : 0;
416
+ return this.position.seconds + rate * Math.min(elapsedSec, held);
348
417
  }
349
418
  }
350
419
 
@@ -0,0 +1,63 @@
1
+ /**
2
+ * @file A viewer's statement about itself, taken in.
3
+ *
4
+ * The VIEWER layer's own entry point, and the whole of what happens when a page
5
+ * says where it is: the statement reaches the person it is about, and readings
6
+ * that have gone stale stop deciding for the people still here.
7
+ *
8
+ * It lived in the session manager, which is a description of an OUTPUT, and the
9
+ * two questions were tangled there: whether this is a measurement of a LINK,
10
+ * and whether this is a statement by a VIEWER. Required to be both, a page that
11
+ * had measured nothing could say nothing — see `Viewer.report`.
12
+ *
13
+ * Given the session and the registry as plain arguments, so nothing here knows
14
+ * how a session is found, what a variant is, or that there is an encoder.
15
+ */
16
+
17
+ import { viewersOf } from "./Viewer.js";
18
+
19
+ /**
20
+ * Take one report.
21
+ *
22
+ * @param {object} params
23
+ * @param {{ of: (session: object, consumerId: string) => import("./Viewer.js").Viewer }} params.viewers
24
+ * @param {object} params.session - The output this viewer has on screen.
25
+ * @param {string} params.consumerId - Who is reporting; empty when the page
26
+ * does not name itself, and then every such page shares one viewer, which is
27
+ * exactly what one unnamed browser is.
28
+ * @param {object} params.report - What they said about themselves.
29
+ * @param {number} params.now
30
+ * @param {number} params.linkFreshMs - How long a link reading describes the
31
+ * link it was measured on.
32
+ * @returns {true}
33
+ */
34
+ export function takeViewerReport({ viewers, session, consumerId, report, now, linkFreshMs }) {
35
+ viewers.of(session, consumerId).report(report, now);
36
+ forgetStaleLinkReadings({ session, now, linkFreshMs });
37
+ return true;
38
+ }
39
+
40
+ /**
41
+ * Drop link readings older than the time they describe.
42
+ *
43
+ * ONLY THE READING EXPIRES. Whether the person is still watching is a different
44
+ * question with its own answer — their connection — and a viewer who has simply
45
+ * stopped reporting is not thereby gone. Answering both from this one place is
46
+ * what stopped a soundtrack's encoder on 2026-09-05, seconds after it started
47
+ * and before it had made the `init.mp4` the picture could not be played
48
+ * without.
49
+ *
50
+ * @param {object} params
51
+ * @param {object} params.session
52
+ * @param {number} params.now
53
+ * @param {number} params.linkFreshMs
54
+ * @returns {void}
55
+ */
56
+ export function forgetStaleLinkReadings({ session, now, linkFreshMs }) {
57
+ for (const viewer of viewersOf(session).values()) {
58
+ const reading = viewer.netReport;
59
+ if (reading !== null && now - reading.at > linkFreshMs) {
60
+ viewer.netReport = null;
61
+ }
62
+ }
63
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * @file The route that hands a browser bytes to time its link with.
3
+ *
4
+ * Only this end's half. How big an ask should be is derived on the page, from
5
+ * what its last ask measured, and is checked where that function lives — a copy
6
+ * of it here would be two owners of one rule, which is the fault this
7
+ * repository keeps recording.
8
+ */
9
+
10
+ import assert from "node:assert/strict";
11
+ import test from "node:test";
12
+
13
+ import { handleApiLinkProbeGet, MAX_LINK_PROBE_BYTES } from "../routes/api/link-probe/get.js";
14
+
15
+ /**
16
+ * A reply that records what it was given.
17
+ *
18
+ * @returns {{ code: (n: number) => object, header: () => object, send: (b: unknown) => object, status: number | null, body: unknown }}
19
+ */
20
+ function fakeReply() {
21
+ const reply = {
22
+ status: /** @type {number | null} */ (null),
23
+ body: /** @type {unknown} */ (null),
24
+ code(n) {
25
+ reply.status = n;
26
+ return reply;
27
+ },
28
+ header() {
29
+ return reply;
30
+ },
31
+ send(body) {
32
+ reply.body = body;
33
+ return reply;
34
+ }
35
+ };
36
+ return reply;
37
+ }
38
+
39
+
40
+
41
+ test("the route answers exactly what was asked for, up to its own ceiling", async () => {
42
+ const small = fakeReply();
43
+ await handleApiLinkProbeGet({ query: { bytes: "65536" } }, small);
44
+ assert.equal(small.status, 200);
45
+ assert.equal(/** @type {Buffer} */ (small.body).length, 65536);
46
+
47
+ const huge = fakeReply();
48
+ await handleApiLinkProbeGet({ query: { bytes: "999999999" } }, huge);
49
+ assert.equal(/** @type {Buffer} */ (huge.body).length, MAX_LINK_PROBE_BYTES);
50
+ });
51
+
52
+ test("the route refuses an ask that is not a size", async () => {
53
+ const reply = fakeReply();
54
+ await handleApiLinkProbeGet({ query: { bytes: "none" } }, reply);
55
+ assert.equal(reply.status, 400);
56
+ });