@torrent-tv/proxy 2.107.37 → 2.107.39

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.37",
3
+ "version": "2.107.39",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "repository": {
@@ -229,7 +229,18 @@ export class ViewerRequests {
229
229
  sourceObserved: true, waitElapsedMs });
230
230
  const featuresChanged = this.#recordEarlyWaitSnapshot(context, context.features);
231
231
  const elapsedMs = waitElapsedSince(context, waitElapsedMs);
232
- const measuredWorkSeconds = processing.firstRequiredSegmentsSeconds;
232
+ // The wait ends when the start forecast lets the picture start, and the
233
+ // first required pieces being there is only part of that: a source
234
+ // supplied slower than the film plays is held until the cushion carries
235
+ // it to the end. Counted to the first pieces alone, the estimate reached
236
+ // zero with seven minutes still to wait, and the page had no time to show
237
+ // (Home Assistant 2026-10-26, Despicable Me 3 BDRemux at 0.53x,
238
+ // torrent-tv/meta#239).
239
+ const firstRequiredSeconds = processing.firstRequiredSegmentsSeconds;
240
+ const startGateSeconds = processing.startGateSeconds;
241
+ const measuredWorkSeconds = Number.isFinite(startGateSeconds) && startGateSeconds >= 0
242
+ ? Math.max(startGateSeconds, Number.isFinite(firstRequiredSeconds) ? firstRequiredSeconds : 0)
243
+ : firstRequiredSeconds;
233
244
  const hasActiveEstimate = Number.isFinite(measuredWorkSeconds) && measuredWorkSeconds >= 0;
234
245
  const stagesChanged = Array.isArray(processing.firstRequiredStages) &&
235
246
  JSON.stringify(processing.firstRequiredStages) !== JSON.stringify(context.activeFirstRequiredStages);
@@ -258,7 +269,8 @@ export class ViewerRequests {
258
269
  const seconds = earlyWaitRemainingSeconds(totalSeconds, elapsedMs);
259
270
  if (!Number.isFinite(seconds) || seconds < 0) return null;
260
271
  this.#logEarlyForecast(context, { elapsedMs, seconds, totalSeconds, historical,
261
- firstRequiredSegmentsSeconds: hasActiveEstimate ? measuredWorkSeconds : context.activeFirstRequiredSegmentsSeconds,
272
+ firstRequiredSegmentsSeconds: hasActiveEstimate ? firstRequiredSeconds ?? null : context.activeFirstRequiredSegmentsSeconds,
273
+ startGateSeconds: hasActiveEstimate && Number.isFinite(startGateSeconds) ? startGateSeconds : null,
262
274
  firstRequiredStages: context.activeFirstRequiredStages });
263
275
  return { version: 1, waitId, revision: context.revision, remainingSeconds: seconds, ageMs: 0 };
264
276
  }
@@ -269,11 +281,14 @@ export class ViewerRequests {
269
281
  }
270
282
 
271
283
  #logEarlyForecast(context, { elapsedMs, seconds, totalSeconds, historical, firstRequiredSegmentsSeconds,
