@torrent-tv/proxy 2.80.4 → 2.80.6

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.
Files changed (33) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/package.json +1 -1
  3. package/routes/api/delivery-sink/get.js +8 -4
  4. package/server.js +415 -403
  5. package/services/data-channel-handler.js +87 -25
  6. package/services/delivery-probe.js +38 -5
  7. package/services/encode/CoverageMap.js +77 -4
  8. package/services/encode/EncodePlan.js +1025 -358
  9. package/services/encode/EncodeRun.js +42 -22
  10. package/services/encode/SegmentDemand.js +0 -0
  11. package/services/encode/SegmentStore.js +55 -3
  12. package/services/encode/open-piece.js +47 -24
  13. package/services/encode/run-command.js +12 -2
  14. package/services/hls-session-manager.js +38 -158
  15. package/services/hwaccel.js +182 -54
  16. package/services/orchestrators/EncodeOrchestrator.js +123 -94
  17. package/services/output/LiveOutputs.js +233 -213
  18. package/services/output/Timeline.js +333 -256
  19. package/services/priority/PriorityMap.js +262 -108
  20. package/services/priority/PriorityOrchestrator.js +31 -6
  21. package/services/quality/EncodeCost.js +555 -500
  22. package/services/torrent-pool.js +9 -4
  23. package/test/encode-orchestrator.test.js +195 -65
  24. package/test/encode-plan-viewers.test.js +719 -0
  25. package/test/encode-plan.test.js +174 -81
  26. package/test/open-piece.test.js +152 -0
  27. package/test/output-speed.test.js +86 -0
  28. package/test/priority-map-download.test.js +25 -7
  29. package/test/priority-map.test.js +134 -83
  30. package/test/seek-landing.test.js +109 -76
  31. package/test/segment-demand.test.js +54 -56
  32. package/test/wedge-certainty.test.js +3 -3
  33. package/test/flushed-piece.test.js +0 -108
@@ -61,11 +61,10 @@ const MICROSECONDS_PER_SECOND = 1_000_000;
61
61
  * @property {number} livedMs
62
62
  * @property {boolean} normal - Whether this ending is the expected one.
63
63
  * @property {string} lastError - The last thing ffmpeg said on stderr.
64
- * @property {string | null} flushedName - The piece the encoder wrote out while
65
- * it was shutting down, if it wrote one. It is closed and it is SHORT: it
66
- * holds film only up to the instant the run was stopped, while its name
67
- * promises the whole span the playlist gives that number. Null where the run
68
- * was never told to stop.
64
+ * @property {string | null} provenName - The last piece this run named while it
65
+ * was still running normally, and therefore the last one it is known to have
66
+ * finished. Anything on disk beyond it was open when the run ended, whatever
67
+ * ended it. Null where the run named nothing.
69
68
  */
70
69
 
71
70
  /**
@@ -105,24 +104,27 @@ export class EncodeRun {
105
104
  #closedTail = "";
106
105
 
107
106
  /**
108
- * The last piece named on the ready channel AFTER the run was told to stop.
107
+ * The last piece named on the ready channel while the run was still running
108
+ * normally — the last one it is KNOWN to have finished.
109
109
  *
110
- * On SIGTERM ffmpeg writes out the piece it had open and names it like any
111
- * other, so "the encoder closed it" stops meaning "it is whole". Field
112
- * 2026-09-06: a run stopped mid-piece left `segment-00010.mp4` holding 3.92 s
113
- * of the 5.589 s its name promises, and the viewer's picture jumped 1.5 s at
114
- * 1:02. The soundtrack did the same at 17.5 s, 2.8 s wide, in the same
115
- * session.
110
+ * "The encoder named it" was taken to mean "it is whole". That is true of the
111
+ * file and false of the span. On SIGTERM ffmpeg writes out the piece it had
112
+ * open and names it like any other; killed harder, or dying on its own, it
113
+ * leaves that piece unnamed and half-written. Both are readable, and neither
114
+ * covers the span its number promises. Field 2026-09-06: a run stopped
115
+ * mid-piece left `segment-00010.mp4` holding 3.92 s of the 5.589 s the
116
+ * playlist gives #10, and the viewer's picture jumped 1.5 s at 1:02; the
117
+ * soundtrack did the same at 17.5 s, 2.8 s wide, in the same session.
116
118
  *
117
- * Distinguished by WHEN the name arrives, which is exact and needs no reading
118
- * of the file: a name that arrives after the stop was ordered is the flush.
119
- * A piece genuinely closed a moment before the stop can land here too, and
120
- * then it is made a second time — the cheaper of the two errors, since the
121
- * other one is a hole the viewer sees.
119
+ * Recorded by WHEN the name arrives, so nothing is read and no span is
120
+ * measured: a name that arrives after the stop was ordered is the flush and
121
+ * does not count as proof. A piece genuinely closed in the moment between the
122
+ * last normal name and the stop is then made a second time — the cheaper of
123
+ * the two errors, since the other is a hole the viewer sees.
122
124
  *
123
125
  * @type {string | null}
124
126
  */
