@torrent-tv/proxy 2.89.13 → 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,11 @@
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
+
5
+ ## 2.89.14
6
+
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.
8
+
1
9
  ## 2.89.13
2
10
 
3
11
  - **Fix**: The proxy no longer stops answering everybody when one output is priced near zero. Asking how many encoders an output may run counted them one at a time until the machine was full, so the number of steps grew as the price shrank. On the stand on 2026-10-04 a soundtrack priced at 5.4e-14 s of machine per second of film held the main thread in that count for minutes, past 3.3 billion steps: no data channel was served, `/healthz` did not answer, and every viewer stopped. The count is now found by doubling and halving, in at most a few hundred steps whatever the price. The price itself is wrong and is not corrected here.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.89.13",
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": {
@@ -141,7 +141,13 @@ export function parseFfmpegStreamCounts(stderrText) {
141
141
  if (typeof stderrText !== "string" || stderrText.length === 0) {
142
142
  return null;
143
143
  }
144
- const lines = stderrText.match(/^\s*Stream #\d+:\d+.*$/gim);
144
+ // The source's streams only. A probe that writes somewhere — the planner's
145
+ // `-f null -` does — goes on to print `Stream mapping:` and `Output #0` with
146
+ // stream lines of their own, and counting those doubled the picture and the
147
+ // sound (field 2026-10-04: one soundtrack read as two).
148
+ const end = stderrText.search(/^(?:Stream mapping:|Output #\d+)/m);
149
+ const inputText = end >= 0 ? stderrText.slice(0, end) : stderrText;
150
+ const lines = inputText.match(/^\s*Stream #\d+:\d+.*$/gim);
145
151
  if (!lines || lines.length === 0) {
146
152
  return null;
147
153
  }
@@ -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)) {
@@ -76,3 +76,26 @@ test("a stream carrying metadata in brackets is still read", () => {
76
76
  `);
77
77
  assert.deepEqual(counts, { video: 1, audio: 1, subtitle: 0, other: 0 });
78
78
  });
79
+
80
+ test("only the input's streams are counted, not the output ffmpeg describes after them", () => {
81
+ // The planner probes with `-i <file> -t 0.1 -f null -`, so the banner goes on
82
+ // to describe what it maps and the null output it writes. Counted whole, this
83
+ // field file (one picture, one soundtrack, one subtitle, one font) read as
84
+ // "2 video, 2 audio, 1 subtitle, 3 other" on 2026-10-04 — which would make a
85
+ // second soundtrack look present when it is not.
86
+ const banner = `
87
+ Input #0, matroska,webm, from 'http://127.0.0.1:9090/stream?sourceKey=torrent%3A84acab5f&fileIndex=35':
88
+ Duration: 00:23:41.44, start: 0.000000, bitrate: 3195 kb/s
89
+ Stream #0:0: Video: h264 (High), yuv420p(progressive), 1920x1080 [SAR 1:1 DAR 16:9], 23.81 fps, 23.81 tbr, 1k tbn (default)
90
+ Stream #0:1(jpn): Audio: aac (LC), 44100 Hz, stereo, fltp (default)
91
+ Stream #0:2(eng): Subtitle: ass (ssa) (default)
92
+ Stream #0:3: Attachment: ttf
93
+ Stream mapping:
94
+ Stream #0:0 -> #0:0 (h264 (native) -> wrapped_avframe (native))
95
+ Stream #0:1 -> #0:1 (aac (native) -> pcm_s16le (native))
96
+ Output #0, null, to 'pipe:':
97
+ Stream #0:0: Video: wrapped_avframe, yuv420p(progressive), 1920x1080 [SAR 1:1 DAR 16:9], q=2-31, 200 kb/s, 23.81 fps, 23.81 tbn (default)
98
+ Stream #0:1(jpn): Audio: pcm_s16le, 44100 Hz, stereo, s16, 1411 kb/s (default)
99
+ `;
100
+ assert.deepEqual(parseFfmpegStreamCounts(banner), { video: 1, audio: 1, subtitle: 1, other: 1 });
101
+ });
@@ -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
+ });