@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.
- package/CHANGELOG.md +10 -0
- package/bin/cli.js +9 -1
- package/openspec/changes/disk-cap/.openspec.yaml +2 -0
- package/openspec/changes/disk-cap/proposal.md +37 -0
- package/openspec/changes/disk-cap/specs/disk-cap/spec.md +25 -0
- package/openspec/changes/disk-cap/tasks.md +19 -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/openspec/changes/transcode-quality/.openspec.yaml +2 -0
- package/openspec/changes/transcode-quality/proposal.md +51 -0
- package/openspec/changes/transcode-quality/specs/transcode-quality/spec.md +37 -0
- package/openspec/changes/transcode-quality/tasks.md +34 -0
- package/package.json +2 -1
- package/routes/api/sources/files/get.js +47 -4
- package/routes/api/subtitles/get.js +92 -30
- package/server.js +3 -2
- package/services/hls-session-manager.js +33 -1
- package/services/hwaccel.js +560 -514
- package/services/language-detect.js +73 -0
- package/services/subtitle-convert.js +150 -0
- package/services/torrent-pool.js +131 -3
|
@@ -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
|
+
}
|
package/services/torrent-pool.js
CHANGED
|
@@ -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
|
-
|
|
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;
|