@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.
Files changed (34) hide show
  1. package/CHANGELOG.md +1453 -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 +400 -135
  7. package/services/container/ContainerFactory.js +55 -31
  8. package/services/container/MatroskaContainer.js +1166 -516
  9. package/services/container/Mp4Container.js +898 -392
  10. package/services/container/SubtitleFileContainer.js +323 -261
  11. package/services/controllers/SubtitleController.js +128 -127
  12. package/services/delivery-probe.js +64 -6
  13. package/services/hls-session-manager.js +32 -35
  14. package/services/language-detect.js +174 -228
  15. package/services/playback-planner.js +747 -747
  16. package/services/produced-index.js +300 -0
  17. package/services/torrent-worker/subtitle-cues.js +549 -633
  18. package/services/tracks/TextSubtitleTrack.js +287 -47
  19. package/services/tracks/index.js +14 -14
  20. package/test/delivery-probe.test.js +67 -0
  21. package/test/matroska-blocks.test.js +0 -0
  22. package/test/mp4-subtitles.test.js +173 -127
  23. package/test/produced-index.test.js +188 -0
  24. package/test/subtitle-cue-framing.test.js +200 -202
  25. package/test/subtitle-cue-walk.test.js +369 -0
  26. package/test/subtitle-defaults.test.js +97 -97
  27. package/test/subtitle-language.test.js +252 -252
  28. package/test/subtitle-track-numbering.test.js +370 -370
  29. package/services/container-index/matroska-blocks.js +0 -202
  30. package/services/container-index/matroska-subtitles.js +0 -372
  31. package/services/container-index/mp4-subtitles.js +0 -404
  32. package/services/subtitle-convert.js +0 -144
  33. package/services/subtitle-defaults.js +0 -157
  34. 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
+