@torrent-tv/proxy 2.97.26 → 2.97.28
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 +49 -10
- package/services/encode/EncodePlan.js +32 -9
- package/services/encode/output/OutputSpec.js +12 -1
- package/services/storage/processor-report.js +75 -22
- package/services/storage/segment-store/SegmentStore.js +117 -22
- package/services/torrent/download/FutureDownload.js +44 -18
- package/services/torrent/map-zones.js +63 -5
- package/services/viewer/DownloadMaps.js +41 -3
- package/services/viewer/PriorityOrchestrator.js +56 -1
- package/services/viewer/playback-pass.js +58 -0
package/package.json
CHANGED
package/server.js
CHANGED
|
@@ -56,11 +56,12 @@ import { createPlaybackPlanner } from "./services/media/playback-planner.js";
|
|
|
56
56
|
import { KeyframeTables } from "./services/media/KeyframeTables.js";
|
|
57
57
|
import { contentsOf } from "./services/torrent/Contents.js";
|
|
58
58
|
import { joinedWithinPieces } from "./services/torrent/demand/pieces.js";
|
|
59
|
-
import { withoutRepeatedZones } from "./services/torrent/map-zones.js";
|
|
59
|
+
import { coalescedZones, withoutRepeatedZones } from "./services/torrent/map-zones.js";
|
|
60
60
|
import { SubtitleOrchestrator } from "./services/media/SubtitleOrchestrator.js";
|
|
61
61
|
import { containerOrchestrator, CONTAINER_HEAD_BYTES, describeWorkTags } from "./services/media/ContainerOrchestrator.js";
|
|
62
62
|
import { readPlaybackDeclarations } from "./services/media/read-playback-declarations.js";
|
|
63
63
|
import { DownloadMaps } from "./services/viewer/DownloadMaps.js";
|
|
64
|
+
import { passInputs, samePass } from "./services/viewer/playback-pass.js";
|
|
64
65
|
import { nativeSourceMap, nativeOriginalSourceMap } from "./services/viewer/NativeSourceMap.js";
|
|
65
66
|
import { MediaReadRequests } from "./services/media/MediaReadRequests.js";
|
|
66
67
|
import { SegmentInputs } from "./services/media/SegmentInputs.js";
|
|
@@ -183,6 +184,7 @@ export async function startProxyServer({
|
|
|
183
184
|
downloadMaps.retire(sourceKey);
|
|
184
185
|
for (const key of segmentSourceRanges.keys()) if (key.startsWith(`${sourceKey}:`)) segmentSourceRanges.delete(key);
|
|
185
186
|
for (const key of segmentReadings.keys()) if (key.startsWith(`${sourceKey}:`)) segmentReadings.delete(key);
|
|
187
|
+
for (const key of resolvedPasses.keys()) if (key.startsWith(`${sourceKey}:`)) resolvedPasses.delete(key);
|
|
186
188
|
outputParts.encodeInputs?.bytesChanged();
|
|
187
189
|
},
|
|
188
190
|
// Pieces arriving is ANNOUNCED by the thread that owns the swarm; what is
|
|
@@ -436,10 +438,23 @@ export async function startProxyServer({
|
|
|
436
438
|
* change; forgotten with the source.
|
|
437
439
|
*/
|
|
438
440
|
const segmentReadings = new Map();
|
|
441
|
+
// Per output, then per segment: every segment of an output of one pass shares
|
|
442
|
+
// its list of track choices, so the output's key and the choices' key are
|
|
443
|
+
// worked out once for it. Built for each of a pass's segments, the two
|
|
444
|
+
// strings cost the main thread 13 s in ten minutes of a cold AVI on Home
|
|
445
|
+
// Assistant (torrent-tv/meta#178).
|
|
446
|
+
const readingsByChoices = new WeakMap();
|
|
439
447
|
const segmentReadingOf = (zone, trackList, shift, map) => {
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
448
|
+
let output = readingsByChoices.get(zone.tracks);
|
|
449
|
+
if (!output) {
|
|
450
|
+
const outputKey = `${map.sourceKey}:${map.fileIndex}:${zone.outputKey}`;
|
|
451
|
+
let byIndex = segmentReadings.get(outputKey);
|
|
452
|
+
if (!byIndex) segmentReadings.set(outputKey, byIndex = new Map());
|
|
453
|
+
output = { byIndex, choicesKey: zone.tracks.map(choice => `${choice.type}/${choice.index}/${choice.mode}`).join(",") };
|
|
454
|
+
readingsByChoices.set(zone.tracks, output);
|
|
455
|
+
}
|
|
456
|
+
const { byIndex, choicesKey } = output;
|
|
457
|
+
const kept = byIndex.get(zone.index);
|
|
443
458
|
if (kept && kept.trackList === trackList && kept.shift === shift && kept.choicesKey === choicesKey &&
|
|
444
459
|
kept.from === zone.sourceInterval.from && kept.to === zone.sourceInterval.to) return kept.reading;
|
|
445
460
|
const selected = zone.tracks.map(choice => ({ choice, track: choice.type === "video"
|
|
@@ -453,9 +468,14 @@ export async function startProxyServer({
|
|
|
453
468
|
modes: Object.fromEntries(wanted.map(track => [track.trackNumber, modes.get(track)])) };
|
|
454
469
|
reading = { wanted, modes, interval, rangesKey: `${map.sourceKey}:${map.fileIndex}:${JSON.stringify(interval)}` };
|
|
455
470
|
}
|
|
456
|
-
|
|
471
|
+
byIndex.set(zone.index, { trackList, shift, choicesKey, from: zone.sourceInterval.from, to: zone.sourceInterval.to, reading });
|
|
457
472
|
return reading;
|
|
458
473
|
};
|
|
474
|
+
/**
|
|
475
|
+
* The last complete pass over each file, by file: what it was worked out
|
|
476
|
+
* from and what it resolved (`playback-pass.js`); forgotten with the source.
|
|
477
|
+
*/
|
|
478
|
+
const resolvedPasses = new Map();
|
|
459
479
|
/** A segment's ranges joined at the precision the torrent fetches, once per kept list and piece length. */
|
|
460
480
|
const joinedRanges = new WeakMap();
|
|
461
481
|
const joinedRangesOf = (ranges, pieceLength) => {
|
|
@@ -474,6 +494,10 @@ export async function startProxyServer({
|
|
|
474
494
|
// same bytes — lost them with 2.96.11, and the start forecast answered
|
|
475
495
|
// "source-input-ranges-unavailable" for ever (Home Assistant 2026-10-11).
|
|
476
496
|
publish: (map) => torrentPool.setPriorityMap({ ...map, zones: withoutRepeatedZones(map.zones) }),
|
|
497
|
+
// And what is sent is packed: a stretch of one level and priority is one
|
|
498
|
+
// window, not one zone per range of each of its segments
|
|
499
|
+
// (`coalescedZones`, torrent-tv/meta#178).
|
|
500
|
+
pack: (zones) => coalescedZones(zones),
|
|
477
501
|
log: (line) => logger.info(line),
|
|
478
502
|
resolvePlayback: async (map) => {
|
|
479
503
|
if (map.zones.length === 0) return [];
|
|
@@ -521,6 +545,17 @@ export async function startProxyServer({
|
|
|
521
545
|
}
|
|
522
546
|
}
|
|
523
547
|
demands.sort((left, right) => left.deadlineAt - right.deadlineAt || right.priority - left.priority || left.index - right.index);
|
|
548
|
+
// WHAT THIS PASS IS WORKED OUT FROM, compared with the last complete pass
|
|
549
|
+
// over the file (`resolvedPasses`): the outputs and their targets, how the
|
|
550
|
+
// torrent fetches and where the file's clock starts, and every segment
|
|
551
|
+
// asked for, in the order of its deadline, with its level and priority.
|
|
552
|
+
const fileKey = `${map.sourceKey}:${map.fileIndex}`;
|
|
553
|
+
const pass = { tracks: tracks.value, ...passInputs({ pieceLength, shift, targets, demands }) };
|
|
554
|
+
const last = resolvedPasses.get(fileKey);
|
|
555
|
+
if (samePass(last, pass)) return last.result;
|
|
556
|
+
// Kept only from a pass that worked every segment out; one that met a
|
|
557
|
+
// reading not there yet is worked out again until it is.
|
|
558
|
+
let complete = true;
|
|
524
559
|
// A container that names the bytes of an interval from an index it holds
|
|
525
560
|
// answers every segment by arithmetic: once that index is read
|
|
526
561
|
// ("source-navigation"), the ranges are computed here directly instead of
|
|
@@ -563,7 +598,7 @@ export async function startProxyServer({
|
|
|
563
598
|
// were never asked for (Home Assistant 2026-10-08, torrent-tv/meta#151).
|
|
564
599
|
const known = segmentSourceRanges.get(rangesKey);
|
|
565
600
|
if (known) { convert(known); continue; }
|
|
566
|
-
if (waiting.has(zone.outputKey)) continue;
|
|
601
|
+
if (waiting.has(zone.outputKey)) { complete = false; continue; }
|
|
567
602
|
// What is resolved so far is stated before a lower zone is worked out
|
|
568
603
|
// (DownloadMaps, torrent-tv/meta#178).
|
|
569
604
|
map.workingOut?.(zone.priority, expanded);
|
|
@@ -596,9 +631,10 @@ export async function startProxyServer({
|
|
|
596
631
|
requestId: `download:${map.sourceKey}:${map.fileIndex}:${zone.outputKey}:${zone.index}`,
|
|
597
632
|
demand: { ...zone.owner, leadSeconds: zone.leadSeconds } });
|
|
598
633
|
if (!map.isCurrent()) return [];
|
|
599
|
-
if (!intervalParams) continue;
|
|
634
|
+
if (!intervalParams) { complete = false; continue; }
|
|
600
635
|
const navigation = await containerOrchestrator.inspect(intervalParams, "source-navigation");
|
|
601
636
|
if (navigation.kind !== "result") {
|
|
637
|
+
complete = false;
|
|
602
638
|
if (navigation.kind === "needs-ranges" || navigation.kind === "needs-memory") waiting.add(zone.outputKey);
|
|
603
639
|
continue;
|
|
604
640
|
}
|
|
@@ -606,13 +642,16 @@ export async function startProxyServer({
|
|
|
606
642
|
const packets = await containerOrchestrator.inspect(intervalParams, originalRanges ? "source-ranges" : "packets");
|
|
607
643
|
if (!map.isCurrent()) return [];
|
|
608
644
|
if (packets.kind === "needs-memory") waiting.add(zone.outputKey);
|
|
609
|
-
if (packets.kind !== "result") continue;
|
|
645
|
+
if (packets.kind !== "result") { complete = false; continue; }
|
|
610
646
|
const input = originalRanges ? packets.value : new SegmentInputs({ index: packets.value, tracks: wanted }).forInterval({ ...interval, mode: track => modes.get(track) });
|
|
611
|
-
if (input.kind !== "result") continue;
|
|
647
|
+
if (input.kind !== "result") { complete = false; continue; }
|
|
612
648
|
segmentSourceRanges.set(rangesKey, input.ranges);
|
|
613
649
|
convert(input.ranges);
|
|
614
650
|
}
|
|
615
|
-
|
|
651
|
+
const result = expanded();
|
|
652
|
+
if (complete) resolvedPasses.set(fileKey, { ...pass, result });
|
|
653
|
+
else resolvedPasses.delete(fileKey);
|
|
654
|
+
return result;
|
|
616
655
|
}
|
|
617
656
|
});
|
|
618
657
|
const mediaReads = new MediaReadRequests({
|
|
@@ -720,6 +720,37 @@ export function planEncoders({
|
|
|
720
720
|
* @returns {{ stall: number, ahead: number, whole: number }} Three counts of
|
|
721
721
|
* seconds, compared in that order by {@link cheaperThan}.
|
|
722
722
|
*/
|
|
723
|
+
/** Each number's rank in the map, worked out once per map rather than per arrangement scored. */
|
|
724
|
+
const ranksByMap = new WeakMap();
|
|
725
|
+
|
|
726
|
+
/**
|
|
727
|
+
* The highest priority any span gives each number from `first` to `last`.
|
|
728
|
+
*
|
|
729
|
+
* Asked once per number of the film by every arrangement a plan scores, it
|
|
730
|
+
* walked every span each time: 19 s of the main thread in ten minutes of a cold
|
|
731
|
+
* AVI on Home Assistant (torrent-tv/meta#178). The map does not change between
|
|
732
|
+
* the arrangements of one plan, so the answer is kept with it.
|
|
733
|
+
*
|
|
734
|
+
* @param {WantedSpan[]} wanted
|
|
735
|
+
* @param {number} first
|
|
736
|
+
* @param {number} last
|
|
737
|
+
* @returns {(index: number) => number}
|
|
738
|
+
*/
|
|
739
|
+
function ranksOf(wanted, first, last) {
|
|
740
|
+
const kept = ranksByMap.get(wanted);
|
|
741
|
+
if (kept && kept.first === first && kept.last === last) return kept.rankAt;
|
|
742
|
+
const ranks = new Float64Array(Math.max(0, last - first + 1));
|
|
743
|
+
for (const span of wanted) {
|
|
744
|
+
const priority = Number(span.priority) || 0;
|
|
745
|
+
for (let index = Math.max(first, span.from); index <= Math.min(last, span.to); index += 1) {
|
|
746
|
+
if (priority > ranks[index - first]) ranks[index - first] = priority;
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
const rankAt = (index) => (index >= first && index <= last ? ranks[index - first] : 0);
|
|
750
|
+
ranksByMap.set(wanted, { first, last, rankAt });
|
|
751
|
+
return rankAt;
|
|
752
|
+
}
|
|
753
|
+
|
|
723
754
|
function latenessOf(encoders, coverage, wanted, untilNeeded, rate, refetchSecPerSegment, segmentSeconds) {
|
|
724
755
|
const first = Math.min(...wanted.map((span) => span.from));
|
|
725
756
|
const last = Math.max(...wanted.map((span) => span.to));
|
|
@@ -750,15 +781,7 @@ function latenessOf(encoders, coverage, wanted, untilNeeded, rate, refetchSecPer
|
|
|
750
781
|
// that is 2024 s, which outvotes everything: field 2026-09-08, one viewer got
|
|
751
782
|
// three encoders, two of them on film behind them, and the run serving them
|
|
752
783
|
// was killed to make room for one.
|
|
753
|
-
const rankAt = (
|
|
754
|
-
let rank = 0;
|
|
755
|
-
for (const span of wanted) {
|
|
756
|
-
if (at >= span.from && at <= span.to) {
|
|
757
|
-
rank = Math.max(rank, Number(span.priority) || 0);
|
|
758
|
-
}
|
|
759
|
-
}
|
|
760
|
-
return rank;
|
|
761
|
-
};
|
|
784
|
+
const rankAt = ranksOf(wanted, first, last);
|
|
762
785
|
// The ranks the map actually states, most urgent first. The comparison is over
|
|
763
786
|
// these and nothing else, so a rank can never be outvoted by a lower one
|
|
764
787
|
// however many seconds are at stake there.
|
|
@@ -196,6 +196,9 @@ const KEY_PATTERN =
|
|
|
196
196
|
* packaged how.
|
|
197
197
|
*/
|
|
198
198
|
export class OutputSpec {
|
|
199
|
+
/** The identity, once worked out. @type {string | null} */
|
|
200
|
+
#key = null;
|
|
201
|
+
|
|
199
202
|
/**
|
|
200
203
|
* @param {object} params
|
|
201
204
|
* @param {string} params.sourceKey - `torrent:<infohash>`, the canonical
|
|
@@ -282,6 +285,13 @@ export class OutputSpec {
|
|
|
282
285
|
* @returns {string}
|
|
283
286
|
*/
|
|
284
287
|
toKey() {
|
|
288
|
+
// Worked out once: nothing that enters it changes after construction —
|
|
289
|
+
// every part copies what it is given — and it is asked for wherever an
|
|
290
|
+
// output is addressed, 5 s of the main thread in fifteen minutes on Home
|
|
291
|
+
// Assistant (r22, torrent-tv/meta#178).
|
|
292
|
+
if (this.#key !== null) {
|
|
293
|
+
return this.#key;
|
|
294
|
+
}
|
|
285
295
|
const parts = [this.sourceKey, `fmt=${this.segmentFormatId}`, this.grid.toKey(), this.carries];
|
|
286
296
|
if (this.video) {
|
|
287
297
|
parts.push(this.video.toKey());
|
|
@@ -289,7 +299,8 @@ export class OutputSpec {
|
|
|
289
299
|
if (this.audio) {
|
|
290
300
|
parts.push(this.audio.toKey());
|
|
291
301
|
}
|
|
292
|
-
|
|
302
|
+
this.#key = parts.join(":");
|
|
303
|
+
return this.#key;
|
|
293
304
|
}
|
|
294
305
|
|
|
295
306
|
/**
|
|
@@ -74,31 +74,78 @@ function statOf(file) {
|
|
|
74
74
|
|
|
75
75
|
/**
|
|
76
76
|
* CPU seconds of this process's threads, by part, or null where `/proc` cannot
|
|
77
|
-
* be read.
|
|
77
|
+
* be read. Each reading names which torrent threads it counted (`epoch`): the
|
|
78
|
+
* difference between two readings that counted different ones says nothing.
|
|
78
79
|
*
|
|
79
80
|
* READ SYNCHRONOUSLY, with the clock read beside it: a reading is only a share
|
|
80
81
|
* when every counter in it and its time belong to one instant. Read as
|
|
81
82
|
* promises on the field host (2.97.0), the counters were taken while the main
|
|
82
83
|
* thread was busy and the time after, and a second read "process 1238 %" of a
|
|
83
|
-
* core (torrent-tv/meta#177).
|
|
84
|
-
* memory, no disk.
|
|
84
|
+
* core (torrent-tv/meta#177).
|
|
85
85
|
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
86
|
+
* THREE OR FOUR FILES A SECOND, NOT EVERY THREAD'S. The line names the main
|
|
87
|
+
* thread, the torrent thread and the rest, and the rest is the whole process
|
|
88
|
+
* less the two: the process's own counter carries every thread it has had.
|
|
89
|
+
* Reading the stat of each of its threads every second, about a hundred
|
|
90
|
+
* files, took 12 s of the main thread in fifteen minutes on Home Assistant
|
|
91
|
+
* (r22, torrent-tv/meta#178). Which threads are the torrent thread is looked
|
|
92
|
+
* up again when one of them is gone and at every `rediscover()`.
|
|
93
|
+
*
|
|
94
|
+
* @param {object} [params]
|
|
95
|
+
* @param {number} [params.pid]
|
|
96
|
+
* @param {(file: string) => string} [params.readText]
|
|
97
|
+
* @param {(dir: string) => string[]} [params.list]
|
|
98
|
+
* @returns {{ read: () => ({ main: number, worker: number, other: number, epoch: number } | null), rediscover: () => void }}
|
|
88
99
|
*/
|
|
89
|
-
export function
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
100
|
+
export function threadReader({ pid = process.pid, readText = (file) => readFileSync(file, "utf8"), list = readdirSync } = {}) {
|
|
101
|
+
const stat = (file) => {
|
|
102
|
+
try {
|
|
103
|
+
return parseStat(readText(file));
|
|
104
|
+
} catch {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
/** The torrent thread's ids, or null until they are looked up. @type {number[] | null} */
|
|
109
|
+
let workers = null;
|
|
110
|
+
/** How many times they have been looked up. */
|
|
111
|
+
let epoch = 0;
|
|
112
|
+
const lookUp = () => {
|
|
113
|
+
let tids;
|
|
114
|
+
try {
|
|
115
|
+
tids = list(`/proc/${pid}/task`);
|
|
116
|
+
} catch {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
return tids.map(Number).filter((tid) => {
|
|
120
|
+
const one = stat(`/proc/${pid}/task/${tid}/stat`);
|
|
121
|
+
return one && partOfThread(tid, one.name, pid) === "worker";
|
|
122
|
+
});
|
|
123
|
+
};
|
|
124
|
+
return {
|
|
125
|
+
read() {
|
|
126
|
+
if (workers === null) {
|
|
127
|
+
workers = lookUp();
|
|
128
|
+
epoch += 1;
|
|
129
|
+
}
|
|
130
|
+
if (workers === null) return null;
|
|
131
|
+
const total = stat(`/proc/${pid}/stat`);
|
|
132
|
+
const main = stat(`/proc/${pid}/task/${pid}/stat`);
|
|
133
|
+
if (!total || !main) return null;
|
|
134
|
+
let worker = 0;
|
|
135
|
+
for (const tid of workers) {
|
|
136
|
+
const one = stat(`/proc/${pid}/task/${tid}/stat`);
|
|
137
|
+
if (!one) {
|
|
138
|
+
workers = null;
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
worker += one.seconds;
|
|
142
|
+
}
|
|
143
|
+
return { main: main.seconds, worker, other: Math.max(0, total.seconds - main.seconds - worker), epoch };
|
|
144
|
+
},
|
|
145
|
+
rediscover() {
|
|
146
|
+
workers = null;
|
|
147
|
+
}
|
|
148
|
+
};
|
|
102
149
|
}
|
|
103
150
|
|
|
104
151
|
/**
|
|
@@ -129,6 +176,9 @@ export function sharesBetween(before, after) {
|
|
|
129
176
|
const seconds = (after.at - before.at) / 1000;
|
|
130
177
|
if (!(seconds > 0)) return null;
|
|
131
178
|
const share = (from, to) => (Number.isFinite(from) && Number.isFinite(to) ? Math.max(0, to - from) / seconds : null);
|
|
179
|
+
// The torrent threads looked up again in between: their counters and the
|
|
180
|
+
// rest's are not comparable across that.
|
|
181
|
+
const threads = before.threads?.epoch === after.threads?.epoch;
|
|
132
182
|
let ffmpeg = null;
|
|
133
183
|
for (const [pid, value] of after.ffmpeg) {
|
|
134
184
|
const earlier = before.ffmpeg.get(pid);
|
|
@@ -137,9 +187,9 @@ export function sharesBetween(before, after) {
|
|
|
137
187
|
return {
|
|
138
188
|
seconds,
|
|
139
189
|
process: share(before.process, after.process),
|
|
140
|
-
main: share(before.threads?.main, after.threads?.main),
|
|
141
|
-
worker: share(before.threads?.worker, after.threads?.worker),
|
|
142
|
-
other: share(before.threads?.other, after.threads?.other),
|
|
190
|
+
main: threads ? share(before.threads?.main, after.threads?.main) : null,
|
|
191
|
+
worker: threads ? share(before.threads?.worker, after.threads?.worker) : null,
|
|
192
|
+
other: threads ? share(before.threads?.other, after.threads?.other) : null,
|
|
143
193
|
ffmpeg
|
|
144
194
|
};
|
|
145
195
|
}
|
|
@@ -178,10 +228,11 @@ export function startProcessorReport({ log, ffmpegPids, detailed = () => false,
|
|
|
178
228
|
let previous = null;
|
|
179
229
|
let series = [];
|
|
180
230
|
let since = Date.now();
|
|
231
|
+
const threads = threadReader();
|
|
181
232
|
const tick = () => {
|
|
182
233
|
const at = Date.now();
|
|
183
234
|
const usage = process.cpuUsage();
|
|
184
|
-
const now = { at, process: (usage.user + usage.system) / 1e6, threads:
|
|
235
|
+
const now = { at, process: (usage.user + usage.system) / 1e6, threads: threads.read(), ffmpeg: readProcessesSeconds(ffmpegPids()) };
|
|
185
236
|
const shares = previous ? sharesBetween(previous, now) : null;
|
|
186
237
|
previous = now;
|
|
187
238
|
if (!shares) return;
|
|
@@ -191,6 +242,8 @@ export function startProcessorReport({ log, ffmpegPids, detailed = () => false,
|
|
|
191
242
|
log(`processor: ${describeShares(series)}`);
|
|
192
243
|
series = [];
|
|
193
244
|
since = now.at;
|
|
245
|
+
// A torrent thread made again since is counted as the rest until then.
|
|
246
|
+
threads.rediscover();
|
|
194
247
|
}
|
|
195
248
|
};
|
|
196
249
|
const timer = setInterval(tick, everyMs);
|
|
@@ -66,6 +66,20 @@ export function directoryNameFor(key) {
|
|
|
66
66
|
return createHash("sha256").update(String(key)).digest("hex").slice(0, 16);
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
/**
|
|
70
|
+
* The biggest of these pieces and its number.
|
|
71
|
+
*
|
|
72
|
+
* @param {Map<number, number>} sizes
|
|
73
|
+
* @returns {{ index: number, size: number }}
|
|
74
|
+
*/
|
|
75
|
+
function largestOf(sizes) {
|
|
76
|
+
let largest = { index: -1, size: 0 };
|
|
77
|
+
for (const [index, size] of sizes) {
|
|
78
|
+
if (size > largest.size) largest = { index, size };
|
|
79
|
+
}
|
|
80
|
+
return largest;
|
|
81
|
+
}
|
|
82
|
+
|
|
69
83
|
/**
|
|
70
84
|
* What one output's directory holds, as last read.
|
|
71
85
|
*
|
|
@@ -75,6 +89,9 @@ export function directoryNameFor(key) {
|
|
|
75
89
|
* @property {number} bytes - What those files weigh.
|
|
76
90
|
* @property {number} unproven - The highest number, whose closure nothing
|
|
77
91
|
* proves, or -1 when the directory holds no segments.
|
|
92
|
+
* @property {boolean} followed - Whether every change since the reading has
|
|
93
|
+
* been told to this store: it was read while the directory was watched, and
|
|
94
|
+
* each piece published, removed or seen arriving since was put into it.
|
|
78
95
|
*/
|
|
79
96
|
|
|
80
97
|
export class SegmentStore {
|
|
@@ -226,21 +243,36 @@ export class SegmentStore {
|
|
|
226
243
|
}
|
|
227
244
|
|
|
228
245
|
/**
|
|
229
|
-
*
|
|
246
|
+
* What this output's directory holds: kept up to date piece by piece while
|
|
247
|
+
* the directory is watched, read whole otherwise.
|
|
248
|
+
*
|
|
249
|
+
* A WATCHED DIRECTORY IS READ ONCE. Every change to it after that is a piece
|
|
250
|
+
* published or removed here, or one the watch names, and each puts that one
|
|
251
|
+
* piece in or takes it out. It used to be read again — `readdir` and a `stat`
|
|
252
|
+
* of every piece — whenever its modification time moved, and the time moves
|
|
253
|
+
* with every working file an encoder opens: on Home Assistant 71 s of the
|
|
254
|
+
* main thread in fifteen minutes of a two-hour AVI, 2250 pieces of soundtrack
|
|
255
|
+
* listed again for each new one (r22, torrent-tv/meta#178).
|
|
230
256
|
*
|
|
231
|
-
*
|
|
232
|
-
*
|
|
257
|
+
* Unwatched, a directory's modification time still decides, so a quiet
|
|
258
|
+
* request costs one `stat` rather than a listing.
|
|
233
259
|
*
|
|
234
260
|
* @param {string} key
|
|
235
261
|
* @returns {HeldContents}
|
|
236
262
|
*/
|
|
237
263
|
refresh(key) {
|
|
238
264
|
const format = this.#formats.get(key);
|
|
239
|
-
const empty = { readAt: 0, byNumber: new Map(), bytes: 0, unproven: -1, largest: { index: -1, size: 0 } };
|
|
265
|
+
const empty = { readAt: 0, byNumber: new Map(), bytes: 0, unproven: -1, largest: { index: -1, size: 0 }, followed: false };
|
|
240
266
|
if (!format) {
|
|
241
267
|
return this.#held.get(key) ?? empty;
|
|
242
268
|
}
|
|
269
|
+
const followed = this.#held.get(key);
|
|
270
|
+
if (followed?.followed && this.#watchers.has(key)) {
|
|
271
|
+
return followed;
|
|
272
|
+
}
|
|
243
273
|
const dir = path.join(this.#root, directoryNameFor(key));
|
|
274
|
+
// Watched from before the listing, so nothing that happens after it is missed.
|
|
275
|
+
const watched = this.#watchers.has(key);
|
|
244
276
|
let mtime = 0;
|
|
245
277
|
try {
|
|
246
278
|
mtime = statSync(dir).mtimeMs;
|
|
@@ -300,11 +332,61 @@ export class SegmentStore {
|
|
|
300
332
|
this.#held.delete(key);
|
|
301
333
|
return empty;
|
|
302
334
|
}
|
|
303
|
-
const contents = { readAt: mtime, byNumber, sizes, bytes, unproven: highest, largest };
|
|
335
|
+
const contents = { readAt: mtime, byNumber, sizes, bytes, unproven: highest, largest, followed: watched };
|
|
304
336
|
this.#held.set(key, contents);
|
|
305
337
|
return contents;
|
|
306
338
|
}
|
|
307
339
|
|
|
340
|
+
/**
|
|
341
|
+
* Put one closed piece into what is held, from the file now under its served
|
|
342
|
+
* name. Nothing is done while nothing is held: the next question reads the
|
|
343
|
+
* directory whole.
|
|
344
|
+
*
|
|
345
|
+
* @param {string} key
|
|
346
|
+
* @param {number} index
|
|
347
|
+
* @returns {void}
|
|
348
|
+
*/
|
|
349
|
+
#heldArrived(key, index) {
|
|
350
|
+
const contents = this.#held.get(key);
|
|
351
|
+
const format = this.#formats.get(key);
|
|
352
|
+
if (!contents || !format?.segmentFileName) {
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
const full = path.join(this.#root, directoryNameFor(key), format.segmentFileName(index));
|
|
356
|
+
const size = statSync(full, { throwIfNoEntry: false })?.size ?? 0;
|
|
357
|
+
// Under the rule `refresh` applies to a listing: a file of no bytes is not
|
|
358
|
+
// a segment, whatever its name says.
|
|
359
|
+
if (!(size > 0)) {
|
|
360
|
+
this.#heldGone(key, index);
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
contents.bytes += size - (contents.sizes.get(index) ?? 0);
|
|
364
|
+
contents.byNumber.set(index, full);
|
|
365
|
+
contents.sizes.set(index, size);
|
|
366
|
+
if (index > contents.unproven) contents.unproven = index;
|
|
367
|
+
if (size > contents.largest.size) contents.largest = { index, size };
|
|
368
|
+
else if (contents.largest.index === index) contents.largest = largestOf(contents.sizes);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Take one piece out of what is held.
|
|
373
|
+
*
|
|
374
|
+
* @param {string} key
|
|
375
|
+
* @param {number} index
|
|
376
|
+
* @returns {void}
|
|
377
|
+
*/
|
|
378
|
+
#heldGone(key, index) {
|
|
379
|
+
const contents = this.#held.get(key);
|
|
380
|
+
if (!contents?.byNumber.has(index)) {
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
contents.bytes -= contents.sizes.get(index) ?? 0;
|
|
384
|
+
contents.byNumber.delete(index);
|
|
385
|
+
contents.sizes.delete(index);
|
|
386
|
+
if (contents.unproven === index) contents.unproven = Math.max(-1, ...contents.byNumber.keys());
|
|
387
|
+
if (contents.largest.index === index) contents.largest = largestOf(contents.sizes);
|
|
388
|
+
}
|
|
389
|
+
|
|
308
390
|
/**
|
|
309
391
|
* The segment numbers this output holds that are finished.
|
|
310
392
|
*
|
|
@@ -566,8 +648,9 @@ export class SegmentStore {
|
|
|
566
648
|
}
|
|
567
649
|
// Publication registers the material even after the previous directory was dropped.
|
|
568
650
|
this.useFormat(key, format);
|
|
569
|
-
// What the directory holds has changed
|
|
570
|
-
this.#
|
|
651
|
+
// What the directory holds has changed by this one piece.
|
|
652
|
+
if (index >= 0) this.#heldArrived(key, index);
|
|
653
|
+
else this.#held.delete(key);
|
|
571
654
|
if (index >= 0) {
|
|
572
655
|
if (mediaRanges) {
|
|
573
656
|
const byIndex = this.#mediaRanges.get(key) ?? new Map();
|
|
@@ -750,6 +833,8 @@ export class SegmentStore {
|
|
|
750
833
|
end();
|
|
751
834
|
return;
|
|
752
835
|
}
|
|
836
|
+
// Nothing can be inferred from a change the platform did not name.
|
|
837
|
+
if (typeof name !== "string") this.#held.delete(key);
|
|
753
838
|
this.#noticed(key, typeof name === "string" ? name : null);
|
|
754
839
|
this.#changed(key);
|
|
755
840
|
});
|
|
@@ -781,6 +866,9 @@ export class SegmentStore {
|
|
|
781
866
|
return;
|
|
782
867
|
}
|
|
783
868
|
this.#watchers.delete(key);
|
|
869
|
+
// Changes are no longer told: what is held is checked against the disk again.
|
|
870
|
+
const held = this.#held.get(key);
|
|
871
|
+
if (held) held.followed = false;
|
|
784
872
|
try {
|
|
785
873
|
watcher.close();
|
|
786
874
|
} catch {
|
|
@@ -800,19 +888,16 @@ export class SegmentStore {
|
|
|
800
888
|
* @returns {void}
|
|
801
889
|
*/
|
|
802
890
|
#noticed(key, name) {
|
|
803
|
-
//
|
|
804
|
-
//
|
|
805
|
-
//
|
|
806
|
-
//
|
|
807
|
-
//
|
|
808
|
-
//
|
|
809
|
-
//
|
|
810
|
-
//
|
|
811
|
-
//
|
|
812
|
-
|
|
813
|
-
// there. That is the hazard `refresh` documents and `publish` has always
|
|
814
|
-
// guarded against by dropping the memory.
|
|
815
|
-
this.#held.delete(key);
|
|
891
|
+
// A reading taken before the watch began is checked against the
|
|
892
|
+
// directory's modification time, which is stated in milliseconds: a file
|
|
893
|
+
// written in the same millisecond as that reading leaves it unmoved, and
|
|
894
|
+
// the piece would be reported absent although it is there. Such a reading
|
|
895
|
+
// is dropped, and the next question reads the directory once, while it is
|
|
896
|
+
// watched. A reading kept up piece by piece is told of this one piece below
|
|
897
|
+
// — not read again, which is a listing of a directory that grows with every
|
|
898
|
+
// file for every file written (71 s in fifteen minutes on Home Assistant,
|
|
899
|
+
// r22, torrent-tv/meta#178).
|
|
900
|
+
if (!this.#held.get(key)?.followed) this.#held.delete(key);
|
|
816
901
|
const format = this.#formats.get(key);
|
|
817
902
|
const dir = path.join(this.#root, directoryNameFor(key));
|
|
818
903
|
const index = name && format?.isSegmentFileName?.(name)
|
|
@@ -823,8 +908,10 @@ export class SegmentStore {
|
|
|
823
908
|
// before anything is announced — otherwise clearing up after a dead run
|
|
824
909
|
// would read as that run's pieces arriving.
|
|
825
910
|
if (!existsSync(path.join(dir, name))) {
|
|
911
|
+
this.#heldGone(key, index);
|
|
826
912
|
return;
|
|
827
913
|
}
|
|
914
|
+
this.#heldArrived(key, index);
|
|
828
915
|
this.#announceArrival(key, index);
|
|
829
916
|
return;
|
|
830
917
|
}
|
|
@@ -839,6 +926,7 @@ export class SegmentStore {
|
|
|
839
926
|
}
|
|
840
927
|
for (const waiting of [...byIndex.keys()]) {
|
|
841
928
|
if (existsSync(path.join(dir, format.segmentFileName(waiting)))) {
|
|
929
|
+
this.#heldArrived(key, waiting);
|
|
842
930
|
this.#announceArrival(key, waiting);
|
|
843
931
|
}
|
|
844
932
|
}
|
|
@@ -1025,7 +1113,14 @@ export class SegmentStore {
|
|
|
1025
1113
|
*/
|
|
1026
1114
|
pathOf(key, index) {
|
|
1027
1115
|
this.#touched.set(key, this.#now());
|
|
1028
|
-
|
|
1116
|
+
const contents = this.refresh(key);
|
|
1117
|
+
const found = contents.byNumber.get(index);
|
|
1118
|
+
if (found || !contents.followed) return found ?? null;
|
|
1119
|
+
// A reading kept up piece by piece answers from what it was told, and a
|
|
1120
|
+
// change the watch failed to deliver would leave a closed piece absent for
|
|
1121
|
+
// good. The one file asked about is looked at — a `stat`, not a listing.
|
|
1122
|
+
this.#heldArrived(key, index);
|
|
1123
|
+
return contents.byNumber.get(index) ?? null;
|
|
1029
1124
|
}
|
|
1030
1125
|
|
|
1031
1126
|
/**
|
|
@@ -1180,7 +1275,7 @@ export class SegmentStore {
|
|
|
1180
1275
|
this.#logger.warn(`segment store: could not remove segment ${index}: ${error.message}`);
|
|
1181
1276
|
return 0;
|
|
1182
1277
|
}
|
|
1183
|
-
this.#
|
|
1278
|
+
this.#heldGone(key, index);
|
|
1184
1279
|
this.#mediaRanges.get(key)?.delete(index);
|
|
1185
1280
|
this.#changed(key);
|
|
1186
1281
|
return size;
|
|
@@ -6,9 +6,12 @@ import { compareBands } from "./bands.js";
|
|
|
6
6
|
const BLOCK_BYTES = 16 * 1024;
|
|
7
7
|
|
|
8
8
|
/** Estimate completion from observed queues and the public selection bands.
|
|
9
|
-
* The native picker
|
|
10
|
-
*
|
|
11
|
-
*
|
|
9
|
+
* The native picker takes a band's pieces from its first up (the `sequential`
|
|
10
|
+
* strategy), so an unqueued piece arrives after the band's pieces before it.
|
|
11
|
+
* A piece whose every missing block is asked for arrives when its queues say,
|
|
12
|
+
* and is not the band's to share out. A supplier is a peer that can be asked
|
|
13
|
+
* for the piece now. Pieces without a measured supplier remain unknown. No
|
|
14
|
+
* requests are changed.
|
|
12
15
|
* Times are conditional on the measured rate and successful verification.
|
|
13
16
|
*/
|
|
14
17
|
export async function futureDownload(entries, { now = Date.now(), findStore = pieceStoreOf, isCurrent = () => true } = {}) {
|
|
@@ -78,9 +81,23 @@ export async function futureDownload(entries, { now = Date.now(), findStore = pi
|
|
|
78
81
|
at = Math.max(at, arrival);
|
|
79
82
|
}
|
|
80
83
|
arrivals.set(index, at);
|
|
84
|
+
// EVERY MISSING BLOCK ASKED FOR: the queues holding them say when it
|
|
85
|
+
// arrives. Each block is reserved to the wire it was asked of, so a faster
|
|
86
|
+
// peer elsewhere does not bring it sooner, and its bytes are not the
|
|
87
|
+
// band's to share out (torrent-tv/meta#178 review, WebTorrent 2.8.5
|
|
88
|
+
// `_request`, torrent.js:1758).
|
|
89
|
+
if (at !== null) {
|
|
90
|
+
if (++steps % 256 === 0) {
|
|
91
|
+
await nextTurn();
|
|
92
|
+
if (!isCurrent()) return new Map();
|
|
93
|
+
}
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
81
96
|
const remaining = piece?.missing;
|
|
82
97
|
const speed = (torrent.wires ?? []).reduce((sum, wire) => {
|
|
83
|
-
|
|
98
|
+
// Only a peer that can be asked for it now: one choking us is asked for
|
|
99
|
+
// nothing outside the pieces it allows, whatever it carried before.
|
|
100
|
+
if (wire.destroyed || !wire.peerPieces?.get(index) || !mayAsk(wire, index)) return sum;
|
|
84
101
|
// What it carried with its queue kept full, where it has been seen so:
|
|
85
102
|
// its speed while our selection gave it little measures our asking
|
|
86
103
|
// (`SelectionLimit`, torrent-tv/meta#178).
|
|
@@ -97,21 +114,30 @@ export async function futureDownload(entries, { now = Date.now(), findStore = pi
|
|
|
97
114
|
}
|
|
98
115
|
forecasts.set(torrent, { measuredAt: now, arrivals });
|
|
99
116
|
}
|
|
100
|
-
|
|
117
|
+
// WITHIN A BAND, IN THE ORDER THE PIECES COME. The picker walks each
|
|
118
|
+
// selection from its first piece up (WebTorrent 2.8.5's default `sequential`
|
|
119
|
+
// strategy, which this proxy keeps), so a piece of a band arrives once those
|
|
120
|
+
// before it have. Charged the whole band's end instead, a band of many
|
|
121
|
+
// segments — the map states one per stretch of a level since 2.97.27 — would
|
|
122
|
+
// put its nearest piece at its farthest piece's time (torrent-tv/meta#178).
|
|
123
|
+
pending.sort((left, right) => compareBands(left, right) || left.index - right.index);
|
|
101
124
|
let finish = now;
|
|
102
|
-
for (
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
while (end < pending.length && compareBands(pending[end], first) === 0) {
|
|
106
|
-
const seconds = pending[end++].seconds;
|
|
107
|
-
finish = finish === null || seconds === null ? null : finish + seconds * 1000;
|
|
108
|
-
}
|
|
109
|
-
for (let i = start; i < end; i++) {
|
|
110
|
-
const { arrivals, index } = pending[i];
|
|
111
|
-
const queuedAt = arrivals.get(index);
|
|
112
|
-
if (finish !== null) arrivals.set(index, queuedAt === null ? finish : Math.min(queuedAt, finish));
|
|
113
|
-
}
|
|
114
|
-
start = end;
|
|
125
|
+
for (const { arrivals, index, seconds } of pending) {
|
|
126
|
+
finish = finish === null || seconds === null ? null : finish + seconds * 1000;
|
|
127
|
+
if (finish !== null) arrivals.set(index, finish);
|
|
115
128
|
}
|
|
116
129
|
return isCurrent() ? forecasts : new Map();
|
|
117
130
|
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Whether this peer can be asked for this piece now: it is not choking us, or
|
|
134
|
+
* it allows this piece while it does (BEP 6, WebTorrent 2.8.5 `_updateWire`).
|
|
135
|
+
*
|
|
136
|
+
* @param {{ peerChoking?: boolean, hasFast?: boolean, peerAllowedFastSet?: number[] }} wire
|
|
137
|
+
* @param {number} index
|
|
138
|
+
* @returns {boolean}
|
|
139
|
+
*/
|
|
140
|
+
function mayAsk(wire, index) {
|
|
141
|
+
if (wire.peerChoking !== true) return true;
|
|
142
|
+
return wire.hasFast === true && Array.isArray(wire.peerAllowedFastSet) && wire.peerAllowedFastSet.includes(index);
|
|
143
|
+
}
|
|
@@ -80,12 +80,70 @@ export function deadlineOfMapZone(zone, now = Date.now()) {
|
|
|
80
80
|
* @returns {Zone[]} In their order, without the repeats.
|
|
81
81
|
*/
|
|
82
82
|
export function withoutRepeatedZones(zones, now = Date.now()) {
|
|
83
|
-
const
|
|
83
|
+
const keys = zones.map(zone => `${zone.byteStart}:${zone.byteEnd}`);
|
|
84
84
|
const best = new Map();
|
|
85
85
|
zones.forEach((zone, index) => {
|
|
86
|
-
const
|
|
87
|
-
const
|
|
88
|
-
if (!kept || compareBands(
|
|
86
|
+
const kept = best.get(keys[index]);
|
|
87
|
+
const band = { urgency: levelOfMapZone(zone), priority: priorityOfMapZone(zone), deadlineAt: deadlineOfMapZone(zone, now) };
|
|
88
|
+
if (!kept || compareBands(band, kept.band) < 0) best.set(keys[index], { index, band });
|
|
89
89
|
});
|
|
90
|
-
return zones.filter((
|
|
90
|
+
return zones.filter((_zone, index) => best.get(keys[index]).index === index);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* What the torrent is told, as few windows as say the same thing: every stretch
|
|
95
|
+
* of bytes once, in the best band that names it, and the stretches of one
|
|
96
|
+
* level and priority that touch or overlap joined into one window, wanted by
|
|
97
|
+
* the earliest deadline among them.
|
|
98
|
+
*
|
|
99
|
+
* A map names every range of every segment — three of an AVI's segment, two of
|
|
100
|
+
* them the header and index all its segments read — and a film is thousands of
|
|
101
|
+
* segments: 10 000 zones, built, compared, copied to the torrent thread and
|
|
102
|
+
* stated there every three seconds a viewer plays on, about 70 s of the main
|
|
103
|
+
* thread in ten minutes on Home Assistant (torrent-tv/meta#178). The swarm acts
|
|
104
|
+
* on the band of a piece and nothing else, and within one band the pieces of
|
|
105
|
+
* consecutive segments are consecutive bytes. A zone a metadata read states
|
|
106
|
+
* keeps its own request and order, and is left as it is.
|
|
107
|
+
*
|
|
108
|
+
* @template {{ byteStart: number, byteEnd: number }} Zone
|
|
109
|
+
* @param {Zone[]} zones
|
|
110
|
+
* @param {number} [now]
|
|
111
|
+
* @returns {object[]}
|
|
112
|
+
*/
|
|
113
|
+
export function coalescedZones(zones, now = Date.now()) {
|
|
114
|
+
const kept = [];
|
|
115
|
+
const groups = new Map();
|
|
116
|
+
for (const zone of withoutRepeatedZones(zones, now)) {
|
|
117
|
+
if (zone.requestId !== undefined || zone.downloadOnly || (Number.isSafeInteger(zone.order) && zone.order > 0)) {
|
|
118
|
+
kept.push(zone);
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
const urgency = levelOfMapZone(zone);
|
|
122
|
+
const priority = priorityOfMapZone(zone);
|
|
123
|
+
const key = `${urgency}:${priority}`;
|
|
124
|
+
let group = groups.get(key);
|
|
125
|
+
if (!group) groups.set(key, group = { urgency, priority, parts: [] });
|
|
126
|
+
group.parts.push({ start: zone.byteStart, end: zone.byteEnd, deadlineAt: deadlineOfMapZone(zone, now) });
|
|
127
|
+
}
|
|
128
|
+
for (const { urgency, priority, parts } of groups.values()) {
|
|
129
|
+
parts.sort((left, right) => left.start - right.start);
|
|
130
|
+
let window = null;
|
|
131
|
+
const close = () => {
|
|
132
|
+
if (!window) return;
|
|
133
|
+
kept.push({ byteStart: window.start, byteEnd: window.end, priority, urgent: urgency === Urgency.NEAR,
|
|
134
|
+
behind: urgency === Urgency.BEHIND, deferred: urgency === Urgency.TAIL,
|
|
135
|
+
...(Number.isFinite(window.deadlineAt) ? { deadlineAt: window.deadlineAt } : {}) });
|
|
136
|
+
};
|
|
137
|
+
for (const part of parts) {
|
|
138
|
+
if (window && part.start <= window.end + 1) {
|
|
139
|
+
window.end = Math.max(window.end, part.end);
|
|
140
|
+
window.deadlineAt = Math.min(window.deadlineAt, part.deadlineAt);
|
|
141
|
+
} else {
|
|
142
|
+
close();
|
|
143
|
+
window = { ...part };
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
close();
|
|
147
|
+
}
|
|
148
|
+
return kept;
|
|
91
149
|
}
|
|
@@ -18,12 +18,17 @@ export class DownloadMaps {
|
|
|
18
18
|
* thread's receipt included (torrent-tv/meta#177).
|
|
19
19
|
*/
|
|
20
20
|
#clock;
|
|
21
|
+
/** What the torrent is told of the playback zones: as many windows as say the same thing. */
|
|
22
|
+
#pack;
|
|
23
|
+
/** What a list of zones says each segment reads, by the list. @type {WeakMap<object[], Map>} */
|
|
24
|
+
#inputsOf = new WeakMap();
|
|
21
25
|
|
|
22
26
|
constructor({ publish, resolvePlayback = async map => map.zones, log = null, now = Date.now,
|
|
23
|
-
clock = () => performance.timeOrigin + performance.now() }) {
|
|
27
|
+
clock = () => performance.timeOrigin + performance.now(), pack = zones => zones }) {
|
|
24
28
|
if (typeof publish !== "function") throw new TypeError("Download map publication is required.");
|
|
25
29
|
this.#publish = publish;
|
|
26
30
|
this.#resolvePlayback = resolvePlayback;
|
|
31
|
+
this.#pack = pack;
|
|
27
32
|
this.#log = typeof log === "function" ? log : null;
|
|
28
33
|
this.#now = now;
|
|
29
34
|
this.#clock = clock;
|
|
@@ -74,7 +79,8 @@ export class DownloadMaps {
|
|
|
74
79
|
}
|
|
75
80
|
if (changed) await this.#emit(file);
|
|
76
81
|
if (revision !== file.revision) return;
|
|
77
|
-
const
|
|
82
|
+
const fileKey = `${sourceKey}:${fileIndex}`;
|
|
83
|
+
const isCurrent = () => this.#files.get(fileKey) === file && revision === file.revision;
|
|
78
84
|
// WHAT IS RESOLVED IS STATED WHILE THE REST IS STILL BEING RESOLVED. A pass
|
|
79
85
|
// works the zones out in the order the swarm is to fetch them, and
|
|
80
86
|
// published only at its end it kept the first bytes from the swarm for as
|
|
@@ -250,7 +256,16 @@ export class DownloadMaps {
|
|
|
250
256
|
file.zones = zones;
|
|
251
257
|
// What `#emit` reads has changed, whether or not it is published now.
|
|
252
258
|
file.dirty = true;
|
|
259
|
+
// A pass that resolves what the pass before resolved hands back the same
|
|
260
|
+
// zones (`resolvedPasses` in server.js), and what they say of each segment
|
|
261
|
+
// is the same too.
|
|
262
|
+
const known = this.#inputsOf.get(zones);
|
|
263
|
+
if (known) {
|
|
264
|
+
file.inputs = known;
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
253
267
|
file.inputs = new Map();
|
|
268
|
+
this.#inputsOf.set(zones, file.inputs);
|
|
254
269
|
for (const zone of zones) {
|
|
255
270
|
if (typeof zone.outputKey !== "string" || !Number.isSafeInteger(zone.index)) continue;
|
|
256
271
|
let output = file.inputs.get(zone.outputKey);
|
|
@@ -264,7 +279,9 @@ export class DownloadMaps {
|
|
|
264
279
|
#emit(file) {
|
|
265
280
|
file.dirty = false;
|
|
266
281
|
const builtFrom = this.#clock();
|
|
267
|
-
|
|
282
|
+
// Compared and sent as the torrent is to act on it: the playback zones
|
|
283
|
+
// packed into as few windows as say the same (`coalescedZones`).
|
|
284
|
+
const zones = [...this.#packedPlayback(file)];
|
|
268
285
|
for (const { ranges, requestId, priority, urgent, deadlineAt, order } of file.metadata.values()) {
|
|
269
286
|
for (const [byteStart, byteEnd] of ranges) {
|
|
270
287
|
zones.push({ byteStart, byteEnd, priority, urgent, downloadOnly: true, requestId, deadlineAt, ...(order ? { order } : {}) });
|
|
@@ -333,6 +350,27 @@ export class DownloadMaps {
|
|
|
333
350
|
return pending;
|
|
334
351
|
}
|
|
335
352
|
|
|
353
|
+
/**
|
|
354
|
+
* The playback and native zones packed, worked out again only when they
|
|
355
|
+
* have changed. Both are replaced whole, never edited in place, so the arrays
|
|
356
|
+
* themselves say whether they have. A metadata statement used to pack every
|
|
357
|
+
* zone of the film again before it added its own few ranges: 27 s of the main
|
|
358
|
+
* thread in fifteen minutes of a two-hour AVI on Home Assistant, the thread
|
|
359
|
+
* that also sends the viewer's segments (r22, torrent-tv/meta#178).
|
|
360
|
+
*
|
|
361
|
+
* Not kept when a zone is wanted "within N seconds": packing turns that into
|
|
362
|
+
* an instant on the clock it is packed at.
|
|
363
|
+
*/
|
|
364
|
+
#packedPlayback(file) {
|
|
365
|
+
const kept = file.packed;
|
|
366
|
+
if (kept && kept.zones === file.zones && kept.nativeZones === file.nativeZones) return kept.result;
|
|
367
|
+
const input = [...file.zones, ...file.nativeZones];
|
|
368
|
+
const result = this.#pack(input);
|
|
369
|
+
const relative = input.some(zone => !Number.isFinite(zone.deadlineAt) && Number.isFinite(zone.withinSeconds));
|
|
370
|
+
file.packed = relative ? null : { zones: file.zones, nativeZones: file.nativeZones, result };
|
|
371
|
+
return result;
|
|
372
|
+
}
|
|
373
|
+
|
|
336
374
|
#statsOf(file) {
|
|
337
375
|
file.stats ??= emptyStats(this.#now());
|
|
338
376
|
return file.stats;
|
|
@@ -18,6 +18,40 @@
|
|
|
18
18
|
|
|
19
19
|
import { emptyMap, mapForViewer, mergeMaps, runsOf, pauseCoefficient } from "./PriorityMap.js";
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* What a map built from these viewers depends on, as values to compare: the
|
|
23
|
+
* same values build the same map (`mapForViewer`, `mergeMaps`).
|
|
24
|
+
*
|
|
25
|
+
* A viewer's map is a function of the second they stand at, not of the moment
|
|
26
|
+
* it is asked — `mapForViewer` floors the position — and of the time they have
|
|
27
|
+
* paused only while that pause is lowering their priorities. A map built on
|
|
28
|
+
* every pass of the encoders' plan, which is every piece a soundtrack copy
|
|
29
|
+
* closes, was the same map 2.4 times a second (Home Assistant r22,
|
|
30
|
+
* torrent-tv/meta#178).
|
|
31
|
+
*
|
|
32
|
+
* @param {{ durationSeconds: number, allowanceSeconds: number, viewers: object[], viewerCount?: number }} params
|
|
33
|
+
* @returns {number[]}
|
|
34
|
+
*/
|
|
35
|
+
export function mapInputsOf({ durationSeconds, allowanceSeconds, viewers, viewerCount }) {
|
|
36
|
+
const count = viewerCount ?? new Set((viewers ?? []).map((one, index) => one.id ?? index)).size;
|
|
37
|
+
const inputs = [Number(durationSeconds) || 0, Number(allowanceSeconds) || 0, count];
|
|
38
|
+
for (const viewer of viewers ?? []) {
|
|
39
|
+
const playing = viewer.playing !== false;
|
|
40
|
+
const urgentReady = viewer.urgentReady === true;
|
|
41
|
+
const at = Number.isFinite(viewer.atSeconds) && viewer.atSeconds > 0 ? Math.floor(viewer.atSeconds) : 0;
|
|
42
|
+
const attenuated = !playing && urgentReady && count > 1;
|
|
43
|
+
inputs.push(at, playing ? 1 : 0, urgentReady ? 1 : 0, attenuated ? Number(viewer.pauseSeconds) || 0 : 0);
|
|
44
|
+
}
|
|
45
|
+
return inputs;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Whether two lists hold the same values in the same order. */
|
|
49
|
+
function sameValues(left, right) {
|
|
50
|
+
if (!left || left.length !== right.length) return false;
|
|
51
|
+
for (let index = 0; index < left.length; index++) if (!Object.is(left[index], right[index])) return false;
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
|
|
21
55
|
export class PriorityOrchestrator {
|
|
22
56
|
/** Where the map goes once it is built. @type {(published: object) => void} */
|
|
23
57
|
#publish;
|
|
@@ -57,6 +91,12 @@ export class PriorityOrchestrator {
|
|
|
57
91
|
*/
|
|
58
92
|
#byOutput = new Map();
|
|
59
93
|
|
|
94
|
+
/** What the map of each file was built from, with its demands. @type {Map<string, { inputs: number[], demand: string }>} */
|
|
95
|
+
#builtFrom = new Map();
|
|
96
|
+
|
|
97
|
+
/** What the map of each output was built from. @type {Map<string, number[]>} */
|
|
98
|
+
#outputBuiltFrom = new Map();
|
|
99
|
+
|
|
60
100
|
/** The registry of this component that knows who watches each output. @type {{ forOutput: (output: object) => Map<string, object> }} */
|
|
61
101
|
#viewers;
|
|
62
102
|
|
|
@@ -155,8 +195,18 @@ export class PriorityOrchestrator {
|
|
|
155
195
|
* film, merged over everyone watching it.
|
|
156
196
|
*/
|
|
157
197
|
build({ sourceKey, fileIndex, durationSeconds, allowanceSeconds, viewers, demandKey = null }) {
|
|
158
|
-
const map = this.#mapFrom({ durationSeconds, allowanceSeconds, viewers });
|
|
159
198
|
const key = `${sourceKey}:${fileIndex}`;
|
|
199
|
+
// The same map and the same demands publish nothing new: neither is built
|
|
200
|
+
// again (`mapInputsOf`).
|
|
201
|
+
const inputs = mapInputsOf({ durationSeconds, allowanceSeconds, viewers });
|
|
202
|
+
const demand = JSON.stringify(demandKey);
|
|
203
|
+
const built = this.#builtFrom.get(key);
|
|
204
|
+
const kept = this.#maps.get(key);
|
|
205
|
+
if (kept && built?.demand === demand && sameValues(built.inputs, inputs)) {
|
|
206
|
+
return kept;
|
|
207
|
+
}
|
|
208
|
+
this.#builtFrom.set(key, { inputs, demand });
|
|
209
|
+
const map = this.#mapFrom({ durationSeconds, allowanceSeconds, viewers });
|
|
160
210
|
this.#maps.set(key, map);
|
|
161
211
|
// Unchanged maps are not republished: the downloading rebuilds what it asks
|
|
162
212
|
// the swarm for on every one, and a viewer sitting still would otherwise
|
|
@@ -302,6 +352,7 @@ export class PriorityOrchestrator {
|
|
|
302
352
|
const last = this.#last.get(key);
|
|
303
353
|
this.#maps.delete(key);
|
|
304
354
|
this.#last.delete(key);
|
|
355
|
+
this.#builtFrom.delete(key);
|
|
305
356
|
if (last) {
|
|
306
357
|
this.#publishNothingFor(last);
|
|
307
358
|
}
|
|
@@ -310,6 +361,7 @@ export class PriorityOrchestrator {
|
|
|
310
361
|
for (const address of [...this.#byOutput.keys()]) {
|
|
311
362
|
if (!byOutput.has(address)) {
|
|
312
363
|
this.#byOutput.delete(address);
|
|
364
|
+
this.#outputBuiltFrom.delete(address);
|
|
313
365
|
}
|
|
314
366
|
}
|
|
315
367
|
for (const one of byFile.values()) {
|
|
@@ -326,6 +378,9 @@ export class PriorityOrchestrator {
|
|
|
326
378
|
for (const [address, one] of byOutput) {
|
|
327
379
|
// Competition is per file, even when two viewers use different outputs.
|
|
328
380
|
one.viewerCount = new Set((byFile.get(one.fileKey)?.viewers ?? []).map((viewer, index) => viewer.id ?? index)).size;
|
|
381
|
+
const inputs = mapInputsOf(one);
|
|
382
|
+
if (this.#byOutput.has(address) && sameValues(this.#outputBuiltFrom.get(address), inputs)) continue;
|
|
383
|
+
this.#outputBuiltFrom.set(address, inputs);
|
|
329
384
|
this.#byOutput.set(address, this.#mapFrom(one));
|
|
330
385
|
}
|
|
331
386
|
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file What one pass that turns a file's playback map into bytes is worked out
|
|
3
|
+
* from, so a pass whose inputs have not changed hands back what the last one
|
|
4
|
+
* resolved (torrent-tv/meta#178).
|
|
5
|
+
*
|
|
6
|
+
* The viewer's map moves a second at a time while segments are seconds long,
|
|
7
|
+
* so most passes give the zones the pass before gave: on Home Assistant 30–110
|
|
8
|
+
* of 50–120 download maps a minute came out unchanged, and working them out
|
|
9
|
+
* took 72 s of the main thread in fifteen minutes (r22). The deadlines are left
|
|
10
|
+
* out of what is compared and their order is kept in it: a segment's deadline
|
|
11
|
+
* is when the viewer reaches it, which moves only when they stop or seek, and
|
|
12
|
+
* what is sent to the torrent is compared by the deadlines' order alone
|
|
13
|
+
* (`DownloadMaps` `shapeOf`).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @param {object} params
|
|
18
|
+
* @param {number} params.pieceLength - The precision the torrent fetches at.
|
|
19
|
+
* @param {number} params.shift - Where the file's own clock starts.
|
|
20
|
+
* @param {Map<string, number | null>} params.targets - Each output's next input, by output.
|
|
21
|
+
* @param {Array<{ outputKey: string, index: number, priority: number, urgent?: boolean,
|
|
22
|
+
* behind?: boolean, deferred?: boolean, from: number, to: number,
|
|
23
|
+
* sourceInterval?: { from: number, to: number } }>} params.demands - In the
|
|
24
|
+
* order of their deadlines.
|
|
25
|
+
* @returns {{ outputs: string[], inputs: number[] }}
|
|
26
|
+
*/
|
|
27
|
+
export function passInputs({ pieceLength, shift, targets, demands }) {
|
|
28
|
+
const outputs = [...targets.keys()];
|
|
29
|
+
const inputs = [pieceLength, shift];
|
|
30
|
+
for (const target of targets.values()) inputs.push(target ?? -1);
|
|
31
|
+
for (const demand of demands) {
|
|
32
|
+
// The segment's own interval too: a cut grid read again can give one
|
|
33
|
+
// number other bytes.
|
|
34
|
+
inputs.push(outputs.indexOf(demand.outputKey), demand.index, demand.priority,
|
|
35
|
+
(demand.urgent ? 1 : 0) + (demand.behind ? 2 : 0) + (demand.deferred ? 4 : 0),
|
|
36
|
+
demand.from, demand.to, demand.sourceInterval?.from ?? -1, demand.sourceInterval?.to ?? -1);
|
|
37
|
+
}
|
|
38
|
+
return { outputs, inputs };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Whether a pass is worked out from what an earlier one was.
|
|
43
|
+
*
|
|
44
|
+
* @param {{ tracks: unknown, outputs: string[], inputs: number[] } | undefined} last
|
|
45
|
+
* @param {{ tracks: unknown, outputs: string[], inputs: number[] }} next
|
|
46
|
+
* @returns {boolean}
|
|
47
|
+
*/
|
|
48
|
+
export function samePass(last, next) {
|
|
49
|
+
return Boolean(last) && last.tracks === next.tracks &&
|
|
50
|
+
sameValues(last.outputs, next.outputs) && sameValues(last.inputs, next.inputs);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Whether two lists hold the same values in the same order. */
|
|
54
|
+
function sameValues(left, right) {
|
|
55
|
+
if (left.length !== right.length) return false;
|
|
56
|
+
for (let index = 0; index < left.length; index++) if (!Object.is(left[index], right[index])) return false;
|
|
57
|
+
return true;
|
|
58
|
+
}
|