@torrent-tv/proxy 2.87.13 → 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,13 @@
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
+
7
+ ## 2.87.14
8
+
9
+ - **Fix**: Evaluate source-stall coverage against the continuous timeline already prepared on the proxy, while independently checking client-buffer continuity and delivery capacity.
10
+
1
11
  ## 2.87.13
2
12
 
3
13
  - **Fix**: Include distinct active piece reads in memory demand and preserve their slots immediately, so reads without priority-map ranges cannot deadlock the torrent piece store.
@@ -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.13",
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
 
@@ -81,7 +70,7 @@ export class RateTrend {
81
70
  * @param {number} input.durationSeconds
82
71
  * @param {number} input.bufferedAheadSeconds
83
72
  * @param {number} input.bufferLimitSeconds
84
- * @param {number} input.reserveSeconds
73
+ * @param {number} input.reserveSeconds - Measured source-stall coverage, in media seconds.
85
74
  * @param {number} input.lookaheadSeconds
86
75
  * @param {number} input.now
87
76
  * @param {boolean} [input.requiredAudio]
@@ -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 : [];
@@ -110,12 +101,9 @@ export function predictPlaybackReadiness(input = {}) {
110
101
  if (remaining <= buffered) {
111
102
  return result(true, 0, buffered, reserve, 0, "client-buffer-covers-end", 0);
112
103
  }
113
- if (!(duration > 0) || !(capacity > 0) || !(reserve > 0) || tracks.length === 0) {
104
+ if (!(duration > 0) || !(capacity > 0) || tracks.length === 0) {
114
105
  return result(false, null, buffered, reserve, null, "incomplete-state", 0);
115
106
  }
116
- if (reserve > capacity) {
117
- return result(false, null, buffered, reserve, null, "reserve-exceeds-client-capacity", 0);
118
- }
119
107
  const requiredTracks = input.requiredAudio === true ? 2 : 1;
120
108
  if (tracks.length < requiredTracks) {
121
109
  return result(false, null, buffered, reserve, null, "separate-audio-not-observed", 0);
@@ -166,7 +154,7 @@ export function predictPlaybackReadiness(input = {}) {
166
154
 
167
155
  for (let trackIndex = 0; trackIndex < trackState.length; trackIndex += 1) {
168
156
  const state = trackState[trackIndex];
169
- const { track, ready, segments } = state;
157
+ const { track, segments } = state;
170
158
  const measuredBitsPerMediaSecond = state.bitsPerMediaSecond;
171
159
  for (const segment of segments) {
172
160
  if (segment.endSeconds <= position + buffered) {
@@ -240,32 +228,6 @@ export function predictPlaybackReadiness(input = {}) {
240
228
  }
241
229
  }
242
230
 
243
- const rateRecoveryDelays = [];
244
- const sourceDemandByService = new Map();
245
- for (const sourceId of new Set(jobs.flatMap(({ sourceIntervals }) => [...sourceIntervals.keys()]))) {
246
- const source = sourceState.get(sourceId);
247
- const demand = sourceDemandByService.get(source.serviceId) ?? new Map();
248
- demand.set(sourceId, Number(source.source.bytesPerMediaSecond));
249
- sourceDemandByService.set(source.serviceId, demand);
250
- }
251
- for (const [serviceId, demandBySource] of sourceDemandByService) {
252
- const demand = [...demandBySource.values()].reduce((sum, value) => sum + value, 0);
253
- rateRecoveryDelays.push(downloadServices.get(serviceId).curve.timeAtLeast(demand));
254
- }
255
- for (const { track, curve } of trackState) {
256
- if (jobs.some((job) => job.track === track && !job.produced)) {
257
- rateRecoveryDelays.push(curve.timeAtLeast(1));
258
- }
259
- }
260
- const linkDemand = trackState.reduce((sum, { track, bitsPerMediaSecond }) =>
261
- sum + (jobs.some((job) => job.track === track) ? bitsPerMediaSecond : 0), 0);
262
- if (linkDemand > 0) {
263
- rateRecoveryDelays.push(link.timeAtLeast(linkDemand));
264
- }
265
- const rateRecoveryDelay = rateRecoveryDelays
266
- .filter(Number.isFinite)
267
- .reduce((latest, delay) => Math.max(latest, delay), 0);
268
-
269
231
  jobs.sort((left, right) => left.segment.startSeconds - right.segment.startSeconds ||
270
232
  left.segment.endSeconds - right.segment.endSeconds || left.trackIndex - right.trackIndex);
271
233
 
@@ -277,6 +239,7 @@ export function predictPlaybackReadiness(input = {}) {
277
239
  const trackFinish = trackState.map(() => 0);
278
240
  let transferFinish = 0;
279
241
  const completions = [];
242
+ const productions = [];
280
243
  for (const job of jobs) {
281
244
  const { track, trackIndex, segment, ahead, sizeBits, encodeWork, sourceIntervals } = job;
282
245
  if (prefillOnly && (ahead > prefillReach || segment.endSeconds - position > capacity)) {
@@ -322,7 +285,14 @@ export function predictPlaybackReadiness(input = {}) {
322
285
  if (!job.produced) {
323
286
  trackFinish[trackIndex] = encodedAt;
324
287
  }
325
- const producedAt = Math.max(sourceReadyAtLatest, encodedAt);
288
+ const producedAt = job.produced ? 0 : Math.max(sourceReadyAtLatest, encodedAt);
289
+ productions.push({
290
+ at: producedAt,
291
+ sourceReadyAt: sourceReadyAtLatest,
292
+ trackIndex,
293
+ segment,
294
+ requiresSource: sourceIntervals.size > 0
295
+ });
326
296
  const roomAt = segment.endSeconds - position <= capacity
327
297
  ? 0
328
298
  : startDelaySeconds + segment.endSeconds - position - capacity;
@@ -339,12 +309,17 @@ export function predictPlaybackReadiness(input = {}) {
339
309
  ? completions.filter(({ segment }) => segment.startSeconds - position <= prefillReach &&
340
310
  segment.endSeconds - position <= capacity)
341
311
  : completions;
342
- const safety = isSafeSchedule(usefulCompletions, trackState, position, duration,
312
+ const usefulProductions = prefillOnly
313
+ ? productions.filter(({ segment }) => segment.startSeconds - position <= prefillReach &&
314
+ segment.endSeconds - position <= capacity)
315
+ : productions;
316
+ const safety = isSafeSchedule(usefulCompletions, usefulProductions, trackState, position, duration,
343
317
  buffered, capacity, reserve, startDelaySeconds);
344
318
  return { safe: safety.safe,
345
319
  bufferedAtStart: safety.bufferedAtStart,
346
- finishAt: usefulCompletions.reduce((latest, item) => Math.max(latest, item.at), 0),
347
- completions: usefulCompletions };
320
+ neededSeconds: safety.neededSeconds,
321
+ completionTimes: usefulCompletions.map(({ at }) => at),
322
+ finishAt: usefulCompletions.reduce((latest, item) => Math.max(latest, item.at), 0) };
348
323
  };
349
324
 
350
325
  const prefillReach = Math.min(capacity, lookahead, remaining);
@@ -353,10 +328,11 @@ export function predictPlaybackReadiness(input = {}) {
353
328
  if (!Number.isFinite(maximumUsefulDelay)) {
354
329
  return result(false, null, buffered, reserve, null, "prefill-cannot-complete", preparedSegments);
355
330
  }
356
- if (schedule(0).safe) {
331
+ const immediate = schedule(0);
332
+ if (immediate.safe) {
357
333
  return result(true, 0, buffered, reserve, 0, "trajectory-safe-now", preparedSegments);
358
334
  }
359
- const searchLimit = Math.max(maximumUsefulDelay, rateRecoveryDelay);
335
+ const searchLimit = maximumUsefulDelay;
360
336
  if (!(searchLimit > 0)) {
361
337
  return result(false, null, buffered, reserve, null, "no-safe-start-found", preparedSegments);
362
338
  }
@@ -366,7 +342,7 @@ export function predictPlaybackReadiness(input = {}) {
366
342
  const breakpoints = [...new Set([
367
343
  maximumUsefulDelay,
368
344
  searchLimit,
369
- ...rateRecoveryDelays.filter(Number.isFinite)
345
+ ...prefillSchedule.completionTimes
370
346
  ])]
371
347
  .filter((delay) => delay > 0 && delay <= searchLimit)
372
348
  .sort((left, right) => left - right);
@@ -396,23 +372,15 @@ export function predictPlaybackReadiness(input = {}) {
396
372
  }
397
373
  }
398
374
  const readiness = schedule(high);
399
- return result(false, high, buffered, reserve, Math.max(0, reserve - buffered),
375
+ return result(false, high, buffered, reserve, immediate.neededSeconds,
400
376
  "minimum-safe-delay", preparedSegments, readiness.bufferedAtStart);
401
377
  }
402
378
 
403
379
  function rateCurve(readings, now) {
404
380
  if (readings && Number.isFinite(readings.count) && readings.count > 0 &&
405
- Number.isFinite(readings.originAt) && Number.isFinite(readings.lastAt) &&
406
- Number.isFinite(readings.sumTime) && Number.isFinite(readings.sumTimeSquared) &&
407
- Number.isFinite(readings.sumValue) && Number.isFinite(readings.sumTimeValue)) {
408
- const { count, originAt, sumTime, sumTimeSquared, sumValue, sumTimeValue } = readings;
409
- const meanAt = sumTime / count;
410
- const meanValue = sumValue / count;
411
- const denominator = sumTimeSquared - count * meanAt ** 2;
412
- const numerator = sumTimeValue - count * meanAt * meanValue;
413
- const slope = denominator > 0 ? numerator / denominator : 0;
414
- const current = Math.max(0, meanValue + slope * ((now - originAt) / 1000 - meanAt));
415
- 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);
416
384
  }
417
385
  const ordered = (Array.isArray(readings) ? readings : [])
418
386
  .filter((reading) => Number.isFinite(reading?.at) && Number.isFinite(reading?.value) && reading.value >= 0)
@@ -423,71 +391,70 @@ function rateCurve(readings, now) {
423
391
  return null;
424
392
  }
425
393
 
426
- const points = ordered.map((reading) => ({
427
- at: (reading.at - now) / 1000,
428
- value: reading.value
429
- }));
430
- const meanAt = points.reduce((sum, point) => sum + point.at, 0) / points.length;
431
- const meanValue = points.reduce((sum, point) => sum + point.value, 0) / points.length;
432
- const numerator = points.reduce((sum, point) =>
433
- sum + (point.at - meanAt) * (point.value - meanValue), 0);
434
- const denominator = points.reduce((sum, point) =>
435
- sum + (point.at - meanAt) ** 2, 0);
436
- const slope = denominator > 0 ? numerator / denominator : 0;
437
- const current = Math.max(0, meanValue - slope * meanAt);
438
- return rateCurveFrom(current, slope);
394
+ return rateCurveFrom(summarizeRate(ordered), now);
439
395
  }
440
396
 
441
- function rateCurveFrom(current, slope) {
442
- const zeroAt = slope < 0 ? -current / slope : Number.POSITIVE_INFINITY;
443
-
444
- const workBy = (seconds) => {
445
- const span = Math.min(Math.max(0, seconds), zeroAt);
446
- return Math.max(0, current * span + (slope * span ** 2) / 2);
447
- };
448
- const timeFor = (work) => {
449
- if (!(work > 0)) {
450
- return 0;
451
- }
452
- if (!(current > 0) && !(slope > 0)) {
453
- return Number.POSITIVE_INFINITY;
454
- }
455
- const discriminant = current ** 2 + 2 * slope * work;
456
- if (discriminant < 0) {
457
- return Number.POSITIVE_INFINITY;
458
- }
459
- if (slope < 0 && work > workBy(zeroAt)) {
460
- return Number.POSITIVE_INFINITY;
461
- }
462
- if (slope === 0) {
463
- return current > 0 ? work / current : Number.POSITIVE_INFINITY;
464
- }
465
- const root = Math.sqrt(discriminant);
466
- const denominator = current + root;
467
- 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
468
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);
469
417
  return {
470
- timeAtLeast: (target) => {
471
- if (!Number.isFinite(target) || target < 0) {
472
- return Number.POSITIVE_INFINITY;
473
- }
474
- if (current >= target) {
475
- return 0;
476
- }
477
- return slope > 0 ? (target - current) / slope : Number.POSITIVE_INFINITY;
478
- },
479
- 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),
480
419
  workBy,
481
420
  finish: (work, startAt) => {
482
- if (!(work > 0)) {
483
- return Math.max(0, startAt);
484
- }
485
421
  const start = Math.max(0, startAt);
486
- return start + (timeFor(work + workBy(start)) - start);
422
+ return start + invertRateIntegral(Math.max(0, work), baseline,
423
+ excess * Math.exp(-start / span), span);
487
424
  }
488
425
  };
489
426
  }
490
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
+
491
458
  function averageBitsPerMediaSecond(track, segments, ready) {
492
459
  let bits = 0;
493
460
  let mediaSeconds = 0;
@@ -508,53 +475,104 @@ function averageBitsPerMediaSecond(track, segments, ready) {
508
475
  return finiteNonNegative(track.bitsPerMediaSecond);
509
476
  }
510
477
 
511
- function isSafeSchedule(completions, trackState, position, duration, buffered, capacity, reserve, startDelay) {
512
- const ranges = trackState.map(() => [{ start: position, end: position + buffered }]);
513
- const ordered = [...completions]
478
+ function isSafeSchedule(completions, productions, trackState, position, duration, buffered, capacity, reserve,
479
+ startDelay) {
480
+ // Client continuity is constrained by its buffer limit; source-stall reserve
481
+ // is covered by the larger union of client data and output already prepared here.
482
+ const clientRanges = trackState.map(() => [{ start: position, end: position + buffered }]);
483
+ const preparedRanges = trackState.map(() => [{ start: position, end: position + buffered }]);
484
+ const orderedCompletions = [...completions]
485
+ .filter(({ at }) => Number.isFinite(at))
486
+ .sort((left, right) => left.at - right.at || left.trackIndex - right.trackIndex);
487
+ const orderedProductions = [...productions]
514
488
  .filter(({ at }) => Number.isFinite(at))
515
489
  .sort((left, right) => left.at - right.at || left.trackIndex - right.trackIndex);
490
+ const reserveUntil = trackState.map(() => Number.NEGATIVE_INFINITY);
491
+ // The measured reserve applies only until this track's last required source
492
+ // interval has arrived; encoding and client delivery stay in the schedule.
493
+ for (const production of productions) {
494
+ if (production.requiresSource) {
495
+ reserveUntil[production.trackIndex] = Math.max(reserveUntil[production.trackIndex],
496
+ production.sourceReadyAt);
497
+ }
498
+ }
516
499
  let completionIndex = 0;
500
+ let productionIndex = 0;
517
501
 
518
502
  const addThrough = (time) => {
519
- while (completionIndex < ordered.length && ordered[completionIndex].at <= time) {
520
- const { trackIndex, segment } = ordered[completionIndex];
521
- addRange(ranges[trackIndex], segment.startSeconds, segment.endSeconds);
503
+ while (productionIndex < orderedProductions.length && orderedProductions[productionIndex].at <= time) {
504
+ const { trackIndex, segment } = orderedProductions[productionIndex];
505
+ addRange(preparedRanges[trackIndex], segment.startSeconds, segment.endSeconds);
506
+ productionIndex += 1;
507
+ }
508
+ while (completionIndex < orderedCompletions.length && orderedCompletions[completionIndex].at <= time) {
509
+ const { trackIndex, segment } = orderedCompletions[completionIndex];
510
+ addRange(clientRanges[trackIndex], segment.startSeconds, segment.endSeconds);
511
+ addRange(preparedRanges[trackIndex], segment.startSeconds, segment.endSeconds);
522
512
  completionIndex += 1;
523
513
  }
524
514
  };
525
- const safeAt = (time) => {
515
+ const safeAt = (time, checkClient = true) => {
526
516
  const playerPosition = position + Math.max(0, time - startDelay);
527
- const available = Math.min(capacity, ...ranges.map((trackRanges) =>
517
+ if (playerPosition >= duration) {
518
+ return { safe: true, stockSafe: true, available: 0, neededSeconds: 0 };
519
+ }
520
+ const available = Math.min(capacity, ...clientRanges.map((trackRanges) =>
528
521
  Math.max(0, contiguousEnd(trackRanges, playerPosition) - playerPosition)));
529
- const required = Math.min(reserve, Math.max(0, duration - playerPosition));
530
- return { safe: available >= required, available };
522
+ let neededSeconds = 0;
523
+ let stockSafe = true;
524
+ for (let trackIndex = 0; trackIndex < trackState.length; trackIndex += 1) {
525
+ const required = time <= reserveUntil[trackIndex]
526
+ ? Math.min(reserve, Math.max(0, duration - playerPosition))
527
+ : 0;
528
+ const preparedAhead = Math.max(0,
529
+ contiguousEnd(preparedRanges[trackIndex], playerPosition) - playerPosition);
530
+ const deficit = Math.max(0, required - preparedAhead);
531
+ neededSeconds = Math.max(neededSeconds, deficit);
532
+ stockSafe &&= deficit === 0;
533
+ }
534
+ return {
535
+ safe: (!checkClient || available > 0) && stockSafe,
536
+ stockSafe,
537
+ available,
538
+ neededSeconds
539
+ };
531
540
  };
532
541
 
533
542
  addThrough(startDelay);
534
543
  const atStart = safeAt(startDelay);
535
544
  if (!atStart.safe) {
536
- return { safe: false, bufferedAtStart: atStart.available };
537
- }
538
-
539
- while (completionIndex < ordered.length) {
540
- const time = ordered[completionIndex].at;
541
- if (time > startDelay) {
542
- const beforeArrival = safeAt(time);
543
- if (!beforeArrival.safe) {
544
- return { safe: false, bufferedAtStart: atStart.available };
545
- }
545
+ return { safe: false, bufferedAtStart: atStart.available, neededSeconds: atStart.neededSeconds };
546
+ }
547
+
548
+ const playbackEnd = startDelay + Math.max(0, duration - position);
549
+ const playbackBoundaries = [position + buffered,
550
+ ...trackState.flatMap(({ segments }) => segments.map(({ endSeconds }) => endSeconds))]
551
+ .filter((boundary) => boundary > position && boundary < duration)
552
+ .map((boundary) => startDelay + boundary - position);
553
+ const sourceReadyTimes = productions
554
+ .filter(({ requiresSource }) => requiresSource)
555
+ .map(({ sourceReadyAt }) => sourceReadyAt);
556
+ const eventTimes = [...orderedCompletions.map(({ at }) => at),
557
+ ...orderedProductions.map(({ at }) => at), ...playbackBoundaries, ...sourceReadyTimes]
558
+ .filter((time) => Number.isFinite(time) && time > startDelay && time < playbackEnd)
559
+ .sort((left, right) => left - right)
560
+ .filter((time, index, all) => index === 0 || time !== all[index - 1]);
561
+
562
+ for (const time of eventTimes) {
563
+ const beforeArrival = safeAt(time, false);
564
+ if (!beforeArrival.stockSafe) {
565
+ return { safe: false, bufferedAtStart: atStart.available, neededSeconds: beforeArrival.neededSeconds };
546
566
  }
547
567
  addThrough(time);
548
- if (time > startDelay) {
549
- const afterArrival = safeAt(time);
550
- if (!afterArrival.safe) {
551
- return { safe: false, bufferedAtStart: atStart.available };
552
- }
568
+ const afterArrival = safeAt(time);
569
+ if (!afterArrival.safe) {
570
+ return { safe: false, bufferedAtStart: atStart.available, neededSeconds: afterArrival.neededSeconds };
553
571
  }
554
572
  }
555
573
 
556
- const atEnd = safeAt(startDelay + Math.max(0, duration - position));
557
- return { safe: atEnd.safe, bufferedAtStart: atStart.available };
574
+ const atEnd = safeAt(playbackEnd);
575
+ return { safe: atEnd.safe, bufferedAtStart: atStart.available, neededSeconds: atStart.neededSeconds };
558
576
  }
559
577
 
560
578
  function addRange(ranges, start, end) {
@@ -36,7 +36,50 @@ test("finds the first safe start from segments delivered before playback", () =>
36
36
 
37
37
  assert.equal(forecast.ready, false);
38
38
  assert.equal(forecast.reason, "minimum-safe-delay");
39
- assert.ok(Math.abs(forecast.delaySeconds - 3) < 1e-8);
39
+ assert.ok(Math.abs(forecast.delaySeconds - 1) < 1e-8);
40
+ });
41
+
42
+ test("keeps source-stall reserve in the proxy's prepared timeline, not the capped browser buffer", () => {
43
+ const now = 10_000;
44
+ const forecast = predictPlaybackReadiness({
45
+ now,
46
+ positionSeconds: 0,
47
+ durationSeconds: 12,
48
+ bufferedAheadSeconds: 2,
49
+ bufferLimitSeconds: 4,
50
+ reserveSeconds: 6,
51
+ lookaheadSeconds: 12,
52
+ sources: [{
53
+ id: "source",
54
+ complete: false,
55
+ bytesPerMediaSecond: 1,
56
+ readings: [{ at: now - 1_000, value: 2 }, { at: now, value: 2 }]
57
+ }],
58
+ tracks: [{
59
+ id: "video",
60
+ sourceIds: ["source"],
61
+ processedSeconds: 8,
62
+ bitsPerMediaSecond: 80,
63
+ readings: [{ at: now - 1_000, value: 2 }, { at: now, value: 2 }],
64
+ segments: [
65
+ { index: 0, startSeconds: 0, endSeconds: 2 },
66
+ { index: 1, startSeconds: 2, endSeconds: 4 },
67
+ { index: 2, startSeconds: 4, endSeconds: 6 },
68
+ { index: 3, startSeconds: 6, endSeconds: 8 },
69
+ { index: 4, startSeconds: 8, endSeconds: 10 },
70
+ { index: 5, startSeconds: 10, endSeconds: 12 }
71
+ ],
72
+ readySegmentIndices: [0, 1, 2, 3],
73
+ segmentSizesBytes: new Map([[0, 20], [1, 20], [2, 20], [3, 20], [4, 20], [5, 20]])
74
+ }],
75
+ linkReadings: [{ at: now - 1_000, value: 80_000 }, { at: now, value: 80_000 }]
76
+ });
77
+
78
+ assert.equal(forecast.ready, true);
79
+ assert.equal(forecast.reason, "trajectory-safe-now");
80
+ assert.equal(forecast.reserveSeconds, 6);
81
+ assert.equal(forecast.bufferedSeconds, 2);
82
+ assert.equal(forecast.preparedSegments, 4);
40
83
  });
41
84
 
42
85
  test("overlaps source reads and encoding, then waits for the slower stage and delivery", () => {
@@ -53,7 +96,7 @@ test("overlaps source reads and encoding, then waits for the slower stage and de
53
96
  const forecast = predictPlaybackReadiness(input({ sources: [source], tracks: [track] }));
54
97
 
55
98
  assert.equal(forecast.ready, false);
56
- assert.ok(Math.abs(forecast.delaySeconds - 3) < 1e-8);
99
+ assert.ok(Math.abs(forecast.delaySeconds - 2) < 1e-8);
57
100
  });
58
101
 
59
102
  test("does not produce an ETA without a client-link measurement", () => {
@@ -64,12 +107,14 @@ test("does not produce an ETA without a client-link measurement", () => {
64
107
  assert.equal(forecast.reason, "link-rate-unavailable");
65
108
  });
66
109
 
67
- test("rate trend projects the measured linear trend without a fitted weight", () => {
110
+ test("rate forecast does not extrapolate acceleration beyond observed service", () => {
68
111
  const trend = new RateTrend();
69
112
  trend.add(9_000, 2);
70
113
  trend.add(10_000, 4);
71
114
 
72
- 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);
73
118
  });
74
119
 
75
120
  test("uses each track's own segment boundaries and requires continuous coverage on both tracks", () => {
@@ -177,7 +222,7 @@ test("downloads an overlapping source interval once when two tracks use the same
177
222
 
178
223
  assert.equal(forecast.ready, false);
179
224
  assert.equal(forecast.reason, "minimum-safe-delay");
180
- assert.ok(Math.abs(forecast.delaySeconds - 2.048) < 1e-8);
225
+ assert.ok(Math.abs(forecast.delaySeconds - 2.032) < 1e-8);
181
226
  });
182
227
 
183
228
  test("downloads sources on separate torrents concurrently", () => {
@@ -288,7 +333,7 @@ test("shares one torrent download rate across distinct video and audio files", (
288
333
  assert.ok(Math.abs(forecast.delaySeconds - 4.032) < 1e-8);
289
334
  });
290
335
 
291
- 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", () => {
292
337
  const now = 1_000;
293
338
  const forecast = predictPlaybackReadiness({
294
339
  now,
@@ -317,7 +362,81 @@ test("finds a safe interval when rising encode speed and falling link speed make
317
362
  });
318
363
 
319
364
  assert.equal(forecast.ready, false);
320
- assert.equal(forecast.reason, "minimum-safe-delay");
321
- assert.ok(Number.isFinite(forecast.delaySeconds));
322
- assert.ok(forecast.delaySeconds > 7 && forecast.delaySeconds < 10);
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);
323
442
  });