@torrent-tv/proxy 2.80.13 → 2.80.15

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.
@@ -1,718 +1,743 @@
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
- import { discardOpenPiece } from "./open-piece.js";
43
-
44
- /** Where every output's segments live. One root for the process. */
45
- export const DEFAULT_STORE_ROOT = path.join(os.tmpdir(), "torrent-tv-hls");
46
-
47
- /** The file inside each directory that says which output it holds. */
48
- const KEY_FILE = "key.txt";
49
-
50
- /**
51
- * The directory name for an output key.
52
- *
53
- * A digest rather than the key itself: the key carries `:` and `/`, which a
54
- * path may not, and sanitising them would make two different keys collide.
55
- * Sixteen hex characters is enough that a collision is not a thing that
56
- * happens, and short enough to read in a log line.
57
- *
58
- * @param {string} key
59
- * @returns {string}
60
- */
61
- export function directoryNameFor(key) {
62
- return createHash("sha256").update(String(key)).digest("hex").slice(0, 16);
63
- }
64
-
65
- /**
66
- * What one output's directory holds, as last read.
67
- *
68
- * @typedef {object} HeldContents
69
- * @property {number} readAt - The directory's modification time when it was read.
70
- * @property {Map<number, string>} byNumber - Segment number to full path.
71
- * @property {number} bytes - What those files weigh.
72
- * @property {number} unproven - The highest number, whose closure nothing
73
- * proves, or -1 when the directory holds no segments.
74
- */
75
-
76
- export class SegmentStore {
77
- /** @type {string} */
78
- #root;
79
-
80
- /** Output key → what its directory holds. @type {Map<string, HeldContents>} */
81
- #held = new Map();
82
-
83
- /** Output key → how to read its file names. @type {Map<string, object>} */
84
- #formats = new Map();
85
-
86
- /** Output key → when it was last asked for. @type {Map<string, number>} */
87
- #touched = new Map();
88
-
89
- /**
90
- * Numbers known closed for a reason other than a successor on the disk.
91
- *
92
- * Two things fill it. A live run says what it has finished as it finishes it.
93
- * And adoption records what the successor rule proved BEFORE it removes the
94
- * unproven piece — otherwise removing that piece would un-prove the segment
95
- * below it, which is a file that was demonstrably closed a moment earlier.
96
- *
97
- * @type {Map<string, Set<number>>}
98
- */
99
- #closed = new Map();
100
-
101
- /** Pieces already reported as taken on the successor rule, so one is said
102
- * once. @type {Map<string, Set<number>>} */
103
- #unreportedSaid = new Map();
104
-
105
- /** @type {{ info: Function, warn: Function }} */
106
- #logger;
107
-
108
- /** @type {() => number} */
109
- #now;
110
-
111
- /**
112
- * @param {object} [params]
113
- * @param {string} [params.root] - Where the directories live.
114
- * @param {{ info: Function, warn: Function }} [params.logger]
115
- * @param {() => number} [params.now]
116
- */
117
- constructor({ root = DEFAULT_STORE_ROOT, logger = null, now = Date.now } = {}) {
118
- this.#root = root;
119
- this.#logger = logger ?? { info: () => {}, warn: () => {} };
120
- this.#now = now;
121
- }
122
-
123
- /** @returns {string} */
124
- get root() {
125
- return this.#root;
126
- }
127
-
128
- /**
129
- * Where this output's segments live, without making anything.
130
- *
131
- * Separate from {@link directoryFor} because a session works out its path
132
- * long before it is sure it will exist: a probe or a keyframe read between
133
- * the two can still fail, and a directory made in advance of that is a
134
- * leftover nothing tracks — proxy 2.9.101 failed on every request and its
135
- * abandoned directories were the only trace on disk.
136
- *
137
- * @param {string} key - `OutputSpec.toKey()`.
138
- * @returns {string}
139
- */
140
- pathFor(key) {
141
- return path.join(this.#root, directoryNameFor(key));
142
- }
143
-
144
- /**
145
- * The directory this output's segments live in, made if it is not there.
146
- *
147
- * @param {string} key - `OutputSpec.toKey()`.
148
- * @returns {string}
149
- */
150
- directoryFor(key) {
151
- const dir = path.join(this.#root, directoryNameFor(key));
152
- if (!existsSync(dir)) {
153
- mkdirSync(dir, { recursive: true });
154
- // What this directory is, for whoever finds it after this process has
155
- // been killed. Without it the sweep can only throw everything away.
156
- writeFileSync(path.join(dir, KEY_FILE), `${key}\n`, "utf8");
157
- }
158
- this.#touched.set(key, this.#now());
159
- return dir;
160
- }
161
-
162
- /**
163
- * Say how this output's files are named, so the store can read its directory.
164
- *
165
- * @param {string} key
166
- * @param {{ isSegmentFileName: (name: string) => boolean, segmentIndexFromName: (name: string) => number }} segmentFormat
167
- */
168
- useFormat(key, segmentFormat) {
169
- if (segmentFormat && typeof segmentFormat.isSegmentFileName === "function") {
170
- this.#formats.set(key, segmentFormat);
171
- }
172
- }
173
-
174
- /**
175
- * Re-read this output's directory if it has moved since last time.
176
- *
177
- * A directory's modification time changes when an entry is added or removed,
178
- * so a quiet request costs one `stat` rather than a listing.
179
- *
180
- * @param {string} key
181
- * @returns {HeldContents}
182
- */
183
- refresh(key) {
184
- const format = this.#formats.get(key);
185
- const empty = { readAt: 0, byNumber: new Map(), bytes: 0, unproven: -1 };
186
- if (!format) {
187
- return this.#held.get(key) ?? empty;
188
- }
189
- const dir = path.join(this.#root, directoryNameFor(key));
190
- let mtime = 0;
191
- try {
192
- mtime = statSync(dir).mtimeMs;
193
- } catch {
194
- this.#held.delete(key);
195
- return empty;
196
- }
197
- const known = this.#held.get(key);
198
- if (known && known.readAt === mtime) {
199
- return known;
200
- }
201
- const byNumber = new Map();
202
- let bytes = 0;
203
- let highest = -1;
204
- try {
205
- for (const name of readdirSync(dir)) {
206
- if (!format.isSegmentFileName(name)) {
207
- continue;
208
- }
209
- const index = format.segmentIndexFromName(name);
210
- if (!Number.isInteger(index) || index < 0) {
211
- continue;
212
- }
213
- const full = path.join(dir, name);
214
- let size = 0;
215
- try {
216
- size = statSync(full).size;
217
- } catch {
218
- continue;
219
- }
220
- // A file of no bytes is not a segment, whatever its name says. It is
221
- // what a run killed the instant after opening its next piece leaves,
222
- // and taking it for a segment once convinced the look-ahead that a
223
- // number had been produced and kept the encoder stopped for it.
224
- if (size <= 0) {
225
- continue;
226
- }
227
- byNumber.set(index, full);
228
- bytes += size;
229
- if (index > highest) {
230
- highest = index;
231
- }
232
- }
233
- } catch {
234
- this.#held.delete(key);
235
- return empty;
236
- }
237
- const contents = { readAt: mtime, byNumber, bytes, unproven: highest };
238
- this.#held.set(key, contents);
239
- return contents;
240
- }
241
-
242
- /**
243
- * The segment numbers this output holds that are PROVEN closed.
244
- *
245
- * Two proofs, and they answer for the two ways this proxy writes segments.
246
- *
247
- * 1. **the writer said so** the `segment` muxer names each file on a channel
248
- * of its own the moment it closes it;
249
- * 2. **the next file exists** — which is all there is for the `hls` muxer,
250
- * which carries no such channel at all. That branch writes under a
251
- * temporary name and renames on close, so a file that exists is whole by
252
- * construction, and it is also what proves the pieces a previous life of
253
- * this process left behind.
254
- *
255
- * The highest number is left out either way: nothing distinguishes a finished
256
- * last piece from one that was being written when its run died.
257
- *
258
- * WHAT THE SECOND PROOF CANNOT ANSWER, and is left open deliberately: a piece
259
- * a LIVE RUN IS REWRITING. Several runs share one directory, so a file left by
260
- * an earlier one is a successor to a name the run working now has just
261
- * reopened, and the disk cannot know the difference. Whether that has ever
262
- * moved a decision is not established from any log we hold, and every remedy
263
- * for it changes what "ready" means for five readers with different questions
264
- * — so it waits for a session that shows it, rather than being guessed at.
265
- *
266
- * @param {string} key
267
- * @returns {number[]}
268
- */
269
- provenNumbers(key) {
270
- const contents = this.refresh(key);
271
- const stated = this.#closed.get(key);
272
- const proven = [];
273
- for (const index of contents.byNumber.keys()) {
274
- if (contents.byNumber.has(index + 1) || stated?.has(index)) {
275
- proven.push(index);
276
- }
277
- }
278
- return proven.sort((left, right) => left - right);
279
- }
280
-
281
- /**
282
- * How many numbered files this output holds, closed or not.
283
- *
284
- * What the disk has, against what has been proven closed: the two are printed
285
- * side by side, so "the files are there and nobody reported them" reads
286
- * differently from "there is nothing there".
287
- *
288
- * @param {string} key
289
- * @returns {number}
290
- */
291
- filesHeld(key) {
292
- return this.refresh(key).byNumber.size;
293
- }
294
-
295
- /**
296
- * A run is about to write these numbers again: forget that they were closed.
297
- *
298
- * A number closed once is not closed for ever. An encoder started at #N
299
- * rewrites #N and everything after it, and while it is doing so the file
300
- * under that name is half a segment — but the store remembered the earlier
301
- * closing and would call it whole. Field 2026-09-05: seventeen runs were
302
- * stopped and none ended normally, so numbers were being rewritten
303
- * constantly, and the player met a fatal append error it never recovered
304
- * from — an empty picture for the six minutes that followed.
305
- *
306
- * @param {string} key
307
- * @param {number} from - First number the run will write.
308
- * @param {number} [to] - Last one, inclusive. Infinite for a run given no end,
309
- * which does walk to the end of the film.
310
- */
311
- forgetClosed(key, from, to = Number.POSITIVE_INFINITY) {
312
- const known = this.#closed.get(key);
313
- if (!known || !Number.isInteger(from)) {
314
- return;
315
- }
316
- // BOUNDED BY THE RUN'S OWN STRETCH, because that is what it will rewrite.
317
- //
318
- // It used to forget everything from `from` upwards, on the reading that a
319
- // run has no end — which was true until runs were given intervals. A run of
320
- // #0..#0 then unproved the whole rest of the film, and with readiness a
321
- // projection of what is proven that is an output declaring itself unmade
322
- // every time an encoder starts anywhere near the beginning.
323
- const last = Number.isFinite(to) ? Math.max(from, Math.trunc(to)) : Number.POSITIVE_INFINITY;
324
- for (const index of known) {
325
- if (index >= from && index <= last) {
326
- known.delete(index);
327
- }
328
- }
329
- // What the directory says has to be read again too, so that the size of a
330
- // reopened piece is the size it has now and not the one it had before.
331
- this.#held.delete(key);
332
- }
333
-
334
- /**
335
- * Whether this piece is finished, and may therefore be served.
336
- *
337
- * Two proofs, and the first is the good one:
338
- *
339
- * 1. **the encoder said so** — the `segment` muxer names each file on a
340
- * channel of its own the moment it closes it, so the name is the writer's
341
- * own statement that the piece is whole;
342
- * 2. **the next file exists** — the only proof available on the `hls` branch,
343
- * which has no such channel, and for pieces left by an earlier life of this
344
- * process. On that branch it is sound: the muxer renames into place on
345
- * close, so a file that exists is finished.
346
- *
347
- * KNOWN AND LEFT ALONE HERE: on the `segment` branch this second proof can
348
- * still pass a piece a run is halfway through rewriting, which is how 110 698
349
- * bytes came to be served under a name whose neighbours are 12 MB (field
350
- * 2026-09-06). Telling the two branches apart is a fact of how a run writes,
351
- * it needs a field session of its own to verify, and it is not what stopped
352
- * playback on 2026-09-07 — so it stays open rather than being changed blind in
353
- * the path that hands bytes to a player. What the PLAN believes is a different
354
- * question and is answered: a live run's claim outranks readiness there.
355
- *
356
- * @param {string} key
357
- * @param {number} index
358
- * @returns {boolean}
359
- */
360
- isClosed(key, index) {
361
- if (this.#closed.get(key)?.has(index)) {
362
- return true;
363
- }
364
- const bySuccessor = this.refresh(key).byNumber.has(index + 1);
365
- if (bySuccessor) {
366
- this.#noteUnreported(key, index);
367
- }
368
- return bySuccessor;
369
- }
370
-
371
- /**
372
- * A piece taken as finished because the NEXT one exists, with nothing from a
373
- * run to say so.
374
- *
375
- * The successor rule is for what this process did not watch being written
376
- * pieces from a previous life of it. It is also the one way an unfinished
377
- * piece can be served: a file that stops short still has a successor if
378
- * anything wrote one, and then its name promises a whole span while it holds
379
- * a fraction. Field 2026-09-06: 110 698 bytes served under a name whose
380
- * neighbours are 12 MB, 40 ms of film where the playlist declared 10.4 s, and
381
- * the player jumped the hole it left.
382
- *
383
- * That cannot arise from two encoders any more — their stretches no longer
384
- * overlap — so what is left is a piece from a process that died without
385
- * clearing up. Said once per piece, with its size, so a return of it is
386
- * visible rather than inferred.
387
- *
388
- * @param {string} key
389
- * @param {number} index
390
- */
391
- #noteUnreported(key, index) {
392
- let said = this.#unreportedSaid.get(key);
393
- if (!said) {
394
- said = new Set();
395
- this.#unreportedSaid.set(key, said);
396
- }
397
- if (said.has(index)) {
398
- return;
399
- }
400
- said.add(index);
401
- let bytes = -1;
402
- try {
403
- bytes = statSync(this.pathOf(key, index)).size;
404
- } catch {
405
- // Gone between the listing and this: nothing to report about it.
406
- return;
407
- }
408
- this.#logger?.info?.(
409
- `segment store: #${index} of ${key} is taken as finished because ` +
410
- `#${index + 1} exists no run reported it (${bytes} bytes). Expected only for ` +
411
- "pieces left by a previous life of this process."
412
- );
413
- }
414
-
415
- /**
416
- * Say that a segment is closed for a reason the disk cannot show.
417
- *
418
- * A run reports what it has finished; the successor rule is only for what
419
- * this process did not watch being written.
420
- *
421
- * @param {string} key
422
- * @param {number} index
423
- */
424
- markClosed(key, index) {
425
- if (!Number.isInteger(index) || index < 0) {
426
- return;
427
- }
428
- let known = this.#closed.get(key);
429
- if (!known) {
430
- known = new Set();
431
- this.#closed.set(key, known);
432
- }
433
- known.add(index);
434
- }
435
-
436
- /**
437
- * Throw away the piece a run had open when it ended, if it is unusable.
438
- *
439
- * The store owns this output's directory and knows how its files are named,
440
- * so it is the one place that can answer which file a run left open. The
441
- * judging of a NON-EMPTY file does it carry every track it should — needs
442
- * the output's init bytes and belongs to whoever holds them; passed in, and
443
- * absent it only an empty file is removed, which is the case that caused this
444
- * to be written (a run stopped 548 ms after starting left a zero-byte file
445
- * whose name then read as a segment made).
446
- *
447
- * @param {string} key
448
- * @param {{ from: number, to: number } | null} within - The run's own
449
- * numbers: several runs write into one directory, so the piece to discard
450
- * has to be looked for inside the stretch the ended run was given.
451
- * @param {((raw: Buffer) => boolean) | null} [judgeUsable]
452
- * @returns {Promise<number | null>} The segment number removed, or null.
453
- */
454
- async discardOpenPieceOf(key, within, judgeUsable = null, provenName = null) {
455
- const format = this.#formats.get(key);
456
- if (!format) {
457
- return null;
458
- }
459
- const removed = await discardOpenPiece(this.directoryFor(key), format, within, judgeUsable, provenName);
460
- if (removed !== null) {
461
- this.#held.delete(key);
462
- this.#logger?.info?.(
463
- `segment store: discarded the open piece #${removed} of ${key.slice(0, 60)}`
464
- );
465
- }
466
- return removed;
467
- }
468
-
469
- /**
470
- * The one number in this output whose closure nothing on disk proves.
471
- *
472
- * @param {string} key
473
- * @returns {number} -1 when the directory holds no segments.
474
- */
475
- unprovenNumber(key) {
476
- return this.refresh(key).unproven;
477
- }
478
-
479
- /**
480
- * Where a segment is, or null when this output does not hold it.
481
- *
482
- * @param {string} key
483
- * @param {number} index
484
- * @returns {string | null}
485
- */
486
- pathOf(key, index) {
487
- this.#touched.set(key, this.#now());
488
- return this.refresh(key).byNumber.get(index) ?? null;
489
- }
490
-
491
- /**
492
- * What every output in the store weighs.
493
- *
494
- * @returns {{ outputs: number, bytes: number }}
495
- */
496
- stats() {
497
- let bytes = 0;
498
- for (const key of this.#formats.keys()) {
499
- bytes += this.refresh(key).bytes;
500
- }
501
- return { outputs: this.#formats.size, bytes };
502
- }
503
-
504
- /**
505
- * Throw one output's segments away.
506
- *
507
- * @param {string} key
508
- * @param {string} because
509
- */
510
- drop(key, because) {
511
- const dir = path.join(this.#root, directoryNameFor(key));
512
- try {
513
- rmSync(dir, { recursive: true, force: true });
514
- } catch {
515
- // Already gone, or in use; the next sweep sees it either way.
516
- }
517
- this.#held.delete(key);
518
- this.#formats.delete(key);
519
- this.#touched.delete(key);
520
- this.#closed.delete(key);
521
- this.#logger.info(`segment-store dropped ${directoryNameFor(key)} (${because})`);
522
- }
523
-
524
- /**
525
- * Keep only what is still being read, and only as much of it as there is room
526
- * for.
527
- *
528
- * **Not tied to a session.** An output is worth keeping while somebody may
529
- * still ask for it, and a session ending says nothing about that: the viewer
530
- * who left may come back, and a viewer who never had a session here may open
531
- * the same film a minute later and find every segment already made. So the
532
- * only question asked is when this output was last READ, and the only bound
533
- * is the disk.
534
- *
535
- * The idle period is deliberately long. Its job is not to reclaim space —
536
- * that is the cap's — but to stop an output nobody has touched in hours from
537
- * sitting there for the life of the process.
538
- *
539
- * @param {object} params
540
- * @param {number} params.idleMs - Untouched for longer than this, and it goes.
541
- * @param {number} params.maxBytes - The most the whole store may hold. What
542
- * was read longest ago goes first.
543
- * @returns {{ droppedIdle: number, droppedForRoom: number, bytes: number }}
544
- */
545
- enforce({ idleMs, maxBytes }) {
546
- const now = this.#now();
547
- let droppedIdle = 0;
548
- for (const [key, touchedAt] of [...this.#touched]) {
549
- if (now - touchedAt > idleMs) {
550
- this.drop(key, `nothing has read it for ${Math.round((now - touchedAt) / 60000)} minutes`);
551
- droppedIdle += 1;
552
- }
553
- }
554
- let droppedForRoom = 0;
555
- let held = this.stats().bytes;
556
- if (Number.isFinite(maxBytes) && maxBytes > 0 && held > maxBytes) {
557
- // Least recently read first: what nobody has asked for in the longest
558
- // time is what a viewer is least likely to want next.
559
- const byAge = [...this.#touched.entries()].sort((left, right) => left[1] - right[1]);
560
- for (const [key] of byAge) {
561
- if (held <= maxBytes) {
562
- break;
563
- }
564
- const size = this.refresh(key).bytes;
565
- this.drop(key, `the store is over its ${(maxBytes / 1073741824).toFixed(1)}GB allowance`);
566
- held -= size;
567
- droppedForRoom += 1;
568
- }
569
- }
570
- return { droppedIdle, droppedForRoom, bytes: held };
571
- }
572
-
573
- /**
574
- * What a previous life of this process left on the disk.
575
- *
576
- * This is the only record there is of a death nobody saw. The kernel kills
577
- * this process often enough to matter — two kills in one viewing on
578
- * 2026-09-02 — and when it does, no exit handler runs, nothing is cleared up,
579
- * and memory is reclaimed while the disk is not: `/tmp` in the addon
580
- * container is on the overlay filesystem, measured 2026-09-04, so the files
581
- * survive the process and its restart.
582
- *
583
- * So the sweep reports rather than deletes quietly. What it finds is the
584
- * evidence, and every directory it names is one abnormal ending that went
585
- * unrecorded.
586
- *
587
- * @returns {{ directories: number, segments: number, bytes: number, unidentified: number, found: {key: string, dir: string, segments: number, bytes: number}[] }}
588
- */
589
- sweep() {
590
- const found = [];
591
- let unidentified = 0;
592
- let names = [];
593
- try {
594
- names = readdirSync(this.#root);
595
- } catch {
596
- return { directories: 0, segments: 0, bytes: 0, unidentified: 0, found: [] };
597
- }
598
- for (const name of names) {
599
- const dir = path.join(this.#root, name);
600
- let key = "";
601
- try {
602
- if (!statSync(dir).isDirectory()) {
603
- continue;
604
- }
605
- key = readFileSync(path.join(dir, KEY_FILE), "utf8").trim();
606
- } catch {
607
- key = "";
608
- }
609
- let segments = 0;
610
- let bytes = 0;
611
- try {
612
- for (const entry of readdirSync(dir)) {
613
- if (entry === KEY_FILE) {
614
- continue;
615
- }
616
- try {
617
- bytes += statSync(path.join(dir, entry)).size;
618
- segments += 1;
619
- } catch {
620
- // Vanished between the listing and the question.
621
- }
622
- }
623
- } catch {
624
- continue;
625
- }
626
- if (!key) {
627
- // A directory that cannot say what it holds is from before this layer,
628
- // or its key file did not survive. Nothing can be served out of it,
629
- // because nothing can match it to a request.
630
- unidentified += 1;
631
- }
632
- found.push({ key, dir, segments, bytes });
633
- }
634
- const totals = found.reduce(
635
- (sum, entry) => ({ segments: sum.segments + entry.segments, bytes: sum.bytes + entry.bytes }),
636
- { segments: 0, bytes: 0 }
637
- );
638
- if (found.length > 0) {
639
- this.#logger.info(
640
- `segment-store startup sweep: ${found.length} directories left by a previous run, ` +
641
- `${totals.segments} segments, ${(totals.bytes / 1048576).toFixed(1)}MB, ` +
642
- `${unidentified} of them unidentifiable — each one is an encoder that ended ` +
643
- "without anything recording why"
644
- );
645
- }
646
- return {
647
- directories: found.length,
648
- segments: totals.segments,
649
- bytes: totals.bytes,
650
- unidentified,
651
- found
652
- };
653
- }
654
-
655
- /**
656
- * Take back what a previous life left: keep what is proven, remove the rest.
657
- *
658
- * Deliberately not "throw everything away". A killed process leaves material
659
- * that is valid by construction — a copied segment's bytes depend only on the
660
- * source and re-encoding it costs the machine that is already known to be
661
- * short of processor. What cannot be kept is a directory that cannot name
662
- * itself, and the one file per directory whose closure nothing proves.
663
- *
664
- * @param {(key: string) => object | null} formatFor - How to read the file
665
- * names of an output, given its key. Null when this proxy cannot serve that
666
- * output at all, and then the directory goes.
667
- * @returns {{ adopted: number, dropped: number, unprovenRemoved: number }}
668
- */
669
- adoptWhatSurvived(formatFor) {
670
- const swept = this.sweep();
671
- let adopted = 0;
672
- let dropped = 0;
673
- let unprovenRemoved = 0;
674
- for (const entry of swept.found) {
675
- const format = entry.key ? formatFor(entry.key) : null;
676
- if (!format) {
677
- try {
678
- rmSync(entry.dir, { recursive: true, force: true });
679
- } catch {
680
- // Leave it; the next sweep reports it again.
681
- }
682
- dropped += 1;
683
- this.#logger.info(
684
- `segment-store discarded ${path.basename(entry.dir)}: ` +
685
- (entry.key ? "this proxy cannot serve that output" : "it does not say what it holds")
686
- );
687
- continue;
688
- }
689
- this.#formats.set(entry.key, format);
690
- // Recorded BEFORE the unproven piece goes: taking that file away would
691
- // otherwise leave the segment below it without a successor, and a file
692
- // that was demonstrably closed a moment ago would stop being servable.
693
- for (const index of this.provenNumbers(entry.key)) {
694
- this.markClosed(entry.key, index);
695
- }
696
- const unproven = this.unprovenNumber(entry.key);
697
- if (unproven >= 0) {
698
- const held = this.refresh(entry.key);
699
- const filePath = held.byNumber.get(unproven);
700
- if (filePath) {
701
- try {
702
- rmSync(filePath, { force: true });
703
- unprovenRemoved += 1;
704
- } catch {
705
- // Then it stays unproven and is simply never served.
706
- }
707
- }
708
- this.#held.delete(entry.key);
709
- }
710
- adopted += 1;
711
- this.#logger.info(
712
- `segment-store adopted ${path.basename(entry.dir)}: ${this.provenNumbers(entry.key).length} ` +
713
- `segments a killed process had already made, ${unproven >= 0 ? "1" : "no"} unfinished piece removed`
714
- );
715
- }
716
- return { adopted, dropped, unprovenRemoved };
717
- }
718
- }
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
+ import { discardOpenPiece } from "./open-piece.js";
43
+
44
+ /** Where every output's segments live. One root for the process. */
45
+ export const DEFAULT_STORE_ROOT = path.join(os.tmpdir(), "torrent-tv-hls");
46
+
47
+ /** The file inside each directory that says which output it holds. */
48
+ const KEY_FILE = "key.txt";
49
+
50
+ /**
51
+ * The directory name for an output key.
52
+ *
53
+ * A digest rather than the key itself: the key carries `:` and `/`, which a
54
+ * path may not, and sanitising them would make two different keys collide.
55
+ * Sixteen hex characters is enough that a collision is not a thing that
56
+ * happens, and short enough to read in a log line.
57
+ *
58
+ * @param {string} key
59
+ * @returns {string}
60
+ */
61
+ export function directoryNameFor(key) {
62
+ return createHash("sha256").update(String(key)).digest("hex").slice(0, 16);
63
+ }
64
+
65
+ /**
66
+ * What one output's directory holds, as last read.
67
+ *
68
+ * @typedef {object} HeldContents
69
+ * @property {number} readAt - The directory's modification time when it was read.
70
+ * @property {Map<number, string>} byNumber - Segment number to full path.
71
+ * @property {number} bytes - What those files weigh.
72
+ * @property {number} unproven - The highest number, whose closure nothing
73
+ * proves, or -1 when the directory holds no segments.
74
+ */
75
+
76
+ export class SegmentStore {
77
+ /** @type {string} */
78
+ #root;
79
+
80
+ /** Output key → what its directory holds. @type {Map<string, HeldContents>} */
81
+ #held = new Map();
82
+
83
+ /** Output key → how to read its file names. @type {Map<string, object>} */
84
+ #formats = new Map();
85
+
86
+ /** Output key → when it was last asked for. @type {Map<string, number>} */
87
+ #touched = new Map();
88
+
89
+ /**
90
+ * Numbers known closed for a reason other than a successor on the disk.
91
+ *
92
+ * Two things fill it. A live run says what it has finished as it finishes it.
93
+ * And adoption records what the successor rule proved BEFORE it removes the
94
+ * unproven piece — otherwise removing that piece would un-prove the segment
95
+ * below it, which is a file that was demonstrably closed a moment earlier.
96
+ *
97
+ * @type {Map<string, Set<number>>}
98
+ */
99
+ #closed = new Map();
100
+
101
+ /** Pieces already reported as taken on the successor rule, so one is said
102
+ * once. @type {Map<string, Set<number>>} */
103
+ #unreportedSaid = new Map();
104
+
105
+ /** @type {{ info: Function, warn: Function }} */
106
+ #logger;
107
+
108
+ /** @type {() => number} */
109
+ #now;
110
+
111
+ /**
112
+ * @param {object} [params]
113
+ * @param {string} [params.root] - Where the directories live.
114
+ * @param {{ info: Function, warn: Function }} [params.logger]
115
+ * @param {() => number} [params.now]
116
+ */
117
+ constructor({ root = DEFAULT_STORE_ROOT, logger = null, now = Date.now } = {}) {
118
+ this.#root = root;
119
+ this.#logger = logger ?? { info: () => {}, warn: () => {} };
120
+ this.#now = now;
121
+ }
122
+
123
+ /** @returns {string} */
124
+ get root() {
125
+ return this.#root;
126
+ }
127
+
128
+ /**
129
+ * Where this output's segments live, without making anything.
130
+ *
131
+ * Separate from {@link directoryFor} because a session works out its path
132
+ * long before it is sure it will exist: a probe or a keyframe read between
133
+ * the two can still fail, and a directory made in advance of that is a
134
+ * leftover nothing tracks — proxy 2.9.101 failed on every request and its
135
+ * abandoned directories were the only trace on disk.
136
+ *
137
+ * @param {string} key - `OutputSpec.toKey()`.
138
+ * @returns {string}
139
+ */
140
+ pathFor(key) {
141
+ return path.join(this.#root, directoryNameFor(key));
142
+ }
143
+
144
+ /**
145
+ * The directory this output's segments live in, made if it is not there.
146
+ *
147
+ * @param {string} key - `OutputSpec.toKey()`.
148
+ * @returns {string}
149
+ */
150
+ directoryFor(key) {
151
+ const dir = path.join(this.#root, directoryNameFor(key));
152
+ if (!existsSync(dir)) {
153
+ mkdirSync(dir, { recursive: true });
154
+ // What this directory is, for whoever finds it after this process has
155
+ // been killed. Without it the sweep can only throw everything away.
156
+ writeFileSync(path.join(dir, KEY_FILE), `${key}\n`, "utf8");
157
+ }
158
+ this.#touched.set(key, this.#now());
159
+ return dir;
160
+ }
161
+
162
+ /**
163
+ * Say how this output's files are named, so the store can read its directory.
164
+ *
165
+ * @param {string} key
166
+ * @param {{ isSegmentFileName: (name: string) => boolean, segmentIndexFromName: (name: string) => number }} segmentFormat
167
+ */
168
+ useFormat(key, segmentFormat) {
169
+ if (segmentFormat && typeof segmentFormat.isSegmentFileName === "function") {
170
+ this.#formats.set(key, segmentFormat);
171
+ }
172
+ }
173
+
174
+ /**
175
+ * Re-read this output's directory if it has moved since last time.
176
+ *
177
+ * A directory's modification time changes when an entry is added or removed,
178
+ * so a quiet request costs one `stat` rather than a listing.
179
+ *
180
+ * @param {string} key
181
+ * @returns {HeldContents}
182
+ */
183
+ refresh(key) {
184
+ const format = this.#formats.get(key);
185
+ const empty = { readAt: 0, byNumber: new Map(), bytes: 0, unproven: -1, largest: { index: -1, size: 0 } };
186
+ if (!format) {
187
+ return this.#held.get(key) ?? empty;
188
+ }
189
+ const dir = path.join(this.#root, directoryNameFor(key));
190
+ let mtime = 0;
191
+ try {
192
+ mtime = statSync(dir).mtimeMs;
193
+ } catch {
194
+ this.#held.delete(key);
195
+ return empty;
196
+ }
197
+ const known = this.#held.get(key);
198
+ if (known && known.readAt === mtime) {
199
+ return known;
200
+ }
201
+ const byNumber = new Map();
202
+ let bytes = 0;
203
+ let highest = -1;
204
+ // The biggest piece and which number it is. What reads it is the figure the
205
+ // master playlist declares: `BANDWIDTH` is the PEAK a link must carry, and
206
+ // the peak of a variable-bitrate source is nothing like its average — the
207
+ // field file of 2026-09-08 ran at 17.1 Mbit/s with a piece at 73. Which
208
+ // number it is matters because pieces are not all the same length, and only
209
+ // whoever holds the cut table can turn bytes into bits per second.
210
+ let largest = { index: -1, size: 0 };
211
+ try {
212
+ for (const name of readdirSync(dir)) {
213
+ if (!format.isSegmentFileName(name)) {
214
+ continue;
215
+ }
216
+ const index = format.segmentIndexFromName(name);
217
+ if (!Number.isInteger(index) || index < 0) {
218
+ continue;
219
+ }
220
+ const full = path.join(dir, name);
221
+ let size = 0;
222
+ try {
223
+ size = statSync(full).size;
224
+ } catch {
225
+ continue;
226
+ }
227
+ // A file of no bytes is not a segment, whatever its name says. It is
228
+ // what a run killed the instant after opening its next piece leaves,
229
+ // and taking it for a segment once convinced the look-ahead that a
230
+ // number had been produced and kept the encoder stopped for it.
231
+ if (size <= 0) {
232
+ continue;
233
+ }
234
+ byNumber.set(index, full);
235
+ bytes += size;
236
+ if (index > highest) {
237
+ highest = index;
238
+ }
239
+ if (size > largest.size) {
240
+ largest = { index, size };
241
+ }
242
+ }
243
+ } catch {
244
+ this.#held.delete(key);
245
+ return empty;
246
+ }
247
+ const contents = { readAt: mtime, byNumber, bytes, unproven: highest, largest };
248
+ this.#held.set(key, contents);
249
+ return contents;
250
+ }
251
+
252
+ /**
253
+ * The segment numbers this output holds that are PROVEN closed.
254
+ *
255
+ * Two proofs, and they answer for the two ways this proxy writes segments.
256
+ *
257
+ * 1. **the writer said so** — the `segment` muxer names each file on a channel
258
+ * of its own the moment it closes it;
259
+ * 2. **the next file exists** which is all there is for the `hls` muxer,
260
+ * which carries no such channel at all. That branch writes under a
261
+ * temporary name and renames on close, so a file that exists is whole by
262
+ * construction, and it is also what proves the pieces a previous life of
263
+ * this process left behind.
264
+ *
265
+ * The highest number is left out either way: nothing distinguishes a finished
266
+ * last piece from one that was being written when its run died.
267
+ *
268
+ * WHAT THE SECOND PROOF CANNOT ANSWER, and is left open deliberately: a piece
269
+ * a LIVE RUN IS REWRITING. Several runs share one directory, so a file left by
270
+ * an earlier one is a successor to a name the run working now has just
271
+ * reopened, and the disk cannot know the difference. Whether that has ever
272
+ * moved a decision is not established from any log we hold, and every remedy
273
+ * for it changes what "ready" means for five readers with different questions
274
+ * so it waits for a session that shows it, rather than being guessed at.
275
+ *
276
+ * @param {string} key
277
+ * @returns {number[]}
278
+ */
279
+ provenNumbers(key) {
280
+ const contents = this.refresh(key);
281
+ const stated = this.#closed.get(key);
282
+ const proven = [];
283
+ for (const index of contents.byNumber.keys()) {
284
+ if (contents.byNumber.has(index + 1) || stated?.has(index)) {
285
+ proven.push(index);
286
+ }
287
+ }
288
+ return proven.sort((left, right) => left - right);
289
+ }
290
+
291
+ /**
292
+ * How many numbered files this output holds, closed or not.
293
+ *
294
+ * What the disk has, against what has been proven closed: the two are printed
295
+ * side by side, so "the files are there and nobody reported them" reads
296
+ * differently from "there is nothing there".
297
+ *
298
+ * @param {string} key
299
+ * @returns {number}
300
+ */
301
+ filesHeld(key) {
302
+ return this.refresh(key).byNumber.size;
303
+ }
304
+
305
+ /**
306
+ * The biggest piece this output has made, and which number it is.
307
+ *
308
+ * Read by whoever declares the variant's peak rate. Bytes alone cannot say
309
+ * it pieces are not all the same length — so the number comes with them and
310
+ * whoever holds the cut table does the division.
311
+ *
312
+ * @param {string} key
313
+ * @returns {{ index: number, size: number }} An index of `-1` while nothing
314
+ * has been made, which is a statement and not a zero.
315
+ */
316
+ largestPiece(key) {
317
+ return this.refresh(key).largest ?? { index: -1, size: 0 };
318
+ }
319
+
320
+ /**
321
+ * A run is about to write these numbers again: forget that they were closed.
322
+ *
323
+ * A number closed once is not closed for ever. An encoder started at #N
324
+ * rewrites #N and everything after it, and while it is doing so the file
325
+ * under that name is half a segment — but the store remembered the earlier
326
+ * closing and would call it whole. Field 2026-09-05: seventeen runs were
327
+ * stopped and none ended normally, so numbers were being rewritten
328
+ * constantly, and the player met a fatal append error it never recovered
329
+ * from an empty picture for the six minutes that followed.
330
+ *
331
+ * @param {string} key
332
+ * @param {number} from - First number the run will write.
333
+ * @param {number} [to] - Last one, inclusive. Infinite for a run given no end,
334
+ * which does walk to the end of the film.
335
+ */
336
+ forgetClosed(key, from, to = Number.POSITIVE_INFINITY) {
337
+ const known = this.#closed.get(key);
338
+ if (!known || !Number.isInteger(from)) {
339
+ return;
340
+ }
341
+ // BOUNDED BY THE RUN'S OWN STRETCH, because that is what it will rewrite.
342
+ //
343
+ // It used to forget everything from `from` upwards, on the reading that a
344
+ // run has no end which was true until runs were given intervals. A run of
345
+ // #0..#0 then unproved the whole rest of the film, and with readiness a
346
+ // projection of what is proven that is an output declaring itself unmade
347
+ // every time an encoder starts anywhere near the beginning.
348
+ const last = Number.isFinite(to) ? Math.max(from, Math.trunc(to)) : Number.POSITIVE_INFINITY;
349
+ for (const index of known) {
350
+ if (index >= from && index <= last) {
351
+ known.delete(index);
352
+ }
353
+ }
354
+ // What the directory says has to be read again too, so that the size of a
355
+ // reopened piece is the size it has now and not the one it had before.
356
+ this.#held.delete(key);
357
+ }
358
+
359
+ /**
360
+ * Whether this piece is finished, and may therefore be served.
361
+ *
362
+ * Two proofs, and the first is the good one:
363
+ *
364
+ * 1. **the encoder said so** — the `segment` muxer names each file on a
365
+ * channel of its own the moment it closes it, so the name is the writer's
366
+ * own statement that the piece is whole;
367
+ * 2. **the next file exists** — the only proof available on the `hls` branch,
368
+ * which has no such channel, and for pieces left by an earlier life of this
369
+ * process. On that branch it is sound: the muxer renames into place on
370
+ * close, so a file that exists is finished.
371
+ *
372
+ * KNOWN AND LEFT ALONE HERE: on the `segment` branch this second proof can
373
+ * still pass a piece a run is halfway through rewriting, which is how 110 698
374
+ * bytes came to be served under a name whose neighbours are 12 MB (field
375
+ * 2026-09-06). Telling the two branches apart is a fact of how a run writes,
376
+ * it needs a field session of its own to verify, and it is not what stopped
377
+ * playback on 2026-09-07 so it stays open rather than being changed blind in
378
+ * the path that hands bytes to a player. What the PLAN believes is a different
379
+ * question and is answered: a live run's claim outranks readiness there.
380
+ *
381
+ * @param {string} key
382
+ * @param {number} index
383
+ * @returns {boolean}
384
+ */
385
+ isClosed(key, index) {
386
+ if (this.#closed.get(key)?.has(index)) {
387
+ return true;
388
+ }
389
+ const bySuccessor = this.refresh(key).byNumber.has(index + 1);
390
+ if (bySuccessor) {
391
+ this.#noteUnreported(key, index);
392
+ }
393
+ return bySuccessor;
394
+ }
395
+
396
+ /**
397
+ * A piece taken as finished because the NEXT one exists, with nothing from a
398
+ * run to say so.
399
+ *
400
+ * The successor rule is for what this process did not watch being written —
401
+ * pieces from a previous life of it. It is also the one way an unfinished
402
+ * piece can be served: a file that stops short still has a successor if
403
+ * anything wrote one, and then its name promises a whole span while it holds
404
+ * a fraction. Field 2026-09-06: 110 698 bytes served under a name whose
405
+ * neighbours are 12 MB, 40 ms of film where the playlist declared 10.4 s, and
406
+ * the player jumped the hole it left.
407
+ *
408
+ * That cannot arise from two encoders any more — their stretches no longer
409
+ * overlap so what is left is a piece from a process that died without
410
+ * clearing up. Said once per piece, with its size, so a return of it is
411
+ * visible rather than inferred.
412
+ *
413
+ * @param {string} key
414
+ * @param {number} index
415
+ */
416
+ #noteUnreported(key, index) {
417
+ let said = this.#unreportedSaid.get(key);
418
+ if (!said) {
419
+ said = new Set();
420
+ this.#unreportedSaid.set(key, said);
421
+ }
422
+ if (said.has(index)) {
423
+ return;
424
+ }
425
+ said.add(index);
426
+ let bytes = -1;
427
+ try {
428
+ bytes = statSync(this.pathOf(key, index)).size;
429
+ } catch {
430
+ // Gone between the listing and this: nothing to report about it.
431
+ return;
432
+ }
433
+ this.#logger?.info?.(
434
+ `segment store: #${index} of ${key} is taken as finished because ` +
435
+ `#${index + 1} exists — no run reported it (${bytes} bytes). Expected only for ` +
436
+ "pieces left by a previous life of this process."
437
+ );
438
+ }
439
+
440
+ /**
441
+ * Say that a segment is closed for a reason the disk cannot show.
442
+ *
443
+ * A run reports what it has finished; the successor rule is only for what
444
+ * this process did not watch being written.
445
+ *
446
+ * @param {string} key
447
+ * @param {number} index
448
+ */
449
+ markClosed(key, index) {
450
+ if (!Number.isInteger(index) || index < 0) {
451
+ return;
452
+ }
453
+ let known = this.#closed.get(key);
454
+ if (!known) {
455
+ known = new Set();
456
+ this.#closed.set(key, known);
457
+ }
458
+ known.add(index);
459
+ }
460
+
461
+ /**
462
+ * Throw away the piece a run had open when it ended, if it is unusable.
463
+ *
464
+ * The store owns this output's directory and knows how its files are named,
465
+ * so it is the one place that can answer which file a run left open. The
466
+ * judging of a NON-EMPTY file — does it carry every track it should — needs
467
+ * the output's init bytes and belongs to whoever holds them; passed in, and
468
+ * absent it only an empty file is removed, which is the case that caused this
469
+ * to be written (a run stopped 548 ms after starting left a zero-byte file
470
+ * whose name then read as a segment made).
471
+ *
472
+ * @param {string} key
473
+ * @param {{ from: number, to: number } | null} within - The run's own
474
+ * numbers: several runs write into one directory, so the piece to discard
475
+ * has to be looked for inside the stretch the ended run was given.
476
+ * @param {((raw: Buffer) => boolean) | null} [judgeUsable]
477
+ * @returns {Promise<number | null>} The segment number removed, or null.
478
+ */
479
+ async discardOpenPieceOf(key, within, judgeUsable = null, provenName = null) {
480
+ const format = this.#formats.get(key);
481
+ if (!format) {
482
+ return null;
483
+ }
484
+ const removed = await discardOpenPiece(this.directoryFor(key), format, within, judgeUsable, provenName);
485
+ if (removed !== null) {
486
+ this.#held.delete(key);
487
+ this.#logger?.info?.(
488
+ `segment store: discarded the open piece #${removed} of ${key.slice(0, 60)}`
489
+ );
490
+ }
491
+ return removed;
492
+ }
493
+
494
+ /**
495
+ * The one number in this output whose closure nothing on disk proves.
496
+ *
497
+ * @param {string} key
498
+ * @returns {number} -1 when the directory holds no segments.
499
+ */
500
+ unprovenNumber(key) {
501
+ return this.refresh(key).unproven;
502
+ }
503
+
504
+ /**
505
+ * Where a segment is, or null when this output does not hold it.
506
+ *
507
+ * @param {string} key
508
+ * @param {number} index
509
+ * @returns {string | null}
510
+ */
511
+ pathOf(key, index) {
512
+ this.#touched.set(key, this.#now());
513
+ return this.refresh(key).byNumber.get(index) ?? null;
514
+ }
515
+
516
+ /**
517
+ * What every output in the store weighs.
518
+ *
519
+ * @returns {{ outputs: number, bytes: number }}
520
+ */
521
+ stats() {
522
+ let bytes = 0;
523
+ for (const key of this.#formats.keys()) {
524
+ bytes += this.refresh(key).bytes;
525
+ }
526
+ return { outputs: this.#formats.size, bytes };
527
+ }
528
+
529
+ /**
530
+ * Throw one output's segments away.
531
+ *
532
+ * @param {string} key
533
+ * @param {string} because
534
+ */
535
+ drop(key, because) {
536
+ const dir = path.join(this.#root, directoryNameFor(key));
537
+ try {
538
+ rmSync(dir, { recursive: true, force: true });
539
+ } catch {
540
+ // Already gone, or in use; the next sweep sees it either way.
541
+ }
542
+ this.#held.delete(key);
543
+ this.#formats.delete(key);
544
+ this.#touched.delete(key);
545
+ this.#closed.delete(key);
546
+ this.#logger.info(`segment-store dropped ${directoryNameFor(key)} (${because})`);
547
+ }
548
+
549
+ /**
550
+ * Keep only what is still being read, and only as much of it as there is room
551
+ * for.
552
+ *
553
+ * **Not tied to a session.** An output is worth keeping while somebody may
554
+ * still ask for it, and a session ending says nothing about that: the viewer
555
+ * who left may come back, and a viewer who never had a session here may open
556
+ * the same film a minute later and find every segment already made. So the
557
+ * only question asked is when this output was last READ, and the only bound
558
+ * is the disk.
559
+ *
560
+ * The idle period is deliberately long. Its job is not to reclaim space —
561
+ * that is the cap's — but to stop an output nobody has touched in hours from
562
+ * sitting there for the life of the process.
563
+ *
564
+ * @param {object} params
565
+ * @param {number} params.idleMs - Untouched for longer than this, and it goes.
566
+ * @param {number} params.maxBytes - The most the whole store may hold. What
567
+ * was read longest ago goes first.
568
+ * @returns {{ droppedIdle: number, droppedForRoom: number, bytes: number }}
569
+ */
570
+ enforce({ idleMs, maxBytes }) {
571
+ const now = this.#now();
572
+ let droppedIdle = 0;
573
+ for (const [key, touchedAt] of [...this.#touched]) {
574
+ if (now - touchedAt > idleMs) {
575
+ this.drop(key, `nothing has read it for ${Math.round((now - touchedAt) / 60000)} minutes`);
576
+ droppedIdle += 1;
577
+ }
578
+ }
579
+ let droppedForRoom = 0;
580
+ let held = this.stats().bytes;
581
+ if (Number.isFinite(maxBytes) && maxBytes > 0 && held > maxBytes) {
582
+ // Least recently read first: what nobody has asked for in the longest
583
+ // time is what a viewer is least likely to want next.
584
+ const byAge = [...this.#touched.entries()].sort((left, right) => left[1] - right[1]);
585
+ for (const [key] of byAge) {
586
+ if (held <= maxBytes) {
587
+ break;
588
+ }
589
+ const size = this.refresh(key).bytes;
590
+ this.drop(key, `the store is over its ${(maxBytes / 1073741824).toFixed(1)}GB allowance`);
591
+ held -= size;
592
+ droppedForRoom += 1;
593
+ }
594
+ }
595
+ return { droppedIdle, droppedForRoom, bytes: held };
596
+ }
597
+
598
+ /**
599
+ * What a previous life of this process left on the disk.
600
+ *
601
+ * This is the only record there is of a death nobody saw. The kernel kills
602
+ * this process often enough to matter — two kills in one viewing on
603
+ * 2026-09-02 — and when it does, no exit handler runs, nothing is cleared up,
604
+ * and memory is reclaimed while the disk is not: `/tmp` in the addon
605
+ * container is on the overlay filesystem, measured 2026-09-04, so the files
606
+ * survive the process and its restart.
607
+ *
608
+ * So the sweep reports rather than deletes quietly. What it finds is the
609
+ * evidence, and every directory it names is one abnormal ending that went
610
+ * unrecorded.
611
+ *
612
+ * @returns {{ directories: number, segments: number, bytes: number, unidentified: number, found: {key: string, dir: string, segments: number, bytes: number}[] }}
613
+ */
614
+ sweep() {
615
+ const found = [];
616
+ let unidentified = 0;
617
+ let names = [];
618
+ try {
619
+ names = readdirSync(this.#root);
620
+ } catch {
621
+ return { directories: 0, segments: 0, bytes: 0, unidentified: 0, found: [] };
622
+ }
623
+ for (const name of names) {
624
+ const dir = path.join(this.#root, name);
625
+ let key = "";
626
+ try {
627
+ if (!statSync(dir).isDirectory()) {
628
+ continue;
629
+ }
630
+ key = readFileSync(path.join(dir, KEY_FILE), "utf8").trim();
631
+ } catch {
632
+ key = "";
633
+ }
634
+ let segments = 0;
635
+ let bytes = 0;
636
+ try {
637
+ for (const entry of readdirSync(dir)) {
638
+ if (entry === KEY_FILE) {
639
+ continue;
640
+ }
641
+ try {
642
+ bytes += statSync(path.join(dir, entry)).size;
643
+ segments += 1;
644
+ } catch {
645
+ // Vanished between the listing and the question.
646
+ }
647
+ }
648
+ } catch {
649
+ continue;
650
+ }
651
+ if (!key) {
652
+ // A directory that cannot say what it holds is from before this layer,
653
+ // or its key file did not survive. Nothing can be served out of it,
654
+ // because nothing can match it to a request.
655
+ unidentified += 1;
656
+ }
657
+ found.push({ key, dir, segments, bytes });
658
+ }
659
+ const totals = found.reduce(
660
+ (sum, entry) => ({ segments: sum.segments + entry.segments, bytes: sum.bytes + entry.bytes }),
661
+ { segments: 0, bytes: 0 }
662
+ );
663
+ if (found.length > 0) {
664
+ this.#logger.info(
665
+ `segment-store startup sweep: ${found.length} directories left by a previous run, ` +
666
+ `${totals.segments} segments, ${(totals.bytes / 1048576).toFixed(1)}MB, ` +
667
+ `${unidentified} of them unidentifiable each one is an encoder that ended ` +
668
+ "without anything recording why"
669
+ );
670
+ }
671
+ return {
672
+ directories: found.length,
673
+ segments: totals.segments,
674
+ bytes: totals.bytes,
675
+ unidentified,
676
+ found
677
+ };
678
+ }
679
+
680
+ /**
681
+ * Take back what a previous life left: keep what is proven, remove the rest.
682
+ *
683
+ * Deliberately not "throw everything away". A killed process leaves material
684
+ * that is valid by construction — a copied segment's bytes depend only on the
685
+ * source and re-encoding it costs the machine that is already known to be
686
+ * short of processor. What cannot be kept is a directory that cannot name
687
+ * itself, and the one file per directory whose closure nothing proves.
688
+ *
689
+ * @param {(key: string) => object | null} formatFor - How to read the file
690
+ * names of an output, given its key. Null when this proxy cannot serve that
691
+ * output at all, and then the directory goes.
692
+ * @returns {{ adopted: number, dropped: number, unprovenRemoved: number }}
693
+ */
694
+ adoptWhatSurvived(formatFor) {
695
+ const swept = this.sweep();
696
+ let adopted = 0;
697
+ let dropped = 0;
698
+ let unprovenRemoved = 0;
699
+ for (const entry of swept.found) {
700
+ const format = entry.key ? formatFor(entry.key) : null;
701
+ if (!format) {
702
+ try {
703
+ rmSync(entry.dir, { recursive: true, force: true });
704
+ } catch {
705
+ // Leave it; the next sweep reports it again.
706
+ }
707
+ dropped += 1;
708
+ this.#logger.info(
709
+ `segment-store discarded ${path.basename(entry.dir)}: ` +
710
+ (entry.key ? "this proxy cannot serve that output" : "it does not say what it holds")
711
+ );
712
+ continue;
713
+ }
714
+ this.#formats.set(entry.key, format);
715
+ // Recorded BEFORE the unproven piece goes: taking that file away would
716
+ // otherwise leave the segment below it without a successor, and a file
717
+ // that was demonstrably closed a moment ago would stop being servable.
718
+ for (const index of this.provenNumbers(entry.key)) {
719
+ this.markClosed(entry.key, index);
720
+ }
721
+ const unproven = this.unprovenNumber(entry.key);
722
+ if (unproven >= 0) {
723
+ const held = this.refresh(entry.key);
724
+ const filePath = held.byNumber.get(unproven);
725
+ if (filePath) {
726
+ try {
727
+ rmSync(filePath, { force: true });
728
+ unprovenRemoved += 1;
729
+ } catch {
730
+ // Then it stays unproven and is simply never served.
731
+ }
732
+ }
733
+ this.#held.delete(entry.key);
734
+ }
735
+ adopted += 1;
736
+ this.#logger.info(
737
+ `segment-store adopted ${path.basename(entry.dir)}: ${this.provenNumbers(entry.key).length} ` +
738
+ `segments a killed process had already made, ${unproven >= 0 ? "1" : "no"} unfinished piece removed`
739
+ );
740
+ }
741
+ return { adopted, dropped, unprovenRemoved };
742
+ }
743
+ }