@torrent-tv/proxy 2.101.4 → 2.101.6

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.101.4",
3
+ "version": "2.101.6",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "repository": {
@@ -160,9 +160,16 @@ export class PauseRule {
160
160
  const since = this.#triedAt.get(run) ?? Number.NEGATIVE_INFINITY;
161
161
  const beside = lead.filter((second) => second.running.has(run) && second.at > since);
162
162
  if (beside.length < LEAST_SECONDS) {
163
- // Let go by this rule and not measured yet: it runs until it is.
164
- // Running since before anything could be compared: the urgency rule.
165
- return this.#triedAt.has(run) ? { runs: true, because: `being measured beside the leader (${beside.length}s so far)` } : null;
163
+ if (this.#triedAt.has(run)) {
164
+ // Let go by this rule and not measured yet: it runs until it is.
165
+ return { runs: true, because: `being measured beside the leader (${beside.length}s so far)` };
166
+ }
167
+ // Running and not measured yet without having been let go by this rule
168
+ // (a start the plan made, or a run that led before): judged as a try,
169
+ // the condition `wouldStart` let it start under. Paused at once and
170
+ // tried a tick later instead, short runs on Home Assistant (r80) went
171
+ // through both: 81 pauses of 0.3 s (median), one per run.
172
+ return this.#whetherToTry({ leader, band, run, need, lead, without, draw, free, others, otherRunning, now });
166
173
  }
167
174
  const besideSpeed = middleAndScatter(beside.map((second) => second.speed));
168
175
  if (Number.isFinite(need)) {
@@ -157,7 +157,7 @@ export class HostLoad {
157
157
  #supplyByKey = new Map();
158
158
 
159
159
  /**
160
- * @param {object} host - `liveRunsOf`, `runStateOf`, the readings of `encode/host-load.js` (`readMachineState`, `readProcessCpuSeconds`, `readProxyCpuSeconds`, `readSystemCpu`, `shareOfMachine`), `getSourceStats`, `getTorrentTotals`, `outputs`
160
+ * @param {object} host - `liveRunsOf`, `runStateOf`, the readings of `encode/host-load.js` (`readMachineState`, `readProcessCpuSeconds`, `readProxyCpuSeconds`, `readSystemCpu`, `shareOfMachine`), `getSourceStats`, `getTorrentTotals`, `outputs`, and optionally `costSecOf(session)`, `EncodeCost`'s cost of an output, said beside each run's `k`
161
161
  */
162
162
  /** The share of the machine this process draws doing nothing priced; zero until measured. */
163
163
  get baseDraw() {
@@ -634,6 +634,13 @@ export class HostLoad {
634
634
  * Only runs read at both ends that made film in between are said. For the
635
635
  * log only.
636
636
  *
637
+ * Beside each `k`, `EncodeCost`'s cost of the run's output (`c`) and `k`
638
+ * converted to that unit over the cores this process may use (`k/U`), on the
639
+ * same reading: where a run uses less than it is given the two differ, and
640
+ * whether the processor claim moves from `c` to `k` is judged from these
641
+ * lines (plan 2.3, torrent-tv/meta#180). `c` is filed as if the run were
642
+ * alone on a quiet machine; a `k` read beside other runs is not.
643
+ *
637
644
  * @param {{ takenAt: number, byPid: Map<number, number>, runs?: Map<number, { film: number | null, label: string, kind: string }> }} previous
638
645
  * @param {{ takenAt: number, byPid: Map<number, number>, runs?: Map<number, { film: number | null, label: string, kind: string }> }} sample
639
646
  */
@@ -642,6 +649,8 @@ export class HostLoad {
642
649
  const elapsedSec = (sample.takenAt - previous.takenAt) / 1000;
643
650
  if (!(elapsedSec > 0)) return;
644
651
  const alive = new Map([...previous.runs, ...sample.runs]);
652
+ const boundCores = Number(sample.processor?.bound?.cores);
653
+ const cores = Number.isFinite(boundCores) && boundCores > 0 ? boundCores : Math.max(1, os.cpus().length);
645
654
  const said = [];
646
655
  for (const [pid, run] of sample.runs) {
647
656
  const before = previous.runs.get(pid);
@@ -653,15 +662,34 @@ export class HostLoad {
653
662
  if (!(film > 0)) continue;
654
663
  const cpu = cpuNow - cpuBefore;
655
664
  const beside = [...alive].filter(([other]) => other !== pid).map(([, other]) => other.kind);
656
- said.push(`${run.label} ${run.kind} k=${(cpu / film).toFixed(3)} CPU-s per film-s ` +
665
+ const k = cpu / film;
666
+ said.push(`${run.label} ${run.kind} k=${k.toFixed(3)} CPU-s per film-s ` +
657
667
  `(${cpu.toFixed(2)} CPU-s, ${film.toFixed(2)} film-s, speed ${(film / elapsedSec).toFixed(2)}x, ` +
658
- `${(cpu / elapsedSec).toFixed(2)} cores), beside ${beside.length}` + (beside.length > 0 ? ` [${beside.join(", ")}]` : ""));
668
+ `${(cpu / elapsedSec).toFixed(2)} cores), ` +
669
+ `c=${Number.isFinite(run.costSec) && run.costSec > 0 ? run.costSec.toFixed(3) : "unpriced"} k/U=${(k / cores).toFixed(3)} ` +
670
+ `(U=${cores.toFixed(2)} cores), beside ${beside.length}` + (beside.length > 0 ? ` [${beside.join(", ")}]` : ""));
659
671
  }
660
672
  if (said.length > 0) {
661
673
  logger.info(`processor: run cost over ${elapsedSec.toFixed(1)}s: ${said.join("; ")}`);
662
674
  }
663
675
  }
664
676
 
677
+ /**
678
+ * `EncodeCost`'s cost of an output, for the log; null where it is unpriced
679
+ * or cannot be read.
680
+ *
681
+ * @param {object} session
682
+ * @returns {number | null}
683
+ */
684
+ #costSecOf(session) {
685
+ try {
686
+ const cost = Number(this.#host.costSecOf?.(session));
687
+ return Number.isFinite(cost) && cost > 0 ? cost : null;
688
+ } catch {
689
+ return null;
690
+ }
691
+ }
692
+
665
693
  /**
666
694
  * @param {{ elapsedSec: number, processShare: number | null, systemShare: number | null, rejected?: string[] } | null} share
667
695
  * @param {{ proxyCpuSeconds: number | null }} previous
@@ -750,7 +778,8 @@ export class HostLoad {
750
778
  startedAt: run.startedAt,
751
779
  film: filmMadeBy(run),
752
780
  label: `${session.outputKey ?? session.id} #${run.from}..#${run.to}`,
753
- kind: costKindForSession(session)
781
+ kind: costKindForSession(session),
782
+ costSec: this.#costSecOf(session)
754
783
  })));
755
784
  const [system, endedChildren, ...cpuReadings] = await Promise.all([
756
785
  this.#host.readSystemCpu(),
@@ -761,7 +790,7 @@ export class HostLoad {
761
790
  const byPid = new Map();
762
791
  /** @type {Map<number, number>} */
763
792
  const startedAt = new Map();
764
- /** @type {Map<number, { film: number | null, label: string, kind: string }>} */
793
+ /** @type {Map<number, { film: number | null, label: string, kind: string, costSec: number | null }>} */
765
794
  const runsByPid = new Map();
766
795
  runs.forEach((run, index) => {
767
796
  const seconds = cpuReadings[index];
@@ -769,7 +798,7 @@ export class HostLoad {
769
798
  byPid.set(run.pid, seconds);
770
799
  startedAt.set(run.pid, run.startedAt);
771
800
  }
772
- runsByPid.set(run.pid, { film: run.film, label: run.label, kind: run.kind });
801
+ runsByPid.set(run.pid, { film: run.film, label: run.label, kind: run.kind, costSec: run.costSec });
773
802
  });
774
803
  const sample = {
775
804
  takenAt: Date.now(),
@@ -828,7 +828,9 @@ export class ViewerRequests {
828
828
  logger.info(`playback held ${session.id} consumer=${consumerId}: delay ${seconds(delay)}s by ` +
829
829
  (holding.kind === "position-not-held" ? "the position, which nothing buffered covers" :
830
830
  `the ${holding.kind} of ${holding.track} #${holding.segment} in ${seconds(holding.atSeconds)}s against ` +
831
- `${seconds(holding.aheadSeconds)}s of media before it${holding.requiresSource ? ", its input still downloading" : ""}`) +
831
+ `${seconds(holding.aheadSeconds)}s of media before it${holding.requiresSource ? ", its input still downloading" : ""}` +
832
+ (Number.isFinite(holding.sourceArrivalSeconds) ? ` until ${seconds(holding.sourceArrivalSeconds)}s by ` +
833
+ (holding.sourcePlanned ? "the torrent's queues" : "the measured download rate") : "")) +
832
834
  `; buffered ${seconds(forecast.bufferedSeconds)}s; rates ${rates.join(", ") || "none"}; download ${downloads.join(", ") || "none"}`);
833
835
  }
834
836
 
@@ -309,6 +309,8 @@ export function wireOutputs({
309
309
  describeProcessor,
310
310
  liveRunsOf: (...args) => parts.encodeRuns.liveRunsOf(...args),
311
311
  runStateOf: (...args) => parts.encodeRuns.runStateOf(...args),
312
+ // Today's cost `c` beside each run's measured `k` (torrent-tv/meta#180, plan 2.3).
313
+ costSecOf: (session) => parts.encodeCost?.loadOfOutput(session.outputKey)?.costSec ?? null,
312
314
  get getSourceStats() { return parts.getSourceStats; },
313
315
  get getTorrentTotals() { return parts.getTorrentTotals; },
314
316
  get outputs() { return parts.outputs; },
@@ -1,27 +1,25 @@
1
1
  import { setImmediate as nextTurn } from "node:timers/promises";
2
2
  import { piecesOf } from "../demand/pieces.js";
3
3
  import { pieceStoreOf } from "../piece-store-of.js";
4
- import { compareBands } from "./bands.js";
5
4
 
6
5
  const BLOCK_BYTES = 16 * 1024;
7
6
 
8
- /** Estimate completion from observed queues and the public selection bands.
9
- * The native picker takes a band's pieces from its first up (the `sequential`
10
- * strategy), so an unqueued piece arrives after the band's pieces before it.
11
- * A piece whose every missing block is asked for arrives when its queues say,
12
- * and is not the band's to share out. A supplier is a peer that can be asked
13
- * for the piece now. Pieces without a measured supplier remain unknown. No
14
- * requests are changed.
7
+ /** When the pieces whose every missing block is asked for arrive, from the
8
+ * queues that hold them. A block is reserved to the wire it was asked of, so a
9
+ * faster peer elsewhere does not bring it sooner (WebTorrent 2.8.5 `_request`,
10
+ * torrent.js:1758). Every other missing piece is left unknown: the start
11
+ * forecast brings those at the source's measured service, in its own media
12
+ * order (`viewer/playback-readiness.js`), one model of supply rather than a
13
+ * second one here (torrent-tv/meta#185). No requests are changed.
15
14
  * Times are conditional on the measured rate and successful verification.
16
15
  */
17
16
  export async function futureDownload(entries, { now = Date.now(), findStore = pieceStoreOf, isCurrent = () => true } = {}) {
18
17
  if (!isCurrent()) return new Map();
19
18
  const forecasts = new Map();
20
- const pending = [];
21
19
  let steps = 0;
22
- for (const { torrent, register, limit = null } of entries) {
20
+ for (const { torrent, register } of entries) {
23
21
  if (torrent.destroyed || torrent.ready === false) continue;
24
- const wanted = new Map();
22
+ const wanted = new Set();
25
23
  for (const window of register.windows()) {
26
24
  const file = torrent.files?.[window.fileIndex];
27
25
  if (!file) continue;
@@ -29,10 +27,7 @@ export async function futureDownload(entries, { now = Date.now(), findStore = pi
29
27
  byteEnd: Math.min(window.byteEnd, file.length - 1), pieceLength: torrent.pieceLength });
30
28
  if (!range) continue;
31
29
  for (let piece = range.from; piece <= range.to; piece++) {
32
- const previous = wanted.get(piece);
33
- if (!previous || compareBands(window, previous) < 0) {
34
- wanted.set(piece, { urgency: window.urgency, priority: window.priority, deadlineAt: window.deadlineAt });
35
- }
30
+ wanted.add(piece);
36
31
  if (++steps % 256 === 0) {
37
32
  await nextTurn();
38
33
  if (!isCurrent()) return new Map();
@@ -63,7 +58,7 @@ export async function futureDownload(entries, { now = Date.now(), findStore = pi
63
58
  }
64
59
  const store = findStore(torrent);
65
60
  const arrivals = new Map();
66
- for (const [index, band] of wanted) {
61
+ for (const index of wanted) {
67
62
  const present = store?.locationOf ? store.locationOf(index) !== "missing" : torrent.bitfield?.get(index);
68
63
  if (present) {
69
64
  arrivals.set(index, now);
@@ -81,33 +76,6 @@ export async function futureDownload(entries, { now = Date.now(), findStore = pi
81
76
  at = Math.max(at, arrival);
82
77
  }
83
78
  arrivals.set(index, at);
84
- // EVERY MISSING BLOCK ASKED FOR: the queues holding them say when it
85
- // arrives. Each block is reserved to the wire it was asked of, so a faster
86
- // peer elsewhere does not bring it sooner, and its bytes are not the
87
- // band's to share out (torrent-tv/meta#178 review, WebTorrent 2.8.5
88
- // `_request`, torrent.js:1758).
89
- if (at !== null) {
90
- if (++steps % 256 === 0) {
91
- await nextTurn();
92
- if (!isCurrent()) return new Map();
93
- }
94
- continue;
95
- }
96
- const remaining = piece?.missing;
97
- const holders = [];
98
- for (const wire of torrent.wires ?? []) {
99
- // Only a peer that can be asked for it now: one choking us is asked for
100
- // nothing outside the pieces it allows, whatever it carried before.
101
- if (wire.destroyed || !wire.peerPieces?.get(index) || !mayAsk(wire, index)) continue;
102
- // What it carried with its queue kept full, where it has been seen so:
103
- // its speed while our selection gave it little measures our asking
104
- // (`SelectionLimit`, torrent-tv/meta#178).
105
- const rate = Number(limit?.capacityOf?.(wire) ?? wire.downloadSpeed?.());
106
- if (Number.isFinite(rate) && rate > 0) holders.push({ wire, rate });
107
- }
108
- pending.push({ arrivals, index, ...band, holders, remaining:
109
- Number.isSafeInteger(remaining) && remaining > 0 && remaining <= piece.length && holders.length > 0
110
- ? remaining : null });
111
79
  if (++steps % 256 === 0) {
112
80
  await nextTurn();
113
81
  if (!isCurrent()) return new Map();
@@ -115,77 +83,5 @@ export async function futureDownload(entries, { now = Date.now(), findStore = pi
115
83
  }
116
84
  forecasts.set(torrent, { measuredAt: now, arrivals });
117
85
  }
118
- // WITHIN A BAND, IN THE ORDER THE PIECES COME. The picker walks each
119
- // selection from its first piece up (WebTorrent 2.8.5's default `sequential`
120
- // strategy, which this proxy keeps), so a piece of a band arrives once those
121
- // before it have. Charged the whole band's end instead, a band of many
122
- // segments — the map states one per stretch of a level since 2.97.27 — would
123
- // put its nearest piece at its farthest piece's time (torrent-tv/meta#178).
124
- //
125
- // AND IN PARALLEL WHERE THE PEERS DIFFER. A piece is brought by the peers
126
- // holding it, each from when it has brought what was asked of it before, and
127
- // no sooner than the measured supply of every peer that holds a piece still
128
- // to come carries all the bytes ahead of it. Summed one piece after another
129
- // at its own holders' speed, a two-hour film whose far pieces each had a few
130
- // unchoked holders was forecast to arrive in 127 838 s at 2.33 MB/s, and a
131
- // seek was held for 39 s on it (r49, torrent-tv/meta#178). One holder for
132
- // every piece gives the sum it was; a piece nobody can be asked for still
133
- // leaves every piece after it unknown.
134
- pending.sort((left, right) => compareBands(left, right) || left.index - right.index);
135
- const supply = new Map();
136
- for (const { holders } of pending) for (const { wire, rate } of holders) supply.set(wire, rate);
137
- const total = [...supply.values()].reduce((sum, rate) => sum + rate, 0);
138
- const freeAt = new Map();
139
- let ahead = 0;
140
- let known = true;
141
- for (const { arrivals, index, holders, remaining } of pending) {
142
- if (!known || remaining === null || !(total > 0)) {
143
- known = false;
144
- continue;
145
- }
146
- ahead += remaining;
147
- const finish = Math.max(sharedFinish(holders, freeAt, remaining, now), now + ahead / total * 1000);
148
- for (const { wire } of holders) freeAt.set(wire, Math.max(freeAt.get(wire) ?? now, finish));
149
- arrivals.set(index, finish);
150
- }
151
86
  return isCurrent() ? forecasts : new Map();
152
87
  }
153
-
154
- /**
155
- * When peers bringing `bytes` together finish: each starts when it is free
156
- * and brings at its own rate, so the finish is where their combined bytes
157
- * reach `bytes` (the earliest of them working alone at first, more joining as
158
- * they come free).
159
- *
160
- * @param {Array<{ wire: object, rate: number }>} holders - Bytes per second.
161
- * @param {Map<object, number>} freeAt - Milliseconds.
162
- * @param {number} bytes
163
- * @param {number} now - Milliseconds.
164
- * @returns {number} Milliseconds.
165
- */
166
- function sharedFinish(holders, freeAt, bytes, now) {
167
- const starts = holders.map(({ wire, rate }) => ({ at: Math.max(now, freeAt.get(wire) ?? now), rate }))
168
- .sort((left, right) => left.at - right.at);
169
- let rate = 0;
170
- let weighted = 0;
171
- for (let index = 0; index < starts.length; index++) {
172
- rate += starts[index].rate;
173
- weighted += starts[index].rate * starts[index].at;
174
- const finish = (bytes * 1000 + weighted) / rate;
175
- if (index === starts.length - 1 || finish <= starts[index + 1].at) return finish;
176
- }
177
- return now;
178
- }
179
-
180
- /**
181
- * Whether this peer can be asked for this piece now: it is not choking us, or
182
- * it allows this piece while it does (BEP 6, WebTorrent 2.8.5 `_updateWire`).
183
- *
184
- * @param {{ peerChoking?: boolean, hasFast?: boolean, peerAllowedFastSet?: number[] }} wire
185
- * @param {number} index
186
- * @returns {boolean}
187
- */
188
- function mayAsk(wire, index) {
189
- if (wire.peerChoking !== true) return true;
190
- return wire.hasFast === true && Array.isArray(wire.peerAllowedFastSet) && wire.peerAllowedFastSet.includes(index);
191
- }
@@ -380,7 +380,7 @@ export function predictPlaybackReadiness(input = {}) {
380
380
  const arrival = (range.availableAt - now) / 1000;
381
381
  // The entire protocol piece becomes readable after verification;
382
382
  // bytes inside it never arrive proportionally to their file address.
383
- sourceByteService.get(id).push({ start: range.start, end: range.end, begin: arrival, finish: arrival });
383
+ sourceByteService.get(id).push({ start: range.start, end: range.end, begin: arrival, finish: arrival, planned: true });
384
384
  }
385
385
  }
386
386
  }
@@ -409,6 +409,7 @@ export function predictPlaybackReadiness(input = {}) {
409
409
  for (const job of jobs) {
410
410
  const { track, trackIndex, segment, sizeBits, encodeWork, sourceByteIntervals } = job;
411
411
  let sourceArrivalAt = 0;
412
+ const plan = { finish: 0 };
412
413
  for (const [sourceId, ranges] of sourceByteIntervals) {
413
414
  const source = sourceState.get(sourceId);
414
415
  if (!source) {
@@ -417,7 +418,7 @@ export function predictPlaybackReadiness(input = {}) {
417
418
  for (const interval of ranges) {
418
419
  const missing = uncoveredIntervals(interval, sourceByteCoverage.get(sourceId) ?? []);
419
420
  if (missing.length === 0) {
420
- sourceArrivalAt = Math.max(sourceArrivalAt, latestArrival(interval, sourceArrivals.get(sourceId)));
421
+ sourceArrivalAt = Math.max(sourceArrivalAt, latestArrival(interval, sourceArrivals.get(sourceId), plan));
421
422
  continue;
422
423
  }
423
424
 
@@ -468,10 +469,13 @@ export function predictPlaybackReadiness(input = {}) {
468
469
  }
469
470
  const producedAt = job.produced || encodeWork === 0 ? 0 : processing.finish(encodeWork, admittedAt);
470
471
  trackFinish[trackIndex] = Math.max(trackFinish[trackIndex], producedAt);
471
- productions.push({ at: producedAt, trackIndex, segment, requiresSource: sourceArrivalAt > 0 });
472
+ const sourcePlanned = sourceArrivalAt > 0 && plan.finish >= sourceArrivalAt;
473
+ productions.push({ at: producedAt, trackIndex, segment, requiresSource: sourceArrivalAt > 0, sourceArrivalAt,
474
+ sourcePlanned });
472
475
  const deliveryAt = link.finish(sizeBits, Math.max(transferFinish, producedAt));
473
476
  transferFinish = deliveryAt;
474
- completions.push({ at: deliveryAt + finiteNonNegative(track.appendSeconds), trackIndex, segment });
477
+ completions.push({ at: deliveryAt + finiteNonNegative(track.appendSeconds), trackIndex, segment, sourceArrivalAt,
478
+ sourcePlanned });
475
479
  }
476
480
 
477
481
  const constraints = [];
@@ -479,10 +483,12 @@ export function predictPlaybackReadiness(input = {}) {
479
483
  // largest of thousands, and the log could only say that some term was
480
484
  // positive while the picture stood for 17 s (torrent-tv/meta#178).
481
485
  let holding = null;
482
- const constrain = (value, kind, trackIndex, segment, atSeconds, aheadSeconds, requiresSource) => {
486
+ const constrain = (value, kind, trackIndex, segment, atSeconds, aheadSeconds, requiresSource,
487
+ sourceArrivalAt = 0, sourcePlanned = false) => {
483
488
  constraints.push(value);
484
489
  if (value > 0 && (holding === null || value > holding.value)) {
485
- holding = { value, kind, trackIndex, segment, atSeconds, aheadSeconds, requiresSource };
490
+ holding = { value, kind, trackIndex, segment, atSeconds, aheadSeconds, requiresSource, sourceArrivalAt,
491
+ sourcePlanned };
486
492
  }
487
493
  };
488
494
  const end = rescale(fromSeconds(duration), unit);
@@ -501,7 +507,8 @@ export function predictPlaybackReadiness(input = {}) {
501
507
  for (const range of completion.segment.ranges) addMediaRange(delivered, range);
502
508
  after = contiguousEnd(delivered, at);
503
509
  const ahead = secondsAhead(earliest(before, trackEnd), at);
504
- constrain(completion.at - ahead, "delivery", trackIndex, completion.segment.index, completion.at, ahead, false);
510
+ constrain(completion.at - ahead, "delivery", trackIndex, completion.segment.index, completion.at, ahead, false,
511
+ completion.sourceArrivalAt, completion.sourcePlanned);
505
512
  if (compare(after, before) <= 0 && completion.segment.ranges.some(({ start }) => compare(start, before) > 0)) {
506
513
  // A timestamp hole cannot be repaired by waiting longer. Keep it
507
514
  // distinct from an unavailable rate or a slow but finite service.
@@ -524,7 +531,7 @@ export function predictPlaybackReadiness(input = {}) {
524
531
  : Math.max(0, state.tailEndSeconds - toSeconds(before));
525
532
  const ahead = Math.max(0, secondsAhead(before, at) - Math.min(reserve, left) * Number(production.requiresSource));
526
533
  constrain(production.at - ahead, "production", trackIndex, production.segment.index, production.at, ahead,
527
- production.requiresSource);
534
+ production.requiresSource, production.sourceArrivalAt, production.sourcePlanned);
528
535
  }
529
536
  }
530
537
  if (trackState.some(({ tail }) => tail.length > 0)) {
@@ -548,7 +555,11 @@ export function predictPlaybackReadiness(input = {}) {
548
555
  holdingBy: ready ? null : holding === null ? (originReady ? null : { kind: "position-not-held" })
549
556
  : { kind: holding.kind, track: tracks[holding.trackIndex]?.id ?? holding.trackIndex,
550
557
  segment: holding.segment, atSeconds: holding.atSeconds, aheadSeconds: holding.aheadSeconds,
551
- requiresSource: holding.requiresSource },
558
+ requiresSource: holding.requiresSource,
559
+ // When its input's last bytes come, and whether the torrent thread's
560
+ // queues say so rather than the source's measured service.
561
+ ...(holding.sourceArrivalAt > 0 ? { sourceArrivalSeconds: holding.sourceArrivalAt,
562
+ sourcePlanned: holding.sourcePlanned } : {}) },
552
563
  preparedUntilSeconds: Math.min(duration, ...trackState.map((state, index) => {
553
564
  const ranges = state.clientRanges.map((range) => ({ ...range }));
554
565
  for (const item of productions.filter((item) => item.trackIndex === index && item.at <= delay)) {
@@ -600,6 +611,7 @@ function scheduleTail({ trackState, sourceState, sourceByteCoverage, sourceArriv
600
611
  return state;
601
612
  };
602
613
  const next = trackState.map(() => 0);
614
+ const plan = { finish: 0 };
603
615
  let delivered = transferFinish;
604
616
  for (;;) {
605
617
  // The next piece in media order across the tracks, as the near window orders them.
@@ -635,6 +647,7 @@ function scheduleTail({ trackState, sourceState, sourceByteCoverage, sourceArriv
635
647
  work = Math.max(0, work);
636
648
  }
637
649
  let sourceArrivalAt = 0;
650
+ plan.finish = 0;
638
651
  if (work > 0) {
639
652
  if (!state.curve) return { reason: "encode-rate-unavailable" };
640
653
  const trackSources = Array.isArray(track.sourceIds) ? track.sourceIds : [];
@@ -653,7 +666,7 @@ function scheduleTail({ trackState, sourceState, sourceByteCoverage, sourceArriv
653
666
  let order = 0;
654
667
  for (const range of ranges) {
655
668
  // 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)));
669
+ sourceArrivalAt = Math.max(sourceArrivalAt, latestArrival(range, sourceArrivals.get(sourceId), plan));
657
670
  const after = missingBefore(bytes, range.end);
658
671
  if (after > missingBefore(bytes, range.start)) order = Math.max(order, after);
659
672
  }
@@ -703,10 +716,13 @@ function scheduleTail({ trackState, sourceState, sourceByteCoverage, sourceArriv
703
716
  // hole stops the media is decided in the near window, in exact time.
704
717
  const ahead = Math.max(0, startsAt - position);
705
718
  const requiresSource = sourceArrivalAt > 0;
706
- constrain(completedAt - ahead, "delivery", trackIndex, segment.index, completedAt, ahead, false);
719
+ const sourcePlanned = requiresSource && plan.finish >= sourceArrivalAt;
720
+ constrain(completedAt - ahead, "delivery", trackIndex, segment.index, completedAt, ahead, false, sourceArrivalAt,
721
+ sourcePlanned);
707
722
  const covered = Math.max(0, ahead - Math.min(reserve, Math.max(0, state.tailEndSeconds - startsAt)) *
708
723
  Number(requiresSource));
709
- constrain(producedAt - covered, "production", trackIndex, segment.index, producedAt, covered, requiresSource);
724
+ constrain(producedAt - covered, "production", trackIndex, segment.index, producedAt, covered, requiresSource,
725
+ sourceArrivalAt, sourcePlanned);
710
726
  }
711
727
  }
712
728
 
@@ -1023,7 +1039,16 @@ function indexedArrivals(ranges) {
1023
1039
  .map(range => ({ ...range, maximumEnd: maximumEnd = Math.max(maximumEnd, range.end) }));
1024
1040
  }
1025
1041
 
1026
- function latestArrival(interval, ranges = []) {
1042
+ /**
1043
+ * When the last of an interval's bytes arrives, by the arrivals indexed; with
1044
+ * `plan`, the latest of those the torrent thread's queues gave is kept in it.
1045
+ *
1046
+ * @param {{ start: number, end: number }} interval
1047
+ * @param {Array<{ start: number, end: number, finish: number, maximumEnd: number, planned?: boolean }>} [ranges]
1048
+ * @param {{ finish: number } | null} [plan]
1049
+ * @returns {number}
1050
+ */
1051
+ function latestArrival(interval, ranges = [], plan = null) {
1027
1052
  let low = 0, high = ranges.length;
1028
1053
  while (low < high) {
1029
1054
  const middle = (low + high) >>> 1;
@@ -1032,7 +1057,10 @@ function latestArrival(interval, ranges = []) {
1032
1057
  }
1033
1058
  let finish = 0;
1034
1059
  for (let index = low; index < ranges.length && ranges[index].start < interval.end; index++) {
1035
- if (ranges[index].end > interval.start) finish = Math.max(finish, ranges[index].finish);
1060
+ if (ranges[index].end > interval.start) {
1061
+ finish = Math.max(finish, ranges[index].finish);
1062
+ if (plan && ranges[index].planned) plan.finish = Math.max(plan.finish, ranges[index].finish);
1063
+ }
1036
1064
  }
1037
1065
  return finish;
1038
1066
  }