@torrent-tv/proxy 2.82.0 → 2.83.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/CLAUDE.md +11 -0
  3. package/docs/disk-architecture.md +161 -0
  4. package/docs/encode-architecture.md +36 -7
  5. package/package.json +1 -1
  6. package/routes/api/sources/stats/get.js +11 -2
  7. package/routes/stream/get.js +74 -3
  8. package/services/delivery-probe.js +248 -43
  9. package/services/disk/keep.js +48 -0
  10. package/services/disk/returns.js +103 -0
  11. package/services/download/SwarmSelection.js +5 -5
  12. package/services/download/registry.js +20 -0
  13. package/services/encode/EncodeRun.js +1 -0
  14. package/services/encode/Encoder.js +15 -0
  15. package/services/encode/QsvEncoder.js +5 -0
  16. package/services/encode/SegmentStore.js +14 -0
  17. package/services/encode/VaapiEncoder.js +5 -0
  18. package/services/encode/encode-exit.js +17 -0
  19. package/services/encode/start-stop-cost.js +6 -2
  20. package/services/files/CompletedFiles.js +276 -0
  21. package/services/files/piece-from-whole-file.js +118 -0
  22. package/services/hls-session-manager.js +17 -1
  23. package/services/hwaccel.js +4 -0
  24. package/services/output/cut-grid.js +13 -3
  25. package/services/piece-store/piece-disk-store.js +143 -8
  26. package/services/piece-store/piece-lru.js +17 -0
  27. package/services/piece-store/shared-piece-store.js +1803 -1549
  28. package/services/torrent-pool.js +246 -26
  29. package/services/torrent-worker/client.js +21 -0
  30. package/services/torrent-worker/protocol.js +9 -1
  31. package/services/torrent-worker/worker.js +183 -2
  32. package/test/completed-files.test.js +115 -0
  33. package/test/cuts-follow-published-grid.test.js +35 -0
  34. package/test/delivery-probe.test.js +114 -1
  35. package/test/encode-exit.test.js +18 -0
  36. package/test/keeping-period.test.js +83 -0
  37. package/test/piece-disk-store.test.js +114 -0
  38. package/test/piece-from-whole-file.test.js +129 -0
  39. package/test/piece-store-eviction.test.js +28 -15
  40. package/test/piece-store-never-refuses.test.js +153 -0
  41. package/test/piece-store-reservations.test.js +16 -3
  42. package/test/probe-wedge-certainty.test.js +3 -3
  43. package/test/shared-piece-store.test.js +27 -13
  44. package/test/stream-route.test.js +41 -0
  45. package/test/swarm-follows-readers.test.js +126 -0
  46. package/test/swarm-reach.test.js +5 -0
  47. package/test/upload-hurry.test.js +27 -0
@@ -481,6 +481,20 @@ export class SegmentStore {
481
481
  this.#logger.info(`segment-store dropped ${directoryNameFor(key)} (${because})`);
482
482
  }
483
483
 
484
+ /**
485
+ * When this output was last asked for, or null where it has never been.
486
+ *
487
+ * The one reading that makes the keeping period measurable rather than
488
+ * guessed: a session opened on an output this answers for IS a return, and
489
+ * this is its age.
490
+ *
491
+ * @param {string} key
492
+ * @returns {number | null}
493
+ */
494
+ lastReadAt(key) {
495
+ return this.#touched.get(key) ?? null;
496
+ }
497
+
484
498
  /**
485
499
  * Throw away everything this store owns, and the root with it.
486
500
  *
@@ -40,6 +40,11 @@ export class VaapiEncoder extends Encoder {
40
40
 
41
41
  // No fps filter: VAAPI inherits the source rate and keeps keyframes on the
42
42
  // grid via time-based -force_key_frames, so it already honours source fps.
43
+ /** @returns {string[]} */
44
+ benchmarkInputArgs() {
45
+ return this.device ? ["-vaapi_device", this.device] : [];
46
+ }
47
+
43
48
  /** @param {string | null} rung @returns {string[]} */
