@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
|
@@ -663,7 +663,7 @@ export class ViewerRequests {
|
|
|
663
663
|
return Number.isFinite(segmentSeconds) && segmentSeconds > 0 ? segmentSeconds : 0;
|
|
664
664
|
}));
|
|
665
665
|
|
|
666
|
-
|
|
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.
|
|
10
|
-
*
|
|
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
|
|
15
|
-
*
|
|
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
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
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
|
-
#
|
|
39
|
-
#
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
48
|
+
at <= (this.#readings.at(-1)?.at ?? Number.NEGATIVE_INFINITY)) {
|
|
49
49
|
return false;
|
|
50
50
|
}
|
|
51
|
-
this.#
|
|
52
|
-
const
|
|
53
|
-
this.#
|
|
54
|
-
|
|
55
|
-
|
|
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.#
|
|
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
|
-
|
|
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 =
|
|
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
|
-
...
|
|
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.
|
|
416
|
-
Number.isFinite(readings.
|
|
417
|
-
|
|
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
|
-
|
|
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
|
|
452
|
-
const
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
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
|
-
|
|
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 + (
|
|
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
|
|
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
|
-
|
|
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("
|
|
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, "
|
|
364
|
-
assert.
|
|
365
|
-
|
|
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
|
});
|