@torrent-tv/proxy 2.75.0 → 2.76.1
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 +1509 -1461
- package/CLAUDE.md +11 -1
- package/biome.json +182 -1
- package/docs/container-architecture.md +2 -1
- package/docs/encode-run-state.md +1 -1
- package/knip.json +14 -0
- package/package.json +1 -1
- package/routes/api/sources/warm/post.js +1 -1
- package/routes/api/transcode-sessions/post.js +185 -185
- package/routes/api/transcode-sessions/progress/get.js +5 -1
- package/routes/transcode/audio-file/get.js +11 -1
- package/routes/transcode/audio-warm/get.js +11 -1
- package/routes/transcode/session-file/get.js +1 -1
- package/routes/transcode/variant-file/get.js +10 -1
- package/scripts/render-run-graph.js +2 -2
- package/server.js +25 -0
- package/services/audio-inventory.js +206 -201
- package/services/container/AviContainer.js +1 -1
- package/services/container/Container.js +33 -1
- package/services/container/MatroskaContainer.js +1 -1
- package/services/container/Mp4Container.js +1 -1
- package/services/container/SubtitleFileContainer.js +0 -1
- package/services/controllers/SubtitleController.js +128 -128
- package/services/demand/index.js +7 -10
- package/services/download/registry.js +0 -14
- package/services/encode/CoverageMap.js +281 -0
- package/services/encode/EncodePlan.js +255 -0
- package/services/encode/EncodeRun.js +587 -0
- package/services/encode/Encoder.js +84 -0
- package/services/encode/NvencEncoder.js +45 -0
- package/services/encode/QsvEncoder.js +47 -0
- package/services/encode/SegmentDemand.js +0 -0
- package/services/encode/SegmentStore.js +529 -0
- package/services/encode/SoftwareEncoder.js +111 -0
- package/services/encode/V4l2m2mEncoder.js +53 -0
- package/services/encode/VaapiEncoder.js +53 -0
- package/services/encode/args.js +200 -0
- package/services/{encode-exit.js → encode/encode-exit.js} +17 -0
- package/services/encode/index.js +9 -0
- package/services/encode/run-command.js +647 -0
- package/services/hls-session-manager.js +11100 -10711
- package/services/hwaccel.js +1688 -1992
- package/services/orchestrators/EncodeOrchestrator.js +359 -0
- package/services/output/LiveOutputs.js +213 -0
- package/services/output/Output.js +94 -0
- package/services/output/OutputSpec.js +195 -0
- package/services/output/Timeline.js +220 -0
- package/services/output/index.js +1 -0
- package/services/output/ladder.js +26 -0
- package/services/playback-planner.js +806 -775
- package/services/produced-index.js +222 -300
- package/services/source/SourceFile.js +346 -0
- package/services/{sidecar-files.js → torrent/files.js} +107 -11
- package/services/torrent/naming.js +619 -0
- package/services/torrent-worker/client.js +10 -0
- package/services/torrent-worker/container-tracks.js +71 -43
- package/services/torrent-worker/pool-adapter.js +18 -0
- package/services/torrent-worker/protocol.js +7 -0
- package/services/torrent-worker/subtitle-cues.js +549 -549
- package/services/torrent-worker/worker.js +18 -0
- package/services/tracks/TextSubtitleTrack.js +287 -287
- package/services/tracks/index.js +15 -14
- package/services/viewer/Viewer.js +145 -0
- package/services/viewer/Viewers.js +124 -0
- package/test/auto-quality-step.test.js +508 -506
- package/test/behind-head-repair.test.js +17 -7
- package/test/coverage-map.test.js +153 -0
- package/test/cut-times-timeline.test.js +6 -5
- package/test/cuts-follow-published-grid.test.js +4 -4
- package/test/decode-cost.test.js +31 -12
- package/test/encode-exit.test.js +1 -1
- package/test/encode-orchestrator.test.js +196 -0
- package/test/encode-plan.test.js +245 -0
- package/test/encode-run-state.test.js +2 -2
- package/test/encode-run.test.js +168 -0
- package/test/encoder-kinds.test.js +122 -0
- package/test/held-request-width.test.js +9 -3
- package/test/helpers/encode-run.js +128 -0
- package/test/keyframe-index-accuracy.test.js +19 -12
- package/test/keyframes-belong-to-the-file.test.js +132 -0
- package/test/orchestrator-wired.test.js +164 -0
- package/test/output-shape.test.js +68 -0
- package/test/output-spec.test.js +157 -0
- package/test/produced-copy-choice.test.js +58 -92
- package/test/produced-index.test.js +142 -188
- package/test/quality-variants.test.js +1079 -1075
- package/test/run-graph-drift.test.js +1 -1
- package/test/run-intervals.test.js +329 -0
- package/test/run-position-follows-published-grid.test.js +4 -4
- package/test/seek-landing.test.js +8 -8
- package/test/seek-target-not-superseded.test.js +21 -9
- package/test/segment-demand.test.js +82 -0
- package/test/segment-serve-wiring.test.js +47 -52
- package/test/segment-store.test.js +187 -0
- package/test/segments-are-shared.test.js +175 -0
- package/test/sidecar-naming.test.js +142 -0
- package/test/source-file.test.js +133 -0
- package/test/stale-request-after-seek.test.js +18 -12
- package/test/subtitle-language.test.js +252 -252
- package/test/timeline.test.js +95 -0
- package/test/{sidecar-files.test.js → torrent-files.test.js} +44 -1
- package/test/torrent-naming.test.js +255 -0
- package/test/tracks-begin-together.test.js +44 -32
- package/test/two-viewers-one-picture.test.js +347 -0
- package/test/viewer-outputs.test.js +273 -0
- package/test/viewer.test.js +91 -0
- package/utils/perf.js +1 -63
- package/services/container/index.js +0 -6
- package/services/controllers/index.js +0 -2
- package/services/download/index.js +0 -8
- package/services/orchestrators/index.js +0 -2
- /package/services/{encode-run-state.js → encode/encode-run-state.js} +0 -0
- /package/services/{language-detect.js → tracks/language-detect.js} +0 -0
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Two viewers of one picture, each with their own soundtrack.
|
|
3
|
+
*
|
|
4
|
+
* Measured 2026-09-03 (`research/two-viewers-one-file-2026-09-03.md`): two
|
|
5
|
+
* browsers on one copied file got two picture sessions with byte-identical
|
|
6
|
+
* output, because the key carried the soundtrack a picture without sound does
|
|
7
|
+
* not have. Once they share one picture, everything about the sound that used
|
|
8
|
+
* to be a field of the session has to be a fact about a viewer — otherwise they
|
|
9
|
+
* switch each other's soundtrack off, once per segment, for the whole film.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import test from "node:test";
|
|
13
|
+
import { fakeProcess as fakeEncoder, startRunOn } from "./helpers/encode-run.js";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Whether anything of this session is encoding.
|
|
17
|
+
*
|
|
18
|
+
* A session holds a SET of runs, so the question is about the set and not about
|
|
19
|
+
* a field: a run told to stop is not encoding, whatever its process is still
|
|
20
|
+
* doing about the signal.
|
|
21
|
+
*
|
|
22
|
+
* @param {object} session
|
|
23
|
+
* @returns {boolean}
|
|
24
|
+
*/
|
|
25
|
+
function encoding(session) {
|
|
26
|
+
return [...(session?.runs ?? [])].some((run) => run.isAlive);
|
|
27
|
+
}
|
|
28
|
+
import assert from "node:assert/strict";
|
|
29
|
+
import { SourceFile } from "../services/source/SourceFile.js";
|
|
30
|
+
import { Timeline } from "../services/output/Timeline.js";
|
|
31
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
32
|
+
import os from "node:os";
|
|
33
|
+
import path from "node:path";
|
|
34
|
+
import { audioRenditionKey, HlsSessionManager } from "../services/hls-session-manager.js";
|
|
35
|
+
import { viewerOf } from "../services/viewer/Viewer.js";
|
|
36
|
+
import { fmp4Format } from "../services/segment-formats/fmp4.js";
|
|
37
|
+
import { Output } from "../services/output/Output.js";
|
|
38
|
+
|
|
39
|
+
const BASE_ID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
|
|
40
|
+
const SEGMENT_SECONDS = 4;
|
|
41
|
+
const FIRST = "viewer-one";
|
|
42
|
+
const SECOND = "viewer-two";
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* A session shaped like a live one, without the ffmpeg run behind it.
|
|
46
|
+
*
|
|
47
|
+
* @param {{ id: string, dirPath: string, audioTrackIndex?: number, transcodeAudio?: boolean }} params
|
|
48
|
+
* @returns {object}
|
|
49
|
+
*/
|
|
50
|
+
function fakeSession({ id, dirPath, audioTrackIndex = 0, transcodeAudio = true }) {
|
|
51
|
+
return {
|
|
52
|
+
id,
|
|
53
|
+
dirPath,
|
|
54
|
+
// Where this file is cut, held by the file. A fixture that stated it
|
|
55
|
+
// on the session was describing what production no longer does.
|
|
56
|
+
timeline: new Timeline({
|
|
57
|
+
boundaries: Array.from({ length: 101 }, (_, index) => index * SEGMENT_SECONDS),
|
|
58
|
+
cutGrid: "keyframe"
|
|
59
|
+
}),
|
|
60
|
+
state: "ready",
|
|
61
|
+
file: new SourceFile({ sourceKey: "torrent:abc", fileIndex: 0, name: "video.mkv" }).learn({ width: 1920, height: 1080 }),
|
|
62
|
+
// An ordinary session reads its own file, and its sound is inside it. The
|
|
63
|
+
// three differ only for a soundtrack shipped as a file of its own.
|
|
64
|
+
get inputFile() { return this.file; },
|
|
65
|
+
get audioFile() { return this.file; },
|
|
66
|
+
startedAt: Date.now(),
|
|
67
|
+
createEntryMs: Date.now(),
|
|
68
|
+
lastAccessedAt: Date.now(),
|
|
69
|
+
ffmpeg: null,
|
|
70
|
+
lastError: "",
|
|
71
|
+
consumers: new Set(),
|
|
72
|
+
viewers: new Map(),
|
|
73
|
+
|
|
74
|
+
segmentFormat: fmp4Format,
|
|
75
|
+
transcodeVideo: false,
|
|
76
|
+
transcodeAudio,
|
|
77
|
+
audioTrackIndex,
|
|
78
|
+
audioSourceTrackIndex: audioTrackIndex,
|
|
79
|
+
// The shape this output is encoded AS, decided once for the output.
|
|
80
|
+
output: new Output({
|
|
81
|
+
encodeWidth: 0,
|
|
82
|
+
encodeHeight: 0,
|
|
83
|
+
outputFps: 24,
|
|
84
|
+
softwarePreset: null,
|
|
85
|
+
applyTonemap: false
|
|
86
|
+
}),
|
|
87
|
+
encodeRunGeneration: 0,
|
|
88
|
+
encodeStartIndex: 0,
|
|
89
|
+
waitEpoch: 0,
|
|
90
|
+
useSyntheticPlaylist: true,
|
|
91
|
+
playlistText: "#EXTM3U\n",
|
|
92
|
+
segmentCount: 100,
|
|
93
|
+
progress: { state: "running", processedSeconds: 0, startPositionSeconds: 0, speed: "1.0x" }
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* A base picture serving two viewers, with its audio published separately and
|
|
100
|
+
* every rendition created by a stub instead of an encoder.
|
|
101
|
+
*
|
|
102
|
+
* @returns {Promise<{ manager: HlsSessionManager, base: object, dirPath: string, renditions: Map<string, object> }>}
|
|
103
|
+
*/
|
|
104
|
+
async function pictureWithTwoViewers() {
|
|
105
|
+
const dirPath = await mkdtemp(path.join(os.tmpdir(), "two-viewers-"));
|
|
106
|
+
const manager = new HlsSessionManager({
|
|
107
|
+
enabled: true,
|
|
108
|
+
ffmpegBin: "ffmpeg",
|
|
109
|
+
localBindHost: "127.0.0.1",
|
|
110
|
+
localPort: 9090
|
|
111
|
+
});
|
|
112
|
+
const base = fakeSession({ id: BASE_ID, dirPath });
|
|
113
|
+
base.audioSeparate = true;
|
|
114
|
+
base.consumers = new Set([FIRST, SECOND]);
|
|
115
|
+
// Both viewers are watching the picture, which is what keeps their choices
|
|
116
|
+
// alive; a viewer whose head has expired holds no encoder.
|
|
117
|
+
viewerOf(base, FIRST).head = { segment: 3, seconds: 12, at: Date.now() };
|
|
118
|
+
viewerOf(base, SECOND).head = { segment: 3, seconds: 12, at: Date.now() };
|
|
119
|
+
viewerOf(base, FIRST).audio = { trackIndex: 0, transcode: true };
|
|
120
|
+
viewerOf(base, SECOND).audio = { trackIndex: 1, transcode: true };
|
|
121
|
+
manager.sessionsById.set(BASE_ID, base);
|
|
122
|
+
manager.getCachedAudioTracks = () => [
|
|
123
|
+
{ index: 0, language: "rus", title: "Дубляж", isDefault: true, fileIndex: 0, sourceTrackIndex: 0 },
|
|
124
|
+
{ index: 1, language: "eng", title: "", isDefault: false, fileIndex: 0, sourceTrackIndex: 1 }
|
|
125
|
+
];
|
|
126
|
+
manager.getCachedMediaInfo = () => ({ height: 1080, width: 1920, durationSeconds: 400 });
|
|
127
|
+
|
|
128
|
+
/** @type {Map<string, object>} */
|
|
129
|
+
const renditions = new Map();
|
|
130
|
+
manager.createOrGetSession = async (params) => {
|
|
131
|
+
const key = audioRenditionKey(params.audioTrackIndex, params.transcodeAudio);
|
|
132
|
+
const existing = renditions.get(key);
|
|
133
|
+
if (existing) {
|
|
134
|
+
return existing;
|
|
135
|
+
}
|
|
136
|
+
const rendition = fakeSession({
|
|
137
|
+
id: `rendition-${key}`,
|
|
138
|
+
dirPath,
|
|
139
|
+
audioTrackIndex: params.audioTrackIndex,
|
|
140
|
+
transcodeAudio: params.transcodeAudio
|
|
141
|
+
});
|
|
142
|
+
rendition.audioOnly = true;
|
|
143
|
+
startRunOn(rendition, { process: fakeEncoder() });
|
|
144
|
+
manager.sessionsById.set(rendition.id, rendition);
|
|
145
|
+
renditions.set(key, rendition);
|
|
146
|
+
return rendition;
|
|
147
|
+
};
|
|
148
|
+
return { manager, base, dirPath, renditions };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
test("one viewer fetching their soundtrack does not stop the other viewer's", async (t) => {
|
|
152
|
+
const { manager, renditions, dirPath } = await pictureWithTwoViewers();
|
|
153
|
+
t.after(async () => {
|
|
154
|
+
await manager.disposeAll();
|
|
155
|
+
await rm(dirPath, { recursive: true, force: true });
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
const first = await manager.resolveAudioRenditionFile(BASE_ID, 0, "segment-00003.mp4", FIRST);
|
|
159
|
+
const second = await manager.resolveAudioRenditionFile(BASE_ID, 1, "segment-00003.mp4", SECOND);
|
|
160
|
+
|
|
161
|
+
assert.notEqual(first.sessionId, second.sessionId, "two soundtracks are two encodes");
|
|
162
|
+
for (const [key, rendition] of renditions) {
|
|
163
|
+
assert.ok([...rendition.runs][0]?.process, `the encoder of ${key} is still running`);
|
|
164
|
+
assert.deepEqual([...rendition.runs][0].process.signals, [], `nothing signalled ${key}`);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// And it holds under the traffic that actually happens: they alternate.
|
|
168
|
+
await manager.resolveAudioRenditionFile(BASE_ID, 0, "segment-00004.mp4", FIRST);
|
|
169
|
+
await manager.resolveAudioRenditionFile(BASE_ID, 1, "segment-00004.mp4", SECOND);
|
|
170
|
+
for (const [key, rendition] of renditions) {
|
|
171
|
+
assert.deepEqual([...rendition.runs][0]?.process?.signals ?? [], [], `nothing signalled ${key} on the second round`);
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("a soundtrack nobody is listening to any more is stopped", async (t) => {
|
|
176
|
+
const { manager, base, renditions, dirPath } = await pictureWithTwoViewers();
|
|
177
|
+
t.after(async () => {
|
|
178
|
+
await manager.disposeAll();
|
|
179
|
+
await rm(dirPath, { recursive: true, force: true });
|
|
180
|
+
});
|
|
181
|
+
// One viewer only, so what they leave is left for nobody. This is the case
|
|
182
|
+
// the stop exists for: an encoder AND a reader holding pieces of the torrent.
|
|
183
|
+
base.consumers = new Set([FIRST]);
|
|
184
|
+
base.viewers.delete(SECOND);
|
|
185
|
+
base.viewers.delete(SECOND);
|
|
186
|
+
|
|
187
|
+
await manager.resolveAudioRenditionFile(BASE_ID, 0, "segment-00003.mp4", FIRST);
|
|
188
|
+
await manager.resolveAudioRenditionFile(BASE_ID, 1, "segment-00004.mp4", FIRST);
|
|
189
|
+
|
|
190
|
+
const left = renditions.get(audioRenditionKey(0, true));
|
|
191
|
+
const moved = renditions.get(audioRenditionKey(1, true));
|
|
192
|
+
assert.equal(encoding(left), false, "the track the viewer left is not encoding for anybody");
|
|
193
|
+
assert.ok(encoding(moved), "the track they moved to is");
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("each viewer's browser decides for itself whether its soundtrack is re-encoded", async (t) => {
|
|
197
|
+
const { manager, base, renditions, dirPath } = await pictureWithTwoViewers();
|
|
198
|
+
t.after(async () => {
|
|
199
|
+
await manager.disposeAll();
|
|
200
|
+
await rm(dirPath, { recursive: true, force: true });
|
|
201
|
+
});
|
|
202
|
+
// The same track, two browsers: one can decode it as it stands, the other
|
|
203
|
+
// cannot. Answering both from the session's own flag would leave the second
|
|
204
|
+
// viewer with silence.
|
|
205
|
+
viewerOf(base, FIRST).audio = { trackIndex: 0, transcode: false };
|
|
206
|
+
viewerOf(base, SECOND).audio = { trackIndex: 0, transcode: true };
|
|
207
|
+
|
|
208
|
+
const copied = await manager.resolveAudioRenditionFile(BASE_ID, 0, "segment-00003.mp4", FIRST);
|
|
209
|
+
const encoded = await manager.resolveAudioRenditionFile(BASE_ID, 0, "segment-00003.mp4", SECOND);
|
|
210
|
+
|
|
211
|
+
assert.notEqual(copied.sessionId, encoded.sessionId);
|
|
212
|
+
assert.equal(renditions.get(audioRenditionKey(0, false)).transcodeAudio, false);
|
|
213
|
+
assert.equal(renditions.get(audioRenditionKey(0, true)).transcodeAudio, true);
|
|
214
|
+
// Both are wanted, so neither is stopped.
|
|
215
|
+
assert.ok(encoding(renditions.get(audioRenditionKey(0, false))));
|
|
216
|
+
assert.ok(encoding(renditions.get(audioRenditionKey(0, true))));
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test("the master marks each viewer's own soundtrack as the default one", async (t) => {
|
|
220
|
+
const { manager, dirPath } = await pictureWithTwoViewers();
|
|
221
|
+
t.after(async () => {
|
|
222
|
+
await manager.disposeAll();
|
|
223
|
+
await rm(dirPath, { recursive: true, force: true });
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
const forFirst = manager.buildMasterPlaylist(BASE_ID, FIRST);
|
|
227
|
+
const forSecond = manager.buildMasterPlaylist(BASE_ID, SECOND);
|
|
228
|
+
|
|
229
|
+
const defaultsOf = (master) =>
|
|
230
|
+
[...master.matchAll(/^#EXT-X-MEDIA:.*?NAME="([^"]+)".*?DEFAULT=(YES|NO)/gm)]
|
|
231
|
+
.filter((match) => match[2] === "YES")
|
|
232
|
+
.map((match) => match[1]);
|
|
233
|
+
assert.deepEqual(defaultsOf(forFirst), ["Дубляж"]);
|
|
234
|
+
assert.deepEqual(defaultsOf(forSecond).length, 1);
|
|
235
|
+
assert.notDeepEqual(defaultsOf(forFirst), defaultsOf(forSecond));
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test("one viewer changing quality does not take the other off their step", async (t) => {
|
|
239
|
+
const { manager, base, dirPath } = await pictureWithTwoViewers();
|
|
240
|
+
t.after(async () => {
|
|
241
|
+
await manager.disposeAll();
|
|
242
|
+
await rm(dirPath, { recursive: true, force: true });
|
|
243
|
+
});
|
|
244
|
+
/** @type {Map<number, object>} */
|
|
245
|
+
const variants = new Map();
|
|
246
|
+
manager.createOrGetSession = async (params) => {
|
|
247
|
+
const height = params.targetHeight;
|
|
248
|
+
const existing = variants.get(height);
|
|
249
|
+
if (existing) {
|
|
250
|
+
return existing;
|
|
251
|
+
}
|
|
252
|
+
const variant = fakeSession({ id: `variant-${height}`, dirPath });
|
|
253
|
+
variant.transcodeVideo = true;
|
|
254
|
+
variant.output.encodeHeight = height;
|
|
255
|
+
variant.variantHeight = height;
|
|
256
|
+
variant.isStep = true;
|
|
257
|
+
variant.file = base.file;
|
|
258
|
+
startRunOn(variant, { process: fakeEncoder() });
|
|
259
|
+
manager.sessionsById.set(variant.id, variant);
|
|
260
|
+
variants.set(height, variant);
|
|
261
|
+
return variant;
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
await manager.resolveVariantFile(BASE_ID, 720, "segment-00003.mp4", FIRST);
|
|
265
|
+
await manager.resolveVariantFile(BASE_ID, 540, "segment-00003.mp4", SECOND);
|
|
266
|
+
// Both viewers go on watching their own step, which is what a player does
|
|
267
|
+
// every few seconds.
|
|
268
|
+
await manager.resolveVariantFile(BASE_ID, 720, "segment-00004.mp4", FIRST);
|
|
269
|
+
await manager.resolveVariantFile(BASE_ID, 540, "segment-00004.mp4", SECOND);
|
|
270
|
+
|
|
271
|
+
assert.ok(encoding(variants.get(720)), "the first viewer's step is still encoding");
|
|
272
|
+
assert.ok(encoding(variants.get(540)), "and so is the second viewer's");
|
|
273
|
+
|
|
274
|
+
// Now the first viewer steps down. Theirs is left for nobody and stops; the
|
|
275
|
+
// other viewer's is untouched.
|
|
276
|
+
await manager.resolveVariantFile(BASE_ID, 480, "segment-00005.mp4", FIRST);
|
|
277
|
+
|
|
278
|
+
assert.equal(encoding(variants.get(720)), false, "the step nobody is on stops");
|
|
279
|
+
assert.ok(encoding(variants.get(540)), "the step the other viewer is watching does not");
|
|
280
|
+
assert.ok(encoding(variants.get(480)), "and the one they moved to is encoding");
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test("a step somebody is watching is never withdrawn from the offer", async (t) => {
|
|
284
|
+
const { manager, base, dirPath } = await pictureWithTwoViewers();
|
|
285
|
+
t.after(async () => {
|
|
286
|
+
await manager.disposeAll();
|
|
287
|
+
await rm(dirPath, { recursive: true, force: true });
|
|
288
|
+
});
|
|
289
|
+
// A host that can re-encode 240p and nothing above it — the shape of the
|
|
290
|
+
// field case of 2026-08-15.
|
|
291
|
+
base.output.encodeHeight = 1080;
|
|
292
|
+
base.variantHeight = 1080;
|
|
293
|
+
manager.softwarePresetBenchmark = [{ preset: "ultrafast", pixelsPerSec: 12_000_000 }];
|
|
294
|
+
manager.decodeCostModel = { pixelTerm: 0.00793, bitrateTerm: 0, constantTerm: 0 };
|
|
295
|
+
// 1080p24 at 8 Mbit/s, stated as the file's own facts — what decoding costs
|
|
296
|
+
// is derived from them.
|
|
297
|
+
base.file.learn({ width: 1920, height: 1080, fps: 24, bitrateKbps: 8000 });
|
|
298
|
+
|
|
299
|
+
const variant = fakeSession({ id: "variant-720", dirPath });
|
|
300
|
+
variant.transcodeVideo = true;
|
|
301
|
+
variant.output.encodeHeight = 720;
|
|
302
|
+
variant.variantHeight = 720;
|
|
303
|
+
// One file, two sessions of it.
|
|
304
|
+
variant.file = base.file;
|
|
305
|
+
variant.isStep = true;
|
|
306
|
+
variant.file = base.file;
|
|
307
|
+
manager.sessionsById.set(variant.id, variant);
|
|
308
|
+
base.file.stepHeights.set(720, 720);
|
|
309
|
+
|
|
310
|
+
// Nobody on it: measured below realtime, it is withdrawn. This half is the
|
|
311
|
+
// control — without it the other half proves nothing.
|
|
312
|
+
const withoutAViewer = manager.offeredHeights(base);
|
|
313
|
+
assert.ok(
|
|
314
|
+
!withoutAViewer.includes(720),
|
|
315
|
+
`a step nobody is on and that cannot keep up is withdrawn: ${withoutAViewer.join(" ")}`
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
viewerOf(base, SECOND).activeVariantId = variant.id;
|
|
319
|
+
const withAViewer = manager.offeredHeights(base);
|
|
320
|
+
|
|
321
|
+
assert.ok(
|
|
322
|
+
withAViewer.includes(720),
|
|
323
|
+
`a step on somebody's screen stays offered, whatever it is measured at: ${withAViewer.join(" ")}`
|
|
324
|
+
);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
test("a viewer whose picture has gone quiet holds no soundtrack encoder", async (t) => {
|
|
328
|
+
const { manager, base, renditions, dirPath } = await pictureWithTwoViewers();
|
|
329
|
+
t.after(async () => {
|
|
330
|
+
await manager.disposeAll();
|
|
331
|
+
await rm(dirPath, { recursive: true, force: true });
|
|
332
|
+
});
|
|
333
|
+
await manager.resolveAudioRenditionFile(BASE_ID, 0, "segment-00003.mp4", FIRST);
|
|
334
|
+
await manager.resolveAudioRenditionFile(BASE_ID, 1, "segment-00003.mp4", SECOND);
|
|
335
|
+
// The second viewer's tab is gone. Nothing releases the session when a
|
|
336
|
+
// channel closes (roadmap item 54), so what expires is their head on the
|
|
337
|
+
// picture — and with it their claim on an encoder.
|
|
338
|
+
base.viewers.delete(SECOND);
|
|
339
|
+
|
|
340
|
+
await manager.resolveAudioRenditionFile(BASE_ID, 1, "segment-00004.mp4", FIRST);
|
|
341
|
+
|
|
342
|
+
assert.equal(
|
|
343
|
+
encoding(renditions.get(audioRenditionKey(0, true))),
|
|
344
|
+
false,
|
|
345
|
+
"the first viewer moved on, so their old track is stopped"
|
|
346
|
+
);
|
|
347
|
+
});
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file What a viewer is watching, and what happens to it when they leave.
|
|
3
|
+
*
|
|
4
|
+
* The relation "this person watches this output" is indexed both ways — the
|
|
5
|
+
* output holds its viewers, the viewer holds its outputs — and these checks
|
|
6
|
+
* hold the three things that go wrong when the two indexes come apart.
|
|
7
|
+
*
|
|
8
|
+
* 1. The viewer must never be dropped from the PICTURE. Their chosen
|
|
9
|
+
* soundtrack, their position and their step are recorded there, and the
|
|
10
|
+
* picture is the only id the browser knows. Dropping them was reachable by
|
|
11
|
+
* an ordinary sequence — down a step, back to the picture's own height, down
|
|
12
|
+
* again — and cost them the soundtrack they had picked.
|
|
13
|
+
* 2. A viewer who leaves must be subtracted from EVERY output they were
|
|
14
|
+
* watching, and an output with nobody left must be let go. Nothing outside
|
|
15
|
+
* the session manager knows the id of a quality step or of a separately
|
|
16
|
+
* published soundtrack, so nothing else can ever release one.
|
|
17
|
+
* 3. Whatever removes a viewer from an output must release what their watching
|
|
18
|
+
* claimed of production. The only place a claim is released is the plan's
|
|
19
|
+
* pass over the output's viewers, so a viewer deleted by any other route
|
|
20
|
+
* leaves a claim that nothing can reach.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import test from "node:test";
|
|
24
|
+
import assert from "node:assert/strict";
|
|
25
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
26
|
+
import os from "node:os";
|
|
27
|
+
import path from "node:path";
|
|
28
|
+
import { HlsSessionManager, variantConsumerId } from "../services/hls-session-manager.js";
|
|
29
|
+
import { Viewers } from "../services/viewer/Viewers.js";
|
|
30
|
+
import { SourceFile } from "../services/source/SourceFile.js";
|
|
31
|
+
import { Timeline } from "../services/output/Timeline.js";
|
|
32
|
+
import { Output } from "../services/output/Output.js";
|
|
33
|
+
import { fmp4Format } from "../services/segment-formats/fmp4.js";
|
|
34
|
+
import { fakeProcess as fakeEncoder, startRunOn } from "./helpers/encode-run.js";
|
|
35
|
+
|
|
36
|
+
const BASE_ID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
|
|
37
|
+
const STEP_ID = "11111111-2222-3333-4444-555555555555";
|
|
38
|
+
const AUDIO_ID = "99999999-8888-7777-6666-555555555555";
|
|
39
|
+
const VIEWER = "viewer-one";
|
|
40
|
+
const SEGMENT_SECONDS = 4;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A session shaped like a live one, without the ffmpeg run behind it.
|
|
44
|
+
*
|
|
45
|
+
* @param {{ id: string, dirPath: string, file: SourceFile, encodeHeight?: number,
|
|
46
|
+
* audioOnly?: boolean, isStep?: boolean }} params
|
|
47
|
+
* @returns {object}
|
|
48
|
+
*/
|
|
49
|
+
function fakeSession({ id, dirPath, file, encodeHeight = 0, audioOnly = false, isStep = false }) {
|
|
50
|
+
return {
|
|
51
|
+
id,
|
|
52
|
+
dirPath,
|
|
53
|
+
timeline: new Timeline({
|
|
54
|
+
boundaries: Array.from({ length: 101 }, (_, index) => index * SEGMENT_SECONDS),
|
|
55
|
+
cutGrid: "uniform"
|
|
56
|
+
}),
|
|
57
|
+
state: "ready",
|
|
58
|
+
file,
|
|
59
|
+
get inputFile() { return this.file; },
|
|
60
|
+
get audioFile() { return this.file; },
|
|
61
|
+
startedAt: Date.now(),
|
|
62
|
+
createEntryMs: Date.now(),
|
|
63
|
+
lastAccessedAt: Date.now(),
|
|
64
|
+
ffmpeg: null,
|
|
65
|
+
lastError: "",
|
|
66
|
+
consumers: new Set(),
|
|
67
|
+
segmentFormat: fmp4Format,
|
|
68
|
+
transcodeVideo: !audioOnly,
|
|
69
|
+
transcodeAudio: true,
|
|
70
|
+
audioTrackIndex: 0,
|
|
71
|
+
audioOnly,
|
|
72
|
+
isStep,
|
|
73
|
+
variantHeight: isStep ? encodeHeight : undefined,
|
|
74
|
+
// Left set so that disposal does not remove a directory the other sessions
|
|
75
|
+
// of this output are still named by.
|
|
76
|
+
outputKey: `output-${id}`,
|
|
77
|
+
output: new Output({
|
|
78
|
+
encodeWidth: 0,
|
|
79
|
+
encodeHeight,
|
|
80
|
+
outputFps: 24,
|
|
81
|
+
softwarePreset: null,
|
|
82
|
+
applyTonemap: false
|
|
83
|
+
}),
|
|
84
|
+
encodeRunGeneration: 0,
|
|
85
|
+
lastRestartAt: 0,
|
|
86
|
+
seekFailureTarget: -1,
|
|
87
|
+
seekFailureCount: 0,
|
|
88
|
+
seekSettleTimer: null,
|
|
89
|
+
seekTarget: null,
|
|
90
|
+
waitEpoch: 0,
|
|
91
|
+
runs: new Set(),
|
|
92
|
+
viewers: new Map(),
|
|
93
|
+
usesExplicitCuts: false,
|
|
94
|
+
useSyntheticPlaylist: true,
|
|
95
|
+
playlistText: "#EXTM3U\n",
|
|
96
|
+
segmentCount: 100,
|
|
97
|
+
progress: { state: "running", processedSeconds: 0, startPositionSeconds: 0, speed: "1.0x" }
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* A picture with one quality step and one separately published soundtrack, all
|
|
103
|
+
* of one file, and one viewer watching all three — which is what an ordinary
|
|
104
|
+
* session looks like once the viewer has picked a language and a quality.
|
|
105
|
+
*
|
|
106
|
+
* @returns {Promise<{ manager: HlsSessionManager, base: object, step: object,
|
|
107
|
+
* audio: object, dirPath: string, released: string[] }>}
|
|
108
|
+
*/
|
|
109
|
+
async function pictureWithStepAndSoundtrack() {
|
|
110
|
+
const dirPath = await mkdtemp(path.join(os.tmpdir(), "viewer-outputs-"));
|
|
111
|
+
const manager = new HlsSessionManager({
|
|
112
|
+
enabled: true,
|
|
113
|
+
ffmpegBin: "ffmpeg",
|
|
114
|
+
localBindHost: "127.0.0.1",
|
|
115
|
+
localPort: 9090
|
|
116
|
+
});
|
|
117
|
+
const file = new SourceFile({ sourceKey: "source-1", fileIndex: 0, name: "video.mkv" })
|
|
118
|
+
.learn({ width: 1920, height: 1080, fps: 24, bitrateKbps: 8000 });
|
|
119
|
+
const base = fakeSession({ id: BASE_ID, dirPath, file, encodeHeight: 812 });
|
|
120
|
+
const step = fakeSession({ id: STEP_ID, dirPath, file, encodeHeight: 540, isStep: true });
|
|
121
|
+
const audio = fakeSession({ id: AUDIO_ID, dirPath, file, audioOnly: true });
|
|
122
|
+
base.consumers = new Set([VIEWER]);
|
|
123
|
+
// The family's own claim, which is how a picture holds what it made: the
|
|
124
|
+
// browser never learns these two ids.
|
|
125
|
+
step.consumers = new Set([variantConsumerId(BASE_ID)]);
|
|
126
|
+
audio.consumers = new Set([variantConsumerId(BASE_ID)]);
|
|
127
|
+
for (const session of [base, step, audio]) {
|
|
128
|
+
manager.sessionsById.set(session.id, session);
|
|
129
|
+
}
|
|
130
|
+
file.stepHeights.set(540, 540);
|
|
131
|
+
/** @type {string[]} */
|
|
132
|
+
const released = [];
|
|
133
|
+
const realRelease = manager.encodeOrchestrator.release.bind(manager.encodeOrchestrator);
|
|
134
|
+
manager.encodeOrchestrator.release = (claimant) => {
|
|
135
|
+
released.push(claimant);
|
|
136
|
+
return realRelease(claimant);
|
|
137
|
+
};
|
|
138
|
+
return { manager, base, step, audio, dirPath, released };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
test("a viewer who steps down, back to the picture's own height and down again keeps their record", async (t) => {
|
|
142
|
+
const { manager, base, dirPath } = await pictureWithStepAndSoundtrack();
|
|
143
|
+
t.after(() => rm(dirPath, { recursive: true, force: true }));
|
|
144
|
+
startRunOn(base, { process: fakeEncoder() });
|
|
145
|
+
const viewer = manager.viewers.of(base, VIEWER);
|
|
146
|
+
viewer.audio = { trackIndex: 1, transcode: true };
|
|
147
|
+
viewer.head = { segment: 25, seconds: 100, at: Date.now() };
|
|
148
|
+
|
|
149
|
+
await manager.resolveVariantFile(BASE_ID, 540, "segment-00025.mp4", VIEWER);
|
|
150
|
+
await manager.resolveVariantFile(BASE_ID, 812, "segment-00026.mp4", VIEWER);
|
|
151
|
+
await manager.resolveVariantFile(BASE_ID, 540, "segment-00027.mp4", VIEWER);
|
|
152
|
+
|
|
153
|
+
const known = base.viewers.get(VIEWER);
|
|
154
|
+
assert.ok(known, "the picture is the one id the browser holds — a viewer is never dropped from it");
|
|
155
|
+
assert.deepEqual(
|
|
156
|
+
known.audio,
|
|
157
|
+
{ trackIndex: 1, transcode: true },
|
|
158
|
+
"the soundtrack they chose is recorded on the picture, and a quality switch does not touch it"
|
|
159
|
+
);
|
|
160
|
+
assert.equal(known.activeVariantId, STEP_ID, "and the step they moved to is where they are");
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("a viewer leaving is subtracted from every output, and one nobody is left watching is let go", async (t) => {
|
|
164
|
+
const { manager, base, step, audio, dirPath } = await pictureWithStepAndSoundtrack();
|
|
165
|
+
t.after(() => rm(dirPath, { recursive: true, force: true }));
|
|
166
|
+
// Watching all three, which is what a viewer who picked a language and a
|
|
167
|
+
// quality is doing.
|
|
168
|
+
manager.viewers.of(base, VIEWER).head = { segment: 25, seconds: 100, at: Date.now() };
|
|
169
|
+
manager.viewers.of(step, VIEWER);
|
|
170
|
+
manager.viewers.of(audio, VIEWER);
|
|
171
|
+
assert.deepEqual(
|
|
172
|
+
[...manager.viewers.of(base, VIEWER).outputs].sort(),
|
|
173
|
+
[BASE_ID, STEP_ID, AUDIO_ID].sort(),
|
|
174
|
+
"one viewer, one object, and it knows all three outputs it is watching"
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
await manager.releaseSessionConsumer(BASE_ID, VIEWER, "the viewer left");
|
|
178
|
+
|
|
179
|
+
assert.equal(manager.sessionsById.has(BASE_ID), false, "the picture goes with its last consumer");
|
|
180
|
+
assert.equal(
|
|
181
|
+
manager.sessionsById.has(STEP_ID),
|
|
182
|
+
false,
|
|
183
|
+
"and so does the quality step: nobody is watching it, and no one outside this class knows its id"
|
|
184
|
+
);
|
|
185
|
+
assert.equal(
|
|
186
|
+
manager.sessionsById.has(AUDIO_ID),
|
|
187
|
+
false,
|
|
188
|
+
"and the soundtrack, for the same reason — it used to sit for half an hour holding an encoder, a directory and a claim on the torrent"
|
|
189
|
+
);
|
|
190
|
+
assert.equal(manager.viewers.size, 0, "and nobody is left in the registry, which must not be a map that only grows");
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("an output somebody else is still watching is kept when one viewer leaves", async (t) => {
|
|
194
|
+
const { manager, base, step, audio, dirPath } = await pictureWithStepAndSoundtrack();
|
|
195
|
+
t.after(() => rm(dirPath, { recursive: true, force: true }));
|
|
196
|
+
const second = "viewer-two";
|
|
197
|
+
base.consumers.add(second);
|
|
198
|
+
manager.viewers.of(base, VIEWER).head = { segment: 25, seconds: 100, at: Date.now() };
|
|
199
|
+
manager.viewers.of(step, VIEWER);
|
|
200
|
+
manager.viewers.of(audio, VIEWER);
|
|
201
|
+
// The second viewer is listening to the same soundtrack and watching the
|
|
202
|
+
// picture at its own height.
|
|
203
|
+
manager.viewers.of(base, second).head = { segment: 25, seconds: 100, at: Date.now() };
|
|
204
|
+
manager.viewers.of(audio, second);
|
|
205
|
+
|
|
206
|
+
await manager.releaseSessionConsumer(BASE_ID, VIEWER, "the first viewer left");
|
|
207
|
+
|
|
208
|
+
assert.equal(manager.sessionsById.has(BASE_ID), true, "the picture stays: it still has a consumer");
|
|
209
|
+
assert.equal(
|
|
210
|
+
manager.sessionsById.has(AUDIO_ID),
|
|
211
|
+
true,
|
|
212
|
+
"and the soundtrack stays, because having no listeners is what kills it and it has one"
|
|
213
|
+
);
|
|
214
|
+
assert.equal(
|
|
215
|
+
base.viewers.has(VIEWER),
|
|
216
|
+
false,
|
|
217
|
+
"the viewer who left is gone from the picture"
|
|
218
|
+
);
|
|
219
|
+
assert.equal(
|
|
220
|
+
audio.viewers.has(VIEWER),
|
|
221
|
+
false,
|
|
222
|
+
"and from the soundtrack, which is the half of the relation the viewer holds"
|
|
223
|
+
);
|
|
224
|
+
assert.equal(step.viewers.size, 0, "the step they had is watched by nobody");
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("leaving an output releases what the watching claimed of production", async (t) => {
|
|
228
|
+
const { manager, base, step, audio, dirPath, released } = await pictureWithStepAndSoundtrack();
|
|
229
|
+
t.after(() => rm(dirPath, { recursive: true, force: true }));
|
|
230
|
+
manager.viewers.of(base, VIEWER).head = { segment: 25, seconds: 100, at: Date.now() };
|
|
231
|
+
manager.viewers.of(step, VIEWER);
|
|
232
|
+
manager.viewers.of(audio, VIEWER);
|
|
233
|
+
|
|
234
|
+
await manager.releaseSessionConsumer(BASE_ID, VIEWER, "the viewer left");
|
|
235
|
+
|
|
236
|
+
for (const id of [BASE_ID, STEP_ID, AUDIO_ID]) {
|
|
237
|
+
assert.ok(
|
|
238
|
+
released.includes(`${id}:${VIEWER}`),
|
|
239
|
+
`the claim on ${id.slice(0, 8)} is released — the plan's own pass cannot do it once the viewer is out of the map`
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test("the two indexes of one relation are written together", () => {
|
|
245
|
+
const viewers = new Viewers();
|
|
246
|
+
const picture = { id: "picture" };
|
|
247
|
+
const soundtrack = { id: "soundtrack" };
|
|
248
|
+
|
|
249
|
+
const viewer = viewers.of(picture, VIEWER);
|
|
250
|
+
viewers.of(soundtrack, VIEWER);
|
|
251
|
+
assert.equal(viewers.of(soundtrack, VIEWER), viewer, "one person is one object, whatever they are watching");
|
|
252
|
+
assert.deepEqual([...viewer.outputs].sort(), ["picture", "soundtrack"]);
|
|
253
|
+
assert.equal(picture.viewers.get(VIEWER), viewer);
|
|
254
|
+
|
|
255
|
+
viewers.leaves(soundtrack, VIEWER);
|
|
256
|
+
assert.deepEqual([...viewer.outputs], ["picture"], "both directions go together");
|
|
257
|
+
assert.equal(soundtrack.viewers.has(VIEWER), false);
|
|
258
|
+
assert.equal(viewers.size, 1, "and the person is still known, because they are still watching something");
|
|
259
|
+
|
|
260
|
+
viewers.leaves(picture, VIEWER);
|
|
261
|
+
assert.equal(viewers.size, 0, "watching nothing, they are forgotten");
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test("a viewer that cannot name itself belongs to the session that met it", () => {
|
|
265
|
+
const viewers = new Viewers();
|
|
266
|
+
const oneFilm = { id: "one" };
|
|
267
|
+
const another = { id: "another" };
|
|
268
|
+
|
|
269
|
+
const first = viewers.of(oneFilm, "");
|
|
270
|
+
const second = viewers.of(another, "");
|
|
271
|
+
assert.notEqual(first, second, "two anonymous viewers of two films are not one person");
|
|
272
|
+
assert.equal(viewers.size, 0, "and neither is in the registry, which is keyed by a name they do not have");
|
|
273
|
+
});
|