@torrent-tv/proxy 2.107.37 → 2.107.39
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
|
@@ -229,7 +229,18 @@ export class ViewerRequests {
|
|
|
229
229
|
sourceObserved: true, waitElapsedMs });
|
|
230
230
|
const featuresChanged = this.#recordEarlyWaitSnapshot(context, context.features);
|
|
231
231
|
const elapsedMs = waitElapsedSince(context, waitElapsedMs);
|
|
232
|
-
|
|
232
|
+
// The wait ends when the start forecast lets the picture start, and the
|
|
233
|
+
// first required pieces being there is only part of that: a source
|
|
234
|
+
// supplied slower than the film plays is held until the cushion carries
|
|
235
|
+
// it to the end. Counted to the first pieces alone, the estimate reached
|
|
236
|
+
// zero with seven minutes still to wait, and the page had no time to show
|
|
237
|
+
// (Home Assistant 2026-10-26, Despicable Me 3 BDRemux at 0.53x,
|
|
238
|
+
// torrent-tv/meta#239).
|
|
239
|
+
const firstRequiredSeconds = processing.firstRequiredSegmentsSeconds;
|
|
240
|
+
const startGateSeconds = processing.startGateSeconds;
|
|
241
|
+
const measuredWorkSeconds = Number.isFinite(startGateSeconds) && startGateSeconds >= 0
|
|
242
|
+
? Math.max(startGateSeconds, Number.isFinite(firstRequiredSeconds) ? firstRequiredSeconds : 0)
|
|
243
|
+
: firstRequiredSeconds;
|
|
233
244
|
const hasActiveEstimate = Number.isFinite(measuredWorkSeconds) && measuredWorkSeconds >= 0;
|
|
234
245
|
const stagesChanged = Array.isArray(processing.firstRequiredStages) &&
|
|
235
246
|
JSON.stringify(processing.firstRequiredStages) !== JSON.stringify(context.activeFirstRequiredStages);
|
|
@@ -258,7 +269,8 @@ export class ViewerRequests {
|
|
|
258
269
|
const seconds = earlyWaitRemainingSeconds(totalSeconds, elapsedMs);
|
|
259
270
|
if (!Number.isFinite(seconds) || seconds < 0) return null;
|
|
260
271
|
this.#logEarlyForecast(context, { elapsedMs, seconds, totalSeconds, historical,
|
|
261
|
-
firstRequiredSegmentsSeconds: hasActiveEstimate ?
|
|
272
|
+
firstRequiredSegmentsSeconds: hasActiveEstimate ? firstRequiredSeconds ?? null : context.activeFirstRequiredSegmentsSeconds,
|
|
273
|
+
startGateSeconds: hasActiveEstimate && Number.isFinite(startGateSeconds) ? startGateSeconds : null,
|
|
262
274
|
firstRequiredStages: context.activeFirstRequiredStages });
|
|
263
275
|
return { version: 1, waitId, revision: context.revision, remainingSeconds: seconds, ageMs: 0 };
|
|
264
276
|
}
|
|
@@ -269,11 +281,14 @@ export class ViewerRequests {
|
|
|
269
281
|
}
|
|
270
282
|
|
|
271
283
|
#logEarlyForecast(context, { elapsedMs, seconds, totalSeconds, historical, firstRequiredSegmentsSeconds,
|
|
272
|
-
firstRequiredStages }) {
|
|
284
|
+
startGateSeconds = null, firstRequiredStages }) {
|
|
273
285
|
if (context.loggedForecastRevision === context.revision) return;
|
|
274
|
-
const
|
|
275
|
-
|
|
276
|
-
|
|
286
|
+
const gated = Number.isFinite(startGateSeconds) &&
|
|
287
|
+
!(Number.isFinite(firstRequiredSegmentsSeconds) && firstRequiredSegmentsSeconds >= startGateSeconds);
|
|
288
|
+
const basis = gated ? "start-gate" : Number.isFinite(firstRequiredSegmentsSeconds) ? "first-required" : "historical-stages";
|
|
289
|
+
const estimate = basis !== "historical-stages"
|
|
290
|
+
? { firstRequiredSegmentsSeconds, ...(Number.isFinite(startGateSeconds) ? { startGateSeconds } : {}),
|
|
291
|
+
...(Array.isArray(firstRequiredStages) ? { firstRequiredStages } : {}) }
|
|
277
292
|
: { stagesMs: historical?.stagesMs ?? null, observations: historical?.observations ?? null };
|
|
278
293
|
logger.info(`early wait forecast ${JSON.stringify({ waitId: context.waitId, consumer: context.consumerId,
|
|
279
294
|
generation: context.generation, features: context.features, elapsedMs, revision: context.revision,
|
|
@@ -990,6 +1005,7 @@ export class ViewerRequests {
|
|
|
990
1005
|
? stats.fileLength / session.file.durationSeconds : null,
|
|
991
1006
|
pieceLength: stats?.pieceLength,
|
|
992
1007
|
downloadRateReadings: this.#sourceRateReadings(measurement, output.file.sourceKey, stats, now),
|
|
1008
|
+
tailRateReadings: this.#sourceTailRateReadings(measurement, output.file.sourceKey, now),
|
|
993
1009
|
downloadRateSummaries: this.#sourceRateSummaries(measurement, output.file.sourceKey),
|
|
994
1010
|
downloadForecast,
|
|
995
1011
|
});
|
|
@@ -1066,6 +1082,9 @@ export class ViewerRequests {
|
|
|
1066
1082
|
bufferLimitSeconds: viewerReading?.bufferLimitSeconds,
|
|
1067
1083
|
reserveSeconds,
|
|
1068
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,
|
|
1069
1088
|
requiredAudio: requiresSeparateAudio,
|
|
1070
1089
|
sources: [...sourceMeasurements.values()],
|
|
1071
1090
|
tracks,
|
|
@@ -1121,6 +1140,10 @@ export class ViewerRequests {
|
|
|
1121
1140
|
videoSpeedX: videoOperation?.speed,
|
|
1122
1141
|
audioSpeedX: audioOperation?.speed,
|
|
1123
1142
|
firstRequiredSegmentsSeconds: forecast.firstRequiredSegmentsSeconds,
|
|
1143
|
+
// When the start forecast lets the picture start: its delay while
|
|
1144
|
+
// it holds the start, and nothing to add once it does not.
|
|
1145
|
+
...(forecast.ready !== true && Number.isFinite(forecast.delaySeconds)
|
|
1146
|
+
? { startGateSeconds: forecast.delaySeconds } : {}),
|
|
1124
1147
|
firstRequiredStages: forecast.firstRequiredStages
|
|
1125
1148
|
} });
|
|
1126
1149
|
if (waitEstimate) forecast.waitEstimate = waitEstimate;
|
|
@@ -1361,16 +1384,21 @@ export class ViewerRequests {
|
|
|
1361
1384
|
#sourceRateReadings(state, id, stats, now) {
|
|
1362
1385
|
let source = state.sources.get(id);
|
|
1363
1386
|
if (!source) {
|
|
1364
|
-
const { RateTrend } = this.#host.playbackReadiness;
|
|
1365
|
-
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 };
|
|
1366
1391
|
state.sources.set(id, source);
|
|
1367
1392
|
}
|
|
1368
1393
|
if (Number.isFinite(stats?.downloadSpeed) && stats.downloadSpeed >= 0) {
|
|
1369
1394
|
source.delivered.add(now, stats.downloadSpeed);
|
|
1395
|
+
source.deliveredAll.add(now, stats.downloadSpeed);
|
|
1370
1396
|
}
|
|
1371
1397
|
const limit = stats?.selectionLimit;
|
|
1372
1398
|
if (limit && source.limit && limit.freeMs > source.limit.freeMs) {
|
|
1373
|
-
|
|
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);
|
|
1374
1402
|
}
|
|
1375
1403
|
// Totals that went back belong to a torrent added again: read on from them.
|
|
1376
1404
|
if (limit) source.limit = limit;
|
|
@@ -1378,6 +1406,14 @@ export class ViewerRequests {
|
|
|
1378
1406
|
(summary) => this.#host.playbackReadiness.forecastRate(summary, now, 0, "time"));
|
|
1379
1407
|
}
|
|
1380
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
|
+
|
|
1381
1417
|
/** Both summaries a source's service was chosen from, for the forecast's log line. */
|
|
1382
1418
|
#sourceRateSummaries(state, id) {
|
|
1383
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,22 @@ 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
|
+
const stop = scheduleTail({ trackState, sourceState, sourceByteCoverage, runStarts, trackFinish,
|
|
691
|
+
link, position, buffered, reserve, transferFinish, constrain, duration, stock: tailStock });
|
|
692
|
+
if (stop) {
|
|
693
|
+
return { ...result(false, null, buffered, reserve, null, stop.reason, preparedSegments),
|
|
694
|
+
...(stop.unavailableSource ? { unavailableSource: stop.unavailableSource } : {}) };
|
|
695
|
+
}
|
|
696
|
+
}
|
|
620
697
|
const firstRequiredStages = trackState.slice(0, requiredTracks).map((state, trackIndex) => {
|
|
621
698
|
const first = state.segments.find(segment => segment.endSeconds > position);
|
|
622
699
|
if (!first) return { track: state.track.id ?? trackIndex, segment: null, alreadyBuffered: true,
|
|
@@ -646,6 +723,23 @@ export function predictPlaybackReadiness(input = {}) {
|
|
|
646
723
|
const firstRequiredSegmentsSeconds = firstRequiredTrackSeconds.every(Number.isFinite)
|
|
647
724
|
? Math.max(0, ...firstRequiredTrackSeconds) : null;
|
|
648
725
|
const delay = Math.max(0, ...constraints);
|
|
726
|
+
// THE STOCK THE START ASKS FOR HAS TO FIT. Starting without a stop while the
|
|
727
|
+
// source comes slower than the film plays means holding, at the start, what
|
|
728
|
+
// is already here and everything that arrives before it. Where that is more
|
|
729
|
+
// than this proxy may keep of downloaded pieces, the oldest of it would be
|
|
730
|
+
// given up before the viewer reached it, and no wait makes the start safe:
|
|
731
|
+
// Despicable Me 3 BDRemux at 1.4 MB/s asked for about 36 GB against a 29.5 GB
|
|
732
|
+
// share (Home Assistant 2026-10-26, torrent-tv/meta#239). Said as such, and
|
|
733
|
+
// the viewer keeps waiting rather than starting into stops.
|
|
734
|
+
const storageBytes = Number(input.sourceStorageBytes);
|
|
735
|
+
if (Number.isFinite(delay) && Number.isFinite(storageBytes) && storageBytes >= 0 && tailStock.length > 0) {
|
|
736
|
+
const cushionBytes = tailStock.reduce((sum, part) => sum + Math.max(0, part.totalBytes - part.missingBytes) +
|
|
737
|
+
Math.max(0, part.missingBytes - part.bytesPerSecond * Math.max(0, part.finish - delay)), 0);
|
|
738
|
+
if (cushionBytes > storageBytes) {
|
|
739
|
+
return { ...result(false, null, buffered, reserve, null, "cushion-exceeds-storage", preparedSegments),
|
|
740
|
+
cushionBytes, storageBytes };
|
|
741
|
+
}
|
|
742
|
+
}
|
|
649
743
|
const originReady = trackState.every(({ clientRanges }) => compare(contiguousEnd(clientRanges, at), at) > 0);
|
|
650
744
|
const ready = delay === 0 && originReady;
|
|
651
745
|
const forecast = result(ready, Number.isFinite(delay) ? delay : null, buffered, reserve,
|
|
@@ -681,6 +775,102 @@ function isPiece(segment) {
|
|
|
681
775
|
Number.isFinite(segment?.endSeconds) && segment.endSeconds > segment.startSeconds;
|
|
682
776
|
}
|
|
683
777
|
|
|
778
|
+
/**
|
|
779
|
+
* A duration-level estimate of the suffix after the exact near window. Source
|
|
780
|
+
* work uses each file's aggregate byte density and measured download service;
|
|
781
|
+
* processing uses each track's measured rate; delivery uses output bitrates and
|
|
782
|
+
* measured link service. Held source bytes and made segments reduce the
|
|
783
|
+
* estimate. This conditional forecast does not assert exact source ranges or
|
|
784
|
+
* individual frame timing. Its work is bounded by sources and tracks, not by
|
|
785
|
+
* the film's segment count.
|
|
786
|
+
*
|
|
787
|
+
* @returns {{ reason: string, unavailableSource?: object } | null} Why there is no answer, or null.
|
|
788
|
+
*/
|
|
789
|
+
function scheduleTail({ trackState, sourceState, sourceByteCoverage, runStarts, trackFinish, link,
|
|
790
|
+
position, buffered, reserve, transferFinish, constrain, duration, stock = null }) {
|
|
791
|
+
const tailStart = Math.max(position, ...trackState.map(state => state.tailStartSeconds));
|
|
792
|
+
if (!Number.isFinite(tailStart)) return null;
|
|
793
|
+
const sourceDurations = new Map();
|
|
794
|
+
for (const state of trackState) {
|
|
795
|
+
const seconds = Math.max(0, state.tailEndSeconds - tailStart) * state.tailWorkFraction;
|
|
796
|
+
if (!(seconds > 0)) continue;
|
|
797
|
+
for (const sourceId of new Set(state.track.sourceIds ?? [])) {
|
|
798
|
+
sourceDurations.set(sourceId, Math.max(sourceDurations.get(sourceId) ?? 0, seconds));
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
let sourceArrivalAt = 0;
|
|
803
|
+
for (const [sourceId, seconds] of sourceDurations) {
|
|
804
|
+
const source = sourceState.get(sourceId);
|
|
805
|
+
if (!source) return { reason: "source-measurement-unavailable" };
|
|
806
|
+
if (source.source.complete === true) continue;
|
|
807
|
+
// Held means on this proxy now, past the bytes the near window already
|
|
808
|
+
// scheduled: those are counted above, and the window's planned arrivals
|
|
809
|
+
// counted again as held took 117 s of film off a 90-minute tail.
|
|
810
|
+
const windowEnd = Math.max(0, ...(sourceByteCoverage.get(sourceId) ?? []).map(range => range.end));
|
|
811
|
+
const bytes = estimateUnmappedSourceBytes(source.source, seconds, duration,
|
|
812
|
+
heldBeyond(source.source.residence, windowEnd), windowEnd);
|
|
813
|
+
if (bytes === null) return { reason: "source-measurement-unavailable",
|
|
814
|
+
unavailableSource: { sourceId, phase: "tail" } };
|
|
815
|
+
if (!(bytes > 0)) continue;
|
|
816
|
+
const { service } = source;
|
|
817
|
+
const rate = service.tailRate ?? service.rate;
|
|
818
|
+
if (!rate || !(rate.rateAt(0) > 0)) {
|
|
819
|
+
return { reason: rate ? "service-not-advancing" : "download-rate-unavailable",
|
|
820
|
+
unavailableSource: { sourceId, estimatedBytes: bytes, phase: "tail" } };
|
|
821
|
+
}
|
|
822
|
+
service.finish = rate.finish(bytes, service.finish);
|
|
823
|
+
sourceArrivalAt = Math.max(sourceArrivalAt, service.finish);
|
|
824
|
+
// What this stretch weighs and what it still lacks: the stock the start
|
|
825
|
+
// asks to hold is read from these once the start's delay is known.
|
|
826
|
+
stock?.push({ totalBytes: estimateUnmappedSourceBytes(source.source, seconds, duration, []) ?? bytes,
|
|
827
|
+
missingBytes: bytes, bytesPerSecond: rate.rateAt(0), finish: service.finish });
|
|
828
|
+
const state = trackState.find(candidate => candidate.track.sourceIds?.includes(sourceId));
|
|
829
|
+
const index = state?.segments.at(-1)?.index ?? 0;
|
|
830
|
+
const ahead = Math.max(0, (state?.tailEndSeconds ?? tailStart) - position);
|
|
831
|
+
constrain(service.finish - Math.max(0, ahead - reserve), "source", trackState.indexOf(state), index,
|
|
832
|
+
service.finish, ahead, true, service.finish, false);
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
let outputBits = 0;
|
|
836
|
+
let latestTrackFinish = 0;
|
|
837
|
+
for (let trackIndex = 0; trackIndex < trackState.length; trackIndex += 1) {
|
|
838
|
+
const state = trackState[trackIndex];
|
|
839
|
+
const end = state.tailEndSeconds;
|
|
840
|
+
const seconds = Math.max(0, end - tailStart);
|
|
841
|
+
if (!(seconds > 0)) continue;
|
|
842
|
+
const track = state.track;
|
|
843
|
+
const segment = state.segments.at(-1) ?? { index: 0 };
|
|
844
|
+
const remaining = seconds * state.tailWorkFraction;
|
|
845
|
+
const work = Math.max(0, remaining - numericCoverage((track.processingRanges ?? [])
|
|
846
|
+
.filter(range => Number.isFinite(range?.start) && Number.isFinite(range?.end))
|
|
847
|
+
.map(range => ({ start: Math.max(tailStart, range.start), end: Math.min(end, range.end) }))
|
|
848
|
+
.filter(range => range.end > range.start)).reduce((sum, range) => sum + range.end - range.start, 0));
|
|
849
|
+
const processing = state.curve;
|
|
850
|
+
if (work > 0 && !processing) return { reason: "encode-rate-unavailable" };
|
|
851
|
+
const run = runStarts[trackIndex];
|
|
852
|
+
const localStart = tailStart - state.clockSeconds;
|
|
853
|
+
const inRun = run.spans.some(span => localStart >= span.start && localStart < span.end);
|
|
854
|
+
const open = work > 0 && !inRun ? run.openSeconds : 0;
|
|
855
|
+
const finishedAt = work > 0 ? processing.finish(work, trackFinish[trackIndex] + open) : trackFinish[trackIndex];
|
|
856
|
+
latestTrackFinish = Math.max(latestTrackFinish, finishedAt);
|
|
857
|
+
const deliveryStart = Math.max(tailStart, position + buffered);
|
|
858
|
+
outputBits += Math.max(0, state.bitsPerMediaSecond) * Math.max(0, end - deliveryStart);
|
|
859
|
+
const ahead = Math.max(0, end - position);
|
|
860
|
+
constrain(finishedAt - Math.max(0, ahead - reserve), "production", trackIndex, segment.index,
|
|
861
|
+
finishedAt, ahead, sourceArrivalAt > 0, sourceArrivalAt, false);
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
if (outputBits > 0) {
|
|
865
|
+
const end = Math.max(...trackState.map(state => state.tailEndSeconds));
|
|
866
|
+
const completedAt = link.finish(outputBits, Math.max(transferFinish, latestTrackFinish));
|
|
867
|
+
const ahead = Math.max(0, end - position);
|
|
868
|
+
constrain(completedAt - Math.max(0, ahead - reserve), "delivery", 0,
|
|
869
|
+
trackState[0]?.segments.at(-1)?.index ?? 0, completedAt, ahead, false, sourceArrivalAt, false);
|
|
870
|
+
}
|
|
871
|
+
return null;
|
|
872
|
+
}
|
|
873
|
+
|
|
684
874
|
/**
|
|
685
875
|
* Seconds from `from` to `to`, or zero when `to` is not later.
|
|
686
876
|
*
|
|
@@ -986,9 +1176,9 @@ function numericCoverage(ranges) {
|
|
|
986
1176
|
/**
|
|
987
1177
|
* Estimate download work without claiming an address or held byte. The whole
|
|
988
1178
|
* file's measured density, reduced by the held fraction, prices unresolved
|
|
989
|
-
* intervals
|
|
1179
|
+
* intervals and the aggregate suffix.
|
|
990
1180
|
*/
|
|
991
|
-
function estimateUnmappedSourceBytes(source, mediaSeconds, durationSeconds, coverage = null) {
|
|
1181
|
+
function estimateUnmappedSourceBytes(source, mediaSeconds, durationSeconds, coverage = null, from = 0) {
|
|
992
1182
|
const fileLength = Number(source.fileLength);
|
|
993
1183
|
const density = Number(source.bytesPerMediaSecond) > 0 ? Number(source.bytesPerMediaSecond)
|
|
994
1184
|
: fileLength > 0 && durationSeconds > 0 ? fileLength / durationSeconds : 0;
|
|
@@ -999,7 +1189,15 @@ function estimateUnmappedSourceBytes(source, mediaSeconds, durationSeconds, cove
|
|
|
999
1189
|
range?.location !== "missing" && Number.isFinite(range?.start) && Number.isFinite(range?.end) &&
|
|
1000
1190
|
range.end > range.start));
|
|
1001
1191
|
const heldBytes = held.reduce((sum, range) => sum + range.end - range.start, 0);
|
|
1002
|
-
|
|
1192
|
+
const span = Math.max(0, fileLength - Math.max(0, from));
|
|
1193
|
+
return span > 0 ? density * mediaSeconds * Math.max(0, Math.min(1, (span - heldBytes) / span)) : 0;
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
/** The held ranges past `from`, cut at it. */
|
|
1197
|
+
function heldBeyond(ranges, from) {
|
|
1198
|
+
return (Array.isArray(ranges) ? ranges : [])
|
|
1199
|
+
.filter(range => range?.location !== "missing" && Number.isFinite(range?.start) && range.end > from)
|
|
1200
|
+
.map(range => ({ start: Math.max(from, range.start), end: range.end }));
|
|
1003
1201
|
}
|
|
1004
1202
|
|
|
1005
1203
|
/** Already held bytes have no arrival delay. Index future arrivals once. */
|