@torrent-tv/proxy 2.9.26 → 2.9.29
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/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 +1 -1
- package/routes/api/sources/files/get.js +47 -4
- package/server.js +3 -2
- package/services/hls-session-manager.js +33 -1
- package/services/hwaccel.js +560 -514
- package/services/torrent-pool.js +152 -3
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
|
|
|
@@ -187,6 +304,28 @@ export class TorrentPool {
|
|
|
187
304
|
const promise = new Promise((resolve, reject) => {
|
|
188
305
|
const onError = (error) => {
|
|
189
306
|
this.client.off("error", onError);
|
|
307
|
+
// The same content can arrive as a .torrent AND as a magnet —
|
|
308
|
+
// different pool keys, one swarm. WebTorrent rejects the duplicate
|
|
309
|
+
// add; resolve with the already-loaded torrent instead of failing.
|
|
310
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
311
|
+
const dupMatch = /duplicate torrent ([0-9a-f]{40})/i.exec(message);
|
|
312
|
+
if (dupMatch) {
|
|
313
|
+
const existing = this.client.torrents.find((t) => t?.infoHash === dupMatch[1]);
|
|
314
|
+
if (existing) {
|
|
315
|
+
const settle = () => {
|
|
316
|
+
this.torrents.set(key, existing);
|
|
317
|
+
this.#lastAccess.set(existing, Date.now());
|
|
318
|
+
this.#pending.delete(key);
|
|
319
|
+
resolve(existing);
|
|
320
|
+
};
|
|
321
|
+
if (existing.ready) {
|
|
322
|
+
settle();
|
|
323
|
+
} else {
|
|
324
|
+
existing.once("ready", settle);
|
|
325
|
+
}
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
190
329
|
this.#pending.delete(key);
|
|
191
330
|
reject(error);
|
|
192
331
|
};
|
|
@@ -194,6 +333,7 @@ export class TorrentPool {
|
|
|
194
333
|
this.client.add(torrentId, (readyTorrent) => {
|
|
195
334
|
this.client.off("error", onError);
|
|
196
335
|
this.torrents.set(key, readyTorrent);
|
|
336
|
+
this.#lastAccess.set(readyTorrent, Date.now());
|
|
197
337
|
this.#pending.delete(key);
|
|
198
338
|
// Key layout is `${sourceType}:${sha1}`; log with the sha1 prefix so
|
|
199
339
|
// lines correlate with the [stats] source key.
|
|
@@ -253,8 +393,10 @@ export class TorrentPool {
|
|
|
253
393
|
usage = new Map();
|
|
254
394
|
this.fileUsageByTorrent.set(torrent, usage);
|
|
255
395
|
}
|
|
256
|
-
// 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.
|
|
257
398
|
this.#cancelIdleRemoval(torrent);
|
|
399
|
+
this.#lastAccess.set(torrent, Date.now());
|
|
258
400
|
usage.set(fileIndex, (usage.get(fileIndex) ?? 0) + 1);
|
|
259
401
|
this.#syncSelections(torrent, usage);
|
|
260
402
|
|
|
@@ -339,6 +481,7 @@ export class TorrentPool {
|
|
|
339
481
|
}
|
|
340
482
|
}
|
|
341
483
|
this.fileUsageByTorrent.delete(torrent);
|
|
484
|
+
this.#lastAccess.delete(torrent);
|
|
342
485
|
const name = typeof torrent.name === "string" ? torrent.name : "(unknown)";
|
|
343
486
|
try {
|
|
344
487
|
torrent.destroy({ destroyStore: true }, () => {
|
|
@@ -596,11 +739,17 @@ export class TorrentPool {
|
|
|
596
739
|
* @returns {Promise<void>}
|
|
597
740
|
*/
|
|
598
741
|
async destroyAll() {
|
|
742
|
+
// Stop periodic disk-cap enforcement.
|
|
743
|
+
if (this.#diskSweepTimer) {
|
|
744
|
+
clearInterval(this.#diskSweepTimer);
|
|
745
|
+
this.#diskSweepTimer = null;
|
|
746
|
+
}
|
|
599
747
|
// Cancel any pending idle-removal timers — destroyAll handles teardown.
|
|
600
748
|
for (const timer of this.#idleTimers.values()) {
|
|
601
749
|
clearTimeout(timer);
|
|
602
750
|
}
|
|
603
751
|
this.#idleTimers.clear();
|
|
752
|
+
this.#lastAccess.clear();
|
|
604
753
|
|
|
605
754
|
if (!this.client || this.client.destroyed) {
|
|
606
755
|
return;
|