@torrent-tv/proxy 2.80.7 → 2.80.9

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,669 +1,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 };
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
- * The proof is the successor: a segment ffmpeg has moved past is finished,
246
- * whatever branch wrote it. The highest number is left out, because nothing
247
- * on the disk distinguishes a finished last segment from one that was being
248
- * written when its run died.
249
- *
250
- * @param {string} key
251
- * @returns {number[]}
252
- */
253
- provenNumbers(key) {
254
- const contents = this.refresh(key);
255
- const stated = this.#closed.get(key);
256
- const proven = [];
257
- for (const index of contents.byNumber.keys()) {
258
- if (contents.byNumber.has(index + 1) || stated?.has(index)) {
259
- proven.push(index);
260
- }
261
- }
262
- return proven.sort((left, right) => left - right);
263
- }
264
-
265
- /**
266
- * A run is about to write these numbers again: forget that they were closed.
267
- *
268
- * A number closed once is not closed for ever. An encoder started at #N
269
- * rewrites #N and everything after it, and while it is doing so the file
270
- * under that name is half a segment — but the store remembered the earlier
271
- * closing and would call it whole. Field 2026-09-05: seventeen runs were
272
- * stopped and none ended normally, so numbers were being rewritten
273
- * constantly, and the player met a fatal append error it never recovered
274
- * from an empty picture for the six minutes that followed.
275
- *
276
- * @param {string} key
277
- * @param {number} from
278
- */
279
- forgetClosedFrom(key, from) {
280
- const known = this.#closed.get(key);
281
- if (!known || !Number.isInteger(from)) {
282
- return;
283
- }
284
- for (const index of known) {
285
- if (index >= from) {
286
- known.delete(index);
287
- }
288
- }
289
- // What the directory says has to be read again too: the successor rule
290
- // would otherwise prove the rewritten piece from a file made before it.
291
- this.#held.delete(key);
292
- }
293
-
294
- /**
295
- * Whether this piece is finished, and may therefore be served.
296
- *
297
- * Two proofs, and the first is the good one:
298
- *
299
- * 1. **the encoder said so** it names each file on a channel of its own the
300
- * moment it closes it, so the name is the writer's own statement that the
301
- * piece is whole;
302
- * 2. **the next file exists** which only proves it for pieces this process
303
- * did not watch being written, left by an earlier life of it. It is not
304
- * true of the last piece of any run, and that is what used to hold the
305
- * first segment of every run from the viewer.
306
- *
307
- * @param {string} key
308
- * @param {number} index
309
- * @returns {boolean}
310
- */
311
- isClosed(key, index) {
312
- if (this.#closed.get(key)?.has(index)) {
313
- return true;
314
- }
315
- const bySuccessor = this.refresh(key).byNumber.has(index + 1);
316
- if (bySuccessor) {
317
- this.#noteUnreported(key, index);
318
- }
319
- return bySuccessor;
320
- }
321
-
322
- /**
323
- * A piece taken as finished because the NEXT one exists, with nothing from a
324
- * run to say so.
325
- *
326
- * The successor rule is for what this process did not watch being written —
327
- * pieces from a previous life of it. It is also the one way an unfinished
328
- * piece can be served: a file that stops short still has a successor if
329
- * anything wrote one, and then its name promises a whole span while it holds
330
- * a fraction. Field 2026-09-06: 110 698 bytes served under a name whose
331
- * neighbours are 12 MB, 40 ms of film where the playlist declared 10.4 s, and
332
- * the player jumped the hole it left.
333
- *
334
- * That cannot arise from two encoders any more — their stretches no longer
335
- * overlap so what is left is a piece from a process that died without
336
- * clearing up. Said once per piece, with its size, so a return of it is
337
- * visible rather than inferred.
338
- *
339
- * @param {string} key
340
- * @param {number} index
341
- */
342
- #noteUnreported(key, index) {
343
- let said = this.#unreportedSaid.get(key);
344
- if (!said) {
345
- said = new Set();
346
- this.#unreportedSaid.set(key, said);
347
- }
348
- if (said.has(index)) {
349
- return;
350
- }
351
- said.add(index);
352
- let bytes = -1;
353
- try {
354
- bytes = statSync(this.pathOf(key, index)).size;
355
- } catch {
356
- // Gone between the listing and this: nothing to report about it.
357
- return;
358
- }
359
- this.#logger?.info?.(
360
- `segment store: #${index} of ${key.slice(0, 60)} is taken as finished because ` +
361
- `#${index + 1} exists — no run reported it (${bytes} bytes). Expected only for ` +
362
- "pieces left by a previous life of this process."
363
- );
364
- }
365
-
366
- /**
367
- * Say that a segment is closed for a reason the disk cannot show.
368
- *
369
- * A run reports what it has finished; the successor rule is only for what
370
- * this process did not watch being written.
371
- *
372
- * @param {string} key
373
- * @param {number} index
374
- */
375
- markClosed(key, index) {
376
- if (!Number.isInteger(index) || index < 0) {
377
- return;
378
- }
379
- let known = this.#closed.get(key);
380
- if (!known) {
381
- known = new Set();
382
- this.#closed.set(key, known);
383
- }
384
- known.add(index);
385
- }
386
-
387
- /**
388
- * Throw away the piece a run had open when it ended, if it is unusable.
389
- *
390
- * The store owns this output's directory and knows how its files are named,
391
- * so it is the one place that can answer which file a run left open. The
392
- * judging of a NON-EMPTY file — does it carry every track it should — needs
393
- * the output's init bytes and belongs to whoever holds them; passed in, and
394
- * absent it only an empty file is removed, which is the case that caused this
395
- * to be written (a run stopped 548 ms after starting left a zero-byte file
396
- * whose name then read as a segment made).
397
- *
398
- * @param {string} key
399
- * @param {{ from: number, to: number } | null} within - The run's own
400
- * numbers: several runs write into one directory, so the piece to discard
401
- * has to be looked for inside the stretch the ended run was given.
402
- * @param {((raw: Buffer) => boolean) | null} [judgeUsable]
403
- * @returns {Promise<number | null>} The segment number removed, or null.
404
- */
405
- async discardOpenPieceOf(key, within, judgeUsable = null, provenName = null) {
406
- const format = this.#formats.get(key);
407
- if (!format) {
408
- return null;
409
- }
410
- const removed = await discardOpenPiece(this.directoryFor(key), format, within, judgeUsable, provenName);
411
- if (removed !== null) {
412
- this.#held.delete(key);
413
- this.#logger?.info?.(
414
- `segment store: discarded the open piece #${removed} of ${key.slice(0, 60)}`
415
- );
416
- }
417
- return removed;
418
- }
419
-
420
- /**
421
- * The one number in this output whose closure nothing on disk proves.
422
- *
423
- * @param {string} key
424
- * @returns {number} -1 when the directory holds no segments.
425
- */
426
- unprovenNumber(key) {
427
- return this.refresh(key).unproven;
428
- }
429
-
430
- /**
431
- * Where a segment is, or null when this output does not hold it.
432
- *
433
- * @param {string} key
434
- * @param {number} index
435
- * @returns {string | null}
436
- */
437
- pathOf(key, index) {
438
- this.#touched.set(key, this.#now());
439
- return this.refresh(key).byNumber.get(index) ?? null;
440
- }
441
-
442
- /**
443
- * What every output in the store weighs.
444
- *
445
- * @returns {{ outputs: number, bytes: number }}
446
- */
447
- stats() {
448
- let bytes = 0;
449
- for (const key of this.#formats.keys()) {
450
- bytes += this.refresh(key).bytes;
451
- }
452
- return { outputs: this.#formats.size, bytes };
453
- }
454
-
455
- /**
456
- * Throw one output's segments away.
457
- *
458
- * @param {string} key
459
- * @param {string} because
460
- */
461
- drop(key, because) {
462
- const dir = path.join(this.#root, directoryNameFor(key));
463
- try {
464
- rmSync(dir, { recursive: true, force: true });
465
- } catch {
466
- // Already gone, or in use; the next sweep sees it either way.
467
- }
468
- this.#held.delete(key);
469
- this.#formats.delete(key);
470
- this.#touched.delete(key);
471
- this.#closed.delete(key);
472
- this.#logger.info(`segment-store dropped ${directoryNameFor(key)} (${because})`);
473
- }
474
-
475
- /**
476
- * Keep only what is still being read, and only as much of it as there is room
477
- * for.
478
- *
479
- * **Not tied to a session.** An output is worth keeping while somebody may
480
- * still ask for it, and a session ending says nothing about that: the viewer
481
- * who left may come back, and a viewer who never had a session here may open
482
- * the same film a minute later and find every segment already made. So the
483
- * only question asked is when this output was last READ, and the only bound
484
- * is the disk.
485
- *
486
- * The idle period is deliberately long. Its job is not to reclaim space —
487
- * that is the cap's — but to stop an output nobody has touched in hours from
488
- * sitting there for the life of the process.
489
- *
490
- * @param {object} params
491
- * @param {number} params.idleMs - Untouched for longer than this, and it goes.
492
- * @param {number} params.maxBytes - The most the whole store may hold. What
493
- * was read longest ago goes first.
494
- * @returns {{ droppedIdle: number, droppedForRoom: number, bytes: number }}
495
- */
496
- enforce({ idleMs, maxBytes }) {
497
- const now = this.#now();
498
- let droppedIdle = 0;
499
- for (const [key, touchedAt] of [...this.#touched]) {
500
- if (now - touchedAt > idleMs) {
501
- this.drop(key, `nothing has read it for ${Math.round((now - touchedAt) / 60000)} minutes`);
502
- droppedIdle += 1;
503
- }
504
- }
505
- let droppedForRoom = 0;
506
- let held = this.stats().bytes;
507
- if (Number.isFinite(maxBytes) && maxBytes > 0 && held > maxBytes) {
508
- // Least recently read first: what nobody has asked for in the longest
509
- // time is what a viewer is least likely to want next.
510
- const byAge = [...this.#touched.entries()].sort((left, right) => left[1] - right[1]);
511
- for (const [key] of byAge) {
512
- if (held <= maxBytes) {
513
- break;
514
- }
515
- const size = this.refresh(key).bytes;
516
- this.drop(key, `the store is over its ${(maxBytes / 1073741824).toFixed(1)}GB allowance`);
517
- held -= size;
518
- droppedForRoom += 1;
519
- }
520
- }
521
- return { droppedIdle, droppedForRoom, bytes: held };
522
- }
523
-
524
- /**
525
- * What a previous life of this process left on the disk.
526
- *
527
- * This is the only record there is of a death nobody saw. The kernel kills
528
- * this process often enough to matter two kills in one viewing on
529
- * 2026-09-02 and when it does, no exit handler runs, nothing is cleared up,
530
- * and memory is reclaimed while the disk is not: `/tmp` in the addon
531
- * container is on the overlay filesystem, measured 2026-09-04, so the files
532
- * survive the process and its restart.
533
- *
534
- * So the sweep reports rather than deletes quietly. What it finds is the
535
- * evidence, and every directory it names is one abnormal ending that went
536
- * unrecorded.
537
- *
538
- * @returns {{ directories: number, segments: number, bytes: number, unidentified: number, found: {key: string, dir: string, segments: number, bytes: number}[] }}
539
- */
540
- sweep() {
541
- const found = [];
542
- let unidentified = 0;
543
- let names = [];
544
- try {
545
- names = readdirSync(this.#root);
546
- } catch {
547
- return { directories: 0, segments: 0, bytes: 0, unidentified: 0, found: [] };
548
- }
549
- for (const name of names) {
550
- const dir = path.join(this.#root, name);
551
- let key = "";
552
- try {
553
- if (!statSync(dir).isDirectory()) {
554
- continue;
555
- }
556
- key = readFileSync(path.join(dir, KEY_FILE), "utf8").trim();
557
- } catch {
558
- key = "";
559
- }
560
- let segments = 0;
561
- let bytes = 0;
562
- try {
563
- for (const entry of readdirSync(dir)) {
564
- if (entry === KEY_FILE) {
565
- continue;
566
- }
567
- try {
568
- bytes += statSync(path.join(dir, entry)).size;
569
- segments += 1;
570
- } catch {
571
- // Vanished between the listing and the question.
572
- }
573
- }
574
- } catch {
575
- continue;
576
- }
577
- if (!key) {
578
- // A directory that cannot say what it holds is from before this layer,
579
- // or its key file did not survive. Nothing can be served out of it,
580
- // because nothing can match it to a request.
581
- unidentified += 1;
582
- }
583
- found.push({ key, dir, segments, bytes });
584
- }
585
- const totals = found.reduce(
586
- (sum, entry) => ({ segments: sum.segments + entry.segments, bytes: sum.bytes + entry.bytes }),
587
- { segments: 0, bytes: 0 }
588
- );
589
- if (found.length > 0) {
590
- this.#logger.info(
591
- `segment-store startup sweep: ${found.length} directories left by a previous run, ` +
592
- `${totals.segments} segments, ${(totals.bytes / 1048576).toFixed(1)}MB, ` +
593
- `${unidentified} of them unidentifiable — each one is an encoder that ended ` +
594
- "without anything recording why"
595
- );
596
- }
597
- return {
598
- directories: found.length,
599
- segments: totals.segments,
600
- bytes: totals.bytes,
601
- unidentified,
602
- found
603
- };
604
- }
605
-
606
- /**
607
- * Take back what a previous life left: keep what is proven, remove the rest.
608
- *
609
- * Deliberately not "throw everything away". A killed process leaves material
610
- * that is valid by construction — a copied segment's bytes depend only on the
611
- * source — and re-encoding it costs the machine that is already known to be
612
- * short of processor. What cannot be kept is a directory that cannot name
613
- * itself, and the one file per directory whose closure nothing proves.
614
- *
615
- * @param {(key: string) => object | null} formatFor - How to read the file
616
- * names of an output, given its key. Null when this proxy cannot serve that
617
- * output at all, and then the directory goes.
618
- * @returns {{ adopted: number, dropped: number, unprovenRemoved: number }}
619
- */
620
- adoptWhatSurvived(formatFor) {
621
- const swept = this.sweep();
622
- let adopted = 0;
623
- let dropped = 0;
624
- let unprovenRemoved = 0;
625
- for (const entry of swept.found) {
626
- const format = entry.key ? formatFor(entry.key) : null;
627
- if (!format) {
628
- try {
629
- rmSync(entry.dir, { recursive: true, force: true });
630
- } catch {
631
- // Leave it; the next sweep reports it again.
632
- }
633
- dropped += 1;
634
- this.#logger.info(
635
- `segment-store discarded ${path.basename(entry.dir)}: ` +
636
- (entry.key ? "this proxy cannot serve that output" : "it does not say what it holds")
637
- );
638
- continue;
639
- }
640
- this.#formats.set(entry.key, format);
641
- // Recorded BEFORE the unproven piece goes: taking that file away would
642
- // otherwise leave the segment below it without a successor, and a file
643
- // that was demonstrably closed a moment ago would stop being servable.
644
- for (const index of this.provenNumbers(entry.key)) {
645
- this.markClosed(entry.key, index);
646
- }
647
- const unproven = this.unprovenNumber(entry.key);
648
- if (unproven >= 0) {
649
- const held = this.refresh(entry.key);
650
- const filePath = held.byNumber.get(unproven);
651
- if (filePath) {
652
- try {
653
- rmSync(filePath, { force: true });
654
- unprovenRemoved += 1;
655
- } catch {
656
- // Then it stays unproven and is simply never served.
657
- }
658
- }
659
- this.#held.delete(entry.key);
660
- }
661
- adopted += 1;
662
- this.#logger.info(
663
- `segment-store adopted ${path.basename(entry.dir)}: ${this.provenNumbers(entry.key).length} ` +
664
- `segments a killed process had already made, ${unproven >= 0 ? "1" : "no"} unfinished piece removed`
665
- );
666
- }
667
- return { adopted, dropped, unprovenRemoved };
668
- }
669
- }
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
+ }