@torrent-tv/proxy 2.81.2 → 2.83.0

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.
@@ -8,6 +8,7 @@
8
8
  * download.
9
9
  */
10
10
 
11
+ import { IDLE_KEEP_MS } from "./disk/keep.js";
11
12
  import dns from "node:dns/promises";
12
13
  import os from "node:os";
13
14
  import path from "node:path";
@@ -61,13 +62,13 @@ const DHT_RESOLVE_TIMEOUT_MS = 2000;
61
62
  // torrent data lives under here.
62
63
  const WEBTORRENT_STORE_ROOT = path.join(os.tmpdir(), "webtorrent");
63
64
 
64
- // How long a torrent may sit with zero active file readers before it is
65
- // removed (with its on-disk store). Generous so brief gaps between ffmpeg
66
- // range readsa pause, a backgrounded tab, or a phone turned off for a few
67
- // minutes do not evict an in-use torrent's already-downloaded data, so a
68
- // resume plays from disk instead of re-downloading. A longer idle (viewer truly
69
- // gone) frees the disk; the global disk cap still evicts earlier under pressure.
70
- const TORRENT_IDLE_TTL_MS = 15 * 60 * 1000;
65
+ // How long a torrent may sit with zero active file readers before it is removed
66
+ // with its on-disk store. ONE NUMBER for everything nobody is using, shared with
67
+ // the produced segments see `services/disk/keep.js` for why it is one and what
68
+ // it stands for. It was fifteen minutes here against thirty for the session it
69
+ // feeds, so a viewer returning at the twentieth minute got a session whose source
70
+ // had gone.
71
+ const TORRENT_IDLE_TTL_MS = IDLE_KEEP_MS;
71
72
 
72
73
  // Bytes ahead of a read position to mark CRITICAL on each range request. In
73
74
  // WebTorrent, `critical` does NOT reorder the sequential piece scan — it enables
@@ -0,0 +1,83 @@
1
+ /**
2
+ * @file How long material nobody is using is kept, and where that number comes
3
+ * from.
4
+ *
5
+ * It was three numbers and they contradicted each other: a torrent went at
6
+ * fifteen minutes while the session it feeds lived to thirty, so between them
7
+ * there was a session with no source. All three stand for one unmeasured thing
8
+ * — whether the viewer comes back — so they are one number now, and the thing
9
+ * they stand for is being measured.
10
+ */
11
+
12
+ import test from "node:test";
13
+ import assert from "node:assert/strict";
14
+ import { IDLE_KEEP_MS } from "../services/disk/keep.js";
15
+ import { Returns } from "../services/disk/returns.js";
16
+
17
+ const MINUTE = 60 * 1000;
18
+
19
+ test("material outlives the session that reads it", () => {
20
+ // The contradiction this replaced, stated as the rule it must never break
21
+ // again: whatever holds a source must not go while something that reads it
22
+ // is still alive. The session's own period is thirty minutes.
23
+ const SESSION_TTL_MS = 30 * MINUTE;
24
+ assert.ok(
25
+ IDLE_KEEP_MS > SESSION_TTL_MS,
26
+ "a session would outlive its own source again: material is kept " +
27
+ `${IDLE_KEEP_MS / MINUTE}min against a session's ${SESSION_TTL_MS / MINUTE}min`
28
+ );
29
+ });
30
+
31
+ test("one number, and both kinds of material read it", async () => {
32
+ // Two guesses about one unknown is what produced the contradiction. Asserted
33
+ // on the source: a second literal period appearing anywhere is the fault
34
+ // coming back.
35
+ const { readFileSync } = await import("node:fs");
36
+ const path = await import("node:path");
37
+ const { fileURLToPath } = await import("node:url");
38
+ const here = path.dirname(fileURLToPath(import.meta.url));
39
+ const read = (relative) => readFileSync(path.join(here, "..", relative), "utf8");
40
+
41
+ assert.match(read("services/torrent-pool.js"), /TORRENT_IDLE_TTL_MS = IDLE_KEEP_MS/);
42
+ assert.match(read("services/hls-session-manager.js"), /SEGMENT_STORE_IDLE_MS = IDLE_KEEP_MS/);
43
+ });
44
+
45
+ test("a session opened on material still held is a return, and its age is kept", () => {
46
+ const returns = new Returns();
47
+ const now = 10 * 60 * MINUTE;
48
+
49
+ returns.note({ lastReadAt: now - 5 * MINUTE, now });
50
+ returns.note({ lastReadAt: now - 45 * MINUTE, now });
51
+ returns.note({ lastReadAt: now - 20 * MINUTE, now });
52
+
53
+ const shape = returns.shape();
54
+ assert.equal(shape.warm, 3);
55
+ assert.equal(shape.cold, 0);
56
+ assert.equal(shape.medianMs, 20 * MINUTE, "the middle return is not the median");
57
+ assert.equal(shape.longestMs, 45 * MINUTE);
58
+ });
59
+
60
+ test("a session opened on material this proxy never had is not a return", () => {
61
+ const returns = new Returns();
62
+ const now = 10 * 60 * MINUTE;
63
+
64
+ returns.note({ lastReadAt: null, now });
65
+ returns.note({ lastReadAt: 0, now });
66
+
67
+ assert.equal(returns.shape(), null, "an opening with nothing behind it was counted as a return");
68
+ });
69
+
70
+ test("the reading says what viewers do beside what is being kept", () => {
71
+ const returns = new Returns();
72
+ const now = 10 * 60 * MINUTE;
73
+ assert.equal(returns.describe(IDLE_KEEP_MS), null, "it spoke before it had anything to say");
74
+
75
+ returns.note({ lastReadAt: now - 12 * MINUTE, now });
76
+ returns.note({ lastReadAt: null, now });
77
+
78
+ const line = returns.describe(IDLE_KEEP_MS);
79
+ assert.match(line, /1 session\(s\) opened on material still held/);
80
+ assert.match(line, /1 on material gone/);
81
+ assert.match(line, /median 12min after the last read/);
82
+ assert.match(line, /kept for 60min/);
83
+ });
@@ -265,3 +265,91 @@ test("the spill ceiling is what the disk's owner said, divided between the store
265
265
  await fs.rm(directory, { recursive: true, force: true, maxRetries: 10, retryDelay: 20 });
266
266
  }
