@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,128 @@
1
+ /**
2
+ * @file A real `EncodeRun` over a process that is not real.
3
+ *
4
+ * Tests used to set a session's process fields by hand — `session.ffmpeg`,
5
+ * `session.runState`, `session.encodeStartIndex` — which is how a test can pass
6
+ * over code that no longer works: the fields were the thing under test as much
7
+ * as the behaviour was. A run is an object now, so a test builds the object the
8
+ * product builds and injects only the one thing a test may not have, which is a
9
+ * child process.
10
+ *
11
+ * The pid is deliberately absent unless a test asks for one. `pause` and
12
+ * `resume` send signals by pid, and a made-up number is somebody else's process
13
+ * on the machine running the tests.
14
+ */
15
+
16
+ import { EncodeRun } from "../../services/encode/EncodeRun.js";
17
+
18
+ /**
19
+ * A child process that records what was done to it.
20
+ *
21
+ * @param {object} [options]
22
+ * @param {number | null} [options.pid] - Only where a test genuinely exercises
23
+ * suspend or resume, and then it must be this process's own.
24
+ * @returns {object}
25
+ */
26
+ export function fakeProcess({ pid = null, exitsWhenKilled = true } = {}) {
27
+ /** @type {Map<string, (...args: unknown[]) => void>} */
28
+ const listeners = new Map();
29
+ return {
30
+ pid,
31
+ killed: false,
32
+ exitCode: null,
33
+ signalCode: null,
34
+ /** Every signal it was sent, in order. */
35
+ signals: [],
36
+ stdout: { on() {} },
37
+ stderr: { on() {} },
38
+ on(event, handler) {
39
+ const kept = listeners.get(event) ?? [];
40
+ kept.push(handler);
41
+ listeners.set(event, kept);
42
+ return this;
43
+ },
44
+ once(event, handler) {
45
+ return this.on(event, handler);
46
+ },
47
+ kill(signal = "SIGTERM") {
48
+ this.signals.push(signal);
49
+ this.killed = true;
50
+ // A real process answers a signal by exiting, and it does so on a later
51
+ // turn. A fake that never exits makes every disposal wait out the grace
52
+ // period, which is two seconds a test spends proving nothing.
53
+ if (exitsWhenKilled && this.exitCode === null && this.signalCode === null) {
54
+ queueMicrotask(() => this.exit(null, signal));
55
+ }
56
+ return true;
57
+ },
58
+ /**
59
+ * Report an exit, as the real thing would.
60
+ *
61
+ * @param {number | null} code
62
+ * @param {string | null} [signal]
63
+ */
64
+ exit(code, signal = null) {
65
+ this.exitCode = code;
66
+ this.signalCode = signal;
67
+ for (const handler of listeners.get("exit") ?? []) {
68
+ handler(code, signal);
69
+ }
70
+ }
71
+ };
72
+ }
73
+
74
+ /** A logger that says nothing, for tests that are not about the log. */
75
+ export const silentLogger = { info() {}, warn() {}, error() {} };
76
+
77
+ /**
78
+ * Add a run to a session, started, over a fake process.
79
+ *
80
+ * A session holds a SET of runs — as many as the machine affords — so a test
81
+ * that wants two heads on one output calls this twice.
82
+ *
83
+ * @param {object} session - The session under test.
84
+ * @param {object} [options]
85
+ * @param {number} [options.from] - First segment number it is making.
86
+ * @param {number} [options.to] - Last, inclusive; below `from` means no end.
87
+ * @param {object} [options.process] - The process it should own.
88
+ * @param {boolean} [options.producing] - Whether it has already made its first
89
+ * segment, which is what moves it out of starting.
90
+ * @param {number | null} [options.lastSegmentIndex] - The film's last number,
91
+ * for telling a finished file from an input that dried up.
92
+ * @param {boolean} [options.usesExplicitCuts]
93
+ * @returns {import("../../services/encode/EncodeRun.js").EncodeRun}
94
+ */
95
+ export function startRunOn(session, options = {}) {
96
+ const {
97
+ from = 0,
98
+ to = -1,
99
+ process: child = fakeProcess(),
100
+ producing = true,
101
+ lastSegmentIndex = null,
102
+ usesExplicitCuts = false
103
+ } = options;
104
+ const run = new EncodeRun({
105
+ address: session.outputKey ?? session.id ?? "output",
106
+ encoder: { name: "libx264", kind: "software" },
107
+ from,
108
+ to,
109
+ buildArgs: () => [],
110
+ spawn: () => child,
111
+ logger: silentLogger,
112
+ lastSegmentIndex: () => lastSegmentIndex,
113
+ usesExplicitCuts
114
+ });
115
+ run.start("a test asked for it");
116
+ if (producing) {
117
+ // What moves a run out of starting is its first segment, in the product as
118
+ // here — so a run that is producing has made one, and its head stands one
119
+ // past where it began. `from` is still where it started, which is what a
120
+ // test asserting a run's position asks for.
121
+ run.noteProduced(from);
122
+ }
123
+ if (!(session.runs instanceof Set)) {
124
+ session.runs = new Set();
125
+ }
126
+ session.runs.add(run);
127
+ return run;
128
+ }
@@ -15,6 +15,8 @@
15
15
 
