@torrent-tv/proxy 2.98.6 → 2.98.8

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.6",
3
+ "version": "2.98.8",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "repository": {
@@ -273,7 +273,15 @@ export class EncodeInputs {
273
273
  bytesChanged(arrived = null) {
274
274
  this.#revision++;
275
275
  for (const request of this.#requests.values()) {
276
- if (request.pending || request.result?.kind === "result" || request.result?.kind === "terminal") continue;
276
+ if (request.pending) {
277
+ // The try in flight may have read before these bytes came. What it
278
+ // waits for is known only when it answers, so they are kept for that
279
+ // answer; bytes not named make it try again whatever it answers.
280
+ if (!arrived) request.unnamedArrival = true;
281
+ else (request.arrivedMeanwhile ??= []).push(arrived);
282
+ continue;
283
+ }
284
+ if (request.result?.kind === "result" || request.result?.kind === "terminal") continue;
277
285
  if (arrived && request.result?.kind === "needs-bytes" && !awaitedIn(request.result, arrived)) {
278
286
  // Nothing it waits for has come: it stands as tried at this revision.
279
287
  request.revision = this.#revision;
@@ -341,6 +349,8 @@ export class EncodeInputs {
341
349
  request.pending = true;
342
350
  request.revision = this.#revision;
343
351
  request.memoryRevision = this.#memoryRevision;
352
+ request.arrivedMeanwhile = null;
353
+ request.unnamedArrival = false;
344
354
  // Where the time from the request to its input goes, across every attempt:
345
355
  // field 2026-10-10, a picture's request took 38 s before its copy began and
346
356
  // nothing said on what (torrent-tv/meta#166).
@@ -382,12 +392,29 @@ export class EncodeInputs {
382
392
  request.pending = false;
383
393
  if (request.result?.kind !== "needs-memory") this.#wanted.delete(request.key);
384
394
  const changed = request.result?.kind === "needs-memory"
385
- ? request.memoryRevision !== this.#memoryRevision : request.revision !== this.#revision;
395
+ ? request.memoryRevision !== this.#memoryRevision : this.#arrivedFor(request);
386
396
  if (this.#requests.get(request.key) === request && changed &&
387
397
  request.result?.kind !== "result" && request.result?.kind !== "terminal") this.#prepare(request);
388
398
  });
389
399
  }
390
400
 
401
+ /**
402
+ * Whether bytes came during a try that the try may have missed. A try that
403
+ * waits for named bytes is repeated only when some of them came, or when an
404
+ * event named nothing; the others stand it as tried at this revision. In r36
405
+ * a held-range check took 344 ms, and a piece of the file arriving during it
406
+ * started another (torrent-tv/meta#178).
407
+ */
408
+ #arrivedFor(request) {
409
+ if (request.revision === this.#revision) return false;
410
+ const arrivals = request.arrivedMeanwhile ?? [];
411
+ request.arrivedMeanwhile = null;
412
+ if (request.unnamedArrival || request.result?.kind !== "needs-bytes" ||
413
+ arrivals.some(arrived => awaitedIn(request.result, arrived))) return true;
414
+ request.revision = this.#revision;
415
+ return false;
416
+ }
417
+
391
418
  /** The reservation a packet input makes: all of it, or nothing. */
392
419
  #reserveFor(request) {
393
420
  return async bytes => {
@@ -60,7 +60,13 @@ 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
+ * The pieces of each output as the start forecast read them last (see
65
+ * `#segmentsOf`), and what the forecast keeps of their time geometry while
66
+ * they stand (`playback-readiness.js` `timescaleOfPieces`).
67
+ *
68
+ * @type {WeakMap<object, { pieces: object[], geometry: object }>}
69
+ */
64
70
  #forecastPieces = new WeakMap();
65
71
 
66
72
  /**
@@ -658,6 +664,7 @@ export class ViewerRequests {
658
664
  this.#host.initialOutputBitsPerMediaSecondOf?.(output) ?? 0,
659
665
  readings: trackRates,
660
666
  segments,
667
+ geometry: this.#forecastPieces.get(output)?.geometry,
661
668
  readySegmentIndices: [...inventory.keys()],
662
669
  segmentSizesBytes: inventory
663
670
  });
@@ -808,11 +815,12 @@ export class ViewerRequests {
808
815
  #segmentsOf(output, sessionInit, inventory, positionSeconds) {
809
816
  const timeline = output.timeline;
810
817
  const count = timeline?.segmentCount ?? 0;
811
- let kept = this.#forecastPieces.get(output);
812
- if (!kept) {
813
- kept = [];
814
- this.#forecastPieces.set(output, kept);
818
+ let forecast = this.#forecastPieces.get(output);
819
+ if (!forecast) {
820
+ forecast = { pieces: [], geometry: {} };
821
+ this.#forecastPieces.set(output, forecast);
815
822
  }
823
+ const kept = forecast.pieces;
816
824
  const segments = [];
817
825
  for (let index = 0; index < count; index++) {
818
826
  const endSeconds = timeline.publishedStartOf(index + 1);
@@ -139,21 +139,16 @@ export function predictPlaybackReadiness(input = {}) {
139
139
  range.end > range.start).map((range) => ({ start: fromSeconds(range.start), end: fromSeconds(range.end) }))
140
140
  : null);
141
141
  // 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.
142
+ // ticks, so that adding and comparing never seek a common denominator. A
143
+ // track's pieces give it the same timescale while they are the same pieces.
143
144
  const unit = commonTimescale([
144
145
  fromSeconds(finiteNonNegative(input.positionSeconds)), fromSeconds(duration),
145
146
  fromSeconds(finiteNonNegative(input.bufferedAheadSeconds)), REPORTED_TIME_ERROR, ...clocks,
146
147
  ...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
- ]))
148
+ ...tracks.map((track) => mediaTime(0n, timescaleOfPieces(track)))
151
149
  ]);
152
150
  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));
151
+ const segmentsOf = tracks.map((track, trackIndex) => placedPieces(track, rescale(clocks[trackIndex], unit), unit));
157
152
  const origin = latest(zero, ...segmentsOf.map((segments) =>
158
153
  segments.find(({ index }) => index === 0)?.ranges?.[0]?.start ?? zero));
159
154
  const at = latest(origin, rescale(fromSeconds(finiteNonNegative(input.positionSeconds)), unit));
@@ -645,10 +640,69 @@ function timesOf(segment) {
645
640
  return segment.exact ?? exactTimesOf(segment);
646
641
  }
647
642
 
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);
643
+ /**
644
+ * WHAT A TRACK'S PIECES GIVE THE FORECAST'S TIME, KEPT BETWEEN POLLS while
645
+ * they are the same pieces. The forecast runs on every poll of a waiting
646
+ * viewer, and gathering the edges of every remaining piece for the timescale,
647
+ * then placing each on the page's clock, was about 3 s of the proxy's main
648
+ * thread in r35's first 15 minutes on Home Assistant (torrent-tv/meta#178).
649
+ * `track.geometry` is kept by whoever keeps the pieces; without it nothing is
650
+ * 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.
653
+ *
654
+ * @param {{ segments?: object[], geometry?: object }} track
655
+ * @returns {bigint} The least timescale holding every edge and every served tick of its pieces.
656
+ */
657
+ function timescaleOfPieces(track) {
658
+ const segments = Array.isArray(track.segments) ? track.segments : [];
659
+ const geometry = track.geometry;
660
+ if (geometry && samePieces(geometry.pieces, segments)) return geometry.timescale;
661
+ const distinct = new Set();
662
+ for (const segment of segments) {
663
+ if (segment.mediaRanges?.timescale) distinct.add(segment.mediaRanges.timescale);
664
+ const { start, end } = exactTimesOf(segment);
665
+ if (start) distinct.add(start.timescale);
666
+ if (end) distinct.add(end.timescale);
667
+ }
668
+ const timescale = commonTimescale([...distinct].map((value) => mediaTime(0n, value)));
669
+ if (geometry) {
670
+ geometry.pieces = segments.map((segment) => ({ segment, index: segment.index, startSeconds: segment.startSeconds,
671
+ endSeconds: segment.endSeconds, mediaRanges: segment.mediaRanges }));
672
+ geometry.timescale = timescale;
673
+ geometry.placed = null;
674
+ }
675
+ return timescale;
676
+ }
677
+
678
+ /**
679
+ * 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.
682
+ *
683
+ * @param {{ segments?: object[], geometry?: object }} track
684
+ * @param {import("./media-time.js").MediaTime} clock - In `unit`.
685
+ * @param {bigint} unit
686
+ */
687
+ function placedPieces(track, clock, unit) {
688
+ 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;
695
+ }
696
+
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;
700
+ for (let index = 0; index < segments.length; index++) {
701
+ 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;
704
+ }
705
+ return true;
652
706
  }
653
707
 
654
708
  /**
@@ -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 */