@torrent-tv/proxy 2.97.16 → 2.97.18

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.16",
3
+ "version": "2.97.18",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "repository": {
@@ -19,14 +19,15 @@ import { recordViewerReport } from "../../../../services/viewer/report-intake.js
19
19
  *
20
20
  * @param {import("fastify").FastifyRequest} req
21
21
  * @param {import("fastify").FastifyReply} reply
22
- * @param {{ outputs: { get: (id: string) => object | undefined }, viewers: object, renditions: { viewerPlays: Function, noteViewerReported: Function }, quality: { noteViewerReported: Function } }} deps -
22
+ * @param {{ outputs: { get: (id: string) => object | undefined }, viewers: object, renditions: { viewerPlays: Function, noteViewerReported: Function }, quality: { noteViewerReported: Function }, planSoon?: () => void, segmentAt?: (output: object, seconds: number) => number | null }} deps -
23
23
  * The live outputs, the registry of viewers, and the rungs of a picture. The
24
24
  * VIEWER component takes the statement; which rung a stated height is, is the
25
25
  * encoding component's. This route's whole job is turning a request into
26
26
  * those calls and their answer into a status code.
27
27
  * @returns {Promise<void>}
28
28
  */
29
- export async function handleApiTranscodeSessionNetReportPost(req, reply, { outputs, viewers, renditions, quality }) {
29
+ export async function handleApiTranscodeSessionNetReportPost(req, reply,
30
+ { outputs, viewers, renditions, quality, planSoon = () => {}, segmentAt = () => null }) {
30
31
  const sessionId = typeof req.params.sessionId === "string" ? req.params.sessionId : "";
31
32
  const body = req.body && typeof req.body === "object" && !Array.isArray(req.body) ? req.body : {};
32
33
  // WHAT THIS REPORT IS. A statement by one viewer about itself: where the
@@ -100,6 +101,10 @@ export async function handleApiTranscodeSessionNetReportPost(req, reply, { outpu
100
101
  Number.isFinite(positionSeconds) && positionSeconds >= 0 ? positionSeconds : undefined
101
102
  );
102
103
  }
104
+ // Where they stood and whether they were playing or waiting, before this
105
+ // report: what the encoders are planned from (`movedForThePlan`).
106
+ const before = viewers.get?.(consumerId);
107
+ const was = before ? { seconds: before.position?.seconds ?? null, playing: before.playing, waiting: before.waiting } : null;
103
108
  const recorded = recordViewerReport({
104
109
  outputs,
105
110
  viewers,
@@ -127,6 +132,14 @@ export async function handleApiTranscodeSessionNetReportPost(req, reply, { outpu
127
132
  if (!recorded) {
128
133
  return reply.code(404).send({ error: "Transcode session was not found." });
129
134
  }
135
+ // A VIEWER WHO MOVED INTO ANOTHER PIECE, or started or stopped playing or
136
+ // waiting, changed the maps the encoders are placed from; the plan is taken
137
+ // again now. Recorded and not re-planned, a soundtrack the viewer was
138
+ // stopped for waited 59 s behind a picture that had long left the urgent
139
+ // band, because only the end of that picture's run planned again (Home
140
+ // Assistant 2026-10-12, torrent-tv/meta#178).
141
+ const after = viewers.get?.(consumerId);
142
+ if (after && movedForThePlan(was, after, outputs.get(sessionId), segmentAt)) planSoon();
130
143
  // After the statement is recorded, so what is judged is what they just said:
131
144
  // a move of theirs to another limit may no longer be wanted.
132
145
  renditions.noteViewerReported(sessionId, consumerId);
@@ -136,3 +149,23 @@ export async function handleApiTranscodeSessionNetReportPost(req, reply, { outpu
136
149
  void quality.noteViewerReported(sessionId, consumerId);
137
150
  return reply.code(204).send();
138
151
  }
152
+
153
+ /**
154
+ * Whether a report changed what the encoders are planned from: the piece the
155
+ * viewer stands in, or whether they play or wait.
156
+ *
157
+ * @param {{ seconds: number | null, playing: boolean, waiting: boolean } | null} was
158
+ * @param {{ position?: { seconds: number } | null, playing: boolean, waiting: boolean }} now
159
+ * @param {object | undefined} output
160
+ * @param {(output: object, seconds: number) => number | null} segmentAt
161
+ * @returns {boolean}
162
+ */
163
+ export function movedForThePlan(was, now, output, segmentAt) {
164
+ if (!was) return true;
165
+ if (was.playing !== now.playing || was.waiting !== now.waiting) return true;
166
+ const seconds = now.position?.seconds;
167
+ if (!Number.isFinite(seconds)) return false;
168
+ if (!Number.isFinite(was.seconds)) return true;
169
+ if (!output) return false;
170
+ return segmentAt(output, was.seconds) !== segmentAt(output, seconds);
171
+ }
package/server.js CHANGED
@@ -556,6 +556,9 @@ export async function startProxyServer({
556
556
  const known = segmentSourceRanges.get(rangesKey);
557
557
  if (known) { convert(known); continue; }
558
558
  if (waiting.has(zone.outputKey)) continue;
559
+ // What is resolved so far is stated before a lower zone is worked out
560
+ // (DownloadMaps, torrent-tv/meta#178).
561
+ map.workingOut?.(zone.priority, converted);
559
562
  if (direct) {
560
563
  // Asked per zone too: a soundtrack of an AVI is read from its own
561
564
  // packets, which the same index states (torrent-tv/meta#166).
@@ -568,9 +571,15 @@ export async function startProxyServer({
568
571
  value = new SegmentInputs({ index, tracks: wanted }).forInterval({ ...interval, mode: track => modes.get(track) });
569
572
  }
570
573
  } catch { value = null; }
574
+ // A zone worked out gives the thread back before the next: worked out
575
+ // in one stretch, the first pass over a film's 4000 zones held the main
576
+ // thread 5-7 s while FFmpeg's first open waited for its answers. What
577
+ // was worked out is kept, so a pass a newer map ends is not lost
578
+ // (torrent-tv/meta#178).
579
+ if (value?.kind === "result") segmentSourceRanges.set(rangesKey, value.ranges);
580
+ await new Promise(resolve => setImmediate(resolve));
571
581
  if (!map.isCurrent()) return [];
572
582
  if (value?.kind === "result") {
573
- segmentSourceRanges.set(rangesKey, value.ranges);
574
583
  convert(value.ranges);
575
584
  continue;
576
585
  }
@@ -1301,7 +1310,12 @@ export async function startProxyServer({
1301
1310
  outputs: outputParts.outputs,
1302
1311
  viewers: outputParts.viewers,
1303
1312
  renditions: outputParts.renditions,
1304
- quality: outputParts.quality
1313
+ quality: outputParts.quality,
1314
+ planSoon: () => outputParts.encodeRuns.planEncodersSoon(),
1315
+ segmentAt: (output, seconds) => {
1316
+ // A cut table not built yet has no pieces to move between.
1317
+ try { return outputParts.outputTimes.segmentIndexForTime(output, seconds); } catch { return null; }
1318
+ }
1305
1319
  })
1306
1320
  );
1307
1321
  app.post("/api/transcode-sessions/:sessionId/fragment-far", async (req, reply) =>
@@ -130,6 +130,17 @@ export class EncodeOrchestrator {
130
130
  * @type {{ urgent: boolean, priority: number, address: string, index: number } | null}
131
131
  */
132
132
  #leading = null;
133
+ /**
134
+ * Outputs whose start the last plan held back behind a more urgent encoder,
135
+ * and the band of the number it would have started at. Looked at again
136
+ * whenever that encoder moves on (`shareTheMachine`), because a closed piece
137
+ * is what lowers its band: placed only by a full plan, a soundtrack the
138
+ * viewer was stopped for waited 59 s behind a picture that had long left
139
+ * the urgent band (Home Assistant 2026-10-12, torrent-tv/meta#178).
140
+ *
141
+ * @type {Map<string, { urgent: boolean, priority: number }>}
142
+ */
143
+ #heldBack = new Map();
133
144
 
134
145
  /** What a stop and a start have cost on this host. */
135
146
  #costs = new RunCosts();
@@ -689,7 +700,10 @@ export class EncodeOrchestrator {
689
700
  shareTheMachine() {
690
701
  const leading = this.#leadingBand();
691
702
  this.#leading = leading;
692
- if (!leading) return;
703
+ if (!leading) {
704
+ this.#placeHeldBack(null);
705
+ return;
706
+ }
693
707
  for (const [address, runs] of this.#runs) {
694
708
  for (const run of runs) {
695
709
  if (!run.isAlive || run.isStopping || typeof run.pause !== "function") continue;
@@ -702,6 +716,22 @@ export class EncodeOrchestrator {
702
716
  }
703
717
  }
704
718
  }
719
+ this.#placeHeldBack(leading);
720
+ }
721
+
722
+ /**
723
+ * Plan again every output whose start was held back behind what is no
724
+ * longer more urgent than it: the start is placed now, by the same plan,
725
+ * rather than at whatever event next re-plans everything.
726
+ *
727
+ * @param {{ urgent: boolean, priority: number } | null} leading
728
+ */
729
+ #placeHeldBack(leading) {
730
+ for (const [address, band] of [...this.#heldBack]) {
731
+ if (leading && compareUrgency(band, leading) > 0) continue;
732
+ this.#heldBack.delete(address);
733
+ this.#reconcileOne(address);
734
+ }
705
735
  }
706
736
 
707
737
  /**
@@ -916,6 +946,8 @@ export class EncodeOrchestrator {
916
946
  // a 159 090-line file, 31 % of the log, all of one wedge.
917
947
  const wanting = firstUnmetWant(coverage, windows);
918
948
  const stillRunning = this.runsOn(address).filter((run) => run.isAlive);
949
+ if (heldBack) this.#heldBack.set(address, this.#bandAt(address, heldBack.from));
950
+ else this.#heldBack.delete(address);
919
951
  if (wanting === null || stillRunning.length > 0) {
920
952
  this.#lastUnmet.delete(address);
921
953
  } else if (heldBack) {
@@ -43,6 +43,8 @@ export class AviContainer extends Container {
43
43
  /** The index alone: undefined until read, null when the file states none. */
44
44
  #index = undefined;
45
45
  #layout = null;
46
+ /** The first RIFF's `idx1`: undefined until looked for, null when there is none. */
47
+ #idx1Chunk = undefined;
46
48
 
47
49
  async #readHeaders() {
48
50
  if (this.#headers) return this.#headers;
@@ -124,17 +126,40 @@ export class AviContainer extends Container {
124
126
  }
125
127
 
126
128
 
127
- /** The first RIFF's `movi` list and `idx1` chunk. */
129
+ /**
130
+ * The first RIFF's `movi` list, found without reading past it: the chunk
131
+ * after `movi` lies where the first RIFF ends, which on a cold start is a
132
+ * piece of its own off the swarm.
133
+ */
128
134
  async #topLevel() {
129
135
  if (this.#layout) return this.#layout;
130
136
  const header = await this.readRange(0, 11);
131
- const chunks = await riffChunks(this.readRange, 12, 8 + header.readUInt32LE(4));
132
- const movi = chunks.find(chunk => chunk.id === "LIST" && chunk.type === "movi");
137
+ const end = 8 + header.readUInt32LE(4);
138
+ const before = await riffChunks(this.readRange, 12, end, "movi");
139
+ const movi = before.find(chunk => chunk.id === "LIST" && chunk.type === "movi");
133
140
  if (!movi) throw new Error("AVI media list is absent.");
134
- this.#layout = { movi, idx1: chunks.find(chunk => chunk.id === "idx1") ?? null };
141
+ this.#layout = { movi, end, before };
135
142
  return this.#layout;
136
143
  }
137
144
 
145
+ /**
146
+ * The first RIFF's `idx1` chunk, which follows `movi`. Read only for a file
147
+ * with no OpenDML index, as FFmpeg reads it: a loaded OpenDML index marks
148
+ * the index loaded and `avi_load_index` is not called (avidec.c n6.1 and
149
+ * n8.1, `read_odml_index`). Looked for on every file, it cost a cold
150
+ * two-hour OpenDML AVI a round trip to the swarm: its index's own piece was
151
+ * asked for 4.4 s later than it could have been (Home Assistant 2026-10-12,
152
+ * torrent-tv/meta#178).
153
+ */
154
+ async #idx1() {
155
+ if (this.#idx1Chunk !== undefined) return this.#idx1Chunk;
156
+ const { movi, end, before } = await this.#topLevel();
157
+ const after = movi.end + ((movi.end - movi.start) & 1);
158
+ const chunks = [...before, ...(after < end ? await riffChunks(this.readRange, after, end) : [])];
159
+ this.#idx1Chunk = chunks.find(chunk => chunk.id === "idx1") ?? null;
160
+ return this.#idx1Chunk;
161
+ }
162
+
138
163
  /**
139
164
  * What the index states, read once; null when the file states no index.
140
165
  * A shortage of bytes or memory is thrown and nothing is kept, so the next
@@ -144,7 +169,8 @@ export class AviContainer extends Container {
144
169
  if (this.#index !== undefined) return this.#index;
145
170
  const tracks = await this.readTracks();
146
171
  const { streams } = await this.#readHeaders();
147
- const { movi, idx1 } = await this.#topLevel();
172
+ const { movi } = await this.#topLevel();
173
+ const idx1 = streams.some(stream => stream.indexChunks.length) ? null : await this.#idx1();
148
174
  const record = { dispose: () => this.#index?.dispose?.() };
149
175
  const allocation = this.packetMemory?.forRecord?.(record, "index") ?? this.packetMemory;
150
176
  const index = await readAviIndex({ read: this.readRange, fileSize: this.fileSize, movi, idx1, allocation,
@@ -404,8 +430,19 @@ export class AviContainer extends Container {
404
430
  packets.append(track.trackNumber, { pts: stream.times[previous], duration: Math.max(0, end - stream.times[previous]),
405
431
  keyframe: true, ranges: [[stream.starts[previous], stream.starts[previous] + stream.lengths[previous] - 1]] });
406
432
  };
433
+ // THE THREAD IS GIVEN BACK at each portion of the file the table
434
+ // reaches: a two-hour soundtrack is some 300 000 packets, and built in
435
+ // one stretch it held the main thread with the map's first pass for
436
+ // 5-7 s while FFmpeg's first open waited for its answers
437
+ // (torrent-tv/meta#178).
438
+ let portion = -1;
407
439
  for (let at = 0; at < stream.count; at++) {
408
440
  if (!stream.lengths[at]) continue;
441
+ const reached = Math.floor(stream.starts[at] / this.portionBytes);
442
+ if (Number.isFinite(reached) && reached !== portion) {
443
+ if (portion >= 0) await new Promise(resolve => setImmediate(resolve));
444
+ portion = reached;
445
+ }
409
446
  if (previous >= 0) append(stream.times[at]);
410
447
  previous = at;
411
448
  }
@@ -646,6 +646,14 @@ export class ViewerRequests {
646
646
  processingRanges: liveRuns.map(({ progress }) => ({
647
647
  start: progress.startPositionSeconds, end: progress.processedSeconds
648
648
  })),
649
+ // What a run's start costs this output, and the stretches its live
650
+ // runs were given: the forecast starts a run wherever production meets
651
+ // bytes that were not held when the run before was admitted.
652
+ openSeconds: this.#host.openSecondsOf?.(output) ?? null,
653
+ runSpans: liveRuns.map(run => ({
654
+ start: output.timeline?.publishedStartOf?.(run.from),
655
+ end: Number.isInteger(run.to) && run.to >= run.from ? output.timeline?.publishedStartOf?.(run.to + 1) : Number.POSITIVE_INFINITY
656
+ })),
649
657
  bitsPerMediaSecond: Number.isFinite(observedMbps) && observedMbps > 0 ? observedMbps * 1_000_000 :
650
658
  this.#host.initialOutputBitsPerMediaSecondOf?.(output) ?? 0,
651
659
  readings: trackRates,
@@ -705,7 +713,8 @@ export class ViewerRequests {
705
713
  const forecast = withSubtitleReadiness(this.#host.playbackReadiness.predict(input), subtitle,
706
714
  subtitle ? this.#host.subtitleReadyFor?.(viewer) !== false : true);
707
715
  forecast.operations = outputs.flatMap((output, index) => {
708
- const common = { speed: this.#host.playbackReadiness.forecastRate(tracks[index].readings), processedSeconds: tracks[index].processedSeconds };
716
+ const common = { speed: this.#host.playbackReadiness.forecastRate(tracks[index].readings, now, 0, "piece"),
717
+ processedSeconds: tracks[index].processedSeconds };
709
718
  return [
710
719
  ...(output.spec.video ? [{ ...common, track: "video",
711
720
  operation: output.spec.transcodesVideo ? "encode" : "copy",
@@ -855,10 +864,17 @@ export class ViewerRequests {
855
864
  const reading = this.#host.encodeSpeedReadingOf?.(output, now) ?? null;
856
865
  if (Number.isFinite(reading?.speed) && reading.speed > 0 && Number.isFinite(reading.at)) {
857
866
  trend.add(reading.at, reading.speed);
858
- } else {
859
- const projected = Number(this.#host.projectedEncodeSpeedOf?.(output));
867
+ } else if (!trend.snapshot()) {
868
+ // ONLY UNTIL A PIECE IS MEASURED does the prediction stand for the
869
+ // encoder, and it is not kept among the readings. Added on every poll
870
+ // between runs, it put the machine's share of that moment — 1 % at a
871
+ // cold start — on the whole film, and the forecast read 0.03x
872
+ // (torrent-tv/meta#178).
873
+ const projected = Number(this.#host.predictedEncodeSpeedOf?.(output) ?? this.#host.projectedEncodeSpeedOf?.(output));
860
874
  if (Number.isFinite(projected) && projected > 0) {
861
- trend.add(now, projected);
875
+ const seed = new this.#host.playbackReadiness.RateTrend();
876
+ seed.add(now, projected);
877
+ return seed.snapshot();
862
878
  }
863
879
  }
864
880
  return trend.snapshot();
@@ -579,13 +579,16 @@ export function wireOutputs({
579
579
  sourceInputsFor: (output, index) => parts.sourceInputsFor?.(output, index),
580
580
  encodeSpeedReadingOf: (output) => parts.encodeCost.latestSpeedReadingOf(output),
581
581
  projectedEncodeSpeedOf: (output) => parts.encodeCost.projectedSpeedOf(output),
582
+ // The speed the offer predicted for this output when it chose its mode: a
583
+ // forecast with no measured piece yet stands on it rather than on the
584
+ // machine's share of the moment (torrent-tv/meta#178).
585
+ predictedEncodeSpeedOf: (output) => {
586
+ const predicted = Number(qualityStateOf(output).predictedSpeedWhenOffered);
587
+ return Number.isFinite(predicted) && predicted > 0 ? predicted : null;
588
+ },
582
589
  audioDescriptionOf,
583
- processingStartupSecondsOf: (output) => output.spec.carries === "audio-only" ?
584
- audioReadingFor(audioCalibration, { ...audioDescriptionOf(output),
585
- codec: audioDescriptionOf(output)?.codec ?? output.file.media?.audioCodec,
586
- transcode: output.spec.audio?.transcode })?.startupSeconds ?? 0 :
587
- Math.max(0, (startStopCost?.firstByteWaitSec ?? 0) - parts.segmentDurationSec /
588
- parts.encodeCost.projectedSpeedOf(output)),
590
+ openSecondsOf: (output) => runStartSecondsOf(output),
591
+ processingStartupSecondsOf: (output) => runStartSecondsOf(output),
589
592
  initialOutputBitsPerMediaSecondOf: (output) => {
590
593
  const description = audioDescriptionOf(output);
591
594
  const reference = audioReadingFor(audioCalibration, { codec: description?.codec, transcode: false });
@@ -1045,6 +1048,27 @@ export function wireOutputs({
1045
1048
  readFree: freeBytesFor,
1046
1049
  logger
1047
1050
  });
1051
+ /**
1052
+ * What starting a run costs this output before its first piece is encoded:
1053
+ * its own measured open where it has one (`RunCosts.openOf`), else the
1054
+ * host's startup reading of spawn to first piece less that piece's encoding;
1055
+ * for sound, its packaged startup reading.
1056
+ *
1057
+ * @param {object} output
1058
+ * @returns {number}
1059
+ */
1060
+ function runStartSecondsOf(output) {
1061
+ if (output.spec.carries === "audio-only") {
1062
+ return audioReadingFor(audioCalibration, { ...audioDescriptionOf(output),
1063
+ codec: audioDescriptionOf(output)?.codec ?? output.file.media?.audioCodec,
1064
+ transcode: output.spec.audio?.transcode })?.startupSeconds ?? 0;
1065
+ }
1066
+ const open = parts.encodeOrchestrator?.openOf?.(output.outputKey ?? "");
1067
+ if (open?.measured) return open.seconds;
1068
+ return Math.max(0, (startStopCost?.firstByteWaitSec ?? 0) - parts.segmentDurationSec /
1069
+ parts.encodeCost.projectedSpeedOf(output));
1070
+ }
1071
+
1048
1072
  // Realtime-budget monitor: only meaningful for the software encoder with a
1049
1073
  // benchmark (the only path that can pick/step resolution). Cheap no-op scan
1050
1074
  // otherwise.
@@ -74,8 +74,28 @@ export class DownloadMaps {
74
74
  }
75
75
  if (changed) await this.#emit(file);
76
76
  if (revision !== file.revision) return;
77
+ const isCurrent = () => this.#files.get(`${sourceKey}:${fileIndex}`) === file && revision === file.revision;
78
+ // WHAT IS RESOLVED IS STATED WHILE THE REST IS STILL BEING RESOLVED. A pass
79
+ // works the zones out in the order the swarm is to fetch them, and
80
+ // published only at its end it kept the first bytes from the swarm for as
81
+ // long as the far end of the film took: 14.6 s on a cold two-hour AVI, the
82
+ // swarm fetching nothing for 17.5 s while the encoder waited for its next
83
+ // piece (Home Assistant 2026-10-12, torrent-tv/meta#178). The swarm
84
+ // fetches the highest group still missing, so before the pass works out a
85
+ // zone of a lower priority than any before it, what it has resolved is
86
+ // published, with the zones still retained for what it has not reached. A
87
+ // pass that works nothing out — every pass over a film already worked out
88
+ // — states nothing early.
89
+ let stated = Number.POSITIVE_INFINITY;
90
+ const workingOut = (priority, zones) => {
91
+ if (!(priority < stated)) return;
92
+ stated = priority;
93
+ if (zones.length === 0 || !isCurrent()) return;
94
+ this.#setZones(file, withRetained(zones, retained));
95
+ this.#emit(file).catch(() => undefined);
96
+ };
77
97
  const resolved = await this.#resolvePlayback({ sourceKey, fileIndex, durationSeconds, zones: file.sourceZones,
78
- isCurrent: () => this.#files.get(`${sourceKey}:${fileIndex}`) === file && revision === file.revision });
98
+ isCurrent, workingOut });
79
99
  if (revision !== file.revision) return;
80
100
  this.#setZones(file, resolved);
81
101
  return this.#emit(file);
@@ -333,6 +353,19 @@ export class DownloadMaps {
333
353
  }
334
354
  }
335
355
 
356
+ /**
357
+ * Zones resolved so far, with the retained zones of every segment they do not
358
+ * yet resolve again: a segment's bytes are stated once, by its newest reading.
359
+ */
360
+ function withRetained(resolved, retained) {
361
+ const keyOf = zone => typeof zone.outputKey === "string" && Number.isSafeInteger(zone.index) ? `${zone.outputKey}:${zone.index}` : null;
362
+ const resolvedKeys = new Set(resolved.map(keyOf).filter(key => key !== null));
363
+ return [...resolved, ...retained.filter(zone => {
364
+ const key = keyOf(zone);
365
+ return key === null || !resolvedKeys.has(key);
366
+ })];
367
+ }
368
+
336
369
  /** @param {number} since */
337
370
  function emptyStats(since) {
338
371
  return { since, sent: 0, unchanged: 0, zones: [], deliveredMs: [], appliedMs: [],
@@ -49,8 +49,8 @@ const CLIENT_RANGE_START_TOLERANCE_SECONDS = 0.25;
49
49
  * @param {number} horizonSeconds
50
50
  * @returns {number | null}
51
51
  */
52
- export function forecastRate(readings, now = Date.now(), horizonSeconds = 0) {
53
- const curve = rateCurve(readings, now);
52
+ export function forecastRate(readings, now = Date.now(), horizonSeconds = 0, weighting = "last") {
53
+ const curve = rateCurve(readings, now, weighting);
54
54
  if (!curve) {
55
55
  return null;
56
56
  }
@@ -192,7 +192,12 @@ export function predictPlaybackReadiness(input = {}) {
192
192
  [{ start: at, end: add(at, measured ? nearest(fromSeconds(buffered), unit) :
193
193
  rescale(fromSeconds(buffered), unit)), frame: zero }],
194
194
  ready: new Set(Array.isArray(track.readySegmentIndices) ? track.readySegmentIndices : []),
195
- curve: rateCurve(track.readings, now),
195
+ // Film made over the encoder's working time, over the readings the trend
196
+ // keeps: one reading is one closed piece, so the harmonic mean is that
197
+ // ratio. The last reading alone is one piece's content and machine — a
198
+ // first piece of credits read 6.49x and started a cold AVI on 2.4 s of
199
+ // buffer that the next piece, at 0.57x, did not hold (torrent-tv/meta#178).
200
+ curve: rateCurve(track.readings, now, "piece"),
196
201
  bitsPerMediaSecond: averageBitsPerMediaSecond(track, track.segments,
197
202
  new Set(Array.isArray(track.readySegmentIndices) ? track.readySegmentIndices : [])),
198
203
  segments: segmentsOf[trackIndex]
@@ -211,7 +216,10 @@ export function predictPlaybackReadiness(input = {}) {
211
216
  const serviceKey = typeof source.serviceKey === "string" ? source.serviceKey : id;
212
217
  let service = sourceServices.get(serviceKey);
213
218
  if (!service) {
214
- service = { rate: rateCurve(source.downloadRateReadings, now), finish: 0 };
219
+ // Bytes over time across the readings the trend keeps: the last 5 s of a
220
+ // swarm moved between 0.97 and 5.8 MB/s and was carried to the end of the
221
+ // film each time (torrent-tv/meta#178).
222
+ service = { rate: rateCurve(source.downloadRateReadings, now, "time"), finish: 0 };
215
223
  sourceServices.set(serviceKey, service);
216
224
  }
217
225
  sourceState.set(id, { source, service });
@@ -330,6 +338,22 @@ export function predictPlaybackReadiness(input = {}) {
330
338
  }
331
339
  }
332
340
  const trackFinish = trackState.map(({ track }) => finiteNonNegative(track.startupRemainingSeconds));
341
+ // WHERE A RUN STARTS, AND WHAT IT COSTS. A run is given the stretch whose
342
+ // bytes are held when its input is admitted, and the next one starts after
343
+ // it, when more bytes are held, and opens its input first — 6-10 s on an
344
+ // OpenDML AVI. Charged once at the first start only, a cold AVI made of runs
345
+ // of one to five pieces was forecast as one run (torrent-tv/meta#178). So a
346
+ // piece outside the live runs' stretches whose bytes arrive after the run
347
+ // the schedule last started was admitted begins a run of its own, admitted
348
+ // when the piece before it is made and its bytes are held, then opened.
349
+ // The first run's start is `startupRemainingSeconds`, charged once above; a
350
+ // stretch's bound by memory and by its copy time is not modelled.
351
+ const runStarts = trackState.map(({ track }) => ({
352
+ openSeconds: finiteNonNegative(track.openSeconds),
353
+ spans: (Array.isArray(track.runSpans) ? track.runSpans : [])
354
+ .filter(span => Number.isFinite(span?.start) && span.end > span.start),
355
+ admittedAt: finiteNonNegative(track.startupRemainingSeconds) > 0 ? 0 : null
356
+ }));
333
357
  const sourceByteCoverage = new Map([...sourceByteService].map(([id, ranges]) => [id, numericCoverage(ranges)]));
334
358
  const sourceArrivals = new Map([...sourceByteService].map(([id, ranges]) => [id, indexedArrivals(ranges)]));
335
359
  let transferFinish = 0;
@@ -386,7 +410,15 @@ export function predictPlaybackReadiness(input = {}) {
386
410
  // Production accepts a complete held segment input. It cannot overlap its
387
411
  // own source download; reused input retains its original arrival time.
388
412
  const processing = trackState[trackIndex].curve;
389
- const admittedAt = Math.max(trackFinish[trackIndex], sourceArrivalAt);
413
+ let admittedAt = Math.max(trackFinish[trackIndex], sourceArrivalAt);
414
+ if (!job.produced && encodeWork > 0) {
415
+ const run = runStarts[trackIndex];
416
+ const live = run.spans.some(span => segment.startSeconds >= span.start && segment.startSeconds < span.end);
417
+ if (!live && (run.admittedAt === null || sourceArrivalAt > run.admittedAt)) {
418
+ run.admittedAt = admittedAt;
419
+ admittedAt += run.openSeconds;
420
+ }
421
+ }
390
422
  const producedAt = job.produced || encodeWork === 0 ? 0 : processing.finish(encodeWork, admittedAt);
391
423
  trackFinish[trackIndex] = Math.max(trackFinish[trackIndex], producedAt);
392
424
  productions.push({ at: producedAt, trackIndex, segment, requiresSource: sourceArrivalAt > 0 });
@@ -787,11 +819,19 @@ function roundedHalfUp(numerator, denominator) {
787
819
  return 2n * (numerator - floor * denominator) >= denominator ? floor + 1n : floor;
788
820
  }
789
821
 
790
- function rateCurve(readings, now) {
822
+ /**
823
+ * @param {*} readings - A summary (`RateTrend.snapshot`) or the readings themselves.
824
+ * @param {number} now
825
+ * @param {"last" | "piece" | "time"} [weighting] - Which rate the readings
826
+ * stand for: the last one; work per working time where each reading is one
827
+ * piece of film; or quantity per wall time where each reading holds until
828
+ * the next.
829
+ */
830
+ function rateCurve(readings, now, weighting = "last") {
791
831
  if (readings && Number.isFinite(readings.count) && readings.count > 0 &&
792
832
  Number.isFinite(readings.lastAt) && Number.isFinite(readings.lastValue) &&
793
833
  Number.isFinite(readings.meanValue) && Number.isFinite(readings.spanSeconds)) {
794
- return rateCurveFrom(readings, now);
834
+ return rateCurveFrom(readings, weighting);
795
835
  }
796
836
  const ordered = (Array.isArray(readings) ? readings : [])
797
837
  .filter((reading) => Number.isFinite(reading?.at) && Number.isFinite(reading?.value) && reading.value >= 0)
@@ -802,25 +842,38 @@ function rateCurve(readings, now) {
802
842
  return null;
803
843
  }
804
844
 
805
- return rateCurveFrom(summarizeRate(ordered), now);
845
+ return rateCurveFrom(summarizeRate(ordered), weighting);
806
846
  }
807
847
 
808
848
  function summarizeRate(readings) {
809
849
  const latest = readings.at(-1);
850
+ let weighted = 0;
851
+ for (let index = 0; index + 1 < readings.length; index += 1) {
852
+ weighted += readings[index].value * (readings[index + 1].at - readings[index].at);
853
+ }
854
+ const spanMs = latest.at - readings[0].at;
810
855
  return {
811
856
  count: readings.length,
812
857
  lastAt: latest.at,
813
858
  lastValue: latest.value,
814
859
  meanValue: readings.reduce((sum, reading) => sum + reading.value, 0) / readings.length,
815
- spanSeconds: (latest.at - readings[0].at) / 1000
860
+ // Each reading one piece of work: work over the time it took.
861
+ harmonicValue: readings.some(reading => !(reading.value > 0)) ? 0
862
+ : readings.length / readings.reduce((sum, reading) => sum + 1 / reading.value, 0),
863
+ // Each reading holding until the next: quantity over the span.
864
+ timeWeightedValue: spanMs > 0 ? weighted / spanMs : latest.value,
865
+ spanSeconds: spanMs / 1000
816
866
  };
817
867
  }
818
868
 
819
- function rateCurveFrom({ lastValue }) {
820
- // Conditional projection of the current measured service. No exponential
869
+ function rateCurveFrom(summary, weighting = "last") {
870
+ // Conditional projection of the measured service. No exponential
821
871
  // relaxation, smoothing time, slope extrapolation or guessed recovery.
822
872
  // A new measurement changes the integral; polling does not change its rate.
823
- const rate = Math.max(0, lastValue);
873
+ const value = weighting === "piece" && Number.isFinite(summary.harmonicValue) ? summary.harmonicValue
874
+ : weighting === "time" && Number.isFinite(summary.timeWeightedValue) ? summary.timeWeightedValue
875
+ : summary.lastValue;
876
+ const rate = Math.max(0, value);
824
877
  const workBy = (seconds) => rate * Math.max(0, seconds);
825
878
  return {
826
879
  rateAt: () => rate,