16
16
  import test from "node:test";
17
17
  import assert from "node:assert/strict";
18
+ import { SourceFile } from "../services/source/SourceFile.js";
19
+ import { Timeline } from "../services/output/Timeline.js";
18
20
  import { newIndexCheck, noteIndexDeviation } from "../services/hls-session-manager.js";
19
21
 
20
22
  test("an index that describes its file exactly is reported as such", () => {
@@ -65,14 +67,17 @@ test("a boundary the index got wrong is replaced by the time the file really has
65
67
  localPort: 9090
66
68
  });
67
69
  t.after(() => manager.disposeAll());
70
+ // ONE table for the film, held by both. It used to be a copy each, kept in
71
+ // step by writing the correction into every member — which is what the shared
72
+ // table replaces, and what drifted in the field.
73
+ const boundaries = [0, 10, 20, 30, 40];
68
74
  const base = {
69
75
  id: "aaaaaaaa-1111-2222-3333-444444444444",
70
76
  fileName: "film.mkv",
71
77
  state: "ready",
72
78
  transcodeVideo: false,
73
- segmentBoundaries: [0, 10, 20, 30, 40],
74
- indexCheck: newIndexCheck(),
75
- variants: new Map(),
79
+ timeline: new Timeline({ boundaries: boundaries, cutGrid: "uniform" }),
80
+ file: new SourceFile({ sourceKey: "source-1", fileIndex: 0, name: "film.mkv" }),
76
81
  segmentFormat: { segmentFileName: (index) => `segment-${index}.mp4` }
77
82
  };
78
83
  const rung = {
@@ -80,11 +85,13 @@ test("a boundary the index got wrong is replaced by the time the file really has
80
85
  fileName: "film.mkv",
81
86
  state: "ready",
82
87
  transcodeVideo: true,
83
- segmentBoundaries: [0, 10, 20, 30, 40],
84
- indexCheck: newIndexCheck(),
85
- variantBases: new Set([base.id])
88
+ timeline: new Timeline({ boundaries: boundaries, cutGrid: "uniform" }),
89
+ // A step of the picture: the same file, and made as a step.
90
+ file: base.file,
91
+ variantHeight: 540,
92
+ isStep: true
86
93
  };
87
- base.variants.set(540, rung.id);
94
+ base.file.stepHeights.set(540, 540);
88
95
  manager.sessionsById.set(base.id, base);
89
96
  manager.sessionsById.set(rung.id, rung);
90
97
 
@@ -93,17 +100,17 @@ test("a boundary the index got wrong is replaced by the time the file really has
93
100
  manager.correctBoundaryFromSegment(base, 2, 17.4);
94
101
 
95
102
  assert.equal(
96
- base.segmentBoundaries[2],
103
+ base.timeline.boundaries[2],
97
104
  17.4,
98
105
  "the grid must describe the file, not the index — a rung forced onto 20 s would not join the copy"
99
106
  );
100
107
  assert.equal(
101
- rung.segmentBoundaries[2],
108
+ rung.timeline.boundaries[2],
102
109
  17.4,
103
- "the family shares one grid, so a correction reaches the rungs cut against it"
110
+ "the family shares one grid — the same array, so there is nothing to keep in step"
104
111
  );
105
112
  assert.deepEqual(
106
- base.segmentBoundaries,
113
+ base.timeline.boundaries,
107
114
  [0, 10, 17.4, 30, 40],
108
115
  "only the boundary that was shown to be wrong moves"
109
116
  );
@@ -114,7 +121,7 @@ test("a boundary the index got wrong is replaced by the time the file really has
114
121
  manager.correctBoundaryFromSegment(base, 2, 35);
115
122
  manager.correctBoundaryFromSegment(base, 2, 5);
116
123
  manager.correctBoundaryFromSegment(base, 0, 3);
117
- assert.deepEqual(base.segmentBoundaries, [0, 10, 17.4, 30, 40], "out-of-order readings are refused");
124
+ assert.deepEqual(base.timeline.boundaries, [0, 10, 17.4, 30, 40], "out-of-order readings are refused");
118
125
  });
119
126
 
120
127
  test("a segment requested again is not new evidence", () => {
@@ -0,0 +1,132 @@
1
+ /**
2
+ * @file Where a file's keyframes are is a fact of the file, not of a session.
3
+ *
4
+ * It is a property of immutable bytes, like the duration and the track list, so
5
+ * a second reading could only agree. Two sessions created in the same moment
6
+ * used to read it twice — which is what two viewers opening one film do,
7
+ * measured 13 ms apart on 2026-09-03 — and the answer decides whether the
8
+ * picture can be copied at all, so it has to be one answer.
9
+ */
10
+
11
+ import test from "node:test";
12
+ import assert from "node:assert/strict";
13
+ import { HlsSessionManager } from "../services/hls-session-manager.js";
14
+ import { Container } from "../services/container/Container.js";
15
+
16
+ /**
17
+ * @returns {HlsSessionManager}
18
+ */
19
+ function manager() {
20
+ return new HlsSessionManager({
21
+ enabled: true,
22
+ ffmpegBin: "ffmpeg",
23
+ localBindHost: "127.0.0.1",
24
+ localPort: 9090
25
+ });
26
+ }
27
+
28
+ test("two sessions asking at once make one read and join one wait", async () => {
29
+ const session = manager();
30
+ let reads = 0;
31
+ let answer = null;
32
+ session.getContainerKeyframes = () => {
33
+ reads += 1;
34
+ return new Promise((resolve) => {
35
+ answer = resolve;
36
+ });
37
+ };
38
+
39
+ const first = session.warmKeyframeIndex({ sourceKey: "torrent:abc", fileIndex: 0, logName: "a.mkv" });
40
+ const second = session.warmKeyframeIndex({ sourceKey: "torrent:abc", fileIndex: 0, logName: "a.mkv" });
41
+ assert.equal(reads, 1, "the second asker joined the read already running");
42
+
43
+ answer({ times: [0, 4, 8], tolerance: 0 });
44
+ await Promise.all([first, second]);
45
+ assert.equal(reads, 1);
46
+ });
47
+
48
+ test("the answer is remembered for the file, so a later session reads nothing", async () => {
49
+ const session = manager();
50
+ let reads = 0;
51
+ session.getContainerKeyframes = async () => {
52
+ reads += 1;
53
+ return { times: [0, 4, 8], tolerance: 0 };
54
+ };
55
+
56
+ await session.warmKeyframeIndex({ sourceKey: "torrent:abc", fileIndex: 0, logName: "a.mkv" });
57
+ await session.warmKeyframeIndex({ sourceKey: "torrent:abc", fileIndex: 0, logName: "a.mkv" });
58
+
59
+ assert.equal(reads, 1);
60
+ });
61
+
62
+ test("two files of one torrent are two answers", async () => {
63
+ const session = manager();
64
+ const asked = [];
65
+ session.getContainerKeyframes = async ({ fileIndex }) => {
66
+ asked.push(fileIndex);
67
+ return { times: [0, 4], tolerance: 0 };
68
+ };
69
+
70
+ await session.warmKeyframeIndex({ sourceKey: "torrent:abc", fileIndex: 0, logName: "a.mkv" });
71
+ await session.warmKeyframeIndex({ sourceKey: "torrent:abc", fileIndex: 1, logName: "b.mkv" });
72
+
73
+ assert.deepEqual(asked, [0, 1]);
74
+ });
75
+
76
+ test("a file with no readable index says so once, and keeps saying it", async () => {
77
+ const session = manager();
78
+ let reads = 0;
79
+ session.getContainerKeyframes = async () => {
80
+ reads += 1;
81
+ return null;
82
+ };
83
+
84
+ await session.warmKeyframeIndex({ sourceKey: "torrent:abc", fileIndex: 0, logName: "a.ts" });
85
+ await session.warmKeyframeIndex({ sourceKey: "torrent:abc", fileIndex: 0, logName: "a.ts" });
86
+
87
+ // "No index" is an answer about the file — it is what makes a copy of it
88
+ // re-encode instead — and it must be the same answer for every viewer.
89
+ assert.equal(reads, 1);
90
+ });
91
+
92
+ test("a container reads its own table once", async () => {
93
+ let parses = 0;
94
+ class OneTable extends Container {
95
+ async parseKeyframeIndex() {
96
+ parses += 1;
97
+ return { times: [0, 2, 4], tolerance: 0 };
98
+ }
99
+ }
100
+ const container = new OneTable({ readRange: async () => null, fileSize: 10 });
101
+
102
+ const [first, second] = await Promise.all([
103
+ container.readKeyframeIndex(),
104
+ container.readKeyframeIndex()
105
+ ]);
106
+ await container.readKeyframeIndex();
107
+
108
+ assert.equal(parses, 1);
109
+ assert.deepEqual(first, second);
110
+ });
111
+
112
+ test("a read that threw is not remembered as an answer", async () => {
113
+ let parses = 0;
114
+ class Late extends Container {
115
+ async parseKeyframeIndex() {
116
+ parses += 1;
117
+ if (parses === 1) {
118
+ // The bytes it needed had not arrived yet, which is not a statement
119
+ // about the file.
120
+ throw new Error("nothing to read there yet");
121
+ }
122
+ return { times: [0, 2], tolerance: 0 };
123
+ }
124
+ }
125
+ const container = new Late({ readRange: async () => null, fileSize: 10 });
126
+
127
+ await assert.rejects(() => container.readKeyframeIndex());
128
+ const second = await container.readKeyframeIndex();
129
+
130
+ assert.equal(parses, 2);
131
+ assert.deepEqual(second.times, [0, 2]);
132
+ });
@@ -0,0 +1,164 @@
1
+ /**
2
+ * @file The plan is what decides the encoders, and it is asked of real sessions.
3
+ *
4
+ * Three rules written into the session manager become one here: where a run
5
+ * belongs, when it has been overtaken, and how many the machine affords. The
6
+ * point of wiring rather than duplicating is that the arithmetic has sixteen
7
+ * checks of its own and no ffmpeg behind it.
8
+ */
9
+
10
+ import test from "node:test";
11
+ import assert from "node:assert/strict";
12
+ import { SourceFile } from "../services/source/SourceFile.js";
13
+ import { Timeline } from "../services/output/Timeline.js";
14
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
15
+ import os from "node:os";
16
+ import path from "node:path";
17
+ import { HlsSessionManager } from "../services/hls-session-manager.js";
18
+ import { SegmentStore } from "../services/encode/SegmentStore.js";
19
+ import { fmp4Format } from "../services/segment-formats/fmp4.js";
20
+ import { viewerOf } from "../services/viewer/Viewer.js";
21
+ import { startRunOn } from "./helpers/encode-run.js";
22
+
23
+ const KEY = "torrent:abc:fmt=fmp4:grid=kf@0:video-only:v=0/copy";
24
+
25
+ function managerWithAnOutput() {
26
+ const root = mkdtempSync(path.join(os.tmpdir(), "orchestrator-wired-"));
27
+ const store = new SegmentStore({ root });
28
+ const manager = new HlsSessionManager({
29
+ enabled: true,
30
+ ffmpegBin: "ffmpeg",
31
+ localBindHost: "127.0.0.1",
32
+ localPort: 9090,
33
+ segmentStore: store
34
+ });
35
+ const dirPath = store.directoryFor(KEY);
36
+ store.useFormat(KEY, fmp4Format);
37
+ return { manager, store, root, dirPath };
38
+ }
39
+
40
+ function sessionOn({ id, dirPath, encodeStartIndex = 0, runEndIndex = -1, speed = 0, running = true }) {
41
+ const session = {
42
+ id,
43
+ outputKey: KEY,
44
+ dirPath,
45
+ // Where this file is cut, held by the file. A fixture that stated it
46
+ // on the session was describing what production no longer does.
47
+ timeline: new Timeline({
48
+ boundaries: Array.from({ length: 1001 }, (_, index) => index * 4),
49
+ cutGrid: "uniform"
50
+ }),
51
+ state: "ready",
52
+ file: new SourceFile({ sourceKey: "source-1", fileIndex: 0, name: "video.mkv" }),
53
+ // An ordinary session reads its own file, and its sound is inside it. The
54
+ // three differ only for a soundtrack shipped as a file of its own.
55
+ get inputFile() { return this.file; },
56
+ get audioFile() { return this.file; },
57
+ segmentFormat: fmp4Format,
58
+ segmentCount: 1000,
59
+ recentSpeed: null,
60
+ consumers: new Set(),
61
+ viewers: new Map(),
62
+ runs: new Set(),
63
+ lastAccessedAt: Date.now()
64
+ };
65
+ if (running) {
66
+ const run = startRunOn(session, { from: encodeStartIndex, to: runEndIndex });
67
+ // A speed is a reading taken FROM a run, so it names the run it came from.
68
+ session.recentSpeed = speed > 0 ? { speed, at: Date.now(), run } : null;
69
+ run.noteSpeed(speed);
70
+ }
71
+ return session;
72
+ }
73
+
74
+ test("a session is handed to the plan as the run it is", (t) => {
75
+ const { manager, root, dirPath } = managerWithAnOutput();
76
+ t.after(() => rmSync(root, { recursive: true, force: true }));
77
+
78
+ const session = sessionOn({ id: "one", dirPath, encodeStartIndex: 10, runEndIndex: 40 });
79
+ manager.sessionsById.set(session.id, session);
80
+ viewerOf(session, "watching").head = { segment: 12, seconds: 48, at: Date.now() };
81
+
82
+ manager.runQualityBudgetOnce;
83
+ manager.planEncodersNow();
84
+
85
+ const runs = manager.encodeOrchestrator.runsOn(KEY);
86
+ assert.equal(runs.length, 1, "the session the browser already has is a run like any other");
87
+ assert.equal(runs[0], [...session.runs][0], "and it is the run the session actually holds");
88
+ assert.equal(runs[0].from, 10);
89
+ assert.equal(runs[0].to, 40);
90
+ });
91
+
92
+ test("what a viewer waits for reaches the plan without their name", (t) => {
93
+ const { manager, root, dirPath } = managerWithAnOutput();
94
+ t.after(() => rmSync(root, { recursive: true, force: true }));
95
+
96
+ const session = sessionOn({ id: "one", dirPath, encodeStartIndex: 0, runEndIndex: -1 });
97
+ manager.sessionsById.set(session.id, session);
98
+ viewerOf(session, "someone").head = { segment: 5, seconds: 20, at: Date.now() };
99
+
100
+ manager.planEncodersNow();
101
+
102
+ const wanted = manager.encodeOrchestrator.demand.windowsOn(KEY);
103
+ assert.equal(wanted.length, 1);
104
+ assert.equal(wanted[0].from, 5, "where they are");
105
+ assert.ok(wanted[0].to > 5, "and the cushion in front of them");
106
+ });
107
+
108
+ test("a viewer who has gone quiet stops being waited for", (t) => {
109
+ const { manager, root, dirPath } = managerWithAnOutput();
110
+ t.after(() => rmSync(root, { recursive: true, force: true }));
111
+
112
+ const session = sessionOn({ id: "one", dirPath });
113
+ manager.sessionsById.set(session.id, session);
114
+ viewerOf(session, "gone").head = { segment: 5, seconds: 20, at: Date.now() - 10 * 60 * 1000 };
115
+
116
+ manager.planEncodersNow();
117
+
118
+ assert.equal(manager.encodeOrchestrator.demand.windowsOn(KEY).length, 0);
119
+ });
120
+
121
+ test("how many encoders the machine affords is measured, not chosen", (t) => {
122
+ const { manager, root, dirPath } = managerWithAnOutput();
123
+ t.after(() => rmSync(root, { recursive: true, force: true }));
124
+
125
+ // Nothing measured yet: one is what it has.
126
+ const cold = sessionOn({ id: "cold", dirPath });
127
+ manager.sessionsById.set(cold.id, cold);
128
+ assert.equal(manager.maxRunsForOutput(KEY), 1);
129
+
130
+ // Fast, but what a second job costs on THIS machine has not been measured,
131
+ // and an unmeasured penalty of 1 is not a statement that it is free.
132
+ cold.recentSpeed = { speed: 7.12 };
133
+ assert.equal(manager.maxRunsForOutput(KEY), 1, "no measurement, no second encoder");
134
+
135
+ // Measured on the addon host 2026-09-03: at 854x480 one run made 7.12x and
136
+ // two made 4.20x and 4.16x, a penalty of 1.70x, and both stayed far above
137
+ // realtime.
138
+ manager.contentionPenalties = new Map([[1, 1.7]]);
139
+ assert.ok(manager.maxRunsForOutput(KEY) > 1, "measured, and a second fits");
140
+
141
+ // The same host at 1920x1080: one made 1.96x, two made 0.99x and 0.98x.
142
+ manager.contentionPenalties = new Map([[1, 1.98]]);
143
+ cold.recentSpeed = { speed: 1.96 };
144
+ assert.equal(manager.maxRunsForOutput(KEY), 1, "the machine is full at one");
145
+ });
146
+
147
+ test("segments already made are known to the plan, whoever made them", (t) => {
148
+ const { manager, store, root, dirPath } = managerWithAnOutput();
149
+ t.after(() => rmSync(root, { recursive: true, force: true }));
150
+
151
+ for (const index of [0, 1, 2, 3]) {
152
+ writeFileSync(path.join(dirPath, fmp4Format.segmentFileName(index)), Buffer.alloc(16, 1));
153
+ }
154
+ const session = sessionOn({ id: "one", dirPath });
155
+ manager.sessionsById.set(session.id, session);
156
+
157
+ manager.planEncodersNow();
158
+
159
+ const coverage = manager.encodeOrchestrator.coverageOf(KEY);
160
+ assert.equal(coverage.isReady(0), true);
161
+ assert.equal(coverage.isReady(2), true);
162
+ assert.equal(coverage.isReady(3), false, "the highest has no successor to prove it closed");
163
+ void store;
164
+ });
@@ -0,0 +1,68 @@
1
+ /**
2
+ * @file What an output is encoded AS is decided once, for the output.
3
+ *
4
+ * The realtime budget decides the box, the frame rate and the speed setting
5
+ * from what the machine could hold at that moment. Decided per SESSION, two
6
+ * sessions of one output made minutes apart could be given different pictures
7
+ * while claiming the same identity — and everything downstream assumes they
8
+ * cannot be, from a segment of one standing in for a segment of the other to
9
+ * the single RESOLUTION the master names for both.
10
+ */
11
+
12
+ import test from "node:test";
13
+ import assert from "node:assert/strict";
14
+ import { Output, Outputs } from "../services/output/Output.js";
15
+
16
+ const KEY = "torrent:abc:fmt=fmp4:grid=even@0:video-only:v=0/enc:1280x720:auto";
17
+
18
+ test("the second session of one output is given the shape the first was", () => {
19
+ const outputs = new Outputs();
20
+ const first = outputs.get(KEY, () => new Output({
21
+ encodeWidth: 1280,
22
+ encodeHeight: 720,
23
+ outputFps: 24,
24
+ softwarePreset: "veryfast",
25
+ applyTonemap: false
26
+ }));
27
+ // A minute later the machine is busier and the budget would answer 480p. It
28
+ // is not asked: the shape of this output was settled when it was first made.
29
+ const second = outputs.get(KEY, () => {
30
+ throw new Error("deciding twice is how two sessions of one output diverge");
31
+ });
32
+
33
+ assert.equal(second, first);
34
+ assert.equal(second.encodeHeight, 720);
35
+ });
36
+
37
+ test("two outputs are two shapes", () => {
38
+ const outputs = new Outputs();
39
+ const tall = outputs.get(KEY, () => new Output({ encodeWidth: 1280, encodeHeight: 720, outputFps: 24 }));
40
+ const short = outputs.get(`${KEY}:other`, () => new Output({
41
+ encodeWidth: 854,
42
+ encodeHeight: 480,
43
+ outputFps: 24
44
+ }));
45
+
46
+ assert.notEqual(short, tall);
47
+ assert.equal(short.encodeHeight, 480);
48
+ });
49
+
50
+ test("a copied picture has no box of its own, and says so with zeroes", () => {
51
+ // Zero means the source's own size, which is what a copy is by definition:
52
+ // no box asked for can change one byte of it.
53
+ const copied = new Output({ encodeWidth: 0, encodeHeight: 0, outputFps: 24 });
54
+
55
+ assert.equal(copied.encodeWidth, 0);
56
+ assert.equal(copied.encodeHeight, 0);
57
+ assert.equal(copied.softwarePreset, null, "and no speed setting, having no encoder");
58
+ });
59
+
60
+ test("a shape nobody holds is dropped", () => {
61
+ const outputs = new Outputs();
62
+ const kept = outputs.get(KEY, () => new Output({ encodeWidth: 1280, encodeHeight: 720, outputFps: 24 }));
63
+ outputs.get(`${KEY}:other`, () => new Output({ encodeWidth: 854, encodeHeight: 480, outputFps: 24 }));
64
+
65
+ assert.equal(outputs.size, 2);
66
+ assert.equal(outputs.forgetUnused(new Set([kept])), 1);
67
+ assert.equal(outputs.size, 1);
68
+ });