@torrent-tv/proxy 2.9.27 → 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.
@@ -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
+ }
@@ -9,7 +9,7 @@
9
9
  import crypto from "node:crypto";
10
10
  import os from "node:os";
11
11
  import path from "node:path";
12
- import { rmSync } from "node:fs";
12
+ import { rmSync, statfsSync } from "node:fs";
13
13
  import WebTorrent from "webtorrent";
14
14
  import { logger } from "../utils/logger.js";
15
15
 
@@ -34,6 +34,37 @@ const PRIORITY_WINDOW_BYTES = 8 * 1024 * 1024;
34
34
  const HEADER_HEAD_BYTES = 256 * 1024;
35
35
  const HEADER_TAIL_BYTES = 2 * 1024 * 1024;
36
36
 
37
+ // Global disk cap. Downloaded torrent data is removed on idle TTL and at
38
+ // shutdown, but under pressure (several large files within the TTL window)
39
+ // it can still fill a small HA host's disk (SD/eMMC), which can take down
40
+ // Home Assistant itself. When the total exceeds the cap, whole torrents with
41
+ // no active reader are evicted least-recently-used first. Active torrents are
42
+ // never evicted (we cannot delete what is playing).
43
+ const DISK_CAP_ABSOLUTE_MAX_BYTES = 10 * 1024 * 1024 * 1024; // 10 GB
44
+ const DISK_CAP_SWEEP_INTERVAL_MS = 30_000;
45
+
46
+ /**
47
+ * Compute the default disk cap: the smaller of a fixed 10 GB and half of the
48
+ * currently free space on the store's filesystem (so a tiny host is never
49
+ * asked to hold more than it can). Best-effort; falls back to the fixed max
50
+ * when the filesystem cannot be stat'd.
51
+ *
52
+ * @param {string} storePath
53
+ * @returns {number}
54
+ */
55
+ function computeDefaultDiskCap(storePath) {
56
+ try {
57
+ const stat = statfsSync(storePath);
58
+ const freeBytes = stat.bavail * stat.bsize;
59
+ if (Number.isFinite(freeBytes) && freeBytes > 0) {
60
+ return Math.min(DISK_CAP_ABSOLUTE_MAX_BYTES, Math.floor(freeBytes / 2));
61
+ }
62
+ } catch {
63
+ // statfs unavailable (old Node / odd FS) — fall back to the fixed max.
64
+ }
65
+ return DISK_CAP_ABSOLUTE_MAX_BYTES;
66
+ }
67
+
37
68
  /**
38
69
  * Decode a raw torrent source value into the format expected by WebTorrent.
39
70
  *
@@ -77,7 +108,27 @@ export class TorrentPool {
77
108
  */
78
109
  #idleTimers = new Map();
79
110
 
