@torrent-tv/proxy 2.96.4 → 2.96.6
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 +11 -1
- package/services/encode/EncodeInputs.js +61 -11
- package/services/media/ContainerOrchestrator.js +6 -0
- package/services/torrent/worker/client.js +1 -1
- package/services/torrent/worker/pool-adapter.js +1 -1
- package/services/torrent/worker/worker.js +10 -5
- package/services/viewer/DownloadMaps.js +133 -4
package/package.json
CHANGED
package/server.js
CHANGED
|
@@ -422,6 +422,7 @@ export async function startProxyServer({
|
|
|
422
422
|
const segmentSourceRanges = new Map();
|
|
423
423
|
const downloadMaps = new DownloadMaps({
|
|
424
424
|
publish: (map) => torrentPool.setPriorityMap(map),
|
|
425
|
+
log: (line) => logger.info(line),
|
|
425
426
|
resolvePlayback: async (map) => {
|
|
426
427
|
if (map.zones.length === 0) return [];
|
|
427
428
|
const params = await containerOver(map);
|
|
@@ -630,7 +631,9 @@ export async function startProxyServer({
|
|
|
630
631
|
error.canRetry = true;
|
|
631
632
|
throw error;
|
|
632
633
|
},
|
|
633
|
-
|
|
634
|
+
// `timing`, when given, is told how long each read of a file waited behind
|
|
635
|
+
// the reads asked before it and how long it took (torrent-tv/meta#166).
|
|
636
|
+
resolveEncodeInput: async (output, fromIndex, toIndex, timing = null) => {
|
|
634
637
|
const grid = output.timeline?.published ?? output.timeline?.boundaries;
|
|
635
638
|
const from = grid?.[fromIndex], to = grid?.[toIndex + 1];
|
|
636
639
|
if (!Number.isFinite(from) || !(to > from)) return { kind: "terminal", reason: "output-interval-not-declared" };
|
|
@@ -650,6 +653,13 @@ export async function startProxyServer({
|
|
|
650
653
|
for (const [fileIndex, choices] of selected) {
|
|
651
654
|
const params = await containerOver({ sourceKey: output.file.sourceKey, fileIndex });
|
|
652
655
|
if (!params) return { kind: "needs-source" };
|
|
656
|
+
if (timing) {
|
|
657
|
+
params.onTimed = (_statement, waitedMs, readMs) => {
|
|
658
|
+
timing.reads += 1;
|
|
659
|
+
timing.queuedMs += waitedMs;
|
|
660
|
+
timing.readMs += readMs;
|
|
661
|
+
};
|
|
662
|
+
}
|
|
653
663
|
const tracks = await containerOrchestrator.inspect(params, "tracks");
|
|
654
664
|
if (tracks.kind !== "result") return tracks;
|
|
655
665
|
const container = containerOrchestrator.known(params.sourceKey, fileIndex);
|
|
@@ -59,12 +59,36 @@ export class EncodeInputs {
|
|
|
59
59
|
this.#now = now;
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
/**
|
|
63
|
-
|
|
62
|
+
/**
|
|
63
|
+
* One line when a request's state changes, so a wait that never ends names
|
|
64
|
+
* what it waits for. `detail` is said with it and does not count as a change.
|
|
65
|
+
*/
|
|
66
|
+
#note(request, state, detail = "") {
|
|
64
67
|
if (request.noted === state) return;
|
|
65
68
|
request.noted = state;
|
|
66
69
|
this.#log(`encode input #${request.from}..#${request.to} of ${request.output.outputKey ?? request.output.id}: ${state}` +
|
|
67
|
-
` (held ${this.#held} of ${this.#allowed} allowed)`);
|
|
70
|
+
` (held ${this.#held} of ${this.#allowed} allowed)${detail}`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The container's statement of an interval, its time added to the request's account. */
|
|
74
|
+
async #resolveTimed(timing, output, from, to) {
|
|
75
|
+
const startedAt = this.#now();
|
|
76
|
+
try {
|
|
77
|
+
return await this.#resolve(output, from, to, timing);
|
|
78
|
+
} finally {
|
|
79
|
+
timing.resolutions += 1;
|
|
80
|
+
timing.resolveMs += this.#now() - startedAt;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** `work`'s time added to one field of the request's account. */
|
|
85
|
+
async #spent(timing, field, work) {
|
|
86
|
+
const startedAt = this.#now();
|
|
87
|
+
try {
|
|
88
|
+
return await work();
|
|
89
|
+
} finally {
|
|
90
|
+
timing[field] += this.#now() - startedAt;
|
|
91
|
+
}
|
|
68
92
|
}
|
|
69
93
|
|
|
70
94
|
held() { return this.#held; }
|
|
@@ -216,8 +240,14 @@ export class EncodeInputs {
|
|
|
216
240
|
request.stale = false;
|
|
217
241
|
request.revision = this.#revision;
|
|
218
242
|
request.memoryRevision = this.#memoryRevision;
|
|
243
|
+
// Where the time from the request to its input goes, across every attempt:
|
|
244
|
+
// field 2026-10-10, a picture's request took 38 s before its copy began and
|
|
245
|
+
// nothing said on what (torrent-tv/meta#166).
|
|
246
|
+
request.timing ??= { askedAt: this.#now(), attempts: 0, resolutions: 0, resolveMs: 0,
|
|
247
|
+
reads: 0, queuedMs: 0, readMs: 0, heldMs: 0, budgetMs: 0 };
|
|
248
|
+
request.timing.attempts += 1;
|
|
219
249
|
request.promise = (async () => {
|
|
220
|
-
const resolved = await this.#
|
|
250
|
+
const resolved = await this.#resolveTimed(request.timing, request.output, request.from, request.from);
|
|
221
251
|
if (this.#requests.get(request.key) !== request) return;
|
|
222
252
|
let result;
|
|
223
253
|
if (resolved.kind !== "result") {
|
|
@@ -240,7 +270,8 @@ export class EncodeInputs {
|
|
|
240
270
|
request.result = result;
|
|
241
271
|
this.#note(request, result.kind === "result" ? `ready #${result.from}..#${result.to}, ${result.bytes ?? "?"} bytes` +
|
|
242
272
|
(Number.isFinite(result.copyMs) ? `, copied in ${result.copyMs}ms` : "")
|
|
243
|
-
: `${result.kind}${result.reason ? ` ${result.reason}` : ""}${Number.isFinite(result.bytes) ? ` ${result.bytes} bytes` : ""}
|
|
273
|
+
: `${result.kind}${result.reason ? ` ${result.reason}` : ""}${Number.isFinite(result.bytes) ? ` ${result.bytes} bytes` : ""}`,
|
|
274
|
+
describeTiming(request.timing, this.#now()));
|
|
244
275
|
if (result.kind === "result" || result.kind === "terminal") this.#changed(request.output, result);
|
|
245
276
|
})().catch(error => {
|
|
246
277
|
if (this.#requests.get(request.key) === request) {
|
|
@@ -261,7 +292,7 @@ export class EncodeInputs {
|
|
|
261
292
|
#reserveFor(request) {
|
|
262
293
|
return async bytes => {
|
|
263
294
|
this.#wanted.set(request.key, { wanted: bytes, required: bytes });
|
|
264
|
-
await this.#revise();
|
|
295
|
+
await this.#spent(request.timing, "budgetMs", () => this.#revise());
|
|
265
296
|
const capacity = this.#capacity();
|
|
266
297
|
if (capacity !== null && bytes > capacity && this.#urgent(request.output, request.from)) return {
|
|
267
298
|
kind: "terminal", reason: "source-input-exceeds-memory-capacity", bytes, capacity
|
|
@@ -303,7 +334,7 @@ export class EncodeInputs {
|
|
|
303
334
|
if (!stretches.has(end)) {
|
|
304
335
|
// A longer stretch that cannot be stated is not one to copy; the
|
|
305
336
|
// shorter one already stated still runs.
|
|
306
|
-
const resolved = await this.#
|
|
337
|
+
const resolved = await this.#resolveTimed(request.timing, output, from, end).catch(() => null);
|
|
307
338
|
stretches.set(end, resolved?.kind === "result" && resolved.sources?.every(source => source.input.original === true)
|
|
308
339
|
? { sources: resolved.sources, bytes: originalInputBytes(resolved.sources) } : null);
|
|
309
340
|
}
|
|
@@ -311,7 +342,7 @@ export class EncodeInputs {
|
|
|
311
342
|
};
|
|
312
343
|
const fits = async (end, maxBytes) => {
|
|
313
344
|
const stretch = await at(end);
|
|
314
|
-
return Boolean(stretch) && stretch.bytes <= maxBytes && await this.#present(stretch.sources, held);
|
|
345
|
+
return Boolean(stretch) && stretch.bytes <= maxBytes && await this.#present(stretch.sources, held, request.timing);
|
|
315
346
|
};
|
|
316
347
|
// Grows by doubling and then halves back: a handful of resolutions find
|
|
317
348
|
// the end whether it is a piece away or the rest of the film.
|
|
@@ -333,7 +364,7 @@ export class EncodeInputs {
|
|
|
333
364
|
let end = target === null || this.#heldRanges === null ? from : await longest(target);
|
|
334
365
|
if (this.#requests.get(request.key) !== request) return undefined;
|
|
335
366
|
this.#wanted.set(request.key, { wanted: stretches.get(end).bytes, required: minimum });
|
|
336
|
-
await this.#revise();
|
|
367
|
+
await this.#spent(request.timing, "budgetMs", () => this.#revise());
|
|
337
368
|
if (this.#requests.get(request.key) !== request) return undefined;
|
|
338
369
|
const capacity = this.#capacity();
|
|
339
370
|
if (capacity !== null && minimum > capacity && this.#urgent(output, from)) {
|
|
@@ -371,10 +402,10 @@ export class EncodeInputs {
|
|
|
371
402
|
}
|
|
372
403
|
|
|
373
404
|
/** Whether every range of these sources is downloaded whole now. */
|
|
374
|
-
async #present(sources, held) {
|
|
405
|
+
async #present(sources, held, timing) {
|
|
375
406
|
for (const source of sources) {
|
|
376
407
|
const name = `${source.sourceKey}:${source.fileIndex}`;
|
|
377
|
-
if (!held.has(name)) held.set(name, await Promise.resolve().then(() => this.#heldRanges(source)).catch(() => null) ?? []);
|
|
408
|
+
if (!held.has(name)) held.set(name, await this.#spent(timing, "heldMs", () => Promise.resolve().then(() => this.#heldRanges(source)).catch(() => null)) ?? []);
|
|
378
409
|
const ranges = held.get(name);
|
|
379
410
|
if (!source.input.ranges.every(([start, end]) => ranges.some(([first, last]) => first <= start && last >= end))) return false;
|
|
380
411
|
}
|
|
@@ -382,5 +413,24 @@ export class EncodeInputs {
|
|
|
382
413
|
}
|
|
383
414
|
}
|
|
384
415
|
|
|
416
|
+
/**
|
|
417
|
+
* Where a request has spent its time since it was asked, as one clause of its
|
|
418
|
+
* log line. The resolutions include the reads of the file they made, and those
|
|
419
|
+
* reads are split into the time spent behind earlier reads of the same file and
|
|
420
|
+
* the time of the read itself; what is left of the total is the copy and the
|
|
421
|
+
* waits between attempts for bytes or memory.
|
|
422
|
+
*
|
|
423
|
+
* @param {object | undefined} timing
|
|
424
|
+
* @param {number} now
|
|
425
|
+
* @returns {string}
|
|
426
|
+
*/
|
|
427
|
+
function describeTiming(timing, now) {
|
|
428
|
+
if (!timing) return "";
|
|
429
|
+
return `; asked ${Math.max(0, now - timing.askedAt)}ms ago over ${timing.attempts} attempt(s): ` +
|
|
430
|
+
`${timing.resolutions} resolution(s) ${timing.resolveMs}ms ` +
|
|
431
|
+
`(${timing.reads} file read(s): queued ${timing.queuedMs}ms, reading ${timing.readMs}ms), ` +
|
|
432
|
+
`held ranges ${timing.heldMs}ms, budget ${timing.budgetMs}ms`;
|
|
433
|
+
}
|
|
434
|
+
|
|
385
435
|
function addressOf(output) { return output.outputKey ?? output.id; }
|
|
386
436
|
function keyOf(output, from) { return `${addressOf(output)}:${from}`; }
|
|
@@ -137,10 +137,16 @@ export class ContainerOrchestrator {
|
|
|
137
137
|
if (!lifetime) this.#lifetimes.set(key, lifetime = {});
|
|
138
138
|
const isCurrent = () => this.#lifetimes.get(key) === lifetime && params.isCurrent?.() !== false;
|
|
139
139
|
const previous = this.#reads.get(key) ?? Promise.resolve();
|
|
140
|
+
// Reads of one file run one after another, so an answer can wait for every
|
|
141
|
+
// read asked before it. Told to whoever asked, apart from the read itself,
|
|
142
|
+
// because the two call for different remedies (torrent-tv/meta#166).
|
|
143
|
+
const askedAt = Date.now();
|
|
140
144
|
const reading = previous.catch(() => undefined).then(async () => {
|
|
141
145
|
if (!isCurrent()) return { result: { kind: "terminal", reason: "request-obsolete", requestId: params.requestId } };
|
|
146
|
+
const startedAt = Date.now();
|
|
142
147
|
const revision = params.onReadStart?.(statement);
|
|
143
148
|
const result = await this.#inspectRead({ ...params, isCurrent }, statement);
|
|
149
|
+
params.onTimed?.(statement, startedAt - askedAt, Date.now() - startedAt);
|
|
144
150
|
return { result, revision };
|
|
145
151
|
});
|
|
146
152
|
this.#reads.set(key, reading);
|
|
@@ -476,7 +476,7 @@ export class TorrentWorkerClient {
|
|
|
476
476
|
* @returns {Promise<void>}
|
|
477
477
|
*/
|
|
478
478
|
async setPriorityMap({ sourceKey, fileIndex, zones, durationSeconds }) {
|
|
479
|
-
|
|
479
|
+
return this.#caller.call(Command.PRIORITY_MAP, {
|
|
480
480
|
sourceKey,
|
|
481
481
|
fileIndex,
|
|
482
482
|
zones,
|
|
@@ -168,7 +168,7 @@ export class WorkerTorrentPool {
|
|
|
168
168
|
if (!sourceKey) {
|
|
169
169
|
return;
|
|
170
170
|
}
|
|
171
|
-
|
|
171
|
+
return this.#client.setPriorityMap({ sourceKey, fileIndex, durationSeconds, zones });
|
|
172
172
|
}
|
|
173
173
|
|
|
174
174
|
/**
|
|
@@ -459,12 +459,17 @@ async function runCommand(command, params, id) {
|
|
|
459
459
|
const zones = Array.isArray(params.zones) ? params.zones : [];
|
|
460
460
|
// Nor may one for a file held whole, which has nothing left to fetch.
|
|
461
461
|
const torrent = await knownTorrent(params.sourceKey);
|
|
462
|
-
if (torrent) {
|
|
463
|
-
|
|
464
|
-
pool.applyPriorityMap(torrent, params.fileIndex, zones, params.durationSeconds);
|
|
465
|
-
torrent.emit("priority-map-changed", params.fileIndex);
|
|
462
|
+
if (!torrent) {
|
|
463
|
+
return { appliedMs: null };
|
|
466
464
|
}
|
|
467
|
-
|
|
465
|
+
// What applying a map costs this thread, told to whoever sent it: every
|
|
466
|
+
// zone becomes a window of the register and the swarm is told again
|
|
467
|
+
// (torrent-tv/meta#166).
|
|
468
|
+
const startedAt = performance.now();
|
|
469
|
+
ensureArrivalsWired(params.sourceKey, torrent);
|
|
470
|
+
pool.applyPriorityMap(torrent, params.fileIndex, zones, params.durationSeconds);
|
|
471
|
+
torrent.emit("priority-map-changed", params.fileIndex);
|
|
472
|
+
return { appliedMs: performance.now() - startedAt };
|
|
468
473
|
}
|
|
469
474
|
|
|
470
475
|
case Command.READ_RANGE: {
|
|
@@ -1,14 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How often what the publications of one file cost is said, at most: the
|
|
3
|
+
* cadence of the proxy's other once-a-minute readings.
|
|
4
|
+
*/
|
|
5
|
+
const REPORT_EVERY_MS = 60_000;
|
|
6
|
+
|
|
1
7
|
/** One published download map per file, including download-only metadata reads. */
|
|
2
8
|
export class DownloadMaps {
|
|
3
9
|
#files = new Map();
|
|
4
10
|
#publish;
|
|
5
11
|
#resolvePlayback;
|
|
6
12
|
#nextEpoch = 0;
|
|
13
|
+
#log;
|
|
14
|
+
#now;
|
|
7
15
|
|
|
8
|
-
constructor({ publish, resolvePlayback = async map => map.zones }) {
|
|
16
|
+
constructor({ publish, resolvePlayback = async map => map.zones, log = null, now = Date.now }) {
|
|
9
17
|
if (typeof publish !== "function") throw new TypeError("Download map publication is required.");
|
|
10
18
|
this.#publish = publish;
|
|
11
19
|
this.#resolvePlayback = resolvePlayback;
|
|
20
|
+
this.#log = typeof log === "function" ? log : null;
|
|
21
|
+
this.#now = now;
|
|
12
22
|
}
|
|
13
23
|
|
|
14
24
|
async playback({ sourceKey, fileIndex, durationSeconds, zones }) {
|
|
@@ -197,11 +207,130 @@ export class DownloadMaps {
|
|
|
197
207
|
}
|
|
198
208
|
}
|
|
199
209
|
const publication = { sourceKey: file.sourceKey, fileIndex: file.fileIndex, durationSeconds: file.durationSeconds, zones };
|
|
210
|
+
// SENT ONLY WHEN THE TORRENT WOULD ACT ON IT DIFFERENTLY: other bytes,
|
|
211
|
+
// another level or order of claim, or deadlines in another order. A map was
|
|
212
|
+
// sent after every read of the file's statements, changed or not, and the
|
|
213
|
+
// read waited for it: field 2026-10-10, 48-64 such reads per stretch of a
|
|
214
|
+
// two-hour AVI, 28-54 s before its copy began, with 5-12 thousand zones in
|
|
215
|
+
// each map (torrent-tv/meta#166). A deadline worked out again on a later
|
|
216
|
+
// clock moves every zone of a viewer playing on by the same amount and
|
|
217
|
+
// orders nothing differently, so the deadlines' order is compared and not
|
|
218
|
+
// their values.
|
|
219
|
+
const shape = shapeOf(publication);
|
|
220
|
+
if (file.shape && sameShape(file.shape, shape)) {
|
|
221
|
+
this.#statsOf(file).unchanged += 1;
|
|
222
|
+
this.#report(file);
|
|
223
|
+
return Promise.resolve();
|
|
224
|
+
}
|
|
225
|
+
file.shape = shape;
|
|
200
226
|
const pending = file.pending.then(() => {
|
|
201
|
-
if (this.#files.get(`${file.sourceKey}:${file.fileIndex}`)
|
|
227
|
+
if (this.#files.get(`${file.sourceKey}:${file.fileIndex}`) !== file) return undefined;
|
|
228
|
+
const sentAt = this.#now();
|
|
229
|
+
return Promise.resolve(this.#publish(publication)).then(reply => {
|
|
230
|
+
// A torrent not yet in the thread applies nothing, so this map was not
|
|
231
|
+
// delivered and the next one, however alike, has to be.
|
|
232
|
+
if (reply?.appliedMs === null && file.shape === shape) file.shape = null;
|
|
233
|
+
// The window open now, which a report since this map was asked for may have started.
|
|
234
|
+
const stats = this.#statsOf(file);
|
|
235
|
+
stats.sent += 1;
|
|
236
|
+
stats.zones.push(zones.length);
|
|
237
|
+
stats.deliveredMs.push(this.#now() - sentAt);
|
|
238
|
+
if (Number.isFinite(reply?.appliedMs)) stats.appliedMs.push(reply.appliedMs);
|
|
239
|
+
this.#report(file);
|
|
240
|
+
return reply;
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
// A failed publication must not prevent the next state change from being
|
|
244
|
+
// sent, and leaves nothing to compare the next one against.
|
|
245
|
+
file.pending = pending.catch(() => {
|
|
246
|
+
if (file.shape === shape) file.shape = null;
|
|
202
247
|
});
|
|
203
|
-
// A failed publication must not prevent the next state change from being sent.
|
|
204
|
-
file.pending = pending.catch(() => undefined);
|
|
205
248
|
return pending;
|
|
206
249
|
}
|
|
250
|
+
|
|
251
|
+
#statsOf(file) {
|
|
252
|
+
file.stats ??= { since: this.#now(), sent: 0, unchanged: 0, zones: [], deliveredMs: [], appliedMs: [] };
|
|
253
|
+
return file.stats;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** What this file's publications cost, said once a minute at most and only when one was asked for. */
|
|
257
|
+
#report(file) {
|
|
258
|
+
const stats = file.stats;
|
|
259
|
+
const now = this.#now();
|
|
260
|
+
if (!this.#log || !stats || now - stats.since < REPORT_EVERY_MS) return;
|
|
261
|
+
const total = values => Math.round(values.reduce((sum, value) => sum + value, 0));
|
|
262
|
+
this.#log(`download map ${file.sourceKey.slice(0, 48)}:${file.fileIndex}: ${stats.sent} sent and ` +
|
|
263
|
+
`${stats.unchanged} unchanged not sent in ${((now - stats.since) / 1000).toFixed(1)}s; ` +
|
|
264
|
+
`zones median ${middle(stats.zones)} max ${largest(stats.zones)}; ` +
|
|
265
|
+
`delivered median ${middle(stats.deliveredMs)}ms max ${largest(stats.deliveredMs)}ms (${total(stats.deliveredMs)}ms in all), ` +
|
|
266
|
+
`applied in the torrent thread median ${middle(stats.appliedMs)}ms max ${largest(stats.appliedMs)}ms ` +
|
|
267
|
+
`(${total(stats.appliedMs)}ms in all)`);
|
|
268
|
+
file.stats = { since: now, sent: 0, unchanged: 0, zones: [], deliveredMs: [], appliedMs: [] };
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** How many numbers describe one zone's part in what the torrent does. */
|
|
273
|
+
const ZONE_FIELDS = 7;
|
|
274
|
+
|
|
275
|
+
/** @param {boolean | undefined} value */
|
|
276
|
+
function flag(value) {
|
|
277
|
+
return value === true ? 2 : value === false ? 1 : 0;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** @param {{ deadlineAt?: number }} zone */
|
|
281
|
+
function deadlineOf(zone) {
|
|
282
|
+
return Number.isFinite(zone.deadlineAt) ? zone.deadlineAt : Number.POSITIVE_INFINITY;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* What the torrent acts on in a publication, packed for comparison: each
|
|
287
|
+
* zone's bytes, priority, words of level, order of claim, and the RANK of its
|
|
288
|
+
* deadline among the map's zones rather than the deadline itself. A zone with
|
|
289
|
+
* no deadline carries the time it is wanted within, which the torrent turns
|
|
290
|
+
* into a deadline on its own clock when the map is applied.
|
|
291
|
+
*
|
|
292
|
+
* @param {{ durationSeconds: number, zones: object[] }} publication
|
|
293
|
+
* @returns {{ durationSeconds: number, fields: Float64Array }}
|
|
294
|
+
*/
|
|
295
|
+
function shapeOf({ durationSeconds, zones }) {
|
|
296
|
+
const byDeadline = zones.map((_zone, index) => index).sort((left, right) => {
|
|
297
|
+
const first = deadlineOf(zones[left]);
|
|
298
|
+
const second = deadlineOf(zones[right]);
|
|
299
|
+
return first === second ? left - right : first < second ? -1 : 1;
|
|
300
|
+
});
|
|
301
|
+
const fields = new Float64Array(zones.length * ZONE_FIELDS);
|
|
302
|
+
byDeadline.forEach((index, rank) => {
|
|
303
|
+
fields[index * ZONE_FIELDS + 5] = rank;
|
|
304
|
+
});
|
|
305
|
+
zones.forEach((zone, index) => {
|
|
306
|
+
const at = index * ZONE_FIELDS;
|
|
307
|
+
fields[at] = Number.isFinite(zone.byteStart) ? zone.byteStart : -1;
|
|
308
|
+
fields[at + 1] = Number.isFinite(zone.byteEnd) ? zone.byteEnd : -1;
|
|
309
|
+
fields[at + 2] = Number.isFinite(zone.priority) ? zone.priority : -1;
|
|
310
|
+
fields[at + 3] = flag(zone.urgent) * 27 + flag(zone.deferred) * 9 + flag(zone.behind) * 3 + flag(zone.downloadOnly);
|
|
311
|
+
fields[at + 4] = Number.isSafeInteger(zone.order) ? zone.order : 0;
|
|
312
|
+
fields[at + 6] = Number.isFinite(zone.deadlineAt) ? -2 : Number.isFinite(zone.withinSeconds) ? zone.withinSeconds : -1;
|
|
313
|
+
});
|
|
314
|
+
return { durationSeconds: Number.isFinite(durationSeconds) ? durationSeconds : -1, fields };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** @param {{ durationSeconds: number, fields: Float64Array }} left @param {{ durationSeconds: number, fields: Float64Array }} right */
|
|
318
|
+
function sameShape(left, right) {
|
|
319
|
+
if (left.durationSeconds !== right.durationSeconds || left.fields.length !== right.fields.length) return false;
|
|
320
|
+
for (let index = 0; index < left.fields.length; index++) {
|
|
321
|
+
if (left.fields[index] !== right.fields[index]) return false;
|
|
322
|
+
}
|
|
323
|
+
return true;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** @param {number[]} values */
|
|
327
|
+
function middle(values) {
|
|
328
|
+
if (values.length === 0) return "n/a";
|
|
329
|
+
const sorted = [...values].sort((left, right) => left - right);
|
|
330
|
+
return Math.round(sorted[Math.floor(sorted.length / 2)]);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** @param {number[]} values */
|
|
334
|
+
function largest(values) {
|
|
335
|
+
return values.length === 0 ? "n/a" : Math.round(Math.max(...values));
|
|
207
336
|
}
|