@torrent-tv/proxy 2.101.0 → 2.101.2

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.
@@ -143,8 +143,20 @@ export function predictPlaybackReadiness(input = {}) {
143
143
  // Kept are the pieces still to play, the first piece (where the track
144
144
  // starts), the read piece nearest before them (what an unread piece is placed
145
145
  // by) and read pieces inside what the page reports holding (their frames).
146
+ //
147
+ // THE NEAR WINDOW AND THE TAIL (torrent-tv/meta#185). Pieces starting within
148
+ // the proxy's look-ahead of the viewer are placed in exact time, where whether
149
+ // the media is continuous is decided, and their bytes are those the torrent
150
+ // thread plans arrivals for. The pieces after it are scheduled by the same
151
+ // rules in seconds, by {@link scheduleTail}: no exact time and no planned
152
+ // arrival exists for them. Placing every remaining piece of a two-hour film
153
+ // in exact time on every poll cost 70-1128 ms a call on Home Assistant (r73,
154
+ // r74).
155
+ const lookahead = finiteNonNegative(input.lookaheadSeconds);
156
+ const horizonEnd = lookahead > 0 ? finiteNonNegative(input.positionSeconds) + lookahead : Number.POSITIVE_INFINITY;
146
157
  const tracks = (Array.isArray(input.tracks) ? input.tracks : []).map((track) =>
147
- Array.isArray(track.segments) ? { ...track, segments: piecesThatMatter(track, finiteNonNegative(input.positionSeconds)) } : track);
158
+ Array.isArray(track.segments) ? { ...track,
159
+ segments: piecesThatMatter(track, finiteNonNegative(input.positionSeconds), horizonEnd) } : track);
148
160
  const duration = finiteNonNegative(input.durationSeconds);
149
161
  // Each track's served ticks are placed on the clock the page reports by the
150
162
  // offset its player applied (`timestampOffset`).
@@ -212,11 +224,20 @@ export function predictPlaybackReadiness(input = {}) {
212
224
  bitsPerMediaSecond: averageBitsPerMediaSecond(track, track.segments,
213
225
  new Set(Array.isArray(track.readySegmentIndices) ? track.readySegmentIndices : [])),
214
226
  segments: segmentsOf[trackIndex]
215
- .filter((segment) => Number.isInteger(segment?.index) &&
216
- Number.isFinite(segment?.startSeconds) && Number.isFinite(segment?.endSeconds) &&
217
- segment.endSeconds > segment.startSeconds && segment.endSeconds > position)
218
- .sort((left, right) => left.startSeconds - right.startSeconds || left.index - right.index)
227
+ .filter((segment) => isPiece(segment) && segment.endSeconds > position && segment.startSeconds < horizonEnd)
228
+ .sort((left, right) => left.startSeconds - right.startSeconds || left.index - right.index),
229
+ // The pieces after the near window, as the playlist cuts them, and the
230
+ // offset the page's player placed them by.
231
+ tail: horizonEnd === Number.POSITIVE_INFINITY ? [] :
232
+ (Array.isArray(input.tracks[trackIndex].segments) ? input.tracks[trackIndex].segments : [])
233
+ .filter((segment) => isPiece(segment) && segment.startSeconds >= horizonEnd && segment.endSeconds > position)
234
+ .sort((left, right) => left.startSeconds - right.startSeconds || left.index - right.index),
235
+ clockSeconds: Number.isFinite(track.clockOffsetSeconds) ? track.clockOffsetSeconds : 0
219
236
  }));
237
+ for (const state of trackState) {
238
+ state.tailEndSeconds = state.tail.length > 0
239
+ ? Math.min(duration, state.tail.at(-1).endSeconds + state.clockSeconds) : null;
240
+ }
220
241
  if (trackState.some(({ segments }) => segments.length === 0)) {
221
242
  return result(false, null, buffered, reserve, null, "timeline-unavailable", 0);
222
243
  }
@@ -241,8 +262,9 @@ export function predictPlaybackReadiness(input = {}) {
241
262
  return result(false, null, buffered, reserve, null, "link-rate-unavailable", 0);
242
263
  }
243
264
 
244
- const preparedSegments = trackState.reduce((sum, { ready, segments }) =>
245
- sum + segments.filter((segment) => ready.has(segment.index)).length, 0);
265
+ const preparedSegments = trackState.reduce((sum, { ready, segments, tail }) =>
266
+ sum + segments.filter((segment) => ready.has(segment.index)).length +
267
+ tail.filter((segment) => ready.has(segment.index)).length, 0);
246
268
  const jobs = [];
247
269
 
