@torrent-tv/proxy 2.9.13 → 2.9.15
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 +8 -0
- package/CLAUDE.md +52 -0
- package/package.json +1 -1
- package/server.js +3 -0
- package/services/torrent-pool.js +112 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
## 2.9.15
|
|
2
|
+
|
|
3
|
+
- **Fix**: Torrent data is now cleaned up on graceful shutdown. `TorrentPool.destroyAll()` removes every torrent **with its on-disk store** (`torrent.destroy({ destroyStore: true })`) and then tears down the WebTorrent client; it is wired into the Fastify `onClose` hook (after `hlsSessionManager.disposeAll()`, so ffmpeg readers stop before their source files are removed). Previously nothing called `client.remove()`/`torrent.destroy()` anywhere, so downloaded files accumulated under `os.tmpdir()` until the process was killed — and even a clean SIGTERM/SIGINT left them behind. (First step of disk-hygiene Level 1; refcount/TTL removal and the startup orphan sweep are separate, still pending.)
|
|
4
|
+
|
|
5
|
+
## 2.9.14
|
|
6
|
+
|
|
7
|
+
- **New**: `GET /api/sources/:sourceKey/stats` now reports `headerBytes` / `headerDownloadedBytes` — how much of the file's header/index region (leading 256 KB + trailing 2 MB, the bytes the codec probe needs) is downloaded, counted by whole torrent pieces from the bitfield. Lets the browser show the download phase's progress and ETA toward the next (transcode) phase. Coarse by design (piece granularity).
|
|
8
|
+
|
|
1
9
|
## 2.9.13
|
|
2
10
|
|
|
3
11
|
- **Fix**: Video-copy path (`video=copy`, audio transcoded or copied) no longer drops video / desyncs audio at the start. The output timeline is now forced 0-based: the container `start_time` (parsed from the probe; many MKVs report ~0.1 s) is subtracted via `-output_ts_offset -start_time` together with `-copyts`, so segment 0 begins exactly at 0 with audio and video aligned (previously `-copyts` preserved the non-zero start, leaving a hole at the beginning where video was blank but audio played).
|
package/CLAUDE.md
CHANGED
|
@@ -47,6 +47,58 @@ Linux-only host (e.g. POSIX-only signals must degrade elsewhere).
|
|
|
47
47
|
around it with `--ignore-scripts` + a targeted rebuild of `node-datachannel`;
|
|
48
48
|
if you ever change install flow, keep that in mind.
|
|
49
49
|
|
|
50
|
+
## Planned: public reachability (remote access)
|
|
51
|
+
|
|
52
|
+
Decided direction — full plan in the parent `../CLAUDE.md`. Proxy-side pieces:
|
|
53
|
+
|
|
54
|
+
- **Auto port mapping** at startup via UPnP IGD / NAT-PMP / PCP (`nat-api` /
|
|
55
|
+
`@silentbot1/nat-api`; WebTorrent already does this for the torrent port).
|
|
56
|
+
Always request a lease time and renew it while running; remove the mapping
|
|
57
|
+
on graceful shutdown — lease expiry cleans up after crashes. Zero user
|
|
58
|
+
action, nothing left behind on the router.
|
|
59
|
+
- Report the mapped external endpoint (and local addresses) to the server over
|
|
60
|
+
the tunnel; the server dial-back-verifies reachability before use.
|
|
61
|
+
- **HTTPS listener**: serve the existing routes over TLS with a per-proxy
|
|
62
|
+
certificate delivered by the server through the tunnel (persist cert+key
|
|
63
|
+
locally; ~90-day renewals are pushed the same way). Add CORS headers for the
|
|
64
|
+
web-app origin so hls.js / `<video>` can fetch cross-origin.
|
|
65
|
+
- Plain HTTPS becomes the preferred video transport; WebRTC data channel stays
|
|
66
|
+
as fallback for hosts where no port could be opened.
|
|
67
|
+
- **NAT-traversal toolbox** (staged, see parent `../CLAUDE.md`): birthday-
|
|
68
|
+
paradox port prediction (open ~256 UDP sockets, inject predicted-port ICE
|
|
69
|
+
candidates) for symmetric NAT; IPv6-first (audit the candidate filter — do
|
|
70
|
+
not drop *global* v6); STUN-based NAT pre-classification at startup reported
|
|
71
|
+
to the registry; relay-then-upgrade later.
|
|
72
|
+
- Future: ed25519 proxy identity (sign announcements), BEP 44 endpoint
|
|
73
|
+
announcements via the `bittorrent-dht` already bundled with WebTorrent.
|
|
74
|
+
|
|
75
|
+
All of this must stay deployment-agnostic (HA addon, bare npm, Docker).
|
|
76
|
+
|
|
77
|
+
## Disk hygiene (open item — torrent data is NOT cleaned up today)
|
|
78
|
+
|
|
79
|
+
HLS segments are handled (`hls-session-manager.js`: idle TTL, `disposeSession`,
|
|
80
|
+
`disposeAll`). Torrent data is NOT: `new WebTorrent()` in `torrent-pool.js` uses
|
|
81
|
+
the default FS store under `os.tmpdir()`, there is no `client.remove()` /
|
|
82
|
+
`torrent.destroy()`, `deselect()` only stops further download, nothing sweeps
|
|
83
|
+
orphans at startup, and `TorrentPool` is not wired into the `onClose` shutdown
|
|
84
|
+
hook (`server.js` only disposes HLS sessions on close — see `cli.js` shutdown →
|
|
85
|
+
`app.close()` → `onClose`).
|
|
86
|
+
|
|
87
|
+
Level 1 (do first): `client.remove(torrent, { destroyStore: true })` on last-
|
|
88
|
+
file refcount 0 + idle TTL; startup sweep of orphaned store dirs; wire
|
|
89
|
+
`TorrentPool` teardown into the `onClose` hook so closing the proxy cleans up
|
|
90
|
+
(not only startup); global disk cap with LRU eviction of whole torrents.
|
|
91
|
+
Level 2 (research): sliding-window chunk store. Full rationale in the parent
|
|
92
|
+
`../CLAUDE.md` "Disk hygiene" section.
|
|
93
|
+
|
|
94
|
+
## Cloud proxy
|
|
95
|
+
|
|
96
|
+
The same proxy code also runs as the company-hosted fallback when the user
|
|
97
|
+
pool can't serve a viewer. Keep the proxy host-agnostic so it runs unchanged on
|
|
98
|
+
rented infra (flat-rate/unmetered bandwidth — Hetzner dedicated / OVH; NOT
|
|
99
|
+
metered-egress clouds). Provider/economics analysis in the parent
|
|
100
|
+
`../CLAUDE.md` "Cloud proxy" section.
|
|
101
|
+
|
|
50
102
|
## Changelog
|
|
51
103
|
|
|
52
104
|
Every behavioural change must be recorded in `CHANGELOG.md` — add an entry under
|
package/package.json
CHANGED
package/server.js
CHANGED
|
@@ -154,7 +154,10 @@ export async function startProxyServer({ host, port, transcodeAudio, ffmpegBin }
|
|
|
154
154
|
});
|
|
155
155
|
|
|
156
156
|
app.addHook("onClose", async () => {
|
|
157
|
+
// Order matters: stop the ffmpeg readers (HLS sessions) before destroying
|
|
158
|
+
// the torrents whose files they read from, then remove the torrent data.
|
|
157
159
|
await hlsSessionManager.disposeAll();
|
|
160
|
+
await torrentPool.destroyAll();
|
|
158
161
|
});
|
|
159
162
|
|
|
160
163
|
await app.listen({ host, port: selectedPort });
|
package/services/torrent-pool.js
CHANGED
|
@@ -15,6 +15,11 @@ import { logger } from "../utils/logger.js";
|
|
|
15
15
|
// small enough not to make "everything critical" (which defeats prioritization).
|
|
16
16
|
const PRIORITY_WINDOW_BYTES = 8 * 1024 * 1024;
|
|
17
17
|
|
|
18
|
+
// The file's header/index region the codec probe needs (phase 1). Must match
|
|
19
|
+
// the ranges prefetchFileEdges fetches: leading bytes + trailing bytes.
|
|
20
|
+
const HEADER_HEAD_BYTES = 256 * 1024;
|
|
21
|
+
const HEADER_TAIL_BYTES = 2 * 1024 * 1024;
|
|
22
|
+
|
|
18
23
|
/**
|
|
19
24
|
* Decode a raw torrent source value into the format expected by WebTorrent.
|
|
20
25
|
*
|
|
@@ -217,14 +222,68 @@ export class TorrentPool {
|
|
|
217
222
|
return { ...base, fileProgress: null, fileDownloaded: null, fileLength: null };
|
|
218
223
|
}
|
|
219
224
|
|
|
225
|
+
const header = this.#getHeaderRangeProgress(torrent, file);
|
|
226
|
+
|
|
220
227
|
return {
|
|
221
228
|
...base,
|
|
222
229
|
fileProgress: typeof file.progress === "number" ? file.progress : 0,
|
|
223
230
|
fileDownloaded: typeof file.downloaded === "number" ? file.downloaded : 0,
|
|
224
|
-
fileLength: typeof file.length === "number" ? file.length : 0
|
|
231
|
+
fileLength: typeof file.length === "number" ? file.length : 0,
|
|
232
|
+
// Phase-1 progress: how much of the header/index region (the bytes the
|
|
233
|
+
// codec probe needs before transcoding can start) is downloaded. Counted
|
|
234
|
+
// by whole pieces from the torrent bitfield, so it advances coarsely
|
|
235
|
+
// (piece granularity). Null when the bitfield/piece info is unavailable.
|
|
236
|
+
headerBytes: header ? header.totalBytes : null,
|
|
237
|
+
headerDownloadedBytes: header ? header.downloadedBytes : null
|
|
225
238
|
};
|
|
226
239
|
}
|
|
227
240
|
|
|
241
|
+
/**
|
|
242
|
+
* Count, by whole torrent pieces, how many bytes of a file's header/index
|
|
243
|
+
* region (leading {@link HEADER_HEAD_BYTES} + trailing {@link HEADER_TAIL_BYTES})
|
|
244
|
+
* are downloaded. Used to show progress toward the codec-probe phase.
|
|
245
|
+
*
|
|
246
|
+
* @param {import("webtorrent").Torrent} torrent
|
|
247
|
+
* @param {import("webtorrent").TorrentFile} file
|
|
248
|
+
* @returns {{ totalBytes: number, downloadedBytes: number } | null}
|
|
249
|
+
*/
|
|
250
|
+
#getHeaderRangeProgress(torrent, file) {
|
|
251
|
+
const pieceLength = Number(torrent?.pieceLength);
|
|
252
|
+
const bitfield = torrent?.bitfield;
|
|
253
|
+
const fileLength = Number(file?.length);
|
|
254
|
+
if (
|
|
255
|
+
!Number.isFinite(pieceLength) || pieceLength <= 0 ||
|
|
256
|
+
!bitfield || typeof bitfield.get !== "function" ||
|
|
257
|
+
!Number.isFinite(fileLength) || fileLength <= 0
|
|
258
|
+
) {
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
261
|
+
const fileOffset = Number.isFinite(file.offset) ? file.offset : 0;
|
|
262
|
+
const headEnd = Math.min(HEADER_HEAD_BYTES, fileLength) - 1;
|
|
263
|
+
const ranges = [[0, headEnd]];
|
|
264
|
+
const tailStart = Math.max(headEnd + 1, fileLength - HEADER_TAIL_BYTES);
|
|
265
|
+
if (tailStart <= fileLength - 1) {
|
|
266
|
+
ranges.push([tailStart, fileLength - 1]);
|
|
267
|
+
}
|
|
268
|
+
const pieces = new Set();
|
|
269
|
+
for (const [start, end] of ranges) {
|
|
270
|
+
const first = Math.floor((fileOffset + start) / pieceLength);
|
|
271
|
+
const last = Math.floor((fileOffset + end) / pieceLength);
|
|
272
|
+
for (let piece = first; piece <= last; piece += 1) {
|
|
273
|
+
pieces.add(piece);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
let totalBytes = 0;
|
|
277
|
+
let downloadedBytes = 0;
|
|
278
|
+
for (const piece of pieces) {
|
|
279
|
+
totalBytes += pieceLength;
|
|
280
|
+
if (bitfield.get(piece)) {
|
|
281
|
+
downloadedBytes += pieceLength;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return { totalBytes, downloadedBytes };
|
|
285
|
+
}
|
|
286
|
+
|
|
228
287
|
/**
|
|
229
288
|
* Pre-fetch the leading and trailing bytes of a torrent file so that
|
|
230
289
|
* WebTorrent prioritises the pieces that contain file headers and footers.
|
|
@@ -364,4 +423,56 @@ export class TorrentPool {
|
|
|
364
423
|
// Best effort — never break streaming because prioritization failed.
|
|
365
424
|
}
|
|
366
425
|
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Destroy every torrent together with its on-disk store, then tear down the
|
|
429
|
+
* WebTorrent client. Called from the proxy's graceful-shutdown `onClose`
|
|
430
|
+
* hook so downloaded torrent data does not linger under `os.tmpdir()` after
|
|
431
|
+
* the process stops.
|
|
432
|
+
*
|
|
433
|
+
* `client.destroy()` on its own destroys the torrents but only *closes* their
|
|
434
|
+
* stores (data stays on disk), so each torrent is removed explicitly with
|
|
435
|
+
* `{ destroyStore: true }` first. Best-effort: never rejects and never hangs
|
|
436
|
+
* on a single store-removal error during shutdown.
|
|
437
|
+
*
|
|
438
|
+
* @returns {Promise<void>}
|
|
439
|
+
*/
|
|
440
|
+
async destroyAll() {
|
|
441
|
+
if (!this.client || this.client.destroyed) {
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// Destroy each torrent with its store so downloaded pieces are removed
|
|
446
|
+
// from disk. This also removes the torrent from `client.torrents`, so the
|
|
447
|
+
// subsequent `client.destroy()` only tears down the client internals.
|
|
448
|
+
const torrents = [...this.client.torrents];
|
|
449
|
+
await Promise.all(
|
|
450
|
+
torrents.map(
|
|
451
|
+
(torrent) =>
|
|
452
|
+
new Promise((resolve) => {
|
|
453
|
+
try {
|
|
454
|
+
torrent.destroy({ destroyStore: true }, () => resolve());
|
|
455
|
+
} catch (error) {
|
|
456
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
457
|
+
logger.warn(`failed to destroy torrent store: ${message}`);
|
|
458
|
+
resolve();
|
|
459
|
+
}
|
|
460
|
+
})
|
|
461
|
+
)
|
|
462
|
+
);
|
|
463
|
+
|
|
464
|
+
// Tear down the client itself (DHT, connection pool, TCP server).
|
|
465
|
+
await new Promise((resolve) => {
|
|
466
|
+
try {
|
|
467
|
+
this.client.destroy(() => resolve());
|
|
468
|
+
} catch (error) {
|
|
469
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
470
|
+
logger.warn(`failed to destroy WebTorrent client: ${message}`);
|
|
471
|
+
resolve();
|
|
472
|
+
}
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
this.torrents.clear();
|
|
476
|
+
this.#pending.clear();
|
|
477
|
+
}
|
|
367
478
|
}
|