44
49
  benchmarkArgs(rung = null) {
45
50
  // Raw frames live in this process; VAAPI encodes what is in the device, so
@@ -73,6 +73,11 @@ export const ENCODE_EXIT = Object.freeze({
73
73
  * session has on disk, or null when that could not be read.
74
74
  * @param {number | null} [facts.lastSegmentIndex] - Index of the file's last
75
75
  * segment according to the published playlist, or null when unknown.
76
+ * @param {number | null} [facts.producedCount] - How many segments THIS run
77
+ * made. Zero and "could not be read" are different facts and were both `null`
78
+ * until 2026-09-11: a run that made nothing exited zero and was recorded as
79
+ * having finished, which is how a segment that does not exist came to be
80
+ * believed present for forty-six minutes.
76
81
  * @param {boolean} [facts.inputUnavailable] - The error names a missing input.
77
82
  * @returns {string} One of {@link ENCODE_EXIT}.
78
83
  */
@@ -81,12 +86,24 @@ export function classifyEncodeExit({
81
86
  code = null,
82
87
  producedThrough = null,
83
88
  lastSegmentIndex = null,
89
+ producedCount = null,
84
90
  inputUnavailable = false
85
91
  } = {}) {
86
92
  if (superseded) {
87
93
  return ENCODE_EXIT.IGNORED;
88
94
  }
89
95
  if (code === 0) {
96
+ // PRODUCING NOTHING IS THE CLEAREST CASE OF NOT FINISHING, and it used to be
97
+ // the one case that read as success: `producedThrough` is null when the run
98
+ // made no segment at all, null is "unknown", and unknown fell through to
99
+ // complete. Field 2026-09-11: a run given #541..#541 was handed a start
100
+ // later than its own end, wrote 190 bytes that are not a fragment, exited
101
+ // zero — and was recorded as having reached the end of what it was given.
102
+ // Nothing asked for that segment again for forty-six minutes, until the
103
+ // viewer arrived at it and waited 23 s for a 404.
104
+ if (producedCount === 0 && lastSegmentIndex !== null) {
105
+ return ENCODE_EXIT.SHORT;
106
+ }
90
107
  const stoppedShort =
91
108
  lastSegmentIndex !== null &&
92
109
  producedThrough !== null &&
@@ -70,6 +70,8 @@ export async function measureStartAndStop({
70
70
  "-nostats",
71
71
  "-loglevel",
72
72
  "error",
73
+ // The device, where the kind takes one.
74
+ ...(typeof encoder?.benchmarkInputArgs === "function" ? encoder.benchmarkInputArgs() : []),
73
75
  // A generated picture: the reading is of this host's encoder and muxer,
74
76
  // and a file would add its own reading and its own download.
75
77
  "-f",
@@ -81,8 +83,10 @@ export async function measureStartAndStop({
81
83
  // The encoder this proxy has chosen, asked for its own arguments: the
82
84
  // reading must be of the thing that will actually run, since what a start
83
85
  // costs is mostly the encoder opening.
84
- ...(typeof encoder?.buildVideoArgs === "function"
85
- ? encoder.buildVideoArgs({ targetWidth: 640, targetHeight: 360, segmentDurationSec, fps: 25 })
86
+ // The same arguments the throughput benchmark uses, for the same reason:
87
+ // a start is timed on the encoder itself, not on a scaler in front of it.
88
+ ...(typeof encoder?.benchmarkArgs === "function"
89
+ ? encoder.benchmarkArgs(null)
86
90
  : ["-c:v", "libx264", "-preset", "ultrafast"]),
87
91
  "-an",
88
92
  "-f",
@@ -0,0 +1,276 @@
1
+ /**
2
+ * @file Files this proxy has downloaded whole, kept as files.
3
+ *
4
+ * A torrent is a way of GETTING bytes. Once every byte of a file is here, the
5
+ * torrent has nothing left to do for it: the file is a file, and reading it is
6
+ * an ordinary read of an ordinary file — no piece store, no memory ceiling, no
7
+ * eviction, no revival from a spill, and nothing that can refuse a read for
8
+ * want of memory.
9
+ *
10
+ * WHY THIS EXISTS AT ALL, in the words it was asked for (2026-09-11): as soon
11
+ * as a torrent is fully downloaded, downloading stops, the torrent is deleted,
12
+ * and the artefacts — what was downloaded — stay for as long as they are
13
+ * wanted.
14
+ *
15
+ * WHERE THEY LIVE, and it is not beside the torrent's own store. Destroying a
16
+ * torrent with `destroyStore` removes that directory whole, which is exactly
17
+ * what the instruction above ends with — so a file kept inside it would be
18
+ * deleted by the very act it is meant to survive.
19
+ *
20
+ * WHAT IS HERE IS ADOPTED AT STARTUP, for the same reason the piece store
21
+ * adopts its own directory: a proxy that has restarted has these files and must
22
+ * not fetch them again. A file whose size does not match what the torrent says
23
+ * is not adopted — it was being written when the process died.
24
+ */
25
+
26
+ import fs from "node:fs/promises";
27
+ import os from "node:os";
28
+ import path from "node:path";
29
+
30
+ /**
31
+ * Where whole files live.
32
+ *
33
+ * Beside the torrents' own directories and not inside them: destroying a
34
+ * torrent with its store removes that directory whole, and these files exist to
35
+ * survive exactly that.
36
+ *
37
+ * @returns {string}
38
+ */
39
+ export function completedFilesRoot() {
40
+ return path.join(os.tmpdir(), "torrent-tv-files");
41
+ }
42
+
43
+ /** How a removal is asked for; Windows needs the retries, POSIX ignores them. */
44
+ const REMOVAL = { force: true, maxRetries: 10, retryDelay: 20 };
45
+
46
+ /**
47
+ * One directory of whole files, keyed by the torrent they came from.
48
+ */
49
+ export class CompletedFiles {
50
+ #root;
51
+
52
+ /** `${infoHash}/${fileIndex}` → what is held. @type {Map<string, { path: string, length: number, name: string }>} */
53
+ #held = new Map();
54
+
55
+ /** Assemblies in flight, so two passes cannot write one file at once. @type {Set<string>} */
56
+ #writing = new Set();
57
+
58
+ /**
59
+ * @param {object} params
60
+ * @param {string} params.root - Where whole files live. Outside any torrent's
61
+ * own store directory, which is removed with the torrent.
62
+ */
63
+ constructor({ root }) {
64
+ this.#root = root;
65
+ }
66
+
67
+ /** Where these files live, for logging. */
68
+ get root() {
69
+ return this.#root;
70
+ }
71
+
72
+ /** How many whole files are held. */
73
+ get size() {
74
+ return this.#held.size;
75
+ }
76
+
77
+ /** What they weigh. */
78
+ get bytes() {
79
+ let total = 0;
80
+ for (const file of this.#held.values()) {
81
+ total += file.length;
82
+ }
83
+ return total;
84
+ }
85
+
86
+ /**
87
+ * @param {string} infoHash
88
+ * @param {number} fileIndex
89
+ * @returns {string}
90
+ */
91
+ #keyOf(infoHash, fileIndex) {
92
+ return `${String(infoHash)}/${fileIndex}`;
93
+ }
94
+
95
+ /**
96
+ * The whole file for this torrent and index, or null.
97
+ *
98
+ * @param {string} infoHash
99
+ * @param {number} fileIndex
100
+ * @returns {{ path: string, length: number, name: string } | null}
101
+ */
102
+ find(infoHash, fileIndex) {
103
+ return this.#held.get(this.#keyOf(infoHash, fileIndex)) ?? null;
104
+ }
105
+
106
+ /**
107
+ * Take up whole files a previous life of this proxy left here.
108
+ *
109
+ * @param {(infoHash: string, fileIndex: number) => number | null} lengthOf -
110
+ * What the torrent says this file weighs, or null when it is not known. A
111
+ * file of the wrong size was being written when the process died and is not
112
+ * adopted.
113
+ * @returns {Promise<number>} How many were taken up.
114
+ */
115
+ async adopt(lengthOf) {
116
+ let adopted = 0;
117
+ let torrents = [];
118
+ try {
119
+ torrents = await fs.readdir(this.#root, { withFileTypes: true });
120
+ } catch {
121
+ return 0;
122
+ }
123
+ for (const entry of torrents) {
124
+ if (!entry.isDirectory()) {
125
+ continue;
126
+ }
127
+ const infoHash = entry.name;
128
+ let files = [];
129
+ try {
130
+ files = await fs.readdir(path.join(this.#root, infoHash), { withFileTypes: true });
131
+ } catch {
132
+ continue;
133
+ }
134
+ let manifest = {};
135
+ try {
136
+ manifest = JSON.parse(
137
+ await fs.readFile(path.join(this.#root, infoHash, "manifest.json"), "utf8")
138
+ );
139
+ } catch {
140
+ // No manifest, or an unreadable one: the bytes are still servable, and
141
+ // a file named by its number is better than a file thrown away.
142
+ }
143
+ for (const held of files) {
144
+ const fileIndex = Number.parseInt(held.name, 10);
145
+ if (!held.isFile() || !Number.isInteger(fileIndex) || fileIndex < 0) {
146
+ continue;
147
+ }
148
+ const where = path.join(this.#root, infoHash, held.name);
149
+ try {
150
+ const { size } = await fs.stat(where);
151
+ const expected = lengthOf(infoHash, fileIndex);
152
+ if (expected !== null && size !== expected) {
153
+ await fs.rm(where, REMOVAL);
154
+ continue;
155
+ }
156
+ this.#held.set(this.#keyOf(infoHash, fileIndex), {
157
+ path: where,
158
+ length: size,
159
+ name: String(manifest?.[String(fileIndex)]?.name ?? fileIndex)
160
+ });
161
+ adopted += 1;
162
+ } catch {
163
+ // Gone between the listing and the reading: not ours to worry about.
164
+ }
165
+ }
166
+ }
167
+ return adopted;
168
+ }
169
+
170
+ /**
171
+ * Write one whole file out of whatever the torrent can read it from.
172
+ *
173
+ * Written under a name nothing serves and renamed when it is closed, so a
174
+ * process killed halfway leaves no file that looks complete. The rename is
175
+ * the one moment at which the file becomes servable, which is also why
176
+ * nothing has to be locked against readers.
177
+ *
178
+ * @param {object} params
179
+ * @param {string} params.infoHash
180
+ * @param {number} params.fileIndex
181
+ * @param {number} params.length - What the torrent says the file weighs.
182
+ * @param {string} params.name - What the torrent calls it. Kept because the
183
+ * torrent is what is about to be deleted, and a file with no name can only
184
+ * be served as a number.
185
+ * @param {() => NodeJS.ReadableStream} params.open - The torrent's own read of
186
+ * the whole file.
187
+ * @returns {Promise<{ path: string, length: number } | null>} Null when
188
+ * another pass is already writing it, or when what was read does not weigh
189
+ * what the torrent said.
190
+ */
191
+ async keep({ infoHash, fileIndex, length, name, open }) {
192
+ const key = this.#keyOf(infoHash, fileIndex);
193
+ const held = this.#held.get(key);
194
+ if (held) {
195
+ return held;
196
+ }
197
+ if (this.#writing.has(key)) {
198
+ return null;
199
+ }
200
+ this.#writing.add(key);
201
+ const directory = path.join(this.#root, String(infoHash));
202
+ const where = path.join(directory, String(fileIndex));
203
+ const partial = `${where}.partial`;
204
+ try {
205
+ await fs.mkdir(directory, { recursive: true });
206
+ await fs.rm(partial, REMOVAL);
207
+ const source = open();
208
+ const handle = await fs.open(partial, "w");
209
+ let written = 0;
210
+ try {
211
+ for await (const chunk of source) {
212
+ await handle.write(chunk);
213
+ written += chunk.length;
214
+ }
215
+ } finally {
216
+ await handle.close();
217
+ }
218
+ if (written !== length) {
219
+ // The read ended early — the data went away mid-write, which over a
220
+ // torrent is ordinary. What must not happen is a short file under a
221
+ // name that says it is whole.
222
+ await fs.rm(partial, REMOVAL);
223
+ return null;
224
+ }
225
+ await fs.rename(partial, where);
226
+ const file = { path: where, length, name: String(name ?? fileIndex) };
227
+ this.#held.set(key, file);
228
+ await this.#writeManifest(infoHash);
229
+ return file;
230
+ } catch {
231
+ await fs.rm(partial, REMOVAL).catch(() => undefined);
232
+ return null;
233
+ } finally {
234
+ this.#writing.delete(key);
235
+ }
236
+ }
237
+
238
+ /**
239
+ * Write down what each file of one torrent is called and weighs.
240
+ *
241
+ * The torrent is what is about to be deleted, and it is the only thing that
242
+ * knows either. A number on disk is enough to serve the bytes and not enough
243
+ * to say what they are.
244
+ *
245
+ * @param {string} infoHash
246
+ * @returns {Promise<void>}
247
+ */
248
+ async #writeManifest(infoHash) {
249
+ const named = {};
250
+ for (const [key, file] of this.#held) {
251
+ if (key.startsWith(`${infoHash}/`)) {
252
+ named[key.slice(infoHash.length + 1)] = { length: file.length, name: file.name };
253
+ }
254
+ }
255
+ await fs
256
+ .writeFile(path.join(this.#root, String(infoHash), "manifest.json"), JSON.stringify(named))
257
+ .catch(() => undefined);
258
+ }
259
+
260
+ /**
261
+ * Forget and remove every whole file of one torrent.
262
+ *
263
+ * @param {string} infoHash
264
+ * @returns {Promise<void>}
265
+ */
266
+ async forget(infoHash) {
267
+ for (const key of [...this.#held.keys()]) {
268
+ if (key.startsWith(`${infoHash}/`)) {
269
+ this.#held.delete(key);
270
+ }
271
+ }
272
+ await fs.rm(path.join(this.#root, String(infoHash)), { ...REMOVAL, recursive: true }).catch(
273
+ () => undefined
274
+ );
275
+ }
276
+ }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * @file Reading one piece of a torrent out of the files it has already been
3
+ * assembled into.
4
+ *
5
+ * THE KEYSTONE OF KEEPING WHOLE FILES AT ALL. Without it a whole file is a
6
+ * second copy of bytes the piece store is also holding, and neither copy can be
7
+ * dropped: the store cannot drop its own because it is what every piece read
8
+ * goes to, and the file cannot be dropped because it is what a torrent-free read
9
+ * goes to. With it the store has a place to fall back to, so
10
+ *
11
+ * 1. the spilled copy of a whole file is redundant and can go — the film stops
12
+ * being on the disk twice, which on the field host of 2026-09-11 was
13
+ * 1417 MB of segments plus 1424 MB of spilled pieces for one episode;
14
+ * 2. the torrent can be destroyed with its store and added again later
15
+ * without fetching a byte: what it verifies, it reads from here.
16
+ *
17
+ * A piece is a byte range of the torrent, and the torrent's files are laid end
18
+ * to end in that same space — so a piece belongs to one file, or straddles the
19
+ * boundary between two. Both cases are the same walk.
20
+ */
21
+
22
+ import fs from "node:fs/promises";
23
+
24
+ /**
25
+ * Read one piece out of whole files, or answer null.
26
+ *
27
+ * Null when any part of the piece is in a file this proxy does not hold whole:
28
+ * a piece half read is worse than a piece not read, because the layer above
29
+ * would hash it and mark the piece bad.
30
+ *
31
+ * @param {object} params
32
+ * @param {number} params.index - The piece.
33
+ * @param {number} params.pieceLength - Every piece but the last is this long.
34
+ * @param {number} params.length - What the whole torrent weighs.
35
+ * @param {Array<{ offset: number, length: number }>} params.files - The
36
+ * torrent's files in order, as it lays them out.
37
+ * @param {(fileIndex: number) => { path: string, length: number } | null} params.wholeFileAt
38
+ * @returns {Promise<Buffer | null>}
39
+ */
40
+ export async function pieceFromWholeFiles({ index, pieceLength, length, files, wholeFileAt }) {
41
+ if (!Array.isArray(files) || files.length === 0 || !(pieceLength > 0)) {
42
+ return null;
43
+ }
44
+ const pieceStart = index * pieceLength;
45
+ const pieceEnd = Math.min(pieceStart + pieceLength, length) - 1;
46
+ if (pieceStart > pieceEnd) {
47
+ return null;
48
+ }
49
+ const piece = Buffer.allocUnsafe(pieceEnd - pieceStart + 1);
50
+ let filled = 0;
51
+ for (const [fileIndex, file] of files.entries()) {
52
+ const fileStart = Number(file?.offset ?? 0);
53
+ const fileEnd = fileStart + Number(file?.length ?? 0) - 1;
54
+ const from = Math.max(pieceStart, fileStart);
55
+ const to = Math.min(pieceEnd, fileEnd);
56
+ if (from > to) {
57
+ continue;
58
+ }
59
+ const whole = wholeFileAt(fileIndex);
60
+ if (!whole) {
61
+ // Part of this piece is in a file this proxy does not hold whole.
62
+ return null;
63
+ }
64
+ let handle = null;
65
+ try {
66
+ handle = await fs.open(whole.path, "r");
67
+ const { bytesRead } = await handle.read(piece, from - pieceStart, to - from + 1, from - fileStart);
68
+ if (bytesRead !== to - from + 1) {
69
+ return null;
70
+ }
71
+ filled += bytesRead;
72
+ } catch {
73
+ return null;
74
+ } finally {
75
+ await handle?.close().catch(() => undefined);
76
+ }
77
+ }
78
+ return filled === piece.length ? piece : null;
79
+ }
80
+
81
+ /**
82
+ * Whether every byte of one piece is in files this proxy holds whole.
83
+ *
84
+ * Asked before dropping a spilled copy, and answered without reading anything.
85
+ *
86
+ * @param {object} params
87
+ * @param {number} params.index
88
+ * @param {number} params.pieceLength
89
+ * @param {number} params.length
90
+ * @param {Array<{ offset: number, length: number }>} params.files
91
+ * @param {(fileIndex: number) => { path: string, length: number } | null} params.wholeFileAt
92
+ * @returns {boolean}
93
+ */
94
+ export function pieceIsInWholeFiles({ index, pieceLength, length, files, wholeFileAt }) {
95
+ if (!Array.isArray(files) || files.length === 0 || !(pieceLength > 0)) {
96
+ return false;
97
+ }
98
+ const pieceStart = index * pieceLength;
99
+ const pieceEnd = Math.min(pieceStart + pieceLength, length) - 1;
100
+ if (pieceStart > pieceEnd) {
101
+ return false;
102
+ }
103
+ let covered = 0;
104
+ for (const [fileIndex, file] of files.entries()) {
105
+ const fileStart = Number(file?.offset ?? 0);
106
+ const fileEnd = fileStart + Number(file?.length ?? 0) - 1;
107
+ const from = Math.max(pieceStart, fileStart);
108
+ const to = Math.min(pieceEnd, fileEnd);
109
+ if (from > to) {
110
+ continue;
111
+ }
112
+ if (!wholeFileAt(fileIndex)) {
113
+ return false;
114
+ }
115
+ covered += to - from + 1;
116
+ }
117
+ return covered === pieceEnd - pieceStart + 1;
118
+ }
@@ -94,6 +94,8 @@ import { LiveOutputs } from "./output/LiveOutputs.js";
94
94
  import { variantHeightsFor } from "./output/ladder.js";
95
95
  import { EncodeOrchestrator } from "./orchestrators/EncodeOrchestrator.js";
96
96
  import { wireDiskSpace } from "./disk/wire.js";
97
+ import { IDLE_KEEP_MS } from "./disk/keep.js";
98
+ import { Returns } from "./disk/returns.js";
97
99
  import { freeBytesFor } from "./disk/free.js";
98
100
 
99
101
  /**
@@ -474,7 +476,7 @@ const DEFAULT_SESSION_TTL_MS = 30 * 60 * 1000;
474
476
  * not this; this only stops something nobody has touched all day from sitting
475
477
  * there for the life of the process.
476
478
  */
477
- const SEGMENT_STORE_IDLE_MS = 6 * 60 * 60 * 1000;
479
+ const SEGMENT_STORE_IDLE_MS = IDLE_KEEP_MS;
478
480
  const DEFAULT_STARTUP_WAIT_MS = 5_000;
479
481
  // Realtime budget — runtime downswitch (software encoder only). Periodically
480
482
  // check each active software-transcode session's ffmpeg `speed`; when it stays
@@ -1605,6 +1607,10 @@ export class HlsSessionManager {
1605
1607
  void this.cleanupExpired();
1606
1608
  }, CLEANUP_INTERVAL_MS);
1607
1609
  this.cleanupTimer.unref();
1610
+ // How long after material stops being read somebody asks for it again — the
1611
+ // one term of the keeping period that is guessed rather than measured, and
1612
+ // the only place it can be measured from.
1613
+ this.returns = new Returns();
1608
1614
  // One owner of the disk, and the list of what takes it lives with the owner.
1609
1615
  this.diskSpace = wireDiskSpace({
1610
1616
  segmentStore: this.segmentStore,
@@ -2257,6 +2263,10 @@ export class HlsSessionManager {
2257
2263
  // session was never registered, and no sweep looks for one. Proxy
2258
2264
  // 2.9.101-2.9.102 failed here on every single request and the leftovers
2259
2265
  // were the only trace of it on disk.
2266
+ // A RETURN, if this output was held before — and its age, which is the one
2267
+ // term of the keeping period that nothing measures. Read BEFORE the
2268
+ // directory is claimed, since claiming it is what marks it read.
2269
+ this.returns.note({ lastReadAt: this.segmentStore.lastReadAt(spec.toKey()), now: Date.now() });
2260
2270
  this.segmentStore.directoryFor(spec.toKey());
2261
2271
  this.segmentStore.useFormat(spec.toKey(), segmentFormat);
2262
2272
 
@@ -9678,6 +9688,12 @@ export class HlsSessionManager {
9678
9688
  // last read, and how much room the disk has for the lot.
9679
9689
  // The room is the disk owner's to divide; this asks what the share is now.
9680
9690
  await this.diskSpace.revise();
9691
+ // What viewers actually do, beside the period that stands in for it. Said
9692
+ // where it can be read against the disk figures rather than on its own.
9693
+ const returns = this.returns.describe(IDLE_KEEP_MS);
9694
+ if (returns !== null) {
9695
+ logger.info(returns);
9696
+ }
9681
9697
  this.segmentStore.enforce({
9682
9698
  idleMs: SEGMENT_STORE_IDLE_MS,
9683
9699
  maxBytes: this.diskSpace.segmentBytes(),
@@ -1434,6 +1434,10 @@ function measureEncodeSlope(ffmpegBin, encoder, rung, rawFramesPath) {
1434
1434
  return new Promise((resolve) => {
1435
1435
  const args = [
1436
1436
  "-hide_banner", "-loglevel", "error", "-nostats",
1437
+ // What a device-backed encoder needs before the input — the device. Not
1438
+ // its decoding setup: this is fed raw frames and there is nothing to
1439
+ // decode, and `-hwaccel vaapi` over rawvideo fails to open.
1440
+ ...(typeof encoder?.benchmarkInputArgs === "function" ? encoder.benchmarkInputArgs() : []),
1437
1441
  "-stream_loop", "-1",
1438
1442
  "-f", "rawvideo", "-pix_fmt", "yuv420p",
1439
1443
  "-s", `${BENCHMARK_REF_W}x${BENCHMARK_REF_H}`, "-r", String(TRANSCODE_FPS),
@@ -60,8 +60,8 @@ export function computeCutGrid({ useKeyframeGrid, durationSeconds, segDur, keyfr
60
60
  const base = Number.isFinite(startTime) ? startTime : 0;
61
61
  const uniform = () => {
62
62
  const boundaries = [];
63
- for (let t = 0; t < total - 0.001; t += step) {
64
- boundaries.push(Number(t.toFixed(6)));
63
+ for (let cutAt = 0; cutAt < total - 0.001; cutAt += step) {
64
+ boundaries.push(Number(cutAt.toFixed(6)));
65
65
  }
66
66
  boundaries.push(total);
67
67
  // One clock: nothing here is a keyframe of the source, so nothing is owed
@@ -74,7 +74,17 @@ export function computeCutGrid({ useKeyframeGrid, durationSeconds, segDur, keyfr
74
74
  const kept = keyframeTimes
75
75
  .filter((time) => Number.isFinite(time))
76
76
  .map((time) => ({ source: time, published: time - base }))
77
- .filter((cut) => cut.published >= -0.001 && cut.published < total - 0.05)
77
+ // THE END IS MEASURED THE SAME WAY AS EVERY OTHER CUT. A keyframe nearer to
78
+ // the end than one segment leaves a tail too short to be a segment, and the
79
+ // rule below — no cut closer than a step to the one before it — never looks
80
+ // at the end at all. It used to be guarded by fifty milliseconds, a number
81
+ // from nowhere: field 2026-09-11, a keyframe 160 ms before the end of a
82
+ // 54-minute film passed it and left segment #541 lasting 0.16 s. The sound
83
+ // has no data in such a tail, so its run made 541 segments where the picture
84
+ // made 542, was marked short, and the repair that followed was handed a
85
+ // start later than its own end — 190 bytes that are not a fragment, and a
86
+ // viewer held 23 s at the last minute of the film for a 404.
87
+ .filter((cut) => cut.published >= -0.001 && cut.published < total - step)
78
88
  .sort((left, right) => left.published - right.published);
79
89
  // The first cut is the start of the file, whatever the container's own clock
80
90
  // says that is; the last is its end. Neither is a keyframe, and a run never