@torrent-tv/proxy 2.97.13 → 2.97.15

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.13",
3
+ "version": "2.97.15",
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).
@@ -489,17 +526,18 @@ export async function startProxyServer({
489
526
  typeof container?.readSourceRanges === "function" ? container : null;
490
527
  for (const zone of demands) {
491
528
  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)])) };
529
+ // WHAT A SEGMENT READS IS WORKED OUT ONCE, not on every publication: its
530
+ // tracks, its interval and the key its bytes are kept under depend on
531
+ // the output, the segment and the file's start time, none of which a
532
+ // new map changes. Rebuilt for every segment of the film on every map,
533
+ // this pass held the main thread up to 1.5 s at a time on Home
534
+ // Assistant (torrent-tv/meta#178).
535
+ const reading = segmentReadingOf(zone, tracks.value, shift, map);
536
+ if (!reading) continue;
537
+ const { wanted, modes, interval, rangesKey } = reading;
500
538
  const { tracks: _choices, owner: _owner, sourceInterval: _sourceInterval, ...demand } = zone;
501
539
  const convert = ranges => {
502
- for (const [byteStart, byteEnd] of joinedWithinPieces(ranges, pieceLength)) converted.push({ ...demand,
540
+ for (const [byteStart, byteEnd] of joinedRangesOf(ranges, pieceLength)) converted.push({ ...demand,
503
541
  downloadInterval: { from: zone.owner.from, to: zone.owner.to }, byteStart, byteEnd });
504
542
  };
505
543
  // The bytes one segment needs are a fact of the file and the output, so
@@ -507,7 +545,6 @@ export async function startProxyServer({
507
545
  // the map changes every few seconds; worked out afresh on every change,
508
546
  // a pass over a two-hour AVI never finished and the next segment's bytes
509
547
  // were never asked for (Home Assistant 2026-10-08, torrent-tv/meta#151).
510
- const rangesKey = `${map.sourceKey}:${map.fileIndex}:${JSON.stringify(interval)}`;
511
548
  const known = segmentSourceRanges.get(rangesKey);
512
549
  if (known) { convert(known); continue; }
513
550
  if (direct) {
@@ -461,6 +461,17 @@ export class EncodeInputs {
461
461
  if (this.#requests.get(request.key) !== request) return undefined;
462
462
  }
463
463
  const chosen = stretches.get(end);
464
+ // EVERY BYTE IS HERE BEFORE THE RUN IS ANSWERED. The stretch is copied
465
+ // after the answer (`OriginalInput.js`), so a stretch that is not all
466
+ // downloaded would start an encoder whose input then fails; checked here,
467
+ // it waits for the bytes as one read of everything used to. Field
468
+ // 2026-10-12: a cold MKV's first piece was answered, its stretch read
469
+ // found pieces missing, and the run was refused for good (torrent-tv/meta#178).
470
+ if (this.#heldRanges !== null && !await this.#present(chosen.sources, held, request.timing)) {
471
+ const [first] = chosen.sources;
472
+ return { kind: "needs-bytes", sourceKey: first.sourceKey, fileIndex: first.fileIndex, ranges: first.input.ranges };
473
+ }
474
+ if (this.#requests.get(request.key) !== request) return undefined;
464
475
  this.#wanted.set(request.key, { wanted: chosen.bytes, required: chosen.bytes });
465
476
  result = await admitOriginalInput({ sources: chosen.sources, readRanges: this.#read, now: this.#now,
466
477
  reserve: async bytes => this.#hold(request, bytes) });
@@ -1088,7 +1088,10 @@ export class EncodeRuns {
1088
1088
  // torrent cannot tell them apart.
1089
1089
  lastSegmentIndex: () =>
1090
1090
  run.to,
1091
- inputUnavailable: (message) => isInputUnavailable(message),
1091
+ // A stretch whose copy failed after the run started — a piece left the
1092
+ // store between the check and the copy — is an input that went away, not
1093
+ // a fault of the input: the plan asks for it again (torrent-tv/meta#178).
1094
+ inputUnavailable: (message) => isInputUnavailable(message) || admittedInput?.copyFailed?.() === true,
1092
1095
  onProgress: (report) => this.#noteRunProgress(session, run, report),
1093
1096
  // A closed piece moves this encoder forward, perhaps out of the band that
1094
1097
  // led; who has the processor is decided again at once.
@@ -1370,7 +1373,8 @@ export class EncodeRuns {
1370
1373
  // been dead code — which is also why nothing ever stopped the restart loop
1371
1374
  // recorded in the field the same day.
1372
1375
  const wasCurrent = this.runsOf(session).includes(run);
1373
- const inputFailed = run.admittedInputKeys && failedAdmittedInput(ended,
1376
+ // A stretch that could not be copied says nothing of the input itself.
1377
+ const inputFailed = run.admittedInputKeys && run.originalInput?.copyFailed?.() !== true && failedAdmittedInput(ended,
1374
1378
  session.spec.transcodesVideo && this.#host.videoEncoder.kind !== "software");
1375
1379
  if (inputFailed) {
1376
1380
  const index = run.head;
@@ -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;
@@ -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);
@@ -736,6 +741,55 @@ export class ViewerRequests {
736
741
  return forecast;
737
742
  }
738
743
 
744
+ /**
745
+ * The pieces of one output as the start forecast reads them, kept between
746
+ * polls: a piece whose playlist edges, read ranges and torrent inputs have
747
+ * not changed is the same object it was, so what the forecast derived from it
748
+ * once — its exact edges, its joined ranges — is not derived again. An unread
749
+ * piece that is not made and ends before the viewer is not listed: the
750
+ * forecast reads nothing from it. Building the whole film anew on every poll
751
+ * was part of what held the main thread for seconds (torrent-tv/meta#178).
752
+ *
753
+ * @param {object} output
754
+ * @param {Buffer | null} sessionInit
755
+ * @param {Map<number, number>} inventory - Made pieces and their sizes.
756
+ * @param {number} positionSeconds
757
+ * @returns {object[]}
758
+ */
759
+ #segmentsOf(output, sessionInit, inventory, positionSeconds) {
760
+ const timeline = output.timeline;
761
+ const count = timeline?.segmentCount ?? 0;
762
+ let kept = this.#forecastPieces.get(output);
763
+ if (!kept) {
764
+ kept = [];
765
+ this.#forecastPieces.set(output, kept);
766
+ }
767
+ const segments = [];
768
+ for (let index = 0; index < count; index++) {
769
+ const endSeconds = timeline.publishedStartOf(index + 1);
770
+ const ranges = this.#host.encodeRuns.finishedMediaRangesOf(output, index);
771
+ const mediaRanges = ranges && output.segmentFormat?.servedMediaRanges ?
772
+ output.segmentFormat.servedMediaRanges(ranges, { initBytes: sessionInit }) : undefined;
773
+ if (index !== 0 && mediaRanges === undefined && !inventory.has(index) &&
774
+ Number.isFinite(endSeconds) && Number.isFinite(positionSeconds) && endSeconds <= positionSeconds) {
775
+ continue;
776
+ }
777
+ const startSeconds = timeline.publishedStartOf(index);
778
+ const sourceInputs = this.#host.sourceInputsFor?.(output, index) ?? undefined;
779
+ const previous = kept[index];
780
+ if (previous && previous.startSeconds === startSeconds && previous.endSeconds === endSeconds &&
781
+ previous.mediaRanges === mediaRanges && sameInputs(previous.sourceInputs, sourceInputs)) {
782
+ segments.push(previous);
783
+ continue;
784
+ }
785
+ const segment = { index, startSeconds, endSeconds, sourceInputs, mediaRanges };
786
+ kept[index] = segment;
787
+ segments.push(segment);
788
+ }
789
+ if (kept.length > count) kept.length = count;
790
+ return segments;
791
+ }
792
+
739
793
  #rateStateFor(session, consumerId) {
740
794
  let byViewer = this.#rateStateBySession.get(session);
741
795
  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
@@ -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);
@@ -1,5 +1,13 @@
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`, four unless set). A
6
+ * piece from disk is three of their operations, and more at once would only
7
+ * wait behind them (torrent-tv/meta#178).
8
+ */
9
+ const FILE_READS_AT_ONCE = Math.max(1, Number.parseInt(process.env.UV_THREADPOOL_SIZE ?? "", 10) || 4);
10
+
3
11
  /**
4
12
  * @file What of a file the torrent already HOLDS, and reading it without
5
13
  * asking the swarm for anything.
@@ -212,17 +220,35 @@ export async function readHeldRanges(torrent, fileIndex, ranges, maxBytes, logge
212
220
  }
213
221
  try {
214
222
  const result = ranges.map(([start, end]) => Buffer.allocUnsafeSlow(end - start + 1));
215
- for (const [index, span] of pieceSpans) {
223
+ // SEVERAL PIECES AT ONCE, each written straight into the copy. One after
224
+ // another, every read from disk waited for this thread's loop on its own,
225
+ // and the loop runs 0.3-0.7 s late while the swarm delivers 7-14 MB/s: a
226
+ // 210 MB stretch took 11 s here (Home Assistant 2026-10-12,
227
+ // torrent-tv/meta#178). As many are in flight as the runtime has threads
228
+ // for file operations (libuv's pool, `UV_THREADPOOL_SIZE`, four by
229
+ // default); more would only queue behind them and ahead of everything else
230
+ // this process reads or writes.
231
+ const readOne = async ([index, span]) => {
216
232
  const pieceStart = index * pieceLength;
217
233
  const offset = span.start - pieceStart;
218
234
  const length = span.end - span.start + 1;
235
+ const [only] = span.ranges;
236
+ // A piece holding one range alone is read into the copy itself; one
237
+ // shared by several ranges is read once and divided, as before.
238
+ if (span.ranges.length === 1 && typeof store.getInto === "function") {
239
+ const from = Math.max(only.absoluteStart, pieceStart);
240
+ const filled = await store.getInto(index, offset, result[only.rangeIndex].subarray(from - only.absoluteStart, from - only.absoluteStart + length));
241
+ if (!filled) return false;
242
+ onRead?.(length);
243
+ return true;
244
+ }
219
245
  const buffer = await new Promise((resolve, reject) => {
220
246
  store.get(index, { offset, length }, (error, value) => {
221
247
  if (error) reject(error);
222
248
  else resolve(value);
223
249
  });
224
250
  });
225
- if (!buffer || buffer.length !== length) return null;
251
+ if (!buffer || buffer.length !== length) return false;
226
252
  onRead?.(length);
227
253
  for (const { rangeIndex, absoluteStart, absoluteEnd } of span.ranges) {
228
254
  const from = Math.max(absoluteStart, pieceStart);
@@ -230,8 +256,27 @@ export async function readHeldRanges(torrent, fileIndex, ranges, maxBytes, logge
230
256
  const sourceStart = from - span.start;
231
257
  result[rangeIndex].set(buffer.subarray(sourceStart, sourceStart + to - from + 1), from - absoluteStart);
232
258
  }
233
- }
234
- return result;
259
+ return true;
260
+ };
261
+ const spans = [...pieceSpans];
262
+ let next = 0;
263
+ let whole = true;
264
+ const reader = async () => {
265
+ while (whole && next < spans.length) {
266
+ try {
267
+ if (!await readOne(spans[next++])) whole = false;
268
+ } catch (error) {
269
+ whole = false;
270
+ throw error;
271
+ }
272
+ }
273
+ };
274
+ // Every read has returned before the pieces are let go, a failed one too:
275
+ // a read still in flight would otherwise read a piece nothing holds.
276
+ const outcomes = await Promise.allSettled(Array.from({ length: Math.min(spans.length, FILE_READS_AT_ONCE) }, reader));
277
+ const failed = outcomes.find((outcome) => outcome.status === "rejected");
278
+ if (failed) throw failed.reason;
279
+ return whole ? result : null;
235
280
  } catch (error) {
236
281
  logger?.info?.(`held ranges read ${fileIndex} failed: ${error?.message ?? error}`);
237
282
  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
  }
@@ -534,6 +546,95 @@ function heldRanges(ranges, segments, unit, position) {
534
546
  return held;
535
547
  }
536
548
 
549
+ /**
550
+ * The pieces of a track this forecast reads: those still to play (ending after
551
+ * `position`), the first piece, every piece that has been read and every piece
552
+ * that is made. A piece that is none of these changes no answer: it is not
553
+ * scheduled, it is not where the track starts, no unread piece is placed by it
554
+ * (only read pieces are), it lends no frame to what the page holds, and it has
555
+ * no size to measure the output's rate by.
556
+ *
557
+ * @param {{ segments: object[], readySegmentIndices?: number[] }} track
558
+ * @param {number} position - Seconds.
559
+ * @returns {object[]}
560
+ */
561
+ function piecesThatMatter(track, position) {
562
+ const made = new Set(Array.isArray(track.readySegmentIndices) ? track.readySegmentIndices : []);
563
+ const kept = track.segments.filter((segment) => segment?.index === 0 || segment?.mediaRanges !== undefined ||
564
+ made.has(segment?.index) || !(Number.isFinite(segment?.endSeconds) && segment.endSeconds <= position));
565
+ return kept.length === track.segments.length ? track.segments : kept;
566
+ }
567
+
568
+ /**
569
+ * The exact times of a piece's playlist edges, converted once per piece and
570
+ * kept with it: the playlist's cut times do not change between polls, and
571
+ * converting them again on every one was a fifth of the forecast's time.
572
+ *
573
+ * @type {WeakMap<object, { startSeconds: number, endSeconds: number, start: import("./media-time.js").MediaTime | null, end: import("./media-time.js").MediaTime | null }>}
574
+ */
575
+ const exactBounds = new WeakMap();
576
+
577
+ function exactTimesOf(segment) {
578
+ let bounds = exactBounds.get(segment);
579
+ if (!bounds || bounds.startSeconds !== segment.startSeconds || bounds.endSeconds !== segment.endSeconds) {
580
+ bounds = {
581
+ startSeconds: segment.startSeconds,
582
+ endSeconds: segment.endSeconds,
583
+ start: Number.isFinite(segment.startSeconds) ? fromSeconds(segment.startSeconds) : null,
584
+ end: Number.isFinite(segment.endSeconds) ? fromSeconds(segment.endSeconds) : null
585
+ };
586
+ exactBounds.set(segment, bounds);
587
+ }
588
+ return bounds;
589
+ }
590
+
591
+ /** The exact edges a piece carries from the forecast, or converts once. */
592
+ function timesOf(segment) {
593
+ return segment.exact ?? exactTimesOf(segment);
594
+ }
595
+
596
+ /** @returns {import("./media-time.js").MediaTime[]} The finite playlist edges of a piece, exactly. */
597
+ function exactBoundsOf(segment) {
598
+ const { start, end } = exactTimesOf(segment);
599
+ return [start, end].filter(Boolean);
600
+ }
601
+
602
+ /**
603
+ * `coverage` (sorted, joined; this forecast's own list) with `range` added in
604
+ * place, as `numericCoverage` of the whole list would give it: found by halves,
605
+ * joined with every neighbour it reaches or touches. Bytes held further on —
606
+ * a piece of the file read for another viewer — sit after the place pieces are
607
+ * added in media order, so adding only at the end did not hold.
608
+ */
609
+ function withCoverage(coverage, range) {
610
+ let low = 0, high = coverage.length;
611
+ while (low < high) {
612
+ const middle = (low + high) >>> 1;
613
+ if (coverage[middle].end < range.start) low = middle + 1;
614
+ else high = middle;
615
+ }
616
+ const joined = { start: range.start, end: range.end };
617
+ let last = low;
618
+ while (last < coverage.length && coverage[last].start <= joined.end) {
619
+ joined.start = Math.min(joined.start, coverage[last].start);
620
+ joined.end = Math.max(joined.end, coverage[last].end);
621
+ last += 1;
622
+ }
623
+ coverage.splice(low, last - low, joined);
624
+ return coverage;
625
+ }
626
+
627
+ /** `arrivals` (this forecast's own list) with `range` indexed, as `indexedArrivals` of the whole list would give it. */
628
+ function withArrival(arrivals, range, all) {
629
+ if (!(range.finish > 0)) return arrivals;
630
+ const last = arrivals.at(-1);
631
+ if (!last || range.start > last.start || (range.start === last.start && range.end >= last.end)) {
632
+ arrivals.push({ ...range, maximumEnd: Math.max(last?.maximumEnd ?? 0, range.end) });
633
+ return arrivals;
634
+ }
635
+ return indexedArrivals(all);
636
+ }
637
+
537
638
  /** Coverage is joined once per resource, independently of arrival times. */
538
639
  function numericCoverage(ranges) {
539
640
  const joined = [];
@@ -614,30 +715,58 @@ function sourcePiecesFor(ranges, source) {
614
715
  */
615
716
  function predictedMediaSegments(segments, unit) {
616
717
  const zero = mediaTime(0n, unit);
617
- const anchors = segments.filter(({ ranges }) => ranges?.length > 0);
718
+ // In order of index, searched by halves: each unread piece looked through
719
+ // every read one for its neighbours, a cost of pieces times read pieces.
720
+ const anchors = segments.filter(({ ranges }) => ranges?.length > 0)
721
+ .sort((left, right) => left.index - right.index);
722
+ const firstAfter = (index) => {
723
+ let low = 0, high = anchors.length;
724
+ while (low < high) {
725
+ const middle = (low + high) >>> 1;
726
+ if (anchors[middle].index <= index) low = middle + 1;
727
+ else high = middle;
728
+ }
729
+ return low;
730
+ };
618
731
  return segments.map((segment) => {
619
732
  if (segment.ranges !== undefined) return segment;
733
+ const times = timesOf(segment);
620
734
  if (anchors.length === 0) {
621
- return { ...segment, ranges: [{ start: rescale(fromSeconds(segment.startSeconds), unit),
622
- end: rescale(fromSeconds(segment.endSeconds), unit), frame: zero }] };
735
+ return { ...segment, ranges: [{ start: rescale(times.start, unit), end: rescale(times.end, unit), frame: zero }] };
623
736
  }
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);
737
+ const after = firstAfter(segment.index);
738
+ let before = after - 1;
739
+ while (before >= 0 && anchors[before].index >= segment.index) before -= 1;
740
+ const previous = before >= 0 ? anchors[before] : undefined;
741
+ const next = anchors[after];
742
+ const nominalLeft = previous ? timesOf(previous).end : timesOf(next).start;
627
743
  const actualLeft = previous?.ranges.at(-1).end ?? next.ranges[0].start;
628
- const nominalRight = next ? fromSeconds(next.startSeconds) : nominalLeft;
744
+ const nominalRight = next ? timesOf(next).start : nominalLeft;
629
745
  const actualRight = next?.ranges[0].start ?? actualLeft;
630
746
  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);
747
+ // `nearest(actualLeft + fromLeft * (actualRight - actualLeft) / nominalSpan)`
748
+ // in ticks of `unit`, as one fraction: both actual edges are already whole
749
+ // ticks of it, so only the scaled part is rounded, and no common
750
+ // denominator is sought for every piece (most of the forecast's time).
751
+ const actualSpan = actualRight.ticks - actualLeft.ticks;
752
+ const place = (time) => {
753
+ const fromLeft = subtract(time, nominalLeft);
754
+ const [numerator, denominator] = nominalSpan.ticks > 0n
755
+ ? [fromLeft.ticks * nominalSpan.timescale * actualSpan, fromLeft.timescale * nominalSpan.ticks]
756
+ : [fromLeft.ticks * unit, fromLeft.timescale];
757
+ return { ticks: actualLeft.ticks + roundedHalfUp(numerator, denominator), timescale: unit };
636
758
  };
637
- return { ...segment, ranges: [{ start: place(segment.startSeconds), end: place(segment.endSeconds), frame: zero }] };
759
+ return { ...segment, ranges: [{ start: place(times.start), end: place(times.end), frame: zero }] };
638
760
  });
639
761
  }
640
762
 
763
+ /** `numerator / denominator` to the nearest whole number, halves up, as `nearest` rounds; `denominator` positive. */
764
+ function roundedHalfUp(numerator, denominator) {
765
+ const quotient = numerator / denominator;
766
+ const floor = numerator - quotient * denominator < 0n ? quotient - 1n : quotient;
767
+ return 2n * (numerator - floor * denominator) >= denominator ? floor + 1n : floor;
768
+ }
769
+
641
770
  function rateCurve(readings, now) {
642
771
  if (readings && Number.isFinite(readings.count) && readings.count > 0 &&
643
772
  Number.isFinite(readings.lastAt) && Number.isFinite(readings.lastValue) &&