125
- #flushedName = null;
127
+ #provenName = null;
126
128
 
127
129
  /**
128
130
  * When this run was told to stop, so the death itself can be priced.
@@ -197,6 +199,10 @@ export class EncodeRun {
197
199
  * kept so a failure can quote what produced it.
198
200
  * @param {boolean} [params.usesExplicitCuts] - Whether this run cuts at times
199
201
  * it was given, which decides how a segment is judged finished.
202
+ * @param {(name: string) => number | null} [params.indexOfName] - The number
203
+ * a closed piece's name carries. How a piece is named belongs to the format
204
+ * that writes it, so it arrives as a plain function rather than this class
205
+ * knowing any naming.
200
206
  */
201
207
  constructor({
202
208
  address,
@@ -213,7 +219,8 @@ export class EncodeRun {
213
219
  lastSegmentIndex,
214
220
  inputUnavailable,
215
221
  argsDescribed = "",
216
- usesExplicitCuts = false
222
+ usesExplicitCuts = false,
223
+ indexOfName
217
224
  }) {
218
225
  this.address = address;
219
226
  this.encoder = encoder;
@@ -232,6 +239,7 @@ export class EncodeRun {
232
239
  this.inputUnavailable = typeof inputUnavailable === "function" ? inputUnavailable : () => false;
233
240
  this.argsDescribed = argsDescribed;
234
241
  this.usesExplicitCuts = usesExplicitCuts === true;
242
+ this.indexOfName = typeof indexOfName === "function" ? indexOfName : () => null;
235
243
  /** The last thing ffmpeg said on stderr, which is what a failure is explained by. */
236
244
  this.lastError = "";
237
245
  }
@@ -451,8 +459,20 @@ export class EncodeRun {
451
459
  if (name.length === 0) {
452
460
  continue;
453
461
  }
454
- if (this.#stopping) {
455
- this.#flushedName = name;
462
+ if (!this.#stopping) {
463
+ this.#provenName = name;
464
+ }
465
+ // WHAT THIS RUN HAS MADE IS THIS RUN'S OWN FACT, and this channel is where
466
+ // it learns it. It used to be told from outside, by whoever listed the
467
+ // output directory — which every run of an output shares — so a run
468
+ // inherited every number any other run had ever left there. Field
469
+ // 2026-09-06: a run beginning at the piece for 3:39 reported "reached #20
470
+ // (483 segment(s))", having produced none of them, and its head therefore
471
+ // described somebody else's work. Both the claim it holds and the cleanup
472
+ // after it read that head.
473
+ const index = this.indexOfName(name);
474
+ if (Number.isInteger(index)) {
475
+ this.noteProduced(index);
456
476
  }
457
477
  this.onClosed(name);
458
478
  }
@@ -643,7 +663,7 @@ export class EncodeRun {
643
663
  from: this.from,
644
664
  to: this.to,
645
665
  reached: this.reached,
646
- flushedName: this.#flushedName,
666
+ provenName: this.#provenName,
647
667
  livedMs,
648
668
  // How long dying took, and how long the first output took to appear.
649
669
  // Null where the run was never told to stop, or never produced anything:
Binary file
@@ -98,6 +98,10 @@ export class SegmentStore {
98
98
  */
99
99
  #closed = new Map();
100
100
 
101
+ /** Pieces already reported as taken on the successor rule, so one is said
102
+ * once. @type {Map<string, Set<number>>} */
103
+ #unreportedSaid = new Map();
104
+
101
105
  /** @type {{ info: Function, warn: Function }} */
102
106
  #logger;
103
107
 
@@ -308,7 +312,55 @@ export class SegmentStore {
308
312
  if (this.#closed.get(key)?.has(index)) {
309
313
  return true;
310
314
  }
311
- return this.refresh(key).byNumber.has(index + 1);
315
+ const bySuccessor = this.refresh(key).byNumber.has(index + 1);
316
+ if (bySuccessor) {
317
+ this.#noteUnreported(key, index);
318
+ }
319
+ return bySuccessor;
320
+ }
321
+
322
+ /**
323
+ * A piece taken as finished because the NEXT one exists, with nothing from a
324
+ * run to say so.
325
+ *
326
+ * The successor rule is for what this process did not watch being written —
327
+ * pieces from a previous life of it. It is also the one way an unfinished
328
+ * piece can be served: a file that stops short still has a successor if
329
+ * anything wrote one, and then its name promises a whole span while it holds
330
+ * a fraction. Field 2026-09-06: 110 698 bytes served under a name whose
331
+ * neighbours are 12 MB, 40 ms of film where the playlist declared 10.4 s, and
332
+ * the player jumped the hole it left.
333
+ *
334
+ * That cannot arise from two encoders any more — their stretches no longer
335
+ * overlap — so what is left is a piece from a process that died without
336
+ * clearing up. Said once per piece, with its size, so a return of it is
337
+ * visible rather than inferred.
338
+ *
339
+ * @param {string} key
340
+ * @param {number} index
341
+ */
342
+ #noteUnreported(key, index) {
343
+ let said = this.#unreportedSaid.get(key);
344
+ if (!said) {
345
+ said = new Set();
346
+ this.#unreportedSaid.set(key, said);
347
+ }
348
+ if (said.has(index)) {
349
+ return;
350
+ }
351
+ said.add(index);
352
+ let bytes = -1;
353
+ try {
354
+ bytes = statSync(this.pathOf(key, index)).size;
355
+ } catch {
356
+ // Gone between the listing and this: nothing to report about it.
357
+ return;
358
+ }
359
+ this.#logger?.info?.(
360
+ `segment store: #${index} of ${key.slice(0, 60)} is taken as finished because ` +
361
+ `#${index + 1} exists — no run reported it (${bytes} bytes). Expected only for ` +
362
+ "pieces left by a previous life of this process."
363
+ );
312
364
  }
313
365
 
314
366
  /**
@@ -350,12 +402,12 @@ export class SegmentStore {
350
402
  * @param {((raw: Buffer) => boolean) | null} [judgeUsable]
351
403
  * @returns {Promise<number | null>} The segment number removed, or null.
352
404
  */
353
- async discardOpenPieceOf(key, within, judgeUsable = null, flushedName = null) {
405
+ async discardOpenPieceOf(key, within, judgeUsable = null, provenName = null) {
354
406
  const format = this.#formats.get(key);
355
407
  if (!format) {
356
408
  return null;
357
409
  }
358
- const removed = await discardOpenPiece(this.directoryFor(key), format, within, judgeUsable, flushedName);
410
+ const removed = await discardOpenPiece(this.directoryFor(key), format, within, judgeUsable, provenName);
359
411
  if (removed !== null) {
360
412
  this.#held.delete(key);
361
413
  this.#logger?.info?.(
@@ -23,37 +23,39 @@ import path from "node:path";
23
23
  * numbering and convinced the look-ahead to keep the encoder stopped for having
24
24
  * "produced" it.
25
25
  *
26
- * Two kinds of file are removed, and the second is the one a viewer feels.
26
+ * **A piece is whole only if the run PROVED it, and reading it proves nothing.**
27
27
  *
28
- * The first is unreadable a run that died mid-write. The second READS
29
- * perfectly and is SHORT: on SIGTERM ffmpeg writes out the piece it had open
30
- * and names it on the ready channel like any other, so it is a valid fMP4
31
- * holding film only up to the instant of the stop, under a name that promises
32
- * the whole span the playlist gives that number. Field 2026-09-06:
28
+ * The highest-numbered file in a run's stretch is the one it had open. How a
29
+ * run ends decides what became of that file, and all three outcomes leave it
30
+ * readable-looking: stopped with SIGTERM, ffmpeg writes it out and names it on
31
+ * the ready channel exactly as it names a finished one; killed harder, or dying
32
+ * on its own, it leaves the bytes it had written with no name at all. In every
33
+ * case the file decodes and holds film only up to the instant the run ended,
34
+ * under a number whose playlist entry promises a whole span. Field 2026-09-06:
33
35
  * `segment-00010.mp4` held 3.92 s of its declared 5.589 s — 96 frames — and the
34
36
  * picture jumped 1.5 s at 1:02; the soundtrack did the same at 17.5 s, 2.8 s
35
- * wide, in the same session. Judging such a file by whether it decodes says
36
- * yes, which is how both survived.
37
+ * wide, in the same session. Both decoded, which is how both reached the viewer.
37
38
  *
38
- * Which file that is comes from the run itself the last name it wrote after
39
- * being told to stop and not from reading the pieces, so there is no span to
40
- * measure and no tolerance to choose.
39
+ * So the question asked here is not what the file contains but whether the run
40
+ * named it while it was still running normally. That is a fact the run holds,
41
+ * so there is no span to measure and no tolerance to choose.
41
42
  *
42
- * A piece that was genuinely finished a moment before the stop can be named
43
- * here too, and is then made a second time. That is the cheaper error: the
44
- * other one is a hole the viewer sees.
43
+ * A piece finished in the moment between the last such name and the end is
44
+ * then made a second time. That is the cheaper error: the other is a hole the
45
+ * viewer sees.
45
46
  *
46
47
  * @param {string | null | undefined} runDirPath
47
48
  * @param {{ isSegmentFileName: (name: string) => boolean, segmentIndexFromName: (name: string) => number }} segmentFormat
48
49
  * @param {((raw: Buffer) => boolean) | null} judgeUsable - Whether a non-empty
49
50
  * piece carries what it should. Null where nothing can say, and then only an
50
51
  * empty file is removed.
51
- * @param {string | null} [flushedName] - The piece the encoder wrote out while
52
- * shutting down. Removed whether or not it reads, because reading is not the
53
- * question about it.
52
+ * @param {string | null} [provenName] - The last piece the run named while it
53
+ * was running normally. A file beyond it was open when the run ended and goes
54
+ * whether or not it reads. Null where the run proved nothing, and then every
55
+ * piece it left is unproven.
54
56
  * @returns {Promise<number | null>} The segment number removed, or null.
55
57
  */
56
- export async function discardOpenPiece(runDirPath, segmentFormat, within, judgeUsable, flushedName = null) {
58
+ export async function discardOpenPiece(runDirPath, segmentFormat, within, judgeUsable, provenName = null) {
57
59
  if (!runDirPath || typeof segmentFormat?.isSegmentFileName !== "function") {
58
60
  return null;
59
61
  }
@@ -63,7 +65,28 @@ export async function discardOpenPiece(runDirPath, segmentFormat, within, judgeU
63
65
  // belong to a run that is still going, and removing it would take away a
64
66
  // piece somebody is producing.
65
67
  const from = Number.isInteger(within?.from) ? within.from : 0;
66
- const to = Number.isInteger(within?.to) && within.to >= from ? within.to : Number.MAX_SAFE_INTEGER;
68
+ // THE RUN'S OWN REACH, which is a fact it holds and needs nothing measured.
69
+ //
70
+ // A run cannot have opened a file above the one just past the last it named:
71
+ // ffmpeg names a piece when it closes it and opens the next, so the open piece
72
+ // is at most `proven + 1`, and where it named nothing at all the open one is
73
+ // the first it was given.
74
+ //
75
+ // Used only where no end was declared — `to` below `from`, which is how "to
76
+ // the end of the track" is written everywhere here. Such a run had no bound at
77
+ // all: the search covered the whole directory and took the highest-numbered
78
+ // file in it. Every run of an output writes into that one
79
+ // directory, so what it took was a piece a LIVE run had just finished. Field
80
+ // 2026-09-06: the piece holding 2:47-2:57 went that way, its number is spent
81
+ // for good because names only grow, and the picture stood still for 647 s.
82
+ const provenIndex = typeof provenName === "string" && segmentFormat.isSegmentFileName(provenName)
83
+ ? segmentFormat.segmentIndexFromName(provenName)
84
+ : null;
85
+ const reach = Number.isInteger(provenIndex) && provenIndex >= from ? provenIndex + 1 : from;
86
+ // The declared end is the bound wherever there is one. The run's own reach is
87
+ // the bound of LAST RESORT, for a run given none: before it, such a run had no
88
+ // bound at all and the search covered the whole directory.
89
+ const to = Number.isInteger(within?.to) && within.to >= from ? within.to : reach;
67
90
  let highest = null;
68
91
  try {
69
92
  for (const name of await readdir(runDirPath)) {
@@ -85,11 +108,11 @@ export async function discardOpenPiece(runDirPath, segmentFormat, within, judgeU
85
108
  return null;
86
109
  }
87
110
  const filePath = path.join(runDirPath, highest.name);
88
- // The encoder named this one on its way out, so it holds film up to the stop
89
- // and no further. Nothing about its contents can say that it decodes — so
90
- // nothing about its contents is asked.
91
- const wasFlushedOnTheWayOut = typeof flushedName === "string" && flushedName === highest.name;
92
- let unusable = wasFlushedOnTheWayOut;
111
+ // Proven finished only if the run said so while it was running. Anything
112
+ // beyond that name was open when the run ended, and its contents cannot say
113
+ // so it decodes.
114
+ const proven = typeof provenName === "string" && provenName === highest.name;
115
+ let unusable = !proven;
93
116
  try {
94
117
  const info = await stat(filePath);
95
118
  if (info.size === 0) {
@@ -208,7 +208,17 @@ export function nearestKeyframeAtOrBefore(keyframeTimes, target) {
208
208
  export function seekLandingOffsetFor(material, keyframe) {
209
209
  // A re-encode trims to the requested time itself, so it needs no help and
210
210
  // must not be pushed past what it was asked for.
211
- if (material?.transcodeVideo === true) {
211
+ //
212
+ // AN OUTPUT CARRYING ONLY SOUND IS SUCH A RE-ENCODE, and asking about the
213
+ // picture missed it: the flag says whether the PICTURE is re-encoded, and an
214
+ // output with no picture answers no. So the whole offset was added to the
215
+ // soundtrack's own seek, and the sound then played 130 ms ahead of the picture
216
+ // from every restart onward — reported by the viewer 2026-09-06 as lips out of
217
+ // step with the voice from two minutes in, and measured: both outputs were
218
+ // repositioned to the same boundary and both given `-ss 166.963435`, after
219
+ // which the copied picture landed on the keyframe at 166.833 while the
220
+ // re-encoded sound began where it was asked.
221
+ if (material?.transcodeVideo === true || material?.audioOnly === true) {
212
222
  return 0;
213
223
  }
214
224
  // A grid whose times are approximate needs that error added on top, or a name
@@ -504,7 +514,7 @@ export function buildRunCommand({
504
514
  if (snappedKeyframe !== null) {
505
515
  const residualSeconds = Math.max(0, seekSeconds - snappedKeyframe);
506
516
  if (snappedKeyframe > 0) {
507
- args.push("-ss", ffmpegSeconds(snappedKeyframe + seekLandingOffsetFor({ transcodeVideo, file }, snappedKeyframe)));
517
+ args.push("-ss", ffmpegSeconds(snappedKeyframe + seekLandingOffsetFor({ audioOnly, transcodeVideo, file }, snappedKeyframe)));
508
518
  }
509
519
  args.push("-i", inputUrl);
510
520
  // The coarse landing, not the exact target: the residual below is discarded
@@ -23,7 +23,6 @@ import { speedFromReadings } from "./encoder-readings.js";
23
23
  import { availableShareFrom } from "./available-share.js";
24
24
  import { contentionPenalty } from "./contention.js";
25
25
  import { minimumBufferFrom } from "./supply-margin.js";
26
- import { mapForViewer } 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";
@@ -556,12 +555,6 @@ const SEGMENT_STORE_IDLE_MS = 6 * 60 * 60 * 1000;
556
555
  const SEGMENT_STORE_FREE_SHARE = 0.25;
557
556
  /** What the store may hold where the free space cannot be read at all. */
558
557
  const SEGMENT_STORE_FALLBACK_BYTES = 2 * 1024 * 1024 * 1024;
559
- /**
560
- * What it costs to stop an encoder and start another where the material is
561
- * missing. Measured on the addon host 2026-09-04: a spawn with its input open
562
- * is 0.12 s there, 0.5-0.6 s on a developer's desktop.
563
- */
564
- const RUN_RESTART_COST_SEC = 0.12;
565
558
  const DEFAULT_STARTUP_WAIT_MS = 5_000;
566
559
  // Realtime budget — runtime downswitch (software encoder only). Periodically
567
560
  // check each active software-transcode session's ffmpeg `speed`; when it stays
@@ -1475,6 +1468,7 @@ export class HlsSessionManager {
1475
1468
  // could not be measured, and then nothing is corrected — the alternative
1476
1469
  // is inventing a penalty, which is the same fault as inventing a fill rate.
1477
1470
  contentionPenalties = null,
1471
+ copySpeedX = null,
1478
1472
  tonemapSupported = false,
1479
1473
  getCachedMediaInfo = null,
1480
1474
  getCachedAudioTracks = null,
@@ -1529,6 +1523,10 @@ export class HlsSessionManager {
1529
1523
  this.getSourceStats = typeof getSourceStats === "function" ? getSourceStats : null;
1530
1524
  this.setPriorityMap = typeof setPriorityMap === "function" ? setPriorityMap : null;
1531
1525
  this.contentionPenalties = contentionPenalties instanceof Map ? contentionPenalties : null;
1526
+ // Seconds of film per second when the picture is COPIED, measured at
1527
+ // startup. Nothing else prices that branch: the other benchmarks measure
1528
+ // encoding and decoding, and a copy does neither.
1529
+ this.copySpeedX = Number.isFinite(copySpeedX) && copySpeedX > 0 ? copySpeedX : null;
1532
1530
  // Totals across every torrent this proxy holds, used to price what the
1533
1531
  // torrent itself costs the machine (item 7). Optional: a proxy wired
1534
1532
  // without it simply never learns that figure.
@@ -1612,6 +1610,7 @@ export class HlsSessionManager {
1612
1610
  benchmark: this.softwarePresetBenchmark,
1613
1611
  decodeModel: this.decodeCostModel,
1614
1612
  contentionPenalties: this.contentionPenalties,
1613
+ copySpeedX: this.copySpeedX,
1615
1614
  availability: this.hostAvailability
1616
1615
  }),
1617
1616
  audioCostKey: (session) => this.#audioCostKey(session),
@@ -1638,7 +1637,8 @@ export class HlsSessionManager {
1638
1637
  maxRunsFor: (address) => this.maxRunsForOutput(address),
1639
1638
  makeRun: ({ address, from, to }) => this.#makeRunAt(address, from, to),
1640
1639
  segmentSeconds: this.segmentDurationSec,
1641
- restartCostSec: RUN_RESTART_COST_SEC,
1640
+ contentionPenalties: this.contentionPenalties,
1641
+ startingSpeedFor: (address) => this.encodeCost.speedForOutput(address),
1642
1642
  segmentStore: this.segmentStore,
1643
1643
  logger
1644
1644
  });
@@ -3659,63 +3659,6 @@ export class HlsSessionManager {
3659
3659
  return readers;
3660
3660
  }
3661
3661
 
3662
- /**
3663
- * One viewer's demand map, translated into this output's segment numbers.
3664
- *
3665
- * The map itself is seconds of film and knows nothing about cut grids
3666
- * (`services/priority/PriorityMap.js`). The translation is this output's own
3667
- * business, and it is exact: the timeline holds the boundaries.
3668
- *
3669
- * Two measurements feed it, and neither is chosen here:
3670
- *
3671
- * 1. the allowance below which an interruption reaches the viewer, from this
3672
- * file's own recent interruptions (`minimumBufferFrom`). Null until the
3673
- * reader has seen two of them, and then only the segment itself counts;
3674
- * 2. how fast this machine encodes THIS track, from ffmpeg's own progress.
3675
- * Below realtime it decides how much has to exist before playback starts;
3676
- * above it, nothing beyond the allowance is needed.
3677
- *
3678
- * @param {object} session
3679
- * @param {number} atSegment - Where the viewer is.
3680
- * @returns {{from: number, to: number, priority: number}[]} In segment
3681
- * numbers, both ends inclusive.
3682
- */
3683
- #demandZonesFor(session, atSeconds, playing = true) {
3684
- const boundaries = session.timeline?.boundaries ?? [];
3685
- const segmentCount = Number(session.timeline?.segmentCount) || 0;
3686
- // Where they are, in this output's own numbering. The viewer holds seconds;
3687
- // every cut grid turns them into its own numbers, and two grids of one film
3688
- // give different numbers for the same second.
3689
- const atSegment = segmentCount > 0 ? this.#segmentIndexForTime(session, atSeconds) : 0;
3690
- if (segmentCount <= 0) {
3691
- // No playlist yet: the only thing that can be said is that they want
3692
- // where they are.
3693
- return [{ from: atSegment, to: atSegment, priority: 3 }];
3694
- }
3695
- const durationSeconds = Number(boundaries[boundaries.length - 1]) ||
3696
- segmentCount * this.segmentDurationSec;
3697
- const zones = mapForViewer({
3698
- atSeconds,
3699
- durationSeconds,
3700
- allowanceSeconds: minimumBufferFrom({
3701
- segmentSeconds: this.segmentDurationSec,
3702
- worstSupplyWaitSec: session.supplyFigures?.worstWaitSec
3703
- })?.seconds ?? this.segmentDurationSec,
3704
- playing
3705
- });
3706
- /** @type {{from: number, to: number, priority: number}[]} */
3707
- const inSegments = [];
3708
- for (const zone of zones) {
3709
- const from = Math.max(atSegment, this.#segmentIndexForTime(session, zone.from));
3710
- const to = Math.min(segmentCount - 1, this.#segmentIndexForTime(session, zone.to));
3711
- if (to >= from) {
3712
- inSegments.push({ from, to, priority: zone.priority });
3713
- }
3714
- }
3715
- return inSegments.length > 0
3716
- ? inSegments
3717
- : [{ from: atSegment, to: atSegment, priority: 3 }];
3718
- }
3719
3662
 
3720
3663
  /**
3721
3664
  * Say what the cushion is, for every session.
@@ -3871,62 +3814,35 @@ export class HlsSessionManager {
3871
3814
  }
3872
3815
  coverage.markReadyAll(this.segmentStore.provenNumbers(address));
3873
3816
  for (const session of sessions) {
3874
- for (const run of this.#runsOfSession(session)) {
3817
+ for (const run of liveRunsOf(session)) {
3875
3818
  this.encodeOrchestrator.adopt(address, run);
3876
3819
  }
3877
- // What the viewers of this session are waiting for, as spans. A viewer
3878
- // wants the segment they are at and the cushion in front of it, which
3879
- // is what the encoder is steered by everywhere else in this class.
3880
- //
3881
- // PRESENCE AND POSITION ARE ASKED SEPARATELY, and that is the whole of
3882
- // the 2026-09-05 fix. Presence decides whether this viewer states a
3883
- // want at all; position decides what the want is. Asked as one question
3884
- // — which is what a single field written only by segment requests
3885
- // amounted to — a viewer who had just arrived answered "absent", every
3886
- // encoder on their output was stopped for having nobody, and the
3887
- // `init.mp4` they were waiting for in order to request their first
3888
- // segment was therefore never made.
3889
- for (const [consumerId, viewer] of viewersOf(session)) {
3890
- if (!viewer.isPresent(now, staleAfterMs)) {
3891
- this.encodeOrchestrator.release(`${session.id}:${consumerId}`);
3892
- continue;
3893
- }
3894
- // Placed when they arrived, from the position their own request
3895
- // named. A viewer with no position is one assembled by hand outside
3896
- // this class; they want the beginning, which is where an output
3897
- // starts when nobody says otherwise.
3898
- // SECONDS, and turned into this output's own numbering below. A
3899
- // segment number taken off the viewer would mean two different
3900
- // moments of film on the picture and on the soundtrack, which are cut
3901
- // independently: 454 pieces against 401 on the field file.
3902
- const at = viewer.positionSeconds() ?? 0;
3903
- // Their own map, in seconds of film, from measurements: how much must
3904
- // be ready before they set off so that they never stop — the observed
3905
- // allowance for this file plus what an encoder at THIS machine's
3906
- // measured speed will fail to deliver in time — then what it reaches
3907
- // while they watch that, then the rest of the track. No constant is
3908
- // consulted: the 120 seconds that used to size this window were the
3909
- // suspended encoder's threshold, one chosen number answering seven
3910
- // different questions.
3911
- for (const zone of this.#demandZonesFor(session, at, viewer.playing !== false)) {
3912
- this.encodeOrchestrator.want({
3913
- // The claimant is the PERSON, without the priority in it: their
3914
- // zones are separate windows, but they leave together, and
3915
- // `release` matches on this name.
3916
- claimant: `${session.id}:${consumerId}`,
3917
- address,
3918
- from: zone.from,
3919
- to: zone.to,
3920
- priority: zone.priority
3921
- });
3922
- }
3923
- }
3924
3820
  }
3925
3821
  }
3822
+ // THE MAP IS BUILT ONCE, IN ITS OWN LAYER, AND BOTH SIDES READ THAT ONE.
3823
+ //
3824
+ // It used to be built twice: once here, per viewer, converted and stated to
3825
+ // the encoding as a window each, and once again inside `publishFor` for the
3826
+ // downloading. Two answers to one question, and the encoding's copy carried
3827
+ // the viewer's NAME as the key of a claim — against the rule that the
3828
+ // encoding and the viewer are not connected at all.
3926
3829
  this.priority.publishFor({
3927
3830
  sessionGroups: byOutput.values(),
3928
3831
  staleAfterMs: this.presenceStaleAfterMs()
3929
3832
  });
3833
+ // Converted into each output's own numbering, because two outputs of one
3834
+ // film are cut independently and the same second is a different number in
3835
+ // each: 454 pieces against 401 on the field file.
3836
+ for (const [address, sessions] of byOutput) {
3837
+ const timeline = sessions[0].timeline;
3838
+ this.encodeOrchestrator.notePriorityMap(
3839
+ address,
3840
+ timeline?.inSegments?.(
3841
+ this.priority.mapFor(sessions[0].sourceKey, sessions[0].fileIndex),
3842
+ Number(timeline?.segmentCount) || 0
3843
+ ) ?? []
3844
+ );
3845
+ }
3930
3846
  this.encodeOrchestrator.reconcile();
3931
3847
  }
3932
3848
 
@@ -3988,34 +3904,6 @@ export class HlsSessionManager {
3988
3904
  }
3989
3905
  }
3990
3906
 
3991
- /**
3992
- * This session's run, told what the disk holds before it is asked where it
3993
- * has got to.
3994
- *
3995
- * A run learns of its own segments as they are SERVED, which is not when they
3996
- * are made — a viewer two minutes behind the encoder has asked for none of
3997
- * what is in front of them. The store knows, so the run is told, and its head
3998
- * is then the same figure the look-ahead and the plan have always used.
3999
- *
4000
- * @param {HlsSession} session
4001
- * @returns {import("./encode/EncodeRun.js").EncodeRun[]}
4002
- */
4003
- #runsOfSession(session) {
4004
- const runs = liveRunsOf(session);
4005
- if (runs.length === 0) {
4006
- return runs;
4007
- }
4008
- const produced = this.producedSegmentNumbers(session);
4009
- for (const run of runs) {
4010
- for (const index of produced) {
4011
- if (index >= run.from) {
4012
- run.noteProduced(index);
4013
- }
4014
- }
4015
- }
4016
- return runs;
4017
- }
4018
-
4019
3907
  /**
4020
3908
  * How many encoders this machine can afford on one output at once.
4021
3909
  *
@@ -4031,19 +3919,10 @@ export class HlsSessionManager {
4031
3919
  * @returns {number}
4032
3920
  */
4033
3921
  maxRunsForOutput(address) {
4034
- let alone = 0;
4035
- for (const session of this.sessionsById.values()) {
4036
- if (session.outputKey !== address || session.state === "disposed") {
4037
- continue;
4038
- }
4039
- const measured = Number(session.recentSpeed?.speed);
4040
- if (Number.isFinite(measured) && measured > alone) {
4041
- alone = measured;
4042
- }
4043
- }
3922
+ const alone = this.encodeCost.speedForOutput(address);
4044
3923
  if (!(alone > 0)) {
4045
- // Nothing measured on this output yet. One encoder is what it has, and
4046
- // what it has is what it keeps until there is a reading to argue with.
3924
+ // The host measured nothing at all, which is a broken startup rather than
3925
+ // a state to plan around. One encoder is what it keeps.
4047
3926
  return 1;
4048
3927
  }
4049
3928
  let affordable = 1;
@@ -5716,6 +5595,7 @@ export class HlsSessionManager {
5716
5595
  session.timeline?.segmentCount > 0 ? session.timeline.segmentCount - 1 : null,
5717
5596
  inputUnavailable: (message) => isInputUnavailable(message),
5718
5597
  onProgress: (report) => this.#noteRunProgress(session, run, report),
5598
+ indexOfName: (name) => session.segmentFormat.segmentIndexFromName(name),
5719
5599
  onClosed: (name) => this.segmentStore.markClosed(session.outputKey ?? "", session.segmentFormat.segmentIndexFromName(name)),
5720
5600
  onEnded: (ended) => this.noteRunEnded(session, run, ended)
5721
5601
  });
@@ -7070,11 +6950,11 @@ export class HlsSessionManager {
7070
6950
  if (!output) {
7071
6951
  return false;
7072
6952
  }
7073
- const left = this.viewers.leaves(output, consumerId);
7074
- if (left) {
7075
- this.encodeOrchestrator.release(`${output.id}:${consumerId}`);
7076
- }
7077
- return left;
6953
+ // Nothing to release in the encoding: it holds one map per output, built
6954
+ // from where the viewers are, and the map that arrives next simply does not
6955
+ // have this one in it. A name to release was the last place a viewer
6956
+ // appeared inside the encoding at all.
6957
+ return this.viewers.leaves(output, consumerId);
7078
6958
  }
7079
6959
 
7080
6960
  /**