@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.
@@ -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
 
@@ -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;