@torrent-tv/proxy 2.98.3 → 2.98.5

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.3",
3
+ "version": "2.98.5",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "repository": {
@@ -83,6 +83,10 @@ export async function handleApiTranscodeSessionNetReportPost(req, reply,
83
83
  // by an output of the same quality or better that is already made.
84
84
  const qualityMode = body.qualityMode === "auto" || body.qualityMode === "manual" ? body.qualityMode : undefined;
85
85
  const positionSeconds = Number(body.positionSeconds);
86
+ // Which of this viewer's viewings the page made the report in; a page one
87
+ // release behind does not say (`Viewer.isOfAnEarlierViewing`).
88
+ const stated = Number(body.generation);
89
+ const generation = Number.isInteger(stated) ? stated : undefined;
86
90
  // The picture as the viewer sees it, in physical pixels: the upper bound of
87
91
  // the height of a re-encoded output made for them (roadmap item 98). Sent the
88
92
  // moment it changes; a page that does not say leaves the size as it was.
@@ -94,12 +98,10 @@ export async function handleApiTranscodeSessionNetReportPost(req, reply,
94
98
  // to the rung on screen. A page that does not say leaves the rung as it was.
95
99
  const playingHeight = Number(body.playingHeight);
96
100
  if (Number.isInteger(playingHeight) && playingHeight > 0) {
97
- renditions.viewerPlays(
98
- sessionId,
99
- consumerId,
100
- playingHeight,
101
- Number.isFinite(positionSeconds) && positionSeconds >= 0 ? positionSeconds : undefined
102
- );
101
+ // Where they stood when they said it, unless they have seeked since.
102
+ const placed = Number.isFinite(positionSeconds) && positionSeconds >= 0 &&
103
+ !viewers.get?.(consumerId)?.isOfAnEarlierViewing(generation);
104
+ renditions.viewerPlays(sessionId, consumerId, playingHeight, placed ? positionSeconds : undefined);
103
105
  }
104
106
  // Where they stood and whether they were playing or waiting, before this
105
107
  // report: what the encoders are planned from (`movedForThePlan`).
@@ -126,7 +128,8 @@ export async function handleApiTranscodeSessionNetReportPost(req, reply,
126
128
  qualityMode,
127
129
  visiblePicture,
128
130
  positionSeconds:
129
- Number.isFinite(positionSeconds) && positionSeconds >= 0 ? positionSeconds : undefined
131
+ Number.isFinite(positionSeconds) && positionSeconds >= 0 ? positionSeconds : undefined,
132
+ generation
130
133
  }
131
134
  });
132
135
  if (!recorded) {
package/server.js CHANGED
@@ -16,8 +16,8 @@ import { handleMediaGet } from "./routes/media/get.js";
16
16
  import { ProbeRequests } from "./services/media/ProbeRequests.js";
17
17
  import { IndexMemory } from "./services/storage/IndexMemory.js";
18
18
  import { probePackets } from "./services/media/probe-packets.js";
19
- import { segmentDemands, sourceDeadline } from "./services/viewer/segment-demands.js";
20
- import { expandToTargetInputs } from "./services/viewer/target-input-expansion.js";
19
+ import { trackChoicesOf } from "./services/viewer/segment-demands.js";
20
+ import { PlaybackProjection } from "./services/torrent/PlaybackProjection.js";
21
21
  import { SegmentInputTable } from "./services/viewer/segment-input-table.js";
22
22
  import { SourcePreparation } from "./services/viewer/SourcePreparation.js";
23
23
  import { handleHealthzGet } from "./routes/healthz/get.js";
@@ -62,7 +62,6 @@ import { SubtitleOrchestrator } from "./services/media/SubtitleOrchestrator.js";
62
62
  import { containerOrchestrator, CONTAINER_HEAD_BYTES, describeWorkTags } from "./services/media/ContainerOrchestrator.js";
63
63
  import { readPlaybackDeclarations } from "./services/media/read-playback-declarations.js";
64
64
  import { DownloadMaps } from "./services/viewer/DownloadMaps.js";
65
- import { passInputs, samePass } from "./services/viewer/playback-pass.js";
66
65
  import { nativeSourceMap, nativeOriginalSourceMap } from "./services/viewer/NativeSourceMap.js";
67
66
  import { MediaReadRequests } from "./services/media/MediaReadRequests.js";
68
67
  import { SegmentInputs } from "./services/media/SegmentInputs.js";
@@ -188,7 +187,8 @@ export async function startProxyServer({
188
187
  downloadMaps.retire(sourceKey);
189
188
  for (const key of segmentSourceRanges.keys()) if (key.startsWith(`${sourceKey}:`)) segmentSourceRanges.delete(key);
190
189
  for (const key of segmentReadings.keys()) if (key.startsWith(`${sourceKey}:`)) segmentReadings.delete(key);
191
- for (const key of resolvedPasses.keys()) if (key.startsWith(`${sourceKey}:`)) resolvedPasses.delete(key);
190
+ for (const key of playbackProjections.keys()) if (key.startsWith(`${sourceKey}:`)) playbackProjections.delete(key);
191
+ for (const key of madeRates.keys()) if (key.startsWith(`${sourceKey}:`)) madeRates.delete(key);
192
192
  segmentInputs.forget(sourceKey);
193
193
  outputParts.encodeInputs?.bytesChanged();
194
194
  },
@@ -487,37 +487,67 @@ export async function startProxyServer({
487
487
  records.set(demand.index, record);
488
488
  return record;
489
489
  };
490
- /**
491
- * The last complete pass over each file, by file: what it was worked out
492
- * from and what it resolved (`playback-pass.js`); forgotten with the source.
493
- */
494
- const resolvedPasses = new Map();
490
+ /** What the torrent is told of each played file, kept from map to map (`PlaybackProjection`); forgotten with the source. */
491
+ const playbackProjections = new Map();
495
492
  /** Which source bytes each segment of an output reads, for the forecast (`segment-input-table.js`). */
496
493
  const segmentInputs = new SegmentInputTable();
497
- // WHAT THE PASSES COST, said once a minute: how many were taken whole from
498
- // the last one and how many worked out again, how many segments and ranges
499
- // those went through, how many zones they built, and the time from a pass's
500
- // start to its result — which includes waits for reads, so it bounds the
501
- // main thread's share rather than measuring it. A pass worked out again
502
- // whose zones state the same bytes as the last is the repeated work worth
503
- // removing; how often that happens is what the map's own "unchanged not
504
- // sent" count says (torrent-tv/meta#178 review).
505
- const passCosts = { since: Date.now(), reused: 0, worked: 0, incomplete: 0, segments: 0, ranges: 0, zones: 0, built: 0, buildMs: 0, ms: 0, maxMs: 0 };
506
- const notePass = ({ reused = false, complete = true, segments = 0, ranges = 0, zones = 0, ms = 0 }) => {
507
- if (reused) passCosts.reused += 1;
508
- else {
509
- passCosts.worked += 1;
510
- if (!complete) passCosts.incomplete += 1;
511
- passCosts.segments += segments; passCosts.ranges += ranges; passCosts.zones += zones;
512
- passCosts.ms += ms; passCosts.maxMs = Math.max(passCosts.maxMs, ms);
494
+ /** An output's cut grid, checked once for each timeline. */
495
+ const checkedGrids = new WeakMap();
496
+ const gridOf = (output) => {
497
+ const timeline = output.timeline;
498
+ if (!timeline || typeof timeline !== "object") return null;
499
+ if (checkedGrids.has(timeline)) return checkedGrids.get(timeline);
500
+ const grid = timeline.published ?? timeline.boundaries;
501
+ if (!Array.isArray(grid)) {
502
+ checkedGrids.set(timeline, null);
503
+ return null;
513
504
  }
505
+ for (let index = 0; index + 1 < grid.length; index++) {
506
+ if (!Number.isFinite(grid[index]) || !Number.isFinite(grid[index + 1]) || !(grid[index + 1] > grid[index])) {
507
+ throw new Error("An output segment needs a valid interval.");
508
+ }
509
+ }
510
+ checkedGrids.set(timeline, grid);
511
+ return grid;
512
+ };
513
+ /**
514
+ * How many bytes a second of an output's film takes, from what it has made,
515
+ * read again only when what it has made changes (`SegmentStore.revisionOf`):
516
+ * summed over every made segment on every pass, it was a walk of the whole
517
+ * film a second (torrent-tv/meta#178).
518
+ */
519
+ const madeRates = new Map();
520
+ const bytesPerSecondOf = (output, revision) => {
521
+ const kept = madeRates.get(output.outputKey);
522
+ if (kept && kept.revision === revision && kept.timeline === output.timeline) return kept.bytesPerSecond;
523
+ let bytes = 0, seconds = 0;
524
+ for (const [index, size] of outputParts.segmentStore.sizesOf(output.outputKey)) {
525
+ const span = output.timeline.publishedStartOf(index + 1) - output.timeline.publishedStartOf(index);
526
+ if (span > 0) { bytes += size; seconds += span; }
527
+ }
528
+ const bytesPerSecond = seconds > 0 ? bytes / seconds : null;
529
+ madeRates.set(output.outputKey, { revision, timeline: output.timeline, bytesPerSecond });
530
+ return bytesPerSecond;
531
+ };
532
+ // WHAT THE PASSES COST, said once a minute: how many maps were taken, how
533
+ // many segments a map made the projection work out again and how many of
534
+ // those maps it worked out whole, how many segments had their ranges worked
535
+ // out, the windows told, and the time from a pass's start to its result —
536
+ // which includes waits for reads, so it bounds the main thread's share
537
+ // rather than measuring it (torrent-tv/meta#178).
538
+ const passCosts = { since: Date.now(), passes: 0, whole: 0, reworked: 0, resolved: 0, windows: 0, ms: 0, maxMs: 0 };
539
+ const notePass = ({ whole = false, reworked = 0, resolved = 0, windows = 0, ms = 0 }) => {
540
+ passCosts.passes += 1;
541
+ if (whole) passCosts.whole += 1;
542
+ passCosts.reworked += reworked; passCosts.resolved += resolved; passCosts.windows += windows;
543
+ passCosts.ms += ms; passCosts.maxMs = Math.max(passCosts.maxMs, ms);
514
544
  const now = Date.now();
515
545
  if (now - passCosts.since < 60_000) return;
516
- logger.info(`download map passes in ${((now - passCosts.since) / 1000).toFixed(1)}s: ${passCosts.reused} taken whole from the last, ` +
517
- `${passCosts.worked} worked out (${passCosts.incomplete} incomplete) over ${passCosts.segments} segment(s) and ${passCosts.ranges} range(s), ` +
518
- `giving ${passCosts.zones} zone(s); ${Math.round(passCosts.ms)}ms from start to result in all, longest ${Math.round(passCosts.maxMs)}ms; ` +
519
- `${passCosts.built} zone list(s) built for the map in ${Math.round(passCosts.buildMs)}ms`);
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 });
546
+ logger.info(`download map passes in ${((now - passCosts.since) / 1000).toFixed(1)}s: ${passCosts.passes} map(s), ` +
547
+ `${passCosts.whole} worked out whole, ${passCosts.reworked} segment(s) worked out again, ` +
548
+ `${passCosts.resolved} segment(s) had their ranges worked out, ${passCosts.windows} window(s) told; ` +
549
+ `${Math.round(passCosts.ms)}ms from start to result in all, longest ${Math.round(passCosts.maxMs)}ms`);
550
+ Object.assign(passCosts, { since: now, passes: 0, whole: 0, reworked: 0, resolved: 0, windows: 0, ms: 0, maxMs: 0 });
521
551
  };
522
552
  const downloadMaps = new DownloadMaps({
523
553
  // The ranges a picture reads at open are named again by every segment;
@@ -530,11 +560,16 @@ export async function startProxyServer({
530
560
  publish: (map) => torrentPool.setPriorityMap({ ...map, zones: withoutRepeatedZones(map.zones) }),
531
561
  // And what is sent is packed: a stretch of one level and priority is one
532
562
  // window, not one zone per range of each of its segments
533
- // (`coalescedZones`, torrent-tv/meta#178).
563
+ // (`coalescedZones`, torrent-tv/meta#178). The playback zones come packed
564
+ // already from the projection; this packs them with the native ones.
534
565
  pack: (zones) => coalescedZones(zones),
535
566
  log: (line) => logger.info(line),
536
567
  resolvePlayback: async (map) => {
537
- if (map.zones.length === 0) return [];
568
+ const fileKey = `${map.sourceKey}:${map.fileIndex}`;
569
+ if (map.zones.length === 0) {
570
+ playbackProjections.get(fileKey)?.clear();
571
+ return [];
572
+ }
538
573
  const params = await containerOver(map);
539
574
  if (!params || !map.isCurrent()) return [];
540
575
  const tracks = await containerOrchestrator.inspect(params, "tracks");
@@ -548,67 +583,47 @@ export async function startProxyServer({
548
583
  // ranges closer than a piece hold the same pieces as one.
549
584
  const pieceLength = Number(torrentPool.knownTorrent(map.sourceKey)?.pieceLength);
550
585
  const passStartedAt = performance.now();
551
- let rangeCount = 0;
552
- const converted = [];
553
- const demands = [];
554
- // What each output's next run would copy, asked of the swarm as one group
555
- // with its first segment (`target-input-expansion.js`).
556
- const targets = new Map();
557
- const expanded = () => {
558
- const startedAt = performance.now();
559
- const zones = expandToTargetInputs(converted, outputKey => targets.get(outputKey) ?? null);
560
- passCosts.built += 1;
561
- passCosts.buildMs += performance.now() - startedAt;
562
- return zones;
563
- };
586
+ // THE PROJECTION IS KEPT while what it was made from stands: the file's
587
+ // tracks, where its clock starts and how the torrent fetches. Any of them
588
+ // changed, every segment's ranges are other ranges.
589
+ let projection = playbackProjections.get(fileKey);
590
+ if (!projection || projection.trackList !== tracks.value || projection.shift !== shift ||
591
+ !Object.is(projection.pieceLength, pieceLength)) {
592
+ projection?.clear();
593
+ projection = new PlaybackProjection();
594
+ Object.assign(projection, { trackList: tracks.value, shift, pieceLength });
595
+ playbackProjections.set(fileKey, projection);
596
+ }
597
+ // Every output of this file somebody is watching, with what decides how
598
+ // long its segments take to make and deliver, what it has made, and what
599
+ // its next run would copy.
600
+ const played = [];
601
+ /** By output: what its segments read and how they are looked up. */
602
+ const readers = new Map();
564
603
  for (const output of outputParts.outputs.values()) {
565
604
  if (output.file.sourceKey !== map.sourceKey) continue;
566
605
  const consumers = [...outputParts.viewers.forOutput(output).values()].filter(viewer =>
567
606
  viewer.isPresent() && !outputParts.outputs.supersededBy(output, viewer.activeVariantId ?? null));
568
607
  if (consumers.length === 0) continue;
569
- targets.set(output.outputKey, outputParts.encodeInputs?.targetInputBytes?.(output) ?? null);
570
- const inventory = outputParts.segmentStore.sizesOf(output.outputKey);
571
- let bytes = 0, seconds = 0;
572
- for (const [index, size] of inventory) {
573
- const span = output.timeline.publishedStartOf(index + 1) - output.timeline.publishedStartOf(index);
574
- if (span > 0) { bytes += size; seconds += span; }
575
- }
608
+ const choices = trackChoicesOf(output, map.fileIndex);
609
+ if (choices.length === 0) continue;
610
+ const grid = gridOf(output);
611
+ if (!grid) continue;
612
+ const revision = outputParts.segmentStore.revisionOf(output.outputKey);
576
613
  const links = consumers.map(viewer => viewer.linkReading()?.linkMbps)
577
614
  .filter(value => Number.isFinite(value) && value > 0);
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.
584
- for (const demand of segmentDemands(output, map.fileIndex, map.zones)) {
585
- if (inventory.has(demand.index)) continue;
586
- const deadlineAt = sourceDeadline(demand, { encodeSpeed,
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);
595
- }
615
+ played.push({ outputKey: output.outputKey, grid,
616
+ encodeSpeed: outputParts.encodeCost.latestSpeedReadingOf(output)?.speed,
617
+ bytesPerSecond: bytesPerSecondOf(output, revision), linkMbps: links.length ? Math.min(...links) : null,
618
+ readyRevision: revision, ready: () => outputParts.segmentStore.sizesOf(output.outputKey),
619
+ target: outputParts.encodeInputs?.targetInputBytes?.(output) ?? null });
620
+ readers.set(output.outputKey, { records: segmentRecordsOf(map, output), choices,
621
+ facts: { timeline: output.timeline, trackList: tracks.value, shift, map } });
596
622
  }
597
- demands.sort((left, right) => left.deadlineAt - right.deadlineAt || right.priority - left.priority || left.index - right.index);
598
- // WHAT THIS PASS IS WORKED OUT FROM, compared with the last complete pass
599
- // over the file (`resolvedPasses`): the outputs and their targets, how the
600
- // torrent fetches and where the file's clock starts, and every segment
601
- // asked for, in the order of its deadline, with its level and priority.
602
- const fileKey = `${map.sourceKey}:${map.fileIndex}`;
603
- const pass = { tracks: tracks.value, ...passInputs({ pieceLength, shift, targets, demands }) };
604
- const last = resolvedPasses.get(fileKey);
605
- if (samePass(last, pass)) {
606
- notePass({ reused: true });
607
- return last.result;
608
- }
609
- // Kept only from a pass that worked every segment out; one that met a
610
- // reading not there yet is worked out again until it is.
611
- let complete = true;
623
+ // WHAT THE MAP CHANGED, and only that, is worked out again here.
624
+ projection.update({ zones: map.zones, outputs: played });
625
+ const reworked = projection.lastUpdate;
626
+ let resolved = 0;
612
627
  // A container that names the bytes of an interval from an index it holds
613
628
  // answers every segment by arithmetic: once that index is read
614
629
  // ("source-navigation"), the ranges are computed here directly instead of
@@ -628,22 +643,21 @@ export async function startProxyServer({
628
643
  // two-hour AVI (torrent-tv/meta#178).
629
644
  const waiting = new Set();
630
645
  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
- };
638
- for (const zone of demands) {
646
+ // ONLY THE SEGMENTS WHOSE RANGES ARE NOT KNOWN are worked out, in the
647
+ // order the swarm is to fetch them.
648
+ for (const zone of projection.unresolved()) {
639
649
  if (!map.isCurrent()) return [];
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
643
- // Assistant (torrent-tv/meta#178).
644
- const { record } = zone;
650
+ const reader = readers.get(zone.outputKey);
651
+ if (!reader) continue;
652
+ // WHAT A SEGMENT READS IS WORKED OUT ONCE (`segmentRecordOf`). Rebuilt
653
+ // for every segment of the film on every map, this pass held the main
654
+ // thread up to 1.5 s at a time on Home Assistant (torrent-tv/meta#178).
655
+ const record = segmentRecordOf(reader.records, { index: zone.index, tracks: reader.choices }, reader.facts);
645
656
  const reading = record.reading;
646
- if (!reading) continue;
657
+ if (!reading) {
658
+ projection.setUnreadable(zone.outputKey, zone.index);
659
+ continue;
660
+ }
647
661
  const { wanted, modes, interval, rangesKey } = reading;
648
662
  const convert = ranges => {
649
663
  if (record.ranges !== ranges || record.pieceLength !== pieceLength) {
@@ -652,23 +666,14 @@ export async function startProxyServer({
652
666
  record.joined = joinedWithinPieces(ranges, pieceLength);
653
667
  }
654
668
  const joined = record.joined;
655
- rangeCount += joined.length;
656
669
  // What the segment reads is kept for the forecast in a table of its
657
670
  // own, whatever form the torrent is told it in.
658
671
  if (record.registered !== joined) {
659
672
  segmentInputs.set(map.sourceKey, map.fileIndex, zone.outputKey, zone.index, joined);
660
673
  record.registered = joined;
661
674
  }
662
- // ONE ZONE FOR THE SEGMENT, its demand stated once for all the
663
- // ranges it reads. The demand — level, priority, deadline, the map
664
- // zone it belongs to — is this pass's; the ranges are the list kept
665
- // for the segment, unchanged from pass to pass. A zone for each
666
- // range copied the demand into every one: ten thousand objects a
667
- // pass over a two-hour AVI, made again 21-61 times a minute on Home
668
- // Assistant (r26, torrent-tv/meta#178).
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 });
675
+ projection.setRanges(zone.outputKey, zone.index, joined);
676
+ resolved += 1;
672
677
  };
673
678
  // The bytes one segment needs are a fact of the file and the output, so
674
679
  // they are worked out once. A whole film is thousands of segments and
@@ -677,10 +682,10 @@ export async function startProxyServer({
677
682
  // were never asked for (Home Assistant 2026-10-08, torrent-tv/meta#151).
678
683
  const known = record.ranges ?? segmentSourceRanges.get(rangesKey);
679
684
  if (known) { convert(known); continue; }
680
- if (waiting.has(zone.outputKey)) { complete = false; continue; }
685
+ if (waiting.has(zone.outputKey)) continue;
681
686
  // What is resolved so far is stated before a lower zone is worked out
682
687
  // (DownloadMaps, torrent-tv/meta#178).
683
- map.workingOut?.(zone.priority, expanded);
688
+ map.workingOut?.(zone.priority, () => projection.windows());
684
689
  if (direct) {
685
690
  // Asked per zone too: a soundtrack of an AVI is read from its own
686
691
  // packets, which the same index states (torrent-tv/meta#166).
@@ -710,10 +715,9 @@ export async function startProxyServer({
710
715
  requestId: `download:${map.sourceKey}:${map.fileIndex}:${zone.outputKey}:${zone.index}`,
711
716
  demand: { ...zone.owner, leadSeconds: zone.leadSeconds } });
712
717
  if (!map.isCurrent()) return [];
713
- if (!intervalParams) { complete = false; continue; }
718
+ if (!intervalParams) continue;
714
719
  const navigation = await containerOrchestrator.inspect(intervalParams, "source-navigation");
715
720
  if (navigation.kind !== "result") {
716
- complete = false;
717
721
  if (navigation.kind === "needs-ranges" || navigation.kind === "needs-memory") waiting.add(zone.outputKey);
718
722
  continue;
719
723
  }
@@ -721,16 +725,15 @@ export async function startProxyServer({
721
725
  const packets = await containerOrchestrator.inspect(intervalParams, originalRanges ? "source-ranges" : "packets");
722
726
  if (!map.isCurrent()) return [];
723
727
  if (packets.kind === "needs-memory") waiting.add(zone.outputKey);
724
- if (packets.kind !== "result") { complete = false; continue; }
728
+ if (packets.kind !== "result") continue;
725
729
  const input = originalRanges ? packets.value : new SegmentInputs({ index: packets.value, tracks: wanted }).forInterval({ ...interval, mode: track => modes.get(track) });
726
- if (input.kind !== "result") { complete = false; continue; }
730
+ if (input.kind !== "result") continue;
727
731
  segmentSourceRanges.set(rangesKey, input.ranges);
728
732
  convert(input.ranges);
729
733
  }
730
- const result = expanded();
731
- notePass({ complete, segments: converted.length, ranges: rangeCount, zones: result.length, ms: performance.now() - passStartedAt });
732
- if (complete) resolvedPasses.set(fileKey, { ...pass, result });
733
- else resolvedPasses.delete(fileKey);
734
+ const result = projection.windows();
735
+ notePass({ whole: reworked.whole, reworked: reworked.segments, resolved, windows: result.length,
736
+ ms: performance.now() - passStartedAt });
734
737
  return result;
735
738
  }
736
739
  });
@@ -1001,6 +1004,7 @@ export async function startProxyServer({
1001
1004
  mediaReads.retain(sourceKey, fileIndex, params => params.requestId?.startsWith("prepare:"));
1002
1005
  if (!outputParts.viewers.forSource(sourceKey).length) probeReads.forget(sourceKey, fileIndex);
1003
1006
  await downloadMaps.forget(sourceKey, fileIndex, { keepPreparation: true });
1007
+ playbackProjections.get(`${sourceKey}:${fileIndex}`)?.clear();
1004
1008
  return;
1005
1009
  }
1006
1010
  await downloadMaps.playback({ sourceKey, fileIndex, durationSeconds, zones });
@@ -307,7 +307,7 @@ export class EncodeInputs {
307
307
  * What the next run of this output would be given to copy, whatever has
308
308
  * arrived: the stretch `stretchFor` sizes, within the memory encoder inputs
309
309
  * may hold. The download map asks the swarm for that much as one group
310
- * (`viewer/target-input-expansion.js`); a stretch shortened to what is here
310
+ * (`torrent/PlaybackProjection.js`); a stretch shortened to what is here
311
311
  * would ask only for what is here. Null while it is not known.
312
312
  *
313
313
  * @param {object} output
@@ -139,6 +139,9 @@ export class SegmentStore {
139
139
  */
140
140
  #inits = new Map();
141
141
 
142
+ /** The last number handed out by `revisionOf`'s readings. */
143
+ #revisions = 0;
144
+
142
145
  /** Told of every segment published, whatever it is. @type {Set<(key: string, index: number) => void>} */
143
146
  #publishedListeners = new Set();
144
147
 
@@ -332,7 +335,8 @@ export class SegmentStore {
332
335
  this.#held.delete(key);
333
336
  return empty;
334
337
  }
335
- const contents = { readAt: mtime, byNumber, sizes, bytes, unproven: highest, largest, followed: watched };
338
+ const contents = { readAt: mtime, byNumber, sizes, bytes, unproven: highest, largest, followed: watched,
339
+ revision: ++this.#revisions };
336
340
  this.#held.set(key, contents);
337
341
  return contents;
338
342
  }
@@ -361,6 +365,7 @@ export class SegmentStore {
361
365
  return;
362
366
  }
363
367
  contents.bytes += size - (contents.sizes.get(index) ?? 0);
368
+ contents.revision = ++this.#revisions;
364
369
  contents.byNumber.set(index, full);
365
370
  contents.sizes.set(index, size);
366
371
  if (index > contents.unproven) contents.unproven = index;
@@ -381,6 +386,7 @@ export class SegmentStore {
381
386
  return;
382
387
  }
383
388
  contents.bytes -= contents.sizes.get(index) ?? 0;
389
+ contents.revision = ++this.#revisions;
384
390
  contents.byNumber.delete(index);
385
391
  contents.sizes.delete(index);
386
392
  if (contents.unproven === index) contents.unproven = Math.max(-1, ...contents.byNumber.keys());
@@ -440,6 +446,20 @@ export class SegmentStore {
440
446
  return new Map(this.refresh(key).sizes ?? []);
441
447
  }
442
448
 
449
+ /**
450
+ * A number that moves whenever what this output holds changes, and only
451
+ * then: whoever keeps a reading of `sizesOf` reads it again when this moves
452
+ * rather than on every question. Unique across outputs and across the
453
+ * lifetimes of one output's directory, so a directory made again does not
454
+ * repeat an earlier number (torrent-tv/meta#178).
455
+ *
456
+ * @param {string} key
457
+ * @returns {number}
458
+ */
459
+ revisionOf(key) {
460
+ return this.refresh(key).revision ?? 0;
461
+ }
462
+
443
463
  /**
444
464
  * The biggest piece this output has made, and which number it is.
445
465
  *