@torrent-tv/proxy 2.98.8 → 2.98.10

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.8",
3
+ "version": "2.98.10",
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,17 @@ export class EncodeRuns {
492
496
  }
493
497
  }
494
498
 
499
+ /**
500
+ * The speed readings of this output's runs within the forecast's horizon,
501
+ * in the order they were taken.
502
+ *
503
+ * @param {object} output
504
+ * @returns {Array<{ speed: number, at: number }>}
505
+ */
506
+ speedReadingsOf(output) {
507
+ return this.#speedReadings.windowOf(output);
508
+ }
509
+
495
510
  /**
496
511
  * The media intervals of a stored piece of this output, or undefined.
497
512
  *
@@ -1179,7 +1194,10 @@ export class EncodeRuns {
1179
1194
  // group before it in hand — the leading pictures the run that starts
1180
1195
  // there cannot make.
1181
1196
  onTail: (name) => this.#keepLeadingPictures(session, admittedInput, name),
1182
- onSpeedMeasured: () => this.#host.noteRunSpeedMeasured?.(session, run),
1197
+ onSpeedMeasured: (reading) => {
1198
+ this.#speedReadings.note(session, reading);
1199
+ this.#host.noteRunSpeedMeasured?.(session, run);
1200
+ },
1183
1201
  onEnded: (ended) => {
1184
1202
  this.#runsByInputToken.delete(inputToken);
1185
1203
  if (admittedInput.original) {
@@ -0,0 +1,49 @@
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; the forecast summarizes the
6
+ * whole window on each poll. Two runs of one output can announce out of order —
7
+ * each reading carries the time of its own run's last progress report — so the
8
+ * window is kept in the order the readings were TAKEN, and two taken in the
9
+ * same millisecond are two readings. The forecast used to take only the newest
10
+ * reading of a run alive when it asked: on a cold start on Home Assistant the
11
+ * runs were three pieces long and ended between its polls, and of the readings
12
+ * 0.78, 1.80, 2.25, 2.84 and 2.69x it got 0.78 and 2.69x, and held the picture
13
+ * on 0.78x for 13 s (r37).
14
+ */
15
+
16
+ export class SpeedReadings {
17
+ /** @type {WeakMap<object, Array<{ speed: number, at: number }>>} */
18
+ #byOutput = new WeakMap();
19
+ #keepMs;
20
+
21
+ /** @param {number} keepSeconds - How far back a reading is kept: the forecast's horizon. */
22
+ constructor(keepSeconds) {
23
+ this.#keepMs = Math.max(0, keepSeconds) * 1000;
24
+ }
25
+
26
+ /**
27
+ * @param {object} output
28
+ * @param {{ speed: number, at: number } | null} reading
29
+ */
30
+ note(output, reading) {
31
+ if (!output || !(reading?.speed > 0) || !Number.isFinite(reading.at)) return;
32
+ let readings = this.#byOutput.get(output);
33
+ if (!readings) this.#byOutput.set(output, readings = []);
34
+ let place = readings.length;
35
+ while (place > 0 && readings[place - 1].at > reading.at) place -= 1;
36
+ readings.splice(place, 0, { speed: reading.speed, at: reading.at });
37
+ const cutoff = readings.at(-1).at - this.#keepMs;
38
+ // As the forecast's own trend keeps them: the newest one before the horizon stays.
39
+ while (readings.length > 1 && readings[1].at <= cutoff) readings.shift();
40
+ }
41
+
42
+ /**
43
+ * @param {object} output
44
+ * @returns {Array<{ speed: number, at: number }>} The window, in the order the readings were taken.
45
+ */
46
+ windowOf(output) {
47
+ return [...(this.#byOutput.get(output) ?? [])];
48
+ }
49
+ }
@@ -60,6 +60,27 @@ export class ViewerRequests {
60
60
  #host;
61
61
  /** @type {WeakMap<object, Map<string, object>>} */
62
62
  #rateStateBySession = new WeakMap();
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
+
63
84
  /**
64
85
  * The pieces of each output as the start forecast read them last (see
65
86
  * `#segmentsOf`), and what the forecast keeps of their time geometry while
@@ -70,7 +91,7 @@ export class ViewerRequests {
70
91
  #forecastPieces = new WeakMap();
71
92
 
72
93
  /**
73
- * @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`
74
95
  */
75
96
  constructor(host) {
76
97
  this.#host = host;
@@ -583,7 +604,7 @@ export class ViewerRequests {
583
604
  const tracks = [];
584
605
  for (const output of outputs) {
585
606
  const trackProgress = output === session ? progress : this.#host.encodeRuns.progressOf(output);
586
- const trackRates = this.#trackRateReadings(measurement, output, now);
607
+ const trackRates = this.#trackRateReadings(output, now);
587
608
  const trackName = output === session ? "video" : "audio";
588
609
  // The page states the offset its player applied to this track, and the
589
610
  // init it applied it to is the session's. Until both are known, the
@@ -670,12 +691,6 @@ export class ViewerRequests {
670
691
  });
671
692
  }
672
693
 
673
- const activeTrackIds = new Set(tracks.map(({ id }) => id));
674
- for (const id of measurement.tracks.keys()) {
675
- if (!activeTrackIds.has(id)) {
676
- measurement.tracks.delete(id);
677
- }
678
- }
679
694
  const bufferedAheadSeconds = Number(viewerReading?.bufferedAheadSec);
680
695
  const positionSeconds = Number.isFinite(viewerReading?.positionSeconds)
681
696
  ? viewerReading.positionSeconds
@@ -717,7 +732,10 @@ export class ViewerRequests {
717
732
  // fetched whole by the page before it is chosen, so it never matches here.
718
733
  const subtitle = viewer?.subtitle?.sourceKey === session.file.sourceKey &&
719
734
  viewer.subtitle.fileIndex === session.file.fileIndex ? viewer.subtitle : null;
720
- const forecast = withSubtitleReadiness(this.#host.playbackReadiness.predict(input), subtitle,
735
+ const startedAt = performance.now();
736
+ const predicted = this.#host.playbackReadiness.predict(input);
737
+ this.#noteForecast(performance.now() - startedAt, tracks.map(track => track.geometry?.reason ?? "none"));
738
+ const forecast = withSubtitleReadiness(predicted, subtitle,
721
739
  subtitle ? this.#host.subtitleReadyFor?.(viewer) !== false : true);
722
740
  forecast.operations = outputs.flatMap((output, index) => {
723
741
  const common = { speed: this.#host.playbackReadiness.forecastRate(tracks[index].readings, now, 0, "piece"),
@@ -856,23 +874,23 @@ export class ViewerRequests {
856
874
  const id = typeof consumerId === "string" ? consumerId : "";
857
875
  let state = byViewer.get(id);
858
876
  if (!state) {
859
- state = { link: new this.#host.playbackReadiness.RateTrend(this.#host.lookaheadSeconds), tracks: new Map(), sources: new Map() };
877
+ state = { link: new this.#host.playbackReadiness.RateTrend(this.#host.lookaheadSeconds), sources: new Map() };
860
878
  byViewer.set(id, state);
861
879
  }
862
880
  return state;
863
881
  }
864
882
 
865
- #trackRateReadings(state, output, now) {
866
- const id = output.outputKey;
867
- let trend = state.tracks.get(id);
868
- if (!trend) {
869
- trend = new this.#host.playbackReadiness.RateTrend(this.#host.lookaheadSeconds);
870
- state.tracks.set(id, trend);
871
- }
872
- const reading = this.#host.encodeSpeedReadingOf?.(output, now) ?? null;
873
- if (Number.isFinite(reading?.speed) && reading.speed > 0 && Number.isFinite(reading.at)) {
874
- trend.add(reading.at, reading.speed);
875
- } else if (!trend.snapshot()) {
883
+ /**
884
+ * The encoder's rate for the forecast: the output's whole window of readings,
885
+ * summarized again on each poll. A run that ended between two polls took its
886
+ * readings with it when the forecast asked for the newest one alone; and two
887
+ * runs of one output can report out of order, so a summary kept and added to
888
+ * would drop the later-told, earlier-taken one (torrent-tv/meta#178).
889
+ */
890
+ #trackRateReadings(output, now) {
891
+ const readings = (this.#host.encodeSpeedReadingsOf?.(output) ?? []).filter(reading => reading.at <= now);
892
+ const summary = readings.length > 0 ? this.#host.playbackReadiness.rateSummaryOf(readings) : null;
893
+ if (!summary) {
876
894
  // ONLY UNTIL A PIECE IS MEASURED does the prediction stand for the
877
895
  // encoder, and it is not kept among the readings. Added on every poll
878
896
  // between runs, it put the machine's share of that moment — 1 % at a
@@ -885,7 +903,7 @@ export class ViewerRequests {
885
903
  return seed.snapshot();
886
904
  }
887
905
  }
888
- return trend.snapshot();
906
+ return summary;
889
907
  }
890
908
 
891
909
  /**
@@ -71,7 +71,7 @@ import { ViewerRequests } from "./ViewerRequests.js";
71
71
  import { OutputLifecycle } from "./OutputLifecycle.js";
72
72
  import { SegmentServing } from "./SegmentServing.js";
73
73
  import { audioStartSecondsFor } from "../viewer/audio-start.js";
74
- import { forecastRate, predictPlaybackReadiness, RateTrend } from "../viewer/playback-readiness.js";
74
+ import { forecastRate, rateSummaryOf, predictPlaybackReadiness, RateTrend } from "../viewer/playback-readiness.js";
75
75
  import { audioRenditionName } from "../media/audio-inventory.js";
76
76
  import { Renditions } from "../encode/Renditions.js";
77
77
  import { CushionReport, LOOKAHEAD_PAUSE_SECONDS } from "../encode/CushionReport.js";
@@ -578,12 +578,12 @@ export function wireOutputs({
578
578
  get encodeRuns() { return parts.encodeRuns; },
579
579
  // When this viewer's playback can start and run to the end: the viewer
580
580
  // component's forecast, which models the viewer's browser.
581
- playbackReadiness: { predict: predictPlaybackReadiness, forecastRate, RateTrend },
581
+ playbackReadiness: { predict: predictPlaybackReadiness, forecastRate, rateSummaryOf, RateTrend },
582
582
  // Whether the subtitle a viewer chose has been read where they stand — the
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) => parts.encodeRuns.speedReadingsOf(output),
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
@@ -58,6 +58,22 @@ export function forecastRate(readings, now = Date.now(), horizonSeconds = 0, wei
58
58
  return horizon > 0 ? curve.workBy(horizon) / horizon : curve.rateAt(0);
59
59
  }
60
60
 
61
+ /**
62
+ * The summary the forecast reads, of a window of readings taken in any order:
63
+ * ordered by when each was taken, readings taken in the same millisecond kept
64
+ * apart. Null for no reading.
65
+ *
66
+ * @param {Array<{ at: number, speed?: number, value?: number }>} readings
67
+ * @returns {object | null}
68
+ */
69
+ export function rateSummaryOf(readings) {
70
+ const ordered = (Array.isArray(readings) ? readings : [])
71
+ .map(reading => ({ at: reading?.at, value: reading?.value ?? reading?.speed }))
72
+ .filter(reading => Number.isFinite(reading.at) && Number.isFinite(reading.value) && reading.value >= 0)
73
+ .sort((left, right) => left.at - right.at);
74
+ return ordered.length > 0 ? summarizeRate(ordered) : null;
75
+ }
76
+
61
77
  /**
62
78
  * Measurements over the output's existing look-ahead horizon. The oldest
63
79
  * boundary sample is retained until another sample replaces it, so a repeated
@@ -648,8 +664,11 @@ function timesOf(segment) {
648
664
  * thread in r35's first 15 minutes on Home Assistant (torrent-tv/meta#178).
649
665
  * `track.geometry` is kept by whoever keeps the pieces; without it nothing is
650
666
  * kept, and the answer is the same either way: a placement is reused only for
651
- * the same pieces, the same timescale and the same clock offset, so no time is
652
- * rounded for the reuse.
667
+ * pieces with the same edges and read ranges, the same timescale and the same
668
+ * clock offset, so no time is rounded for the reuse. Only the times are kept: a
669
+ * piece whose torrent inputs were told again is a new object with the same
670
+ * times, and it is read with its inputs of this poll. In r37 most polls placed
671
+ * every piece again, the MPEG-TS output having no read ranges to keep them by.
653
672
  *
654
673
  * @param {{ segments?: object[], geometry?: object }} track
655
674
  * @returns {bigint} The least timescale holding every edge and every served tick of its pieces.
@@ -657,7 +676,9 @@ function timesOf(segment) {
657
676
  function timescaleOfPieces(track) {
658
677
  const segments = Array.isArray(track.segments) ? track.segments : [];
659
678
  const geometry = track.geometry;
660
- if (geometry && samePieces(geometry.pieces, segments)) return geometry.timescale;
679
+ const changed = geometry ? piecesChanged(geometry.pieces, segments) : null;
680
+ if (geometry && changed === null) return geometry.timescale;
681
+ if (geometry) geometry.reason = changed;
661
682
  const distinct = new Set();
662
683
  for (const segment of segments) {
663
684
  if (segment.mediaRanges?.timescale) distinct.add(segment.mediaRanges.timescale);
@@ -667,7 +688,7 @@ function timescaleOfPieces(track) {
667
688
  }
668
689
  const timescale = commonTimescale([...distinct].map((value) => mediaTime(0n, value)));
669
690
  if (geometry) {
670
- geometry.pieces = segments.map((segment) => ({ segment, index: segment.index, startSeconds: segment.startSeconds,
691
+ geometry.pieces = segments.map((segment) => ({ index: segment.index, startSeconds: segment.startSeconds,
671
692
  endSeconds: segment.endSeconds, mediaRanges: segment.mediaRanges }));
672
693
  geometry.timescale = timescale;
673
694
  geometry.placed = null;
@@ -677,8 +698,9 @@ function timescaleOfPieces(track) {
677
698
 
678
699
  /**
679
700
  * A track's pieces on the page's clock in `unit`: read pieces at their served
680
- * ranges, the others placed between them. Reused while the pieces are those
681
- * {@link timescaleOfPieces} last took and `unit` and `clock` are the same.
701
+ * ranges, the others placed between them. The times are reused while the
702
+ * pieces are those {@link timescaleOfPieces} last took and `unit` and `clock`
703
+ * are the same; everything else of a piece is this poll's.
682
704
  *
683
705
  * @param {{ segments?: object[], geometry?: object }} track
684
706
  * @param {import("./media-time.js").MediaTime} clock - In `unit`.
@@ -686,23 +708,34 @@ function timescaleOfPieces(track) {
686
708
  */
687
709
  function placedPieces(track, clock, unit) {
688
710
  const geometry = track.geometry;
689
- if (geometry?.placed && geometry.placedUnit === unit && geometry.placedClock === clock.ticks) return geometry.placed;
690
- const placed = predictedMediaSegments((Array.isArray(track.segments) ? track.segments : []).map((segment) => ({
691
- ...segment, exact: exactTimesOf(segment), ranges: playerRanges(segment.mediaRanges, clock, unit)
692
- })), unit);
693
- if (geometry) Object.assign(geometry, { placed, placedUnit: unit, placedClock: clock.ticks });
694
- return placed;
711
+ const segments = Array.isArray(track.segments) ? track.segments : [];
712
+ let times = geometry?.placed && geometry.placedUnit === unit && geometry.placedClock === clock.ticks ? geometry.placed : null;
713
+ if (geometry) geometry.reason = times ? "kept" : !geometry.placed ? geometry.reason ?? "first" : geometry.placedUnit !== unit ? "timescale" : "clock";
714
+ if (!times) {
715
+ times = predictedMediaSegments(segments.map((segment) => ({
716
+ ...segment, exact: exactTimesOf(segment), ranges: playerRanges(segment.mediaRanges, clock, unit)
717
+ })), unit).map(({ exact, ranges }) => ({ exact, ranges }));
718
+ if (geometry) Object.assign(geometry, { placed: times, placedUnit: unit, placedClock: clock.ticks });
719
+ }
720
+ return segments.map((segment, index) => ({ ...segment, exact: times[index].exact, ranges: times[index].ranges }));
695
721
  }
696
722
 
697
- /** Whether `segments` are the pieces `kept` recorded, field by field the forecast reads. */
698
- function samePieces(kept, segments) {
699
- if (!Array.isArray(kept) || kept.length !== segments.length) return false;
723
+ /**
724
+ * How `segments` differ from the pieces `kept` recorded, by the first field
725
+ * their times come from that differs, or null when they are the same pieces.
726
+ *
727
+ * @returns {"first" | "count" | "index" | "edges" | "read ranges" | null}
728
+ */
729
+ function piecesChanged(kept, segments) {
730
+ if (!Array.isArray(kept)) return "first";
731
+ if (kept.length !== segments.length) return "count";
700
732
  for (let index = 0; index < segments.length; index++) {
701
733
  const segment = segments[index], was = kept[index];
702
- if (segment !== was.segment || segment.index !== was.index || segment.startSeconds !== was.startSeconds ||
703
- segment.endSeconds !== was.endSeconds || segment.mediaRanges !== was.mediaRanges) return false;
734
+ if (segment.index !== was.index) return "index";
735
+ if (segment.startSeconds !== was.startSeconds || segment.endSeconds !== was.endSeconds) return "edges";
736
+ if (segment.mediaRanges !== was.mediaRanges) return "read ranges";
704
737
  }
705
- return true;
738
+ return null;
706
739
  }
707
740
 
708
741
  /**