@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,45 @@
1
+ /**
2
+ * @file h264_nvenc — NVIDIA's encoder.
3
+ */
4
+
5
+ import { Encoder } from "./Encoder.js";
6
+ import { keyFrameArgs, safeDimensions } from "./args.js";
7
+
8
+ export class NvencEncoder extends Encoder {
9
+ constructor() {
10
+ super({ name: "h264_nvenc", kind: "nvenc", device: null, inputArgs: [] });
11
+ }
12
+
13
+ /** @returns {import("./Encoder.js").SpeedLadder} */
14
+ get speedLadder() {
15
+ return {
16
+ flag: "-preset",
17
+ // NOT read from a live ffmpeg: the build on the addon host is ARM and
18
+ // carries no NVENC. `p1`…`p7` is NVENC's own ladder, `p1` fastest, so
19
+ // stated slowest first here to match every other kind. To be confirmed on
20
+ // a host that has one.
21
+ values: ["p7", "p6", "p5", "p4", "p3", "p2", "p1"],
22
+ measured: false,
23
+ note:
24
+ "Declared, not verified: no host here has an NVIDIA encoder to ask. " +
25
+ "`p4` is passed unconditionally by `buildVideoArgs` — one rung in the " +
26
+ "middle of a ladder nobody has measured."
27
+ };
28
+ }
29
+
30
+ // No fps filter: NVENC is fast and places keyframes by time-based
31
+ // -force_key_frames, so it inherits the exact source rate (fractional
32
+ // included) with no need to round or cap. Same rationale as VAAPI/QSV.
33
+ buildVideoArgs({ targetWidth, targetHeight, segmentDurationSec, forcedKeyframeTimes }) {
34
+ const { w, h } = safeDimensions(targetWidth, targetHeight);
35
+ return [
36
+ "-vf",
37
+ `scale=${w}:${h}:force_original_aspect_ratio=decrease:force_divisible_by=2`,
38
+ "-c:v", "h264_nvenc",
39
+ "-preset", "p4",
40
+ "-cq", "24",
41
+ "-pix_fmt", "yuv420p",
42
+ ...keyFrameArgs(segmentDurationSec, forcedKeyframeTimes)
43
+ ];
44
+ }
45
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * @file h264_qsv — Intel QuickSync.
3
+ */
4
+
5
+ import { Encoder } from "./Encoder.js";
6
+ import { keyFrameArgs, safeDimensions } from "./args.js";
7
+
8
+ export class QsvEncoder extends Encoder {
9
+ /**
10
+ * @param {string} device
11
+ */
12
+ constructor(device) {
13
+ super({
14
+ name: "h264_qsv",
15
+ kind: "qsv",
16
+ device,
17
+ inputArgs: ["-hwaccel", "qsv", "-qsv_device", device]
18
+ });
19
+ }
20
+
21
+ /** @returns {import("./Encoder.js").SpeedLadder} */
22
+ get speedLadder() {
23
+ return {
24
+ flag: "-preset",
25
+ // NOT read from a live ffmpeg: the build on the addon host is ARM and
26
+ // carries no QSV encoder, so there was nothing to ask. These are the
27
+ // names ffmpeg documents for `h264_qsv`, ordered slowest first, and they
28
+ // are to be confirmed against a host that actually has one before
29
+ // anything uses them.
30
+ values: ["veryslow", "slower", "slow", "medium", "fast", "faster", "veryfast"],
31
+ measured: false,
32
+ note:
33
+ "Declared, not verified: no host here has a QSV encoder to ask. " +
34
+ "Nothing passes a preset today, so a QSV encode runs at its default."
35
+ };
36
+ }
37
+
38
+ buildVideoArgs({ targetWidth, targetHeight, segmentDurationSec, forcedKeyframeTimes }) {
39
+ const { w, h } = safeDimensions(targetWidth, targetHeight);
40
+ return [
41
+ "-vf", `scale_qsv=w=${w}:h=${h}`,
42
+ "-c:v", "h264_qsv",
43
+ "-global_quality", "24",
44
+ ...keyFrameArgs(segmentDurationSec, forcedKeyframeTimes)
45
+ ];
46
+ }
47
+ }
Binary file
@@ -0,0 +1,529 @@
1
+ /**
2
+ * @file One store of produced segments for the whole proxy, addressed by what
3
+ * the segments ARE rather than by who made them.
4
+ *
5
+ * Until now a segment lived under the id of the session whose encoder wrote it
6
+ * — under that session's own id, in a directory of that run's own — and the index
7
+ * over it was built per session, so a segment was visible only inside the
8
+ * session that made it. Two viewers of one film got two sessions with
9
+ * byte-identical output and neither could see the other's work (measured
10
+ * 2026-09-03, `research/two-viewers-one-file-2026-09-03.md`).
11
+ *
12
+ * Here the address is the output's own parameters. Which viewer asked never
13
+ * enters it, and neither does which encoder produced the bytes: a segment is
14
+ * the same segment whoever made it.
15
+ *
16
+ * **The directory carries its own identity.** Its NAME is a digest, because the
17
+ * key contains characters a path may not; the key itself is written inside it,
18
+ * in `key.txt`. That is what lets a new process, started after this one was
19
+ * killed, work out what it is looking at — without it, everything on disk after
20
+ * a kill is unidentifiable and can only be thrown away.
21
+ *
22
+ * **What proves a segment is closed.** On the `hls` output branch ffmpeg
23
+ * renames a temporary file into place, so a file that exists is complete. On
24
+ * the `segment` branch — every copied picture and every rung forced onto the
25
+ * source's keyframes — it does not, and a file appears and grows. So the rule
26
+ * this store applies to the disk is the one the serving path has always used:
27
+ * **a segment is closed when the NEXT number exists.** The highest number in a
28
+ * directory is therefore the only unproven one, which is exactly the file a run
29
+ * killed mid-write leaves behind.
30
+ *
31
+ * **A live run does not need that rule.** While this process is alive the
32
+ * coverage map is told what has been closed as it happens; the disk rule is for
33
+ * what a previous life left behind, and for a run that died without saying so.
34
+ */
35
+
36
+ import { createHash } from "node:crypto";
37
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
38
+ import { rmSync } from "node:fs";
39
+ import os from "node:os";
40
+ import path from "node:path";
41
+
42
+ /** Where every output's segments live. One root for the process. */
43
+ export const DEFAULT_STORE_ROOT = path.join(os.tmpdir(), "torrent-tv-hls");
44
+
45
+ /** The file inside each directory that says which output it holds. */
46
+ const KEY_FILE = "key.txt";
47
+
48
+ /**
49
+ * The directory name for an output key.
50
+ *
51
+ * A digest rather than the key itself: the key carries `:` and `/`, which a
52
+ * path may not, and sanitising them would make two different keys collide.
53
+ * Sixteen hex characters is enough that a collision is not a thing that
54
+ * happens, and short enough to read in a log line.
55
+ *
56
+ * @param {string} key
57
+ * @returns {string}
58
+ */
59
+ export function directoryNameFor(key) {
60
+ return createHash("sha256").update(String(key)).digest("hex").slice(0, 16);
61
+ }
62
+
63
+ /**
64
+ * What one output's directory holds, as last read.
65
+ *
66
+ * @typedef {object} HeldContents
67
+ * @property {number} readAt - The directory's modification time when it was read.
68
+ * @property {Map<number, string>} byNumber - Segment number to full path.
69
+ * @property {number} bytes - What those files weigh.
70
+ * @property {number} unproven - The highest number, whose closure nothing
71
+ * proves, or -1 when the directory holds no segments.
72
+ */
73
+
74
+ export class SegmentStore {
75
+ /** @type {string} */
76
+ #root;
77
+
78
+ /** Output key → what its directory holds. @type {Map<string, HeldContents>} */
79
+ #held = new Map();
80
+
81
+ /** Output key → how to read its file names. @type {Map<string, object>} */
82
+ #formats = new Map();
83
+
84
+ /** Output key → when it was last asked for. @type {Map<string, number>} */
85
+ #touched = new Map();
86
+
87
+ /**
88
+ * Numbers known closed for a reason other than a successor on the disk.
89
+ *
90
+ * Two things fill it. A live run says what it has finished as it finishes it.
91
+ * And adoption records what the successor rule proved BEFORE it removes the
92
+ * unproven piece — otherwise removing that piece would un-prove the segment
93
+ * below it, which is a file that was demonstrably closed a moment earlier.
94
+ *
95
+ * @type {Map<string, Set<number>>}
96
+ */
97
+ #closed = new Map();
98
+
99
+ /** @type {{ info: Function, warn: Function }} */
100
+ #logger;
101
+
102
+ /** @type {() => number} */
103
+ #now;
104
+
105
+ /**
106
+ * @param {object} [params]
107
+ * @param {string} [params.root] - Where the directories live.
108
+ * @param {{ info: Function, warn: Function }} [params.logger]
109
+ * @param {() => number} [params.now]
110
+ */
111
+ constructor({ root = DEFAULT_STORE_ROOT, logger = null, now = Date.now } = {}) {
112
+ this.#root = root;
113
+ this.#logger = logger ?? { info: () => {}, warn: () => {} };
114
+ this.#now = now;
115
+ }
116
+
117
+ /** @returns {string} */
118
+ get root() {
119
+ return this.#root;
120
+ }
121
+
122
+ /**
123
+ * Where this output's segments live, without making anything.
124
+ *
125
+ * Separate from {@link directoryFor} because a session works out its path
126
+ * long before it is sure it will exist: a probe or a keyframe read between
127
+ * the two can still fail, and a directory made in advance of that is a
128
+ * leftover nothing tracks — proxy 2.9.101 failed on every request and its
129
+ * abandoned directories were the only trace on disk.
130
+ *
131
+ * @param {string} key - `OutputSpec.toKey()`.
132
+ * @returns {string}
133
+ */
134
+ pathFor(key) {
135
+ return path.join(this.#root, directoryNameFor(key));
136
+ }
137
+
138
+ /**
139
+ * The directory this output's segments live in, made if it is not there.
140
+ *
141
+ * @param {string} key - `OutputSpec.toKey()`.
142
+ * @returns {string}
143
+ */
144
+ directoryFor(key) {
145
+ const dir = path.join(this.#root, directoryNameFor(key));
146
+ if (!existsSync(dir)) {
147
+ mkdirSync(dir, { recursive: true });
148
+ // What this directory is, for whoever finds it after this process has
149
+ // been killed. Without it the sweep can only throw everything away.
150
+ writeFileSync(path.join(dir, KEY_FILE), `${key}\n`, "utf8");
151
+ }
152
+ this.#touched.set(key, this.#now());
153
+ return dir;
154
+ }
155
+
156
+ /**
157
+ * Say how this output's files are named, so the store can read its directory.
158
+ *
159
+ * @param {string} key
160
+ * @param {{ isSegmentFileName: (name: string) => boolean, segmentIndexFromName: (name: string) => number }} segmentFormat
161
+ */
162
+ useFormat(key, segmentFormat) {
163
+ if (segmentFormat && typeof segmentFormat.isSegmentFileName === "function") {
164
+ this.#formats.set(key, segmentFormat);
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Re-read this output's directory if it has moved since last time.
170
+ *
171
+ * A directory's modification time changes when an entry is added or removed,
172
+ * so a quiet request costs one `stat` rather than a listing.
173
+ *
174
+ * @param {string} key
175
+ * @returns {HeldContents}
176
+ */
177
+ refresh(key) {
178
+ const format = this.#formats.get(key);
179
+ const empty = { readAt: 0, byNumber: new Map(), bytes: 0, unproven: -1 };
180
+ if (!format) {
181
+ return this.#held.get(key) ?? empty;
182
+ }
183
+ const dir = path.join(this.#root, directoryNameFor(key));
184
+ let mtime = 0;
185
+ try {
186
+ mtime = statSync(dir).mtimeMs;
187
+ } catch {
188
+ this.#held.delete(key);
189
+ return empty;
190
+ }
191
+ const known = this.#held.get(key);
192
+ if (known && known.readAt === mtime) {
193
+ return known;
194
+ }
195
+ const byNumber = new Map();
196
+ let bytes = 0;
197
+ let highest = -1;
198
+ try {
199
+ for (const name of readdirSync(dir)) {
200
+ if (!format.isSegmentFileName(name)) {
201
+ continue;
202
+ }
203
+ const index = format.segmentIndexFromName(name);
204
+ if (!Number.isInteger(index) || index < 0) {
205
+ continue;
206
+ }
207
+ const full = path.join(dir, name);
208
+ let size = 0;
209
+ try {
210
+ size = statSync(full).size;
211
+ } catch {
212
+ continue;
213
+ }
214
+ // A file of no bytes is not a segment, whatever its name says. It is
215
+ // what a run killed the instant after opening its next piece leaves,
216
+ // and taking it for a segment once convinced the look-ahead that a
217
+ // number had been produced and kept the encoder stopped for it.
218
+ if (size <= 0) {
219
+ continue;
220
+ }
221
+ byNumber.set(index, full);
222
+ bytes += size;
223
+ if (index > highest) {
224
+ highest = index;
225
+ }
226
+ }
227
+ } catch {
228
+ this.#held.delete(key);
229
+ return empty;
230
+ }
231
+ const contents = { readAt: mtime, byNumber, bytes, unproven: highest };
232
+ this.#held.set(key, contents);
233
+ return contents;
234
+ }
235
+
236
+ /**
237
+ * The segment numbers this output holds that are PROVEN closed.
238
+ *
239
+ * The proof is the successor: a segment ffmpeg has moved past is finished,
240
+ * whatever branch wrote it. The highest number is left out, because nothing
241
+ * on the disk distinguishes a finished last segment from one that was being
242
+ * written when its run died.
243
+ *
244
+ * @param {string} key
245
+ * @returns {number[]}
246
+ */
247
+ provenNumbers(key) {
248
+ const contents = this.refresh(key);
249
+ const stated = this.#closed.get(key);
250
+ const proven = [];
251
+ for (const index of contents.byNumber.keys()) {
252
+ if (contents.byNumber.has(index + 1) || stated?.has(index)) {
253
+ proven.push(index);
254
+ }
255
+ }
256
+ return proven.sort((left, right) => left - right);
257
+ }
258
+
259
+ /**
260
+ * Say that a segment is closed for a reason the disk cannot show.
261
+ *
262
+ * A run reports what it has finished; the successor rule is only for what
263
+ * this process did not watch being written.
264
+ *
265
+ * @param {string} key
266
+ * @param {number} index
267
+ */
268
+ markClosed(key, index) {
269
+ if (!Number.isInteger(index) || index < 0) {
270
+ return;
271
+ }
272
+ let known = this.#closed.get(key);
273
+ if (!known) {
274
+ known = new Set();
275
+ this.#closed.set(key, known);
276
+ }
277
+ known.add(index);
278
+ }
279
+
280
+ /**
281
+ * The one number in this output whose closure nothing on disk proves.
282
+ *
283
+ * @param {string} key
284
+ * @returns {number} -1 when the directory holds no segments.
285
+ */
286
+ unprovenNumber(key) {
287
+ return this.refresh(key).unproven;
288
+ }
289
+
290
+ /**
291
+ * Where a segment is, or null when this output does not hold it.
292
+ *
293
+ * @param {string} key
294
+ * @param {number} index
295
+ * @returns {string | null}
296
+ */
297
+ pathOf(key, index) {
298
+ this.#touched.set(key, this.#now());
299
+ return this.refresh(key).byNumber.get(index) ?? null;
300
+ }
301
+
302
+ /**
303
+ * What every output in the store weighs.
304
+ *
305
+ * @returns {{ outputs: number, bytes: number }}
306
+ */
307
+ stats() {
308
+ let bytes = 0;
309
+ for (const key of this.#formats.keys()) {
310
+ bytes += this.refresh(key).bytes;
311
+ }
312
+ return { outputs: this.#formats.size, bytes };
313
+ }
314
+
315
+ /**
316
+ * Throw one output's segments away.
317
+ *
318
+ * @param {string} key
319
+ * @param {string} because
320
+ */
321
+ drop(key, because) {
322
+ const dir = path.join(this.#root, directoryNameFor(key));
323
+ try {
324
+ rmSync(dir, { recursive: true, force: true });
325
+ } catch {
326
+ // Already gone, or in use; the next sweep sees it either way.
327
+ }
328
+ this.#held.delete(key);
329
+ this.#formats.delete(key);
330
+ this.#touched.delete(key);
331
+ this.#closed.delete(key);
332
+ this.#logger.info(`segment-store dropped ${directoryNameFor(key)} (${because})`);
333
+ }
334
+
335
+ /**
336
+ * Keep only what is still being read, and only as much of it as there is room
337
+ * for.
338
+ *
339
+ * **Not tied to a session.** An output is worth keeping while somebody may
340
+ * still ask for it, and a session ending says nothing about that: the viewer
341
+ * who left may come back, and a viewer who never had a session here may open
342
+ * the same film a minute later and find every segment already made. So the
343
+ * only question asked is when this output was last READ, and the only bound
344
+ * is the disk.
345
+ *
346
+ * The idle period is deliberately long. Its job is not to reclaim space —
347
+ * that is the cap's — but to stop an output nobody has touched in hours from
348
+ * sitting there for the life of the process.
349
+ *
350
+ * @param {object} params
351
+ * @param {number} params.idleMs - Untouched for longer than this, and it goes.
352
+ * @param {number} params.maxBytes - The most the whole store may hold. What
353
+ * was read longest ago goes first.
354
+ * @returns {{ droppedIdle: number, droppedForRoom: number, bytes: number }}
355
+ */
356
+ enforce({ idleMs, maxBytes }) {
357
+ const now = this.#now();
358
+ let droppedIdle = 0;
359
+ for (const [key, touchedAt] of [...this.#touched]) {
360
+ if (now - touchedAt > idleMs) {
361
+ this.drop(key, `nothing has read it for ${Math.round((now - touchedAt) / 60000)} minutes`);
362
+ droppedIdle += 1;
363
+ }
364
+ }
365
+ let droppedForRoom = 0;
366
+ let held = this.stats().bytes;
367
+ if (Number.isFinite(maxBytes) && maxBytes > 0 && held > maxBytes) {
368
+ // Least recently read first: what nobody has asked for in the longest
369
+ // time is what a viewer is least likely to want next.
370
+ const byAge = [...this.#touched.entries()].sort((left, right) => left[1] - right[1]);
371
+ for (const [key] of byAge) {
372
+ if (held <= maxBytes) {
373
+ break;
374
+ }
375
+ const size = this.refresh(key).bytes;
376
+ this.drop(key, `the store is over its ${(maxBytes / 1073741824).toFixed(1)}GB allowance`);
377
+ held -= size;
378
+ droppedForRoom += 1;
379
+ }
380
+ }
381
+ return { droppedIdle, droppedForRoom, bytes: held };
382
+ }
383
+
384
+ /**
385
+ * What a previous life of this process left on the disk.
386
+ *
387
+ * This is the only record there is of a death nobody saw. The kernel kills
388
+ * this process often enough to matter — two kills in one viewing on
389
+ * 2026-09-02 — and when it does, no exit handler runs, nothing is cleared up,
390
+ * and memory is reclaimed while the disk is not: `/tmp` in the addon
391
+ * container is on the overlay filesystem, measured 2026-09-04, so the files
392
+ * survive the process and its restart.
393
+ *
394
+ * So the sweep reports rather than deletes quietly. What it finds is the
395
+ * evidence, and every directory it names is one abnormal ending that went
396
+ * unrecorded.
397
+ *
398
+ * @returns {{ directories: number, segments: number, bytes: number, unidentified: number, found: {key: string, dir: string, segments: number, bytes: number}[] }}
399
+ */
400
+ sweep() {
401
+ const found = [];
402
+ let unidentified = 0;
403
+ let names = [];
404
+ try {
405
+ names = readdirSync(this.#root);
406
+ } catch {
407
+ return { directories: 0, segments: 0, bytes: 0, unidentified: 0, found: [] };
408
+ }
409
+ for (const name of names) {
410
+ const dir = path.join(this.#root, name);
411
+ let key = "";
412
+ try {
413
+ if (!statSync(dir).isDirectory()) {
414
+ continue;
415
+ }
416
+ key = readFileSync(path.join(dir, KEY_FILE), "utf8").trim();
417
+ } catch {
418
+ key = "";
419
+ }
420
+ let segments = 0;
421
+ let bytes = 0;
422
+ try {
423
+ for (const entry of readdirSync(dir)) {
424
+ if (entry === KEY_FILE) {
425
+ continue;
426
+ }
427
+ try {
428
+ bytes += statSync(path.join(dir, entry)).size;
429
+ segments += 1;
430
+ } catch {
431
+ // Vanished between the listing and the question.
432
+ }
433
+ }
434
+ } catch {
435
+ continue;
436
+ }
437
+ if (!key) {
438
+ // A directory that cannot say what it holds is from before this layer,
439
+ // or its key file did not survive. Nothing can be served out of it,
440
+ // because nothing can match it to a request.
441
+ unidentified += 1;
442
+ }
443
+ found.push({ key, dir, segments, bytes });
444
+ }
445
+ const totals = found.reduce(
446
+ (sum, entry) => ({ segments: sum.segments + entry.segments, bytes: sum.bytes + entry.bytes }),
447
+ { segments: 0, bytes: 0 }
448
+ );
449
+ if (found.length > 0) {
450
+ this.#logger.info(
451
+ `segment-store startup sweep: ${found.length} directories left by a previous run, ` +
452
+ `${totals.segments} segments, ${(totals.bytes / 1048576).toFixed(1)}MB, ` +
453
+ `${unidentified} of them unidentifiable — each one is an encoder that ended ` +
454
+ "without anything recording why"
455
+ );
456
+ }
457
+ return {
458
+ directories: found.length,
459
+ segments: totals.segments,
460
+ bytes: totals.bytes,
461
+ unidentified,
462
+ found
463
+ };
464
+ }
465
+
466
+ /**
467
+ * Take back what a previous life left: keep what is proven, remove the rest.
468
+ *
469
+ * Deliberately not "throw everything away". A killed process leaves material
470
+ * that is valid by construction — a copied segment's bytes depend only on the
471
+ * source — and re-encoding it costs the machine that is already known to be
472
+ * short of processor. What cannot be kept is a directory that cannot name
473
+ * itself, and the one file per directory whose closure nothing proves.
474
+ *
475
+ * @param {(key: string) => object | null} formatFor - How to read the file
476
+ * names of an output, given its key. Null when this proxy cannot serve that
477
+ * output at all, and then the directory goes.
478
+ * @returns {{ adopted: number, dropped: number, unprovenRemoved: number }}
479
+ */
480
+ adoptWhatSurvived(formatFor) {
481
+ const swept = this.sweep();
482
+ let adopted = 0;
483
+ let dropped = 0;
484
+ let unprovenRemoved = 0;
485
+ for (const entry of swept.found) {
486
+ const format = entry.key ? formatFor(entry.key) : null;
487
+ if (!format) {
488
+ try {
489
+ rmSync(entry.dir, { recursive: true, force: true });
490
+ } catch {
491
+ // Leave it; the next sweep reports it again.
492
+ }
493
+ dropped += 1;
494
+ this.#logger.info(
495
+ `segment-store discarded ${path.basename(entry.dir)}: ` +
496
+ (entry.key ? "this proxy cannot serve that output" : "it does not say what it holds")
497
+ );
498
+ continue;
499
+ }
500
+ this.#formats.set(entry.key, format);
501
+ // Recorded BEFORE the unproven piece goes: taking that file away would
502
+ // otherwise leave the segment below it without a successor, and a file
503
+ // that was demonstrably closed a moment ago would stop being servable.
504
+ for (const index of this.provenNumbers(entry.key)) {
505
+ this.markClosed(entry.key, index);
506
+ }
507
+ const unproven = this.unprovenNumber(entry.key);
508
+ if (unproven >= 0) {
509
+ const held = this.refresh(entry.key);
510
+ const filePath = held.byNumber.get(unproven);
511
+ if (filePath) {
512
+ try {
513
+ rmSync(filePath, { force: true });
514
+ unprovenRemoved += 1;
515
+ } catch {
516
+ // Then it stays unproven and is simply never served.
517
+ }
518
+ }
519
+ this.#held.delete(entry.key);
520
+ }
521
+ adopted += 1;
522
+ this.#logger.info(
523
+ `segment-store adopted ${path.basename(entry.dir)}: ${this.provenNumbers(entry.key).length} ` +
524
+ `segments a killed process had already made, ${unproven >= 0 ? "1" : "no"} unfinished piece removed`
525
+ );
526
+ }
527
+ return { adopted, dropped, unprovenRemoved };
528
+ }
529
+ }