@torrent-tv/proxy 2.9.42 → 2.9.44

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,12 @@
1
+ ## 2.9.44
2
+
3
+ - **Fix**: Roll back to WebTorrent **2.8.5** (pinned) — 3.x introduced two regressions that broke downloading. (1) `torrent.downloaded`/`file.downloaded`/`file.progress` throw on a `deselect`-ed null piece (worked around in 2.9.43). (2) Worse: the internal piece picker itself throws `Cannot read properties of null (reading 'reserve'/'missing')` when it tries to request a block from a piece our seek prioritization (`prioritizeByteRange` `deselect`) removed — download freezes dead after a seek (field-observed: file stuck at ~51%, `down=0`, picker crashing every second). 2.8.5 is the known-good version: `select`/`deselect`/`critical` and the byte getters all work (verified — add, multi-file download, and the full deselect+critical seek pattern run with zero crashes on 2.8.5). Also pinned **`uint8-util` 2.2.6**: 2.8.5's own range is `^2.2.5`, which *allows* the incompatible 2.3.x that a fresh global install pulled (the original `arr2hex` crash), so the transitive version must be forced back — webtorrent dedupes to 2.2.6 while sub-deps that need 2.3.x keep their own nested copy. The 2.9.43 null-safe getter helpers are now redundant (2.8.5 getters never throw) but left in as harmless defensive code.
4
+
5
+ ## 2.9.43
6
+
7
+ - **Fix**: Torrents stalled at the metadata/download stage on WebTorrent 3.x — the adaptive upload throttle dropped the client-wide limit to `0` whenever no file had an active reader (e.g. the window before the first read is acquired). In WebTorrent 3.x `throttleUpload(0)` blocks the ENTIRE swarm exchange client-wide — even peer connections and DOWNLOAD — not just seeding (verified: `throttleUpload(0)` → 0 peers, 0 download; `throttleUpload(8KB/s)` → peers connect, multi-MB/s download). The idle branch now returns a minimal keep-alive floor (`UPLOAD_IDLE_FLOOR_BYTES` = 8 KB/s) instead of 0; still effectively no seeding, but the swarm stays alive.
8
+ - **Fix**: Spurious `uncaughtException: Cannot read properties of null (reading 'length')` every few seconds during playback. WebTorrent 3.x nulls `pieces[index]` for pieces we removed from the download set via `deselect` (file selection, seek-behind-playhead demotion), and its own `torrent.downloaded` / `file.downloaded` / `file.progress` getters do not guard that null — they threw in our disk-cap sweep and stats builder. Added null-safe `torrentDownloadedBytes` / `fileDownloadedBytes` helpers (a deselected piece = 0 downloaded, the correct value, while still counting every other piece) and use them in `#currentDiskBytes`/`#enforceDiskCap` and `getFileStats`. Verified byte-for-byte identical to WebTorrent's own getters when no piece is null. (The underlying WebTorrent getter bug is filed upstream; it is non-fatal — download survives it — but the throws were noisy and risky.)
9
+
1
10
  ## 2.9.42
2
11
 
3
12
  - **Fix**: Torrents failed to load with a proxy crash — the REAL root cause (2.9.41 misdiagnosed it). WebTorrent 2.8.5's `Torrent._onTorrentId` does `arr2hex(parsedTorrent.infoHash)`, but `parse-torrent` returns `infoHash` as a hex **string**. `uint8-util` **2.3.x** rewrote `arr2hex` to require a TypedArray (`Buffer.from(data.buffer …)`); a string's `.buffer` is `undefined` → `Buffer.from(undefined)` → `ERR_INVALID_ARG_TYPE` thrown in a detached microtask. `uint8-util` 2.2.x iterated the argument and tolerated a string, so it only broke once the addon's unpinned global `npm install` pulled 2.3.x. It hit **every** torrent (v1/v2/hybrid alike — `arr2hex` is always called). Diagnosed by reproducing `client.add` inside the addon container and isolating `arr2hex('<hex>')` throwing on 2.3.2 but not 2.2.6. Fix: update **WebTorrent 2.8.5 → 3.x**, where the maintainer replaced that line with `parsedTorrent.infoHash?.substring(0, 7)` (no `arr2hex` on the string) — a proper dependency-forward fix, not a version pin, so `uint8-util`/`parse-torrent` stay current. Verified: the exact broken combo (webtorrent 3.0.16 + uint8-util 2.3.2 + parse-torrent 11.0.23) now adds cleanly, and the full API the proxy uses (`select`/`deselect`/`critical`/`_critical`/`wires`/`throttleUpload`/`createReadStream`/`destroy({destroyStore})`) is unchanged in 3.x.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.9.42",
