@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.
- package/CHANGELOG.md +17 -0
- package/CLAUDE.md +11 -0
- package/docs/disk-architecture.md +161 -0
- package/docs/encode-architecture.md +36 -7
- package/package.json +1 -1
- package/server.js +38 -19
- package/services/disk/keep.js +48 -0
- package/services/disk/returns.js +103 -0
- package/services/encode/Encoder.js +42 -0
- package/services/encode/QsvEncoder.js +11 -0
- package/services/encode/SegmentStore.js +14 -0
- package/services/encode/SoftwareEncoder.js +5 -0
- package/services/encode/VaapiEncoder.js +13 -0
- package/services/encode/run-costs.js +37 -2
- package/services/encode/start-stop-cost.js +178 -0
- package/services/hls-session-manager.js +23 -1
- package/services/hwaccel.js +1854 -1843
- package/services/orchestrators/EncodeOrchestrator.js +11 -0
- package/services/piece-store/piece-disk-store.js +71 -6
- package/services/piece-store/piece-lru.js +17 -0
- package/services/piece-store/shared-piece-store.js +1556 -1549
- package/services/torrent-pool.js +8 -7
- package/test/keeping-period.test.js +83 -0
- package/test/piece-disk-store.test.js +88 -0
- package/test/startup-readings.test.js +119 -0
package/services/torrent-pool.js
CHANGED
|
@@ -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
|
-
//
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
// gone
|
|
70
|
-
const TORRENT_IDLE_TTL_MS =
|
|
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
|
+
});
|