80
- constructor() {
111
+ /**
112
+ * Last time each torrent was acquired or fetched, for LRU eviction under
113
+ * the disk cap.
114
+ *
115
+ * @type {Map<import("webtorrent").Torrent, number>}
116
+ */
117
+ #lastAccess = new Map();
118
+
119
+ /** Global disk cap in bytes (0 = disabled). */
120
+ #maxDiskBytes = 0;
121
+
122
+ /** Periodic disk-cap enforcement timer. */
123
+ #diskSweepTimer = null;
124
+
125
+ /**
126
+ * @param {{ maxDiskBytes?: number }} [options]
127
+ * `maxDiskBytes` caps total downloaded torrent data; when omitted a
128
+ * default is computed from free disk (min(10 GB, half free)). Pass 0 to
129
+ * disable the cap.
130
+ */
131
+ constructor({ maxDiskBytes } = {}) {
81
132
  // Sweep orphaned torrent data left by a previous hard kill (no graceful
82
133
  // shutdown ran, so destroyAll never cleaned the store). Safe here: no
83
134
  // torrents are loaded yet at construction. Best-effort, synchronous so it
@@ -114,6 +165,71 @@ export class TorrentPool {
114
165
  const message = warning instanceof Error ? warning.message : String(warning);
115
166
  logger.warn(`torrent-pool: client warning: ${message}`);
116
167
  });
168
+
169
+ this.#maxDiskBytes = Number.isFinite(maxDiskBytes) && maxDiskBytes >= 0
170
+ ? maxDiskBytes
171
+ : computeDefaultDiskCap(os.tmpdir());
172
+ if (this.#maxDiskBytes > 0) {
173
+ const gb = (this.#maxDiskBytes / (1024 * 1024 * 1024)).toFixed(1);
174
+ logger.info(`torrent-pool: disk cap ${gb} GB (LRU eviction of idle torrents above it)`);
175
+ this.#diskSweepTimer = setInterval(() => this.#enforceDiskCap(), DISK_CAP_SWEEP_INTERVAL_MS);
176
+ this.#diskSweepTimer.unref?.();
177
+ }
178
+ }
179
+
180
+ /**
181
+ * Sum of downloaded bytes across pooled torrents — a cheap proxy for the
182
+ * on-disk footprint (the FS store writes downloaded pieces).
183
+ *
184
+ * @returns {number}
185
+ */
186
+ #currentDiskBytes() {
187
+ let total = 0;
188
+ for (const torrent of this.torrents.values()) {
189
+ const downloaded = typeof torrent?.downloaded === "number" ? torrent.downloaded : 0;
190
+ total += Math.max(0, downloaded);
191
+ }
192
+ return total;
193
+ }
194
+
195
+ /**
196
+ * Evict whole torrents, least-recently-used first, while the total on-disk
197
+ * footprint exceeds the cap. Only torrents with NO active file reader are
198
+ * evictable — a playing torrent cannot be deleted. Best-effort.
199
+ *
200
+ * @returns {void}
201
+ */
202
+ #enforceDiskCap() {
203
+ if (this.#maxDiskBytes <= 0) {
204
+ return;
205
+ }
206
+ let used = this.#currentDiskBytes();
207
+ if (used <= this.#maxDiskBytes) {
208
+ return;
209
+ }
210
+ // Candidates: pooled torrents with zero active readers, LRU first.
211
+ const candidates = [...this.torrents.values()]
212
+ .filter((t) => {
213
+ const usage = this.fileUsageByTorrent.get(t);
214
+ return !usage || usage.size === 0;
215
+ })
216
+ .sort((a, b) => (this.#lastAccess.get(a) ?? 0) - (this.#lastAccess.get(b) ?? 0));
217
+
218
+ for (const torrent of candidates) {
219
+ if (used <= this.#maxDiskBytes) {
220
+ break;
221
+ }
222
+ const freed = typeof torrent?.downloaded === "number" ? Math.max(0, torrent.downloaded) : 0;
223
+ const name = typeof torrent?.name === "string" ? torrent.name : "(unknown)";
224
+ const gb = (this.#maxDiskBytes / (1024 * 1024 * 1024)).toFixed(1);
225
+ logger.info(
226
+ `torrent-pool: disk cap ${gb} GB exceeded — evicting idle torrent "${name}" ` +
227
+ `(~${(freed / (1024 * 1024)).toFixed(0)} MB)`
228
+ );
229
+ this.#cancelIdleRemoval(torrent);
230
+ this.#removeTorrent(torrent);
231
+ used -= freed;
232
+ }
117
233
  }
118
234
 
119
235
  /**
@@ -173,6 +289,7 @@ export class TorrentPool {
173
289
  // Already resolved — return immediately.
174
290
  const existing = this.torrents.get(key);
175
291
  if (existing) {
292
+ this.#lastAccess.set(existing, Date.now());
176
293
  return existing;
177
294
  }
178
295
 
@@ -197,6 +314,7 @@ export class TorrentPool {
197
314
  if (existing) {
198
315
  const settle = () => {
199
316
  this.torrents.set(key, existing);
317
+ this.#lastAccess.set(existing, Date.now());
200
318
  this.#pending.delete(key);
201
319
  resolve(existing);
202
320
  };
@@ -215,6 +333,7 @@ export class TorrentPool {
215
333
  this.client.add(torrentId, (readyTorrent) => {
216
334
  this.client.off("error", onError);
217
335
  this.torrents.set(key, readyTorrent);
336
+ this.#lastAccess.set(readyTorrent, Date.now());
218
337
  this.#pending.delete(key);
219
338
  // Key layout is `${sourceType}:${sha1}`; log with the sha1 prefix so
220
339
  // lines correlate with the [stats] source key.
@@ -274,8 +393,10 @@ export class TorrentPool {
274
393
  usage = new Map();
275
394
  this.fileUsageByTorrent.set(torrent, usage);
276
395
  }
277
- // The torrent is in use again — cancel any pending idle removal.
396
+ // The torrent is in use again — cancel any pending idle removal and mark
397
+ // it recently accessed so LRU eviction keeps it.
278
398
  this.#cancelIdleRemoval(torrent);
399
+ this.#lastAccess.set(torrent, Date.now());
279
400
  usage.set(fileIndex, (usage.get(fileIndex) ?? 0) + 1);
280
401
  this.#syncSelections(torrent, usage);
281
402
 
@@ -360,6 +481,7 @@ export class TorrentPool {
360
481
  }
361
482
  }
362
483
  this.fileUsageByTorrent.delete(torrent);
484
+ this.#lastAccess.delete(torrent);
363
485
  const name = typeof torrent.name === "string" ? torrent.name : "(unknown)";
364
486
  try {
365
487
  torrent.destroy({ destroyStore: true }, () => {
@@ -617,11 +739,17 @@ export class TorrentPool {
617
739
  * @returns {Promise<void>}
618
740
  */
619
741
  async destroyAll() {
742
+ // Stop periodic disk-cap enforcement.
743
+ if (this.#diskSweepTimer) {
744
+ clearInterval(this.#diskSweepTimer);
745
+ this.#diskSweepTimer = null;
746
+ }
620
747
  // Cancel any pending idle-removal timers — destroyAll handles teardown.
621
748
  for (const timer of this.#idleTimers.values()) {
622
749
  clearTimeout(timer);
623
750
  }
624
751
  this.#idleTimers.clear();
752
+ this.#lastAccess.clear();
625
753
 
626
754
  if (!this.client || this.client.destroyed) {
627
755
  return;