@torrent-tv/proxy 2.101.13 → 2.101.15
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/package.json
CHANGED
package/routes/stream/get.js
CHANGED
|
@@ -108,6 +108,65 @@ function wholeFileFor(torrentPool, sourceKey, fileIndex) {
|
|
|
108
108
|
return held.get(`${infoHash}/${fileIndex}`) ?? null;
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
+
/**
|
|
112
|
+
* Open the whole file a response under way can continue from, if there is one.
|
|
113
|
+
*
|
|
114
|
+
* Any failure to open it leaves the response on the torrent, which can still
|
|
115
|
+
* answer; a file the disk owner removed is also forgotten here, as the route
|
|
116
|
+
* does before headers.
|
|
117
|
+
*
|
|
118
|
+
* @param {{ wholeFiles?: Map<string, { path: string, length: number, name: string }> }} torrentPool
|
|
119
|
+
* @param {string} sourceKey
|
|
120
|
+
* @param {number} fileIndex
|
|
121
|
+
* @param {number} length - The length the response was promised for.
|
|
122
|
+
* @returns {Promise<import("node:fs/promises").FileHandle | null>}
|
|
123
|
+
*/
|
|
124
|
+
async function openWholeFileToContinue(torrentPool, sourceKey, fileIndex, length) {
|
|
125
|
+
const whole = wholeFileFor(torrentPool, sourceKey, fileIndex);
|
|
126
|
+
if (!whole || whole.length !== length) {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
try {
|
|
130
|
+
return await open(whole.path, "r");
|
|
131
|
+
} catch (error) {
|
|
132
|
+
if (error.code === "ENOENT") {
|
|
133
|
+
const key = `${sourceKey.slice("torrent:".length).toLowerCase()}/${fileIndex}`;
|
|
134
|
+
if (torrentPool.wholeFiles.get(key) === whole) torrentPool.wholeFiles.delete(key);
|
|
135
|
+
}
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Write bytes `from`..`end` of an open file to a response, each write waited
|
|
142
|
+
* for, and close the file.
|
|
143
|
+
*
|
|
144
|
+
* @param {import("node:http").ServerResponse} raw
|
|
145
|
+
* @param {import("node:fs/promises").FileHandle} handle
|
|
146
|
+
* @param {number} from - Inclusive.
|
|
147
|
+
* @param {number} end - Inclusive.
|
|
148
|
+
* @param {(length: number) => void} onWritten
|
|
149
|
+
* @returns {Promise<number>} Bytes written.
|
|
150
|
+
*/
|
|
151
|
+
async function writeFromWholeFile(raw, handle, from, end, onWritten) {
|
|
152
|
+
let written = 0;
|
|
153
|
+
try {
|
|
154
|
+
for await (const chunk of handle.createReadStream({ start: from, end, autoClose: false })) {
|
|
155
|
+
if (raw.writableEnded || raw.destroyed) {
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
await new Promise((resolve, reject) => {
|
|
159
|
+
raw.write(chunk, (error) => (error ? reject(error) : resolve()));
|
|
160
|
+
});
|
|
161
|
+
written += chunk.length;
|
|
162
|
+
onWritten(chunk.length);
|
|
163
|
+
}
|
|
164
|
+
} finally {
|
|
165
|
+
await handle.close().catch(() => undefined);
|
|
166
|
+
}
|
|
167
|
+
return written;
|
|
168
|
+
}
|
|
169
|
+
|
|
111
170
|
/**
|
|
112
171
|
* Serve a whole file off the disk, with ranges.
|
|
113
172
|
*
|
|
@@ -264,34 +323,66 @@ export async function handleStreamGet(req, reply, { sourceRegistry, torrentPool,
|
|
|
264
323
|
reply.raw.once("close", () => fragments.cancel());
|
|
265
324
|
|
|
266
325
|
let sent = 0;
|
|
326
|
+
// THE TORRENT STOPS ANSWERING FOR A FILE AS SOON AS IT IS A FILE, also for
|
|
327
|
+
// a response that began before (user, 2026-10-09, torrent-tv/meta#173). An
|
|
328
|
+
// encoder slower than the film is never suspended and reads one response
|
|
329
|
+
// to the end of the file; left on the piece store, that read keeps the
|
|
330
|
+
// torrent alive for as long as the viewer stays (torrent-tv/meta#105). So
|
|
331
|
+
// between fragments, and when the torrent's read fails, the rest of the
|
|
332
|
+
// range is taken from the file — the same bytes at the same offsets.
|
|
333
|
+
let continueFrom = null;
|
|
267
334
|
try {
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
await
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
335
|
+
try {
|
|
336
|
+
for await (const fragment of markWaits ? markingWaits(fragments, markWaits) : fragments) {
|
|
337
|
+
if (reply.raw.writableEnded || reply.raw.destroyed) {
|
|
338
|
+
fragment.release();
|
|
339
|
+
break;
|
|
340
|
+
}
|
|
341
|
+
continueFrom = await openWholeFileToContinue(torrentPool, sourceKey, fileIndex, file.length);
|
|
342
|
+
if (continueFrom) {
|
|
343
|
+
fragment.release();
|
|
344
|
+
break;
|
|
345
|
+
}
|
|
346
|
+
try {
|
|
347
|
+
await new Promise((resolve, reject) => {
|
|
348
|
+
reply.raw.write(fragment.bytes, (error) => (error ? reject(error) : resolve()));
|
|
349
|
+
});
|
|
350
|
+
sent += fragment.bytes.length;
|
|
351
|
+
// Say so, if this read belongs to a transcode session. It is the only
|
|
352
|
+
// proof that a session which has produced nothing yet is nevertheless
|
|
353
|
+
// being fed: the encoder's own progress cannot move until its first
|
|
354
|
+
// frame is decoded, and a viewer waiting for that first frame was being
|
|
355
|
+
// told the proxy had died while the swarm was delivering to it. Field
|
|
356
|
+
// 2026-09-03: 46.3 s on one piece, `processedSeconds` frozen at the
|
|
357
|
+
// start position throughout, and the browser gave up 0.4 s before the
|
|
358
|
+
// piece landed.
|
|
359
|
+
if (noteInputBytes) {
|
|
360
|
+
noteInputBytes(sessionId, fragment.bytes.length);
|
|
361
|
+
}
|
|
362
|
+
// Only now are these bytes gone: the piece can be unpinned, and the
|
|
363
|
+
// slot it occupies reused. Releasing before this point corrupts the
|
|
364
|
+
// response silently.
|
|
365
|
+
} finally {
|
|
366
|
+
fragment.release();
|
|
367
|
+
}
|
|
294
368
|
}
|
|
369
|
+
} catch (error) {
|
|
370
|
+
if (sent >= contentLength || req.raw.aborted || reply.raw.destroyed) throw error;
|
|
371
|
+
continueFrom = await openWholeFileToContinue(torrentPool, sourceKey, fileIndex, file.length);
|
|
372
|
+
if (!continueFrom) throw error;
|
|
373
|
+
}
|
|
374
|
+
if (continueFrom) {
|
|
375
|
+
fragments.cancel();
|
|
376
|
+
const fromTorrent = sent;
|
|
377
|
+
sent += await writeFromWholeFile(reply.raw, continueFrom, start + sent, end, (length) => {
|
|
378
|
+
if (noteInputBytes) {
|
|
379
|
+
noteInputBytes(sessionId, length);
|
|
380
|
+
}
|
|
381
|
+
});
|
|
382
|
+
logger.info(
|
|
383
|
+
`stream: read of "${file.name}" bytes ${start}-${end} continued from the whole file: ` +
|
|
384
|
+
`${fromTorrent} byte(s) from the torrent, ${sent - fromTorrent} from the file`
|
|
385
|
+
);
|
|
295
386
|
}
|
|
296
387
|
// THE BODY MUST BE AS LONG AS THE HEADER PROMISED, and nothing checked.
|
|
297
388
|
//
|
|
@@ -457,6 +457,9 @@ export class EncodeOrchestrator {
|
|
|
457
457
|
* @param {string} address
|
|
458
458
|
*/
|
|
459
459
|
forgetOutput(address) {
|
|
460
|
+
// The output no longer exists. Its last map must not remain an input to
|
|
461
|
+
// later plans while stopped child processes finish exiting.
|
|
462
|
+
this.notePriorityMap(address, []);
|
|
460
463
|
if (this.runsOn(address).length > 0) {
|
|
461
464
|
return;
|
|
462
465
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @file Which files of a torrent somebody is using, for the sweep that writes
|
|
3
|
-
* whole files out and removes a torrent that has nothing left to fetch
|
|
3
|
+
* whole files out and removes a torrent that has nothing left to fetch, and
|
|
4
|
+
* the two decisions that sweep makes with it.
|
|
4
5
|
*
|
|
5
6
|
* Two facts, and both are needed. A file somebody has stated a need for is in
|
|
6
7
|
* use. So is a file being read: a read whose pieces are all here waits for
|
|
@@ -34,6 +35,59 @@ export function filesInUse({ torrent, windows, openReads }) {
|
|
|
34
35
|
return used;
|
|
35
36
|
}
|
|
36
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Which downloaded files to write out whole now.
|
|
40
|
+
*
|
|
41
|
+
* WHILE SOMEBODY WATCHES IT, and only then (user, 2026-10-09,
|
|
42
|
+
* torrent-tv/meta#173): as soon as a media file is downloaded it becomes a file
|
|
43
|
+
* and is read from that file. Written after its last viewer has left, it serves
|
|
44
|
+
* nobody — field 2026-10-09: a film re-encoded at 0.56x reached 100 % while it
|
|
45
|
+
* played, stayed 667 spilled pieces for the whole showing because the encoder
|
|
46
|
+
* read it without a pause, and was written a minute after the tab closed.
|
|
47
|
+
*
|
|
48
|
+
* @param {object} options
|
|
49
|
+
* @param {Array<{ done?: boolean }>} options.files - The torrent's files.
|
|
50
|
+
* @param {(fileIndex: number) => boolean} options.isKept - Already a file, or being written.
|
|
51
|
+
* @param {Set<number>} options.inUse - {@link filesInUse}.
|
|
52
|
+
* @returns {number[]} File indices.
|
|
53
|
+
*/
|
|
54
|
+
export function filesToKeep({ files, isKept, inUse }) {
|
|
55
|
+
const indices = [];
|
|
56
|
+
for (const [fileIndex, file] of files.entries()) {
|
|
57
|
+
if (file?.done === true && inUse.has(fileIndex) && !isKept(fileIndex)) {
|
|
58
|
+
indices.push(fileIndex);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return indices;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Whether a torrent downloaded whole may be removed now.
|
|
66
|
+
*
|
|
67
|
+
* Every file of it is a file on disk, so a viewer's stated need is answered
|
|
68
|
+
* from those files and does not keep the torrent (torrent-tv/meta#173). A read
|
|
69
|
+
* still running on the piece store does, because removing the torrent closes
|
|
70
|
+
* that store under it (torrent-tv/meta#105). Nor in the sweep that announced a
|
|
71
|
+
* file: the main thread learns of it from that announcement, and a read it
|
|
72
|
+
* sent to this thread before then would find the torrent gone.
|
|
73
|
+
*
|
|
74
|
+
* @param {object} options
|
|
75
|
+
* @param {object} options.torrent
|
|
76
|
+
* @param {(fileIndex: number) => boolean} options.isKept - Already a file.
|
|
77
|
+
* @param {Iterable<{ torrent: object, fileIndex: number }>} options.openReads - Reads in flight, read just now.
|
|
78
|
+
* @param {boolean} options.announcedNow - A file of it was announced in this sweep.
|
|
79
|
+
* @returns {boolean}
|
|
80
|
+
*/
|
|
81
|
+
export function mayRemoveWholeTorrent({ torrent, isKept, openReads, announcedNow }) {
|
|
82
|
+
if (torrent?.done !== true || !Array.isArray(torrent.files) || announcedNow) {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
if (!torrent.files.every((unused, fileIndex) => isKept(fileIndex))) {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
return filesInUse({ torrent, windows: [], openReads }).size === 0;
|
|
89
|
+
}
|
|
90
|
+
|
|
37
91
|
/** Range admission owns its read before any asynchronous storage operation. */
|
|
38
92
|
export async function readWhileInUse(openReads, id, torrent, fileIndex, read) {
|
|
39
93
|
if (!torrent) return read();
|
|
@@ -33,7 +33,7 @@ import { heldRangesOf, ownsItsMemory, readHeldBytes, readHeldRanges } from "./he
|
|
|
33
33
|
import { CompletedFiles, completedFilesRoot } from "../../storage/files/CompletedFiles.js";
|
|
34
34
|
import { pieceFromWholeFiles, pieceIsInWholeFiles } from "../../storage/files/piece-from-whole-file.js";
|
|
35
35
|
import { Command, Event } from "./protocol.js";
|
|
36
|
-
import { filesInUse, readWhileInUse } from "./files-in-use.js";
|
|
36
|
+
import { filesInUse, filesToKeep, mayRemoveWholeTorrent, readWhileInUse } from "./files-in-use.js";
|
|
37
37
|
import { createWholeSources } from "./whole-sources.js";
|
|
38
38
|
import { wholeFileStats } from "./whole-file-stats.js";
|
|
39
39
|
import { describeHeldObjects, readUtpOpenCount } from "./held-objects.js";
|
|
@@ -1069,8 +1069,8 @@ const WHOLE_FILE_SWEEP_MS = 10_000;
|
|
|
1069
1069
|
const beingKept = new Set();
|
|
1070
1070
|
|
|
1071
1071
|
/**
|
|
1072
|
-
* Keep every file that is now whole, and let go of a
|
|
1073
|
-
* left to fetch.
|
|
1072
|
+
* Keep every file that is now whole while it is watched, and let go of a
|
|
1073
|
+
* torrent that has nothing left to fetch.
|
|
1074
1074
|
*
|
|
1075
1075
|
* The instruction this serves, 2026-09-11: as soon as a torrent is fully
|
|
1076
1076
|
* downloaded, downloading stops, the torrent is deleted, and what was
|
|
@@ -1098,21 +1098,22 @@ async function keepWholeFiles() {
|
|
|
1098
1098
|
windows: demandFor(torrent).register.windows(),
|
|
1099
1099
|
openReads: openReads.values()
|
|
1100
1100
|
});
|
|
1101
|
-
|
|
1101
|
+
// WHILE SOMEBODY WATCHES IT, and only then: see `filesToKeep`. Its cost
|
|
1102
|
+
// beside the viewer is the disk — the pieces are read with `store.get`,
|
|
1103
|
+
// which takes no block of memory from anybody.
|
|
1104
|
+
const toKeep = filesToKeep({
|
|
1105
|
+
files: torrent.files,
|
|
1106
|
+
isKept: (fileIndex) =>
|
|
1107
|
+
completedFiles.find(infoHash, fileIndex) !== null || beingKept.has(`${infoHash}/${fileIndex}`),
|
|
1108
|
+
inUse: wanted
|
|
1109
|
+
});
|
|
1110
|
+
let announcedNow = false;
|
|
1111
|
+
for (const fileIndex of toKeep) {
|
|
1112
|
+
const file = torrent.files[fileIndex];
|
|
1102
1113
|
const key = `${infoHash}/${fileIndex}`;
|
|
1103
|
-
if (file?.done !== true || completedFiles.find(infoHash, fileIndex) || beingKept.has(key)) {
|
|
1104
|
-
continue;
|
|
1105
|
-
}
|
|
1106
|
-
// NOT WHILE ANYBODY WANTS IT. Writing a film out is a read of the whole
|
|
1107
|
-
// of it and a write of the whole of it — a gigabyte and a half on the
|
|
1108
|
-
// file this was measured against — and doing that beside a viewer takes
|
|
1109
|
-
// the disk and the piece store from them for nothing they asked for. The
|
|
1110
|
-
// file is complete; it will still be complete when they leave.
|
|
1111
|
-
if (wanted.has(fileIndex)) {
|
|
1112
|
-
continue;
|
|
1113
|
-
}
|
|
1114
1114
|
beingKept.add(key);
|
|
1115
1115
|
try {
|
|
1116
|
+
const startedAt = performance.now();
|
|
1116
1117
|
const kept = await completedFiles.keep({
|
|
1117
1118
|
infoHash,
|
|
1118
1119
|
fileIndex,
|
|
@@ -1122,7 +1123,8 @@ async function keepWholeFiles() {
|
|
|
1122
1123
|
});
|
|
1123
1124
|
if (kept) {
|
|
1124
1125
|
logger.info(
|
|
1125
|
-
`whole files: kept "${file.name}" (${Math.round(kept.length / 1048576)}MB
|
|
1126
|
+
`whole files: kept "${file.name}" (${Math.round(kept.length / 1048576)}MB, ` +
|
|
1127
|
+
`written in ${((performance.now() - startedAt) / 1000).toFixed(1)}s) — ` +
|
|
1126
1128
|
"it is a file now, and reading it needs no torrent"
|
|
1127
1129
|
);
|
|
1128
1130
|
// The pieces it was built from are a second copy of the same bytes.
|
|
@@ -1145,6 +1147,7 @@ async function keepWholeFiles() {
|
|
|
1145
1147
|
length: kept.length,
|
|
1146
1148
|
name: kept.name
|
|
1147
1149
|
});
|
|
1150
|
+
announcedNow = true;
|
|
1148
1151
|
holdingsChanged();
|
|
1149
1152
|
}
|
|
1150
1153
|
} catch (error) {
|
|
@@ -1159,12 +1162,18 @@ async function keepWholeFiles() {
|
|
|
1159
1162
|
//
|
|
1160
1163
|
// Safe to destroy only because a piece can now be read out of those files:
|
|
1161
1164
|
// a path that asks for this source again adds the torrent back, and what it
|
|
1162
|
-
// verifies it reads from the files rather than from the swarm.
|
|
1163
|
-
//
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1165
|
+
// verifies it reads from the files rather than from the swarm. A viewer
|
|
1166
|
+
// still watching does not keep it — the files answer them — but a read
|
|
1167
|
+
// still running on its piece store does: see `mayRemoveWholeTorrent`. The
|
|
1168
|
+
// reads are taken now, not from the start of this sweep, because a write
|
|
1169
|
+
// above can take minutes and a read can open while it runs.
|
|
1170
|
+
const isRemovable = mayRemoveWholeTorrent({
|
|
1171
|
+
torrent,
|
|
1172
|
+
isKept: (fileIndex) => completedFiles.find(infoHash, fileIndex) !== null,
|
|
1173
|
+
openReads: openReads.values(),
|
|
1174
|
+
announcedNow
|
|
1175
|
+
});
|
|
1176
|
+
if (isRemovable) {
|
|
1168
1177
|
logger.info(
|
|
1169
1178
|
`whole files: "${torrent.name}" is downloaded whole and saved — removing the torrent, keeping the files`
|
|
1170
1179
|
);
|