@torrent-tv/proxy 2.97.15 → 2.97.17
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 +1 -1
- package/routes/api/transcode-sessions/net-report/post.js +35 -2
- package/server.js +28 -4
- package/services/encode/EncodeOrchestrator.js +33 -1
- package/services/media/MediaReadRequests.js +59 -3
- package/services/media/container/AviContainer.js +11 -0
- package/services/server/ViewerRequests.js +60 -4
- package/services/server/wire-outputs.js +31 -7
- package/services/storage/MachineBudget.js +30 -7
- package/services/storage/wire.js +18 -6
- package/services/torrent/worker/held-bytes.js +7 -5
- package/services/viewer/playback-readiness.js +88 -15
package/package.json
CHANGED
|
@@ -19,14 +19,15 @@ import { recordViewerReport } from "../../../../services/viewer/report-intake.js
|
|
|
19
19
|
*
|
|
20
20
|
* @param {import("fastify").FastifyRequest} req
|
|
21
21
|
* @param {import("fastify").FastifyReply} reply
|
|
22
|
-
* @param {{ outputs: { get: (id: string) => object | undefined }, viewers: object, renditions: { viewerPlays: Function, noteViewerReported: Function }, quality: { noteViewerReported: Function } }} deps -
|
|
22
|
+
* @param {{ outputs: { get: (id: string) => object | undefined }, viewers: object, renditions: { viewerPlays: Function, noteViewerReported: Function }, quality: { noteViewerReported: Function }, planSoon?: () => void, segmentAt?: (output: object, seconds: number) => number | null }} deps -
|
|
23
23
|
* The live outputs, the registry of viewers, and the rungs of a picture. The
|
|
24
24
|
* VIEWER component takes the statement; which rung a stated height is, is the
|
|
25
25
|
* encoding component's. This route's whole job is turning a request into
|
|
26
26
|
* those calls and their answer into a status code.
|
|
27
27
|
* @returns {Promise<void>}
|
|
28
28
|
*/
|
|
29
|
-
export async function handleApiTranscodeSessionNetReportPost(req, reply,
|
|
29
|
+
export async function handleApiTranscodeSessionNetReportPost(req, reply,
|
|
30
|
+
{ outputs, viewers, renditions, quality, planSoon = () => {}, segmentAt = () => null }) {
|
|
30
31
|
const sessionId = typeof req.params.sessionId === "string" ? req.params.sessionId : "";
|
|
31
32
|
const body = req.body && typeof req.body === "object" && !Array.isArray(req.body) ? req.body : {};
|
|
32
33
|
// WHAT THIS REPORT IS. A statement by one viewer about itself: where the
|
|
@@ -100,6 +101,10 @@ export async function handleApiTranscodeSessionNetReportPost(req, reply, { outpu
|
|
|
100
101
|
Number.isFinite(positionSeconds) && positionSeconds >= 0 ? positionSeconds : undefined
|
|
101
102
|
);
|
|
102
103
|
}
|
|
104
|
+
// Where they stood and whether they were playing or waiting, before this
|
|
105
|
+
// report: what the encoders are planned from (`movedForThePlan`).
|
|
106
|
+
const before = viewers.get?.(consumerId);
|
|
107
|
+
const was = before ? { seconds: before.position?.seconds ?? null, playing: before.playing, waiting: before.waiting } : null;
|
|
103
108
|
const recorded = recordViewerReport({
|
|
104
109
|
outputs,
|
|
105
110
|
viewers,
|
|
@@ -127,6 +132,14 @@ export async function handleApiTranscodeSessionNetReportPost(req, reply, { outpu
|
|
|
127
132
|
if (!recorded) {
|
|
128
133
|
return reply.code(404).send({ error: "Transcode session was not found." });
|
|
129
134
|
}
|
|
135
|
+
// A VIEWER WHO MOVED INTO ANOTHER PIECE, or started or stopped playing or
|
|
136
|
+
// waiting, changed the maps the encoders are placed from; the plan is taken
|
|
137
|
+
// again now. Recorded and not re-planned, a soundtrack the viewer was
|
|
138
|
+
// stopped for waited 59 s behind a picture that had long left the urgent
|
|
139
|
+
// band, because only the end of that picture's run planned again (Home
|
|
140
|
+
// Assistant 2026-10-12, torrent-tv/meta#178).
|
|
141
|
+
const after = viewers.get?.(consumerId);
|
|
142
|
+
if (after && movedForThePlan(was, after, outputs.get(sessionId), segmentAt)) planSoon();
|
|
130
143
|
// After the statement is recorded, so what is judged is what they just said:
|
|
131
144
|
// a move of theirs to another limit may no longer be wanted.
|
|
132
145
|
renditions.noteViewerReported(sessionId, consumerId);
|
|
@@ -136,3 +149,23 @@ export async function handleApiTranscodeSessionNetReportPost(req, reply, { outpu
|
|
|
136
149
|
void quality.noteViewerReported(sessionId, consumerId);
|
|
137
150
|
return reply.code(204).send();
|
|
138
151
|
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Whether a report changed what the encoders are planned from: the piece the
|
|
155
|
+
* viewer stands in, or whether they play or wait.
|
|
156
|
+
*
|
|
157
|
+
* @param {{ seconds: number | null, playing: boolean, waiting: boolean } | null} was
|
|
158
|
+
* @param {{ position?: { seconds: number } | null, playing: boolean, waiting: boolean }} now
|
|
159
|
+
* @param {object | undefined} output
|
|
160
|
+
* @param {(output: object, seconds: number) => number | null} segmentAt
|
|
161
|
+
* @returns {boolean}
|
|
162
|
+
*/
|
|
163
|
+
export function movedForThePlan(was, now, output, segmentAt) {
|
|
164
|
+
if (!was) return true;
|
|
165
|
+
if (was.playing !== now.playing || was.waiting !== now.waiting) return true;
|
|
166
|
+
const seconds = now.position?.seconds;
|
|
167
|
+
if (!Number.isFinite(seconds)) return false;
|
|
168
|
+
if (!Number.isFinite(was.seconds)) return true;
|
|
169
|
+
if (!output) return false;
|
|
170
|
+
return segmentAt(output, was.seconds) !== segmentAt(output, seconds);
|
|
171
|
+
}
|
package/server.js
CHANGED
|
@@ -524,6 +524,14 @@ export async function startProxyServer({
|
|
|
524
524
|
if (!map.isCurrent()) return [];
|
|
525
525
|
const direct = navigated.kind === "result" && navigated.value === true &&
|
|
526
526
|
typeof container?.readSourceRanges === "function" ? container : null;
|
|
527
|
+
// An output whose file-level reading is not there in this pass — the
|
|
528
|
+
// index or Cues that say where a segment is, not yet downloaded, or the
|
|
529
|
+
// table its segments read, refused memory — answers its other segments
|
|
530
|
+
// the same way. One unanswered request is kept to be retried when the
|
|
531
|
+
// bytes or the memory change, and the map is built again when it
|
|
532
|
+
// answers; asking every segment held the main thread for 3.3 s on a cold
|
|
533
|
+
// two-hour AVI (torrent-tv/meta#178).
|
|
534
|
+
const waiting = new Set();
|
|
527
535
|
for (const zone of demands) {
|
|
528
536
|
if (!map.isCurrent()) return [];
|
|
529
537
|
// WHAT A SEGMENT READS IS WORKED OUT ONCE, not on every publication: its
|
|
@@ -547,6 +555,7 @@ export async function startProxyServer({
|
|
|
547
555
|
// were never asked for (Home Assistant 2026-10-08, torrent-tv/meta#151).
|
|
548
556
|
const known = segmentSourceRanges.get(rangesKey);
|
|
549
557
|
if (known) { convert(known); continue; }
|
|
558
|
+
if (waiting.has(zone.outputKey)) continue;
|
|
550
559
|
if (direct) {
|
|
551
560
|
// Asked per zone too: a soundtrack of an AVI is read from its own
|
|
552
561
|
// packets, which the same index states (torrent-tv/meta#166).
|
|
@@ -559,9 +568,15 @@ export async function startProxyServer({
|
|
|
559
568
|
value = new SegmentInputs({ index, tracks: wanted }).forInterval({ ...interval, mode: track => modes.get(track) });
|
|
560
569
|
}
|
|
561
570
|
} catch { value = null; }
|
|
571
|
+
// A zone worked out gives the thread back before the next: worked out
|
|
572
|
+
// in one stretch, the first pass over a film's 4000 zones held the main
|
|
573
|
+
// thread 5-7 s while FFmpeg's first open waited for its answers. What
|
|
574
|
+
// was worked out is kept, so a pass a newer map ends is not lost
|
|
575
|
+
// (torrent-tv/meta#178).
|
|
576
|
+
if (value?.kind === "result") segmentSourceRanges.set(rangesKey, value.ranges);
|
|
577
|
+
await new Promise(resolve => setImmediate(resolve));
|
|
562
578
|
if (!map.isCurrent()) return [];
|
|
563
579
|
if (value?.kind === "result") {
|
|
564
|
-
segmentSourceRanges.set(rangesKey, value.ranges);
|
|
565
580
|
convert(value.ranges);
|
|
566
581
|
continue;
|
|
567
582
|
}
|
|
@@ -572,10 +587,14 @@ export async function startProxyServer({
|
|
|
572
587
|
if (!map.isCurrent()) return [];
|
|
573
588
|
if (!intervalParams) continue;
|
|
574
589
|
const navigation = await containerOrchestrator.inspect(intervalParams, "source-navigation");
|
|
575
|
-
if (navigation.kind !== "result")
|
|
590
|
+
if (navigation.kind !== "result") {
|
|
591
|
+
if (navigation.kind === "needs-ranges" || navigation.kind === "needs-memory") waiting.add(zone.outputKey);
|
|
592
|
+
continue;
|
|
593
|
+
}
|
|
576
594
|
const originalRanges = navigation.value;
|
|
577
595
|
const packets = await containerOrchestrator.inspect(intervalParams, originalRanges ? "source-ranges" : "packets");
|
|
578
596
|
if (!map.isCurrent()) return [];
|
|
597
|
+
if (packets.kind === "needs-memory") waiting.add(zone.outputKey);
|
|
579
598
|
if (packets.kind !== "result") continue;
|
|
580
599
|
const input = originalRanges ? packets.value : new SegmentInputs({ index: packets.value, tracks: wanted }).forInterval({ ...interval, mode: track => modes.get(track) });
|
|
581
600
|
if (input.kind !== "result") continue;
|
|
@@ -599,7 +618,7 @@ export async function startProxyServer({
|
|
|
599
618
|
failed: (request, error) => logger.warn(`media request=${request.key} probe failed: ${error?.message ?? error}`)
|
|
600
619
|
});
|
|
601
620
|
const indexMemory = new IndexMemory({
|
|
602
|
-
reviseBudget: () => { void outputParts.machineBudget.revise().catch(error => logger.warn(`packet index budget: ${error.message}`)); },
|
|
621
|
+
reviseBudget: () => { void outputParts.machineBudget.revise("memory", "media metadata").catch(error => logger.warn(`packet index budget: ${error.message}`)); },
|
|
603
622
|
changed: () => { mediaReads.memoryChanged(); probeReads.memoryChanged(); outputParts.encodeInputs?.memoryChanged(); }
|
|
604
623
|
});
|
|
605
624
|
const containerOver = async ({ sourceKey, fileIndex, requestId, packetInterval, demand }) => {
|
|
@@ -1288,7 +1307,12 @@ export async function startProxyServer({
|
|
|
1288
1307
|
outputs: outputParts.outputs,
|
|
1289
1308
|
viewers: outputParts.viewers,
|
|
1290
1309
|
renditions: outputParts.renditions,
|
|
1291
|
-
quality: outputParts.quality
|
|
1310
|
+
quality: outputParts.quality,
|
|
1311
|
+
planSoon: () => outputParts.encodeRuns.planEncodersSoon(),
|
|
1312
|
+
segmentAt: (output, seconds) => {
|
|
1313
|
+
// A cut table not built yet has no pieces to move between.
|
|
1314
|
+
try { return outputParts.outputTimes.segmentIndexForTime(output, seconds); } catch { return null; }
|
|
1315
|
+
}
|
|
1292
1316
|
})
|
|
1293
1317
|
);
|
|
1294
1318
|
app.post("/api/transcode-sessions/:sessionId/fragment-far", async (req, reply) =>
|
|
@@ -130,6 +130,17 @@ export class EncodeOrchestrator {
|
|
|
130
130
|
* @type {{ urgent: boolean, priority: number, address: string, index: number } | null}
|
|
131
131
|
*/
|
|
132
132
|
#leading = null;
|
|
133
|
+
/**
|
|
134
|
+
* Outputs whose start the last plan held back behind a more urgent encoder,
|
|
135
|
+
* and the band of the number it would have started at. Looked at again
|
|
136
|
+
* whenever that encoder moves on (`shareTheMachine`), because a closed piece
|
|
137
|
+
* is what lowers its band: placed only by a full plan, a soundtrack the
|
|
138
|
+
* viewer was stopped for waited 59 s behind a picture that had long left
|
|
139
|
+
* the urgent band (Home Assistant 2026-10-12, torrent-tv/meta#178).
|
|
140
|
+
*
|
|
141
|
+
* @type {Map<string, { urgent: boolean, priority: number }>}
|
|
142
|
+
*/
|
|
143
|
+
#heldBack = new Map();
|
|
133
144
|
|
|
134
145
|
/** What a stop and a start have cost on this host. */
|
|
135
146
|
#costs = new RunCosts();
|
|
@@ -689,7 +700,10 @@ export class EncodeOrchestrator {
|
|
|
689
700
|
shareTheMachine() {
|
|
690
701
|
const leading = this.#leadingBand();
|
|
691
702
|
this.#leading = leading;
|
|
692
|
-
if (!leading)
|
|
703
|
+
if (!leading) {
|
|
704
|
+
this.#placeHeldBack(null);
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
693
707
|
for (const [address, runs] of this.#runs) {
|
|
694
708
|
for (const run of runs) {
|
|
695
709
|
if (!run.isAlive || run.isStopping || typeof run.pause !== "function") continue;
|
|
@@ -702,6 +716,22 @@ export class EncodeOrchestrator {
|
|
|
702
716
|
}
|
|
703
717
|
}
|
|
704
718
|
}
|
|
719
|
+
this.#placeHeldBack(leading);
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/**
|
|
723
|
+
* Plan again every output whose start was held back behind what is no
|
|
724
|
+
* longer more urgent than it: the start is placed now, by the same plan,
|
|
725
|
+
* rather than at whatever event next re-plans everything.
|
|
726
|
+
*
|
|
727
|
+
* @param {{ urgent: boolean, priority: number } | null} leading
|
|
728
|
+
*/
|
|
729
|
+
#placeHeldBack(leading) {
|
|
730
|
+
for (const [address, band] of [...this.#heldBack]) {
|
|
731
|
+
if (leading && compareUrgency(band, leading) > 0) continue;
|
|
732
|
+
this.#heldBack.delete(address);
|
|
733
|
+
this.#reconcileOne(address);
|
|
734
|
+
}
|
|
705
735
|
}
|
|
706
736
|
|
|
707
737
|
/**
|
|
@@ -916,6 +946,8 @@ export class EncodeOrchestrator {
|
|
|
916
946
|
// a 159 090-line file, 31 % of the log, all of one wedge.
|
|
917
947
|
const wanting = firstUnmetWant(coverage, windows);
|
|
918
948
|
const stillRunning = this.runsOn(address).filter((run) => run.isAlive);
|
|
949
|
+
if (heldBack) this.#heldBack.set(address, this.#bandAt(address, heldBack.from));
|
|
950
|
+
else this.#heldBack.delete(address);
|
|
919
951
|
if (wanting === null || stillRunning.length > 0) {
|
|
920
952
|
this.#lastUnmet.delete(address);
|
|
921
953
|
} else if (heldBack) {
|
|
@@ -1,6 +1,22 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/**
|
|
2
|
+
* Retry unfinished statements when their missing resource changes.
|
|
3
|
+
*
|
|
4
|
+
* ONE REFUSAL OF MEMORY ANSWERS THE STATEMENT, not one request of it. The
|
|
5
|
+
* table a statement reserves belongs to the file — a soundtrack's packet table
|
|
6
|
+
* is one table for every segment of it — so once a read of a statement has
|
|
7
|
+
* been refused memory, the other requests of the same statement of the same
|
|
8
|
+
* file wait for memory to change instead of being read and refused one by one.
|
|
9
|
+
* Read one by one, a cold two-hour AVI's 1943 soundtrack segments were refused
|
|
10
|
+
* in turn when the index's last piece arrived, which held the main thread for
|
|
11
|
+
* 3.3 s and the budget revision that would have given the memory behind it
|
|
12
|
+
* (Home Assistant 2026-10-12, torrent-tv/meta#178). A retry wave therefore
|
|
13
|
+
* reads one request of each statement first and the others once it has
|
|
14
|
+
* answered.
|
|
15
|
+
*/
|
|
2
16
|
export class MediaReadRequests {
|
|
3
17
|
#requests = new Map();
|
|
18
|
+
/** File and statement → the memory revision at which a read of it was refused memory. */
|
|
19
|
+
#refusedMemory = new Map();
|
|
4
20
|
#read;
|
|
5
21
|
#failed;
|
|
6
22
|
#versions = new Map();
|
|
@@ -39,6 +55,7 @@ export class MediaReadRequests {
|
|
|
39
55
|
this.#notify(params.sourceKey, params.fileIndex, result);
|
|
40
56
|
return this.#requests.delete(key);
|
|
41
57
|
}
|
|
58
|
+
if (result.kind === "needs-memory") this.#refusedMemory.set(statementKey(params, statement), memoryRevision);
|
|
42
59
|
const existing = this.#requests.get(key);
|
|
43
60
|
if (existing) { existing.params = params; existing.kind = result.kind; }
|
|
44
61
|
else this.#requests.set(key, { params, statement, kind: result.kind, running: false, changed: false });
|
|
@@ -54,24 +71,49 @@ export class MediaReadRequests {
|
|
|
54
71
|
|
|
55
72
|
memoryChanged() {
|
|
56
73
|
this.#memoryVersion++;
|
|
74
|
+
const wave = [];
|
|
57
75
|
for (const request of this.#requests.values()) {
|
|
58
76
|
if (request.kind !== "needs-memory") continue;
|
|
59
77
|
// The retried statement announces its result. A memory event is not a
|
|
60
78
|
// source change for another statement still waiting for missing bytes.
|
|
61
79
|
request.changed = true;
|
|
62
|
-
if (!request.running)
|
|
80
|
+
if (!request.running) wave.push(request);
|
|
63
81
|
}
|
|
82
|
+
this.#retryWave(wave);
|
|
64
83
|
}
|
|
65
84
|
|
|
66
85
|
bytesChanged(sourceKey, fileIndex) {
|
|
67
86
|
const fileKey = `${sourceKey}:${fileIndex}`;
|
|
68
87
|
this.#versions.set(fileKey, this.revision(sourceKey, fileIndex) + 1);
|
|
69
88
|
this.#notify(sourceKey, fileIndex, { kind: "bytes-changed" });
|
|
89
|
+
const wave = [];
|
|
70
90
|
for (const request of this.#requests.values()) {
|
|
71
91
|
if (request.params.sourceKey !== sourceKey || request.params.fileIndex !== fileIndex) continue;
|
|
72
92
|
if (request.kind !== "needs-ranges") continue;
|
|
73
93
|
request.changed = true;
|
|
74
|
-
if (!request.running)
|
|
94
|
+
if (!request.running) wave.push(request);
|
|
95
|
+
}
|
|
96
|
+
this.#retryWave(wave);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Retry `requests`: one of each statement of each file first, the others of
|
|
101
|
+
* that statement once it has answered.
|
|
102
|
+
*
|
|
103
|
+
* @param {object[]} requests
|
|
104
|
+
*/
|
|
105
|
+
#retryWave(requests) {
|
|
106
|
+
const groups = new Map();
|
|
107
|
+
for (const request of requests) {
|
|
108
|
+
const key = statementKey(request.params, request.statement);
|
|
109
|
+
const group = groups.get(key);
|
|
110
|
+
if (group) group.push(request);
|
|
111
|
+
else groups.set(key, [request]);
|
|
112
|
+
}
|
|
113
|
+
for (const [head, ...rest] of groups.values()) {
|
|
114
|
+
void this.#retry(head).then(() => {
|
|
115
|
+
for (const request of rest) if (!request.running) void this.#retry(request);
|
|
116
|
+
});
|
|
75
117
|
}
|
|
76
118
|
}
|
|
77
119
|
|
|
@@ -90,6 +132,10 @@ export class MediaReadRequests {
|
|
|
90
132
|
for (const key of this.#versions.keys()) {
|
|
91
133
|
if (fileIndex === undefined ? key.startsWith(`${sourceKey}:`) : key === `${sourceKey}:${fileIndex}`) this.#versions.delete(key);
|
|
92
134
|
}
|
|
135
|
+
for (const key of [...this.#refusedMemory.keys()]) {
|
|
136
|
+
const [refusedSource, refusedFile] = JSON.parse(key);
|
|
137
|
+
if (refusedSource === sourceKey && (fileIndex === undefined || refusedFile === fileIndex)) this.#refusedMemory.delete(key);
|
|
138
|
+
}
|
|
93
139
|
}
|
|
94
140
|
|
|
95
141
|
retain(sourceKey, fileIndex, wanted) {
|
|
@@ -105,6 +151,12 @@ export class MediaReadRequests {
|
|
|
105
151
|
try {
|
|
106
152
|
while (request.changed && this.#requests.get(key) === request) {
|
|
107
153
|
request.changed = false;
|
|
154
|
+
if (this.#refusedMemory.get(statementKey(request.params, request.statement)) === this.#memoryVersion) {
|
|
155
|
+
// The same statement of the same file was refused memory in this
|
|
156
|
+
// very state of memory: so is this one, without reading it.
|
|
157
|
+
request.kind = "needs-memory";
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
108
160
|
await this.#read(request.params, request.statement);
|
|
109
161
|
}
|
|
110
162
|
} catch (error) {
|
|
@@ -118,3 +170,7 @@ export class MediaReadRequests {
|
|
|
118
170
|
function requestKey(params, statement) {
|
|
119
171
|
return JSON.stringify([params.sourceKey, params.fileIndex, statement, params.requestId ?? null]);
|
|
120
172
|
}
|
|
173
|
+
|
|
174
|
+
function statementKey(params, statement) {
|
|
175
|
+
return JSON.stringify([params.sourceKey, params.fileIndex, statement]);
|
|
176
|
+
}
|
|
@@ -404,8 +404,19 @@ export class AviContainer extends Container {
|
|
|
404
404
|
packets.append(track.trackNumber, { pts: stream.times[previous], duration: Math.max(0, end - stream.times[previous]),
|
|
405
405
|
keyframe: true, ranges: [[stream.starts[previous], stream.starts[previous] + stream.lengths[previous] - 1]] });
|
|
406
406
|
};
|
|
407
|
+
// THE THREAD IS GIVEN BACK at each portion of the file the table
|
|
408
|
+
// reaches: a two-hour soundtrack is some 300 000 packets, and built in
|
|
409
|
+
// one stretch it held the main thread with the map's first pass for
|
|
410
|
+
// 5-7 s while FFmpeg's first open waited for its answers
|
|
411
|
+
// (torrent-tv/meta#178).
|
|
412
|
+
let portion = -1;
|
|
407
413
|
for (let at = 0; at < stream.count; at++) {
|
|
408
414
|
if (!stream.lengths[at]) continue;
|
|
415
|
+
const reached = Math.floor(stream.starts[at] / this.portionBytes);
|
|
416
|
+
if (Number.isFinite(reached) && reached !== portion) {
|
|
417
|
+
if (portion >= 0) await new Promise(resolve => setImmediate(resolve));
|
|
418
|
+
portion = reached;
|
|
419
|
+
}
|
|
409
420
|
if (previous >= 0) append(stream.times[at]);
|
|
410
421
|
previous = at;
|
|
411
422
|
}
|
|
@@ -646,6 +646,14 @@ export class ViewerRequests {
|
|
|
646
646
|
processingRanges: liveRuns.map(({ progress }) => ({
|
|
647
647
|
start: progress.startPositionSeconds, end: progress.processedSeconds
|
|
648
648
|
})),
|
|
649
|
+
// What a run's start costs this output, and the stretches its live
|
|
650
|
+
// runs were given: the forecast starts a run wherever production meets
|
|
651
|
+
// bytes that were not held when the run before was admitted.
|
|
652
|
+
openSeconds: this.#host.openSecondsOf?.(output) ?? null,
|
|
653
|
+
runSpans: liveRuns.map(run => ({
|
|
654
|
+
start: output.timeline?.publishedStartOf?.(run.from),
|
|
655
|
+
end: Number.isInteger(run.to) && run.to >= run.from ? output.timeline?.publishedStartOf?.(run.to + 1) : Number.POSITIVE_INFINITY
|
|
656
|
+
})),
|
|
649
657
|
bitsPerMediaSecond: Number.isFinite(observedMbps) && observedMbps > 0 ? observedMbps * 1_000_000 :
|
|
650
658
|
this.#host.initialOutputBitsPerMediaSecondOf?.(output) ?? 0,
|
|
651
659
|
readings: trackRates,
|
|
@@ -705,7 +713,8 @@ export class ViewerRequests {
|
|
|
705
713
|
const forecast = withSubtitleReadiness(this.#host.playbackReadiness.predict(input), subtitle,
|
|
706
714
|
subtitle ? this.#host.subtitleReadyFor?.(viewer) !== false : true);
|
|
707
715
|
forecast.operations = outputs.flatMap((output, index) => {
|
|
708
|
-
const common = { speed: this.#host.playbackReadiness.forecastRate(tracks[index].readings
|
|
716
|
+
const common = { speed: this.#host.playbackReadiness.forecastRate(tracks[index].readings, now, 0, "piece"),
|
|
717
|
+
processedSeconds: tracks[index].processedSeconds };
|
|
709
718
|
return [
|
|
710
719
|
...(output.spec.video ? [{ ...common, track: "video",
|
|
711
720
|
operation: output.spec.transcodesVideo ? "encode" : "copy",
|
|
@@ -738,9 +747,49 @@ export class ViewerRequests {
|
|
|
738
747
|
}))
|
|
739
748
|
})}`);
|
|
740
749
|
}
|
|
750
|
+
this.#sayHolding(session, consumerId, measurement, forecast, input, now);
|
|
741
751
|
return forecast;
|
|
742
752
|
}
|
|
743
753
|
|
|
754
|
+
/**
|
|
755
|
+
* What holds a waiting viewer's start, said when it changes: which piece,
|
|
756
|
+
* its production or its delivery, when it comes against the media before it,
|
|
757
|
+
* and the rates the forecast used. Said again when the delay has moved by a
|
|
758
|
+
* second, at most once a second; a hold that changes nothing says nothing.
|
|
759
|
+
*
|
|
760
|
+
* @param {object} session
|
|
761
|
+
* @param {string} consumerId
|
|
762
|
+
* @param {object} measurement - This viewer's forecast state.
|
|
763
|
+
* @param {object} forecast
|
|
764
|
+
* @param {object} input
|
|
765
|
+
* @param {number} now
|
|
766
|
+
*/
|
|
767
|
+
#sayHolding(session, consumerId, measurement, forecast, input, now) {
|
|
768
|
+
const holding = forecast.holdingBy;
|
|
769
|
+
if (!holding) {
|
|
770
|
+
measurement.held = null;
|
|
771
|
+
return;
|
|
772
|
+
}
|
|
773
|
+
const key = `${holding.kind}:${holding.track}:${holding.segment}`;
|
|
774
|
+
const delay = Number.isFinite(forecast.delaySeconds) ? forecast.delaySeconds : null;
|
|
775
|
+
const said = measurement.held;
|
|
776
|
+
if (said && said.key === key && (delay === null || said.delay === null || Math.abs(delay - said.delay) < 1 ||
|
|
777
|
+
now - said.at < 1000)) return;
|
|
778
|
+
measurement.held = { key, delay, at: now };
|
|
779
|
+
const seconds = value => Number.isFinite(value) ? value.toFixed(2) : "?";
|
|
780
|
+
const rates = (forecast.operations ?? []).map(operation =>
|
|
781
|
+
`${operation.track} ${operation.operation} ${Number.isFinite(operation.speed) ? operation.speed.toFixed(2) : "?"}x`);
|
|
782
|
+
const downloads = (input.sources ?? []).map(source => {
|
|
783
|
+
const rate = source.downloadRateReadings?.lastValue;
|
|
784
|
+
return `${Number.isFinite(rate) ? (rate / 1_048_576).toFixed(2) : "?"} MB/s`;
|
|
785
|
+
});
|
|
786
|
+
logger.info(`playback held ${session.id} consumer=${consumerId}: delay ${seconds(delay)}s by ` +
|
|
787
|
+
(holding.kind === "position-not-held" ? "the position, which nothing buffered covers" :
|
|
788
|
+
`the ${holding.kind} of ${holding.track} #${holding.segment} in ${seconds(holding.atSeconds)}s against ` +
|
|
789
|
+
`${seconds(holding.aheadSeconds)}s of media before it${holding.requiresSource ? ", its input still downloading" : ""}`) +
|
|
790
|
+
`; buffered ${seconds(forecast.bufferedSeconds)}s; rates ${rates.join(", ") || "none"}; download ${downloads.join(", ") || "none"}`);
|
|
791
|
+
}
|
|
792
|
+
|
|
744
793
|
/**
|
|
745
794
|
* The pieces of one output as the start forecast reads them, kept between
|
|
746
795
|
* polls: a piece whose playlist edges, read ranges and torrent inputs have
|
|
@@ -815,10 +864,17 @@ export class ViewerRequests {
|
|
|
815
864
|
const reading = this.#host.encodeSpeedReadingOf?.(output, now) ?? null;
|
|
816
865
|
if (Number.isFinite(reading?.speed) && reading.speed > 0 && Number.isFinite(reading.at)) {
|
|
817
866
|
trend.add(reading.at, reading.speed);
|
|
818
|
-
} else {
|
|
819
|
-
|
|
867
|
+
} else if (!trend.snapshot()) {
|
|
868
|
+
// ONLY UNTIL A PIECE IS MEASURED does the prediction stand for the
|
|
869
|
+
// encoder, and it is not kept among the readings. Added on every poll
|
|
870
|
+
// between runs, it put the machine's share of that moment — 1 % at a
|
|
871
|
+
// cold start — on the whole film, and the forecast read 0.03x
|
|
872
|
+
// (torrent-tv/meta#178).
|
|
873
|
+
const projected = Number(this.#host.predictedEncodeSpeedOf?.(output) ?? this.#host.projectedEncodeSpeedOf?.(output));
|
|
820
874
|
if (Number.isFinite(projected) && projected > 0) {
|
|
821
|
-
|
|
875
|
+
const seed = new this.#host.playbackReadiness.RateTrend();
|
|
876
|
+
seed.add(now, projected);
|
|
877
|
+
return seed.snapshot();
|
|
822
878
|
}
|
|
823
879
|
}
|
|
824
880
|
return trend.snapshot();
|
|
@@ -579,13 +579,16 @@ export function wireOutputs({
|
|
|
579
579
|
sourceInputsFor: (output, index) => parts.sourceInputsFor?.(output, index),
|
|
580
580
|
encodeSpeedReadingOf: (output) => parts.encodeCost.latestSpeedReadingOf(output),
|
|
581
581
|
projectedEncodeSpeedOf: (output) => parts.encodeCost.projectedSpeedOf(output),
|
|
582
|
+
// The speed the offer predicted for this output when it chose its mode: a
|
|
583
|
+
// forecast with no measured piece yet stands on it rather than on the
|
|
584
|
+
// machine's share of the moment (torrent-tv/meta#178).
|
|
585
|
+
predictedEncodeSpeedOf: (output) => {
|
|
586
|
+
const predicted = Number(qualityStateOf(output).predictedSpeedWhenOffered);
|
|
587
|
+
return Number.isFinite(predicted) && predicted > 0 ? predicted : null;
|
|
588
|
+
},
|
|
582
589
|
audioDescriptionOf,
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
codec: audioDescriptionOf(output)?.codec ?? output.file.media?.audioCodec,
|
|
586
|
-
transcode: output.spec.audio?.transcode })?.startupSeconds ?? 0 :
|
|
587
|
-
Math.max(0, (startStopCost?.firstByteWaitSec ?? 0) - parts.segmentDurationSec /
|
|
588
|
-
parts.encodeCost.projectedSpeedOf(output)),
|
|
590
|
+
openSecondsOf: (output) => runStartSecondsOf(output),
|
|
591
|
+
processingStartupSecondsOf: (output) => runStartSecondsOf(output),
|
|
589
592
|
initialOutputBitsPerMediaSecondOf: (output) => {
|
|
590
593
|
const description = audioDescriptionOf(output);
|
|
591
594
|
const reference = audioReadingFor(audioCalibration, { codec: description?.codec, transcode: false });
|
|
@@ -1008,7 +1011,7 @@ export function wireOutputs({
|
|
|
1008
1011
|
resolve: resolveEncodeInput,
|
|
1009
1012
|
readRanges: readEncodeRanges,
|
|
1010
1013
|
heldRanges: readEncodeHeldRanges,
|
|
1011
|
-
reviseBudget: () => parts.machineBudget.revise(),
|
|
1014
|
+
reviseBudget: () => parts.machineBudget.revise("memory", "admitted encode inputs"),
|
|
1012
1015
|
capacity: () => parts.machineBudget.capacityOf("memory"),
|
|
1013
1016
|
// How long this output's input takes to open, from the one owner of start
|
|
1014
1017
|
// readings (torrent-tv/meta#184).
|
|
@@ -1045,6 +1048,27 @@ export function wireOutputs({
|
|
|
1045
1048
|
readFree: freeBytesFor,
|
|
1046
1049
|
logger
|
|
1047
1050
|
});
|
|
1051
|
+
/**
|
|
1052
|
+
* What starting a run costs this output before its first piece is encoded:
|
|
1053
|
+
* its own measured open where it has one (`RunCosts.openOf`), else the
|
|
1054
|
+
* host's startup reading of spawn to first piece less that piece's encoding;
|
|
1055
|
+
* for sound, its packaged startup reading.
|
|
1056
|
+
*
|
|
1057
|
+
* @param {object} output
|
|
1058
|
+
* @returns {number}
|
|
1059
|
+
*/
|
|
1060
|
+
function runStartSecondsOf(output) {
|
|
1061
|
+
if (output.spec.carries === "audio-only") {
|
|
1062
|
+
return audioReadingFor(audioCalibration, { ...audioDescriptionOf(output),
|
|
1063
|
+
codec: audioDescriptionOf(output)?.codec ?? output.file.media?.audioCodec,
|
|
1064
|
+
transcode: output.spec.audio?.transcode })?.startupSeconds ?? 0;
|
|
1065
|
+
}
|
|
1066
|
+
const open = parts.encodeOrchestrator?.openOf?.(output.outputKey ?? "");
|
|
1067
|
+
if (open?.measured) return open.seconds;
|
|
1068
|
+
return Math.max(0, (startStopCost?.firstByteWaitSec ?? 0) - parts.segmentDurationSec /
|
|
1069
|
+
parts.encodeCost.projectedSpeedOf(output));
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1048
1072
|
// Realtime-budget monitor: only meaningful for the software encoder with a
|
|
1049
1073
|
// benchmark (the only path that can pick/step resolution). Cheap no-op scan
|
|
1050
1074
|
// otherwise.
|
|
@@ -77,6 +77,14 @@ import { OtherDemand, divideAllowance } from "./allowance.js";
|
|
|
77
77
|
export class MachineBudget {
|
|
78
78
|
#revisionPromise = null;
|
|
79
79
|
#reviseAgain = false;
|
|
80
|
+
/**
|
|
81
|
+
* The resources the next pass reads: `null` for every one, else their names.
|
|
82
|
+
* A claimant short of memory asks for memory alone; reading the disks first
|
|
83
|
+
* made its answer wait for them (torrent-tv/meta#178).
|
|
84
|
+
*
|
|
85
|
+
* @type {Set<string> | null}
|
|
86
|
+
*/
|
|
87
|
+
#againScope = null;
|
|
80
88
|
/**
|
|
81
89
|
* The revision in hand, for its own line: when it was first asked for, how
|
|
82
90
|
* many asks joined it, and which pass this is. At a cold start on the addon
|
|
@@ -151,32 +159,45 @@ export class MachineBudget {
|
|
|
151
159
|
}
|
|
152
160
|
|
|
153
161
|
/**
|
|
154
|
-
* Read
|
|
162
|
+
* Read the resources, divide each, and tell each claimant its share.
|
|
155
163
|
*
|
|
164
|
+
* @param {string | null} [resource] - One resource to read and divide; every
|
|
165
|
+
* resource when absent. The others keep their last division.
|
|
166
|
+
* @param {string | null} [because] - Who asked, said in the revision's line.
|
|
156
167
|
* @returns {Promise<Map<string, { freeBytes: number, allowanceBytes: number, shares: object[] }>>}
|
|
157
168
|
*/
|
|
158
|
-
revise() {
|
|
169
|
+
revise(resource = null, because = null) {
|
|
170
|
+
const scope = resource === null ? null : new Set([resource]);
|
|
159
171
|
if (this.#revisionPromise) {
|
|
160
172
|
this.#reviseAgain = true;
|
|
173
|
+
this.#againScope = this.#againScope === null || scope === null ? null : new Set([...this.#againScope, ...scope]);
|
|
161
174
|
this.#revision.asks += 1;
|
|
162
175
|
return this.#revisionPromise;
|
|
163
176
|
}
|
|
164
|
-
this.#revision = { askedAt: Date.now(), asks: 1, pass: 0 };
|
|
177
|
+
this.#revision = { askedAt: Date.now(), asks: 1, pass: 0, because };
|
|
178
|
+
this.#againScope = scope;
|
|
165
179
|
this.#revisionPromise = (async () => {
|
|
166
180
|
do {
|
|
181
|
+
const passScope = this.#againScope;
|
|
182
|
+
this.#againScope = new Set();
|
|
167
183
|
this.#reviseAgain = false;
|
|
168
184
|
this.#revision.pass += 1;
|
|
169
|
-
await this.#readRevision();
|
|
185
|
+
await this.#readRevision(passScope);
|
|
170
186
|
} while (this.#reviseAgain);
|
|
171
187
|
return this.#last;
|
|
172
188
|
})().finally(() => { this.#revisionPromise = null; });
|
|
173
189
|
return this.#revisionPromise;
|
|
174
190
|
}
|
|
175
191
|
|
|
176
|
-
|
|
192
|
+
/** @param {Set<string> | null} scope */
|
|
193
|
+
async #readRevision(scope) {
|
|
177
194
|
const passFrom = Date.now();
|
|
178
195
|
let readingMs = 0;
|
|
196
|
+
const readings = [];
|
|
179
197
|
for (const [name, resource] of this.#resources) {
|
|
198
|
+
if (scope !== null && !scope.has(name)) {
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
180
201
|
const claimants = this.#claimants.filter((one) => one.resource === name);
|
|
181
202
|
if (claimants.length === 0) {
|
|
182
203
|
continue;
|
|
@@ -185,6 +206,7 @@ export class MachineBudget {
|
|
|
185
206
|
const readFrom = Date.now();
|
|
186
207
|
const free = await resource.readFree();
|
|
187
208
|
readingMs += Date.now() - readFrom;
|
|
209
|
+
readings.push(`${name} ${Date.now() - readFrom}ms`);
|
|
188
210
|
// WITHOUT A READING, NOTHING IS ALLOWED TO GROW. A resource whose free
|
|
189
211
|
// space cannot be read is not a resource with room; answering "unbounded"
|
|
190
212
|
// there is how the spill file came to have no limit in the first place.
|
|
@@ -213,8 +235,9 @@ export class MachineBudget {
|
|
|
213
235
|
}
|
|
214
236
|
const revision = this.#revision;
|
|
215
237
|
this.#logger?.info?.(`${this.describe()}` + (revision
|
|
216
|
-
? ` (pass ${revision.pass} of a revision asked ${revision.asks} time(s), first ${Date.now() - revision.askedAt}ms ago
|
|
217
|
-
|
|
238
|
+
? ` (pass ${revision.pass} of a revision asked ${revision.asks} time(s), first ${Date.now() - revision.askedAt}ms ago` +
|
|
239
|
+
`${revision.because ? `, first by ${revision.because}` : ""}; ` +
|
|
240
|
+
`this pass ${Date.now() - passFrom}ms, ${readingMs}ms of it reading what is free: ${readings.join(", ") || "nothing"})`
|
|
218
241
|
: ""));
|
|
219
242
|
return this.#last;
|
|
220
243
|
}
|
package/services/storage/wire.js
CHANGED
|
@@ -153,8 +153,9 @@ export function wireMachineBudget({
|
|
|
153
153
|
});
|
|
154
154
|
}
|
|
155
155
|
|
|
156
|
+
let evidenceOn = null;
|
|
156
157
|
if (diagnostics) {
|
|
157
|
-
|
|
158
|
+
evidenceOn = diagnosticsRoot ? deviceOf(diagnosticsRoot) : segmentsOn;
|
|
158
159
|
defineDisk(evidenceOn, diagnosticsRoot || segmentStore.root);
|
|
159
160
|
budget.register({
|
|
160
161
|
name: "diagnostics",
|
|
@@ -169,11 +170,22 @@ export function wireMachineBudget({
|
|
|
169
170
|
|
|
170
171
|
return {
|
|
171
172
|
capacityOf: name => budget.capacityOf(name),
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
173
|
+
/**
|
|
174
|
+
* @param {string | null} [resource] - One resource to revise; all when absent.
|
|
175
|
+
* @param {string | null} [because] - Who asked, for the revision's line.
|
|
176
|
+
*/
|
|
177
|
+
revise: async (resource = null, because = null) => {
|
|
178
|
+
// Re-read the evidence before dividing its disk: it is the one claimant
|
|
179
|
+
// whose bytes are written by something that never reports them. Only
|
|
180
|
+
// there: a claimant short of memory asks for memory alone, and walking
|
|
181
|
+
// the evidence's directory first made every memory answer wait for it —
|
|
182
|
+
// 3.7 s on a cold AVI behind 1943 other reads (torrent-tv/meta#178).
|
|
183
|
+
if (diagnostics && (resource === null || resource === evidenceOn)) {
|
|
184
|
+
const from = Date.now();
|
|
185
|
+
await diagnostics.measure();
|
|
186
|
+
because = `${because ?? "a full revision"}, after the evidence was measured in ${Date.now() - from}ms`;
|
|
187
|
+
}
|
|
188
|
+
return budget.revise(resource, because);
|
|
177
189
|
},
|
|
178
190
|
segmentBytes: () => segmentBytes,
|
|
179
191
|
diagnostics: diagnostics ?? null,
|
|
@@ -2,9 +2,11 @@ import { pieceStoreOf } from "../piece-store-of.js";
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* How many pieces one read for a reader asks of the store at once: the threads
|
|
5
|
-
* libuv runs file operations on (`UV_THREADPOOL_SIZE
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* libuv runs file operations on (`UV_THREADPOOL_SIZE`). The proxy's entry point
|
|
6
|
+
* sets 64 where the deployment states none (`bin/thread-pool.js`), and libuv's
|
|
7
|
+
* own default of four applies only to code run without it, such as the
|
|
8
|
+
* checks. A piece from disk is three of their operations, and more at once
|
|
9
|
+
* would only wait behind them (torrent-tv/meta#178).
|
|
8
10
|
*/
|
|
9
11
|
const FILE_READS_AT_ONCE = Math.max(1, Number.parseInt(process.env.UV_THREADPOOL_SIZE ?? "", 10) || 4);
|
|
10
12
|
|
|
@@ -225,8 +227,8 @@ export async function readHeldRanges(torrent, fileIndex, ranges, maxBytes, logge
|
|
|
225
227
|
// and the loop runs 0.3-0.7 s late while the swarm delivers 7-14 MB/s: a
|
|
226
228
|
// 210 MB stretch took 11 s here (Home Assistant 2026-10-12,
|
|
227
229
|
// torrent-tv/meta#178). As many are in flight as the runtime has threads
|
|
228
|
-
// for file operations (libuv's pool, `UV_THREADPOOL_SIZE`,
|
|
229
|
-
//
|
|
230
|
+
// for file operations (libuv's pool, `UV_THREADPOOL_SIZE`, 64 as the proxy
|
|
231
|
+
// starts); more would only queue behind them and ahead of everything else
|
|
230
232
|
// this process reads or writes.
|
|
231
233
|
const readOne = async ([index, span]) => {
|
|
232
234
|
const pieceStart = index * pieceLength;
|
|
@@ -49,8 +49,8 @@ const CLIENT_RANGE_START_TOLERANCE_SECONDS = 0.25;
|
|
|
49
49
|
* @param {number} horizonSeconds
|
|
50
50
|
* @returns {number | null}
|
|
51
51
|
*/
|
|
52
|
-
export function forecastRate(readings, now = Date.now(), horizonSeconds = 0) {
|
|
53
|
-
const curve = rateCurve(readings, now);
|
|
52
|
+
export function forecastRate(readings, now = Date.now(), horizonSeconds = 0, weighting = "last") {
|
|
53
|
+
const curve = rateCurve(readings, now, weighting);
|
|
54
54
|
if (!curve) {
|
|
55
55
|
return null;
|
|
56
56
|
}
|
|
@@ -192,7 +192,12 @@ export function predictPlaybackReadiness(input = {}) {
|
|
|
192
192
|
[{ start: at, end: add(at, measured ? nearest(fromSeconds(buffered), unit) :
|
|
193
193
|
rescale(fromSeconds(buffered), unit)), frame: zero }],
|
|
194
194
|
ready: new Set(Array.isArray(track.readySegmentIndices) ? track.readySegmentIndices : []),
|
|
195
|
-
|
|
195
|
+
// Film made over the encoder's working time, over the readings the trend
|
|
196
|
+
// keeps: one reading is one closed piece, so the harmonic mean is that
|
|
197
|
+
// ratio. The last reading alone is one piece's content and machine — a
|
|
198
|
+
// first piece of credits read 6.49x and started a cold AVI on 2.4 s of
|
|
199
|
+
// buffer that the next piece, at 0.57x, did not hold (torrent-tv/meta#178).
|
|
200
|
+
curve: rateCurve(track.readings, now, "piece"),
|
|
196
201
|
bitsPerMediaSecond: averageBitsPerMediaSecond(track, track.segments,
|
|
197
202
|
new Set(Array.isArray(track.readySegmentIndices) ? track.readySegmentIndices : [])),
|
|
198
203
|
segments: segmentsOf[trackIndex]
|
|
@@ -211,7 +216,10 @@ export function predictPlaybackReadiness(input = {}) {
|
|
|
211
216
|
const serviceKey = typeof source.serviceKey === "string" ? source.serviceKey : id;
|
|
212
217
|
let service = sourceServices.get(serviceKey);
|
|
213
218
|
if (!service) {
|
|
214
|
-
|
|
219
|
+
// Bytes over time across the readings the trend keeps: the last 5 s of a
|
|
220
|
+
// swarm moved between 0.97 and 5.8 MB/s and was carried to the end of the
|
|
221
|
+
// film each time (torrent-tv/meta#178).
|
|
222
|
+
service = { rate: rateCurve(source.downloadRateReadings, now, "time"), finish: 0 };
|
|
215
223
|
sourceServices.set(serviceKey, service);
|
|
216
224
|
}
|
|
217
225
|
sourceState.set(id, { source, service });
|
|
@@ -330,6 +338,22 @@ export function predictPlaybackReadiness(input = {}) {
|
|
|
330
338
|
}
|
|
331
339
|
}
|
|
332
340
|
const trackFinish = trackState.map(({ track }) => finiteNonNegative(track.startupRemainingSeconds));
|
|
341
|
+
// WHERE A RUN STARTS, AND WHAT IT COSTS. A run is given the stretch whose
|
|
342
|
+
// bytes are held when its input is admitted, and the next one starts after
|
|
343
|
+
// it, when more bytes are held, and opens its input first — 6-10 s on an
|
|
344
|
+
// OpenDML AVI. Charged once at the first start only, a cold AVI made of runs
|
|
345
|
+
// of one to five pieces was forecast as one run (torrent-tv/meta#178). So a
|
|
346
|
+
// piece outside the live runs' stretches whose bytes arrive after the run
|
|
347
|
+
// the schedule last started was admitted begins a run of its own, admitted
|
|
348
|
+
// when the piece before it is made and its bytes are held, then opened.
|
|
349
|
+
// The first run's start is `startupRemainingSeconds`, charged once above; a
|
|
350
|
+
// stretch's bound by memory and by its copy time is not modelled.
|
|
351
|
+
const runStarts = trackState.map(({ track }) => ({
|
|
352
|
+
openSeconds: finiteNonNegative(track.openSeconds),
|
|
353
|
+
spans: (Array.isArray(track.runSpans) ? track.runSpans : [])
|
|
354
|
+
.filter(span => Number.isFinite(span?.start) && span.end > span.start),
|
|
355
|
+
admittedAt: finiteNonNegative(track.startupRemainingSeconds) > 0 ? 0 : null
|
|
356
|
+
}));
|
|
333
357
|
const sourceByteCoverage = new Map([...sourceByteService].map(([id, ranges]) => [id, numericCoverage(ranges)]));
|
|
334
358
|
const sourceArrivals = new Map([...sourceByteService].map(([id, ranges]) => [id, indexedArrivals(ranges)]));
|
|
335
359
|
let transferFinish = 0;
|
|
@@ -386,7 +410,15 @@ export function predictPlaybackReadiness(input = {}) {
|
|
|
386
410
|
// Production accepts a complete held segment input. It cannot overlap its
|
|
387
411
|
// own source download; reused input retains its original arrival time.
|
|
388
412
|
const processing = trackState[trackIndex].curve;
|
|
389
|
-
|
|
413
|
+
let admittedAt = Math.max(trackFinish[trackIndex], sourceArrivalAt);
|
|
414
|
+
if (!job.produced && encodeWork > 0) {
|
|
415
|
+
const run = runStarts[trackIndex];
|
|
416
|
+
const live = run.spans.some(span => segment.startSeconds >= span.start && segment.startSeconds < span.end);
|
|
417
|
+
if (!live && (run.admittedAt === null || sourceArrivalAt > run.admittedAt)) {
|
|
418
|
+
run.admittedAt = admittedAt;
|
|
419
|
+
admittedAt += run.openSeconds;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
390
422
|
const producedAt = job.produced || encodeWork === 0 ? 0 : processing.finish(encodeWork, admittedAt);
|
|
391
423
|
trackFinish[trackIndex] = Math.max(trackFinish[trackIndex], producedAt);
|
|
392
424
|
productions.push({ at: producedAt, trackIndex, segment, requiresSource: sourceArrivalAt > 0 });
|
|
@@ -396,6 +428,16 @@ export function predictPlaybackReadiness(input = {}) {
|
|
|
396
428
|
}
|
|
397
429
|
|
|
398
430
|
const constraints = [];
|
|
431
|
+
// WHICH CONSTRAINT HOLDS THE START, said with the answer: the delay is the
|
|
432
|
+
// largest of thousands, and the log could only say that some term was
|
|
433
|
+
// positive while the picture stood for 17 s (torrent-tv/meta#178).
|
|
434
|
+
let holding = null;
|
|
435
|
+
const constrain = (value, kind, trackIndex, segment, atSeconds, aheadSeconds, requiresSource) => {
|
|
436
|
+
constraints.push(value);
|
|
437
|
+
if (value > 0 && (holding === null || value > holding.value)) {
|
|
438
|
+
holding = { value, kind, trackIndex, segment, atSeconds, aheadSeconds, requiresSource };
|
|
439
|
+
}
|
|
440
|
+
};
|
|
399
441
|
const end = rescale(fromSeconds(duration), unit);
|
|
400
442
|
for (let trackIndex = 0; trackIndex < trackState.length; trackIndex += 1) {
|
|
401
443
|
const state = trackState[trackIndex];
|
|
@@ -407,7 +449,8 @@ export function predictPlaybackReadiness(input = {}) {
|
|
|
407
449
|
const before = contiguousEnd(delivered, at);
|
|
408
450
|
for (const range of completion.segment.ranges) addMediaRange(delivered, range);
|
|
409
451
|
const after = contiguousEnd(delivered, at);
|
|
410
|
-
|
|
452
|
+
const ahead = secondsAhead(earliest(before, trackEnd), at);
|
|
453
|
+
constrain(completion.at - ahead, "delivery", trackIndex, completion.segment.index, completion.at, ahead, false);
|
|
411
454
|
if (compare(after, before) <= 0 && completion.segment.ranges.some(({ start }) => compare(start, before) > 0)) {
|
|
412
455
|
// A timestamp hole cannot be repaired by waiting longer. Keep it
|
|
413
456
|
// distinct from an unavailable rate or a slow but finite service.
|
|
@@ -425,8 +468,10 @@ export function predictPlaybackReadiness(input = {}) {
|
|
|
425
468
|
for (const range of production.segment.ranges) addMediaRange(prepared, range);
|
|
426
469
|
// Supply interruption coverage is stock held by the proxy as well as
|
|
427
470
|
// the browser. It is not a browser buffer-size admission rule.
|
|
428
|
-
|
|
429
|
-
Math.min(reserve, secondsAhead(trackEnd, before)) * Number(production.requiresSource))
|
|
471
|
+
const ahead = Math.max(0, secondsAhead(before, at) -
|
|
472
|
+
Math.min(reserve, secondsAhead(trackEnd, before)) * Number(production.requiresSource));
|
|
473
|
+
constrain(production.at - ahead, "production", trackIndex, production.segment.index, production.at, ahead,
|
|
474
|
+
production.requiresSource);
|
|
430
475
|
}
|
|
431
476
|
}
|
|
432
477
|
const delay = Math.max(0, ...constraints);
|
|
@@ -436,6 +481,13 @@ export function predictPlaybackReadiness(input = {}) {
|
|
|
436
481
|
Math.max(0, reserve - buffered), ready ? 'trajectory-safe-now' :
|
|
437
482
|
Number.isFinite(delay) ? 'minimum-safe-delay' : 'service-not-advancing', preparedSegments);
|
|
438
483
|
return { ...forecast, measuredAt: now, predictedStartAt: Number.isFinite(delay) ? now + delay * 1000 : null,
|
|
484
|
+
// The piece whose `kind` — its production, or its delivery to the page —
|
|
485
|
+
// comes `atSeconds` from now while the media before it lasts
|
|
486
|
+
// `aheadSeconds`: the difference is the delay.
|
|
487
|
+
holdingBy: ready ? null : holding === null ? (originReady ? null : { kind: "position-not-held" })
|
|
488
|
+
: { kind: holding.kind, track: tracks[holding.trackIndex]?.id ?? holding.trackIndex,
|
|
489
|
+
segment: holding.segment, atSeconds: holding.atSeconds, aheadSeconds: holding.aheadSeconds,
|
|
490
|
+
requiresSource: holding.requiresSource },
|
|
439
491
|
preparedUntilSeconds: Math.min(duration, ...trackState.map((state, index) => {
|
|
440
492
|
const ranges = state.clientRanges.map((range) => ({ ...range }));
|
|
441
493
|
for (const item of productions.filter((item) => item.trackIndex === index && item.at <= delay)) {
|
|
@@ -767,11 +819,19 @@ function roundedHalfUp(numerator, denominator) {
|
|
|
767
819
|
return 2n * (numerator - floor * denominator) >= denominator ? floor + 1n : floor;
|
|
768
820
|
}
|
|
769
821
|
|
|
770
|
-
|
|
822
|
+
/**
|
|
823
|
+
* @param {*} readings - A summary (`RateTrend.snapshot`) or the readings themselves.
|
|
824
|
+
* @param {number} now
|
|
825
|
+
* @param {"last" | "piece" | "time"} [weighting] - Which rate the readings
|
|
826
|
+
* stand for: the last one; work per working time where each reading is one
|
|
827
|
+
* piece of film; or quantity per wall time where each reading holds until
|
|
828
|
+
* the next.
|
|
829
|
+
*/
|
|
830
|
+
function rateCurve(readings, now, weighting = "last") {
|
|
771
831
|
if (readings && Number.isFinite(readings.count) && readings.count > 0 &&
|
|
772
832
|
Number.isFinite(readings.lastAt) && Number.isFinite(readings.lastValue) &&
|
|
773
833
|
Number.isFinite(readings.meanValue) && Number.isFinite(readings.spanSeconds)) {
|
|
774
|
-
return rateCurveFrom(readings,
|
|
834
|
+
return rateCurveFrom(readings, weighting);
|
|
775
835
|
}
|
|
776
836
|
const ordered = (Array.isArray(readings) ? readings : [])
|
|
777
837
|
.filter((reading) => Number.isFinite(reading?.at) && Number.isFinite(reading?.value) && reading.value >= 0)
|
|
@@ -782,25 +842,38 @@ function rateCurve(readings, now) {
|
|
|
782
842
|
return null;
|
|
783
843
|
}
|
|
784
844
|
|
|
785
|
-
return rateCurveFrom(summarizeRate(ordered),
|
|
845
|
+
return rateCurveFrom(summarizeRate(ordered), weighting);
|
|
786
846
|
}
|
|
787
847
|
|
|
788
848
|
function summarizeRate(readings) {
|
|
789
849
|
const latest = readings.at(-1);
|
|
850
|
+
let weighted = 0;
|
|
851
|
+
for (let index = 0; index + 1 < readings.length; index += 1) {
|
|
852
|
+
weighted += readings[index].value * (readings[index + 1].at - readings[index].at);
|
|
853
|
+
}
|
|
854
|
+
const spanMs = latest.at - readings[0].at;
|
|
790
855
|
return {
|
|
791
856
|
count: readings.length,
|
|
792
857
|
lastAt: latest.at,
|
|
793
858
|
lastValue: latest.value,
|
|
794
859
|
meanValue: readings.reduce((sum, reading) => sum + reading.value, 0) / readings.length,
|
|
795
|
-
|
|
860
|
+
// Each reading one piece of work: work over the time it took.
|
|
861
|
+
harmonicValue: readings.some(reading => !(reading.value > 0)) ? 0
|
|
862
|
+
: readings.length / readings.reduce((sum, reading) => sum + 1 / reading.value, 0),
|
|
863
|
+
// Each reading holding until the next: quantity over the span.
|
|
864
|
+
timeWeightedValue: spanMs > 0 ? weighted / spanMs : latest.value,
|
|
865
|
+
spanSeconds: spanMs / 1000
|
|
796
866
|
};
|
|
797
867
|
}
|
|
798
868
|
|
|
799
|
-
function rateCurveFrom(
|
|
800
|
-
// Conditional projection of the
|
|
869
|
+
function rateCurveFrom(summary, weighting = "last") {
|
|
870
|
+
// Conditional projection of the measured service. No exponential
|
|
801
871
|
// relaxation, smoothing time, slope extrapolation or guessed recovery.
|
|
802
872
|
// A new measurement changes the integral; polling does not change its rate.
|
|
803
|
-
const
|
|
873
|
+
const value = weighting === "piece" && Number.isFinite(summary.harmonicValue) ? summary.harmonicValue
|
|
874
|
+
: weighting === "time" && Number.isFinite(summary.timeWeightedValue) ? summary.timeWeightedValue
|
|
875
|
+
: summary.lastValue;
|
|
876
|
+
const rate = Math.max(0, value);
|
|
804
877
|
const workBy = (seconds) => rate * Math.max(0, seconds);
|
|
805
878
|
return {
|
|
806
879
|
rateAt: () => rate,
|