@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.
- package/CHANGELOG.md +44 -0
- package/CLAUDE.md +11 -0
- package/docs/disk-architecture.md +161 -0
- package/docs/encode-architecture.md +36 -7
- package/package.json +1 -1
- package/routes/api/sources/stats/get.js +11 -2
- package/routes/stream/get.js +74 -3
- package/services/delivery-probe.js +248 -43
- package/services/disk/keep.js +48 -0
- package/services/disk/returns.js +103 -0
- package/services/download/SwarmSelection.js +5 -5
- package/services/download/registry.js +20 -0
- package/services/encode/EncodeRun.js +1 -0
- package/services/encode/Encoder.js +15 -0
- package/services/encode/QsvEncoder.js +5 -0
- package/services/encode/SegmentStore.js +14 -0
- package/services/encode/VaapiEncoder.js +5 -0
- package/services/encode/encode-exit.js +17 -0
- package/services/encode/start-stop-cost.js +6 -2
- package/services/files/CompletedFiles.js +276 -0
- package/services/files/piece-from-whole-file.js +118 -0
- package/services/hls-session-manager.js +17 -1
- package/services/hwaccel.js +4 -0
- package/services/output/cut-grid.js +13 -3
- package/services/piece-store/piece-disk-store.js +143 -8
- package/services/piece-store/piece-lru.js +17 -0
- package/services/piece-store/shared-piece-store.js +1803 -1549
- package/services/torrent-pool.js +246 -26
- package/services/torrent-worker/client.js +21 -0
- package/services/torrent-worker/protocol.js +9 -1
- package/services/torrent-worker/worker.js +183 -2
- package/test/completed-files.test.js +115 -0
- package/test/cuts-follow-published-grid.test.js +35 -0
- package/test/delivery-probe.test.js +114 -1
- package/test/encode-exit.test.js +18 -0
- package/test/keeping-period.test.js +83 -0
- package/test/piece-disk-store.test.js +114 -0
- package/test/piece-from-whole-file.test.js +129 -0
- package/test/piece-store-eviction.test.js +28 -15
- package/test/piece-store-never-refuses.test.js +153 -0
- package/test/piece-store-reservations.test.js +16 -3
- package/test/probe-wedge-certainty.test.js +3 -3
- package/test/shared-piece-store.test.js +27 -13
- package/test/stream-route.test.js +41 -0
- package/test/swarm-follows-readers.test.js +126 -0
- package/test/swarm-reach.test.js +5 -0
- 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
|
-
|
|
85
|
-
|
|
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 =
|
|
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(),
|
package/services/hwaccel.js
CHANGED
|
@@ -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
|
|
64
|
-
boundaries.push(Number(
|
|
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
|
-
|
|
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
|