@torrent-tv/proxy 2.78.0 → 2.80.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.
@@ -27,6 +27,8 @@ import { CoverageMap } from "../encode/CoverageMap.js";
27
27
  import { firstUnmetWant, planEncoders } from "../encode/EncodePlan.js";
28
28
  import { endOfRun } from "../encode/EncodeRun.js";
29
29
  import { ENCODE_EXIT } from "../encode/encode-exit.js";
30
+ import { affordableRuns } from "../encode/run-budget.js";
31
+ import { RunCosts } from "../encode/run-costs.js";
30
32
  import { SegmentDemand } from "../encode/SegmentDemand.js";
31
33
 
32
34
  export class EncodeOrchestrator {
@@ -36,9 +38,19 @@ export class EncodeOrchestrator {
36
38
  /** Output address to the runs on it. @type {Map<string, import("../encode/EncodeRun.js").EncodeRun[]>} */
37
39
  #runs = new Map();
38
40
 
41
+
39
42
  /** How runs have ended, by cause. @type {Map<string, number>} */
40
43
  #endings = new Map();
41
44
 
45
+ /** The last state said out loud, so an unchanged state is not repeated. */
46
+ #lastDescribed = "";
47
+
48
+ /** What a stop and a start have cost on this host. */
49
+ #costs = new RunCosts();
50
+
51
+ /** The last reason a budget was cut, so the same one is not said twice. */
52
+ #lastBudgetReason = new Map();
53
+
42
54
  /**
43
55
  * @param {object} params
44
56
  * @param {(address: string) => number} params.maxRunsFor - How many encoders
@@ -53,17 +65,31 @@ export class EncodeOrchestrator {
53
65
  * @param {{ info: (line: string) => void, warn: (line: string) => void }} params.logger
54
66
  * @param {() => number} [params.now]
55
67
  */
56
- constructor({ maxRunsFor, makeRun, segmentSeconds, restartCostSec, lookaheadSegments = 0, logger, now }) {
68
+ constructor({
69
+ maxRunsFor,
70
+ makeRun,
71
+ segmentSeconds,
72
+ restartCostSec,
73
+ refetchSecPerFilmSecond = () => 0,
74
+ segmentStore = null,
75
+ logger,
76
+ now
77
+ }) {
78
+ // The store of produced segments — the layer below this one. It is asked to
79
+ // clean up after a run that ended other than by reaching the end of its
80
+ // stretch, which is the one thing an ending must not leave behind: a file
81
+ // under a name that promises a whole segment.
82
+ this.segmentStore = segmentStore;
57
83
  this.demand = new SegmentDemand();
58
84
  this.maxRunsFor = maxRunsFor;
85
+ // Seconds of swarm time per second of film: what re-encoding material that
86
+ // already exists costs the download, over and above the encoder's own time.
87
+ // Injected, because the film's byte rate and the swarm's are measured
88
+ // elsewhere and this class must not reach for them.
89
+ this.refetchSecPerFilmSecond = refetchSecPerFilmSecond;
59
90
  this.makeRun = makeRun;
60
91
  this.segmentSeconds = segmentSeconds;
61
92
  this.restartCostSec = restartCostSec;
62
- // How far in front of its viewer a run is allowed to get. It is what bounds
63
- // the claim of a run that was given no end — see #claimFor.
64
- this.lookaheadSegments = Number.isFinite(lookaheadSegments) && lookaheadSegments > 0
65
- ? Math.ceil(lookaheadSegments)
66
- : 0;
67
93
  this.logger = logger;
68
94
  this.now = typeof now === "function" ? now : Date.now;
69
95
  }
@@ -178,6 +204,19 @@ export class EncodeOrchestrator {
178
204
  for (const address of addresses) {
179
205
  this.#reconcileOne(address);
180
206
  }
207
+ // WHAT THIS CLASS BELIEVES, said by this class. `describe()` was written
208
+ // and called from nowhere, so on 2026-09-05 the question "why did the plan
209
+ // not see the gap the viewer was stopped at" had to be answered by
210
+ // inference from start and stop lines, and was not answered at all.
211
+ //
212
+ // Printed on CHANGE rather than on a timer: a quiet session says nothing, a
213
+ // session that is deciding something says what it decided, and there is no
214
+ // interval to choose.
215
+ const state = this.describe();
216
+ if (state !== this.#lastDescribed) {
217
+ this.#lastDescribed = state;
218
+ this.logger.info(state);
219
+ }
181
220
  }
182
221
 
183
222
  /**
@@ -218,9 +257,17 @@ export class EncodeOrchestrator {
218
257
  // each; what it hands back names the run by BEING it, so nothing has to
219
258
  // invent a token to refer to one by.
220
259
  runs: live,
221
- maxRuns: Math.max(0, this.maxRunsFor(address)),
260
+ maxRuns: this.#affordableOn(address, live),
222
261
  segmentSeconds: this.segmentSeconds,
223
- restartCostSec: this.restartCostSec
262
+ restartCostSec: this.restartCostSec,
263
+ // Measured from this host's own runs, rather than written into the code
264
+ // from one machine's reading.
265
+ ...this.#costs.seconds(),
266
+ // What a second of film costs to fetch again, in seconds of swarm time.
267
+ // Answered by whoever measures the film's own byte rate and the swarm's;
268
+ // zero until they have, which makes driving through look cheaper than it
269
+ // is and is stated here so the bias is known.
270
+ refetchSecPerFilmSecond: this.refetchSecPerFilmSecond(address)
224
271
  });
225
272
 
226
273
  for (const action of actions) {
@@ -254,24 +301,68 @@ export class EncodeOrchestrator {
254
301
  * @param {string} because
255
302
  */
256
303
  #start(address, from, to, because) {
304
+ // The encoder is built here and now: whoever builds one waits for nothing,
305
+ // so it exists by the time this line returns. That is what makes the
306
+ // stretch held from this instant — this class knows what it is making
307
+ // because it has just made it, and no second encoder can be started for the
308
+ // same stretch on the next pass.
309
+ //
310
+ // It was not always so. The builder used to answer with nothing and start
311
+ // the encoder behind the answer, so the stretch stayed FREE for as long as
312
+ // that took, and every pass in between started another one: 684 starts in
313
+ // 482 seconds of field 2026-09-05, of which 973 answers said the encoder
314
+ // was not there yet — every start without exception.
315
+ //
257
316
  // The run names itself: identity is a property of the thing, and two
258
317
  // places minting names is how one stops being unique.
259
318
  const run = this.makeRun({ address, from, to });
260
319
  if (!run) {
261
- // Not necessarily a failure: making an encoder can take a probe and a
262
- // keyframe read, and this call is arithmetic that must not wait on
263
- // either. Whoever builds one answers with nothing while it is on its way,
264
- // and the next pass sees it.
265
- this.logger.info(`encode: an encoder for #${from}..#${to} of ${address} is not there yet`);
320
+ // A refusal, not a wait: no session serves this output, or this position
321
+ // has failed to start too many times running.
322
+ this.logger.warn(`encode: no encoder could be made for #${from}..#${to} of ${address}`);
266
323
  return;
267
324
  }
268
325
  const onThisOutput = this.#runs.get(address) ?? [];
269
326
  onThisOutput.push(run);
270
327
  this.#runs.set(address, onThisOutput);
271
- this.coverageOf(address).claim(run, from, to);
328
+ this.coverageOf(address).claim(run, from, endOfRun({ from, to }));
272
329
  run.start(because);
273
330
  }
274
331
 
332
+ /**
333
+ * How many encoders may run on this output, from every limit at once.
334
+ *
335
+ * The processor is one of them and is answered from outside, where the
336
+ * machine is measured. The other two are known here: what the swarm delivers,
337
+ * through the seconds of swarm time a second of film costs, and — once it is
338
+ * supplied — the memory the piece store may hold against what one encoder's
339
+ * reader keeps.
340
+ *
341
+ * Said out loud when it is not the processor that decided, because "why is
342
+ * there only one encoder" is otherwise a question no log can answer.
343
+ *
344
+ * @param {string} address
345
+ * @param {{ speedX: number }[]} live
346
+ * @returns {number}
347
+ */
348
+ #affordableOn(address, live) {
349
+ const byProcessor = Math.max(0, this.maxRunsFor(address));
350
+ const fastest = live.reduce((best, run) => Math.max(best, run.speedX || 0), 0);
351
+ const budget = affordableRuns({
352
+ byProcessor,
353
+ speedX: fastest,
354
+ refetchSecPerFilmSecond: this.refetchSecPerFilmSecond(address)
355
+ });
356
+ if (budget.runs !== byProcessor && budget.because !== this.#lastBudgetReason.get(address)) {
357
+ this.#lastBudgetReason.set(address, budget.because);
358
+ this.logger.info(
359
+ `encode: ${budget.runs} encoder(s) on ${address.slice(0, 60)} — ${budget.because} ` +
360
+ `(the processor alone would allow ${byProcessor})`
361
+ );
362
+ }
363
+ return budget.runs;
364
+ }
365
+
275
366
  /**
276
367
  * Take charge of a run this class did not start.
277
368
  *
@@ -326,8 +417,18 @@ export class EncodeOrchestrator {
326
417
  coverage.claim(run, from, end);
327
418
  return;
328
419
  }
420
+ // A RUN WITH NO END HOLDS WHAT IT HAS MADE, NOT WHAT IT MIGHT MAKE.
421
+ //
422
+ // "No end" means the film's length is not known, so there is no last number
423
+ // to claim towards. Claiming the rest of the film would leave a viewer who
424
+ // opens the same film further in with every number taken and no encoder at
425
+ // all. Claiming a fixed distance in front of the head — which is what this
426
+ // did — needs a number nobody measured, and the number it used was the
427
+ // suspended-encoder threshold that no longer exists.
428
+ //
429
+ // What it has made is a fact, and it is the only one available here.
329
430
  const head = Number.isFinite(run?.head) ? run.head : from;
330
- coverage.claim(run, from, Math.max(from, head + this.lookaheadSegments));
431
+ coverage.claim(run, from, Math.max(from, head));
331
432
  }
332
433
 
333
434
  /**
@@ -348,6 +449,15 @@ export class EncodeOrchestrator {
348
449
  * @param {import("../encode/EncodeRun.js").RunEnded} ended
349
450
  */
350
451
  noteEnded(ended) {
452
+ this.#costs.note(ended);
453
+ // Exactly one ending is normal — the run reached the end of the stretch it
454
+ // was given and closed its last file. Every other leaves a piece open, and
455
+ // that file's name is indistinguishable from a finished one's.
456
+ if (ended.ending !== ENCODE_EXIT.COMPLETE && this.segmentStore) {
457
+ void this.segmentStore
458
+ .discardOpenPieceOf(ended.address, { from: ended.from, to: ended.to })
459
+ .catch(() => {});
460
+ }
351
461
  this.coverageOf(ended.address).release(ended.run);
352
462
  const remaining = this.runsOn(ended.address).filter((run) => run !== ended.run);
353
463
  if (remaining.length === 0) {
@@ -388,14 +498,21 @@ export class EncodeOrchestrator {
388
498
  const parts = [];
389
499
  for (const address of new Set([...this.demand.addresses(), ...this.#runs.keys()])) {
390
500
  const coverage = this.coverageOf(address);
391
- const windows = this.demand.windowsOn(address).map((w) => ({ from: w.from, to: w.to }));
501
+ const stated = this.demand.windowsOn(address);
502
+ const windows = stated.map((w) => ({ from: w.from, to: w.to }));
392
503
  const waiting = firstUnmetWant(coverage, windows);
393
504
  const runs = this.runsOn(address)
394
505
  .map((run) => `#${run.head}..#${run.to}@${run.speedX.toFixed(1)}x`)
395
506
  .join(" ");
507
+ // The zones as they were stated, with their order, so a plan that is
508
+ // working at the wrong end of the film is visible rather than inferred.
509
+ const zones = [...stated]
510
+ .sort((left, right) => (right.priority ?? 0) - (left.priority ?? 0) || left.from - right.from)
511
+ .map((w) => `p${w.priority ?? 0}:#${w.from}..#${w.to}`)
512
+ .join(" ");
396
513
  parts.push(
397
514
  `${address.slice(0, 60)} ready=${coverage.stats().ready} ` +
398
- `viewers=${windows.length} runs=[${runs}] ` +
515
+ `zones=[${zones}] runs=[${runs}] ` +
399
516
  `waiting=${waiting === null ? "nobody" : `#${waiting}`}`
400
517
  );
401
518
  }
@@ -1,6 +1,11 @@
1
1
  /**
2
- * @file What one viewer needs, what all of them need together, and in what
3
- * order the work should be taken.
2
+ * @file THE PRIORITY MAP — what one viewer needs, what all of them need
3
+ * together, and in what order the work should be taken.
4
+ *
5
+ * A layer of its own, below both orchestrators and depending on nothing. It
6
+ * knows ONLY priorities: what is downloaded is the download orchestrator's own
7
+ * knowledge, what is encoded is the encoding orchestrator's, and neither is
8
+ * visible from here. Both read this and recompute their own on every change.
4
9
  *
5
10
  * The shape, stated by the user 2026-09-05:
6
11
  *
@@ -49,32 +54,44 @@
49
54
  */
50
55
 
51
56
  /** Where the viewer stands. Nothing outranks it. */
52
- const AT_THE_VIEWER = 3;
57
+ const AT_THE_VIEWER = 33;
53
58
 
54
59
  /** In front of them, within reach while they watch what is already made. */
55
- const IN_FRONT = 2;
60
+ const IN_FRONT = 32;
56
61
 
57
62
  /** The rest of the track: wanted, because the file is encoded whole. */
58
63
  const THE_REST = 1;
59
64
 
60
65
  /**
61
- * One viewer's map.
62
- *
63
- * Three zones, and the two boundaries between them are measured rather than
64
- * chosen:
65
- *
66
- * 1. **what must be ready before they set off, so that they never stop.** While
67
- * they watch, film is consumed at one second per second and produced at
68
- * `encodeSpeedX`. Above realtime the encoder gains on them, and all that is
69
- * needed in front is the measured allowance for unevenness in the swarm and
70
- * in production. Below realtime it LOSES `1 - speed` of a second for every
71
- * second played, so over the film in front of them the shortfall is
72
- * `remaining × (1 - speed)` — at 0.5x on twenty minutes ahead, ten minutes
73
- * must exist before they start, or they meet a stall partway through;
74
- * 2. **what the machine reaches while they watch zone 1** — in front of a
75
- * moving viewer, so ahead of the rest, and nobody is waiting for it yet, so
76
- * behind zone 1;
77
- * 3. **the rest of the track.**
66
+ * How many zones one viewer's map may hold.
67
+ *
68
+ * Below realtime the zones grow geometrically, so their number is the logarithm
69
+ * of the film left over what the viewer holds — five on the addon host's worst
70
+ * measured case, and it grows by one each time the speed halves. The bound is
71
+ * not a policy about how many encoders may run (the machine's budget answers
72
+ * that, and it is far smaller); it stops a speed measured at almost zero from
73
+ * turning a film into thousands of slivers before anything reads the map.
74
+ */
75
+ const MOST_ZONES = 32;
76
+
77
+ /**
78
+ * One viewer's map, and every boundary in it is measured rather than chosen.
79
+ *
80
+ * The shape depends on one measured number how fast this machine encodes this
81
+ * track against realtime:
82
+ *
83
+ * - **at or above realtime** the encoder gains on the viewer everywhere, so one
84
+ * of them holds the whole film. Three zones: the measured allowance in front
85
+ * of the viewer, what the encoder reaches while they watch it, and the rest;
86
+ * - **below realtime** the encoder loses `1 - speed` of a second for every
87
+ * second played, so one cannot hold the film and the map says how many can.
88
+ * An encoder starting at `q` stays ahead of a viewer at `p` for
89
+ * `(q - p) * s / (1 - s)`, which GROWS with its distance from them — so the
90
+ * zones grow, each is one encoder's share, and their number is the smallest
91
+ * that holds this viewer.
92
+ *
93
+ * The last zone is always the rest of the track, wanted because the file is
94
+ * encoded whole and last because nobody is waiting on it.
78
95
  *
79
96
  * @param {object} params
80
97
  * @param {number} params.atSeconds - Where they are watching from.
@@ -93,28 +110,76 @@ export function mapForViewer({ atSeconds, durationSeconds, allowanceSeconds, enc
93
110
  if (!(end > from)) {
94
111
  return [];
95
112
  }
96
- const remaining = end - from;
97
113
  const allowance = Number.isFinite(allowanceSeconds) && allowanceSeconds > 0 ? allowanceSeconds : 0;
98
114
  const speed = Number.isFinite(encodeSpeedX) && encodeSpeedX > 0 ? encodeSpeedX : 0;
99
- const shortfall = speed > 0 && speed < 1 ? remaining * (1 - speed) : 0;
100
115
 
101
116
  /** @type {DemandZone[]} */
102
117
  const zones = [];
103
- const readyBy = Math.min(end, from + allowance + shortfall);
104
- zones.push({ from, to: readyBy, priority: AT_THE_VIEWER });
105
118
 
106
- if (readyBy < end && speed > 0) {
107
- // While they watch what zone 1 holds, the encoder makes `speed` times that
108
- // much. Beyond it nobody is waiting yet.
109
- const reach = Math.min(end, readyBy + (readyBy - from) * speed);
110
- if (reach > readyBy) {
111
- zones.push({ from: readyBy, to: reach, priority: IN_FRONT });
119
+ // AT OR ABOVE REALTIME ONE ENCODER SUFFICES, whatever the film's length.
120
+ //
121
+ // From the condition below with `s >= 1`: the encoder gains on the viewer at
122
+ // every point, so there is no distance at which they catch it. All that has
123
+ // to exist in front of them is the allowance this file's own interruptions
124
+ // have shown to be necessary.
125
+ if (speed === 0 || speed >= 1) {
126
+ const readyBy = Math.min(end, from + allowance);
127
+ zones.push({ from, to: readyBy, priority: AT_THE_VIEWER });
128
+ if (readyBy < end && speed > 0) {
129
+ // While they watch what the first zone holds, the encoder makes `speed`
130
+ // times as much again. Beyond that nobody is waiting yet.
131
+ const reach = Math.min(end, readyBy + (readyBy - from) * speed);
132
+ if (reach > readyBy) {
133
+ zones.push({ from: readyBy, to: reach, priority: IN_FRONT });
134
+ }
112
135
  }
136
+ const covered = zones[zones.length - 1].to;
137
+ if (covered < end) {
138
+ zones.push({ from: covered, to: end, priority: THE_REST });
139
+ }
140
+ return zones;
113
141
  }
114
142
 
115
- const covered = zones[zones.length - 1].to;
116
- if (covered < end) {
117
- zones.push({ from: covered, to: end, priority: THE_REST });
143
+ // BELOW REALTIME THE ZONES GROW, AND EACH IS ONE ENCODER'S SHARE.
144
+ //
145
+ // An encoder starting at `q` produces the point `q + y` after `y / s`, and the
146
+ // viewer reaches it after `q + y - p`. It stays ahead while
147
+ //
148
+ // y <= (q - p) * s / (1 - s)
149
+ //
150
+ // so the length one encoder can hold GROWS with its distance from the viewer:
151
+ // the further off it starts, the later the viewer arrives. Equal shares are
152
+ // therefore the wrong division, and by a wide margin — on the addon host with
153
+ // 2400 s of film left, 120 s held and 0.5x, equal shares need twenty encoders
154
+ // and growing ones need five (120, 240, 480, 960, 1920).
155
+ //
156
+ // Each zone is exactly as long as its bound allows, which makes the count the
157
+ // smallest that can hold this viewer: any zone longer stalls them, and any
158
+ // shorter leaves the next one starting nearer, where its own bound is tighter.
159
+ // With nothing held, no partition holds this viewer: the first encoder can
160
+ // stay ahead for `b * s / (1 - s)`, and that is zero. Saying so plainly beats
161
+ // slicing the film into equal slivers that pretend otherwise — the whole of
162
+ // what is left is urgent, and it will still not be enough.
163
+ if (allowance <= 0) {
164
+ return [{ from, to: end, priority: AT_THE_VIEWER }];
165
+ }
166
+ const growth = speed / (1 - speed);
167
+ let at = from;
168
+ let held = allowance;
169
+ let priority = AT_THE_VIEWER;
170
+ while (at < end && zones.length < MOST_ZONES) {
171
+ const share = held * growth;
172
+ const to = Math.min(end, at + share);
173
+ zones.push({ from: at, to, priority });
174
+ held += to - at;
175
+ at = to;
176
+ // The next zone is one step less urgent: the viewer meets the one before it
177
+ // first. One scale for every viewer, or merging two maps would compare
178
+ // numbers that mean different things.
179
+ priority = Math.max(THE_REST + 1, priority - 1);
180
+ }
181
+ if (at < end) {
182
+ zones.push({ from: at, to: end, priority: THE_REST });
118
183
  }
119
184
  return zones;
120
185
  }
@@ -17,9 +17,14 @@
17
17
  * only as long as someone remembers to copy it back after a container is
18
18
  * recreated.
19
19
  *
20
- * `gdb` attaching with ptrace pauses every thread of the process for the
21
- * duration of the read one `getsockopt` call, measured at a fraction of a
22
- * second in the manual procedure this automates.
20
+ * **THIS STOPS THE PRODUCT WHILE IT WORKS, so it is off unless asked for.**
21
+ * `gdb` attaches with ptrace and pauses every thread of this process for as
22
+ * long as it is attached. The manual procedure this automates took a fraction
23
+ * of a second; on 2026-09-05 the automatic one held the proxy for four minutes
24
+ * and the viewer was told the proxy had sent no video. Two things follow, and
25
+ * both are in the code below: the deadline is counted by a separate process,
26
+ * because a timer inside a stopped process never fires; and `--usrsctp-state`
27
+ * has to be given for any of this to happen at all.
23
28
  */
24
29
 
25
30
  import { spawn } from "node:child_process";
@@ -91,9 +96,29 @@ export function createUsrsctpStateReader({
91
96
  resolve();
92
97
  };
93
98
  try {
99
+ // THE DEADLINE IS SET OUTSIDE THE PROCESS THAT IS BEING STOPPED.
100
+ //
101
+ // gdb attaches with ptrace and stops every thread of this process
102
+ // while it works — including whichever one would have counted the
103
+ // seconds. The guard below used to be a `setTimeout` here, and on
104
+ // 2026-09-05 it did not fire: gdb held the proxy for four minutes,
105
+ // the log ended mid-second and the viewer was told the proxy had
106
+ // sent no video. `timeout` is a separate process and keeps counting
107
+ // whatever happens to this one.
94
108
  child = spawnProcess(
95
- "gdb",
96
- ["-q", "-batch", "-p", String(pid), "-x", scriptPath],
109
+ "timeout",
110
+ [
111
+ "-s",
112
+ "KILL",
113
+ String(Math.ceil(GDB_TIMEOUT_MS / 1000)),
114
+ "gdb",
115
+ "-q",
116
+ "-batch",
117
+ "-p",
118
+ String(pid),
119
+ "-x",
120
+ scriptPath
121
+ ],
97
122
  { stdio: ["ignore", "pipe", "pipe"] }
98
123
  );
99
124
  } catch (error) {
@@ -112,13 +137,17 @@ export function createUsrsctpStateReader({
112
137
  finish();
113
138
  });
114
139
  child.on("close", finish);
140
+ // A second guard, for the case where `timeout` itself is not on the
141
+ // host: it can only fire while this process is running, which is
142
+ // exactly what cannot be relied on here, so it is a backstop and not
143
+ // the deadline.
115
144
  const killer = setTimeout(() => {
116
145
  try {
117
146
  child.kill("SIGKILL");
118
147
  } catch {
119
148
  // already gone
120
149
  }
121
- }, GDB_TIMEOUT_MS);
150
+ }, GDB_TIMEOUT_MS * 2);
122
151
  if (typeof killer.unref === "function") {
123
152
  killer.unref();
124
153
  }
@@ -125,6 +125,89 @@ export class Viewer {
125
125
  * @type {Set<string>}
126
126
  */
127
127
  this.outputs = new Set();
128
+ // Whether the picture is moving. A viewer who has stopped it consumes
129
+ // nothing, so nothing in front of them ever becomes due — they have no
130
+ // deadline at all, and the work goes to whoever is watching. The page knows
131
+ // this exactly and says it outright; inferring it from a position that has
132
+ // not moved takes two reports and lies whenever a browser holding a full
133
+ // cushion goes quiet between segments, which it does.
134
+ this.playing = true;
135
+ // Seconds of film held ahead of the picture, as the page last said.
136
+ this.bufferedSeconds = null;
137
+ }
138
+
139
+ /**
140
+ * Where they are, in SECONDS of film, and nothing else.
141
+ *
142
+ * A segment number cannot live here: the picture and the soundtrack of one
143
+ * film are cut independently and into different numbers of pieces — 454
144
+ * against 401 on the field file of 2026-09-05 — so piece 48 of one is not the
145
+ * same moment as piece 48 of the other. Whoever holds a cut grid turns these
146
+ * seconds into their own numbers.
147
+ *
148
+ * @param {number} seconds
149
+ * @param {number} [now]
150
+ */
151
+ moveTo(seconds, now = Date.now()) {
152
+ if (!Number.isFinite(seconds) || seconds < 0) {
153
+ return;
154
+ }
155
+ this.position = { seconds, at: now, seeked: seconds };
156
+ this.lastSeenAt = now;
157
+ }
158
+
159
+ /**
160
+ * Everything a viewer says about itself, in one statement.
161
+ *
162
+ * The page sends four things together — how fast its link measured, how much
163
+ * film it holds, where the picture is, and whether the picture is moving —
164
+ * and all four are facts about this viewer. Taking them apart and assigning
165
+ * them one by one somewhere else is how they came to be spread over five
166
+ * places, two of them on a session shared with other people.
167
+ *
168
+ * @param {object} report
169
+ * @param {number} report.linkMbps
170
+ * @param {number} report.bufferedAheadSec
171
+ * @param {number | null} [report.positionSeconds] - Null from a page that
172
+ * does not say; then the position stands as it was.
173
+ * @param {boolean} [report.playing] - Absent from a page that does not say;
174
+ * then the viewer counts as playing, which is what every page meant before
175
+ * it could say otherwise.
176
+ * @param {number} [now]
177
+ */
178
+ report({ linkMbps, bufferedAheadSec, positionSeconds = null, playing }, now = Date.now()) {
179
+ this.netReport = {
180
+ linkMbps,
181
+ bufferedAheadSec,
182
+ positionSeconds:
183
+ Number.isFinite(positionSeconds) && positionSeconds >= 0 ? positionSeconds : null,
184
+ at: now
185
+ };
186
+ this.bufferedSeconds = bufferedAheadSec;
187
+ this.playing = playing === undefined ? true : Boolean(playing);
188
+ if (Number.isFinite(positionSeconds) && positionSeconds >= 0) {
189
+ this.moveTo(/** @type {number} */ (positionSeconds), now);
190
+ }
191
+ this.seen(now);
192
+ }
193
+
194
+ /**
195
+ * When this viewer runs out of what they hold, in milliseconds.
196
+ *
197
+ * Film is consumed at one second per second while the picture moves, so the
198
+ * moment they run dry is now plus what they hold. Stopped, they consume
199
+ * nothing and there is no such moment — which is why a pause needs no rule of
200
+ * its own anywhere: it falls out of this as an absent deadline.
201
+ *
202
+ * @param {number} [now]
203
+ * @returns {number | null}
204
+ */
205
+ deadlineAt(now = Date.now()) {
206
+ if (!this.playing) {
207
+ return null;
208
+ }
209
+ const held = Number.isFinite(this.bufferedSeconds) ? Math.max(0, this.bufferedSeconds) : 0;
210
+ return now + held * 1000;
128
211
  }
129
212
 
130
213
  /**
@@ -45,6 +45,12 @@ function orchestrator({ maxRuns = 2 } = {}) {
45
45
  maxRunsFor: () => maxRuns,
46
46
  segmentSeconds: 4,
47
47
  restartCostSec: 0.12,
48
+ // A host that has measured what the swarm charges to fetch a second of film
49
+ // again. Without it the drive-or-move comparison has only one side and the
50
+ // plan keeps the encoder rather than paying an unknown price — which is its
51
+ // own check in `encode-plan.test.js` rather than the shape every check here
52
+ // is written against.
53
+ refetchSecPerFilmSecond: () => 0.25,
48
54
  now: () => 1000,
49
55
  logger: { info: (line) => lines.push(line), warn: (line) => lines.push(line) },
50
56
  makeRun: ({ address, from, to }) => {
@@ -195,22 +201,25 @@ test("nothing wanted anywhere is said plainly", () => {
195
201
  assert.match(made.describe(), /nothing wanted/);
196
202
  });
197
203
 
198
- test("a run adopted with no end holds the look-ahead, not the rest of the film", () => {
204
+ test("a run adopted with no end holds what it has made, not the rest of the film", () => {
199
205
  // A session's own encoder is handed to the plan rather than built by it, and
200
- // it carries `to = -1` — no end. Claiming the film from there would leave a
201
- // viewer further in with no encoder at all: they would wait for this run to
202
- // encode its way to them, which on a long film is an hour. What bounds it is
203
- // the look-ahead, because a run is suspended once it is that far in front of
204
- // its viewer and produces nothing until somebody asks.
206
+ // it carries `to = -1` — no end, which means the film's length is not known.
207
+ // Claiming the film from there would leave a viewer further in with no
208
+ // encoder at all: they would wait for this run to encode its way to them,
209
+ // which on a long film is an hour. What it holds is what it has produced,
210
+ // which is a fact rather than a distance nobody measured.
205
211
  const { made } = orchestrator({ maxRuns: 2 });
206
- made.lookaheadSegments = 30;
207
212
  const adopted = {
208
213
  from: 0,
209
214
  to: -1,
210
215
  head: 3,
211
216
  isAlive: true,
212
217
  isStopping: false,
213
- speedX: 8,
218
+ // Slow enough that the swarm is not what limits the count here: at 0.25
219
+ // seconds of swarm time per second of film, one encoder at 1x takes a
220
+ // quarter of what is delivered and four may run. The swarm's own limit has
221
+ // its own check below.
222
+ speedX: 1,
214
223
  stop() {
215
224
  this.isAlive = false;
216
225
  }
@@ -225,3 +234,22 @@ test("a run adopted with no end holds the look-ahead, not the rest of the film",
225
234
  assert.ok(adopted.isAlive, "and the adopted run was not stopped to make room");
226
235
  assert.equal(runs.some((run) => run.from === 200), true, "started where that viewer is waiting");
227
236
  });
237
+
238
+ test("the swarm limits the encoders, whatever the processor allows", () => {
239
+ // Every encoder reads the same torrent, so together they cannot consume
240
+ // faster than it is delivered. At 0.25 seconds of swarm time per second of
241
+ // film, one encoder running at 8x takes twice everything there is — so a
242
+ // machine whose processor would allow two gets one.
243
+ const { made, lines } = orchestrator({ maxRuns: 2 });
244
+ made.want({ claimant: "one", address: PICTURE, from: 100, to: 130 });
245
+ made.reconcile();
246
+ made.runsOn(PICTURE)[0].noteSpeed(8);
247
+ made.want({ claimant: "two", address: PICTURE, from: 500, to: 530 });
248
+ made.reconcile();
249
+
250
+ assert.equal(made.runsOn(PICTURE).length, 1);
251
+ assert.ok(
252
+ lines.some((line) => line.includes("what the swarm delivers")),
253
+ "and the line says which limit decided it"
254
+ );
255
+ });