248
270
  for (let trackIndex = 0; trackIndex < trackState.length; trackIndex += 1) {
@@ -497,12 +519,22 @@ export function predictPlaybackReadiness(input = {}) {
497
519
  for (const range of production.segment.ranges) addMediaRange(prepared, range);
498
520
  // Supply interruption coverage is stock held by the proxy as well as
499
521
  // the browser. It is not a browser buffer-size admission rule.
500
- const ahead = Math.max(0, secondsAhead(before, at) -
501
- Math.min(reserve, secondsAhead(trackEnd, before)) * Number(production.requiresSource));
522
+ // The reserve is bounded by what is left of the track, not of the window.
523
+ const left = state.tailEndSeconds === null ? secondsAhead(trackEnd, before)
524
+ : Math.max(0, state.tailEndSeconds - toSeconds(before));
525
+ const ahead = Math.max(0, secondsAhead(before, at) - Math.min(reserve, left) * Number(production.requiresSource));
502
526
  constrain(production.at - ahead, "production", trackIndex, production.segment.index, production.at, ahead,
503
527
  production.requiresSource);
504
528
  }
505
529
  }
530
+ if (trackState.some(({ tail }) => tail.length > 0)) {
531
+ const stop = scheduleTail({ trackState, sourceState, sourceByteCoverage, sourceArrivals, runStarts, trackFinish,
532
+ link, position, reserve, transferFinish, constrain });
533
+ if (stop) {
534
+ return { ...result(false, null, buffered, reserve, null, stop.reason, preparedSegments),
535
+ ...(stop.unavailableSource ? { unavailableSource: stop.unavailableSource } : {}) };
536
+ }
537
+ }
506
538
  const delay = Math.max(0, ...constraints);
507
539
  const originReady = trackState.every(({ clientRanges }) => compare(contiguousEnd(clientRanges, at), at) > 0);
508
540
  const ready = delay === 0 && originReady;
@@ -527,6 +559,183 @@ export function predictPlaybackReadiness(input = {}) {
527
559
 
528
560
  }
529
561
 
