@torrent-tv/proxy 2.97.14 → 2.97.15
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 +47 -10
- 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/container/AviContainer.js +1 -1
- package/services/media/container/avi-index.js +10 -0
- package/services/server/ViewerRequests.js +69 -15
- package/services/server/wire-outputs.js +1 -0
- package/services/storage/piece-store/shared-piece-store.js +41 -0
- package/services/torrent/worker/held-bytes.js +49 -4
- package/services/viewer/playback-readiness.js +149 -20
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).
|
|
@@ -489,17 +526,18 @@ export async function startProxyServer({
|
|
|
489
526
|
typeof container?.readSourceRanges === "function" ? container : null;
|
|
490
527
|
for (const zone of demands) {
|
|
491
528
|
if (!map.isCurrent()) return [];
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
const
|
|
499
|
-
|
|
529
|
+
// WHAT A SEGMENT READS IS WORKED OUT ONCE, not on every publication: its
|
|
530
|
+
// tracks, its interval and the key its bytes are kept under depend on
|
|
531
|
+
// the output, the segment and the file's start time, none of which a
|
|
532
|
+
// new map changes. Rebuilt for every segment of the film on every map,
|
|
533
|
+
// this pass held the main thread up to 1.5 s at a time on Home
|
|
534
|
+
// Assistant (torrent-tv/meta#178).
|
|
535
|
+
const reading = segmentReadingOf(zone, tracks.value, shift, map);
|
|
536
|
+
if (!reading) continue;
|
|
537
|
+
const { wanted, modes, interval, rangesKey } = reading;
|
|
500
538
|
const { tracks: _choices, owner: _owner, sourceInterval: _sourceInterval, ...demand } = zone;
|
|
501
539
|
const convert = ranges => {
|
|
502
|
-
for (const [byteStart, byteEnd] of
|
|
540
|
+
for (const [byteStart, byteEnd] of joinedRangesOf(ranges, pieceLength)) converted.push({ ...demand,
|
|
503
541
|
downloadInterval: { from: zone.owner.from, to: zone.owner.to }, byteStart, byteEnd });
|
|
504
542
|
};
|
|
505
543
|
// The bytes one segment needs are a fact of the file and the output, so
|
|
@@ -507,7 +545,6 @@ export async function startProxyServer({
|
|
|
507
545
|
// the map changes every few seconds; worked out afresh on every change,
|
|
508
546
|
// a pass over a two-hour AVI never finished and the next segment's bytes
|
|
509
547
|
// were never asked for (Home Assistant 2026-10-08, torrent-tv/meta#151).
|
|
510
|
-
const rangesKey = `${map.sourceKey}:${map.fileIndex}:${JSON.stringify(interval)}`;
|
|
511
548
|
const known = segmentSourceRanges.get(rangesKey);
|
|
512
549
|
if (known) { convert(known); continue; }
|
|
513
550
|
if (direct) {
|
|
@@ -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;
|
|
@@ -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);
|
|
@@ -736,6 +741,55 @@ export class ViewerRequests {
|
|
|
736
741
|
return forecast;
|
|
737
742
|
}
|
|
738
743
|
|
|
744
|
+
/**
|
|
745
|
+
* The pieces of one output as the start forecast reads them, kept between
|
|
746
|
+
* polls: a piece whose playlist edges, read ranges and torrent inputs have
|
|
747
|
+
* not changed is the same object it was, so what the forecast derived from it
|
|
748
|
+
* once — its exact edges, its joined ranges — is not derived again. An unread
|
|
749
|
+
* piece that is not made and ends before the viewer is not listed: the
|
|
750
|
+
* forecast reads nothing from it. Building the whole film anew on every poll
|
|
751
|
+
* was part of what held the main thread for seconds (torrent-tv/meta#178).
|
|
752
|
+
*
|
|
753
|
+
* @param {object} output
|
|
754
|
+
* @param {Buffer | null} sessionInit
|
|
755
|
+
* @param {Map<number, number>} inventory - Made pieces and their sizes.
|
|
756
|
+
* @param {number} positionSeconds
|
|
757
|
+
* @returns {object[]}
|
|
758
|
+
*/
|
|
759
|
+
#segmentsOf(output, sessionInit, inventory, positionSeconds) {
|
|
760
|
+
const timeline = output.timeline;
|
|
761
|
+
const count = timeline?.segmentCount ?? 0;
|
|
762
|
+
let kept = this.#forecastPieces.get(output);
|
|
763
|
+
if (!kept) {
|
|
764
|
+
kept = [];
|
|
765
|
+
this.#forecastPieces.set(output, kept);
|
|
766
|
+
}
|
|
767
|
+
const segments = [];
|
|
768
|
+
for (let index = 0; index < count; index++) {
|
|
769
|
+
const endSeconds = timeline.publishedStartOf(index + 1);
|
|
770
|
+
const ranges = this.#host.encodeRuns.finishedMediaRangesOf(output, index);
|
|
771
|
+
const mediaRanges = ranges && output.segmentFormat?.servedMediaRanges ?
|
|
772
|
+
output.segmentFormat.servedMediaRanges(ranges, { initBytes: sessionInit }) : undefined;
|
|
773
|
+
if (index !== 0 && mediaRanges === undefined && !inventory.has(index) &&
|
|
774
|
+
Number.isFinite(endSeconds) && Number.isFinite(positionSeconds) && endSeconds <= positionSeconds) {
|
|
775
|
+
continue;
|
|
776
|
+
}
|
|
777
|
+
const startSeconds = timeline.publishedStartOf(index);
|
|
778
|
+
const sourceInputs = this.#host.sourceInputsFor?.(output, index) ?? undefined;
|
|
779
|
+
const previous = kept[index];
|
|
780
|
+
if (previous && previous.startSeconds === startSeconds && previous.endSeconds === endSeconds &&
|
|
781
|
+
previous.mediaRanges === mediaRanges && sameInputs(previous.sourceInputs, sourceInputs)) {
|
|
782
|
+
segments.push(previous);
|
|
783
|
+
continue;
|
|
784
|
+
}
|
|
785
|
+
const segment = { index, startSeconds, endSeconds, sourceInputs, mediaRanges };
|
|
786
|
+
kept[index] = segment;
|
|
787
|
+
segments.push(segment);
|
|
788
|
+
}
|
|
789
|
+
if (kept.length > count) kept.length = count;
|
|
790
|
+
return segments;
|
|
791
|
+
}
|
|
792
|
+
|
|
739
793
|
#rateStateFor(session, consumerId) {
|
|
740
794
|
let byViewer = this.#rateStateBySession.get(session);
|
|
741
795
|
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
|
|
@@ -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);
|
|
@@ -1,5 +1,13 @@
|
|
|
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`, four unless set). A
|
|
6
|
+
* piece from disk is three of their operations, and more at once would only
|
|
7
|
+
* wait behind them (torrent-tv/meta#178).
|
|
8
|
+
*/
|
|
9
|
+
const FILE_READS_AT_ONCE = Math.max(1, Number.parseInt(process.env.UV_THREADPOOL_SIZE ?? "", 10) || 4);
|
|
10
|
+
|
|
3
11
|
/**
|
|
4
12
|
* @file What of a file the torrent already HOLDS, and reading it without
|
|
5
13
|
* asking the swarm for anything.
|
|
@@ -212,17 +220,35 @@ export async function readHeldRanges(torrent, fileIndex, ranges, maxBytes, logge
|
|
|
212
220
|
}
|
|
213
221
|
try {
|
|
214
222
|
const result = ranges.map(([start, end]) => Buffer.allocUnsafeSlow(end - start + 1));
|
|
215
|
-
|
|
223
|
+
// SEVERAL PIECES AT ONCE, each written straight into the copy. One after
|
|
224
|
+
// another, every read from disk waited for this thread's loop on its own,
|
|
225
|
+
// and the loop runs 0.3-0.7 s late while the swarm delivers 7-14 MB/s: a
|
|
226
|
+
// 210 MB stretch took 11 s here (Home Assistant 2026-10-12,
|
|
227
|
+
// torrent-tv/meta#178). As many are in flight as the runtime has threads
|
|
228
|
+
// for file operations (libuv's pool, `UV_THREADPOOL_SIZE`, four by
|
|
229
|
+
// default); more would only queue behind them and ahead of everything else
|
|
230
|
+
// this process reads or writes.
|
|
231
|
+
const readOne = async ([index, span]) => {
|
|
216
232
|
const pieceStart = index * pieceLength;
|
|
217
233
|
const offset = span.start - pieceStart;
|
|
218
234
|
const length = span.end - span.start + 1;
|
|
235
|
+
const [only] = span.ranges;
|
|
236
|
+
// A piece holding one range alone is read into the copy itself; one
|
|
237
|
+
// shared by several ranges is read once and divided, as before.
|
|
238
|
+
if (span.ranges.length === 1 && typeof store.getInto === "function") {
|
|
239
|
+
const from = Math.max(only.absoluteStart, pieceStart);
|
|
240
|
+
const filled = await store.getInto(index, offset, result[only.rangeIndex].subarray(from - only.absoluteStart, from - only.absoluteStart + length));
|
|
241
|
+
if (!filled) return false;
|
|
242
|
+
onRead?.(length);
|
|
243
|
+
return true;
|
|
244
|
+
}
|
|
219
245
|
const buffer = await new Promise((resolve, reject) => {
|
|
220
246
|
store.get(index, { offset, length }, (error, value) => {
|
|
221
247
|
if (error) reject(error);
|
|
222
248
|
else resolve(value);
|
|
223
249
|
});
|
|
224
250
|
});
|
|
225
|
-
if (!buffer || buffer.length !== length) return
|
|
251
|
+
if (!buffer || buffer.length !== length) return false;
|
|
226
252
|
onRead?.(length);
|
|
227
253
|
for (const { rangeIndex, absoluteStart, absoluteEnd } of span.ranges) {
|
|
228
254
|
const from = Math.max(absoluteStart, pieceStart);
|
|
@@ -230,8 +256,27 @@ export async function readHeldRanges(torrent, fileIndex, ranges, maxBytes, logge
|
|
|
230
256
|
const sourceStart = from - span.start;
|
|
231
257
|
result[rangeIndex].set(buffer.subarray(sourceStart, sourceStart + to - from + 1), from - absoluteStart);
|
|
232
258
|
}
|
|
233
|
-
|
|
234
|
-
|
|
259
|
+
return true;
|
|
260
|
+
};
|
|
261
|
+
const spans = [...pieceSpans];
|
|
262
|
+
let next = 0;
|
|
263
|
+
let whole = true;
|
|
264
|
+
const reader = async () => {
|
|
265
|
+
while (whole && next < spans.length) {
|
|
266
|
+
try {
|
|
267
|
+
if (!await readOne(spans[next++])) whole = false;
|
|
268
|
+
} catch (error) {
|
|
269
|
+
whole = false;
|
|
270
|
+
throw error;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
};
|
|
274
|
+
// Every read has returned before the pieces are let go, a failed one too:
|
|
275
|
+
// a read still in flight would otherwise read a piece nothing holds.
|
|
276
|
+
const outcomes = await Promise.allSettled(Array.from({ length: Math.min(spans.length, FILE_READS_AT_ONCE) }, reader));
|
|
277
|
+
const failed = outcomes.find((outcome) => outcome.status === "rejected");
|
|
278
|
+
if (failed) throw failed.reason;
|
|
279
|
+
return whole ? result : null;
|
|
235
280
|
} catch (error) {
|
|
236
281
|
logger?.info?.(`held ranges read ${fileIndex} failed: ${error?.message ?? error}`);
|
|
237
282
|
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
|
}
|
|
@@ -534,6 +546,95 @@ function heldRanges(ranges, segments, unit, position) {
|
|
|
534
546
|
return held;
|
|
535
547
|
}
|
|
536
548
|
|
|
549
|
+
/**
|
|
550
|
+
* The pieces of a track this forecast reads: those still to play (ending after
|
|
551
|
+
* `position`), the first piece, every piece that has been read and every piece
|
|
552
|
+
* that is made. A piece that is none of these changes no answer: it is not
|
|
553
|
+
* scheduled, it is not where the track starts, no unread piece is placed by it
|
|
554
|
+
* (only read pieces are), it lends no frame to what the page holds, and it has
|
|
555
|
+
* no size to measure the output's rate by.
|
|
556
|
+
*
|
|
557
|
+
* @param {{ segments: object[], readySegmentIndices?: number[] }} track
|
|
558
|
+
* @param {number} position - Seconds.
|
|
559
|
+
* @returns {object[]}
|
|
560
|
+
*/
|
|
561
|
+
function piecesThatMatter(track, position) {
|
|
562
|
+
const made = new Set(Array.isArray(track.readySegmentIndices) ? track.readySegmentIndices : []);
|
|
563
|
+
const kept = track.segments.filter((segment) => segment?.index === 0 || segment?.mediaRanges !== undefined ||
|
|
564
|
+
made.has(segment?.index) || !(Number.isFinite(segment?.endSeconds) && segment.endSeconds <= position));
|
|
565
|
+
return kept.length === track.segments.length ? track.segments : kept;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* The exact times of a piece's playlist edges, converted once per piece and
|
|
570
|
+
* kept with it: the playlist's cut times do not change between polls, and
|
|
571
|
+
* converting them again on every one was a fifth of the forecast's time.
|
|
572
|
+
*
|
|
573
|
+
* @type {WeakMap<object, { startSeconds: number, endSeconds: number, start: import("./media-time.js").MediaTime | null, end: import("./media-time.js").MediaTime | null }>}
|
|
574
|
+
*/
|
|
575
|
+
const exactBounds = new WeakMap();
|
|
576
|
+
|
|
577
|
+
function exactTimesOf(segment) {
|
|
578
|
+
let bounds = exactBounds.get(segment);
|
|
579
|
+
if (!bounds || bounds.startSeconds !== segment.startSeconds || bounds.endSeconds !== segment.endSeconds) {
|
|
580
|
+
bounds = {
|
|
581
|
+
startSeconds: segment.startSeconds,
|
|
582
|
+
endSeconds: segment.endSeconds,
|
|
583
|
+
start: Number.isFinite(segment.startSeconds) ? fromSeconds(segment.startSeconds) : null,
|
|
584
|
+
end: Number.isFinite(segment.endSeconds) ? fromSeconds(segment.endSeconds) : null
|
|
585
|
+
};
|
|
586
|
+
exactBounds.set(segment, bounds);
|
|
587
|
+
}
|
|
588
|
+
return bounds;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/** The exact edges a piece carries from the forecast, or converts once. */
|
|
592
|
+
function timesOf(segment) {
|
|
593
|
+
return segment.exact ?? exactTimesOf(segment);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/** @returns {import("./media-time.js").MediaTime[]} The finite playlist edges of a piece, exactly. */
|
|
597
|
+
function exactBoundsOf(segment) {
|
|
598
|
+
const { start, end } = exactTimesOf(segment);
|
|
599
|
+
return [start, end].filter(Boolean);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* `coverage` (sorted, joined; this forecast's own list) with `range` added in
|
|
604
|
+
* place, as `numericCoverage` of the whole list would give it: found by halves,
|
|
605
|
+
* joined with every neighbour it reaches or touches. Bytes held further on —
|
|
606
|
+
* a piece of the file read for another viewer — sit after the place pieces are
|
|
607
|
+
* added in media order, so adding only at the end did not hold.
|
|
608
|
+
*/
|
|
609
|
+
function withCoverage(coverage, range) {
|
|
610
|
+
let low = 0, high = coverage.length;
|
|
611
|
+
while (low < high) {
|
|
612
|
+
const middle = (low + high) >>> 1;
|
|
613
|
+
if (coverage[middle].end < range.start) low = middle + 1;
|
|
614
|
+
else high = middle;
|
|
615
|
+
}
|
|
616
|
+
const joined = { start: range.start, end: range.end };
|
|
617
|
+
let last = low;
|
|
618
|
+
while (last < coverage.length && coverage[last].start <= joined.end) {
|
|
619
|
+
joined.start = Math.min(joined.start, coverage[last].start);
|
|
620
|
+
joined.end = Math.max(joined.end, coverage[last].end);
|
|
621
|
+
last += 1;
|
|
622
|
+
}
|
|
623
|
+
coverage.splice(low, last - low, joined);
|
|
624
|
+
return coverage;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/** `arrivals` (this forecast's own list) with `range` indexed, as `indexedArrivals` of the whole list would give it. */
|
|
628
|
+
function withArrival(arrivals, range, all) {
|
|
629
|
+
if (!(range.finish > 0)) return arrivals;
|
|
630
|
+
const last = arrivals.at(-1);
|
|
631
|
+
if (!last || range.start > last.start || (range.start === last.start && range.end >= last.end)) {
|
|
632
|
+
arrivals.push({ ...range, maximumEnd: Math.max(last?.maximumEnd ?? 0, range.end) });
|
|
633
|
+
return arrivals;
|
|
634
|
+
}
|
|
635
|
+
return indexedArrivals(all);
|
|
636
|
+
}
|
|
637
|
+
|
|
537
638
|
/** Coverage is joined once per resource, independently of arrival times. */
|
|
538
639
|
function numericCoverage(ranges) {
|
|
539
640
|
const joined = [];
|
|
@@ -614,30 +715,58 @@ function sourcePiecesFor(ranges, source) {
|
|
|
614
715
|
*/
|
|
615
716
|
function predictedMediaSegments(segments, unit) {
|
|
616
717
|
const zero = mediaTime(0n, unit);
|
|
617
|
-
|
|
718
|
+
// In order of index, searched by halves: each unread piece looked through
|
|
719
|
+
// every read one for its neighbours, a cost of pieces times read pieces.
|
|
720
|
+
const anchors = segments.filter(({ ranges }) => ranges?.length > 0)
|
|
721
|
+
.sort((left, right) => left.index - right.index);
|
|
722
|
+
const firstAfter = (index) => {
|
|
723
|
+
let low = 0, high = anchors.length;
|
|
724
|
+
while (low < high) {
|
|
725
|
+
const middle = (low + high) >>> 1;
|
|
726
|
+
if (anchors[middle].index <= index) low = middle + 1;
|
|
727
|
+
else high = middle;
|
|
728
|
+
}
|
|
729
|
+
return low;
|
|
730
|
+
};
|
|
618
731
|
return segments.map((segment) => {
|
|
619
732
|
if (segment.ranges !== undefined) return segment;
|
|
733
|
+
const times = timesOf(segment);
|
|
620
734
|
if (anchors.length === 0) {
|
|
621
|
-
return { ...segment, ranges: [{ start: rescale(
|
|
622
|
-
end: rescale(fromSeconds(segment.endSeconds), unit), frame: zero }] };
|
|
735
|
+
return { ...segment, ranges: [{ start: rescale(times.start, unit), end: rescale(times.end, unit), frame: zero }] };
|
|
623
736
|
}
|
|
624
|
-
const
|
|
625
|
-
|
|
626
|
-
|
|
737
|
+
const after = firstAfter(segment.index);
|
|
738
|
+
let before = after - 1;
|
|
739
|
+
while (before >= 0 && anchors[before].index >= segment.index) before -= 1;
|
|
740
|
+
const previous = before >= 0 ? anchors[before] : undefined;
|
|
741
|
+
const next = anchors[after];
|
|
742
|
+
const nominalLeft = previous ? timesOf(previous).end : timesOf(next).start;
|
|
627
743
|
const actualLeft = previous?.ranges.at(-1).end ?? next.ranges[0].start;
|
|
628
|
-
const nominalRight = next ?
|
|
744
|
+
const nominalRight = next ? timesOf(next).start : nominalLeft;
|
|
629
745
|
const actualRight = next?.ranges[0].start ?? actualLeft;
|
|
630
746
|
const nominalSpan = subtract(nominalRight, nominalLeft);
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
747
|
+
// `nearest(actualLeft + fromLeft * (actualRight - actualLeft) / nominalSpan)`
|
|
748
|
+
// in ticks of `unit`, as one fraction: both actual edges are already whole
|
|
749
|
+
// ticks of it, so only the scaled part is rounded, and no common
|
|
750
|
+
// denominator is sought for every piece (most of the forecast's time).
|
|
751
|
+
const actualSpan = actualRight.ticks - actualLeft.ticks;
|
|
752
|
+
const place = (time) => {
|
|
753
|
+
const fromLeft = subtract(time, nominalLeft);
|
|
754
|
+
const [numerator, denominator] = nominalSpan.ticks > 0n
|
|
755
|
+
? [fromLeft.ticks * nominalSpan.timescale * actualSpan, fromLeft.timescale * nominalSpan.ticks]
|
|
756
|
+
: [fromLeft.ticks * unit, fromLeft.timescale];
|
|
757
|
+
return { ticks: actualLeft.ticks + roundedHalfUp(numerator, denominator), timescale: unit };
|
|
636
758
|
};
|
|
637
|
-
return { ...segment, ranges: [{ start: place(
|
|
759
|
+
return { ...segment, ranges: [{ start: place(times.start), end: place(times.end), frame: zero }] };
|
|
638
760
|
});
|
|
639
761
|
}
|
|
640
762
|
|
|
763
|
+
/** `numerator / denominator` to the nearest whole number, halves up, as `nearest` rounds; `denominator` positive. */
|
|
764
|
+
function roundedHalfUp(numerator, denominator) {
|
|
765
|
+
const quotient = numerator / denominator;
|
|
766
|
+
const floor = numerator - quotient * denominator < 0n ? quotient - 1n : quotient;
|
|
767
|
+
return 2n * (numerator - floor * denominator) >= denominator ? floor + 1n : floor;
|
|
768
|
+
}
|
|
769
|
+
|
|
641
770
|
function rateCurve(readings, now) {
|
|
642
771
|
if (readings && Number.isFinite(readings.count) && readings.count > 0 &&
|
|
643
772
|
Number.isFinite(readings.lastAt) && Number.isFinite(readings.lastValue) &&
|