@torrent-tv/proxy 2.87.14 → 2.87.15

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/CHANGELOG.md CHANGED
@@ -1,3 +1,9 @@
1
+ ## 2.87.15
2
+
3
+ - **Fix**: Integrate a bounded, nonnegative forecast of recent measured service rates, so an old faster link sample cannot predict permanent delivery failure and prevent playback of fully prepared media.
4
+ - **Fix**: Treat the already-held browser buffer as a measured lower bound on capacity when its loader target has been reduced by fragment retries.
5
+ - **Chore**: Record forecast measurements when the playback-readiness reason changes.
6
+
1
7
  ## 2.87.14
2
8
 
3
9
  - **Fix**: Evaluate source-stall coverage against the continuous timeline already prepared on the proxy, while independently checking client-buffer continuity and delivery capacity.
@@ -101,6 +101,15 @@ remaining-work estimates and share the measured download rate of their torrent.
101
101
  The browser reports buffer state and delivery measurements, but does not
102
102
  calculate or override readiness.
103
103
 
104
+ Service work is the integral of a nonnegative measured-rate forecast. The latest
105
+ rate relaxes over the observed measurement span toward the lower of its latest
106
+ value and the recent mean; the measurement history covers the output look-ahead.
107
+ A positive measured rate cannot become permanently zero from an old declining
108
+ linear fit. A measured zero remains zero until a new measurement arrives.
109
+ The reported loader limit is bounded below by the browser buffer already held.
110
+ During startup waiting the browser refreshes stale delivery measurements through
111
+ the existing link probe and reports their age to avoid clock-offset errors.
112
+
104
113
  ## Subtitles
105
114
 
106
115
  `GET /api/subtitles` requires `sourceKey` and `fileIndex`. Without `trackIndex`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.87.14",
3
+ "version": "2.87.15",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -663,7 +663,7 @@ export class ViewerRequests {
663
663
  return Number.isFinite(segmentSeconds) && segmentSeconds > 0 ? segmentSeconds : 0;
664
664
  }));
665
665
 
666
- return predictPlaybackReadiness({
666
+ const input = {
667
667
  now,
668
668
  positionSeconds,
669
669
  durationSeconds: session.file.durationSeconds,
@@ -675,7 +675,24 @@ export class ViewerRequests {
675
675
  sources: [...sourceMeasurements.values()],
676
676
  tracks,
677
677
  linkReadings: measurement.link.snapshot()
678
- });
678
+ };
679
+ const forecast = predictPlaybackReadiness(input);
680
+ if (measurement.forecastReason !== forecast.reason) {
681
+ measurement.forecastReason = forecast.reason;
682
+ logger.info(`playback readiness ${session.id} consumer=${consumerId} ${JSON.stringify({
683
+ ...forecast,
684
+ positionSeconds,
685
+ durationSeconds: input.durationSeconds,
686
+ bufferLimitSeconds: input.bufferLimitSeconds,
687
+ lookaheadSeconds: input.lookaheadSeconds,
688
+ link: input.linkReadings,
689
+ sources: input.sources,
690
+ tracks: tracks.map(({ id, processedSeconds, readings, segments, readySegmentIndices }) => ({
691
+ id, processedSeconds, readings, segments: segments.length, prepared: readySegmentIndices.length
692
+ }))
693
+ })}`);
694
+ }
695
+ return forecast;
679
696
  }
680
697
 
681
698
  #rateStateFor(session, consumerId) {
