@torrent-tv/proxy 2.97.10 → 2.97.12

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.10",
3
+ "version": "2.97.12",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "repository": {
@@ -1,7 +1,7 @@
1
1
  import { parseRange } from "../../utils/parse-range.js";
2
2
 
3
3
  /** FFmpeg reads only original byte ranges already owned by its live run. */
4
- export function handleEncodeInputGet(req, reply, { inputOf, refused = () => {} }) {
4
+ export async function handleEncodeInputGet(req, reply, { inputOf, refused = () => {} }) {
5
5
  const address = req.raw?.socket?.remoteAddress;
6
6
  if (!["127.0.0.1", "::1", "::ffff:127.0.0.1"].includes(address)) return reply.code(403).send();
7
7
  const token = Number(req.params.token), fileIndex = Number(req.params.fileIndex);
@@ -29,7 +29,15 @@ export function handleEncodeInputGet(req, reply, { inputOf, refused = () => {} }
29
29
  // and a byte past it is refused, which from 7.1 on ends the run: every read
30
30
  // FFmpeg makes, its stream search at open included, must be named by the
31
31
  // container (torrent-tv/meta#165).
32
- const held = input.read(fileIndex, range.start, range.end, true);
32
+ // A read inside the stretch of film waits for its copy, which goes on while
33
+ // FFmpeg opens (`OriginalInput.js`); the answer is then whole, as any other.
34
+ const held = await input.read(fileIndex, range.start, range.end, true);
35
+ if (!held && input.copyFailed?.()) {
36
+ // The copy of the stretch failed: a piece left the store between the
37
+ // check that it was there and the copy. The run ends on this refusal, and
38
+ // the failure is said where the copy is accounted for (`EncodeInputs.js`).
39
+ return reply.code(503).send({ error: "Original input bytes could not be copied." });
40
+ }
33
41
  if (!held) {
34
42
  // FFmpeg asked for a byte the run was not given: the container named too
35
43
  // little. Said, because FFmpeg itself reports only a failed seek or read.
package/server.js CHANGED
@@ -716,7 +716,10 @@ export async function startProxyServer({
716
716
  if (original) {
717
717
  const ranges = await containerOrchestrator.inspect(params, "source-ranges");
718
718
  if (ranges.kind !== "result") return ranges;
719
+ // The torrent's piece is the unit its stretch is copied in, so a copy
720
+ // nobody needs any more stops at the next piece (`OriginalInput.js`).
719
721
  sources.push({ sourceKey: params.sourceKey, fileIndex, timeShiftSeconds,
722
+ pieceLength: torrentPool.knownTorrent(params.sourceKey)?.pieceLength ?? null,
720
723
  input: { ...ranges.value, original: true, selections: wanted.map(track => ({ track,
721
724
  index: track.declaredIndex ?? choices.find(choice => choice.type === track.type)?.index ?? 0 })) } });
722
725
  continue;
@@ -51,13 +51,11 @@ export class EncodeInputs {
51
51
  #capacity;
52
52
  #urgent;
53
53
  #rankAt;
54
- #deadlineOf;
55
- #firstPieceIn;
56
54
  #log;
57
55
  #now;
58
56
 
59
57
  constructor({ resolve, readRanges, heldRanges = null, reviseBudget, changed, failed, capacity = () => null, urgent = () => true,
60
- rankAt = () => null, deadlineOf = () => null, firstPieceIn = () => null, openOf = () => null, log = () => {}, now = Date.now }) {
58
+ rankAt = () => null, openOf = () => null, log = () => {}, now = Date.now }) {
61
59
  this.#openOf = openOf;
62
60
  this.#resolve = resolve;
63
61
  this.#read = readRanges;
@@ -68,8 +66,6 @@ export class EncodeInputs {
68
66
  this.#capacity = capacity;
69
67
  this.#urgent = urgent;
70
68
  this.#rankAt = rankAt;
71
- this.#deadlineOf = deadlineOf;
72
- this.#firstPieceIn = firstPieceIn;
73
69
  this.#log = log;
74
70
  this.#now = now;
75
71
  }
@@ -191,6 +187,33 @@ export class EncodeInputs {
191
187
  this.#retryMemory();
192
188
  }
193
189
 
190
+ /**
191
+ * Withdraw what is being prepared or held for a start the plan no longer
192
+ * places: `starts` holds every start the last plan of this output placed or
193
+ * held back, and the number after each run it left alive. `retain` withdrew
194
+ * such a copy only once it was held, and only when a run was started; while
195
+ * it was being made it shared the torrent thread with the copy the viewer
196
+ * was waiting for. Field 2026-10-12: after a seek back, the next stretch of
197
+ * the run the seek had stopped went on being copied beside the one for the
198
+ * new position, 127.5 and 126.5 MB in 16 s each, and was withdrawn only once
199
+ * it was done (torrent-tv/meta#178).
200
+ *
201
+ * @param {object} output
202
+ * @param {Set<number>} starts
203
+ */
204
+ withdrawUnplaced(output, starts) {
205
+ for (const [key, request] of this.#requests) {
206
+ if (request.output !== output || request.result?.kind === "terminal" || starts.has(request.from)) continue;
207
+ this.#note(request, "withdrawn: the plan places no run there");
208
+ this.#requests.delete(key);
209
+ this.#wanted.delete(key);
210
+ // A copy already answered may still be copying its stretch; released,
211
+ // it stops at the next piece.
212
+ request.result?.release?.();
213
+ }
214
+ this.#retryMemory();
215
+ }
216
+
194
217
  /**
195
218
  * The input of a run starting at `from`, once it is prepared.
196
219
  *
@@ -310,7 +333,9 @@ export class EncodeInputs {
310
333
  if (result.kind === "result" && result.to > request.to) result.to = Math.max(request.from, request.to);
311
334
  request.result = result;
312
335
  this.#note(request, result.kind === "result" ? `ready #${result.from}..#${result.to}, ${result.bytes ?? "?"} bytes` +
313
- (Number.isFinite(result.copyMs) ? `, copied in ${result.copyMs}ms` : "")
336
+ (Number.isFinite(result.copyMs) ? (result.deferredBytes > 0
337
+ ? `, opening part copied in ${result.copyMs}ms, ${result.deferredBytes} bytes of the stretch copied while the run opens`
338
+ : `, copied in ${result.copyMs}ms`) : "")
314
339
  : `${result.kind}${result.reason ? ` ${result.reason}` : ""}${Number.isFinite(result.bytes) ? ` ${result.bytes} bytes` : ""}`,
315
340
  describeTiming(request.timing, this.#now()));
316
341
  if (result.kind === "result" || result.kind === "terminal") this.#changed(request.output, result);
@@ -411,26 +436,8 @@ export class EncodeInputs {
411
436
  return good;
412
437
  };
413
438
  const sizing = this.stretchFor(output);
414
- let target = sizing?.bytes ?? null;
439
+ const target = sizing?.bytes ?? null;
415
440
  if (sizing && !sizing.openMeasured) request.timing.openApproximate = true;
416
- // NO LONGER THAN THE VIEWER CAN WAIT. A stretch is sized so its copy takes
417
- // no longer than an open; for a piece the viewer reaches soon, the copy and
418
- // the time to that piece's being served must both fit before they reach it.
419
- // Field 2026-10-12: after a seek back, 222.4 MB were copied for 7.7 s ahead
420
- // of a piece needed in about 8 s, and the picture stood 13.2 s. Unknown
421
- // terms leave the stretch as it was.
422
- const deadline = this.#deadlineOf(output, serves);
423
- const firstPiece = this.#firstPieceIn(output, from, serves);
424
- const copyRate = middleOf(this.#copyRates);
425
- if (target !== null && Number.isFinite(deadline) && Number.isFinite(firstPiece) && copyRate !== null) {
426
- const fitting = Math.floor(copyRate * Math.max(0, deadline - firstPiece) * 1000);
427
- if (fitting < target) {
428
- const capped = Math.max(minimum, fitting);
429
- this.#log(`encode input #${from}..#${request.to} of ${output.outputKey ?? output.id}: copy held to ${capped} of ${target} bytes ` +
430
- `— #${serves} is needed in ${deadline.toFixed(1)}s and its first piece takes ${firstPiece.toFixed(1)}s to be served`);
431
- target = capped;
432
- }
433
- }
434
441
  let end = target === null || this.#heldRanges === null ? floor : await longest(target);
435
442
  if (this.#requests.get(request.key) !== request) return undefined;
436
443
  this.#wanted.set(request.key, { wanted: stretches.get(end).bytes, required: minimum });
@@ -466,7 +473,16 @@ export class EncodeInputs {
466
473
  if (result.kind === "needs-memory") this.#wanted.set(request.key, { wanted: minimum, required: minimum });
467
474
  if (result.kind === "result") {
468
475
  Object.assign(result, { from, to: end });
469
- if (result.copyMs > 0) keep(this.#copyRates, result.bytes / result.copyMs);
476
+ // The stretch is copied after the answer, while the run opens; the rate
477
+ // is the whole copy's, learned once it is done.
478
+ const copied = result.whole ?? Promise.resolve(true);
479
+ void copied.then(whole => {
480
+ if (whole && result.copyMs > 0) keep(this.#copyRates, result.bytes / result.copyMs);
481
+ this.#log(`encode input #${from}..#${end} of ${addressOf(output)}: ` + (whole
482
+ ? `stretch copied, ${result.bytes} bytes in ${result.copyMs}ms of copying since it was admitted`
483
+ : result.copyFailed?.() ? "the stretch could not be copied: a piece it needs is no longer held"
484
+ : "released before its stretch was copied"));
485
+ });
470
486
  }
471
487
  return result;
472
488
  }
@@ -228,6 +228,7 @@ export class EncodeOrchestrator {
228
228
  segmentCoverage = null,
229
229
  planSoon = null,
230
230
  inputDemandChanged = null,
231
+ inputsUnplaced = null,
231
232
  describeWaits = null,
232
233
  admission = null,
233
234
  logger,
@@ -264,6 +265,9 @@ export class EncodeOrchestrator {
264
265
  // loop that calls it. Absent in a test, where the clock is the test's own.
265
266
  this.planSoon = typeof planSoon === "function" ? planSoon : () => undefined;
266
267
  this.inputDemandChanged = typeof inputDemandChanged === "function" ? inputDemandChanged : () => undefined;
268
+ // Told, after each plan of an output, where that plan places runs and
269
+ // where the runs it kept end, so inputs prepared for anywhere else go back.
270
+ this.inputsUnplaced = typeof inputsUnplaced === "function" ? inputsUnplaced : () => undefined;
267
271
  }
268
272
 
269
273
  /**
@@ -882,6 +886,20 @@ export class EncodeOrchestrator {
882
886
  coverage.claim(action.run, action.from, endOfRun({ from: action.from, to: action.to }));
883
887
  }
884
888
 
889
+ // AN INPUT IS PREPARED ONLY FOR A START THIS PLAN PLACES: one it starts or
890
+ // holds back (a copied picture's input may begin one number earlier), or
891
+ // the number after a run it left alive, whose next stretch is copied while
892
+ // it works. Any other copy shares the torrent thread with the one a viewer
893
+ // waits for (torrent-tv/meta#178).
894
+ const starts = new Set();
895
+ for (const action of actions) {
896
+ if (action.type !== "start" && action.type !== "move") continue;
897
+ starts.add(action.from);
898
+ starts.add(action.from - 1);
899
+ }
900
+ for (const run of this.runsOn(address)) if (run.isAlive) starts.add(run.to + 1);
901
+ this.inputsUnplaced(address, starts);
902
+
885
903
  // NOBODY IS MAKING WHAT SOMEBODY IS WAITING FOR. Said here, with the numbers
886
904
  // the decision was taken from, because it is the one state in which a viewer
887
905
  // waits for ever and every line above it reads as a healthy proxy.
@@ -1178,7 +1178,16 @@ export class EncodeRuns {
1178
1178
  // 48.9 s after run #1..#32 ended, then the start of #33..#892, and the
1179
1179
  // viewer stood 69 s (torrent-tv/meta#166). Asked now, it is copied while
1180
1180
  // this run works and is ready when the plan places the next run there.
1181
- if (inputEnd < mayReach) this.#host.encodeInputs?.take(session, inputEnd + 1, mayReach);
1181
+ // It waits for this run's own stretch to be copied: that copy goes on
1182
+ // while the run opens and FFmpeg's reads wait for it, and a second copy
1183
+ // beside it on the same thread would slow it (torrent-tv/meta#178).
1184
+ if (inputEnd < mayReach) {
1185
+ const askNext = () => {
1186
+ if (this.isLive(session) && run.isAlive) this.#host.encodeInputs?.take(session, inputEnd + 1, mayReach);
1187
+ };
1188
+ if (admittedInput.whole) void admittedInput.whole.then(whole => { if (whole) askNext(); });
1189
+ else askNext();
1190
+ }
1182
1191
  } else if (admittedInput) {
1183
1192
  run.admittedInputKeys = new Map([[safeIndex, inputKey]]);
1184
1193
  let nextIndex = startIndex + 1;
@@ -1252,10 +1261,14 @@ export class EncodeRuns {
1252
1261
  const pieceSeconds = Number(grid?.[run.from + 1]) - Number(grid?.[run.from]);
1253
1262
  const speed = run.speedX > 0 ? run.speedX : this.#host.encodeOrchestrator.speedOn?.(session.outputKey) ?? 0;
1254
1263
  if (!Number.isFinite(ended.firstNamedMs) || !(pieceSeconds > 0) || !(speed > 0)) return;
1255
- const openSeconds = Math.max(0, ended.firstNamedMs / 1000 - pieceSeconds / speed);
1264
+ // FFmpeg's reads may have waited for the stretch being copied; that wait
1265
+ // is the copy's, and the stretch is sized from the open (torrent-tv/meta#178).
1266
+ const waitedMs = Number.isFinite(ended.firstNamedWallMs) ? run.originalInput?.waitedBefore?.(ended.firstNamedWallMs) ?? 0 : 0;
1267
+ const openSeconds = Math.max(0, (ended.firstNamedMs - waitedMs) / 1000 - pieceSeconds / speed);
1256
1268
  this.#host.encodeOrchestrator.noteOpen?.(session.outputKey ?? "", openSeconds);
1257
1269
  this.#host.logger.info(`encode input of run #${run.from}..#${run.to} on ${session.outputKey}: opened in ` +
1258
- `${openSeconds.toFixed(2)}s (first piece named at ${ended.firstNamedMs}ms, ${pieceSeconds.toFixed(3)}s of film at ${speed.toFixed(2)}x)`);
1270
+ `${openSeconds.toFixed(2)}s (first piece named at ${ended.firstNamedMs}ms, ${pieceSeconds.toFixed(3)}s of film at ${speed.toFixed(2)}x` +
1271
+ `${waitedMs > 0 ? `, ${waitedMs}ms of it waiting for the stretch to be copied, not counted` : ""})`);
1259
1272
  }
1260
1273
 
1261
1274
  /**
@@ -3,6 +3,15 @@ import { createHash } from "node:crypto";
3
3
  /** Retain complete original byte ranges under the encoder's input allowance.
4
4
  * The available-only reader acquires complete pieces atomically before copying;
5
5
  * owned buffers then outlive torrent eviction without keeping duplicate pins.
6
+ *
7
+ * THE RUN STARTS ONCE WHAT FFMPEG OPENS WITH IS COPIED. Each source's largest
8
+ * range — the stretch of film — is copied after the rest, and the answer comes
9
+ * back before it: FFmpeg's start and its reads of the header and the index then
10
+ * overlap that copy, and a read inside the stretch waits for it. Field
11
+ * 2026-10-12, after a seek back: 126.5 MB copied in 16.3 s before the encoder
12
+ * was started, then 10.1 s of its own open, and the viewer stood 33 s
13
+ * (torrent-tv/meta#178). `whole` settles when everything is copied — true — or
14
+ * when the copy failed or the input was released first — false.
6
15
  */
7
16
  export async function admitOriginalInput({ sources, reserve, readRanges, now = Date.now }) {
8
17
  const bytes = originalInputBytes(sources);
@@ -15,27 +24,84 @@ export async function admitOriginalInput({ sources, reserve, readRanges, now = D
15
24
  // How long the copy itself took, apart from any wait for memory or bytes
16
25
  // before it: the figure a run's stretch is sized from, read where the copy is.
17
26
  let copyMs = 0;
27
+ // Per source, the range copied after the answer: its position in the
28
+ // source's ranges, and the copy's promise once it has begun.
29
+ const later = [];
30
+ const read = async (source, ranges) => {
31
+ const startedAt = now();
32
+ const buffers = await readRanges(source, ranges, bytes);
33
+ copyMs += Math.max(0, now() - startedAt);
34
+ if (buffers === null) return null;
35
+ if (!Array.isArray(buffers) || buffers.length !== ranges.length || buffers.some((buffer, index) =>
36
+ !Buffer.isBuffer(buffer) || buffer.length !== ranges[index][1] - ranges[index][0] + 1)) {
37
+ throw new Error("Original input reader returned incomplete ranges.");
38
+ }
39
+ return buffers;
40
+ };
18
41
  try {
19
42
  for (const source of sources) {
20
- const startedAt = now();
21
- const buffers = await readRanges(source, source.input.ranges, bytes);
22
- copyMs += Math.max(0, now() - startedAt);
23
- if (buffers === null) return { kind: "needs-bytes", sourceKey: source.sourceKey, fileIndex: source.fileIndex, ranges: source.input.ranges };
24
- if (!Array.isArray(buffers) || buffers.length !== source.input.ranges.length || buffers.some((buffer, index) =>
25
- !Buffer.isBuffer(buffer) || buffer.length !== source.input.ranges[index][1] - source.input.ranges[index][0] + 1)) {
26
- throw new Error("Original input reader returned incomplete ranges.");
27
- }
43
+ const { ranges } = source.input;
44
+ const largest = ranges.reduce((best, range, index) =>
45
+ range[1] - range[0] > ranges[best][1] - ranges[best][0] ? index : best, 0);
46
+ const deferred = ranges.length > 1 ? largest : -1;
47
+ const first = ranges.filter((_, index) => index !== deferred);
48
+ const buffers = await read(source, first);
49
+ if (buffers === null) return { kind: "needs-bytes", sourceKey: source.sourceKey, fileIndex: source.fileIndex, ranges };
28
50
  // THE INPUT IS NAMED BY WHAT IT IS, NOT BY ITS BYTES. Every source is a
29
51
  // torrent whose pieces are verified before they are stored, and a read
30
52
  // gives every byte or none, so the same source, file, ranges and
31
53
  // selections are the same bytes. Hashing the bytes as well took 13.45 s
32
54
  // of 180 s of the main thread on a Matroska film whose picture and sound
33
55
  // copy the same ~30 MB a piece (torrent-tv/meta#178).
34
- hash.update(JSON.stringify([source.sourceKey, source.fileIndex, source.input.ranges, source.input.selections]));
35
- held.set(source.fileIndex, { source, buffers });
56
+ hash.update(JSON.stringify([source.sourceKey, source.fileIndex, ranges, source.input.selections]));
57
+ const entry = { source, buffers: ranges.map((_, index) => index === deferred ? null : buffers[index - (deferred >= 0 && index > deferred ? 1 : 0)]) };
58
+ held.set(source.fileIndex, entry);
59
+ if (deferred >= 0) later.push({ entry, position: deferred });
36
60
  }
37
61
  let released = false;
62
+ let failed = false;
38
63
  retained = true;
64
+ // A stretch is copied one piece of the torrent at a time, so a release
65
+ // stops it at the next piece: the thread it is copied on serves every other
66
+ // read too, and a copy nobody will use went on to its end (torrent-tv/meta#178).
67
+ const copyLater = async job => {
68
+ const { source } = job.entry;
69
+ const [first, last] = source.input.ranges[job.position];
70
+ const unit = Number(source.pieceLength) > 0 ? Number(source.pieceLength) : last - first + 1;
71
+ const target = Buffer.allocUnsafeSlow(last - first + 1);
72
+ try {
73
+ for (let at = first; at <= last; at += unit) {
74
+ if (released) return false;
75
+ const buffers = await read(source, [[at, Math.min(last, at + unit - 1)]]);
76
+ if (buffers === null) { failed = true; return false; }
77
+ buffers[0].copy(target, at - first);
78
+ }
79
+ } catch {
80
+ failed = true;
81
+ return false;
82
+ }
83
+ if (released) return false;
84
+ job.entry.buffers[job.position] = target;
85
+ return true;
86
+ };
87
+ // The stretches, one source after another: they share one reader, and the
88
+ // first source's stretch is the one FFmpeg reaches first.
89
+ const whole = (async () => {
90
+ for (const job of later) {
91
+ if (released) return false;
92
+ job.copy = job.copy ?? copyLater(job);
93
+ if (!await job.copy) return false;
94
+ }
95
+ return !released;
96
+ })();
97
+ // A read of a stretch waits for its copy, which begins at once for the
98
+ // stretch asked for even when another is still being copied before it.
99
+ const copied = (entry, position) => {
100
+ const job = later.find(candidate => candidate.entry === entry && candidate.position === position);
101
+ if (!job) return Promise.resolve(entry.buffers[position] !== null);
102
+ job.copy = job.copy ?? copyLater(job);
103
+ return job.copy;
104
+ };
39
105
  // What FFmpeg asked of this input, for the run's own account of where its
40
106
  // time went: field 2026-10-08, a run took 6-9 s for a piece FFmpeg makes in
41
107
  // 1.6 s from a file, and nothing said whether it was waiting on its input.
@@ -45,12 +111,38 @@ export async function admitOriginalInput({ sources, reserve, readRanges, now = D
45
111
  // whether all of it was (torrent-tv/meta#177).
46
112
  /** @type {{ fileIndex: number, start: number, end: number, written: number, handedOver: boolean, at: number }[]} */
47
113
  const answers = [];
114
+ // When FFmpeg's reads waited for a stretch still being copied.
115
+ /** @type {{ from: number, to: number | null }[]} */
116
+ const waits = [];
117
+ const answer = (entry, position, start, stop) => {
118
+ const now = Date.now();
119
+ reads.count += 1;
120
+ reads.bytes += stop - start + 1;
121
+ reads.firstAt ||= now;
122
+ reads.lastAt = now;
123
+ const [from] = entry.source.input.ranges[position];
124
+ return { length: entry.source.input.fileLength, end: stop, bytes: entry.buffers[position].subarray(start - from, stop - from + 1) };
125
+ };
48
126
  return {
49
- kind: "result", original: true, sources, bytes, copyMs, fingerprint: hash.digest("hex"),
127
+ kind: "result", original: true, sources, bytes, fingerprint: hash.digest("hex"),
128
+ // What the copy took so far; final once `whole` has settled.
129
+ get copyMs() { return copyMs; },
130
+ whole,
131
+ // Bytes of the stretches still to be copied when the answer was given.
132
+ deferredBytes: later.reduce((sum, job) => {
133
+ const [first, last] = job.entry.source.input.ranges[job.position];
134
+ return sum + last - first + 1;
135
+ }, 0),
136
+ copyFailed: () => failed,
50
137
  // No track's own end: the container names the bytes an interval needs,
51
138
  // not where each track stops. The final piece is judged by the input
52
139
  // reaching the end of the file instead (`piece-completeness.js`).
53
140
  tracks: sources.flatMap(source => (source.input.selections ?? []).map(selection => ({ track: selection.track }))),
141
+ /**
142
+ * The answer to a read from `start`, or null when it lies outside what
143
+ * the run holds. Inside a stretch still being copied the answer is a
144
+ * promise of the same, settled once the stretch is copied.
145
+ */
54
146
  read(fileIndex, start, end, partial = false) {
55
147
  if (released) return null;
56
148
  const entry = held.get(fileIndex);
@@ -61,12 +153,22 @@ export async function admitOriginalInput({ sources, reserve, readRanges, now = D
61
153
  const position = ranges.findIndex(([from, to]) => from <= start && (partial ? start : stop) <= to);
62
154
  if (position < 0) return null;
63
155
  stop = Math.min(stop, ranges[position][1]);
64
- const now = Date.now();
65
- reads.count += 1;
66
- reads.bytes += stop - start + 1;
67
- reads.firstAt ||= now;
68
- reads.lastAt = now;
69
- return { length: fileLength, end: stop, bytes: entry.buffers[position].subarray(start - ranges[position][0], stop - ranges[position][0] + 1) };
156
+ if (entry.buffers[position] !== null) return answer(entry, position, start, stop);
157
+ const wait = { from: Date.now(), to: null };
158
+ waits.push(wait);
159
+ return copied(entry, position).then(ok => {
160
+ wait.to = Date.now();
161
+ return ok && !released ? answer(entry, position, start, stop) : null;
162
+ });
163
+ },
164
+ /**
165
+ * Wall-clock milliseconds FFmpeg's reads spent waiting for a stretch to
166
+ * be copied before `until`. A run's open is measured from its first piece,
167
+ * and this wait is the copy's, not the open's: counted in, the stretch
168
+ * sized from the open would grow by its own copy time.
169
+ */
170
+ waitedBefore(until) {
171
+ return waits.reduce((sum, wait) => sum + Math.max(0, Math.min(wait.to ?? until, until) - wait.from), 0);
70
172
  },
71
173
  lengthOf: fileIndex => held.get(fileIndex)?.source.input.fileLength ?? null,
72
174
  reads: () => ({ ...reads, answers: describeAnswers(answers) }),
@@ -98,7 +98,6 @@ import { contentOf, LocalObservations, PROXY_ROOT } from "../encode/LocalObserva
98
98
  import { configurationKeyOf } from "../encode/fingerprint.js";
99
99
  import { qualityStateOf } from "../encode/quality/OutputQualityState.js";
100
100
  import { downloadShare } from "../storage/processor-share.js";
101
- import { secondsToServe, secondsUntilPiece } from "../encode/input-deadline.js";
102
101
  import { ENCODE_RUN_STATE, processCanBeSignalled } from "../encode/encode-run-state.js";
103
102
 
104
103
  // Where a variant and an audio rendition live under a session — `v/<height>/…`
@@ -960,6 +959,9 @@ export function wireOutputs({
960
959
  inputDemandChanged: (address, windows) => {
961
960
  for (const output of parts.outputs.outputsOn(address)) parts.encodeInputs?.retain(output, windows);
962
961
  },
962
+ inputsUnplaced: (address, starts) => {
963
+ for (const output of parts.outputs.outputsOn(address)) parts.encodeInputs?.withdrawUnplaced(output, starts);
964
+ },
963
965
  admission: parts.admission,
964
966
  // What the viewers actually waited for, by band. The ledger is the
965
967
  // priority layer's; the encoding is handed a way to ask it.
@@ -1014,19 +1016,6 @@ export function wireOutputs({
1014
1016
  index >= window.from && index <= window.to && window.urgent === true),
1015
1017
  // The piece's rank in the output's own map, said with an input's state.
1016
1018
  rankAt: (output, index) => parts.encodeOrchestrator?.rankAt?.(output.outputKey ?? "", index) ?? null,
1017
- // WHEN THE VIEWER REACHES A PIECE: the output map's zones state it for their
1018
- // near edge, and a piece inside a zone is that much film further on.
1019
- deadlineOf: (output, index) => secondsUntilPiece(parts.encodeOrchestrator?.wantedSegmentsOn(output.outputKey ?? "") ?? [],
1020
- output.timeline?.published ?? output.timeline?.boundaries, index),
1021
- // HOW LONG A RUN STARTED AT `from` TAKES TO SERVE `serves`: its open, the
1022
- // pieces it encodes up to and including `serves`, and the next one where a
1023
- // piece waits for the file after it.
1024
- firstPieceIn: (output, from, serves) => secondsToServe({
1025
- open: parts.encodeOrchestrator?.openOf?.(output.outputKey ?? "")?.seconds,
1026
- speed: parts.encodeOrchestrator?.speedOn?.(output.outputKey ?? ""),
1027
- grid: output.timeline?.published ?? output.timeline?.boundaries,
1028
- from, serves, waitsForNext: !parts.encodeRuns.publishesAlone(output)
1029
- }),
1030
1019
  log: line => logger.info(line),
1031
1020
  changed: (output, result) => {
1032
1021
  if (result.kind === "terminal") {
@@ -1,52 +0,0 @@
1
- /**
2
- * @file When the viewer reaches a piece, and how long a run takes to serve it:
3
- * the two terms that keep an encoder input's copy from making the viewer wait
4
- * (torrent-tv/meta#178).
5
- *
6
- * Field 2026-10-12, an AVI on Home Assistant: after a seek back to 100 s the
7
- * input of run `#27..#224` — 222.4 MB, copied in 7.7 s — was prepared ahead of
8
- * a piece the viewer reached about 8 s later, and the picture stood 13.2 s. A
9
- * stretch is sized so that its copy takes no longer than an open; for a piece
10
- * the viewer reaches soon the copy has to leave room for the run to serve it.
11
- */
12
-
13
- /**
14
- * Seconds until the viewer reaches piece `index`, from the output's map.
15
- *
16
- * A zone states `withinSeconds` for its near edge (`output/Timeline.js`); a
17
- * piece inside it is that much film further on. The nearest zone holding the
18
- * piece decides. Null where no zone holds it or the grid is unknown.
19
- *
20
- * @param {{ from: number, to: number, withinSeconds?: number }[]} zones
21
- * @param {number[] | null | undefined} grid - Where each piece begins, in seconds of film.
22
- * @param {number} index
23
- * @returns {number | null}
24
- */
25
- export function secondsUntilPiece(zones, grid, index) {
26
- if (!Array.isArray(grid) || !Number.isFinite(grid[index])) return null;
27
- let nearest = null;
28
- for (const zone of zones) {
29
- if (!(index >= zone.from && index <= zone.to) || !Number.isFinite(zone.withinSeconds) || !Number.isFinite(grid[zone.from])) continue;
30
- const seconds = Math.max(0, zone.withinSeconds) + (grid[index] - grid[zone.from]);
31
- if (nearest === null || seconds < nearest) nearest = seconds;
32
- }
33
- return nearest;
34
- }
35
-
36
- /**
37
- * Seconds from a run's start at `from` to piece `serves` being served.
38
- *
39
- * The open (spawn and first input, without encoding its first piece, as
40
- * `EncodeRuns` measures it), the film from `from` through `serves` at the
41
- * output's speed, and — where a piece is published only when the file after it
42
- * closes — that next piece too. Null when a term is unknown.
43
- *
44
- * @param {{ open?: number | null, speed?: number | null, grid?: number[] | null, from: number, serves: number, waitsForNext?: boolean }} params
45
- * @returns {number | null}
46
- */
47
- export function secondsToServe({ open, speed, grid, from, serves, waitsForNext = false }) {
48
- if (!Number.isFinite(open) || open < 0 || !Number.isFinite(speed) || !(speed > 0) || !Array.isArray(grid)) return null;
49
- const last = waitsForNext && Number.isFinite(grid[serves + 2]) ? serves + 2 : serves + 1;
50
- const film = grid[last] - grid[from];
51
- return Number.isFinite(film) && film > 0 ? open + film / speed : null;
52
- }