@torrent-tv/proxy 2.98.0 → 2.98.2

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.98.0",
3
+ "version": "2.98.2",
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
@@ -437,44 +437,55 @@ export async function startProxyServer({
437
437
  /** Source byte ranges of one segment interval, by file and interval; forgotten with the source. */
438
438
  const segmentSourceRanges = new Map();
439
439
  /**
440
- * What one segment of an output reads — its tracks, its interval and the key
441
- * its bytes are kept under — by output and segment, until the tracks, the
442
- * file's start time, the segment's interval or the choices of the output
443
- * change; forgotten with the source.
440
+ * WHAT ONE SEGMENT OF AN OUTPUT IS, kept from pass to pass: where it lies in
441
+ * the source, what it reads — its tracks and the key its bytes are kept under
442
+ * — and, once known, those bytes joined at the precision the torrent fetches,
443
+ * by output and segment. Worked out again only when the output's timeline,
444
+ * the tracks, the file's start time or the output's choices change; forgotten
445
+ * with the source. None of it depends on where a viewer stands, and a pass is
446
+ * made every time one moves: looked up, made and joined again for each of a
447
+ * film's 3500 segments on every pass, they were 6 s of the 40 s the passes
448
+ * cost the main thread in fifteen minutes on Home Assistant (r30,
449
+ * torrent-tv/meta#178).
444
450
  */
445
451
  const segmentReadings = new Map();
446
- // Per output, then per segment: every segment of an output of one pass shares
447
- // its list of track choices, so the output's key and the choices' key are
448
- // worked out once for it. Built for each of a pass's segments, the two
449
- // strings cost the main thread 13 s in ten minutes of a cold AVI on Home
450
- // Assistant (torrent-tv/meta#178).
451
- const readingsByChoices = new WeakMap();
452
- const segmentReadingOf = (zone, trackList, shift, map) => {
453
- let output = readingsByChoices.get(zone.tracks);
454
- if (!output) {
455
- const outputKey = `${map.sourceKey}:${map.fileIndex}:${zone.outputKey}`;
456
- let byIndex = segmentReadings.get(outputKey);
457
- if (!byIndex) segmentReadings.set(outputKey, byIndex = new Map());
458
- output = { byIndex, choicesKey: zone.tracks.map(choice => `${choice.type}/${choice.index}/${choice.mode}`).join(",") };
459
- readingsByChoices.set(zone.tracks, output);
460
- }
461
- const { byIndex, choicesKey } = output;
462
- const kept = byIndex.get(zone.index);
463
- if (kept && kept.trackList === trackList && kept.shift === shift && kept.choicesKey === choicesKey &&
464
- kept.from === zone.sourceInterval.from && kept.to === zone.sourceInterval.to) return kept.reading;
465
- const selected = zone.tracks.map(choice => ({ choice, track: choice.type === "video"
452
+ /** One output's segments, by index. */
453
+ const segmentRecordsOf = (map, output) => {
454
+ const key = `${map.sourceKey}:${map.fileIndex}:${output.outputKey}`;
455
+ let records = segmentReadings.get(key);
456
+ if (!records) segmentReadings.set(key, records = new Map());
457
+ return records;
458
+ };
459
+ /** The choices of one output's tracks as a key, worked out once for the list of them a pass shares. */
460
+ const choicesKeys = new WeakMap();
461
+ const choicesKeyOf = (choices) => {
462
+ let key = choicesKeys.get(choices);
463
+ if (key === undefined) choicesKeys.set(choices, key = choices.map(choice => `${choice.type}/${choice.index}/${choice.mode}`).join(","));
464
+ return key;
465
+ };
466
+ const segmentRecordOf = (records, demand, { timeline, trackList, shift, map }) => {
467
+ const choicesKey = choicesKeyOf(demand.tracks);
468
+ const kept = records.get(demand.index);
469
+ if (kept && kept.timeline === timeline && kept.trackList === trackList && kept.shift === shift && kept.choicesKey === choicesKey) return kept;
470
+ const sourceInterval = timeline.sourceInterval(demand.index, demand.index, shift);
471
+ const selected = demand.tracks.map(choice => ({ choice, track: choice.type === "video"
466
472
  ? ContainerTrack.firstUsable(trackList, "video")
467
473
  : trackList.filter(track => track.type === choice.type)[choice.index] }));
468
474
  let reading = null;
469
475
  if (!selected.some(({ track }) => !track)) {
470
476
  const wanted = selected.map(({ track }) => track);
471
477
  const modes = new Map(selected.map(({ choice, track }) => [track, choice.mode]));
472
- const interval = { ...zone.sourceInterval, trackIds: wanted.map(track => track.trackNumber),
478
+ const interval = { ...sourceInterval, trackIds: wanted.map(track => track.trackNumber),
473
479
  modes: Object.fromEntries(wanted.map(track => [track.trackNumber, modes.get(track)])) };
474
480
  reading = { wanted, modes, interval, rangesKey: `${map.sourceKey}:${map.fileIndex}:${JSON.stringify(interval)}` };
475
481
  }
476
- byIndex.set(zone.index, { trackList, shift, choicesKey, from: zone.sourceInterval.from, to: zone.sourceInterval.to, reading });
477
- return reading;
482
+ // `joined` is the segment's ranges as the torrent is told them, made from
483
+ // the list `ranges` at `pieceLength`; `registered` is the list the input
484
+ // table was last given.
485
+ const record = { timeline, trackList, shift, choicesKey, sourceInterval, reading,
486
+ ranges: null, pieceLength: Number.NaN, joined: null, registered: null };
487
+ records.set(demand.index, record);
488
+ return record;
478
489
  };
479
490
  /**
480
491
  * The last complete pass over each file, by file: what it was worked out
@@ -508,15 +519,6 @@ export async function startProxyServer({
508
519
  `${passCosts.built} zone list(s) built for the map in ${Math.round(passCosts.buildMs)}ms`);
509
520
  Object.assign(passCosts, { since: now, reused: 0, worked: 0, incomplete: 0, segments: 0, ranges: 0, zones: 0, built: 0, buildMs: 0, ms: 0, maxMs: 0 });
510
521
  };
511
- /** A segment's ranges joined at the precision the torrent fetches, once per kept list and piece length. */
512
- const joinedRanges = new WeakMap();
513
- const joinedRangesOf = (ranges, pieceLength) => {
514
- const kept = joinedRanges.get(ranges);
515
- if (kept && kept.pieceLength === pieceLength) return kept.joined;
516
- const joined = joinedWithinPieces(ranges, pieceLength);
517
- if (ranges !== null && typeof ranges === "object") joinedRanges.set(ranges, { pieceLength, joined });
518
- return joined;
519
- };
520
522
  const downloadMaps = new DownloadMaps({
521
523
  // The ranges a picture reads at open are named again by every segment;
522
524
  // only the zone the swarm would rank first is sent (torrent-tv/meta#166).
@@ -574,14 +576,22 @@ export async function startProxyServer({
574
576
  const links = consumers.map(viewer => viewer.linkReading()?.linkMbps)
575
577
  .filter(value => Number.isFinite(value) && value > 0);
576
578
  const encodeSpeed = outputParts.encodeCost.latestSpeedReadingOf(output)?.speed;
579
+ const linkMbps = links.length ? Math.min(...links) : null;
580
+ const records = segmentRecordsOf(map, output);
581
+ const facts = { timeline: output.timeline, trackList: tracks.value, shift, map };
582
+ // Each demand is this pass's own object (`segmentDemands`), so it is
583
+ // completed in place rather than copied.
577
584
  for (const demand of segmentDemands(output, map.fileIndex, map.zones)) {
578
585
  if (inventory.has(demand.index)) continue;
579
586
  const deadlineAt = sourceDeadline(demand, { encodeSpeed,
580
- outputBytes: seconds > 0 ? bytes / seconds * (demand.to - demand.from) : null,
581
- linkMbps: links.length ? Math.min(...links) : null });
582
- const leadSeconds = Number.isFinite(demand.deadlineAt) ? Math.max(0, (demand.deadlineAt - deadlineAt) / 1000) : 0;
583
- demands.push({ ...demand, deadlineAt, leadSeconds, outputKey: output.outputKey,
584
- sourceInterval: output.timeline.sourceInterval(demand.index, demand.index, shift) });
587
+ outputBytes: seconds > 0 ? bytes / seconds * (demand.to - demand.from) : null, linkMbps });
588
+ const record = segmentRecordOf(records, demand, facts);
589
+ demand.leadSeconds = Number.isFinite(demand.deadlineAt) ? Math.max(0, (demand.deadlineAt - deadlineAt) / 1000) : 0;
590
+ demand.deadlineAt = deadlineAt;
591
+ demand.outputKey = output.outputKey;
592
+ demand.sourceInterval = record.sourceInterval;
593
+ demand.record = record;
594
+ demands.push(demand);
585
595
  }
586
596
  }
587
597
  demands.sort((left, right) => left.deadlineAt - right.deadlineAt || right.priority - left.priority || left.index - right.index);
@@ -618,24 +628,37 @@ export async function startProxyServer({
618
628
  // two-hour AVI (torrent-tv/meta#178).
619
629
  const waiting = new Set();
620
630
  const turn = takeTurns();
631
+ /** One download interval per map zone, shared by the segments it owns: read, never changed. */
632
+ const downloadIntervals = new Map();
633
+ const downloadIntervalOf = (owner) => {
634
+ let interval = downloadIntervals.get(owner);
635
+ if (!interval) downloadIntervals.set(owner, interval = { from: owner.from, to: owner.to });
636
+ return interval;
637
+ };
621
638
  for (const zone of demands) {
622
639
  if (!map.isCurrent()) return [];
623
- // WHAT A SEGMENT READS IS WORKED OUT ONCE, not on every publication: its
624
- // tracks, its interval and the key its bytes are kept under depend on
625
- // the output, the segment and the file's start time, none of which a
626
- // new map changes. Rebuilt for every segment of the film on every map,
627
- // this pass held the main thread up to 1.5 s at a time on Home
640
+ // WHAT A SEGMENT READS IS WORKED OUT ONCE, not on every publication
641
+ // (`segmentRecordOf`). Rebuilt for every segment of the film on every
642
+ // map, this pass held the main thread up to 1.5 s at a time on Home
628
643
  // Assistant (torrent-tv/meta#178).
629
- const reading = segmentReadingOf(zone, tracks.value, shift, map);
644
+ const { record } = zone;
645
+ const reading = record.reading;
630
646
  if (!reading) continue;
631
647
  const { wanted, modes, interval, rangesKey } = reading;
632
- const { tracks: _choices, owner: _owner, sourceInterval: _sourceInterval, ...demand } = zone;
633
648
  const convert = ranges => {
634
- const joined = joinedRangesOf(ranges, pieceLength);
649
+ if (record.ranges !== ranges || record.pieceLength !== pieceLength) {
650
+ record.ranges = ranges;
651
+ record.pieceLength = pieceLength;
652
+ record.joined = joinedWithinPieces(ranges, pieceLength);
653
+ }
654
+ const joined = record.joined;
635
655
  rangeCount += joined.length;
636
656
  // What the segment reads is kept for the forecast in a table of its
637
657
  // own, whatever form the torrent is told it in.
638
- segmentInputs.set(map.sourceKey, map.fileIndex, zone.outputKey, zone.index, joined);
658
+ if (record.registered !== joined) {
659
+ segmentInputs.set(map.sourceKey, map.fileIndex, zone.outputKey, zone.index, joined);
660
+ record.registered = joined;
661
+ }
639
662
  // ONE ZONE FOR THE SEGMENT, its demand stated once for all the
640
663
  // ranges it reads. The demand — level, priority, deadline, the map
641
664
  // zone it belongs to — is this pass's; the ranges are the list kept
@@ -643,14 +666,16 @@ export async function startProxyServer({
643
666
  // range copied the demand into every one: ten thousand objects a
644
667
  // pass over a two-hour AVI, made again 21-61 times a minute on Home
645
668
  // Assistant (r26, torrent-tv/meta#178).
646
- converted.push({ ...demand, downloadInterval: { from: zone.owner.from, to: zone.owner.to }, ranges: joined });
669
+ converted.push({ index: zone.index, from: zone.from, to: zone.to, priority: zone.priority, urgent: zone.urgent,
670
+ behind: zone.behind, deferred: zone.deferred, deadlineAt: zone.deadlineAt, leadSeconds: zone.leadSeconds,
671
+ outputKey: zone.outputKey, downloadInterval: downloadIntervalOf(zone.owner), ranges: joined });
647
672
  };
648
673
  // The bytes one segment needs are a fact of the file and the output, so
649
674
  // they are worked out once. A whole film is thousands of segments and
650
675
  // the map changes every few seconds; worked out afresh on every change,
651
676
  // a pass over a two-hour AVI never finished and the next segment's bytes
652
677
  // were never asked for (Home Assistant 2026-10-08, torrent-tv/meta#151).
653
- const known = segmentSourceRanges.get(rangesKey);
678
+ const known = record.ranges ?? segmentSourceRanges.get(rangesKey);
654
679
  if (known) { convert(known); continue; }
655
680
  if (waiting.has(zone.outputKey)) { complete = false; continue; }
656
681
  // What is resolved so far is stated before a lower zone is worked out
@@ -991,47 +991,64 @@ function lastOf(windows) {
991
991
 
992
992
  function deadlineReaderFor(windows, segmentSeconds) {
993
993
  const perSegment = segmentSeconds > 0 ? segmentSeconds : 0;
994
- return (index) => {
995
- let soonest = Number.POSITIVE_INFINITY;
996
- for (const span of windows) {
997
- if (index < span.from || index > span.to) {
998
- continue;
999
- }
1000
- const stated = /** @type {{ withinSeconds?: number }} */ (span).withinSeconds;
1001
- // A stretch stated with no time is somebody waiting at its near edge: that
1002
- // is what stating one means. The rest of it grows with the distance, the
1003
- // same as a stated one — read as due all at once instead, a window as wide
1004
- // as a viewer's cushion demanded its far end instantly and bought an
1005
- // encoder to stand beside one already working.
1006
- // `null` IS A STATEMENT AND IT SAYS NOBODY IS COMING. `undefined` is the
1007
- // absence of one, and a caller that knows only a position is somebody
1008
- // waiting at it.
1009
- //
1010
- // Read through `Number()`, `null` becomes 0 — due NOW — so the film BEHIND
1011
- // the viewers, which the map marks with exactly that, was the most urgent
1012
- // material in the file. Everything followed from it: it bought encoders,
1013
- // it took the run standing in front of the viewer because that run was the
1014
- // nearest encoder to it, and it did so again on every pass. Field 2026-09-08:
1015
- // 39 moves in one session, 24 between three adjacent numbers, one viewer
1016
- // on three encoders, and the picture stood still for 116.7 s in three
1017
- // interruptions, the worst of them 91.8 s.
1018
- //
1019
- // The map has always said it plainly — `{"from":0,"to":57,"priority":1,
1020
- // "withinSeconds":null,"behind":true}` is in the log of every session — and
1021
- // this line turned it into its opposite. Fourth time in this repository
1022
- // that the input to a calculation was not what the calculation assumed.
1023
- const within = stated === undefined ? 0 : (stated === null ? Number.NaN : Number(stated));
1024
- if (!Number.isFinite(within)) {
1025
- // Stated as no time at all: nobody is coming here.
1026
- continue;
1027
- }
994
+ // A TABLE BY SEGMENT, filled once per plan. Read for every segment of every
995
+ // arrangement the plan weighs, a search of every window for each index was
996
+ // the plan's own walk times the windows (r26, torrent-tv/meta#178). Filled
997
+ // span by span with the same rule, the soonest of overlapping spans kept.
998
+ let last = -1;
999
+ for (const span of windows) {
1000
+ if (Number.isFinite(span.to) && span.to > last) last = Math.floor(span.to);
1001
+ }
1002
+ const table = new Float64Array(last + 1).fill(Number.POSITIVE_INFINITY);
1003
+ for (const span of windows) {
1004
+ const within = withinOf(span);
1005
+ if (!Number.isFinite(within)) {
1006
+ // Stated as no time at all: nobody is coming here.
1007
+ continue;
1008
+ }
1009
+ for (let index = Math.max(0, Math.ceil(span.from)); index <= span.to; index += 1) {
1028
1010
  const here = within + (index - span.from) * perSegment;
1029
- if (here < soonest) {
1030
- soonest = here;
1011
+ if (here < table[index]) {
1012
+ table[index] = here;
1031
1013
  }
1032
1014
  }
1033
- return soonest;
1034
- };
1015
+ }
1016
+ return (index) => (index >= 0 && index < table.length ? table[index] : Number.POSITIVE_INFINITY);
1017
+ }
1018
+
1019
+ /**
1020
+ * When a window is first needed, in seconds from now: NaN where it says nobody
1021
+ * is coming.
1022
+ *
1023
+ * A stretch stated with no time is somebody waiting at its near edge: that
1024
+ * is what stating one means. The rest of it grows with the distance, the
1025
+ * same as a stated one — read as due all at once instead, a window as wide
1026
+ * as a viewer's cushion demanded its far end instantly and bought an
1027
+ * encoder to stand beside one already working.
1028
+ * `null` IS A STATEMENT AND IT SAYS NOBODY IS COMING. `undefined` is the
1029
+ * absence of one, and a caller that knows only a position is somebody
1030
+ * waiting at it.
1031
+ *
1032
+ * Read through `Number()`, `null` becomes 0 — due NOW — so the film BEHIND
1033
+ * the viewers, which the map marks with exactly that, was the most urgent
1034
+ * material in the file. Everything followed from it: it bought encoders,
1035
+ * it took the run standing in front of the viewer because that run was the
1036
+ * nearest encoder to it, and it did so again on every pass. Field 2026-09-08:
1037
+ * 39 moves in one session, 24 between three adjacent numbers, one viewer
1038
+ * on three encoders, and the picture stood still for 116.7 s in three
1039
+ * interruptions, the worst of them 91.8 s.
1040
+ *
1041
+ * The map has always said it plainly — `{"from":0,"to":57,"priority":1,
1042
+ * "withinSeconds":null,"behind":true}` is in the log of every session — and
1043
+ * that reading turned it into its opposite. Fourth time in this repository
1044
+ * that the input to a calculation was not what the calculation assumed.
1045
+ *
1046
+ * @param {WantedSpan} span
1047
+ * @returns {number}
1048
+ */
1049
+ function withinOf(span) {
1050
+ const stated = /** @type {{ withinSeconds?: number }} */ (span).withinSeconds;
1051
+ return stated === undefined ? 0 : (stated === null ? Number.NaN : Number(stated));
1035
1052
  }
1036
1053
 
1037
1054
  /**
@@ -156,8 +156,8 @@ export class Timeline {
156
156
  * It lived in the session manager, which is a place, not a layer. Here it is
157
157
  * beside the table it converts against.
158
158
  *
159
- * @param {import("../../viewer/PriorityMap.js").PriorityMap} map - One number
160
- * per second of film.
159
+ * @param {import("../../viewer/PriorityMap.js").PriorityMap} map - What every
160
+ * viewer of this output wants, in seconds of film.
161
161
  * @param {number} segmentCount - How many pieces this output has.
162
162
  * @param {{ runsPastEnd?: boolean }} [shape] - `runsPastEnd`: the output's
163
163
  * pieces copy a stream, which is cut where its packets end, so a piece
@@ -188,22 +188,10 @@ export class Timeline {
188
188
  const until = index + 1 < segmentCount
189
189
  ? Math.max(from + 1, runsPastEnd ? Math.floor(end) + 1 : Math.ceil(end))
190
190
  : map.durationSeconds;
191
- let priority = 0;
192
- let withinSeconds = Number.POSITIVE_INFINITY;
193
- let behind = true;
194
- let urgent = false;
195
- for (let second = from; second < until && second < map.durationSeconds; second += 1) {
196
- if (map.priority[second] > priority) {
197
- priority = map.priority[second];
198
- }
199
- if (map.secondsUntilPlayed[second] < withinSeconds) {
200
- withinSeconds = map.secondsUntilPlayed[second];
201
- }
202
- if (map.behind[second] === 0) {
203
- behind = false;
204
- }
205
- if (map.urgent?.[second]) urgent = true;
206
- }
191
+ // Asked of the map for the piece's seconds as a whole, not second by
192
+ // second: a walk of every second of the film for every output on every
193
+ // plan (torrent-tv/meta#178).
194
+ const { priority, withinSeconds, behind, urgent } = map.on(from, until);
207
195
  if (priority === 0) {
208
196
  continue;
209
197
  }
@@ -39,6 +39,7 @@ export class DownloadMaps {
39
39
  const now = Date.now();
40
40
  file.sourceZones = zones.map(zone => Number.isFinite(zone.deadlineAt) || !Number.isFinite(zone.withinSeconds)
41
41
  ? zone : { ...zone, deadlineAt: now + Math.max(0, zone.withinSeconds) * 1000 });
42
+ const sourceZones = zoneIndexOf(file);
42
43
  let changed = false;
43
44
  // Until the new zones are resolved to bytes, a resolved zone stays while
44
45
  // the film it covers is still wanted. A viewer moving on re-cuts every zone
@@ -49,9 +50,9 @@ export class DownloadMaps {
49
50
  // a seek away — is withdrawn at once.
50
51
  const retained = file.zones.flatMap(zone => {
51
52
  const interval = zone.downloadInterval ?? zone;
52
- const current = file.sourceZones.find(candidate => candidate.from === interval.from && candidate.to === interval.to);
53
+ const current = sourceZones.exactly(interval);
53
54
  if (!current) {
54
- const stillWanted = file.sourceZones.some(candidate => candidate.from < interval.to && interval.from < candidate.to);
55
+ const stillWanted = sourceZones.overlaps(interval);
55
56
  if (!stillWanted) changed = true;
56
57
  return stillWanted ? [zone] : [];
57
58
  }
@@ -76,7 +77,7 @@ export class DownloadMaps {
76
77
  changed = true;
77
78
  }
78
79
  else if (metadata.interval) {
79
- const current = file.sourceZones.find(zone => zone.from === metadata.interval.from && zone.to === metadata.interval.to);
80
+ const current = sourceZones.exactly(metadata.interval);
80
81
  const deadlineAt = Number.isFinite(current.deadlineAt) ? current.deadlineAt - metadata.leadSeconds * 1000 : current.deadlineAt;
81
82
  if (metadata.priority !== current.priority || metadata.urgent !== current.urgent || metadata.deadlineAt !== deadlineAt) changed = true;
82
83
  metadata.priority = current.priority;
@@ -147,7 +148,8 @@ export class DownloadMaps {
147
148
  }
148
149
 
149
150
  wantsInterval(sourceKey, fileIndex, { from, to }) {
150
- return this.#files.get(`${sourceKey}:${fileIndex}`)?.sourceZones.some(zone => zone.from === from && zone.to === to) ?? false;
151
+ const file = this.#files.get(`${sourceKey}:${fileIndex}`);
152
+ return Boolean(file && zoneIndexOf(file).exactly({ from, to }));
151
153
  }
152
154
 
153
155
  metadata({ sourceKey, fileIndex, statement, result, priority = 100, urgent = true, deadlineAt = 0, interval, leadSeconds = 0, scope = "playback", order = 0 }) {
@@ -155,7 +157,7 @@ export class DownloadMaps {
155
157
  const file = this.#file(sourceKey, fileIndex);
156
158
  if (result.kind === "needs-ranges") {
157
159
  if (interval && !this.wantsInterval(sourceKey, fileIndex, interval)) return Promise.resolve();
158
- const current = interval && file.sourceZones.find(zone => zone.from === interval.from && zone.to === interval.to);
160
+ const current = interval && zoneIndexOf(file).exactly(interval);
159
161
  if (current) {
160
162
  priority = current.priority;
161
163
  urgent = current.urgent;
@@ -372,6 +374,60 @@ export class DownloadMaps {
372
374
  }
373
375
  }
374
376
 
377
+ /**
378
+ * The map zones of one statement, looked up by interval.
379
+ *
380
+ * Every zone kept from the last pass asks which zone of the new map it belongs
381
+ * to, by its exact interval, and if none, whether any new zone still overlaps
382
+ * it — a search of every new zone for every kept one, thousands by tens on
383
+ * every statement over a film (r26, torrent-tv/meta#178). The exact match is
384
+ * a table, and the overlap a binary search over the zones, which a map states
385
+ * ascending and apart; zones in any other order are searched as before.
386
+ */
387
+ class ZoneIndex {
388
+ /** The list indexed: replaced whole, never edited in place, so it says whether the index is current. */
389
+ zones;
390
+ #byInterval = new Map();
391
+ #ordered = true;
392
+
393
+ /** @param {Array<{ from: number, to: number }>} zones */
394
+ constructor(zones) {
395
+ this.zones = zones;
396
+ zones.forEach((zone, index) => {
397
+ const key = `${zone.from}:${zone.to}`;
398
+ // The first of equals, as a search from the start would find.
399
+ if (!this.#byInterval.has(key)) this.#byInterval.set(key, zone);
400
+ if (index > 0 && !(zone.from >= zones[index - 1].to)) this.#ordered = false;
401
+ });
402
+ }
403
+
404
+ /** The zone with exactly this interval. @param {{ from: number, to: number }} interval */
405
+ exactly({ from, to }) {
406
+ return this.#byInterval.get(`${from}:${to}`);
407
+ }
408
+
409
+ /** Whether any zone overlaps this interval. @param {{ from: number, to: number }} interval */
410
+ overlaps({ from, to }) {
411
+ const zones = this.zones;
412
+ if (!this.#ordered) return zones.some(zone => zone.from < to && from < zone.to);
413
+ // The first zone that ends after `from`; it overlaps if it begins before `to`.
414
+ let low = 0;
415
+ let high = zones.length;
416
+ while (low < high) {
417
+ const middle = (low + high) >> 1;
418
+ if (zones[middle].to <= from) low = middle + 1;
419
+ else high = middle;
420
+ }
421
+ return low < zones.length && zones[low].from < to;
422
+ }
423
+ }
424
+
425
+ /** The index of a file's map zones, made again when they have been replaced. */
426
+ function zoneIndexOf(file) {
427
+ if (file.sourceIndex?.zones !== file.sourceZones) file.sourceIndex = new ZoneIndex(file.sourceZones);
428
+ return file.sourceIndex;
429
+ }
430
+
375
431
  /**
376
432
  * Zones resolved so far, with the retained zones of every segment they do not
377
433
  * yet resolve again: a segment's bytes are stated once, by its newest reading.
@@ -15,7 +15,7 @@ export async function nativeOriginalSourceMap({ container, viewers, durationSeco
15
15
  const input = await container.readMappedSourceRanges({ from: zone.from + startTimeSeconds,
16
16
  to: Math.min(durationSeconds, zone.to) + startTimeSeconds });
17
17
  if (input.kind !== "result") continue;
18
- const links = viewers.filter((_viewer, index) => maps[index].priority[zone.from] === zone.priority)
18
+ const links = viewers.filter((_viewer, index) => maps[index].at(zone.from).priority === zone.priority)
19
19
  .map(viewer => viewer.linkReading()?.linkMbps).filter(rate => Number.isFinite(rate) && rate > 0);
20
20
  const bytes = input.ranges.reduce((sum, [start, end]) => sum + end - start + 1, 0);
21
21
  const deliverySeconds = links.length ? bytes * 8 / (Math.min(...links) * 1_000_000) : 0;
@@ -46,7 +46,7 @@ export function nativeSourceMap({ index, tracks, viewers, durationSeconds, start
46
46
  const input = index.inputFor({ trackId: track.trackNumber, from, to });
47
47
  if (input.kind === "needs-index") continue;
48
48
  if (input.kind !== "result") throw new Error(`Native source input refused: ${input.reason}`);
49
- const links = viewers.filter((_viewer, viewerIndex) => maps[viewerIndex].priority[zone.from] === zone.priority)
49
+ const links = viewers.filter((_viewer, viewerIndex) => maps[viewerIndex].at(zone.from).priority === zone.priority)
50
50
  .map(viewer => viewer.linkReading()?.linkMbps).filter(rate => Number.isFinite(rate) && rate > 0);
51
51
  const bytes = input.ranges.reduce((sum, [start, end]) => sum + end - start + 1, 0);
52
52
  const deliverySeconds = links.length ? bytes * 8 / (Math.min(...links) * 1_000_000) : 0;
@@ -44,34 +44,45 @@
44
44
  */
45
45
 
46
46
  /**
47
- * THE MAP ITSELF: one number per second of film.
48
- *
49
- * Literally an array as long as the film, where the value at second `x` says how
50
- * urgently that second is wanted. Stated by the user 2026-09-06, and it replaced
51
- * a list of stretches each carrying two numbers.
52
- *
53
- * Two arrays rather than one, because two different questions are asked of the
54
- * map and only one of them can be answered by a rank:
47
+ * THE MAP ITSELF: what each viewer wants, kept as the rules that say it, and
48
+ * answered for a second, for a stretch, or as the stretches of one value.
49
+ *
50
+ * The rules are the ones the map has had since 2026-09-06, when an array of one
51
+ * number per second of film replaced a list of stretches: a priority for every
52
+ * second, the time until a viewer plays it, and whether it lies behind every
53
+ * viewer, is wanted before playback can continue, or has been yielded by a
54
+ * viewer who stopped. What changed (torrent-tv/meta#178) is only how they are
55
+ * held. Every one of those values is a step function of the distance from a
56
+ * viewer, with steps the rules themselves place — where the viewer stands, the
57
+ * end of the urgent allowance, the doubling bands of priority — and the time is
58
+ * that distance. Built as arrays the length of the film, a map cost a pass over
59
+ * every second for every viewer and again for the merge, on every move of a
60
+ * viewer: on Home Assistant the main thread built them 21-61 times a minute
61
+ * over a two-hour film (r26). Held as rules, a question costs what the viewers
62
+ * and their steps cost, and nothing per second of film.
63
+ *
64
+ * Two different questions are asked of the map and only one of them can be
65
+ * answered by a rank:
55
66
  *
56
67
  * - `priority` orders the work. Only the ORDER between values is meaningful;
57
68
  * the scale below says what the numbers separate;
58
- * - `secondsUntilPlayed` is a quantity, and it is what makes lateness
59
- * computable at all: whoever fills the map compares "when is this needed"
60
- * against "when would it arrive", and only the second half is theirs to work
61
- * out. `Infinity` where nobody is on their way.
62
- *
63
- * @typedef {object} PriorityMap
64
- * @property {number} durationSeconds - The film's length, in whole seconds.
65
- * @property {Uint8Array} priority - One entry per second. Zero means nothing is
66
- * wanted there, which happens only where nobody is watching at all.
67
- * @property {Float64Array} secondsUntilPlayed - One entry per second.
68
- * @property {Uint8Array} behind - One entry per second, 1 where the second lies
69
- * behind EVERY viewer. Stated rather than worked out from the other two,
70
- * because working it out means knowing this file's scale, and the consumers
71
- * are in other layers. Inferred from the absence of a time, as it was, it was
72
- * wrong for a stopped viewer: their whole film carries no time, so the film in
73
- * front of them counted as behind them and the encoders wandered to the start
74
- * of the file.
69
+ * - the time until a second is played is a quantity, and it is what makes
70
+ * lateness computable at all: whoever fills the map compares "when is this
71
+ * needed" against "when would it arrive", and only the second half is theirs
72
+ * to work out. `Infinity` where nobody is on their way.
73
+ *
74
+ * The merge keeps what it always did: the highest priority and the soonest
75
+ * time, taken separately — two viewers can tie on the coarse priority while one
76
+ * is genuinely nearer; in front of ANYBODY is in front; a second is urgent if
77
+ * any viewer needs it before playback can continue; and it is deferred only if
78
+ * every viewer in front of it has yielded it.
79
+ *
80
+ * @typedef {object} MapPoint
81
+ * @property {number} priority - Zero where nobody wants it.
82
+ * @property {number} untilSeconds - Seconds until a viewer plays it.
83
+ * @property {boolean} behind - Behind every viewer.
84
+ * @property {boolean} urgent
85
+ * @property {boolean} deferred
75
86
  */
76
87
 
77
88
  /**
@@ -87,6 +98,7 @@
87
98
  * @property {boolean} behind - Whether this lies behind every viewer.
88
99
  */
89
100
 
101
+
90
102
  /**
91
103
  * Wanted seconds use priorities 1..100. Distance lowers priority relative to
92
104
  * the measured urgent allowance. Pause preserves the same priorities until
@@ -167,28 +179,27 @@ function stepsAway(distanceSeconds, allowanceSeconds) {
167
179
  return Math.floor(Math.log2(1 + distanceSeconds / allowanceSeconds));
168
180
  }
169
181
 
182
+ /** Where nothing is wanted. */
183
+ const NO_DEMAND = Object.freeze({ priority: NOTHING, untilSeconds: Number.POSITIVE_INFINITY, behind: true, urgent: false, deferred: false });
184
+
170
185
  /**
171
- * An empty map of the right length: nothing wanted anywhere.
186
+ * The first whole distance whose step is at least `step`, worked out by the
187
+ * same arithmetic the step is read with, so a boundary is exactly where the
188
+ * value changes.
172
189
  *
173
- * @param {number} durationSeconds
174
- * @returns {PriorityMap}
190
+ * @param {number} step
191
+ * @param {number} allowanceSeconds
192
+ * @returns {number}
175
193
  */
176
- export function emptyMap(durationSeconds) {
177
- const seconds = Number.isFinite(durationSeconds) && durationSeconds > 0
178
- ? Math.ceil(durationSeconds)
179
- : 0;
180
- return {
181
- durationSeconds: seconds,
182
- priority: new Uint8Array(seconds),
183
- secondsUntilPlayed: new Float64Array(seconds).fill(Number.POSITIVE_INFINITY),
184
- behind: new Uint8Array(seconds).fill(1),
185
- urgent: new Uint8Array(seconds),
186
- deferred: new Uint8Array(seconds)
187
- };
194
+ function firstDistanceOfStep(step, allowanceSeconds) {
195
+ let distance = Math.ceil(allowanceSeconds * (2 ** step - 1));
196
+ while (distance > 0 && stepsAway(distance - 1, allowanceSeconds) >= step) distance -= 1;
197
+ while (stepsAway(distance, allowanceSeconds) < step) distance += 1;
198
+ return distance;
188
199
  }
189
200
 
190
201
  /**
191
- * One viewer's map.
202
+ * One viewer's demand: the rules of their map, with where they stand.
192
203
  *
193
204
  * **The number is a reading of how soon they will be there.** A viewer watching
194
205
  * forward reaches second `x` after `x - p` seconds of film. That distance — not
@@ -198,195 +209,303 @@ export function emptyMap(durationSeconds) {
198
209
  * Pause retains urgent preparation first. Once that work is ready, the
199
210
  * allowance / (allowance + pauseSeconds) coefficient gradually reduces this
200
211
  * viewer's priorities if others consume the file. Resume restores them.
201
- *
202
- * **What must NOT be here**, and the boundary is the point: how fast this
203
- * machine encodes, how many encoders that takes, what a second weighs in bytes,
204
- * where a piece boundary falls, and what has already been made. Those are
205
- * answers the encoding and the downloading work out for themselves, from this
206
- * map and from what each knows about itself.
207
- *
208
- * @param {object} params
209
- * @param {number} params.atSeconds - Where they are watching from.
210
- * @param {number} params.durationSeconds - How long the film is.
211
- * @param {number} params.allowanceSeconds - The measured depth below which an
212
- * interruption reaches this viewer. The width of the first step.
213
- * @param {boolean} [params.playing] - Whether the picture is moving.
214
- * @returns {PriorityMap}
215
212
  */
216
- export function pauseCoefficient({ playing, urgentReady, viewerCount, pauseSeconds, allowanceSeconds }) {
217
- if (playing !== false || !urgentReady || viewerCount <= 1) return 1;
218
- const allowance = Number.isFinite(allowanceSeconds) && allowanceSeconds > 0 ? allowanceSeconds : 1;
219
- const elapsed = Number.isFinite(pauseSeconds) ? Math.max(0, pauseSeconds) : 0;
220
- return allowance / (allowance + elapsed);
221
- }
213
+ class ViewerDemand {
214
+ /**
215
+ * @param {{ atSeconds: number, durationSeconds: number, allowanceSeconds: number, playing?: boolean,
216
+ * pauseSeconds?: number, urgentReady?: boolean, viewerCount?: number }} params
217
+ */
218
+ constructor({ atSeconds, durationSeconds, allowanceSeconds, playing = true, pauseSeconds = 0, urgentReady = false, viewerCount = 1 }) {
219
+ this.durationSeconds = Number.isFinite(durationSeconds) && durationSeconds > 0 ? Math.ceil(durationSeconds) : 0;
220
+ this.at = Number.isFinite(atSeconds) && atSeconds > 0 ? Math.floor(atSeconds) : 0;
221
+ this.allowance = Number.isFinite(allowanceSeconds) && allowanceSeconds > 0 ? allowanceSeconds : this.durationSeconds;
222
+ // The measured urgent span is the time scale. There is no chosen decay
223
+ // period: one span on pause halves the excess above minimum priority.
224
+ this.attenuate = playing === false && urgentReady && viewerCount > 1;
225
+ this.elapsed = Number.isFinite(pauseSeconds) ? Math.max(0, pauseSeconds) : 0;
226
+ this.coefficient = pauseCoefficient({ playing, urgentReady, viewerCount, pauseSeconds: this.elapsed, allowanceSeconds: this.allowance });
227
+ }
222
228
 
223
- export function mapForViewer({ atSeconds, durationSeconds, allowanceSeconds, playing = true,
224
- pauseSeconds = 0, urgentReady = false, viewerCount = 1 }) {
225
- const map = emptyMap(durationSeconds);
226
- if (map.durationSeconds === 0) {
227
- return map;
229
+ /** The priority at a whole distance in front of the viewer. @param {number} distance */
230
+ priorityAt(distance) {
231
+ const priority = Math.max(NOBODY_IS_COMING, AT_A_WATCHING_VIEWER - stepsAway(distance, this.allowance));
232
+ return Math.max(NOBODY_IS_COMING, 1 + Math.floor((priority - 1) * this.coefficient));
228
233
  }
229
- const at = Number.isFinite(atSeconds) && atSeconds > 0 ? Math.floor(atSeconds) : 0;
230
- const allowance = Number.isFinite(allowanceSeconds) && allowanceSeconds > 0
231
- ? allowanceSeconds
232
- : map.durationSeconds;
233
- const watching = playing !== false;
234
- // The measured urgent span is the time scale. There is no chosen decay
235
- // period: one span on pause halves the excess above minimum priority.
236
- const attenuate = !watching && urgentReady && viewerCount > 1;
237
- const elapsed = Number.isFinite(pauseSeconds) ? Math.max(0, pauseSeconds) : 0;
238
- const coefficient = pauseCoefficient({ playing, urgentReady, viewerCount, pauseSeconds: elapsed, allowanceSeconds: allowance });
239
- for (let second = 0; second < map.durationSeconds; second += 1) {
240
- if (second < at) {
241
- map.priority[second] = NOBODY_IS_COMING;
242
- continue;
234
+
235
+ /**
236
+ * @param {number} second
237
+ * @returns {MapPoint | null} Null where the film does not reach.
238
+ */
239
+ pointAt(second) {
240
+ if (!(second >= 0 && second < this.durationSeconds)) return null;
241
+ if (second < this.at) {
242
+ return { priority: NOBODY_IS_COMING, untilSeconds: Number.POSITIVE_INFINITY, behind: true, urgent: false, deferred: false };
243
243
  }
244
- map.behind[second] = 0;
245
- const distance = second - at;
246
- const priority = Math.max(NOBODY_IS_COMING, AT_A_WATCHING_VIEWER - stepsAway(distance, allowance));
247
- map.priority[second] = Math.max(NOBODY_IS_COMING, 1 + Math.floor((priority - 1) * coefficient));
248
- map.urgent[second] = distance < allowance && !attenuate ? 1 : 0;
249
- map.deferred[second] = attenuate ? 1 : 0;
244
+ const distance = second - this.at;
250
245
  // Urgent preparation keeps its deadline during pause. After completion,
251
246
  // pause duration defers this viewer's work without altering other viewers.
252
- map.secondsUntilPlayed[second] = distance + (attenuate ? elapsed : 0);
247
+ return { priority: this.priorityAt(distance), untilSeconds: distance + (this.attenuate ? this.elapsed : 0), behind: false,
248
+ urgent: distance < this.allowance && !this.attenuate, deferred: this.attenuate };
249
+ }
250
+
251
+ /** Every second where one of this viewer's values may change, inside the film. @returns {number[]} */
252
+ breaks() {
253
+ const breaks = [this.at, this.durationSeconds, this.at + Math.ceil(this.allowance)];
254
+ for (let step = 1; step < AT_A_WATCHING_VIEWER - NOBODY_IS_COMING; step += 1) {
255
+ const second = this.at + firstDistanceOfStep(step, this.allowance);
256
+ if (second >= this.durationSeconds) break;
257
+ breaks.push(second);
258
+ }
259
+ return breaks.filter(second => second > 0 && second <= this.durationSeconds);
253
260
  }
254
- return map;
255
261
  }
256
262
 
257
263
  /**
258
- * Where viewers stand, read off a map: every second whose priority rises above
259
- * the second before it.
260
- *
261
- * A viewer's own map is highest where they stand and falls away in front of
262
- * them, so in a merged map each viewer's position is a step UP — the one place
263
- * a value is higher than the value just behind it. A viewer standing inside the
264
- * stretch somebody else is walking towards adds nothing that map does not
265
- * already ask for, and is not a step; neither is somebody behind everybody.
266
- *
267
- * Read by whoever orders work by where people are and must not know what a
268
- * viewer is — the subtitle walk reads the clusters at these times first.
269
- *
270
- * @param {PriorityMap | null | undefined} map
271
- * @returns {number[]} Seconds, ascending.
264
+ * Every viewer's demand on one film, merged: the map.
272
265
  */
273
- export function viewerStartsOn(map) {
274
- const starts = [];
275
- if (!map || !(map.durationSeconds > 0)) {
276
- return starts;
266
+ export class PriorityMap {
267
+ /** @type {ViewerDemand[]} */
268
+ #viewers;
269
+ /** The film between the breaks, each stretch with its value at its start; worked out once. */
270
+ #pieces = null;
271
+
272
+ /** @param {ViewerDemand[]} [viewers] */
273
+ constructor(viewers = []) {
274
+ this.#viewers = viewers.filter(viewer => viewer.durationSeconds > 0);
275
+ /** The film's length, in whole seconds. */
276
+ this.durationSeconds = this.#viewers.reduce((longest, viewer) => Math.max(longest, viewer.durationSeconds), 0);
277
+ }
278
+
279
+ /** The viewers' demands, for a merge. */
280
+ get viewers() {
281
+ return this.#viewers;
282
+ }
283
+
284
+ /**
285
+ * What is wanted at one second.
286
+ *
287
+ * @param {number} second
288
+ * @returns {MapPoint}
289
+ */
290
+ at(second) {
291
+ let priority = NOTHING;
292
+ let untilSeconds = Number.POSITIVE_INFINITY;
293
+ let behind = true;
294
+ let urgent = false;
295
+ let yieldedByAll = true;
296
+ for (const viewer of this.#viewers) {
297
+ const point = viewer.pointAt(second);
298
+ if (!point) continue;
299
+ if (point.priority > priority) priority = point.priority;
300
+ if (point.untilSeconds < untilSeconds) untilSeconds = point.untilSeconds;
301
+ if (!point.behind) {
302
+ // In front of ANYBODY is in front: a stretch one viewer has passed is
303
+ // still the film another is walking towards.
304
+ behind = false;
305
+ // Deferred only if every viewer needing this second has yielded it.
306
+ if (!point.deferred) yieldedByAll = false;
307
+ }
308
+ if (point.urgent) urgent = true;
309
+ }
310
+ if (priority === NOTHING) return NO_DEMAND;
311
+ return { priority, untilSeconds, behind, urgent, deferred: !behind && yieldedByAll };
312
+ }
313
+
314
+ /**
315
+ * What a stretch of seconds wants as a whole, taken as a piece made or not
316
+ * made whole is wanted: as much as its strongest second, as soon as its
317
+ * soonest one, in front of somebody if any second of it is, urgent if any
318
+ * second of it is.
319
+ *
320
+ * A viewer's priority does not rise with distance and their time rises a
321
+ * second a second, so the second of the stretch nearest to them answers both.
322
+ *
323
+ * @param {number} from - First second, inclusive.
324
+ * @param {number} to - Last second, exclusive.
325
+ * @returns {{ priority: number, withinSeconds: number, behind: boolean, urgent: boolean }}
326
+ * Priority zero where nobody wants any of it.
327
+ */
328
+ on(from, to) {
329
+ let priority = NOTHING;
330
+ let withinSeconds = Number.POSITIVE_INFINITY;
331
+ let behind = true;
332
+ let urgent = false;
333
+ const pieces = this.#piecesOfFilm();
334
+ // The first stretch that ends after `from`.
335
+ let low = 0;
336
+ let high = pieces.length;
337
+ while (low < high) {
338
+ const middle = (low + high) >> 1;
339
+ if (pieces[middle].to <= from) low = middle + 1;
340
+ else high = middle;
341
+ }
342
+ for (let index = low; index < pieces.length && pieces[index].from < to; index += 1) {
343
+ const { from: start, point } = pieces[index];
344
+ if (point.priority === NOTHING) continue;
345
+ if (point.priority > priority) priority = point.priority;
346
+ // Inside a stretch every viewer in front stays in front and their time
347
+ // rises a second a second, so the soonest time rises with it.
348
+ const until = point.untilSeconds + (Math.max(from, start) - start);
349
+ if (until < withinSeconds) withinSeconds = until;
350
+ if (!point.behind) behind = false;
351
+ if (point.urgent) urgent = true;
352
+ }
353
+ return { priority, withinSeconds, behind, urgent };
277
354
  }
278
- for (let second = 0; second < map.durationSeconds; second += 1) {
279
- const value = map.priority[second];
280
- const before = second === 0 ? NOBODY_IS_COMING : map.priority[second - 1];
281
- if (value > before && value > NOBODY_IS_COMING) {
282
- starts.push(second);
355
+
356
+ /** @returns {Array<{ from: number, to: number, point: MapPoint }>} */
357
+ #piecesOfFilm() {
358
+ if (this.#pieces) return this.#pieces;
359
+ const breaks = this.#breaks();
360
+ const pieces = [];
361
+ for (let index = 0; index + 1 < breaks.length; index += 1) {
362
+ pieces.push({ from: breaks[index], to: breaks[index + 1], point: this.at(breaks[index]) });
283
363
  }
364
+ this.#pieces = pieces;
365
+ return pieces;
366
+ }
367
+
368
+ /** Every second where a merged value may change, with 0 and the end, ascending. */
369
+ #breaks() {
370
+ const breaks = new Set([0, this.durationSeconds]);
371
+ for (const viewer of this.#viewers) for (const second of viewer.breaks()) breaks.add(second);
372
+ return [...breaks].filter(second => second >= 0 && second <= this.durationSeconds).sort((left, right) => left - right);
373
+ }
374
+
375
+ /**
376
+ * The map as stretches: runs of seconds that agree on everything but the
377
+ * time, ascending, without gaps or overlaps. Seconds nobody wants are left
378
+ * out.
379
+ *
380
+ * THE TIME OF A STRETCH IS THE TIME OF ITS NEAR EDGE. A stretch is met at its
381
+ * beginning, so its beginning is when it must exist; whoever needs the time
382
+ * of a second inside it walks forward from there, at a second of film per
383
+ * second, which is the same arithmetic that put the number here. Merging on
384
+ * the exact time as well would merge nothing at all: the time is the
385
+ * distance, so it differs at every second.
386
+ *
387
+ * @returns {DemandZone[]}
388
+ */
389
+ runs() {
390
+ /** @type {DemandZone[]} */
391
+ const runs = [];
392
+ for (const { from, to, point } of this.#piecesOfFilm()) {
393
+ if (point.priority === NOTHING) continue;
394
+ const previous = runs[runs.length - 1];
395
+ if (previous && previous.to === from && previous.priority === point.priority && previous.behind === point.behind &&
396
+ previous.urgent === point.urgent && previous.deferred === point.deferred) {
397
+ previous.to = to;
398
+ continue;
399
+ }
400
+ runs.push({ from, to, priority: point.priority, withinSeconds: point.untilSeconds, behind: point.behind,
401
+ urgent: point.urgent, deferred: point.deferred });
402
+ }
403
+ return runs;
404
+ }
405
+
406
+ /**
407
+ * Where viewers stand, read off the map: every second whose priority rises
408
+ * above the second before it.
409
+ *
410
+ * A viewer's own map is highest where they stand and falls away in front of
411
+ * them, so in a merged map each viewer's position is a step UP — the one place
412
+ * a value is higher than the value just behind it. A viewer standing inside the
413
+ * stretch somebody else is walking towards adds nothing that map does not
414
+ * already ask for, and is not a step; neither is somebody behind everybody.
415
+ *
416
+ * Read by whoever orders work by where people are and must not know what a
417
+ * viewer is — the subtitle walk reads the clusters at these times first.
418
+ *
419
+ * @returns {number[]} Seconds, ascending.
420
+ */
421
+ viewerStarts() {
422
+ const starts = [];
423
+ for (const second of this.#breaks()) {
424
+ if (second >= this.durationSeconds) continue;
425
+ const value = this.at(second).priority;
426
+ const before = second === 0 ? NOBODY_IS_COMING : this.at(second - 1).priority;
427
+ if (value > before && value > NOBODY_IS_COMING) starts.push(second);
428
+ }
429
+ return starts;
284
430
  }
285
- return starts;
286
431
  }
287
432
 
288
433
  /**
289
- * Every viewer's map as one.
434
+ * A map with nothing in it, which says the same as nobody watching.
290
435
  *
291
- * The highest priority per second wins, and the soonest time: film two people
292
- * want is as urgent as the more urgent of them, and making it once serves both.
293
- * The two are taken separately on purpose — the priority is coarse, so two
294
- * viewers can tie on it while one is genuinely nearer, and whoever schedules
295
- * against the time must be given the nearer one.
436
+ * @returns {PriorityMap}
437
+ */
438
+ export function emptyMap() {
439
+ return new PriorityMap([]);
440
+ }
441
+
442
+ /**
443
+ * How far a viewer's priorities are lowered while they pause beside other
444
+ * viewers: allowance / (allowance + pauseSeconds), once their urgent work is
445
+ * ready; 1 otherwise.
446
+ *
447
+ * @param {{ playing?: boolean, urgentReady?: boolean, viewerCount: number, pauseSeconds?: number, allowanceSeconds?: number }} params
448
+ * @returns {number}
449
+ */
450
+ export function pauseCoefficient({ playing, urgentReady, viewerCount, pauseSeconds, allowanceSeconds }) {
451
+ if (playing !== false || !urgentReady || viewerCount <= 1) return 1;
452
+ const allowance = Number.isFinite(allowanceSeconds) && allowanceSeconds > 0 ? allowanceSeconds : 1;
453
+ const elapsed = Number.isFinite(pauseSeconds) ? Math.max(0, pauseSeconds) : 0;
454
+ return allowance / (allowance + elapsed);
455
+ }
456
+
457
+ /**
458
+ * One viewer's map.
296
459
  *
297
- * In front of ANYBODY is in front, and the third array says so directly.
460
+ * **What must NOT be here**, and the boundary is the point: how fast this
461
+ * machine encodes, how many encoders that takes, what a second weighs in bytes,
462
+ * where a piece boundary falls, and what has already been made. Those are
463
+ * answers the encoding and the downloading work out for themselves, from this
464
+ * map and from what each knows about itself.
465
+ *
466
+ * @param {object} params
467
+ * @param {number} params.atSeconds - Where they are watching from.
468
+ * @param {number} params.durationSeconds - How long the film is.
469
+ * @param {number} params.allowanceSeconds - The measured depth below which an
470
+ * interruption reaches this viewer. The width of the first step.
471
+ * @param {boolean} [params.playing] - Whether the picture is moving.
472
+ * @param {number} [params.pauseSeconds]
473
+ * @param {boolean} [params.urgentReady]
474
+ * @param {number} [params.viewerCount]
475
+ * @returns {PriorityMap}
476
+ */
477
+ export function mapForViewer(params) {
478
+ return new PriorityMap([new ViewerDemand(params)]);
479
+ }
480
+
481
+ /**
482
+ * Every viewer's map as one.
298
483
  *
299
484
  * @param {PriorityMap[]} maps
300
485
  * @returns {PriorityMap}
301
486
  */
302
487
  export function mergeMaps(maps) {
303
- const all = (maps ?? []).filter((map) => map && map.durationSeconds > 0);
304
- if (all.length === 0) {
305
- return emptyMap(0);
306
- }
307
- const merged = emptyMap(Math.max(...all.map((map) => map.durationSeconds)));
308
- merged.deferred.fill(1);
309
- for (const map of all) {
310
- for (let second = 0; second < map.durationSeconds; second += 1) {
311
- if (map.priority[second] > merged.priority[second]) {
312
- merged.priority[second] = map.priority[second];
313
- }
314
- if (map.secondsUntilPlayed[second] < merged.secondsUntilPlayed[second]) {
315
- merged.secondsUntilPlayed[second] = map.secondsUntilPlayed[second];
316
- }
317
- // In front of ANYBODY is in front: a stretch one viewer has passed is
318
- // still the film another is walking towards.
319
- if (map.behind[second] === 0) {
320
- merged.behind[second] = 0;
321
- }
322
- if (map.urgent?.[second]) merged.urgent[second] = 1;
323
- // Deferred only if every viewer needing this second has yielded it.
324
- if (map.behind[second] === 0 && !map.deferred?.[second]) merged.deferred[second] = 0;
325
- }
326
- }
327
- for (let second = 0; second < merged.durationSeconds; second += 1) {
328
- if (merged.behind[second]) merged.deferred[second] = 0;
329
- }
330
- return merged;
488
+ return new PriorityMap((maps ?? []).filter(Boolean).flatMap(map => map.viewers));
331
489
  }
332
490
 
333
491
  /**
334
- * The map as stretches: runs of seconds that agree on both numbers.
335
- *
336
- * A VIEW of the map, never a second copy of it. Consumers that work in ranges —
337
- * the swarm is asked for byte ranges, an encoder is given a stretch of pieces —
338
- * would otherwise each write this walk for themselves.
492
+ * The map as stretches (`PriorityMap.runs`).
339
493
  *
340
- * @param {PriorityMap} map
341
- * @returns {DemandZone[]} Ascending, without gaps or overlaps. Seconds nobody
342
- * wants are left out.
494
+ * @param {PriorityMap | null | undefined} map
495
+ * @returns {DemandZone[]}
343
496
  */
344
497
  export function runsOf(map) {
345
- /** @type {DemandZone[]} */
346
- const runs = [];
347
- if (!map || !(map.durationSeconds > 0)) {
348
- return runs;
349
- }
350
- for (let second = 0; second < map.durationSeconds; second += 1) {
351
- const priority = map.priority[second];
352
- if (priority === NOTHING) {
353
- continue;
354
- }
355
- const behind = map.behind[second] === 1;
356
- const urgent = map.urgent?.[second] === 1;
357
- const deferred = map.deferred?.[second] === 1;
358
- const previous = runs[runs.length - 1];
359
- if (
360
- previous
361
- && previous.to === second
362
- && previous.priority === priority
363
- && previous.behind === behind
364
- && previous.urgent === urgent
365
- && previous.deferred === deferred
366
- ) {
367
- previous.to = second + 1;
368
- continue;
369
- }
370
- // THE TIME OF A STRETCH IS THE TIME OF ITS NEAR EDGE. A stretch is met at
371
- // its beginning, so its beginning is when it must exist; whoever needs the
372
- // time of a second inside it walks forward from there, at a second of film
373
- // per second, which is the same arithmetic that put the number here.
374
- //
375
- // Merging on the exact time as well would merge nothing at all: the time is
376
- // the distance, so it differs at every second, and the map came back as one
377
- // stretch per second of film.
378
- const withinSeconds = map.secondsUntilPlayed[second];
379
- runs.push({
380
- from: second,
381
- to: second + 1,
382
- priority,
383
- withinSeconds,
384
- behind,
385
- urgent,
386
- deferred
387
- });
388
- }
389
- return runs;
498
+ return map ? map.runs() : [];
499
+ }
500
+
501
+ /**
502
+ * Where viewers stand on a map (`PriorityMap.viewerStarts`).
503
+ *
504
+ * @param {PriorityMap | null | undefined} map
505
+ * @returns {number[]}
506
+ */
507
+ export function viewerStartsOn(map) {
508
+ return map ? map.viewerStarts() : [];
390
509
  }
391
510
 
392
511
  /**
@@ -22,18 +22,30 @@
22
22
  * behind?: boolean, deferred?: boolean, from: number, to: number,
23
23
  * sourceInterval?: { from: number, to: number } }>} params.demands - In the
24
24
  * order of their deadlines.
25
- * @returns {{ outputs: string[], inputs: number[] }}
25
+ * @returns {{ outputs: string[], inputs: Float64Array }}
26
26
  */
27
27
  export function passInputs({ pieceLength, shift, targets, demands }) {
28
28
  const outputs = [...targets.keys()];
29
- const inputs = [pieceLength, shift];
30
- for (const target of targets.values()) inputs.push(target ?? -1);
29
+ const position = new Map(outputs.map((outputKey, at) => [outputKey, at]));
30
+ // Numbers in a list made to its length: grown a value at a time, eight for
31
+ // each of a film's 3500 segments, it was most of what comparing a pass cost
32
+ // (r30, torrent-tv/meta#178).
33
+ const inputs = new Float64Array(2 + targets.size + demands.length * 8);
34
+ let at = 0;
35
+ inputs[at++] = pieceLength;
36
+ inputs[at++] = shift;
37
+ for (const target of targets.values()) inputs[at++] = target ?? -1;
31
38
  for (const demand of demands) {
32
39
  // The segment's own interval too: a cut grid read again can give one
33
40
  // number other bytes.
34
- inputs.push(outputs.indexOf(demand.outputKey), demand.index, demand.priority,
35
- (demand.urgent ? 1 : 0) + (demand.behind ? 2 : 0) + (demand.deferred ? 4 : 0),
36
- demand.from, demand.to, demand.sourceInterval?.from ?? -1, demand.sourceInterval?.to ?? -1);
41
+ inputs[at++] = position.get(demand.outputKey) ?? -1;
42
+ inputs[at++] = demand.index;
43
+ inputs[at++] = demand.priority;
44
+ inputs[at++] = (demand.urgent ? 1 : 0) + (demand.behind ? 2 : 0) + (demand.deferred ? 4 : 0);
45
+ inputs[at++] = demand.from;
46
+ inputs[at++] = demand.to;
47
+ inputs[at++] = demand.sourceInterval?.from ?? -1;
48
+ inputs[at++] = demand.sourceInterval?.to ?? -1;
37
49
  }
38
50
  return { outputs, inputs };
39
51
  }
@@ -41,8 +53,8 @@ export function passInputs({ pieceLength, shift, targets, demands }) {
41
53
  /**
42
54
  * Whether a pass is worked out from what an earlier one was.
43
55
  *
44
- * @param {{ tracks: unknown, outputs: string[], inputs: number[] } | undefined} last
45
- * @param {{ tracks: unknown, outputs: string[], inputs: number[] }} next
56
+ * @param {{ tracks: unknown, outputs: string[], inputs: ArrayLike<number> } | undefined} last
57
+ * @param {{ tracks: unknown, outputs: string[], inputs: ArrayLike<number> }} next
46
58
  * @returns {boolean}
47
59
  */
48
60
  export function samePass(last, next) {
@@ -11,26 +11,50 @@ export function segmentDemands(output, fileIndex, zones) {
11
11
  const grid = output.timeline?.published ?? output.timeline?.boundaries;
12
12
  if (!Array.isArray(grid)) return [];
13
13
  const demands = [];
14
+ // THE ZONES ARE WALKED BESIDE THE SEGMENTS, not searched for each of them.
15
+ // A map's zones come ascending and apart (`PriorityMap.runs`) and the grid is
16
+ // ascending, so the zones a segment overlaps begin at or after those of the
17
+ // segment before it. Every zone filtered for every segment was thousands of
18
+ // segments by tens of zones on every pass over a film (r26,
19
+ // torrent-tv/meta#178). Zones in any other order are filtered as before.
20
+ const ordered = ascendingAndApart(zones);
21
+ let first = 0;
14
22
  for (let index = 0; index + 1 < grid.length; index++) {
15
23
  const from = grid[index], to = grid[index + 1];
16
24
  if (!Number.isFinite(from) || !Number.isFinite(to) || !(to > from)) throw new Error("An output segment needs a valid interval.");
17
- const overlapping = zones.filter(zone => zone.priority > 0 && zone.from < to && zone.to > from);
18
- if (!overlapping.length) continue;
19
- // A zone states the deadline of its near edge. A later segment within it
20
- // is reached later, at one second of film per second of playback.
21
- const deadlineOf = zone => Number.isFinite(zone.deadlineAt)
22
- ? zone.deadlineAt + Math.max(0, from - zone.from) * 1000 : Infinity;
23
- const owner = overlapping.reduce((first, zone) => deadlineOf(zone) < deadlineOf(first) ? zone : first);
24
- demands.push({ index, from, to, tracks, owner,
25
- priority: Math.max(...overlapping.map(zone => zone.priority)),
26
- urgent: overlapping.some(zone => zone.urgent),
27
- behind: overlapping.every(zone => zone.behind),
28
- deferred: overlapping.every(zone => zone.deferred),
29
- deadlineAt: Math.min(...overlapping.map(deadlineOf)) });
25
+ if (ordered) while (first < zones.length && zones[first].to <= from) first += 1;
26
+ // The zones a segment overlaps are folded as they are met, into numbers:
27
+ // a list of them, and the lists made of it for each field, were five
28
+ // arrays for every segment of a film on every pass (r30,
29
+ // torrent-tv/meta#178).
30
+ let owner = null, ownerDeadline = Infinity, priority = 0, urgent = false, behind = true, deferred = true;
31
+ for (let at = ordered ? first : 0; at < zones.length; at += 1) {
32
+ const zone = zones[at];
33
+ if (ordered && !(zone.from < to)) break;
34
+ if (!(zone.priority > 0) || !(zone.from < to && zone.to > from)) continue;
35
+ // A zone states the deadline of its near edge. A later segment within it
36
+ // is reached later, at one second of film per second of playback.
37
+ const deadline = Number.isFinite(zone.deadlineAt) ? zone.deadlineAt + Math.max(0, from - zone.from) * 1000 : Infinity;
38
+ if (owner === null || deadline < ownerDeadline) { owner = zone; ownerDeadline = deadline; }
39
+ if (zone.priority > priority) priority = zone.priority;
40
+ if (zone.urgent) urgent = true;
41
+ if (!zone.behind) behind = false;
42
+ if (!zone.deferred) deferred = false;
43
+ }
44
+ if (owner === null) continue;
45
+ demands.push({ index, from, to, tracks, owner, priority, urgent, behind, deferred, deadlineAt: ownerDeadline });
30
46
  }
31
47
  return demands.sort((left, right) => left.deadlineAt - right.deadlineAt || right.priority - left.priority || left.index - right.index);
32
48
  }
33
49
 
50
+ /** Whether zones come ascending and without overlaps, as a map's runs do. */
51
+ function ascendingAndApart(zones) {
52
+ for (let index = 1; index < zones.length; index += 1) {
53
+ if (!(zones[index].from >= zones[index - 1].to)) return false;
54
+ }
55
+ return true;
56
+ }
57
+
34
58
  /** Source bytes must arrive before measured processing and delivery consume time. */
35
59
  export function sourceDeadline(demand, { encodeSpeed, outputBytes, linkMbps }) {
36
60
  const span = demand.to - demand.from;