@torrent-tv/proxy 2.107.19 → 2.107.21
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
|
@@ -589,6 +589,12 @@ export class EncodeRun {
|
|
|
589
589
|
producedSeconds: Math.max(0, this.progress.processedSeconds - this.progress.startPositionSeconds),
|
|
590
590
|
workingMs: this.clock.workingMs(at)
|
|
591
591
|
};
|
|
592
|
+
// A run can be admitted for exactly one segment. Establish its baseline
|
|
593
|
+
// on the first media progress report so closing that segment yields a
|
|
594
|
+
// processing rate without counting the preceding input wait.
|
|
595
|
+
if (this.#pieceSample === null && this.#workSample.producedSeconds > 0) {
|
|
596
|
+
this.#pieceSample = this.#workSample;
|
|
597
|
+
}
|
|
592
598
|
}
|
|
593
599
|
this.onProgress(this.progress.snapshot());
|
|
594
600
|
}
|
|
@@ -739,11 +745,10 @@ export class EncodeRun {
|
|
|
739
745
|
|
|
740
746
|
/**
|
|
741
747
|
* A closed piece is the moment this run's speed is read: what it made since
|
|
742
|
-
* the last reading, over its own working time.
|
|
743
|
-
*
|
|
744
|
-
* for its first input bytes
|
|
745
|
-
*
|
|
746
|
-
* and the next reading spans both.
|
|
748
|
+
* the last reading, over its own working time. The first baseline is the
|
|
749
|
+
* first positive progress report, so a one-piece run can be measured without
|
|
750
|
+
* charging the wait for its first input bytes as processing. A stretch with
|
|
751
|
+
* no progress keeps the earlier sample, and the next reading spans both.
|
|
747
752
|
*/
|
|
748
753
|
#measureSpeed() {
|
|
749
754
|
const sample = this.#workSample;
|
|
@@ -751,7 +756,7 @@ export class EncodeRun {
|
|
|
751
756
|
return;
|
|
752
757
|
}
|
|
753
758
|
if (this.#pieceSample === null) {
|
|
754
|
-
this.#pieceSample = sample;
|
|
759
|
+
if (sample.producedSeconds > 0) this.#pieceSample = sample;
|
|
755
760
|
return;
|
|
756
761
|
}
|
|
757
762
|
const speed = speedFromWork(this.#pieceSample, sample);
|
|
@@ -936,7 +936,7 @@ export class ViewerRequests {
|
|
|
936
936
|
const tracks = [];
|
|
937
937
|
for (const output of outputs) {
|
|
938
938
|
const trackProgress = output === session ? progress : this.#host.encodeRuns.progressOf(output);
|
|
939
|
-
const trackRates = this.#trackRateReadings(output, now
|
|
939
|
+
const trackRates = this.#trackRateReadings(output, now);
|
|
940
940
|
const trackName = output === session ? "video" : "audio";
|
|
941
941
|
// The page states the offset its player applied to this track, and the
|
|
942
942
|
// init it applied it to is the session's. Until both are known, the
|
|
@@ -985,6 +985,9 @@ export class ViewerRequests {
|
|
|
985
985
|
residence: stats?.residence,
|
|
986
986
|
fileOffset: stats?.fileOffset,
|
|
987
987
|
fileLength: stats?.fileLength,
|
|
988
|
+
bytesPerMediaSecond: Number.isFinite(stats?.fileLength) && stats.fileLength > 0 &&
|
|
989
|
+
Number.isFinite(session.file.durationSeconds) && session.file.durationSeconds > 0
|
|
990
|
+
? stats.fileLength / session.file.durationSeconds : null,
|
|
988
991
|
pieceLength: stats?.pieceLength,
|
|
989
992
|
downloadRateReadings: this.#sourceRateReadings(measurement, output.file.sourceKey, stats, now),
|
|
990
993
|
downloadRateSummaries: this.#sourceRateSummaries(measurement, output.file.sourceKey),
|
|
@@ -1022,6 +1025,8 @@ export class ViewerRequests {
|
|
|
1022
1025
|
})),
|
|
1023
1026
|
bitsPerMediaSecond: Number.isFinite(observedMbps) && observedMbps > 0 ? observedMbps * 1_000_000 :
|
|
1024
1027
|
this.#host.initialOutputBitsPerMediaSecondOf?.(output) ?? 0,
|
|
1028
|
+
durationSeconds: output.timeline?.publishedStartOf(output.timeline.segmentCount) ?? session.file.durationSeconds,
|
|
1029
|
+
segmentCount: output.timeline?.segmentCount ?? segments.length,
|
|
1025
1030
|
readings: trackRates,
|
|
1026
1031
|
segments,
|
|
1027
1032
|
geometry: this.#forecastPieces.get(output)?.geometry,
|
|
@@ -1247,7 +1252,27 @@ export class ViewerRequests {
|
|
|
1247
1252
|
}
|
|
1248
1253
|
const kept = forecast.pieces;
|
|
1249
1254
|
const segments = [];
|
|
1250
|
-
|
|
1255
|
+
const lookahead = Number(this.#host.lookaheadSeconds);
|
|
1256
|
+
const start = Number.isFinite(positionSeconds) ? Math.max(0, positionSeconds) : 0;
|
|
1257
|
+
const end = Number.isFinite(lookahead) && lookahead > 0 ? start + lookahead : Number.POSITIVE_INFINITY;
|
|
1258
|
+
let low = 0, high = count;
|
|
1259
|
+
while (low < high) {
|
|
1260
|
+
const middle = (low + high) >>> 1;
|
|
1261
|
+
if (timeline.publishedStartOf(middle + 1) <= start) low = middle + 1;
|
|
1262
|
+
else high = middle;
|
|
1263
|
+
}
|
|
1264
|
+
const first = Math.max(0, low - 1);
|
|
1265
|
+
low = first;
|
|
1266
|
+
high = count;
|
|
1267
|
+
while (low < high) {
|
|
1268
|
+
const middle = (low + high) >>> 1;
|
|
1269
|
+
if (timeline.publishedStartOf(middle) < end) low = middle + 1;
|
|
1270
|
+
else high = middle;
|
|
1271
|
+
}
|
|
1272
|
+
const last = low;
|
|
1273
|
+
const indexes = new Set([0]);
|
|
1274
|
+
for (let index = first; index < last; index++) indexes.add(index);
|
|
1275
|
+
for (const index of indexes) {
|
|
1251
1276
|
const endSeconds = timeline.publishedStartOf(index + 1);
|
|
1252
1277
|
// Only a made piece has media intervals to read. Asked of every piece,
|
|
1253
1278
|
// each one not made cost a `stat` of its file in the store's safety net
|
|
@@ -1286,8 +1311,7 @@ export class ViewerRequests {
|
|
|
1286
1311
|
const id = typeof consumerId === "string" ? consumerId : "";
|
|
1287
1312
|
let state = byViewer.get(id);
|
|
1288
1313
|
if (!state) {
|
|
1289
|
-
state = { link: new this.#host.playbackReadiness.RateTrend(this.#host.lookaheadSeconds), sources: new Map()
|
|
1290
|
-
trackProgress: new WeakMap() };
|
|
1314
|
+
state = { link: new this.#host.playbackReadiness.RateTrend(this.#host.lookaheadSeconds), sources: new Map() };
|
|
1291
1315
|
byViewer.set(id, state);
|
|
1292
1316
|
}
|
|
1293
1317
|
return state;
|
|
@@ -1300,7 +1324,7 @@ export class ViewerRequests {
|
|
|
1300
1324
|
* runs of one output can report out of order, so a summary kept and added to
|
|
1301
1325
|
* would drop the later-told, earlier-taken one (torrent-tv/meta#178).
|
|
1302
1326
|
*/
|
|
1303
|
-
#trackRateReadings(output, now
|
|
1327
|
+
#trackRateReadings(output, now) {
|
|
1304
1328
|
const readings = (this.#host.encodeSpeedReadingsOf?.(output) ?? []).filter(reading => reading.at <= now);
|
|
1305
1329
|
const summary = readings.length > 0 ? this.#host.playbackReadiness.rateSummaryOf(readings) : null;
|
|
1306
1330
|
if (summary) return summary;
|
|
@@ -1315,21 +1339,9 @@ export class ViewerRequests {
|
|
|
1315
1339
|
seed.add(now, projected);
|
|
1316
1340
|
return seed.snapshot();
|
|
1317
1341
|
}
|
|
1318
|
-
//
|
|
1319
|
-
//
|
|
1320
|
-
//
|
|
1321
|
-
// inflate, the speed used by the forecast.
|
|
1322
|
-
if (output?.spec?.carries === "audio-only") {
|
|
1323
|
-
let trend = measurement.trackProgress.get(output);
|
|
1324
|
-
if (!trend) {
|
|
1325
|
-
trend = new this.#host.playbackReadiness.ProgressRate(
|
|
1326
|
-
this.#host.lookaheadSeconds,
|
|
1327
|
-
this.#host.segmentDurationSec
|
|
1328
|
-
);
|
|
1329
|
-
measurement.trackProgress.set(output, trend);
|
|
1330
|
-
}
|
|
1331
|
-
return trend.note(now, progress?.processedSeconds, progress?.startPositionSeconds);
|
|
1332
|
-
}
|
|
1342
|
+
// Progress over wall time includes source waits, which the forecast prices
|
|
1343
|
+
// separately. Keep the rate unknown until a measured or calibrated
|
|
1344
|
+
// processing rate exists; single-piece runs now publish their own rate.
|
|
1333
1345
|
return null;
|
|
1334
1346
|
}
|
|
1335
1347
|
|
|
@@ -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 { forecastRate, rateSummaryOf, predictPlaybackReadiness,
|
|
75
|
+
import { forecastRate, rateSummaryOf, predictPlaybackReadiness, RateTrend } 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";
|
|
@@ -640,7 +640,7 @@ export function wireOutputs({
|
|
|
640
640
|
get encodeRuns() { return parts.encodeRuns; },
|
|
641
641
|
// When this viewer's playback can start and run to the end: the viewer
|
|
642
642
|
// component's forecast, which models the viewer's browser.
|
|
643
|
-
playbackReadiness: { predict: predictPlaybackReadiness, forecastRate, rateSummaryOf, RateTrend
|
|
643
|
+
playbackReadiness: { predict: predictPlaybackReadiness, forecastRate, rateSummaryOf, RateTrend },
|
|
644
644
|
// Whether the subtitle a viewer chose has been read where they stand — the
|
|
645
645
|
// source preparation's answer, set on these parts by the assembly.
|
|
646
646
|
subtitleReadyFor: (viewer) => parts.subtitleReadyFor?.(viewer) ?? true,
|
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Predict the
|
|
3
|
-
* exhausting the continuous media available for the required tracks.
|
|
2
|
+
* Predict whether the viewer can start from the current position without
|
|
3
|
+
* exhausting the continuous media already available for the required tracks.
|
|
4
4
|
*
|
|
5
5
|
* This module contains the playback decision's only model. Its inputs are
|
|
6
6
|
* measurements and physical limits: mapped whole-piece arrivals, encoded
|
|
7
7
|
* media and encoder rate, segment sizes and client-link rate, existing
|
|
8
|
-
* continuous media, and measured supply interruptions.
|
|
9
|
-
*
|
|
10
|
-
*
|
|
8
|
+
* continuous media, and measured supply interruptions. Exact scheduling is
|
|
9
|
+
* limited to the configured near window; the remaining duration is priced by
|
|
10
|
+
* aggregate work per source and track. Each service integral extends its latest
|
|
11
|
+
* measured rate until the next observation. The forecast is conditional on
|
|
12
|
+
* these measured services continuing. Browser storage
|
|
11
13
|
* capacity is not an admission condition. There are no fitted weights or
|
|
12
14
|
* rate multipliers, and no search over candidate start times.
|
|
13
15
|
*
|
|
@@ -108,44 +110,6 @@ export class RateTrend {
|
|
|
108
110
|
}
|
|
109
111
|
}
|
|
110
112
|
|
|
111
|
-
/**
|
|
112
|
-
* Rates the media an output actually produced between progress reports.
|
|
113
|
-
* Unchanged progress keeps its original time, so a source wait is included
|
|
114
|
-
* instead of being mistaken for encoder throughput.
|
|
115
|
-
*/
|
|
116
|
-
export class ProgressRate {
|
|
117
|
-
#trend;
|
|
118
|
-
#horizonSeconds;
|
|
119
|
-
#adjacentRunSeconds;
|
|
120
|
-
#previous = null;
|
|
121
|
-
|
|
122
|
-
constructor(horizonSeconds, adjacentRunSeconds = 4) {
|
|
123
|
-
this.#horizonSeconds = horizonSeconds;
|
|
124
|
-
this.#adjacentRunSeconds = Number.isFinite(adjacentRunSeconds) && adjacentRunSeconds > 0
|
|
125
|
-
? adjacentRunSeconds
|
|
126
|
-
: 4;
|
|
127
|
-
this.#trend = new RateTrend(horizonSeconds);
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
note(at, processedSeconds, startPositionSeconds) {
|
|
131
|
-
if (!Number.isFinite(at) || !Number.isFinite(processedSeconds)) return this.#trend.snapshot();
|
|
132
|
-
const start = Number.isFinite(startPositionSeconds) ? startPositionSeconds : 0;
|
|
133
|
-
const discontinuousStart = this.#previous && start !== this.#previous.start &&
|
|
134
|
-
(start < this.#previous.start || start > this.#previous.processedSeconds + this.#adjacentRunSeconds);
|
|
135
|
-
if (!this.#previous || discontinuousStart || processedSeconds < this.#previous.processedSeconds) {
|
|
136
|
-
this.#previous = { at, processedSeconds, start };
|
|
137
|
-
this.#trend = new RateTrend(this.#horizonSeconds);
|
|
138
|
-
return this.#trend.snapshot();
|
|
139
|
-
}
|
|
140
|
-
const elapsedMs = at - this.#previous.at;
|
|
141
|
-
if (processedSeconds > this.#previous.processedSeconds && elapsedMs > 0) {
|
|
142
|
-
this.#trend.add(at, (processedSeconds - this.#previous.processedSeconds) * 1000 / elapsedMs);
|
|
143
|
-
this.#previous = { at, processedSeconds, start };
|
|
144
|
-
}
|
|
145
|
-
return this.#trend.snapshot();
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
|
|
149
113
|
/**
|
|
150
114
|
* @param {object} input
|
|
151
115
|
* @param {number} input.positionSeconds
|
|
@@ -157,7 +121,7 @@ export class ProgressRate {
|
|
|
157
121
|
* @param {number} input.now
|
|
158
122
|
* @param {boolean} [input.requiredAudio]
|
|
159
123
|
* @param {Array<{ id: string, serviceKey?: string, complete: boolean, fileOffset?: number,
|
|
160
|
-
* fileLength?: number, pieceLength?: number, downloadRateReadings?: object,
|
|
124
|
+
* fileLength?: number, bytesPerMediaSecond?: number, pieceLength?: number, downloadRateReadings?: object,
|
|
161
125
|
* downloadForecast?: { ranges: Array<{ start: number, end: number, availableAt: number | null }> } }>} input.sources
|
|
162
126
|
* @param {Array<{ id: string, sourceIds: string[], processedSeconds: number,
|
|
163
127
|
* bitsPerMediaSecond: number, readings: Array<{ at: number, value: number }>,
|
|
@@ -182,18 +146,15 @@ export function predictPlaybackReadiness(input = {}) {
|
|
|
182
146
|
// starts), the read piece nearest before them (what an unread piece is placed
|
|
183
147
|
// by) and read pieces inside what the page reports holding (their frames).
|
|
184
148
|
//
|
|
185
|
-
// THE NEAR WINDOW AND THE
|
|
186
|
-
//
|
|
187
|
-
//
|
|
188
|
-
//
|
|
189
|
-
//
|
|
190
|
-
// arrival exists for them. Placing every remaining piece of a two-hour film
|
|
191
|
-
// in exact time on every poll cost 70-1128 ms a call on Home Assistant (r73,
|
|
192
|
-
// r74).
|
|
149
|
+
// THE NEAR WINDOW AND THE SUFFIX. The look-ahead is scheduled with exact
|
|
150
|
+
// segment times and source ranges when known. Missing range metadata is priced
|
|
151
|
+
// from aggregate file density and measured download service without claiming
|
|
152
|
+
// an address or byte. Beyond the look-ahead, {@link scheduleTail} estimates
|
|
153
|
+
// remaining source, encoder and delivery work without visiting every segment.
|
|
193
154
|
const lookahead = finiteNonNegative(input.lookaheadSeconds);
|
|
194
155
|
const horizonEnd = lookahead > 0 ? finiteNonNegative(input.positionSeconds) + lookahead : Number.POSITIVE_INFINITY;
|
|
195
156
|
const tracks = (Array.isArray(input.tracks) ? input.tracks : []).map((track) =>
|
|
196
|
-
Array.isArray(track.segments) ? { ...track,
|
|
157
|
+
Array.isArray(track.segments) ? { ...track, segmentCount: track.segmentCount ?? track.segments.length,
|
|
197
158
|
segments: piecesThatMatter(track, finiteNonNegative(input.positionSeconds), horizonEnd) } : track);
|
|
198
159
|
const duration = finiteNonNegative(input.durationSeconds);
|
|
199
160
|
// Each track's served ticks are placed on the clock the page reports by the
|
|
@@ -266,15 +227,23 @@ export function predictPlaybackReadiness(input = {}) {
|
|
|
266
227
|
.sort((left, right) => left.startSeconds - right.startSeconds || left.index - right.index),
|
|
267
228
|
// The pieces after the near window, as the playlist cuts them, and the
|
|
268
229
|
// offset the page's player placed them by.
|
|
269
|
-
tail:
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
230
|
+
tail: [],
|
|
231
|
+
tailStartSeconds: horizonEnd,
|
|
232
|
+
durationSeconds: Number.isFinite(track.durationSeconds) ? track.durationSeconds : duration,
|
|
233
|
+
segmentCount: Number.isSafeInteger(track.segmentCount) ? track.segmentCount
|
|
234
|
+
: Math.max(0, ...track.segments.map(segment => segment.index + 1)),
|
|
273
235
|
clockSeconds: Number.isFinite(track.clockOffsetSeconds) ? track.clockOffsetSeconds : 0
|
|
274
236
|
}));
|
|
275
237
|
for (const state of trackState) {
|
|
276
|
-
state.tailEndSeconds =
|
|
277
|
-
? Math.min(duration, state.
|
|
238
|
+
state.tailEndSeconds = Number.isFinite(horizonEnd) && horizonEnd < duration
|
|
239
|
+
? Math.min(duration, state.durationSeconds + state.clockSeconds) : null;
|
|
240
|
+
const endIndex = state.segments.reduce((last, segment) => segment.startSeconds < horizonEnd
|
|
241
|
+
? Math.max(last, segment.index) : last, -1);
|
|
242
|
+
state.tailSegmentCount = Math.max(0, state.segmentCount - endIndex - 1);
|
|
243
|
+
const readyTail = [...state.ready].reduce((count, index) => count + Number(index > endIndex), 0);
|
|
244
|
+
state.tailWorkFraction = state.tailSegmentCount > 0
|
|
245
|
+
? Math.max(0, 1 - Math.min(state.tailSegmentCount, readyTail) / state.tailSegmentCount)
|
|
246
|
+
: 0;
|
|
278
247
|
}
|
|
279
248
|
if (trackState.some(({ segments }) => segments.length === 0)) {
|
|
280
249
|
return result(false, null, buffered, reserve, null, "timeline-unavailable", 0);
|
|
@@ -294,6 +263,13 @@ export function predictPlaybackReadiness(input = {}) {
|
|
|
294
263
|
}
|
|
295
264
|
sourceState.set(id, { source, service });
|
|
296
265
|
}
|
|
266
|
+
const sourceUseCounts = new Map();
|
|
267
|
+
for (const { track } of trackState) {
|
|
268
|
+
for (const sourceId of new Set([...(track.sourceIds ?? []),
|
|
269
|
+
...(track.segments ?? []).flatMap(segment => (segment.sourceInputs ?? []).map(item => item.sourceId))])) {
|
|
270
|
+
sourceUseCounts.set(sourceId, (sourceUseCounts.get(sourceId) ?? 0) + 1);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
297
273
|
const link = rateCurve(input.linkReadings, now);
|
|
298
274
|
let unknownReason = null;
|
|
299
275
|
let unavailableSource = null;
|
|
@@ -301,15 +277,12 @@ export function predictPlaybackReadiness(input = {}) {
|
|
|
301
277
|
return result(false, null, buffered, reserve, null, "link-rate-unavailable", 0);
|
|
302
278
|
}
|
|
303
279
|
|
|
304
|
-
let preparedSegments = trackState.reduce((sum, { ready
|
|
305
|
-
sum + segments.filter((segment) => ready.has(segment.index)).length +
|
|
306
|
-
tail.filter((segment) => ready.has(segment.index)).length, 0);
|
|
280
|
+
let preparedSegments = trackState.reduce((sum, { ready }) => sum + ready.size, 0);
|
|
307
281
|
const jobs = [];
|
|
308
282
|
|
|
309
283
|
for (let trackIndex = 0; trackIndex < trackState.length; trackIndex += 1) {
|
|
310
284
|
const state = trackState[trackIndex];
|
|
311
285
|
const { track, segments } = state;
|
|
312
|
-
const safeThrough = position + finiteNonNegative(input.bufferedAheadSeconds) + reserve;
|
|
313
286
|
const processingCoverage = numericCoverage((Array.isArray(track.processingRanges) ? track.processingRanges : [])
|
|
314
287
|
.filter(range => Number.isFinite(range?.start) && Number.isFinite(range?.end) && range.end > range.start));
|
|
315
288
|
const measuredBitsPerMediaSecond = state.bitsPerMediaSecond;
|
|
@@ -345,7 +318,7 @@ export function predictPlaybackReadiness(input = {}) {
|
|
|
345
318
|
const encodeWork = produced ? 0 : remainingProcessing.reduce((sum, part) => sum + part.end - part.start, 0);
|
|
346
319
|
|
|
347
320
|
let sourceByteIntervals = NO_SOURCE_BYTES;
|
|
348
|
-
|
|
321
|
+
const estimatedSourceBytes = new Map();
|
|
349
322
|
if (!produced && encodeWork > 0) {
|
|
350
323
|
// Each source the piece reads, in order and once: the track's own, then
|
|
351
324
|
// those its inputs name. Gathered with loops and no copies, because
|
|
@@ -363,20 +336,18 @@ export function predictPlaybackReadiness(input = {}) {
|
|
|
363
336
|
unknownReason ??= "source-measurement-unavailable";
|
|
364
337
|
} else if (source.source.complete !== true) {
|
|
365
338
|
const ranges = exactInputs ? inputRangesOf(exactInputs, sourceId) : null;
|
|
366
|
-
if (ranges === null) {
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
state.tail = [];
|
|
373
|
-
deferredAtBoundary = true;
|
|
374
|
-
break;
|
|
375
|
-
}
|
|
376
|
-
if (unknownReason === null) {
|
|
339
|
+
if (ranges === null && exactInputs === null) {
|
|
340
|
+
const estimate = estimateUnmappedSourceBytes(source.source, segmentSeconds, input.durationSeconds);
|
|
341
|
+
if (estimate !== null) {
|
|
342
|
+
const bytes = estimate / Math.max(1, sourceUseCounts.get(sourceId) ?? 1);
|
|
343
|
+
estimatedSourceBytes.set(sourceId, bytes);
|
|
344
|
+
} else if (unknownReason === null) {
|
|
377
345
|
unknownReason = "source-input-ranges-unavailable";
|
|
378
346
|
unavailableSource = { sourceId, trackId: track.id, segmentIndex: segment.index, phase: "near" };
|
|
379
347
|
}
|
|
348
|
+
} else if (ranges === null) {
|
|
349
|
+
unknownReason ??= "source-input-ranges-unavailable";
|
|
350
|
+
unavailableSource ??= { sourceId, trackId: track.id, segmentIndex: segment.index, phase: "near" };
|
|
380
351
|
} else {
|
|
381
352
|
if (sourceByteIntervals === NO_SOURCE_BYTES) sourceByteIntervals = new Map();
|
|
382
353
|
sourceByteIntervals.set(sourceId, ranges);
|
|
@@ -384,7 +355,6 @@ export function predictPlaybackReadiness(input = {}) {
|
|
|
384
355
|
}
|
|
385
356
|
}
|
|
386
357
|
}
|
|
387
|
-
if (deferredAtBoundary) break;
|
|
388
358
|
if (!produced && encodeWork > 0 && !state.curve) {
|
|
389
359
|
unknownReason ??= "encode-rate-unavailable";
|
|
390
360
|
}
|
|
@@ -396,14 +366,13 @@ export function predictPlaybackReadiness(input = {}) {
|
|
|
396
366
|
sizeBits,
|
|
397
367
|
encodeWork,
|
|
398
368
|
sourceByteIntervals,
|
|
369
|
+
estimatedSourceBytes,
|
|
399
370
|
produced
|
|
400
371
|
});
|
|
401
372
|
}
|
|
402
373
|
}
|
|
403
374
|
|
|
404
|
-
preparedSegments = trackState.reduce((sum, { ready
|
|
405
|
-
sum + segments.filter((segment) => ready.has(segment.index)).length +
|
|
406
|
-
tail.filter((segment) => ready.has(segment.index)).length, 0);
|
|
375
|
+
preparedSegments = trackState.reduce((sum, { ready }) => sum + ready.size, 0);
|
|
407
376
|
|
|
408
377
|
if (unknownReason) {
|
|
409
378
|
return { ...result(false, null, buffered, reserve, null, unknownReason, preparedSegments),
|
|
@@ -466,7 +435,7 @@ export function predictPlaybackReadiness(input = {}) {
|
|
|
466
435
|
const completions = [];
|
|
467
436
|
const productions = [];
|
|
468
437
|
for (const job of jobs) {
|
|
469
|
-
const { track, trackIndex, segment, sizeBits, encodeWork, sourceByteIntervals } = job;
|
|
438
|
+
const { track, trackIndex, segment, sizeBits, encodeWork, sourceByteIntervals, estimatedSourceBytes } = job;
|
|
470
439
|
let sourceArrivalAt = 0;
|
|
471
440
|
const plan = { finish: 0 };
|
|
472
441
|
for (const [sourceId, ranges] of sourceByteIntervals) {
|
|
@@ -514,6 +483,21 @@ export function predictPlaybackReadiness(input = {}) {
|
|
|
514
483
|
sourceService.finish = finish;
|
|
515
484
|
}
|
|
516
485
|
}
|
|
486
|
+
for (const [sourceId, bytes] of estimatedSourceBytes) {
|
|
487
|
+
if (!(bytes > 0)) continue;
|
|
488
|
+
const source = sourceState.get(sourceId);
|
|
489
|
+
if (!source) {
|
|
490
|
+
return result(false, null, buffered, reserve, null, "source-measurement-unavailable", preparedSegments);
|
|
491
|
+
}
|
|
492
|
+
const { service } = source;
|
|
493
|
+
if (!service.rate || !(service.rate.rateAt(0) > 0)) {
|
|
494
|
+
return { ...result(false, null, buffered, reserve, null,
|
|
495
|
+
service.rate ? "service-not-advancing" : "download-rate-unavailable", preparedSegments),
|
|
496
|
+
unavailableSource: { sourceId, segmentIndex: segment.index, estimatedBytes: bytes } };
|
|
497
|
+
}
|
|
498
|
+
service.finish = service.rate.finish(bytes, Math.max(service.finish, sourceArrivalAt));
|
|
499
|
+
sourceArrivalAt = Math.max(sourceArrivalAt, service.finish);
|
|
500
|
+
}
|
|
517
501
|
// Production accepts a complete held segment input. It cannot overlap its
|
|
518
502
|
// own source download; reused input retains its original arrival time.
|
|
519
503
|
const processing = trackState[trackIndex].curve;
|
|
@@ -608,9 +592,9 @@ export function predictPlaybackReadiness(input = {}) {
|
|
|
608
592
|
production.requiresSource, production.sourceArrivalAt, production.sourcePlanned);
|
|
609
593
|
}
|
|
610
594
|
}
|
|
611
|
-
if (trackState.some(({
|
|
612
|
-
const stop = scheduleTail({ trackState, sourceState, sourceByteCoverage,
|
|
613
|
-
link, position, reserve, transferFinish, constrain });
|
|
595
|
+
if (trackState.some(({ tailEndSeconds }) => Number.isFinite(tailEndSeconds))) {
|
|
596
|
+
const stop = scheduleTail({ trackState, sourceState, sourceByteCoverage, runStarts, trackFinish,
|
|
597
|
+
link, position, buffered, reserve, transferFinish, constrain, duration });
|
|
614
598
|
if (stop) {
|
|
615
599
|
return { ...result(false, null, buffered, reserve, null, stop.reason, preparedSegments),
|
|
616
600
|
...(stop.unavailableSource ? { unavailableSource: stop.unavailableSource } : {}) };
|
|
@@ -681,185 +665,90 @@ function isPiece(segment) {
|
|
|
681
665
|
}
|
|
682
666
|
|
|
683
667
|
/**
|
|
684
|
-
*
|
|
685
|
-
*
|
|
686
|
-
*
|
|
687
|
-
*
|
|
688
|
-
*
|
|
689
|
-
*
|
|
690
|
-
*
|
|
691
|
-
* Where a byte stands in that order is the bytes still missing before it,
|
|
692
|
-
* read off what is held, planned and brought by the near window
|
|
693
|
-
* ({@link missingBefore}); a piece is placed by the last range of it that
|
|
694
|
-
* misses bytes, so a range it reads that is already held — an AVI's index at
|
|
695
|
-
* the end of the file — does not place it at the end of the file. Planned and
|
|
696
|
-
* brought bytes come when they were to come. No exact time, list of arrivals
|
|
697
|
-
* or per-piece object is built: this runs over the rest of the film on every
|
|
698
|
-
* poll (torrent-tv/meta#185).
|
|
668
|
+
* A duration-level estimate of the suffix after the exact near window. Source
|
|
669
|
+
* work uses each file's aggregate byte density and measured download service;
|
|
670
|
+
* processing uses each track's measured rate; delivery uses output bitrates and
|
|
671
|
+
* measured link service. Held source bytes and made segments reduce the
|
|
672
|
+
* estimate. This conditional forecast does not assert exact source ranges or
|
|
673
|
+
* individual frame timing. Its work is bounded by sources and tracks, not by
|
|
674
|
+
* the film's segment count.
|
|
699
675
|
*
|
|
700
676
|
* @returns {{ reason: string, unavailableSource?: object } | null} Why there is no answer, or null.
|
|
701
677
|
*/
|
|
702
|
-
function scheduleTail({ trackState, sourceState, sourceByteCoverage,
|
|
703
|
-
position, reserve, transferFinish, constrain }) {
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
const
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
state = { coverage, heldBefore, asked: null, orders: [], finishes: [] };
|
|
713
|
-
supply.set(sourceId, state);
|
|
714
|
-
}
|
|
715
|
-
return state;
|
|
716
|
-
};
|
|
717
|
-
const next = trackState.map(() => 0);
|
|
718
|
-
const plan = { finish: 0 };
|
|
719
|
-
let delivered = transferFinish;
|
|
720
|
-
for (;;) {
|
|
721
|
-
// The next piece in media order across the tracks, as the near window orders them.
|
|
722
|
-
let trackIndex = -1;
|
|
723
|
-
for (let index = 0; index < trackState.length; index += 1) {
|
|
724
|
-
const candidate = trackState[index].tail[next[index]];
|
|
725
|
-
if (!candidate) continue;
|
|
726
|
-
const best = trackIndex < 0 ? null : trackState[trackIndex].tail[next[trackIndex]];
|
|
727
|
-
if (!best || candidate.startSeconds < best.startSeconds ||
|
|
728
|
-
(candidate.startSeconds === best.startSeconds && candidate.endSeconds < best.endSeconds)) trackIndex = index;
|
|
729
|
-
}
|
|
730
|
-
if (trackIndex < 0) return null;
|
|
731
|
-
const state = trackState[trackIndex];
|
|
732
|
-
const { track } = state;
|
|
733
|
-
const segment = state.tail[next[trackIndex]++];
|
|
734
|
-
const startsAt = segment.startSeconds + state.clockSeconds;
|
|
735
|
-
const endsAt = segment.endSeconds + state.clockSeconds;
|
|
736
|
-
// Media the page already holds is not transferred again.
|
|
737
|
-
if (Array.isArray(track.clientRanges) && track.clientRanges.some((range) =>
|
|
738
|
-
range?.start <= Math.max(startsAt, position) && range?.end >= endsAt)) continue;
|
|
739
|
-
const exactBytes = sizeOf(track.segmentSizesBytes, segment.index);
|
|
740
|
-
const sizeBits = exactBytes > 0 ? exactBytes * 8 : state.bitsPerMediaSecond * (segment.endSeconds - segment.startSeconds);
|
|
741
|
-
if (!(sizeBits > 0)) return { reason: "segment-size-unavailable" };
|
|
742
|
-
const produced = state.ready.has(segment.index);
|
|
743
|
-
let work = 0;
|
|
744
|
-
if (!produced) {
|
|
745
|
-
work = segment.endSeconds - segment.startSeconds;
|
|
746
|
-
for (const range of Array.isArray(track.processingRanges) ? track.processingRanges : []) {
|
|
747
|
-
if (Number.isFinite(range?.start) && Number.isFinite(range?.end)) {
|
|
748
|
-
work -= Math.max(0, Math.min(range.end, segment.endSeconds) - Math.max(range.start, segment.startSeconds));
|
|
749
|
-
}
|
|
750
|
-
}
|
|
751
|
-
work = Math.max(0, work);
|
|
752
|
-
}
|
|
753
|
-
let sourceArrivalAt = 0;
|
|
754
|
-
plan.finish = 0;
|
|
755
|
-
if (work > 0) {
|
|
756
|
-
if (!state.curve) return { reason: "encode-rate-unavailable" };
|
|
757
|
-
const trackSources = Array.isArray(track.sourceIds) ? track.sourceIds : [];
|
|
758
|
-
const exactInputs = Array.isArray(segment.sourceInputs) ? segment.sourceInputs : null;
|
|
759
|
-
const count = trackSources.length + (exactInputs?.length ?? 0);
|
|
760
|
-
for (let place = 0; place < count; place += 1) {
|
|
761
|
-
const sourceId = place < trackSources.length ? trackSources[place]
|
|
762
|
-
: exactInputs[place - trackSources.length]?.sourceId;
|
|
763
|
-
if (namedBefore(sourceId, place, trackSources, exactInputs)) continue;
|
|
764
|
-
const source = sourceState.get(sourceId);
|
|
765
|
-
if (!source) return { reason: "source-measurement-unavailable" };
|
|
766
|
-
if (source.source.complete === true) continue;
|
|
767
|
-
const ranges = exactInputs ? inputRangesOf(exactInputs, sourceId) : null;
|
|
768
|
-
if (ranges === null) {
|
|
769
|
-
// The tail extends beyond the ranges the playback map has asked to
|
|
770
|
-
// resolve. Stop this projection at that boundary: the near window
|
|
771
|
-
// remains exact, and later segments enter the map as the viewer
|
|
772
|
-
// advances. Waiting for the whole tail here prevents a cold start.
|
|
773
|
-
return null;
|
|
774
|
-
}
|
|
775
|
-
const bytes = supplyOf(sourceId);
|
|
776
|
-
let order = 0;
|
|
777
|
-
for (const range of ranges) {
|
|
778
|
-
// Bytes the torrent thread planned, or the near window brought, come when they were to come.
|
|
779
|
-
sourceArrivalAt = Math.max(sourceArrivalAt, latestArrival(range, sourceArrivals.get(sourceId), plan));
|
|
780
|
-
const after = missingBefore(bytes, range.end);
|
|
781
|
-
if (after > missingBefore(bytes, range.start)) order = Math.max(order, after);
|
|
782
|
-
}
|
|
783
|
-
if (order === 0) continue;
|
|
784
|
-
const { service } = source;
|
|
785
|
-
if (!service.rate || !(service.rate.rateAt(0) > 0)) {
|
|
786
|
-
return { reason: service.rate ? "service-not-advancing" : "download-rate-unavailable",
|
|
787
|
-
unavailableSource: { sourceId, segmentIndex: segment.index, missingBytes: order - (bytes.asked ?? 0) } };
|
|
788
|
-
}
|
|
789
|
-
// The tail's first asked byte is where this source's tail begins:
|
|
790
|
-
// bytes missing before it are not this film's to wait for.
|
|
791
|
-
bytes.asked ??= Math.max(0, order - missingWithin(bytes, ranges));
|
|
792
|
-
let arrival;
|
|
793
|
-
if (order > bytes.asked) {
|
|
794
|
-
service.finish = service.rate.finish(order - bytes.asked, service.finish);
|
|
795
|
-
bytes.asked = order;
|
|
796
|
-
bytes.orders.push(order);
|
|
797
|
-
bytes.finishes.push(service.finish);
|
|
798
|
-
arrival = service.finish;
|
|
799
|
-
} else {
|
|
800
|
-
// Brought by an earlier piece's ask: when that ask reached it.
|
|
801
|
-
let low = 0, high = bytes.orders.length - 1;
|
|
802
|
-
while (low < high) {
|
|
803
|
-
const middle = (low + high) >>> 1;
|
|
804
|
-
if (bytes.orders[middle] < order) low = middle + 1;
|
|
805
|
-
else high = middle;
|
|
806
|
-
}
|
|
807
|
-
arrival = bytes.finishes[low] ?? service.finish;
|
|
808
|
-
}
|
|
809
|
-
sourceArrivalAt = Math.max(sourceArrivalAt, arrival);
|
|
810
|
-
}
|
|
678
|
+
function scheduleTail({ trackState, sourceState, sourceByteCoverage, runStarts, trackFinish, link,
|
|
679
|
+
position, buffered, reserve, transferFinish, constrain, duration }) {
|
|
680
|
+
const tailStart = Math.max(position, ...trackState.map(state => state.tailStartSeconds));
|
|
681
|
+
if (!Number.isFinite(tailStart)) return null;
|
|
682
|
+
const sourceDurations = new Map();
|
|
683
|
+
for (const state of trackState) {
|
|
684
|
+
const seconds = Math.max(0, state.tailEndSeconds - tailStart) * state.tailWorkFraction;
|
|
685
|
+
if (!(seconds > 0)) continue;
|
|
686
|
+
for (const sourceId of new Set(state.track.sourceIds ?? [])) {
|
|
687
|
+
sourceDurations.set(sourceId, Math.max(sourceDurations.get(sourceId) ?? 0, seconds));
|
|
811
688
|
}
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
let sourceArrivalAt = 0;
|
|
692
|
+
for (const [sourceId, seconds] of sourceDurations) {
|
|
693
|
+
const source = sourceState.get(sourceId);
|
|
694
|
+
if (!source) return { reason: "source-measurement-unavailable" };
|
|
695
|
+
if (source.source.complete === true) continue;
|
|
696
|
+
const bytes = estimateUnmappedSourceBytes(source.source, seconds, duration,
|
|
697
|
+
sourceByteCoverage.get(sourceId));
|
|
698
|
+
if (bytes === null) return { reason: "source-measurement-unavailable",
|
|
699
|
+
unavailableSource: { sourceId, phase: "tail" } };
|
|
700
|
+
if (!(bytes > 0)) continue;
|
|
701
|
+
const { service } = source;
|
|
702
|
+
if (!service.rate || !(service.rate.rateAt(0) > 0)) {
|
|
703
|
+
return { reason: service.rate ? "service-not-advancing" : "download-rate-unavailable",
|
|
704
|
+
unavailableSource: { sourceId, estimatedBytes: bytes, phase: "tail" } };
|
|
820
705
|
}
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
const
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
const requiresSource = sourceArrivalAt > 0;
|
|
829
|
-
const sourcePlanned = requiresSource && plan.finish >= sourceArrivalAt;
|
|
830
|
-
constrain(completedAt - ahead, "delivery", trackIndex, segment.index, completedAt, ahead, false, sourceArrivalAt,
|
|
831
|
-
sourcePlanned);
|
|
832
|
-
const covered = Math.max(0, ahead - Math.min(reserve, Math.max(0, state.tailEndSeconds - startsAt)) *
|
|
833
|
-
Number(requiresSource));
|
|
834
|
-
constrain(producedAt - covered, "production", trackIndex, segment.index, producedAt, covered, requiresSource,
|
|
835
|
-
sourceArrivalAt, sourcePlanned);
|
|
706
|
+
service.finish = service.rate.finish(bytes, service.finish);
|
|
707
|
+
sourceArrivalAt = Math.max(sourceArrivalAt, service.finish);
|
|
708
|
+
const state = trackState.find(candidate => candidate.track.sourceIds?.includes(sourceId));
|
|
709
|
+
const index = state?.segments.at(-1)?.index ?? 0;
|
|
710
|
+
const ahead = Math.max(0, (state?.tailEndSeconds ?? tailStart) - position);
|
|
711
|
+
constrain(service.finish - Math.max(0, ahead - reserve), "source", trackState.indexOf(state), index,
|
|
712
|
+
service.finish, ahead, true, service.finish, false);
|
|
836
713
|
}
|
|
837
|
-
}
|
|
838
714
|
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
715
|
+
let outputBits = 0;
|
|
716
|
+
let latestTrackFinish = 0;
|
|
717
|
+
for (let trackIndex = 0; trackIndex < trackState.length; trackIndex += 1) {
|
|
718
|
+
const state = trackState[trackIndex];
|
|
719
|
+
const end = state.tailEndSeconds;
|
|
720
|
+
const seconds = Math.max(0, end - tailStart);
|
|
721
|
+
if (!(seconds > 0)) continue;
|
|
722
|
+
const track = state.track;
|
|
723
|
+
const segment = state.segments.at(-1) ?? { index: 0 };
|
|
724
|
+
const remaining = seconds * state.tailWorkFraction;
|
|
725
|
+
const work = Math.max(0, remaining - numericCoverage((track.processingRanges ?? [])
|
|
726
|
+
.filter(range => Number.isFinite(range?.start) && Number.isFinite(range?.end))
|
|
727
|
+
.map(range => ({ start: Math.max(tailStart, range.start), end: Math.min(end, range.end) }))
|
|
728
|
+
.filter(range => range.end > range.start)).reduce((sum, range) => sum + range.end - range.start, 0));
|
|
729
|
+
const processing = state.curve;
|
|
730
|
+
if (work > 0 && !processing) return { reason: "encode-rate-unavailable" };
|
|
731
|
+
const run = runStarts[trackIndex];
|
|
732
|
+
const localStart = tailStart - state.clockSeconds;
|
|
733
|
+
const inRun = run.spans.some(span => localStart >= span.start && localStart < span.end);
|
|
734
|
+
const open = work > 0 && !inRun ? run.openSeconds : 0;
|
|
735
|
+
const finishedAt = work > 0 ? processing.finish(work, trackFinish[trackIndex] + open) : trackFinish[trackIndex];
|
|
736
|
+
latestTrackFinish = Math.max(latestTrackFinish, finishedAt);
|
|
737
|
+
const deliveryStart = Math.max(tailStart, position + buffered);
|
|
738
|
+
outputBits += Math.max(0, state.bitsPerMediaSecond) * Math.max(0, end - deliveryStart);
|
|
739
|
+
const ahead = Math.max(0, end - position);
|
|
740
|
+
constrain(finishedAt - Math.max(0, ahead - reserve), "production", trackIndex, segment.index,
|
|
741
|
+
finishedAt, ahead, sourceArrivalAt > 0, sourceArrivalAt, false);
|
|
853
742
|
}
|
|
854
|
-
const inside = low < coverage.length && coverage[low].start < byte ? byte - coverage[low].start : 0;
|
|
855
|
-
return byte - heldBefore[low] - inside;
|
|
856
|
-
}
|
|
857
743
|
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
744
|
+
if (outputBits > 0) {
|
|
745
|
+
const end = Math.max(...trackState.map(state => state.tailEndSeconds));
|
|
746
|
+
const completedAt = link.finish(outputBits, Math.max(transferFinish, latestTrackFinish));
|
|
747
|
+
const ahead = Math.max(0, end - position);
|
|
748
|
+
constrain(completedAt - Math.max(0, ahead - reserve), "delivery", 0,
|
|
749
|
+
trackState[0]?.segments.at(-1)?.index ?? 0, completedAt, ahead, false, sourceArrivalAt, false);
|
|
750
|
+
}
|
|
751
|
+
return null;
|
|
863
752
|
}
|
|
864
753
|
|
|
865
754
|
/**
|
|
@@ -1141,6 +1030,25 @@ function numericCoverage(ranges) {
|
|
|
1141
1030
|
return joined;
|
|
1142
1031
|
}
|
|
1143
1032
|
|
|
1033
|
+
/**
|
|
1034
|
+
* Estimate download work without claiming an address or held byte. The whole
|
|
1035
|
+
* file's measured density, reduced by the held fraction, prices unresolved
|
|
1036
|
+
* intervals and the aggregate suffix.
|
|
1037
|
+
*/
|
|
1038
|
+
function estimateUnmappedSourceBytes(source, mediaSeconds, durationSeconds, coverage = null) {
|
|
1039
|
+
const fileLength = Number(source.fileLength);
|
|
1040
|
+
const density = Number(source.bytesPerMediaSecond) > 0 ? Number(source.bytesPerMediaSecond)
|
|
1041
|
+
: fileLength > 0 && durationSeconds > 0 ? fileLength / durationSeconds : 0;
|
|
1042
|
+
if (!(density > 0) || !(mediaSeconds > 0)) return null;
|
|
1043
|
+
const heldRanges = coverage ?? source.residence;
|
|
1044
|
+
if (!(fileLength > 0) || !Array.isArray(heldRanges)) return density * mediaSeconds;
|
|
1045
|
+
const held = numericCoverage(heldRanges.filter(range =>
|
|
1046
|
+
range?.location !== "missing" && Number.isFinite(range?.start) && Number.isFinite(range?.end) &&
|
|
1047
|
+
range.end > range.start));
|
|
1048
|
+
const heldBytes = held.reduce((sum, range) => sum + range.end - range.start, 0);
|
|
1049
|
+
return density * mediaSeconds * Math.max(0, Math.min(1, (fileLength - heldBytes) / fileLength));
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1144
1052
|
/** Already held bytes have no arrival delay. Index future arrivals once. */
|
|
1145
1053
|
function indexedArrivals(ranges) {
|
|
1146
1054
|
let maximumEnd = 0;
|