@torrent-tv/proxy 2.101.1 → 2.101.2
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
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file The one owner of what this machine's processor gives the encoders
|
|
3
|
+
* (torrent-tv/meta#180, phase 2: one owner, results unchanged).
|
|
4
|
+
*
|
|
5
|
+
* Before it, the same two readings reached the processor's decisions by six
|
|
6
|
+
* routes: the share of the machine other work leaves (`HostLoad`'s latest
|
|
7
|
+
* reading) was read separately by the admission, the quality offer, the cost
|
|
8
|
+
* model and the download share, and the contention penalties measured at
|
|
9
|
+
* startup were handed separately to the cost model, the runs and the plan. A
|
|
10
|
+
* reading that changes, or a new one (the processor's bound and measured free
|
|
11
|
+
* amount, `storage/machine-processor.js`), would have had to be threaded to
|
|
12
|
+
* each of them again. Now each asks this object, and the arithmetic stays in
|
|
13
|
+
* the two files that define it (`available-share.js`, `contention.js`).
|
|
14
|
+
*
|
|
15
|
+
* It lives in the encoding component because every question it answers is the
|
|
16
|
+
* encoders', and that component may import nothing outside itself; it is
|
|
17
|
+
* assembled beside the machine budget in `server/wire-outputs.js`. Its unit is
|
|
18
|
+
* today's: shares of the whole machine and seconds of the whole machine per
|
|
19
|
+
* second of film. Nothing here changes an answer.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { correctForAvailability, leftForEncoders } from "./available-share.js";
|
|
23
|
+
import { contentionPenalty } from "./contention.js";
|
|
24
|
+
|
|
25
|
+
export class ProcessorBudget {
|
|
26
|
+
/** @type {() => ({ share: number, forEncoders?: number, known: boolean } | null | undefined)} */
|
|
27
|
+
#reading;
|
|
28
|
+
|
|
29
|
+
/** @type {import("./contention.js").ContentionPenalties | null} */
|
|
30
|
+
#penalties;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @param {object} params
|
|
34
|
+
* @param {() => ({ share: number, forEncoders?: number, known: boolean } | null | undefined)} params.reading -
|
|
35
|
+
* The latest share of the machine other work leaves, as `HostLoad` keeps it.
|
|
36
|
+
* @param {import("./contention.js").ContentionPenalties | null} [params.penalties] -
|
|
37
|
+
* How much slower one job runs beside others, measured at startup.
|
|
38
|
+
*/
|
|
39
|
+
constructor({ reading, penalties = null }) {
|
|
40
|
+
if (typeof reading !== "function") {
|
|
41
|
+
throw new TypeError("ProcessorBudget requires reading");
|
|
42
|
+
}
|
|
43
|
+
this.#reading = reading;
|
|
44
|
+
this.#penalties = penalties instanceof Map ? penalties : null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The latest share of the machine other work leaves, or null before the
|
|
49
|
+
* first reading.
|
|
50
|
+
*
|
|
51
|
+
* @returns {{ share: number, forEncoders?: number, known: boolean } | null}
|
|
52
|
+
*/
|
|
53
|
+
availability() {
|
|
54
|
+
return this.#reading() ?? null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The share of the machine an encoder gets now; 1 where the host does not
|
|
59
|
+
* report its load.
|
|
60
|
+
*
|
|
61
|
+
* @returns {number}
|
|
62
|
+
*/
|
|
63
|
+
left() {
|
|
64
|
+
return leftForEncoders(this.availability());
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* A speed measured alone on a quiet machine, as it would run now.
|
|
69
|
+
*
|
|
70
|
+
* @param {number} aloneSpeed
|
|
71
|
+
* @returns {number}
|
|
72
|
+
*/
|
|
73
|
+
speedOf(aloneSpeed) {
|
|
74
|
+
return correctForAvailability(aloneSpeed, this.availability());
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The measured penalties, as the plan and the runs read them.
|
|
79
|
+
*
|
|
80
|
+
* @returns {import("./contention.js").ContentionPenalties | null}
|
|
81
|
+
*/
|
|
82
|
+
get penalties() {
|
|
83
|
+
return this.#penalties;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Replace the penalties, as a new measurement of them does.
|
|
88
|
+
*
|
|
89
|
+
* @param {import("./contention.js").ContentionPenalties | null} penalties
|
|
90
|
+
*/
|
|
91
|
+
set penalties(penalties) {
|
|
92
|
+
this.#penalties = penalties instanceof Map ? penalties : null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* How much slower one job runs with `others` beside it.
|
|
97
|
+
*
|
|
98
|
+
* @param {number} others
|
|
99
|
+
* @returns {{ penalty: number, measured: boolean, from: number }}
|
|
100
|
+
*/
|
|
101
|
+
penaltyFor(others) {
|
|
102
|
+
return contentionPenalty(others, this.#penalties);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -658,6 +658,7 @@ export class ViewerRequests {
|
|
|
658
658
|
fileLength: stats?.fileLength,
|
|
659
659
|
pieceLength: stats?.pieceLength,
|
|
660
660
|
downloadRateReadings: this.#sourceRateReadings(measurement, output.file.sourceKey, stats, now),
|
|
661
|
+
downloadRateSummaries: this.#sourceRateSummaries(measurement, output.file.sourceKey),
|
|
661
662
|
downloadForecast,
|
|
662
663
|
});
|
|
663
664
|
}
|
|
@@ -774,11 +775,14 @@ export class ViewerRequests {
|
|
|
774
775
|
bufferLimitSeconds: input.bufferLimitSeconds,
|
|
775
776
|
lookaheadSeconds: input.lookaheadSeconds,
|
|
776
777
|
link: input.linkReadings,
|
|
777
|
-
sources: input.sources.map(({ id, complete, residence, downloadForecast, downloadRateReadings
|
|
778
|
+
sources: input.sources.map(({ id, complete, residence, downloadForecast, downloadRateReadings,
|
|
779
|
+
downloadRateSummaries }) => ({
|
|
778
780
|
id, complete, residenceRanges: residence?.length ?? 0,
|
|
779
781
|
forecastAt: downloadForecast?.measuredAt ?? null, plannedPieces: downloadForecast?.ranges.length ?? 0,
|
|
780
782
|
unknownPieces: downloadForecast?.ranges.filter(range => range.availableAt === null).length ?? 0,
|
|
781
|
-
|
|
783
|
+
// The rate the forecast reads, and both summaries it was chosen from.
|
|
784
|
+
downloadRateBytesPerSecond: this.#host.playbackReadiness.forecastRate(downloadRateReadings, now, 0, "time"),
|
|
785
|
+
downloadRateSummaries
|
|
782
786
|
})),
|
|
783
787
|
tracks: tracks.map(({ id, processedSeconds, readings, segments, readySegmentIndices }) => ({
|
|
784
788
|
id, processedSeconds, readings, segments: segments.length, prepared: readySegmentIndices.length
|
|
@@ -818,7 +822,7 @@ export class ViewerRequests {
|
|
|
818
822
|
const rates = (forecast.operations ?? []).map(operation =>
|
|
819
823
|
`${operation.track} ${operation.operation} ${Number.isFinite(operation.speed) ? operation.speed.toFixed(2) : "?"}x`);
|
|
820
824
|
const downloads = (input.sources ?? []).map(source => {
|
|
821
|
-
const rate = source.downloadRateReadings
|
|
825
|
+
const rate = this.#host.playbackReadiness.forecastRate(source.downloadRateReadings, now, 0, "time");
|
|
822
826
|
return `${Number.isFinite(rate) ? (rate / 1_048_576).toFixed(2) : "?"} MB/s`;
|
|
823
827
|
});
|
|
824
828
|
logger.info(`playback held ${session.id} consumer=${consumerId}: delay ${seconds(delay)}s by ` +
|
|
@@ -949,7 +953,14 @@ export class ViewerRequests {
|
|
|
949
953
|
}
|
|
950
954
|
// Totals that went back belong to a torrent added again: read on from them.
|
|
951
955
|
if (limit) source.limit = limit;
|
|
952
|
-
return sourceService(source.free.snapshot(), source.delivered.snapshot()
|
|
956
|
+
return sourceService(source.free.snapshot(), source.delivered.snapshot(),
|
|
957
|
+
(summary) => this.#host.playbackReadiness.forecastRate(summary, now, 0, "time"));
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
/** Both summaries a source's service was chosen from, for the forecast's log line. */
|
|
961
|
+
#sourceRateSummaries(state, id) {
|
|
962
|
+
const source = state.sources.get(id);
|
|
963
|
+
return source ? { free: source.free.snapshot(), delivered: source.delivered.snapshot() } : null;
|
|
953
964
|
}
|
|
954
965
|
}
|
|
955
966
|
|
|
@@ -962,12 +973,19 @@ export class ViewerRequests {
|
|
|
962
973
|
* downloaded 5-18 MB/s, answered "service not advancing", and held the viewer
|
|
963
974
|
* (r54, torrent-tv/meta#178). What the swarm did carry, it can carry.
|
|
964
975
|
*
|
|
976
|
+
* The two are compared by the rate the forecast reads from them, bytes over
|
|
977
|
+
* the span measured. Compared by their last readings, a summary whose last
|
|
978
|
+
* reading was 10.7 MB/s and whose span read 0 B/s won over 3.4-16.7 MB/s
|
|
979
|
+
* carried, and the forecast held the viewer with no delay to state (r74,
|
|
980
|
+
* torrent-tv/meta#185).
|
|
981
|
+
*
|
|
965
982
|
* @param {object | null} free - A rate summary (`RateTrend.snapshot`).
|
|
966
983
|
* @param {object | null} delivered
|
|
984
|
+
* @param {(summary: object) => number | null} rateOf - The rate the forecast reads from a summary.
|
|
967
985
|
* @returns {object | null}
|
|
968
986
|
*/
|
|
969
|
-
export function sourceService(free, delivered) {
|
|
987
|
+
export function sourceService(free, delivered, rateOf) {
|
|
970
988
|
if (!free) return delivered;
|
|
971
989
|
if (!delivered) return free;
|
|
972
|
-
return delivered
|
|
990
|
+
return (rateOf(delivered) ?? 0) > (rateOf(free) ?? 0) ? delivered : free;
|
|
973
991
|
}
|
|
@@ -102,6 +102,7 @@ import { configurationKeyOf } from "../encode/fingerprint.js";
|
|
|
102
102
|
import { qualityStateOf } from "../encode/quality/OutputQualityState.js";
|
|
103
103
|
import { downloadShare } from "../storage/processor-share.js";
|
|
104
104
|
import { describeProcessor, processorBetween, sampleProcessor } from "../storage/machine-processor.js";
|
|
105
|
+
import { ProcessorBudget } from "../encode/ProcessorBudget.js";
|
|
105
106
|
import { ENCODE_RUN_STATE, processCanBeSignalled } from "../encode/encode-run-state.js";
|
|
106
107
|
import { secondsUntilClosed } from "../encode/piece-close-time.js";
|
|
107
108
|
|
|
@@ -350,7 +351,7 @@ export function wireOutputs({
|
|
|
350
351
|
producedNumbers: (...args) => parts.serving.producedNumbers(...args),
|
|
351
352
|
servesAudioSeparately: (...args) => parts.renditions.servesAudioSeparately(...args),
|
|
352
353
|
disposeSession: (...args) => parts.lifecycle.disposeSession(...args),
|
|
353
|
-
get contentionPenalties() { return parts.
|
|
354
|
+
get contentionPenalties() { return parts.processor?.penalties ?? null; },
|
|
354
355
|
get encodeCost() { return parts.encodeCost; },
|
|
355
356
|
get encodeOrchestrator() { return parts.encodeOrchestrator; },
|
|
356
357
|
get ffmpegBin() { return parts.ffmpegBin; },
|
|
@@ -736,7 +737,7 @@ export function wireOutputs({
|
|
|
736
737
|
files.set(key, file);
|
|
737
738
|
}
|
|
738
739
|
const watched = [...files.values()].filter((file) => Number.isFinite(file.bytesPerFilmSecond) && file.bytesPerFilmSecond > 0);
|
|
739
|
-
const share = downloadShare({ availableShare: parts.
|
|
740
|
+
const share = downloadShare({ availableShare: parts.processor?.availability()?.share ?? 1,
|
|
740
741
|
baseDraw: parts.hostLoad.baseDraw, costPerMegabyte: parts.hostLoad.observedTorrentCostPerMegabyte, files: watched });
|
|
741
742
|
// Kept beside the readings it is judged against: what the torrents moved
|
|
742
743
|
// over each interval is said next to the limit in force (meta#178).
|
|
@@ -744,7 +745,17 @@ export function wireOutputs({
|
|
|
744
745
|
void Promise.resolve(parts.setDownloadRate(share.bytesPerSecond, `film speed ${share.filmSpeed.toFixed(2)}x ` +
|
|
745
746
|
`over ${watched.length} file(s), ${Math.round(share.capacity * 100)}% of the machine for the proxy`)).catch(() => {});
|
|
746
747
|
};
|
|
747
|
-
|
|
748
|
+
// The one owner of what the processor gives the encoders: the share of the
|
|
749
|
+
// machine other work leaves and the measured contention penalties, read by
|
|
750
|
+
// every place that decides on the processor (torrent-tv/meta#180).
|
|
751
|
+
parts.processor = new ProcessorBudget({ reading: () => parts.hostLoad.hostAvailability, penalties: contentionPenalties });
|
|
752
|
+
// Kept as the name the penalties were always set and read by, now the budget's.
|
|
753
|
+
Object.defineProperty(parts, "contentionPenalties", {
|
|
754
|
+
get: () => parts.processor.penalties,
|
|
755
|
+
set: (penalties) => { parts.processor.penalties = penalties; },
|
|
756
|
+
enumerable: true,
|
|
757
|
+
configurable: true
|
|
758
|
+
});
|
|
748
759
|
// Seconds of film per second when the picture is COPIED, measured at
|
|
749
760
|
// startup. Nothing else prices that branch: the other benchmarks measure
|
|
750
761
|
// encoding and decoding, and a copy does neither.
|
|
@@ -913,11 +924,11 @@ export function wireOutputs({
|
|
|
913
924
|
host: () => ({
|
|
914
925
|
benchmark: parts.softwarePresetBenchmark,
|
|
915
926
|
decodeModel: parts.decodeCostModel,
|
|
916
|
-
contentionPenalties: parts.
|
|
927
|
+
contentionPenalties: parts.processor.penalties,
|
|
917
928
|
copySpeedX: parts.copySpeedX,
|
|
918
929
|
audioCalibration,
|
|
919
930
|
audioDescriptionOf,
|
|
920
|
-
availability: parts.
|
|
931
|
+
availability: parts.processor.availability() ?? undefined,
|
|
921
932
|
// WHICH encoder this host settled on. Only the software ladder is
|
|
922
933
|
// benchmarked, so a reading taken off a hardware encoder cannot be
|
|
923
934
|
// split into its decode and encode halves and is not filed as one.
|
|
@@ -957,7 +968,7 @@ export function wireOutputs({
|
|
|
957
968
|
encodeCost: parts.encodeCost,
|
|
958
969
|
occupiedCostSec: (fileKey) => parts.admission?.occupiedCost({ exceptFileKey: fileKey }).costSec ?? 0,
|
|
959
970
|
occupancyKnownFor: (fileKey) => (parts.admission?.occupiedCost({ exceptFileKey: fileKey }).unpriced ?? 0) === 0,
|
|
960
|
-
availability: () => parts.
|
|
971
|
+
availability: () => parts.processor.availability(),
|
|
961
972
|
outputs: parts.outputs,
|
|
962
973
|
stateFor: (session) => parts.encodeRuns.runStateOf(session),
|
|
963
974
|
// WHICH HEIGHTS A LIVE VIEWER HAS ON SCREEN, as numbers. Who is watching
|
|
@@ -1051,7 +1062,7 @@ export function wireOutputs({
|
|
|
1051
1062
|
finished: (address) => parts.outputs.outputsOn(address).some((session) => finishedOutput(session)),
|
|
1052
1063
|
loadOf: (address) => parts.encodeCost.loadOfOutput(address),
|
|
1053
1064
|
loadForCandidate: (spec, file) => parts.encodeCost.loadForCandidate(spec, file),
|
|
1054
|
-
availability: () => parts.
|
|
1065
|
+
availability: () => parts.processor.availability()
|
|
1055
1066
|
});
|
|
1056
1067
|
parts.encodeOrchestrator = new EncodeOrchestrator({
|
|
1057
1068
|
inputDemandChanged: (address, windows) => {
|
|
@@ -1067,7 +1078,7 @@ export function wireOutputs({
|
|
|
1067
1078
|
maxRunsFor: (address) => parts.encodeRuns.maxRunsForOutput(address),
|
|
1068
1079
|
makeRun: ({ address, from, to, because }) => parts.encodeRuns.makeRunAt(address, from, to, because),
|
|
1069
1080
|
segmentSeconds: parts.segmentDurationSec,
|
|
1070
|
-
contentionPenalties: parts.
|
|
1081
|
+
contentionPenalties: parts.processor.penalties,
|
|
1071
1082
|
startingSpeedFor: (address) => parts.encodeCost.speedForOutput(address),
|
|
1072
1083
|
segmentCoverage,
|
|
1073
1084
|
// HOW IT ASKS TO DECIDE AGAIN. A plan that refuses to place anything
|
|
@@ -143,8 +143,20 @@ export function predictPlaybackReadiness(input = {}) {
|
|
|
143
143
|
// Kept are the pieces still to play, the first piece (where the track
|
|
144
144
|
// starts), the read piece nearest before them (what an unread piece is placed
|
|
145
145
|
// by) and read pieces inside what the page reports holding (their frames).
|
|
146
|
+
//
|
|
147
|
+
// THE NEAR WINDOW AND THE TAIL (torrent-tv/meta#185). Pieces starting within
|
|
148
|
+
// the proxy's look-ahead of the viewer are placed in exact time, where whether
|
|
149
|
+
// the media is continuous is decided, and their bytes are those the torrent
|
|
150
|
+
// thread plans arrivals for. The pieces after it are scheduled by the same
|
|
151
|
+
// rules in seconds, by {@link scheduleTail}: no exact time and no planned
|
|
152
|
+
// arrival exists for them. Placing every remaining piece of a two-hour film
|
|
153
|
+
// in exact time on every poll cost 70-1128 ms a call on Home Assistant (r73,
|
|
154
|
+
// r74).
|
|
155
|
+
const lookahead = finiteNonNegative(input.lookaheadSeconds);
|
|
156
|
+
const horizonEnd = lookahead > 0 ? finiteNonNegative(input.positionSeconds) + lookahead : Number.POSITIVE_INFINITY;
|
|
146
157
|
const tracks = (Array.isArray(input.tracks) ? input.tracks : []).map((track) =>
|
|
147
|
-
Array.isArray(track.segments) ? { ...track,
|
|
158
|
+
Array.isArray(track.segments) ? { ...track,
|
|
159
|
+
segments: piecesThatMatter(track, finiteNonNegative(input.positionSeconds), horizonEnd) } : track);
|
|
148
160
|
const duration = finiteNonNegative(input.durationSeconds);
|
|
149
161
|
// Each track's served ticks are placed on the clock the page reports by the
|
|
150
162
|
// offset its player applied (`timestampOffset`).
|
|
@@ -212,11 +224,20 @@ export function predictPlaybackReadiness(input = {}) {
|
|
|
212
224
|
bitsPerMediaSecond: averageBitsPerMediaSecond(track, track.segments,
|
|
213
225
|
new Set(Array.isArray(track.readySegmentIndices) ? track.readySegmentIndices : [])),
|
|
214
226
|
segments: segmentsOf[trackIndex]
|
|
215
|
-
.filter((segment) =>
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
227
|
+
.filter((segment) => isPiece(segment) && segment.endSeconds > position && segment.startSeconds < horizonEnd)
|
|
228
|
+
.sort((left, right) => left.startSeconds - right.startSeconds || left.index - right.index),
|
|
229
|
+
// The pieces after the near window, as the playlist cuts them, and the
|
|
230
|
+
// offset the page's player placed them by.
|
|
231
|
+
tail: horizonEnd === Number.POSITIVE_INFINITY ? [] :
|
|
232
|
+
(Array.isArray(input.tracks[trackIndex].segments) ? input.tracks[trackIndex].segments : [])
|
|
233
|
+
.filter((segment) => isPiece(segment) && segment.startSeconds >= horizonEnd && segment.endSeconds > position)
|
|
234
|
+
.sort((left, right) => left.startSeconds - right.startSeconds || left.index - right.index),
|
|
235
|
+
clockSeconds: Number.isFinite(track.clockOffsetSeconds) ? track.clockOffsetSeconds : 0
|
|
219
236
|
}));
|
|
237
|
+
for (const state of trackState) {
|
|
238
|
+
state.tailEndSeconds = state.tail.length > 0
|
|
239
|
+
? Math.min(duration, state.tail.at(-1).endSeconds + state.clockSeconds) : null;
|
|
240
|
+
}
|
|
220
241
|
if (trackState.some(({ segments }) => segments.length === 0)) {
|
|
221
242
|
return result(false, null, buffered, reserve, null, "timeline-unavailable", 0);
|
|
222
243
|
}
|
|
@@ -241,8 +262,9 @@ export function predictPlaybackReadiness(input = {}) {
|
|
|
241
262
|
return result(false, null, buffered, reserve, null, "link-rate-unavailable", 0);
|
|
242
263
|
}
|
|
243
264
|
|
|
244
|
-
const preparedSegments = trackState.reduce((sum, { ready, segments }) =>
|
|
245
|
-
sum + segments.filter((segment) => ready.has(segment.index)).length
|
|
265
|
+
const preparedSegments = trackState.reduce((sum, { ready, segments, tail }) =>
|
|
266
|
+
sum + segments.filter((segment) => ready.has(segment.index)).length +
|
|
267
|
+
tail.filter((segment) => ready.has(segment.index)).length, 0);
|
|
246
268
|
const jobs = [];
|
|
247
269
|
|
|
248
270
|
for (let trackIndex = 0; trackIndex < trackState.length; trackIndex += 1) {
|
|
@@ -497,12 +519,22 @@ export function predictPlaybackReadiness(input = {}) {
|
|
|
497
519
|
for (const range of production.segment.ranges) addMediaRange(prepared, range);
|
|
498
520
|
// Supply interruption coverage is stock held by the proxy as well as
|
|
499
521
|
// the browser. It is not a browser buffer-size admission rule.
|
|
500
|
-
|
|
501
|
-
|
|
522
|
+
// The reserve is bounded by what is left of the track, not of the window.
|
|
523
|
+
const left = state.tailEndSeconds === null ? secondsAhead(trackEnd, before)
|
|
524
|
+
: Math.max(0, state.tailEndSeconds - toSeconds(before));
|
|
525
|
+
const ahead = Math.max(0, secondsAhead(before, at) - Math.min(reserve, left) * Number(production.requiresSource));
|
|
502
526
|
constrain(production.at - ahead, "production", trackIndex, production.segment.index, production.at, ahead,
|
|
503
527
|
production.requiresSource);
|
|
504
528
|
}
|
|
505
529
|
}
|
|
530
|
+
if (trackState.some(({ tail }) => tail.length > 0)) {
|
|
531
|
+
const stop = scheduleTail({ trackState, sourceState, sourceByteCoverage, sourceArrivals, runStarts, trackFinish,
|
|
532
|
+
link, position, reserve, transferFinish, constrain });
|
|
533
|
+
if (stop) {
|
|
534
|
+
return { ...result(false, null, buffered, reserve, null, stop.reason, preparedSegments),
|
|
535
|
+
...(stop.unavailableSource ? { unavailableSource: stop.unavailableSource } : {}) };
|
|
536
|
+
}
|
|
537
|
+
}
|
|
506
538
|
const delay = Math.max(0, ...constraints);
|
|
507
539
|
const originReady = trackState.every(({ clientRanges }) => compare(contiguousEnd(clientRanges, at), at) > 0);
|
|
508
540
|
const ready = delay === 0 && originReady;
|
|
@@ -527,6 +559,183 @@ export function predictPlaybackReadiness(input = {}) {
|
|
|
527
559
|
|
|
528
560
|
}
|
|
529
561
|
|
|
562
|
+
/** A playlist piece with an index and a positive span in seconds. */
|
|
563
|
+
function isPiece(segment) {
|
|
564
|
+
return Number.isInteger(segment?.index) && Number.isFinite(segment?.startSeconds) &&
|
|
565
|
+
Number.isFinite(segment?.endSeconds) && segment.endSeconds > segment.startSeconds;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* The pieces after the near window, scheduled by the near window's rules in
|
|
570
|
+
* seconds and constrained as they are scheduled: their source bytes arrive in
|
|
571
|
+
* byte order at the source's measured service after the near window's bytes,
|
|
572
|
+
* a piece whose bytes come after its output's last run was admitted starts a
|
|
573
|
+
* run and pays the output's open, it is encoded at the output's measured rate
|
|
574
|
+
* and delivered over the one link in media order.
|
|
575
|
+
*
|
|
576
|
+
* Where a byte stands in that order is the bytes still missing before it,
|
|
577
|
+
* read off what is held, planned and brought by the near window
|
|
578
|
+
* ({@link missingBefore}); a piece is placed by the last range of it that
|
|
579
|
+
* misses bytes, so a range it reads that is already held — an AVI's index at
|
|
580
|
+
* the end of the file — does not place it at the end of the file. Planned and
|
|
581
|
+
* brought bytes come when they were to come. No exact time, list of arrivals
|
|
582
|
+
* or per-piece object is built: this runs over the rest of the film on every
|
|
583
|
+
* poll (torrent-tv/meta#185).
|
|
584
|
+
*
|
|
585
|
+
* @returns {{ reason: string, unavailableSource?: object } | null} Why there is no answer, or null.
|
|
586
|
+
*/
|
|
587
|
+
function scheduleTail({ trackState, sourceState, sourceByteCoverage, sourceArrivals, runStarts, trackFinish, link,
|
|
588
|
+
position, reserve, transferFinish, constrain }) {
|
|
589
|
+
// Per source: what is missing before a byte, and how far the tail has asked.
|
|
590
|
+
const supply = new Map();
|
|
591
|
+
const supplyOf = (sourceId) => {
|
|
592
|
+
let state = supply.get(sourceId);
|
|
593
|
+
if (!state) {
|
|
594
|
+
const coverage = sourceByteCoverage.get(sourceId) ?? [];
|
|
595
|
+
const heldBefore = [0];
|
|
596
|
+
for (const range of coverage) heldBefore.push(heldBefore.at(-1) + range.end - range.start);
|
|
597
|
+
state = { coverage, heldBefore, asked: null, orders: [], finishes: [] };
|
|
598
|
+
supply.set(sourceId, state);
|
|
599
|
+
}
|
|
600
|
+
return state;
|
|
601
|
+
};
|
|
602
|
+
const next = trackState.map(() => 0);
|
|
603
|
+
let delivered = transferFinish;
|
|
604
|
+
for (;;) {
|
|
605
|
+
// The next piece in media order across the tracks, as the near window orders them.
|
|
606
|
+
let trackIndex = -1;
|
|
607
|
+
for (let index = 0; index < trackState.length; index += 1) {
|
|
608
|
+
const candidate = trackState[index].tail[next[index]];
|
|
609
|
+
if (!candidate) continue;
|
|
610
|
+
const best = trackIndex < 0 ? null : trackState[trackIndex].tail[next[trackIndex]];
|
|
611
|
+
if (!best || candidate.startSeconds < best.startSeconds ||
|
|
612
|
+
(candidate.startSeconds === best.startSeconds && candidate.endSeconds < best.endSeconds)) trackIndex = index;
|
|
613
|
+
}
|
|
614
|
+
if (trackIndex < 0) return null;
|
|
615
|
+
const state = trackState[trackIndex];
|
|
616
|
+
const { track } = state;
|
|
617
|
+
const segment = state.tail[next[trackIndex]++];
|
|
618
|
+
const startsAt = segment.startSeconds + state.clockSeconds;
|
|
619
|
+
const endsAt = segment.endSeconds + state.clockSeconds;
|
|
620
|
+
// Media the page already holds is not transferred again.
|
|
621
|
+
if (Array.isArray(track.clientRanges) && track.clientRanges.some((range) =>
|
|
622
|
+
range?.start <= Math.max(startsAt, position) && range?.end >= endsAt)) continue;
|
|
623
|
+
const exactBytes = sizeOf(track.segmentSizesBytes, segment.index);
|
|
624
|
+
const sizeBits = exactBytes > 0 ? exactBytes * 8 : state.bitsPerMediaSecond * (segment.endSeconds - segment.startSeconds);
|
|
625
|
+
if (!(sizeBits > 0)) return { reason: "segment-size-unavailable" };
|
|
626
|
+
const produced = state.ready.has(segment.index);
|
|
627
|
+
let work = 0;
|
|
628
|
+
if (!produced) {
|
|
629
|
+
work = segment.endSeconds - segment.startSeconds;
|
|
630
|
+
for (const range of Array.isArray(track.processingRanges) ? track.processingRanges : []) {
|
|
631
|
+
if (Number.isFinite(range?.start) && Number.isFinite(range?.end)) {
|
|
632
|
+
work -= Math.max(0, Math.min(range.end, segment.endSeconds) - Math.max(range.start, segment.startSeconds));
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
work = Math.max(0, work);
|
|
636
|
+
}
|
|
637
|
+
let sourceArrivalAt = 0;
|
|
638
|
+
if (work > 0) {
|
|
639
|
+
if (!state.curve) return { reason: "encode-rate-unavailable" };
|
|
640
|
+
const trackSources = Array.isArray(track.sourceIds) ? track.sourceIds : [];
|
|
641
|
+
const exactInputs = Array.isArray(segment.sourceInputs) ? segment.sourceInputs : null;
|
|
642
|
+
const count = trackSources.length + (exactInputs?.length ?? 0);
|
|
643
|
+
for (let place = 0; place < count; place += 1) {
|
|
644
|
+
const sourceId = place < trackSources.length ? trackSources[place]
|
|
645
|
+
: exactInputs[place - trackSources.length]?.sourceId;
|
|
646
|
+
if (namedBefore(sourceId, place, trackSources, exactInputs)) continue;
|
|
647
|
+
const source = sourceState.get(sourceId);
|
|
648
|
+
if (!source) return { reason: "source-measurement-unavailable" };
|
|
649
|
+
if (source.source.complete === true) continue;
|
|
650
|
+
const ranges = exactInputs ? inputRangesOf(exactInputs, sourceId) : null;
|
|
651
|
+
if (ranges === null) return { reason: "source-input-ranges-unavailable" };
|
|
652
|
+
const bytes = supplyOf(sourceId);
|
|
653
|
+
let order = 0;
|
|
654
|
+
for (const range of ranges) {
|
|
655
|
+
// Bytes the torrent thread planned, or the near window brought, come when they were to come.
|
|
656
|
+
sourceArrivalAt = Math.max(sourceArrivalAt, latestArrival(range, sourceArrivals.get(sourceId)));
|
|
657
|
+
const after = missingBefore(bytes, range.end);
|
|
658
|
+
if (after > missingBefore(bytes, range.start)) order = Math.max(order, after);
|
|
659
|
+
}
|
|
660
|
+
if (order === 0) continue;
|
|
661
|
+
const { service } = source;
|
|
662
|
+
if (!service.rate || !(service.rate.rateAt(0) > 0)) {
|
|
663
|
+
return { reason: service.rate ? "service-not-advancing" : "download-rate-unavailable",
|
|
664
|
+
unavailableSource: { sourceId, segmentIndex: segment.index, missingBytes: order - (bytes.asked ?? 0) } };
|
|
665
|
+
}
|
|
666
|
+
// The tail's first asked byte is where this source's tail begins:
|
|
667
|
+
// bytes missing before it are not this film's to wait for.
|
|
668
|
+
bytes.asked ??= Math.max(0, order - missingWithin(bytes, ranges));
|
|
669
|
+
let arrival;
|
|
670
|
+
if (order > bytes.asked) {
|
|
671
|
+
service.finish = service.rate.finish(order - bytes.asked, service.finish);
|
|
672
|
+
bytes.asked = order;
|
|
673
|
+
bytes.orders.push(order);
|
|
674
|
+
bytes.finishes.push(service.finish);
|
|
675
|
+
arrival = service.finish;
|
|
676
|
+
} else {
|
|
677
|
+
// Brought by an earlier piece's ask: when that ask reached it.
|
|
678
|
+
let low = 0, high = bytes.orders.length - 1;
|
|
679
|
+
while (low < high) {
|
|
680
|
+
const middle = (low + high) >>> 1;
|
|
681
|
+
if (bytes.orders[middle] < order) low = middle + 1;
|
|
682
|
+
else high = middle;
|
|
683
|
+
}
|
|
684
|
+
arrival = bytes.finishes[low] ?? service.finish;
|
|
685
|
+
}
|
|
686
|
+
sourceArrivalAt = Math.max(sourceArrivalAt, arrival);
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
let admittedAt = Math.max(trackFinish[trackIndex], sourceArrivalAt);
|
|
690
|
+
if (work > 0) {
|
|
691
|
+
const run = runStarts[trackIndex];
|
|
692
|
+
const live = run.spans.some(span => segment.startSeconds >= span.start && segment.startSeconds < span.end);
|
|
693
|
+
if (!live && (run.admittedAt === null || sourceArrivalAt > run.admittedAt)) {
|
|
694
|
+
run.admittedAt = admittedAt;
|
|
695
|
+
admittedAt += run.openSeconds;
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
const producedAt = work > 0 ? state.curve.finish(work, admittedAt) : 0;
|
|
699
|
+
trackFinish[trackIndex] = Math.max(trackFinish[trackIndex], producedAt);
|
|
700
|
+
delivered = link.finish(sizeBits, Math.max(delivered, producedAt));
|
|
701
|
+
const completedAt = delivered + finiteNonNegative(track.appendSeconds);
|
|
702
|
+
// The media before a piece is what the playlist puts before it: whether a
|
|
703
|
+
// hole stops the media is decided in the near window, in exact time.
|
|
704
|
+
const ahead = Math.max(0, startsAt - position);
|
|
705
|
+
const requiresSource = sourceArrivalAt > 0;
|
|
706
|
+
constrain(completedAt - ahead, "delivery", trackIndex, segment.index, completedAt, ahead, false);
|
|
707
|
+
const covered = Math.max(0, ahead - Math.min(reserve, Math.max(0, state.tailEndSeconds - startsAt)) *
|
|
708
|
+
Number(requiresSource));
|
|
709
|
+
constrain(producedAt - covered, "production", trackIndex, segment.index, producedAt, covered, requiresSource);
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/**
|
|
714
|
+
* The bytes not held, and not brought by the near window, before `byte` of a
|
|
715
|
+
* source: `byte` less what the sorted coverage holds below it.
|
|
716
|
+
*
|
|
717
|
+
* @param {{ coverage: Array<{ start: number, end: number }>, heldBefore: number[] }} bytes
|
|
718
|
+
* @param {number} byte
|
|
719
|
+
*/
|
|
720
|
+
function missingBefore(bytes, byte) {
|
|
721
|
+
const { coverage, heldBefore } = bytes;
|
|
722
|
+
let low = 0, high = coverage.length;
|
|
723
|
+
while (low < high) {
|
|
724
|
+
const middle = (low + high) >>> 1;
|
|
725
|
+
if (coverage[middle].end <= byte) low = middle + 1;
|
|
726
|
+
else high = middle;
|
|
727
|
+
}
|
|
728
|
+
const inside = low < coverage.length && coverage[low].start < byte ? byte - coverage[low].start : 0;
|
|
729
|
+
return byte - heldBefore[low] - inside;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/** The missing bytes of a piece's ranges of one source. */
|
|
733
|
+
function missingWithin(bytes, ranges) {
|
|
734
|
+
let missing = 0;
|
|
735
|
+
for (const range of ranges) missing += missingBefore(bytes, range.end) - missingBefore(bytes, range.start);
|
|
736
|
+
return missing;
|
|
737
|
+
}
|
|
738
|
+
|
|
530
739
|
/**
|
|
531
740
|
* Seconds from `from` to `to`, or zero when `to` is not later.
|
|
532
741
|
*
|
|
@@ -628,21 +837,24 @@ function heldRanges(ranges, segments, unit, position) {
|
|
|
628
837
|
}
|
|
629
838
|
|
|
630
839
|
/**
|
|
631
|
-
* The pieces of a track this forecast
|
|
632
|
-
* `position`)
|
|
633
|
-
*
|
|
634
|
-
*
|
|
635
|
-
*
|
|
636
|
-
* no
|
|
840
|
+
* The pieces of a track this forecast places in exact time: those still to
|
|
841
|
+
* play (ending after `position`) that start before `horizonEnd`, the first
|
|
842
|
+
* piece, every piece that has been read and every piece that is made. A piece
|
|
843
|
+
* that is none of these changes no exact answer: after the near window it is
|
|
844
|
+
* scheduled in seconds, it is not where the track starts, no unread piece is
|
|
845
|
+
* placed by it (only read pieces are), it lends no frame to what the page
|
|
846
|
+
* holds, and it has no size to measure the output's rate by.
|
|
637
847
|
*
|
|
638
848
|
* @param {{ segments: object[], readySegmentIndices?: number[] }} track
|
|
639
849
|
* @param {number} position - Seconds.
|
|
850
|
+
* @param {number} [horizonEnd] - Seconds: where the near window ends.
|
|
640
851
|
* @returns {object[]}
|
|
641
852
|
*/
|
|
642
|
-
function piecesThatMatter(track, position) {
|
|
853
|
+
function piecesThatMatter(track, position, horizonEnd = Number.POSITIVE_INFINITY) {
|
|
643
854
|
const made = new Set(Array.isArray(track.readySegmentIndices) ? track.readySegmentIndices : []);
|
|
644
855
|
const kept = track.segments.filter((segment) => segment?.index === 0 || segment?.mediaRanges !== undefined ||
|
|
645
|
-
made.has(segment?.index) || !(Number.isFinite(segment?.endSeconds) && segment.endSeconds <= position)
|
|
856
|
+
made.has(segment?.index) || (!(Number.isFinite(segment?.endSeconds) && segment.endSeconds <= position) &&
|
|
857
|
+
!(Number.isFinite(segment?.startSeconds) && segment.startSeconds >= horizonEnd)));
|
|
646
858
|
return kept.length === track.segments.length ? track.segments : kept;
|
|
647
859
|
}
|
|
648
860
|
|