@torrent-tv/proxy 2.40.2 → 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,8 @@
|
|
|
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
|
+
|
|
1
6
|
## 2.40.2
|
|
2
7
|
|
|
3
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`.
|
package/package.json
CHANGED
|
@@ -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
|
-
|
|
174
|
+
const settle = () => {
|
|
142
175
|
clearTimeout(killTimer);
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
ffmpeg.
|
|
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
|
/**
|
|
@@ -761,6 +761,10 @@ export async function* readFragments({
|
|
|
761
761
|
* nothing passed in and no assumption about who is reading.
|
|
762
762
|
*/
|
|
763
763
|
let deliveredBytes = 0;
|
|
764
|
+
// How often this read had to stop, and for how long in total. Reported at the
|
|
765
|
+
// end whatever the outcome, so a read that never stopped is counted too.
|
|
766
|
+
let waitCount = 0;
|
|
767
|
+
let waitedTotalMs = 0;
|
|
764
768
|
const readStartedAt = Date.now();
|
|
765
769
|
const consumeBytesPerSec = () => {
|
|
766
770
|
const seconds = (Date.now() - readStartedAt) / 1000;
|
|
@@ -979,6 +983,8 @@ export async function* readFragments({
|
|
|
979
983
|
const supplyKey = `${torrent?.infoHash ?? "?"}/${file?.name ?? "?"}`;
|
|
980
984
|
noteSteeringOutcome(supplyKey, waitedMs, pushed.asked > 0 || duplicated > 0);
|
|
981
985
|
noteReadMode(supplyKey, waitedMs, readMode);
|
|
986
|
+
waitCount += 1;
|
|
987
|
+
waitedTotalMs += waitedMs;
|
|
982
988
|
noteSupplyWait(supplyKey, file?.name ?? "", waitedMs);
|
|
983
989
|
}
|
|
984
990
|
const widened = nextWindowPieces({
|
|
@@ -1097,9 +1103,22 @@ export async function* readFragments({
|
|
|
1097
1103
|
releaseHeldPin();
|
|
1098
1104
|
releaseHeldPin = null;
|
|
1099
1105
|
}
|
|
1100
|
-
//
|
|
1101
|
-
//
|
|
1102
|
-
// a
|
|
1106
|
+
// What this read did, said once at its end and under EVERY outcome — the
|
|
1107
|
+
// arm it ran under, how much it delivered, and how much of that time was
|
|
1108
|
+
// spent waiting. Until now the arm was named only beside a wait, so a read
|
|
1109
|
+
// that never waited left no trace of which way it had claimed its pieces:
|
|
1110
|
+
// measured 2026-08-19 across eight sessions with zero waits, the log could
|
|
1111
|
+
// not say whether `flat` or `bands` had run even once. A comparison that
|
|
1112
|
+
// only records the bad outcomes cannot say that the good ones happened at
|
|
1113
|
+
// all, and "no wait" is exactly the result worth counting.
|
|
1114
|
+
const readSeconds = (Date.now() - readStartedAt) / 1000;
|
|
1115
|
+
if (deliveredBytes > 0) {
|
|
1116
|
+
logger.info(
|
|
1117
|
+
`read "${String(file?.name ?? "?").slice(0, 40)}" mode=${readMode} ` +
|
|
1118
|
+
`delivered=${(deliveredBytes / 1e6).toFixed(1)}MB in ${readSeconds.toFixed(1)}s ` +
|
|
1119
|
+
`waits=${waitCount} waited=${(waitedTotalMs / 1000).toFixed(1)}s`
|
|
1120
|
+
);
|
|
1121
|
+
}
|
|
1103
1122
|
// Reached on completion, on cancellation, on a throw, and when the consumer
|
|
1104
1123
|
// stops iterating — a band left behind would keep the swarm fetching for a
|
|
1105
1124
|
// reader that no longer exists.
|