@torrent-tv/proxy 2.40.1 → 2.41.0

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,12 @@
1
+ ## 2.41.0
2
+
3
+ - **Fix**: An embedded subtitle track is prepared in the background and kept, instead of being extracted afresh inside a request the browser cannot hold open. Extracting one makes ffmpeg read the WHOLE film, because subtitles are interleaved through it — measured 2026-08-19 on a release with three tracks: track 0 produced **3040 bytes over 752 seconds**, track 1 76 KB over 193 s, track 2 68 KB over 55 s, with the data channel idle throughout (`maxBuffered=0`, the time all in reading the body). The browser gives up at sixty seconds, and every retry started the same twelve-minute scan again, so the first track never arrived at all. The route now starts the work once per `(source, file, track)`, answers `202 { pending: true }` while it runs, and serves the kept result the moment it exists. The scan still takes what it takes; what changes is that it happens once and its result is not thrown away.
4
+ - **New**: Every read says which way it claimed its pieces, whatever the outcome. The arm — `flat` or `bands`, chosen at random per read so the two accumulate side by side — was named only beside a WAIT, and across eight sessions on 2026-08-19 there were none: the swarm kept up, the log recorded nothing, and the comparison the arms exist for could not tell whether either had ever run. A read now reports its arm, what it delivered, how long it took and how much of that was spent waiting, at its end and under every outcome. "No wait" is the result worth counting, and it was the one being discarded.
5
+
6
+ ## 2.40.2
7
+
8
+ - **Fix**: The second place `utp-native` read a callback result that was never written. 2.40.1 got our patched build into the loading path at last, and the process went on dying — twice within an hour, 22:32 and 22:50 — with a stack naming `on_utp_accept` rather than the `on_utp_read` the patch had covered. The code there carried the comment "will never throw due to the event being NTed in js" and then read `next` unconditionally; throwing is not the only way a callback fails, and once the environment is closing or the function reference has gone, `napi_make_callback` returns without writing anything. `next` was then whatever the stack happened to hold, and V8 dereferenced it. Both places are now guarded the same way, and every other call in that file passes NULL for the result and cannot have the fault. `@torrent-tv/utp-native@2.5.3-ttv.2`.
9
+
1
10
  ## 2.40.1
2
11
 
3
12
  - **Fix**: The patched `utp-native` now replaces every copy in the tree, not just the top one. Installed at this package's own level, it left `webtorrent/node_modules/utp-native` untouched — and Node resolves from the requiring module outward, so WebTorrent went on loading the published build with the defect in it. The crash of 2026-08-19 21:03 names that exact path in frame 2, and every earlier one did too: the substitution shipped in 2.36.2 was never once in the loading path. `overrides` in this package's manifest now redirects the whole tree, npm applies it because a global install makes this package the root, and the addon image additionally deletes any nested copy and FAILS THE BUILD if a surviving `utp_native.node` belongs to another package. A silent fallback to the broken one is what made a fix that changed nothing look like a fix that worked.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.40.1",
3
+ "version": "2.41.0",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -44,6 +44,6 @@
44
44
  "@biomejs/biome": "^2.5.7"
45
45
  },
46
46
  "overrides": {
47
- "utp-native": "npm:@torrent-tv/utp-native@2.5.3-ttv.1"
47
+ "utp-native": "npm:@torrent-tv/utp-native@2.5.3-ttv.2"
48
48
  }
49
49
  }
@@ -27,6 +27,7 @@
27
27
  import { spawn } from "node:child_process";
28
28
  import { convertSubtitleToVtt, decodeSubtitleBytes } from "../../../services/subtitle-convert.js";
29
29
  import { detectLanguage } from "../../../services/language-detect.js";
30
+ import { logger } from "../../../utils/logger.js";
30
31
 
31
32
  // Safety cap: no embedded extraction may outlive this.
32
33
  const EXTRACTION_TIMEOUT_MS = 30 * 60 * 1000;
