@torrent-tv/proxy 2.75.0 → 2.76.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.
Files changed (113) hide show
  1. package/CHANGELOG.md +1504 -1461
  2. package/CLAUDE.md +11 -1
  3. package/biome.json +182 -1
  4. package/docs/container-architecture.md +2 -1
  5. package/docs/encode-run-state.md +1 -1
  6. package/knip.json +14 -0
  7. package/package.json +1 -1
  8. package/routes/api/sources/warm/post.js +1 -1
  9. package/routes/api/transcode-sessions/post.js +185 -185
  10. package/routes/api/transcode-sessions/progress/get.js +5 -1
  11. package/routes/transcode/audio-file/get.js +11 -1
  12. package/routes/transcode/audio-warm/get.js +11 -1
  13. package/routes/transcode/session-file/get.js +1 -1
  14. package/routes/transcode/variant-file/get.js +10 -1
  15. package/scripts/render-run-graph.js +2 -2
  16. package/server.js +25 -0
  17. package/services/audio-inventory.js +206 -201
  18. package/services/container/AviContainer.js +1 -1
  19. package/services/container/Container.js +33 -1
  20. package/services/container/MatroskaContainer.js +1 -1
  21. package/services/container/Mp4Container.js +1 -1
  22. package/services/container/SubtitleFileContainer.js +0 -1
  23. package/services/controllers/SubtitleController.js +128 -128
  24. package/services/demand/index.js +7 -10
  25. package/services/download/registry.js +0 -14
  26. package/services/encode/CoverageMap.js +281 -0
  27. package/services/encode/EncodePlan.js +255 -0
  28. package/services/encode/EncodeRun.js +587 -0
  29. package/services/encode/Encoder.js +84 -0
  30. package/services/encode/NvencEncoder.js +45 -0
  31. package/services/encode/QsvEncoder.js +47 -0
  32. package/services/encode/SegmentDemand.js +0 -0
  33. package/services/encode/SegmentStore.js +529 -0
  34. package/services/encode/SoftwareEncoder.js +111 -0
  35. package/services/encode/V4l2m2mEncoder.js +53 -0
  36. package/services/encode/VaapiEncoder.js +53 -0
  37. package/services/encode/args.js +200 -0
  38. package/services/{encode-exit.js → encode/encode-exit.js} +17 -0
  39. package/services/encode/index.js +9 -0
  40. package/services/encode/run-command.js +647 -0
  41. package/services/hls-session-manager.js +11073 -10711
  42. package/services/hwaccel.js +1688 -1992
  43. package/services/orchestrators/EncodeOrchestrator.js +359 -0
  44. package/services/output/LiveOutputs.js +213 -0
  45. package/services/output/Output.js +94 -0
  46. package/services/output/OutputSpec.js +195 -0
  47. package/services/output/Timeline.js +220 -0
  48. package/services/output/index.js +1 -0
  49. package/services/output/ladder.js +26 -0
  50. package/services/playback-planner.js +806 -775
  51. package/services/produced-index.js +222 -300
  52. package/services/source/SourceFile.js +346 -0
  53. package/services/{sidecar-files.js → torrent/files.js} +107 -11
  54. package/services/torrent/naming.js +619 -0
  55. package/services/torrent-worker/client.js +10 -0
  56. package/services/torrent-worker/container-tracks.js +71 -43
  57. package/services/torrent-worker/pool-adapter.js +18 -0
  58. package/services/torrent-worker/protocol.js +7 -0
  59. package/services/torrent-worker/subtitle-cues.js +549 -549
  60. package/services/torrent-worker/worker.js +18 -0
  61. package/services/tracks/TextSubtitleTrack.js +287 -287
  62. package/services/tracks/index.js +15 -14
  63. package/services/viewer/Viewer.js +145 -0
  64. package/services/viewer/Viewers.js +124 -0
  65. package/test/auto-quality-step.test.js +508 -506
  66. package/test/behind-head-repair.test.js +17 -7
  67. package/test/coverage-map.test.js +153 -0
  68. package/test/cut-times-timeline.test.js +6 -5
  69. package/test/cuts-follow-published-grid.test.js +4 -4
  70. package/test/decode-cost.test.js +31 -12
  71. package/test/encode-exit.test.js +1 -1
  72. package/test/encode-orchestrator.test.js +196 -0
  73. package/test/encode-plan.test.js +245 -0
  74. package/test/encode-run-state.test.js +2 -2
  75. package/test/encode-run.test.js +168 -0
  76. package/test/encoder-kinds.test.js +122 -0
  77. package/test/held-request-width.test.js +9 -3
  78. package/test/helpers/encode-run.js +128 -0
  79. package/test/keyframe-index-accuracy.test.js +19 -12
  80. package/test/keyframes-belong-to-the-file.test.js +132 -0
  81. package/test/orchestrator-wired.test.js +164 -0
  82. package/test/output-shape.test.js +68 -0
  83. package/test/output-spec.test.js +157 -0
  84. package/test/produced-copy-choice.test.js +58 -92
  85. package/test/produced-index.test.js +142 -188
  86. package/test/quality-variants.test.js +1079 -1075
  87. package/test/run-graph-drift.test.js +1 -1
  88. package/test/run-intervals.test.js +329 -0
  89. package/test/run-position-follows-published-grid.test.js +4 -4
  90. package/test/seek-landing.test.js +8 -8
  91. package/test/seek-target-not-superseded.test.js +21 -9
  92. package/test/segment-demand.test.js +82 -0
  93. package/test/segment-serve-wiring.test.js +47 -52
  94. package/test/segment-store.test.js +187 -0
  95. package/test/segments-are-shared.test.js +175 -0
  96. package/test/sidecar-naming.test.js +142 -0
  97. package/test/source-file.test.js +133 -0
  98. package/test/stale-request-after-seek.test.js +18 -12
  99. package/test/subtitle-language.test.js +252 -252
  100. package/test/timeline.test.js +95 -0
  101. package/test/{sidecar-files.test.js → torrent-files.test.js} +44 -1
  102. package/test/torrent-naming.test.js +255 -0
  103. package/test/tracks-begin-together.test.js +44 -32
  104. package/test/two-viewers-one-picture.test.js +347 -0
  105. package/test/viewer-outputs.test.js +273 -0
  106. package/test/viewer.test.js +91 -0
  107. package/utils/perf.js +1 -63
  108. package/services/container/index.js +0 -6
  109. package/services/controllers/index.js +0 -2
  110. package/services/download/index.js +0 -8
  111. package/services/orchestrators/index.js +0 -2
  112. /package/services/{encode-run-state.js → encode/encode-run-state.js} +0 -0
  113. /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
+ });