@torrent-tv/proxy 2.97.27 → 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.27",
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
@@ -61,6 +61,7 @@ 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
@@ -469,6 +471,11 @@ export async function startProxyServer({
469
471
  byIndex.set(zone.index, { trackList, shift, choicesKey, from: zone.sourceInterval.from, to: zone.sourceInterval.to, reading });
470
472
  return reading;
471
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();
472
479
  /** A segment's ranges joined at the precision the torrent fetches, once per kept list and piece length. */
473
480
  const joinedRanges = new WeakMap();
474
481
  const joinedRangesOf = (ranges, pieceLength) => {
@@ -538,6 +545,17 @@ export async function startProxyServer({
538
545
  }
539
546
  }
540
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;
541
559
  // A container that names the bytes of an interval from an index it holds
542
560
  // answers every segment by arithmetic: once that index is read
543
561
  // ("source-navigation"), the ranges are computed here directly instead of
@@ -580,7 +598,7 @@ export async function startProxyServer({
580
598
  // were never asked for (Home Assistant 2026-10-08, torrent-tv/meta#151).
581
599
  const known = segmentSourceRanges.get(rangesKey);
582
600
  if (known) { convert(known); continue; }
583
- if (waiting.has(zone.outputKey)) continue;
601
+ if (waiting.has(zone.outputKey)) { complete = false; continue; }
584
602
  // What is resolved so far is stated before a lower zone is worked out
585
603
  // (DownloadMaps, torrent-tv/meta#178).
586
604
  map.workingOut?.(zone.priority, expanded);
@@ -613,9 +631,10 @@ export async function startProxyServer({
613
631
  requestId: `download:${map.sourceKey}:${map.fileIndex}:${zone.outputKey}:${zone.index}`,
614
632
  demand: { ...zone.owner, leadSeconds: zone.leadSeconds } });
615
633
  if (!map.isCurrent()) return [];
616
- if (!intervalParams) continue;
634
+ if (!intervalParams) { complete = false; continue; }
617
635
  const navigation = await containerOrchestrator.inspect(intervalParams, "source-navigation");
618
636
  if (navigation.kind !== "result") {
637
+ complete = false;
619
638
  if (navigation.kind === "needs-ranges" || navigation.kind === "needs-memory") waiting.add(zone.outputKey);
620
639
  continue;
621
640
  }
@@ -623,13 +642,16 @@ export async function startProxyServer({
623
642
  const packets = await containerOrchestrator.inspect(intervalParams, originalRanges ? "source-ranges" : "packets");
624
643
  if (!map.isCurrent()) return [];
625
644
  if (packets.kind === "needs-memory") waiting.add(zone.outputKey);
626
- if (packets.kind !== "result") continue;
645
+ if (packets.kind !== "result") { complete = false; continue; }
627
646
  const input = originalRanges ? packets.value : new SegmentInputs({ index: packets.value, tracks: wanted }).forInterval({ ...interval, mode: track => modes.get(track) });
628
- if (input.kind !== "result") continue;
647
+ if (input.kind !== "result") { complete = false; continue; }
629
648
  segmentSourceRanges.set(rangesKey, input.ranges);
630
649
  convert(input.ranges);
631
650
  }
632
- return expanded();
651
+ const result = expanded();
652
+ if (complete) resolvedPasses.set(fileKey, { ...pass, result });
653
+ else resolvedPasses.delete(fileKey);
654
+ return result;
633
655
  }
634
656
  });
635
657
  const mediaReads = new MediaReadRequests({
@@ -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;
@@ -8,7 +8,10 @@ const BLOCK_BYTES = 16 * 1024;
8
8
  /** Estimate completion from observed queues and the public selection bands.
9
9
  * The native picker takes a band's pieces from its first up (the `sequential`
10
10
  * strategy), so an unqueued piece arrives after the band's pieces before it.
11
- * Pieces without a measured supplier remain unknown. No requests are changed.
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).
@@ -107,8 +124,20 @@ export async function futureDownload(entries, { now = Date.now(), findStore = pi
107
124
  let finish = now;
108
125
  for (const { arrivals, index, seconds } of pending) {
109
126
  finish = finish === null || seconds === null ? null : finish + seconds * 1000;
110
- const queuedAt = arrivals.get(index);
111
- if (finish !== null) arrivals.set(index, queuedAt === null ? finish : Math.min(queuedAt, finish));
127
+ if (finish !== null) arrivals.set(index, finish);
112
128
  }
113
129
  return isCurrent() ? forecasts : new Map();
114
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
+ }
@@ -20,6 +20,8 @@ export class DownloadMaps {
20
20
  #clock;
21
21
  /** What the torrent is told of the playback zones: as many windows as say the same thing. */
22
22
  #pack;
23
+ /** What a list of zones says each segment reads, by the list. @type {WeakMap<object[], Map>} */
24
+ #inputsOf = new WeakMap();
23
25
 
24
26
  constructor({ publish, resolvePlayback = async map => map.zones, log = null, now = Date.now,
25
27
  clock = () => performance.timeOrigin + performance.now(), pack = zones => zones }) {
@@ -254,7 +256,16 @@ export class DownloadMaps {
254
256
  file.zones = zones;
255
257
  // What `#emit` reads has changed, whether or not it is published now.
256
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
+ }
257
267
  file.inputs = new Map();
268
+ this.#inputsOf.set(zones, file.inputs);
258
269
  for (const zone of zones) {
259
270
  if (typeof zone.outputKey !== "string" || !Number.isSafeInteger(zone.index)) continue;
260
271
  let output = file.inputs.get(zone.outputKey);
@@ -270,7 +281,7 @@ export class DownloadMaps {
270
281
  const builtFrom = this.#clock();
271
282
  // Compared and sent as the torrent is to act on it: the playback zones
272
283
  // packed into as few windows as say the same (`coalescedZones`).
273
- const zones = this.#pack([...file.zones, ...file.nativeZones]);
284
+ const zones = [...this.#packedPlayback(file)];
274
285
  for (const { ranges, requestId, priority, urgent, deadlineAt, order } of file.metadata.values()) {
275
286
  for (const [byteStart, byteEnd] of ranges) {
276
287
  zones.push({ byteStart, byteEnd, priority, urgent, downloadOnly: true, requestId, deadlineAt, ...(order ? { order } : {}) });
@@ -339,6 +350,27 @@ export class DownloadMaps {
339
350
  return pending;
340
351
  }
341
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
+
342
374
  #statsOf(file) {
343
375
  file.stats ??= emptyStats(this.#now());
344
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
+ }