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