@torrent-tv/proxy 2.97.27 → 2.97.29
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 +27 -5
- 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 +33 -4
- package/services/viewer/DownloadMaps.js +44 -3
- package/services/viewer/PriorityOrchestrator.js +56 -1
- package/services/viewer/playback-pass.js +58 -0
- package/services/viewer/target-input-expansion.js +118 -40
package/package.json
CHANGED
package/server.js
CHANGED
|
@@ -61,6 +61,7 @@ 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
|
|
@@ -469,6 +471,11 @@ export async function startProxyServer({
|
|
|
469
471
|
byIndex.set(zone.index, { trackList, shift, choicesKey, from: zone.sourceInterval.from, to: zone.sourceInterval.to, reading });
|
|
470
472
|
return reading;
|
|
471
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();
|
|
472
479
|
/** A segment's ranges joined at the precision the torrent fetches, once per kept list and piece length. */
|
|
473
480
|
const joinedRanges = new WeakMap();
|
|
474
481
|
const joinedRangesOf = (ranges, pieceLength) => {
|
|
@@ -538,6 +545,17 @@ export async function startProxyServer({
|
|
|
538
545
|
}
|
|
539
546
|
}
|
|
540
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;
|
|
541
559
|
// A container that names the bytes of an interval from an index it holds
|
|
542
560
|
// answers every segment by arithmetic: once that index is read
|
|
543
561
|
// ("source-navigation"), the ranges are computed here directly instead of
|
|
@@ -580,7 +598,7 @@ export async function startProxyServer({
|
|
|
580
598
|
// were never asked for (Home Assistant 2026-10-08, torrent-tv/meta#151).
|
|
581
599
|
const known = segmentSourceRanges.get(rangesKey);
|
|
582
600
|
if (known) { convert(known); continue; }
|
|
583
|
-
if (waiting.has(zone.outputKey)) continue;
|
|
601
|
+
if (waiting.has(zone.outputKey)) { complete = false; continue; }
|
|
584
602
|
// What is resolved so far is stated before a lower zone is worked out
|
|
585
603
|
// (DownloadMaps, torrent-tv/meta#178).
|
|
586
604
|
map.workingOut?.(zone.priority, expanded);
|
|
@@ -613,9 +631,10 @@ export async function startProxyServer({
|
|
|
613
631
|
requestId: `download:${map.sourceKey}:${map.fileIndex}:${zone.outputKey}:${zone.index}`,
|
|
614
632
|
demand: { ...zone.owner, leadSeconds: zone.leadSeconds } });
|
|
615
633
|
if (!map.isCurrent()) return [];
|
|
616
|
-
if (!intervalParams) continue;
|
|
634
|
+
if (!intervalParams) { complete = false; continue; }
|
|
617
635
|
const navigation = await containerOrchestrator.inspect(intervalParams, "source-navigation");
|
|
618
636
|
if (navigation.kind !== "result") {
|
|
637
|
+
complete = false;
|
|
619
638
|
if (navigation.kind === "needs-ranges" || navigation.kind === "needs-memory") waiting.add(zone.outputKey);
|
|
620
639
|
continue;
|
|
621
640
|
}
|
|
@@ -623,13 +642,16 @@ export async function startProxyServer({
|
|
|
623
642
|
const packets = await containerOrchestrator.inspect(intervalParams, originalRanges ? "source-ranges" : "packets");
|
|
624
643
|
if (!map.isCurrent()) return [];
|
|
625
644
|
if (packets.kind === "needs-memory") waiting.add(zone.outputKey);
|
|
626
|
-
if (packets.kind !== "result") continue;
|
|
645
|
+
if (packets.kind !== "result") { complete = false; continue; }
|
|
627
646
|
const input = originalRanges ? packets.value : new SegmentInputs({ index: packets.value, tracks: wanted }).forInterval({ ...interval, mode: track => modes.get(track) });
|
|
628
|
-
if (input.kind !== "result") continue;
|
|
647
|
+
if (input.kind !== "result") { complete = false; continue; }
|
|
629
648
|
segmentSourceRanges.set(rangesKey, input.ranges);
|
|
630
649
|
convert(input.ranges);
|
|
631
650
|
}
|
|
632
|
-
|
|
651
|
+
const result = expanded();
|
|
652
|
+
if (complete) resolvedPasses.set(fileKey, { ...pass, result });
|
|
653
|
+
else resolvedPasses.delete(fileKey);
|
|
654
|
+
return result;
|
|
633
655
|
}
|
|
634
656
|
});
|
|
635
657
|
const mediaReads = new MediaReadRequests({
|
|
@@ -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;
|
|
@@ -8,7 +8,10 @@ const BLOCK_BYTES = 16 * 1024;
|
|
|
8
8
|
/** Estimate completion from observed queues and the public selection bands.
|
|
9
9
|
* The native picker takes a band's pieces from its first up (the `sequential`
|
|
10
10
|
* strategy), so an unqueued piece arrives after the band's pieces before it.
|
|
11
|
-
*
|
|
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).
|
|
@@ -107,8 +124,20 @@ export async function futureDownload(entries, { now = Date.now(), findStore = pi
|
|
|
107
124
|
let finish = now;
|
|
108
125
|
for (const { arrivals, index, seconds } of pending) {
|
|
109
126
|
finish = finish === null || seconds === null ? null : finish + seconds * 1000;
|
|
110
|
-
|
|
111
|
-
if (finish !== null) arrivals.set(index, queuedAt === null ? finish : Math.min(queuedAt, finish));
|
|
127
|
+
if (finish !== null) arrivals.set(index, finish);
|
|
112
128
|
}
|
|
113
129
|
return isCurrent() ? forecasts : new Map();
|
|
114
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
|
+
}
|
|
@@ -20,6 +20,8 @@ export class DownloadMaps {
|
|
|
20
20
|
#clock;
|
|
21
21
|
/** What the torrent is told of the playback zones: as many windows as say the same thing. */
|
|
22
22
|
#pack;
|
|
23
|
+
/** What a list of zones says each segment reads, by the list. @type {WeakMap<object[], Map>} */
|
|
24
|
+
#inputsOf = new WeakMap();
|
|
23
25
|
|
|
24
26
|
constructor({ publish, resolvePlayback = async map => map.zones, log = null, now = Date.now,
|
|
25
27
|
clock = () => performance.timeOrigin + performance.now(), pack = zones => zones }) {
|
|
@@ -57,10 +59,19 @@ export class DownloadMaps {
|
|
|
57
59
|
}
|
|
58
60
|
const deadlineAt = Number.isFinite(current.deadlineAt)
|
|
59
61
|
? current.deadlineAt + (Math.max(0, zone.from - current.from) - (zone.leadSeconds ?? 0)) * 1000 : current.deadlineAt;
|
|
60
|
-
|
|
62
|
+
// A deadline worked out again on a later clock is no change: the
|
|
63
|
+
// torrent orders by deadlines and is sent their order, which the pass
|
|
64
|
+
// below states with the rest.
|
|
65
|
+
if (Object.keys(current).some(key => key !== "from" && key !== "to" && key !== "deadlineAt" && key !== "withinSeconds" &&
|
|
66
|
+
!Object.is(zone[key], current[key]))) changed = true;
|
|
61
67
|
return [{ ...zone, ...current, from: zone.from, to: zone.to, deadlineAt }];
|
|
62
68
|
});
|
|
63
|
-
|
|
69
|
+
// THE ZONES STAND AS THEY ARE UNLESS ONE IS WITHDRAWN OR RE-WORDED. Set
|
|
70
|
+
// anew on every pass, they were a new list each time, so every metadata
|
|
71
|
+
// statement during the pass packed the whole film again: 24 s of the main
|
|
72
|
+
// thread in fifteen minutes of a two-hour AVI on Home Assistant (r24,
|
|
73
|
+
// torrent-tv/meta#178).
|
|
74
|
+
if (changed) this.#setZones(file, retained);
|
|
64
75
|
for (const [statement, metadata] of file.metadata) {
|
|
65
76
|
if (metadata.interval && !this.wantsInterval(sourceKey, fileIndex, metadata.interval)) {
|
|
66
77
|
file.metadata.delete(statement);
|
|
@@ -254,7 +265,16 @@ export class DownloadMaps {
|
|
|
254
265
|
file.zones = zones;
|
|
255
266
|
// What `#emit` reads has changed, whether or not it is published now.
|
|
256
267
|
file.dirty = true;
|
|
268
|
+
// A pass that resolves what the pass before resolved hands back the same
|
|
269
|
+
// zones (`resolvedPasses` in server.js), and what they say of each segment
|
|
270
|
+
// is the same too.
|
|
271
|
+
const known = this.#inputsOf.get(zones);
|
|
272
|
+
if (known) {
|
|
273
|
+
file.inputs = known;
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
257
276
|
file.inputs = new Map();
|
|
277
|
+
this.#inputsOf.set(zones, file.inputs);
|
|
258
278
|
for (const zone of zones) {
|
|
259
279
|
if (typeof zone.outputKey !== "string" || !Number.isSafeInteger(zone.index)) continue;
|
|
260
280
|
let output = file.inputs.get(zone.outputKey);
|
|
@@ -270,7 +290,7 @@ export class DownloadMaps {
|
|
|
270
290
|
const builtFrom = this.#clock();
|
|
271
291
|
// Compared and sent as the torrent is to act on it: the playback zones
|
|
272
292
|
// packed into as few windows as say the same (`coalescedZones`).
|
|
273
|
-
const zones = this.#
|
|
293
|
+
const zones = [...this.#packedPlayback(file)];
|
|
274
294
|
for (const { ranges, requestId, priority, urgent, deadlineAt, order } of file.metadata.values()) {
|
|
275
295
|
for (const [byteStart, byteEnd] of ranges) {
|
|
276
296
|
zones.push({ byteStart, byteEnd, priority, urgent, downloadOnly: true, requestId, deadlineAt, ...(order ? { order } : {}) });
|
|
@@ -339,6 +359,27 @@ export class DownloadMaps {
|
|
|
339
359
|
return pending;
|
|
340
360
|
}
|
|
341
361
|
|
|
362
|
+
/**
|
|
363
|
+
* The playback and native zones packed, worked out again only when they
|
|
364
|
+
* have changed. Both are replaced whole, never edited in place, so the arrays
|
|
365
|
+
* themselves say whether they have. A metadata statement used to pack every
|
|
366
|
+
* zone of the film again before it added its own few ranges: 27 s of the main
|
|
367
|
+
* thread in fifteen minutes of a two-hour AVI on Home Assistant, the thread
|
|
368
|
+
* that also sends the viewer's segments (r22, torrent-tv/meta#178).
|
|
369
|
+
*
|
|
370
|
+
* Not kept when a zone is wanted "within N seconds": packing turns that into
|
|
371
|
+
* an instant on the clock it is packed at.
|
|
372
|
+
*/
|
|
373
|
+
#packedPlayback(file) {
|
|
374
|
+
const kept = file.packed;
|
|
375
|
+
if (kept && kept.zones === file.zones && kept.nativeZones === file.nativeZones) return kept.result;
|
|
376
|
+
const input = [...file.zones, ...file.nativeZones];
|
|
377
|
+
const result = this.#pack(input);
|
|
378
|
+
const relative = input.some(zone => !Number.isFinite(zone.deadlineAt) && Number.isFinite(zone.withinSeconds));
|
|
379
|
+
file.packed = relative ? null : { zones: file.zones, nativeZones: file.nativeZones, result };
|
|
380
|
+
return result;
|
|
381
|
+
}
|
|
382
|
+
|
|
342
383
|
#statsOf(file) {
|
|
343
384
|
file.stats ??= emptyStats(this.#now());
|
|
344
385
|
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
|
+
}
|
|
@@ -19,22 +19,83 @@
|
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
21
|
/**
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* @returns {number}
|
|
22
|
+
* The bytes a run's input holds, each counted once: the declarations and
|
|
23
|
+
* indexes every segment of an original file names are held once by the run
|
|
24
|
+
* that reads them. Kept merged and in order, so a segment's ranges are
|
|
25
|
+
* counted against it by search rather than by sorting everything held again —
|
|
26
|
+
* a soundtrack's segments add a few bytes each, and its stretch is walked far.
|
|
28
27
|
*/
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
28
|
+
class HeldBytes {
|
|
29
|
+
/** Inclusive [start, end], merged, ascending. @type {Array<[number, number]>} */
|
|
30
|
+
#ranges = [];
|
|
31
|
+
total = 0;
|
|
32
|
+
|
|
33
|
+
/** @param {Array<[number, number]>} ranges */
|
|
34
|
+
constructor(ranges = []) {
|
|
35
|
+
this.add(ranges);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The first held range that ends at or after `at`. */
|
|
39
|
+
#from(at) {
|
|
40
|
+
let low = 0;
|
|
41
|
+
let high = this.#ranges.length;
|
|
42
|
+
while (low < high) {
|
|
43
|
+
const middle = (low + high) >> 1;
|
|
44
|
+
if (this.#ranges[middle][1] < at) low = middle + 1;
|
|
45
|
+
else high = middle;
|
|
46
|
+
}
|
|
47
|
+
return low;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* What these ranges would add, without adding them.
|
|
52
|
+
*
|
|
53
|
+
* @param {Array<[number, number]>} ranges
|
|
54
|
+
* @returns {number}
|
|
55
|
+
*/
|
|
56
|
+
adds(ranges) {
|
|
57
|
+
let added = 0;
|
|
58
|
+
for (const [start, end] of merged(ranges)) {
|
|
59
|
+
let at = start;
|
|
60
|
+
for (let index = this.#from(start); at <= end && index < this.#ranges.length && this.#ranges[index][0] <= end; index++) {
|
|
61
|
+
const [heldStart, heldEnd] = this.#ranges[index];
|
|
62
|
+
if (heldStart > at) added += heldStart - at;
|
|
63
|
+
at = Math.max(at, heldEnd + 1);
|
|
64
|
+
}
|
|
65
|
+
if (at <= end) added += end - at + 1;
|
|
66
|
+
}
|
|
67
|
+
return added;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** @param {Array<[number, number]>} ranges */
|
|
71
|
+
add(ranges) {
|
|
72
|
+
for (const [start, end] of merged(ranges)) {
|
|
73
|
+
this.total += this.adds([[start, end]]);
|
|
74
|
+
// Joined with every held range it touches or overlaps.
|
|
75
|
+
const first = this.#from(start - 1);
|
|
76
|
+
let last = first;
|
|
77
|
+
let joinedStart = start;
|
|
78
|
+
let joinedEnd = end;
|
|
79
|
+
while (last < this.#ranges.length && this.#ranges[last][0] <= end + 1) {
|
|
80
|
+
joinedStart = Math.min(joinedStart, this.#ranges[last][0]);
|
|
81
|
+
joinedEnd = Math.max(joinedEnd, this.#ranges[last][1]);
|
|
82
|
+
last++;
|
|
83
|
+
}
|
|
84
|
+
this.#ranges.splice(first, last - first, [joinedStart, joinedEnd]);
|
|
85
|
+
}
|
|
36
86
|
}
|
|
37
|
-
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Inclusive ranges merged where they overlap or touch, ascending. @param {Array<[number, number]>} ranges */
|
|
90
|
+
function merged(ranges) {
|
|
91
|
+
const sorted = [...ranges].sort((left, right) => left[0] - right[0]);
|
|
92
|
+
const out = [];
|
|
93
|
+
for (const [start, end] of sorted) {
|
|
94
|
+
const previous = out.at(-1);
|
|
95
|
+
if (previous && start <= previous[1] + 1) previous[1] = Math.max(previous[1], end);
|
|
96
|
+
else out.push([start, end]);
|
|
97
|
+
}
|
|
98
|
+
return out;
|
|
38
99
|
}
|
|
39
100
|
|
|
40
101
|
/**
|
|
@@ -48,39 +109,56 @@ function coveredBytes(ranges) {
|
|
|
48
109
|
* @returns {Zone[]} In their order; raised zones are copies.
|
|
49
110
|
*/
|
|
50
111
|
export function expandToTargetInputs(zones, targetBytesOf) {
|
|
51
|
-
|
|
52
|
-
|
|
112
|
+
// ONE WALK FOR EACH OUTPUT'S FIRST SEGMENT, and the segments after it only
|
|
113
|
+
// as far as the run's input reaches. The map names every segment of a film,
|
|
114
|
+
// and each pass used to group all of them and work out each one's highest
|
|
115
|
+
// priority before looking at the few the run copies: 26 s of the main thread
|
|
116
|
+
// in fifteen minutes of a two-hour AVI on Home Assistant (r24,
|
|
117
|
+
// torrent-tv/meta#178).
|
|
118
|
+
/** @type {Map<string, { index: number, priority: number, zone: Zone }>} */
|
|
119
|
+
const first = new Map();
|
|
53
120
|
for (const zone of zones) {
|
|
54
|
-
|
|
55
|
-
if (!
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
else segments.set(zone.index, [zone]);
|
|
121
|
+
const best = first.get(zone.outputKey);
|
|
122
|
+
if (!best || zone.priority > best.priority || (zone.priority === best.priority && zone.index < best.index)) {
|
|
123
|
+
first.set(zone.outputKey, { index: zone.index, priority: zone.priority, zone });
|
|
124
|
+
}
|
|
59
125
|
}
|
|
60
|
-
/** @type {Map<Zone, Zone>} */
|
|
61
|
-
const
|
|
62
|
-
for (const [outputKey,
|
|
126
|
+
/** @type {Map<string, { first: { index: number, priority: number, zone: Zone }, target: number, after: Map<number, Zone[]> }>} */
|
|
127
|
+
const runs = new Map();
|
|
128
|
+
for (const [outputKey, best] of first) {
|
|
63
129
|
const target = targetBytesOf(outputKey);
|
|
64
|
-
if (
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
130
|
+
if (target > 0) runs.set(outputKey, { first: best, target, after: new Map() });
|
|
131
|
+
}
|
|
132
|
+
if (runs.size === 0) return zones;
|
|
133
|
+
/** @type {Map<string, Array<[number, number]>>} */
|
|
134
|
+
const held = new Map();
|
|
135
|
+
for (const zone of zones) {
|
|
136
|
+
const run = runs.get(zone.outputKey);
|
|
137
|
+
if (!run) continue;
|
|
138
|
+
if (zone.index === run.first.index) {
|
|
139
|
+
let ranges = held.get(zone.outputKey);
|
|
140
|
+
if (!ranges) held.set(zone.outputKey, ranges = []);
|
|
141
|
+
ranges.push([zone.byteStart, zone.byteEnd]);
|
|
142
|
+
} else if (zone.index > run.first.index) {
|
|
143
|
+
const list = run.after.get(zone.index);
|
|
144
|
+
if (list) list.push(zone);
|
|
145
|
+
else run.after.set(zone.index, [zone]);
|
|
71
146
|
}
|
|
72
|
-
|
|
73
|
-
|
|
147
|
+
}
|
|
148
|
+
/** @type {Map<Zone, Zone>} */
|
|
149
|
+
const raised = new Map();
|
|
150
|
+
for (const [outputKey, run] of runs) {
|
|
151
|
+
const holding = new HeldBytes(held.get(outputKey) ?? []);
|
|
74
152
|
// The run's input is a stretch of consecutive segments: one already made,
|
|
75
153
|
// or not yet worked out, ends it.
|
|
76
|
-
for (let index = first.index + 1;
|
|
77
|
-
const list =
|
|
78
|
-
const ranges =
|
|
79
|
-
if (
|
|
80
|
-
|
|
154
|
+
for (let index = run.first.index + 1; run.after.has(index); index++) {
|
|
155
|
+
const list = run.after.get(index);
|
|
156
|
+
const ranges = list.map(zone => [zone.byteStart, zone.byteEnd]);
|
|
157
|
+
if (holding.total + holding.adds(ranges) > run.target) break;
|
|
158
|
+
holding.add(ranges);
|
|
81
159
|
for (const zone of list) {
|
|
82
|
-
raised.set(zone, { ...zone, priority: first.priority, urgent: first.zone.urgent,
|
|
83
|
-
behind: first.zone.behind, deferred: first.zone.deferred });
|
|
160
|
+
raised.set(zone, { ...zone, priority: run.first.priority, urgent: run.first.zone.urgent,
|
|
161
|
+
behind: run.first.zone.behind, deferred: run.first.zone.deferred });
|
|
84
162
|
}
|
|
85
163
|
}
|
|
86
164
|
}
|