@torrent-tv/proxy 2.80.12 → 2.80.13

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.
@@ -0,0 +1,142 @@
1
+ /**
2
+ * @file How well the priority map is being served, measured where somebody waits.
3
+ *
4
+ * The map says what matters most. Nothing said whether what mattered most was
5
+ * actually delivered first — so "is the prioritisation any good" had no answer
6
+ * of any kind, for either of the two things that read the map.
7
+ *
8
+ * The measure is the same for both, and it is a wait recorded against the rank
9
+ * the map gave the thing waited for AT THE MOMENT it was asked for. Read that
10
+ * way it says which part is wrong rather than whether the whole scheme is:
11
+ *
12
+ * - long waits at the TOP rank mean the urgent zone is not being served first,
13
+ * which is a fault in whoever acts on the map;
14
+ * - long waits further down with none at the top mean the zones are the wrong
15
+ * width — the urgent one too narrow, so the viewer reaches material that was
16
+ * only ever ranked "soon";
17
+ * - a rank with no waits at all is not a good sign or a bad one, it is silence,
18
+ * and it is reported as such rather than as a zero.
19
+ *
20
+ * There is nothing in here about encoders, torrents, sessions or viewers: it is
21
+ * given a rank and a number of milliseconds.
22
+ */
23
+
24
+ /** How many waits to keep per rank. A median wants a sample, not a history. */
25
+ const HISTORY = 200;
26
+
27
+ /**
28
+ * Ranks are collapsed into bands before they are counted.
29
+ *
30
+ * The map's ranks are as many as the film needs — 100 down to 1 on a long file
31
+ * — and a table with a hundred rows says nothing a reader can hold. What is
32
+ * being asked is coarse: was the thing waited for what the viewer needs NOW,
33
+ * what they reach shortly, or the rest of the film. So the top rank is its own
34
+ * band, the next few are the second, and everything below is the third.
35
+ *
36
+ * @param {number} rank
37
+ * @param {number} topRank - The highest rank the map currently states.
38
+ * @returns {"now" | "soon" | "later"}
39
+ */
40
+ export function bandOf(rank, topRank) {
41
+ if (!Number.isFinite(rank) || rank <= 0) {
42
+ return "later";
43
+ }
44
+ const top = Number.isFinite(topRank) && topRank > 0 ? topRank : rank;
45
+ if (rank >= top) {
46
+ return "now";
47
+ }
48
+ // Within a tenth of the top: what a viewer reaches while watching what they
49
+ // hold. A tenth is the map's own shape — its zones widen geometrically — and
50
+ // not a threshold chosen for this table.
51
+ return rank >= top - Math.max(1, Math.round(top / 10)) ? "soon" : "later";
52
+ }
53
+
54
+ export class WaitLedger {
55
+ /** @type {Map<string, Map<string, number[]>>} */
56
+ #waits = new Map();
57
+
58
+ /** @type {Map<string, Map<string, number>>} */
59
+ #counts = new Map();
60
+
61
+ /**
62
+ * Somebody waited this long for something the map ranked this highly.
63
+ *
64
+ * @param {string} key - What the waits belong to: an output, or a file.
65
+ * @param {number} waitedMs
66
+ * @param {number} rank - The map's rank for the thing waited for.
67
+ * @param {number} topRank - The highest rank the map states, so the rank can
68
+ * be read as a position rather than as an absolute number.
69
+ */
70
+ note(key, waitedMs, rank, topRank) {
71
+ if (!key || !Number.isFinite(waitedMs) || waitedMs < 0) {
72
+ return;
73
+ }
74
+ const band = bandOf(rank, topRank);
75
+ let byBand = this.#waits.get(key);
76
+ if (!byBand) {
77
+ byBand = new Map();
78
+ this.#waits.set(key, byBand);
79
+ }
80
+ const held = byBand.get(band) ?? [];
81
+ held.push(waitedMs);
82
+ while (held.length > HISTORY) {
83
+ held.shift();
84
+ }
85
+ byBand.set(band, held);
86
+
87
+ let counts = this.#counts.get(key);
88
+ if (!counts) {
89
+ counts = new Map();
90
+ this.#counts.set(key, counts);
91
+ }
92
+ counts.set(band, (counts.get(band) ?? 0) + 1);
93
+ }
94
+
95
+ /**
96
+ * What the waits say, in one line, or null while nothing has waited.
97
+ *
98
+ * The count is the whole run and the median and worst are the recent sample,
99
+ * because those answer different questions: how often, and how badly.
100
+ *
101
+ * @param {string} key
102
+ * @returns {string | null}
103
+ */
104
+ describe(key) {
105
+ const byBand = this.#waits.get(key);
106
+ if (!byBand || byBand.size === 0) {
107
+ return null;
108
+ }
109
+ const counts = this.#counts.get(key) ?? new Map();
110
+ const parts = [];
111
+ for (const band of ["now", "soon", "later"]) {
112
+ const held = byBand.get(band);
113
+ if (!held || held.length === 0) {
114
+ // Said out loud, because an absent band and a band that never waited
115
+ // are the same silence and neither is a zero.
116
+ parts.push(`${band} none`);
117
+ continue;
118
+ }
119
+ const sorted = [...held].sort((a, b) => a - b);
120
+ const median = sorted[Math.floor(sorted.length / 2)];
121
+ parts.push(
122
+ `${band} ${counts.get(band) ?? held.length} wait(s) median ${Math.round(median)}ms ` +
123
+ `worst ${Math.round(sorted[sorted.length - 1])}ms`
124
+ );
125
+ }
126
+ return parts.join(", ");
127
+ }
128
+
129
+ /**
130
+ * @param {string} key
131
+ */
132
+ forget(key) {
133
+ this.#waits.delete(key);
134
+ this.#counts.delete(key);
135
+ }
136
+ }
137
+
138
+ /**
139
+ * One ledger for the process, because the question is asked in two layers and
140
+ * the answer is comparable only if the scale is the same.
141
+ */
142
+ export const waits = new WaitLedger();
@@ -15,7 +15,7 @@ import { rmSync, statfsSync } from "node:fs";
15
15
  import WebTorrent from "webtorrent";
