@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.
Files changed (113) hide show
  1. package/CHANGELOG.md +1509 -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 +11100 -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
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * @file The drawing cannot disagree with the table.
3
3
  *
4
- * `docs/encode-run-state.md` is generated from `services/encode-run-state.js`.
4
+ * `docs/encode-run-state.md` is generated from `services/encode/encode-run-state.js`.
5
5
  * A picture kept beside the code drifts — this repository has the receipts —
6
6
  * so the check is mechanical: regenerate it and compare. A behavioural change
7
7
  * to the table that forgets `npm run graph` fails here, in the same commit.
@@ -0,0 +1,329 @@
1
+ /**
2
+ * @file A run is given a stretch nobody else holds, and stops at the end of it.
3
+ *
4
+ * This is what removes the per-run directories. Runs used to be kept apart by a
5
+ * directory each, because two of them writing one segment name at the same time
6
+ * produce a file belonging to neither — which was also the only reason a restart
7
+ * ever had to wait for its predecessor to die. Intervals remove that by
8
+ * construction, so one flat directory per output is correct.
9
+ *
10
+ * The other half is the argument that states the end, and WHICH argument it is
11
+ * belongs to the branch — measured, not reasoned:
12
+ * `research/encoder-layer-2026-09-04.md` §11.
13
+ */
14
+
15
+ import test from "node:test";
16
+ import assert from "node:assert/strict";
17
+ import { SourceFile } from "../services/source/SourceFile.js";
18
+ import { startRunOn } from "./helpers/encode-run.js";
19
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
20
+ import os from "node:os";
21
+ import path from "node:path";
22
+ import { HlsSessionManager } from "../services/hls-session-manager.js";
23
+ import { SegmentStore } from "../services/encode/SegmentStore.js";
24
+ import { Timeline } from "../services/output/Timeline.js";
25
+ import { fmp4Format } from "../services/segment-formats/fmp4.js";
26
+ import { viewerOf } from "../services/viewer/Viewer.js";
27
+
28
+ const KEY = "torrent:abc:fmt=fmp4:grid=kf@0:video-only:v=0/copy";
29
+
30
+ /**
31
+ * @returns {{ manager: HlsSessionManager, store: SegmentStore, root: string, dirPath: string }}
32
+ */
33
+ function managerWithAnEmptyOutput() {
34
+ const root = mkdtempSync(path.join(os.tmpdir(), "run-intervals-"));
35
+ const store = new SegmentStore({ root });
36
+ const manager = new HlsSessionManager({
37
+ enabled: true,
38
+ ffmpegBin: "ffmpeg",
39
+ localBindHost: "127.0.0.1",
40
+ localPort: 9090,
41
+ segmentStore: store
42
+ });
43
+ const dirPath = store.directoryFor(KEY);
44
+ store.useFormat(KEY, fmp4Format);
45
+ return { manager, store, root, dirPath };
46
+ }
47
+
48
+ /**
49
+ * @param {object} params
50
+ * @returns {object}
51
+ */
52
+ function sessionOn({ id, dirPath, segmentCount = 100, runState = null, encodeStartIndex = 0, runEndIndex = -1 }) {
53
+ const session = {
54
+ id,
55
+ outputKey: KEY,
56
+ dirPath,
57
+ state: "ready",
58
+ file: new SourceFile({ sourceKey: "source-1", fileIndex: 0, name: "video.mkv" }),
59
+ // An ordinary session reads its own file, and its sound is inside it. The
60
+ // three differ only for a soundtrack shipped as a file of its own.
61
+ get inputFile() { return this.file; },
62
+ get audioFile() { return this.file; },
63
+ segmentFormat: fmp4Format,
64
+ segmentCount,
65
+ runs: new Set(),
66
+ consumers: new Set(),
67
+ lastAccessedAt: Date.now()
68
+ };
69
+ if (runState !== null) {
70
+ const run = startRunOn(session, { from: encodeStartIndex, to: runEndIndex });
71
+ if (runState === "ENDED_FAILED") {
72
+ // A run that failed, said so, and left what it had made behind.
73
+ run.process?.exit(255, null);
74
+ }
75
+ }
76
+ return session;
77
+ }
78
+
79
+ /**
80
+ * @param {string} dirPath
81
+ * @param {number[]} indexes
82
+ */
83
+ function alreadyMade(dirPath, indexes) {
84
+ for (const index of indexes) {
85
+ writeFileSync(path.join(dirPath, fmp4Format.segmentFileName(index)), Buffer.alloc(16, 1));
86
+ }
87
+ }
88
+
89
+ test("with nothing made, a run gets everything from where it was asked", (t) => {
90
+ const { manager, root, dirPath } = managerWithAnEmptyOutput();
91
+ t.after(() => rmSync(root, { recursive: true, force: true }));
92
+
93
+ const session = sessionOn({ id: "s", dirPath });
94
+ // -1 for the end means "to the end of the film", which is what every run had
95
+ // before ends existed — and the only case where that is still right.
96
+ assert.deepEqual(manager.planRunInterval(session, 0), { from: 0, to: -1 });
97
+ });
98
+
99
+ test("a run stops before material that is already made", (t) => {
100
+ const { manager, root, dirPath } = managerWithAnEmptyOutput();
101
+ t.after(() => rmSync(root, { recursive: true, force: true }));
102
+
103
+ // 10..20 on the disk. Only 10..19 are PROVEN — the highest has no successor,
104
+ // so nothing shows whether it was closed or was being written when its run
105
+ // died.
106
+ alreadyMade(dirPath, [10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20]);
107
+ const session = sessionOn({ id: "s", dirPath });
108
+
109
+ assert.deepEqual(manager.planRunInterval(session, 0), { from: 0, to: 9 });
110
+ });
111
+
112
+ test("a run asked to start inside made material is moved forward to the gap", (t) => {
113
+ const { manager, root, dirPath } = managerWithAnEmptyOutput();
114
+ t.after(() => rmSync(root, { recursive: true, force: true }));
115
+
116
+ alreadyMade(dirPath, [10, 11, 12, 13, 14, 15]);
117
+ const session = sessionOn({ id: "s", dirPath });
118
+
119
+ // Asked for 11, which is made, and so is everything to 14. The first thing
120
+ // worth encoding is 15 — unproven, because 16 does not exist.
121
+ assert.deepEqual(manager.planRunInterval(session, 11), { from: 15, to: -1 });
122
+ });
123
+
124
+ test("a run stops before a stretch another live run was given", (t) => {
125
+ const { manager, root, dirPath } = managerWithAnEmptyOutput();
126
+ t.after(() => rmSync(root, { recursive: true, force: true }));
127
+
128
+ const other = sessionOn({
129
+ id: "other",
130
+ dirPath,
131
+ runState: "PRODUCING",
132
+ encodeStartIndex: 40,
133
+ runEndIndex: 60
134
+ });
135
+ manager.sessionsById.set(other.id, other);
136
+ const session = sessionOn({ id: "s", dirPath });
137
+
138
+ // Two viewers of one film, one already encoding 40..60. The second run takes
139
+ // what is free in front of it and stops where the other one begins, so
140
+ // neither ever writes a name the other wants.
141
+ assert.deepEqual(manager.planRunInterval(session, 0), { from: 0, to: 39 });
142
+ assert.deepEqual(manager.planRunInterval(session, 45), { from: 61, to: -1 });
143
+ });
144
+
145
+ test("a run that has died holds nothing back", (t) => {
146
+ const { manager, root, dirPath } = managerWithAnEmptyOutput();
147
+ t.after(() => rmSync(root, { recursive: true, force: true }));
148
+
149
+ // The same stretch, but the process cannot be signalled — it has ended. What
150
+ // it did not finish is free again, without anything having to release it.
151
+ const dead = sessionOn({
152
+ id: "dead",
153
+ dirPath,
154
+ runState: "ENDED_FAILED",
155
+ encodeStartIndex: 40,
156
+ runEndIndex: 60
157
+ });
158
+ manager.sessionsById.set(dead.id, dead);
159
+ const session = sessionOn({ id: "s", dirPath });
160
+
161
+ assert.deepEqual(manager.planRunInterval(session, 0), { from: 0, to: -1 });
162
+ });
163
+
164
+ test("nothing left to make is answered with nothing, not with a run", (t) => {
165
+ const { manager, root, dirPath } = managerWithAnEmptyOutput();
166
+ t.after(() => rmSync(root, { recursive: true, force: true }));
167
+
168
+ const session = sessionOn({ id: "s", dirPath, segmentCount: 5 });
169
+ alreadyMade(dirPath, [0, 1, 2, 3, 4]);
170
+ const other = sessionOn({
171
+ id: "other",
172
+ dirPath,
173
+ segmentCount: 5,
174
+ runState: "PRODUCING",
175
+ encodeStartIndex: 4,
176
+ runEndIndex: 4
177
+ });
178
+ manager.sessionsById.set(other.id, other);
179
+
180
+ // Starting an encoder here would only repeat somebody else's work, which is
181
+ // what three ffmpeg processes making one identical picture cost on a CM4.
182
+ assert.equal(manager.planRunInterval(session, 0), null);
183
+ });
184
+
185
+ test("a session with no address keeps the old shape: start here, no end", (t) => {
186
+ const { manager, root, dirPath } = managerWithAnEmptyOutput();
187
+ t.after(() => rmSync(root, { recursive: true, force: true }));
188
+
189
+ const session = sessionOn({ id: "s", dirPath });
190
+ session.outputKey = "";
191
+
192
+ assert.deepEqual(manager.planRunInterval(session, 7), { from: 7, to: -1 });
193
+ });
194
+
195
+ test("a run without an end does not claim the whole film away from a later viewer", (t) => {
196
+ const { manager, root, dirPath } = managerWithAnEmptyOutput();
197
+ t.after(() => rmSync(root, { recursive: true, force: true }));
198
+
199
+ // The first viewer's run, at the beginning, with no end — which is every run
200
+ // whose output was empty when it started.
201
+ const first = sessionOn({
202
+ id: "first",
203
+ dirPath,
204
+ segmentCount: 1000,
205
+ runState: "PRODUCING",
206
+ encodeStartIndex: 0,
207
+ runEndIndex: -1
208
+ });
209
+ manager.sessionsById.set(first.id, first);
210
+ const second = sessionOn({ id: "second", dirPath, segmentCount: 1000 });
211
+
212
+ // A second viewer opens the same film in the middle. Counting the first run's
213
+ // claim as the whole film would leave them with no encoder at all, waiting for
214
+ // it to encode its way there — an hour on a long film. It only claims as far
215
+ // as it will actually get, which is its head plus the look-ahead.
216
+ const planned = manager.planRunInterval(second, 500);
217
+ assert.notEqual(planned, null);
218
+ assert.equal(planned.from, 500);
219
+ });
220
+
221
+ test("a run stops when it reaches a stretch another run was given", async (t) => {
222
+ const { manager, root, dirPath } = managerWithAnEmptyOutput();
223
+ t.after(() => rmSync(root, { recursive: true, force: true }));
224
+
225
+ const stopped = [];
226
+ const ahead = sessionOn({
227
+ id: "ahead",
228
+ dirPath,
229
+ runState: "PRODUCING",
230
+ encodeStartIndex: 500,
231
+ runEndIndex: 600
232
+ });
233
+ manager.sessionsById.set(ahead.id, ahead);
234
+ const behind = sessionOn({
235
+ id: "behind",
236
+ dirPath,
237
+ runState: "PRODUCING",
238
+ encodeStartIndex: 0,
239
+ runEndIndex: -1
240
+ });
241
+ manager.sessionsById.set(behind.id, behind);
242
+
243
+ // Its own end was set from the gaps of the moment it began, and the viewer who
244
+ // opened the film further on was not in that picture. Walking into their
245
+ // stretch means writing names they are writing.
246
+ const behindRun = [...behind.runs][0];
247
+ const aheadRun = [...ahead.runs][0];
248
+ assert.equal(manager.runMakingSegment(behind, 550, behindRun), aheadRun);
249
+ assert.equal(manager.runMakingSegment(behind, 499, behindRun), null);
250
+ assert.equal(
251
+ manager.runMakingSegment(ahead, 550, aheadRun),
252
+ null,
253
+ "a run without an end owns only as far as it will get, not the rest of the film"
254
+ );
255
+ void stopped;
256
+ });
257
+
258
+ test("a seek backwards does not drag the picture away from a viewer watching ahead", (t) => {
259
+ const { manager, root, dirPath } = managerWithAnEmptyOutput();
260
+ t.after(() => rmSync(root, { recursive: true, force: true }));
261
+
262
+ const shared = sessionOn({
263
+ id: "shared",
264
+ dirPath,
265
+ segmentCount: 1000,
266
+ runState: "PRODUCING",
267
+ encodeStartIndex: 200,
268
+ runEndIndex: -1
269
+ });
270
+ shared.sourceKey = "torrent:abc";
271
+ shared.fileIndex = 0;
272
+ shared.timeline = new Timeline({ boundaries: Array.from({ length: 1001 }, (_, index) => index * 4), cutGrid: "uniform" });
273
+ shared.acquireSource = null;
274
+ shared.progress = { processedSeconds: 900, startPositionSeconds: 800 };
275
+ manager.sessionsById.set(shared.id, shared);
276
+
277
+ // One viewer is at segment 250 and being served. The other jumps back an hour.
278
+ viewerOf(shared, "ahead").head = { segment: 250, seconds: 1000, at: Date.now() };
279
+ viewerOf(shared, "back").head = { segment: 250, seconds: 1000, at: Date.now() };
280
+
281
+ const servingAhead = [...shared.runs][0];
282
+ const startedAt = servingAhead.from;
283
+ manager.requestSeek("shared", 40, "back");
284
+
285
+ assert.equal(
286
+ servingAhead.from,
287
+ startedAt,
288
+ "the run serving the viewer in front is left exactly where it was"
289
+ );
290
+ assert.ok(shared.runs.has(servingAhead), "and it is not stopped");
291
+ // A run start claims its attempt before it awaits anything, so the assertion
292
+ // holds without the test needing an ffmpeg. 40 s is segment #10 on this grid.
293
+ assert.equal(
294
+ shared.pendingRun?.startIndex,
295
+ 10,
296
+ "and the one who jumped is given a run of their own, there"
297
+ );
298
+ });
299
+
300
+ test("a seek backwards with nobody else watching still moves the run", (t) => {
301
+ const { manager, root, dirPath } = managerWithAnEmptyOutput();
302
+ t.after(() => rmSync(root, { recursive: true, force: true }));
303
+
304
+ let created = 0;
305
+ manager.createOrGetSession = async () => {
306
+ created += 1;
307
+ return null;
308
+ };
309
+
310
+ const alone = sessionOn({
311
+ id: "alone",
312
+ dirPath,
313
+ segmentCount: 1000,
314
+ runState: "PRODUCING",
315
+ encodeStartIndex: 200,
316
+ runEndIndex: -1
317
+ });
318
+ alone.timeline = new Timeline({ boundaries: Array.from({ length: 1001 }, (_, index) => index * 4), cutGrid: "uniform" });
319
+ alone.progress = { processedSeconds: 900, startPositionSeconds: 800 };
320
+ manager.sessionsById.set(alone.id, alone);
321
+ viewerOf(alone, "only").head = { segment: 250, seconds: 1000, at: Date.now() };
322
+
323
+ manager.requestSeek("alone", 40, "only");
324
+
325
+ // Nobody is left behind, so a second run would be an encoder for one viewer
326
+ // who is not there any more.
327
+ assert.equal(created, 0);
328
+ assert.equal(alone.seekTarget !== undefined, true, "the run itself is repositioned, as before");
329
+ });
@@ -19,6 +19,7 @@
19
19
  */
