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