16
16
  import { logger } from "../utils/logger.js";
17
17
  import { SharedPieceStore, findSharedStore } from "./piece-store/shared-piece-store.js";
18
- import { Urgency } from "./demand/index.js";
18
+ import { Urgency, urgencyName } from "./demand/index.js";
19
19
  import { demandFor, forgetTorrent, reconcileAll } from "./download/registry.js";
20
20
  import { isAtAWatchingViewer, isBehindEverybody, isNobodyComingNow } from "./priority/PriorityMap.js";
21
21
  import { deriveSourceKey } from "./torrent-source-key.js";
@@ -682,6 +682,12 @@ export function dhtNodeCount(client) {
682
682
  }
683
683
  }
684
684
 
685
+ // The last shape said out loud per torrent, so an unchanged one is not
686
+ // repeated. Held here rather than on the pool because `applyPriorityMap`
687
+ // reads nothing but its arguments — which is what lets it be exercised
688
+ // without building a pool, a torrent client or a thread.
689
+ const lastMapSaid = new WeakMap();
690
+
685
691
  export class TorrentPool {
686
692
  /**
687
693
  * In-flight `client.add()` promises keyed by the same key as `torrents`.
@@ -1099,6 +1105,37 @@ export class TorrentPool {
1099
1105
  register.withdraw(window.claimant);
1100
1106
  }
1101
1107
  }
1108
+ // WHAT THE SWARM WAS ACTUALLY TOLD, said on change and never on a timer.
1109
+ // The map was applied in silence: that it had been BUILT was visible in the
1110
+ // encoding's own line, and that the download had received it was visible
1111
+ // nowhere at all — so "is the map reaching the swarm" could only be taken
1112
+ // on trust. Field 2026-09-08: not one line about it in a whole session.
1113
+ //
1114
+ // Per LEVEL rather than per zone, because the register has five levels and
1115
+ // the map has as many bands as the film needs; the fit between them is the
1116
+ // one thing here that could be wrong, and this is what shows it. Megabytes,
1117
+ // because that is what a swarm delivers.
1118
+ const byLevel = new Map();
1119
+ for (const zone of ordered) {
1120
+ const level = levelOf(zone);
1121
+ const seconds = Math.max(0, zone.to - zone.from);
1122
+ const held = byLevel.get(level) ?? { zones: 0, megabytes: 0 };
1123
+ held.zones += 1;
1124
+ held.megabytes += (seconds / duration) * length / 1048576;
1125
+ byLevel.set(level, held);
1126
+ }
1127
+ const shape = [...byLevel.entries()]
1128
+ .sort((left, right) => right[0] - left[0])
1129
+ .map(([level, held]) => `${urgencyName(level)} ${held.zones} zone(s) ${held.megabytes.toFixed(0)}MB`)
1130
+ .join(", ");
1131
+ const said = `${fileIndex}:${shape}`;
1132
+ if (lastMapSaid.get(torrent) !== said) {
1133
+ lastMapSaid.set(torrent, said);
1134
+ logger.info(
1135
+ `torrent-pool: the swarm is told, for "${file.name}": ${shape || "nothing"} ` +
1136
+ `(${ordered.length} band(s) of the map, over ${Math.round(duration)}s of film)`
1137
+ );
1138
+ }
1102
1139
  }
1103
1140
 
1104
1141
  #reportStalledDownloads() {
@@ -228,12 +228,7 @@ export class TorrentWorkerClient {
228
228
  // Fail everything outstanding rather than leaving callers hanging: a dead
229
229
  // worker will never answer, and a stalled request is worse than an error
230
230
  // the loading flow can retry.
231
- const reason = new Error("Torrent worker stopped unexpectedly.");
232
- this.#caller.rejectAll(reason);
233
- for (const [, read] of this.#reads) {
234
- read.fail(reason);
235
- }
236
- this.#reads.clear();
231
+ this.#failEverythingOutstanding(new Error("Torrent worker stopped unexpectedly."));
237
232
  });
238
233
 
239
234
  // A worker that ENDS was, until now, not noticed at all: only `message` and
@@ -252,15 +247,35 @@ export class TorrentWorkerClient {
252
247
  `torrent-worker: thread ended on its own with code ${code} — nobody asked it to. ` +
253
248
  "Everything waiting on it is failed; the proxy has no torrent client until it is rebuilt."
254
249
  );
255
- const reason = new Error("Torrent worker ended unexpectedly.");
256
- this.#caller.rejectAll(reason);
257
- for (const [, read] of this.#reads) {
258
- read.fail(reason);
259
- }
260
- this.#reads.clear();
250
+ this.#failEverythingOutstanding(new Error("Torrent worker ended unexpectedly."));
261
251
  });
262
252
  }
263
253
 
254
+ /**
255
+ * A dead thread will never answer, so everything waiting on it is failed.
256
+ *
257
+ * INCLUDING THE FRAGMENT READS, which is what ffmpeg's input is. Both death
258
+ * handlers walked `#reads` alone and left `#fragmentReaders` untouched, so an
259
+ * encoder's input neither ended nor errored — it went quiet. Field 2026-08-31,
260
+ * three times: the thread died, both ffmpeg runs stayed ALIVE and stopped
261
+ * producing (167 `holding segment-00085.mp4 … encoder alive` lines), the
262
+ * browser drained its cushion and then retried a segment that would never
263
+ * exist, for ever, with nothing shown to the viewer.
264
+ *
265
+ * @param {Error} reason
266
+ */
267
+ #failEverythingOutstanding(reason) {
268
+ this.#caller.rejectAll(reason);
269
+ for (const [, read] of this.#reads) {
270
+ read.fail(reason);
271
+ }
272
+ this.#reads.clear();
273
+ for (const [, reader] of this.#fragmentReaders) {
274
+ reader.fail(reason);
275
+ }
276
+ this.#fragmentReaders.clear();
277
+ }
278
+
264
279
  /**
265
280
  * Add (or join) a torrent and register it under `sourceKey`.
266
281
  *
@@ -0,0 +1,100 @@
1
+ /**
2
+ * @file Whether the priority map is being served in its own order.
3
+ *
4
+ * The map says what matters most. Until 2026-09-08 nothing said whether what
5
+ * mattered most was delivered first, for either of the two things that read it
6
+ * — so a map read backwards would have looked identical in every log line the
7
+ * proxy writes.
8
+ */
9
+
10
+ import test from "node:test";
11
+ import assert from "node:assert/strict";
12
+ import { bandOf, WaitLedger } from "../services/priority/WaitLedger.js";
13
+ import { SegmentDemand } from "../services/encode/SegmentDemand.js";
14
+
15
+ test("the top rank is its own band, and the rest fall behind it", () => {
16
+ // The map's ranks are as many as the film needs, and a table with a hundred
17
+ // rows says nothing a reader can hold. What is being asked is coarse.
18
+ assert.equal(bandOf(100, 100), "now", "where the viewer is standing");
19
+ assert.equal(bandOf(95, 100), "soon", "what they reach while watching what they hold");
20
+ assert.equal(bandOf(50, 100), "later", "the rest of the film");
21
+ assert.equal(bandOf(0, 100), "later", "in nobody's zone at all");
22
+ });
23
+
24
+ test("a short map has a top band too", () => {
25
+ // A film with three bands must not report every wait as `later` because its
26
+ // numbers are small: a rank is a position in THIS map, not an absolute.
27
+ assert.equal(bandOf(3, 3), "now");
28
+ assert.equal(bandOf(1, 3), "later");
29
+ });
30
+
31
+ test("a band that never waited is said out loud, not reported as zero", () => {
32
+ const ledger = new WaitLedger();
33
+ ledger.note("out:1080", 40, 100, 100);
34
+
35
+ const said = ledger.describe("out:1080");
36
+ assert.match(said, /now 1 wait\(s\) median 40ms worst 40ms/);
37
+ assert.match(said, /soon none/, "silence is not a zero");
38
+ assert.match(said, /later none/);
39
+ });
40
+
41
+ test("nothing waited at all reads as nothing, so it cannot be mistaken for good", () => {
42
+ assert.equal(new WaitLedger().describe("out:1080"), null);
43
+ });
44
+
45
+ test("the count is the whole run and the median is the recent sample", () => {
46
+ // Two different questions: how often, and how badly.
47
+ const ledger = new WaitLedger();
48
+ for (let n = 0; n < 300; n += 1) {
49
+ ledger.note("out:1080", n, 100, 100);
50
+ }
51
+
52
+ const said = ledger.describe("out:1080");
53
+ assert.match(said, /now 300 wait\(s\)/, "every wait is counted");
54
+ assert.match(said, /worst 299ms/, "and the worst of what is still held");
55
+ });
56
+
57
+ test("waits of two outputs do not mix", () => {
58
+ const ledger = new WaitLedger();
59
+ ledger.note("out:1080", 5000, 100, 100);
60
+ ledger.note("out:480", 10, 100, 100);
61
+
62
+ assert.match(ledger.describe("out:1080"), /now 1 wait\(s\) median 5000ms/);
63
+ assert.match(ledger.describe("out:480"), /now 1 wait\(s\) median 10ms/);
64
+ });
65
+
66
+ test("the map answers what it says about one segment, and about itself", () => {
67
+ // The rank alone cannot be read: a wait means one thing at the top of a map
68
+ // and another at the bottom, so the highest rank stated comes with it.
69
+ const demand = new SegmentDemand();
70
+ demand.state("out:1080", [
71
+ { from: 10, to: 12, priority: 100, withinSeconds: 0 },
72
+ { from: 13, to: 20, priority: 97, withinSeconds: 4 },
73
+ { from: 21, to: 90, priority: 90, withinSeconds: 40 }
74
+ ]);
75
+
76
+ assert.deepEqual(demand.rankOf("out:1080", 11), { rank: 100, topRank: 100 });
77
+ assert.deepEqual(demand.rankOf("out:1080", 15), { rank: 97, topRank: 100 });
78
+ assert.deepEqual(
79
+ demand.rankOf("out:1080", 500),
80
+ { rank: 0, topRank: 100 },
81
+ "in nobody's zone is a statement: nothing is coming for it"
82
+ );
83
+ assert.deepEqual(
84
+ demand.rankOf("out:none", 11),
85
+ { rank: 0, topRank: 0 },
86
+ "and a map that has not been built says nothing at all, which is different"
87
+ );
88
+ });
89
+
90
+ test("overlapping zones give a segment the highest rank that covers it", () => {
91
+ // Two viewers a few seconds apart state stretches that overlap; the segment
92
+ // is as urgent as the most urgent claim on it.
93
+ const demand = new SegmentDemand();
94
+ demand.state("out:1080", [
95
+ { from: 0, to: 100, priority: 90, withinSeconds: 40 },
96
+ { from: 10, to: 12, priority: 100, withinSeconds: 0 }
97
+ ]);
98
+
99
+ assert.equal(demand.rankOf("out:1080", 11).rank, 100);
100
+ });