20
20
 
21
21
  import assert from "node:assert/strict";
22
+ import { Timeline } from "../services/output/Timeline.js";
22
23
  import test from "node:test";
23
24
 
24
25
  import { HlsSessionManager, describeGridDrift, segmentCutTimesFrom } from "../services/hls-session-manager.js";
@@ -40,8 +41,7 @@ function sessionWithDriftedGrid() {
40
41
  });
41
42
  const session = {
42
43
  id: "picture",
43
- segmentBoundaries: [...CORRECTED],
44
- publishedBoundaries: [...PUBLISHED]
44
+ timeline: new Timeline({ boundaries: [...CORRECTED], published: [...PUBLISHED], cutGrid: "uniform" })
45
45
  };
46
46
  return { manager, session };
47
47
  }
@@ -49,7 +49,7 @@ function sessionWithDriftedGrid() {
49
49
  test("a run starts at the time the player was told, not at the corrected one", () => {
50
50
  const { manager, session } = sessionWithDriftedGrid();
51
51
  assert.equal(manager.runStartTimeFor(session, 2), 16.684);
52
- assert.notEqual(manager.runStartTimeFor(session, 2), session.segmentBoundaries[2]);
52
+ assert.notEqual(manager.runStartTimeFor(session, 2), session.timeline.boundaries[2]);
53
53
  });
54
54
 
55
55
  test("position and cut list come from the same table", () => {
@@ -65,7 +65,7 @@ test("position and cut list come from the same table", () => {
65
65
 
66
66
  test("a session that published no grid positions on the live one", () => {
67
67
  const { manager } = sessionWithDriftedGrid();
68
- const session = { id: "no-playlist", segmentBoundaries: [...CORRECTED], publishedBoundaries: [] };
68
+ const session = { id: "no-playlist", timeline: new Timeline({ boundaries: [...CORRECTED], published: [], cutGrid: "uniform" }) };
69
69
  assert.equal(manager.runStartTimeFor(session, 2), CORRECTED[2]);
70
70
  });
71
71
 
@@ -14,6 +14,7 @@
14
14
  */
15
15
 
16
16
  import assert from "node:assert/strict";
17
+ import { SourceFile } from "../services/source/SourceFile.js";
17
18
  import test from "node:test";
18
19
 
19
20
  import { seekLandingOffsetFor } from "../services/hls-session-manager.js";
@@ -21,26 +22,26 @@ import { seekLandingOffsetFor } from "../services/hls-session-manager.js";
21
22
  const OFFSET = 3 / 23;
22
23
 
23
24
  test("a copied picture is asked for one heuristic later than the keyframe", () => {
24
- const session = { transcodeVideo: false, keyframeTimes: [0, 2.002, 4.004, 6.006] };
25
+ const session = { transcodeVideo: false, file: new SourceFile({ sourceKey: "s", fileIndex: 0 }).learn({ keyframeTimes: [0, 2.002, 4.004, 6.006] }) };
25
26
  assert.equal(seekLandingOffsetFor(session, 2.002), OFFSET);
26
27
  });
27
28
 
28
29
  test("a re-encode is asked for exactly what it should produce", () => {
29
30
  // It decodes from the keyframe and discards frames up to the requested time,
30
31
  // so pushing the request later would start its output late.
31
- const session = { transcodeVideo: true, keyframeTimes: [0, 2.002, 4.004] };
32
+ const session = { transcodeVideo: true, file: new SourceFile({ sourceKey: "s", fileIndex: 0 }).learn({ keyframeTimes: [0, 2.002, 4.004] }) };
32
33
  assert.equal(seekLandingOffsetFor(session, 2.002), 0);
33
34
  });
34
35
 
35
36
  test("the offset never reaches the next keyframe", () => {
36
37
  // Keyframes 0.1 s apart: half of that is the most that can be added without
37
38
  // risking a landing on the NEXT one where the heuristic does not fire.
38
- const session = { transcodeVideo: false, keyframeTimes: [0, 0.1, 0.2, 0.3] };
39
+ const session = { transcodeVideo: false, file: new SourceFile({ sourceKey: "s", fileIndex: 0 }).learn({ keyframeTimes: [0, 0.1, 0.2, 0.3] }) };
39
40
  assert.equal(seekLandingOffsetFor(session, 0.1), 0.05);
40
41
  });
41
42
 
42
43
  test("the last keyframe has nothing after it to collide with", () => {
43
- const session = { transcodeVideo: false, keyframeTimes: [0, 2.002, 4.004] };
44
+ const session = { transcodeVideo: false, file: new SourceFile({ sourceKey: "s", fileIndex: 0 }).learn({ keyframeTimes: [0, 2.002, 4.004] }) };
44
45
  assert.equal(seekLandingOffsetFor(session, 4.004), OFFSET);
45
46
  });
46
47
 
@@ -57,8 +58,7 @@ test("a grid whose times are approximate is asked for that much later again", ()
57
58
  // the one before that, which is the fault this offset exists for.
58
59
  const session = {
59
60
  transcodeVideo: false,
60
- keyframeTolerance: 0.04,
61
- keyframeTimes: [0, 4.004, 8.008, 12.012]
61
+ file: new SourceFile({ sourceKey: "s", fileIndex: 0 }).learn({ keyframeTimes: [0, 4.004, 8.008, 12.012], keyframeTolerance: 0.04 })
62
62
  };
63
63
  assert.equal(seekLandingOffsetFor(session, 4.004), OFFSET + 0.04);
64
64
  });
@@ -66,11 +66,11 @@ test("a grid whose times are approximate is asked for that much later again", ()
66
66
  test("an exact grid claims no tolerance", () => {
67
67
  // Matroska and MP4 state instants outright — measured the same day, nine
68
68
  // files and 11 665 keyframes with not one disagreement.
69
- const session = { transcodeVideo: false, keyframeTolerance: 0, keyframeTimes: [0, 4.004, 8.008] };
69
+ const session = { transcodeVideo: false, file: new SourceFile({ sourceKey: "s", fileIndex: 0 }).learn({ keyframeTimes: [0, 4.004, 8.008], keyframeTolerance: 0 }) };
70
70
  assert.equal(seekLandingOffsetFor(session, 4.004), OFFSET);
71
71
  });
72
72
 
73
73
  test("the bound still holds once a tolerance is added", () => {
74
- const session = { transcodeVideo: false, keyframeTolerance: 1, keyframeTimes: [0, 0.1, 0.2] };
74
+ const session = { transcodeVideo: false, file: new SourceFile({ sourceKey: "s", fileIndex: 0 }).learn({ keyframeTimes: [0, 0.1, 0.2], keyframeTolerance: 1 }) };
75
75
  assert.equal(seekLandingOffsetFor(session, 0.1), 0.05);
76
76
  });
@@ -15,10 +15,13 @@
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 { mkdtemp, rm } from "node:fs/promises";
19
21
  import os from "node:os";
20
22
  import path from "node:path";
21
23
  import { HlsSessionManager } from "../services/hls-session-manager.js";
24
+ import { viewerOf } from "../services/viewer/Viewer.js";
22
25
  import { fmp4Format } from "../services/segment-formats/fmp4.js";
23
26
 
24
27
  const SESSION_ID = "aaaaaaaa-1111-2222-3333-444444444444";
@@ -42,19 +45,28 @@ async function managerAfterSeek() {
42
45
  manager.sessionsById.set(SESSION_ID, {
43
46
  id: SESSION_ID,
44
47
  dirPath,
48
+ // Where this file is cut, held by the file. A fixture that stated it
49
+ // on the session was describing what production no longer does.
50
+ timeline: new Timeline({
51
+ boundaries: Array.from({ length: 400 }, (_, index) => index * SEGMENT_SECONDS),
52
+ cutGrid: "uniform"
53
+ }),
45
54
  state: "ready",
46
- fileName: "film.mkv",
55
+ file: new SourceFile({ sourceKey: "source-1", fileIndex: 0, name: "film.mkv" }),
56
+ // An ordinary session reads its own file, and its sound is inside it. The
57
+ // three differ only for a soundtrack shipped as a file of its own.
58
+ get inputFile() { return this.file; },
59
+ get audioFile() { return this.file; },
47
60
  startedAt: Date.now(),
48
61
  lastAccessedAt: Date.now(),
49
62
  consumers: new Set(),
50
63
  segmentFormat: fmp4Format,
51
64
  transcodeVideo: false,
52
65
  useSyntheticPlaylist: true,
53
- segmentBoundaries: Array.from({ length: 400 }, (_, index) => index * SEGMENT_SECONDS),
54
66
  segmentCount: 399,
55
67
  encodeStartIndex: SEGMENT_AT_SEEK,
56
68
  waitEpoch: 1,
57
- viewerPositionSeconds: SEEK_TO_SECONDS,
69
+ furthestViewerSeconds: SEEK_TO_SECONDS,
58
70
  runState: "PRODUCING"
59
71
  });
60
72
  return { manager, dirPath };
@@ -128,16 +140,16 @@ test("a request as deep as the cushion the browser is told to hold is still want
128
140
  test("the viewer who made the request is the one it is judged against", async () => {
129
141
  const { manager, dirPath } = await managerAfterSeek();
130
142
  const session = manager.sessionsById.get(SESSION_ID);
131
- session.consumerHeads = new Map();
143
+ session.viewers = new Map();
132
144
  // Two people watching one copied picture. The shared position belongs to the
133
145
  // one in front — it is the furthest segment anybody asked for — and the one
134
146
  // behind is a hundred segments back, waiting for a segment there.
135
147
  const behind = SEGMENT_AT_SEEK - 100;
136
- session.consumerHeads.set("behind", {
148
+ viewerOf(session, "behind").head = {
137
149
  segment: behind,
138
150
  seconds: behind * SEGMENT_SECONDS,
139
151
  at: Date.now()
140
- });
152
+ };
141
153
 
142
154
  assert.equal(
143
155
  manager.requestStillWanted(SESSION_ID, `segment-${String(behind).padStart(5, "0")}.mp4`, "behind"),
@@ -156,13 +168,13 @@ test("the viewer who made the request is the one it is judged against", async ()
156
168
  test("a seek moves the seeking viewer's own head, and nobody else's", async () => {
157
169
  const { manager, dirPath } = await managerAfterSeek();
158
170
  const session = manager.sessionsById.get(SESSION_ID);
159
- session.consumerHeads = new Map();
171
+ session.viewers = new Map();
160
172
  const staying = SEGMENT_AT_SEEK - 40;
161
- session.consumerHeads.set("staying", {
173
+ viewerOf(session, "staying").head = {
162
174
  segment: staying,
163
175
  seconds: staying * SEGMENT_SECONDS,
164
176
  at: Date.now()
165
- });
177
+ };
166
178
 
167
179
  const jumpTo = 120;
168
180
  manager.requestSeek(SESSION_ID, jumpTo, "jumping");
@@ -0,0 +1,82 @@
1
+ /**
2
+ * @file What viewers want of an output, stated once each and read as a union.
3
+ */
4
+
5
+ import test from "node:test";
6
+ import assert from "node:assert/strict";
7
+ import { SegmentDemand } from "../services/encode/SegmentDemand.js";
8
+
9
+ const PICTURE = "torrent:abc:fmt=fmp4:grid=kf@0:video-only:v=0/copy";
10
+ const SOUND = "torrent:abc:fmt=fmp4:grid=kf@0:audio-only:a=0/1/copy";
11
+
12
+ test("a viewer restating a window replaces it rather than adding to it", () => {
13
+ // A player restates its window every few seconds. Accumulated, the demand
14
+ // would grow to the whole film within a minute.
15
+ const demand = new SegmentDemand();
16
+ demand.state({ claimant: "one", address: PICTURE, from: 0, to: 10, statedAt: 1 });
17
+ demand.state({ claimant: "one", address: PICTURE, from: 30, to: 40, statedAt: 2 });
18
+ assert.deepEqual(demand.spanOn(PICTURE), { from: 30, to: 40 });
19
+ assert.equal(demand.stats().windows, 1);
20
+ });
21
+
22
+ test("one viewer states a window per output, and both stand", () => {
23
+ const demand = new SegmentDemand();
24
+ demand.state({ claimant: "one", address: PICTURE, from: 0, to: 10, statedAt: 1 });
25
+ demand.state({ claimant: "one", address: SOUND, from: 0, to: 10, statedAt: 1 });
26
+ assert.equal(demand.stats().windows, 2);
27
+ assert.deepEqual(demand.addresses().sort(), [SOUND, PICTURE].sort());
28
+ });
29
+
30
+ test("two viewers of one output are a union, not a sum", () => {
31
+ // Two viewers seconds apart want mostly the same segments; counted twice, a
32
+ // stretch would look twice as wanted as it is.
33
+ const demand = new SegmentDemand();
34
+ demand.state({ claimant: "one", address: PICTURE, from: 10, to: 14, statedAt: 1 });
35
+ demand.state({ claimant: "two", address: PICTURE, from: 12, to: 16, statedAt: 1 });
36
+ assert.deepEqual(demand.wantedOn(PICTURE), [10, 11, 12, 13, 14, 15, 16]);
37
+ });
38
+
39
+ test("two viewers far apart make a span that covers the ground between them", () => {
40
+ // Not so it is all made — so that a search for a gap considers all of it.
41
+ const demand = new SegmentDemand();
42
+ demand.state({ claimant: "one", address: PICTURE, from: 0, to: 5, statedAt: 1 });
43
+ demand.state({ claimant: "two", address: PICTURE, from: 900, to: 905, statedAt: 1 });
44
+ assert.deepEqual(demand.spanOn(PICTURE), { from: 0, to: 905 });
45
+ });
46
+
47
+ test("a viewer who leaves takes every window they stated", () => {
48
+ const demand = new SegmentDemand();
49
+ demand.state({ claimant: "one", address: PICTURE, from: 0, to: 5, statedAt: 1 });
50
+ demand.state({ claimant: "one", address: SOUND, from: 0, to: 5, statedAt: 1 });
51
+ demand.state({ claimant: "two", address: PICTURE, from: 0, to: 5, statedAt: 1 });
52
+ assert.equal(demand.forget("one"), 2);
53
+ assert.equal(demand.stats().claimants, 1);
54
+ assert.equal(demand.addresses().length, 1);
55
+ });
56
+
57
+ test("expiry is carried out here and decided elsewhere", () => {
58
+ // The register has no clock on purpose: the rule that says how stale is too
59
+ // stale lives with whoever measures it, and this stays exercisable with
60
+ // numbers alone.
61
+ const demand = new SegmentDemand();
62
+ demand.state({ claimant: "one", address: PICTURE, from: 0, to: 5, statedAt: 1000 });
63
+ demand.state({ claimant: "two", address: PICTURE, from: 0, to: 5, statedAt: 9000 });
64
+ const dropped = demand.forgetStatedBefore(5000);
65
+ assert.equal(dropped.length, 1);
66
+ assert.equal(dropped[0].claimant, "one");
67
+ assert.equal(demand.stats().windows, 1);
68
+ });
69
+
70
+ test("a window that is not a window is refused rather than stored", () => {
71
+ const demand = new SegmentDemand();
72
+ assert.equal(demand.state({ claimant: "one", address: PICTURE, from: 5, to: 1, statedAt: 1 }), null);
73
+ assert.equal(demand.state({ claimant: "", address: PICTURE, from: 0, to: 1, statedAt: 1 }), null);
74
+ assert.equal(demand.state({ claimant: "one", address: "", from: 0, to: 1, statedAt: 1 }), null);
75
+ assert.equal(demand.stats().windows, 0);
76
+ });
77
+
78
+ test("nothing wanted on an output answers null rather than an empty span", () => {
79
+ const demand = new SegmentDemand();
80
+ assert.equal(demand.spanOn(PICTURE), null);
81
+ assert.deepEqual(demand.wantedOn(PICTURE), []);
82
+ });