3
+ "version": "2.9.44",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -29,7 +29,8 @@
29
29
  "franc": "^6.2.0",
30
30
  "get-port": "^7.1.0",
31
31
  "node-datachannel": "^0.32.0",
32
- "webtorrent": "^3.0.16",
32
+ "uint8-util": "2.2.6",
33
+ "webtorrent": "2.8.5",
33
34
  "ws": "^8.18.2"
34
35
  }
35
36
  }
@@ -52,13 +52,19 @@ const DISK_CAP_SWEEP_INTERVAL_MS = 30_000;
52
52
 
53
53
  // Adaptive upload. Seeding to the BitTorrent swarm does not help our viewer (we
54
54
  // deliver over our own WebRTC/HTTPS channel) — it is pure uplink cost and the
55
- // riskiest legal act (active distribution). So the default is minimal: no
56
- // seeding when nothing is being watched, and only a token upload while actively
57
- // downloading. BUT zero upload can get us choked by tit-for-tat (peers re-rank
55
+ // riskiest legal act (active distribution). So the default is minimal: a
56
+ // near-zero keep-alive when nothing is being watched (NOT 0 that blocks the
57
+ // swarm in wt3.x), and only a token upload while actively downloading. BUT zero
58
+ // upload can get us choked by tit-for-tat (peers re-rank
58
59
  // and stop sending) → slower download → the exact starvation we fight. So the
59
60
  // limit is ADAPTIVE: raised only when download is starving AND the wires show
60
61
  // reciprocity is the cause (many peers we want data from are choking us).
61
62
  const UPLOAD_FLOOR_BYTES = 50 * 1024; // token upload while a reader is active
63
+ // Minimal keep-alive upload when NOTHING is being watched. It must NOT be 0:
64
+ // in webtorrent 3.x `throttleUpload(0)` blocks ALL swarm exchange client-wide
65
+ // (even peer connections and DOWNLOAD), so a torrent still fetching metadata /
66
+ // pieces would stall. A few KB/s is negligible seeding but keeps the swarm alive.
67
+ const UPLOAD_IDLE_FLOOR_BYTES = 8 * 1024;
62
68
  const UPLOAD_BOOST_BYTES = 512 * 1024; // raised to earn tit-for-tat unchoke slots
63
69
  const UPLOAD_STARVING_SPEED_BYTES = 200 * 1024; // download below this (with demand) = starving
64
70
  const UPLOAD_CHOKED_WIRE_THRESHOLD = 2; // interested-but-choked wires implying reciprocity
@@ -69,24 +75,27 @@ const UPLOAD_ADJUST_INTERVAL_MS = 5_000;
69
75
  * currently have an active reader. Pure function so the policy is unit-testable
70
76
  * without a live swarm.
71
77
  *
72
- * - No active readers → 0 (stop seeding entirely; nothing is being watched).
78
+ * - No active readers → a MINIMAL keep-alive floor (NOT 0: `throttleUpload(0)`
79
+ * blocks the whole swarm client-wide in webtorrent 3.x, stalling any torrent
80
+ * still downloading). Effectively no seeding, just enough to keep peers.
73
81
  * - Any active torrent starving (still wants data, download barely trickling)
74
82
  * AND showing reciprocity choke (>= threshold wires we are interested in that
75
83
  * are choking us) → boost, to earn unchoke slots.
76
84
  * - Otherwise → floor (token upload, avoids an immediate choke without seeding).
77
85
  *
78
- * @param {Array<{ wires?: Array<{ amInterested?: boolean, peerChoking?: boolean }>, downloadSpeed?: number, progress?: number, name?: string }>} activeTorrents
79
- * @param {{ floor?: number, boost?: number, starvingSpeed?: number, chokedThreshold?: number }} [opts]
86
+ * @param {Array<{ wires?: Array<{ amInterested?: boolean, peerChoking?: boolean }>, downloadSpeed?: number, done?: boolean, name?: string }>} activeTorrents
87
+ * @param {{ floor?: number, idleFloor?: number, boost?: number, starvingSpeed?: number, chokedThreshold?: number }} [opts]
80
88
  * @returns {{ bytesPerSec: number, reason: string }}