@@ -687,7 +704,7 @@ export class ViewerRequests {
687
704
  const id = typeof consumerId === "string" ? consumerId : "";
688
705
  let state = byViewer.get(id);
689
706
  if (!state) {
690
- state = { link: new RateTrend(), downloads: new Map(), tracks: new Map() };
707
+ state = { link: new RateTrend(this.#host.lookaheadSeconds), downloads: new Map(), tracks: new Map() };
691
708
  byViewer.set(id, state);
692
709
  }
693
710
  return state;
@@ -696,7 +713,7 @@ export class ViewerRequests {
696
713
  #downloadRateReadings(state, serviceId, downloadSpeed, now) {
697
714
  let trend = state.downloads.get(serviceId);
698
715
  if (!trend) {
699
- trend = new RateTrend();
716
+ trend = new RateTrend(this.#host.lookaheadSeconds);
700
717
  state.downloads.set(serviceId, trend);
701
718
  }
702
719
  const speed = Number(downloadSpeed);
@@ -710,7 +727,7 @@ export class ViewerRequests {
710
727
  const id = output.outputKey;
711
728
  let trend = state.tracks.get(id);
712
729
  if (!trend) {
713
- trend = new RateTrend();
730
+ trend = new RateTrend(this.#host.lookaheadSeconds);
714
731
  state.tracks.set(id, trend);
715
732
  }
716
733
  const reading = this.#host.encodeSpeedReadingOf?.(output, now) ?? null;
@@ -6,14 +6,15 @@
6
6
  * measurements and physical limits: source bytes and download rate, encoded
7
7
  * media and encoder rate, segment sizes and client-link rate, the existing
8
8
  * browser buffer, its capacity, the proxy look-ahead, and the measured reserve
9
- * for interruptions. Rates are extrapolated from their measured linear trend;
10
- * there are no fitted weights or rate multipliers.
9
+ * for interruptions. A measured rate relaxes toward the lower of its latest
10
+ * value and its observed mean over the measurement span. Its integral remains
11
+ * unbounded for a positive measured rate; an old slope cannot invent a permanent
12
+ * cessation of service. There are no fitted weights or rate multipliers.
11
13
  */
12
14
 
13
15
  /**
14
- * The mean rate predicted over a horizon by the trend in all supplied
15
- * measurements. With no trend this is the measured rate. The regression treats
16
- * every measurement equally.
16
+ * The mean rate predicted over a horizon by the integral of the measured rate.
17
+ * Every supplied measurement contributes equally to the observed mean.
17
18
  *
18
19
  * @param {Array<{ at: number, value: number }>} readings
19
20
  * @param {number} now
@@ -30,48 +31,36 @@ export function forecastRate(readings, now = Date.now(), horizonSeconds = 0) {
30
31
  }
31
32
 
32
33
  /**
33
- * Constant-space measurements for one rate trend. Linear regression can be
34
- * evaluated from these sums without retaining every poll for the life of a
35
- * film.
34
+ * Measurements over the output's existing look-ahead horizon. The oldest
35
+ * boundary sample is retained until another sample replaces it, so a repeated
36
+ * report never changes the history or invents a new measurement.
36
37
  */
37
38
  export class RateTrend {
38
- #originAt = null;
39
- #lastAt = null;
40
- #count = 0;
41
- #sumTime = 0;
42
- #sumTimeSquared = 0;
43
- #sumValue = 0;
44
- #sumTimeValue = 0;
39
+ #readings = [];
40
+ #horizonMs;
41
+
42
+ constructor(horizonSeconds = Number.POSITIVE_INFINITY) {
43
+ this.#horizonMs = Math.max(0, horizonSeconds) * 1000;
44
+ }
45
45
 
46
46
  add(at, value) {
47
47
  if (!Number.isFinite(at) || !Number.isFinite(value) || value < 0 ||
48
- (this.#lastAt !== null && at <= this.#lastAt)) {
48
+ at <= (this.#readings.at(-1)?.at ?? Number.NEGATIVE_INFINITY)) {
49
49
  return false;
50
50
  }
51
- this.#originAt ??= at;
52
- const elapsed = (at - this.#originAt) / 1000;
53
- this.#count += 1;
54
- this.#sumTime += elapsed;
55
- this.#sumTimeSquared += elapsed ** 2;
56
- this.#sumValue += value;
57
- this.#sumTimeValue += elapsed * value;
58
- this.#lastAt = at;
51
+ this.#readings.push({ at, value });
52
+ const cutoff = at - this.#horizonMs;
53
+ while (this.#readings.length > 1 && this.#readings[1].at <= cutoff) {
54
+ this.#readings.shift();
55
+ }
59
56
  return true;
60
57
  }
61
58
 
62
59
  snapshot() {
63
- if (this.#count === 0) {
60
+ if (this.#readings.length === 0) {
64
61
  return null;
65
62
  }
66
- return {
67
- count: this.#count,
68
- originAt: this.#originAt,
69
- lastAt: this.#lastAt,
70
- sumTime: this.#sumTime,
71
- sumTimeSquared: this.#sumTimeSquared,
72
- sumValue: this.#sumValue,
73
- sumTimeValue: this.#sumTimeValue
74
- };
63
+ return summarizeRate(this.#readings);
75
64
  }
76
65
  }
77
66
 
@@ -100,7 +89,9 @@ export function predictPlaybackReadiness(input = {}) {
100
89
  const duration = finiteNonNegative(input.durationSeconds);
101
90
  const remaining = Math.max(0, duration - position);
102
91
  const buffered = finiteNonNegative(input.bufferedAheadSeconds);
103
- const capacity = finiteNonNegative(input.bufferLimitSeconds);
92
+ // A loader target can be lowered after a fragment retry. Bytes already held
93
+ // prove a larger capacity, even when an older page reports only that target.
94
+ const capacity = Math.max(buffered, finiteNonNegative(input.bufferLimitSeconds));
104
95
  const reserve = Math.min(remaining, finiteNonNegative(input.reserveSeconds));
105
96
  const lookahead = finiteNonNegative(input.lookaheadSeconds);
106
97
  const tracks = Array.isArray(input.tracks) ? input.tracks : [];
@@ -237,32 +228,6 @@ export function predictPlaybackReadiness(input = {}) {
237
228
  }
238
229
  }
239
230
 
240
- const rateRecoveryDelays = [];
241
- const sourceDemandByService = new Map();
242
- for (const sourceId of new Set(jobs.flatMap(({ sourceIntervals }) => [...sourceIntervals.keys()]))) {
243
- const source = sourceState.get(sourceId);
244
- const demand = sourceDemandByService.get(source.serviceId) ?? new Map();
245
- demand.set(sourceId, Number(source.source.bytesPerMediaSecond));
246
- sourceDemandByService.set(source.serviceId, demand);
247
- }
248
- for (const [serviceId, demandBySource] of sourceDemandByService) {
249
- const demand = [...demandBySource.values()].reduce((sum, value) => sum + value, 0);
250
- rateRecoveryDelays.push(downloadServices.get(serviceId).curve.timeAtLeast(demand));
251
- }
252
- for (const { track, curve } of trackState) {
253
- if (jobs.some((job) => job.track === track && !job.produced)) {
254
- rateRecoveryDelays.push(curve.timeAtLeast(1));
255
- }
256
- }
257
- const linkDemand = trackState.reduce((sum, { track, bitsPerMediaSecond }) =>
258
- sum + (jobs.some((job) => job.track === track) ? bitsPerMediaSecond : 0), 0);
259
- if (linkDemand > 0) {
260
- rateRecoveryDelays.push(link.timeAtLeast(linkDemand));
261
- }
262
- const rateRecoveryDelay = rateRecoveryDelays
263
- .filter(Number.isFinite)
264
- .reduce((latest, delay) => Math.max(latest, delay), 0);
265
-
266
231
  jobs.sort((left, right) => left.segment.startSeconds - right.segment.startSeconds ||
267
232
  left.segment.endSeconds - right.segment.endSeconds || left.trackIndex - right.trackIndex);
268
233
 
@@ -353,6 +318,7 @@ export function predictPlaybackReadiness(input = {}) {
353
318
  return { safe: safety.safe,
354
319
  bufferedAtStart: safety.bufferedAtStart,
355
320
  neededSeconds: safety.neededSeconds,
321
+ completionTimes: usefulCompletions.map(({ at }) => at),
356
322
  finishAt: usefulCompletions.reduce((latest, item) => Math.max(latest, item.at), 0) };
357
323
  };
358
324
 
@@ -366,7 +332,7 @@ export function predictPlaybackReadiness(input = {}) {
366
332
  if (immediate.safe) {
367
333
  return result(true, 0, buffered, reserve, 0, "trajectory-safe-now", preparedSegments);
368
334
  }
369
- const searchLimit = Math.max(maximumUsefulDelay, rateRecoveryDelay);
335
+ const searchLimit = maximumUsefulDelay;
370
336
  if (!(searchLimit > 0)) {
371
337
  return result(false, null, buffered, reserve, null, "no-safe-start-found", preparedSegments);
372
338
  }
@@ -376,7 +342,7 @@ export function predictPlaybackReadiness(input = {}) {
376
342
  const breakpoints = [...new Set([
377
343
  maximumUsefulDelay,
378
344
  searchLimit,
379
- ...rateRecoveryDelays.filter(Number.isFinite)
345
+ ...prefillSchedule.completionTimes
380
346
  ])]
381
347
  .filter((delay) => delay > 0 && delay <= searchLimit)
382
348
  .sort((left, right) => left - right);
@@ -412,17 +378,9 @@ export function predictPlaybackReadiness(input = {}) {
412
378
 
413
379
  function rateCurve(readings, now) {
414
380
  if (readings && Number.isFinite(readings.count) && readings.count > 0 &&
415
- Number.isFinite(readings.originAt) && Number.isFinite(readings.lastAt) &&
416
- Number.isFinite(readings.sumTime) && Number.isFinite(readings.sumTimeSquared) &&
417
- Number.isFinite(readings.sumValue) && Number.isFinite(readings.sumTimeValue)) {
418
- const { count, originAt, sumTime, sumTimeSquared, sumValue, sumTimeValue } = readings;
419
- const meanAt = sumTime / count;
420
- const meanValue = sumValue / count;
421
- const denominator = sumTimeSquared - count * meanAt ** 2;
422
- const numerator = sumTimeValue - count * meanAt * meanValue;
423
- const slope = denominator > 0 ? numerator / denominator : 0;
424
- const current = Math.max(0, meanValue + slope * ((now - originAt) / 1000 - meanAt));
425
- return rateCurveFrom(current, slope);
381
+ Number.isFinite(readings.lastAt) && Number.isFinite(readings.lastValue) &&
382
+ Number.isFinite(readings.meanValue) && Number.isFinite(readings.spanSeconds)) {
383
+ return rateCurveFrom(readings, now);
426
384
  }
427
385
  const ordered = (Array.isArray(readings) ? readings : [])
428
386
  .filter((reading) => Number.isFinite(reading?.at) && Number.isFinite(reading?.value) && reading.value >= 0)
@@ -433,71 +391,70 @@ function rateCurve(readings, now) {
433
391
  return null;
434
392
  }
435
393
 
436
- const points = ordered.map((reading) => ({
437
- at: (reading.at - now) / 1000,
438
- value: reading.value
439
- }));
440
- const meanAt = points.reduce((sum, point) => sum + point.at, 0) / points.length;
441
- const meanValue = points.reduce((sum, point) => sum + point.value, 0) / points.length;
442
- const numerator = points.reduce((sum, point) =>
443
- sum + (point.at - meanAt) * (point.value - meanValue), 0);
444
- const denominator = points.reduce((sum, point) =>
445
- sum + (point.at - meanAt) ** 2, 0);
446
- const slope = denominator > 0 ? numerator / denominator : 0;
447
- const current = Math.max(0, meanValue - slope * meanAt);
448
- return rateCurveFrom(current, slope);
394
+ return rateCurveFrom(summarizeRate(ordered), now);
449
395
  }
450
396
 
451
- function rateCurveFrom(current, slope) {
452
- const zeroAt = slope < 0 ? -current / slope : Number.POSITIVE_INFINITY;
453
-
454
- const workBy = (seconds) => {
455
- const span = Math.min(Math.max(0, seconds), zeroAt);
456
- return Math.max(0, current * span + (slope * span ** 2) / 2);
457
- };
458
- const timeFor = (work) => {
459
- if (!(work > 0)) {
460
- return 0;
461
- }
462
- if (!(current > 0) && !(slope > 0)) {
463
- return Number.POSITIVE_INFINITY;
464
- }
465
- const discriminant = current ** 2 + 2 * slope * work;
466
- if (discriminant < 0) {
467
- return Number.POSITIVE_INFINITY;
468
- }
469
- if (slope < 0 && work > workBy(zeroAt)) {
470
- return Number.POSITIVE_INFINITY;
471
- }
472
- if (slope === 0) {
473
- return current > 0 ? work / current : Number.POSITIVE_INFINITY;
474
- }
475
- const root = Math.sqrt(discriminant);
476
- const denominator = current + root;
477
- return denominator > 0 ? (2 * work) / denominator : Number.POSITIVE_INFINITY;
397
+ function summarizeRate(readings) {
398
+ const latest = readings.at(-1);
399
+ return {
400
+ count: readings.length,
401
+ lastAt: latest.at,
402
+ lastValue: latest.value,
403
+ meanValue: readings.reduce((sum, reading) => sum + reading.value, 0) / readings.length,
404
+ spanSeconds: (latest.at - readings[0].at) / 1000
478
405
  };
406
+ }
407
+
408
+ function rateCurveFrom({ lastAt, lastValue, meanValue, spanSeconds }, now) {
409
+ const baseline = Math.max(0, Math.min(lastValue, meanValue));
410
+ const span = Math.max(Number.EPSILON, spanSeconds);
411
+ const age = Math.max(0, (now - lastAt) / 1000);
412
+ const excess = Math.max(0, lastValue - baseline) * Math.exp(-age / span);
413
+ // r(t) = baseline + excess * exp(-t/span).
414
+ // W(t) = integral(0..t, r(u)du). Positive measured service has no finite
415
+ // total-work ceiling merely because an old sample was faster.
416
+ const workBy = (seconds) => rateIntegral(Math.max(0, seconds), baseline, excess, span);
479
417
  return {
480
- timeAtLeast: (target) => {
481
- if (!Number.isFinite(target) || target < 0) {
482
- return Number.POSITIVE_INFINITY;
483
- }
484
- if (current >= target) {
485
- return 0;
486
- }
487
- return slope > 0 ? (target - current) / slope : Number.POSITIVE_INFINITY;
488
- },
489
- rateAt: (seconds) => Math.max(0, current + slope * Math.min(Math.max(0, seconds), zeroAt)),
418
+ rateAt: (seconds) => baseline + excess * Math.exp(-Math.max(0, seconds) / span),
490
419
  workBy,
491
420
  finish: (work, startAt) => {
492
- if (!(work > 0)) {
493
- return Math.max(0, startAt);
494
- }
495
421
  const start = Math.max(0, startAt);
496
- return start + (timeFor(work + workBy(start)) - start);
422
+ return start + invertRateIntegral(Math.max(0, work), baseline,
423
+ excess * Math.exp(-start / span), span);
497
424
  }
498
425
  };
499
426
  }
500
427
 
428
+ function rateIntegral(seconds, baseline, excess, span) {
429
+ return baseline * seconds - excess * span * Math.expm1(-seconds / span);
430
+ }
431
+
432
+ /** Invert the strictly increasing service integral, with no playback rule. */
433
+ function invertRateIntegral(work, baseline, excess, span) {
434
+ if (work === 0) {
435
+ return 0;
436
+ }
437
+ if (!(baseline > 0) || !Number.isFinite(work)) {
438
+ return Number.POSITIVE_INFINITY;
439
+ }
440
+ let low = 0;
441
+ let high = work / baseline;
442
+ if (excess === 0) {
443
+ return high;
444
+ }
445
+ while (true) {
446
+ const middle = (low + high) / 2;
447
+ if (middle === low || middle === high) {
448
+ return high;
449
+ }
450
+ if (rateIntegral(middle, baseline, excess, span) >= work) {
451
+ high = middle;
452
+ } else {
453
+ low = middle;
454
+ }
455
+ }
456
+ }
457
+
501
458
  function averageBitsPerMediaSecond(track, segments, ready) {
502
459
  let bits = 0;
503
460
  let mediaSeconds = 0;
@@ -107,12 +107,14 @@ test("does not produce an ETA without a client-link measurement", () => {
107
107
  assert.equal(forecast.reason, "link-rate-unavailable");
108
108
  });
109
109
 
110
- test("rate trend projects the measured linear trend without a fitted weight", () => {
110
+ test("rate forecast does not extrapolate acceleration beyond observed service", () => {
111
111
  const trend = new RateTrend();
112
112
  trend.add(9_000, 2);
113
113
  trend.add(10_000, 4);
114
114
 
115
- assert.equal(forecastRate(trend.snapshot(), 10_000, 2), 6);
115
+ const predicted = forecastRate(trend.snapshot(), 10_000, 2);
116
+ assert.ok(predicted > 3 && predicted < 4);
117
+ assert.ok(forecastRate(trend.snapshot(), 100_000, 2) >= 3);
116
118
  });
117
119
 
118
120
  test("uses each track's own segment boundaries and requires continuous coverage on both tracks", () => {
@@ -331,7 +333,7 @@ test("shares one torrent download rate across distinct video and audio files", (
331
333
  assert.ok(Math.abs(forecast.delaySeconds - 4.032) < 1e-8);
332
334
  });
333
335
 
334
- test("finds a safe interval when rising encode speed and falling link speed make the forecast non-monotonic", () => {
336
+ test("does not invent future encode acceleration to start an unsustainable output", () => {
335
337
  const now = 1_000;
336
338
  const forecast = predictPlaybackReadiness({
337
339
  now,
@@ -360,7 +362,81 @@ test("finds a safe interval when rising encode speed and falling link speed make
360
362
  });
361
363
 
362
364
  assert.equal(forecast.ready, false);
363
- assert.equal(forecast.reason, "minimum-safe-delay");
364
- assert.ok(Number.isFinite(forecast.delaySeconds));
365
- assert.ok(forecast.delaySeconds > 4 && forecast.delaySeconds < 5);
365
+ assert.equal(forecast.reason, "no-safe-start-found");
366
+ assert.equal(forecast.delaySeconds, null);
367
+ });
368
+
369
+ test("a faster old probe cannot turn positive delivery into a permanent zero rate", () => {
370
+ const trend = new RateTrend(120);
371
+ trend.add(0, 112_400_000);
372
+ trend.add(66_757, 37_640_000);
373
+ assert.equal(forecastRate(trend.snapshot(), 20 * 60_000, 1443.97), 37_640_000);
374
+ assert.equal(trend.add(66_757, 37_640_000), false);
375
+ });
376
+
377
+ test("the measurement window retains one boundary sample and discards older history", () => {
378
+ const trend = new RateTrend(2);
379
+ trend.add(0, 100);
380
+ trend.add(1_000, 40);
381
+ trend.add(4_000, 20);
382
+ assert.equal(trend.snapshot().count, 2);
383
+ assert.equal(trend.snapshot().meanValue, 30);
384
+ });
385
+
386
+ function preparedEpisode(bufferLimitSeconds, linkReadings) {
387
+ const durationSeconds = 1443.97;
388
+ const boundaries = [0, 10.01, 18.977, 29.488];
389
+ for (let index = 4; index <= 197; index += 1) {
390
+ boundaries.push(29.488 + (durationSeconds - 29.488) * (index - 3) / 194);
391
+ }
392
+ const segments = boundaries.slice(0, -1).map((startSeconds, index) => ({
393
+ index, startSeconds, endSeconds: boundaries[index + 1]
394
+ }));
395
+ const tracks = [9_257_176.8, 189_000].map((bitsPerMediaSecond, index) => ({
396
+ id: String(index),
397
+ sourceIds: ["source"],
398
+ processedSeconds: 1437.269,
399
+ bitsPerMediaSecond,
400
+ readings: [],
401
+ segments,
402
+ readySegmentIndices: segments.map(({ index }) => index),
403
+ segmentSizesBytes: new Map(segments.map((segment) => [segment.index,
404
+ (segment.endSeconds - segment.startSeconds) * bitsPerMediaSecond / 8]))
405
+ }));
406
+ return {
407
+ now: 20 * 60_000,
408
+ positionSeconds: 0,
409
+ durationSeconds,
410
+ bufferedAheadSeconds: 19.187,
411
+ bufferLimitSeconds,
412
+ reserveSeconds: 5.674,
413
+ lookaheadSeconds: 120,
414
+ requiredAudio: true,
415
+ sources: [{ id: "source", complete: false, bytesPerMediaSecond: 1, readings: [{ at: 0, value: 0 }] }],
416
+ tracks,
417
+ linkReadings
418
+ };
419
+ }
420
+
421
+ test("starts a prepared episode despite an old declining link measurement", () => {
422
+ const forecast = predictPlaybackReadiness(preparedEpisode(120, [
423
+ { at: 0, value: 112_400_000 }, { at: 66_757, value: 37_640_000 }
424
+ ]));
425
+ assert.equal(forecast.preparedSegments, 394);
426
+ assert.equal(forecast.ready, true);
427
+ assert.equal(forecast.reason, "trajectory-safe-now");
428
+ });
429
+
430
+ test("the measured buffer proves capacity even when the loader reports a smaller target", () => {
431
+ const forecast = predictPlaybackReadiness(preparedEpisode(9.092, [{ at: 0, value: 37_640_000 }]));
432
+ assert.equal(forecast.ready, true);
433
+ });
434
+
435
+ test("a measured source stop is not replaced by hypothetical future recovery", () => {
436
+ const forecast = predictPlaybackReadiness(input({ sources: [{
437
+ id: "source", complete: false, bytesPerMediaSecond: 1,
438
+ readings: [{ at: 9_000, value: 10 }, { at: 10_000, value: 0 }]
439
+ }] }));
440
+ assert.equal(forecast.ready, false);
441
+ assert.equal(forecast.delaySeconds, null);
366
442
  });