@torrent-tv/proxy 2.80.10 → 2.80.12
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 +1690 -1675
- package/CLAUDE.md +6 -0
- package/docs/download-architecture.md +22 -1
- package/docs/encode-architecture.md +150 -0
- package/package.json +50 -51
- package/services/encode/run-command.js +729 -707
- package/services/hls-session-manager.js +10172 -10388
- package/services/orchestrators/EncodeOrchestrator.js +726 -726
- package/services/output/LiveOutputs.js +267 -233
- package/services/priority/PriorityOrchestrator.js +278 -174
- package/test/decode-cost-fit.test.js +57 -0
- package/{test-measured/decode-cost.measured.js → test/decode-cost.test.js} +13 -36
- package/test/keyframe-table-budget.test.js +7 -1
- package/test/one-authority.test.js +220 -105
- package/test/piece-store-slow-disk.test.js +32 -2
- package/test/priority-map-per-output.test.js +211 -0
- package/test/quality-variants.test.js +1222 -1209
- package/test/tracks-begin-together.test.js +32 -13
- package/test/tunnel-renewal.test.js +34 -5
- package/test/two-viewers-one-picture.test.js +374 -347
- package/test/viewer-outputs.test.js +277 -278
- package/test-measured/decode-measurement.measured.js +0 -83
|
@@ -1,105 +1,220 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file One place decides which encoders exist.
|
|
3
|
-
*
|
|
4
|
-
* Not a check of behaviour but of shape, and it is here because the shape is
|
|
5
|
-
* what failed. Three separate places used to decide where an encoder should
|
|
6
|
-
* work and whether it should go on living, and they disagreed on every pass:
|
|
7
|
-
* measured in the field on 2026-09-05, 684 starts and 660 stops in 482 seconds,
|
|
8
|
-
* of which 294 were one place killing what another had just decided to keep,
|
|
9
|
-
* while the viewer's own segment went unmade for 32.3 seconds.
|
|
10
|
-
*
|
|
11
|
-
* Every rule below is one that was broken then. A reader who needs to add a
|
|
12
|
-
* fourth place should read this file first and then not.
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
import test from "node:test";
|
|
16
|
-
import assert from "node:assert/strict";
|
|
17
|
-
import { readFileSync } from "node:fs";
|
|
18
|
-
import path from "node:path";
|
|
19
|
-
import { fileURLToPath } from "node:url";
|
|
20
|
-
|
|
21
|
-
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* @param {string} relative
|
|
25
|
-
* @returns {string}
|
|
26
|
-
*/
|
|
27
|
-
function source(relative) {
|
|
28
|
-
return readFileSync(path.join(HERE, "..", relative), "utf8");
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* Lines of code, without comments or blanks: a rule about what the code does
|
|
33
|
-
* must not be answered by what a comment says about it.
|
|
34
|
-
*
|
|
35
|
-
* @param {string} text
|
|
36
|
-
* @returns {string[]}
|
|
37
|
-
*/
|
|
38
|
-
function statements(text) {
|
|
39
|
-
return text
|
|
40
|
-
.split("\n")
|
|
41
|
-
.map((line) => line.trim())
|
|
42
|
-
.filter((line) => line.length > 0 && !line.startsWith("//") && !line.startsWith("*") && !line.startsWith("/*"));
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
test("an encoder is stopped for scheduling reasons in exactly one place", () => {
|
|
46
|
-
// The orchestrator decides; nobody else may. What is left in the session
|
|
47
|
-
// manager is teardown — the session is going away and its encoders with it —
|
|
48
|
-
// which is not a decision about which encoders should exist.
|
|
49
|
-
const orchestrator = statements(source("services/orchestrators/EncodeOrchestrator.js"));
|
|
50
|
-
const stopsInOrchestrator = orchestrator.filter((line) => line.includes("run.stop("));
|
|
51
|
-
assert.equal(stopsInOrchestrator.length, 1, "the orchestrator stops runs in one place");
|
|
52
|
-
|
|
53
|
-
const manager = statements(source("services/hls-session-manager.js"));
|
|
54
|
-
const stopsInManager = manager.filter((line) => line.includes(".stop("));
|
|
55
|
-
assert.equal(
|
|
56
|
-
stopsInManager.length,
|
|
57
|
-
2,
|
|
58
|
-
"the session manager stops runs only when a session is torn down: " +
|
|
59
|
-
stopsInManager.join(" / ")
|
|
60
|
-
);
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
test("nothing outside the encoding layer starts an encoder", () => {
|
|
64
|
-
// A run is built in one place. Two places building them is how a start came
|
|
65
|
-
// to kill what the plan had decided to keep — the killing lived in the
|
|
66
|
-
// building.
|
|
67
|
-
const manager = statements(source("services/hls-session-manager.js"));
|
|
68
|
-
const builds = manager.filter((line) => line.includes("new EncodeRun("));
|
|
69
|
-
assert.equal(builds.length, 1, "one place builds an encoder");
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
test("starting an encoder stops nothing", () => {
|
|
73
|
-
// The rule that broke it: the start path looked for a live run whose own
|
|
74
|
-
// start was not below the new one's and killed it. It is not enough that the
|
|
75
|
-
// line is gone — the words it was written with must not come back.
|
|
76
|
-
const manager = source("services/hls-session-manager.js");
|
|
77
|
-
assert.equal(
|
|
78
|
-
manager.includes("previousRun"),
|
|
79
|
-
false,
|
|
80
|
-
"there is no such thing as the previous run: a session holds several"
|
|
81
|
-
);
|
|
82
|
-
assert.equal(manager.includes("a new run is taking its place"), false);
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
test("a seek moves the viewer and nothing else", () => {
|
|
86
|
-
// It used to do eleven things and write the position into five places. What
|
|
87
|
-
// follows from a viewer moving is the map's business, and the orchestrators
|
|
88
|
-
// read the map.
|
|
89
|
-
const manager = source("services/hls-session-manager.js");
|
|
90
|
-
const seek = manager.slice(
|
|
91
|
-
manager.indexOf("requestSeek(sessionId, positionSeconds"),
|
|
92
|
-
manager.indexOf("requestSeek(sessionId, positionSeconds") + 2000
|
|
93
|
-
);
|
|
94
|
-
const body = seek.slice(0, seek.indexOf("\n }\n"));
|
|
95
|
-
assert.equal(body.includes("#startEncodeRun"), false, "a seek starts no encoder");
|
|
96
|
-
assert.equal(body.includes("setTimeout"), false, "and waits for nothing");
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
test("how far an encoder may work is answered once", () => {
|
|
100
|
-
// The plan computes the stretch and it reaches ffmpeg. A second computation
|
|
101
|
-
// somewhere else is what made the first one pointless: it was passed and then
|
|
102
|
-
// dropped by a parameter list that did not name it.
|
|
103
|
-
const orchestrator = source("services/orchestrators/EncodeOrchestrator.js");
|
|
104
|
-
assert.
|
|
105
|
-
|
|
1
|
+
/**
|
|
2
|
+
* @file One place decides which encoders exist.
|
|
3
|
+
*
|
|
4
|
+
* Not a check of behaviour but of shape, and it is here because the shape is
|
|
5
|
+
* what failed. Three separate places used to decide where an encoder should
|
|
6
|
+
* work and whether it should go on living, and they disagreed on every pass:
|
|
7
|
+
* measured in the field on 2026-09-05, 684 starts and 660 stops in 482 seconds,
|
|
8
|
+
* of which 294 were one place killing what another had just decided to keep,
|
|
9
|
+
* while the viewer's own segment went unmade for 32.3 seconds.
|
|
10
|
+
*
|
|
11
|
+
* Every rule below is one that was broken then. A reader who needs to add a
|
|
12
|
+
* fourth place should read this file first and then not.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import test from "node:test";
|
|
16
|
+
import assert from "node:assert/strict";
|
|
17
|
+
import { readFileSync } from "node:fs";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
import { fileURLToPath } from "node:url";
|
|
20
|
+
|
|
21
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param {string} relative
|
|
25
|
+
* @returns {string}
|
|
26
|
+
*/
|
|
27
|
+
function source(relative) {
|
|
28
|
+
return readFileSync(path.join(HERE, "..", relative), "utf8");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Lines of code, without comments or blanks: a rule about what the code does
|
|
33
|
+
* must not be answered by what a comment says about it.
|
|
34
|
+
*
|
|
35
|
+
* @param {string} text
|
|
36
|
+
* @returns {string[]}
|
|
37
|
+
*/
|
|
38
|
+
function statements(text) {
|
|
39
|
+
return text
|
|
40
|
+
.split("\n")
|
|
41
|
+
.map((line) => line.trim())
|
|
42
|
+
.filter((line) => line.length > 0 && !line.startsWith("//") && !line.startsWith("*") && !line.startsWith("/*"));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
test("an encoder is stopped for scheduling reasons in exactly one place", () => {
|
|
46
|
+
// The orchestrator decides; nobody else may. What is left in the session
|
|
47
|
+
// manager is teardown — the session is going away and its encoders with it —
|
|
48
|
+
// which is not a decision about which encoders should exist.
|
|
49
|
+
const orchestrator = statements(source("services/orchestrators/EncodeOrchestrator.js"));
|
|
50
|
+
const stopsInOrchestrator = orchestrator.filter((line) => line.includes("run.stop("));
|
|
51
|
+
assert.equal(stopsInOrchestrator.length, 1, "the orchestrator stops runs in one place");
|
|
52
|
+
|
|
53
|
+
const manager = statements(source("services/hls-session-manager.js"));
|
|
54
|
+
const stopsInManager = manager.filter((line) => line.includes(".stop("));
|
|
55
|
+
assert.equal(
|
|
56
|
+
stopsInManager.length,
|
|
57
|
+
2,
|
|
58
|
+
"the session manager stops runs only when a session is torn down: " +
|
|
59
|
+
stopsInManager.join(" / ")
|
|
60
|
+
);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("nothing outside the encoding layer starts an encoder", () => {
|
|
64
|
+
// A run is built in one place. Two places building them is how a start came
|
|
65
|
+
// to kill what the plan had decided to keep — the killing lived in the
|
|
66
|
+
// building.
|
|
67
|
+
const manager = statements(source("services/hls-session-manager.js"));
|
|
68
|
+
const builds = manager.filter((line) => line.includes("new EncodeRun("));
|
|
69
|
+
assert.equal(builds.length, 1, "one place builds an encoder");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("starting an encoder stops nothing", () => {
|
|
73
|
+
// The rule that broke it: the start path looked for a live run whose own
|
|
74
|
+
// start was not below the new one's and killed it. It is not enough that the
|
|
75
|
+
// line is gone — the words it was written with must not come back.
|
|
76
|
+
const manager = source("services/hls-session-manager.js");
|
|
77
|
+
assert.equal(
|
|
78
|
+
manager.includes("previousRun"),
|
|
79
|
+
false,
|
|
80
|
+
"there is no such thing as the previous run: a session holds several"
|
|
81
|
+
);
|
|
82
|
+
assert.equal(manager.includes("a new run is taking its place"), false);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("a seek moves the viewer and nothing else", () => {
|
|
86
|
+
// It used to do eleven things and write the position into five places. What
|
|
87
|
+
// follows from a viewer moving is the map's business, and the orchestrators
|
|
88
|
+
// read the map.
|
|
89
|
+
const manager = source("services/hls-session-manager.js");
|
|
90
|
+
const seek = manager.slice(
|
|
91
|
+
manager.indexOf("requestSeek(sessionId, positionSeconds"),
|
|
92
|
+
manager.indexOf("requestSeek(sessionId, positionSeconds") + 2000
|
|
93
|
+
);
|
|
94
|
+
const body = seek.slice(0, seek.indexOf("\n }\n"));
|
|
95
|
+
assert.equal(body.includes("#startEncodeRun"), false, "a seek starts no encoder");
|
|
96
|
+
assert.equal(body.includes("setTimeout"), false, "and waits for nothing");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("how far an encoder may work is answered once", () => {
|
|
100
|
+
// The plan computes the stretch and it reaches ffmpeg. A second computation
|
|
101
|
+
// somewhere else is what made the first one pointless: it was passed and then
|
|
102
|
+
// dropped by a parameter list that did not name it.
|
|
103
|
+
const orchestrator = source("services/orchestrators/EncodeOrchestrator.js");
|
|
104
|
+
assert.match(
|
|
105
|
+
orchestrator,
|
|
106
|
+
/makeRun\(\{ address, from, to, because \}\)/,
|
|
107
|
+
"the stretch is handed over, and so are the plan's own words for why"
|
|
108
|
+
);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("only the plan places an encoder", () => {
|
|
112
|
+
// The count is the whole of this item. There were eight other places: the
|
|
113
|
+
// first run of a session, a viewer joining it further in, a rung or a
|
|
114
|
+
// soundtrack being warmed, one being switched to, a hardware encoder falling
|
|
115
|
+
// back to software, an input coming back, a cut table correcting itself, and
|
|
116
|
+
// a settled seek. Each of them chose a position by a rule of its own, and the
|
|
117
|
+
// plan — which is arithmetic over what is made, what is being made and what is
|
|
118
|
+
// wanted — was left to compare its answer against theirs.
|
|
119
|
+
const manager = source("services/hls-session-manager.js");
|
|
120
|
+
const starts = [...manager.matchAll(/this\.#startEncodeRun\(/g)].length;
|
|
121
|
+
assert.equal(starts, 1, "one caller, and it is the one the plan asks through");
|
|
122
|
+
assert.match(
|
|
123
|
+
manager,
|
|
124
|
+
/#makeRunAt\(address, from, to, because\)[\s\S]{0,3000}this\.#startEncodeRun\(base, from, because, \{ to \}\)/,
|
|
125
|
+
"and that caller is what the plan is given to build runs with"
|
|
126
|
+
);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("nobody stops an encoder for being unwatched", () => {
|
|
130
|
+
// Whether an encoder is still wanted is the same question as where one should
|
|
131
|
+
// be, and the plan answers it: an output with nobody on it has a priority map
|
|
132
|
+
// with nothing in it. Answered here as well, it was answered twice by two
|
|
133
|
+
// rules — and since a viewer moving between steps announces itself, the plan
|
|
134
|
+
// started again what this class had just killed, several times a second.
|
|
135
|
+
const manager = source("services/hls-session-manager.js");
|
|
136
|
+
assert.equal(
|
|
137
|
+
manager.includes("no viewer is watching"),
|
|
138
|
+
false,
|
|
139
|
+
"a rung nobody is on is a fact, not an act"
|
|
140
|
+
);
|
|
141
|
+
assert.equal(manager.includes("no viewer is listening to audio track"), false);
|
|
142
|
+
assert.equal(
|
|
143
|
+
manager.includes("warmed for a switch the viewer did not make"),
|
|
144
|
+
false,
|
|
145
|
+
"an abandoned warm-up is a viewer leaving an output"
|
|
146
|
+
);
|
|
147
|
+
assert.equal(manager.includes("prepared for a track change the viewer did not make"), false);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("the seek settle machinery is gone, whole", () => {
|
|
151
|
+
// A viewer's position is applied the moment they state it. The settle was a
|
|
152
|
+
// second debounce on a signal the browser had already debounced, and every
|
|
153
|
+
// millisecond of it was dead time in front of the viewer; the cooldown behind
|
|
154
|
+
// it existed because segment REQUESTS once steered the encoder.
|
|
155
|
+
const manager = source("services/hls-session-manager.js");
|
|
156
|
+
for (const gone of [
|
|
157
|
+
"seekSettleTimer",
|
|
158
|
+
"seekTarget",
|
|
159
|
+
"seekFirstFarAt",
|
|
160
|
+
"SEEK_SETTLE_MS",
|
|
161
|
+
"SEEK_SETTLE_MAX_MS",
|
|
162
|
+
"RESTART_COOLDOWN_MS",
|
|
163
|
+
"SEEK_BACKOFF_SEGMENTS",
|
|
164
|
+
"#fireSettledSeek",
|
|
165
|
+
"#seekSession"
|
|
166
|
+
]) {
|
|
167
|
+
assert.equal(manager.includes(gone), false, `${gone} is gone`);
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("where a soundtrack begins is read off the table, not handed in", () => {
|
|
172
|
+
// The instant a number really begins is a fact of the FILE's cutting, held in
|
|
173
|
+
// the live table every session of the file shares. Passed as an argument by
|
|
174
|
+
// the one caller that had measured it, only a run started by that caller ever
|
|
175
|
+
// had it, and a run the plan placed at the same number landed apart again.
|
|
176
|
+
const manager = source("services/hls-session-manager.js");
|
|
177
|
+
assert.match(
|
|
178
|
+
manager,
|
|
179
|
+
/const positionSecondsOverride = session\.audioOnly === true\s*\n?\s*\? trueStartOf\(session\.timeline, startIndex\)/,
|
|
180
|
+
"derived where the run is built"
|
|
181
|
+
);
|
|
182
|
+
assert.equal(
|
|
183
|
+
manager.includes("this.#startEncodeRun(member, index, trueStart)"),
|
|
184
|
+
false,
|
|
185
|
+
"and not carried in from the correction that measured it"
|
|
186
|
+
);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("a changed bitrate cap stops the encoder carrying the old one and nothing more", () => {
|
|
190
|
+
// An argument list is fixed when a process starts, so a run carrying the
|
|
191
|
+
// previous cap cannot be told about the new one — that is what is known here.
|
|
192
|
+
// Where the replacement stands is a different question, and the old answer to
|
|
193
|
+
// it was neither where a viewer is nor a gap in the material: it was the
|
|
194
|
+
// segment the process being replaced happened to have reached.
|
|
195
|
+
const manager = source("services/hls-session-manager.js");
|
|
196
|
+
assert.equal(manager.includes("#restartAtViewer"), false, "the second answer is gone");
|
|
197
|
+
assert.match(
|
|
198
|
+
manager,
|
|
199
|
+
/#reencodeAtNewRate\(session\) \{\s*\n\s*this\.#stopEncodeRun\(session, "its bitrate cap changed"\);\s*\n\s*this\.planEncodersSoon\(\);\s*\n\s*\}/,
|
|
200
|
+
"stopped, and then decided again"
|
|
201
|
+
);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("each output is handed its own priority map, and the plan is what reads it", () => {
|
|
205
|
+
// The map is one fact asked at two scopes, and both are right for what asks
|
|
206
|
+
// them: the swarm is asked for bytes of a FILE, which every output of it
|
|
207
|
+
// reads, and encoders are placed per OUTPUT, which a person watching 480p
|
|
208
|
+
// wants nothing of at 1080p.
|
|
209
|
+
const manager = source("services/hls-session-manager.js");
|
|
210
|
+
assert.match(
|
|
211
|
+
manager,
|
|
212
|
+
/notePriorityMap\([\s\S]{0,200}mapForOutput\(address\)/,
|
|
213
|
+
"the encoding reads the output's own map"
|
|
214
|
+
);
|
|
215
|
+
assert.equal(
|
|
216
|
+
manager.includes("this.priority.mapFor("),
|
|
217
|
+
false,
|
|
218
|
+
"and never the whole film's, which wanted an encoder on every output of it"
|
|
219
|
+
);
|
|
220
|
+
});
|
|
@@ -23,6 +23,25 @@ import { SharedPieceStore } from "../services/piece-store/shared-piece-store.js"
|
|
|
23
23
|
|
|
24
24
|
const PIECE = 1024;
|
|
25
25
|
|
|
26
|
+
/**
|
|
27
|
+
* Wait for the thing being asserted, not for a length of time.
|
|
28
|
+
*
|
|
29
|
+
* @param {() => boolean} until
|
|
30
|
+
* @param {string} what - Named in the failure, since a timeout otherwise says
|
|
31
|
+
* only that something did not happen.
|
|
32
|
+
* @param {number} [limit]
|
|
33
|
+
* @returns {Promise<void>}
|
|
34
|
+
*/
|
|
35
|
+
async function waitFor(until, what, limit = 10_000) {
|
|
36
|
+
const deadline = Date.now() + limit;
|
|
37
|
+
while (!until()) {
|
|
38
|
+
if (Date.now() > deadline) {
|
|
39
|
+
throw new Error(`${what} never happened`);
|
|
40
|
+
}
|
|
41
|
+
await new Promise((resolve) => { setTimeout(resolve, 5); });
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
26
45
|
/**
|
|
27
46
|
* A disk that answers only when the test lets it, so writes can be made slower
|
|
28
47
|
* than arrivals on purpose — which is the whole of the field condition.
|
|
@@ -85,8 +104,19 @@ test("memory in use never runs past the allowance while the disk is behind", asy
|
|
|
85
104
|
for (let index = capacity; index < capacity + 40; index += 1) {
|
|
86
105
|
arrivals.push(put(store, index).catch(() => undefined));
|
|
87
106
|
}
|
|
88
|
-
//
|
|
89
|
-
|
|
107
|
+
// Until the state being asserted has been reached: a write is outstanding
|
|
108
|
+
// and admission has waited for the disk rather than evicting. Waited out as
|
|
109
|
+
// 200 ms instead, this asserted how far the arrivals had got in that time —
|
|
110
|
+
// and on a machine slow enough that none of them reached a disk write, both
|
|
111
|
+
// of those figures are zero and the test fails with nothing wrong.
|
|
112
|
+
//
|
|
113
|
+
// The bound below is what the wait must NOT decide, so it is checked after
|
|
114
|
+
// the state is reached rather than after an interval: the store may never
|
|
115
|
+
// hold more blocks than it is allowed, however many arrivals are in flight.
|
|
116
|
+
await waitFor(() => {
|
|
117
|
+
const now = store.stats();
|
|
118
|
+
return now.blocksInFlight > 0 && now.waitedForDisk > 0;
|
|
119
|
+
}, "a held write and an admission that waited for it");
|
|
90
120
|
|
|
91
121
|
const held = store.stats();
|
|
92
122
|
// The check the field failure would fail: a store allowed four pieces held
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file One fact asked at two scopes: what the swarm is asked for, and what the
|
|
3
|
+
* encoders are placed by.
|
|
4
|
+
*
|
|
5
|
+
* Both are right for what asks them. The swarm is asked for bytes of a FILE,
|
|
6
|
+
* and the picture, a quality step and a soundtrack of one film read the same
|
|
7
|
+
* bytes — so every viewer of any of them wants that file's bytes. Encoders are
|
|
8
|
+
* placed per OUTPUT, and a person watching 480p wants nothing of the 1080p
|
|
9
|
+
* output at all.
|
|
10
|
+
*
|
|
11
|
+
* One map for both was the second authority over encoders. Every output of a
|
|
12
|
+
* film was handed the whole film's map, so the plan wanted an encoder on every
|
|
13
|
+
* one of them; what actually stopped the ones nobody was watching was the
|
|
14
|
+
* session manager killing them by its own judgement — and since a viewer moving
|
|
15
|
+
* between steps also announces itself, the plan started them again on the next
|
|
16
|
+
* pass. Field 2026-09-08.
|
|
17
|
+
*
|
|
18
|
+
* Built out of the real classes throughout: the real viewer registry holds real
|
|
19
|
+
* viewers, the real `LiveOutputs` answers what a film's shape is, and the real
|
|
20
|
+
* `PriorityOrchestrator` builds the maps. A session is a plain object in this
|
|
21
|
+
* proxy — there is no class for one — so the literals below are the thing
|
|
22
|
+
* itself and not a stand-in for it.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import test from "node:test";
|
|
26
|
+
import assert from "node:assert/strict";
|
|
27
|
+
import { PriorityOrchestrator } from "../services/priority/PriorityOrchestrator.js";
|
|
28
|
+
import { LiveOutputs } from "../services/output/LiveOutputs.js";
|
|
29
|
+
import { Viewers } from "../services/viewer/Viewers.js";
|
|
30
|
+
import { viewersOf } from "../services/viewer/Viewer.js";
|
|
31
|
+
import { runsOf } from "../services/priority/PriorityMap.js";
|
|
32
|
+
|
|
33
|
+
const FILM = { sourceKey: "source-1", fileIndex: 0, durationSeconds: 600 };
|
|
34
|
+
const STALE_AFTER_MS = 60_000;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A session, as much of one as these classes read.
|
|
38
|
+
*
|
|
39
|
+
* @param {{ id: string, outputKey: string, isStep?: boolean, audioOnly?: boolean }} params
|
|
40
|
+
* @returns {object}
|
|
41
|
+
*/
|
|
42
|
+
function outputOf({ id, outputKey, isStep = false, audioOnly = false }) {
|
|
43
|
+
return {
|
|
44
|
+
id,
|
|
45
|
+
outputKey,
|
|
46
|
+
isStep,
|
|
47
|
+
audioOnly,
|
|
48
|
+
state: "ready",
|
|
49
|
+
sourceKey: FILM.sourceKey,
|
|
50
|
+
fileIndex: FILM.fileIndex,
|
|
51
|
+
file: { key: "film-1", durationSeconds: FILM.durationSeconds }
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The real orchestrator over the real registry, wired the way the session
|
|
57
|
+
* manager wires it.
|
|
58
|
+
*
|
|
59
|
+
* @param {object[]} sessions
|
|
60
|
+
* @returns {{ priority: PriorityOrchestrator, viewers: Viewers, publish: () => void }}
|
|
61
|
+
*/
|
|
62
|
+
function over(sessions) {
|
|
63
|
+
const live = new LiveOutputs({ sessionsById: new Map(sessions.map((one) => [one.id, one])) });
|
|
64
|
+
const viewers = new Viewers();
|
|
65
|
+
const priority = new PriorityOrchestrator({
|
|
66
|
+
publish: () => {},
|
|
67
|
+
viewersOf: (session) => viewersOf(session),
|
|
68
|
+
allowanceFor: () => 10,
|
|
69
|
+
watchedBy: (session, viewer) => !live.supersededBy(session, viewer.activeVariantId ?? null)
|
|
70
|
+
});
|
|
71
|
+
return {
|
|
72
|
+
priority,
|
|
73
|
+
viewers,
|
|
74
|
+
live,
|
|
75
|
+
publish: () => priority.publishFor({ sessionGroups: [sessions], staleAfterMs: STALE_AFTER_MS })
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
test("a person on a step wants nothing of the picture they stepped off", () => {
|
|
80
|
+
const picture = outputOf({ id: "pic", outputKey: "out:1080" });
|
|
81
|
+
const step = outputOf({ id: "step", outputKey: "out:480", isStep: true });
|
|
82
|
+
const { priority, viewers, publish } = over([picture, step]);
|
|
83
|
+
// ONE viewer object per person, referenced from both outputs — which is why
|
|
84
|
+
// the step they are on is known when the picture is asked about.
|
|
85
|
+
viewers.of(picture, "p");
|
|
86
|
+
const person = viewers.of(step, "p");
|
|
87
|
+
person.moveTo(300);
|
|
88
|
+
person.activeVariantId = "step";
|
|
89
|
+
publish();
|
|
90
|
+
|
|
91
|
+
assert.ok(
|
|
92
|
+
runsOf(priority.mapForOutput("out:480")).length > 0,
|
|
93
|
+
"the step they are watching is wanted"
|
|
94
|
+
);
|
|
95
|
+
assert.deepEqual(
|
|
96
|
+
runsOf(priority.mapForOutput("out:1080")),
|
|
97
|
+
[],
|
|
98
|
+
"and the picture is producing for nobody, which is what makes its encoder unwanted"
|
|
99
|
+
);
|
|
100
|
+
// The bytes are another matter: both outputs read the same file, and the
|
|
101
|
+
// swarm is asked for the file.
|
|
102
|
+
assert.ok(
|
|
103
|
+
runsOf(priority.mapFor(FILM.sourceKey, FILM.fileIndex)).length > 0,
|
|
104
|
+
"the download still wants the film, because that is what is being watched"
|
|
105
|
+
);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("a soundtrack is wanted by whoever is registered on it", () => {
|
|
109
|
+
const picture = outputOf({ id: "pic", outputKey: "out:1080" });
|
|
110
|
+
const sound = outputOf({ id: "rus", outputKey: "out:a1", audioOnly: true });
|
|
111
|
+
const { priority, viewers, publish } = over([picture, sound]);
|
|
112
|
+
// Standing on a STEP of the picture says nothing about the sound: the tracks
|
|
113
|
+
// nobody chose are let go of where a track is chosen, so being known to a
|
|
114
|
+
// soundtrack is listening to it.
|
|
115
|
+
const person = viewers.of(sound, "p");
|
|
116
|
+
person.moveTo(120);
|
|
117
|
+
person.activeVariantId = "step";
|
|
118
|
+
publish();
|
|
119
|
+
|
|
120
|
+
assert.ok(runsOf(priority.mapForOutput("out:a1")).length > 0);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("a step being warmed is wanted, and so is the picture still on screen", () => {
|
|
124
|
+
// Both are genuinely being produced through a warm-up, which is the price of
|
|
125
|
+
// the switch not being visible. Whether the machine can afford two encoders
|
|
126
|
+
// is the budget's question and not this one's.
|
|
127
|
+
const picture = outputOf({ id: "pic", outputKey: "out:1080" });
|
|
128
|
+
const step = outputOf({ id: "step", outputKey: "out:480", isStep: true });
|
|
129
|
+
const { priority, viewers, publish } = over([picture, step]);
|
|
130
|
+
viewers.of(picture, "p");
|
|
131
|
+
const person = viewers.of(step, "p");
|
|
132
|
+
person.moveTo(300);
|
|
133
|
+
person.warmingVariantId = "step";
|
|
134
|
+
publish();
|
|
135
|
+
|
|
136
|
+
assert.ok(runsOf(priority.mapForOutput("out:1080")).length > 0, "still on screen");
|
|
137
|
+
assert.ok(runsOf(priority.mapForOutput("out:480")).length > 0, "being made ready");
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("an output everybody has left is stated as empty, not left as it was", () => {
|
|
141
|
+
// The difference matters: the plan stops the encoders of an output whose map
|
|
142
|
+
// is empty, and would keep the map it had when somebody was watching.
|
|
143
|
+
const picture = outputOf({ id: "pic", outputKey: "out:1080" });
|
|
144
|
+
const { priority, viewers, publish } = over([picture]);
|
|
145
|
+
viewers.of(picture, "p").moveTo(300);
|
|
146
|
+
|
|
147
|
+
publish();
|
|
148
|
+
assert.ok(runsOf(priority.mapForOutput("out:1080")).length > 0);
|
|
149
|
+
|
|
150
|
+
viewers.leaves(picture, "p");
|
|
151
|
+
publish();
|
|
152
|
+
assert.deepEqual(runsOf(priority.mapForOutput("out:1080")), []);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("a viewer nothing has been heard from is not watching anything", () => {
|
|
156
|
+
// The backstop for a viewer who never said they were leaving: a browser whose
|
|
157
|
+
// tab is gone releases nothing, and their own silence is what expires.
|
|
158
|
+
const picture = outputOf({ id: "pic", outputKey: "out:1080" });
|
|
159
|
+
const live = new LiveOutputs({ sessionsById: new Map([["pic", picture]]) });
|
|
160
|
+
const viewers = new Viewers();
|
|
161
|
+
const priority = new PriorityOrchestrator({
|
|
162
|
+
publish: () => {},
|
|
163
|
+
viewersOf: (session) => viewersOf(session),
|
|
164
|
+
allowanceFor: () => 10,
|
|
165
|
+
watchedBy: (session, viewer) => !live.supersededBy(session, viewer.activeVariantId ?? null)
|
|
166
|
+
});
|
|
167
|
+
const person = viewers.of(picture, "p");
|
|
168
|
+
person.moveTo(300);
|
|
169
|
+
|
|
170
|
+
priority.publishFor({
|
|
171
|
+
sessionGroups: [[picture]],
|
|
172
|
+
staleAfterMs: STALE_AFTER_MS,
|
|
173
|
+
now: person.lastSeenAt + STALE_AFTER_MS + 1
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
assert.deepEqual(runsOf(priority.mapForOutput("out:1080")), []);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("a file and an output that are gone are forgotten", () => {
|
|
180
|
+
// These are the projection of the live sessions, never a memory of them. Left
|
|
181
|
+
// to accumulate they were three maps that only grew, and `forget` was written
|
|
182
|
+
// for that and called from nowhere.
|
|
183
|
+
const picture = outputOf({ id: "pic", outputKey: "out:1080" });
|
|
184
|
+
const { priority, viewers, publish } = over([picture]);
|
|
185
|
+
viewers.of(picture, "p").moveTo(300);
|
|
186
|
+
|
|
187
|
+
publish();
|
|
188
|
+
assert.ok(runsOf(priority.mapForOutput("out:1080")).length > 0);
|
|
189
|
+
|
|
190
|
+
priority.publishFor({ sessionGroups: [[]], staleAfterMs: STALE_AFTER_MS });
|
|
191
|
+
assert.deepEqual(runsOf(priority.mapForOutput("out:1080")), [], "the output is gone");
|
|
192
|
+
assert.deepEqual(
|
|
193
|
+
runsOf(priority.mapFor(FILM.sourceKey, FILM.fileIndex)),
|
|
194
|
+
[],
|
|
195
|
+
"and so is the file"
|
|
196
|
+
);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("the picture is watched by a person who never moved off it", () => {
|
|
200
|
+
const picture = outputOf({ id: "pic", outputKey: "out:1080" });
|
|
201
|
+
const { viewers, live } = over([picture]);
|
|
202
|
+
const person = viewers.of(picture, "p");
|
|
203
|
+
|
|
204
|
+
assert.equal(live.supersededBy(picture, null), false, "no step is active");
|
|
205
|
+
person.activeVariantId = "pic";
|
|
206
|
+
assert.equal(
|
|
207
|
+
live.supersededBy(picture, person.activeVariantId),
|
|
208
|
+
false,
|
|
209
|
+
"and naming the picture itself as the step is the same statement"
|
|
210
|
+
);
|
|
211
|
+
});
|