@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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.97.25",
3
+ "version": "2.97.27",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "repository": {
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
- const choicesKey = zone.tracks.map(choice => `${choice.type}/${choice.index}/${choice.mode}`).join(",");
441
- const key = `${map.sourceKey}:${map.fileIndex}:${zone.outputKey}:${zone.index}`;
442
- const kept = segmentReadings.get(key);
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
- segmentReadings.set(key, { trackList, shift, choicesKey, from: zone.sourceInterval.from, to: zone.sourceInterval.to, reading });
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 = (at) => {
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: await readMappingSummary(),
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 decides the order within a band, so every unqueued piece
10
- * uses that band's completion time rather than an invented request order.
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
- pending.sort(compareBands);
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 (let start = 0; start < pending.length;) {
103
- let end = start;
104
- const first = pending[start];
105
- while (end < pending.length && compareBands(pending[end], first) === 0) {
106
- const seconds = pending[end++].seconds;
107
- finish = finish === null || seconds === null ? null : finish + seconds * 1000;
108
- }
109
- for (let i = start; i < end; i++) {
110
- const { arrivals, index } = pending[i];
111
- const queuedAt = arrivals.get(index);
112
- if (finish !== null) arrivals.set(index, queuedAt === null ? finish : Math.min(queuedAt, finish));
113
- }
114
- start = end;
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 bandOf = zone => ({ urgency: levelOfMapZone(zone), priority: priorityOfMapZone(zone), deadlineAt: deadlineOfMapZone(zone, now) });
83
+ const keys = zones.map(zone => `${zone.byteStart}:${zone.byteEnd}`);
84
84
  const best = new Map();
85
85
  zones.forEach((zone, index) => {
86
- const key = `${zone.byteStart}:${zone.byteEnd}`;
87
- const kept = best.get(key);
88
- if (!kept || compareBands(bandOf(zone), kept.band) < 0) best.set(key, { index, band: bandOf(zone) });
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((zone, index) => best.get(`${zone.byteStart}:${zone.byteEnd}`).index === index);
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 isCurrent = () => this.#files.get(`${sourceKey}:${fileIndex}`) === file && revision === file.revision;
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
- const workingOut = (priority, zones) => {
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
- const zones = [...file.zones, ...file.nativeZones];
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 } : {}) });