@torrent-tv/proxy 2.98.7 → 2.98.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.98.7",
3
+ "version": "2.98.9",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "repository": {
@@ -10,6 +10,8 @@
10
10
  import path from "node:path";
11
11
  import { spawn } from "node:child_process";
12
12
  import { contentionPenalty } from "./contention.js";
13
+ import { LOOKAHEAD_PAUSE_SECONDS } from "./CushionReport.js";
14
+ import { SpeedReadings } from "./SpeedReadings.js";
13
15
  import { ENCODE_RUN_STATE, liveRunsOf, runStateOf, wireState } from "./encode-run-state.js";
14
16
  import { ENCODE_EXIT } from "./encode-exit.js";
15
17
  import { EncodeRun, endOfRun } from "./EncodeRun.js";
@@ -171,6 +173,8 @@ export class EncodeRuns {
171
173
  * @type {WeakMap<object, { cpuSeconds: number, filmSeconds: number, reading: boolean, beside: number }>}
172
174
  */
173
175
  #cpuOfRun = new WeakMap();
176
+ /** Every speed reading of each output's runs, for the start forecast (`SpeedReadings.js`). */
177
+ #speedReadings = new SpeedReadings(LOOKAHEAD_PAUSE_SECONDS);
174
178
 
175
179
  #lastInputToken = 0;
176
180
  #inputFailures = new InputFailures();
@@ -492,6 +496,18 @@ export class EncodeRuns {
492
496
  }
493
497
  }
494
498
 
499
+ /**
500
+ * The speed readings of this output's runs measured after `after`, oldest
501
+ * first, within the forecast's horizon.
502
+ *
503
+ * @param {object} output
504
+ * @param {number} [after] - The time of the last reading the asker has.
505
+ * @returns {Array<{ speed: number, at: number }>}
506
+ */
507
+ speedReadingsOf(output, after) {
508
+ return this.#speedReadings.after(output, after);
509
+ }
510
+
495
511
  /**
496
512
  * The media intervals of a stored piece of this output, or undefined.
497
513
  *
@@ -1179,7 +1195,10 @@ export class EncodeRuns {
1179
1195
  // group before it in hand — the leading pictures the run that starts
1180
1196
  // there cannot make.
1181
1197
  onTail: (name) => this.#keepLeadingPictures(session, admittedInput, name),
1182
- onSpeedMeasured: () => this.#host.noteRunSpeedMeasured?.(session, run),
1198
+ onSpeedMeasured: (reading) => {
1199
+ this.#speedReadings.note(session, reading);
1200
+ this.#host.noteRunSpeedMeasured?.(session, run);
1201
+ },
1183
1202
  onEnded: (ended) => {
1184
1203
  this.#runsByInputToken.delete(inputToken);
1185
1204
  if (admittedInput.original) {
@@ -0,0 +1,45 @@
1
+ /**
2
+ * @file The speed readings of each output's runs, in the order the runs
3
+ * measured them, over the start forecast's horizon (torrent-tv/meta#178).
4
+ *
5
+ * A run announces a reading at each closed piece; whoever forecasts takes every
6
+ * reading after the last one it has. The forecast used to take only the newest
7
+ * reading of a run alive when it asked: on a cold start on Home Assistant the
8
+ * runs were three pieces long and ended between its polls, and of the readings
9
+ * 0.78, 1.80, 2.25, 2.84 and 2.69x it got 0.78 and 2.69x, and held the picture
10
+ * on 0.78x for 13 s (r37).
11
+ */
12
+
13
+ export class SpeedReadings {
14
+ /** @type {WeakMap<object, Array<{ speed: number, at: number }>>} */
15
+ #byOutput = new WeakMap();
16
+ #keepMs;
17
+
18
+ /** @param {number} keepSeconds - How far back a reading is kept: the forecast's horizon. */
19
+ constructor(keepSeconds) {
20
+ this.#keepMs = Math.max(0, keepSeconds) * 1000;
21
+ }
22
+
23
+ /**
24
+ * @param {object} output
25
+ * @param {{ speed: number, at: number } | null} reading
26
+ */
27
+ note(output, reading) {
28
+ if (!output || !(reading?.speed > 0) || !Number.isFinite(reading.at)) return;
29
+ let readings = this.#byOutput.get(output);
30
+ if (!readings) this.#byOutput.set(output, readings = []);
31
+ readings.push({ speed: reading.speed, at: reading.at });
32
+ const cutoff = reading.at - this.#keepMs;
33
+ // As the forecast's own trend keeps them: the newest one before the horizon stays.
34
+ while (readings.length > 1 && readings[1].at <= cutoff) readings.shift();
35
+ }
36
+
37
+ /**
38
+ * @param {object} output
39
+ * @param {number} [after] - The time of the last reading the asker has.
40
+ * @returns {Array<{ speed: number, at: number }>} Oldest first.
41
+ */
42
+ after(output, after = Number.NEGATIVE_INFINITY) {
43
+ return (this.#byOutput.get(output) ?? []).filter(reading => reading.at > after);
44
+ }
45
+ }
@@ -60,11 +60,38 @@ export class ViewerRequests {
60
60
  #host;
61
61
  /** @type {WeakMap<object, Map<string, object>>} */
62
62
  #rateStateBySession = new WeakMap();
63
- /** The pieces of each output as the start forecast read them last; see `#segmentsOf`. */
63
+ /**
64
+ * What the start forecasts cost, said once a minute: how many there were,
65
+ * their time on the main thread, and for each track whether the times of its
66
+ * pieces were kept from the last poll or why they were worked out again. In
67
+ * r37 the forecast took 19.2 s of Home Assistant's main thread in 15 minutes
68
+ * and the times were mostly worked out again (torrent-tv/meta#178).
69
+ */
70
+ #forecastCosts = { since: Date.now(), polls: 0, ms: 0, maxMs: 0, times: new Map() };
71
+
72
+ #noteForecast(ms, reasons) {
73
+ const costs = this.#forecastCosts;
74
+ costs.polls += 1; costs.ms += ms; costs.maxMs = Math.max(costs.maxMs, ms);
75
+ for (const reason of reasons) costs.times.set(reason, (costs.times.get(reason) ?? 0) + 1);
76
+ const now = Date.now();
77
+ if (now - costs.since < 60_000) return;
78
+ logger.info(`start forecasts in ${((now - costs.since) / 1000).toFixed(1)}s: ${costs.polls}, ` +
79
+ `${Math.round(costs.ms)}ms in all, ${(costs.ms / costs.polls).toFixed(1)}ms each, longest ${Math.round(costs.maxMs)}ms; ` +
80
+ `times of a track's pieces: ${[...costs.times].map(([reason, count]) => `${reason} ${count}`).join(", ")}`);
81
+ Object.assign(costs, { since: now, polls: 0, ms: 0, maxMs: 0, times: new Map() });
82
+ }
83
+
84
+ /**
85
+ * The pieces of each output as the start forecast read them last (see
86
+ * `#segmentsOf`), and what the forecast keeps of their time geometry while
87
+ * they stand (`playback-readiness.js` `timescaleOfPieces`).
88
+ *
89
+ * @type {WeakMap<object, { pieces: object[], geometry: object }>}
90
+ */
64
91
  #forecastPieces = new WeakMap();
65
92
 
66
93
  /**
67
- * @param {object} host - `opening`, `activeOutputFor`, `viewerSecondsOn`, `minimumBufferSecondsFor`, `getSourceStats`, `disposeSession`, `planEncodersSoon`, `waitUntilReady`, `encodeRuns`, `encodeSpeedReadingOf`, `outputTimes`, `outputs`, `lookaheadSeconds`, `quality`, `qualityOffer`, `renditions`, `segmentDurationSec`, `segmentStore`, `sourceFiles`, `startupWaitMs`, `viewers`, `acceptsGeneration`, `generationOfRequest`, `noteGivenOutput`, `holdForResponse`, `noteServingVerdict`, `servingVerdictOf`
94
+ * @param {object} host - `opening`, `activeOutputFor`, `viewerSecondsOn`, `minimumBufferSecondsFor`, `getSourceStats`, `disposeSession`, `planEncodersSoon`, `waitUntilReady`, `encodeRuns`, `encodeSpeedReadingsOf`, `outputTimes`, `outputs`, `lookaheadSeconds`, `quality`, `qualityOffer`, `renditions`, `segmentDurationSec`, `segmentStore`, `sourceFiles`, `startupWaitMs`, `viewers`, `acceptsGeneration`, `generationOfRequest`, `noteGivenOutput`, `holdForResponse`, `noteServingVerdict`, `servingVerdictOf`
68
95
  */
69
96
  constructor(host) {
70
97
  this.#host = host;
@@ -658,6 +685,7 @@ export class ViewerRequests {
658
685
  this.#host.initialOutputBitsPerMediaSecondOf?.(output) ?? 0,
659
686
  readings: trackRates,
660
687
  segments,
688
+ geometry: this.#forecastPieces.get(output)?.geometry,
661
689
  readySegmentIndices: [...inventory.keys()],
662
690
  segmentSizesBytes: inventory
663
691
  });
@@ -710,7 +738,10 @@ export class ViewerRequests {
710
738
  // fetched whole by the page before it is chosen, so it never matches here.
711
739
  const subtitle = viewer?.subtitle?.sourceKey === session.file.sourceKey &&
712
740
  viewer.subtitle.fileIndex === session.file.fileIndex ? viewer.subtitle : null;
713
- const forecast = withSubtitleReadiness(this.#host.playbackReadiness.predict(input), subtitle,
741
+ const startedAt = performance.now();
742
+ const predicted = this.#host.playbackReadiness.predict(input);
743
+ this.#noteForecast(performance.now() - startedAt, tracks.map(track => track.geometry?.reason ?? "none"));
744
+ const forecast = withSubtitleReadiness(predicted, subtitle,
714
745
  subtitle ? this.#host.subtitleReadyFor?.(viewer) !== false : true);
715
746
  forecast.operations = outputs.flatMap((output, index) => {
716
747
  const common = { speed: this.#host.playbackReadiness.forecastRate(tracks[index].readings, now, 0, "piece"),
@@ -808,11 +839,12 @@ export class ViewerRequests {
808
839
  #segmentsOf(output, sessionInit, inventory, positionSeconds) {
809
840
  const timeline = output.timeline;
810
841
  const count = timeline?.segmentCount ?? 0;
811
- let kept = this.#forecastPieces.get(output);
812
- if (!kept) {
813
- kept = [];
814
- this.#forecastPieces.set(output, kept);
842
+ let forecast = this.#forecastPieces.get(output);
843
+ if (!forecast) {
844
+ forecast = { pieces: [], geometry: {} };
845
+ this.#forecastPieces.set(output, forecast);
815
846
  }
847
+ const kept = forecast.pieces;
816
848
  const segments = [];
817
849
  for (let index = 0; index < count; index++) {
818
850
  const endSeconds = timeline.publishedStartOf(index + 1);
@@ -861,10 +893,12 @@ export class ViewerRequests {
861
893
  trend = new this.#host.playbackReadiness.RateTrend(this.#host.lookaheadSeconds);
862
894
  state.tracks.set(id, trend);
863
895
  }
864
- const reading = this.#host.encodeSpeedReadingOf?.(output, now) ?? null;
865
- if (Number.isFinite(reading?.speed) && reading.speed > 0 && Number.isFinite(reading.at)) {
866
- trend.add(reading.at, reading.speed);
867
- } else if (!trend.snapshot()) {
896
+ // Every reading since this trend's last, not the newest one at the poll:
897
+ // a run that ended between two polls took its readings with it.
898
+ for (const reading of this.#host.encodeSpeedReadingsOf?.(output, trend.lastAt) ?? []) {
899
+ if (reading.at <= now) trend.add(reading.at, reading.speed);
900
+ }
901
+ if (!trend.snapshot()) {
868
902
  // ONLY UNTIL A PIECE IS MEASURED does the prediction stand for the
869
903
  // encoder, and it is not kept among the readings. Added on every poll
870
904
  // between runs, it put the machine's share of that moment — 1 % at a
@@ -583,7 +583,7 @@ export function wireOutputs({
583
583
  // source preparation's answer, set on these parts by the assembly.
584
584
  subtitleReadyFor: (viewer) => parts.subtitleReadyFor?.(viewer) ?? true,
585
585
  sourceInputsFor: (output, index) => parts.sourceInputsFor?.(output, index),
586
- encodeSpeedReadingOf: (output) => parts.encodeCost.latestSpeedReadingOf(output),
586
+ encodeSpeedReadingsOf: (output, after) => parts.encodeRuns.speedReadingsOf(output, after),
587
587
  projectedEncodeSpeedOf: (output) => parts.encodeCost.projectedSpeedOf(output),
588
588
  // The speed the offer predicted for this output when it chose its mode: a
589
589
  // forecast with no measured piece yet stands on it rather than on the
@@ -84,6 +84,11 @@ export class RateTrend {
84
84
  return true;
85
85
  }
86
86
 
87
+ /** The time of the newest reading kept, or -Infinity. */
88
+ get lastAt() {
89
+ return this.#readings.at(-1)?.at ?? Number.NEGATIVE_INFINITY;
90
+ }
91
+
87
92
  snapshot() {
88
93
  if (this.#readings.length === 0) {
89
94
  return null;
@@ -139,21 +144,16 @@ export function predictPlaybackReadiness(input = {}) {
139
144
  range.end > range.start).map((range) => ({ start: fromSeconds(range.start), end: fromSeconds(range.end) }))
140
145
  : null);
141
146
  // One timescale in which every time of this forecast is a whole number of
142
- // ticks, so that adding and comparing never seek a common denominator.
147
+ // ticks, so that adding and comparing never seek a common denominator. A
148
+ // track's pieces give it the same timescale while they are the same pieces.
143
149
  const unit = commonTimescale([
144
150
  fromSeconds(finiteNonNegative(input.positionSeconds)), fromSeconds(duration),
145
151
  fromSeconds(finiteNonNegative(input.bufferedAheadSeconds)), REPORTED_TIME_ERROR, ...clocks,
146
152
  ...reported.flatMap((ranges) => (ranges ?? []).flatMap(({ start, end }) => [start, end])),
147
- ...tracks.flatMap((track) => (Array.isArray(track.segments) ? track.segments : []).flatMap((segment) => [
148
- ...(segment.mediaRanges?.timescale ? [mediaTime(0n, segment.mediaRanges.timescale)] : []),
149
- ...exactBoundsOf(segment)
150
- ]))
153
+ ...tracks.map((track) => mediaTime(0n, timescaleOfPieces(track)))
151
154
  ]);
152
155
  const zero = mediaTime(0n, unit);
153
- const segmentsOf = tracks.map((track, trackIndex) => predictedMediaSegments(
154
- (Array.isArray(track.segments) ? track.segments : []).map((segment) => ({
155
- ...segment, exact: exactTimesOf(segment), ranges: playerRanges(segment.mediaRanges, rescale(clocks[trackIndex], unit), unit)
156
- })), unit));
156
+ const segmentsOf = tracks.map((track, trackIndex) => placedPieces(track, rescale(clocks[trackIndex], unit), unit));
157
157
  const origin = latest(zero, ...segmentsOf.map((segments) =>
158
158
  segments.find(({ index }) => index === 0)?.ranges?.[0]?.start ?? zero));
159
159
  const at = latest(origin, rescale(fromSeconds(finiteNonNegative(input.positionSeconds)), unit));
@@ -645,10 +645,86 @@ function timesOf(segment) {
645
645
  return segment.exact ?? exactTimesOf(segment);
646
646
  }
647
647
 
648
- /** @returns {import("./media-time.js").MediaTime[]} The finite playlist edges of a piece, exactly. */
649
- function exactBoundsOf(segment) {
650
- const { start, end } = exactTimesOf(segment);
651
- return [start, end].filter(Boolean);
648
+ /**
649
+ * WHAT A TRACK'S PIECES GIVE THE FORECAST'S TIME, KEPT BETWEEN POLLS while
650
+ * they are the same pieces. The forecast runs on every poll of a waiting
651
+ * viewer, and gathering the edges of every remaining piece for the timescale,
652
+ * then placing each on the page's clock, was about 3 s of the proxy's main
653
+ * thread in r35's first 15 minutes on Home Assistant (torrent-tv/meta#178).
654
+ * `track.geometry` is kept by whoever keeps the pieces; without it nothing is
655
+ * kept, and the answer is the same either way: a placement is reused only for
656
+ * pieces with the same edges and read ranges, the same timescale and the same
657
+ * clock offset, so no time is rounded for the reuse. Only the times are kept: a
658
+ * piece whose torrent inputs were told again is a new object with the same
659
+ * times, and it is read with its inputs of this poll. In r37 most polls placed
660
+ * every piece again, the MPEG-TS output having no read ranges to keep them by.
661
+ *
662
+ * @param {{ segments?: object[], geometry?: object }} track
663
+ * @returns {bigint} The least timescale holding every edge and every served tick of its pieces.
664
+ */
665
+ function timescaleOfPieces(track) {
666
+ const segments = Array.isArray(track.segments) ? track.segments : [];
667
+ const geometry = track.geometry;
668
+ const changed = geometry ? piecesChanged(geometry.pieces, segments) : null;
669
+ if (geometry && changed === null) return geometry.timescale;
670
+ if (geometry) geometry.reason = changed;
671
+ const distinct = new Set();
672
+ for (const segment of segments) {
673
+ if (segment.mediaRanges?.timescale) distinct.add(segment.mediaRanges.timescale);
674
+ const { start, end } = exactTimesOf(segment);
675
+ if (start) distinct.add(start.timescale);
676
+ if (end) distinct.add(end.timescale);
677
+ }
678
+ const timescale = commonTimescale([...distinct].map((value) => mediaTime(0n, value)));
679
+ if (geometry) {
680
+ geometry.pieces = segments.map((segment) => ({ index: segment.index, startSeconds: segment.startSeconds,
681
+ endSeconds: segment.endSeconds, mediaRanges: segment.mediaRanges }));
682
+ geometry.timescale = timescale;
683
+ geometry.placed = null;
684
+ }
685
+ return timescale;
686
+ }
687
+
688
+ /**
689
+ * A track's pieces on the page's clock in `unit`: read pieces at their served
690
+ * ranges, the others placed between them. The times are reused while the
691
+ * pieces are those {@link timescaleOfPieces} last took and `unit` and `clock`
692
+ * are the same; everything else of a piece is this poll's.
693
+ *
694
+ * @param {{ segments?: object[], geometry?: object }} track
695
+ * @param {import("./media-time.js").MediaTime} clock - In `unit`.
696
+ * @param {bigint} unit
697
+ */
698
+ function placedPieces(track, clock, unit) {
699
+ const geometry = track.geometry;
700
+ const segments = Array.isArray(track.segments) ? track.segments : [];
701
+ let times = geometry?.placed && geometry.placedUnit === unit && geometry.placedClock === clock.ticks ? geometry.placed : null;
702
+ if (geometry) geometry.reason = times ? "kept" : !geometry.placed ? geometry.reason ?? "first" : geometry.placedUnit !== unit ? "timescale" : "clock";
703
+ if (!times) {
704
+ times = predictedMediaSegments(segments.map((segment) => ({
705
+ ...segment, exact: exactTimesOf(segment), ranges: playerRanges(segment.mediaRanges, clock, unit)
706
+ })), unit).map(({ exact, ranges }) => ({ exact, ranges }));
707
+ if (geometry) Object.assign(geometry, { placed: times, placedUnit: unit, placedClock: clock.ticks });
708
+ }
709
+ return segments.map((segment, index) => ({ ...segment, exact: times[index].exact, ranges: times[index].ranges }));
710
+ }
711
+
712
+ /**
713
+ * How `segments` differ from the pieces `kept` recorded, by the first field
714
+ * their times come from that differs, or null when they are the same pieces.
715
+ *
716
+ * @returns {"first" | "count" | "index" | "edges" | "read ranges" | null}
717
+ */
718
+ function piecesChanged(kept, segments) {
719
+ if (!Array.isArray(kept)) return "first";
720
+ if (kept.length !== segments.length) return "count";
721
+ for (let index = 0; index < segments.length; index++) {
722
+ const segment = segments[index], was = kept[index];
723
+ if (segment.index !== was.index) return "index";
724
+ if (segment.startSeconds !== was.startSeconds || segment.endSeconds !== was.endSeconds) return "edges";
725
+ if (segment.mediaRanges !== was.mediaRanges) return "read ranges";
726
+ }
727
+ return null;
652
728
  }
653
729
 
654
730
  /**
@@ -10,7 +10,7 @@
10
10
  */
11
11
 
12
12
  export class SegmentInputTable {
13
- /** `sourceKey` → output → segment → file → its ranges. @type {Map<string, Map<string, Map<number, Map<number, { joined: Array<[number, number]>, ranges: Array<{ start: number, end: number }> }>>>>} */
13
+ /** `sourceKey` → output → segment → its files' ranges, and the list `inputsFor` answers while they stand. @type {Map<string, Map<string, Map<number, { files: Map<number, { joined: Array<[number, number]>, ranges: Array<{ start: number, end: number }> }>, inputs: Array<{ sourceId: string, ranges: Array<{ start: number, end: number }> }> | null }>>>} */
14
14
  #bySource = new Map();
15
15
 
16
16
  /**
@@ -25,15 +25,19 @@ export class SegmentInputTable {
25
25
  if (!outputs) this.#bySource.set(sourceKey, outputs = new Map());
26
26
  let segments = outputs.get(outputKey);
27
27
  if (!segments) outputs.set(outputKey, segments = new Map());
28
- let files = segments.get(index);
29
- if (!files) segments.set(index, files = new Map());
28
+ let segment = segments.get(index);
29
+ if (!segment) segments.set(index, segment = { files: new Map(), inputs: null });
30
30
  // The same reading keeps its list: whoever compares inputs compares them by it.
31
- if (files.get(fileIndex)?.joined === joined) return;
32
- files.set(fileIndex, { joined, ranges: joined.map(([start, end]) => ({ start, end: end + 1 })) });
31
+ if (segment.files.get(fileIndex)?.joined === joined) return;
32
+ segment.files.set(fileIndex, { joined, ranges: joined.map(([start, end]) => ({ start, end: end + 1 })) });
33
+ segment.inputs = null;
33
34
  }
34
35
 
35
36
  /**
36
- * The bytes one segment reads, by file, or null where none are known.
37
+ * The bytes one segment reads, by file, or null where none are known. The
38
+ * same list while they stand: the start forecast asks for every segment of
39
+ * the film on each poll, and keeps what it derived from a segment whose
40
+ * inputs are the same list.
37
41
  *
38
42
  * @param {string} sourceKey
39
43
  * @param {string} outputKey
@@ -41,9 +45,10 @@ export class SegmentInputTable {
41
45
  * @returns {Array<{ sourceId: string, ranges: Array<{ start: number, end: number }> }> | null}
42
46
  */
43
47
  inputsFor(sourceKey, outputKey, index) {
44
- const files = this.#bySource.get(sourceKey)?.get(outputKey)?.get(index);
45
- if (!files?.size) return null;
46
- return [...files].map(([fileIndex, { ranges }]) => ({ sourceId: `${sourceKey}:${fileIndex}`, ranges }));
48
+ const segment = this.#bySource.get(sourceKey)?.get(outputKey)?.get(index);
49
+ if (!segment?.files.size) return null;
50
+ segment.inputs ??= [...segment.files].map(([fileIndex, { ranges }]) => ({ sourceId: `${sourceKey}:${fileIndex}`, ranges }));
51
+ return segment.inputs;
47
52
  }
48
53
 
49
54
  /** @param {string} sourceKey */