@torrent-tv/proxy 2.80.8 → 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.
- package/CHANGELOG.md +1666 -1658
- package/bin/cli.js +606 -596
- package/package.json +1 -1
- package/services/encode/CoverageMap.js +428 -401
- package/services/encode/SegmentStore.js +718 -669
- package/services/hls-session-manager.js +7 -19
- package/services/memory-report.js +596 -592
- package/services/orchestrators/EncodeOrchestrator.js +726 -594
- package/test/coverage-follows-the-disk.test.js +187 -0
- package/test/coverage-map.test.js +195 -178
- package/test/encode-plan.test.js +539 -540
- package/test/run-intervals.test.js +100 -100
- package/test/segment-store.test.js +216 -187
|
@@ -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
|
-
*
|
|
246
|
-
*
|
|
247
|
-
*
|
|
248
|
-
*
|
|
249
|
-
*
|
|
250
|
-
*
|
|
251
|
-
*
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
*
|
|
267
|
-
*
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
*
|
|
297
|
-
*
|
|
298
|
-
*
|
|
299
|
-
*
|
|
300
|
-
*
|
|
301
|
-
*
|
|
302
|
-
*
|
|
303
|
-
*
|
|
304
|
-
*
|
|
305
|
-
*
|
|
306
|
-
*
|
|
307
|
-
* @param {
|
|
308
|
-
* @param {number}
|
|
309
|
-
*
|
|
310
|
-
*/
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
*
|
|
336
|
-
*
|
|
337
|
-
*
|
|
338
|
-
*
|
|
339
|
-
*
|
|
340
|
-
*
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
*
|
|
373
|
-
*
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
*
|
|
389
|
-
*
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
*
|
|
422
|
-
*
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
*
|
|
444
|
-
*
|
|
445
|
-
*
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
*
|
|
481
|
-
*
|
|
482
|
-
*
|
|
483
|
-
*
|
|
484
|
-
*
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
*
|
|
493
|
-
*
|
|
494
|
-
* @returns {{
|
|
495
|
-
*/
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
}
|
|
523
|
-
|
|
524
|
-
/**
|
|
525
|
-
*
|
|
526
|
-
*
|
|
527
|
-
*
|
|
528
|
-
*
|
|
529
|
-
*
|
|
530
|
-
*
|
|
531
|
-
*
|
|
532
|
-
*
|
|
533
|
-
*
|
|
534
|
-
*
|
|
535
|
-
*
|
|
536
|
-
*
|
|
537
|
-
*
|
|
538
|
-
*
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
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
|
+
}
|