@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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.101.13",
3
+ "version": "2.101.15",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "repository": {
@@ -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
- for await (const fragment of markWaits ? markingWaits(fragments, markWaits) : fragments) {
269
- if (reply.raw.writableEnded || reply.raw.destroyed) {
270
- fragment.release();
271
- break;
272
- }
273
- try {
274
- await new Promise((resolve, reject) => {
275
- reply.raw.write(fragment.bytes, (error) => (error ? reject(error) : resolve()));
276
- });
277
- sent += fragment.bytes.length;
278
- // Say so, if this read belongs to a transcode session. It is the only
279
- // proof that a session which has produced nothing yet is nevertheless
280
- // being fed: the encoder's own progress cannot move until its first
281
- // frame is decoded, and a viewer waiting for that first frame was being
282
- // told the proxy had died while the swarm was delivering to it. Field
283
- // 2026-09-03: 46.3 s on one piece, `processedSeconds` frozen at the
284
- // start position throughout, and the browser gave up 0.4 s before the
285
- // piece landed.
286
- if (noteInputBytes) {
287
- noteInputBytes(sessionId, fragment.bytes.length);
288
- }
289
- // Only now are these bytes gone: the piece can be unpinned, and the
290
- // slot it occupies reused. Releasing before this point corrupts the
291
- // response silently.
292
- } finally {
293
- fragment.release();
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 torrent that has nothing
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
- for (const [fileIndex, file] of torrent.files.entries()) {
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. Not while
1163
- // anybody is reading it, for the same reason the writing above waits.
1164
- const isWhole =
1165
- torrent.done === true &&
1166
- torrent.files.every((unused, fileIndex) => completedFiles.find(infoHash, fileIndex) !== null);
1167
- if (isWhole && wanted.size === 0) {
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
  );