@torrent-tv/proxy 2.89.14 → 2.89.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/CHANGELOG.md CHANGED
@@ -1,3 +1,7 @@
1
+ ## 2.89.15
2
+
3
+ - **Fix**: A torrent downloaded whole is no longer removed while something is still reading it. The sweep that writes whole files out and removes the torrent asked only who had stated a need, and a read whose pieces are all here waits for nothing and states nothing. On the stand on 2026-10-04 the torrent was removed under ffmpeg's own read of the film: the read ended with "Piece store is closed" 277 MB in, ffmpeg reported `Stream ends prematurely`, the copied picture lost 2.6 s, and the page refused to start with "Prepared media contains a timestamp gap" (torrent-tv/meta#105). Open reads now count as use.
4
+
1
5
  ## 2.89.14
2
6
 
3
7
  - **Fix**: A failed run counts only the source's own streams. The planner probes with an output, so ffmpeg's banner goes on to list the streams it maps and writes, and those were counted too: a file with one picture, one soundtrack, one subtitle and one font read as `2 video, 2 audio, 1 subtitle, 3 other` in the field — enough to make a second soundtrack look present when it is not.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.89.14",
3
+ "version": "2.89.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": {
@@ -0,0 +1,35 @@
1
+ /**
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.
4
+ *
5
+ * Two facts, and both are needed. A file somebody has stated a need for is in
6
+ * use. So is a file being read: a read whose pieces are all here waits for
7
+ * nothing, and so states nothing in the demand register. Field 2026-10-04: a
8
+ * torrent was removed under such a read, ffmpeg's input ended with "Piece store
9
+ * is closed" 277 MB in, and the copied picture lost 2.6 s that the readiness
10
+ * forecast then refused as a timestamp gap (torrent-tv/meta#105).
11
+ */
12
+
13
+ /** Claimants that keep pieces coming without anybody reading. */
14
+ const NOT_A_READER = ["file-edges:", "torrent-fill:", "background-fill:"];
15
+
16
+ /**
17
+ * @param {object} options
18
+ * @param {object} options.torrent
19
+ * @param {Array<{ claimant: unknown, fileIndex: number }>} options.windows - The demand register's windows.
20
+ * @param {Iterable<{ torrent: object, fileIndex: number }>} options.openReads - Reads in flight.
21
+ * @returns {Set<number>} File indices in use.
22
+ */
23
+ export function filesInUse({ torrent, windows, openReads }) {
24
+ const used = new Set(
25
+ windows
26
+ .filter((window) => !NOT_A_READER.some((prefix) => String(window.claimant).startsWith(prefix)))
27
+ .map((window) => window.fileIndex)
28
+ );
29
+ for (const read of openReads) {
30
+ if (read.torrent === torrent) {
31
+ used.add(read.fileIndex);
32
+ }
33
+ }
34
+ return used;
35
+ }
@@ -34,6 +34,7 @@ import { heldRangesOf, ownsItsMemory, readHeldBytes } from "./held-bytes.js";
34
34
  import { CompletedFiles, completedFilesRoot } from "../../storage/files/CompletedFiles.js";
35
35
  import { pieceFromWholeFiles, pieceIsInWholeFiles } from "../../storage/files/piece-from-whole-file.js";
36
36
  import { Command, Event } from "./protocol.js";
37
+ import { filesInUse } from "./files-in-use.js";
37
38
  import { startMemoryReport, WORKER_MEMORY_SAMPLE_MS } from "../../storage/memory-report.js";
38
39
  import { forwardLogsTo, logger } from "../../../utils/logger.js";
39
40
 
@@ -94,6 +95,14 @@ const torrentsByKey = new Map();
94
95
  const sourceRecipes = new Map();
95
96
  /** In-flight reads, so a cancel can stop one mid-body. */
96
97
  const readsById = new Map();
98
+ /**
99
+ * What each in-flight read is reading. A read whose pieces are all here waits
100
+ * for nothing and so states nothing in the demand register; this is how the
101
+ * thread knows it is still open.
102
+ *
103
+ * @type {Map<number, { torrent: object, fileIndex: number }>}
104
+ */
105
+ const openReads = new Map();
97
106
 
98
107
  /**
99
108
  * Shorthand for this file. The same path as `logger.info` anywhere else in the
@@ -257,6 +266,7 @@ async function streamRange({ id, sourceKey, fileIndex, start, end, windowBytes }
257
266
 
258
267
  const sender = createSendStream({ port: parentPort, requestId: id });
259
268
  readsById.set(id, sender);
269
+ openReads.set(id, { torrent, fileIndex });
260
270
 
261
271
  const rangeStart = start ?? 0;
262
272
  const rangeEnd = end ?? file.length - 1;
@@ -295,6 +305,7 @@ async function streamRange({ id, sourceKey, fileIndex, start, end, windowBytes }
295
305
  throw error;
296
306
  } finally {
297
307
  readsById.delete(id);
308
+ openReads.delete(id);
298
309
  // Any fragment still awaiting confirmation will never get one now; settling
299
310
  // it here releases its pin rather than leaking a held slot.
300
311
  settleFragment(id);
@@ -947,18 +958,12 @@ async function keepWholeFiles() {
947
958
  if (!infoHash || !Array.isArray(torrent.files)) {
948
959
  continue;
949
960
  }
950
- // What anybody wants of this torrent. A file something is stated for is a
951
- // file somebody may be reading, and this is the same list the reader counts
952
- // used to give.
953
- const wanted = new Set(
954
- demandFor(torrent).register.windows()
955
- .filter((window) =>
956
- !String(window.claimant).startsWith("file-edges:") &&
957
- !String(window.claimant).startsWith("torrent-fill:") &&
958
- !String(window.claimant).startsWith("background-fill:")
959
- )
960
- .map((window) => window.fileIndex)
961
- );
961
+ // What anybody wants of this torrent or is reading from it now.
962
+ const wanted = filesInUse({
963
+ torrent,
964
+ windows: demandFor(torrent).register.windows(),
965
+ openReads: openReads.values()
966
+ });
962
967
  for (const [fileIndex, file] of torrent.files.entries()) {
963
968
  const key = `${infoHash}/${fileIndex}`;
964
969
  if (file?.done !== true || completedFiles.find(infoHash, fileIndex) || beingKept.has(key)) {
@@ -0,0 +1,34 @@
1
+ /**
2
+ * @file Which files of a torrent are in use for the whole-file sweep. Pure:
3
+ * no torrent is started (see services/torrent/worker/files-in-use.js).
4
+ */
5
+ import test from "node:test";
6
+ import assert from "node:assert/strict";
7
+ import { filesInUse } from "../services/torrent/worker/files-in-use.js";
8
+
9
+ test("a file being read is in use though nobody states a need for it", () => {
10
+ // Field 2026-10-04: every piece was here, so the read waited for nothing and
11
+ // stated nothing, and the torrent was removed under it.
12
+ const torrent = {};
13
+ const used = filesInUse({ torrent, windows: [], openReads: [{ torrent, fileIndex: 0 }] });
14
+ assert.deepEqual([...used], [0]);
15
+ });
16
+
17
+ test("a read of another torrent does not count", () => {
18
+ const used = filesInUse({ torrent: {}, windows: [], openReads: [{ torrent: {}, fileIndex: 0 }] });
19
+ assert.equal(used.size, 0);
20
+ });
21
+
22
+ test("stated needs count, and the fills that read nothing do not", () => {
23
+ const used = filesInUse({
24
+ torrent: {},
25
+ windows: [
26
+ { claimant: "reader:7", fileIndex: 2 },
27
+ { claimant: "file-edges:1", fileIndex: 1 },
28
+ { claimant: "torrent-fill:x", fileIndex: 3 },
29
+ { claimant: "background-fill:x", fileIndex: 4 }
30
+ ],
31
+ openReads: []
32
+ });
33
+ assert.deepEqual([...used], [2]);
34
+ });