@torrent-tv/proxy 2.107.38 → 2.107.40

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.107.38",
3
+ "version": "2.107.40",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "repository": {
@@ -1005,6 +1005,7 @@ export class ViewerRequests {
1005
1005
  ? stats.fileLength / session.file.durationSeconds : null,
1006
1006
  pieceLength: stats?.pieceLength,
1007
1007
  downloadRateReadings: this.#sourceRateReadings(measurement, output.file.sourceKey, stats, now),
1008
+ tailRateReadings: this.#sourceTailRateReadings(measurement, output.file.sourceKey, now),
1008
1009
  downloadRateSummaries: this.#sourceRateSummaries(measurement, output.file.sourceKey),
1009
1010
  downloadForecast,
1010
1011
  });
@@ -1081,6 +1082,9 @@ export class ViewerRequests {
1081
1082
  bufferLimitSeconds: viewerReading?.bufferLimitSeconds,
1082
1083
  reserveSeconds,
1083
1084
  lookaheadSeconds: this.#host.lookaheadSeconds,
1085
+ // What this proxy may keep of downloaded pieces on disk: the most a
1086
+ // start can hold of the film ahead of the viewer.
1087
+ sourceStorageBytes: this.#host.sourceStorageBytes?.() ?? null,
1084
1088
  requiredAudio: requiresSeparateAudio,
1085
1089
  sources: [...sourceMeasurements.values()],
1086
1090
  tracks,
@@ -1380,16 +1384,21 @@ export class ViewerRequests {
1380
1384
  #sourceRateReadings(state, id, stats, now) {
1381
1385
  let source = state.sources.get(id);
1382
1386
  if (!source) {
1383
- const { RateTrend } = this.#host.playbackReadiness;
1384
- source = { delivered: new RateTrend(this.#host.lookaheadSeconds), free: new RateTrend(this.#host.lookaheadSeconds), limit: null };
1387
+ const { RateTrend, SessionRate } = this.#host.playbackReadiness;
1388
+ source = { delivered: new RateTrend(this.#host.lookaheadSeconds), free: new RateTrend(this.#host.lookaheadSeconds),
1389
+ // The same readings over the whole session, which the rest of the film is priced by.
1390
+ deliveredAll: new SessionRate(), freeAll: new SessionRate(), limit: null };
1385
1391
  state.sources.set(id, source);
1386
1392
  }
1387
1393
  if (Number.isFinite(stats?.downloadSpeed) && stats.downloadSpeed >= 0) {
1388
1394
  source.delivered.add(now, stats.downloadSpeed);
1395
+ source.deliveredAll.add(now, stats.downloadSpeed);
1389
1396
  }
1390
1397
  const limit = stats?.selectionLimit;
1391
1398
  if (limit && source.limit && limit.freeMs > source.limit.freeMs) {
1392
- source.free.add(now, Math.max(0, limit.freeBytes - source.limit.freeBytes) * 1000 / (limit.freeMs - source.limit.freeMs));
1399
+ const free = Math.max(0, limit.freeBytes - source.limit.freeBytes) * 1000 / (limit.freeMs - source.limit.freeMs);
1400
+ source.free.add(now, free);
1401
+ source.freeAll.add(now, free);
1393
1402
  }
1394
1403
  // Totals that went back belong to a torrent added again: read on from them.
1395
1404
  if (limit) source.limit = limit;
@@ -1397,6 +1406,14 @@ export class ViewerRequests {
1397
1406
  (summary) => this.#host.playbackReadiness.forecastRate(summary, now, 0, "time"));
1398
1407
  }
1399
1408
 
1409
+ /** The source's service over the whole session, chosen by the same rule as the recent one. */
1410
+ #sourceTailRateReadings(state, id, now) {
1411
+ const source = state.sources.get(id);
1412
+ if (!source) return null;
1413
+ return sourceService(source.freeAll.snapshot(), source.deliveredAll.snapshot(),
1414
+ (summary) => this.#host.playbackReadiness.forecastRate(summary, now, 0, "time"));
1415
+ }
1416
+
1400
1417
  /** Both summaries a source's service was chosen from, for the forecast's log line. */
1401
1418
  #sourceRateSummaries(state, id) {
1402
1419
  const source = state.sources.get(id);
@@ -72,7 +72,7 @@ import { ViewerRequests } from "./ViewerRequests.js";
72
72
  import { OutputLifecycle } from "./OutputLifecycle.js";
73
73
  import { SegmentServing } from "./SegmentServing.js";
74
74
  import { audioStartSecondsFor } from "../viewer/audio-start.js";
75
- import { endsWithinPlayerSeekTolerance, forecastRate, rateSummaryOf, predictPlaybackReadiness, RateTrend } from "../viewer/playback-readiness.js";
75
+ import { endsWithinPlayerSeekTolerance, forecastRate, rateSummaryOf, predictPlaybackReadiness, RateTrend, SessionRate } from "../viewer/playback-readiness.js";
76
76
  import { audioRenditionName } from "../media/audio-inventory.js";
77
77
  import { Renditions } from "../encode/Renditions.js";
78
78
  import { CushionReport, LOOKAHEAD_PAUSE_SECONDS } from "../encode/CushionReport.js";
@@ -628,6 +628,8 @@ export function wireOutputs({
628
628
  activeOutputFor: (args) => activeOutputFor({ ...args, viewers: parts.viewers }),
629
629
  viewerSecondsOn: (output, consumerId, now) => viewerSecondsOn(parts.viewers, output, consumerId, now),
630
630
  minimumBufferSecondsFor: (output) => minimumBufferSecondsOf(output),
631
+ // What this proxy may keep of downloaded pieces on disk.
632
+ sourceStorageBytes: () => parts.machineBudget?.allowedFor?.("spilled pieces") ?? null,
631
633
  getSourceStats: (...args) => parts.getSourceStats?.(...args) ?? null,
632
634
  disposeSession: (...args) => parts.lifecycle.disposeSession(...args),
633
635
  planEncodersSoon: (...args) => parts.encodeRuns.planEncodersSoon(...args),
@@ -635,7 +637,7 @@ export function wireOutputs({
635
637
  get encodeRuns() { return parts.encodeRuns; },
636
638
  // When this viewer's playback can start and run to the end: the viewer
637
639
  // component's forecast, which models the viewer's browser.
638
- playbackReadiness: { predict: predictPlaybackReadiness, forecastRate, rateSummaryOf, RateTrend,
640
+ playbackReadiness: { predict: predictPlaybackReadiness, forecastRate, rateSummaryOf, RateTrend, SessionRate,
639
641
  endsWithinPlayerSeekTolerance },
640
642
  // Whether the subtitle a viewer chose has been read where they stand — the
641
643
  // source preparation's answer, set on these parts by the assembly.
@@ -152,6 +152,20 @@ export class MachineBudget {
152
152
  this.#claimants = this.#claimants.filter((one) => one.name !== name);
153
153
  }
154
154
 
155
+ /**
156
+ * What one claimant was last allowed, or null before a measured division.
157
+ *
158
+ * @param {string} name
159
+ * @returns {number | null}
160
+ */
161
+ allowedFor(name) {
162
+ for (const reading of this.#last.values()) {
163
+ const share = reading.measured ? reading.shares.find((one) => one.name === name) : null;
164
+ if (share) return share.allowed;
165
+ }
166
+ return null;
167
+ }
168
+
155
169
  /** Total measured policy allowance, independent of its current division. */
156
170
  capacityOf(name) {
157
171
  const reading = this.#last.get(name);
@@ -87,6 +87,57 @@ export function rateSummaryOf(readings) {
87
87
  return ordered.length > 0 ? summarizeRate(ordered) : null;
88
88
  }
89
89
 
90
+ /**
91
+ * Every measurement of one session, summarised as it arrives: the same
92
+ * summary {@link RateTrend} gives over its horizon, kept in O(1).
93
+ *
94
+ * The rest of the film is priced at this mean, and the near window at the
95
+ * recent one. Compared on field download readings (24 runs of 10-22 minutes,
96
+ * 0.4-13 MB/s, torrent-tv/meta#239,
97
+ * `research/coarse-tail-forecast-2026-10-26.md`), the mean since the start
98
+ * had the smallest worst-case error for the next 3-10 minutes (p90 34-44 %
99
+ * against 39-57 % for the 120 s mean) at a slightly larger typical error, and
100
+ * a slope through recent readings was the worst predictor at every horizon of
101
+ * 3 minutes or more.
102
+ */
103
+ export class SessionRate {
104
+ #count = 0;
105
+ #first = null;
106
+ #last = null;
107
+ #sum = 0;
108
+ #inverseSum = 0;
109
+ #zero = false;
110
+ #weighted = 0;
111
+
112
+ add(at, value) {
113
+ if (!Number.isFinite(at) || !Number.isFinite(value) || value < 0 || at <= (this.#last?.at ?? Number.NEGATIVE_INFINITY)) {
114
+ return false;
115
+ }
116
+ if (this.#last) this.#weighted += this.#last.value * (at - this.#last.at);
117
+ this.#first ??= { at, value };
118
+ this.#last = { at, value };
119
+ this.#count += 1;
120
+ this.#sum += value;
121
+ if (value > 0) this.#inverseSum += 1 / value;
122
+ else this.#zero = true;
123
+ return true;
124
+ }
125
+
126
+ snapshot() {
127
+ if (!this.#last) return null;
128
+ const spanMs = this.#last.at - this.#first.at;
129
+ return {
130
+ count: this.#count,
131
+ lastAt: this.#last.at,
132
+ lastValue: this.#last.value,
133
+ meanValue: this.#sum / this.#count,
134
+ harmonicValue: this.#zero ? 0 : this.#count / this.#inverseSum,
135
+ timeWeightedValue: spanMs > 0 ? this.#weighted / spanMs : this.#last.value,
136
+ spanSeconds: spanMs / 1000
137
+ };
138
+ }
139
+ }
140
+
90
141
  /**
91
142
  * Measurements over the output's existing look-ahead horizon. The oldest
92
143
  * boundary sample is retained until another sample replaces it, so a repeated
@@ -133,6 +184,7 @@ export class RateTrend {
133
184
  * @param {boolean} [input.requiredAudio]
134
185
  * @param {Array<{ id: string, serviceKey?: string, complete: boolean, fileOffset?: number,
135
186
  * fileLength?: number, bytesPerMediaSecond?: number, pieceLength?: number, downloadRateReadings?: object,
187
+ * tailRateReadings?: object,
136
188
  * downloadForecast?: { ranges: Array<{ start: number, end: number, availableAt: number | null }> } }>} input.sources
137
189
  * @param {Array<{ id: string, sourceIds: string[], processedSeconds: number, durationSeconds?: number,
138
190
  * segmentCount?: number,
@@ -158,17 +210,16 @@ export function predictPlaybackReadiness(input = {}) {
158
210
  // starts), the read piece nearest before them (what an unread piece is placed
159
211
  // by) and read pieces inside what the page reports holding (their frames).
160
212
  //
161
- // THE NEAR WINDOW. The look-ahead is scheduled with exact segment times and
162
- // source ranges when known. Missing range metadata is priced from aggregate
163
- // file density and measured download service without claiming an address or
164
- // byte. This forecast decides whether playback can start from work near the
165
- // viewer; it does not make the first frame wait on an aggregate estimate of
166
- // the whole remaining film. Each later poll advances this same window.
213
+ // THE NEAR WINDOW AND THE SUFFIX. The look-ahead is scheduled with exact
214
+ // segment times and source ranges when known. Missing range metadata is priced
215
+ // from aggregate file density and measured download service without claiming
216
+ // an address or byte. Beyond the look-ahead, {@link scheduleTail} estimates
217
+ // remaining source, encoder and delivery work without visiting every segment.
167
218
  const lookahead = finiteNonNegative(input.lookaheadSeconds);
168
219
  const horizonEnd = lookahead > 0 ? finiteNonNegative(input.positionSeconds) + lookahead : Number.POSITIVE_INFINITY;
169
220
  const tracks = (Array.isArray(input.tracks) ? input.tracks : []).map((track) =>
170
- Array.isArray(track.segments)
171
- ? { ...track, segments: piecesThatMatter(track, finiteNonNegative(input.positionSeconds), horizonEnd) } : track);
221
+ Array.isArray(track.segments) ? { ...track, segmentCount: track.segmentCount ?? track.segments.length,
222
+ segments: piecesThatMatter(track, finiteNonNegative(input.positionSeconds), horizonEnd) } : track);
172
223
  const duration = finiteNonNegative(input.durationSeconds);
173
224
  // Each track's served ticks are placed on the clock the page reports by the
174
225
  // offset its player applied (`timestampOffset`).
@@ -239,11 +290,25 @@ export function predictPlaybackReadiness(input = {}) {
239
290
  .filter((segment) => isPiece(segment) &&
240
291
  segment.endSeconds >= position - PLAYER_SEEK_TOLERANCE_SECONDS && segment.startSeconds < horizonEnd)
241
292
  .sort((left, right) => left.startSeconds - right.startSeconds || left.index - right.index),
293
+ // The pieces after the near window, as the playlist cuts them, and the
294
+ // offset the page's player placed them by.
295
+ tail: [],
296
+ tailStartSeconds: horizonEnd,
242
297
  durationSeconds: Number.isFinite(track.durationSeconds) ? track.durationSeconds : duration,
298
+ segmentCount: Number.isSafeInteger(track.segmentCount) ? track.segmentCount
299
+ : Math.max(0, ...track.segments.map(segment => segment.index + 1)),
243
300
  clockSeconds: Number.isFinite(track.clockOffsetSeconds) ? track.clockOffsetSeconds : 0
244
301
  }));
245
302
  for (const state of trackState) {
246
- state.trackEndSeconds = Math.min(duration, state.durationSeconds + state.clockSeconds);
303
+ state.tailEndSeconds = Number.isFinite(horizonEnd) && horizonEnd < duration
304
+ ? Math.min(duration, state.durationSeconds + state.clockSeconds) : null;
305
+ const endIndex = state.segments.reduce((last, segment) => segment.startSeconds < horizonEnd
306
+ ? Math.max(last, segment.index) : last, -1);
307
+ state.tailSegmentCount = Math.max(0, state.segmentCount - endIndex - 1);
308
+ const readyTail = [...state.ready].reduce((count, index) => count + Number(index > endIndex), 0);
309
+ state.tailWorkFraction = state.tailSegmentCount > 0
310
+ ? Math.max(0, 1 - Math.min(state.tailSegmentCount, readyTail) / state.tailSegmentCount)
311
+ : 0;
247
312
  }
248
313
  // A file can declare a duration longer than its last packet. The HLS VOD
249
314
  // playlist still has a final numbered piece, but its made fragment ends at
@@ -277,7 +342,9 @@ export function predictPlaybackReadiness(input = {}) {
277
342
  // Bytes over time across the readings the trend keeps: the last 5 s of a
278
343
  // swarm moved between 0.97 and 5.8 MB/s and was carried to the end of the
279
344
  // film each time (torrent-tv/meta#178).
280
- service = { rate: rateCurve(source.downloadRateReadings, now, "time"), finish: 0 };
345
+ service = { rate: rateCurve(source.downloadRateReadings, now, "time"),
346
+ // The rest of the film at the mean since the session began (`SessionRate`).
347
+ tailRate: rateCurve(source.tailRateReadings ?? source.downloadRateReadings, now, "time"), finish: 0 };
281
348
  sourceServices.set(serviceKey, service);
282
349
  }
283
350
  sourceState.set(id, { source, service });
@@ -611,12 +678,30 @@ export function predictPlaybackReadiness(input = {}) {
611
678
  // Supply interruption coverage is stock held by the proxy as well as
612
679
  // the browser. It is not a browser buffer-size admission rule.
613
680
  // The reserve is bounded by what is left of the track, not of the window.
614
- const left = Math.max(0, state.trackEndSeconds - toSeconds(before));
681
+ const left = state.tailEndSeconds === null ? secondsAhead(trackEnd, before)
682
+ : Math.max(0, state.tailEndSeconds - toSeconds(before));
615
683
  const ahead = Math.max(0, secondsAhead(before, at) - Math.min(reserve, left) * Number(production.requiresSource));
616
684
  constrain(production.at - ahead, "production", trackIndex, production.segment.index, production.at, ahead,
617
685
  production.requiresSource, production.sourceArrivalAt, production.sourcePlanned);
618
686
  }
619
687
  }
688
+ const tailStock = [];
689
+ if (trackState.some(({ tailEndSeconds }) => Number.isFinite(tailEndSeconds))) {
690
+ // Where each source's bytes stop being the near window's: the last byte
691
+ // its pieces read, in the file's own offsets.
692
+ const windowEnds = new Map();
693
+ for (const { sourceByteIntervals } of jobs) {
694
+ for (const [sourceId, ranges] of sourceByteIntervals) {
695
+ for (const range of ranges) windowEnds.set(sourceId, Math.max(windowEnds.get(sourceId) ?? 0, range.end));
696
+ }
697
+ }
698
+ const stop = scheduleTail({ trackState, sourceState, runStarts, trackFinish,
699
+ link, position, buffered, reserve, transferFinish, constrain, duration, stock: tailStock, windowEnds });
700
+ if (stop) {
701
+ return { ...result(false, null, buffered, reserve, null, stop.reason, preparedSegments),
702
+ ...(stop.unavailableSource ? { unavailableSource: stop.unavailableSource } : {}) };
703
+ }
704
+ }
620
705
  const firstRequiredStages = trackState.slice(0, requiredTracks).map((state, trackIndex) => {
621
706
  const first = state.segments.find(segment => segment.endSeconds > position);
622
707
  if (!first) return { track: state.track.id ?? trackIndex, segment: null, alreadyBuffered: true,
@@ -646,6 +731,23 @@ export function predictPlaybackReadiness(input = {}) {
646
731
  const firstRequiredSegmentsSeconds = firstRequiredTrackSeconds.every(Number.isFinite)
647
732
  ? Math.max(0, ...firstRequiredTrackSeconds) : null;
648
733
  const delay = Math.max(0, ...constraints);
734
+ // THE STOCK THE START ASKS FOR HAS TO FIT. Starting without a stop while the
735
+ // source comes slower than the film plays means holding, at the start, what
736
+ // is already here and everything that arrives before it. Where that is more
737
+ // than this proxy may keep of downloaded pieces, the oldest of it would be
738
+ // given up before the viewer reached it, and no wait makes the start safe:
739
+ // Despicable Me 3 BDRemux at 1.4 MB/s asked for about 36 GB against a 29.5 GB
740
+ // share (Home Assistant 2026-10-26, torrent-tv/meta#239). Said as such, and
741
+ // the viewer keeps waiting rather than starting into stops.
742
+ const storageBytes = Number(input.sourceStorageBytes);
743
+ if (Number.isFinite(delay) && Number.isFinite(storageBytes) && storageBytes >= 0 && tailStock.length > 0) {
744
+ const cushionBytes = tailStock.reduce((sum, part) => sum + Math.max(0, part.totalBytes - part.missingBytes) +
745
+ Math.max(0, part.missingBytes - part.bytesPerSecond * Math.max(0, part.finish - delay)), 0);
746
+ if (cushionBytes > storageBytes) {
747
+ return { ...result(false, null, buffered, reserve, null, "cushion-exceeds-storage", preparedSegments),
748
+ cushionBytes, storageBytes };
749
+ }
750
+ }
649
751
  const originReady = trackState.every(({ clientRanges }) => compare(contiguousEnd(clientRanges, at), at) > 0);
650
752
  const ready = delay === 0 && originReady;
651
753
  const forecast = result(ready, Number.isFinite(delay) ? delay : null, buffered, reserve,
@@ -681,6 +783,103 @@ function isPiece(segment) {
681
783
  Number.isFinite(segment?.endSeconds) && segment.endSeconds > segment.startSeconds;
682
784
  }
683
785
 
786
+ /**
787
+ * A duration-level estimate of the suffix after the exact near window. Source
788
+ * work uses each file's aggregate byte density and measured download service;
789
+ * processing uses each track's measured rate; delivery uses output bitrates and
790
+ * measured link service. Held source bytes and made segments reduce the
791
+ * estimate. This conditional forecast does not assert exact source ranges or
792
+ * individual frame timing. Its work is bounded by sources and tracks, not by
793
+ * the film's segment count.
794
+ *
795
+ * @returns {{ reason: string, unavailableSource?: object } | null} Why there is no answer, or null.
796
+ */
797
+ function scheduleTail({ trackState, sourceState, runStarts, trackFinish, link,
798
+ position, buffered, reserve, transferFinish, constrain, duration, stock = null, windowEnds = null }) {
799
+ const tailStart = Math.max(position, ...trackState.map(state => state.tailStartSeconds));
800
+ if (!Number.isFinite(tailStart)) return null;
801
+ const sourceDurations = new Map();
802
+ for (const state of trackState) {
803
+ const seconds = Math.max(0, state.tailEndSeconds - tailStart) * state.tailWorkFraction;
804
+ if (!(seconds > 0)) continue;
805
+ for (const sourceId of new Set(state.track.sourceIds ?? [])) {
806
+ sourceDurations.set(sourceId, Math.max(sourceDurations.get(sourceId) ?? 0, seconds));
807
+ }
808
+ }
809
+
810
+ let sourceArrivalAt = 0;
811
+ for (const [sourceId, seconds] of sourceDurations) {
812
+ const source = sourceState.get(sourceId);
813
+ if (!source) return { reason: "source-measurement-unavailable" };
814
+ if (source.source.complete === true) continue;
815
+ // Held means on this proxy now, past the last byte the near window's
816
+ // pieces read: those are counted above, and the window's planned arrivals
817
+ // counted again as held took 117 s of film off a 90-minute tail. The
818
+ // torrent thread's plan is not that end: it can reach the end of the file.
819
+ const windowEnd = windowEnds?.get(sourceId) ?? 0;
820
+ const bytes = estimateUnmappedSourceBytes(source.source, seconds, duration,
821
+ heldBeyond(source.source.residence, windowEnd), windowEnd);
822
+ if (bytes === null) return { reason: "source-measurement-unavailable",
823
+ unavailableSource: { sourceId, phase: "tail" } };
824
+ if (!(bytes > 0)) continue;
825
+ const { service } = source;
826
+ const rate = service.tailRate ?? service.rate;
827
+ if (!rate || !(rate.rateAt(0) > 0)) {
828
+ return { reason: rate ? "service-not-advancing" : "download-rate-unavailable",
829
+ unavailableSource: { sourceId, estimatedBytes: bytes, phase: "tail" } };
830
+ }
831
+ service.finish = rate.finish(bytes, service.finish);
832
+ sourceArrivalAt = Math.max(sourceArrivalAt, service.finish);
833
+ // What this stretch weighs and what it still lacks: the stock the start
834
+ // asks to hold is read from these once the start's delay is known.
835
+ stock?.push({ totalBytes: estimateUnmappedSourceBytes(source.source, seconds, duration, []) ?? bytes,
836
+ missingBytes: bytes, bytesPerSecond: rate.rateAt(0), finish: service.finish });
837
+ const state = trackState.find(candidate => candidate.track.sourceIds?.includes(sourceId));
838
+ const index = state?.segments.at(-1)?.index ?? 0;
839
+ const ahead = Math.max(0, (state?.tailEndSeconds ?? tailStart) - position);
840
+ constrain(service.finish - Math.max(0, ahead - reserve), "source", trackState.indexOf(state), index,
841
+ service.finish, ahead, true, service.finish, false);
842
+ }
843
+
844
+ let outputBits = 0;
845
+ let latestTrackFinish = 0;
846
+ for (let trackIndex = 0; trackIndex < trackState.length; trackIndex += 1) {
847
+ const state = trackState[trackIndex];
848
+ const end = state.tailEndSeconds;
849
+ const seconds = Math.max(0, end - tailStart);
850
+ if (!(seconds > 0)) continue;
851
+ const track = state.track;
852
+ const segment = state.segments.at(-1) ?? { index: 0 };
853
+ const remaining = seconds * state.tailWorkFraction;
854
+ const work = Math.max(0, remaining - numericCoverage((track.processingRanges ?? [])
855
+ .filter(range => Number.isFinite(range?.start) && Number.isFinite(range?.end))
856
+ .map(range => ({ start: Math.max(tailStart, range.start), end: Math.min(end, range.end) }))
857
+ .filter(range => range.end > range.start)).reduce((sum, range) => sum + range.end - range.start, 0));
858
+ const processing = state.curve;
859
+ if (work > 0 && !processing) return { reason: "encode-rate-unavailable" };
860
+ const run = runStarts[trackIndex];
861
+ const localStart = tailStart - state.clockSeconds;
862
+ const inRun = run.spans.some(span => localStart >= span.start && localStart < span.end);
863
+ const open = work > 0 && !inRun ? run.openSeconds : 0;
864
+ const finishedAt = work > 0 ? processing.finish(work, trackFinish[trackIndex] + open) : trackFinish[trackIndex];
865
+ latestTrackFinish = Math.max(latestTrackFinish, finishedAt);
866
+ const deliveryStart = Math.max(tailStart, position + buffered);
867
+ outputBits += Math.max(0, state.bitsPerMediaSecond) * Math.max(0, end - deliveryStart);
868
+ const ahead = Math.max(0, end - position);
869
+ constrain(finishedAt - Math.max(0, ahead - reserve), "production", trackIndex, segment.index,
870
+ finishedAt, ahead, sourceArrivalAt > 0, sourceArrivalAt, false);
871
+ }
872
+
873
+ if (outputBits > 0) {
874
+ const end = Math.max(...trackState.map(state => state.tailEndSeconds));
875
+ const completedAt = link.finish(outputBits, Math.max(transferFinish, latestTrackFinish));
876
+ const ahead = Math.max(0, end - position);
877
+ constrain(completedAt - Math.max(0, ahead - reserve), "delivery", 0,
878
+ trackState[0]?.segments.at(-1)?.index ?? 0, completedAt, ahead, false, sourceArrivalAt, false);
879
+ }
880
+ return null;
881
+ }
882
+
684
883
  /**
685
884
  * Seconds from `from` to `to`, or zero when `to` is not later.
686
885
  *
@@ -986,9 +1185,9 @@ function numericCoverage(ranges) {
986
1185
  /**
987
1186
  * Estimate download work without claiming an address or held byte. The whole
988
1187
  * file's measured density, reduced by the held fraction, prices unresolved
989
- * intervals in the near window.
1188
+ * intervals and the aggregate suffix.
990
1189
  */
991
- function estimateUnmappedSourceBytes(source, mediaSeconds, durationSeconds, coverage = null) {
1190
+ function estimateUnmappedSourceBytes(source, mediaSeconds, durationSeconds, coverage = null, from = 0) {
992
1191
  const fileLength = Number(source.fileLength);
993
1192
  const density = Number(source.bytesPerMediaSecond) > 0 ? Number(source.bytesPerMediaSecond)
994
1193
  : fileLength > 0 && durationSeconds > 0 ? fileLength / durationSeconds : 0;
@@ -999,7 +1198,15 @@ function estimateUnmappedSourceBytes(source, mediaSeconds, durationSeconds, cove
999
1198
  range?.location !== "missing" && Number.isFinite(range?.start) && Number.isFinite(range?.end) &&
1000
1199
  range.end > range.start));
1001
1200
  const heldBytes = held.reduce((sum, range) => sum + range.end - range.start, 0);
1002
- return density * mediaSeconds * Math.max(0, Math.min(1, (fileLength - heldBytes) / fileLength));
1201
+ const span = Math.max(0, fileLength - Math.max(0, from));
1202
+ return span > 0 ? density * mediaSeconds * Math.max(0, Math.min(1, (span - heldBytes) / span)) : 0;
1203
+ }
1204
+
1205
+ /** The held ranges past `from`, cut at it. */
1206
+ function heldBeyond(ranges, from) {
1207
+ return (Array.isArray(ranges) ? ranges : [])
1208
+ .filter(range => range?.location !== "missing" && Number.isFinite(range?.start) && range.end > from)
1209
+ .map(range => ({ start: Math.max(from, range.start), end: range.end }));
1003
1210
  }
1004
1211
 
1005
1212
  /** Already held bytes have no arrival delay. Index future arrivals once. */