@torrent-tv/proxy 2.97.26 → 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.26",
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 [];
@@ -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.
@@ -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
@@ -264,7 +268,9 @@ export class DownloadMaps {
264
268
  #emit(file) {
265
269
  file.dirty = false;
266
270
  const builtFrom = this.#clock();
267
- 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]);
268
274
  for (const { ranges, requestId, priority, urgent, deadlineAt, order } of file.metadata.values()) {
269
275
  for (const [byteStart, byteEnd] of ranges) {
270
276
  zones.push({ byteStart, byteEnd, priority, urgent, downloadOnly: true, requestId, deadlineAt, ...(order ? { order } : {}) });