@torrent-tv/proxy 2.107.38 → 2.107.40
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
|
@@ -1005,6 +1005,7 @@ export class ViewerRequests {
|
|
|
1005
1005
|
? stats.fileLength / session.file.durationSeconds : null,
|
|
1006
1006
|
pieceLength: stats?.pieceLength,
|
|
1007
1007
|
downloadRateReadings: this.#sourceRateReadings(measurement, output.file.sourceKey, stats, now),
|
|
1008
|
+
tailRateReadings: this.#sourceTailRateReadings(measurement, output.file.sourceKey, now),
|
|
1008
1009
|
downloadRateSummaries: this.#sourceRateSummaries(measurement, output.file.sourceKey),
|
|
1009
1010
|
downloadForecast,
|
|
1010
1011
|
});
|
|
@@ -1081,6 +1082,9 @@ export class ViewerRequests {
|
|
|
1081
1082
|
bufferLimitSeconds: viewerReading?.bufferLimitSeconds,
|
|
1082
1083
|
reserveSeconds,
|
|
1083
1084
|
lookaheadSeconds: this.#host.lookaheadSeconds,
|
|
1085
|
+
// What this proxy may keep of downloaded pieces on disk: the most a
|
|
1086
|
+
// start can hold of the film ahead of the viewer.
|
|
1087
|
+
sourceStorageBytes: this.#host.sourceStorageBytes?.() ?? null,
|
|
1084
1088
|
requiredAudio: requiresSeparateAudio,
|
|
1085
1089
|
sources: [...sourceMeasurements.values()],
|
|
1086
1090
|
tracks,
|
|
@@ -1380,16 +1384,21 @@ export class ViewerRequests {
|
|
|
1380
1384
|
#sourceRateReadings(state, id, stats, now) {
|
|
1381
1385
|
let source = state.sources.get(id);
|
|
1382
1386
|
if (!source) {
|
|
1383
|
-
const { RateTrend } = this.#host.playbackReadiness;
|
|
1384
|
-
source = { delivered: new RateTrend(this.#host.lookaheadSeconds), free: new RateTrend(this.#host.lookaheadSeconds),
|
|
1387
|
+
const { RateTrend, SessionRate } = this.#host.playbackReadiness;
|
|
1388
|
+
source = { delivered: new RateTrend(this.#host.lookaheadSeconds), free: new RateTrend(this.#host.lookaheadSeconds),
|
|
1389
|
+
// The same readings over the whole session, which the rest of the film is priced by.
|
|
1390
|
+
deliveredAll: new SessionRate(), freeAll: new SessionRate(), limit: null };
|
|
1385
1391
|
state.sources.set(id, source);
|
|
1386
1392
|
}
|
|
1387
1393
|
if (Number.isFinite(stats?.downloadSpeed) && stats.downloadSpeed >= 0) {
|
|
1388
1394
|
source.delivered.add(now, stats.downloadSpeed);
|
|
1395
|
+
source.deliveredAll.add(now, stats.downloadSpeed);
|
|
1389
1396
|
}
|
|
1390
1397
|
const limit = stats?.selectionLimit;
|
|
1391
1398
|
if (limit && source.limit && limit.freeMs > source.limit.freeMs) {
|
|
1392
|
-
|
|
1399
|
+
const free = Math.max(0, limit.freeBytes - source.limit.freeBytes) * 1000 / (limit.freeMs - source.limit.freeMs);
|
|
1400
|
+
source.free.add(now, free);
|
|
1401
|
+
source.freeAll.add(now, free);
|
|
1393
1402
|
}
|
|
1394
1403
|
// Totals that went back belong to a torrent added again: read on from them.
|
|
1395
1404
|
if (limit) source.limit = limit;
|
|
@@ -1397,6 +1406,14 @@ export class ViewerRequests {
|
|
|
1397
1406
|
(summary) => this.#host.playbackReadiness.forecastRate(summary, now, 0, "time"));
|
|
1398
1407
|
}
|
|
1399
1408
|
|
|
1409
|
+
/** The source's service over the whole session, chosen by the same rule as the recent one. */
|
|
1410
|
+
#sourceTailRateReadings(state, id, now) {
|
|
1411
|
+
const source = state.sources.get(id);
|
|
1412
|
+
if (!source) return null;
|
|
1413
|
+
return sourceService(source.freeAll.snapshot(), source.deliveredAll.snapshot(),
|
|
1414
|
+
(summary) => this.#host.playbackReadiness.forecastRate(summary, now, 0, "time"));
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1400
1417
|
/** Both summaries a source's service was chosen from, for the forecast's log line. */
|
|
1401
1418
|
#sourceRateSummaries(state, id) {
|
|
1402
1419
|
const source = state.sources.get(id);
|
|
@@ -72,7 +72,7 @@ import { ViewerRequests } from "./ViewerRequests.js";
|
|
|
72
72
|
import { OutputLifecycle } from "./OutputLifecycle.js";
|
|
73
73
|
import { SegmentServing } from "./SegmentServing.js";
|
|
74
74
|
import { audioStartSecondsFor } from "../viewer/audio-start.js";
|
|
75
|
-
import { endsWithinPlayerSeekTolerance, forecastRate, rateSummaryOf, predictPlaybackReadiness, RateTrend } from "../viewer/playback-readiness.js";
|
|
75
|
+
import { endsWithinPlayerSeekTolerance, forecastRate, rateSummaryOf, predictPlaybackReadiness, RateTrend, SessionRate } from "../viewer/playback-readiness.js";
|
|
76
76
|
import { audioRenditionName } from "../media/audio-inventory.js";
|
|
77
77
|
import { Renditions } from "../encode/Renditions.js";
|
|
78
78
|
import { CushionReport, LOOKAHEAD_PAUSE_SECONDS } from "../encode/CushionReport.js";
|
|
@@ -628,6 +628,8 @@ export function wireOutputs({
|
|
|
628
628
|
activeOutputFor: (args) => activeOutputFor({ ...args, viewers: parts.viewers }),
|
|
629
629
|
viewerSecondsOn: (output, consumerId, now) => viewerSecondsOn(parts.viewers, output, consumerId, now),
|
|
630
630
|
minimumBufferSecondsFor: (output) => minimumBufferSecondsOf(output),
|
|
631
|
+
// What this proxy may keep of downloaded pieces on disk.
|
|
632
|
+
sourceStorageBytes: () => parts.machineBudget?.allowedFor?.("spilled pieces") ?? null,
|
|
631
633
|
getSourceStats: (...args) => parts.getSourceStats?.(...args) ?? null,
|
|
632
634
|
disposeSession: (...args) => parts.lifecycle.disposeSession(...args),
|
|
633
635
|
planEncodersSoon: (...args) => parts.encodeRuns.planEncodersSoon(...args),
|
|
@@ -635,7 +637,7 @@ export function wireOutputs({
|
|
|
635
637
|
get encodeRuns() { return parts.encodeRuns; },
|
|
636
638
|
// When this viewer's playback can start and run to the end: the viewer
|
|
637
639
|
// component's forecast, which models the viewer's browser.
|
|
638
|
-
playbackReadiness: { predict: predictPlaybackReadiness, forecastRate, rateSummaryOf, RateTrend,
|
|
640
|
+
playbackReadiness: { predict: predictPlaybackReadiness, forecastRate, rateSummaryOf, RateTrend, SessionRate,
|
|
639
641
|
endsWithinPlayerSeekTolerance },
|
|
640
642
|
// Whether the subtitle a viewer chose has been read where they stand — the
|
|
641
643
|
// source preparation's answer, set on these parts by the assembly.
|
|
@@ -152,6 +152,20 @@ export class MachineBudget {
|
|
|
152
152
|
this.#claimants = this.#claimants.filter((one) => one.name !== name);
|
|
153
153
|
}
|
|
154
154
|
|
|
155
|
+
/**
|
|
156
|
+
* What one claimant was last allowed, or null before a measured division.
|
|
157
|
+
*
|
|
158
|
+
* @param {string} name
|
|
159
|
+
* @returns {number | null}
|
|
160
|
+
*/
|
|
161
|
+
allowedFor(name) {
|
|
162
|
+
for (const reading of this.#last.values()) {
|
|
163
|
+
const share = reading.measured ? reading.shares.find((one) => one.name === name) : null;
|
|
164
|
+
if (share) return share.allowed;
|
|
165
|
+
}
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
|
|
155
169
|
/** Total measured policy allowance, independent of its current division. */
|
|
156
170
|
capacityOf(name) {
|
|
157
171
|
const reading = this.#last.get(name);
|
|
@@ -87,6 +87,57 @@ export function rateSummaryOf(readings) {
|
|
|
87
87
|
return ordered.length > 0 ? summarizeRate(ordered) : null;
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
+
/**
|
|
91
|
+
* Every measurement of one session, summarised as it arrives: the same
|
|
92
|
+
* summary {@link RateTrend} gives over its horizon, kept in O(1).
|
|
93
|
+
*
|
|
94
|
+
* The rest of the film is priced at this mean, and the near window at the
|
|
95
|
+
* recent one. Compared on field download readings (24 runs of 10-22 minutes,
|
|
96
|
+
* 0.4-13 MB/s, torrent-tv/meta#239,
|
|
97
|
+
* `research/coarse-tail-forecast-2026-10-26.md`), the mean since the start
|
|
98
|
+
* had the smallest worst-case error for the next 3-10 minutes (p90 34-44 %
|
|
99
|
+
* against 39-57 % for the 120 s mean) at a slightly larger typical error, and
|
|
100
|
+
* a slope through recent readings was the worst predictor at every horizon of
|
|
101
|
+
* 3 minutes or more.
|
|
102
|
+
*/
|
|
103
|
+
export class SessionRate {
|
|
104
|
+
#count = 0;
|
|
105
|
+
#first = null;
|
|
106
|
+
#last = null;
|
|
107
|
+
#sum = 0;
|
|
108
|
+
#inverseSum = 0;
|
|
109
|
+
#zero = false;
|
|
110
|
+
#weighted = 0;
|
|
111
|
+
|
|
112
|
+
add(at, value) {
|
|
113
|
+
if (!Number.isFinite(at) || !Number.isFinite(value) || value < 0 || at <= (this.#last?.at ?? Number.NEGATIVE_INFINITY)) {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
if (this.#last) this.#weighted += this.#last.value * (at - this.#last.at);
|
|
117
|
+
this.#first ??= { at, value };
|
|
118
|
+
this.#last = { at, value };
|
|
119
|
+
this.#count += 1;
|
|
120
|
+
this.#sum += value;
|
|
121
|
+
if (value > 0) this.#inverseSum += 1 / value;
|
|
122
|
+
else this.#zero = true;
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
snapshot() {
|
|
127
|
+
if (!this.#last) return null;
|
|
128
|
+
const spanMs = this.#last.at - this.#first.at;
|
|
129
|
+
return {
|
|
130
|
+
count: this.#count,
|
|
131
|
+
lastAt: this.#last.at,
|
|
132
|
+
lastValue: this.#last.value,
|
|
133
|
+
meanValue: this.#sum / this.#count,
|
|
134
|
+
harmonicValue: this.#zero ? 0 : this.#count / this.#inverseSum,
|
|
135
|
+
timeWeightedValue: spanMs > 0 ? this.#weighted / spanMs : this.#last.value,
|
|
136
|
+
spanSeconds: spanMs / 1000
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
90
141
|
/**
|
|
91
142
|
* Measurements over the output's existing look-ahead horizon. The oldest
|
|
92
143
|
* boundary sample is retained until another sample replaces it, so a repeated
|
|
@@ -133,6 +184,7 @@ export class RateTrend {
|
|
|
133
184
|
* @param {boolean} [input.requiredAudio]
|
|
134
185
|
* @param {Array<{ id: string, serviceKey?: string, complete: boolean, fileOffset?: number,
|
|
135
186
|
* fileLength?: number, bytesPerMediaSecond?: number, pieceLength?: number, downloadRateReadings?: object,
|
|
187
|
+
* tailRateReadings?: object,
|
|
136
188
|
* downloadForecast?: { ranges: Array<{ start: number, end: number, availableAt: number | null }> } }>} input.sources
|
|
137
189
|
* @param {Array<{ id: string, sourceIds: string[], processedSeconds: number, durationSeconds?: number,
|
|
138
190
|
* segmentCount?: number,
|
|
@@ -158,17 +210,16 @@ export function predictPlaybackReadiness(input = {}) {
|
|
|
158
210
|
// starts), the read piece nearest before them (what an unread piece is placed
|
|
159
211
|
// by) and read pieces inside what the page reports holding (their frames).
|
|
160
212
|
//
|
|
161
|
-
// THE NEAR WINDOW. The look-ahead is scheduled with exact
|
|
162
|
-
// source ranges when known. Missing range metadata is priced
|
|
163
|
-
// file density and measured download service without claiming
|
|
164
|
-
//
|
|
165
|
-
//
|
|
166
|
-
// the whole remaining film. Each later poll advances this same window.
|
|
213
|
+
// THE NEAR WINDOW AND THE SUFFIX. The look-ahead is scheduled with exact
|
|
214
|
+
// segment times and source ranges when known. Missing range metadata is priced
|
|
215
|
+
// from aggregate file density and measured download service without claiming
|
|
216
|
+
// an address or byte. Beyond the look-ahead, {@link scheduleTail} estimates
|
|
217
|
+
// remaining source, encoder and delivery work without visiting every segment.
|
|
167
218
|
const lookahead = finiteNonNegative(input.lookaheadSeconds);
|
|
168
219
|
const horizonEnd = lookahead > 0 ? finiteNonNegative(input.positionSeconds) + lookahead : Number.POSITIVE_INFINITY;
|
|
169
220
|
const tracks = (Array.isArray(input.tracks) ? input.tracks : []).map((track) =>
|
|
170
|
-
Array.isArray(track.segments)
|
|
171
|
-
|
|
221
|
+
Array.isArray(track.segments) ? { ...track, segmentCount: track.segmentCount ?? track.segments.length,
|
|
222
|
+
segments: piecesThatMatter(track, finiteNonNegative(input.positionSeconds), horizonEnd) } : track);
|
|
172
223
|
const duration = finiteNonNegative(input.durationSeconds);
|
|
173
224
|
// Each track's served ticks are placed on the clock the page reports by the
|
|
174
225
|
// offset its player applied (`timestampOffset`).
|
|
@@ -239,11 +290,25 @@ export function predictPlaybackReadiness(input = {}) {
|
|
|
239
290
|
.filter((segment) => isPiece(segment) &&
|
|
240
291
|
segment.endSeconds >= position - PLAYER_SEEK_TOLERANCE_SECONDS && segment.startSeconds < horizonEnd)
|
|
241
292
|
.sort((left, right) => left.startSeconds - right.startSeconds || left.index - right.index),
|
|
293
|
+
// The pieces after the near window, as the playlist cuts them, and the
|
|
294
|
+
// offset the page's player placed them by.
|
|
295
|
+
tail: [],
|
|
296
|
+
tailStartSeconds: horizonEnd,
|
|
242
297
|
durationSeconds: Number.isFinite(track.durationSeconds) ? track.durationSeconds : duration,
|
|
298
|
+
segmentCount: Number.isSafeInteger(track.segmentCount) ? track.segmentCount
|
|
299
|
+
: Math.max(0, ...track.segments.map(segment => segment.index + 1)),
|
|
243
300
|
clockSeconds: Number.isFinite(track.clockOffsetSeconds) ? track.clockOffsetSeconds : 0
|
|
244
301
|
}));
|
|
245
302
|
for (const state of trackState) {
|
|
246
|
-
state.
|
|
303
|
+
state.tailEndSeconds = Number.isFinite(horizonEnd) && horizonEnd < duration
|
|
304
|
+
? Math.min(duration, state.durationSeconds + state.clockSeconds) : null;
|
|
305
|
+
const endIndex = state.segments.reduce((last, segment) => segment.startSeconds < horizonEnd
|
|
306
|
+
? Math.max(last, segment.index) : last, -1);
|
|
307
|
+
state.tailSegmentCount = Math.max(0, state.segmentCount - endIndex - 1);
|
|
308
|
+
const readyTail = [...state.ready].reduce((count, index) => count + Number(index > endIndex), 0);
|
|
309
|
+
state.tailWorkFraction = state.tailSegmentCount > 0
|
|
310
|
+
? Math.max(0, 1 - Math.min(state.tailSegmentCount, readyTail) / state.tailSegmentCount)
|
|
311
|
+
: 0;
|
|
247
312
|
}
|
|
248
313
|
// A file can declare a duration longer than its last packet. The HLS VOD
|
|
249
314
|
// playlist still has a final numbered piece, but its made fragment ends at
|
|
@@ -277,7 +342,9 @@ export function predictPlaybackReadiness(input = {}) {
|
|
|
277
342
|
// Bytes over time across the readings the trend keeps: the last 5 s of a
|
|
278
343
|
// swarm moved between 0.97 and 5.8 MB/s and was carried to the end of the
|
|
279
344
|
// film each time (torrent-tv/meta#178).
|
|
280
|
-
service = { rate: rateCurve(source.downloadRateReadings, now, "time"),
|
|
345
|
+
service = { rate: rateCurve(source.downloadRateReadings, now, "time"),
|
|
346
|
+
// The rest of the film at the mean since the session began (`SessionRate`).
|
|
347
|
+
tailRate: rateCurve(source.tailRateReadings ?? source.downloadRateReadings, now, "time"), finish: 0 };
|
|
281
348
|
sourceServices.set(serviceKey, service);
|
|
282
349
|
}
|
|
283
350
|
sourceState.set(id, { source, service });
|
|
@@ -611,12 +678,30 @@ export function predictPlaybackReadiness(input = {}) {
|
|
|
611
678
|
// Supply interruption coverage is stock held by the proxy as well as
|
|
612
679
|
// the browser. It is not a browser buffer-size admission rule.
|
|
613
680
|
// The reserve is bounded by what is left of the track, not of the window.
|
|
614
|
-
const left =
|
|
681
|
+
const left = state.tailEndSeconds === null ? secondsAhead(trackEnd, before)
|
|
682
|
+
: Math.max(0, state.tailEndSeconds - toSeconds(before));
|
|
615
683
|
const ahead = Math.max(0, secondsAhead(before, at) - Math.min(reserve, left) * Number(production.requiresSource));
|
|
616
684
|
constrain(production.at - ahead, "production", trackIndex, production.segment.index, production.at, ahead,
|
|
617
685
|
production.requiresSource, production.sourceArrivalAt, production.sourcePlanned);
|
|
618
686
|
}
|
|
619
687
|
}
|
|
688
|
+
const tailStock = [];
|
|
689
|
+
if (trackState.some(({ tailEndSeconds }) => Number.isFinite(tailEndSeconds))) {
|
|
690
|
+
// Where each source's bytes stop being the near window's: the last byte
|
|
691
|
+
// its pieces read, in the file's own offsets.
|
|
692
|
+
const windowEnds = new Map();
|
|
693
|
+
for (const { sourceByteIntervals } of jobs) {
|
|
694
|
+
for (const [sourceId, ranges] of sourceByteIntervals) {
|
|
695
|
+
for (const range of ranges) windowEnds.set(sourceId, Math.max(windowEnds.get(sourceId) ?? 0, range.end));
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
const stop = scheduleTail({ trackState, sourceState, runStarts, trackFinish,
|
|
699
|
+
link, position, buffered, reserve, transferFinish, constrain, duration, stock: tailStock, windowEnds });
|
|
700
|
+
if (stop) {
|
|
701
|
+
return { ...result(false, null, buffered, reserve, null, stop.reason, preparedSegments),
|
|
702
|
+
...(stop.unavailableSource ? { unavailableSource: stop.unavailableSource } : {}) };
|
|
703
|
+
}
|
|
704
|
+
}
|
|
620
705
|
const firstRequiredStages = trackState.slice(0, requiredTracks).map((state, trackIndex) => {
|
|
621
706
|
const first = state.segments.find(segment => segment.endSeconds > position);
|
|
622
707
|
if (!first) return { track: state.track.id ?? trackIndex, segment: null, alreadyBuffered: true,
|
|
@@ -646,6 +731,23 @@ export function predictPlaybackReadiness(input = {}) {
|
|
|
646
731
|
const firstRequiredSegmentsSeconds = firstRequiredTrackSeconds.every(Number.isFinite)
|
|
647
732
|
? Math.max(0, ...firstRequiredTrackSeconds) : null;
|
|
648
733
|
const delay = Math.max(0, ...constraints);
|
|
734
|
+
// THE STOCK THE START ASKS FOR HAS TO FIT. Starting without a stop while the
|
|
735
|
+
// source comes slower than the film plays means holding, at the start, what
|
|
736
|
+
// is already here and everything that arrives before it. Where that is more
|
|
737
|
+
// than this proxy may keep of downloaded pieces, the oldest of it would be
|
|
738
|
+
// given up before the viewer reached it, and no wait makes the start safe:
|
|
739
|
+
// Despicable Me 3 BDRemux at 1.4 MB/s asked for about 36 GB against a 29.5 GB
|
|
740
|
+
// share (Home Assistant 2026-10-26, torrent-tv/meta#239). Said as such, and
|
|
741
|
+
// the viewer keeps waiting rather than starting into stops.
|
|
742
|
+
const storageBytes = Number(input.sourceStorageBytes);
|
|
743
|
+
if (Number.isFinite(delay) && Number.isFinite(storageBytes) && storageBytes >= 0 && tailStock.length > 0) {
|
|
744
|
+
const cushionBytes = tailStock.reduce((sum, part) => sum + Math.max(0, part.totalBytes - part.missingBytes) +
|
|
745
|
+
Math.max(0, part.missingBytes - part.bytesPerSecond * Math.max(0, part.finish - delay)), 0);
|
|
746
|
+
if (cushionBytes > storageBytes) {
|
|
747
|
+
return { ...result(false, null, buffered, reserve, null, "cushion-exceeds-storage", preparedSegments),
|
|
748
|
+
cushionBytes, storageBytes };
|
|
749
|
+
}
|
|
750
|
+
}
|
|
649
751
|
const originReady = trackState.every(({ clientRanges }) => compare(contiguousEnd(clientRanges, at), at) > 0);
|
|
650
752
|
const ready = delay === 0 && originReady;
|
|
651
753
|
const forecast = result(ready, Number.isFinite(delay) ? delay : null, buffered, reserve,
|
|
@@ -681,6 +783,103 @@ function isPiece(segment) {
|
|
|
681
783
|
Number.isFinite(segment?.endSeconds) && segment.endSeconds > segment.startSeconds;
|
|
682
784
|
}
|
|
683
785
|
|
|
786
|
+
/**
|
|
787
|
+
* A duration-level estimate of the suffix after the exact near window. Source
|
|
788
|
+
* work uses each file's aggregate byte density and measured download service;
|
|
789
|
+
* processing uses each track's measured rate; delivery uses output bitrates and
|
|
790
|
+
* measured link service. Held source bytes and made segments reduce the
|
|
791
|
+
* estimate. This conditional forecast does not assert exact source ranges or
|
|
792
|
+
* individual frame timing. Its work is bounded by sources and tracks, not by
|
|
793
|
+
* the film's segment count.
|
|
794
|
+
*
|
|
795
|
+
* @returns {{ reason: string, unavailableSource?: object } | null} Why there is no answer, or null.
|
|
796
|
+
*/
|
|
797
|
+
function scheduleTail({ trackState, sourceState, runStarts, trackFinish, link,
|
|
798
|
+
position, buffered, reserve, transferFinish, constrain, duration, stock = null, windowEnds = null }) {
|
|
799
|
+
const tailStart = Math.max(position, ...trackState.map(state => state.tailStartSeconds));
|
|
800
|
+
if (!Number.isFinite(tailStart)) return null;
|
|
801
|
+
const sourceDurations = new Map();
|
|
802
|
+
for (const state of trackState) {
|
|
803
|
+
const seconds = Math.max(0, state.tailEndSeconds - tailStart) * state.tailWorkFraction;
|
|
804
|
+
if (!(seconds > 0)) continue;
|
|
805
|
+
for (const sourceId of new Set(state.track.sourceIds ?? [])) {
|
|
806
|
+
sourceDurations.set(sourceId, Math.max(sourceDurations.get(sourceId) ?? 0, seconds));
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
let sourceArrivalAt = 0;
|
|
811
|
+
for (const [sourceId, seconds] of sourceDurations) {
|
|
812
|
+
const source = sourceState.get(sourceId);
|
|
813
|
+
if (!source) return { reason: "source-measurement-unavailable" };
|
|
814
|
+
if (source.source.complete === true) continue;
|
|
815
|
+
// Held means on this proxy now, past the last byte the near window's
|
|
816
|
+
// pieces read: those are counted above, and the window's planned arrivals
|
|
817
|
+
// counted again as held took 117 s of film off a 90-minute tail. The
|
|
818
|
+
// torrent thread's plan is not that end: it can reach the end of the file.
|
|
819
|
+
const windowEnd = windowEnds?.get(sourceId) ?? 0;
|
|
820
|
+
const bytes = estimateUnmappedSourceBytes(source.source, seconds, duration,
|
|
821
|
+
heldBeyond(source.source.residence, windowEnd), windowEnd);
|
|
822
|
+
if (bytes === null) return { reason: "source-measurement-unavailable",
|
|
823
|
+
unavailableSource: { sourceId, phase: "tail" } };
|
|
824
|
+
if (!(bytes > 0)) continue;
|
|
825
|
+
const { service } = source;
|
|
826
|
+
const rate = service.tailRate ?? service.rate;
|
|
827
|
+
if (!rate || !(rate.rateAt(0) > 0)) {
|
|
828
|
+
return { reason: rate ? "service-not-advancing" : "download-rate-unavailable",
|
|
829
|
+
unavailableSource: { sourceId, estimatedBytes: bytes, phase: "tail" } };
|
|
830
|
+
}
|
|
831
|
+
service.finish = rate.finish(bytes, service.finish);
|
|
832
|
+
sourceArrivalAt = Math.max(sourceArrivalAt, service.finish);
|
|
833
|
+
// What this stretch weighs and what it still lacks: the stock the start
|
|
834
|
+
// asks to hold is read from these once the start's delay is known.
|
|
835
|
+
stock?.push({ totalBytes: estimateUnmappedSourceBytes(source.source, seconds, duration, []) ?? bytes,
|
|
836
|
+
missingBytes: bytes, bytesPerSecond: rate.rateAt(0), finish: service.finish });
|
|
837
|
+
const state = trackState.find(candidate => candidate.track.sourceIds?.includes(sourceId));
|
|
838
|
+
const index = state?.segments.at(-1)?.index ?? 0;
|
|
839
|
+
const ahead = Math.max(0, (state?.tailEndSeconds ?? tailStart) - position);
|
|
840
|
+
constrain(service.finish - Math.max(0, ahead - reserve), "source", trackState.indexOf(state), index,
|
|
841
|
+
service.finish, ahead, true, service.finish, false);
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
let outputBits = 0;
|
|
845
|
+
let latestTrackFinish = 0;
|
|
846
|
+
for (let trackIndex = 0; trackIndex < trackState.length; trackIndex += 1) {
|
|
847
|
+
const state = trackState[trackIndex];
|
|
848
|
+
const end = state.tailEndSeconds;
|
|
849
|
+
const seconds = Math.max(0, end - tailStart);
|
|
850
|
+
if (!(seconds > 0)) continue;
|
|
851
|
+
const track = state.track;
|
|
852
|
+
const segment = state.segments.at(-1) ?? { index: 0 };
|
|
853
|
+
const remaining = seconds * state.tailWorkFraction;
|
|
854
|
+
const work = Math.max(0, remaining - numericCoverage((track.processingRanges ?? [])
|
|
855
|
+
.filter(range => Number.isFinite(range?.start) && Number.isFinite(range?.end))
|
|
856
|
+
.map(range => ({ start: Math.max(tailStart, range.start), end: Math.min(end, range.end) }))
|
|
857
|
+
.filter(range => range.end > range.start)).reduce((sum, range) => sum + range.end - range.start, 0));
|
|
858
|
+
const processing = state.curve;
|
|
859
|
+
if (work > 0 && !processing) return { reason: "encode-rate-unavailable" };
|
|
860
|
+
const run = runStarts[trackIndex];
|
|
861
|
+
const localStart = tailStart - state.clockSeconds;
|
|
862
|
+
const inRun = run.spans.some(span => localStart >= span.start && localStart < span.end);
|
|
863
|
+
const open = work > 0 && !inRun ? run.openSeconds : 0;
|
|
864
|
+
const finishedAt = work > 0 ? processing.finish(work, trackFinish[trackIndex] + open) : trackFinish[trackIndex];
|
|
865
|
+
latestTrackFinish = Math.max(latestTrackFinish, finishedAt);
|
|
866
|
+
const deliveryStart = Math.max(tailStart, position + buffered);
|
|
867
|
+
outputBits += Math.max(0, state.bitsPerMediaSecond) * Math.max(0, end - deliveryStart);
|
|
868
|
+
const ahead = Math.max(0, end - position);
|
|
869
|
+
constrain(finishedAt - Math.max(0, ahead - reserve), "production", trackIndex, segment.index,
|
|
870
|
+
finishedAt, ahead, sourceArrivalAt > 0, sourceArrivalAt, false);
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
if (outputBits > 0) {
|
|
874
|
+
const end = Math.max(...trackState.map(state => state.tailEndSeconds));
|
|
875
|
+
const completedAt = link.finish(outputBits, Math.max(transferFinish, latestTrackFinish));
|
|
876
|
+
const ahead = Math.max(0, end - position);
|
|
877
|
+
constrain(completedAt - Math.max(0, ahead - reserve), "delivery", 0,
|
|
878
|
+
trackState[0]?.segments.at(-1)?.index ?? 0, completedAt, ahead, false, sourceArrivalAt, false);
|
|
879
|
+
}
|
|
880
|
+
return null;
|
|
881
|
+
}
|
|
882
|
+
|
|
684
883
|
/**
|
|
685
884
|
* Seconds from `from` to `to`, or zero when `to` is not later.
|
|
686
885
|
*
|
|
@@ -986,9 +1185,9 @@ function numericCoverage(ranges) {
|
|
|
986
1185
|
/**
|
|
987
1186
|
* Estimate download work without claiming an address or held byte. The whole
|
|
988
1187
|
* file's measured density, reduced by the held fraction, prices unresolved
|
|
989
|
-
* intervals
|
|
1188
|
+
* intervals and the aggregate suffix.
|
|
990
1189
|
*/
|
|
991
|
-
function estimateUnmappedSourceBytes(source, mediaSeconds, durationSeconds, coverage = null) {
|
|
1190
|
+
function estimateUnmappedSourceBytes(source, mediaSeconds, durationSeconds, coverage = null, from = 0) {
|
|
992
1191
|
const fileLength = Number(source.fileLength);
|
|
993
1192
|
const density = Number(source.bytesPerMediaSecond) > 0 ? Number(source.bytesPerMediaSecond)
|
|
994
1193
|
: fileLength > 0 && durationSeconds > 0 ? fileLength / durationSeconds : 0;
|
|
@@ -999,7 +1198,15 @@ function estimateUnmappedSourceBytes(source, mediaSeconds, durationSeconds, cove
|
|
|
999
1198
|
range?.location !== "missing" && Number.isFinite(range?.start) && Number.isFinite(range?.end) &&
|
|
1000
1199
|
range.end > range.start));
|
|
1001
1200
|
const heldBytes = held.reduce((sum, range) => sum + range.end - range.start, 0);
|
|
1002
|
-
|
|
1201
|
+
const span = Math.max(0, fileLength - Math.max(0, from));
|
|
1202
|
+
return span > 0 ? density * mediaSeconds * Math.max(0, Math.min(1, (span - heldBytes) / span)) : 0;
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
/** The held ranges past `from`, cut at it. */
|
|
1206
|
+
function heldBeyond(ranges, from) {
|
|
1207
|
+
return (Array.isArray(ranges) ? ranges : [])
|
|
1208
|
+
.filter(range => range?.location !== "missing" && Number.isFinite(range?.start) && range.end > from)
|
|
1209
|
+
.map(range => ({ start: Math.max(from, range.start), end: range.end }));
|
|
1003
1210
|
}
|
|
1004
1211
|
|
|
1005
1212
|
/** Already held bytes have no arrival delay. Index future arrivals once. */
|