@torrent-tv/proxy 2.97.26 → 2.97.28

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.26",
3
+ "version": "2.97.28",
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
@@ -56,11 +56,12 @@ import { createPlaybackPlanner } from "./services/media/playback-planner.js";
56
56
  import { KeyframeTables } from "./services/media/KeyframeTables.js";
57
57
  import { contentsOf } from "./services/torrent/Contents.js";
58
58
  import { joinedWithinPieces } from "./services/torrent/demand/pieces.js";
59
- import { withoutRepeatedZones } from "./services/torrent/map-zones.js";
59
+ import { coalescedZones, withoutRepeatedZones } from "./services/torrent/map-zones.js";
60
60
  import { SubtitleOrchestrator } from "./services/media/SubtitleOrchestrator.js";
61
61
  import { containerOrchestrator, CONTAINER_HEAD_BYTES, describeWorkTags } from "./services/media/ContainerOrchestrator.js";
62
62
  import { readPlaybackDeclarations } from "./services/media/read-playback-declarations.js";
63
63
  import { DownloadMaps } from "./services/viewer/DownloadMaps.js";
64
+ import { passInputs, samePass } from "./services/viewer/playback-pass.js";
64
65
  import { nativeSourceMap, nativeOriginalSourceMap } from "./services/viewer/NativeSourceMap.js";
65
66
  import { MediaReadRequests } from "./services/media/MediaReadRequests.js";
66
67
  import { SegmentInputs } from "./services/media/SegmentInputs.js";
