@torrent-tv/proxy 2.107.56 → 2.107.57

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.
@@ -8,6 +8,7 @@
8
8
  * `server.js` to give each route the ones that route asks.
9
9
  */
10
10
 
11
+ import { EventEmitter } from "node:events";
11
12
  import { logger } from "../../utils/logger.js";
12
13
  import { KeyframeTables } from "../media/KeyframeTables.js";
13
14
  import { bandOf, WaitLedger } from "../encode/OutputWaitLedger.js";
@@ -15,6 +16,7 @@ import { readEndedChildrenCpuSeconds, readMachineState, readProcessCpuSeconds, r
15
16
  import { minimumBufferFrom } from "../torrent/supply-margin.js";
16
17
  import { PriorityOrchestrator } from "../viewer/PriorityOrchestrator.js";
17
18
  import { urgentOutputsReady } from "../viewer/urgent-output-ready.js";
19
+ import { trackChoicesOf } from "../viewer/segment-demands.js";
18
20
  import { softwareDescriptor } from "../encode/hwaccel.js";
19
21
  import { resolveSegmentFormat } from "../encode/segment-formats/index.js";
20
22
  import { Timelines } from "../encode/output/Timeline.js";
@@ -50,7 +52,6 @@ import {
50
52
  noteServingVerdict,
51
53
  noteStepBeingWarmed,
52
54
  noteStepOnScreen,
53
- outputsBeingPrepared,
54
55
  placeInitially,
55
56
  placeOn,
56
57
  presentOn,
@@ -102,7 +103,8 @@ import path from "node:path";
102
103
  import { contentOf, LocalObservations, PROXY_ROOT } from "../encode/LocalObservations.js";
103
104
  import { configurationKeyOf } from "../encode/fingerprint.js";
104
105
  import { qualityStateOf } from "../encode/quality/OutputQualityState.js";
105
- import { downloadShare } from "../storage/processor-share.js";
106
+ import { preparedAddresses, watchedAddresses } from "../viewer/watched-addresses.js";
107
+ import { downloadShare, watchedFiles } from "../storage/processor-share.js";
106
108
  import { describeProcessor, processorBetween, sampleProcessor } from "../storage/machine-processor.js";
107
109
  import { ProcessorBudget } from "../encode/ProcessorBudget.js";
108
110
  import { ENCODE_RUN_STATE, processCanBeSignalled } from "../encode/encode-run-state.js";
@@ -232,19 +234,17 @@ export function wireOutputs({
232
234
  diagnostics = null,
233
235
  diagnosticsRoot = "",
234
236
  memoryClaimant = null,
235
- resolveEncodeInput = null,
237
+ encodeInputResolution = null,
236
238
  repriceEncodeDemand = null,
237
239
  readSourceMedia = null,
238
240
  readMetadataActivity = null,
239
241
  sourceInputsFor = null,
240
242
  readEncodeRanges = null,
241
243
  openEncodeWholeFile = null,
242
- stretchCopied = () => {},
243
244
  readEncodeHeldRanges = null,
244
245
  encodeInputs = null,
245
246
  indexMemory = null,
246
247
  budgetPolicy = null,
247
- onViewerChanged = null,
248
248
  // Told when an encoder starts or ends: the room this host has for one more
249
249
  // encode changes with it, and the server chooses proxies by that room.
250
250
  onEncodersChanged = null}) {
@@ -774,19 +774,15 @@ export function wireOutputs({
774
774
  // watched file of every torrent.
775
775
  parts.applyDownloadShare = () => {
776
776
  if (!parts.setDownloadRate || !parts.hostLoad || !parts.encodeCost) return;
777
- const files = new Map();
778
- for (const output of parts.outputs.values()) {
779
- if (!(parts.viewers?.forOutput(output)?.size > 0)) continue;
780
- const key = output.file.key;
781
- const length = Number(output.file.lengthBytes ?? parts.hostLoad.fileLengthByKey.get(key));
782
- const file = files.get(key) ?? { bytesPerFilmSecond: length / Number(output.file.durationSeconds), encodeCostSec: 0 };
783
- const state = parts.encodeRuns.runStateOf(output);
784
- if (processCanBeSignalled(state) && state !== ENCODE_RUN_STATE.SUSPENDED) {
785
- file.encodeCostSec += parts.encodeCost.loadOfOutput(output.outputKey)?.costSec ?? 0;
777
+ const watched = watchedFiles(parts.outputs.values(), {
778
+ watched: (output) => parts.viewers?.forOutput(output)?.size > 0,
779
+ lengthOf: (output) => Number(output.file.lengthBytes ?? parts.hostLoad.fileLengthByKey.get(output.file.key)),
780
+ encodeCostOf: (output) => {
781
+ const state = parts.encodeRuns.runStateOf(output);
782
+ return processCanBeSignalled(state) && state !== ENCODE_RUN_STATE.SUSPENDED
783
+ ? parts.encodeCost.loadOfOutput(output.outputKey)?.costSec ?? 0 : 0;
786
784
  }
787
- files.set(key, file);
788
- }
789
- const watched = [...files.values()].filter((file) => Number.isFinite(file.bytesPerFilmSecond) && file.bytesPerFilmSecond > 0);
785
+ });
790
786
  const share = downloadShare({ availableShare: parts.processor?.availability()?.share ?? 1,
791
787
  baseDraw: parts.hostLoad.baseDraw, costPerMegabyte: parts.hostLoad.observedTorrentCostPerMegabyte, files: watched });
792
788
  // Kept beside the readings it is judged against: what the torrents moved
@@ -979,10 +975,13 @@ export function wireOutputs({
979
975
  // be re-taken on a five-second timer instead, which made a just-created
980
976
  // output wait up to five seconds before anything noticed it had a viewer.
981
977
  parts.retention = new OutputRetention();
978
+ // Who listens to viewer changes subscribes here (`startPlayback`); a throw
979
+ // of a listener reaches the code that changed the viewers.
980
+ parts.viewerChanges = new EventEmitter();
982
981
  parts.viewers = new Viewers({ onChange: () => {
983
982
  parts.lifecycle.observeUse();
984
983
  parts.encodeRuns.planEncodersSoon();
985
- onViewerChanged?.();
984
+ parts.viewerChanges.emit("change");
986
985
  } });
987
986
  // The outputs that exist, and every question about them: the picture a step
988
987
  // belongs to, the steps, the soundtracks, the height a step is named by.
@@ -1124,36 +1123,12 @@ export function wireOutputs({
1124
1123
  // cost) and how much of the machine nobody has priced (the host's reading).
1125
1124
  parts.admission = new EncodeAdmission({
1126
1125
  liveRunsByAddress: () => parts.encodeOrchestrator.liveRunsByAddress(),
1127
- preparedAddresses: () => {
1128
- const addresses = new Set();
1129
- for (const id of outputsBeingPrepared(parts.viewers)) {
1130
- const key = parts.outputs.get(id)?.outputKey;
1131
- if (key) {
1132
- addresses.add(key);
1133
- }
1134
- }
1135
- return addresses;
1136
- },
1126
+ preparedAddresses: () => preparedAddresses(parts.viewers, parts.outputs),
1137
1127
  // Every output a present viewer RECEIVES — by the same rule the per-output
1138
1128
  // priority map is built by — that still has something left to make. The
1139
1129
  // place an output is opened on is held from the moment its viewer is put
1140
1130
  // on it, not from when the plan gets round to its first encoder.
1141
- watchedAddresses: () => {
1142
- const addresses = new Set();
1143
- for (const session of parts.outputs.values()) {
1144
- const address = session.outputKey;
1145
- if (!address || addresses.has(address) || finishedOutput(session)) {
1146
- continue;
1147
- }
1148
- for (const viewer of parts.viewers.forOutput(session).values()) {
1149
- if (viewer.isPresent() && !parts.outputs.supersededBy(session, viewer.activeVariantId ?? null)) {
1150
- addresses.add(address);
1151
- break;
1152
- }
1153
- }
1154
- }
1155
- return addresses;
1156
- },
1131
+ watchedAddresses: () => watchedAddresses(parts.viewers, parts.outputs, finishedOutput),
1157
1132
  finished: (address) => parts.outputs.outputsOn(address).some((session) => finishedOutput(session)),
1158
1133
  loadOf: (address) => parts.encodeCost.loadOfOutput(address),
1159
1134
  loadForCandidate: (spec, file) => parts.encodeCost.loadForCandidate(spec, file),
@@ -1216,12 +1191,12 @@ export function wireOutputs({
1216
1191
  void parts.machineBudget.revise(parts.machineBudget.storageResource, "playback stock");
1217
1192
  }
1218
1193
  });
1219
- parts.encodeInputs = encodeInputs ?? (typeof resolveEncodeInput === "function" ? new EncodeInputs({
1220
- resolve: resolveEncodeInput,
1194
+ parts.encodeInputs = encodeInputs ?? (typeof encodeInputResolution?.resolve === "function" ? new EncodeInputs({
1195
+ resolve: (...args) => encodeInputResolution.resolve(...args),
1221
1196
  readRanges: readEncodeRanges,
1222
1197
  openWholeFile: openEncodeWholeFile,
1223
1198
  heldRanges: readEncodeHeldRanges,
1224
- stretchCopied,
1199
+ stretchCopied: (...args) => encodeInputResolution.stretchCopied(...args),
1225
1200
  reviseBudget: () => parts.machineBudget.revise("memory", "admitted encode inputs"),
1226
1201
  capacity: () => parts.machineBudget.capacityOf("memory"),
1227
1202
  // How long this output's input takes to open, from the one owner of start
@@ -1376,5 +1351,40 @@ export function wireOutputs({
1376
1351
  void parts.quality.runQualityBudgetOnce();
1377
1352
  }, BUDGET_CHECK_INTERVAL_MS);
1378
1353
  parts.budgetTimer.unref();
1354
+ /**
1355
+ * What the download map's pass reads of the outputs: each output of a source
1356
+ * that a present viewer watches and that reads the file, in catalog order,
1357
+ * with what decides how long its segments take to make and deliver and what
1358
+ * it has made (`PlaybackResolution`).
1359
+ *
1360
+ * @param {string} sourceKey
1361
+ * @param {number} fileIndex
1362
+ * @returns {Array<{ outputKey: string, timeline: object, choices: object[], revision: unknown,
1363
+ * sizes: () => Map<number, number>, encodeSpeed: number | undefined, linkMbps: number | null, target: number | null }>}
1364
+ */
1365
+ parts.playedOutputsOf = (sourceKey, fileIndex) => {
1366
+ const played = [];
1367
+ for (const output of parts.outputs.values()) {
1368
+ if (output.file.sourceKey !== sourceKey) continue;
1369
+ const consumers = [...parts.viewers.forOutput(output).values()].filter(viewer =>
1370
+ viewer.isPresent() && !parts.outputs.supersededBy(output, viewer.activeVariantId ?? null));
1371
+ if (consumers.length === 0) continue;
1372
+ const choices = trackChoicesOf(output, fileIndex);
1373
+ if (choices.length === 0) continue;
1374
+ // An output with no cut grid is left out by the pass (`PlaybackResolution`);
1375
+ // none of what follows is worth working out for it.
1376
+ const timeline = output.timeline;
1377
+ if (!timeline || typeof timeline !== "object" || !Array.isArray(timeline.published ?? timeline.boundaries)) continue;
1378
+ const links = consumers.map(viewer => viewer.linkReading()?.linkMbps)
1379
+ .filter(value => Number.isFinite(value) && value > 0);
1380
+ played.push({ outputKey: output.outputKey, timeline: output.timeline, choices,
1381
+ revision: parts.segmentStore.revisionOf(output.outputKey),
1382
+ sizes: () => parts.segmentStore.sizesOf(output.outputKey),
1383
+ encodeSpeed: parts.encodeCost.latestSpeedReadingOf(output)?.speed,
1384
+ linkMbps: links.length ? Math.min(...links) : null,
1385
+ target: parts.encodeInputs?.targetInputBytes?.(output) ?? null });
1386
+ }
1387
+ return played;
1388
+ };
1379
1389
  return parts;
1380
1390
  }
@@ -55,3 +55,27 @@ export function downloadShare({ availableShare, baseDraw, costPerMegabyte, files
55
55
  const bytesPerSecond = perByte > 0 ? Math.max(filmSpeed, 1) * bytes : Infinity;
56
56
  return { filmSpeed, capacity, bytesPerSecond: Number.isFinite(bytesPerSecond) && bytes > 0 ? bytesPerSecond : Infinity };
57
57
  }
58
+
59
+ /**
60
+ * The watched outputs grouped by the file they read: what the share is worked
61
+ * out over. Moved out of `applyDownloadShare` verbatim (torrent-tv/meta#280).
62
+ *
63
+ * @param {Iterable<{ file: { key: string, durationSeconds: number } }>} outputs
64
+ * @param {object} ports
65
+ * @param {(output: object) => boolean} ports.watched - Whether a viewer receives the output.
66
+ * @param {(output: object) => number} ports.lengthOf - The file's length in bytes.
67
+ * @param {(output: object) => number} ports.encodeCostOf - What its encoder costs, 0 when none runs.
68
+ * @returns {{ bytesPerFilmSecond: number, encodeCostSec: number }[]}
69
+ * One per file in the order first seen; only finite positive rates kept.
70
+ */
71
+ export function watchedFiles(outputs, { watched, lengthOf, encodeCostOf }) {
72
+ const files = new Map();
73
+ for (const output of outputs) {
74
+ if (!watched(output)) continue;
75
+ const key = output.file.key;
76
+ const file = files.get(key) ?? { bytesPerFilmSecond: lengthOf(output) / Number(output.file.durationSeconds), encodeCostSec: 0 };
77
+ file.encodeCostSec += encodeCostOf(output);
78
+ files.set(key, file);
79
+ }
80
+ return [...files.values()].filter((file) => Number.isFinite(file.bytesPerFilmSecond) && file.bytesPerFilmSecond > 0);
81
+ }
@@ -0,0 +1,397 @@
1
+ /**
2
+ * @file The download map's pass: what the torrent is told about the file a
3
+ * viewer plays, worked out from the viewers' map (torrent-tv/meta#270, #273).
4
+ *
5
+ * `DownloadMaps` hands it a file's map; it answers the windows of source bytes
6
+ * the swarm is to fetch. It owns what that pass keeps from map to map: the
7
+ * projection of each played file (`PlaybackProjection`), what each segment of
8
+ * each output reads and the bytes it was worked out to read, the cost of the
9
+ * passes. Everything it reads of the world comes in by a port: the file's
10
+ * parameters, the resolver of a segment's source ranges, the rule of what a
11
+ * segment reads, the outputs somebody watches, the piece length, the table of
12
+ * what each segment reads, the wait-trace and the log.
13
+ */
14
+
15
+ import { performance } from "node:perf_hooks";
16
+ import { takeTurns } from "../../utils/take-turns.js";
17
+ import { joinedWithinPieces } from "./demand/pieces.js";
18
+ import { PlaybackProjection } from "./PlaybackProjection.js";
19
+
20
+ export class PlaybackResolution {
21
+ #paramsFor;
22
+ #sourceRanges;
23
+ #segmentReading;
24
+ #playedOutputsOf;
25
+ #pieceLengthOf;
26
+ #noteSegmentInput;
27
+ #refresh;
28
+ #observeWait;
29
+ #log;
30
+ #warn;
31
+ #now;
32
+ #clock;
33
+ #turns;
34
+ /** What the torrent is told of each played file, kept from map to map (`PlaybackProjection`); forgotten with the source. */
35
+ #projections = new Map();
36
+ /**
37
+ * WHAT ONE SEGMENT OF AN OUTPUT IS, kept from pass to pass: where it lies in
38
+ * the source, what it reads — its tracks and the key its bytes are kept under
39
+ * — and, once known, those bytes joined at the precision the torrent fetches,
40
+ * by output and segment. Worked out again only when the output's timeline,
41
+ * the tracks, the file's start time or the output's choices change; forgotten
42
+ * with the source. None of it depends on where a viewer stands, and a pass is
43
+ * made every time one moves: looked up, made and joined again for each of a
44
+ * film's 3500 segments on every pass, they were 6 s of the 40 s the passes
45
+ * cost the main thread in fifteen minutes on Home Assistant (r30,
46
+ * torrent-tv/meta#178).
47
+ */
48
+ #segmentReadings = new Map();
49
+ /** The choices of one output's tracks as a key, worked out once for the list of them a pass shares. */
50
+ #choicesKeys = new WeakMap();
51
+ /** An output's cut grid, checked once for each timeline. */
52
+ #checkedGrids = new WeakMap();
53
+ /**
54
+ * How many bytes a second of an output's film takes, from what it has made,
55
+ * read again only when what it has made changes (`SegmentStore.revisionOf`):
56
+ * summed over every made segment on every pass, it was a walk of the whole
57
+ * film a second (torrent-tv/meta#178).
58
+ */
59
+ #madeRates = new Map();
60
+ // WHAT THE PASSES COST, said once a minute: how many maps were taken, how
61
+ // many segments a map made the projection work out again and how many of
62
+ // those maps it worked out whole, how many segments had their ranges worked
63
+ // out, the windows told, and the time from a pass's start to its result —
64
+ // which includes waits for reads, so it bounds the main thread's share
65
+ // rather than measuring it (torrent-tv/meta#178).
66
+ #passCosts;
67
+ // A container whose packet table a pass did not wait for builds the map
68
+ // again once, when the table is built or refused (`directSegmentInput`).
69
+ #refreshAfterTable = new WeakSet();
70
+
71
+ /**
72
+ * @param {object} ports
73
+ * @param {(request: { sourceKey: string, fileIndex: number, requestId?: string, packetInterval?: object, demand?: object }) => Promise<object | null>} ports.paramsFor
74
+ * @param {object} ports.sourceRanges - `SegmentSourceRanges`.
75
+ * @param {(trackList: object[], choices: object[], sourceInterval: object) => object | null} ports.segmentReading
76
+ * @param {(sourceKey: string, fileIndex: number) => object[]} ports.playedOutputsOf
77
+ * @param {(sourceKey: string) => number} ports.pieceLengthOf - NaN when the torrent is not known.
78
+ * @param {(sourceKey: string, fileIndex: number, outputKey: string, index: number, joined: Array<[number, number]>) => void} ports.noteSegmentInput
79
+ * @param {(sourceKey: string, fileIndex: number) => Promise<unknown>} ports.refresh
80
+ * @param {(sourceKey: string, event: string, fields: object) => void} ports.observeWait
81
+ * @param {(line: string) => void} ports.log
82
+ * @param {(line: string) => void} ports.warn
83
+ * @param {() => number} [ports.now] - The clock of the once-a-minute reading.
84
+ * @param {() => number} [ports.clock] - Pass and P4.1 timing.
85
+ * @param {() => (() => Promise<unknown>)} [ports.turns]
86
+ */
87
+ constructor({ paramsFor, sourceRanges, segmentReading, playedOutputsOf, pieceLengthOf, noteSegmentInput, refresh,
88
+ observeWait, log, warn, now = Date.now, clock = () => performance.now(), turns = takeTurns }) {
89
+ this.#paramsFor = paramsFor;
90
+ this.#sourceRanges = sourceRanges;
91
+ this.#segmentReading = segmentReading;
92
+ this.#playedOutputsOf = playedOutputsOf;
93
+ this.#pieceLengthOf = pieceLengthOf;
94
+ this.#noteSegmentInput = noteSegmentInput;
95
+ this.#refresh = refresh;
96
+ this.#observeWait = observeWait;
97
+ this.#log = log;
98
+ this.#warn = warn;
99
+ this.#now = now;
100
+ this.#clock = clock;
101
+ this.#turns = turns;
102
+ this.#passCosts = { since: now(), passes: 0, whole: 0, reworked: 0, resolved: 0, windows: 0, ms: 0, maxMs: 0 };
103
+ }
104
+
105
+ /** The file has no playback zones: what is told of it is emptied. */
106
+ clear(sourceKey, fileIndex) {
107
+ this.#projections.get(`${sourceKey}:${fileIndex}`)?.clear();
108
+ }
109
+
110
+ /** The source is forgotten: its projections, records and made rates go. */
111
+ forget(sourceKey) {
112
+ for (const key of this.#segmentReadings.keys()) if (key.startsWith(`${sourceKey}:`)) this.#segmentReadings.delete(key);
113
+ for (const key of this.#projections.keys()) if (key.startsWith(`${sourceKey}:`)) this.#projections.delete(key);
114
+ for (const key of this.#madeRates.keys()) if (key.startsWith(`${sourceKey}:`)) this.#madeRates.delete(key);
115
+ }
116
+
117
+ /** One output's segments, by index. */
118
+ #segmentRecordsOf(map, output) {
119
+ const key = `${map.sourceKey}:${map.fileIndex}:${output.outputKey}`;
120
+ let records = this.#segmentReadings.get(key);
121
+ if (!records) this.#segmentReadings.set(key, records = new Map());
122
+ return records;
123
+ }
124
+
125
+ #choicesKeyOf(choices) {
126
+ let key = this.#choicesKeys.get(choices);
127
+ if (key === undefined) this.#choicesKeys.set(choices, key = choices.map(choice => `${choice.type}/${choice.index}/${choice.mode}`).join(","));
128
+ return key;
129
+ }
130
+
131
+ #segmentRecordOf(records, demand, { timeline, trackList, shift, map }) {
132
+ const choicesKey = this.#choicesKeyOf(demand.tracks);
133
+ const kept = records.get(demand.index);
134
+ if (kept && kept.timeline === timeline && kept.trackList === trackList && kept.shift === shift && kept.choicesKey === choicesKey) return kept;
135
+ const sourceInterval = timeline.sourceInterval(demand.index, demand.index, shift);
136
+ const found = this.#segmentReading(trackList, demand.tracks, sourceInterval);
137
+ const reading = found && { ...found, rangesKey: this.#sourceRanges.keyOf(map.sourceKey, map.fileIndex, found.interval) };
138
+ // `joined` is the segment's ranges as the torrent is told them, made from
139
+ // the list `ranges` at `pieceLength`; `registered` is the list the input
140
+ // table was last given.
141
+ const record = { timeline, trackList, shift, choicesKey, sourceInterval, reading,
142
+ ranges: null, pieceLength: Number.NaN, joined: null, registered: null };
143
+ records.set(demand.index, record);
144
+ return record;
145
+ }
146
+
147
+ #gridOf(timeline) {
148
+ if (!timeline || typeof timeline !== "object") return null;
149
+ if (this.#checkedGrids.has(timeline)) return this.#checkedGrids.get(timeline);
150
+ const grid = timeline.published ?? timeline.boundaries;
151
+ if (!Array.isArray(grid)) {
152
+ this.#checkedGrids.set(timeline, null);
153
+ return null;
154
+ }
155
+ for (let index = 0; index + 1 < grid.length; index++) {
156
+ if (!Number.isFinite(grid[index]) || !Number.isFinite(grid[index + 1]) || !(grid[index + 1] > grid[index])) {
157
+ throw new Error("An output segment needs a valid interval.");
158
+ }
159
+ }
160
+ this.#checkedGrids.set(timeline, grid);
161
+ return grid;
162
+ }
163
+
164
+ #bytesPerSecondOf(entry) {
165
+ const kept = this.#madeRates.get(entry.outputKey);
166
+ if (kept && kept.revision === entry.revision && kept.timeline === entry.timeline) return kept.bytesPerSecond;
167
+ let bytes = 0, seconds = 0;
168
+ for (const [index, size] of entry.sizes()) {
169
+ const span = entry.timeline.publishedStartOf(index + 1) - entry.timeline.publishedStartOf(index);
170
+ if (span > 0) { bytes += size; seconds += span; }
171
+ }
172
+ const bytesPerSecond = seconds > 0 ? bytes / seconds : null;
173
+ this.#madeRates.set(entry.outputKey, { revision: entry.revision, timeline: entry.timeline, bytesPerSecond });
174
+ return bytesPerSecond;
175
+ }
176
+
177
+ #notePass({ whole = false, reworked = 0, resolved = 0, windows = 0, ms = 0 }) {
178
+ const passCosts = this.#passCosts;
179
+ passCosts.passes += 1;
180
+ if (whole) passCosts.whole += 1;
181
+ passCosts.reworked += reworked; passCosts.resolved += resolved; passCosts.windows += windows;
182
+ passCosts.ms += ms; passCosts.maxMs = Math.max(passCosts.maxMs, ms);
183
+ const now = this.#now();
184
+ if (now - passCosts.since < 60_000) return;
185
+ this.#log(`download map passes in ${((now - passCosts.since) / 1000).toFixed(1)}s: ${passCosts.passes} map(s), ` +
186
+ `${passCosts.whole} worked out whole, ${passCosts.reworked} segment(s) worked out again, ` +
187
+ `${passCosts.resolved} segment(s) had their ranges worked out, ${passCosts.windows} window(s) told; ` +
188
+ `${Math.round(passCosts.ms)}ms from start to result in all, longest ${Math.round(passCosts.maxMs)}ms`);
189
+ Object.assign(passCosts, { since: now, passes: 0, whole: 0, reworked: 0, resolved: 0, windows: 0, ms: 0, maxMs: 0 });
190
+ }
191
+
192
+ #refreshWhenBuilt(container, built, { sourceKey, fileIndex }) {
193
+ if (this.#refreshAfterTable.has(container)) return;
194
+ this.#refreshAfterTable.add(container);
195
+ void built.then(() => {
196
+ this.#refreshAfterTable.delete(container);
197
+ return this.#refresh(sourceKey, fileIndex);
198
+ }).catch(error => this.#warn(`download map after a packet table: ${error?.message ?? error}`));
199
+ }
200
+
201
+ /**
202
+ * One pass over a file's map.
203
+ *
204
+ * @param {{ sourceKey: string, fileIndex: number, zones: object[], isCurrent: () => boolean,
205
+ * workingOut?: (priority: number, zonesOf: () => object[]) => void }} map
206
+ * @returns {Promise<object[]>} The windows to tell the torrent; empty when the map was replaced meanwhile.
207
+ */
208
+ async resolve(map) {
209
+ const fileKey = `${map.sourceKey}:${map.fileIndex}`;
210
+ if (map.zones.length === 0) {
211
+ this.#projections.get(fileKey)?.clear();
212
+ return [];
213
+ }
214
+ const params = await this.#paramsFor({ sourceKey: map.sourceKey, fileIndex: map.fileIndex });
215
+ if (!params || !map.isCurrent()) return [];
216
+ const tracks = await this.#sourceRanges.tracksOf(params, () => map.isCurrent());
217
+ if (tracks.kind !== "result") return [];
218
+ const container = tracks.container;
219
+ const media = await this.#sourceRanges.shiftOf(params, () => map.isCurrent());
220
+ if (media.kind !== "result") return [];
221
+ const shift = media.shift;
222
+ // A zone's ranges are stated to the torrent at the precision it fetches:
223
+ // ranges closer than a piece hold the same pieces as one.
224
+ const pieceLength = this.#pieceLengthOf(map.sourceKey);
225
+ const passStartedAt = this.#clock();
226
+ // THE PROJECTION IS KEPT while what it was made from stands: the file's
227
+ // tracks, where its clock starts and how the torrent fetches. Any of them
228
+ // changed, every segment's ranges are other ranges.
229
+ let projection = this.#projections.get(fileKey);
230
+ if (!projection || projection.trackList !== tracks.trackList || projection.shift !== shift ||
231
+ !Object.is(projection.pieceLength, pieceLength)) {
232
+ projection?.clear();
233
+ projection = new PlaybackProjection();
234
+ Object.assign(projection, { trackList: tracks.trackList, shift, pieceLength });
235
+ this.#projections.set(fileKey, projection);
236
+ }
237
+ // Every output of this file somebody is watching, with what decides how
238
+ // long its segments take to make and deliver, what it has made, and what
239
+ // its next run would copy.
240
+ const played = [];
241
+ /** By output: what its segments read and how they are looked up. */
242
+ const readers = new Map();
243
+ for (const entry of this.#playedOutputsOf(map.sourceKey, map.fileIndex)) {
244
+ const grid = this.#gridOf(entry.timeline);
245
+ if (!grid) continue;
246
+ // Every piece answers for its exact ending boundary, as the encode
247
+ // plan reads it: the player may request the preceding piece there.
248
+ played.push({ outputKey: entry.outputKey, grid, encodeSpeed: entry.encodeSpeed,
249
+ bytesPerSecond: this.#bytesPerSecondOf(entry), linkMbps: entry.linkMbps,
250
+ readyRevision: entry.revision, ready: entry.sizes, target: entry.target });
251
+ readers.set(entry.outputKey, { records: this.#segmentRecordsOf(map, entry), choices: entry.choices,
252
+ facts: { timeline: entry.timeline, trackList: tracks.trackList, shift, map } });
253
+ }
254
+ // WHAT THE MAP CHANGED, and only that, is worked out again here.
255
+ projection.update({ zones: map.zones, outputs: played });
256
+ const reworked = projection.lastUpdate;
257
+ let resolved = 0;
258
+ // A container that names the bytes of an interval from an index it holds
259
+ // answers every segment by arithmetic: once that index is read
260
+ // ("source-navigation"), the ranges are computed here directly instead of
261
+ // through the file's queue of reads, three steps per segment. On Home
262
+ // Assistant those steps made a pass over a two-hour AVI take minutes while
263
+ // the arithmetic takes milliseconds (torrent-tv/meta#151).
264
+ const navigated = await this.#sourceRanges.navigable(params, () => map.isCurrent());
265
+ if (navigated.kind === "stale") return [];
266
+ const direct = navigated.kind === "result" && navigated.value === true &&
267
+ typeof container?.readSourceRanges === "function" ? container : null;
268
+ // An output whose file-level reading is not there in this pass — the
269
+ // index or Cues that say where a segment is, not yet downloaded, or the
270
+ // table its segments read, refused memory — answers its other segments
271
+ // the same way. One unanswered request is kept to be retried when the
272
+ // bytes or the memory change, and the map is built again when it
273
+ // answers; asking every segment held the main thread for 3.3 s on a cold
274
+ // two-hour AVI (torrent-tv/meta#178).
275
+ const waiting = new Set();
276
+ const turn = this.#turns();
277
+ // ONLY THE SEGMENTS WHOSE RANGES ARE NOT KNOWN are worked out, in the
278
+ // order the swarm is to fetch them. Range resolution can use either a
279
+ // direct source index or the fallback reads below; yield between zones
280
+ // in both cases so a large map cannot hold the main thread across its
281
+ // full projection.
282
+ for (const zone of projection.unresolved()) {
283
+ if (!map.isCurrent()) return [];
284
+ await turn();
285
+ if (!map.isCurrent()) return [];
286
+ const resolutionStartedAt = this.#clock();
287
+ const resolution = { outcome: "unresolved", method: null, trackCount: null, rangeCount: null, rangeBytes: null };
288
+ const resolutionId = `${map.fileIndex}:${zone.outputKey}:${zone.index}:${passStartedAt.toFixed(3)}`;
289
+ this.#observeWait(map.sourceKey, "process", { process: "P4.1", phase: "begin", resolutionId,
290
+ sourceId: `${map.sourceKey}:${map.fileIndex}`, fileIndex: map.fileIndex, outputKey: zone.outputKey,
291
+ segment: zone.index, priority: zone.priority, deadlineAt: zone.deadlineAt });
292
+ try {
293
+ const reader = readers.get(zone.outputKey);
294
+ if (!reader) {
295
+ resolution.outcome = "reader-missing";
296
+ continue;
297
+ }
298
+ // WHAT A SEGMENT READS IS WORKED OUT ONCE (`#segmentRecordOf`). Rebuilt
299
+ // for every segment of the film on every map, this pass held the main
300
+ // thread up to 1.5 s at a time on Home Assistant (torrent-tv/meta#178).
301
+ const record = this.#segmentRecordOf(reader.records, { index: zone.index, tracks: reader.choices }, reader.facts);
302
+ const reading = record.reading;
303
+ if (!reading) {
304
+ resolution.outcome = "segment-reading-unavailable";
305
+ projection.setUnreadable(zone.outputKey, zone.index);
306
+ continue;
307
+ }
308
+ const { wanted, modes, interval, rangesKey } = reading;
309
+ resolution.trackCount = wanted.length;
310
+ resolution.trackIds = wanted.map(track => track.trackNumber);
311
+ resolution.sourceInterval = { from: interval.from, to: interval.to };
312
+ const convert = (ranges, method) => {
313
+ if (record.ranges !== ranges || record.pieceLength !== pieceLength) {
314
+ record.ranges = ranges;
315
+ record.pieceLength = pieceLength;
316
+ record.joined = joinedWithinPieces(ranges, pieceLength);
317
+ }
318
+ const joined = record.joined;
319
+ resolution.method = method;
320
+ resolution.rangeCount = joined.length;
321
+ resolution.rangeBytes = joined.reduce((total, [start, end]) => total + end - start + 1, 0);
322
+ // What the segment reads is kept for the forecast in a table of its
323
+ // own, whatever form the torrent is told it in.
324
+ if (record.registered !== joined) {
325
+ this.#noteSegmentInput(map.sourceKey, map.fileIndex, zone.outputKey, zone.index, joined);
326
+ record.registered = joined;
327
+ }
328
+ projection.setRanges(zone.outputKey, zone.index, joined);
329
+ resolution.outcome = "resolved";
330
+ resolved += 1;
331
+ };
332
+ // The bytes one segment needs are a fact of the file and the output, so
333
+ // they are worked out once. A whole film is thousands of segments and
334
+ // the map changes every few seconds; worked out afresh on every change,
335
+ // a pass over a two-hour AVI never finished and the next segment's bytes
336
+ // were never asked for (Home Assistant 2026-10-08, torrent-tv/meta#151).
337
+ const known = record.ranges ? { ranges: record.ranges, method: "segment-record-cache" } : this.#sourceRanges.cached(rangesKey);
338
+ if (known) { convert(known.ranges, known.method); continue; }
339
+ if (waiting.has(zone.outputKey)) {
340
+ resolution.outcome = "waiting-for-output-input";
341
+ continue;
342
+ }
343
+ // What is resolved so far is stated before a lower zone is worked out
344
+ // (DownloadMaps, torrent-tv/meta#178).
345
+ map.workingOut?.(zone.priority, () => projection.windows());
346
+ const found = await this.#sourceRanges.resolve({ rangesKey, direct, interval, tracks: wanted, modes,
347
+ isCurrent: () => map.isCurrent(),
348
+ whenBuilt: built => this.#refreshWhenBuilt(direct, built, map),
349
+ intervalParams: () => {
350
+ resolution.method = "container-index-fallback";
351
+ return this.#paramsFor({ sourceKey: map.sourceKey, fileIndex: map.fileIndex, packetInterval: interval,
352
+ requestId: `download:${map.sourceKey}:${map.fileIndex}:${zone.outputKey}:${zone.index}`,
353
+ demand: { ...zone.owner, leadSeconds: zone.leadSeconds } });
354
+ } });
355
+ if (found.kind === "result") { convert(found.ranges, found.method); continue; }
356
+ resolution.method = found.method;
357
+ if (found.kind === "stale") {
358
+ resolution.outcome = "map-replaced";
359
+ return [];
360
+ }
361
+ if (found.kind === "pending") {
362
+ // A table still being built holds this output's segments, and only
363
+ // this output's: the map is built again when it is.
364
+ resolution.outcome = "source-index-pending";
365
+ waiting.add(zone.outputKey);
366
+ continue;
367
+ }
368
+ if (found.kind === "needs-source") {
369
+ resolution.outcome = "source-file-unavailable";
370
+ continue;
371
+ }
372
+ if (found.step === "navigation") {
373
+ resolution.outcome = `source-navigation-${found.kind}`;
374
+ if (found.kind === "needs-ranges" || found.kind === "needs-memory") waiting.add(zone.outputKey);
375
+ continue;
376
+ }
377
+ if (found.kind === "needs-memory") waiting.add(zone.outputKey);
378
+ resolution.outcome = found.step === "packets" ? `source-packets-${found.kind}` : `segment-input-${found.kind}`;
379
+ } catch (error) {
380
+ resolution.outcome = "error";
381
+ resolution.error = error?.message ?? String(error);
382
+ throw error;
383
+ } finally {
384
+ const resolutionEndedAt = this.#clock();
385
+ this.#observeWait(map.sourceKey, "process", { process: "P4.1", phase: "end", resolutionId,
386
+ sourceId: `${map.sourceKey}:${map.fileIndex}`, fileIndex: map.fileIndex, outputKey: zone.outputKey,
387
+ segment: zone.index, priority: zone.priority,
388
+ startedAt: resolutionStartedAt, endedAt: resolutionEndedAt, durationMs: resolutionEndedAt - resolutionStartedAt,
389
+ ...resolution });
390
+ }
391
+ }
392
+ const result = projection.windows();
393
+ this.#notePass({ whole: reworked.whole, reworked: reworked.segments, resolved, windows: result.length,
394
+ ms: this.#clock() - passStartedAt });
395
+ return result;
396
+ }
397
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * @file Which output addresses are being prepared, and which are watched, for
3
+ * the encoder admission. Moved out of `wire-outputs.js` verbatim
4
+ * (torrent-tv/meta#280).
5
+ */
6
+
7
+ import { outputsBeingPrepared } from "./choices.js";
8
+
9
+ /**
10
+ * The output keys of the outputs a viewer is being prepared for.
11
+ *
12
+ * @param {object} viewers
13
+ * @param {{ get: (id: string) => { outputKey?: string } | undefined }} outputs
14
+ * @returns {Set<string>}
15
+ */
16
+ export function preparedAddresses(viewers, outputs) {
17
+ const addresses = new Set();
18
+ for (const id of outputsBeingPrepared(viewers)) {
19
+ const key = outputs.get(id)?.outputKey;
20
+ if (key) {
21
+ addresses.add(key);
22
+ }
23
+ }
24
+ return addresses;
25
+ }
26
+
27
+ /**
28
+ * Every output a present viewer receives that still has something left to
29
+ * make, by address.
30
+ *
31
+ * @param {object} viewers
32
+ * @param {{ values: () => Iterable<object>, supersededBy: (session: object, variantId: string | null) => boolean }} outputs
33
+ * @param {(output: object) => boolean} finished
34
+ * @returns {Set<string>}
35
+ */
36
+ export function watchedAddresses(viewers, outputs, finished) {
37
+ const addresses = new Set();
38
+ for (const session of outputs.values()) {
39
+ const address = session.outputKey;
40
+ if (!address || addresses.has(address) || finished(session)) {
41
+ continue;
42
+ }
43
+ for (const viewer of viewers.forOutput(session).values()) {
44
+ if (viewer.isPresent() && !outputs.supersededBy(session, viewer.activeVariantId ?? null)) {
45
+ addresses.add(address);
46
+ break;
47
+ }
48
+ }
49
+ }
50
+ return addresses;
51
+ }