@torrent-tv/proxy 2.9.39 → 2.9.41

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 CHANGED
@@ -1,3 +1,13 @@
1
+ ## 2.9.41
2
+
3
+ - **Fix**: A malformed or v2-only torrent source no longer crashes the whole proxy in a restart loop. WebTorrent's `Torrent._onTorrentId` does `arr2hex(parsedTorrent.infoHash)` assuming a BitTorrent v1 infohash exists; a v2-only / hybrid magnet (or a corrupt source) parses with `infoHash === undefined`, so that becomes `Buffer.from(undefined)` and throws in a microtask that bypasses the client `error` event — taking down the node and every viewer on it (observed: `ERR_INVALID_ARG_TYPE` → tunnel reconnect loop; the WebRTC session died ~6 s in as the process restarted under it). Two fixes: the torrent-add path now **pre-validates the infohash** with `parse-torrent` and rejects a source without a valid v1 40-hex infohash as a clean error the browser can show; and the process gained a **last-resort `uncaughtException`/`unhandledRejection` guard** that logs the full stack and keeps serving, so no single bad torrent can ever crash-loop the proxy. NOT a regression from the download-performance work (2.9.40) — those paths don't touch torrent parsing; it is a pre-existing crash surfaced by an unusual source.
4
+ - **New**: Longer idle retention so a brief absence resumes instead of restarting. The HLS transcode session idle TTL is raised from 2 min to **10 min** and the torrent-data idle TTL from 5 min to **15 min**. A viewer who pauses, backgrounds the tab, or turns the phone off for a few minutes now resumes without a cold ffmpeg restart and without re-downloading already-fetched data — the warm session also widens the seamless auto-reconnect window. An idle ffmpeg stops producing at the look-ahead cap, so the longer session TTL costs retained segments on disk rather than sustained CPU; the global disk cap still evicts torrent data earlier under pressure, and active playback keeps refreshing both timers so neither expires mid-watch.
5
+
6
+ ## 2.9.40
7
+
8
+ - **New**: Adaptive upload throttle. Seeding to the BitTorrent swarm does not help our viewer (we deliver over our own channel) — it is pure uplink cost and the riskiest legal act — so the client-wide upload limit now defaults to **off** (`throttleUpload(0)`, was WebTorrent's unlimited default) and is raised only when needed. A 5 s adjuster sets: **0** when no file has an active reader (stop seeding entirely once nothing is being watched); a low **floor** (50 KB/s) while a reader is active (a token upload so tit-for-tat does not choke us to zero); and a **boost** (512 KB/s) only when a torrent is starving (download barely trickling while it still needs data) AND its wires show reciprocity choke (≥2 peers we want data from are choking us) — earning unchoke slots to un-starve the download. The policy is a pure function (`decideUploadLimit`, unit-tested); each change is logged for field tuning. Client-wide limit (one active torrent is the norm today).
9
+ - **Fix**: Seek-aware piece prioritization now actually makes a far seek download the seek target first. On every `/stream` range request the proxy deselects the pieces BEHIND the read position, so WebTorrent's picker — which scans each selection sequentially from its first undownloaded piece — starts at the playhead instead of fetching the undownloaded gap behind it. Previously only a `critical()` window was marked, but `critical` does not reorder the scan (it only enables hotswap: re-requesting a block from a faster peer), so a seek into a large undownloaded region still waited behind the sequential backlog. Behind-playhead pieces are only dropped from the download set (stop fetching), never deleted — a backward seek re-selects them via the same call, and the whole file is re-selected on the next reader acquire; the pinned head/tail (codec probe) is unaffected. The critical read-ahead window (now 16 MB) is reset each call so it stays a moving window rather than accumulating over the whole file across seeks. Single-active-reader scope (the multi-viewer union window is roadmap item 23).
10
+
1
11
  ## 2.9.39
2
12
 
3
13
  - **Chore**: Log the stack (first frames) of WebTorrent `warning` events, not just the message. Field diagnosis: a playback froze mid-file with repeated `torrent-pool: … warning: Connection error: Cannot read properties of null (reading 'type')` (a WebTorrent µTP null-peer NPE, webtorrent#1932/#1940) while the swarm had seeders — peer connections were failing and the download starved. The old handler logged only the terse message, hiding which library path threw; the stack pinpoints it before we mitigate (next: prefer HTTP/DHT over the timing-out UDP trackers, then consider disabling µTP).
package/bin/cli.js CHANGED
@@ -26,6 +26,22 @@ import { logger } from "../utils/logger.js";
26
26
  const require = createRequire(import.meta.url);
27
27
  const { version: PROXY_VERSION } = require("../package.json");
28
28
 
29
+ // Last-resort process guard. This proxy runs UNTRUSTED torrents through
30
+ // WebTorrent, which can throw ASYNCHRONOUSLY on a malformed source — e.g. a
31
+ // v2-only / hybrid magnet crashes `arr2hex(parsedTorrent.infoHash)` deep inside
32
+ // `Torrent._onTorrentId` (undefined v1 infohash), in a microtask that bypasses
33
+ // the client "error" event. Without this, one bad torrent takes down the whole
34
+ // node and every viewer on it, and the addon restarts in a crash loop. Log the
35
+ // full stack and keep serving: the offending session fails on its own; everyone
36
+ // else is unaffected. (The add path also pre-validates the infohash, so this is
37
+ // a backstop for anything not caught there.)
38
+ process.on("uncaughtException", (error) => {
39
+ logger.error(`uncaughtException (kept alive): ${error?.stack ?? error}`);
40
+ });
41
+ process.on("unhandledRejection", (reason) => {
42
+ logger.error(`unhandledRejection (kept alive): ${reason instanceof Error ? reason.stack : String(reason)}`);
43
+ });
44
+
29
45
  const program = new Command();
30
46
 
31
47
  const HELP_EXAMPLES = `
@@ -0,0 +1,114 @@
1
+ # SOCKS5 torrent egress — feasibility research (NOT implemented)
2
+
3
+ Research only (2026-07-10), no code. Question: can the proxy route its
4
+ **torrent** traffic (peers, trackers, DHT) through an owner-configured SOCKS5
5
+ endpoint (e.g. a VPN provider's) to hide the owner's home IP from the swarm,
6
+ while WebRTC to the viewer stays direct? Roadmap item 8 in the root
7
+ `CLAUDE.md`.
8
+
9
+ Stack: WebTorrent 2.8.5, Node. Verified against
10
+ `node_modules/webtorrent/lib/*.js` and `index.js`.
11
+
12
+ ## What "torrent traffic" actually is (and which parts can be SOCKS5'd)
13
+
14
+ SOCKS5 forwards a connection through a relay so the destination sees the
15
+ relay's IP, not ours. It supports TCP (CONNECT) and, in theory, UDP (UDP
16
+ ASSOCIATE) — but UDP-ASSOCIATE is rarely usable from Node libraries that bind
17
+ their own UDP sockets. So per traffic type:
18
+
19
+ | Traffic | Transport | Where in WebTorrent | SOCKS5? |
20
+ |---|---|---|---|
21
+ | Peer wire (outgoing) | TCP | `torrent.js` `net.connect(opts)` (~L2112) | **Yes** (TCP CONNECT) |
22
+ | Peer wire (outgoing) | µTP (UDP) | `torrent.js` `utp.connect()` (~L2110) | No (UDP, own socket) → **disable** |
23
+ | HTTP/HTTPS trackers | TCP | `bittorrent-tracker` (HTTP agent) | **Yes** (agent) |
24
+ | WSS trackers | TCP (WebSocket) | `bittorrent-tracker` (ws agent) | **Yes** (agent) |
25
+ | UDP trackers (`udp://`) | UDP | `bittorrent-tracker` | No → **drop** (filter announce list) |
26
+ | DHT | UDP | `bittorrent-dht` | No → **disable** (`dht:false`) |
27
+ | LSD (local peer discovery) | UDP multicast | conn-pool | LAN-only leak → **disable** (`lsd:false`) |
28
+ | Incoming peers | TCP/µTP listener | conn-pool | N/A (inbound; see note) |
29
+
30
+ Conclusion: a clean, leak-free config is **TCP-only through the SOCKS5 relay**
31
+ with every UDP path turned OFF. This is the standard "torrent proxy" model and
32
+ its standard caveat: you lose µTP peers, UDP trackers, and DHT peer discovery —
33
+ peer counts drop, but HTTP/WSS-tracker swarms (like the rutracker trackers our
34
+ test torrents use) still work, and TCP peers are plentiful.
35
+
36
+ WebTorrent already exposes the needed off-switches (verified in `index.js`):
37
+ `utp: false` (L82), `dht: false` (L127), `lsd: false` (L76), `webSeeds`
38
+ (L161). Announce-list filtering to `http(s)://`/`wss://` is ours to do.
39
+
40
+ ## The hard part: no dialer hook for TCP peers
41
+
42
+ WebTorrent has **no public option** to supply a custom socket/dialer for
43
+ outgoing peer connections — it calls `net.connect(opts)` directly inside
44
+ `torrent.js`. Three ways around it:
45
+
46
+ ### Approach A — userland, scoped monkeypatch of `net.connect` (+ disable UDP)
47
+ Wrap `net.connect` so that for a **peer destination** it returns a socket
48
+ tunneled through SOCKS5 (via the already-present `socks` package —
49
+ `SocksClient.createConnection({ proxy, command:'connect', destination })`),
50
+ and for everything else (our loopback HTTP server, the signalling tunnel,
51
+ ffmpeg's `127.0.0.1` fetch, DNS) it calls the real `net.connect`.
52
+ - The `socks` dep is ALREADY in `node_modules` (currently transitive) — would
53
+ become a direct dependency.
54
+ - Shim detail: `net.connect` returns a socket synchronously and connects
55
+ async; `SocksClient.createConnection` is Promise-based. Need a small adapter
56
+ that returns a `net.Socket`-like duplex immediately and attaches the real
57
+ tunneled socket once the SOCKS handshake resolves (or emits `error`). The
58
+ `socks` package's event API supports this.
59
+ - Scoping rule: SOCKS only for non-local IPv4/IPv6 destinations; NEVER for
60
+ `127.0.0.1`/`::1`/the tunnel host — otherwise we'd route our own control
61
+ plane through the VPN.
62
+ - Trackers/webseeds (HTTP/WSS): pass a `socks-proxy-agent` as the HTTP/ws
63
+ agent to `bittorrent-tracker` / webseed fetches (need to confirm WebTorrent
64
+ threads an `agent` option through; may need a small patch).
65
+ - Pros: no fork, no build-chain changes, deployment-agnostic. Cons: global
66
+ monkeypatch is delicate; must be airtight on the scoping rule.
67
+
68
+ ### Approach B — patch/fork WebTorrent to add a `createConnection` hook
69
+ Add an option so `torrent.js` uses `opts.createConnection ?? net.connect`.
70
+ Cleaner and explicit, but a fork to maintain (or an upstream PR — issue
71
+ webtorrent#807 "SOCKS Proxy Support" is open and unresolved, so upstream is
72
+ unlikely soon). More correct long-term if A proves too fragile.
73
+
74
+ ### Approach C — network-level (VPN container / namespace), NOT in-proxy
75
+ Run the whole proxy process behind a VPN with a kill-switch (e.g. a `gluetun`
76
+ sidecar container, or a Linux network namespace bound to the VPN). Catches
77
+ ALL traffic — TCP, µTP, DHT, UDP trackers — leak-proof, zero proxy code.
78
+ Trade-off: not per-app (the whole proxy egresses via VPN, including the
79
+ signalling tunnel unless split), heavier ops, and awkward for the HA addon
80
+ (`host_network: true`). This is the ROBUST alternative to document alongside;
81
+ for a bare-Docker/npm operator it may actually be the better answer than
82
+ in-proxy SOCKS5.
83
+
84
+ ## Kill-switch (fail-closed) — mandatory for the feature to mean anything
85
+ If the SOCKS5 relay is down, torrent traffic must NOT silently fall back to a
86
+ direct connection (that leaks the exact IP we set out to hide). With Approach
87
+ A this is natural: a failed SOCKS handshake fails the peer connection with no
88
+ direct retry, AND all UDP paths are already disabled, so nothing bypasses the
89
+ relay. Must add a startup connectivity self-test + a clear log line
90
+ (BitPlay's "test proxy" pattern), and surface "proxy down → torrent paused"
91
+ rather than leaking.
92
+
93
+ ## Recommended direction (when we build it)
94
+ 1. Primary: **Approach A** — `socks` for TCP peers via a scoped `net.connect`
95
+ shim + `socks-proxy-agent` for HTTP/WSS trackers + webseeds, with
96
+ `utp:false`, `dht:false`, `lsd:false`, and announce-list filtered to
97
+ TCP-based trackers. Fail-closed by construction.
98
+ 2. Document **Approach C** (VPN container) in the addon/deployment docs as the
99
+ leak-proof alternative for operators who prefer it.
100
+ 3. Approach B only if A's monkeypatch proves too fragile in practice.
101
+
102
+ ## Open questions to resolve at build time (not now)
103
+ - Does WebTorrent 2.8.x thread an `agent`/proxy option to `bittorrent-tracker`
104
+ and webseed fetches, or is a small patch needed there too?
105
+ - `socks` socket-shim: cleanest way to present a pre-connect `net.Socket`
106
+ (custom Duplex vs a paused real socket) without confusing
107
+ `bittorrent-protocol`'s wire setup.
108
+ - Incoming peers: with UDP off and outbound via SOCKS, do we still accept
109
+ inbound TCP peers directly (that inbound listener exposes the home IP to
110
+ connecting peers)? For full hiding, inbound may need to be disabled too
111
+ (accept that peer discovery becomes outbound-only) — decide per privacy goal.
112
+ - Interaction with the UPnP port mapping / WebRTC UDP port (those are for the
113
+ viewer transport, NOT torrent egress — must stay direct; confirm the shim's
114
+ scoping never touches them).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.9.39",
3
+ "version": "2.9.41",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -55,10 +55,13 @@ const SEEK_SETTLE_MS = 1_200;
55
55
  // burst, so a still-moving scrubber cannot delay a genuine seek forever.
56
56
  const SEEK_SETTLE_MAX_MS = 2_500;
57
57
  // Idle TTL: a session is disposed this long after the last segment/playlist
58
- // access. Kept short so an ffmpeg process does not keep burning CPU after the
59
- // viewer stops or navigates away. Active playback refreshes the timer on every
60
- // segment fetch, so it never expires mid-watch.
61
- const DEFAULT_SESSION_TTL_MS = 120 * 1000;
58
+ // access. Long enough that a viewer who pauses, backgrounds the tab, or briefly
59
+ // turns the phone off can resume WITHOUT a cold ffmpeg restart (the warm session
60
+ // also backs the seamless auto-reconnect). ffmpeg stops producing at the
61
+ // look-ahead cap when idle, so a lingering session costs retained segments on
62
+ // disk, not sustained CPU. Active playback refreshes the timer on every segment
63
+ // fetch, so it never expires mid-watch.
64
+ const DEFAULT_SESSION_TTL_MS = 10 * 60 * 1000;
62
65
  const DEFAULT_STARTUP_WAIT_MS = 5_000;
63
66
  // Realtime budget — runtime downswitch (software encoder only). Periodically
64
67
  // check each active software-transcode session's ffmpeg `speed`; when it stays
@@ -11,6 +11,7 @@ import os from "node:os";
11
11
  import path from "node:path";
12
12
  import { rmSync, statfsSync } from "node:fs";
13
13
  import WebTorrent from "webtorrent";
14
+ import parseTorrent from "parse-torrent";
14
15
  import { logger } from "../utils/logger.js";
15
16
 
16
17
  // WebTorrent's default download root (see webtorrent lib/torrent.js: TMP =
@@ -20,14 +21,21 @@ const WEBTORRENT_STORE_ROOT = path.join(os.tmpdir(), "webtorrent");
20
21
 
21
22
  // How long a torrent may sit with zero active file readers before it is
22
23
  // removed (with its on-disk store). Generous so brief gaps between ffmpeg
23
- // range reads — or a short pause do not evict an in-use torrent; a longer
24
- // idle (viewer gone) frees the disk. Re-requesting re-adds (re-downloads) it.
25
- const TORRENT_IDLE_TTL_MS = 300_000;
26
-
27
- // Bytes ahead of a read position to mark CRITICAL (download-first) on each
28
- // range request. Big enough to unstick a seek into an undownloaded region,
29
- // small enough not to make "everything critical" (which defeats prioritization).
30
- const PRIORITY_WINDOW_BYTES = 8 * 1024 * 1024;
24
+ // range reads — a pause, a backgrounded tab, or a phone turned off for a few
25
+ // minutes do not evict an in-use torrent's already-downloaded data, so a
26
+ // resume plays from disk instead of re-downloading. A longer idle (viewer truly
27
+ // gone) frees the disk; the global disk cap still evicts earlier under pressure.
28
+ const TORRENT_IDLE_TTL_MS = 15 * 60 * 1000;
29
+
30
+ // Bytes ahead of a read position to mark CRITICAL on each range request. In
31
+ // WebTorrent, `critical` does NOT reorder the sequential piece scan — it enables
32
+ // HOTSWAP (re-request a block from a faster peer when a slower one already
33
+ // reserved it), so this is the near read-ahead cushion where stealing from slow
34
+ // peers pays off. The actual "download the seek target first" effect comes from
35
+ // deselecting the gap BEHIND the playhead (see prioritizeByteRange). Kept a
36
+ // moving window (reset each call) so criticality never accumulates over the
37
+ // whole file across seeks, which would make hotswap thrash.
38
+ const PRIORITY_WINDOW_BYTES = 16 * 1024 * 1024;
31
39
 
32
40
  // The file's header/index region the codec probe needs (phase 1). Must match
33
41
  // the ranges prefetchFileEdges fetches: leading bytes + trailing bytes.
@@ -43,6 +51,67 @@ const HEADER_TAIL_BYTES = 2 * 1024 * 1024;
43
51
  const DISK_CAP_ABSOLUTE_MAX_BYTES = 10 * 1024 * 1024 * 1024; // 10 GB
44
52
  const DISK_CAP_SWEEP_INTERVAL_MS = 30_000;
45
53
 
54
+ // Adaptive upload. Seeding to the BitTorrent swarm does not help our viewer (we
55
+ // deliver over our own WebRTC/HTTPS channel) — it is pure uplink cost and the
56
+ // riskiest legal act (active distribution). So the default is minimal: no
57
+ // seeding when nothing is being watched, and only a token upload while actively
58
+ // downloading. BUT zero upload can get us choked by tit-for-tat (peers re-rank
59
+ // and stop sending) → slower download → the exact starvation we fight. So the
60
+ // limit is ADAPTIVE: raised only when download is starving AND the wires show
61
+ // reciprocity is the cause (many peers we want data from are choking us).
62
+ const UPLOAD_FLOOR_BYTES = 50 * 1024; // token upload while a reader is active
63
+ const UPLOAD_BOOST_BYTES = 512 * 1024; // raised to earn tit-for-tat unchoke slots
64
+ const UPLOAD_STARVING_SPEED_BYTES = 200 * 1024; // download below this (with demand) = starving
65
+ const UPLOAD_CHOKED_WIRE_THRESHOLD = 2; // interested-but-choked wires implying reciprocity
66
+ const UPLOAD_ADJUST_INTERVAL_MS = 5_000;
67
+
68
+ /**
69
+ * Decide the client-wide upload limit (bytes/sec) from the torrents that
70
+ * currently have an active reader. Pure function so the policy is unit-testable
71
+ * without a live swarm.
72
+ *
73
+ * - No active readers → 0 (stop seeding entirely; nothing is being watched).
74
+ * - Any active torrent starving (still wants data, download barely trickling)
75
+ * AND showing reciprocity choke (>= threshold wires we are interested in that
76
+ * are choking us) → boost, to earn unchoke slots.
77
+ * - Otherwise → floor (token upload, avoids an immediate choke without seeding).
78
+ *
79
+ * @param {Array<{ wires?: Array<{ amInterested?: boolean, peerChoking?: boolean }>, downloadSpeed?: number, progress?: number, name?: string }>} activeTorrents
80
+ * @param {{ floor?: number, boost?: number, starvingSpeed?: number, chokedThreshold?: number }} [opts]
81
+ * @returns {{ bytesPerSec: number, reason: string }}
82
+ */
83
+ export function decideUploadLimit(activeTorrents, opts = {}) {
84
+ const floor = opts.floor ?? UPLOAD_FLOOR_BYTES;
85
+ const boost = opts.boost ?? UPLOAD_BOOST_BYTES;
86
+ const starvingSpeed = opts.starvingSpeed ?? UPLOAD_STARVING_SPEED_BYTES;
87
+ const chokedThreshold = opts.chokedThreshold ?? UPLOAD_CHOKED_WIRE_THRESHOLD;
88
+
89
+ if (!Array.isArray(activeTorrents) || activeTorrents.length === 0) {
90
+ return { bytesPerSec: 0, reason: "idle: no active readers — stop seeding" };
91
+ }
92
+
93
+ for (const torrent of activeTorrents) {
94
+ const wires = Array.isArray(torrent?.wires) ? torrent.wires : [];
95
+ const chokedInterested = wires.filter(
96
+ (wire) => wire && wire.amInterested === true && wire.peerChoking === true
97
+ ).length;
98
+ const downloadSpeed = typeof torrent?.downloadSpeed === "number" ? torrent.downloadSpeed : 0;
99
+ const progress = typeof torrent?.progress === "number" ? torrent.progress : 0;
100
+ const starving = progress < 1 && downloadSpeed < starvingSpeed;
101
+ if (starving && chokedInterested >= chokedThreshold) {
102
+ const name = typeof torrent?.name === "string" ? torrent.name : "?";
103
+ return {
104
+ bytesPerSec: boost,
105
+ reason:
106
+ `earn unchoke — "${name}" choked=${chokedInterested}/${wires.length} ` +
107
+ `down=${Math.round(downloadSpeed / 1024)}KB/s`
108
+ };
109
+ }
110
+ }
111
+
112
+ return { bytesPerSec: floor, reason: "active readers, not choke-starved" };
113
+ }
114
+
46
115
  /**
47
116
  * Compute the default disk cap: the smaller of a fixed 10 GB and half of the
48
117
  * currently free space on the store's filesystem (so a tiny host is never
@@ -140,6 +209,12 @@ export class TorrentPool {
140
209
  /** Periodic disk-cap enforcement timer. */
141
210
  #diskSweepTimer = null;
142
211
 
212
+ /** Current client-wide upload limit in bytes/sec (adaptive). -1 = not yet set. */
213
+ #uploadLimit = -1;
214
+
215
+ /** Periodic adaptive-upload adjustment timer. */
216
+ #uploadAdjustTimer = null;
217
+
143
218
  /**
144
219
  * @param {{ maxDiskBytes?: number }} [options]
145
220
  * `maxDiskBytes` caps total downloaded torrent data; when omitted a
@@ -192,6 +267,41 @@ export class TorrentPool {
192
267
  this.#diskSweepTimer = setInterval(() => this.#enforceDiskCap(), DISK_CAP_SWEEP_INTERVAL_MS);
193
268
  this.#diskSweepTimer.unref?.();
194
269
  }
270
+
271
+ // Adaptive upload: start with seeding OFF (nothing is being watched yet),
272
+ // then let the periodic adjuster raise it to the floor while a reader is
273
+ // active and to the boost when download is choke-starved. WebTorrent's
274
+ // default is unlimited upload, which we explicitly do NOT want.
275
+ if (typeof this.client.throttleUpload === "function") {
276
+ this.client.throttleUpload(0);
277
+ this.#uploadLimit = 0;
278
+ }
279
+ this.#uploadAdjustTimer = setInterval(() => this.#adjustUploadLimit(), UPLOAD_ADJUST_INTERVAL_MS);
280
+ this.#uploadAdjustTimer.unref?.();
281
+ }
282
+
283
+ /**
284
+ * Re-evaluate and apply the client-wide upload limit from current swarm state
285
+ * (see {@link decideUploadLimit}). Runs on a timer; only calls into WebTorrent
286
+ * when the target changes, and logs each change for field tuning.
287
+ *
288
+ * @returns {void}
289
+ */
290
+ #adjustUploadLimit() {
291
+ if (!this.client || this.client.destroyed || typeof this.client.throttleUpload !== "function") {
292
+ return;
293
+ }
294
+ const active = [...this.torrents.values()].filter((torrent) => {
295
+ const usage = this.fileUsageByTorrent.get(torrent);
296
+ return usage && usage.size > 0;
297
+ });
298
+ const { bytesPerSec, reason } = decideUploadLimit(active);
299
+ if (bytesPerSec === this.#uploadLimit) {
300
+ return;
301
+ }
302
+ this.#uploadLimit = bytesPerSec;
303
+ this.client.throttleUpload(bytesPerSec);
304
+ logger.info(`torrent-pool: upload limit -> ${Math.round(bytesPerSec / 1024)} KB/s (${reason})`);
195
305
  }
196
306
 
197
307
  /**
@@ -317,6 +427,26 @@ export class TorrentPool {
317
427
  }
318
428
 
319
429
  const torrentId = decodeTorrentSource(sourceType, source);
430
+
431
+ // Pre-validate the infohash BEFORE handing the source to WebTorrent.
432
+ // WebTorrent's Torrent._onTorrentId does `arr2hex(parsedTorrent.infoHash)`
433
+ // assuming a BitTorrent v1 infohash exists; a v2-only / hybrid magnet (or a
434
+ // malformed source) parses with `infoHash === undefined`, so that call does
435
+ // `Buffer.from(undefined)` and throws in a microtask that bypasses the
436
+ // client "error" event — crashing the whole process. Reject cleanly here so
437
+ // the browser gets an error it can show, and the proxy stays up.
438
+ let parsed;
439
+ try {
440
+ parsed = await parseTorrent(torrentId);
441
+ } catch {
442
+ parsed = null;
443
+ }
444
+ if (!parsed || typeof parsed.infoHash !== "string" || !/^[0-9a-f]{40}$/i.test(parsed.infoHash)) {
445
+ throw new Error(
446
+ "Unsupported torrent source: no BitTorrent v1 infohash (v2-only or malformed torrents are not supported)."
447
+ );
448
+ }
449
+
320
450
  const promise = new Promise((resolve, reject) => {
321
451
  const onError = (error) => {
322
452
  this.client.off("error", onError);
@@ -701,16 +831,42 @@ export class TorrentPool {
701
831
  }
702
832
 
703
833
  /**
704
- * Mark the torrent pieces covering a byte window of a file as CRITICAL, so
705
- * WebTorrent downloads them before the rest of the selected file. Called on
706
- * every range request: after a seek, the new read position jumps the download
707
- * queue instead of waiting behind the sequential backlog (which caused
708
- * ~15-18 s stalls when seeking into an undownloaded region).
834
+ * Bias the torrent's download toward the current read position, so a seek
835
+ * downloads the seek target first instead of waiting behind the sequential
836
+ * backlog (which caused ~15-18 s stalls when seeking into an undownloaded
837
+ * region). Called on every range request.
838
+ *
839
+ * Two levers, matched to how WebTorrent's picker actually works:
840
+ *
841
+ * 1. **Demote the gap BEHIND the playhead** — `deselect(fileStart, playhead-1)`.
842
+ * The picker scans each selection sequentially from its first UNdownloaded
843
+ * piece; with the whole file selected, a far forward seek would make it
844
+ * fetch the undownloaded gap behind the new position first. Removing that
845
+ * gap from the selection makes the scan START at the playhead, so all peer
846
+ * capacity goes to the pieces the player needs next. This only STOPS
847
+ * fetching the gap; already-downloaded pieces stay on disk (deleting them
848
+ * is Disk hygiene Level 2), and a later backward seek re-selects the region
849
+ * via this same call. The whole file is re-selected by `file.select()` on
850
+ * the next `acquireFile`, so nothing is permanently dropped.
851
+ *
852
+ * 2. **Critical read-ahead window** — `critical(playhead, playhead+window)`.
853
+ * `critical` does not reorder the scan; it enables HOTSWAP (re-request a
854
+ * block from a faster peer when a slow one reserved it) over the near
855
+ * window. Reset first so criticality stays a moving window rather than
856
+ * accumulating over the whole file across seeks.
857
+ *
858
+ * Scope: single active reader per file (≈100% today). The multi-viewer union
859
+ * window — demote only where behind for ALL sessions — is deferred (roadmap
860
+ * item 23); here the latest read position wins.
861
+ *
862
+ * The pinned head/tail (prefetchFileEdges, codec probe) is downloaded up front
863
+ * and lives forward of the playhead (tail) or is already on disk (head), so
864
+ * demotion never costs the probe its data.
709
865
  *
710
866
  * @param {import("webtorrent").Torrent} torrent
711
867
  * @param {number} fileIndex
712
868
  * @param {number} byteStart - Start offset within the file.
713
- * @param {number} [windowBytes] - Bytes ahead of `byteStart` to prioritize.
869
+ * @param {number} [windowBytes] - Bytes ahead of `byteStart` to mark critical.
714
870
  * @returns {void}
715
871
  */
716
872
  prioritizeByteRange(torrent, fileIndex, byteStart, windowBytes = PRIORITY_WINDOW_BYTES) {
@@ -726,18 +882,43 @@ export class TorrentPool {
726
882
  return;
727
883
  }
728
884
  const fileOffset = Number.isFinite(file.offset) ? file.offset : 0;
885
+ const fileLength = Number(file.length);
886
+ if (!Number.isFinite(fileLength) || fileLength <= 0) {
887
+ return;
888
+ }
889
+ const fileStartPiece = Math.floor(fileOffset / pieceLength);
890
+ const fileEndPiece = Math.floor((fileOffset + fileLength - 1) / pieceLength);
891
+
729
892
  const safeStart = Math.max(0, Number(byteStart) || 0);
730
893
  const absStart = fileOffset + safeStart;
731
- const absEnd = Math.min(fileOffset + file.length - 1, absStart + Math.max(1, windowBytes) - 1);
732
- const startPiece = Math.floor(absStart / pieceLength);
733
- const endPiece = Math.floor(absEnd / pieceLength);
734
- if (endPiece < startPiece) {
735
- return;
894
+ const playheadPiece = Math.floor(absStart / pieceLength);
895
+ const absWindowEnd = Math.min(
896
+ fileOffset + fileLength - 1,
897
+ absStart + Math.max(1, windowBytes) - 1
898
+ );
899
+ const windowEndPiece = Math.floor(absWindowEnd / pieceLength);
900
+
901
+ // (1) Demote the gap behind the playhead so the picker scans forward from
902
+ // the read position. Only when there IS a gap (not at the file start).
903
+ if (playheadPiece > fileStartPiece && typeof torrent.deselect === "function") {
904
+ try {
905
+ torrent.deselect(fileStartPiece, playheadPiece - 1);
906
+ } catch {
907
+ // Best effort — never break streaming because demotion failed.
908
+ }
736
909
  }
737
- try {
738
- torrent.critical(startPiece, endPiece);
739
- } catch {
740
- // Best effort — never break streaming because prioritization failed.
910
+
911
+ // (2) Reset criticality to a moving read-ahead window (hotswap over the near
912
+ // pieces), so it does not accumulate over the whole file across seeks.
913
+ if (Array.isArray(torrent._critical)) {
914
+ torrent._critical.length = 0;
915
+ }
916
+ if (windowEndPiece >= playheadPiece) {
917
+ try {
918
+ torrent.critical(playheadPiece, windowEndPiece);
919
+ } catch {
920
+ // Best effort.
921
+ }
741
922
  }
742
923
  }
743
924
 
@@ -760,6 +941,11 @@ export class TorrentPool {
760
941
  clearInterval(this.#diskSweepTimer);
761
942
  this.#diskSweepTimer = null;
762
943
  }
944
+ // Stop periodic adaptive-upload adjustment.
945
+ if (this.#uploadAdjustTimer) {
946
+ clearInterval(this.#uploadAdjustTimer);
947
+ this.#uploadAdjustTimer = null;
948
+ }
763
949
  // Cancel any pending idle-removal timers — destroyAll handles teardown.
764
950
  for (const timer of this.#idleTimers.values()) {
765
951
  clearTimeout(timer);