@torrent-tv/proxy 2.107.19 → 2.107.21

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.19",
3
+ "version": "2.107.21",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "repository": {
@@ -589,6 +589,12 @@ export class EncodeRun {
589
589
  producedSeconds: Math.max(0, this.progress.processedSeconds - this.progress.startPositionSeconds),
590
590
  workingMs: this.clock.workingMs(at)
591
591
  };
592
+ // A run can be admitted for exactly one segment. Establish its baseline
593
+ // on the first media progress report so closing that segment yields a
594
+ // processing rate without counting the preceding input wait.
595
+ if (this.#pieceSample === null && this.#workSample.producedSeconds > 0) {
596
+ this.#pieceSample = this.#workSample;
597
+ }
592
598
  }
593
599
  this.onProgress(this.progress.snapshot());
594
600
  }
@@ -739,11 +745,10 @@ export class EncodeRun {
739
745
 
740
746
  /**
741
747
  * A closed piece is the moment this run's speed is read: what it made since
742
- * the last reading, over its own working time. Measured from the first
743
- * closed piece rather than from the spawn, so the process start and the wait
744
- * for its first input bytes, which are charged once as startup, are not
745
- * counted as processing. A stretch with no progress keeps the earlier sample,
746
- * and the next reading spans both.
748
+ * the last reading, over its own working time. The first baseline is the
749
+ * first positive progress report, so a one-piece run can be measured without
750
+ * charging the wait for its first input bytes as processing. A stretch with
751
+ * no progress keeps the earlier sample, and the next reading spans both.
747
752
  */
748
753
  #measureSpeed() {
749
754
  const sample = this.#workSample;
@@ -751,7 +756,7 @@ export class EncodeRun {
751
756
  return;
752
757
  }
753
758
  if (this.#pieceSample === null) {
754
- this.#pieceSample = sample;
759
+ if (sample.producedSeconds > 0) this.#pieceSample = sample;
755
760
  return;
756
761
  }
757
762
  const speed = speedFromWork(this.#pieceSample, sample);
@@ -936,7 +936,7 @@ export class ViewerRequests {
936
936
  const tracks = [];
937
937
  for (const output of outputs) {
938
938
  const trackProgress = output === session ? progress : this.#host.encodeRuns.progressOf(output);
939
- const trackRates = this.#trackRateReadings(output, now, trackProgress, measurement);
939
+ const trackRates = this.#trackRateReadings(output, now);
940
940
  const trackName = output === session ? "video" : "audio";
941
941
  // The page states the offset its player applied to this track, and the
942
942
  // init it applied it to is the session's. Until both are known, the
@@ -985,6 +985,9 @@ export class ViewerRequests {
985
985
  residence: stats?.residence,
986
986
  fileOffset: stats?.fileOffset,
987
987
  fileLength: stats?.fileLength,
988
+ bytesPerMediaSecond: Number.isFinite(stats?.fileLength) && stats.fileLength > 0 &&
989
+ Number.isFinite(session.file.durationSeconds) && session.file.durationSeconds > 0
990
+ ? stats.fileLength / session.file.durationSeconds : null,
988
991
  pieceLength: stats?.pieceLength,
989
992
  downloadRateReadings: this.#sourceRateReadings(measurement, output.file.sourceKey, stats, now),
990
993
  downloadRateSummaries: this.#sourceRateSummaries(measurement, output.file.sourceKey),
@@ -1022,6 +1025,8 @@ export class ViewerRequests {
1022
1025
  })),
1023
1026
  bitsPerMediaSecond: Number.isFinite(observedMbps) && observedMbps > 0 ? observedMbps * 1_000_000 :
1024
1027
  this.#host.initialOutputBitsPerMediaSecondOf?.(output) ?? 0,
1028
+ durationSeconds: output.timeline?.publishedStartOf(output.timeline.segmentCount) ?? session.file.durationSeconds,
1029
+ segmentCount: output.timeline?.segmentCount ?? segments.length,
1025
1030
  readings: trackRates,
1026
1031
  segments,
1027
1032
  geometry: this.#forecastPieces.get(output)?.geometry,
@@ -1247,7 +1252,27 @@ export class ViewerRequests {
1247
1252
  }
1248
1253
  const kept = forecast.pieces;
1249
1254
  const segments = [];
1250
- for (let index = 0; index < count; index++) {
1255
+ const lookahead = Number(this.#host.lookaheadSeconds);
1256
+ const start = Number.isFinite(positionSeconds) ? Math.max(0, positionSeconds) : 0;
1257
+ const end = Number.isFinite(lookahead) && lookahead > 0 ? start + lookahead : Number.POSITIVE_INFINITY;
1258
+ let low = 0, high = count;
1259
+ while (low < high) {
1260
+ const middle = (low + high) >>> 1;
1261
+ if (timeline.publishedStartOf(middle + 1) <= start) low = middle + 1;
1262
+ else high = middle;
1263
+ }
1264
+ const first = Math.max(0, low - 1);
1265
+ low = first;
1266
+ high = count;
1267
+ while (low < high) {
1268
+ const middle = (low + high) >>> 1;
1269
+ if (timeline.publishedStartOf(middle) < end) low = middle + 1;
1270
+ else high = middle;
1271
+ }
1272
+ const last = low;
1273
+ const indexes = new Set([0]);
1274
+ for (let index = first; index < last; index++) indexes.add(index);
1275
+ for (const index of indexes) {
1251
1276
  const endSeconds = timeline.publishedStartOf(index + 1);
1252
1277
  // Only a made piece has media intervals to read. Asked of every piece,
1253
1278
  // each one not made cost a `stat` of its file in the store's safety net
@@ -1286,8 +1311,7 @@ export class ViewerRequests {
1286
1311
  const id = typeof consumerId === "string" ? consumerId : "";
1287
1312
  let state = byViewer.get(id);
1288
1313
  if (!state) {
1289
- state = { link: new this.#host.playbackReadiness.RateTrend(this.#host.lookaheadSeconds), sources: new Map(),
1290
- trackProgress: new WeakMap() };
1314
+ state = { link: new this.#host.playbackReadiness.RateTrend(this.#host.lookaheadSeconds), sources: new Map() };
1291
1315
  byViewer.set(id, state);
1292
1316
  }
1293
1317
  return state;
@@ -1300,7 +1324,7 @@ export class ViewerRequests {
1300
1324
  * runs of one output can report out of order, so a summary kept and added to
1301
1325
  * would drop the later-told, earlier-taken one (torrent-tv/meta#178).
1302
1326
  */
1303
- #trackRateReadings(output, now, progress, measurement) {
1327
+ #trackRateReadings(output, now) {
1304
1328
  const readings = (this.#host.encodeSpeedReadingsOf?.(output) ?? []).filter(reading => reading.at <= now);
1305
1329
  const summary = readings.length > 0 ? this.#host.playbackReadiness.rateSummaryOf(readings) : null;
1306
1330
  if (summary) return summary;
@@ -1315,21 +1339,9 @@ export class ViewerRequests {
1315
1339
  seed.add(now, projected);
1316
1340
  return seed.snapshot();
1317
1341
  }
1318
- // A separate soundtrack may have no calibrated codec reading and no
1319
- // completed segment yet. Its progress is still a measured rate: retain the
1320
- // time since the previous advance so source waits reduce, rather than
1321
- // inflate, the speed used by the forecast.
1322
- if (output?.spec?.carries === "audio-only") {
1323
- let trend = measurement.trackProgress.get(output);
1324
- if (!trend) {
1325
- trend = new this.#host.playbackReadiness.ProgressRate(
1326
- this.#host.lookaheadSeconds,
1327
- this.#host.segmentDurationSec
1328
- );
1329
- measurement.trackProgress.set(output, trend);
1330
- }
1331
- return trend.note(now, progress?.processedSeconds, progress?.startPositionSeconds);
1332
- }
1342
+ // Progress over wall time includes source waits, which the forecast prices
1343
+ // separately. Keep the rate unknown until a measured or calibrated
1344
+ // processing rate exists; single-piece runs now publish their own rate.
1333
1345
  return null;
1334
1346
  }
1335
1347
 
@@ -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 { forecastRate, rateSummaryOf, predictPlaybackReadiness, ProgressRate, RateTrend } from "../viewer/playback-readiness.js";
75
+ import { forecastRate, rateSummaryOf, predictPlaybackReadiness, RateTrend } 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";
@@ -640,7 +640,7 @@ export function wireOutputs({
640
640
  get encodeRuns() { return parts.encodeRuns; },
641
641
  // When this viewer's playback can start and run to the end: the viewer
642
642
  // component's forecast, which models the viewer's browser.
643
- playbackReadiness: { predict: predictPlaybackReadiness, forecastRate, rateSummaryOf, RateTrend, ProgressRate },
643
+ playbackReadiness: { predict: predictPlaybackReadiness, forecastRate, rateSummaryOf, RateTrend },
644
644
  // Whether the subtitle a viewer chose has been read where they stand — the
645
645
  // source preparation's answer, set on these parts by the assembly.
646
646
  subtitleReadyFor: (viewer) => parts.subtitleReadyFor?.(viewer) ?? true,
@@ -1,13 +1,15 @@
1
1
  /**
2
- * Predict the minimum delay before playback can run to the end without
3
- * exhausting the continuous media available for the required tracks.
2
+ * Predict whether the viewer can start from the current position without
3
+ * exhausting the continuous media already available for the required tracks.
4
4
  *
5
5
  * This module contains the playback decision's only model. Its inputs are
6
6
  * measurements and physical limits: mapped whole-piece arrivals, encoded
7
7
  * media and encoder rate, segment sizes and client-link rate, existing
8
- * continuous media, and measured supply interruptions. Each service integral
9
- * extends its latest measured rate until the next observation. The forecast
10
- * is conditional on these measured services continuing. Browser storage
8
+ * continuous media, and measured supply interruptions. Exact scheduling is
9
+ * limited to the configured near window; the remaining duration is priced by
10
+ * aggregate work per source and track. Each service integral extends its latest
11
+ * measured rate until the next observation. The forecast is conditional on
12
+ * these measured services continuing. Browser storage
11
13
  * capacity is not an admission condition. There are no fitted weights or
12
14
  * rate multipliers, and no search over candidate start times.
13
15
  *
@@ -108,44 +110,6 @@ export class RateTrend {
108
110
  }
109
111
  }
110
112
 
111
- /**
112
- * Rates the media an output actually produced between progress reports.
113
- * Unchanged progress keeps its original time, so a source wait is included
114
- * instead of being mistaken for encoder throughput.
115
- */
116
- export class ProgressRate {
117
- #trend;
118
- #horizonSeconds;
119
- #adjacentRunSeconds;
120
- #previous = null;
121
-
122
- constructor(horizonSeconds, adjacentRunSeconds = 4) {
123
- this.#horizonSeconds = horizonSeconds;
124
- this.#adjacentRunSeconds = Number.isFinite(adjacentRunSeconds) && adjacentRunSeconds > 0
125
- ? adjacentRunSeconds
126
- : 4;
127
- this.#trend = new RateTrend(horizonSeconds);
128
- }
129
-
130
- note(at, processedSeconds, startPositionSeconds) {
131
- if (!Number.isFinite(at) || !Number.isFinite(processedSeconds)) return this.#trend.snapshot();
132
- const start = Number.isFinite(startPositionSeconds) ? startPositionSeconds : 0;
133
- const discontinuousStart = this.#previous && start !== this.#previous.start &&
134
- (start < this.#previous.start || start > this.#previous.processedSeconds + this.#adjacentRunSeconds);
135
- if (!this.#previous || discontinuousStart || processedSeconds < this.#previous.processedSeconds) {
136
- this.#previous = { at, processedSeconds, start };
137
- this.#trend = new RateTrend(this.#horizonSeconds);
138
- return this.#trend.snapshot();
139
- }
140
- const elapsedMs = at - this.#previous.at;
141
- if (processedSeconds > this.#previous.processedSeconds && elapsedMs > 0) {
142
- this.#trend.add(at, (processedSeconds - this.#previous.processedSeconds) * 1000 / elapsedMs);
143
- this.#previous = { at, processedSeconds, start };
144
- }
145
- return this.#trend.snapshot();
146
- }
147
- }
148
-
149
113
  /**
150
114
  * @param {object} input
151
115
  * @param {number} input.positionSeconds
@@ -157,7 +121,7 @@ export class ProgressRate {
157
121
  * @param {number} input.now
158
122
  * @param {boolean} [input.requiredAudio]
159
123
  * @param {Array<{ id: string, serviceKey?: string, complete: boolean, fileOffset?: number,
160
- * fileLength?: number, pieceLength?: number, downloadRateReadings?: object,
124
+ * fileLength?: number, bytesPerMediaSecond?: number, pieceLength?: number, downloadRateReadings?: object,
161
125
  * downloadForecast?: { ranges: Array<{ start: number, end: number, availableAt: number | null }> } }>} input.sources
162
126
  * @param {Array<{ id: string, sourceIds: string[], processedSeconds: number,
163
127
  * bitsPerMediaSecond: number, readings: Array<{ at: number, value: number }>,
@@ -182,18 +146,15 @@ export function predictPlaybackReadiness(input = {}) {
182
146
  // starts), the read piece nearest before them (what an unread piece is placed
183
147
  // by) and read pieces inside what the page reports holding (their frames).
184
148
  //
185
- // THE NEAR WINDOW AND THE TAIL (torrent-tv/meta#185). Pieces starting within
186
- // the proxy's look-ahead of the viewer are placed in exact time, where whether
187
- // the media is continuous is decided, and their bytes are those the torrent
188
- // thread plans arrivals for. The pieces after it are scheduled by the same
189
- // rules in seconds, by {@link scheduleTail}: no exact time and no planned
190
- // arrival exists for them. Placing every remaining piece of a two-hour film
191
- // in exact time on every poll cost 70-1128 ms a call on Home Assistant (r73,
192
- // r74).
149
+ // THE NEAR WINDOW AND THE SUFFIX. The look-ahead is scheduled with exact
150
+ // segment times and source ranges when known. Missing range metadata is priced
151
+ // from aggregate file density and measured download service without claiming
152
+ // an address or byte. Beyond the look-ahead, {@link scheduleTail} estimates
153
+ // remaining source, encoder and delivery work without visiting every segment.
193
154
  const lookahead = finiteNonNegative(input.lookaheadSeconds);
194
155
  const horizonEnd = lookahead > 0 ? finiteNonNegative(input.positionSeconds) + lookahead : Number.POSITIVE_INFINITY;
195
156
  const tracks = (Array.isArray(input.tracks) ? input.tracks : []).map((track) =>
196
- Array.isArray(track.segments) ? { ...track,
157
+ Array.isArray(track.segments) ? { ...track, segmentCount: track.segmentCount ?? track.segments.length,
197
158
  segments: piecesThatMatter(track, finiteNonNegative(input.positionSeconds), horizonEnd) } : track);
198
159
  const duration = finiteNonNegative(input.durationSeconds);
199
160
  // Each track's served ticks are placed on the clock the page reports by the
@@ -266,15 +227,23 @@ export function predictPlaybackReadiness(input = {}) {
266
227
  .sort((left, right) => left.startSeconds - right.startSeconds || left.index - right.index),
267
228
  // The pieces after the near window, as the playlist cuts them, and the
268
229
  // offset the page's player placed them by.
269
- tail: horizonEnd === Number.POSITIVE_INFINITY ? [] :
270
- (Array.isArray(input.tracks[trackIndex].segments) ? input.tracks[trackIndex].segments : [])
271
- .filter((segment) => isPiece(segment) && segment.startSeconds >= horizonEnd && segment.endSeconds > position)
272
- .sort((left, right) => left.startSeconds - right.startSeconds || left.index - right.index),
230
+ tail: [],
231
+ tailStartSeconds: horizonEnd,
232
+ durationSeconds: Number.isFinite(track.durationSeconds) ? track.durationSeconds : duration,
233
+ segmentCount: Number.isSafeInteger(track.segmentCount) ? track.segmentCount
234
+ : Math.max(0, ...track.segments.map(segment => segment.index + 1)),
273
235
  clockSeconds: Number.isFinite(track.clockOffsetSeconds) ? track.clockOffsetSeconds : 0
274
236
  }));
275
237
  for (const state of trackState) {
276
- state.tailEndSeconds = state.tail.length > 0
277
- ? Math.min(duration, state.tail.at(-1).endSeconds + state.clockSeconds) : null;
238
+ state.tailEndSeconds = Number.isFinite(horizonEnd) && horizonEnd < duration
239
+ ? Math.min(duration, state.durationSeconds + state.clockSeconds) : null;
240
+ const endIndex = state.segments.reduce((last, segment) => segment.startSeconds < horizonEnd
241
+ ? Math.max(last, segment.index) : last, -1);
242
+ state.tailSegmentCount = Math.max(0, state.segmentCount - endIndex - 1);
243
+ const readyTail = [...state.ready].reduce((count, index) => count + Number(index > endIndex), 0);
244
+ state.tailWorkFraction = state.tailSegmentCount > 0
245
+ ? Math.max(0, 1 - Math.min(state.tailSegmentCount, readyTail) / state.tailSegmentCount)
246
+ : 0;
278
247
  }
279
248
  if (trackState.some(({ segments }) => segments.length === 0)) {
280
249
  return result(false, null, buffered, reserve, null, "timeline-unavailable", 0);
@@ -294,6 +263,13 @@ export function predictPlaybackReadiness(input = {}) {
294
263
  }
295
264
  sourceState.set(id, { source, service });
296
265
  }
266
+ const sourceUseCounts = new Map();
267
+ for (const { track } of trackState) {
268
+ for (const sourceId of new Set([...(track.sourceIds ?? []),
269
+ ...(track.segments ?? []).flatMap(segment => (segment.sourceInputs ?? []).map(item => item.sourceId))])) {
270
+ sourceUseCounts.set(sourceId, (sourceUseCounts.get(sourceId) ?? 0) + 1);
271
+ }
272
+ }
297
273
  const link = rateCurve(input.linkReadings, now);
298
274
  let unknownReason = null;
299
275
  let unavailableSource = null;
@@ -301,15 +277,12 @@ export function predictPlaybackReadiness(input = {}) {
301
277
  return result(false, null, buffered, reserve, null, "link-rate-unavailable", 0);
302
278
  }
303
279
 
304
- let preparedSegments = trackState.reduce((sum, { ready, segments, tail }) =>
305
- sum + segments.filter((segment) => ready.has(segment.index)).length +
306
- tail.filter((segment) => ready.has(segment.index)).length, 0);
280
+ let preparedSegments = trackState.reduce((sum, { ready }) => sum + ready.size, 0);
307
281
  const jobs = [];
308
282
 
309
283
  for (let trackIndex = 0; trackIndex < trackState.length; trackIndex += 1) {
310
284
  const state = trackState[trackIndex];
311
285
  const { track, segments } = state;
312
- const safeThrough = position + finiteNonNegative(input.bufferedAheadSeconds) + reserve;
313
286
  const processingCoverage = numericCoverage((Array.isArray(track.processingRanges) ? track.processingRanges : [])
314
287
  .filter(range => Number.isFinite(range?.start) && Number.isFinite(range?.end) && range.end > range.start));
315
288
  const measuredBitsPerMediaSecond = state.bitsPerMediaSecond;
@@ -345,7 +318,7 @@ export function predictPlaybackReadiness(input = {}) {
345
318
  const encodeWork = produced ? 0 : remainingProcessing.reduce((sum, part) => sum + part.end - part.start, 0);
346
319
 
347
320
  let sourceByteIntervals = NO_SOURCE_BYTES;
348
- let deferredAtBoundary = false;
321
+ const estimatedSourceBytes = new Map();
349
322
  if (!produced && encodeWork > 0) {
350
323
  // Each source the piece reads, in order and once: the track's own, then
351
324
  // those its inputs name. Gathered with loops and no copies, because
@@ -363,20 +336,18 @@ export function predictPlaybackReadiness(input = {}) {
363
336
  unknownReason ??= "source-measurement-unavailable";
364
337
  } else if (source.source.complete !== true) {
365
338
  const ranges = exactInputs ? inputRangesOf(exactInputs, sourceId) : null;
366
- if (ranges === null) {
367
- if (segment.startSeconds + state.clockSeconds > safeThrough) {
368
- // Its source ranges lie beyond the browser's held buffer and
369
- // reserve. The playback map will request them as the viewer
370
- // advances; continuing to demand them here prevents startup.
371
- state.segments = state.segments.slice(0, state.segments.indexOf(segment));
372
- state.tail = [];
373
- deferredAtBoundary = true;
374
- break;
375
- }
376
- if (unknownReason === null) {
339
+ if (ranges === null && exactInputs === null) {
340
+ const estimate = estimateUnmappedSourceBytes(source.source, segmentSeconds, input.durationSeconds);
341
+ if (estimate !== null) {
342
+ const bytes = estimate / Math.max(1, sourceUseCounts.get(sourceId) ?? 1);
343
+ estimatedSourceBytes.set(sourceId, bytes);
344
+ } else if (unknownReason === null) {
377
345
  unknownReason = "source-input-ranges-unavailable";
378
346
  unavailableSource = { sourceId, trackId: track.id, segmentIndex: segment.index, phase: "near" };
379
347
  }
348
+ } else if (ranges === null) {
349
+ unknownReason ??= "source-input-ranges-unavailable";
350
+ unavailableSource ??= { sourceId, trackId: track.id, segmentIndex: segment.index, phase: "near" };
380
351
  } else {
381
352
  if (sourceByteIntervals === NO_SOURCE_BYTES) sourceByteIntervals = new Map();
382
353
  sourceByteIntervals.set(sourceId, ranges);
@@ -384,7 +355,6 @@ export function predictPlaybackReadiness(input = {}) {
384
355
  }
385
356
  }
386
357
  }
387
- if (deferredAtBoundary) break;
388
358
  if (!produced && encodeWork > 0 && !state.curve) {
389
359
  unknownReason ??= "encode-rate-unavailable";
390
360
  }
@@ -396,14 +366,13 @@ export function predictPlaybackReadiness(input = {}) {
396
366
  sizeBits,
397
367
  encodeWork,
398
368
  sourceByteIntervals,
369
+ estimatedSourceBytes,
399
370
  produced
400
371
  });
401
372
  }
402
373
  }
403
374
 
404
- preparedSegments = trackState.reduce((sum, { ready, segments, tail }) =>
405
- sum + segments.filter((segment) => ready.has(segment.index)).length +
406
- tail.filter((segment) => ready.has(segment.index)).length, 0);
375
+ preparedSegments = trackState.reduce((sum, { ready }) => sum + ready.size, 0);
407
376
 
408
377
  if (unknownReason) {
409
378
  return { ...result(false, null, buffered, reserve, null, unknownReason, preparedSegments),
@@ -466,7 +435,7 @@ export function predictPlaybackReadiness(input = {}) {
466
435
  const completions = [];
467
436
  const productions = [];
468
437
  for (const job of jobs) {
469
- const { track, trackIndex, segment, sizeBits, encodeWork, sourceByteIntervals } = job;
438
+ const { track, trackIndex, segment, sizeBits, encodeWork, sourceByteIntervals, estimatedSourceBytes } = job;
470
439
  let sourceArrivalAt = 0;
471
440
  const plan = { finish: 0 };
472
441
  for (const [sourceId, ranges] of sourceByteIntervals) {
@@ -514,6 +483,21 @@ export function predictPlaybackReadiness(input = {}) {
514
483
  sourceService.finish = finish;
515
484
  }
516
485
  }
486
+ for (const [sourceId, bytes] of estimatedSourceBytes) {
487
+ if (!(bytes > 0)) continue;
488
+ const source = sourceState.get(sourceId);
489
+ if (!source) {
490
+ return result(false, null, buffered, reserve, null, "source-measurement-unavailable", preparedSegments);
491
+ }
492
+ const { service } = source;
493
+ if (!service.rate || !(service.rate.rateAt(0) > 0)) {
494
+ return { ...result(false, null, buffered, reserve, null,
495
+ service.rate ? "service-not-advancing" : "download-rate-unavailable", preparedSegments),
496
+ unavailableSource: { sourceId, segmentIndex: segment.index, estimatedBytes: bytes } };
497
+ }
498
+ service.finish = service.rate.finish(bytes, Math.max(service.finish, sourceArrivalAt));
499
+ sourceArrivalAt = Math.max(sourceArrivalAt, service.finish);
500
+ }
517
501
  // Production accepts a complete held segment input. It cannot overlap its
518
502
  // own source download; reused input retains its original arrival time.
519
503
  const processing = trackState[trackIndex].curve;
@@ -608,9 +592,9 @@ export function predictPlaybackReadiness(input = {}) {
608
592
  production.requiresSource, production.sourceArrivalAt, production.sourcePlanned);
609
593
  }
610
594
  }
611
- if (trackState.some(({ tail }) => tail.length > 0)) {
612
- const stop = scheduleTail({ trackState, sourceState, sourceByteCoverage, sourceArrivals, runStarts, trackFinish,
613
- link, position, reserve, transferFinish, constrain });
595
+ if (trackState.some(({ tailEndSeconds }) => Number.isFinite(tailEndSeconds))) {
596
+ const stop = scheduleTail({ trackState, sourceState, sourceByteCoverage, runStarts, trackFinish,
597
+ link, position, buffered, reserve, transferFinish, constrain, duration });
614
598
  if (stop) {
615
599
  return { ...result(false, null, buffered, reserve, null, stop.reason, preparedSegments),
616
600
  ...(stop.unavailableSource ? { unavailableSource: stop.unavailableSource } : {}) };
@@ -681,185 +665,90 @@ function isPiece(segment) {
681
665
  }
682
666
 
683
667
  /**
684
- * The pieces after the near window, scheduled by the near window's rules in
685
- * seconds and constrained as they are scheduled: their source bytes arrive in
686
- * byte order at the source's measured service after the near window's bytes,
687
- * a piece whose bytes come after its output's last run was admitted starts a
688
- * run and pays the output's open, it is encoded at the output's measured rate
689
- * and delivered over the one link in media order.
690
- *
691
- * Where a byte stands in that order is the bytes still missing before it,
692
- * read off what is held, planned and brought by the near window
693
- * ({@link missingBefore}); a piece is placed by the last range of it that
694
- * misses bytes, so a range it reads that is already held — an AVI's index at
695
- * the end of the file — does not place it at the end of the file. Planned and
696
- * brought bytes come when they were to come. No exact time, list of arrivals
697
- * or per-piece object is built: this runs over the rest of the film on every
698
- * poll (torrent-tv/meta#185).
668
+ * A duration-level estimate of the suffix after the exact near window. Source
669
+ * work uses each file's aggregate byte density and measured download service;
670
+ * processing uses each track's measured rate; delivery uses output bitrates and
671
+ * measured link service. Held source bytes and made segments reduce the
672
+ * estimate. This conditional forecast does not assert exact source ranges or
673
+ * individual frame timing. Its work is bounded by sources and tracks, not by
674
+ * the film's segment count.
699
675
  *
700
676
  * @returns {{ reason: string, unavailableSource?: object } | null} Why there is no answer, or null.
701
677
  */
702
- function scheduleTail({ trackState, sourceState, sourceByteCoverage, sourceArrivals, runStarts, trackFinish, link,
703
- position, reserve, transferFinish, constrain }) {
704
- // Per source: what is missing before a byte, and how far the tail has asked.
705
- const supply = new Map();
706
- const supplyOf = (sourceId) => {
707
- let state = supply.get(sourceId);
708
- if (!state) {
709
- const coverage = sourceByteCoverage.get(sourceId) ?? [];
710
- const heldBefore = [0];
711
- for (const range of coverage) heldBefore.push(heldBefore.at(-1) + range.end - range.start);
712
- state = { coverage, heldBefore, asked: null, orders: [], finishes: [] };
713
- supply.set(sourceId, state);
714
- }
715
- return state;
716
- };
717
- const next = trackState.map(() => 0);
718
- const plan = { finish: 0 };
719
- let delivered = transferFinish;
720
- for (;;) {
721
- // The next piece in media order across the tracks, as the near window orders them.
722
- let trackIndex = -1;
723
- for (let index = 0; index < trackState.length; index += 1) {
724
- const candidate = trackState[index].tail[next[index]];
725
- if (!candidate) continue;
726
- const best = trackIndex < 0 ? null : trackState[trackIndex].tail[next[trackIndex]];
727
- if (!best || candidate.startSeconds < best.startSeconds ||
728
- (candidate.startSeconds === best.startSeconds && candidate.endSeconds < best.endSeconds)) trackIndex = index;
729
- }
730
- if (trackIndex < 0) return null;
731
- const state = trackState[trackIndex];
732
- const { track } = state;
733
- const segment = state.tail[next[trackIndex]++];
734
- const startsAt = segment.startSeconds + state.clockSeconds;
735
- const endsAt = segment.endSeconds + state.clockSeconds;
736
- // Media the page already holds is not transferred again.
737
- if (Array.isArray(track.clientRanges) && track.clientRanges.some((range) =>
738
- range?.start <= Math.max(startsAt, position) && range?.end >= endsAt)) continue;
739
- const exactBytes = sizeOf(track.segmentSizesBytes, segment.index);
740
- const sizeBits = exactBytes > 0 ? exactBytes * 8 : state.bitsPerMediaSecond * (segment.endSeconds - segment.startSeconds);
741
- if (!(sizeBits > 0)) return { reason: "segment-size-unavailable" };
742
- const produced = state.ready.has(segment.index);
743
- let work = 0;
744
- if (!produced) {
745
- work = segment.endSeconds - segment.startSeconds;
746
- for (const range of Array.isArray(track.processingRanges) ? track.processingRanges : []) {
747
- if (Number.isFinite(range?.start) && Number.isFinite(range?.end)) {
748
- work -= Math.max(0, Math.min(range.end, segment.endSeconds) - Math.max(range.start, segment.startSeconds));
749
- }
750
- }
751
- work = Math.max(0, work);
752
- }
753
- let sourceArrivalAt = 0;
754
- plan.finish = 0;
755
- if (work > 0) {
756
- if (!state.curve) return { reason: "encode-rate-unavailable" };
757
- const trackSources = Array.isArray(track.sourceIds) ? track.sourceIds : [];
758
- const exactInputs = Array.isArray(segment.sourceInputs) ? segment.sourceInputs : null;
759
- const count = trackSources.length + (exactInputs?.length ?? 0);
760
- for (let place = 0; place < count; place += 1) {
761
- const sourceId = place < trackSources.length ? trackSources[place]
762
- : exactInputs[place - trackSources.length]?.sourceId;
763
- if (namedBefore(sourceId, place, trackSources, exactInputs)) continue;
764
- const source = sourceState.get(sourceId);
765
- if (!source) return { reason: "source-measurement-unavailable" };
766
- if (source.source.complete === true) continue;
767
- const ranges = exactInputs ? inputRangesOf(exactInputs, sourceId) : null;
768
- if (ranges === null) {
769
- // The tail extends beyond the ranges the playback map has asked to
770
- // resolve. Stop this projection at that boundary: the near window
771
- // remains exact, and later segments enter the map as the viewer
772
- // advances. Waiting for the whole tail here prevents a cold start.
773
- return null;
774
- }
775
- const bytes = supplyOf(sourceId);
776
- let order = 0;
777
- for (const range of ranges) {
778
- // Bytes the torrent thread planned, or the near window brought, come when they were to come.
779
- sourceArrivalAt = Math.max(sourceArrivalAt, latestArrival(range, sourceArrivals.get(sourceId), plan));
780
- const after = missingBefore(bytes, range.end);
781
- if (after > missingBefore(bytes, range.start)) order = Math.max(order, after);
782
- }
783
- if (order === 0) continue;
784
- const { service } = source;
785
- if (!service.rate || !(service.rate.rateAt(0) > 0)) {
786
- return { reason: service.rate ? "service-not-advancing" : "download-rate-unavailable",
787
- unavailableSource: { sourceId, segmentIndex: segment.index, missingBytes: order - (bytes.asked ?? 0) } };
788
- }
789
- // The tail's first asked byte is where this source's tail begins:
790
- // bytes missing before it are not this film's to wait for.
791
- bytes.asked ??= Math.max(0, order - missingWithin(bytes, ranges));
792
- let arrival;
793
- if (order > bytes.asked) {
794
- service.finish = service.rate.finish(order - bytes.asked, service.finish);
795
- bytes.asked = order;
796
- bytes.orders.push(order);
797
- bytes.finishes.push(service.finish);
798
- arrival = service.finish;
799
- } else {
800
- // Brought by an earlier piece's ask: when that ask reached it.
801
- let low = 0, high = bytes.orders.length - 1;
802
- while (low < high) {
803
- const middle = (low + high) >>> 1;
804
- if (bytes.orders[middle] < order) low = middle + 1;
805
- else high = middle;
806
- }
807
- arrival = bytes.finishes[low] ?? service.finish;
808
- }
809
- sourceArrivalAt = Math.max(sourceArrivalAt, arrival);
810
- }
678
+ function scheduleTail({ trackState, sourceState, sourceByteCoverage, runStarts, trackFinish, link,
679
+ position, buffered, reserve, transferFinish, constrain, duration }) {
680
+ const tailStart = Math.max(position, ...trackState.map(state => state.tailStartSeconds));
681
+ if (!Number.isFinite(tailStart)) return null;
682
+ const sourceDurations = new Map();
683
+ for (const state of trackState) {
684
+ const seconds = Math.max(0, state.tailEndSeconds - tailStart) * state.tailWorkFraction;
685
+ if (!(seconds > 0)) continue;
686
+ for (const sourceId of new Set(state.track.sourceIds ?? [])) {
687
+ sourceDurations.set(sourceId, Math.max(sourceDurations.get(sourceId) ?? 0, seconds));
811
688
  }
812
- let admittedAt = Math.max(trackFinish[trackIndex], sourceArrivalAt);
813
- if (work > 0) {
814
- const run = runStarts[trackIndex];
815
- const live = run.spans.some(span => segment.startSeconds >= span.start && segment.startSeconds < span.end);
816
- if (!live && (run.admittedAt === null || sourceArrivalAt > run.admittedAt)) {
817
- run.admittedAt = admittedAt;
818
- admittedAt += run.openSeconds;
819
- }
689
+ }
690
+
691
+ let sourceArrivalAt = 0;
692
+ for (const [sourceId, seconds] of sourceDurations) {
693
+ const source = sourceState.get(sourceId);
694
+ if (!source) return { reason: "source-measurement-unavailable" };
695
+ if (source.source.complete === true) continue;
696
+ const bytes = estimateUnmappedSourceBytes(source.source, seconds, duration,
697
+ sourceByteCoverage.get(sourceId));
698
+ if (bytes === null) return { reason: "source-measurement-unavailable",
699
+ unavailableSource: { sourceId, phase: "tail" } };
700
+ if (!(bytes > 0)) continue;
701
+ const { service } = source;
702
+ if (!service.rate || !(service.rate.rateAt(0) > 0)) {
703
+ return { reason: service.rate ? "service-not-advancing" : "download-rate-unavailable",
704
+ unavailableSource: { sourceId, estimatedBytes: bytes, phase: "tail" } };
820
705
  }
821
- const producedAt = work > 0 ? state.curve.finish(work, admittedAt) : 0;
822
- trackFinish[trackIndex] = Math.max(trackFinish[trackIndex], producedAt);
823
- delivered = link.finish(sizeBits, Math.max(delivered, producedAt));
824
- const completedAt = delivered + finiteNonNegative(track.appendSeconds);
825
- // The media before a piece is what the playlist puts before it: whether a
826
- // hole stops the media is decided in the near window, in exact time.
827
- const ahead = Math.max(0, startsAt - position);
828
- const requiresSource = sourceArrivalAt > 0;
829
- const sourcePlanned = requiresSource && plan.finish >= sourceArrivalAt;
830
- constrain(completedAt - ahead, "delivery", trackIndex, segment.index, completedAt, ahead, false, sourceArrivalAt,
831
- sourcePlanned);
832
- const covered = Math.max(0, ahead - Math.min(reserve, Math.max(0, state.tailEndSeconds - startsAt)) *
833
- Number(requiresSource));
834
- constrain(producedAt - covered, "production", trackIndex, segment.index, producedAt, covered, requiresSource,
835
- sourceArrivalAt, sourcePlanned);
706
+ service.finish = service.rate.finish(bytes, service.finish);
707
+ sourceArrivalAt = Math.max(sourceArrivalAt, service.finish);
708
+ const state = trackState.find(candidate => candidate.track.sourceIds?.includes(sourceId));
709
+ const index = state?.segments.at(-1)?.index ?? 0;
710
+ const ahead = Math.max(0, (state?.tailEndSeconds ?? tailStart) - position);
711
+ constrain(service.finish - Math.max(0, ahead - reserve), "source", trackState.indexOf(state), index,
712
+ service.finish, ahead, true, service.finish, false);
836
713
  }
837
- }
838
714
 
839
- /**
840
- * The bytes not held, and not brought by the near window, before `byte` of a
841
- * source: `byte` less what the sorted coverage holds below it.
842
- *
843
- * @param {{ coverage: Array<{ start: number, end: number }>, heldBefore: number[] }} bytes
844
- * @param {number} byte
845
- */
846
- function missingBefore(bytes, byte) {
847
- const { coverage, heldBefore } = bytes;
848
- let low = 0, high = coverage.length;
849
- while (low < high) {
850
- const middle = (low + high) >>> 1;
851
- if (coverage[middle].end <= byte) low = middle + 1;
852
- else high = middle;
715
+ let outputBits = 0;
716
+ let latestTrackFinish = 0;
717
+ for (let trackIndex = 0; trackIndex < trackState.length; trackIndex += 1) {
718
+ const state = trackState[trackIndex];
719
+ const end = state.tailEndSeconds;
720
+ const seconds = Math.max(0, end - tailStart);
721
+ if (!(seconds > 0)) continue;
722
+ const track = state.track;
723
+ const segment = state.segments.at(-1) ?? { index: 0 };
724
+ const remaining = seconds * state.tailWorkFraction;
725
+ const work = Math.max(0, remaining - numericCoverage((track.processingRanges ?? [])
726
+ .filter(range => Number.isFinite(range?.start) && Number.isFinite(range?.end))
727
+ .map(range => ({ start: Math.max(tailStart, range.start), end: Math.min(end, range.end) }))
728
+ .filter(range => range.end > range.start)).reduce((sum, range) => sum + range.end - range.start, 0));
729
+ const processing = state.curve;
730
+ if (work > 0 && !processing) return { reason: "encode-rate-unavailable" };
731
+ const run = runStarts[trackIndex];
732
+ const localStart = tailStart - state.clockSeconds;
733
+ const inRun = run.spans.some(span => localStart >= span.start && localStart < span.end);
734
+ const open = work > 0 && !inRun ? run.openSeconds : 0;
735
+ const finishedAt = work > 0 ? processing.finish(work, trackFinish[trackIndex] + open) : trackFinish[trackIndex];
736
+ latestTrackFinish = Math.max(latestTrackFinish, finishedAt);
737
+ const deliveryStart = Math.max(tailStart, position + buffered);
738
+ outputBits += Math.max(0, state.bitsPerMediaSecond) * Math.max(0, end - deliveryStart);
739
+ const ahead = Math.max(0, end - position);
740
+ constrain(finishedAt - Math.max(0, ahead - reserve), "production", trackIndex, segment.index,
741
+ finishedAt, ahead, sourceArrivalAt > 0, sourceArrivalAt, false);
853
742
  }
854
- const inside = low < coverage.length && coverage[low].start < byte ? byte - coverage[low].start : 0;
855
- return byte - heldBefore[low] - inside;
856
- }
857
743
 
858
- /** The missing bytes of a piece's ranges of one source. */
859
- function missingWithin(bytes, ranges) {
860
- let missing = 0;
861
- for (const range of ranges) missing += missingBefore(bytes, range.end) - missingBefore(bytes, range.start);
862
- return missing;
744
+ if (outputBits > 0) {
745
+ const end = Math.max(...trackState.map(state => state.tailEndSeconds));
746
+ const completedAt = link.finish(outputBits, Math.max(transferFinish, latestTrackFinish));
747
+ const ahead = Math.max(0, end - position);
748
+ constrain(completedAt - Math.max(0, ahead - reserve), "delivery", 0,
749
+ trackState[0]?.segments.at(-1)?.index ?? 0, completedAt, ahead, false, sourceArrivalAt, false);
750
+ }
751
+ return null;
863
752
  }
864
753
 
865
754
  /**
@@ -1141,6 +1030,25 @@ function numericCoverage(ranges) {
1141
1030
  return joined;
1142
1031
  }
1143
1032
 
1033
+ /**
1034
+ * Estimate download work without claiming an address or held byte. The whole
1035
+ * file's measured density, reduced by the held fraction, prices unresolved
1036
+ * intervals and the aggregate suffix.
1037
+ */
1038
+ function estimateUnmappedSourceBytes(source, mediaSeconds, durationSeconds, coverage = null) {
1039
+ const fileLength = Number(source.fileLength);
1040
+ const density = Number(source.bytesPerMediaSecond) > 0 ? Number(source.bytesPerMediaSecond)
1041
+ : fileLength > 0 && durationSeconds > 0 ? fileLength / durationSeconds : 0;
1042
+ if (!(density > 0) || !(mediaSeconds > 0)) return null;
1043
+ const heldRanges = coverage ?? source.residence;
1044
+ if (!(fileLength > 0) || !Array.isArray(heldRanges)) return density * mediaSeconds;
1045
+ const held = numericCoverage(heldRanges.filter(range =>
1046
+ range?.location !== "missing" && Number.isFinite(range?.start) && Number.isFinite(range?.end) &&
1047
+ range.end > range.start));
1048
+ const heldBytes = held.reduce((sum, range) => sum + range.end - range.start, 0);
1049
+ return density * mediaSeconds * Math.max(0, Math.min(1, (fileLength - heldBytes) / fileLength));
1050
+ }
1051
+
1144
1052
  /** Already held bytes have no arrival delay. Index future arrivals once. */
1145
1053
  function indexedArrivals(ranges) {
1146
1054
  let maximumEnd = 0;