@torrent-tv/proxy 2.73.1 → 2.74.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.
Files changed (33) hide show
  1. package/CHANGELOG.md +1447 -1437
  2. package/CLAUDE.md +165 -160
  3. package/docs/container-architecture.md +192 -184
  4. package/package.json +1 -1
  5. package/routes/api/subtitles/get.js +205 -205
  6. package/services/container/Container.js +354 -135
  7. package/services/container/MatroskaContainer.js +1155 -516
  8. package/services/container/Mp4Container.js +858 -392
  9. package/services/container/SubtitleFileContainer.js +323 -261
  10. package/services/controllers/SubtitleController.js +128 -127
  11. package/services/delivery-probe.js +64 -6
  12. package/services/hls-session-manager.js +32 -35
  13. package/services/language-detect.js +174 -228
  14. package/services/playback-planner.js +747 -747
  15. package/services/produced-index.js +300 -0
  16. package/services/torrent-worker/subtitle-cues.js +582 -633
  17. package/services/tracks/TextSubtitleTrack.js +287 -47
  18. package/services/tracks/index.js +14 -14
  19. package/test/delivery-probe.test.js +67 -0
  20. package/test/matroska-blocks.test.js +0 -0
  21. package/test/mp4-subtitles.test.js +173 -127
  22. package/test/produced-index.test.js +188 -0
  23. package/test/subtitle-cue-framing.test.js +200 -202
  24. package/test/subtitle-cue-walk.test.js +369 -0
  25. package/test/subtitle-defaults.test.js +97 -97
  26. package/test/subtitle-language.test.js +252 -252
  27. package/test/subtitle-track-numbering.test.js +370 -370
  28. package/services/container-index/matroska-blocks.js +0 -202
  29. package/services/container-index/matroska-subtitles.js +0 -372
  30. package/services/container-index/mp4-subtitles.js +0 -404
  31. package/services/subtitle-convert.js +0 -144
  32. package/services/subtitle-defaults.js +0 -157
  33. 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 { detectLanguageFromVtt } from "../../../services/language-detect.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: 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
+