@torrent-tv/proxy 2.80.14 → 2.80.16

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.
@@ -53,9 +53,22 @@ export class RunCosts {
53
53
  * that was never told to stop did not die on command, and one that produced
54
54
  * nothing has no first output — and an absent reading is not a zero.
55
55
  *
56
- * @param {{ dyingMs?: number | null, firstOutputMs?: number | null }} ended
56
+ * @param {{ dyingMs?: number | null, firstOutputMs?: number | null,
57
+ * livedMs?: number | null }} ended - `livedMs` is how long a run that
58
+ * produced NOTHING was alive, which is a lower bound on the first output.
57
59
  */
58
60
  note(ended) {
61
+ // A RUN THAT PRODUCED NOTHING IS A MEASUREMENT TOO — of a lower bound. It
62
+ // says the first output takes at least as long as this run lived, which is
63
+ // a fact and not an estimate, and it is the only reading a thrash can
64
+ // supply: every run in one is killed before it finishes anything.
65
+ //
66
+ // Without it the figure that prices a move could only ever be learned from
67
+ // runs that survived, so the state in which moves are ruinous was exactly
68
+ // the state in which their cost stayed unknown.
69
+ if (!Number.isFinite(ended?.firstOutputMs) && Number.isFinite(ended?.livedMs) && ended.livedMs > 0) {
70
+ RunCosts.#keep(this.#firstOutput, /** @type {number} */ (ended.livedMs));
71
+ }
59
72
  if (Number.isFinite(ended?.dyingMs)) {
60
73
  RunCosts.#keep(this.#dying, /** @type {number} */ (ended.dyingMs));
61
74
  }
@@ -85,9 +98,47 @@ export class RunCosts {
85
98
  * @returns {{ killCostSec: number, firstByteWaitSec: number, samples: number }}
86
99
  */
87
100
  seconds() {
101
+ const dying = middleOf(this.#dying);
102
+ const first = middleOf(this.#firstOutput);
88
103
  return {
89
- killCostSec: (middleOf(this.#dying) ?? 0) / 1000,
90
- firstByteWaitSec: (middleOf(this.#firstOutput) ?? 0) / 1000,
104
+ // UNKNOWN IS NOT ZERO, and for a cost it is not a small number either: it
105
+ // is the figure that makes the act it prices never worth doing. Reported
106
+ // as 0, an unmeasured move was FREE in the plan's arithmetic, so any gain
107
+ // however small justified it — and moving an encoder is irreversible,
108
+ // because the process it kills cannot be un-killed.
109
+ //
110
+ // The blindness was self-sustaining: `#firstOutput` only takes a reading
111
+ // from a run that produced something, and a run killed 0.8 s after
112
+ // starting produces nothing. So a thrash prevented the measurement that
113
+ // would have stopped it. Field 2026-09-08: 39 moves in one session, 24 of
114
+ // them between three adjacent numbers — #58 to #59, #59 to #58, #58 to
115
+ // #60, #60 to #58, six times each — while the picture stood still for
116
+ // 116.7 s.
117
+ //
118
+ // TWO QUESTIONS, NOT ONE, and they take the unknown differently.
119
+ //
120
+ // PLACING an encoder where there is none has no alternative: the film gets
121
+ // made or it does not. So an unmeasured cost must not stand in the way,
122
+ // and the honest figure is what has been measured or nothing.
123
+ //
124
+ // MOVING one has an alternative — leave it alone — and it is
125
+ // irreversible, because the process it kills cannot be un-killed. There
126
+ // an unmeasured cost must not license the act, and `Infinity` is the
127
+ // identity of the comparison that consumes it: "nobody has measured what
128
+ // this costs" and "never worth doing" are the same statement about an
129
+ // action whose price is unknown.
130
+ //
131
+ // Reported as 0 for both, an unmeasured move was FREE in the plan's
132
+ // arithmetic, so a gain of a fraction of a second justified it. And the
133
+ // blindness was self-sustaining: `#firstOutput` takes a reading only from
134
+ // a run that produced something, and every run in a thrash is killed
135
+ // before it finishes anything.
136
+ killCostSec: (dying ?? 0) / 1000,
137
+ firstByteWaitSec: (first ?? 0) / 1000,
138
+ moveCostSec:
139
+ first === null
140
+ ? Number.POSITIVE_INFINITY
141
+ : ((dying ?? 0) + first) / 1000,
91
142
  samples: Math.min(this.#dying.length, this.#firstOutput.length)
92
143
  };
93
144
  }
@@ -1510,7 +1510,11 @@ export class HlsSessionManager {
1510
1510
  // picture a step belongs to, the steps, the soundtracks, the height a
1511
1511
  // session is named by. Read-only over the register above, and the layer the
1512
1512
  // quality budget and the serving path both stand on.
1513
- this.liveOutputs = new LiveOutputs({ sessionsById: this.sessionsById });
1513
+ this.liveOutputs = new LiveOutputs({
1514
+ sessionsById: this.sessionsById,
1515
+ fileLengthOf: (session) => this.#fileLengthByKey.get(session.file.key) ?? 0,
1516
+ largestPieceOf: (address) => this.segmentStore.largestPiece(address)
1517
+ });
1514
1518
  // What this host learned last time it ran. Without it every restart shows
1515
1519
  // the first viewer a figure with no measurement behind it.
1516
1520
  this.#loadHostTimings();
@@ -8317,7 +8321,7 @@ export class HlsSessionManager {
8317
8321
  // the live judgement travels in `offeredHeights` and in every progress
8318
8322
  // report, and letting it decide the master's existence made a live session
8319
8323
  // answer 404 to its own published address.
8320
- ...this.liveOutputs.masterFactsOf(session, (address) => this.segmentStore.largestPiece(address)),
8324
+ ...this.liveOutputs.masterFactsOf(session),
8321
8325
  renditions,
8322
8326
  playlistFileName: PLAYLIST_FILE_NAME
8323
8327
  });
@@ -24,8 +24,14 @@ export class LiveOutputs {
24
24
  * @param {Map<string, object>} params.sessionsById - The live sessions. Read,
25
25
  * never written.
26
26
  */
27
- constructor({ sessionsById }) {
27
+ constructor({ sessionsById, fileLengthOf = () => 0, largestPieceOf = () => ({ index: -1, size: 0 }) }) {
28
28
  this.sessionsById = sessionsById;
29
+ // Two facts this layer needs and does not own: how many bytes a source file
30
+ // is, which the torrent reports, and the biggest piece an output has made,
31
+ // which the disk knows. Taken as plain functions, so nothing of either layer
32
+ // is held here.
33
+ this.fileLengthOf = fileLengthOf;
34
+ this.largestPieceOf = largestPieceOf;
29
35
  }
30
36
 
31
37
  /**
@@ -278,21 +284,25 @@ export class LiveOutputs {
278
284
  * the asking VIEWER'S business, and that belongs to whoever holds viewers.
279
285
  *
280
286
  * @param {object} session
281
- * @param {(address: string) => { index: number, size: number }} largestPiece -
282
- * The biggest piece an output has made, asked of whoever owns the disk. A
283
- * plain function, so this layer holds no store.
284
287
  * @returns {object}
285
288
  */
286
- masterFactsOf(session, largestPiece) {
289
+ masterFactsOf(session) {
287
290
  return {
288
291
  playlistVersion: session.segmentFormat.playlistVersion,
289
292
  heights: this.splicableHeights(session),
290
293
  sourceWidth: Number(session.file?.width) || 0,
291
294
  sourceHeight: Number(session.file?.height) || 0,
292
295
  ...masterRateArgs({
293
- fileLength: Number(session.file?.length) || 0,
296
+ // FROM WHOEVER KNOWS IT. A source file does not carry its own byte
297
+ // length — the torrent reports it — and reading a field that does not
298
+ // exist is what declared every variant at the floor in 2.80.14.
299
+ fileLength: Number(this.fileLengthOf(session)) || 0,
294
300
  durationSeconds: Number(session.file?.durationSeconds) || 0,
295
- largest: largestPiece(session.outputKey ?? ""),
301
+ // The probe's own reading of the video stream, which is known from the
302
+ // moment the session exists and covers the gap before the torrent has
303
+ // reported a length.
304
+ streamBitsPerSecond: (Number(session.file?.decode?.megabitsPerSecond) || 0) * 1_000_000,
305
+ largest: this.largestPieceOf(session.outputKey ?? ""),
296
306
  boundaries: session.timeline?.published ?? session.timeline?.boundaries ?? null,
297
307
  producedHeight: this.producedHeightOf(session),
298
308
  capKbps: Number(session.rateCapKbps) || 0
@@ -21,21 +21,49 @@
21
21
  * The two rates to declare, from what has been measured of this file.
22
22
  *
23
23
  * @param {object} params
24
- * @param {number} params.fileLength - Bytes of the source file.
24
+ * @param {number} params.fileLength - Bytes of the source file, 0 until the
25
+ * torrent has said. Counts every track and every byte of container, so it is
26
+ * the better figure of the two.
25
27
  * @param {number} params.durationSeconds
28
+ * @param {number} [params.streamBitsPerSecond] - What a probe read off the
29
+ * video stream, known from session creation. Lower than the whole file's rate
30
+ * because it is one track of it, and used while the length is not known.
26
31
  * @param {{ index: number, size: number }} [params.largest] - The biggest piece
27
32
  * produced so far, with its number. An index of `-1` means none yet.
28
33
  * @param {number[]} [params.boundaries] - Where the file is cut, so the biggest
29
34
  * piece's own span is known. Bytes alone cannot give a rate.
30
35
  * @returns {{ averageBitsPerSecond: number, peakOverAverage: number }}
31
36
  */
32
- export function declaredRates({ fileLength, durationSeconds, largest = null, boundaries = null }) {
37
+ export function declaredRates({
38
+ fileLength,
39
+ durationSeconds,
40
+ streamBitsPerSecond = 0,
41
+ largest = null,
42
+ boundaries = null
43
+ }) {
33
44
  const length = Number(fileLength);
34
45
  const duration = Number(durationSeconds);
35
- if (!(length > 0) || !(duration > 0)) {
46
+ // TWO SOURCES, AND THE LARGER OF THEM, because each is measured and each can
47
+ // be absent — and absent is 0, so the larger is whichever was measured.
48
+ //
49
+ // - the whole file's length over its duration: the best figure, since it
50
+ // counts every track and every byte of container. Known only once the
51
+ // torrent has reported the file's size;
52
+ // - what the probe read off the video stream: known when the session is
53
+ // created, and lower, because it is one track of several.
54
+ //
55
+ // The first alone was what shipped in 2.80.14, read from a field that does
56
+ // not exist on a source file. It came back 0 on every session, so every
57
+ // variant was declared at the 400 kbit/s floor — nine times WORSE than the
58
+ // rule of thumb it replaced — and the browser, which sizes its cushion in
59
+ // bytes from this figure, held 0.1 s of film against 120 s asked. The picture
60
+ // stood still for 116.7 s of one viewing. Assuming a field instead of
61
+ // checking it is the whole of that fault.
62
+ const fromLength = length > 0 && duration > 0 ? (length * 8) / duration : 0;
63
+ const averageBitsPerSecond = Math.max(fromLength, Number(streamBitsPerSecond) || 0);
64
+ if (!(averageBitsPerSecond > 0)) {
36
65
  return { averageBitsPerSecond: 0, peakOverAverage: 1 };
37
66
  }
38
- const averageBitsPerSecond = (length * 8) / duration;
39
67
  const index = Number(largest?.index);
40
68
  const size = Number(largest?.size);
41
69
  if (!Number.isInteger(index) || index < 0 || !(size > 0) || !Array.isArray(boundaries)) {
@@ -73,6 +101,7 @@ export function declaredRates({ fileLength, durationSeconds, largest = null, bou
73
101
  * @param {object} params
74
102
  * @param {number} params.fileLength
75
103
  * @param {number} params.durationSeconds
104
+ * @param {number} [params.streamBitsPerSecond]
76
105
  * @param {{ index: number, size: number } | null} [params.largest]
77
106
  * @param {number[] | null} [params.boundaries]
78
107
  * @param {number} [params.producedHeight] - The height this output encodes at.
@@ -83,13 +112,14 @@ export function declaredRates({ fileLength, durationSeconds, largest = null, bou
83
112
  export function masterRateArgs({
84
113
  fileLength,
85
114
  durationSeconds,
115
+ streamBitsPerSecond = 0,
86
116
  largest = null,
87
117
  boundaries = null,
88
118
  producedHeight = 0,
89
119
  capKbps = 0
90
120
  }) {
91
121
  return {
92
- ...declaredRates({ fileLength, durationSeconds, largest, boundaries }),
122
+ ...declaredRates({ fileLength, durationSeconds, streamBitsPerSecond, largest, boundaries }),
93
123
  capKbpsFor: (height) => (height === producedHeight ? capKbps : 0)
94
124
  };
95
125
  }
@@ -367,9 +367,15 @@ test("the one machine goes to whoever is due soonest, not to the smallest number
367
367
  ...HOST,
368
368
  maxRuns: 2
369
369
  });
370
- const started = actions.filter((action) => action.type === "start").map((action) => action.from);
371
-
372
- assert.deepEqual(started, [500], "the one machine goes where somebody is stopped");
370
+ // WHERE the machine ends up, not how it got there. A run standing at #900
371
+ // reaches nothing anybody wants, so it is taken to #500 rather than killed and
372
+ // replaced one process instead of a death and a cold start, which is
373
+ // strictly better and is what the plan now answers.
374
+ const placed = actions
375
+ .filter((action) => action.type === "start" || action.type === "move")
376
+ .map((action) => action.from);
377
+
378
+ assert.deepEqual(placed, [500], "the one machine goes where somebody is stopped");
373
379
  });
374
380
 
375
381
  test("two viewers far apart are both served, by however many encoders serve them soonest", () => {
@@ -0,0 +1,145 @@
1
+ /**
2
+ * @file What moving a running encoder costs, and what the plan does while
3
+ * nobody has measured it.
4
+ *
5
+ * Field 2026-09-08: 39 moves in one session, 24 of them between three adjacent
6
+ * numbers — #58 to #59, #59 to #58, #58 to #60, #60 to #58, six times each,
7
+ * about 0.8 s apart — while the viewer's picture stood still for 116.7 s in
8
+ * three interruptions, the worst of them 91.8 s. The zone the viewer's own
9
+ * position defines slides forward one number at a time, and every slide made
10
+ * standing one number behind it score worse than standing in it.
11
+ *
12
+ * THREE FAULTS IN THE ARITHMETIC, and no threshold anywhere. There was one for
13
+ * a while — "a move must beat staying by at least what moving costs" — and it
14
+ * was a prop under a comparison that was wrong rather than indifferent. It is
15
+ * gone.
16
+ *
17
+ * 1. **A body was charged a whole piece for the one it was already making.**
18
+ * `arrival = delay + (index - at + 1) / rate` is right for a body that does
19
+ * not exist yet and wrong for a run 0.8 s into a 0.9 s piece. `delaySec` is
20
+ * now when the body finishes the piece it STANDS ON, so from #58 reaching #59
21
+ * costs 0.14 + 0.94 = 1.08 s against 1.88 s for a kill and a cold start — a
22
+ * decision by eight hundred milliseconds, where the double charge had made it
23
+ * a coin flip lost by ten.
24
+ * 2. **The piece was priced at the unpenalised rate** while arrivals used the
25
+ * penalised one, so every extra body looked cheaper than it is and the plan
26
+ * bought a second encoder where one served. One rate, the one in force.
27
+ * 3. **`withinSeconds: null` was read through `Number()`**, where it is 0, so
28
+ * the film BEHIND the viewers was due immediately and was the most urgent
29
+ * material in the file. It bought encoders and it took the run standing in
30
+ * front of the viewer, because that run was the nearest body to it.
31
+ *
32
+ * And what a move costs is `Infinity` until something has been measured, because
33
+ * a move is irreversible while leaving the encoder alone is always available. A
34
+ * run killed before producing anything is a measurement too — a lower bound on
35
+ * the first output — which is the only reading a thrash can supply.
36
+ */
37
+
38
+ import test from "node:test";
39
+ import assert from "node:assert/strict";
40
+ import { RunCosts } from "../services/encode/run-costs.js";
41
+ import { planEncoders } from "../services/encode/EncodePlan.js";
42
+ import { CoverageMap } from "../services/encode/CoverageMap.js";
43
+
44
+ test("nothing measured means a move is refused, not priced at zero", () => {
45
+ const costs = new RunCosts();
46
+
47
+ const { moveCostSec, firstByteWaitSec, killCostSec } = costs.seconds();
48
+ assert.equal(moveCostSec, Number.POSITIVE_INFINITY, "moving is not free while unpriced");
49
+ // Placing one where there is none is the OTHER question, and it has no
50
+ // alternative: the film gets made or it does not.
51
+ assert.equal(firstByteWaitSec, 0, "placing an encoder is not blocked by an unknown price");
52
+ assert.equal(killCostSec, 0);
53
+ });
54
+
55
+ test("a run killed before producing anything is a lower bound on the first output", () => {
56
+ const costs = new RunCosts();
57
+
58
+ // Exactly what a thrash supplies: a run that lived 800 ms and finished
59
+ // nothing. It says the first output takes AT LEAST that long, which is a fact.
60
+ costs.note({ livedMs: 800, dyingMs: 40 });
61
+
62
+ const { moveCostSec } = costs.seconds();
63
+ assert.ok(Number.isFinite(moveCostSec), "one killed run is enough to stop the blindness");
64
+ assert.ok(Math.abs(moveCostSec - 0.84) < 0.001, `got ${moveCostSec}`);
65
+ });
66
+
67
+ test("a run that produced something is measured by its first output, not its life", () => {
68
+ const costs = new RunCosts();
69
+
70
+ costs.note({ livedMs: 60_000, firstOutputMs: 900, dyingMs: 100 });
71
+
72
+ const { moveCostSec, firstByteWaitSec } = costs.seconds();
73
+ assert.ok(Math.abs(firstByteWaitSec - 0.9) < 0.001, `got ${firstByteWaitSec}`);
74
+ assert.ok(Math.abs(moveCostSec - 1.0) < 0.001, `got ${moveCostSec}`);
75
+ });
76
+
77
+ test("the zone sliding one number does not move an encoder that is already reaching it", () => {
78
+ // The field shape exactly: a run standing at #58 with the viewer's urgent zone
79
+ // sliding #58..#59 → #59..#60. Driving through one segment costs the encoder a
80
+ // fraction of a second; moving costs a kill and a cold start.
81
+ const coverage = new CoverageMap();
82
+ coverage.setSegmentCount(482);
83
+ const run = { from: 58, to: 481, head: 58, speedX: 4.45, isAlive: true };
84
+ coverage.claim(run, 58, 481);
85
+
86
+ const actions = planEncoders({
87
+ coverage,
88
+ windows: [
89
+ { from: 0, to: 57, priority: 1, withinSeconds: null, behind: true },
90
+ { from: 59, to: 60, priority: 100, withinSeconds: 0, behind: false }
91
+ ],
92
+ runs: [run],
93
+ maxRuns: 3,
94
+ segmentSeconds: 4.2,
95
+ speedX: 4.45,
96
+ // Measured on this host: killing takes 40 ms, a fresh encoder's first piece
97
+ // 900 ms. Against that, driving one segment at 4.45x costs 0.94 s — so the
98
+ // two are close, and what settles it is that the move ALSO has to encode
99
+ // the same segment afterwards.
100
+ killCostSec: 0.04,
101
+ firstByteWaitSec: 0.9,
102
+ moveCostSec: 0.94,
103
+ refetchSecPerFilmSecond: 0,
104
+ contentionPenaltyFor: () => 1
105
+ });
106
+
107
+ assert.deepEqual(
108
+ actions.filter((one) => one.type === "move"),
109
+ [],
110
+ "a run one number behind the zone is already on its way into it"
111
+ );
112
+ });
113
+
114
+ test("a move that genuinely saves the viewer time still happens", () => {
115
+ // The other half: the viewer jumped fourteen segments ahead, and driving there
116
+ // at 4.45x would take 13 s while a cold start takes 0.94 s. Refusing this
117
+ // would be the opposite fault.
118
+ const coverage = new CoverageMap();
119
+ coverage.setSegmentCount(482);
120
+ const run = { from: 0, to: 481, head: 44, speedX: 4.45, isAlive: true };
121
+ coverage.claim(run, 0, 481);
122
+
123
+ const actions = planEncoders({
124
+ coverage,
125
+ windows: [{ from: 58, to: 59, priority: 100, withinSeconds: 0, behind: false }],
126
+ runs: [run],
127
+ maxRuns: 3,
128
+ segmentSeconds: 4.2,
129
+ speedX: 4.45,
130
+ killCostSec: 0.04,
131
+ firstByteWaitSec: 0.9,
132
+ moveCostSec: 0.94,
133
+ refetchSecPerFilmSecond: 0,
134
+ contentionPenaltyFor: () => 1
135
+ });
136
+
137
+ // WHERE the encoder ends up, not how it got there: the run at #44 reaches
138
+ // nothing anybody waits for, so the plan may either take it to #58 or stop it
139
+ // and start one there. Both are one process at #58, and which is cheaper is
140
+ // the measured difference between a kill and a cold start.
141
+ const placed = actions
142
+ .filter((one) => one.type === "move" || one.type === "start")
143
+ .map((one) => one.from);
144
+ assert.deepEqual(placed, [58], "fourteen segments of driving is worth a cold start");
145
+ });