@torrent-tv/proxy 2.97.14 → 2.97.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.97.14",
3
+ "version": "2.97.16",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "repository": {
package/server.js CHANGED
@@ -180,6 +180,7 @@ export async function startProxyServer({
180
180
  indexMemory.forget(sourceKey);
181
181
  downloadMaps.retire(sourceKey);
182
182
  for (const key of segmentSourceRanges.keys()) if (key.startsWith(`${sourceKey}:`)) segmentSourceRanges.delete(key);
183
+ for (const key of segmentReadings.keys()) if (key.startsWith(`${sourceKey}:`)) segmentReadings.delete(key);
183
184
  outputParts.encodeInputs?.bytesChanged();
184
185
  },
185
186
  // Pieces arriving is ANNOUNCED by the thread that owns the swarm; what is
@@ -426,6 +427,42 @@ export async function startProxyServer({
426
427
  // Readers never start their own downloads or wait for source bytes.
427
428
  /** Source byte ranges of one segment interval, by file and interval; forgotten with the source. */
428
429
  const segmentSourceRanges = new Map();
430
+ /**
431
+ * What one segment of an output reads — its tracks, its interval and the key
432
+ * its bytes are kept under — by output and segment, until the tracks, the
433
+ * file's start time, the segment's interval or the choices of the output
434
+ * change; forgotten with the source.
435
+ */
436
+ const segmentReadings = new Map();
437
+ const segmentReadingOf = (zone, trackList, shift, map) => {
438
+ const choicesKey = zone.tracks.map(choice => `${choice.type}/${choice.index}/${choice.mode}`).join(",");
439
+ const key = `${map.sourceKey}:${map.fileIndex}:${zone.outputKey}:${zone.index}`;
440
+ const kept = segmentReadings.get(key);
441
+ if (kept && kept.trackList === trackList && kept.shift === shift && kept.choicesKey === choicesKey &&
442
+ kept.from === zone.sourceInterval.from && kept.to === zone.sourceInterval.to) return kept.reading;
443
+ const selected = zone.tracks.map(choice => ({ choice, track: choice.type === "video"
444
+ ? ContainerTrack.firstUsable(trackList, "video")
445
+ : trackList.filter(track => track.type === choice.type)[choice.index] }));
446
+ let reading = null;
447
+ if (!selected.some(({ track }) => !track)) {
448
+ const wanted = selected.map(({ track }) => track);
449
+ const modes = new Map(selected.map(({ choice, track }) => [track, choice.mode]));
450
+ const interval = { ...zone.sourceInterval, trackIds: wanted.map(track => track.trackNumber),
451
+ modes: Object.fromEntries(wanted.map(track => [track.trackNumber, modes.get(track)])) };
452
+ reading = { wanted, modes, interval, rangesKey: `${map.sourceKey}:${map.fileIndex}:${JSON.stringify(interval)}` };
453
+ }
454
+ segmentReadings.set(key, { trackList, shift, choicesKey, from: zone.sourceInterval.from, to: zone.sourceInterval.to, reading });
455
+ return reading;
456
+ };
457
+ /** A segment's ranges joined at the precision the torrent fetches, once per kept list and piece length. */
458
+ const joinedRanges = new WeakMap();
459
+ const joinedRangesOf = (ranges, pieceLength) => {
460
+ const kept = joinedRanges.get(ranges);
461
+ if (kept && kept.pieceLength === pieceLength) return kept.joined;
462
+ const joined = joinedWithinPieces(ranges, pieceLength);
463
+ if (ranges !== null && typeof ranges === "object") joinedRanges.set(ranges, { pieceLength, joined });
464
+ return joined;
465
+ };
429
466
  const downloadMaps = new DownloadMaps({
430
467
  // The ranges a picture reads at open are named again by every segment;
431
468
  // only the zone the swarm would rank first is sent (torrent-tv/meta#166).
@@ -487,19 +524,28 @@ export async function startProxyServer({
487
524
  if (!map.isCurrent()) return [];
488
525
  const direct = navigated.kind === "result" && navigated.value === true &&
489
526
  typeof container?.readSourceRanges === "function" ? container : null;
527
+ // An output whose file-level reading is not there in this pass — the
528
+ // index or Cues that say where a segment is, not yet downloaded, or the
529
+ // table its segments read, refused memory — answers its other segments
530
+ // the same way. One unanswered request is kept to be retried when the
531
+ // bytes or the memory change, and the map is built again when it
532
+ // answers; asking every segment held the main thread for 3.3 s on a cold
533
+ // two-hour AVI (torrent-tv/meta#178).
534
+ const waiting = new Set();
490
535
  for (const zone of demands) {
491
536
  if (!map.isCurrent()) return [];
492
- const selected = zone.tracks.map(choice => ({ choice, track: choice.type === "video"
493
- ? ContainerTrack.firstUsable(tracks.value, "video")
494
- : tracks.value.filter(track => track.type === choice.type)[choice.index] }));
495
- if (selected.some(({ track }) => !track)) continue;
496
- const wanted = selected.map(({ track }) => track);
497
- const modes = new Map(selected.map(({ choice, track }) => [track, choice.mode]));
498
- const interval = { ...zone.sourceInterval, trackIds: wanted.map(track => track.trackNumber),
499
- modes: Object.fromEntries(wanted.map(track => [track.trackNumber, modes.get(track)])) };
537
+ // WHAT A SEGMENT READS IS WORKED OUT ONCE, not on every publication: its
538
+ // tracks, its interval and the key its bytes are kept under depend on
539
+ // the output, the segment and the file's start time, none of which a
540
+ // new map changes. Rebuilt for every segment of the film on every map,
541
+ // this pass held the main thread up to 1.5 s at a time on Home
542
+ // Assistant (torrent-tv/meta#178).
543
+ const reading = segmentReadingOf(zone, tracks.value, shift, map);
544
+ if (!reading) continue;
545
+ const { wanted, modes, interval, rangesKey } = reading;
500
546
  const { tracks: _choices, owner: _owner, sourceInterval: _sourceInterval, ...demand } = zone;
501
547
  const convert = ranges => {
502
- for (const [byteStart, byteEnd] of joinedWithinPieces(ranges, pieceLength)) converted.push({ ...demand,
548
+ for (const [byteStart, byteEnd] of joinedRangesOf(ranges, pieceLength)) converted.push({ ...demand,
503
549
  downloadInterval: { from: zone.owner.from, to: zone.owner.to }, byteStart, byteEnd });
504
550
  };
505
551
  // The bytes one segment needs are a fact of the file and the output, so
@@ -507,9 +553,9 @@ export async function startProxyServer({
507
553
  // the map changes every few seconds; worked out afresh on every change,
508
554
  // a pass over a two-hour AVI never finished and the next segment's bytes
509
555
  // were never asked for (Home Assistant 2026-10-08, torrent-tv/meta#151).
510
- const rangesKey = `${map.sourceKey}:${map.fileIndex}:${JSON.stringify(interval)}`;
511
556
  const known = segmentSourceRanges.get(rangesKey);
512
557
  if (known) { convert(known); continue; }
558
+ if (waiting.has(zone.outputKey)) continue;
513
559
  if (direct) {
514
560
  // Asked per zone too: a soundtrack of an AVI is read from its own
515
561
  // packets, which the same index states (torrent-tv/meta#166).
@@ -535,10 +581,14 @@ export async function startProxyServer({
535
581
  if (!map.isCurrent()) return [];
536
582
  if (!intervalParams) continue;
537
583
  const navigation = await containerOrchestrator.inspect(intervalParams, "source-navigation");
538
- if (navigation.kind !== "result") continue;
584
+ if (navigation.kind !== "result") {
585
+ if (navigation.kind === "needs-ranges" || navigation.kind === "needs-memory") waiting.add(zone.outputKey);
586
+ continue;
587
+ }
539
588
  const originalRanges = navigation.value;
540
589
  const packets = await containerOrchestrator.inspect(intervalParams, originalRanges ? "source-ranges" : "packets");
541
590
  if (!map.isCurrent()) return [];
591
+ if (packets.kind === "needs-memory") waiting.add(zone.outputKey);
542
592
  if (packets.kind !== "result") continue;
543
593
  const input = originalRanges ? packets.value : new SegmentInputs({ index: packets.value, tracks: wanted }).forInterval({ ...interval, mode: track => modes.get(track) });
544
594
  if (input.kind !== "result") continue;
@@ -562,7 +612,7 @@ export async function startProxyServer({
562
612
  failed: (request, error) => logger.warn(`media request=${request.key} probe failed: ${error?.message ?? error}`)
563
613
  });
564
614
  const indexMemory = new IndexMemory({
565
- reviseBudget: () => { void outputParts.machineBudget.revise().catch(error => logger.warn(`packet index budget: ${error.message}`)); },
615
+ reviseBudget: () => { void outputParts.machineBudget.revise("memory", "media metadata").catch(error => logger.warn(`packet index budget: ${error.message}`)); },
566
616
  changed: () => { mediaReads.memoryChanged(); probeReads.memoryChanged(); outputParts.encodeInputs?.memoryChanged(); }
567
617
  });
568
618
  const containerOver = async ({ sourceKey, fileIndex, requestId, packetInterval, demand }) => {
@@ -197,7 +197,11 @@ export class LocalObservations {
197
197
  * @param {import("./output/OutputSpec.js").OutputSpec} params.spec
198
198
  * @param {ContentDescriptor | null} params.content - What it was encoded from.
199
199
  * @param {number | null} params.aloneSpeedX - Seconds of film per second,
200
- * measured with nothing else encoding.
200
+ * measured with nothing else encoding and filed as if on a quiet machine
201
+ * (`EncodeCost.learnFrom`), the unit the startup prediction it replaces
202
+ * is in. Readings kept before torrent-tv/meta#178 were filed as read,
203
+ * lower by what the machine was doing; the slowest is used, so they err
204
+ * toward the viewer until newer ones replace them.
201
205
  * @param {{ averageKbps: number, peakKbps: number } | null} params.segmentKbps
202
206
  * @returns {void}
203
207
  */
@@ -19,12 +19,28 @@
19
19
  * step was refused, and the quality menu emptied itself down to the one copied
20
20
  * height (fixed in 2.21.1).
21
21
  *
22
- * So: our own encoders are priced by the concurrency arithmetic, and the
23
- * proxy's own work — the torrent, the hashing, the delivery — is priced per
24
- * megabyte moved. Both are already in the budget. What is NOT in it is
25
- * everything else the machine does: the kernel, the container runtime, whatever
26
- * else the owner runs on their box. That is what is subtracted here, and
27
- * nothing more.
22
+ * So: our own encoders are priced by the concurrency arithmetic. The proxy's
23
+ * own work — the torrent thread, the hashing, the delivery, the copies of
24
+ * encoder inputs — was to be priced per megabyte moved, and that price is not
25
+ * established: since 2.97.9 it is measured for the log only, and nothing
26
+ * charges the proxy's processor to an encoder. An encoder therefore gets what
27
+ * other processes AND the proxy itself leave (`forEncoders`), the proxy's
28
+ * share as measured over the same interval, taken off once here and nowhere
29
+ * else (torrent-tv/meta#178). The field case of 2026-08-17 below: a step
30
+ * predicted at 1.83x on a quiet box ran at 1.01-1.12x while ffmpeg had 56 %,
31
+ * the proxy 20 % and the machine 99 %; what other processes leave predicts
32
+ * 1.41x, what the proxy leaves as well predicts 1.04x.
33
+ *
34
+ * Once means in the readings too. A speed a running encoder is seen at already
35
+ * contains what the machine left it then, so it is filed as if alone on a
36
+ * quiet machine — divided by `leftForEncoders` at the reading, the unit the
37
+ * startup measurements are filed in — and multiplied by what is left now when
38
+ * it is used. Filed as read, the proxy's draw at the reading was taken off a
39
+ * second time by every prediction made from it, which is the 2.21.0 failure.
40
+ *
41
+ * `share`, what other processes leave, stays for the download's share of the
42
+ * proxy's processor (`processor-share.js`), which divides the proxy's room
43
+ * between its own download and its encoders and must not lose that room twice.
28
44
  */
29
45
 
30
46
  /**
@@ -34,24 +50,42 @@
34
50
  * `shareOfMachine` reports them.
35
51
  * @param {number | null} reading.systemBusy - Everything the machine is doing.
36
52
  * @param {number | null} reading.encoderShare - Our own ffmpeg processes.
37
- * @param {number | null} reading.proxyShare - The proxy process itself.
38
- * @returns {{ share: number, unattributed: number, known: boolean }}
39
- * `known` is false when the host does not report its own load — then the
40
- * share is 1 and the caller must say it is uncorrected rather than pretend.
53
+ * @param {number | null} reading.proxyShare - The proxy process itself, every
54
+ * thread of it.
55
+ * @returns {{ share: number, forEncoders: number, unattributed: number, known: boolean }}
56
+ * `share` is what other processes leave; `forEncoders` what they and the
57
+ * proxy leave. `known` is false when the host does not report its own load —
58
+ * then both are 1 and the caller must say it is uncorrected rather than pretend.
41
59
  */
42
60
  export function availableShareFrom(reading = {}) {
43
61
  const systemBusy = finite(reading.systemBusy);
44
62
  if (systemBusy === null) {
45
63
  // No reading at all: not every host has /proc. An uncorrected prediction is
46
64
  // the honest answer, and the caller says so.
47
- return { share: 1, unattributed: 0, known: false };
65
+ return { share: 1, forEncoders: 1, unattributed: 0, known: false };
48
66
  }
49
- const ours = (finite(reading.encoderShare) ?? 0) + (finite(reading.proxyShare) ?? 0);
67
+ const encoders = finite(reading.encoderShare) ?? 0;
68
+ const ours = encoders + (finite(reading.proxyShare) ?? 0);
50
69
  // Rounding, and the two readings being taken microseconds apart, can put our
51
70
  // own share fractionally above the system total. Below zero is not a
52
71
  // measurement of anything.
53
72
  const unattributed = clamp(systemBusy - ours, 0, 1);
54
- return { share: clamp(1 - unattributed, 0, 1), unattributed, known: true };
73
+ return { share: clamp(1 - unattributed, 0, 1), forEncoders: clamp(1 - (systemBusy - encoders), 0, 1),
74
+ unattributed, known: true };
75
+ }
76
+
77
+ /**
78
+ * The share of the machine an encoder gets: what other processes and the
79
+ * proxy leave, or 1 for a host that does not report its load.
80
+ *
81
+ * @param {{ share: number, forEncoders?: number, known: boolean } | null | undefined} availability
82
+ * @returns {number}
83
+ */
84
+ export function leftForEncoders(availability) {
85
+ if (!availability?.known) {
86
+ return 1;
87
+ }
88
+ return Number.isFinite(availability.forEncoders) ? availability.forEncoders : availability.share;
55
89
  }
56
90
 
57
91
  /**
@@ -61,17 +95,14 @@ export function availableShareFrom(reading = {}) {
61
95
  * `share` of itself produces `share ×` the speed the benchmark measured alone.
62
96
  *
63
97
  * @param {number} predictedSpeed - From the quiet-host benchmark.
64
- * @param {{ share: number, known: boolean }} availability
98
+ * @param {{ share: number, forEncoders?: number, known: boolean }} availability
65
99
  * @returns {number}
66
100
  */
67
101
  export function correctForAvailability(predictedSpeed, availability) {
68
102
  if (!Number.isFinite(predictedSpeed) || predictedSpeed <= 0) {
69
103
  return predictedSpeed;
70
104
  }
71
- if (!availability?.known) {
72
- return predictedSpeed;
73
- }
74
- return predictedSpeed * availability.share;
105
+ return predictedSpeed * leftForEncoders(availability);
75
106
  }
76
107
 
77
108
  /**
@@ -52,7 +52,7 @@ export function costKindForSession(session) {
52
52
  return session?.spec?.transcodesVideo === true ? "decode" : "copy";
53
53
  }
54
54
 
55
- import { correctForAvailability } from "../available-share.js";
55
+ import { correctForAvailability, leftForEncoders } from "../available-share.js";
56
56
  import { medianOf, movedBeyondScatter, READINGS_KEPT } from "../learned-median.js";
57
57
  import { contentionPenalty } from "../contention.js";
58
58
  import { ENCODE_RUN_STATE, liveRunsOf, processCanBeSignalled } from "../encode-run-state.js";
@@ -366,7 +366,7 @@ export class EncodeCost {
366
366
  const outputs = this.#outputs.outputsOn(address);
367
367
  let measured = 0;
368
368
  for (const session of outputs) {
369
- const speed = Number(qualityStateOf(session).lastAloneSpeed);
369
+ const speed = correctForAvailability(Number(qualityStateOf(session).lastAloneSpeed), this.#host().availability);
370
370
  if (Number.isFinite(speed) && speed > measured) {
371
371
  measured = speed;
372
372
  }
@@ -649,7 +649,7 @@ export class EncodeCost {
649
649
  /** @type {Map<number, number>} */
650
650
  const speeds = new Map();
651
651
  for (const session of this.#outputs.familyOf(base)) {
652
- const speed = qualityStateOf(session).lastAloneSpeed;
652
+ const speed = correctForAvailability(qualityStateOf(session).lastAloneSpeed, this.#host().availability);
653
653
  if (!session.spec.transcodesVideo || !Number.isFinite(speed)) {
654
654
  continue;
655
655
  }
@@ -874,6 +874,17 @@ export class EncodeCost {
874
874
  if (speed === null) {
875
875
  return;
876
876
  }
877
+ // What this would have done alone on a quiet machine: the reading divided
878
+ // by what the machine left encoders while it was taken, the unit every
879
+ // price here is in. A prediction multiplies it by what is left NOW, so the
880
+ // proxy's own draw at the reading is not taken off a second time
881
+ // (torrent-tv/meta#178). A machine that left encoders nothing says nothing
882
+ // about that.
883
+ const left = leftForEncoders(this.#host().availability);
884
+ if (!(left > 0)) {
885
+ return;
886
+ }
887
+ const quietSpeed = speed / left;
877
888
  const state = qualityStateOf(session);
878
889
  const kind = costKindForSession(session);
879
890
  // A reading taken beside another encoder contains that other encoder's
@@ -906,10 +917,11 @@ export class EncodeCost {
906
917
  }
907
918
  // What this encode did with the machine to itself — the one figure a live
908
919
  // reading is authority on, and what withdraws a quality step that has been
909
- // seen failing without letting it speak for steps nobody has run. The time
910
- // the input waited for the swarm is not in it, so a starved run no longer
911
- // reads as a slow machine.
912
- state.lastAloneSpeed = speed;
920
+ // seen failing without letting it speak for steps nobody has run, judged
921
+ // at what the machine leaves encoders now. The time the input waited for
922
+ // the swarm is not in it, so a starved run no longer reads as a slow
923
+ // machine.
924
+ state.lastAloneSpeed = quietSpeed;
913
925
  // What the offer predicted for this very step, against what it then did
914
926
  // with the machine to itself. The prediction is corrected for the share of
915
927
  // the machine that was free at the time, so this ratio is the error that
@@ -936,7 +948,7 @@ export class EncodeCost {
936
948
  // What is left after the work that was already accounted for. `null` when
937
949
  // the subtraction leaves nothing positive, which means the reading says
938
950
  // less than the noise in it.
939
- const ownCostSec = 1 / speed - othersCostSec;
951
+ const ownCostSec = 1 / quietSpeed - othersCostSec;
940
952
  if (!(ownCostSec > 0) || !Number.isFinite(ownCostSec)) {
941
953
  return;
942
954
  }
@@ -944,10 +956,10 @@ export class EncodeCost {
944
956
  return;
945
957
  }
946
958
  if (kind === "decode") {
947
- this.#learnDecodeCost(session, speed);
959
+ this.#learnDecodeCost(session, quietSpeed);
948
960
  return;
949
961
  }
950
- await this.#learnCopyCost(session, speed);
962
+ await this.#learnCopyCost(session, quietSpeed);
951
963
  }
952
964
 
953
965
  async #learnCopyCost(session, speed) {
@@ -572,7 +572,8 @@ export class HostLoad {
572
572
  ` over=${share.elapsedSec.toFixed(1)}s` +
573
573
  // What the offer will multiply a prediction by, in the same line as the
574
574
  // readings it comes from.
575
- ` available=${asPercent(this.hostAvailability.share)} free=${asPercent(free)}` + late
575
+ ` available=${asPercent(this.hostAvailability.share)} for-encoders=${asPercent(this.hostAvailability.forEncoders)}` +
576
+ ` free=${asPercent(free)}` + late
576
577
  );
577
578
  }
578
579
 
@@ -31,6 +31,7 @@ import { chooseOutputFps, computeOutputDimensions, TRANSCODE_FPS } from "../args
31
31
  import { processCanBeSignalled } from "../encode-run-state.js";
32
32
  import { sourceDecodeCharacteristics } from "../../media/SourceFile.js";
33
33
  import { qualityStateOf } from "./OutputQualityState.js";
34
+ import { leftForEncoders } from "../available-share.js";
34
35
 
35
36
  export class QualityOffer {
36
37
  #cost;
@@ -39,6 +40,7 @@ export class QualityOffer {
39
40
  #supplyFor;
40
41
  #stateFor;
41
42
  #occupancyKnownFor;
43
+ #availability;
42
44
 
43
45
  /**
44
46
  * @param {{
@@ -69,8 +71,13 @@ export class QualityOffer {
69
71
  // Whether every other output holding a place has a measured price. The
70
72
  // pool must not be told this host can serve a file while existing work is
71
73
  // unpriced (roadmap item 97, step 14).
72
- occupancyKnownFor = () => true
74
+ occupancyKnownFor = () => true,
75
+ // WHAT THIS MACHINE LEAVES AN ENCODER, as the host last measured it
76
+ // (`available-share.js`): read into the answer, so a change of it reaches
77
+ // the menu (torrent-tv/meta#178).
78
+ availability = () => null
73
79
  }) {
80
+ this.#availability = availability;
74
81
  this.#occupiedCostSec = occupiedCostSec;
75
82
  this.#occupancyKnownFor = occupancyKnownFor;
76
83
  this.#cost = encodeCost;
@@ -164,14 +171,24 @@ export class QualityOffer {
164
171
  // whole film; while rounding alone would hide the 0.995-1.005 crossing,
165
172
  // which is exactly the band a step spends its time in when the host is
166
173
  // marginal. The flag carries the crossing, the rounded cost carries the
167
- // rest.
174
+ // rest. The speed is filed as if alone on a quiet machine; the crossing is
175
+ // judged at what the machine leaves encoders now.
176
+ // WHAT THE MACHINE LEAVES AN ENCODER divides every prediction the answer is
177
+ // made of, and it moves with the download and everything else this process
178
+ // does. Left out, a menu computed while the proxy was quiet stood for the
179
+ // whole film: `fast` at a predicted 1.0x ran at 0.9-1.1x once the swarm
180
+ // delivered, and the picture stood at nearly every piece after a seek
181
+ // (Home Assistant 2026-10-12, torrent-tv/meta#178). To a hundredth of the
182
+ // machine, the finest the predictions it divides are compared at.
183
+ const availability = this.#availability();
184
+ const left = leftForEncoders(availability);
168
185
  const measured = this.#outputs.familyOf(owner)
169
186
  .map((member) => {
170
187
  const speed = qualityStateOf(member).lastAloneSpeed;
171
188
  if (!Number.isFinite(speed) || !(speed > 0)) {
172
189
  return "-";
173
190
  }
174
- return `${speed < 1 ? "slow" : "ok"}${(1 / speed).toFixed(2)}`;
191
+ return `${speed * left < 1 ? "slow" : "ok"}${(1 / speed).toFixed(2)}`;
175
192
  })
176
193
  .join(",");
177
194
  // The bar the answer is judged against, and the rate the torrent's price is
@@ -185,7 +202,7 @@ export class QualityOffer {
185
202
  const version =
186
203
  `${observed?.version ?? 0}:${playing}:${copyVersion}:${torrentCost.toFixed(6)}:` +
187
204
  `${audioVersion}:${running}:${measured}:${(demanded ?? 0).toFixed(2)}:` +
188
- `${(movingMegabytes ?? 0).toFixed(2)}`;
205
+ `${(movingMegabytes ?? 0).toFixed(2)}:${left.toFixed(2)}`;
189
206
  const state = qualityStateOf(owner);
190
207
  if (Array.isArray(state.offeredHeightsCache) && state.offeredHeightsVersion === version) {
191
208
  return state.offeredHeightsCache;
@@ -1,6 +1,22 @@
1
- /** Retry unfinished statements when their missing resource changes. */
1
+ /**
2
+ * Retry unfinished statements when their missing resource changes.
3
+ *
4
+ * ONE REFUSAL OF MEMORY ANSWERS THE STATEMENT, not one request of it. The
5
+ * table a statement reserves belongs to the file — a soundtrack's packet table
6
+ * is one table for every segment of it — so once a read of a statement has
7
+ * been refused memory, the other requests of the same statement of the same
8
+ * file wait for memory to change instead of being read and refused one by one.
9
+ * Read one by one, a cold two-hour AVI's 1943 soundtrack segments were refused
10
+ * in turn when the index's last piece arrived, which held the main thread for
11
+ * 3.3 s and the budget revision that would have given the memory behind it
12
+ * (Home Assistant 2026-10-12, torrent-tv/meta#178). A retry wave therefore
13
+ * reads one request of each statement first and the others once it has
14
+ * answered.
15
+ */
2
16
  export class MediaReadRequests {
3
17
  #requests = new Map();
18
+ /** File and statement → the memory revision at which a read of it was refused memory. */
19
+ #refusedMemory = new Map();
4
20
  #read;
5
21
  #failed;
6
22
  #versions = new Map();
@@ -39,6 +55,7 @@ export class MediaReadRequests {
39
55
  this.#notify(params.sourceKey, params.fileIndex, result);
40
56
  return this.#requests.delete(key);
41
57
  }
58
+ if (result.kind === "needs-memory") this.#refusedMemory.set(statementKey(params, statement), memoryRevision);
42
59
  const existing = this.#requests.get(key);
43
60
  if (existing) { existing.params = params; existing.kind = result.kind; }
44
61
  else this.#requests.set(key, { params, statement, kind: result.kind, running: false, changed: false });
@@ -54,24 +71,49 @@ export class MediaReadRequests {
54
71
 
55
72
  memoryChanged() {
56
73
  this.#memoryVersion++;
74
+ const wave = [];
57
75
  for (const request of this.#requests.values()) {
58
76
  if (request.kind !== "needs-memory") continue;
59
77
  // The retried statement announces its result. A memory event is not a
60
78
  // source change for another statement still waiting for missing bytes.
61
79
  request.changed = true;
62
- if (!request.running) void this.#retry(request);
80
+ if (!request.running) wave.push(request);
63
81
  }
82
+ this.#retryWave(wave);
64
83
  }
65
84
 
66
85
  bytesChanged(sourceKey, fileIndex) {
67
86
  const fileKey = `${sourceKey}:${fileIndex}`;
68
87
  this.#versions.set(fileKey, this.revision(sourceKey, fileIndex) + 1);
69
88
  this.#notify(sourceKey, fileIndex, { kind: "bytes-changed" });
89
+ const wave = [];
70
90
  for (const request of this.#requests.values()) {
71
91
  if (request.params.sourceKey !== sourceKey || request.params.fileIndex !== fileIndex) continue;
72
92
  if (request.kind !== "needs-ranges") continue;
73
93
  request.changed = true;
74
- if (!request.running) void this.#retry(request);
94
+ if (!request.running) wave.push(request);
95
+ }
96
+ this.#retryWave(wave);
97
+ }
98
+
99
+ /**
100
+ * Retry `requests`: one of each statement of each file first, the others of
101
+ * that statement once it has answered.
102
+ *
103
+ * @param {object[]} requests
104
+ */
105
+ #retryWave(requests) {
106
+ const groups = new Map();
107
+ for (const request of requests) {
108
+ const key = statementKey(request.params, request.statement);
109
+ const group = groups.get(key);
110
+ if (group) group.push(request);
111
+ else groups.set(key, [request]);
112
+ }
113
+ for (const [head, ...rest] of groups.values()) {
114
+ void this.#retry(head).then(() => {
115
+ for (const request of rest) if (!request.running) void this.#retry(request);
116
+ });
75
117
  }
76
118
  }
77
119
 
@@ -90,6 +132,10 @@ export class MediaReadRequests {
90
132
  for (const key of this.#versions.keys()) {
91
133
  if (fileIndex === undefined ? key.startsWith(`${sourceKey}:`) : key === `${sourceKey}:${fileIndex}`) this.#versions.delete(key);
92
134
  }
135
+ for (const key of [...this.#refusedMemory.keys()]) {
136
+ const [refusedSource, refusedFile] = JSON.parse(key);
137
+ if (refusedSource === sourceKey && (fileIndex === undefined || refusedFile === fileIndex)) this.#refusedMemory.delete(key);
138
+ }
93
139
  }
94
140
 
95
141
  retain(sourceKey, fileIndex, wanted) {
@@ -105,6 +151,12 @@ export class MediaReadRequests {
105
151
  try {
106
152
  while (request.changed && this.#requests.get(key) === request) {
107
153
  request.changed = false;
154
+ if (this.#refusedMemory.get(statementKey(request.params, request.statement)) === this.#memoryVersion) {
155
+ // The same statement of the same file was refused memory in this
156
+ // very state of memory: so is this one, without reading it.
157
+ request.kind = "needs-memory";
158
+ break;
159
+ }
108
160
  await this.#read(request.params, request.statement);
109
161
  }
110
162
  } catch (error) {
@@ -118,3 +170,7 @@ export class MediaReadRequests {
118
170
  function requestKey(params, statement) {
119
171
  return JSON.stringify([params.sourceKey, params.fileIndex, statement, params.requestId ?? null]);
120
172
  }
173
+
174
+ function statementKey(params, statement) {
175
+ return JSON.stringify([params.sourceKey, params.fileIndex, statement]);
176
+ }
@@ -167,7 +167,7 @@ export class AviContainer extends Container {
167
167
  if (!index) return false;
168
168
  const picture = ContainerTrack.firstUsable(await this.readTracks(), "video");
169
169
  if (picture && interval?.trackIds?.length && !interval.trackIds.includes(picture.trackNumber)) return false;
170
- return !picture || index.keyframeTimes(picture.trackNumber).length > 0;
170
+ return !picture || index.hasKeyframes(picture.trackNumber);
171
171
  }
172
172
 
173
173
  async readMappedSourceRanges(interval) { return this.readSourceRanges(interval); }
@@ -296,6 +296,16 @@ class AviIndex {
296
296
  return keys;
297
297
  }
298
298
 
299
+ /**
300
+ * Whether a stream states any keyframe. Asked for every segment while a map
301
+ * is turned into bytes; answered from the times it built a list of every
302
+ * keyframe of the film each time, 6.1 s at once on Home Assistant
303
+ * (torrent-tv/meta#178).
304
+ */
305
+ hasKeyframes(stream) {
306
+ return this.#keyframesOf(stream).length > 0;
307
+ }
308
+
299
309
  /** Keyframe times of a stream, ascending. */
300
310
  keyframeTimes(stream) {
301
311
  const packets = this.packets[stream];
@@ -26,6 +26,21 @@ function isWarmupTimeoutError(error) {
26
26
  * @param {string} fileName
27
27
  * @returns {number | null}
28
28
  */
29
+ /**
30
+ * Whether two lists of a piece's torrent inputs state the same bytes: the
31
+ * download map hands out new lists around the ranges it keeps, so the ranges
32
+ * themselves are compared.
33
+ *
34
+ * @param {Array<{ sourceId: string, ranges: object[] }> | undefined} left
35
+ * @param {Array<{ sourceId: string, ranges: object[] }> | undefined} right
36
+ * @returns {boolean}
37
+ */
38
+ function sameInputs(left, right) {
39
+ if (left === right) return true;
40
+ if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) return false;
41
+ return left.every((input, index) => input.sourceId === right[index].sourceId && input.ranges === right[index].ranges);
42
+ }
43
+
29
44
  function segmentAddressOf(output, fileName) {
30
45
  const format = output.segmentFormat;
31
46
  if (!format) {
@@ -45,6 +60,8 @@ export class ViewerRequests {
45
60
  #host;
46
61
  /** @type {WeakMap<object, Map<string, object>>} */
47
62
  #rateStateBySession = new WeakMap();
63
+ /** The pieces of each output as the start forecast read them last; see `#segmentsOf`. */
64
+ #forecastPieces = new WeakMap();
48
65
 
49
66
  /**
50
67
  * @param {object} host - `opening`, `activeOutputFor`, `viewerSecondsOn`, `minimumBufferSecondsFor`, `getSourceStats`, `disposeSession`, `planEncodersSoon`, `waitUntilReady`, `encodeRuns`, `encodeSpeedReadingOf`, `outputTimes`, `outputs`, `lookaheadSeconds`, `quality`, `qualityOffer`, `renditions`, `segmentDurationSec`, `segmentStore`, `sourceFiles`, `startupWaitMs`, `viewers`, `acceptsGeneration`, `generationOfRequest`, `noteGivenOutput`, `holdForResponse`, `noteServingVerdict`, `servingVerdictOf`
@@ -561,10 +578,7 @@ export class ViewerRequests {
561
578
  for (const output of outputs) {
562
579
  const trackProgress = output === session ? progress : this.#host.encodeRuns.progressOf(output);
563
580
  const trackRates = this.#trackRateReadings(measurement, output, now);
564
- const timeline = output.timeline;
565
581
  const trackName = output === session ? "video" : "audio";
566
- const nativeRanges = Array.from({ length: timeline?.segmentCount ?? 0 }, (_, index) =>
567
- this.#host.encodeRuns.finishedMediaRangesOf(output, index));
568
582
  // The page states the offset its player applied to this track, and the
569
583
  // init it applied it to is the session's. Until both are known, the
570
584
  // pieces stay on their own presentation timeline: there is nothing yet
@@ -573,17 +587,9 @@ export class ViewerRequests {
573
587
  const servedInit = Number.isFinite(playerOffset) ? this.#host.segmentStore.initOf(output.outputKey) : null;
574
588
  const placedByPlayer = Boolean(servedInit?.length);
575
589
  const sessionInit = placedByPlayer ? servedInit : null;
576
- const segments = Array.from({ length: timeline?.segmentCount ?? 0 }, (_, index) => {
577
- const ranges = nativeRanges[index];
578
- return {
579
- index,
580
- startSeconds: timeline.publishedStartOf(index),
581
- endSeconds: timeline.publishedStartOf(index + 1),
582
- sourceInputs: this.#host.sourceInputsFor?.(output, index) ?? undefined,
583
- mediaRanges: ranges && output.segmentFormat?.servedMediaRanges ?
584
- output.segmentFormat.servedMediaRanges(ranges, { initBytes: sessionInit }) : undefined
585
- };
586
- });
590
+ const inventory = this.#host.segmentStore.sizesOf(output.outputKey);
591
+ const segments = this.#segmentsOf(output, sessionInit, inventory, Number.isFinite(viewerReading?.positionSeconds)
592
+ ? viewerReading.positionSeconds : this.#host.viewerSecondsOn(session, consumerId, now));
587
593
  const sourceIndexes = [...output.spec.sourceFileIndexes];
588
594
  if (sourceIndexes.length === 0) {
589
595
  sourceIndexes.push(output.file.fileIndex);
@@ -622,7 +628,6 @@ export class ViewerRequests {
622
628
  });
623
629
  }
624
630
 
625
- const inventory = this.#host.segmentStore.sizesOf(output.outputKey);
626
631
  const observedMbps = output === session
627
632
  ? outputMbps
628
633
  : await this.#host.quality.observedStreamMbps(output);
@@ -733,9 +738,98 @@ export class ViewerRequests {
733
738
  }))
734
739
  })}`);
735
740
  }
741
+ this.#sayHolding(session, consumerId, measurement, forecast, input, now);
736
742
  return forecast;
737
743
  }
738
744
 
745
+ /**
746
+ * What holds a waiting viewer's start, said when it changes: which piece,
747
+ * its production or its delivery, when it comes against the media before it,
748
+ * and the rates the forecast used. Said again when the delay has moved by a
749
+ * second, at most once a second; a hold that changes nothing says nothing.
750
+ *
751
+ * @param {object} session
752
+ * @param {string} consumerId
753
+ * @param {object} measurement - This viewer's forecast state.
754
+ * @param {object} forecast
755
+ * @param {object} input
756
+ * @param {number} now
757
+ */
758
+ #sayHolding(session, consumerId, measurement, forecast, input, now) {
759
+ const holding = forecast.holdingBy;
760
+ if (!holding) {
761
+ measurement.held = null;
762
+ return;
763
+ }
764
+ const key = `${holding.kind}:${holding.track}:${holding.segment}`;
765
+ const delay = Number.isFinite(forecast.delaySeconds) ? forecast.delaySeconds : null;
766
+ const said = measurement.held;
767
+ if (said && said.key === key && (delay === null || said.delay === null || Math.abs(delay - said.delay) < 1 ||
768
+ now - said.at < 1000)) return;
769
+ measurement.held = { key, delay, at: now };
770
+ const seconds = value => Number.isFinite(value) ? value.toFixed(2) : "?";
771
+ const rates = (forecast.operations ?? []).map(operation =>
772
+ `${operation.track} ${operation.operation} ${Number.isFinite(operation.speed) ? operation.speed.toFixed(2) : "?"}x`);
773
+ const downloads = (input.sources ?? []).map(source => {
774
+ const rate = source.downloadRateReadings?.lastValue;
775
+ return `${Number.isFinite(rate) ? (rate / 1_048_576).toFixed(2) : "?"} MB/s`;
776
+ });
777
+ logger.info(`playback held ${session.id} consumer=${consumerId}: delay ${seconds(delay)}s by ` +
778
+ (holding.kind === "position-not-held" ? "the position, which nothing buffered covers" :
779
+ `the ${holding.kind} of ${holding.track} #${holding.segment} in ${seconds(holding.atSeconds)}s against ` +
780
+ `${seconds(holding.aheadSeconds)}s of media before it${holding.requiresSource ? ", its input still downloading" : ""}`) +
781
+ `; buffered ${seconds(forecast.bufferedSeconds)}s; rates ${rates.join(", ") || "none"}; download ${downloads.join(", ") || "none"}`);
782
+ }
783
+
784
+ /**
785
+ * The pieces of one output as the start forecast reads them, kept between
786
+ * polls: a piece whose playlist edges, read ranges and torrent inputs have
787
+ * not changed is the same object it was, so what the forecast derived from it
788
+ * once — its exact edges, its joined ranges — is not derived again. An unread
789
+ * piece that is not made and ends before the viewer is not listed: the
790
+ * forecast reads nothing from it. Building the whole film anew on every poll
791
+ * was part of what held the main thread for seconds (torrent-tv/meta#178).
792
+ *
793
+ * @param {object} output
794
+ * @param {Buffer | null} sessionInit
795
+ * @param {Map<number, number>} inventory - Made pieces and their sizes.
796
+ * @param {number} positionSeconds
797
+ * @returns {object[]}
798
+ */
799
+ #segmentsOf(output, sessionInit, inventory, positionSeconds) {
800
+ const timeline = output.timeline;
801
+ const count = timeline?.segmentCount ?? 0;
802
+ let kept = this.#forecastPieces.get(output);
803
+ if (!kept) {
804
+ kept = [];
805
+ this.#forecastPieces.set(output, kept);
806
+ }
807
+ const segments = [];
808
+ for (let index = 0; index < count; index++) {
809
+ const endSeconds = timeline.publishedStartOf(index + 1);
810
+ const ranges = this.#host.encodeRuns.finishedMediaRangesOf(output, index);
811
+ const mediaRanges = ranges && output.segmentFormat?.servedMediaRanges ?
812
+ output.segmentFormat.servedMediaRanges(ranges, { initBytes: sessionInit }) : undefined;
813
+ if (index !== 0 && mediaRanges === undefined && !inventory.has(index) &&
814
+ Number.isFinite(endSeconds) && Number.isFinite(positionSeconds) && endSeconds <= positionSeconds) {
815
+ continue;
816
+ }
817
+ const startSeconds = timeline.publishedStartOf(index);
818
+ const sourceInputs = this.#host.sourceInputsFor?.(output, index) ?? undefined;
819
+ const previous = kept[index];
820
+ if (previous && previous.startSeconds === startSeconds && previous.endSeconds === endSeconds &&
821
+ previous.mediaRanges === mediaRanges && sameInputs(previous.sourceInputs, sourceInputs)) {
822
+ segments.push(previous);
823
+ continue;
824
+ }
825
+ const segment = { index, startSeconds, endSeconds, sourceInputs, mediaRanges };
826
+ kept[index] = segment;
827
+ segments.push(segment);
828
+ }
829
+ if (kept.length > count) kept.length = count;
830
+ return segments;
831
+ }
832
+
739
833
  #rateStateFor(session, consumerId) {
740
834
  let byViewer = this.#rateStateBySession.get(session);
741
835
  if (!byViewer) {
@@ -860,6 +860,7 @@ export function wireOutputs({
860
860
  encodeCost: parts.encodeCost,
861
861
  occupiedCostSec: (fileKey) => parts.admission?.occupiedCost({ exceptFileKey: fileKey }).costSec ?? 0,
862
862
  occupancyKnownFor: (fileKey) => (parts.admission?.occupiedCost({ exceptFileKey: fileKey }).unpriced ?? 0) === 0,
863
+ availability: () => parts.hostLoad.hostAvailability ?? null,
863
864
  outputs: parts.outputs,
864
865
  stateFor: (session) => parts.encodeRuns.runStateOf(session),
865
866
  // WHICH HEIGHTS A LIVE VIEWER HAS ON SCREEN, as numbers. Who is watching
@@ -1007,7 +1008,7 @@ export function wireOutputs({
1007
1008
  resolve: resolveEncodeInput,
1008
1009
  readRanges: readEncodeRanges,
1009
1010
  heldRanges: readEncodeHeldRanges,
1010
- reviseBudget: () => parts.machineBudget.revise(),
1011
+ reviseBudget: () => parts.machineBudget.revise("memory", "admitted encode inputs"),
1011
1012
  capacity: () => parts.machineBudget.capacityOf("memory"),
1012
1013
  // How long this output's input takes to open, from the one owner of start
1013
1014
  // readings (torrent-tv/meta#184).
@@ -77,6 +77,14 @@ import { OtherDemand, divideAllowance } from "./allowance.js";
77
77
  export class MachineBudget {
78
78
  #revisionPromise = null;
79
79
  #reviseAgain = false;
80
+ /**
81
+ * The resources the next pass reads: `null` for every one, else their names.
82
+ * A claimant short of memory asks for memory alone; reading the disks first
83
+ * made its answer wait for them (torrent-tv/meta#178).
84
+ *
85
+ * @type {Set<string> | null}
86
+ */
87
+ #againScope = null;
80
88
  /**
81
89
  * The revision in hand, for its own line: when it was first asked for, how
82
90
  * many asks joined it, and which pass this is. At a cold start on the addon
@@ -151,32 +159,45 @@ export class MachineBudget {
151
159
  }
152
160
 
153
161
  /**
154
- * Read every resource, divide each, and tell each claimant its share.
162
+ * Read the resources, divide each, and tell each claimant its share.
155
163
  *
164
+ * @param {string | null} [resource] - One resource to read and divide; every
165
+ * resource when absent. The others keep their last division.
166
+ * @param {string | null} [because] - Who asked, said in the revision's line.
156
167
  * @returns {Promise<Map<string, { freeBytes: number, allowanceBytes: number, shares: object[] }>>}
157
168
  */
158
- revise() {
169
+ revise(resource = null, because = null) {
170
+ const scope = resource === null ? null : new Set([resource]);
159
171
  if (this.#revisionPromise) {
160
172
  this.#reviseAgain = true;
173
+ this.#againScope = this.#againScope === null || scope === null ? null : new Set([...this.#againScope, ...scope]);
161
174
  this.#revision.asks += 1;
162
175
  return this.#revisionPromise;
163
176
  }
164
- this.#revision = { askedAt: Date.now(), asks: 1, pass: 0 };
177
+ this.#revision = { askedAt: Date.now(), asks: 1, pass: 0, because };
178
+ this.#againScope = scope;
165
179
  this.#revisionPromise = (async () => {
166
180
  do {
181
+ const passScope = this.#againScope;
182
+ this.#againScope = new Set();
167
183
  this.#reviseAgain = false;
168
184
  this.#revision.pass += 1;
169
- await this.#readRevision();
185
+ await this.#readRevision(passScope);
170
186
  } while (this.#reviseAgain);
171
187
  return this.#last;
172
188
  })().finally(() => { this.#revisionPromise = null; });
173
189
  return this.#revisionPromise;
174
190
  }
175
191
 
176
- async #readRevision() {
192
+ /** @param {Set<string> | null} scope */
193
+ async #readRevision(scope) {
177
194
  const passFrom = Date.now();
178
195
  let readingMs = 0;
196
+ const readings = [];
179
197
  for (const [name, resource] of this.#resources) {
198
+ if (scope !== null && !scope.has(name)) {
199
+ continue;
200
+ }
180
201
  const claimants = this.#claimants.filter((one) => one.resource === name);
181
202
  if (claimants.length === 0) {
182
203
  continue;
@@ -185,6 +206,7 @@ export class MachineBudget {
185
206
  const readFrom = Date.now();
186
207
  const free = await resource.readFree();
187
208
  readingMs += Date.now() - readFrom;
209
+ readings.push(`${name} ${Date.now() - readFrom}ms`);
188
210
  // WITHOUT A READING, NOTHING IS ALLOWED TO GROW. A resource whose free
189
211
  // space cannot be read is not a resource with room; answering "unbounded"
190
212
  // there is how the spill file came to have no limit in the first place.
@@ -213,8 +235,9 @@ export class MachineBudget {
213
235
  }
214
236
  const revision = this.#revision;
215
237
  this.#logger?.info?.(`${this.describe()}` + (revision
216
- ? ` (pass ${revision.pass} of a revision asked ${revision.asks} time(s), first ${Date.now() - revision.askedAt}ms ago; ` +
217
- `this pass ${Date.now() - passFrom}ms, ${readingMs}ms of it reading what is free)`
238
+ ? ` (pass ${revision.pass} of a revision asked ${revision.asks} time(s), first ${Date.now() - revision.askedAt}ms ago` +
239
+ `${revision.because ? `, first by ${revision.because}` : ""}; ` +
240
+ `this pass ${Date.now() - passFrom}ms, ${readingMs}ms of it reading what is free: ${readings.join(", ") || "nothing"})`
218
241
  : ""));
219
242
  return this.#last;
220
243
  }
@@ -1786,6 +1786,47 @@ export class SharedPieceStore {
1786
1786
  write().then(() => callback(null), (error) => callback(error));
1787
1787
  }
1788
1788
 
1789
+ /**
1790
+ * What `get` answers, written into `target` (exactly `length` bytes long)
1791
+ * instead of a buffer of its own, by the same three routes: memory, disk,
1792
+ * whole file. A reader copying a stretch of film into memory it already holds
1793
+ * then needs nothing beside it while several pieces are read at once
1794
+ * (torrent-tv/meta#178). Answers whether `target` was filled.
1795
+ *
1796
+ * @param {number} index
1797
+ * @param {number} offset - Within the piece.
1798
+ * @param {Buffer} target
1799
+ * @returns {Promise<boolean>}
1800
+ */
1801
+ async getInto(index, offset, target) {
1802
+ if (this.#closed) throw new Error("Piece store is closed.");
1803
+ const length = target.length;
1804
+ if (offset < 0 || offset + length > this.#lengthOf(index)) return false;
1805
+ const fromMemory = () => {
1806
+ const resident = this.#buffers.get(index);
1807
+ if (resident === undefined) return false;
1808
+ this.#lru.touch(index);
1809
+ this.#counters.fromMemory += 1;
1810
+ Buffer.from(resident, offset, length).copy(target);
1811
+ return true;
1812
+ };
1813
+ if (fromMemory()) return true;
1814
+ // A spill that is still being written owns the only copy there is.
1815
+ const spill = this.#evicting.get(index);
1816
+ if (spill) await spill.catch(() => undefined);
1817
+ if (fromMemory()) return true;
1818
+ if (this.#disk.has(index)) {
1819
+ const bytesRead = await this.#disk.read(index, target, offset);
1820
+ this.#counters.fromDisk += 1;
1821
+ return bytesRead === length;
1822
+ }
1823
+ const whole = await this.#fromWholeFiles(index);
1824
+ if (!whole || whole.length < offset + length) return false;
1825
+ this.#counters.fromWholeFile += 1;
1826
+ whole.copy(target, 0, offset, offset + length);
1827
+ return true;
1828
+ }
1829
+
1789
1830
  get(index, options, callback) {
1790
1831
  if (typeof options === "function") {
1791
1832
  return this.get(index, undefined, options);
@@ -153,8 +153,9 @@ export function wireMachineBudget({
153
153
  });
154
154
  }
155
155
 
156
+ let evidenceOn = null;
156
157
  if (diagnostics) {
157
- const evidenceOn = diagnosticsRoot ? deviceOf(diagnosticsRoot) : segmentsOn;
158
+ evidenceOn = diagnosticsRoot ? deviceOf(diagnosticsRoot) : segmentsOn;
158
159
  defineDisk(evidenceOn, diagnosticsRoot || segmentStore.root);
159
160
  budget.register({
160
161
  name: "diagnostics",
@@ -169,11 +170,22 @@ export function wireMachineBudget({
169
170
 
170
171
  return {
171
172
  capacityOf: name => budget.capacityOf(name),
172
- revise: async () => {
173
- // Re-read the evidence before dividing: it is the one claimant whose
174
- // bytes are written by something that never reports them.
175
- await diagnostics?.measure();
176
- return budget.revise();
173
+ /**
174
+ * @param {string | null} [resource] - One resource to revise; all when absent.
175
+ * @param {string | null} [because] - Who asked, for the revision's line.
176
+ */
177
+ revise: async (resource = null, because = null) => {
178
+ // Re-read the evidence before dividing its disk: it is the one claimant
179
+ // whose bytes are written by something that never reports them. Only
180
+ // there: a claimant short of memory asks for memory alone, and walking
181
+ // the evidence's directory first made every memory answer wait for it —
182
+ // 3.7 s on a cold AVI behind 1943 other reads (torrent-tv/meta#178).
183
+ if (diagnostics && (resource === null || resource === evidenceOn)) {
184
+ const from = Date.now();
185
+ await diagnostics.measure();
186
+ because = `${because ?? "a full revision"}, after the evidence was measured in ${Date.now() - from}ms`;
187
+ }
188
+ return budget.revise(resource, because);
177
189
  },
178
190
  segmentBytes: () => segmentBytes,
179
191
  diagnostics: diagnostics ?? null,
@@ -1,5 +1,15 @@
1
1
  import { pieceStoreOf } from "../piece-store-of.js";
2
2
 
3
+ /**
4
+ * How many pieces one read for a reader asks of the store at once: the threads
5
+ * libuv runs file operations on (`UV_THREADPOOL_SIZE`). The proxy's entry point
6
+ * sets 64 where the deployment states none (`bin/thread-pool.js`), and libuv's
7
+ * own default of four applies only to code run without it, such as the
8
+ * checks. A piece from disk is three of their operations, and more at once
9
+ * would only wait behind them (torrent-tv/meta#178).
10
+ */
11
+ const FILE_READS_AT_ONCE = Math.max(1, Number.parseInt(process.env.UV_THREADPOOL_SIZE ?? "", 10) || 4);
12
+
3
13
  /**
4
14
  * @file What of a file the torrent already HOLDS, and reading it without
5
15
  * asking the swarm for anything.
@@ -212,17 +222,35 @@ export async function readHeldRanges(torrent, fileIndex, ranges, maxBytes, logge
212
222
  }
213
223
  try {
214
224
  const result = ranges.map(([start, end]) => Buffer.allocUnsafeSlow(end - start + 1));
215
- for (const [index, span] of pieceSpans) {
225
+ // SEVERAL PIECES AT ONCE, each written straight into the copy. One after
226
+ // another, every read from disk waited for this thread's loop on its own,
227
+ // and the loop runs 0.3-0.7 s late while the swarm delivers 7-14 MB/s: a
228
+ // 210 MB stretch took 11 s here (Home Assistant 2026-10-12,
229
+ // torrent-tv/meta#178). As many are in flight as the runtime has threads
230
+ // for file operations (libuv's pool, `UV_THREADPOOL_SIZE`, 64 as the proxy
231
+ // starts); more would only queue behind them and ahead of everything else
232
+ // this process reads or writes.
233
+ const readOne = async ([index, span]) => {
216
234
  const pieceStart = index * pieceLength;
217
235
  const offset = span.start - pieceStart;
218
236
  const length = span.end - span.start + 1;
237
+ const [only] = span.ranges;
238
+ // A piece holding one range alone is read into the copy itself; one
239
+ // shared by several ranges is read once and divided, as before.
240
+ if (span.ranges.length === 1 && typeof store.getInto === "function") {
241
+ const from = Math.max(only.absoluteStart, pieceStart);
242
+ const filled = await store.getInto(index, offset, result[only.rangeIndex].subarray(from - only.absoluteStart, from - only.absoluteStart + length));
243
+ if (!filled) return false;
244
+ onRead?.(length);
245
+ return true;
246
+ }
219
247
  const buffer = await new Promise((resolve, reject) => {
220
248
  store.get(index, { offset, length }, (error, value) => {
221
249
  if (error) reject(error);
222
250
  else resolve(value);
223
251
  });
224
252
  });
225
- if (!buffer || buffer.length !== length) return null;
253
+ if (!buffer || buffer.length !== length) return false;
226
254
  onRead?.(length);
227
255
  for (const { rangeIndex, absoluteStart, absoluteEnd } of span.ranges) {
228
256
  const from = Math.max(absoluteStart, pieceStart);
@@ -230,8 +258,27 @@ export async function readHeldRanges(torrent, fileIndex, ranges, maxBytes, logge
230
258
  const sourceStart = from - span.start;
231
259
  result[rangeIndex].set(buffer.subarray(sourceStart, sourceStart + to - from + 1), from - absoluteStart);
232
260
  }
233
- }
234
- return result;
261
+ return true;
262
+ };
263
+ const spans = [...pieceSpans];
264
+ let next = 0;
265
+ let whole = true;
266
+ const reader = async () => {
267
+ while (whole && next < spans.length) {
268
+ try {
269
+ if (!await readOne(spans[next++])) whole = false;
270
+ } catch (error) {
271
+ whole = false;
272
+ throw error;
273
+ }
274
+ }
275
+ };
276
+ // Every read has returned before the pieces are let go, a failed one too:
277
+ // a read still in flight would otherwise read a piece nothing holds.
278
+ const outcomes = await Promise.allSettled(Array.from({ length: Math.min(spans.length, FILE_READS_AT_ONCE) }, reader));
279
+ const failed = outcomes.find((outcome) => outcome.status === "rejected");
280
+ if (failed) throw failed.reason;
281
+ return whole ? result : null;
235
282
  } catch (error) {
236
283
  logger?.info?.(`held ranges read ${fileIndex} failed: ${error?.message ?? error}`);
237
284
  return null;
@@ -31,7 +31,6 @@ import {
31
31
  rescale,
32
32
  addRange as addMediaRange,
33
33
  commonTimescale,
34
- scale,
35
34
  subtract,
36
35
  toSeconds,
37
36
  ZERO
@@ -120,7 +119,16 @@ export class RateTrend {
120
119
  */
121
120
  export function predictPlaybackReadiness(input = {}) {
122
121
  const now = Number.isFinite(input.now) ? input.now : Date.now();
123
- const tracks = Array.isArray(input.tracks) ? input.tracks : [];
122
+ // WHAT IS BEHIND THE VIEWER IS NOT PLANNED AGAIN. The forecast runs on every
123
+ // progress poll of a waiting viewer, and it converted every piece of the film
124
+ // to exact time before dropping those already played: 5.1 s of the proxy's
125
+ // main thread at once after a seek back on Home Assistant, every second of
126
+ // which FFmpeg's input and the page's pieces waited (torrent-tv/meta#178).
127
+ // Kept are the pieces still to play, the first piece (where the track
128
+ // starts), the read piece nearest before them (what an unread piece is placed
129
+ // by) and read pieces inside what the page reports holding (their frames).
130
+ const tracks = (Array.isArray(input.tracks) ? input.tracks : []).map((track) =>
131
+ Array.isArray(track.segments) ? { ...track, segments: piecesThatMatter(track, finiteNonNegative(input.positionSeconds)) } : track);
124
132
  const duration = finiteNonNegative(input.durationSeconds);
125
133
  // Each track's served ticks are placed on the clock the page reports by the
126
134
  // offset its player applied (`timestampOffset`).
@@ -138,13 +146,13 @@ export function predictPlaybackReadiness(input = {}) {
138
146
  ...reported.flatMap((ranges) => (ranges ?? []).flatMap(({ start, end }) => [start, end])),
139
147
  ...tracks.flatMap((track) => (Array.isArray(track.segments) ? track.segments : []).flatMap((segment) => [
140
148
  ...(segment.mediaRanges?.timescale ? [mediaTime(0n, segment.mediaRanges.timescale)] : []),
141
- ...[segment.startSeconds, segment.endSeconds].filter(Number.isFinite).map(fromSeconds)
149
+ ...exactBoundsOf(segment)
142
150
  ]))
143
151
  ]);
144
152
  const zero = mediaTime(0n, unit);
145
153
  const segmentsOf = tracks.map((track, trackIndex) => predictedMediaSegments(
146
154
  (Array.isArray(track.segments) ? track.segments : []).map((segment) => ({
147
- ...segment, ranges: playerRanges(segment.mediaRanges, rescale(clocks[trackIndex], unit), unit)
155
+ ...segment, exact: exactTimesOf(segment), ranges: playerRanges(segment.mediaRanges, rescale(clocks[trackIndex], unit), unit)
148
156
  })), unit));
149
157
  const origin = latest(zero, ...segmentsOf.map((segments) =>
150
158
  segments.find(({ index }) => index === 0)?.ranges?.[0]?.start ?? zero));
@@ -362,11 +370,15 @@ export function predictPlaybackReadiness(input = {}) {
362
370
  const stillMissing = uncoveredIntervals(pieceRange, sourceByteCoverage.get(sourceId) ?? []);
363
371
  for (const part of stillMissing) {
364
372
  finish = rate.finish(part.end - part.start, finish);
365
- service.push({ start: part.start, end: part.end, begin: finish, finish });
373
+ const arrival = { start: part.start, end: part.end, begin: finish, finish };
374
+ service.push(arrival);
366
375
  sourceArrivalAt = Math.max(sourceArrivalAt, finish);
376
+ // Joined in place, as the whole list would join: pieces arrive in
377
+ // media order, so each one extends the end. Sorting the whole list
378
+ // again after every piece was most of the forecast's time.
379
+ sourceByteCoverage.set(sourceId, withCoverage(sourceByteCoverage.get(sourceId) ?? [], arrival));
380
+ sourceArrivals.set(sourceId, withArrival(sourceArrivals.get(sourceId) ?? [], arrival, service));
367
381
  }
368
- sourceByteCoverage.set(sourceId, numericCoverage(service));
369
- sourceArrivals.set(sourceId, indexedArrivals(service));
370
382
  }
371
383
  sourceService.finish = finish;
372
384
  }
@@ -384,6 +396,16 @@ export function predictPlaybackReadiness(input = {}) {
384
396
  }
385
397
 
386
398
  const constraints = [];
399
+ // WHICH CONSTRAINT HOLDS THE START, said with the answer: the delay is the
400
+ // largest of thousands, and the log could only say that some term was
401
+ // positive while the picture stood for 17 s (torrent-tv/meta#178).
402
+ let holding = null;
403
+ const constrain = (value, kind, trackIndex, segment, atSeconds, aheadSeconds, requiresSource) => {
404
+ constraints.push(value);
405
+ if (value > 0 && (holding === null || value > holding.value)) {
406
+ holding = { value, kind, trackIndex, segment, atSeconds, aheadSeconds, requiresSource };
407
+ }
408
+ };
387
409
  const end = rescale(fromSeconds(duration), unit);
388
410
  for (let trackIndex = 0; trackIndex < trackState.length; trackIndex += 1) {
389
411
  const state = trackState[trackIndex];
@@ -395,7 +417,8 @@ export function predictPlaybackReadiness(input = {}) {
395
417
  const before = contiguousEnd(delivered, at);
396
418
  for (const range of completion.segment.ranges) addMediaRange(delivered, range);
397
419
  const after = contiguousEnd(delivered, at);
398
- constraints.push(completion.at - secondsAhead(earliest(before, trackEnd), at));
420
+ const ahead = secondsAhead(earliest(before, trackEnd), at);
421
+ constrain(completion.at - ahead, "delivery", trackIndex, completion.segment.index, completion.at, ahead, false);
399
422
  if (compare(after, before) <= 0 && completion.segment.ranges.some(({ start }) => compare(start, before) > 0)) {
400
423
  // A timestamp hole cannot be repaired by waiting longer. Keep it
401
424
  // distinct from an unavailable rate or a slow but finite service.
@@ -413,8 +436,10 @@ export function predictPlaybackReadiness(input = {}) {
413
436
  for (const range of production.segment.ranges) addMediaRange(prepared, range);
414
437
  // Supply interruption coverage is stock held by the proxy as well as
415
438
  // the browser. It is not a browser buffer-size admission rule.
416
- constraints.push(production.at - Math.max(0, secondsAhead(before, at) -
417
- Math.min(reserve, secondsAhead(trackEnd, before)) * Number(production.requiresSource)));
439
+ const ahead = Math.max(0, secondsAhead(before, at) -
440
+ Math.min(reserve, secondsAhead(trackEnd, before)) * Number(production.requiresSource));
441
+ constrain(production.at - ahead, "production", trackIndex, production.segment.index, production.at, ahead,
442
+ production.requiresSource);
418
443
  }
419
444
  }
420
445
  const delay = Math.max(0, ...constraints);
@@ -424,6 +449,13 @@ export function predictPlaybackReadiness(input = {}) {
424
449
  Math.max(0, reserve - buffered), ready ? 'trajectory-safe-now' :
425
450
  Number.isFinite(delay) ? 'minimum-safe-delay' : 'service-not-advancing', preparedSegments);
426
451
  return { ...forecast, measuredAt: now, predictedStartAt: Number.isFinite(delay) ? now + delay * 1000 : null,
452
+ // The piece whose `kind` — its production, or its delivery to the page —
453
+ // comes `atSeconds` from now while the media before it lasts
454
+ // `aheadSeconds`: the difference is the delay.
455
+ holdingBy: ready ? null : holding === null ? (originReady ? null : { kind: "position-not-held" })
456
+ : { kind: holding.kind, track: tracks[holding.trackIndex]?.id ?? holding.trackIndex,
457
+ segment: holding.segment, atSeconds: holding.atSeconds, aheadSeconds: holding.aheadSeconds,
458
+ requiresSource: holding.requiresSource },
427
459
  preparedUntilSeconds: Math.min(duration, ...trackState.map((state, index) => {
428
460
  const ranges = state.clientRanges.map((range) => ({ ...range }));
429
461
  for (const item of productions.filter((item) => item.trackIndex === index && item.at <= delay)) {
@@ -534,6 +566,95 @@ function heldRanges(ranges, segments, unit, position) {
534
566
  return held;
535
567
  }
536
568
 
569
+ /**
570
+ * The pieces of a track this forecast reads: those still to play (ending after
571
+ * `position`), the first piece, every piece that has been read and every piece
572
+ * that is made. A piece that is none of these changes no answer: it is not
573
+ * scheduled, it is not where the track starts, no unread piece is placed by it
574
+ * (only read pieces are), it lends no frame to what the page holds, and it has
575
+ * no size to measure the output's rate by.
576
+ *
577
+ * @param {{ segments: object[], readySegmentIndices?: number[] }} track
578
+ * @param {number} position - Seconds.
579
+ * @returns {object[]}
580
+ */
581
+ function piecesThatMatter(track, position) {
582
+ const made = new Set(Array.isArray(track.readySegmentIndices) ? track.readySegmentIndices : []);
583
+ const kept = track.segments.filter((segment) => segment?.index === 0 || segment?.mediaRanges !== undefined ||
584
+ made.has(segment?.index) || !(Number.isFinite(segment?.endSeconds) && segment.endSeconds <= position));
585
+ return kept.length === track.segments.length ? track.segments : kept;
586
+ }
587
+
588
+ /**
589
+ * The exact times of a piece's playlist edges, converted once per piece and
590
+ * kept with it: the playlist's cut times do not change between polls, and
591
+ * converting them again on every one was a fifth of the forecast's time.
592
+ *
593
+ * @type {WeakMap<object, { startSeconds: number, endSeconds: number, start: import("./media-time.js").MediaTime | null, end: import("./media-time.js").MediaTime | null }>}
594
+ */
595
+ const exactBounds = new WeakMap();
596
+
597
+ function exactTimesOf(segment) {
598
+ let bounds = exactBounds.get(segment);
599
+ if (!bounds || bounds.startSeconds !== segment.startSeconds || bounds.endSeconds !== segment.endSeconds) {
600
+ bounds = {
601
+ startSeconds: segment.startSeconds,
602
+ endSeconds: segment.endSeconds,
603
+ start: Number.isFinite(segment.startSeconds) ? fromSeconds(segment.startSeconds) : null,
604
+ end: Number.isFinite(segment.endSeconds) ? fromSeconds(segment.endSeconds) : null
605
+ };
606
+ exactBounds.set(segment, bounds);
607
+ }
608
+ return bounds;
609
+ }
610
+
611
+ /** The exact edges a piece carries from the forecast, or converts once. */
612
+ function timesOf(segment) {
613
+ return segment.exact ?? exactTimesOf(segment);
614
+ }
615
+
616
+ /** @returns {import("./media-time.js").MediaTime[]} The finite playlist edges of a piece, exactly. */
617
+ function exactBoundsOf(segment) {
618
+ const { start, end } = exactTimesOf(segment);
619
+ return [start, end].filter(Boolean);
620
+ }
621
+
622
+ /**
623
+ * `coverage` (sorted, joined; this forecast's own list) with `range` added in
624
+ * place, as `numericCoverage` of the whole list would give it: found by halves,
625
+ * joined with every neighbour it reaches or touches. Bytes held further on —
626
+ * a piece of the file read for another viewer — sit after the place pieces are
627
+ * added in media order, so adding only at the end did not hold.
628
+ */
629
+ function withCoverage(coverage, range) {
630
+ let low = 0, high = coverage.length;
631
+ while (low < high) {
632
+ const middle = (low + high) >>> 1;
633
+ if (coverage[middle].end < range.start) low = middle + 1;
634
+ else high = middle;
635
+ }
636
+ const joined = { start: range.start, end: range.end };
637
+ let last = low;
638
+ while (last < coverage.length && coverage[last].start <= joined.end) {
639
+ joined.start = Math.min(joined.start, coverage[last].start);
640
+ joined.end = Math.max(joined.end, coverage[last].end);
641
+ last += 1;
642
+ }
643
+ coverage.splice(low, last - low, joined);
644
+ return coverage;
645
+ }
646
+
647
+ /** `arrivals` (this forecast's own list) with `range` indexed, as `indexedArrivals` of the whole list would give it. */
648
+ function withArrival(arrivals, range, all) {
649
+ if (!(range.finish > 0)) return arrivals;
650
+ const last = arrivals.at(-1);
651
+ if (!last || range.start > last.start || (range.start === last.start && range.end >= last.end)) {
652
+ arrivals.push({ ...range, maximumEnd: Math.max(last?.maximumEnd ?? 0, range.end) });
653
+ return arrivals;
654
+ }
655
+ return indexedArrivals(all);
656
+ }
657
+
537
658
  /** Coverage is joined once per resource, independently of arrival times. */
538
659
  function numericCoverage(ranges) {
539
660
  const joined = [];
@@ -614,30 +735,58 @@ function sourcePiecesFor(ranges, source) {
614
735
  */
615
736
  function predictedMediaSegments(segments, unit) {
616
737
  const zero = mediaTime(0n, unit);
617
- const anchors = segments.filter(({ ranges }) => ranges?.length > 0);
738
+ // In order of index, searched by halves: each unread piece looked through
739
+ // every read one for its neighbours, a cost of pieces times read pieces.
740
+ const anchors = segments.filter(({ ranges }) => ranges?.length > 0)
741
+ .sort((left, right) => left.index - right.index);
742
+ const firstAfter = (index) => {
743
+ let low = 0, high = anchors.length;
744
+ while (low < high) {
745
+ const middle = (low + high) >>> 1;
746
+ if (anchors[middle].index <= index) low = middle + 1;
747
+ else high = middle;
748
+ }
749
+ return low;
750
+ };
618
751
  return segments.map((segment) => {
619
752
  if (segment.ranges !== undefined) return segment;
753
+ const times = timesOf(segment);
620
754
  if (anchors.length === 0) {
621
- return { ...segment, ranges: [{ start: rescale(fromSeconds(segment.startSeconds), unit),
622
- end: rescale(fromSeconds(segment.endSeconds), unit), frame: zero }] };
755
+ return { ...segment, ranges: [{ start: rescale(times.start, unit), end: rescale(times.end, unit), frame: zero }] };
623
756
  }
624
- const previous = anchors.findLast(({ index }) => index < segment.index);
625
- const next = anchors.find(({ index }) => index > segment.index);
626
- const nominalLeft = fromSeconds(previous?.endSeconds ?? next.startSeconds);
757
+ const after = firstAfter(segment.index);
758
+ let before = after - 1;
759
+ while (before >= 0 && anchors[before].index >= segment.index) before -= 1;
760
+ const previous = before >= 0 ? anchors[before] : undefined;
761
+ const next = anchors[after];
762
+ const nominalLeft = previous ? timesOf(previous).end : timesOf(next).start;
627
763
  const actualLeft = previous?.ranges.at(-1).end ?? next.ranges[0].start;
628
- const nominalRight = next ? fromSeconds(next.startSeconds) : nominalLeft;
764
+ const nominalRight = next ? timesOf(next).start : nominalLeft;
629
765
  const actualRight = next?.ranges[0].start ?? actualLeft;
630
766
  const nominalSpan = subtract(nominalRight, nominalLeft);
631
- const place = (seconds) => {
632
- const fromLeft = subtract(fromSeconds(seconds), nominalLeft);
633
- return nearest(add(actualLeft, nominalSpan.ticks > 0n
634
- ? scale(fromLeft, subtract(actualRight, actualLeft), nominalSpan)
635
- : fromLeft), unit);
767
+ // `nearest(actualLeft + fromLeft * (actualRight - actualLeft) / nominalSpan)`
768
+ // in ticks of `unit`, as one fraction: both actual edges are already whole
769
+ // ticks of it, so only the scaled part is rounded, and no common
770
+ // denominator is sought for every piece (most of the forecast's time).
771
+ const actualSpan = actualRight.ticks - actualLeft.ticks;
772
+ const place = (time) => {
773
+ const fromLeft = subtract(time, nominalLeft);
774
+ const [numerator, denominator] = nominalSpan.ticks > 0n
775
+ ? [fromLeft.ticks * nominalSpan.timescale * actualSpan, fromLeft.timescale * nominalSpan.ticks]
776
+ : [fromLeft.ticks * unit, fromLeft.timescale];
777
+ return { ticks: actualLeft.ticks + roundedHalfUp(numerator, denominator), timescale: unit };
636
778
  };
637
- return { ...segment, ranges: [{ start: place(segment.startSeconds), end: place(segment.endSeconds), frame: zero }] };
779
+ return { ...segment, ranges: [{ start: place(times.start), end: place(times.end), frame: zero }] };
638
780
  });
639
781
  }
640
782
 
783
+ /** `numerator / denominator` to the nearest whole number, halves up, as `nearest` rounds; `denominator` positive. */
784
+ function roundedHalfUp(numerator, denominator) {
785
+ const quotient = numerator / denominator;
786
+ const floor = numerator - quotient * denominator < 0n ? quotient - 1n : quotient;
787
+ return 2n * (numerator - floor * denominator) >= denominator ? floor + 1n : floor;
788
+ }
789
+
641
790
  function rateCurve(readings, now) {
642
791
  if (readings && Number.isFinite(readings.count) && readings.count > 0 &&
643
792
  Number.isFinite(readings.lastAt) && Number.isFinite(readings.lastValue) &&