@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
|
@@ -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
|
/**
|
|
@@ -72,10 +73,16 @@ export class PieceDiskStore {
|
|
|
72
73
|
|
|
73
74
|
#evictions = 0;
|
|
74
75
|
|
|
76
|
+
/** How many were thrown away for being behind every reader. */
|
|
77
|
+
#behind = 0;
|
|
78
|
+
|
|
75
79
|
#bytes = 0;
|
|
76
80
|
|
|
77
81
|
#now;
|
|
78
82
|
|
|
83
|
+
/** Where the live readers stand, from whoever holds that fact. @type {() => number[]} */
|
|
84
|
+
#readHeads;
|
|
85
|
+
|
|
79
86
|
/**
|
|
80
87
|
* @param {object} params
|
|
81
88
|
* @param {string} params.directory - Where this torrent's pieces live.
|
|
@@ -88,11 +95,65 @@ export class PieceDiskStore {
|
|
|
88
95
|
* means nobody has said yet, and nothing is evicted until somebody does.
|
|
89
96
|
* @param {() => number} [params.now]
|
|
90
97
|
*/
|
|
91
|
-
constructor({ directory, name, chunkLength, allowanceBytes = null, now = Date.now }) {
|
|
98
|
+
constructor({ directory, name, chunkLength, allowanceBytes = null, now = Date.now, readHeads = () => [] }) {
|
|
92
99
|
this.#directory = path.join(directory, name);
|
|
93
100
|
this.#chunkLength = chunkLength;
|
|
94
101
|
this.#allowanceBytes = Number.isFinite(allowanceBytes) && allowanceBytes >= 0 ? allowanceBytes : null;
|
|
95
102
|
this.#now = now;
|
|
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
|
+
}
|
|
96
157
|
}
|
|
97
158
|
|
|
98
159
|
/** Where this store's pieces live, for logging and cleanup. */
|
|
@@ -100,6 +161,15 @@ export class PieceDiskStore {
|
|
|
100
161
|
return this.#directory;
|
|
101
162
|
}
|
|
102
163
|
|
|
164
|
+
/**
|
|
165
|
+
* Every piece this tier holds, as numbers.
|
|
166
|
+
*
|
|
167
|
+
* @returns {number[]}
|
|
168
|
+
*/
|
|
169
|
+
indexes() {
|
|
170
|
+
return [...this.#stored.keys()];
|
|
171
|
+
}
|
|
172
|
+
|
|
103
173
|
/** How many pieces are on disk. */
|
|
104
174
|
get size() {
|
|
105
175
|
return this.#stored.size;
|
|
@@ -139,6 +209,45 @@ export class PieceDiskStore {
|
|
|
139
209
|
return this.#stored.has(index);
|
|
140
210
|
}
|
|
141
211
|
|
|
212
|
+
/**
|
|
213
|
+
* Throw away what no reader will ask for again, without waiting for the disk
|
|
214
|
+
* to be short.
|
|
215
|
+
*
|
|
216
|
+
* THE SECOND RULE, and it answers a different question from the ceiling.
|
|
217
|
+
* Material nobody needs should not sit on somebody's disk merely because
|
|
218
|
+
* there is room for it — the ceiling here is a share of what is free, and on
|
|
219
|
+
* a roomy host that is tens of gigabytes against a measured growth of 14 400
|
|
220
|
+
* MB in one viewing, so the ceiling alone never binds and nothing is ever
|
|
221
|
+
* removed until the torrent itself goes.
|
|
222
|
+
*
|
|
223
|
+
* A piece BEHIND every read head has been read and will not be read again
|
|
224
|
+
* unless somebody seeks back — and a seek back re-downloads it, which is the
|
|
225
|
+
* bargain this tier already makes when it drops a piece for room. Nothing is
|
|
226
|
+
* thrown away while any reader might still reach it.
|
|
227
|
+
*
|
|
228
|
+
* With no reader at all nothing is removed: a store between reads is not a
|
|
229
|
+
* store nobody wants, and the torrent going idle is what empties it whole.
|
|
230
|
+
*
|
|
231
|
+
* @param {number[]} readHeads - The first piece each live reader still wants.
|
|
232
|
+
* @returns {number} How many pieces were thrown away.
|
|
233
|
+
*/
|
|
234
|
+
forgetBehind(readHeads) {
|
|
235
|
+
const heads = (readHeads ?? []).filter((at) => Number.isInteger(at));
|
|
236
|
+
if (heads.length === 0) {
|
|
237
|
+
return 0;
|
|
238
|
+
}
|
|
239
|
+
const earliest = Math.min(...heads);
|
|
240
|
+
let removed = 0;
|
|
241
|
+
for (const index of [...this.#stored.keys()]) {
|
|
242
|
+
if (index < earliest && !this.#reading.has(index)) {
|
|
243
|
+
this.forget(index);
|
|
244
|
+
this.#behind += 1;
|
|
245
|
+
removed += 1;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return removed;
|
|
249
|
+
}
|
|
250
|
+
|
|
142
251
|
/**
|
|
143
252
|
* Write a piece out, making room for it first.
|
|
144
253
|
*
|
|
@@ -167,11 +276,18 @@ export class PieceDiskStore {
|
|
|
167
276
|
/**
|
|
168
277
|
* Read a piece back into a buffer the caller already owns.
|
|
169
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
|
+
*
|
|
170
285
|
* @param {number} index
|
|
171
286
|
* @param {Uint8Array} target - Destination; its length is what gets read.
|
|
287
|
+
* @param {number} [at] - Offset within the piece to start at.
|
|
172
288
|
* @returns {Promise<number>} Bytes read.
|
|
173
289
|
*/
|
|
174
|
-
async read(index, target) {
|
|
290
|
+
async read(index, target, at = 0) {
|
|
175
291
|
if (!this.#stored.has(index)) {
|
|
176
292
|
throw new Error(`Piece ${index} is not on disk.`);
|
|
177
293
|
}
|
|
@@ -183,7 +299,7 @@ export class PieceDiskStore {
|
|
|
183
299
|
let handle = null;
|
|
184
300
|
try {
|
|
185
301
|
handle = await fs.open(this.#pathOf(index), "r");
|
|
186
|
-
const { bytesRead } = await handle.read(target, 0, target.length, 0);
|
|
302
|
+
const { bytesRead } = await handle.read(target, 0, target.length, Math.max(0, at));
|
|
187
303
|
this.#touched.set(index, this.#now());
|
|
188
304
|
return bytesRead;
|
|
189
305
|
} finally {
|
|
@@ -234,14 +350,15 @@ export class PieceDiskStore {
|
|
|
234
350
|
/**
|
|
235
351
|
* What it holds, what it may hold, and what it has had to throw away.
|
|
236
352
|
*
|
|
237
|
-
* @returns {{ pieces: number, bytes: number, allowanceBytes: number | null, evictions: number }}
|
|
353
|
+
* @returns {{ pieces: number, bytes: number, allowanceBytes: number | null, evictions: number, behind: number }}
|
|
238
354
|
*/
|
|
239
355
|
stats() {
|
|
240
356
|
return {
|
|
241
357
|
pieces: this.#stored.size,
|
|
242
358
|
bytes: this.#bytes,
|
|
243
359
|
allowanceBytes: this.#allowanceBytes,
|
|
244
|
-
evictions: this.#evictions
|
|
360
|
+
evictions: this.#evictions,
|
|
361
|
+
behind: this.#behind
|
|
245
362
|
};
|
|
246
363
|
}
|
|
247
364
|
|
|
@@ -349,14 +466,32 @@ export class PieceDiskStore {
|
|
|
349
466
|
* @returns {number | null}
|
|
350
467
|
*/
|
|
351
468
|
#leastRecentlyUsed(except) {
|
|
469
|
+
// WHERE THE READERS STAND DECIDES, and last use only settles ties.
|
|
470
|
+
//
|
|
471
|
+
// What lies behind every read head has been read and will not be read again
|
|
472
|
+
// unless somebody seeks back, so it goes before anything ahead of them,
|
|
473
|
+
// furthest behind first. It is the order the segments are given one layer
|
|
474
|
+
// up, and the order the priority map states, read from the other end.
|
|
475
|
+
// Without the heads there is nothing to order by and last use is all that
|
|
476
|
+
// is left — which is what this was, and what said nothing about what
|
|
477
|
+
// anybody is about to read.
|
|
478
|
+
const heads = this.#readHeads().filter((at) => Number.isInteger(at));
|
|
479
|
+
const earliest = heads.length > 0 ? Math.min(...heads) : null;
|
|
352
480
|
let victim = null;
|
|
353
|
-
let
|
|
481
|
+
let worst = null;
|
|
354
482
|
for (const [index, at] of this.#touched) {
|
|
355
483
|
if (index === except || this.#reading.has(index)) {
|
|
356
484
|
continue;
|
|
357
485
|
}
|
|
358
|
-
|
|
359
|
-
|
|
486
|
+
const behind = earliest !== null && index < earliest;
|
|
487
|
+
const score = { behind, distance: behind ? earliest - index : 0, at };
|
|
488
|
+
if (
|
|
489
|
+
worst === null
|
|
490
|
+
|| (score.behind && !worst.behind)
|
|
491
|
+
|| (score.behind === worst.behind && score.distance > worst.distance)
|
|
492
|
+
|| (score.behind === worst.behind && score.distance === worst.distance && score.at < worst.at)
|
|
493
|
+
) {
|
|
494
|
+
worst = score;
|
|
360
495
|
victim = index;
|
|
361
496
|
}
|
|
362
497
|
}
|
|
@@ -258,6 +258,23 @@ export class PieceLru {
|
|
|
258
258
|
return want;
|
|
259
259
|
}
|
|
260
260
|
|
|
261
|
+
/**
|
|
262
|
+
* Where the live readers stand, as the first piece each of them still wants.
|
|
263
|
+
*
|
|
264
|
+
* A declared range begins at the piece its reader is about to read, so the
|
|
265
|
+
* earliest of those beginnings is the point behind which nothing will be
|
|
266
|
+
* asked for again unless somebody seeks back. That is what makes a spilled
|
|
267
|
+
* piece disposable without waiting for the disk to be short: a piece behind
|
|
268
|
+
* every reader has been read and will not be read again.
|
|
269
|
+
*
|
|
270
|
+
* @returns {number[]} One number per reader, unsorted. Empty when nobody has
|
|
271
|
+
* declared anything, which is a stronger statement than any position — no
|
|
272
|
+
* piece of this file is spoken for at all.
|
|
273
|
+
*/
|
|
274
|
+
readHeads() {
|
|
275
|
+
return [...this.#protected.values()].map((range) => range.from);
|
|
276
|
+
}
|
|
277
|
+
|
|
261
278
|
/**
|
|
262
279
|
* How many pieces the live readers between them are asking to keep, against
|
|
263
280
|
* how many this store may hold.
|