267
267
  });
268
+
269
+ test("what lies behind every reader goes without waiting for the disk to be short", async () => {
270
+ // THE SECOND RULE. The ceiling is a share of free space, and on a roomy host
271
+ // that is tens of gigabytes against a measured growth of 14 400 MB in one
272
+ // viewing — so the ceiling alone never binds and nothing is removed until the
273
+ // torrent itself goes. A piece behind every read head has been read.
274
+ const { store, directory } = await makeStore(1000 * PIECE);
275
+ try {
276
+ for (const index of [0, 1, 2, 3, 4, 5]) {
277
+ await store.write(index, pieceOf(index));
278
+ }
279
+
280
+ const removed = store.forgetBehind([3, 4]);
281
+ await store.settled();
282
+
283
+ assert.equal(removed, 3, "the three behind the earliest reader should have gone");
284
+ assert.deepEqual([0, 1, 2].map((index) => store.has(index)), [false, false, false]);
285
+ assert.deepEqual([3, 4, 5].map((index) => store.has(index)), [true, true, true]);
286
+ assert.equal(store.stats().behind, 3);
287
+ } finally {
288
+ await store.destroy();
289
+ await fs.rm(directory, { recursive: true, force: true, maxRetries: 10, retryDelay: 20 });
290
+ }
291
+ });
292
+
293
+ test("with no reader at all, nothing is thrown away", async () => {
294
+ // A store between reads is not a store nobody wants. What empties it whole is
295
+ // the torrent going idle, which removes the store and its directory.
296
+ const { store, directory } = await makeStore(1000 * PIECE);
297
+ try {
298
+ for (const index of [0, 1, 2]) {
299
+ await store.write(index, pieceOf(index));
300
+ }
301
+ assert.equal(store.forgetBehind([]), 0);
302
+ assert.equal(store.size, 3);
303
+ } finally {
304
+ await store.destroy();
305
+ await fs.rm(directory, { recursive: true, force: true, maxRetries: 10, retryDelay: 20 });
306
+ }
307
+ });
308
+
309
+ test("a piece being read is not taken even when it is behind everybody", async () => {
310
+ const { store, directory } = await makeStore(1000 * PIECE);
311
+ try {
312
+ await store.write(0, pieceOf(0));
313
+ await store.write(5, pieceOf(5));
314
+ const reading = store.read(0, Buffer.alloc(PIECE));
315
+ const removed = store.forgetBehind([5]);
316
+ await reading;
317
+ await store.settled();
318
+
319
+ assert.equal(removed, 0, "a piece under a reader was thrown away");
320
+ assert.equal(store.has(0), true);
321
+ } finally {
322
+ await store.destroy();
323
+ await fs.rm(directory, { recursive: true, force: true, maxRetries: 10, retryDelay: 20 });
324
+ }
325
+ });
326
+
327
+ test("when there is no room, what is behind the readers goes before what is ahead", async () => {
328
+ const directory = await fs.mkdtemp(path.join(os.tmpdir(), "piece-disk-heads-"));
329
+ const clock = { at: 1000 };
330
+ // The reader stands on #5. #4 is behind it and was touched LAST, so under the
331
+ // old rule — least recently used — it would have been the safest piece there.
332
+ const store = new PieceDiskStore({
333
+ directory,
334
+ name: "pieces",
335
+ chunkLength: PIECE,
336
+ allowanceBytes: 3 * PIECE,
337
+ now: () => clock.at,
338
+ readHeads: () => [5]
339
+ });
340
+ try {
341
+ for (const index of [8, 7, 4]) {
342
+ clock.at += 100;
343
+ await store.write(index, pieceOf(index));
344
+ }
345
+ clock.at += 100;
346
+ await store.write(9, pieceOf(9));
347
+ await store.settled();
348
+
349
+ assert.equal(store.has(4), false, "the piece behind the reader was kept because it was touched last");
350
+ assert.deepEqual([7, 8, 9].map((index) => store.has(index)), [true, true, true]);
351
+ } finally {
352
+ await store.destroy();
353
+ await fs.rm(directory, { recursive: true, force: true, maxRetries: 10, retryDelay: 20 });
354
+ }
355
+ });
@@ -0,0 +1,119 @@
1
+ /**
2
+ * @file What this host must have measured before the first viewer arrives.
3
+ *
4
+ * The quality offer, the number of encoders and every decision about moving one
5
+ * are arithmetic over four figures: how fast this host encodes, how fast it
6
+ * decodes, what a second job costs it, and what starting and stopping an
7
+ * encoder cost. A figure nobody measured is reported as zero, and zero does not
8
+ * read as "unknown" — it reads as "free" or "instant", and the arithmetic then
9
+ * answers confidently and wrongly.
10
+ *
11
+ * Two gaps this pins, both found 2026-09-10:
12
+ *
13
+ * 1. three of the four ran only when the chosen encoder was SOFTWARE, and two
14
+ * of those three are not about the encoder at all — a host with a GPU
15
+ * decodes in software just the same, and what a second job costs is a
16
+ * property of the machine. A GPU host measured none of them;
17
+ * 2. starting and stopping were learned only from runs that had already ENDED,
18
+ * so at a cold open both were zero and moving an encoder was free. Field
19
+ * 2026-09-08: an encoder moved between two adjacent numbers every half
20
+ * second and produced nothing.
21
+ */
22
+
23
+ import test from "node:test";
24
+ import assert from "node:assert/strict";
25
+ import { readFileSync } from "node:fs";
26
+ import path from "node:path";
27
+ import { fileURLToPath } from "node:url";
28
+ import { SoftwareEncoder } from "../services/encode/SoftwareEncoder.js";
29
+ import { NvencEncoder } from "../services/encode/NvencEncoder.js";
30
+ import { QsvEncoder } from "../services/encode/QsvEncoder.js";
31
+ import { VaapiEncoder } from "../services/encode/VaapiEncoder.js";
32
+ import { V4l2m2mEncoder } from "../services/encode/V4l2m2mEncoder.js";
33
+ import { RunCosts } from "../services/encode/run-costs.js";
34
+
35
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
36
+
37
+ test("every encoder kind can say how to measure itself", () => {
38
+ const kinds = [
39
+ new SoftwareEncoder(),
40
+ new NvencEncoder(),
41
+ new QsvEncoder(),
42
+ new VaapiEncoder("/dev/dri/renderD128"),
43
+ new V4l2m2mEncoder()
44
+ ];
45
+ for (const encoder of kinds) {
46
+ const args = encoder.benchmarkArgs(null);
47
+ assert.ok(Array.isArray(args) && args.length >= 2, `${encoder.name} says nothing`);
48
+ assert.ok(args.includes("-c:v"), `${encoder.name} does not name a codec`);
49
+ assert.ok(
50
+ args.some((arg) => String(arg).includes(encoder.name)),
51
+ `${encoder.name} does not name itself`
52
+ );
53
+ }
54
+ });
55
+
56
+ test("a kind with a ladder is measured at every rung of it", () => {
57
+ for (const encoder of [new SoftwareEncoder(), new NvencEncoder(), new QsvEncoder()]) {
58
+ const ladder = encoder.speedLadder;
59
+ assert.ok(ladder.values.length > 1, `${encoder.name} declares no ladder to walk`);
60
+ const rungs = ladder.values.map((rung) => encoder.benchmarkArgs(rung).join(" "));
61
+ assert.equal(new Set(rungs).size, ladder.values.length, `${encoder.name} gives the same arguments for different rungs`);
62
+ }
63
+ });
64
+
65
+ test("nothing about the machine is asked only of a software encoder", () => {
66
+ // Decoding and contention are properties of the host. Asked only where the
67
+ // encoder was software, a host with a GPU had neither, and the quality offer
68
+ // could not price a re-encode nor a second process.
69
+ const server = readFileSync(path.join(HERE, "..", "server.js"), "utf8");
70
+ for (const call of ["benchmarkDecodeCost", "benchmarkContention"]) {
71
+ const at = server.indexOf(call);
72
+ assert.ok(at > 0, `${call} is not called at startup at all`);
73
+ const before = server.slice(Math.max(0, at - 200), at);
74
+ assert.equal(
75
+ before.includes('kind === "software"'),
76
+ false,
77
+ `${call} is still gated on the encoder being software`
78
+ );
79
+ }
80
+ });
81
+
82
+ test("the throughput benchmark is given the encoder that will actually run", () => {
83
+ const server = readFileSync(path.join(HERE, "..", "server.js"), "utf8");
84
+ assert.match(
85
+ server,
86
+ /benchmarkSoftwarePresets\(\{[^}]*encoder: videoEncoder/,
87
+ "the benchmark is not told which encoder to measure"
88
+ );
89
+ });
90
+
91
+ test("a start and a stop are measured before any viewer, and reach the plan", () => {
92
+ const server = readFileSync(path.join(HERE, "..", "server.js"), "utf8");
93
+ assert.match(server, /await measureStartAndStop\(/, "nothing measures a start at startup");
94
+ assert.match(server, /\n startStopCost,/, "the reading never reaches the session manager");
95
+
96
+ const manager = readFileSync(path.join(HERE, "..", "services", "hls-session-manager.js"), "utf8");
97
+ assert.match(
98
+ manager,
99
+ /noteStartupCosts\(startStopCost\)/,
100
+ "the reading never reaches the encoding orchestrator"
101
+ );
102
+ });
103
+
104
+ test("with a startup reading, a cold plan is not told that starting is free", () => {
105
+ const costs = new RunCosts();
106
+ assert.equal(costs.seconds().firstByteWaitSec, 0, "nothing measured yet is nothing");
107
+
108
+ costs.noteStartup({ firstByteWaitSec: 0.68, killCostSec: 0.02 });
109
+ const cold = costs.seconds();
110
+ assert.equal(cold.firstByteWaitSec, 0.68, "the startup reading is not used");
111
+ assert.equal(cold.killCostSec, 0.02);
112
+
113
+ // A real run replaces it: the startup figure is where the plan starts from,
114
+ // not where it stays.
115
+ costs.note({ firstOutputMs: 4000, dyingMs: 300 });
116
+ const warm = costs.seconds();
117
+ assert.equal(warm.firstByteWaitSec, 4);
118
+ assert.equal(warm.killCostSec, 0.3);
119
+ });