@torrent-tv/proxy 2.81.1 → 2.82.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 +13 -0
- package/package.json +1 -1
- package/server.js +38 -10
- package/services/disk/free.js +38 -0
- package/services/disk/wire.js +8 -7
- package/services/encode/Encoder.js +27 -0
- package/services/encode/QsvEncoder.js +6 -0
- package/services/encode/SoftwareEncoder.js +5 -0
- package/services/encode/VaapiEncoder.js +8 -0
- package/services/encode/run-costs.js +37 -2
- package/services/encode/start-stop-cost.js +174 -0
- package/services/hls-session-manager.js +15 -4
- package/services/hwaccel.js +1850 -1843
- package/services/orchestrators/EncodeOrchestrator.js +11 -0
- package/test/disk-space.test.js +15 -0
- package/test/startup-readings.test.js +119 -0
|
@@ -53,6 +53,17 @@ export class EncodeOrchestrator {
|
|
|
53
53
|
/** What a stop and a start have cost on this host. */
|
|
54
54
|
#costs = new RunCosts();
|
|
55
55
|
|
|
56
|
+
/**
|
|
57
|
+
* What starting and stopping an encoder was measured to cost on this host,
|
|
58
|
+
* before any viewer existed. Told once; real runs replace it as they end.
|
|
59
|
+
*
|
|
60
|
+
* @param {{ firstByteWaitSec: number, killCostSec: number } | null} measured
|
|
61
|
+
* @returns {void}
|
|
62
|
+
*/
|
|
63
|
+
noteStartupCosts(measured) {
|
|
64
|
+
this.#costs.noteStartup(measured);
|
|
65
|
+
}
|
|
66
|
+
|
|
56
67
|
/** The last reason a budget was cut, so the same one is not said twice. */
|
|
57
68
|
#lastBudgetReason = new Map();
|
|
58
69
|
|
package/test/disk-space.test.js
CHANGED
|
@@ -148,3 +148,18 @@ test("it says what it decided, every pass", async () => {
|
|
|
148
148
|
assert.equal(lines.length, 1, "a pass that decided the shares said nothing about them");
|
|
149
149
|
assert.match(lines[0], /disk: 100MB free; segments 4MB of 8MB/);
|
|
150
150
|
});
|
|
151
|
+
|
|
152
|
+
test("the free space is read from the nearest directory that exists", async () => {
|
|
153
|
+
// The segments' directory is made when the first session starts and removed
|
|
154
|
+
// when the proxy stops, so at every start `statfs` on it fails. Field
|
|
155
|
+
// 2026-09-10: the first reading said `disk: 0MB free` on a host with 103 GB,
|
|
156
|
+
// and zero means "no room" to everything that reads it.
|
|
157
|
+
const { freeBytesFor } = await import("../services/disk/free.js");
|
|
158
|
+
const os = await import("node:os");
|
|
159
|
+
const path = await import("node:path");
|
|
160
|
+
const missing = path.join(os.tmpdir(), "no-such-directory-here", "nor-here");
|
|
161
|
+
|
|
162
|
+
const bytes = await freeBytesFor(missing);
|
|
163
|
+
|
|
164
|
+
assert.ok(Number.isFinite(bytes) && bytes > 0, "a directory that does not exist read as no disk");
|
|
165
|
+
});
|
|
@@ -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
|
+
});
|