@torrent-tv/proxy 2.83.0 → 2.83.2
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 +41 -0
- 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/download/SwarmSelection.js +5 -5
- package/services/download/registry.js +20 -0
- package/services/encode/EncodeRun.js +1 -0
- package/services/encode/encode-exit.js +17 -0
- package/services/files/CompletedFiles.js +276 -0
- package/services/files/piece-from-whole-file.js +118 -0
- package/services/output/cut-grid.js +13 -3
- package/services/piece-store/piece-disk-store.js +72 -2
- package/services/piece-store/shared-piece-store.js +274 -27
- package/services/torrent-pool.js +259 -19
- 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/piece-disk-store.test.js +26 -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 +168 -0
- package/test/swarm-reach.test.js +5 -0
- package/test/upload-hurry.test.js +27 -0
|
@@ -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
|
+
}
|
|
@@ -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
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
*/
|
|
32
32
|
|
|
33
33
|
import fs from "node:fs/promises";
|
|
34
|
+
import { readdirSync, statSync } from "node:fs";
|
|
34
35
|
import path from "node:path";
|
|
35
36
|
|
|
36
37
|
/**
|
|
@@ -100,6 +101,59 @@ export class PieceDiskStore {
|
|
|
100
101
|
this.#allowanceBytes = Number.isFinite(allowanceBytes) && allowanceBytes >= 0 ? allowanceBytes : null;
|
|
101
102
|
this.#now = now;
|
|
102
103
|
this.#readHeads = typeof readHeads === "function" ? readHeads : () => [];
|
|
104
|
+
this.#adoptWhatIsAlreadyHere();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Take up the pieces a previous life of this torrent left in this directory.
|
|
109
|
+
*
|
|
110
|
+
* The directory is the torrent's own, so what is in it belongs to it — and
|
|
111
|
+
* without this, nothing ever reads those files again: a torrent that is torn
|
|
112
|
+
* down and added back gets a store whose index starts empty, answers "not on
|
|
113
|
+
* disk" for every piece it in fact has, and downloads the film a second time
|
|
114
|
+
* while the first copy sits beside it. That is what a torrent destroyed by an
|
|
115
|
+
* error left behind until 2026-09-11, and what the pool's own restart leaves
|
|
116
|
+
* behind every time.
|
|
117
|
+
*
|
|
118
|
+
* Read once, synchronously, because `has()` is answered synchronously and the
|
|
119
|
+
* torrent asks it immediately — a piece reported missing while a scan is
|
|
120
|
+
* still running is a piece fetched again. One directory listing per torrent.
|
|
121
|
+
*
|
|
122
|
+
* Correctness is not taken on trust: the torrent hashes every piece it means
|
|
123
|
+
* to use, so a file here that does not match is refused by the layer above
|
|
124
|
+
* and downloaded again.
|
|
125
|
+
*
|
|
126
|
+
* @returns {void}
|
|
127
|
+
*/
|
|
128
|
+
#adoptWhatIsAlreadyHere() {
|
|
129
|
+
let entries = [];
|
|
130
|
+
try {
|
|
131
|
+
entries = readdirSync(this.#directory, { withFileTypes: true });
|
|
132
|
+
} catch {
|
|
133
|
+
// No directory yet: this torrent is new here, which is the ordinary case.
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
const born = this.#now();
|
|
137
|
+
for (const entry of entries) {
|
|
138
|
+
if (!entry.isFile() || !entry.name.endsWith(".piece")) {
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
const index = Number.parseInt(entry.name.slice(0, -".piece".length), 10);
|
|
142
|
+
if (!Number.isInteger(index) || index < 0) {
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
try {
|
|
146
|
+
const { size } = statSync(path.join(this.#directory, entry.name));
|
|
147
|
+
if (size <= 0) {
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
this.#stored.set(index, size);
|
|
151
|
+
this.#touched.set(index, born);
|
|
152
|
+
this.#bytes += size;
|
|
153
|
+
} catch {
|
|
154
|
+
// Gone between the listing and the reading: not ours to worry about.
|
|
155
|
+
}
|
|
156
|
+
}
|
|
103
157
|
}
|
|
104
158
|
|
|
105
159
|
/** Where this store's pieces live, for logging and cleanup. */
|
|
@@ -107,6 +161,15 @@ export class PieceDiskStore {
|
|
|
107
161
|
return this.#directory;
|
|
108
162
|
}
|
|
109
163
|
|
|
164
|
+
/**
|
|
165
|
+
* Every piece this tier holds, as numbers.
|
|
166
|
+
*
|
|
167
|
+
* @returns {number[]}
|
|
168
|
+
*/
|
|
169
|
+
indexes() {
|
|
170
|
+
return [...this.#stored.keys()];
|
|
171
|
+
}
|
|
172
|
+
|
|
110
173
|
/** How many pieces are on disk. */
|
|
111
174
|
get size() {
|
|
112
175
|
return this.#stored.size;
|
|
@@ -213,11 +276,18 @@ export class PieceDiskStore {
|
|
|
213
276
|
/**
|
|
214
277
|
* Read a piece back into a buffer the caller already owns.
|
|
215
278
|
*
|
|
279
|
+
* PART of a piece, when the caller asks for one. A peer asks for 16 KB at a
|
|
280
|
+
* time and a piece here is megabytes, so reading the whole of it to answer
|
|
281
|
+
* one request is the difference between 16 KB and 4 MB off the disk — field
|
|
282
|
+
* 2026-09-11: 63 416 reads of which 21.6 % came from memory, 49 696 pieces
|
|
283
|
+
* revived whole, to serve an upload capped at 512 KB/s.
|
|
284
|
+
*
|
|
216
285
|
* @param {number} index
|
|
217
286
|
* @param {Uint8Array} target - Destination; its length is what gets read.
|
|
287
|
+
* @param {number} [at] - Offset within the piece to start at.
|
|
218
288
|
* @returns {Promise<number>} Bytes read.
|
|
219
289
|
*/
|
|
220
|
-
async read(index, target) {
|
|
290
|
+
async read(index, target, at = 0) {
|
|
221
291
|
if (!this.#stored.has(index)) {
|
|
222
292
|
throw new Error(`Piece ${index} is not on disk.`);
|
|
223
293
|
}
|
|
@@ -229,7 +299,7 @@ export class PieceDiskStore {
|
|
|
229
299
|
let handle = null;
|
|
230
300
|
try {
|
|
231
301
|
handle = await fs.open(this.#pathOf(index), "r");
|
|
232
|
-
const { bytesRead } = await handle.read(target, 0, target.length, 0);
|
|
302
|
+
const { bytesRead } = await handle.read(target, 0, target.length, Math.max(0, at));
|
|
233
303
|
this.#touched.set(index, this.#now());
|
|
234
304
|
return bytesRead;
|
|
235
305
|
} finally {
|