562
+ /** A playlist piece with an index and a positive span in seconds. */
563
+ function isPiece(segment) {
564
+ return Number.isInteger(segment?.index) && Number.isFinite(segment?.startSeconds) &&
565
+ Number.isFinite(segment?.endSeconds) && segment.endSeconds > segment.startSeconds;
566
+ }
567
+
568
+ /**
569
+ * The pieces after the near window, scheduled by the near window's rules in
570
+ * seconds and constrained as they are scheduled: their source bytes arrive in
571
+ * byte order at the source's measured service after the near window's bytes,
572
+ * a piece whose bytes come after its output's last run was admitted starts a
573
+ * run and pays the output's open, it is encoded at the output's measured rate
574
+ * and delivered over the one link in media order.
575
+ *
576
+ * Where a byte stands in that order is the bytes still missing before it,
577
+ * read off what is held, planned and brought by the near window
578
+ * ({@link missingBefore}); a piece is placed by the last range of it that
579
+ * misses bytes, so a range it reads that is already held — an AVI's index at
580
+ * the end of the file — does not place it at the end of the file. Planned and
581
+ * brought bytes come when they were to come. No exact time, list of arrivals
582
+ * or per-piece object is built: this runs over the rest of the film on every
583
+ * poll (torrent-tv/meta#185).
584
+ *
585
+ * @returns {{ reason: string, unavailableSource?: object } | null} Why there is no answer, or null.
586
+ */
587
+ function scheduleTail({ trackState, sourceState, sourceByteCoverage, sourceArrivals, runStarts, trackFinish, link,
588
+ position, reserve, transferFinish, constrain }) {
589
+ // Per source: what is missing before a byte, and how far the tail has asked.
590
+ const supply = new Map();
591
+ const supplyOf = (sourceId) => {
592
+ let state = supply.get(sourceId);
593
+ if (!state) {
594
+ const coverage = sourceByteCoverage.get(sourceId) ?? [];
595
+ const heldBefore = [0];
596
+ for (const range of coverage) heldBefore.push(heldBefore.at(-1) + range.end - range.start);
597
+ state = { coverage, heldBefore, asked: null, orders: [], finishes: [] };
598
+ supply.set(sourceId, state);
599
+ }
600
+ return state;
601
+ };
602
+ const next = trackState.map(() => 0);
603
+ let delivered = transferFinish;
604
+ for (;;) {
605
+ // The next piece in media order across the tracks, as the near window orders them.
606
+ let trackIndex = -1;
607
+ for (let index = 0; index < trackState.length; index += 1) {
608
+ const candidate = trackState[index].tail[next[index]];
609
+ if (!candidate) continue;
610
+ const best = trackIndex < 0 ? null : trackState[trackIndex].tail[next[trackIndex]];
611
+ if (!best || candidate.startSeconds < best.startSeconds ||
612
+ (candidate.startSeconds === best.startSeconds && candidate.endSeconds < best.endSeconds)) trackIndex = index;
613
+ }
614
+ if (trackIndex < 0) return null;
615
+ const state = trackState[trackIndex];
616
+ const { track } = state;
617
+ const segment = state.tail[next[trackIndex]++];
618
+ const startsAt = segment.startSeconds + state.clockSeconds;
619
+ const endsAt = segment.endSeconds + state.clockSeconds;
620
+ // Media the page already holds is not transferred again.
621
+ if (Array.isArray(track.clientRanges) && track.clientRanges.some((range) =>
622
+ range?.start <= Math.max(startsAt, position) && range?.end >= endsAt)) continue;
623
+ const exactBytes = sizeOf(track.segmentSizesBytes, segment.index);
624
+ const sizeBits = exactBytes > 0 ? exactBytes * 8 : state.bitsPerMediaSecond * (segment.endSeconds - segment.startSeconds);
625
+ if (!(sizeBits > 0)) return { reason: "segment-size-unavailable" };
626
+ const produced = state.ready.has(segment.index);
627
+ let work = 0;
628
+ if (!produced) {
629
+ work = segment.endSeconds - segment.startSeconds;
630
+ for (const range of Array.isArray(track.processingRanges) ? track.processingRanges : []) {
631
+ if (Number.isFinite(range?.start) && Number.isFinite(range?.end)) {
632
+ work -= Math.max(0, Math.min(range.end, segment.endSeconds) - Math.max(range.start, segment.startSeconds));
633
+ }
634
+ }
635
+ work = Math.max(0, work);
636
+ }
637
+ let sourceArrivalAt = 0;
638
+ if (work > 0) {
639
+ if (!state.curve) return { reason: "encode-rate-unavailable" };
640
+ const trackSources = Array.isArray(track.sourceIds) ? track.sourceIds : [];
641
+ const exactInputs = Array.isArray(segment.sourceInputs) ? segment.sourceInputs : null;
642
+ const count = trackSources.length + (exactInputs?.length ?? 0);
643
+ for (let place = 0; place < count; place += 1) {
644
+ const sourceId = place < trackSources.length ? trackSources[place]
645
+ : exactInputs[place - trackSources.length]?.sourceId;
646
+ if (namedBefore(sourceId, place, trackSources, exactInputs)) continue;
647
+ const source = sourceState.get(sourceId);
648
+ if (!source) return { reason: "source-measurement-unavailable" };
649
+ if (source.source.complete === true) continue;
650
+ const ranges = exactInputs ? inputRangesOf(exactInputs, sourceId) : null;
651
+ if (ranges === null) return { reason: "source-input-ranges-unavailable" };
652
+ const bytes = supplyOf(sourceId);
653
+ let order = 0;
654
+ for (const range of ranges) {
655
+ // Bytes the torrent thread planned, or the near window brought, come when they were to come.
656
+ sourceArrivalAt = Math.max(sourceArrivalAt, latestArrival(range, sourceArrivals.get(sourceId)));
657
+ const after = missingBefore(bytes, range.end);
658
+ if (after > missingBefore(bytes, range.start)) order = Math.max(order, after);
659
+ }
660
+ if (order === 0) continue;
661
+ const { service } = source;
662
+ if (!service.rate || !(service.rate.rateAt(0) > 0)) {
663
+ return { reason: service.rate ? "service-not-advancing" : "download-rate-unavailable",
664
+ unavailableSource: { sourceId, segmentIndex: segment.index, missingBytes: order - (bytes.asked ?? 0) } };
665
+ }
666
+ // The tail's first asked byte is where this source's tail begins:
667
+ // bytes missing before it are not this film's to wait for.
668
+ bytes.asked ??= Math.max(0, order - missingWithin(bytes, ranges));
669
+ let arrival;
670
+ if (order > bytes.asked) {
671
+ service.finish = service.rate.finish(order - bytes.asked, service.finish);
672
+ bytes.asked = order;
673
+ bytes.orders.push(order);
674
+ bytes.finishes.push(service.finish);
675
+ arrival = service.finish;
676
+ } else {
677
+ // Brought by an earlier piece's ask: when that ask reached it.
678
+ let low = 0, high = bytes.orders.length - 1;
679
+ while (low < high) {
680
+ const middle = (low + high) >>> 1;
681
+ if (bytes.orders[middle] < order) low = middle + 1;
682
+ else high = middle;
683
+ }
684
+ arrival = bytes.finishes[low] ?? service.finish;
685
+ }
686
+ sourceArrivalAt = Math.max(sourceArrivalAt, arrival);
687
+ }
688
+ }
689
+ let admittedAt = Math.max(trackFinish[trackIndex], sourceArrivalAt);
690
+ if (work > 0) {
691
+ const run = runStarts[trackIndex];
692
+ const live = run.spans.some(span => segment.startSeconds >= span.start && segment.startSeconds < span.end);
693
+ if (!live && (run.admittedAt === null || sourceArrivalAt > run.admittedAt)) {
694
+ run.admittedAt = admittedAt;
695
+ admittedAt += run.openSeconds;
696
+ }
697
+ }
698
+ const producedAt = work > 0 ? state.curve.finish(work, admittedAt) : 0;
699
+ trackFinish[trackIndex] = Math.max(trackFinish[trackIndex], producedAt);
700
+ delivered = link.finish(sizeBits, Math.max(delivered, producedAt));
701
+ const completedAt = delivered + finiteNonNegative(track.appendSeconds);
702
+ // The media before a piece is what the playlist puts before it: whether a
703
+ // hole stops the media is decided in the near window, in exact time.
704
+ const ahead = Math.max(0, startsAt - position);
705
+ const requiresSource = sourceArrivalAt > 0;
706
+ constrain(completedAt - ahead, "delivery", trackIndex, segment.index, completedAt, ahead, false);
707
+ const covered = Math.max(0, ahead - Math.min(reserve, Math.max(0, state.tailEndSeconds - startsAt)) *
708
+ Number(requiresSource));
709
+ constrain(producedAt - covered, "production", trackIndex, segment.index, producedAt, covered, requiresSource);
710
+ }
711
+ }
712
+
713
+ /**
714
+ * The bytes not held, and not brought by the near window, before `byte` of a
715
+ * source: `byte` less what the sorted coverage holds below it.
716
+ *
717
+ * @param {{ coverage: Array<{ start: number, end: number }>, heldBefore: number[] }} bytes
718
+ * @param {number} byte
719
+ */
720
+ function missingBefore(bytes, byte) {
721
+ const { coverage, heldBefore } = bytes;
722
+ let low = 0, high = coverage.length;
723
+ while (low < high) {
724
+ const middle = (low + high) >>> 1;
725
+ if (coverage[middle].end <= byte) low = middle + 1;
726
+ else high = middle;
727
+ }
728
+ const inside = low < coverage.length && coverage[low].start < byte ? byte - coverage[low].start : 0;
729
+ return byte - heldBefore[low] - inside;
730
+ }
731
+
732
+ /** The missing bytes of a piece's ranges of one source. */
733
+ function missingWithin(bytes, ranges) {
734
+ let missing = 0;
735
+ for (const range of ranges) missing += missingBefore(bytes, range.end) - missingBefore(bytes, range.start);
736
+ return missing;
737
+ }
738
+
530
739
  /**
531
740
  * Seconds from `from` to `to`, or zero when `to` is not later.
532
741
  *
@@ -628,21 +837,24 @@ function heldRanges(ranges, segments, unit, position) {
628
837
  }
629
838
 
630
839
  /**
631
- * The pieces of a track this forecast reads: those still to play (ending after
632
- * `position`), the first piece, every piece that has been read and every piece
633
- * that is made. A piece that is none of these changes no answer: it is not
634
- * scheduled, it is not where the track starts, no unread piece is placed by it
635
- * (only read pieces are), it lends no frame to what the page holds, and it has
636
- * no size to measure the output's rate by.
840
+ * The pieces of a track this forecast places in exact time: those still to
841
+ * play (ending after `position`) that start before `horizonEnd`, the first
842
+ * piece, every piece that has been read and every piece that is made. A piece
843
+ * that is none of these changes no exact answer: after the near window it is
844
+ * scheduled in seconds, it is not where the track starts, no unread piece is
845
+ * placed by it (only read pieces are), it lends no frame to what the page
846
+ * holds, and it has no size to measure the output's rate by.
637
847
  *
638
848
  * @param {{ segments: object[], readySegmentIndices?: number[] }} track
639
849
  * @param {number} position - Seconds.
850
+ * @param {number} [horizonEnd] - Seconds: where the near window ends.
640
851
  * @returns {object[]}
641
852
  */
642
- function piecesThatMatter(track, position) {
853
+ function piecesThatMatter(track, position, horizonEnd = Number.POSITIVE_INFINITY) {
643
854
  const made = new Set(Array.isArray(track.readySegmentIndices) ? track.readySegmentIndices : []);
644
855
  const kept = track.segments.filter((segment) => segment?.index === 0 || segment?.mediaRanges !== undefined ||
645
- made.has(segment?.index) || !(Number.isFinite(segment?.endSeconds) && segment.endSeconds <= position));
856
+ made.has(segment?.index) || (!(Number.isFinite(segment?.endSeconds) && segment.endSeconds <= position) &&
857
+ !(Number.isFinite(segment?.startSeconds) && segment.startSeconds >= horizonEnd)));
646
858
  return kept.length === track.segments.length ? track.segments : kept;
647
859
  }
648
860