@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 +9 -0
- package/package.json +3 -2
- package/services/torrent-pool.js +112 -15
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.
|
|
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
|
-
"
|
|
32
|
+
"uint8-util": "2.2.6",
|
|
33
|
+
"webtorrent": "2.8.5",
|
|
33
34
|
"ws": "^8.18.2"
|
|
34
35
|
}
|
|
35
36
|
}
|
package/services/torrent-pool.js
CHANGED
|
@@ -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:
|
|
56
|
-
//
|
|
57
|
-
//
|
|
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 →
|
|
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,
|
|
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:
|
|
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
|
-
|
|
99
|
-
|
|
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
|
-
|
|
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 =
|
|
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:
|
|
657
|
-
fileDownloaded
|
|
658
|
-
fileLength
|
|
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
|