@torrent-tv/proxy 2.97.14 → 2.97.16
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/server.js +62 -12
- package/services/encode/LocalObservations.js +5 -1
- package/services/encode/available-share.js +49 -18
- package/services/encode/quality/EncodeCost.js +22 -10
- package/services/encode/quality/HostLoad.js +2 -1
- package/services/encode/quality/QualityOffer.js +21 -4
- package/services/media/MediaReadRequests.js +59 -3
- package/services/media/container/AviContainer.js +1 -1
- package/services/media/container/avi-index.js +10 -0
- package/services/server/ViewerRequests.js +109 -15
- package/services/server/wire-outputs.js +2 -1
- package/services/storage/MachineBudget.js +30 -7
- package/services/storage/piece-store/shared-piece-store.js +41 -0
- package/services/storage/wire.js +18 -6
- package/services/torrent/worker/held-bytes.js +51 -4
- package/services/viewer/playback-readiness.js +172 -23
package/package.json
CHANGED
package/server.js
CHANGED
|
@@ -180,6 +180,7 @@ export async function startProxyServer({
|
|
|
180
180
|
indexMemory.forget(sourceKey);
|
|
181
181
|
downloadMaps.retire(sourceKey);
|
|
182
182
|
for (const key of segmentSourceRanges.keys()) if (key.startsWith(`${sourceKey}:`)) segmentSourceRanges.delete(key);
|
|
183
|
+
for (const key of segmentReadings.keys()) if (key.startsWith(`${sourceKey}:`)) segmentReadings.delete(key);
|
|
183
184
|
outputParts.encodeInputs?.bytesChanged();
|
|
184
185
|
},
|
|
185
186
|
// Pieces arriving is ANNOUNCED by the thread that owns the swarm; what is
|
|
@@ -426,6 +427,42 @@ export async function startProxyServer({
|
|
|
426
427
|
// Readers never start their own downloads or wait for source bytes.
|
|
427
428
|
/** Source byte ranges of one segment interval, by file and interval; forgotten with the source. */
|
|
428
429
|
const segmentSourceRanges = new Map();
|
|
430
|
+
/**
|
|
431
|
+
* What one segment of an output reads — its tracks, its interval and the key
|
|
432
|
+
* its bytes are kept under — by output and segment, until the tracks, the
|
|
433
|
+
* file's start time, the segment's interval or the choices of the output
|
|
434
|
+
* change; forgotten with the source.
|
|
435
|
+
*/
|
|
436
|
+
const segmentReadings = new Map();
|
|
437
|
+
const segmentReadingOf = (zone, trackList, shift, map) => {
|
|
438
|
+
const choicesKey = zone.tracks.map(choice => `${choice.type}/${choice.index}/${choice.mode}`).join(",");
|
|
439
|
+
const key = `${map.sourceKey}:${map.fileIndex}:${zone.outputKey}:${zone.index}`;
|
|
440
|
+
const kept = segmentReadings.get(key);
|
|
441
|
+
if (kept && kept.trackList === trackList && kept.shift === shift && kept.choicesKey === choicesKey &&
|
|
442
|
+
kept.from === zone.sourceInterval.from && kept.to === zone.sourceInterval.to) return kept.reading;
|
|
443
|
+
const selected = zone.tracks.map(choice => ({ choice, track: choice.type === "video"
|
|
444
|
+
? ContainerTrack.firstUsable(trackList, "video")
|
|
445
|
+
: trackList.filter(track => track.type === choice.type)[choice.index] }));
|
|
446
|
+
let reading = null;
|
|
447
|
+
if (!selected.some(({ track }) => !track)) {
|
|
448
|
+
const wanted = selected.map(({ track }) => track);
|
|
449
|
+
const modes = new Map(selected.map(({ choice, track }) => [track, choice.mode]));
|
|
450
|
+
const interval = { ...zone.sourceInterval, trackIds: wanted.map(track => track.trackNumber),
|
|
451
|
+
modes: Object.fromEntries(wanted.map(track => [track.trackNumber, modes.get(track)])) };
|
|
452
|
+
reading = { wanted, modes, interval, rangesKey: `${map.sourceKey}:${map.fileIndex}:${JSON.stringify(interval)}` };
|
|
453
|
+
}
|
|
454
|
+
segmentReadings.set(key, { trackList, shift, choicesKey, from: zone.sourceInterval.from, to: zone.sourceInterval.to, reading });
|
|
455
|
+
return reading;
|
|
456
|
+
};
|
|
457
|
+
/** A segment's ranges joined at the precision the torrent fetches, once per kept list and piece length. */
|
|
458
|
+
const joinedRanges = new WeakMap();
|
|
459
|
+
const joinedRangesOf = (ranges, pieceLength) => {
|
|
460
|
+
const kept = joinedRanges.get(ranges);
|
|
461
|
+
if (kept && kept.pieceLength === pieceLength) return kept.joined;
|
|
462
|
+
const joined = joinedWithinPieces(ranges, pieceLength);
|
|
463
|
+
if (ranges !== null && typeof ranges === "object") joinedRanges.set(ranges, { pieceLength, joined });
|
|
464
|
+
return joined;
|
|
465
|
+
};
|
|
429
466
|
const downloadMaps = new DownloadMaps({
|
|
430
467
|
// The ranges a picture reads at open are named again by every segment;
|
|
431
468
|
// only the zone the swarm would rank first is sent (torrent-tv/meta#166).
|
|
@@ -487,19 +524,28 @@ export async function startProxyServer({
|
|
|
487
524
|
if (!map.isCurrent()) return [];
|
|
488
525
|
const direct = navigated.kind === "result" && navigated.value === true &&
|
|
489
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();
|
|
490
535
|
for (const zone of demands) {
|
|
491
536
|
if (!map.isCurrent()) return [];
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
const
|
|
499
|
-
|
|
537
|
+
// WHAT A SEGMENT READS IS WORKED OUT ONCE, not on every publication: its
|
|
538
|
+
// tracks, its interval and the key its bytes are kept under depend on
|
|
539
|
+
// the output, the segment and the file's start time, none of which a
|
|
540
|
+
// new map changes. Rebuilt for every segment of the film on every map,
|
|
541
|
+
// this pass held the main thread up to 1.5 s at a time on Home
|
|
542
|
+
// Assistant (torrent-tv/meta#178).
|
|
543
|
+
const reading = segmentReadingOf(zone, tracks.value, shift, map);
|
|
544
|
+
if (!reading) continue;
|
|
545
|
+
const { wanted, modes, interval, rangesKey } = reading;
|
|
500
546
|
const { tracks: _choices, owner: _owner, sourceInterval: _sourceInterval, ...demand } = zone;
|
|
501
547
|
const convert = ranges => {
|
|
502
|
-
for (const [byteStart, byteEnd] of
|
|
548
|
+
for (const [byteStart, byteEnd] of joinedRangesOf(ranges, pieceLength)) converted.push({ ...demand,
|
|
503
549
|
downloadInterval: { from: zone.owner.from, to: zone.owner.to }, byteStart, byteEnd });
|
|
504
550
|
};
|
|
505
551
|
// The bytes one segment needs are a fact of the file and the output, so
|
|
@@ -507,9 +553,9 @@ export async function startProxyServer({
|
|
|
507
553
|
// the map changes every few seconds; worked out afresh on every change,
|
|
508
554
|
// a pass over a two-hour AVI never finished and the next segment's bytes
|
|
509
555
|
// were never asked for (Home Assistant 2026-10-08, torrent-tv/meta#151).
|
|
510
|
-
const rangesKey = `${map.sourceKey}:${map.fileIndex}:${JSON.stringify(interval)}`;
|
|
511
556
|
const known = segmentSourceRanges.get(rangesKey);
|
|
512
557
|
if (known) { convert(known); continue; }
|
|
558
|
+
if (waiting.has(zone.outputKey)) continue;
|
|
513
559
|
if (direct) {
|
|
514
560
|
// Asked per zone too: a soundtrack of an AVI is read from its own
|
|
515
561
|
// packets, which the same index states (torrent-tv/meta#166).
|
|
@@ -535,10 +581,14 @@ export async function startProxyServer({
|
|
|
535
581
|
if (!map.isCurrent()) return [];
|
|
536
582
|
if (!intervalParams) continue;
|
|
537
583
|
const navigation = await containerOrchestrator.inspect(intervalParams, "source-navigation");
|
|
538
|
-
if (navigation.kind !== "result")
|
|
584
|
+
if (navigation.kind !== "result") {
|
|
585
|
+
if (navigation.kind === "needs-ranges" || navigation.kind === "needs-memory") waiting.add(zone.outputKey);
|
|
586
|
+
continue;
|
|
587
|
+
}
|
|
539
588
|
const originalRanges = navigation.value;
|
|
540
589
|
const packets = await containerOrchestrator.inspect(intervalParams, originalRanges ? "source-ranges" : "packets");
|
|
541
590
|
if (!map.isCurrent()) return [];
|
|
591
|
+
if (packets.kind === "needs-memory") waiting.add(zone.outputKey);
|
|
542
592
|
if (packets.kind !== "result") continue;
|
|
543
593
|
const input = originalRanges ? packets.value : new SegmentInputs({ index: packets.value, tracks: wanted }).forInterval({ ...interval, mode: track => modes.get(track) });
|
|
544
594
|
if (input.kind !== "result") continue;
|
|
@@ -562,7 +612,7 @@ export async function startProxyServer({
|
|
|
562
612
|
failed: (request, error) => logger.warn(`media request=${request.key} probe failed: ${error?.message ?? error}`)
|
|
563
613
|
});
|
|
564
614
|
const indexMemory = new IndexMemory({
|
|
565
|
-
reviseBudget: () => { void outputParts.machineBudget.revise().catch(error => logger.warn(`packet index budget: ${error.message}`)); },
|
|
615
|
+
reviseBudget: () => { void outputParts.machineBudget.revise("memory", "media metadata").catch(error => logger.warn(`packet index budget: ${error.message}`)); },
|
|
566
616
|
changed: () => { mediaReads.memoryChanged(); probeReads.memoryChanged(); outputParts.encodeInputs?.memoryChanged(); }
|
|
567
617
|
});
|
|
568
618
|
const containerOver = async ({ sourceKey, fileIndex, requestId, packetInterval, demand }) => {
|
|
@@ -197,7 +197,11 @@ export class LocalObservations {
|
|
|
197
197
|
* @param {import("./output/OutputSpec.js").OutputSpec} params.spec
|
|
198
198
|
* @param {ContentDescriptor | null} params.content - What it was encoded from.
|
|
199
199
|
* @param {number | null} params.aloneSpeedX - Seconds of film per second,
|
|
200
|
-
* measured with nothing else encoding
|
|
200
|
+
* measured with nothing else encoding and filed as if on a quiet machine
|
|
201
|
+
* (`EncodeCost.learnFrom`), the unit the startup prediction it replaces
|
|
202
|
+
* is in. Readings kept before torrent-tv/meta#178 were filed as read,
|
|
203
|
+
* lower by what the machine was doing; the slowest is used, so they err
|
|
204
|
+
* toward the viewer until newer ones replace them.
|
|
201
205
|
* @param {{ averageKbps: number, peakKbps: number } | null} params.segmentKbps
|
|
202
206
|
* @returns {void}
|
|
203
207
|
*/
|
|
@@ -19,12 +19,28 @@
|
|
|
19
19
|
* step was refused, and the quality menu emptied itself down to the one copied
|
|
20
20
|
* height (fixed in 2.21.1).
|
|
21
21
|
*
|
|
22
|
-
* So: our own encoders are priced by the concurrency arithmetic
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
22
|
+
* So: our own encoders are priced by the concurrency arithmetic. The proxy's
|
|
23
|
+
* own work — the torrent thread, the hashing, the delivery, the copies of
|
|
24
|
+
* encoder inputs — was to be priced per megabyte moved, and that price is not
|
|
25
|
+
* established: since 2.97.9 it is measured for the log only, and nothing
|
|
26
|
+
* charges the proxy's processor to an encoder. An encoder therefore gets what
|
|
27
|
+
* other processes AND the proxy itself leave (`forEncoders`), the proxy's
|
|
28
|
+
* share as measured over the same interval, taken off once here and nowhere
|
|
29
|
+
* else (torrent-tv/meta#178). The field case of 2026-08-17 below: a step
|
|
30
|
+
* predicted at 1.83x on a quiet box ran at 1.01-1.12x while ffmpeg had 56 %,
|
|
31
|
+
* the proxy 20 % and the machine 99 %; what other processes leave predicts
|
|
32
|
+
* 1.41x, what the proxy leaves as well predicts 1.04x.
|
|
33
|
+
*
|
|
34
|
+
* Once means in the readings too. A speed a running encoder is seen at already
|
|
35
|
+
* contains what the machine left it then, so it is filed as if alone on a
|
|
36
|
+
* quiet machine — divided by `leftForEncoders` at the reading, the unit the
|
|
37
|
+
* startup measurements are filed in — and multiplied by what is left now when
|
|
38
|
+
* it is used. Filed as read, the proxy's draw at the reading was taken off a
|
|
39
|
+
* second time by every prediction made from it, which is the 2.21.0 failure.
|
|
40
|
+
*
|
|
41
|
+
* `share`, what other processes leave, stays for the download's share of the
|
|
42
|
+
* proxy's processor (`processor-share.js`), which divides the proxy's room
|
|
43
|
+
* between its own download and its encoders and must not lose that room twice.
|
|
28
44
|
*/
|
|
29
45
|
|
|
30
46
|
/**
|
|
@@ -34,24 +50,42 @@
|
|
|
34
50
|
* `shareOfMachine` reports them.
|
|
35
51
|
* @param {number | null} reading.systemBusy - Everything the machine is doing.
|
|
36
52
|
* @param {number | null} reading.encoderShare - Our own ffmpeg processes.
|
|
37
|
-
* @param {number | null} reading.proxyShare - The proxy process itself
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
* share is
|
|
53
|
+
* @param {number | null} reading.proxyShare - The proxy process itself, every
|
|
54
|
+
* thread of it.
|
|
55
|
+
* @returns {{ share: number, forEncoders: number, unattributed: number, known: boolean }}
|
|
56
|
+
* `share` is what other processes leave; `forEncoders` what they and the
|
|
57
|
+
* proxy leave. `known` is false when the host does not report its own load —
|
|
58
|
+
* then both are 1 and the caller must say it is uncorrected rather than pretend.
|
|
41
59
|
*/
|
|
42
60
|
export function availableShareFrom(reading = {}) {
|
|
43
61
|
const systemBusy = finite(reading.systemBusy);
|
|
44
62
|
if (systemBusy === null) {
|
|
45
63
|
// No reading at all: not every host has /proc. An uncorrected prediction is
|
|
46
64
|
// the honest answer, and the caller says so.
|
|
47
|
-
return { share: 1, unattributed: 0, known: false };
|
|
65
|
+
return { share: 1, forEncoders: 1, unattributed: 0, known: false };
|
|
48
66
|
}
|
|
49
|
-
const
|
|
67
|
+
const encoders = finite(reading.encoderShare) ?? 0;
|
|
68
|
+
const ours = encoders + (finite(reading.proxyShare) ?? 0);
|
|
50
69
|
// Rounding, and the two readings being taken microseconds apart, can put our
|
|
51
70
|
// own share fractionally above the system total. Below zero is not a
|
|
52
71
|
// measurement of anything.
|
|
53
72
|
const unattributed = clamp(systemBusy - ours, 0, 1);
|
|
54
|
-
return { share: clamp(1 - unattributed, 0, 1),
|
|
73
|
+
return { share: clamp(1 - unattributed, 0, 1), forEncoders: clamp(1 - (systemBusy - encoders), 0, 1),
|
|
74
|
+
unattributed, known: true };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The share of the machine an encoder gets: what other processes and the
|
|
79
|
+
* proxy leave, or 1 for a host that does not report its load.
|
|
80
|
+
*
|
|
81
|
+
* @param {{ share: number, forEncoders?: number, known: boolean } | null | undefined} availability
|
|
82
|
+
* @returns {number}
|
|
83
|
+
*/
|
|
84
|
+
export function leftForEncoders(availability) {
|
|
85
|
+
if (!availability?.known) {
|
|
86
|
+
return 1;
|
|
87
|
+
}
|
|
88
|
+
return Number.isFinite(availability.forEncoders) ? availability.forEncoders : availability.share;
|
|
55
89
|
}
|
|
56
90
|
|
|
57
91
|
/**
|
|
@@ -61,17 +95,14 @@ export function availableShareFrom(reading = {}) {
|
|
|
61
95
|
* `share` of itself produces `share ×` the speed the benchmark measured alone.
|
|
62
96
|
*
|
|
63
97
|
* @param {number} predictedSpeed - From the quiet-host benchmark.
|
|
64
|
-
* @param {{ share: number, known: boolean }} availability
|
|
98
|
+
* @param {{ share: number, forEncoders?: number, known: boolean }} availability
|
|
65
99
|
* @returns {number}
|
|
66
100
|
*/
|
|
67
101
|
export function correctForAvailability(predictedSpeed, availability) {
|
|
68
102
|
if (!Number.isFinite(predictedSpeed) || predictedSpeed <= 0) {
|
|
69
103
|
return predictedSpeed;
|
|
70
104
|
}
|
|
71
|
-
|
|
72
|
-
return predictedSpeed;
|
|
73
|
-
}
|
|
74
|
-
return predictedSpeed * availability.share;
|
|
105
|
+
return predictedSpeed * leftForEncoders(availability);
|
|
75
106
|
}
|
|
76
107
|
|
|
77
108
|
/**
|
|
@@ -52,7 +52,7 @@ export function costKindForSession(session) {
|
|
|
52
52
|
return session?.spec?.transcodesVideo === true ? "decode" : "copy";
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
import { correctForAvailability } from "../available-share.js";
|
|
55
|
+
import { correctForAvailability, leftForEncoders } from "../available-share.js";
|
|
56
56
|
import { medianOf, movedBeyondScatter, READINGS_KEPT } from "../learned-median.js";
|
|
57
57
|
import { contentionPenalty } from "../contention.js";
|
|
58
58
|
import { ENCODE_RUN_STATE, liveRunsOf, processCanBeSignalled } from "../encode-run-state.js";
|
|
@@ -366,7 +366,7 @@ export class EncodeCost {
|
|
|
366
366
|
const outputs = this.#outputs.outputsOn(address);
|
|
367
367
|
let measured = 0;
|
|
368
368
|
for (const session of outputs) {
|
|
369
|
-
const speed = Number(qualityStateOf(session).lastAloneSpeed);
|
|
369
|
+
const speed = correctForAvailability(Number(qualityStateOf(session).lastAloneSpeed), this.#host().availability);
|
|
370
370
|
if (Number.isFinite(speed) && speed > measured) {
|
|
371
371
|
measured = speed;
|
|
372
372
|
}
|
|
@@ -649,7 +649,7 @@ export class EncodeCost {
|
|
|
649
649
|
/** @type {Map<number, number>} */
|
|
650
650
|
const speeds = new Map();
|
|
651
651
|
for (const session of this.#outputs.familyOf(base)) {
|
|
652
|
-
const speed = qualityStateOf(session).lastAloneSpeed;
|
|
652
|
+
const speed = correctForAvailability(qualityStateOf(session).lastAloneSpeed, this.#host().availability);
|
|
653
653
|
if (!session.spec.transcodesVideo || !Number.isFinite(speed)) {
|
|
654
654
|
continue;
|
|
655
655
|
}
|
|
@@ -874,6 +874,17 @@ export class EncodeCost {
|
|
|
874
874
|
if (speed === null) {
|
|
875
875
|
return;
|
|
876
876
|
}
|
|
877
|
+
// What this would have done alone on a quiet machine: the reading divided
|
|
878
|
+
// by what the machine left encoders while it was taken, the unit every
|
|
879
|
+
// price here is in. A prediction multiplies it by what is left NOW, so the
|
|
880
|
+
// proxy's own draw at the reading is not taken off a second time
|
|
881
|
+
// (torrent-tv/meta#178). A machine that left encoders nothing says nothing
|
|
882
|
+
// about that.
|
|
883
|
+
const left = leftForEncoders(this.#host().availability);
|
|
884
|
+
if (!(left > 0)) {
|
|
885
|
+
return;
|
|
886
|
+
}
|
|
887
|
+
const quietSpeed = speed / left;
|
|
877
888
|
const state = qualityStateOf(session);
|
|
878
889
|
const kind = costKindForSession(session);
|
|
879
890
|
// A reading taken beside another encoder contains that other encoder's
|
|
@@ -906,10 +917,11 @@ export class EncodeCost {
|
|
|
906
917
|
}
|
|
907
918
|
// What this encode did with the machine to itself — the one figure a live
|
|
908
919
|
// reading is authority on, and what withdraws a quality step that has been
|
|
909
|
-
// seen failing without letting it speak for steps nobody has run
|
|
910
|
-
//
|
|
911
|
-
// reads as a slow
|
|
912
|
-
|
|
920
|
+
// seen failing without letting it speak for steps nobody has run, judged
|
|
921
|
+
// at what the machine leaves encoders now. The time the input waited for
|
|
922
|
+
// the swarm is not in it, so a starved run no longer reads as a slow
|
|
923
|
+
// machine.
|
|
924
|
+
state.lastAloneSpeed = quietSpeed;
|
|
913
925
|
// What the offer predicted for this very step, against what it then did
|
|
914
926
|
// with the machine to itself. The prediction is corrected for the share of
|
|
915
927
|
// the machine that was free at the time, so this ratio is the error that
|
|
@@ -936,7 +948,7 @@ export class EncodeCost {
|
|
|
936
948
|
// What is left after the work that was already accounted for. `null` when
|
|
937
949
|
// the subtraction leaves nothing positive, which means the reading says
|
|
938
950
|
// less than the noise in it.
|
|
939
|
-
const ownCostSec = 1 /
|
|
951
|
+
const ownCostSec = 1 / quietSpeed - othersCostSec;
|
|
940
952
|
if (!(ownCostSec > 0) || !Number.isFinite(ownCostSec)) {
|
|
941
953
|
return;
|
|
942
954
|
}
|
|
@@ -944,10 +956,10 @@ export class EncodeCost {
|
|
|
944
956
|
return;
|
|
945
957
|
}
|
|
946
958
|
if (kind === "decode") {
|
|
947
|
-
this.#learnDecodeCost(session,
|
|
959
|
+
this.#learnDecodeCost(session, quietSpeed);
|
|
948
960
|
return;
|
|
949
961
|
}
|
|
950
|
-
await this.#learnCopyCost(session,
|
|
962
|
+
await this.#learnCopyCost(session, quietSpeed);
|
|
951
963
|
}
|
|
952
964
|
|
|
953
965
|
async #learnCopyCost(session, speed) {
|
|
@@ -572,7 +572,8 @@ export class HostLoad {
|
|
|
572
572
|
` over=${share.elapsedSec.toFixed(1)}s` +
|
|
573
573
|
// What the offer will multiply a prediction by, in the same line as the
|
|
574
574
|
// readings it comes from.
|
|
575
|
-
` available=${asPercent(this.hostAvailability.share)}
|
|
575
|
+
` available=${asPercent(this.hostAvailability.share)} for-encoders=${asPercent(this.hostAvailability.forEncoders)}` +
|
|
576
|
+
` free=${asPercent(free)}` + late
|
|
576
577
|
);
|
|
577
578
|
}
|
|
578
579
|
|
|
@@ -31,6 +31,7 @@ import { chooseOutputFps, computeOutputDimensions, TRANSCODE_FPS } from "../args
|
|
|
31
31
|
import { processCanBeSignalled } from "../encode-run-state.js";
|
|
32
32
|
import { sourceDecodeCharacteristics } from "../../media/SourceFile.js";
|
|
33
33
|
import { qualityStateOf } from "./OutputQualityState.js";
|
|
34
|
+
import { leftForEncoders } from "../available-share.js";
|
|
34
35
|
|
|
35
36
|
export class QualityOffer {
|
|
36
37
|
#cost;
|
|
@@ -39,6 +40,7 @@ export class QualityOffer {
|
|
|
39
40
|
#supplyFor;
|
|
40
41
|
#stateFor;
|
|
41
42
|
#occupancyKnownFor;
|
|
43
|
+
#availability;
|
|
42
44
|
|
|
43
45
|
/**
|
|
44
46
|
* @param {{
|
|
@@ -69,8 +71,13 @@ export class QualityOffer {
|
|
|
69
71
|
// Whether every other output holding a place has a measured price. The
|
|
70
72
|
// pool must not be told this host can serve a file while existing work is
|
|
71
73
|
// unpriced (roadmap item 97, step 14).
|
|
72
|
-
occupancyKnownFor = () => true
|
|
74
|
+
occupancyKnownFor = () => true,
|
|
75
|
+
// WHAT THIS MACHINE LEAVES AN ENCODER, as the host last measured it
|
|
76
|
+
// (`available-share.js`): read into the answer, so a change of it reaches
|
|
77
|
+
// the menu (torrent-tv/meta#178).
|
|
78
|
+
availability = () => null
|
|
73
79
|
}) {
|
|
80
|
+
this.#availability = availability;
|
|
74
81
|
this.#occupiedCostSec = occupiedCostSec;
|
|
75
82
|
this.#occupancyKnownFor = occupancyKnownFor;
|
|
76
83
|
this.#cost = encodeCost;
|
|
@@ -164,14 +171,24 @@ export class QualityOffer {
|
|
|
164
171
|
// whole film; while rounding alone would hide the 0.995-1.005 crossing,
|
|
165
172
|
// which is exactly the band a step spends its time in when the host is
|
|
166
173
|
// marginal. The flag carries the crossing, the rounded cost carries the
|
|
167
|
-
// rest.
|
|
174
|
+
// rest. The speed is filed as if alone on a quiet machine; the crossing is
|
|
175
|
+
// judged at what the machine leaves encoders now.
|
|
176
|
+
// WHAT THE MACHINE LEAVES AN ENCODER divides every prediction the answer is
|
|
177
|
+
// made of, and it moves with the download and everything else this process
|
|
178
|
+
// does. Left out, a menu computed while the proxy was quiet stood for the
|
|
179
|
+
// whole film: `fast` at a predicted 1.0x ran at 0.9-1.1x once the swarm
|
|
180
|
+
// delivered, and the picture stood at nearly every piece after a seek
|
|
181
|
+
// (Home Assistant 2026-10-12, torrent-tv/meta#178). To a hundredth of the
|
|
182
|
+
// machine, the finest the predictions it divides are compared at.
|
|
183
|
+
const availability = this.#availability();
|
|
184
|
+
const left = leftForEncoders(availability);
|
|
168
185
|
const measured = this.#outputs.familyOf(owner)
|
|
169
186
|
.map((member) => {
|
|
170
187
|
const speed = qualityStateOf(member).lastAloneSpeed;
|
|
171
188
|
if (!Number.isFinite(speed) || !(speed > 0)) {
|
|
172
189
|
return "-";
|
|
173
190
|
}
|
|
174
|
-
return `${speed < 1 ? "slow" : "ok"}${(1 / speed).toFixed(2)}`;
|
|
191
|
+
return `${speed * left < 1 ? "slow" : "ok"}${(1 / speed).toFixed(2)}`;
|
|
175
192
|
})
|
|
176
193
|
.join(",");
|
|
177
194
|
// The bar the answer is judged against, and the rate the torrent's price is
|
|
@@ -185,7 +202,7 @@ export class QualityOffer {
|
|
|
185
202
|
const version =
|
|
186
203
|
`${observed?.version ?? 0}:${playing}:${copyVersion}:${torrentCost.toFixed(6)}:` +
|
|
187
204
|
`${audioVersion}:${running}:${measured}:${(demanded ?? 0).toFixed(2)}:` +
|
|
188
|
-
`${(movingMegabytes ?? 0).toFixed(2)}`;
|
|
205
|
+
`${(movingMegabytes ?? 0).toFixed(2)}:${left.toFixed(2)}`;
|
|
189
206
|
const state = qualityStateOf(owner);
|
|
190
207
|
if (Array.isArray(state.offeredHeightsCache) && state.offeredHeightsVersion === version) {
|
|
191
208
|
return state.offeredHeightsCache;
|
|
@@ -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
|
+
}
|
|
@@ -167,7 +167,7 @@ export class AviContainer extends Container {
|
|
|
167
167
|
if (!index) return false;
|
|
168
168
|
const picture = ContainerTrack.firstUsable(await this.readTracks(), "video");
|
|
169
169
|
if (picture && interval?.trackIds?.length && !interval.trackIds.includes(picture.trackNumber)) return false;
|
|
170
|
-
return !picture || index.
|
|
170
|
+
return !picture || index.hasKeyframes(picture.trackNumber);
|
|
171
171
|
}
|
|
172
172
|
|
|
173
173
|
async readMappedSourceRanges(interval) { return this.readSourceRanges(interval); }
|
|
@@ -296,6 +296,16 @@ class AviIndex {
|
|
|
296
296
|
return keys;
|
|
297
297
|
}
|
|
298
298
|
|
|
299
|
+
/**
|
|
300
|
+
* Whether a stream states any keyframe. Asked for every segment while a map
|
|
301
|
+
* is turned into bytes; answered from the times it built a list of every
|
|
302
|
+
* keyframe of the film each time, 6.1 s at once on Home Assistant
|
|
303
|
+
* (torrent-tv/meta#178).
|
|
304
|
+
*/
|
|
305
|
+
hasKeyframes(stream) {
|
|
306
|
+
return this.#keyframesOf(stream).length > 0;
|
|
307
|
+
}
|
|
308
|
+
|
|
299
309
|
/** Keyframe times of a stream, ascending. */
|
|
300
310
|
keyframeTimes(stream) {
|
|
301
311
|
const packets = this.packets[stream];
|
|
@@ -26,6 +26,21 @@ function isWarmupTimeoutError(error) {
|
|
|
26
26
|
* @param {string} fileName
|
|
27
27
|
* @returns {number | null}
|
|
28
28
|
*/
|
|
29
|
+
/**
|
|
30
|
+
* Whether two lists of a piece's torrent inputs state the same bytes: the
|
|
31
|
+
* download map hands out new lists around the ranges it keeps, so the ranges
|
|
32
|
+
* themselves are compared.
|
|
33
|
+
*
|
|
34
|
+
* @param {Array<{ sourceId: string, ranges: object[] }> | undefined} left
|
|
35
|
+
* @param {Array<{ sourceId: string, ranges: object[] }> | undefined} right
|
|
36
|
+
* @returns {boolean}
|
|
37
|
+
*/
|
|
38
|
+
function sameInputs(left, right) {
|
|
39
|
+
if (left === right) return true;
|
|
40
|
+
if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) return false;
|
|
41
|
+
return left.every((input, index) => input.sourceId === right[index].sourceId && input.ranges === right[index].ranges);
|
|
42
|
+
}
|
|
43
|
+
|
|
29
44
|
function segmentAddressOf(output, fileName) {
|
|
30
45
|
const format = output.segmentFormat;
|
|
31
46
|
if (!format) {
|
|
@@ -45,6 +60,8 @@ export class ViewerRequests {
|
|
|
45
60
|
#host;
|
|
46
61
|
/** @type {WeakMap<object, Map<string, object>>} */
|
|
47
62
|
#rateStateBySession = new WeakMap();
|
|
63
|
+
/** The pieces of each output as the start forecast read them last; see `#segmentsOf`. */
|
|
64
|
+
#forecastPieces = new WeakMap();
|
|
48
65
|
|
|
49
66
|
/**
|
|
50
67
|
* @param {object} host - `opening`, `activeOutputFor`, `viewerSecondsOn`, `minimumBufferSecondsFor`, `getSourceStats`, `disposeSession`, `planEncodersSoon`, `waitUntilReady`, `encodeRuns`, `encodeSpeedReadingOf`, `outputTimes`, `outputs`, `lookaheadSeconds`, `quality`, `qualityOffer`, `renditions`, `segmentDurationSec`, `segmentStore`, `sourceFiles`, `startupWaitMs`, `viewers`, `acceptsGeneration`, `generationOfRequest`, `noteGivenOutput`, `holdForResponse`, `noteServingVerdict`, `servingVerdictOf`
|
|
@@ -561,10 +578,7 @@ export class ViewerRequests {
|
|
|
561
578
|
for (const output of outputs) {
|
|
562
579
|
const trackProgress = output === session ? progress : this.#host.encodeRuns.progressOf(output);
|
|
563
580
|
const trackRates = this.#trackRateReadings(measurement, output, now);
|
|
564
|
-
const timeline = output.timeline;
|
|
565
581
|
const trackName = output === session ? "video" : "audio";
|
|
566
|
-
const nativeRanges = Array.from({ length: timeline?.segmentCount ?? 0 }, (_, index) =>
|
|
567
|
-
this.#host.encodeRuns.finishedMediaRangesOf(output, index));
|
|
568
582
|
// The page states the offset its player applied to this track, and the
|
|
569
583
|
// init it applied it to is the session's. Until both are known, the
|
|
570
584
|
// pieces stay on their own presentation timeline: there is nothing yet
|
|
@@ -573,17 +587,9 @@ export class ViewerRequests {
|
|
|
573
587
|
const servedInit = Number.isFinite(playerOffset) ? this.#host.segmentStore.initOf(output.outputKey) : null;
|
|
574
588
|
const placedByPlayer = Boolean(servedInit?.length);
|
|
575
589
|
const sessionInit = placedByPlayer ? servedInit : null;
|
|
576
|
-
const
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
index,
|
|
580
|
-
startSeconds: timeline.publishedStartOf(index),
|
|
581
|
-
endSeconds: timeline.publishedStartOf(index + 1),
|
|
582
|
-
sourceInputs: this.#host.sourceInputsFor?.(output, index) ?? undefined,
|
|
583
|
-
mediaRanges: ranges && output.segmentFormat?.servedMediaRanges ?
|
|
584
|
-
output.segmentFormat.servedMediaRanges(ranges, { initBytes: sessionInit }) : undefined
|
|
585
|
-
};
|
|
586
|
-
});
|
|
590
|
+
const inventory = this.#host.segmentStore.sizesOf(output.outputKey);
|
|
591
|
+
const segments = this.#segmentsOf(output, sessionInit, inventory, Number.isFinite(viewerReading?.positionSeconds)
|
|
592
|
+
? viewerReading.positionSeconds : this.#host.viewerSecondsOn(session, consumerId, now));
|
|
587
593
|
const sourceIndexes = [...output.spec.sourceFileIndexes];
|
|
588
594
|
if (sourceIndexes.length === 0) {
|
|
589
595
|
sourceIndexes.push(output.file.fileIndex);
|
|
@@ -622,7 +628,6 @@ export class ViewerRequests {
|
|
|
622
628
|
});
|
|
623
629
|
}
|
|
624
630
|
|
|
625
|
-
const inventory = this.#host.segmentStore.sizesOf(output.outputKey);
|
|
626
631
|
const observedMbps = output === session
|
|
627
632
|
? outputMbps
|
|
628
633
|
: await this.#host.quality.observedStreamMbps(output);
|
|
@@ -733,9 +738,98 @@ export class ViewerRequests {
|
|
|
733
738
|
}))
|
|
734
739
|
})}`);
|
|
735
740
|
}
|
|
741
|
+
this.#sayHolding(session, consumerId, measurement, forecast, input, now);
|
|
736
742
|
return forecast;
|
|
737
743
|
}
|
|
738
744
|
|
|
745
|
+
/**
|
|
746
|
+
* What holds a waiting viewer's start, said when it changes: which piece,
|
|
747
|
+
* its production or its delivery, when it comes against the media before it,
|
|
748
|
+
* and the rates the forecast used. Said again when the delay has moved by a
|
|
749
|
+
* second, at most once a second; a hold that changes nothing says nothing.
|
|
750
|
+
*
|
|
751
|
+
* @param {object} session
|
|
752
|
+
* @param {string} consumerId
|
|
753
|
+
* @param {object} measurement - This viewer's forecast state.
|
|
754
|
+
* @param {object} forecast
|
|
755
|
+
* @param {object} input
|
|
756
|
+
* @param {number} now
|
|
757
|
+
*/
|
|
758
|
+
#sayHolding(session, consumerId, measurement, forecast, input, now) {
|
|
759
|
+
const holding = forecast.holdingBy;
|
|
760
|
+
if (!holding) {
|
|
761
|
+
measurement.held = null;
|
|
762
|
+
return;
|
|
763
|
+
}
|
|
764
|
+
const key = `${holding.kind}:${holding.track}:${holding.segment}`;
|
|
765
|
+
const delay = Number.isFinite(forecast.delaySeconds) ? forecast.delaySeconds : null;
|
|
766
|
+
const said = measurement.held;
|
|
767
|
+
if (said && said.key === key && (delay === null || said.delay === null || Math.abs(delay - said.delay) < 1 ||
|
|
768
|
+
now - said.at < 1000)) return;
|
|
769
|
+
measurement.held = { key, delay, at: now };
|
|
770
|
+
const seconds = value => Number.isFinite(value) ? value.toFixed(2) : "?";
|
|
771
|
+
const rates = (forecast.operations ?? []).map(operation =>
|
|
772
|
+
`${operation.track} ${operation.operation} ${Number.isFinite(operation.speed) ? operation.speed.toFixed(2) : "?"}x`);
|
|
773
|
+
const downloads = (input.sources ?? []).map(source => {
|
|
774
|
+
const rate = source.downloadRateReadings?.lastValue;
|
|
775
|
+
return `${Number.isFinite(rate) ? (rate / 1_048_576).toFixed(2) : "?"} MB/s`;
|
|
776
|
+
});
|
|
777
|
+
logger.info(`playback held ${session.id} consumer=${consumerId}: delay ${seconds(delay)}s by ` +
|
|
778
|
+
(holding.kind === "position-not-held" ? "the position, which nothing buffered covers" :
|
|
779
|
+
`the ${holding.kind} of ${holding.track} #${holding.segment} in ${seconds(holding.atSeconds)}s against ` +
|
|
780
|
+
`${seconds(holding.aheadSeconds)}s of media before it${holding.requiresSource ? ", its input still downloading" : ""}`) +
|
|
781
|
+
`; buffered ${seconds(forecast.bufferedSeconds)}s; rates ${rates.join(", ") || "none"}; download ${downloads.join(", ") || "none"}`);
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
/**
|
|
785
|
+
* The pieces of one output as the start forecast reads them, kept between
|
|
786
|
+
* polls: a piece whose playlist edges, read ranges and torrent inputs have
|
|
787
|
+
* not changed is the same object it was, so what the forecast derived from it
|
|
788
|
+
* once — its exact edges, its joined ranges — is not derived again. An unread
|
|
789
|
+
* piece that is not made and ends before the viewer is not listed: the
|
|
790
|
+
* forecast reads nothing from it. Building the whole film anew on every poll
|
|
791
|
+
* was part of what held the main thread for seconds (torrent-tv/meta#178).
|
|
792
|
+
*
|
|
793
|
+
* @param {object} output
|
|
794
|
+
* @param {Buffer | null} sessionInit
|
|
795
|
+
* @param {Map<number, number>} inventory - Made pieces and their sizes.
|
|
796
|
+
* @param {number} positionSeconds
|
|
797
|
+
* @returns {object[]}
|
|
798
|
+
*/
|
|
799
|
+
#segmentsOf(output, sessionInit, inventory, positionSeconds) {
|
|
800
|
+
const timeline = output.timeline;
|
|
801
|
+
const count = timeline?.segmentCount ?? 0;
|
|
802
|
+
let kept = this.#forecastPieces.get(output);
|
|
803
|
+
if (!kept) {
|
|
804
|
+
kept = [];
|
|
805
|
+
this.#forecastPieces.set(output, kept);
|
|
806
|
+
}
|
|
807
|
+
const segments = [];
|
|
808
|
+
for (let index = 0; index < count; index++) {
|
|
809
|
+
const endSeconds = timeline.publishedStartOf(index + 1);
|
|
810
|
+
const ranges = this.#host.encodeRuns.finishedMediaRangesOf(output, index);
|
|
811
|
+
const mediaRanges = ranges && output.segmentFormat?.servedMediaRanges ?
|
|
812
|
+
output.segmentFormat.servedMediaRanges(ranges, { initBytes: sessionInit }) : undefined;
|
|
813
|
+
if (index !== 0 && mediaRanges === undefined && !inventory.has(index) &&
|
|
814
|
+
Number.isFinite(endSeconds) && Number.isFinite(positionSeconds) && endSeconds <= positionSeconds) {
|
|
815
|
+
continue;
|
|
816
|
+
}
|
|
817
|
+
const startSeconds = timeline.publishedStartOf(index);
|
|
818
|
+
const sourceInputs = this.#host.sourceInputsFor?.(output, index) ?? undefined;
|
|
819
|
+
const previous = kept[index];
|
|
820
|
+
if (previous && previous.startSeconds === startSeconds && previous.endSeconds === endSeconds &&
|
|
821
|
+
previous.mediaRanges === mediaRanges && sameInputs(previous.sourceInputs, sourceInputs)) {
|
|
822
|
+
segments.push(previous);
|
|
823
|
+
continue;
|
|
824
|
+
}
|
|
825
|
+
const segment = { index, startSeconds, endSeconds, sourceInputs, mediaRanges };
|
|
826
|
+
kept[index] = segment;
|
|
827
|
+
segments.push(segment);
|
|
828
|
+
}
|
|
829
|
+
if (kept.length > count) kept.length = count;
|
|
830
|
+
return segments;
|
|
831
|
+
}
|
|
832
|
+
|
|
739
833
|
#rateStateFor(session, consumerId) {
|
|
740
834
|
let byViewer = this.#rateStateBySession.get(session);
|
|
741
835
|
if (!byViewer) {
|
|
@@ -860,6 +860,7 @@ export function wireOutputs({
|
|
|
860
860
|
encodeCost: parts.encodeCost,
|
|
861
861
|
occupiedCostSec: (fileKey) => parts.admission?.occupiedCost({ exceptFileKey: fileKey }).costSec ?? 0,
|
|
862
862
|
occupancyKnownFor: (fileKey) => (parts.admission?.occupiedCost({ exceptFileKey: fileKey }).unpriced ?? 0) === 0,
|
|
863
|
+
availability: () => parts.hostLoad.hostAvailability ?? null,
|
|
863
864
|
outputs: parts.outputs,
|
|
864
865
|
stateFor: (session) => parts.encodeRuns.runStateOf(session),
|
|
865
866
|
// WHICH HEIGHTS A LIVE VIEWER HAS ON SCREEN, as numbers. Who is watching
|
|
@@ -1007,7 +1008,7 @@ export function wireOutputs({
|
|
|
1007
1008
|
resolve: resolveEncodeInput,
|
|
1008
1009
|
readRanges: readEncodeRanges,
|
|
1009
1010
|
heldRanges: readEncodeHeldRanges,
|
|
1010
|
-
reviseBudget: () => parts.machineBudget.revise(),
|
|
1011
|
+
reviseBudget: () => parts.machineBudget.revise("memory", "admitted encode inputs"),
|
|
1011
1012
|
capacity: () => parts.machineBudget.capacityOf("memory"),
|
|
1012
1013
|
// How long this output's input takes to open, from the one owner of start
|
|
1013
1014
|
// readings (torrent-tv/meta#184).
|
|
@@ -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
|
}
|
|
@@ -1786,6 +1786,47 @@ export class SharedPieceStore {
|
|
|
1786
1786
|
write().then(() => callback(null), (error) => callback(error));
|
|
1787
1787
|
}
|
|
1788
1788
|
|
|
1789
|
+
/**
|
|
1790
|
+
* What `get` answers, written into `target` (exactly `length` bytes long)
|
|
1791
|
+
* instead of a buffer of its own, by the same three routes: memory, disk,
|
|
1792
|
+
* whole file. A reader copying a stretch of film into memory it already holds
|
|
1793
|
+
* then needs nothing beside it while several pieces are read at once
|
|
1794
|
+
* (torrent-tv/meta#178). Answers whether `target` was filled.
|
|
1795
|
+
*
|
|
1796
|
+
* @param {number} index
|
|
1797
|
+
* @param {number} offset - Within the piece.
|
|
1798
|
+
* @param {Buffer} target
|
|
1799
|
+
* @returns {Promise<boolean>}
|
|
1800
|
+
*/
|
|
1801
|
+
async getInto(index, offset, target) {
|
|
1802
|
+
if (this.#closed) throw new Error("Piece store is closed.");
|
|
1803
|
+
const length = target.length;
|
|
1804
|
+
if (offset < 0 || offset + length > this.#lengthOf(index)) return false;
|
|
1805
|
+
const fromMemory = () => {
|
|
1806
|
+
const resident = this.#buffers.get(index);
|
|
1807
|
+
if (resident === undefined) return false;
|
|
1808
|
+
this.#lru.touch(index);
|
|
1809
|
+
this.#counters.fromMemory += 1;
|
|
1810
|
+
Buffer.from(resident, offset, length).copy(target);
|
|
1811
|
+
return true;
|
|
1812
|
+
};
|
|
1813
|
+
if (fromMemory()) return true;
|
|
1814
|
+
// A spill that is still being written owns the only copy there is.
|
|
1815
|
+
const spill = this.#evicting.get(index);
|
|
1816
|
+
if (spill) await spill.catch(() => undefined);
|
|
1817
|
+
if (fromMemory()) return true;
|
|
1818
|
+
if (this.#disk.has(index)) {
|
|
1819
|
+
const bytesRead = await this.#disk.read(index, target, offset);
|
|
1820
|
+
this.#counters.fromDisk += 1;
|
|
1821
|
+
return bytesRead === length;
|
|
1822
|
+
}
|
|
1823
|
+
const whole = await this.#fromWholeFiles(index);
|
|
1824
|
+
if (!whole || whole.length < offset + length) return false;
|
|
1825
|
+
this.#counters.fromWholeFile += 1;
|
|
1826
|
+
whole.copy(target, 0, offset, offset + length);
|
|
1827
|
+
return true;
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1789
1830
|
get(index, options, callback) {
|
|
1790
1831
|
if (typeof options === "function") {
|
|
1791
1832
|
return this.get(index, undefined, options);
|
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,
|
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
import { pieceStoreOf } from "../piece-store-of.js";
|
|
2
2
|
|
|
3
|
+
/**
|
|
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`). 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).
|
|
10
|
+
*/
|
|
11
|
+
const FILE_READS_AT_ONCE = Math.max(1, Number.parseInt(process.env.UV_THREADPOOL_SIZE ?? "", 10) || 4);
|
|
12
|
+
|
|
3
13
|
/**
|
|
4
14
|
* @file What of a file the torrent already HOLDS, and reading it without
|
|
5
15
|
* asking the swarm for anything.
|
|
@@ -212,17 +222,35 @@ export async function readHeldRanges(torrent, fileIndex, ranges, maxBytes, logge
|
|
|
212
222
|
}
|
|
213
223
|
try {
|
|
214
224
|
const result = ranges.map(([start, end]) => Buffer.allocUnsafeSlow(end - start + 1));
|
|
215
|
-
|
|
225
|
+
// SEVERAL PIECES AT ONCE, each written straight into the copy. One after
|
|
226
|
+
// another, every read from disk waited for this thread's loop on its own,
|
|
227
|
+
// and the loop runs 0.3-0.7 s late while the swarm delivers 7-14 MB/s: a
|
|
228
|
+
// 210 MB stretch took 11 s here (Home Assistant 2026-10-12,
|
|
229
|
+
// torrent-tv/meta#178). As many are in flight as the runtime has threads
|
|
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
|
|
232
|
+
// this process reads or writes.
|
|
233
|
+
const readOne = async ([index, span]) => {
|
|
216
234
|
const pieceStart = index * pieceLength;
|
|
217
235
|
const offset = span.start - pieceStart;
|
|
218
236
|
const length = span.end - span.start + 1;
|
|
237
|
+
const [only] = span.ranges;
|
|
238
|
+
// A piece holding one range alone is read into the copy itself; one
|
|
239
|
+
// shared by several ranges is read once and divided, as before.
|
|
240
|
+
if (span.ranges.length === 1 && typeof store.getInto === "function") {
|
|
241
|
+
const from = Math.max(only.absoluteStart, pieceStart);
|
|
242
|
+
const filled = await store.getInto(index, offset, result[only.rangeIndex].subarray(from - only.absoluteStart, from - only.absoluteStart + length));
|
|
243
|
+
if (!filled) return false;
|
|
244
|
+
onRead?.(length);
|
|
245
|
+
return true;
|
|
246
|
+
}
|
|
219
247
|
const buffer = await new Promise((resolve, reject) => {
|
|
220
248
|
store.get(index, { offset, length }, (error, value) => {
|
|
221
249
|
if (error) reject(error);
|
|
222
250
|
else resolve(value);
|
|
223
251
|
});
|
|
224
252
|
});
|
|
225
|
-
if (!buffer || buffer.length !== length) return
|
|
253
|
+
if (!buffer || buffer.length !== length) return false;
|
|
226
254
|
onRead?.(length);
|
|
227
255
|
for (const { rangeIndex, absoluteStart, absoluteEnd } of span.ranges) {
|
|
228
256
|
const from = Math.max(absoluteStart, pieceStart);
|
|
@@ -230,8 +258,27 @@ export async function readHeldRanges(torrent, fileIndex, ranges, maxBytes, logge
|
|
|
230
258
|
const sourceStart = from - span.start;
|
|
231
259
|
result[rangeIndex].set(buffer.subarray(sourceStart, sourceStart + to - from + 1), from - absoluteStart);
|
|
232
260
|
}
|
|
233
|
-
|
|
234
|
-
|
|
261
|
+
return true;
|
|
262
|
+
};
|
|
263
|
+
const spans = [...pieceSpans];
|
|
264
|
+
let next = 0;
|
|
265
|
+
let whole = true;
|
|
266
|
+
const reader = async () => {
|
|
267
|
+
while (whole && next < spans.length) {
|
|
268
|
+
try {
|
|
269
|
+
if (!await readOne(spans[next++])) whole = false;
|
|
270
|
+
} catch (error) {
|
|
271
|
+
whole = false;
|
|
272
|
+
throw error;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
};
|
|
276
|
+
// Every read has returned before the pieces are let go, a failed one too:
|
|
277
|
+
// a read still in flight would otherwise read a piece nothing holds.
|
|
278
|
+
const outcomes = await Promise.allSettled(Array.from({ length: Math.min(spans.length, FILE_READS_AT_ONCE) }, reader));
|
|
279
|
+
const failed = outcomes.find((outcome) => outcome.status === "rejected");
|
|
280
|
+
if (failed) throw failed.reason;
|
|
281
|
+
return whole ? result : null;
|
|
235
282
|
} catch (error) {
|
|
236
283
|
logger?.info?.(`held ranges read ${fileIndex} failed: ${error?.message ?? error}`);
|
|
237
284
|
return null;
|
|
@@ -31,7 +31,6 @@ import {
|
|
|
31
31
|
rescale,
|
|
32
32
|
addRange as addMediaRange,
|
|
33
33
|
commonTimescale,
|
|
34
|
-
scale,
|
|
35
34
|
subtract,
|
|
36
35
|
toSeconds,
|
|
37
36
|
ZERO
|
|
@@ -120,7 +119,16 @@ export class RateTrend {
|
|
|
120
119
|
*/
|
|
121
120
|
export function predictPlaybackReadiness(input = {}) {
|
|
122
121
|
const now = Number.isFinite(input.now) ? input.now : Date.now();
|
|
123
|
-
|
|
122
|
+
// WHAT IS BEHIND THE VIEWER IS NOT PLANNED AGAIN. The forecast runs on every
|
|
123
|
+
// progress poll of a waiting viewer, and it converted every piece of the film
|
|
124
|
+
// to exact time before dropping those already played: 5.1 s of the proxy's
|
|
125
|
+
// main thread at once after a seek back on Home Assistant, every second of
|
|
126
|
+
// which FFmpeg's input and the page's pieces waited (torrent-tv/meta#178).
|
|
127
|
+
// Kept are the pieces still to play, the first piece (where the track
|
|
128
|
+
// starts), the read piece nearest before them (what an unread piece is placed
|
|
129
|
+
// by) and read pieces inside what the page reports holding (their frames).
|
|
130
|
+
const tracks = (Array.isArray(input.tracks) ? input.tracks : []).map((track) =>
|
|
131
|
+
Array.isArray(track.segments) ? { ...track, segments: piecesThatMatter(track, finiteNonNegative(input.positionSeconds)) } : track);
|
|
124
132
|
const duration = finiteNonNegative(input.durationSeconds);
|
|
125
133
|
// Each track's served ticks are placed on the clock the page reports by the
|
|
126
134
|
// offset its player applied (`timestampOffset`).
|
|
@@ -138,13 +146,13 @@ export function predictPlaybackReadiness(input = {}) {
|
|
|
138
146
|
...reported.flatMap((ranges) => (ranges ?? []).flatMap(({ start, end }) => [start, end])),
|
|
139
147
|
...tracks.flatMap((track) => (Array.isArray(track.segments) ? track.segments : []).flatMap((segment) => [
|
|
140
148
|
...(segment.mediaRanges?.timescale ? [mediaTime(0n, segment.mediaRanges.timescale)] : []),
|
|
141
|
-
...
|
|
149
|
+
...exactBoundsOf(segment)
|
|
142
150
|
]))
|
|
143
151
|
]);
|
|
144
152
|
const zero = mediaTime(0n, unit);
|
|
145
153
|
const segmentsOf = tracks.map((track, trackIndex) => predictedMediaSegments(
|
|
146
154
|
(Array.isArray(track.segments) ? track.segments : []).map((segment) => ({
|
|
147
|
-
...segment, ranges: playerRanges(segment.mediaRanges, rescale(clocks[trackIndex], unit), unit)
|
|
155
|
+
...segment, exact: exactTimesOf(segment), ranges: playerRanges(segment.mediaRanges, rescale(clocks[trackIndex], unit), unit)
|
|
148
156
|
})), unit));
|
|
149
157
|
const origin = latest(zero, ...segmentsOf.map((segments) =>
|
|
150
158
|
segments.find(({ index }) => index === 0)?.ranges?.[0]?.start ?? zero));
|
|
@@ -362,11 +370,15 @@ export function predictPlaybackReadiness(input = {}) {
|
|
|
362
370
|
const stillMissing = uncoveredIntervals(pieceRange, sourceByteCoverage.get(sourceId) ?? []);
|
|
363
371
|
for (const part of stillMissing) {
|
|
364
372
|
finish = rate.finish(part.end - part.start, finish);
|
|
365
|
-
|
|
373
|
+
const arrival = { start: part.start, end: part.end, begin: finish, finish };
|
|
374
|
+
service.push(arrival);
|
|
366
375
|
sourceArrivalAt = Math.max(sourceArrivalAt, finish);
|
|
376
|
+
// Joined in place, as the whole list would join: pieces arrive in
|
|
377
|
+
// media order, so each one extends the end. Sorting the whole list
|
|
378
|
+
// again after every piece was most of the forecast's time.
|
|
379
|
+
sourceByteCoverage.set(sourceId, withCoverage(sourceByteCoverage.get(sourceId) ?? [], arrival));
|
|
380
|
+
sourceArrivals.set(sourceId, withArrival(sourceArrivals.get(sourceId) ?? [], arrival, service));
|
|
367
381
|
}
|
|
368
|
-
sourceByteCoverage.set(sourceId, numericCoverage(service));
|
|
369
|
-
sourceArrivals.set(sourceId, indexedArrivals(service));
|
|
370
382
|
}
|
|
371
383
|
sourceService.finish = finish;
|
|
372
384
|
}
|
|
@@ -384,6 +396,16 @@ export function predictPlaybackReadiness(input = {}) {
|
|
|
384
396
|
}
|
|
385
397
|
|
|
386
398
|
const constraints = [];
|
|
399
|
+
// WHICH CONSTRAINT HOLDS THE START, said with the answer: the delay is the
|
|
400
|
+
// largest of thousands, and the log could only say that some term was
|
|
401
|
+
// positive while the picture stood for 17 s (torrent-tv/meta#178).
|
|
402
|
+
let holding = null;
|
|
403
|
+
const constrain = (value, kind, trackIndex, segment, atSeconds, aheadSeconds, requiresSource) => {
|
|
404
|
+
constraints.push(value);
|
|
405
|
+
if (value > 0 && (holding === null || value > holding.value)) {
|
|
406
|
+
holding = { value, kind, trackIndex, segment, atSeconds, aheadSeconds, requiresSource };
|
|
407
|
+
}
|
|
408
|
+
};
|
|
387
409
|
const end = rescale(fromSeconds(duration), unit);
|
|
388
410
|
for (let trackIndex = 0; trackIndex < trackState.length; trackIndex += 1) {
|
|
389
411
|
const state = trackState[trackIndex];
|
|
@@ -395,7 +417,8 @@ export function predictPlaybackReadiness(input = {}) {
|
|
|
395
417
|
const before = contiguousEnd(delivered, at);
|
|
396
418
|
for (const range of completion.segment.ranges) addMediaRange(delivered, range);
|
|
397
419
|
const after = contiguousEnd(delivered, at);
|
|
398
|
-
|
|
420
|
+
const ahead = secondsAhead(earliest(before, trackEnd), at);
|
|
421
|
+
constrain(completion.at - ahead, "delivery", trackIndex, completion.segment.index, completion.at, ahead, false);
|
|
399
422
|
if (compare(after, before) <= 0 && completion.segment.ranges.some(({ start }) => compare(start, before) > 0)) {
|
|
400
423
|
// A timestamp hole cannot be repaired by waiting longer. Keep it
|
|
401
424
|
// distinct from an unavailable rate or a slow but finite service.
|
|
@@ -413,8 +436,10 @@ export function predictPlaybackReadiness(input = {}) {
|
|
|
413
436
|
for (const range of production.segment.ranges) addMediaRange(prepared, range);
|
|
414
437
|
// Supply interruption coverage is stock held by the proxy as well as
|
|
415
438
|
// the browser. It is not a browser buffer-size admission rule.
|
|
416
|
-
|
|
417
|
-
Math.min(reserve, secondsAhead(trackEnd, before)) * Number(production.requiresSource))
|
|
439
|
+
const ahead = Math.max(0, secondsAhead(before, at) -
|
|
440
|
+
Math.min(reserve, secondsAhead(trackEnd, before)) * Number(production.requiresSource));
|
|
441
|
+
constrain(production.at - ahead, "production", trackIndex, production.segment.index, production.at, ahead,
|
|
442
|
+
production.requiresSource);
|
|
418
443
|
}
|
|
419
444
|
}
|
|
420
445
|
const delay = Math.max(0, ...constraints);
|
|
@@ -424,6 +449,13 @@ export function predictPlaybackReadiness(input = {}) {
|
|
|
424
449
|
Math.max(0, reserve - buffered), ready ? 'trajectory-safe-now' :
|
|
425
450
|
Number.isFinite(delay) ? 'minimum-safe-delay' : 'service-not-advancing', preparedSegments);
|
|
426
451
|
return { ...forecast, measuredAt: now, predictedStartAt: Number.isFinite(delay) ? now + delay * 1000 : null,
|
|
452
|
+
// The piece whose `kind` — its production, or its delivery to the page —
|
|
453
|
+
// comes `atSeconds` from now while the media before it lasts
|
|
454
|
+
// `aheadSeconds`: the difference is the delay.
|
|
455
|
+
holdingBy: ready ? null : holding === null ? (originReady ? null : { kind: "position-not-held" })
|
|
456
|
+
: { kind: holding.kind, track: tracks[holding.trackIndex]?.id ?? holding.trackIndex,
|
|
457
|
+
segment: holding.segment, atSeconds: holding.atSeconds, aheadSeconds: holding.aheadSeconds,
|
|
458
|
+
requiresSource: holding.requiresSource },
|
|
427
459
|
preparedUntilSeconds: Math.min(duration, ...trackState.map((state, index) => {
|
|
428
460
|
const ranges = state.clientRanges.map((range) => ({ ...range }));
|
|
429
461
|
for (const item of productions.filter((item) => item.trackIndex === index && item.at <= delay)) {
|
|
@@ -534,6 +566,95 @@ function heldRanges(ranges, segments, unit, position) {
|
|
|
534
566
|
return held;
|
|
535
567
|
}
|
|
536
568
|
|
|
569
|
+
/**
|
|
570
|
+
* The pieces of a track this forecast reads: those still to play (ending after
|
|
571
|
+
* `position`), the first piece, every piece that has been read and every piece
|
|
572
|
+
* that is made. A piece that is none of these changes no answer: it is not
|
|
573
|
+
* scheduled, it is not where the track starts, no unread piece is placed by it
|
|
574
|
+
* (only read pieces are), it lends no frame to what the page holds, and it has
|
|
575
|
+
* no size to measure the output's rate by.
|
|
576
|
+
*
|
|
577
|
+
* @param {{ segments: object[], readySegmentIndices?: number[] }} track
|
|
578
|
+
* @param {number} position - Seconds.
|
|
579
|
+
* @returns {object[]}
|
|
580
|
+
*/
|
|
581
|
+
function piecesThatMatter(track, position) {
|
|
582
|
+
const made = new Set(Array.isArray(track.readySegmentIndices) ? track.readySegmentIndices : []);
|
|
583
|
+
const kept = track.segments.filter((segment) => segment?.index === 0 || segment?.mediaRanges !== undefined ||
|
|
584
|
+
made.has(segment?.index) || !(Number.isFinite(segment?.endSeconds) && segment.endSeconds <= position));
|
|
585
|
+
return kept.length === track.segments.length ? track.segments : kept;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* The exact times of a piece's playlist edges, converted once per piece and
|
|
590
|
+
* kept with it: the playlist's cut times do not change between polls, and
|
|
591
|
+
* converting them again on every one was a fifth of the forecast's time.
|
|
592
|
+
*
|
|
593
|
+
* @type {WeakMap<object, { startSeconds: number, endSeconds: number, start: import("./media-time.js").MediaTime | null, end: import("./media-time.js").MediaTime | null }>}
|
|
594
|
+
*/
|
|
595
|
+
const exactBounds = new WeakMap();
|
|
596
|
+
|
|
597
|
+
function exactTimesOf(segment) {
|
|
598
|
+
let bounds = exactBounds.get(segment);
|
|
599
|
+
if (!bounds || bounds.startSeconds !== segment.startSeconds || bounds.endSeconds !== segment.endSeconds) {
|
|
600
|
+
bounds = {
|
|
601
|
+
startSeconds: segment.startSeconds,
|
|
602
|
+
endSeconds: segment.endSeconds,
|
|
603
|
+
start: Number.isFinite(segment.startSeconds) ? fromSeconds(segment.startSeconds) : null,
|
|
604
|
+
end: Number.isFinite(segment.endSeconds) ? fromSeconds(segment.endSeconds) : null
|
|
605
|
+
};
|
|
606
|
+
exactBounds.set(segment, bounds);
|
|
607
|
+
}
|
|
608
|
+
return bounds;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/** The exact edges a piece carries from the forecast, or converts once. */
|
|
612
|
+
function timesOf(segment) {
|
|
613
|
+
return segment.exact ?? exactTimesOf(segment);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/** @returns {import("./media-time.js").MediaTime[]} The finite playlist edges of a piece, exactly. */
|
|
617
|
+
function exactBoundsOf(segment) {
|
|
618
|
+
const { start, end } = exactTimesOf(segment);
|
|
619
|
+
return [start, end].filter(Boolean);
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* `coverage` (sorted, joined; this forecast's own list) with `range` added in
|
|
624
|
+
* place, as `numericCoverage` of the whole list would give it: found by halves,
|
|
625
|
+
* joined with every neighbour it reaches or touches. Bytes held further on —
|
|
626
|
+
* a piece of the file read for another viewer — sit after the place pieces are
|
|
627
|
+
* added in media order, so adding only at the end did not hold.
|
|
628
|
+
*/
|
|
629
|
+
function withCoverage(coverage, range) {
|
|
630
|
+
let low = 0, high = coverage.length;
|
|
631
|
+
while (low < high) {
|
|
632
|
+
const middle = (low + high) >>> 1;
|
|
633
|
+
if (coverage[middle].end < range.start) low = middle + 1;
|
|
634
|
+
else high = middle;
|
|
635
|
+
}
|
|
636
|
+
const joined = { start: range.start, end: range.end };
|
|
637
|
+
let last = low;
|
|
638
|
+
while (last < coverage.length && coverage[last].start <= joined.end) {
|
|
639
|
+
joined.start = Math.min(joined.start, coverage[last].start);
|
|
640
|
+
joined.end = Math.max(joined.end, coverage[last].end);
|
|
641
|
+
last += 1;
|
|
642
|
+
}
|
|
643
|
+
coverage.splice(low, last - low, joined);
|
|
644
|
+
return coverage;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/** `arrivals` (this forecast's own list) with `range` indexed, as `indexedArrivals` of the whole list would give it. */
|
|
648
|
+
function withArrival(arrivals, range, all) {
|
|
649
|
+
if (!(range.finish > 0)) return arrivals;
|
|
650
|
+
const last = arrivals.at(-1);
|
|
651
|
+
if (!last || range.start > last.start || (range.start === last.start && range.end >= last.end)) {
|
|
652
|
+
arrivals.push({ ...range, maximumEnd: Math.max(last?.maximumEnd ?? 0, range.end) });
|
|
653
|
+
return arrivals;
|
|
654
|
+
}
|
|
655
|
+
return indexedArrivals(all);
|
|
656
|
+
}
|
|
657
|
+
|
|
537
658
|
/** Coverage is joined once per resource, independently of arrival times. */
|
|
538
659
|
function numericCoverage(ranges) {
|
|
539
660
|
const joined = [];
|
|
@@ -614,30 +735,58 @@ function sourcePiecesFor(ranges, source) {
|
|
|
614
735
|
*/
|
|
615
736
|
function predictedMediaSegments(segments, unit) {
|
|
616
737
|
const zero = mediaTime(0n, unit);
|
|
617
|
-
|
|
738
|
+
// In order of index, searched by halves: each unread piece looked through
|
|
739
|
+
// every read one for its neighbours, a cost of pieces times read pieces.
|
|
740
|
+
const anchors = segments.filter(({ ranges }) => ranges?.length > 0)
|
|
741
|
+
.sort((left, right) => left.index - right.index);
|
|
742
|
+
const firstAfter = (index) => {
|
|
743
|
+
let low = 0, high = anchors.length;
|
|
744
|
+
while (low < high) {
|
|
745
|
+
const middle = (low + high) >>> 1;
|
|
746
|
+
if (anchors[middle].index <= index) low = middle + 1;
|
|
747
|
+
else high = middle;
|
|
748
|
+
}
|
|
749
|
+
return low;
|
|
750
|
+
};
|
|
618
751
|
return segments.map((segment) => {
|
|
619
752
|
if (segment.ranges !== undefined) return segment;
|
|
753
|
+
const times = timesOf(segment);
|
|
620
754
|
if (anchors.length === 0) {
|
|
621
|
-
return { ...segment, ranges: [{ start: rescale(
|
|
622
|
-
end: rescale(fromSeconds(segment.endSeconds), unit), frame: zero }] };
|
|
755
|
+
return { ...segment, ranges: [{ start: rescale(times.start, unit), end: rescale(times.end, unit), frame: zero }] };
|
|
623
756
|
}
|
|
624
|
-
const
|
|
625
|
-
|
|
626
|
-
|
|
757
|
+
const after = firstAfter(segment.index);
|
|
758
|
+
let before = after - 1;
|
|
759
|
+
while (before >= 0 && anchors[before].index >= segment.index) before -= 1;
|
|
760
|
+
const previous = before >= 0 ? anchors[before] : undefined;
|
|
761
|
+
const next = anchors[after];
|
|
762
|
+
const nominalLeft = previous ? timesOf(previous).end : timesOf(next).start;
|
|
627
763
|
const actualLeft = previous?.ranges.at(-1).end ?? next.ranges[0].start;
|
|
628
|
-
const nominalRight = next ?
|
|
764
|
+
const nominalRight = next ? timesOf(next).start : nominalLeft;
|
|
629
765
|
const actualRight = next?.ranges[0].start ?? actualLeft;
|
|
630
766
|
const nominalSpan = subtract(nominalRight, nominalLeft);
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
767
|
+
// `nearest(actualLeft + fromLeft * (actualRight - actualLeft) / nominalSpan)`
|
|
768
|
+
// in ticks of `unit`, as one fraction: both actual edges are already whole
|
|
769
|
+
// ticks of it, so only the scaled part is rounded, and no common
|
|
770
|
+
// denominator is sought for every piece (most of the forecast's time).
|
|
771
|
+
const actualSpan = actualRight.ticks - actualLeft.ticks;
|
|
772
|
+
const place = (time) => {
|
|
773
|
+
const fromLeft = subtract(time, nominalLeft);
|
|
774
|
+
const [numerator, denominator] = nominalSpan.ticks > 0n
|
|
775
|
+
? [fromLeft.ticks * nominalSpan.timescale * actualSpan, fromLeft.timescale * nominalSpan.ticks]
|
|
776
|
+
: [fromLeft.ticks * unit, fromLeft.timescale];
|
|
777
|
+
return { ticks: actualLeft.ticks + roundedHalfUp(numerator, denominator), timescale: unit };
|
|
636
778
|
};
|
|
637
|
-
return { ...segment, ranges: [{ start: place(
|
|
779
|
+
return { ...segment, ranges: [{ start: place(times.start), end: place(times.end), frame: zero }] };
|
|
638
780
|
});
|
|
639
781
|
}
|
|
640
782
|
|
|
783
|
+
/** `numerator / denominator` to the nearest whole number, halves up, as `nearest` rounds; `denominator` positive. */
|
|
784
|
+
function roundedHalfUp(numerator, denominator) {
|
|
785
|
+
const quotient = numerator / denominator;
|
|
786
|
+
const floor = numerator - quotient * denominator < 0n ? quotient - 1n : quotient;
|
|
787
|
+
return 2n * (numerator - floor * denominator) >= denominator ? floor + 1n : floor;
|
|
788
|
+
}
|
|
789
|
+
|
|
641
790
|
function rateCurve(readings, now) {
|
|
642
791
|
if (readings && Number.isFinite(readings.count) && readings.count > 0 &&
|
|
643
792
|
Number.isFinite(readings.lastAt) && Number.isFinite(readings.lastValue) &&
|