@torrent-tv/proxy 2.9.29 → 2.9.30
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 +4 -0
- package/openspec/changes/subtitle-language/.openspec.yaml +2 -0
- package/openspec/changes/subtitle-language/proposal.md +50 -0
- package/openspec/changes/subtitle-language/specs/subtitle-language/spec.md +34 -0
- package/openspec/changes/subtitle-language/tasks.md +26 -0
- package/package.json +2 -1
- package/routes/api/subtitles/get.js +92 -30
- package/services/language-detect.js +73 -0
- package/services/subtitle-convert.js +150 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
## 2.9.30
|
|
2
|
+
|
|
3
|
+
- **New**: The proxy owns subtitle conversion and detects the language from content (OpenSpec change `subtitle-language`). `GET /api/subtitles` now also serves EXTERNAL subtitle files (no `trackIndex`): it reads the file, decodes its encoding (UTF-8 or Windows-1251 — common for Russian `.srt`), converts `.srt`/`.ass`/`.ssa` → WebVTT on the proxy (the browser no longer converts), and reports the language in `X-Subtitle-Language`/`X-Subtitle-Language-Name`. Language is detected with `franc` (n-gram, MIT) restricted to a curated language set — it distinguishes Russian from Ukrainian (and Latin languages) and avoids short-text false positives, returning no header when undetermined. Embedded tracks detect from the first chunk of extracted VTT. Pairs with the server release that fetches VTT from here and applies the filename → content → audio-language priority.
|
|
4
|
+
|
|
1
5
|
## 2.9.29
|
|
2
6
|
|
|
3
7
|
- **New**: Global disk cap with LRU eviction (OpenSpec change `disk-cap`; Disk hygiene Level 1, final piece). Downloaded torrent data was already removed on a 5-min idle TTL and at shutdown, but under pressure it could still fill a small Home Assistant host's disk (which can take down HA itself). The pool now caps total downloaded data — default min(10 GB, half of free disk), overridable with `--max-disk-bytes` (0 disables) — and, when exceeded, evicts whole torrents with no active reader least-recently-used first (checked every 30 s). A torrent that is currently playing is never evicted. (LRU = least-recently-used.)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# Proposal: Proxy owns subtitle conversion + content-based language detection
|
|
2
|
+
|
|
3
|
+
## Why
|
|
4
|
+
|
|
5
|
+
Subtitle language was guessed only from the filename, so a file without a
|
|
6
|
+
language code (e.g. the Enola release's `.srt`) showed "Unknown", and the
|
|
7
|
+
owner specifically does not want Ukrainian and Russian confused. Reliable
|
|
8
|
+
detection needs an n-gram model on the actual text — which belongs on the
|
|
9
|
+
proxy (Node, node_modules, no per-browser payload, and the subtitle bytes are
|
|
10
|
+
right there) rather than in the browser.
|
|
11
|
+
|
|
12
|
+
## What Changes
|
|
13
|
+
|
|
14
|
+
- The proxy becomes the single owner of subtitle content: `GET /api/subtitles`
|
|
15
|
+
now also serves EXTERNAL subtitle files (no `trackIndex`) — it reads the
|
|
16
|
+
file, decodes its encoding (UTF-8 or Windows-1251, common for Russian
|
|
17
|
+
`.srt`), and converts `.srt`/`.ass`/`.ssa` → WebVTT here. The browser no
|
|
18
|
+
longer converts.
|
|
19
|
+
- The proxy detects the language from the full text with `franc` (n-gram /
|
|
20
|
+
trigram, MIT), restricted to a curated set of plausible subtitle languages
|
|
21
|
+
(distinguishes ru/uk/bg/sr and Latin languages; avoids short-text false
|
|
22
|
+
positives like English→Scots), and reports it in `X-Subtitle-Language`
|
|
23
|
+
(+ `X-Subtitle-Language-Name`). Embedded tracks detect from the first chunk
|
|
24
|
+
of extracted VTT.
|
|
25
|
+
- The browser sets each track's language by priority: explicit code in the
|
|
26
|
+
filename / container metadata (author intent) → proxy content detection →
|
|
27
|
+
the film's audio-track language (forced-signs subs usually match the dub)
|
|
28
|
+
→ Unknown.
|
|
29
|
+
|
|
30
|
+
## Capabilities
|
|
31
|
+
|
|
32
|
+
### New Capabilities
|
|
33
|
+
|
|
34
|
+
- `subtitle-language`: proxy-side subtitle conversion, encoding handling and
|
|
35
|
+
content-based language detection.
|
|
36
|
+
|
|
37
|
+
### Modified Capabilities
|
|
38
|
+
|
|
39
|
+
<!-- track-selection covered embedded extraction; this extends /api/subtitles
|
|
40
|
+
to external files and adds detection. Its change is unarchived, so this
|
|
41
|
+
lands as a new capability rather than a delta. -->
|
|
42
|
+
|
|
43
|
+
## Impact
|
|
44
|
+
|
|
45
|
+
- proxy: new `services/subtitle-convert.js`, `services/language-detect.js`
|
|
46
|
+
(franc dep), extended `routes/api/subtitles/get.js`.
|
|
47
|
+
- server: `components/loading/loading.js` fetches VTT from the proxy for
|
|
48
|
+
external subs (drops client-side conversion) and applies the language
|
|
49
|
+
priority; browser franc/alphabet detection removed.
|
|
50
|
+
- Pairs with a server release; requires the ha-addon bump.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# subtitle-language — delta spec
|
|
2
|
+
|
|
3
|
+
## ADDED Requirements
|
|
4
|
+
|
|
5
|
+
### Requirement: Proxy converts subtitles and reports the language
|
|
6
|
+
`GET /api/subtitles` SHALL serve every subtitle as WebVTT and report the
|
|
7
|
+
detected language in `X-Subtitle-Language` (ISO 639-1) and
|
|
8
|
+
`X-Subtitle-Language-Name`. It SHALL handle two modes: an embedded track
|
|
9
|
+
(`trackIndex` given — extracted via ffmpeg) and an external subtitle file (no
|
|
10
|
+
`trackIndex` — the file is read, its encoding decoded, and `.srt`/`.ass`/
|
|
11
|
+
`.ssa` converted to WebVTT on the proxy). A leading BOM SHALL be stripped and
|
|
12
|
+
Windows-1251 bytes decoded when the file is not valid UTF-8.
|
|
13
|
+
|
|
14
|
+
#### Scenario: External Russian .srt without a filename code
|
|
15
|
+
- **WHEN** an external `.srt` whose name has no language code is requested
|
|
16
|
+
- **THEN** the response is WebVTT and `X-Subtitle-Language` is `ru`
|
|
17
|
+
|
|
18
|
+
#### Scenario: Ukrainian is not reported as Russian
|
|
19
|
+
- **WHEN** the subtitle text is Ukrainian
|
|
20
|
+
- **THEN** the detected language is `uk`, not `ru`
|
|
21
|
+
|
|
22
|
+
#### Scenario: Unsupported format
|
|
23
|
+
- **WHEN** an image-based or unconvertible subtitle is requested
|
|
24
|
+
- **THEN** the proxy responds 422
|
|
25
|
+
|
|
26
|
+
### Requirement: Detection is confidence-gated
|
|
27
|
+
Language detection SHALL be restricted to a curated set of plausible subtitle
|
|
28
|
+
languages and SHALL return no language (omit the header) when the text is too
|
|
29
|
+
short or undetermined, rather than emitting a wrong guess.
|
|
30
|
+
|
|
31
|
+
#### Scenario: Too little text
|
|
32
|
+
- **WHEN** the subtitle has only a few characters
|
|
33
|
+
- **THEN** no language header is set (the browser falls back to filename or
|
|
34
|
+
audio language)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Tasks: Proxy subtitle conversion + language detection
|
|
2
|
+
|
|
3
|
+
## 1. Proxy
|
|
4
|
+
|
|
5
|
+
- [x] 1.1 `services/subtitle-convert.js`: encoding-aware decode (UTF-8/BOM/
|
|
6
|
+
Windows-1251) + srt/ass/ssa → WebVTT (ported, BOM-stripped)
|
|
7
|
+
- [x] 1.2 `services/language-detect.js`: franc restricted to a curated
|
|
8
|
+
ISO 639-3→639-1 allowlist; null when undetermined/too short
|
|
9
|
+
- [x] 1.3 `routes/api/subtitles/get.js`: external-file mode (read, decode,
|
|
10
|
+
convert, detect) + `X-Subtitle-Language(-Name)` on both modes
|
|
11
|
+
- [x] 1.4 franc dependency added to proxy
|
|
12
|
+
- [x] 1.5 Verified: unit-tested convert+detect (ru/uk/en/de, short→null) and
|
|
13
|
+
the route end-to-end on the real Enola .srt (→ ru, 19 cues)
|
|
14
|
+
|
|
15
|
+
## 2. Server (browser)
|
|
16
|
+
|
|
17
|
+
- [x] 2.1 External subs fetch VTT from `/api/subtitles` (no client convert)
|
|
18
|
+
- [x] 2.2 Language priority: filename code → X-Subtitle-Language → audio-track
|
|
19
|
+
language → und; embedded track uses metadata → header → audio
|
|
20
|
+
- [x] 2.3 Removed the client-side convertSubtitleToVtt call + dead import
|
|
21
|
+
|
|
22
|
+
## 3. Release
|
|
23
|
+
|
|
24
|
+
- [ ] 3.1 Proxy publish (OTP) + ha-addon bump + server patch
|
|
25
|
+
- [ ] 3.2 Field-check: Enola external .srt labels "Russian"; a Ukrainian sub
|
|
26
|
+
labels "Ukrainian"; embedded tracks keep their metadata language
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@torrent-tv/proxy",
|
|
3
|
-
"version": "2.9.
|
|
3
|
+
"version": "2.9.30",
|
|
4
4
|
"description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
|
|
5
5
|
"license": "GPL-3.0-or-later",
|
|
6
6
|
"publishConfig": {
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
"commander": "^12.1.0",
|
|
27
27
|
"fastify": "^5.8.5",
|
|
28
28
|
"ffmpeg-static": "^5.3.0",
|
|
29
|
+
"franc": "^6.2.0",
|
|
29
30
|
"get-port": "^7.1.0",
|
|
30
31
|
"node-datachannel": "^0.32.0",
|
|
31
32
|
"webtorrent": "^2.8.4",
|
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
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:
|
|
3
5
|
*
|
|
4
|
-
*
|
|
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.
|
|
5
12
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* `subtitleTracks[].index`.
|
|
9
|
-
*
|
|
10
|
-
* The response streams while ffmpeg produces it. Extraction has to read the
|
|
11
|
-
* file up to the last cue, so on a cold torrent this drives (and waits for)
|
|
12
|
-
* the sequential download — callers must use a generous timeout.
|
|
13
|
+
* The proxy owns subtitle conversion + language detection so no model or
|
|
14
|
+
* converter ships to the browser and detection sees the full text.
|
|
13
15
|
*
|
|
14
16
|
* @param {import("fastify").FastifyRequest} req
|
|
15
17
|
* @param {import("fastify").FastifyReply} reply
|
|
@@ -23,19 +25,35 @@
|
|
|
23
25
|
*/
|
|
24
26
|
|
|
25
27
|
import { spawn } from "node:child_process";
|
|
28
|
+
import { convertSubtitleToVtt, decodeSubtitleBytes } from "../../../services/subtitle-convert.js";
|
|
29
|
+
import { detectLanguage } from "../../../services/language-detect.js";
|
|
26
30
|
|
|
27
|
-
// Safety cap: no extraction may outlive this
|
|
28
|
-
// hold the ffmpeg process forever).
|
|
31
|
+
// Safety cap: no embedded extraction may outlive this.
|
|
29
32
|
const EXTRACTION_TIMEOUT_MS = 30 * 60 * 1000;
|
|
33
|
+
// External subtitle files are small; cap the read to guard against a bad index.
|
|
34
|
+
const EXTERNAL_MAX_BYTES = 8 * 1024 * 1024;
|
|
35
|
+
|
|
36
|
+
/** Set the detected-language response headers (no-op when detection failed). */
|
|
37
|
+
function setLanguageHeaders(reply, lang) {
|
|
38
|
+
if (lang && typeof lang.code === "string") {
|
|
39
|
+
reply.raw.setHeader("X-Subtitle-Language", lang.code);
|
|
40
|
+
if (typeof lang.name === "string") {
|
|
41
|
+
reply.raw.setHeader("X-Subtitle-Language-Name", encodeURIComponent(lang.name));
|
|
42
|
+
}
|
|
43
|
+
// These are custom headers on a cross-origin fetch — expose them.
|
|
44
|
+
reply.raw.setHeader("Access-Control-Expose-Headers", "X-Subtitle-Language, X-Subtitle-Language-Name");
|
|
45
|
+
}
|
|
46
|
+
}
|
|
30
47
|
|
|
31
48
|
export async function handleApiSubtitlesGet(req, reply, { sourceRegistry, torrentPool, ffmpegBin, localBaseUrl }) {
|
|
32
49
|
const query = req.query ?? {};
|
|
33
50
|
const sourceKey = typeof query.sourceKey === "string" ? query.sourceKey.trim() : "";
|
|
34
51
|
const fileIndex = Number(query.fileIndex);
|
|
52
|
+
const hasTrackIndex = query.trackIndex !== undefined && query.trackIndex !== "";
|
|
35
53
|
const trackIndex = Number(query.trackIndex);
|
|
36
54
|
|
|
37
|
-
if (!sourceKey || !Number.isInteger(fileIndex) || fileIndex < 0
|
|
38
|
-
return reply.code(400).send({ error: "sourceKey
|
|
55
|
+
if (!sourceKey || !Number.isInteger(fileIndex) || fileIndex < 0) {
|
|
56
|
+
return reply.code(400).send({ error: "sourceKey and fileIndex are required." });
|
|
39
57
|
}
|
|
40
58
|
|
|
41
59
|
const sourceRecord = sourceRegistry.get(sourceKey);
|
|
@@ -43,28 +61,47 @@ export async function handleApiSubtitlesGet(req, reply, { sourceRegistry, torren
|
|
|
43
61
|
return reply.code(404).send({ error: "Source key was not found." });
|
|
44
62
|
}
|
|
45
63
|
const torrent = await torrentPool.getTorrent(sourceRecord.sourceType, sourceRecord.source);
|
|
46
|
-
|
|
64
|
+
const file = torrent.files[fileIndex];
|
|
65
|
+
if (!file) {
|
|
47
66
|
return reply.code(404).send({ error: "File index was not found in torrent." });
|
|
48
67
|
}
|
|
49
68
|
|
|
69
|
+
// ---- External subtitle FILE (no trackIndex) -----------------------------
|
|
70
|
+
if (!hasTrackIndex) {
|
|
71
|
+
const name = typeof file.name === "string" ? file.name : "";
|
|
72
|
+
const ext = name.slice(name.lastIndexOf(".")).toLowerCase();
|
|
73
|
+
const release = torrentPool.acquireFile(torrent, fileIndex);
|
|
74
|
+
try {
|
|
75
|
+
const bytes = await readFileFully(file, EXTERNAL_MAX_BYTES);
|
|
76
|
+
const text = decodeSubtitleBytes(bytes);
|
|
77
|
+
const vtt = convertSubtitleToVtt(text, ext);
|
|
78
|
+
if (!vtt) {
|
|
79
|
+
return reply.code(422).send({ error: `Unsupported subtitle format: ${ext}` });
|
|
80
|
+
}
|
|
81
|
+
setLanguageHeaders(reply, detectLanguage(text));
|
|
82
|
+
reply.header("content-type", "text/vtt; charset=utf-8");
|
|
83
|
+
reply.header("cache-control", "no-store");
|
|
84
|
+
return reply.send(vtt);
|
|
85
|
+
} catch (error) {
|
|
86
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
87
|
+
return reply.code(502).send({ error: `Could not read subtitle file: ${message}` });
|
|
88
|
+
} finally {
|
|
89
|
+
release();
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ---- Embedded track (ffmpeg extraction, streamed) -----------------------
|
|
94
|
+
if (!Number.isInteger(trackIndex) || trackIndex < 0) {
|
|
95
|
+
return reply.code(400).send({ error: "trackIndex must be a non-negative integer." });
|
|
96
|
+
}
|
|
97
|
+
|
|
50
98
|
const inputUrl = new URL("/stream", `${localBaseUrl}/`);
|
|
51
99
|
inputUrl.searchParams.set("sourceKey", sourceKey);
|
|
52
100
|
inputUrl.searchParams.set("fileIndex", String(fileIndex));
|
|
53
101
|
|
|
54
102
|
const ffmpeg = spawn(
|
|
55
103
|
ffmpegBin,
|
|
56
|
-
[
|
|
57
|
-
"-hide_banner",
|
|
58
|
-
"-loglevel",
|
|
59
|
-
"error",
|
|
60
|
-
"-i",
|
|
61
|
-
inputUrl.toString(),
|
|
62
|
-
"-map",
|
|
63
|
-
`0:s:${trackIndex}`,
|
|
64
|
-
"-f",
|
|
65
|
-
"webvtt",
|
|
66
|
-
"pipe:1"
|
|
67
|
-
],
|
|
104
|
+
["-hide_banner", "-loglevel", "error", "-i", inputUrl.toString(), "-map", `0:s:${trackIndex}`, "-f", "webvtt", "pipe:1"],
|
|
68
105
|
{ stdio: ["ignore", "pipe", "pipe"], windowsHide: true }
|
|
69
106
|
);
|
|
70
107
|
|
|
@@ -81,8 +118,6 @@ export async function handleApiSubtitlesGet(req, reply, { sourceRegistry, torren
|
|
|
81
118
|
}
|
|
82
119
|
}, EXTRACTION_TIMEOUT_MS);
|
|
83
120
|
killTimer.unref?.();
|
|
84
|
-
|
|
85
|
-
// Stop extracting when the client goes away.
|
|
86
121
|
req.raw.on("close", () => {
|
|
87
122
|
clearTimeout(killTimer);
|
|
88
123
|
if (!ffmpeg.killed) {
|
|
@@ -90,8 +125,6 @@ export async function handleApiSubtitlesGet(req, reply, { sourceRegistry, torren
|
|
|
90
125
|
}
|
|
91
126
|
});
|
|
92
127
|
|
|
93
|
-
// Distinguish "bad track / not text-based" (ffmpeg dies before any output)
|
|
94
|
-
// from a mid-stream failure (headers already sent; the stream just ends).
|
|
95
128
|
const firstChunk = await new Promise((resolve) => {
|
|
96
129
|
let settled = false;
|
|
97
130
|
const settle = (value) => {
|
|
@@ -112,6 +145,9 @@ export async function handleApiSubtitlesGet(req, reply, { sourceRegistry, torren
|
|
|
112
145
|
.send({ error: `Subtitle track could not be extracted: ${stderr.trim() || "no output from ffmpeg"}` });
|
|
113
146
|
}
|
|
114
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)));
|
|
115
151
|
reply.raw.writeHead(200, {
|
|
116
152
|
"content-type": "text/vtt; charset=utf-8",
|
|
117
153
|
"cache-control": "no-store",
|
|
@@ -127,3 +163,29 @@ export async function handleApiSubtitlesGet(req, reply, { sourceRegistry, torren
|
|
|
127
163
|
reply.raw.end();
|
|
128
164
|
return reply;
|
|
129
165
|
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Read a torrent file fully into a Buffer, bounded by `maxBytes`.
|
|
169
|
+
*
|
|
170
|
+
* @param {{ createReadStream: () => import("node:stream").Readable, length?: number }} file
|
|
171
|
+
* @param {number} maxBytes
|
|
172
|
+
* @returns {Promise<Buffer>}
|
|
173
|
+
*/
|
|
174
|
+
function readFileFully(file, maxBytes) {
|
|
175
|
+
return new Promise((resolve, reject) => {
|
|
176
|
+
const stream = file.createReadStream();
|
|
177
|
+
const chunks = [];
|
|
178
|
+
let total = 0;
|
|
179
|
+
stream.on("data", (chunk) => {
|
|
180
|
+
total += chunk.length;
|
|
181
|
+
if (total > maxBytes) {
|
|
182
|
+
stream.destroy();
|
|
183
|
+
reject(new Error("subtitle file exceeds the size cap"));
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
chunks.push(chunk);
|
|
187
|
+
});
|
|
188
|
+
stream.on("end", () => resolve(Buffer.concat(chunks)));
|
|
189
|
+
stream.on("error", reject);
|
|
190
|
+
});
|
|
191
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Content-based subtitle language detection (proxy side).
|
|
3
|
+
*
|
|
4
|
+
* Uses `franc` (n-gram / trigram frequency against per-language reference
|
|
5
|
+
* profiles — MIT). Runs on the proxy where the full subtitle text and
|
|
6
|
+
* node_modules live, so no detection model ships to the browser. Detection is
|
|
7
|
+
* restricted to a curated set of plausible subtitle languages via franc's
|
|
8
|
+
* `only` option: this both maps ISO 639-3 → ISO 639-1 + English name and
|
|
9
|
+
* avoids exotic false positives on short text (e.g. English mis-detected as
|
|
10
|
+
* Scots). Returns null when franc is not confident (too little text, or
|
|
11
|
+
* undetermined).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { franc } from "franc";
|
|
15
|
+
|
|
16
|
+
/** ISO 639-3 (franc output) → { code: ISO 639-1 / BCP-47, name }. Curated allowlist. */
|
|
17
|
+
const LANG_3_TO_1 = {
|
|
18
|
+
eng: { code: "en", name: "English" },
|
|
19
|
+
rus: { code: "ru", name: "Russian" },
|
|
20
|
+
ukr: { code: "uk", name: "Ukrainian" },
|
|
21
|
+
bel: { code: "be", name: "Belarusian" },
|
|
22
|
+
jpn: { code: "ja", name: "Japanese" },
|
|
23
|
+
kor: { code: "ko", name: "Korean" },
|
|
24
|
+
cmn: { code: "zh", name: "Chinese" },
|
|
25
|
+
spa: { code: "es", name: "Spanish" },
|
|
26
|
+
fra: { code: "fr", name: "French" },
|
|
27
|
+
deu: { code: "de", name: "German" },
|
|
28
|
+
ita: { code: "it", name: "Italian" },
|
|
29
|
+
por: { code: "pt", name: "Portuguese" },
|
|
30
|
+
pol: { code: "pl", name: "Polish" },
|
|
31
|
+
nld: { code: "nl", name: "Dutch" },
|
|
32
|
+
arb: { code: "ar", name: "Arabic" },
|
|
33
|
+
tur: { code: "tr", name: "Turkish" },
|
|
34
|
+
vie: { code: "vi", name: "Vietnamese" },
|
|
35
|
+
tha: { code: "th", name: "Thai" },
|
|
36
|
+
hin: { code: "hi", name: "Hindi" },
|
|
37
|
+
ind: { code: "id", name: "Indonesian" },
|
|
38
|
+
zlm: { code: "ms", name: "Malay" },
|
|
39
|
+
ces: { code: "cs", name: "Czech" },
|
|
40
|
+
slk: { code: "sk", name: "Slovak" },
|
|
41
|
+
ron: { code: "ro", name: "Romanian" },
|
|
42
|
+
hun: { code: "hu", name: "Hungarian" },
|
|
43
|
+
srp: { code: "sr", name: "Serbian" },
|
|
44
|
+
hrv: { code: "hr", name: "Croatian" },
|
|
45
|
+
bul: { code: "bg", name: "Bulgarian" },
|
|
46
|
+
ell: { code: "el", name: "Greek" },
|
|
47
|
+
heb: { code: "he", name: "Hebrew" },
|
|
48
|
+
dan: { code: "da", name: "Danish" },
|
|
49
|
+
fin: { code: "fi", name: "Finnish" },
|
|
50
|
+
nob: { code: "no", name: "Norwegian" },
|
|
51
|
+
swe: { code: "sv", name: "Swedish" },
|
|
52
|
+
fas: { code: "fa", name: "Persian" }
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const ONLY = Object.keys(LANG_3_TO_1);
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Best-effort detect the language of subtitle text.
|
|
59
|
+
*
|
|
60
|
+
* @param {string} text - Decoded subtitle text (VTT/SRT/ASS — franc ignores markup well enough).
|
|
61
|
+
* @returns {{ code: string, name: string } | null} Detected language, or null when uncertain.
|
|
62
|
+
*/
|
|
63
|
+
export function detectLanguage(text) {
|
|
64
|
+
if (typeof text !== "string" || text.trim().length < 15) {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
// Restrict to plausible subtitle languages; require a little text.
|
|
68
|
+
const iso3 = franc(text, { only: ONLY, minLength: 15 });
|
|
69
|
+
if (iso3 === "und") {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
return LANG_3_TO_1[iso3] ?? null;
|
|
73
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Subtitle conversion (proxy side).
|
|
3
|
+
*
|
|
4
|
+
* Decodes subtitle file bytes (encoding-aware) and converts SubRip (.srt) and
|
|
5
|
+
* ASS/SSA (.ass/.ssa) to WebVTT so the browser can attach them to a `<track>`
|
|
6
|
+
* without any client-side conversion. The proxy owns subtitle conversion so it
|
|
7
|
+
* can also run language detection where the full text is available.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Decode subtitle bytes to text. Prefers UTF-8 (honouring a BOM); if the UTF-8
|
|
12
|
+
* decode yields many replacement characters the bytes are re-decoded as
|
|
13
|
+
* Windows-1251 (very common for Russian .srt files) — otherwise both display
|
|
14
|
+
* and language detection would see mojibake.
|
|
15
|
+
*
|
|
16
|
+
* @param {Buffer | Uint8Array} bytes
|
|
17
|
+
* @returns {string}
|
|
18
|
+
*/
|
|
19
|
+
export function decodeSubtitleBytes(bytes) {
|
|
20
|
+
const buf = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
|
|
21
|
+
// UTF-8 BOM → definitely UTF-8.
|
|
22
|
+
if (buf.length >= 3 && buf[0] === 0xef && buf[1] === 0xbb && buf[2] === 0xbf) {
|
|
23
|
+
return new TextDecoder("utf-8").decode(buf);
|
|
24
|
+
}
|
|
25
|
+
const utf8 = new TextDecoder("utf-8").decode(buf);
|
|
26
|
+
const replacements = (utf8.match(/�/g) || []).length;
|
|
27
|
+
// >0.5% replacement chars ⇒ not valid UTF-8; try the common legacy Cyrillic
|
|
28
|
+
// codepage. TextDecoder supports windows-1251 with a full-ICU Node build.
|
|
29
|
+
if (replacements > Math.max(2, utf8.length * 0.005)) {
|
|
30
|
+
try {
|
|
31
|
+
return new TextDecoder("windows-1251").decode(buf);
|
|
32
|
+
} catch {
|
|
33
|
+
// Decoder unavailable — fall back to the UTF-8 attempt.
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return utf8;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Strip a leading UTF-8 BOM so it never leaks into the WEBVTT signature or first cue. */
|
|
40
|
+
function stripBom(text) {
|
|
41
|
+
return typeof text === "string" && text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function srtTsToVtt(ts) {
|
|
45
|
+
return ts.replace(",", ".");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Convert SubRip (.srt) text to WebVTT.
|
|
50
|
+
*
|
|
51
|
+
* @param {string} text
|
|
52
|
+
* @returns {string}
|
|
53
|
+
*/
|
|
54
|
+
function srtToVtt(text) {
|
|
55
|
+
const lines = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
|
|
56
|
+
const out = ["WEBVTT", ""];
|
|
57
|
+
for (const line of lines) {
|
|
58
|
+
const m = line.match(/^(\d{2}:\d{2}:\d{2},\d{3})\s*-->\s*(\d{2}:\d{2}:\d{2},\d{3})(.*)?$/);
|
|
59
|
+
out.push(m ? `${srtTsToVtt(m[1])} --> ${srtTsToVtt(m[2])}${m[3] ?? ""}` : line);
|
|
60
|
+
}
|
|
61
|
+
return out.join("\n");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function assTsToVtt(ts) {
|
|
65
|
+
const m = ts.match(/^(\d+):(\d{2}):(\d{2})\.(\d{2})$/);
|
|
66
|
+
if (!m) {
|
|
67
|
+
return "00:00:00.000";
|
|
68
|
+
}
|
|
69
|
+
const ms = (parseInt(m[4], 10) * 10).toString().padStart(3, "0");
|
|
70
|
+
return `${m[1].padStart(2, "0")}:${m[2]}:${m[3]}.${ms}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function stripAssTags(text) {
|
|
74
|
+
return text
|
|
75
|
+
.replace(/\{[^}]*\}/g, "")
|
|
76
|
+
.replace(/\\N/g, "\n")
|
|
77
|
+
.replace(/\\n/g, "\n")
|
|
78
|
+
.replace(/\\h/g, " ")
|
|
79
|
+
.trim();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Convert ASS/SSA text to WebVTT (only the [Events] section; styling dropped).
|
|
84
|
+
*
|
|
85
|
+
* @param {string} text
|
|
86
|
+
* @returns {string}
|
|
87
|
+
*/
|
|
88
|
+
function assToVtt(text) {
|
|
89
|
+
const lines = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
|
|
90
|
+
let inEvents = false;
|
|
91
|
+
let formatCols = null;
|
|
92
|
+
const cues = [];
|
|
93
|
+
for (const line of lines) {
|
|
94
|
+
const trimmed = line.trim();
|
|
95
|
+
if (trimmed === "[Events]") {
|
|
96
|
+
inEvents = true;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (trimmed.startsWith("[") && trimmed.endsWith("]") && inEvents) {
|
|
100
|
+
inEvents = false;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (!inEvents) {
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (trimmed.startsWith("Format:")) {
|
|
107
|
+
formatCols = trimmed.slice("Format:".length).split(",").map((c) => c.trim().toLowerCase());
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (trimmed.startsWith("Dialogue:") && formatCols) {
|
|
111
|
+
const parts = trimmed.slice("Dialogue:".length).split(",");
|
|
112
|
+
const startIdx = formatCols.indexOf("start");
|
|
113
|
+
const endIdx = formatCols.indexOf("end");
|
|
114
|
+
const textIdx = formatCols.indexOf("text");
|
|
115
|
+
if (startIdx < 0 || endIdx < 0 || textIdx < 0) {
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
const cueText = stripAssTags(parts.slice(textIdx).join(","));
|
|
119
|
+
if (!cueText) {
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
cues.push(`${assTsToVtt((parts[startIdx] ?? "").trim())} --> ${assTsToVtt((parts[endIdx] ?? "").trim())}\n${cueText}`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return cues.length === 0 ? "WEBVTT\n" : `WEBVTT\n\n${cues.join("\n\n")}`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Convert subtitle text to WebVTT by file extension. Returns null for formats
|
|
130
|
+
* that cannot be converted in-place (image-based .sup, ambiguous .sub, .ttml).
|
|
131
|
+
*
|
|
132
|
+
* @param {string} text
|
|
133
|
+
* @param {string} ext - Lowercase extension including the dot, e.g. ".srt".
|
|
134
|
+
* @returns {string | null}
|
|
135
|
+
*/
|
|
136
|
+
export function convertSubtitleToVtt(text, ext) {
|
|
137
|
+
const clean = stripBom(text);
|
|
138
|
+
switch (ext) {
|
|
139
|
+
case ".vtt":
|
|
140
|
+
case ".webvtt":
|
|
141
|
+
return clean.trimStart().startsWith("WEBVTT") ? clean : `WEBVTT\n\n${clean}`;
|
|
142
|
+
case ".srt":
|
|
143
|
+
return srtToVtt(clean);
|
|
144
|
+
case ".ass":
|
|
145
|
+
case ".ssa":
|
|
146
|
+
return assToVtt(clean);
|
|
147
|
+
default:
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
}
|