272
- firstRequiredStages }) {
284
+ startGateSeconds = null, firstRequiredStages }) {
273
285
  if (context.loggedForecastRevision === context.revision) return;
274
- const basis = Number.isFinite(firstRequiredSegmentsSeconds) ? "first-required" : "historical-stages";
275
- const estimate = basis === "first-required"
276
- ? { firstRequiredSegmentsSeconds, ...(Array.isArray(firstRequiredStages) ? { firstRequiredStages } : {}) }
286
+ const gated = Number.isFinite(startGateSeconds) &&
287
+ !(Number.isFinite(firstRequiredSegmentsSeconds) && firstRequiredSegmentsSeconds >= startGateSeconds);
288
+ const basis = gated ? "start-gate" : Number.isFinite(firstRequiredSegmentsSeconds) ? "first-required" : "historical-stages";
289
+ const estimate = basis !== "historical-stages"
290
+ ? { firstRequiredSegmentsSeconds, ...(Number.isFinite(startGateSeconds) ? { startGateSeconds } : {}),
291
+ ...(Array.isArray(firstRequiredStages) ? { firstRequiredStages } : {}) }
277
292
  : { stagesMs: historical?.stagesMs ?? null, observations: historical?.observations ?? null };
278
293
  logger.info(`early wait forecast ${JSON.stringify({ waitId: context.waitId, consumer: context.consumerId,
279
294
  generation: context.generation, features: context.features, elapsedMs, revision: context.revision,
@@ -990,6 +1005,7 @@ export class ViewerRequests {
990
1005
  ? stats.fileLength / session.file.durationSeconds : null,
991
1006
  pieceLength: stats?.pieceLength,
992
1007
  downloadRateReadings: this.#sourceRateReadings(measurement, output.file.sourceKey, stats, now),
1008
+ tailRateReadings: this.#sourceTailRateReadings(measurement, output.file.sourceKey, now),
993
1009
  downloadRateSummaries: this.#sourceRateSummaries(measurement, output.file.sourceKey),
994
1010
  downloadForecast,
995
1011
  });
@@ -1066,6 +1082,9 @@ export class ViewerRequests {
1066
1082
  bufferLimitSeconds: viewerReading?.bufferLimitSeconds,
1067
1083
  reserveSeconds,
1068
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,
1069
1088
  requiredAudio: requiresSeparateAudio,
1070
1089
  sources: [...sourceMeasurements.values()],
1071
1090
  tracks,
@@ -1121,6 +1140,10 @@ export class ViewerRequests {
1121
1140
  videoSpeedX: videoOperation?.speed,
1122
1141
  audioSpeedX: audioOperation?.speed,
1123
1142
  firstRequiredSegmentsSeconds: forecast.firstRequiredSegmentsSeconds,
1143
+ // When the start forecast lets the picture start: its delay while
1144
+ // it holds the start, and nothing to add once it does not.
1145
+ ...(forecast.ready !== true && Number.isFinite(forecast.delaySeconds)
1146
+ ? { startGateSeconds: forecast.delaySeconds } : {}),
1124
1147
  firstRequiredStages: forecast.firstRequiredStages
1125
1148
  } });
1126
1149
  if (waitEstimate) forecast.waitEstimate = waitEstimate;
@@ -1361,16 +1384,21 @@ export class ViewerRequests {
1361
1384
  #sourceRateReadings(state, id, stats, now) {
1362
1385
  let source = state.sources.get(id);
1363
1386
  if (!source) {
1364
- const { RateTrend } = this.#host.playbackReadiness;
1365
- 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 };
1366
1391
  state.sources.set(id, source);
1367
1392
  }
1368
1393
  if (Number.isFinite(stats?.downloadSpeed) && stats.downloadSpeed >= 0) {
1369
1394
  source.delivered.add(now, stats.downloadSpeed);
1395
+ source.deliveredAll.add(now, stats.downloadSpeed);
1370
1396
  }
1371
1397
  const limit = stats?.selectionLimit;
1372
1398
  if (limit && source.limit && limit.freeMs > source.limit.freeMs) {
1373
- 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);
1374
1402
  }
1375
1403
  // Totals that went back belong to a torrent added again: read on from them.
1376
1404
  if (limit) source.limit = limit;
@@ -1378,6 +1406,14 @@ export class ViewerRequests {
1378
1406
  (summary) => this.#host.playbackReadiness.forecastRate(summary, now, 0, "time"));
1379
1407
  }
1380
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
+
1381
1417
  /** Both summaries a source's service was chosen from, for the forecast's log line. */
1382
1418
  #sourceRateSummaries(state, id) {
1383
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,22 @@ 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
+ const stop = scheduleTail({ trackState, sourceState, sourceByteCoverage, runStarts, trackFinish,
691
+ link, position, buffered, reserve, transferFinish, constrain, duration, stock: tailStock });
692
+ if (stop) {
693
+ return { ...result(false, null, buffered, reserve, null, stop.reason, preparedSegments),
694
+ ...(stop.unavailableSource ? { unavailableSource: stop.unavailableSource } : {}) };
695
+ }
696
+ }
620
697
  const firstRequiredStages = trackState.slice(0, requiredTracks).map((state, trackIndex) => {
621
698
  const first = state.segments.find(segment => segment.endSeconds > position);
622
699
  if (!first) return { track: state.track.id ?? trackIndex, segment: null, alreadyBuffered: true,
@@ -646,6 +723,23 @@ export function predictPlaybackReadiness(input = {}) {
646
723
  const firstRequiredSegmentsSeconds = firstRequiredTrackSeconds.every(Number.isFinite)
647
724
  ? Math.max(0, ...firstRequiredTrackSeconds) : null;
648
725
  const delay = Math.max(0, ...constraints);
726
+ // THE STOCK THE START ASKS FOR HAS TO FIT. Starting without a stop while the
727
+ // source comes slower than the film plays means holding, at the start, what
728
+ // is already here and everything that arrives before it. Where that is more
729
+ // than this proxy may keep of downloaded pieces, the oldest of it would be
730
+ // given up before the viewer reached it, and no wait makes the start safe:
731
+ // Despicable Me 3 BDRemux at 1.4 MB/s asked for about 36 GB against a 29.5 GB
732
+ // share (Home Assistant 2026-10-26, torrent-tv/meta#239). Said as such, and
733
+ // the viewer keeps waiting rather than starting into stops.
734
+ const storageBytes = Number(input.sourceStorageBytes);
735
+ if (Number.isFinite(delay) && Number.isFinite(storageBytes) && storageBytes >= 0 && tailStock.length > 0) {
736
+ const cushionBytes = tailStock.reduce((sum, part) => sum + Math.max(0, part.totalBytes - part.missingBytes) +
737
+ Math.max(0, part.missingBytes - part.bytesPerSecond * Math.max(0, part.finish - delay)), 0);
738
+ if (cushionBytes > storageBytes) {
739
+ return { ...result(false, null, buffered, reserve, null, "cushion-exceeds-storage", preparedSegments),
740
+ cushionBytes, storageBytes };
741
+ }
742
+ }
649
743
  const originReady = trackState.every(({ clientRanges }) => compare(contiguousEnd(clientRanges, at), at) > 0);
650
744
  const ready = delay === 0 && originReady;
651
745
  const forecast = result(ready, Number.isFinite(delay) ? delay : null, buffered, reserve,
@@ -681,6 +775,102 @@ function isPiece(segment) {
681
775
  Number.isFinite(segment?.endSeconds) && segment.endSeconds > segment.startSeconds;
682
776
  }
683
777
 
778
+ /**
779
+ * A duration-level estimate of the suffix after the exact near window. Source
780
+ * work uses each file's aggregate byte density and measured download service;
781
+ * processing uses each track's measured rate; delivery uses output bitrates and
782
+ * measured link service. Held source bytes and made segments reduce the
783
+ * estimate. This conditional forecast does not assert exact source ranges or
784
+ * individual frame timing. Its work is bounded by sources and tracks, not by
785
+ * the film's segment count.
786
+ *
787
+ * @returns {{ reason: string, unavailableSource?: object } | null} Why there is no answer, or null.
788
+ */
789
+ function scheduleTail({ trackState, sourceState, sourceByteCoverage, runStarts, trackFinish, link,
790
+ position, buffered, reserve, transferFinish, constrain, duration, stock = null }) {
791
+ const tailStart = Math.max(position, ...trackState.map(state => state.tailStartSeconds));
792
+ if (!Number.isFinite(tailStart)) return null;
793
+ const sourceDurations = new Map();
794
+ for (const state of trackState) {
795
+ const seconds = Math.max(0, state.tailEndSeconds - tailStart) * state.tailWorkFraction;
796
+ if (!(seconds > 0)) continue;
797
+ for (const sourceId of new Set(state.track.sourceIds ?? [])) {
798
+ sourceDurations.set(sourceId, Math.max(sourceDurations.get(sourceId) ?? 0, seconds));
799
+ }
800
+ }
801
+
802
+ let sourceArrivalAt = 0;
803
+ for (const [sourceId, seconds] of sourceDurations) {
804
+ const source = sourceState.get(sourceId);
805
+ if (!source) return { reason: "source-measurement-unavailable" };
806
+ if (source.source.complete === true) continue;
807
+ // Held means on this proxy now, past the bytes the near window already
808
+ // scheduled: those are counted above, and the window's planned arrivals
809
+ // counted again as held took 117 s of film off a 90-minute tail.
810
+ const windowEnd = Math.max(0, ...(sourceByteCoverage.get(sourceId) ?? []).map(range => range.end));
811
+ const bytes = estimateUnmappedSourceBytes(source.source, seconds, duration,
812
+ heldBeyond(source.source.residence, windowEnd), windowEnd);
813
+ if (bytes === null) return { reason: "source-measurement-unavailable",
814
+ unavailableSource: { sourceId, phase: "tail" } };
815
+ if (!(bytes > 0)) continue;
816
+ const { service } = source;
817
+ const rate = service.tailRate ?? service.rate;
818
+ if (!rate || !(rate.rateAt(0) > 0)) {
819
+ return { reason: rate ? "service-not-advancing" : "download-rate-unavailable",
820
+ unavailableSource: { sourceId, estimatedBytes: bytes, phase: "tail" } };
821
+ }
822
+ service.finish = rate.finish(bytes, service.finish);
823
+ sourceArrivalAt = Math.max(sourceArrivalAt, service.finish);
824
+ // What this stretch weighs and what it still lacks: the stock the start
825
+ // asks to hold is read from these once the start's delay is known.
826
+ stock?.push({ totalBytes: estimateUnmappedSourceBytes(source.source, seconds, duration, []) ?? bytes,
827
+ missingBytes: bytes, bytesPerSecond: rate.rateAt(0), finish: service.finish });
828
+ const state = trackState.find(candidate => candidate.track.sourceIds?.includes(sourceId));
829
+ const index = state?.segments.at(-1)?.index ?? 0;
830
+ const ahead = Math.max(0, (state?.tailEndSeconds ?? tailStart) - position);
831
+ constrain(service.finish - Math.max(0, ahead - reserve), "source", trackState.indexOf(state), index,
832
+ service.finish, ahead, true, service.finish, false);
833
+ }
834
+
835
+ let outputBits = 0;
836
+ let latestTrackFinish = 0;
837
+ for (let trackIndex = 0; trackIndex < trackState.length; trackIndex += 1) {
838
+ const state = trackState[trackIndex];
839
+ const end = state.tailEndSeconds;
840
+ const seconds = Math.max(0, end - tailStart);
841
+ if (!(seconds > 0)) continue;
842
+ const track = state.track;
843
+ const segment = state.segments.at(-1) ?? { index: 0 };
844
+ const remaining = seconds * state.tailWorkFraction;
845
+ const work = Math.max(0, remaining - numericCoverage((track.processingRanges ?? [])
846
+ .filter(range => Number.isFinite(range?.start) && Number.isFinite(range?.end))
847
+ .map(range => ({ start: Math.max(tailStart, range.start), end: Math.min(end, range.end) }))
848
+ .filter(range => range.end > range.start)).reduce((sum, range) => sum + range.end - range.start, 0));
849
+ const processing = state.curve;
850
+ if (work > 0 && !processing) return { reason: "encode-rate-unavailable" };
851
+ const run = runStarts[trackIndex];
852
+ const localStart = tailStart - state.clockSeconds;
853
+ const inRun = run.spans.some(span => localStart >= span.start && localStart < span.end);
854
+ const open = work > 0 && !inRun ? run.openSeconds : 0;
855
+ const finishedAt = work > 0 ? processing.finish(work, trackFinish[trackIndex] + open) : trackFinish[trackIndex];
856
+ latestTrackFinish = Math.max(latestTrackFinish, finishedAt);
857
+ const deliveryStart = Math.max(tailStart, position + buffered);
858
+ outputBits += Math.max(0, state.bitsPerMediaSecond) * Math.max(0, end - deliveryStart);
859
+ const ahead = Math.max(0, end - position);
860
+ constrain(finishedAt - Math.max(0, ahead - reserve), "production", trackIndex, segment.index,
861
+ finishedAt, ahead, sourceArrivalAt > 0, sourceArrivalAt, false);
862
+ }
863
+
864
+ if (outputBits > 0) {
865
+ const end = Math.max(...trackState.map(state => state.tailEndSeconds));
866
+ const completedAt = link.finish(outputBits, Math.max(transferFinish, latestTrackFinish));
867
+ const ahead = Math.max(0, end - position);
868
+ constrain(completedAt - Math.max(0, ahead - reserve), "delivery", 0,
869
+ trackState[0]?.segments.at(-1)?.index ?? 0, completedAt, ahead, false, sourceArrivalAt, false);
870
+ }
871
+ return null;
872
+ }
873
+
684
874
  /**
685
875
  * Seconds from `from` to `to`, or zero when `to` is not later.
686
876
  *
@@ -986,9 +1176,9 @@ function numericCoverage(ranges) {
986
1176
  /**
987
1177
  * Estimate download work without claiming an address or held byte. The whole
988
1178
  * file's measured density, reduced by the held fraction, prices unresolved
989
- * intervals in the near window.
1179
+ * intervals and the aggregate suffix.
990
1180
  */
991
- function estimateUnmappedSourceBytes(source, mediaSeconds, durationSeconds, coverage = null) {
1181
+ function estimateUnmappedSourceBytes(source, mediaSeconds, durationSeconds, coverage = null, from = 0) {
992
1182
  const fileLength = Number(source.fileLength);
993
1183
  const density = Number(source.bytesPerMediaSecond) > 0 ? Number(source.bytesPerMediaSecond)
994
1184
  : fileLength > 0 && durationSeconds > 0 ? fileLength / durationSeconds : 0;
@@ -999,7 +1189,15 @@ function estimateUnmappedSourceBytes(source, mediaSeconds, durationSeconds, cove
999
1189
  range?.location !== "missing" && Number.isFinite(range?.start) && Number.isFinite(range?.end) &&
1000
1190
  range.end > range.start));
1001
1191
  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));
1192
+ const span = Math.max(0, fileLength - Math.max(0, from));
1193
+ return span > 0 ? density * mediaSeconds * Math.max(0, Math.min(1, (span - heldBytes) / span)) : 0;
1194
+ }
1195
+
1196
+ /** The held ranges past `from`, cut at it. */
1197
+ function heldBeyond(ranges, from) {
1198
+ return (Array.isArray(ranges) ? ranges : [])
1199
+ .filter(range => range?.location !== "missing" && Number.isFinite(range?.start) && range.end > from)
1200
+ .map(range => ({ start: Math.max(from, range.start), end: range.end }));
1003
1201
  }
1004
1202
 
1005
1203
  /** Already held bytes have no arrival delay. Index future arrivals once. */