@torrent-tv/proxy 2.97.25 → 2.97.27
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
CHANGED
package/server.js
CHANGED
|
@@ -56,7 +56,7 @@ 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";
|
|
@@ -436,10 +436,23 @@ export async function startProxyServer({
|
|
|
436
436
|
* change; forgotten with the source.
|
|
437
437
|
*/
|
|
438
438
|
const segmentReadings = new Map();
|
|
439
|
+
// Per output, then per segment: every segment of an output of one pass shares
|
|
440
|
+
// its list of track choices, so the output's key and the choices' key are
|
|
441
|
+
// worked out once for it. Built for each of a pass's segments, the two
|
|
442
|
+
// strings cost the main thread 13 s in ten minutes of a cold AVI on Home
|
|
443
|
+
// Assistant (torrent-tv/meta#178).
|
|
444
|
+
const readingsByChoices = new WeakMap();
|
|
439
445
|
const segmentReadingOf = (zone, trackList, shift, map) => {
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
446
|
+
let output = readingsByChoices.get(zone.tracks);
|
|
447
|
+
if (!output) {
|
|
448
|
+
const outputKey = `${map.sourceKey}:${map.fileIndex}:${zone.outputKey}`;
|
|
449
|
+
let byIndex = segmentReadings.get(outputKey);
|
|
450
|
+
if (!byIndex) segmentReadings.set(outputKey, byIndex = new Map());
|
|
451
|
+
output = { byIndex, choicesKey: zone.tracks.map(choice => `${choice.type}/${choice.index}/${choice.mode}`).join(",") };
|
|
452
|
+
readingsByChoices.set(zone.tracks, output);
|
|
453
|
+
}
|
|
454
|
+
const { byIndex, choicesKey } = output;
|
|
455
|
+
const kept = byIndex.get(zone.index);
|
|
443
456
|
if (kept && kept.trackList === trackList && kept.shift === shift && kept.choicesKey === choicesKey &&
|
|
444
457
|
kept.from === zone.sourceInterval.from && kept.to === zone.sourceInterval.to) return kept.reading;
|
|
445
458
|
const selected = zone.tracks.map(choice => ({ choice, track: choice.type === "video"
|
|
@@ -453,7 +466,7 @@ export async function startProxyServer({
|
|
|
453
466
|
modes: Object.fromEntries(wanted.map(track => [track.trackNumber, modes.get(track)])) };
|
|
454
467
|
reading = { wanted, modes, interval, rangesKey: `${map.sourceKey}:${map.fileIndex}:${JSON.stringify(interval)}` };
|
|
455
468
|
}
|
|
456
|
-
|
|
469
|
+
byIndex.set(zone.index, { trackList, shift, choicesKey, from: zone.sourceInterval.from, to: zone.sourceInterval.to, reading });
|
|
457
470
|
return reading;
|
|
458
471
|
};
|
|
459
472
|
/** A segment's ranges joined at the precision the torrent fetches, once per kept list and piece length. */
|
|
@@ -474,6 +487,10 @@ export async function startProxyServer({
|
|
|
474
487
|
// same bytes — lost them with 2.96.11, and the start forecast answered
|
|
475
488
|
// "source-input-ranges-unavailable" for ever (Home Assistant 2026-10-11).
|
|
476
489
|
publish: (map) => torrentPool.setPriorityMap({ ...map, zones: withoutRepeatedZones(map.zones) }),
|
|
490
|
+
// And what is sent is packed: a stretch of one level and priority is one
|
|
491
|
+
// window, not one zone per range of each of its segments
|
|
492
|
+
// (`coalescedZones`, torrent-tv/meta#178).
|
|
493
|
+
pack: (zones) => coalescedZones(zones),
|
|
477
494
|
log: (line) => logger.info(line),
|
|
478
495
|
resolvePlayback: async (map) => {
|
|
479
496
|
if (map.zones.length === 0) return [];
|
|
@@ -566,7 +583,7 @@ export async function startProxyServer({
|
|
|
566
583
|
if (waiting.has(zone.outputKey)) continue;
|
|
567
584
|
// What is resolved so far is stated before a lower zone is worked out
|
|
568
585
|
// (DownloadMaps, torrent-tv/meta#178).
|
|
569
|
-
map.workingOut?.(zone.priority, expanded
|
|
586
|
+
map.workingOut?.(zone.priority, expanded);
|
|
570
587
|
if (direct) {
|
|
571
588
|
// Asked per zone too: a soundtrack of an AVI is read from its own
|
|
572
589
|
// packets, which the same index states (torrent-tv/meta#166).
|
|
@@ -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.
|
|
@@ -468,6 +468,8 @@ export function startMemoryReport({
|
|
|
468
468
|
/** @type {Record<string, number>} */
|
|
469
469
|
let lastWritten = {};
|
|
470
470
|
let lastWrittenAt = 0;
|
|
471
|
+
/** The anonymous memory and the time the mappings were last read at. */
|
|
472
|
+
let mappingsReadAt = null;
|
|
471
473
|
const slug = (label || scope).replace(/[^a-z0-9]+/gi, "-").toLowerCase();
|
|
472
474
|
const directory = snapshotDir || os.tmpdir();
|
|
473
475
|
|
|
@@ -553,13 +555,25 @@ export function startMemoryReport({
|
|
|
553
555
|
// the reading now happens once a second rather than once a minute.
|
|
554
556
|
const available = availableMemory();
|
|
555
557
|
anonymousBytes = await readAnonymousMemory();
|
|
558
|
+
// THE MAPPINGS ONLY WHERE THE ANONYMOUS MEMORY THEY BREAK DOWN HAS
|
|
559
|
+
// MOVED, or once in a quiet interval. The line is written on every
|
|
560
|
+
// swing of resident memory, which array buffers taken and given back
|
|
561
|
+
// make every two or three seconds, and each walk of `smaps` cost the
|
|
562
|
+
// main thread about 70 ms: 15.6 s of it in ten minutes of a cold AVI
|
|
563
|
+
// on Home Assistant, while segments waited to be sent
|
|
564
|
+
// (torrent-tv/meta#178).
|
|
565
|
+
const mappingsDue = mappingsReadAt === null || now - mappingsReadAt.at >= quietMs ||
|
|
566
|
+
!Number.isFinite(anonymousBytes) || !Number.isFinite(mappingsReadAt.anonymousBytes) ||
|
|
567
|
+
Math.abs(anonymousBytes - mappingsReadAt.anonymousBytes) >= changeBytes;
|
|
568
|
+
const mappings = mappingsDue ? await readMappingSummary() : null;
|
|
569
|
+
if (mappingsDue) mappingsReadAt = { at: now, anonymousBytes };
|
|
556
570
|
log(describeMemory({
|
|
557
571
|
scope,
|
|
558
572
|
label,
|
|
559
573
|
process: processMemory,
|
|
560
574
|
available,
|
|
561
575
|
anonymousBytes,
|
|
562
|
-
mappings
|
|
576
|
+
mappings,
|
|
563
577
|
diskFreeBytes: diskPath ? await readDiskFree(diskPath) : null,
|
|
564
578
|
stores,
|
|
565
579
|
extra
|
|
@@ -6,8 +6,8 @@ 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
|
-
*
|
|
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
11
|
* Pieces without a measured supplier remain unknown. No requests are changed.
|
|
12
12
|
* Times are conditional on the measured rate and successful verification.
|
|
13
13
|
*/
|
|
@@ -97,21 +97,18 @@ export async function futureDownload(entries, { now = Date.now(), findStore = pi
|
|
|
97
97
|
}
|
|
98
98
|
forecasts.set(torrent, { measuredAt: now, arrivals });
|
|
99
99
|
}
|
|
100
|
-
|
|
100
|
+
// WITHIN A BAND, IN THE ORDER THE PIECES COME. The picker walks each
|
|
101
|
+
// selection from its first piece up (WebTorrent 2.8.5's default `sequential`
|
|
102
|
+
// strategy, which this proxy keeps), so a piece of a band arrives once those
|
|
103
|
+
// before it have. Charged the whole band's end instead, a band of many
|
|
104
|
+
// segments — the map states one per stretch of a level since 2.97.27 — would
|
|
105
|
+
// put its nearest piece at its farthest piece's time (torrent-tv/meta#178).
|
|
106
|
+
pending.sort((left, right) => compareBands(left, right) || left.index - right.index);
|
|
101
107
|
let finish = now;
|
|
102
|
-
for (
|
|
103
|
-
|
|
104
|
-
const
|
|
105
|
-
|
|
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;
|
|
108
|
+
for (const { arrivals, index, seconds } of pending) {
|
|
109
|
+
finish = finish === null || seconds === null ? null : finish + seconds * 1000;
|
|
110
|
+
const queuedAt = arrivals.get(index);
|
|
111
|
+
if (finish !== null) arrivals.set(index, queuedAt === null ? finish : Math.min(queuedAt, finish));
|
|
115
112
|
}
|
|
116
113
|
return isCurrent() ? forecasts : new Map();
|
|
117
114
|
}
|
|
@@ -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,15 @@ 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;
|
|
21
23
|
|
|
22
24
|
constructor({ publish, resolvePlayback = async map => map.zones, log = null, now = Date.now,
|
|
23
|
-
clock = () => performance.timeOrigin + performance.now() }) {
|
|
25
|
+
clock = () => performance.timeOrigin + performance.now(), pack = zones => zones }) {
|
|
24
26
|
if (typeof publish !== "function") throw new TypeError("Download map publication is required.");
|
|
25
27
|
this.#publish = publish;
|
|
26
28
|
this.#resolvePlayback = resolvePlayback;
|
|
29
|
+
this.#pack = pack;
|
|
27
30
|
this.#log = typeof log === "function" ? log : null;
|
|
28
31
|
this.#now = now;
|
|
29
32
|
this.#clock = clock;
|
|
@@ -74,7 +77,8 @@ export class DownloadMaps {
|
|
|
74
77
|
}
|
|
75
78
|
if (changed) await this.#emit(file);
|
|
76
79
|
if (revision !== file.revision) return;
|
|
77
|
-
const
|
|
80
|
+
const fileKey = `${sourceKey}:${fileIndex}`;
|
|
81
|
+
const isCurrent = () => this.#files.get(fileKey) === file && revision === file.revision;
|
|
78
82
|
// WHAT IS RESOLVED IS STATED WHILE THE REST IS STILL BEING RESOLVED. A pass
|
|
79
83
|
// works the zones out in the order the swarm is to fetch them, and
|
|
80
84
|
// published only at its end it kept the first bytes from the swarm for as
|
|
@@ -87,9 +91,14 @@ export class DownloadMaps {
|
|
|
87
91
|
// pass that works nothing out — every pass over a film already worked out
|
|
88
92
|
// — states nothing early.
|
|
89
93
|
let stated = Number.POSITIVE_INFINITY;
|
|
90
|
-
|
|
94
|
+
// The zones are asked for only when they are stated: worked out before
|
|
95
|
+
// every lower zone of a pass, they were the whole pass over again at each
|
|
96
|
+
// of its 4000 steps, and a cold AVI's start forecast waited 71 s for it
|
|
97
|
+
// (Home Assistant 2026-10-13, torrent-tv/meta#178).
|
|
98
|
+
const workingOut = (priority, zonesOf) => {
|
|
91
99
|
if (!(priority < stated)) return;
|
|
92
100
|
stated = priority;
|
|
101
|
+
const zones = typeof zonesOf === "function" ? zonesOf() : zonesOf;
|
|
93
102
|
if (zones.length === 0 || !isCurrent()) return;
|
|
94
103
|
this.#setZones(file, withRetained(zones, retained));
|
|
95
104
|
this.#emit(file).catch(() => undefined);
|
|
@@ -259,7 +268,9 @@ export class DownloadMaps {
|
|
|
259
268
|
#emit(file) {
|
|
260
269
|
file.dirty = false;
|
|
261
270
|
const builtFrom = this.#clock();
|
|
262
|
-
|
|
271
|
+
// Compared and sent as the torrent is to act on it: the playback zones
|
|
272
|
+
// packed into as few windows as say the same (`coalescedZones`).
|
|
273
|
+
const zones = this.#pack([...file.zones, ...file.nativeZones]);
|
|
263
274
|
for (const { ranges, requestId, priority, urgent, deadlineAt, order } of file.metadata.values()) {
|
|
264
275
|
for (const [byteStart, byteEnd] of ranges) {
|
|
265
276
|
zones.push({ byteStart, byteEnd, priority, urgent, downloadOnly: true, requestId, deadlineAt, ...(order ? { order } : {}) });
|