@@ -183,6 +184,7 @@ export async function startProxyServer({
183
184
  downloadMaps.retire(sourceKey);
184
185
  for (const key of segmentSourceRanges.keys()) if (key.startsWith(`${sourceKey}:`)) segmentSourceRanges.delete(key);
185
186
  for (const key of segmentReadings.keys()) if (key.startsWith(`${sourceKey}:`)) segmentReadings.delete(key);
187
+ for (const key of resolvedPasses.keys()) if (key.startsWith(`${sourceKey}:`)) resolvedPasses.delete(key);
186
188
  outputParts.encodeInputs?.bytesChanged();
187
189
  },
188
190
  // Pieces arriving is ANNOUNCED by the thread that owns the swarm; what is
@@ -436,10 +438,23 @@ export async function startProxyServer({
436
438
  * change; forgotten with the source.
437
439
  */
438
440
  const segmentReadings = new Map();
441
+ // Per output, then per segment: every segment of an output of one pass shares
442
+ // its list of track choices, so the output's key and the choices' key are
443
+ // worked out once for it. Built for each of a pass's segments, the two
444
+ // strings cost the main thread 13 s in ten minutes of a cold AVI on Home
445
+ // Assistant (torrent-tv/meta#178).
446
+ const readingsByChoices = new WeakMap();
439
447
  const segmentReadingOf = (zone, trackList, shift, map) => {
440
- const choicesKey = zone.tracks.map(choice => `${choice.type}/${choice.index}/${choice.mode}`).join(",");
441
- const key = `${map.sourceKey}:${map.fileIndex}:${zone.outputKey}:${zone.index}`;
442
- const kept = segmentReadings.get(key);
448
+ let output = readingsByChoices.get(zone.tracks);
449
+ if (!output) {
450
+ const outputKey = `${map.sourceKey}:${map.fileIndex}:${zone.outputKey}`;
451
+ let byIndex = segmentReadings.get(outputKey);
452
+ if (!byIndex) segmentReadings.set(outputKey, byIndex = new Map());
453
+ output = { byIndex, choicesKey: zone.tracks.map(choice => `${choice.type}/${choice.index}/${choice.mode}`).join(",") };
454
+ readingsByChoices.set(zone.tracks, output);
455
+ }
456
+ const { byIndex, choicesKey } = output;
457
+ const kept = byIndex.get(zone.index);
443
458
  if (kept && kept.trackList === trackList && kept.shift === shift && kept.choicesKey === choicesKey &&
444
459
  kept.from === zone.sourceInterval.from && kept.to === zone.sourceInterval.to) return kept.reading;
445
460
  const selected = zone.tracks.map(choice => ({ choice, track: choice.type === "video"
@@ -453,9 +468,14 @@ export async function startProxyServer({
453
468
  modes: Object.fromEntries(wanted.map(track => [track.trackNumber, modes.get(track)])) };
454
469
  reading = { wanted, modes, interval, rangesKey: `${map.sourceKey}:${map.fileIndex}:${JSON.stringify(interval)}` };
455
470
  }
456
- segmentReadings.set(key, { trackList, shift, choicesKey, from: zone.sourceInterval.from, to: zone.sourceInterval.to, reading });
471
+ byIndex.set(zone.index, { trackList, shift, choicesKey, from: zone.sourceInterval.from, to: zone.sourceInterval.to, reading });
457
472
  return reading;
458
473
  };
474
+ /**
475
+ * The last complete pass over each file, by file: what it was worked out
476
+ * from and what it resolved (`playback-pass.js`); forgotten with the source.
477
+ */
478
+ const resolvedPasses = new Map();
459
479
  /** A segment's ranges joined at the precision the torrent fetches, once per kept list and piece length. */
460
480
  const joinedRanges = new WeakMap();
461
481
  const joinedRangesOf = (ranges, pieceLength) => {
@@ -474,6 +494,10 @@ export async function startProxyServer({
474
494
  // same bytes — lost them with 2.96.11, and the start forecast answered
475
495
  // "source-input-ranges-unavailable" for ever (Home Assistant 2026-10-11).
476
496
  publish: (map) => torrentPool.setPriorityMap({ ...map, zones: withoutRepeatedZones(map.zones) }),
497
+ // And what is sent is packed: a stretch of one level and priority is one
498
+ // window, not one zone per range of each of its segments
499
+ // (`coalescedZones`, torrent-tv/meta#178).
500
+ pack: (zones) => coalescedZones(zones),
477
501
  log: (line) => logger.info(line),
478
502
  resolvePlayback: async (map) => {
479
503
  if (map.zones.length === 0) return [];
@@ -521,6 +545,17 @@ export async function startProxyServer({
521
545
  }
522
546
  }
523
547
  demands.sort((left, right) => left.deadlineAt - right.deadlineAt || right.priority - left.priority || left.index - right.index);
548
+ // WHAT THIS PASS IS WORKED OUT FROM, compared with the last complete pass
549
+ // over the file (`resolvedPasses`): the outputs and their targets, how the
550
+ // torrent fetches and where the file's clock starts, and every segment
551
+ // asked for, in the order of its deadline, with its level and priority.
552
+ const fileKey = `${map.sourceKey}:${map.fileIndex}`;
553
+ const pass = { tracks: tracks.value, ...passInputs({ pieceLength, shift, targets, demands }) };
554
+ const last = resolvedPasses.get(fileKey);
555
+ if (samePass(last, pass)) return last.result;
556
+ // Kept only from a pass that worked every segment out; one that met a
557
+ // reading not there yet is worked out again until it is.
558
+ let complete = true;
524
559
  // A container that names the bytes of an interval from an index it holds
525
560
  // answers every segment by arithmetic: once that index is read
526
561
  // ("source-navigation"), the ranges are computed here directly instead of
@@ -563,7 +598,7 @@ export async function startProxyServer({
563
598
  // were never asked for (Home Assistant 2026-10-08, torrent-tv/meta#151).
564
599
  const known = segmentSourceRanges.get(rangesKey);
565
600
  if (known) { convert(known); continue; }
566
- if (waiting.has(zone.outputKey)) continue;
601
+ if (waiting.has(zone.outputKey)) { complete = false; continue; }
567
602
  // What is resolved so far is stated before a lower zone is worked out
568
603
  // (DownloadMaps, torrent-tv/meta#178).
569
604
  map.workingOut?.(zone.priority, expanded);
@@ -596,9 +631,10 @@ export async function startProxyServer({
596
631
  requestId: `download:${map.sourceKey}:${map.fileIndex}:${zone.outputKey}:${zone.index}`,
597
632
  demand: { ...zone.owner, leadSeconds: zone.leadSeconds } });
598
633
  if (!map.isCurrent()) return [];
599
- if (!intervalParams) continue;
634
+ if (!intervalParams) { complete = false; continue; }
600
635
  const navigation = await containerOrchestrator.inspect(intervalParams, "source-navigation");
601
636
  if (navigation.kind !== "result") {
637
+ complete = false;
602
638
  if (navigation.kind === "needs-ranges" || navigation.kind === "needs-memory") waiting.add(zone.outputKey);
603
639
  continue;
604
640
  }
@@ -606,13 +642,16 @@ export async function startProxyServer({
606
642
  const packets = await containerOrchestrator.inspect(intervalParams, originalRanges ? "source-ranges" : "packets");
607
643
  if (!map.isCurrent()) return [];
608
644
  if (packets.kind === "needs-memory") waiting.add(zone.outputKey);
609
- if (packets.kind !== "result") continue;
645
+ if (packets.kind !== "result") { complete = false; continue; }
610
646
  const input = originalRanges ? packets.value : new SegmentInputs({ index: packets.value, tracks: wanted }).forInterval({ ...interval, mode: track => modes.get(track) });
611
- if (input.kind !== "result") continue;
647
+ if (input.kind !== "result") { complete = false; continue; }
612
648
  segmentSourceRanges.set(rangesKey, input.ranges);
613
649
  convert(input.ranges);
614
650
  }
615
- return expanded();
651
+ const result = expanded();
652
+ if (complete) resolvedPasses.set(fileKey, { ...pass, result });
653
+ else resolvedPasses.delete(fileKey);
654
+ return result;
616
655
  }
617
656
  });
618
657
  const mediaReads = new MediaReadRequests({
@@ -720,6 +720,37 @@ export function planEncoders({
720
720
  * @returns {{ stall: number, ahead: number, whole: number }} Three counts of
721
721
  * seconds, compared in that order by {@link cheaperThan}.
722
722
  */
723
+ /** Each number's rank in the map, worked out once per map rather than per arrangement scored. */
724
+ const ranksByMap = new WeakMap();
725
+
726
+ /**
727
+ * The highest priority any span gives each number from `first` to `last`.
728
+ *
729
+ * Asked once per number of the film by every arrangement a plan scores, it
730
+ * walked every span each time: 19 s of the main thread in ten minutes of a cold
731
+ * AVI on Home Assistant (torrent-tv/meta#178). The map does not change between
732
+ * the arrangements of one plan, so the answer is kept with it.
733
+ *
734
+ * @param {WantedSpan[]} wanted
735
+ * @param {number} first
736
+ * @param {number} last
737
+ * @returns {(index: number) => number}
738
+ */
739
+ function ranksOf(wanted, first, last) {
740
+ const kept = ranksByMap.get(wanted);
741
+ if (kept && kept.first === first && kept.last === last) return kept.rankAt;
742
+ const ranks = new Float64Array(Math.max(0, last - first + 1));
743
+ for (const span of wanted) {
744
+ const priority = Number(span.priority) || 0;
745
+ for (let index = Math.max(first, span.from); index <= Math.min(last, span.to); index += 1) {
746
+ if (priority > ranks[index - first]) ranks[index - first] = priority;
747
+ }
748
+ }
749
+ const rankAt = (index) => (index >= first && index <= last ? ranks[index - first] : 0);
750
+ ranksByMap.set(wanted, { first, last, rankAt });
751
+ return rankAt;
752
+ }
753
+
723
754
  function latenessOf(encoders, coverage, wanted, untilNeeded, rate, refetchSecPerSegment, segmentSeconds) {
724
755
  const first = Math.min(...wanted.map((span) => span.from));
725
756
  const last = Math.max(...wanted.map((span) => span.to));
@@ -750,15 +781,7 @@ function latenessOf(encoders, coverage, wanted, untilNeeded, rate, refetchSecPer
750
781
  // that is 2024 s, which outvotes everything: field 2026-09-08, one viewer got
751
782
  // three encoders, two of them on film behind them, and the run serving them
752
783
  // was killed to make room for one.
753
- const rankAt = (at) => {
754
- let rank = 0;
755
- for (const span of wanted) {
756
- if (at >= span.from && at <= span.to) {
757
- rank = Math.max(rank, Number(span.priority) || 0);
758
- }
759
- }
760
- return rank;
761
- };
784
+ const rankAt = ranksOf(wanted, first, last);
762
785
  // The ranks the map actually states, most urgent first. The comparison is over
763
786
  // these and nothing else, so a rank can never be outvoted by a lower one
764
787
  // however many seconds are at stake there.
@@ -196,6 +196,9 @@ const KEY_PATTERN =
196
196
  * packaged how.
197
197
  */
198
198
  export class OutputSpec {
199
+ /** The identity, once worked out. @type {string | null} */
200
+ #key = null;
201
+
199
202
  /**
200
203
  * @param {object} params
201
204
  * @param {string} params.sourceKey - `torrent:<infohash>`, the canonical
@@ -282,6 +285,13 @@ export class OutputSpec {
282
285
  * @returns {string}
283
286
  */
284
287
  toKey() {
288
+ // Worked out once: nothing that enters it changes after construction —
289
+ // every part copies what it is given — and it is asked for wherever an
290
+ // output is addressed, 5 s of the main thread in fifteen minutes on Home
291
+ // Assistant (r22, torrent-tv/meta#178).
292
+ if (this.#key !== null) {
293
+ return this.#key;
294
+ }
285
295
  const parts = [this.sourceKey, `fmt=${this.segmentFormatId}`, this.grid.toKey(), this.carries];
286
296
  if (this.video) {
287
297
  parts.push(this.video.toKey());
@@ -289,7 +299,8 @@ export class OutputSpec {
289
299
  if (this.audio) {
290
300
  parts.push(this.audio.toKey());
291
301
  }
292
- return parts.join(":");
302
+ this.#key = parts.join(":");
303
+ return this.#key;
293
304
  }
294
305
 
295
306
  /**
@@ -74,31 +74,78 @@ function statOf(file) {
74
74
 
75
75
  /**
76
76
  * CPU seconds of this process's threads, by part, or null where `/proc` cannot
77
- * be read.
77
+ * be read. Each reading names which torrent threads it counted (`epoch`): the
78
+ * difference between two readings that counted different ones says nothing.
78
79
  *
79
80
  * READ SYNCHRONOUSLY, with the clock read beside it: a reading is only a share
80
81
  * when every counter in it and its time belong to one instant. Read as
81
82
  * promises on the field host (2.97.0), the counters were taken while the main
82
83
  * thread was busy and the time after, and a second read "process 1238 %" of a
83
- * core (torrent-tv/meta#177). About a hundred small files a second, kernel
84
- * memory, no disk.
84
+ * core (torrent-tv/meta#177).
85
85
  *
86
- * @param {number} [pid]
87
- * @returns {{ main: number, worker: number, other: number } | null}
86
+ * THREE OR FOUR FILES A SECOND, NOT EVERY THREAD'S. The line names the main
87
+ * thread, the torrent thread and the rest, and the rest is the whole process
88
+ * less the two: the process's own counter carries every thread it has had.
89
+ * Reading the stat of each of its threads every second, about a hundred
90
+ * files, took 12 s of the main thread in fifteen minutes on Home Assistant
91
+ * (r22, torrent-tv/meta#178). Which threads are the torrent thread is looked
92
+ * up again when one of them is gone and at every `rediscover()`.
93
+ *
94
+ * @param {object} [params]
95
+ * @param {number} [params.pid]
96
+ * @param {(file: string) => string} [params.readText]
97
+ * @param {(dir: string) => string[]} [params.list]
98
+ * @returns {{ read: () => ({ main: number, worker: number, other: number, epoch: number } | null), rediscover: () => void }}
88
99
  */
89
- export function readThreadSeconds(pid = process.pid) {
90
- let tids;
91
- try {
92
- tids = readdirSync(`/proc/${pid}/task`);
93
- } catch {
94
- return null;
95
- }
96
- const parts = { main: 0, worker: 0, other: 0 };
97
- for (const entry of tids) {
98
- const stat = statOf(`/proc/${pid}/task/${entry}/stat`);
99
- if (stat) parts[partOfThread(Number(entry), stat.name, pid)] += stat.seconds;
100
- }
101
- return parts;
100
+ export function threadReader({ pid = process.pid, readText = (file) => readFileSync(file, "utf8"), list = readdirSync } = {}) {
101
+ const stat = (file) => {
102
+ try {
103
+ return parseStat(readText(file));
104
+ } catch {
105
+ return null;
106
+ }
107
+ };
108
+ /** The torrent thread's ids, or null until they are looked up. @type {number[] | null} */
109
+ let workers = null;
110
+ /** How many times they have been looked up. */
111
+ let epoch = 0;
112
+ const lookUp = () => {
113
+ let tids;
114
+ try {
115
+ tids = list(`/proc/${pid}/task`);
116
+ } catch {
117
+ return null;
118
+ }
119
+ return tids.map(Number).filter((tid) => {
120
+ const one = stat(`/proc/${pid}/task/${tid}/stat`);
121
+ return one && partOfThread(tid, one.name, pid) === "worker";
122
+ });
123
+ };
124
+ return {
125
+ read() {
126
+ if (workers === null) {
127
+ workers = lookUp();
128
+ epoch += 1;
129
+ }
130
+ if (workers === null) return null;
131
+ const total = stat(`/proc/${pid}/stat`);
132
+ const main = stat(`/proc/${pid}/task/${pid}/stat`);
133
+ if (!total || !main) return null;
134
+ let worker = 0;
135
+ for (const tid of workers) {
136
+ const one = stat(`/proc/${pid}/task/${tid}/stat`);
137
+ if (!one) {
138
+ workers = null;
139
+ return null;
140
+ }
141
+ worker += one.seconds;
142
+ }
143
+ return { main: main.seconds, worker, other: Math.max(0, total.seconds - main.seconds - worker), epoch };
144
+ },
145
+ rediscover() {
146
+ workers = null;
147
+ }
148
+ };
102
149
  }
103
150
 
104
151
  /**
@@ -129,6 +176,9 @@ export function sharesBetween(before, after) {
129
176
  const seconds = (after.at - before.at) / 1000;
130
177
  if (!(seconds > 0)) return null;
131
178
  const share = (from, to) => (Number.isFinite(from) && Number.isFinite(to) ? Math.max(0, to - from) / seconds : null);
179
+ // The torrent threads looked up again in between: their counters and the
180
+ // rest's are not comparable across that.
181
+ const threads = before.threads?.epoch === after.threads?.epoch;
132
182
  let ffmpeg = null;
133
183
  for (const [pid, value] of after.ffmpeg) {
134
184
  const earlier = before.ffmpeg.get(pid);
@@ -137,9 +187,9 @@ export function sharesBetween(before, after) {
137
187
  return {
138
188
  seconds,
139
189
  process: share(before.process, after.process),
140
- main: share(before.threads?.main, after.threads?.main),
141
- worker: share(before.threads?.worker, after.threads?.worker),
142
- other: share(before.threads?.other, after.threads?.other),
190
+ main: threads ? share(before.threads?.main, after.threads?.main) : null,
191
+ worker: threads ? share(before.threads?.worker, after.threads?.worker) : null,
192
+ other: threads ? share(before.threads?.other, after.threads?.other) : null,
143
193
  ffmpeg
144
194
  };
145
195
  }
@@ -178,10 +228,11 @@ export function startProcessorReport({ log, ffmpegPids, detailed = () => false,
178
228
  let previous = null;
179
229
  let series = [];
180
230
  let since = Date.now();
231
+ const threads = threadReader();
181
232
  const tick = () => {
182
233
  const at = Date.now();
183
234
  const usage = process.cpuUsage();
184
- const now = { at, process: (usage.user + usage.system) / 1e6, threads: readThreadSeconds(), ffmpeg: readProcessesSeconds(ffmpegPids()) };
235
+ const now = { at, process: (usage.user + usage.system) / 1e6, threads: threads.read(), ffmpeg: readProcessesSeconds(ffmpegPids()) };
185
236
  const shares = previous ? sharesBetween(previous, now) : null;
186
237
  previous = now;
187
238
  if (!shares) return;
@@ -191,6 +242,8 @@ export function startProcessorReport({ log, ffmpegPids, detailed = () => false,
191
242
  log(`processor: ${describeShares(series)}`);
192
243
  series = [];
193
244
  since = now.at;
245
+ // A torrent thread made again since is counted as the rest until then.
246
+ threads.rediscover();
194
247
  }
195
248
  };
196
249
  const timer = setInterval(tick, everyMs);
@@ -66,6 +66,20 @@ export function directoryNameFor(key) {
66
66
  return createHash("sha256").update(String(key)).digest("hex").slice(0, 16);
67
67
  }
68
68
 
69
+ /**
70
+ * The biggest of these pieces and its number.
71
+ *
72
+ * @param {Map<number, number>} sizes
73
+ * @returns {{ index: number, size: number }}
74
+ */
75
+ function largestOf(sizes) {
76
+ let largest = { index: -1, size: 0 };
77
+ for (const [index, size] of sizes) {
78
+ if (size > largest.size) largest = { index, size };
79
+ }
80
+ return largest;
81
+ }
82
+
69
83
  /**
70
84
  * What one output's directory holds, as last read.
71
85
  *
@@ -75,6 +89,9 @@ export function directoryNameFor(key) {
75
89
  * @property {number} bytes - What those files weigh.
76
90
  * @property {number} unproven - The highest number, whose closure nothing
77
91
  * proves, or -1 when the directory holds no segments.
92
+ * @property {boolean} followed - Whether every change since the reading has
93
+ * been told to this store: it was read while the directory was watched, and
94
+ * each piece published, removed or seen arriving since was put into it.
78
95
  */
79
96
 
80
97
  export class SegmentStore {
@@ -226,21 +243,36 @@ export class SegmentStore {
226
243
  }
227
244
 
228
245
  /**
229
- * Re-read this output's directory if it has moved since last time.
246
+ * What this output's directory holds: kept up to date piece by piece while
247
+ * the directory is watched, read whole otherwise.
248
+ *
249
+ * A WATCHED DIRECTORY IS READ ONCE. Every change to it after that is a piece
250
+ * published or removed here, or one the watch names, and each puts that one
251
+ * piece in or takes it out. It used to be read again — `readdir` and a `stat`
252
+ * of every piece — whenever its modification time moved, and the time moves
253
+ * with every working file an encoder opens: on Home Assistant 71 s of the
254
+ * main thread in fifteen minutes of a two-hour AVI, 2250 pieces of soundtrack
255
+ * listed again for each new one (r22, torrent-tv/meta#178).
230
256
  *
231
- * A directory's modification time changes when an entry is added or removed,
232
- * so a quiet request costs one `stat` rather than a listing.
257
+ * Unwatched, a directory's modification time still decides, so a quiet
258
+ * request costs one `stat` rather than a listing.
233
259
  *
234
260
  * @param {string} key
235
261
  * @returns {HeldContents}
236
262
  */
237
263
  refresh(key) {
238
264
  const format = this.#formats.get(key);
239
- const empty = { readAt: 0, byNumber: new Map(), bytes: 0, unproven: -1, largest: { index: -1, size: 0 } };
265
+ const empty = { readAt: 0, byNumber: new Map(), bytes: 0, unproven: -1, largest: { index: -1, size: 0 }, followed: false };
240
266
  if (!format) {
241
267
  return this.#held.get(key) ?? empty;
242
268
  }
269
+ const followed = this.#held.get(key);
270
+ if (followed?.followed && this.#watchers.has(key)) {
271
+ return followed;
272
+ }
243
273
  const dir = path.join(this.#root, directoryNameFor(key));
274
+ // Watched from before the listing, so nothing that happens after it is missed.
275
+ const watched = this.#watchers.has(key);
244
276
  let mtime = 0;
245
277
  try {
246
278
  mtime = statSync(dir).mtimeMs;
@@ -300,11 +332,61 @@ export class SegmentStore {
300
332
  this.#held.delete(key);
301
333
  return empty;
302
334
  }
303
- const contents = { readAt: mtime, byNumber, sizes, bytes, unproven: highest, largest };
335
+ const contents = { readAt: mtime, byNumber, sizes, bytes, unproven: highest, largest, followed: watched };
304
336
  this.#held.set(key, contents);
305
337
  return contents;
306
338
  }
307
339
 
340
+ /**
341
+ * Put one closed piece into what is held, from the file now under its served
342
+ * name. Nothing is done while nothing is held: the next question reads the
343
+ * directory whole.
344
+ *
345
+ * @param {string} key
346
+ * @param {number} index
347
+ * @returns {void}
348
+ */
349
+ #heldArrived(key, index) {
350
+ const contents = this.#held.get(key);
351
+ const format = this.#formats.get(key);
352
+ if (!contents || !format?.segmentFileName) {
353
+ return;
354
+ }
355
+ const full = path.join(this.#root, directoryNameFor(key), format.segmentFileName(index));
356
+ const size = statSync(full, { throwIfNoEntry: false })?.size ?? 0;
357
+ // Under the rule `refresh` applies to a listing: a file of no bytes is not
358
+ // a segment, whatever its name says.
359
+ if (!(size > 0)) {
360
+ this.#heldGone(key, index);
361
+ return;
362
+ }
363
+ contents.bytes += size - (contents.sizes.get(index) ?? 0);
364
+ contents.byNumber.set(index, full);
365
+ contents.sizes.set(index, size);
366
+ if (index > contents.unproven) contents.unproven = index;
367
+ if (size > contents.largest.size) contents.largest = { index, size };
368
+ else if (contents.largest.index === index) contents.largest = largestOf(contents.sizes);
369
+ }
370
+
371
+ /**
372
+ * Take one piece out of what is held.
373
+ *
374
+ * @param {string} key
375
+ * @param {number} index
376
+ * @returns {void}
377
+ */
378
+ #heldGone(key, index) {
379
+ const contents = this.#held.get(key);
380
+ if (!contents?.byNumber.has(index)) {
381
+ return;
382
+ }
383
+ contents.bytes -= contents.sizes.get(index) ?? 0;
384
+ contents.byNumber.delete(index);
385
+ contents.sizes.delete(index);
386
+ if (contents.unproven === index) contents.unproven = Math.max(-1, ...contents.byNumber.keys());
387
+ if (contents.largest.index === index) contents.largest = largestOf(contents.sizes);
388
+ }
389
+
308
390
  /**
309
391
  * The segment numbers this output holds that are finished.
310
392
  *
@@ -566,8 +648,9 @@ export class SegmentStore {
566
648
  }
567
649
  // Publication registers the material even after the previous directory was dropped.
568
650
  this.useFormat(key, format);
569
- // What the directory holds has changed, so the memory of it is stale.
570
- this.#held.delete(key);
651
+ // What the directory holds has changed by this one piece.
652
+ if (index >= 0) this.#heldArrived(key, index);
653
+ else this.#held.delete(key);
571
654
  if (index >= 0) {
572
655
  if (mediaRanges) {
573
656
  const byIndex = this.#mediaRanges.get(key) ?? new Map();
@@ -750,6 +833,8 @@ export class SegmentStore {
750
833
  end();
751
834
  return;
752
835
  }
836
+ // Nothing can be inferred from a change the platform did not name.
837
+ if (typeof name !== "string") this.#held.delete(key);
753
838
  this.#noticed(key, typeof name === "string" ? name : null);
754
839
  this.#changed(key);
755
840
  });
@@ -781,6 +866,9 @@ export class SegmentStore {
781
866
  return;
782
867
  }
783
868
  this.#watchers.delete(key);
869
+ // Changes are no longer told: what is held is checked against the disk again.
870
+ const held = this.#held.get(key);
871
+ if (held) held.followed = false;
784
872
  try {
785
873
  watcher.close();
786
874
  } catch {
@@ -800,19 +888,16 @@ export class SegmentStore {
800
888
  * @returns {void}
801
889
  */
802
890
  #noticed(key, name) {
803
- // WHAT WAS READ OF THIS DIRECTORY IS STALE. Dropped rather than re-read:
804
- // re-reading HERE is quadratic in the pieces of a film — one listing per
805
- // file written, of a directory that grows with every file, measured at 110 s
806
- // against 3 s over 482 pieces — while dropping costs one listing to whoever
807
- // asks next, which is bounded by the questions and not by the writes.
808
- //
809
- // IT IS NOT ENOUGH TO LET `refresh` NOTICE BY ITSELF. It re-reads when the
810
- // directory's modification time has moved, and that time is stated in
811
- // milliseconds: a file written in the same millisecond as the last reading
812
- // leaves it unmoved, and the piece is then reported absent although it is
813
- // there. That is the hazard `refresh` documents and `publish` has always
814
- // guarded against by dropping the memory.
815
- this.#held.delete(key);
891
+ // A reading taken before the watch began is checked against the
892
+ // directory's modification time, which is stated in milliseconds: a file
893
+ // written in the same millisecond as that reading leaves it unmoved, and
894
+ // the piece would be reported absent although it is there. Such a reading
895
+ // is dropped, and the next question reads the directory once, while it is
896
+ // watched. A reading kept up piece by piece is told of this one piece below
897
+ // — not read again, which is a listing of a directory that grows with every
898
+ // file for every file written (71 s in fifteen minutes on Home Assistant,
899
+ // r22, torrent-tv/meta#178).
900
+ if (!this.#held.get(key)?.followed) this.#held.delete(key);
816
901
  const format = this.#formats.get(key);
817
902
  const dir = path.join(this.#root, directoryNameFor(key));
818
903
  const index = name && format?.isSegmentFileName?.(name)
@@ -823,8 +908,10 @@ export class SegmentStore {
823
908
  // before anything is announced — otherwise clearing up after a dead run
824
909
  // would read as that run's pieces arriving.
825
910
  if (!existsSync(path.join(dir, name))) {
911
+ this.#heldGone(key, index);
826
912
  return;
827
913
  }
914
+ this.#heldArrived(key, index);
828
915
  this.#announceArrival(key, index);
829
916
  return;
830
917
  }
@@ -839,6 +926,7 @@ export class SegmentStore {
839
926
  }
840
927
  for (const waiting of [...byIndex.keys()]) {
841
928
  if (existsSync(path.join(dir, format.segmentFileName(waiting)))) {
929
+ this.#heldArrived(key, waiting);
842
930
  this.#announceArrival(key, waiting);
843
931
  }
844
932
  }
@@ -1025,7 +1113,14 @@ export class SegmentStore {
1025
1113
  */
1026
1114
  pathOf(key, index) {
1027
1115
  this.#touched.set(key, this.#now());
1028
- return this.refresh(key).byNumber.get(index) ?? null;
1116
+ const contents = this.refresh(key);
1117
+ const found = contents.byNumber.get(index);
1118
+ if (found || !contents.followed) return found ?? null;
1119
+ // A reading kept up piece by piece answers from what it was told, and a
1120
+ // change the watch failed to deliver would leave a closed piece absent for
1121
+ // good. The one file asked about is looked at — a `stat`, not a listing.
1122
+ this.#heldArrived(key, index);
1123
+ return contents.byNumber.get(index) ?? null;
1029
1124
  }
1030
1125
 
1031
1126
  /**
@@ -1180,7 +1275,7 @@ export class SegmentStore {
1180
1275
  this.#logger.warn(`segment store: could not remove segment ${index}: ${error.message}`);
1181
1276
  return 0;
1182
1277
  }
1183
- this.#held.delete(key);
1278
+ this.#heldGone(key, index);
1184
1279
  this.#mediaRanges.get(key)?.delete(index);
1185
1280
  this.#changed(key);
1186
1281
  return size;
@@ -6,9 +6,12 @@ import { compareBands } from "./bands.js";
6
6
  const BLOCK_BYTES = 16 * 1024;
7
7
 
8
8
  /** Estimate completion from observed queues and the public selection bands.
9
- * The native picker decides the order within a band, so every unqueued piece
10
- * uses that band's completion time rather than an invented request order.
11
- * Pieces without a measured supplier remain unknown. No requests are changed.
9
+ * The native picker takes a band's pieces from its first up (the `sequential`
10
+ * strategy), so an unqueued piece arrives after the band's pieces before it.
11
+ * A piece whose every missing block is asked for arrives when its queues say,
12
+ * and is not the band's to share out. A supplier is a peer that can be asked
13
+ * for the piece now. Pieces without a measured supplier remain unknown. No
14
+ * requests are changed.
12
15
  * Times are conditional on the measured rate and successful verification.
13
16
  */
14
17
  export async function futureDownload(entries, { now = Date.now(), findStore = pieceStoreOf, isCurrent = () => true } = {}) {
@@ -78,9 +81,23 @@ export async function futureDownload(entries, { now = Date.now(), findStore = pi
78
81
  at = Math.max(at, arrival);
79
82
  }
80
83
  arrivals.set(index, at);
84
+ // EVERY MISSING BLOCK ASKED FOR: the queues holding them say when it
85
+ // arrives. Each block is reserved to the wire it was asked of, so a faster
86
+ // peer elsewhere does not bring it sooner, and its bytes are not the
87
+ // band's to share out (torrent-tv/meta#178 review, WebTorrent 2.8.5
88
+ // `_request`, torrent.js:1758).
89
+ if (at !== null) {
90
+ if (++steps % 256 === 0) {
91
+ await nextTurn();
92
+ if (!isCurrent()) return new Map();
93
+ }
94
+ continue;
95
+ }
81
96
  const remaining = piece?.missing;
82
97
  const speed = (torrent.wires ?? []).reduce((sum, wire) => {
83
- if (wire.destroyed || !wire.peerPieces?.get(index)) return sum;
98
+ // Only a peer that can be asked for it now: one choking us is asked for
99
+ // nothing outside the pieces it allows, whatever it carried before.
100
+ if (wire.destroyed || !wire.peerPieces?.get(index) || !mayAsk(wire, index)) return sum;
84
101
  // What it carried with its queue kept full, where it has been seen so:
85
102
  // its speed while our selection gave it little measures our asking
86
103
  // (`SelectionLimit`, torrent-tv/meta#178).
@@ -97,21 +114,30 @@ export async function futureDownload(entries, { now = Date.now(), findStore = pi
97
114
  }
98
115
  forecasts.set(torrent, { measuredAt: now, arrivals });
99
116
  }
100
- pending.sort(compareBands);
117
+ // WITHIN A BAND, IN THE ORDER THE PIECES COME. The picker walks each
118
+ // selection from its first piece up (WebTorrent 2.8.5's default `sequential`
119
+ // strategy, which this proxy keeps), so a piece of a band arrives once those
120
+ // before it have. Charged the whole band's end instead, a band of many
121
+ // segments — the map states one per stretch of a level since 2.97.27 — would
122
+ // put its nearest piece at its farthest piece's time (torrent-tv/meta#178).
123
+ pending.sort((left, right) => compareBands(left, right) || left.index - right.index);
101
124
  let finish = now;
102
- for (let start = 0; start < pending.length;) {
103
- let end = start;
104
- const first = pending[start];
105
- while (end < pending.length && compareBands(pending[end], first) === 0) {
106
- const seconds = pending[end++].seconds;
107
- finish = finish === null || seconds === null ? null : finish + seconds * 1000;
108
- }
109
- for (let i = start; i < end; i++) {
110
- const { arrivals, index } = pending[i];
111
- const queuedAt = arrivals.get(index);
112
- if (finish !== null) arrivals.set(index, queuedAt === null ? finish : Math.min(queuedAt, finish));
113
- }
114
- start = end;
125
+ for (const { arrivals, index, seconds } of pending) {
126
+ finish = finish === null || seconds === null ? null : finish + seconds * 1000;
127
+ if (finish !== null) arrivals.set(index, finish);
115
128
  }
116
129
  return isCurrent() ? forecasts : new Map();
117
130
  }
131
+
132
+ /**
133
+ * Whether this peer can be asked for this piece now: it is not choking us, or
134
+ * it allows this piece while it does (BEP 6, WebTorrent 2.8.5 `_updateWire`).
135
+ *
136
+ * @param {{ peerChoking?: boolean, hasFast?: boolean, peerAllowedFastSet?: number[] }} wire
137
+ * @param {number} index
138
+ * @returns {boolean}
139
+ */
140
+ function mayAsk(wire, index) {
141
+ if (wire.peerChoking !== true) return true;
142
+ return wire.hasFast === true && Array.isArray(wire.peerAllowedFastSet) && wire.peerAllowedFastSet.includes(index);
143
+ }
@@ -80,12 +80,70 @@ export function deadlineOfMapZone(zone, now = Date.now()) {
80
80
  * @returns {Zone[]} In their order, without the repeats.
81
81
  */
82
82
  export function withoutRepeatedZones(zones, now = Date.now()) {
83
- const bandOf = zone => ({ urgency: levelOfMapZone(zone), priority: priorityOfMapZone(zone), deadlineAt: deadlineOfMapZone(zone, now) });
83
+ const keys = zones.map(zone => `${zone.byteStart}:${zone.byteEnd}`);
84
84
  const best = new Map();
85
85
  zones.forEach((zone, index) => {
86
- const key = `${zone.byteStart}:${zone.byteEnd}`;
87
- const kept = best.get(key);
88
- if (!kept || compareBands(bandOf(zone), kept.band) < 0) best.set(key, { index, band: bandOf(zone) });
86
+ const kept = best.get(keys[index]);
87
+ const band = { urgency: levelOfMapZone(zone), priority: priorityOfMapZone(zone), deadlineAt: deadlineOfMapZone(zone, now) };
88
+ if (!kept || compareBands(band, kept.band) < 0) best.set(keys[index], { index, band });
89
89
  });
90
- return zones.filter((zone, index) => best.get(`${zone.byteStart}:${zone.byteEnd}`).index === index);
90
+ return zones.filter((_zone, index) => best.get(keys[index]).index === index);
91
+ }
92
+
93
+ /**
94
+ * What the torrent is told, as few windows as say the same thing: every stretch
95
+ * of bytes once, in the best band that names it, and the stretches of one
96
+ * level and priority that touch or overlap joined into one window, wanted by
97
+ * the earliest deadline among them.
98
+ *
99
+ * A map names every range of every segment — three of an AVI's segment, two of
100
+ * them the header and index all its segments read — and a film is thousands of
101
+ * segments: 10 000 zones, built, compared, copied to the torrent thread and
102
+ * stated there every three seconds a viewer plays on, about 70 s of the main
103
+ * thread in ten minutes on Home Assistant (torrent-tv/meta#178). The swarm acts
104
+ * on the band of a piece and nothing else, and within one band the pieces of
105
+ * consecutive segments are consecutive bytes. A zone a metadata read states
106
+ * keeps its own request and order, and is left as it is.
107
+ *
108
+ * @template {{ byteStart: number, byteEnd: number }} Zone
109
+ * @param {Zone[]} zones
110
+ * @param {number} [now]
111
+ * @returns {object[]}
112
+ */
113
+ export function coalescedZones(zones, now = Date.now()) {
114
+ const kept = [];
115
+ const groups = new Map();
116
+ for (const zone of withoutRepeatedZones(zones, now)) {
117
+ if (zone.requestId !== undefined || zone.downloadOnly || (Number.isSafeInteger(zone.order) && zone.order > 0)) {
118
+ kept.push(zone);
119
+ continue;
120
+ }
121
+ const urgency = levelOfMapZone(zone);
122
+ const priority = priorityOfMapZone(zone);
123
+ const key = `${urgency}:${priority}`;
124
+ let group = groups.get(key);
125
+ if (!group) groups.set(key, group = { urgency, priority, parts: [] });
126
+ group.parts.push({ start: zone.byteStart, end: zone.byteEnd, deadlineAt: deadlineOfMapZone(zone, now) });
127
+ }
128
+ for (const { urgency, priority, parts } of groups.values()) {
129
+ parts.sort((left, right) => left.start - right.start);
130
+ let window = null;
131
+ const close = () => {
132
+ if (!window) return;
133
+ kept.push({ byteStart: window.start, byteEnd: window.end, priority, urgent: urgency === Urgency.NEAR,
134
+ behind: urgency === Urgency.BEHIND, deferred: urgency === Urgency.TAIL,
135
+ ...(Number.isFinite(window.deadlineAt) ? { deadlineAt: window.deadlineAt } : {}) });
136
+ };
137
+ for (const part of parts) {
138
+ if (window && part.start <= window.end + 1) {
139
+ window.end = Math.max(window.end, part.end);
140
+ window.deadlineAt = Math.min(window.deadlineAt, part.deadlineAt);
141
+ } else {
142
+ close();
143
+ window = { ...part };
144
+ }
145
+ }
146
+ close();
147
+ }
148
+ return kept;
91
149
  }
@@ -18,12 +18,17 @@ export class DownloadMaps {
18
18
  * thread's receipt included (torrent-tv/meta#177).
19
19
  */
20
20
  #clock;
21
+ /** What the torrent is told of the playback zones: as many windows as say the same thing. */
22
+ #pack;
23
+ /** What a list of zones says each segment reads, by the list. @type {WeakMap<object[], Map>} */
24
+ #inputsOf = new WeakMap();
21
25
 
22
26
  constructor({ publish, resolvePlayback = async map => map.zones, log = null, now = Date.now,
23
- clock = () => performance.timeOrigin + performance.now() }) {
27
+ clock = () => performance.timeOrigin + performance.now(), pack = zones => zones }) {
24
28
  if (typeof publish !== "function") throw new TypeError("Download map publication is required.");
25
29
  this.#publish = publish;
26
30
  this.#resolvePlayback = resolvePlayback;
31
+ this.#pack = pack;
27
32
  this.#log = typeof log === "function" ? log : null;
28
33
  this.#now = now;
29
34
  this.#clock = clock;
@@ -74,7 +79,8 @@ export class DownloadMaps {
74
79
  }
75
80
  if (changed) await this.#emit(file);
76
81
  if (revision !== file.revision) return;
77
- const isCurrent = () => this.#files.get(`${sourceKey}:${fileIndex}`) === file && revision === file.revision;
82
+ const fileKey = `${sourceKey}:${fileIndex}`;
83
+ const isCurrent = () => this.#files.get(fileKey) === file && revision === file.revision;
78
84
  // WHAT IS RESOLVED IS STATED WHILE THE REST IS STILL BEING RESOLVED. A pass
79
85
  // works the zones out in the order the swarm is to fetch them, and
80
86
  // published only at its end it kept the first bytes from the swarm for as
@@ -250,7 +256,16 @@ export class DownloadMaps {
250
256
  file.zones = zones;
251
257
  // What `#emit` reads has changed, whether or not it is published now.
252
258
  file.dirty = true;
259
+ // A pass that resolves what the pass before resolved hands back the same
260
+ // zones (`resolvedPasses` in server.js), and what they say of each segment
261
+ // is the same too.
262
+ const known = this.#inputsOf.get(zones);
263
+ if (known) {
264
+ file.inputs = known;
265
+ return;
266
+ }
253
267
  file.inputs = new Map();
268
+ this.#inputsOf.set(zones, file.inputs);
254
269
  for (const zone of zones) {
255
270
  if (typeof zone.outputKey !== "string" || !Number.isSafeInteger(zone.index)) continue;
256
271
  let output = file.inputs.get(zone.outputKey);
@@ -264,7 +279,9 @@ export class DownloadMaps {
264
279
  #emit(file) {
265
280
  file.dirty = false;
266
281
  const builtFrom = this.#clock();
267
- const zones = [...file.zones, ...file.nativeZones];
282
+ // Compared and sent as the torrent is to act on it: the playback zones
283
+ // packed into as few windows as say the same (`coalescedZones`).
284
+ const zones = [...this.#packedPlayback(file)];
268
285
  for (const { ranges, requestId, priority, urgent, deadlineAt, order } of file.metadata.values()) {
269
286
  for (const [byteStart, byteEnd] of ranges) {
270
287
  zones.push({ byteStart, byteEnd, priority, urgent, downloadOnly: true, requestId, deadlineAt, ...(order ? { order } : {}) });
@@ -333,6 +350,27 @@ export class DownloadMaps {
333
350
  return pending;
334
351
  }
335
352
 
353
+ /**
354
+ * The playback and native zones packed, worked out again only when they
355
+ * have changed. Both are replaced whole, never edited in place, so the arrays
356
+ * themselves say whether they have. A metadata statement used to pack every
357
+ * zone of the film again before it added its own few ranges: 27 s of the main
358
+ * thread in fifteen minutes of a two-hour AVI on Home Assistant, the thread
359
+ * that also sends the viewer's segments (r22, torrent-tv/meta#178).
360
+ *
361
+ * Not kept when a zone is wanted "within N seconds": packing turns that into
362
+ * an instant on the clock it is packed at.
363
+ */
364
+ #packedPlayback(file) {
365
+ const kept = file.packed;
366
+ if (kept && kept.zones === file.zones && kept.nativeZones === file.nativeZones) return kept.result;
367
+ const input = [...file.zones, ...file.nativeZones];
368
+ const result = this.#pack(input);
369
+ const relative = input.some(zone => !Number.isFinite(zone.deadlineAt) && Number.isFinite(zone.withinSeconds));
370
+ file.packed = relative ? null : { zones: file.zones, nativeZones: file.nativeZones, result };
371
+ return result;
372
+ }
373
+
336
374
  #statsOf(file) {
337
375
  file.stats ??= emptyStats(this.#now());
338
376
  return file.stats;
@@ -18,6 +18,40 @@
18
18
 
19
19
  import { emptyMap, mapForViewer, mergeMaps, runsOf, pauseCoefficient } from "./PriorityMap.js";
20
20
 
21
+ /**
22
+ * What a map built from these viewers depends on, as values to compare: the
23
+ * same values build the same map (`mapForViewer`, `mergeMaps`).
24
+ *
25
+ * A viewer's map is a function of the second they stand at, not of the moment
26
+ * it is asked — `mapForViewer` floors the position — and of the time they have
27
+ * paused only while that pause is lowering their priorities. A map built on
28
+ * every pass of the encoders' plan, which is every piece a soundtrack copy
29
+ * closes, was the same map 2.4 times a second (Home Assistant r22,
30
+ * torrent-tv/meta#178).
31
+ *
32
+ * @param {{ durationSeconds: number, allowanceSeconds: number, viewers: object[], viewerCount?: number }} params
33
+ * @returns {number[]}
34
+ */
35
+ export function mapInputsOf({ durationSeconds, allowanceSeconds, viewers, viewerCount }) {
36
+ const count = viewerCount ?? new Set((viewers ?? []).map((one, index) => one.id ?? index)).size;
37
+ const inputs = [Number(durationSeconds) || 0, Number(allowanceSeconds) || 0, count];
38
+ for (const viewer of viewers ?? []) {
39
+ const playing = viewer.playing !== false;
40
+ const urgentReady = viewer.urgentReady === true;
41
+ const at = Number.isFinite(viewer.atSeconds) && viewer.atSeconds > 0 ? Math.floor(viewer.atSeconds) : 0;
42
+ const attenuated = !playing && urgentReady && count > 1;
43
+ inputs.push(at, playing ? 1 : 0, urgentReady ? 1 : 0, attenuated ? Number(viewer.pauseSeconds) || 0 : 0);
44
+ }
45
+ return inputs;
46
+ }
47
+
48
+ /** Whether two lists hold the same values in the same order. */
49
+ function sameValues(left, right) {
50
+ if (!left || left.length !== right.length) return false;
51
+ for (let index = 0; index < left.length; index++) if (!Object.is(left[index], right[index])) return false;
52
+ return true;
53
+ }
54
+
21
55
  export class PriorityOrchestrator {
22
56
  /** Where the map goes once it is built. @type {(published: object) => void} */
23
57
  #publish;
@@ -57,6 +91,12 @@ export class PriorityOrchestrator {
57
91
  */
58
92
  #byOutput = new Map();
59
93
 
94
+ /** What the map of each file was built from, with its demands. @type {Map<string, { inputs: number[], demand: string }>} */
95
+ #builtFrom = new Map();
96
+
97
+ /** What the map of each output was built from. @type {Map<string, number[]>} */
98
+ #outputBuiltFrom = new Map();
99
+
60
100
  /** The registry of this component that knows who watches each output. @type {{ forOutput: (output: object) => Map<string, object> }} */
61
101
  #viewers;
62
102
 
@@ -155,8 +195,18 @@ export class PriorityOrchestrator {
155
195
  * film, merged over everyone watching it.
156
196
  */
157
197
  build({ sourceKey, fileIndex, durationSeconds, allowanceSeconds, viewers, demandKey = null }) {
158
- const map = this.#mapFrom({ durationSeconds, allowanceSeconds, viewers });
159
198
  const key = `${sourceKey}:${fileIndex}`;
199
+ // The same map and the same demands publish nothing new: neither is built
200
+ // again (`mapInputsOf`).
201
+ const inputs = mapInputsOf({ durationSeconds, allowanceSeconds, viewers });
202
+ const demand = JSON.stringify(demandKey);
203
+ const built = this.#builtFrom.get(key);
204
+ const kept = this.#maps.get(key);
205
+ if (kept && built?.demand === demand && sameValues(built.inputs, inputs)) {
206
+ return kept;
207
+ }
208
+ this.#builtFrom.set(key, { inputs, demand });
209
+ const map = this.#mapFrom({ durationSeconds, allowanceSeconds, viewers });
160
210
  this.#maps.set(key, map);
161
211
  // Unchanged maps are not republished: the downloading rebuilds what it asks
162
212
  // the swarm for on every one, and a viewer sitting still would otherwise
@@ -302,6 +352,7 @@ export class PriorityOrchestrator {
302
352
  const last = this.#last.get(key);
303
353
  this.#maps.delete(key);
304
354
  this.#last.delete(key);
355
+ this.#builtFrom.delete(key);
305
356
  if (last) {
306
357
  this.#publishNothingFor(last);
307
358
  }
@@ -310,6 +361,7 @@ export class PriorityOrchestrator {
310
361
  for (const address of [...this.#byOutput.keys()]) {
311
362
  if (!byOutput.has(address)) {
312
363
  this.#byOutput.delete(address);
364
+ this.#outputBuiltFrom.delete(address);
313
365
  }
314
366
  }
315
367
  for (const one of byFile.values()) {
@@ -326,6 +378,9 @@ export class PriorityOrchestrator {
326
378
  for (const [address, one] of byOutput) {
327
379
  // Competition is per file, even when two viewers use different outputs.
328
380
  one.viewerCount = new Set((byFile.get(one.fileKey)?.viewers ?? []).map((viewer, index) => viewer.id ?? index)).size;
381
+ const inputs = mapInputsOf(one);
382
+ if (this.#byOutput.has(address) && sameValues(this.#outputBuiltFrom.get(address), inputs)) continue;
383
+ this.#outputBuiltFrom.set(address, inputs);
329
384
  this.#byOutput.set(address, this.#mapFrom(one));
330
385
  }
331
386
  }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * @file What one pass that turns a file's playback map into bytes is worked out
3
+ * from, so a pass whose inputs have not changed hands back what the last one
4
+ * resolved (torrent-tv/meta#178).
5
+ *
6
+ * The viewer's map moves a second at a time while segments are seconds long,
7
+ * so most passes give the zones the pass before gave: on Home Assistant 30–110
8
+ * of 50–120 download maps a minute came out unchanged, and working them out
9
+ * took 72 s of the main thread in fifteen minutes (r22). The deadlines are left
10
+ * out of what is compared and their order is kept in it: a segment's deadline
11
+ * is when the viewer reaches it, which moves only when they stop or seek, and
12
+ * what is sent to the torrent is compared by the deadlines' order alone
13
+ * (`DownloadMaps` `shapeOf`).
14
+ */
15
+
16
+ /**
17
+ * @param {object} params
18
+ * @param {number} params.pieceLength - The precision the torrent fetches at.
19
+ * @param {number} params.shift - Where the file's own clock starts.
20
+ * @param {Map<string, number | null>} params.targets - Each output's next input, by output.
21
+ * @param {Array<{ outputKey: string, index: number, priority: number, urgent?: boolean,
22
+ * behind?: boolean, deferred?: boolean, from: number, to: number,
23
+ * sourceInterval?: { from: number, to: number } }>} params.demands - In the
24
+ * order of their deadlines.
25
+ * @returns {{ outputs: string[], inputs: number[] }}
26
+ */
27
+ export function passInputs({ pieceLength, shift, targets, demands }) {
28
+ const outputs = [...targets.keys()];
29
+ const inputs = [pieceLength, shift];
30
+ for (const target of targets.values()) inputs.push(target ?? -1);
31
+ for (const demand of demands) {
32
+ // The segment's own interval too: a cut grid read again can give one
33
+ // 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);
37
+ }
38
+ return { outputs, inputs };
39
+ }
40
+
41
+ /**
42
+ * Whether a pass is worked out from what an earlier one was.
43
+ *
44
+ * @param {{ tracks: unknown, outputs: string[], inputs: number[] } | undefined} last
45
+ * @param {{ tracks: unknown, outputs: string[], inputs: number[] }} next
46
+ * @returns {boolean}
47
+ */
48
+ export function samePass(last, next) {
49
+ return Boolean(last) && last.tracks === next.tracks &&
50
+ sameValues(last.outputs, next.outputs) && sameValues(last.inputs, next.inputs);
51
+ }
52
+
53
+ /** Whether two lists hold the same values in the same order. */
54
+ function sameValues(left, right) {
55
+ if (left.length !== right.length) return false;
56
+ for (let index = 0; index < left.length; index++) if (!Object.is(left[index], right[index])) return false;
57
+ return true;
58
+ }