@torrent-tv/proxy 2.73.1 → 2.74.1
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 +1453 -1437
- package/CLAUDE.md +165 -160
- package/docs/container-architecture.md +192 -184
- package/package.json +1 -1
- package/routes/api/subtitles/get.js +205 -205
- package/services/container/Container.js +400 -135
- package/services/container/ContainerFactory.js +55 -31
- package/services/container/MatroskaContainer.js +1166 -516
- package/services/container/Mp4Container.js +898 -392
- package/services/container/SubtitleFileContainer.js +323 -261
- package/services/controllers/SubtitleController.js +128 -127
- package/services/delivery-probe.js +64 -6
- package/services/hls-session-manager.js +32 -35
- package/services/language-detect.js +174 -228
- package/services/playback-planner.js +747 -747
- package/services/produced-index.js +300 -0
- package/services/torrent-worker/subtitle-cues.js +549 -633
- package/services/tracks/TextSubtitleTrack.js +287 -47
- package/services/tracks/index.js +14 -14
- package/test/delivery-probe.test.js +67 -0
- package/test/matroska-blocks.test.js +0 -0
- package/test/mp4-subtitles.test.js +173 -127
- package/test/produced-index.test.js +188 -0
- package/test/subtitle-cue-framing.test.js +200 -202
- package/test/subtitle-cue-walk.test.js +369 -0
- package/test/subtitle-defaults.test.js +97 -97
- package/test/subtitle-language.test.js +252 -252
- package/test/subtitle-track-numbering.test.js +370 -370
- package/services/container-index/matroska-blocks.js +0 -202
- package/services/container-index/matroska-subtitles.js +0 -372
- package/services/container-index/mp4-subtitles.js +0 -404
- package/services/subtitle-convert.js +0 -144
- package/services/subtitle-defaults.js +0 -157
- package/services/tracks/subtitle-markup.js +0 -104
|
@@ -1,205 +1,205 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Serve a subtitle as WebVTT, with the detected language reported in the
|
|
3
|
-
* `X-Subtitle-Language` / `X-Subtitle-Language-Name` response headers. Two
|
|
4
|
-
* modes:
|
|
5
|
-
*
|
|
6
|
-
* - Embedded track: ?sourceKey&fileIndex=<video>&trackIndex=<sub stream N>
|
|
7
|
-
* ffmpeg extracts the text subtitle stream (`-map 0:s:N -f webvtt`),
|
|
8
|
-
* streamed as it is produced.
|
|
9
|
-
* - External file: ?sourceKey&fileIndex=<subtitle file> (no trackIndex)
|
|
10
|
-
* the subtitle FILE is read, decoded (UTF-8/Windows-1251), and converted
|
|
11
|
-
* (.srt/.ass/.ssa → WebVTT) here on the proxy.
|
|
12
|
-
*
|
|
13
|
-
* The proxy owns subtitle conversion + language detection so no model or
|
|
14
|
-
* converter ships to the browser and detection sees the full text.
|
|
15
|
-
*
|
|
16
|
-
* @param {import("fastify").FastifyRequest} req
|
|
17
|
-
* @param {import("fastify").FastifyReply} reply
|
|
18
|
-
* @param {{
|
|
19
|
-
* sourceRegistry: ReturnType<import("../../../store/source-registry.js").createSourceRegistry>,
|
|
20
|
-
* torrentPool: import("../../../services/torrent-pool.js").TorrentPool,
|
|
21
|
-
* ffmpegBin: string,
|
|
22
|
-
* localBaseUrl: string
|
|
23
|
-
* }} deps
|
|
24
|
-
* @returns {Promise<void>}
|
|
25
|
-
*/
|
|
26
|
-
|
|
27
|
-
import { spawn } from "node:child_process";
|
|
28
|
-
import {
|
|
29
|
-
import { SubtitleController } from "../../../services/controllers/SubtitleController.js";
|
|
30
|
-
import { logger } from "../../../utils/logger.js";
|
|
31
|
-
|
|
32
|
-
// Safety cap: no embedded extraction may outlive this.
|
|
33
|
-
const EXTRACTION_TIMEOUT_MS = 30 * 60 * 1000;
|
|
34
|
-
|
|
35
|
-
/** Set the detected-language response headers (no-op when detection failed). */
|
|
36
|
-
function setLanguageHeaders(reply, lang) {
|
|
37
|
-
if (lang && typeof lang.code === "string") {
|
|
38
|
-
reply.raw.setHeader("X-Subtitle-Language", lang.code);
|
|
39
|
-
if (typeof lang.name === "string") {
|
|
40
|
-
reply.raw.setHeader("X-Subtitle-Language-Name", encodeURIComponent(lang.name));
|
|
41
|
-
}
|
|
42
|
-
// These are custom headers on a cross-origin fetch — expose them.
|
|
43
|
-
reply.raw.setHeader("Access-Control-Expose-Headers", "X-Subtitle-Language, X-Subtitle-Language-Name");
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
export async function handleApiSubtitlesGet(req, reply, { sourceRegistry, torrentPool, ffmpegBin, localBaseUrl }) {
|
|
48
|
-
const query = req.query ?? {};
|
|
49
|
-
const sourceKey = typeof query.sourceKey === "string" ? query.sourceKey.trim() : "";
|
|
50
|
-
const fileIndex = Number(query.fileIndex);
|
|
51
|
-
const hasTrackIndex = query.trackIndex !== undefined && query.trackIndex !== "";
|
|
52
|
-
const trackIndex = Number(query.trackIndex);
|
|
53
|
-
|
|
54
|
-
if (!sourceKey || !Number.isInteger(fileIndex) || fileIndex < 0) {
|
|
55
|
-
return reply.code(400).send({ error: "sourceKey and fileIndex are required." });
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
// Interface layer delegates to SubtitleController (orchestrator + domain),
|
|
59
|
-
// which owns external-file vs embedded-track branching and the cluster walk.
|
|
60
|
-
const controller = new SubtitleController({ sourceRegistry, torrentPool });
|
|
61
|
-
const since = Number.parseInt(String(req.query?.since ?? ""), 10);
|
|
62
|
-
const after = Number.parseFloat(String(req.query?.after ?? ""));
|
|
63
|
-
const result = await controller.getSubtitle({
|
|
64
|
-
sourceKey,
|
|
65
|
-
fileIndex,
|
|
66
|
-
trackIndex: hasTrackIndex ? trackIndex : undefined,
|
|
67
|
-
since: Number.isInteger(since) ? since : null,
|
|
68
|
-
after: Number.isFinite(after) ? after : null
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
if (result.error) {
|
|
72
|
-
return reply.code(result.status ?? 400).send({ error: result.error });
|
|
73
|
-
}
|
|
74
|
-
if (result.vtt !== undefined) {
|
|
75
|
-
if (result.vtt !== null) {
|
|
76
|
-
// External file or cluster-held cues — controller already detected language.
|
|
77
|
-
const lang = result.language ?? null;
|
|
78
|
-
if (lang) setLanguageHeaders(reply, lang);
|
|
79
|
-
reply.header("content-type", "text/vtt; charset=utf-8");
|
|
80
|
-
reply.header("cache-control", "no-store");
|
|
81
|
-
if (hasTrackIndex) {
|
|
82
|
-
reply.header("access-control-allow-origin", "*");
|
|
83
|
-
if (result.headers) {
|
|
84
|
-
for (const [k, v] of Object.entries(result.headers)) reply.header(k, String(v));
|
|
85
|
-
reply.raw.setHeader(
|
|
86
|
-
"Access-Control-Expose-Headers",
|
|
87
|
-
"X-Subtitle-Language, X-Subtitle-Language-Name, X-Subtitle-Covered-Clusters, X-Subtitle-Indexed-Clusters, X-Subtitle-Cursor"
|
|
88
|
-
);
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
return reply.send(result.vtt);
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
// If controller returned pending, fall through to ffmpeg extraction below.
|
|
95
|
-
|
|
96
|
-
// ---- Embedded track — controller had no held cues, try cluster path directly for headers compatibility
|
|
97
|
-
// The controller's getSubtitle already attempted the cluster walk; reaching here means it returned pending.
|
|
98
|
-
if (!hasTrackIndex) {
|
|
99
|
-
// Should have been handled above — pending for external is unsupported format
|
|
100
|
-
return reply.code(422).send({ error: "Unsupported subtitle format" });
|
|
101
|
-
}
|
|
102
|
-
if (!Number.isInteger(trackIndex) || trackIndex < 0) {
|
|
103
|
-
return reply.code(400).send({ error: "trackIndex must be a non-negative integer." });
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
const inputUrl = new URL("/stream", `${localBaseUrl}/`);
|
|
107
|
-
inputUrl.searchParams.set("sourceKey", sourceKey);
|
|
108
|
-
inputUrl.searchParams.set("fileIndex", String(fileIndex));
|
|
109
|
-
|
|
110
|
-
const key = `${sourceKey}:${fileIndex}:${trackIndex}`;
|
|
111
|
-
const known = extractions.get(key);
|
|
112
|
-
if (known?.state === "done") {
|
|
113
|
-
setLanguageHeaders(reply, known.language);
|
|
114
|
-
reply.header("content-type", "text/vtt; charset=utf-8");
|
|
115
|
-
reply.header("cache-control", "no-store");
|
|
116
|
-
reply.header("access-control-allow-origin", "*");
|
|
117
|
-
return reply.send(known.body);
|
|
118
|
-
}
|
|
119
|
-
if (known?.state === "failed") {
|
|
120
|
-
return reply.code(422).send({ error: known.error });
|
|
121
|
-
}
|
|
122
|
-
if (known?.state !== "running") {
|
|
123
|
-
startExtraction({ key, ffmpegBin, localBaseUrl, sourceKey, fileIndex, trackIndex });
|
|
124
|
-
}
|
|
125
|
-
// Being prepared. The connection is NOT held: extracting an embedded track
|
|
126
|
-
// makes ffmpeg read the whole file, because subtitles are interleaved through
|
|
127
|
-
// it, and that means downloading the film for the sake of a few kilobytes of
|
|
128
|
-
// text. Measured 2026-08-19: track 0 of one release produced 3040 bytes over
|
|
129
|
-
// **752 seconds**, with the data channel idle the whole time — the browser
|
|
130
|
-
// gave up at its own sixty-second limit, and every retry started the same
|
|
131
|
-
// twelve-minute scan again. So the work runs once in the background and the
|
|
132
|
-
// caller is told to come back.
|
|
133
|
-
return reply.code(202).send({ pending: true });
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
/**
|
|
137
|
-
* Extractions by `sourceKey:fileIndex:trackIndex`, so the scan happens once per
|
|
138
|
-
* track however many times it is asked for.
|
|
139
|
-
*
|
|
140
|
-
* @type {Map<string, { state: "running" | "done" | "failed", body?: Buffer, language?: { code: string, name: string } | null, error?: string }>}
|
|
141
|
-
*/
|
|
142
|
-
const extractions = new Map();
|
|
143
|
-
|
|
144
|
-
/**
|
|
145
|
-
* Run one extraction to completion in the background, keeping the result.
|
|
146
|
-
*
|
|
147
|
-
* @param {{ key: string, ffmpegBin: string, localBaseUrl: string, sourceKey: string, fileIndex: number, trackIndex: number }} params
|
|
148
|
-
* @returns {void}
|
|
149
|
-
*/
|
|
150
|
-
function startExtraction({ key, ffmpegBin, localBaseUrl, sourceKey, fileIndex, trackIndex }) {
|
|
151
|
-
const inputUrl = new URL("/stream", `${localBaseUrl}/`);
|
|
152
|
-
inputUrl.searchParams.set("sourceKey", sourceKey);
|
|
153
|
-
inputUrl.searchParams.set("fileIndex", String(fileIndex));
|
|
154
|
-
|
|
155
|
-
extractions.set(key, { state: "running" });
|
|
156
|
-
const startedAt = Date.now();
|
|
157
|
-
const ffmpeg = spawn(
|
|
158
|
-
ffmpegBin,
|
|
159
|
-
["-hide_banner", "-loglevel", "error", "-i", inputUrl.toString(), "-map", `0:s:${trackIndex}`, "-f", "webvtt", "pipe:1"],
|
|
160
|
-
{ stdio: ["ignore", "pipe", "pipe"], windowsHide: true }
|
|
161
|
-
);
|
|
162
|
-
|
|
163
|
-
let stderr = "";
|
|
164
|
-
ffmpeg.stderr.on("data", (chunk) => {
|
|
165
|
-
if (stderr.length < 4096) {
|
|
166
|
-
stderr += String(chunk);
|
|
167
|
-
}
|
|
168
|
-
});
|
|
169
|
-
|
|
170
|
-
/** @type {Buffer[]} */
|
|
171
|
-
const chunks = [];
|
|
172
|
-
ffmpeg.stdout.on("data", (chunk) => chunks.push(chunk));
|
|
173
|
-
|
|
174
|
-
const killTimer = setTimeout(() => {
|
|
175
|
-
if (!ffmpeg.killed) {
|
|
176
|
-
ffmpeg.kill("SIGKILL");
|
|
177
|
-
}
|
|
178
|
-
}, EXTRACTION_TIMEOUT_MS);
|
|
179
|
-
killTimer.unref?.();
|
|
180
|
-
|
|
181
|
-
const settle = () => {
|
|
182
|
-
clearTimeout(killTimer);
|
|
183
|
-
const seconds = ((Date.now() - startedAt) / 1000).toFixed(1);
|
|
184
|
-
const body = Buffer.concat(chunks);
|
|
185
|
-
if (body.length === 0) {
|
|
186
|
-
extractions.set(key, {
|
|
187
|
-
state: "failed",
|
|
188
|
-
error: `Subtitle track could not be extracted: ${stderr.trim() || "no output from ffmpeg"}`
|
|
189
|
-
});
|
|
190
|
-
logger.warn(`subtitles ${key}: nothing produced after ${seconds}s`);
|
|
191
|
-
return;
|
|
192
|
-
}
|
|
193
|
-
// The whole document, decoded as one string, and only its cue text.
|
|
194
|
-
// Detecting on the first 4096 BYTES was wrong twice over: a byte cut lands
|
|
195
|
-
// mid-character on any non-Latin track, and most of those bytes are
|
|
196
|
-
// timestamps rather than words. This runs once per track in the background,
|
|
197
|
-
// so reading all of it costs nothing anybody waits for.
|
|
198
|
-
extractions.set(key, { state: "done", body, language: detectLanguageFromVtt(body.toString("utf8")) });
|
|
199
|
-
logger.info(`subtitles ${key}: ${body.length} bytes in ${seconds}s`);
|
|
200
|
-
};
|
|
201
|
-
ffmpeg.once("close", settle);
|
|
202
|
-
ffmpeg.once("error", settle);
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Serve a subtitle as WebVTT, with the detected language reported in the
|
|
3
|
+
* `X-Subtitle-Language` / `X-Subtitle-Language-Name` response headers. Two
|
|
4
|
+
* modes:
|
|
5
|
+
*
|
|
6
|
+
* - Embedded track: ?sourceKey&fileIndex=<video>&trackIndex=<sub stream N>
|
|
7
|
+
* ffmpeg extracts the text subtitle stream (`-map 0:s:N -f webvtt`),
|
|
8
|
+
* streamed as it is produced.
|
|
9
|
+
* - External file: ?sourceKey&fileIndex=<subtitle file> (no trackIndex)
|
|
10
|
+
* the subtitle FILE is read, decoded (UTF-8/Windows-1251), and converted
|
|
11
|
+
* (.srt/.ass/.ssa → WebVTT) here on the proxy.
|
|
12
|
+
*
|
|
13
|
+
* The proxy owns subtitle conversion + language detection so no model or
|
|
14
|
+
* converter ships to the browser and detection sees the full text.
|
|
15
|
+
*
|
|
16
|
+
* @param {import("fastify").FastifyRequest} req
|
|
17
|
+
* @param {import("fastify").FastifyReply} reply
|
|
18
|
+
* @param {{
|
|
19
|
+
* sourceRegistry: ReturnType<import("../../../store/source-registry.js").createSourceRegistry>,
|
|
20
|
+
* torrentPool: import("../../../services/torrent-pool.js").TorrentPool,
|
|
21
|
+
* ffmpegBin: string,
|
|
22
|
+
* localBaseUrl: string
|
|
23
|
+
* }} deps
|
|
24
|
+
* @returns {Promise<void>}
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { spawn } from "node:child_process";
|
|
28
|
+
import { TextSubtitleTrack } from "../../../services/tracks/TextSubtitleTrack.js";
|
|
29
|
+
import { SubtitleController } from "../../../services/controllers/SubtitleController.js";
|
|
30
|
+
import { logger } from "../../../utils/logger.js";
|
|
31
|
+
|
|
32
|
+
// Safety cap: no embedded extraction may outlive this.
|
|
33
|
+
const EXTRACTION_TIMEOUT_MS = 30 * 60 * 1000;
|
|
34
|
+
|
|
35
|
+
/** Set the detected-language response headers (no-op when detection failed). */
|
|
36
|
+
function setLanguageHeaders(reply, lang) {
|
|
37
|
+
if (lang && typeof lang.code === "string") {
|
|
38
|
+
reply.raw.setHeader("X-Subtitle-Language", lang.code);
|
|
39
|
+
if (typeof lang.name === "string") {
|
|
40
|
+
reply.raw.setHeader("X-Subtitle-Language-Name", encodeURIComponent(lang.name));
|
|
41
|
+
}
|
|
42
|
+
// These are custom headers on a cross-origin fetch — expose them.
|
|
43
|
+
reply.raw.setHeader("Access-Control-Expose-Headers", "X-Subtitle-Language, X-Subtitle-Language-Name");
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function handleApiSubtitlesGet(req, reply, { sourceRegistry, torrentPool, ffmpegBin, localBaseUrl }) {
|
|
48
|
+
const query = req.query ?? {};
|
|
49
|
+
const sourceKey = typeof query.sourceKey === "string" ? query.sourceKey.trim() : "";
|
|
50
|
+
const fileIndex = Number(query.fileIndex);
|
|
51
|
+
const hasTrackIndex = query.trackIndex !== undefined && query.trackIndex !== "";
|
|
52
|
+
const trackIndex = Number(query.trackIndex);
|
|
53
|
+
|
|
54
|
+
if (!sourceKey || !Number.isInteger(fileIndex) || fileIndex < 0) {
|
|
55
|
+
return reply.code(400).send({ error: "sourceKey and fileIndex are required." });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Interface layer delegates to SubtitleController (orchestrator + domain),
|
|
59
|
+
// which owns external-file vs embedded-track branching and the cluster walk.
|
|
60
|
+
const controller = new SubtitleController({ sourceRegistry, torrentPool });
|
|
61
|
+
const since = Number.parseInt(String(req.query?.since ?? ""), 10);
|
|
62
|
+
const after = Number.parseFloat(String(req.query?.after ?? ""));
|
|
63
|
+
const result = await controller.getSubtitle({
|
|
64
|
+
sourceKey,
|
|
65
|
+
fileIndex,
|
|
66
|
+
trackIndex: hasTrackIndex ? trackIndex : undefined,
|
|
67
|
+
since: Number.isInteger(since) ? since : null,
|
|
68
|
+
after: Number.isFinite(after) ? after : null
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
if (result.error) {
|
|
72
|
+
return reply.code(result.status ?? 400).send({ error: result.error });
|
|
73
|
+
}
|
|
74
|
+
if (result.vtt !== undefined) {
|
|
75
|
+
if (result.vtt !== null) {
|
|
76
|
+
// External file or cluster-held cues — controller already detected language.
|
|
77
|
+
const lang = result.language ?? null;
|
|
78
|
+
if (lang) setLanguageHeaders(reply, lang);
|
|
79
|
+
reply.header("content-type", "text/vtt; charset=utf-8");
|
|
80
|
+
reply.header("cache-control", "no-store");
|
|
81
|
+
if (hasTrackIndex) {
|
|
82
|
+
reply.header("access-control-allow-origin", "*");
|
|
83
|
+
if (result.headers) {
|
|
84
|
+
for (const [k, v] of Object.entries(result.headers)) reply.header(k, String(v));
|
|
85
|
+
reply.raw.setHeader(
|
|
86
|
+
"Access-Control-Expose-Headers",
|
|
87
|
+
"X-Subtitle-Language, X-Subtitle-Language-Name, X-Subtitle-Covered-Clusters, X-Subtitle-Indexed-Clusters, X-Subtitle-Cursor"
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return reply.send(result.vtt);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
// If controller returned pending, fall through to ffmpeg extraction below.
|
|
95
|
+
|
|
96
|
+
// ---- Embedded track — controller had no held cues, try cluster path directly for headers compatibility
|
|
97
|
+
// The controller's getSubtitle already attempted the cluster walk; reaching here means it returned pending.
|
|
98
|
+
if (!hasTrackIndex) {
|
|
99
|
+
// Should have been handled above — pending for external is unsupported format
|
|
100
|
+
return reply.code(422).send({ error: "Unsupported subtitle format" });
|
|
101
|
+
}
|
|
102
|
+
if (!Number.isInteger(trackIndex) || trackIndex < 0) {
|
|
103
|
+
return reply.code(400).send({ error: "trackIndex must be a non-negative integer." });
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const inputUrl = new URL("/stream", `${localBaseUrl}/`);
|
|
107
|
+
inputUrl.searchParams.set("sourceKey", sourceKey);
|
|
108
|
+
inputUrl.searchParams.set("fileIndex", String(fileIndex));
|
|
109
|
+
|
|
110
|
+
const key = `${sourceKey}:${fileIndex}:${trackIndex}`;
|
|
111
|
+
const known = extractions.get(key);
|
|
112
|
+
if (known?.state === "done") {
|
|
113
|
+
setLanguageHeaders(reply, known.language);
|
|
114
|
+
reply.header("content-type", "text/vtt; charset=utf-8");
|
|
115
|
+
reply.header("cache-control", "no-store");
|
|
116
|
+
reply.header("access-control-allow-origin", "*");
|
|
117
|
+
return reply.send(known.body);
|
|
118
|
+
}
|
|
119
|
+
if (known?.state === "failed") {
|
|
120
|
+
return reply.code(422).send({ error: known.error });
|
|
121
|
+
}
|
|
122
|
+
if (known?.state !== "running") {
|
|
123
|
+
startExtraction({ key, ffmpegBin, localBaseUrl, sourceKey, fileIndex, trackIndex });
|
|
124
|
+
}
|
|
125
|
+
// Being prepared. The connection is NOT held: extracting an embedded track
|
|
126
|
+
// makes ffmpeg read the whole file, because subtitles are interleaved through
|
|
127
|
+
// it, and that means downloading the film for the sake of a few kilobytes of
|
|
128
|
+
// text. Measured 2026-08-19: track 0 of one release produced 3040 bytes over
|
|
129
|
+
// **752 seconds**, with the data channel idle the whole time — the browser
|
|
130
|
+
// gave up at its own sixty-second limit, and every retry started the same
|
|
131
|
+
// twelve-minute scan again. So the work runs once in the background and the
|
|
132
|
+
// caller is told to come back.
|
|
133
|
+
return reply.code(202).send({ pending: true });
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Extractions by `sourceKey:fileIndex:trackIndex`, so the scan happens once per
|
|
138
|
+
* track however many times it is asked for.
|
|
139
|
+
*
|
|
140
|
+
* @type {Map<string, { state: "running" | "done" | "failed", body?: Buffer, language?: { code: string, name: string } | null, error?: string }>}
|
|
141
|
+
*/
|
|
142
|
+
const extractions = new Map();
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Run one extraction to completion in the background, keeping the result.
|
|
146
|
+
*
|
|
147
|
+
* @param {{ key: string, ffmpegBin: string, localBaseUrl: string, sourceKey: string, fileIndex: number, trackIndex: number }} params
|
|
148
|
+
* @returns {void}
|
|
149
|
+
*/
|
|
150
|
+
function startExtraction({ key, ffmpegBin, localBaseUrl, sourceKey, fileIndex, trackIndex }) {
|
|
151
|
+
const inputUrl = new URL("/stream", `${localBaseUrl}/`);
|
|
152
|
+
inputUrl.searchParams.set("sourceKey", sourceKey);
|
|
153
|
+
inputUrl.searchParams.set("fileIndex", String(fileIndex));
|
|
154
|
+
|
|
155
|
+
extractions.set(key, { state: "running" });
|
|
156
|
+
const startedAt = Date.now();
|
|
157
|
+
const ffmpeg = spawn(
|
|
158
|
+
ffmpegBin,
|
|
159
|
+
["-hide_banner", "-loglevel", "error", "-i", inputUrl.toString(), "-map", `0:s:${trackIndex}`, "-f", "webvtt", "pipe:1"],
|
|
160
|
+
{ stdio: ["ignore", "pipe", "pipe"], windowsHide: true }
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
let stderr = "";
|
|
164
|
+
ffmpeg.stderr.on("data", (chunk) => {
|
|
165
|
+
if (stderr.length < 4096) {
|
|
166
|
+
stderr += String(chunk);
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
/** @type {Buffer[]} */
|
|
171
|
+
const chunks = [];
|
|
172
|
+
ffmpeg.stdout.on("data", (chunk) => chunks.push(chunk));
|
|
173
|
+
|
|
174
|
+
const killTimer = setTimeout(() => {
|
|
175
|
+
if (!ffmpeg.killed) {
|
|
176
|
+
ffmpeg.kill("SIGKILL");
|
|
177
|
+
}
|
|
178
|
+
}, EXTRACTION_TIMEOUT_MS);
|
|
179
|
+
killTimer.unref?.();
|
|
180
|
+
|
|
181
|
+
const settle = () => {
|
|
182
|
+
clearTimeout(killTimer);
|
|
183
|
+
const seconds = ((Date.now() - startedAt) / 1000).toFixed(1);
|
|
184
|
+
const body = Buffer.concat(chunks);
|
|
185
|
+
if (body.length === 0) {
|
|
186
|
+
extractions.set(key, {
|
|
187
|
+
state: "failed",
|
|
188
|
+
error: `Subtitle track could not be extracted: ${stderr.trim() || "no output from ffmpeg"}`
|
|
189
|
+
});
|
|
190
|
+
logger.warn(`subtitles ${key}: nothing produced after ${seconds}s`);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
// The whole document, decoded as one string, and only its cue text.
|
|
194
|
+
// Detecting on the first 4096 BYTES was wrong twice over: a byte cut lands
|
|
195
|
+
// mid-character on any non-Latin track, and most of those bytes are
|
|
196
|
+
// timestamps rather than words. This runs once per track in the background,
|
|
197
|
+
// so reading all of it costs nothing anybody waits for.
|
|
198
|
+
extractions.set(key, { state: "done", body, language: TextSubtitleTrack.detectLanguageFromVtt(body.toString("utf8")) });
|
|
199
|
+
logger.info(`subtitles ${key}: ${body.length} bytes in ${seconds}s`);
|
|
200
|
+
};
|
|
201
|
+
ffmpeg.once("close", settle);
|
|
202
|
+
ffmpeg.once("error", settle);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
|