81
89
  */
82
90
  export function decideUploadLimit(activeTorrents, opts = {}) {
83
91
  const floor = opts.floor ?? UPLOAD_FLOOR_BYTES;
92
+ const idleFloor = opts.idleFloor ?? UPLOAD_IDLE_FLOOR_BYTES;
84
93
  const boost = opts.boost ?? UPLOAD_BOOST_BYTES;
85
94
  const starvingSpeed = opts.starvingSpeed ?? UPLOAD_STARVING_SPEED_BYTES;
86
95
  const chokedThreshold = opts.chokedThreshold ?? UPLOAD_CHOKED_WIRE_THRESHOLD;
87
96
 
88
97
  if (!Array.isArray(activeTorrents) || activeTorrents.length === 0) {
89
- return { bytesPerSec: 0, reason: "idle: no active readers stop seeding" };
98
+ return { bytesPerSec: idleFloor, reason: "idle: minimal keep-alive (0 blocks the swarm in wt3.x)" };
90
99
  }
91
100
 
92
101
  for (const torrent of activeTorrents) {
@@ -95,8 +104,11 @@ export function decideUploadLimit(activeTorrents, opts = {}) {
95
104
  (wire) => wire && wire.amInterested === true && wire.peerChoking === true
96
105
  ).length;
97
106
  const downloadSpeed = typeof torrent?.downloadSpeed === "number" ? torrent.downloadSpeed : 0;
98
- const progress = typeof torrent?.progress === "number" ? torrent.progress : 0;
99
- const starving = progress < 1 && downloadSpeed < starvingSpeed;
107
+ // Use `done` (a plain boolean) NOT `progress`/`downloaded`, whose getters
108
+ // iterate the piece array and throw on webtorrent 3.x when a piece is null
109
+ // (deselected / mid-verify), which would crash this timer every cycle.
110
+ const notDone = torrent?.done !== true;
111
+ const starving = notDone && downloadSpeed < starvingSpeed;
100
112
  if (starving && chokedInterested >= chokedThreshold) {
101
113
  const name = typeof torrent?.name === "string" ? torrent.name : "?";
102
114
  return {
@@ -168,6 +180,87 @@ function decodeTorrentSource(sourceType, source) {
168
180
  throw new Error("Unsupported sourceType. Expected magnet or torrent.");
169
181
  }
170
182
 
183
+ /**
184
+ * Downloaded bytes of a single piece, treating a null (deselected) piece as 0.
185
+ *
186
+ * webtorrent 3.x sets `pieces[index] = null` for pieces we removed from the
187
+ * download set via `deselect` (file selection, seek-behind-playhead demotion).
188
+ * Its own `get downloaded` / `get progress` do NOT guard that null and throw
189
+ * `Cannot read properties of null (reading 'length')`, which crashed our
190
+ * disk-cap and stats timers every cycle. A deselected piece has 0 downloaded
191
+ * bytes, so treating null as 0 is the correct value — and keeps the OTHER
192
+ * pieces counted (a blanket try/catch that returned 0 for the whole torrent
193
+ * would under-report disk usage and freeze the progress bar at 0%).
194
+ *
195
+ * @param {import("webtorrent").Torrent} torrent
196
+ * @param {number} index
197
+ * @returns {number}
198
+ */
199
+ function pieceDownloadedBytes(torrent, index) {
200
+ const len = index === torrent.pieces.length - 1 ? torrent.lastPieceLength : torrent.pieceLength;
201
+ if (torrent.bitfield.get(index)) {
202
+ return len; // verified
203
+ }
204
+ const piece = torrent.pieces[index];
205
+ return piece ? len - piece.missing : 0; // in-progress, or null (deselected) → 0
206
+ }
207
+
208
+ /**
209
+ * Total downloaded bytes across ALL pieces, null-safe (mirrors webtorrent's
210
+ * `torrent.downloaded` but never throws on a deselected null piece).
211
+ *
212
+ * @param {import("webtorrent").Torrent} torrent
213
+ * @returns {number}
214
+ */
215
+ export function torrentDownloadedBytes(torrent) {
216
+ if (!torrent?.bitfield || !Array.isArray(torrent.pieces)) {
217
+ return 0;
218
+ }
219
+ let downloaded = 0;
220
+ for (let index = 0; index < torrent.pieces.length; index += 1) {
221
+ downloaded += pieceDownloadedBytes(torrent, index);
222
+ }
223
+ return downloaded;
224
+ }
225
+
226
+ /**
227
+ * Downloaded bytes of a single file, null-safe (mirrors webtorrent's
228
+ * `file.downloaded`, including the first/last-piece offset trims, but never
229
+ * throws on a deselected null piece).
230
+ *
231
+ * @param {import("webtorrent").Torrent} torrent
232
+ * @param {import("webtorrent").TorrentFile & { _startPiece?: number, _endPiece?: number, offset?: number, length?: number }} file
233
+ * @returns {number}
234
+ */
235
+ export function fileDownloadedBytes(torrent, file) {
236
+ if (!torrent?.bitfield || !Array.isArray(torrent.pieces) || !file) {
237
+ return 0;
238
+ }
239
+ const start = file._startPiece;
240
+ const end = file._endPiece;
241
+ const pieceLength = torrent.pieceLength;
242
+ if (!Number.isInteger(start) || !Number.isInteger(end) || !(pieceLength > 0)) {
243
+ return 0;
244
+ }
245
+ let downloaded = 0;
246
+ for (let index = start; index <= end; index += 1) {
247
+ const pieceDownloaded = pieceDownloadedBytes(torrent, index);
248
+ downloaded += pieceDownloaded;
249
+ if (index === start) {
250
+ // First piece may carry irrelevant bytes from the previous file.
251
+ const irrelevant = file.offset % pieceLength;
252
+ downloaded -= Math.min(irrelevant, pieceDownloaded);
253
+ }
254
+ if (index === end) {
255
+ // Last piece may carry irrelevant bytes from the next file.
256
+ const lastLen = index === torrent.pieces.length - 1 ? torrent.lastPieceLength : pieceLength;
257
+ const irrelevant = lastLen - ((file.offset + file.length) % pieceLength);
258
+ downloaded -= Math.min(irrelevant, pieceDownloaded);
259
+ }
260
+ }
261
+ return downloaded;
262
+ }
263
+
171
264
  /**
172
265
  * Shared WebTorrent pool.
173
266
  *
@@ -312,8 +405,7 @@ export class TorrentPool {
312
405
  #currentDiskBytes() {
313
406
  let total = 0;
314
407
  for (const torrent of this.torrents.values()) {
315
- const downloaded = typeof torrent?.downloaded === "number" ? torrent.downloaded : 0;
316
- total += Math.max(0, downloaded);
408
+ total += Math.max(0, torrentDownloadedBytes(torrent));
317
409
  }
318
410
  return total;
319
411
  }
@@ -345,7 +437,7 @@ export class TorrentPool {
345
437
  if (used <= this.#maxDiskBytes) {
346
438
  break;
347
439
  }
348
- const freed = typeof torrent?.downloaded === "number" ? Math.max(0, torrent.downloaded) : 0;
440
+ const freed = Math.max(0, torrentDownloadedBytes(torrent));
349
441
  const name = typeof torrent?.name === "string" ? torrent.name : "(unknown)";
350
442
  const gb = (this.#maxDiskBytes / (1024 * 1024 * 1024)).toFixed(1);
351
443
  logger.info(
@@ -651,11 +743,16 @@ export class TorrentPool {
651
743
 
652
744
  const header = this.#getHeaderRangeProgress(torrent, file);
653
745
 
746
+ // Null-safe downloaded/progress (webtorrent's own getters throw on a
747
+ // deselected null piece — see fileDownloadedBytes).
748
+ const fileLength = typeof file.length === "number" ? file.length : 0;
749
+ const fileDownloaded = fileDownloadedBytes(torrent, file);
750
+
654
751
  return {
655
752
  ...base,
656
- fileProgress: typeof file.progress === "number" ? file.progress : 0,
657
- fileDownloaded: typeof file.downloaded === "number" ? file.downloaded : 0,
658
- fileLength: typeof file.length === "number" ? file.length : 0,
753
+ fileProgress: fileLength > 0 ? Math.max(0, Math.min(1, fileDownloaded / fileLength)) : 0,
754
+ fileDownloaded,
755
+ fileLength,
659
756
  // Phase-1 progress: how much of the header/index region (the bytes the
660
757
  // codec probe needs before transcoding can start) is downloaded. Counted
661
758
  // by whole pieces from the torrent bitfield, so it advances coarsely