@@ -99,6 +100,53 @@ export async function handleApiSubtitlesGet(req, reply, { sourceRegistry, torren
99
100
  inputUrl.searchParams.set("sourceKey", sourceKey);
100
101
  inputUrl.searchParams.set("fileIndex", String(fileIndex));
101
102
 
103
+ const key = `${sourceKey}:${fileIndex}:${trackIndex}`;
104
+ const known = extractions.get(key);
105
+ if (known?.state === "done") {
106
+ setLanguageHeaders(reply, known.language);
107
+ reply.header("content-type", "text/vtt; charset=utf-8");
108
+ reply.header("cache-control", "no-store");
109
+ reply.header("access-control-allow-origin", "*");
110
+ return reply.send(known.body);
111
+ }
112
+ if (known?.state === "failed") {
113
+ return reply.code(422).send({ error: known.error });
114
+ }
115
+ if (known?.state !== "running") {
116
+ startExtraction({ key, ffmpegBin, localBaseUrl, sourceKey, fileIndex, trackIndex });
117
+ }
118
+ // Being prepared. The connection is NOT held: extracting an embedded track
119
+ // makes ffmpeg read the whole file, because subtitles are interleaved through
120
+ // it, and that means downloading the film for the sake of a few kilobytes of
121
+ // text. Measured 2026-08-19: track 0 of one release produced 3040 bytes over
122
+ // **752 seconds**, with the data channel idle the whole time — the browser
123
+ // gave up at its own sixty-second limit, and every retry started the same
124
+ // twelve-minute scan again. So the work runs once in the background and the
125
+ // caller is told to come back.
126
+ return reply.code(202).send({ pending: true });
127
+ }
128
+
129
+ /**
130
+ * Extractions by `sourceKey:fileIndex:trackIndex`, so the scan happens once per
131
+ * track however many times it is asked for.
132
+ *
133
+ * @type {Map<string, { state: "running" | "done" | "failed", body?: Buffer, language?: string, error?: string }>}
134
+ */
135
+ const extractions = new Map();
136
+
137
+ /**
138
+ * Run one extraction to completion in the background, keeping the result.
139
+ *
140
+ * @param {{ key: string, ffmpegBin: string, localBaseUrl: string, sourceKey: string, fileIndex: number, trackIndex: number }} params
141
+ * @returns {void}
142
+ */
143
+ function startExtraction({ key, ffmpegBin, localBaseUrl, sourceKey, fileIndex, trackIndex }) {
144
+ const inputUrl = new URL("/stream", `${localBaseUrl}/`);
145
+ inputUrl.searchParams.set("sourceKey", sourceKey);
146
+ inputUrl.searchParams.set("fileIndex", String(fileIndex));
147
+
148
+ extractions.set(key, { state: "running" });
149
+ const startedAt = Date.now();
102
150
  const ffmpeg = spawn(
103
151
  ffmpegBin,
104
152
  ["-hide_banner", "-loglevel", "error", "-i", inputUrl.toString(), "-map", `0:s:${trackIndex}`, "-f", "webvtt", "pipe:1"],
@@ -112,56 +160,34 @@ export async function handleApiSubtitlesGet(req, reply, { sourceRegistry, torren
112
160
  }
113
161
  });
114
162
 
163
+ /** @type {Buffer[]} */
164
+ const chunks = [];
165
+ ffmpeg.stdout.on("data", (chunk) => chunks.push(chunk));
166
+
115
167
  const killTimer = setTimeout(() => {
116
168
  if (!ffmpeg.killed) {
117
169
  ffmpeg.kill("SIGKILL");
118
170
  }
119
171
  }, EXTRACTION_TIMEOUT_MS);
120
172
  killTimer.unref?.();
121
- req.raw.on("close", () => {
122
- clearTimeout(killTimer);
123
- if (!ffmpeg.killed) {
124
- ffmpeg.kill("SIGTERM");
125
- }
126
- });
127
-
128
- const firstChunk = await new Promise((resolve) => {
129
- let settled = false;
130
- const settle = (value) => {
131
- if (!settled) {
132
- settled = true;
133
- resolve(value);
134
- }
135
- };
136
- ffmpeg.stdout.once("data", (chunk) => settle(chunk));
137
- ffmpeg.once("exit", () => settle(null));
138
- ffmpeg.once("error", () => settle(null));
139
- });
140
173
 
141
- if (firstChunk === null) {
174
+ const settle = () => {
142
175
  clearTimeout(killTimer);
143
- return reply
144
- .code(422)
145
- .send({ error: `Subtitle track could not be extracted: ${stderr.trim() || "no output from ffmpeg"}` });
146
- }
147
-
148
- // Detect language from the first chunk of the produced VTT (embedded tracks
149
- // frequently lack a language tag in their container metadata).
150
- setLanguageHeaders(reply, detectLanguage(String(firstChunk)));
151
- reply.raw.writeHead(200, {
152
- "content-type": "text/vtt; charset=utf-8",
153
- "cache-control": "no-store",
154
- "access-control-allow-origin": "*"
155
- });
156
- reply.raw.write(firstChunk);
157
- ffmpeg.stdout.pipe(reply.raw);
158
- await new Promise((resolve) => {
159
- ffmpeg.stdout.once("end", resolve);
160
- ffmpeg.once("error", resolve);
161
- });
162
- clearTimeout(killTimer);
163
- reply.raw.end();
164
- return reply;
176
+ const seconds = ((Date.now() - startedAt) / 1000).toFixed(1);
177
+ const body = Buffer.concat(chunks);
178
+ if (body.length === 0) {
179
+ extractions.set(key, {
180
+ state: "failed",
181
+ error: `Subtitle track could not be extracted: ${stderr.trim() || "no output from ffmpeg"}`
182
+ });
183
+ logger.warn(`subtitles ${key}: nothing produced after ${seconds}s`);
184
+ return;
185
+ }
186
+ extractions.set(key, { state: "done", body, language: detectLanguage(String(body.subarray(0, 4096))) });
187
+ logger.info(`subtitles ${key}: ${body.length} bytes in ${seconds}s`);
188
+ };
189
+ ffmpeg.once("close", settle);
190
+ ffmpeg.once("error", settle);
165
191
  }
166
192
 
167
193
  /**