@torrent-tv/proxy 2.9.22 → 2.9.24
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 +2 -0
- package/package.json +1 -1
- package/routes/api/playback-plan/post.js +5 -1
- package/services/playback-planner.js +17 -5
- package/services/torrent-pool.js +114 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
## 2.9.24
|
|
2
2
|
|
|
3
3
|
- **New**: IPv6-first support (roadmap step 5a). (1) A second STUN server (`stun.cloudflare.com:3478`, alongside Google's) is added to the ICE config — both have IPv6 (AAAA) records, so when the proxy host has a global IPv6 address it gathers a `srflx` candidate over v6 too. IPv6 has no NAT, so if both the proxy and a (v6-native, e.g. cellular) viewer have global v6, the connection can go **direct** over v6 — sidestepping the whole NAT-traversal machinery. (2) Candidate logging now classifies each candidate by address scope — `v4-private` / `v4-public` / `v6-global` / `v6-ula` / `v6-linklocal` / `v6-loopback` (replaces the old private/public host label) — so the field log shows whether a global IPv6 path is actually being offered and chosen. Audited the candidate path: the proxy already forwards ALL candidates (incl. global v6) and the browser adds them all — nothing was dropping global v6, so no filter fix was needed. NOTE: not verifiable on the dev's proxy (its ISP exposes only ULA v6 `fd…`, no global v6); needs a proxy with global v6 to confirm in the field — the new `v6-global` log tag is there to spot it.
|
|
4
|
+
- **Fix**: Cold-start playback no longer fails with "Data channel request timed out". `POST /api/playback-plan` (`playback-planner.getPlan`) used to block up to 60 s waiting for the file header to download for the codec probe — exactly the transport's 60 s request timeout, so a cold torrent (peers still connecting, 0 % header) raced and failed. The planner now takes a short per-request budget (`maxWaitMs`, 8 s from the route): it prioritises the file header and probes, and if the header still isn't down it returns the plan flagged `pending: true` (uncached) instead of blocking. The browser polls again — each call keeps the header prioritised — so no single request approaches the 60 s limit and the existing `/stats` poll keeps showing live peers/speed/% the whole time. Pairs with server 0.8.24 (browser-side poll loop, already live).
|
|
5
|
+
- **New**: Disk hygiene (Level 1, `torrent-pool.js`). (1) A torrent with **zero active file readers** is now removed together with its on-disk store after a 300 s idle TTL (`torrent.destroy({ destroyStore: true })`), so downloaded data no longer accumulates while the proxy keeps running; re-requesting the torrent re-adds it. Re-acquiring a file cancels the pending removal, and the TTL is generous so brief gaps between ffmpeg range reads (or a short pause) never evict an in-use torrent. (2) **Startup orphan sweep**: leftover torrent data under `os.tmpdir()/webtorrent` from a previous hard kill (where graceful `destroyAll` never ran) is cleared at construction (safe — no torrents loaded yet). Still pending (Level 1): a global disk cap with LRU eviction.
|
|
4
6
|
|
|
5
7
|
## 2.9.23
|
|
6
8
|
|
package/package.json
CHANGED
|
@@ -35,7 +35,11 @@ export async function handleApiPlaybackPlanPost(req, reply, { playbackPlanner })
|
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
try {
|
|
38
|
-
|
|
38
|
+
// Short per-request budget: if the file header is not downloaded yet the
|
|
39
|
+
// planner returns quickly with `pending: true` instead of blocking up to
|
|
40
|
+
// the transport's 60 s request timeout. The browser polls again (the header
|
|
41
|
+
// keeps downloading, prioritised on each call). Well under that 60 s limit.
|
|
42
|
+
const plan = await playbackPlanner.getPlan({ sourceKey, fileIndex, userAgent, maxWaitMs: 8_000 });
|
|
39
43
|
return reply.send(plan);
|
|
40
44
|
} catch (error) {
|
|
41
45
|
if (error instanceof Error && error.code === "SOURCE_NOT_FOUND") {
|
|
@@ -167,13 +167,22 @@ export function createPlaybackPlanner({
|
|
|
167
167
|
* Throws with `error.code === "SOURCE_NOT_FOUND"` or `"FILE_NOT_FOUND"`
|
|
168
168
|
* when the source or file cannot be located.
|
|
169
169
|
*
|
|
170
|
+
* When the file header has not downloaded yet (cold torrent, peers still
|
|
171
|
+
* connecting) the codec probe cannot succeed. Rather than block the HTTP
|
|
172
|
+
* response until it can, the planner prioritises the header, probes for at
|
|
173
|
+
* most `maxWaitMs`, and if still undetectable returns a plan flagged
|
|
174
|
+
* `pending: true` (NOT cached). The caller polls again — each call keeps the
|
|
175
|
+
* header prioritised and downloading — until a real plan comes back. This
|
|
176
|
+
* avoids a single long request racing the transport's request timeout.
|
|
177
|
+
*
|
|
170
178
|
* @param {object} params
|
|
171
179
|
* @param {string} params.sourceKey
|
|
172
180
|
* @param {number} params.fileIndex
|
|
173
181
|
* @param {string} [params.userAgent=""]
|
|
174
|
-
* @
|
|
182
|
+
* @param {number} [params.maxWaitMs=60000] - Max time to wait for the header within ONE call.
|
|
183
|
+
* @returns {Promise<PlaybackPlan & { pending?: boolean }>}
|
|
175
184
|
*/
|
|
176
|
-
async getPlan({ sourceKey, fileIndex, userAgent = "" }) {
|
|
185
|
+
async getPlan({ sourceKey, fileIndex, userAgent = "", maxWaitMs = 60_000 }) {
|
|
177
186
|
const cacheKey = `${sourceKey}:${fileIndex}`;
|
|
178
187
|
const cached = cache.get(cacheKey);
|
|
179
188
|
if (cached) {
|
|
@@ -218,7 +227,7 @@ export function createPlaybackPlanner({
|
|
|
218
227
|
// file, and an unsupported codec like xvid gets copied → black video.
|
|
219
228
|
await torrentPool.prefetchFileEdges(torrent, fileIndex);
|
|
220
229
|
let probe = await probeStreamCodecs({ ffmpegBin, inputUrl: directUrl, userAgent });
|
|
221
|
-
const probeDeadline = Date.now() +
|
|
230
|
+
const probeDeadline = Date.now() + Math.max(0, maxWaitMs);
|
|
222
231
|
let attempt = 0;
|
|
223
232
|
while (
|
|
224
233
|
probe.audioCodec.length === 0 &&
|
|
@@ -248,11 +257,14 @@ export function createPlaybackPlanner({
|
|
|
248
257
|
};
|
|
249
258
|
// Only cache a plan whose codecs were actually detected. An empty probe is
|
|
250
259
|
// a "header not downloaded yet" signal, not a valid result — caching it
|
|
251
|
-
// would permanently mis-plan the file.
|
|
260
|
+
// would permanently mis-plan the file. In that case flag the plan
|
|
261
|
+
// `pending` so the caller polls again (the header keeps downloading,
|
|
262
|
+
// prioritised by the prefetch above).
|
|
252
263
|
if (codecsDetected) {
|
|
253
264
|
cache.set(cacheKey, plan);
|
|
265
|
+
return plan;
|
|
254
266
|
}
|
|
255
|
-
return plan;
|
|
267
|
+
return { ...plan, pending: true };
|
|
256
268
|
}
|
|
257
269
|
};
|
|
258
270
|
}
|
package/services/torrent-pool.js
CHANGED
|
@@ -7,9 +7,23 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import crypto from "node:crypto";
|
|
10
|
+
import os from "node:os";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { rmSync } from "node:fs";
|
|
10
13
|
import WebTorrent from "webtorrent";
|
|
11
14
|
import { logger } from "../utils/logger.js";
|
|
12
15
|
|
|
16
|
+
// WebTorrent's default download root (see webtorrent lib/torrent.js: TMP =
|
|
17
|
+
// path.join(os.tmpdir(), 'webtorrent')). We use the default store, so all
|
|
18
|
+
// torrent data lives under here.
|
|
19
|
+
const WEBTORRENT_STORE_ROOT = path.join(os.tmpdir(), "webtorrent");
|
|
20
|
+
|
|
21
|
+
// How long a torrent may sit with zero active file readers before it is
|
|
22
|
+
// 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
|
+
|
|
13
27
|
// Bytes ahead of a read position to mark CRITICAL (download-first) on each
|
|
14
28
|
// range request. Big enough to unstick a seek into an undownloaded region,
|
|
15
29
|
// small enough not to make "everything critical" (which defeats prioritization).
|
|
@@ -55,7 +69,26 @@ export class TorrentPool {
|
|
|
55
69
|
*/
|
|
56
70
|
#pending = new Map();
|
|
57
71
|
|
|
72
|
+
/**
|
|
73
|
+
* Pending idle-removal timers, keyed by torrent object. A torrent with zero
|
|
74
|
+
* file refcount is scheduled for removal; re-acquiring it cancels the timer.
|
|
75
|
+
*
|
|
76
|
+
* @type {Map<import("webtorrent").Torrent, ReturnType<typeof setTimeout>>}
|
|
77
|
+
*/
|
|
78
|
+
#idleTimers = new Map();
|
|
79
|
+
|
|
58
80
|
constructor() {
|
|
81
|
+
// Sweep orphaned torrent data left by a previous hard kill (no graceful
|
|
82
|
+
// shutdown ran, so destroyAll never cleaned the store). Safe here: no
|
|
83
|
+
// torrents are loaded yet at construction. Best-effort, synchronous so it
|
|
84
|
+
// completes before the client starts writing.
|
|
85
|
+
try {
|
|
86
|
+
rmSync(WEBTORRENT_STORE_ROOT, { recursive: true, force: true });
|
|
87
|
+
} catch (error) {
|
|
88
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
89
|
+
logger.warn(`torrent-pool: could not sweep orphaned store at startup: ${message}`);
|
|
90
|
+
}
|
|
91
|
+
|
|
59
92
|
/** @type {import("webtorrent").WebTorrent} */
|
|
60
93
|
this.client = new WebTorrent();
|
|
61
94
|
|
|
@@ -170,6 +203,8 @@ export class TorrentPool {
|
|
|
170
203
|
usage = new Map();
|
|
171
204
|
this.fileUsageByTorrent.set(torrent, usage);
|
|
172
205
|
}
|
|
206
|
+
// The torrent is in use again — cancel any pending idle removal.
|
|
207
|
+
this.#cancelIdleRemoval(torrent);
|
|
173
208
|
usage.set(fileIndex, (usage.get(fileIndex) ?? 0) + 1);
|
|
174
209
|
this.#syncSelections(torrent, usage);
|
|
175
210
|
|
|
@@ -187,11 +222,84 @@ export class TorrentPool {
|
|
|
187
222
|
}
|
|
188
223
|
if (usage.size === 0) {
|
|
189
224
|
this.fileUsageByTorrent.delete(torrent);
|
|
225
|
+
// No active readers — schedule removal (with store) after an idle TTL.
|
|
226
|
+
this.#scheduleIdleRemoval(torrent);
|
|
190
227
|
}
|
|
191
228
|
this.#syncSelections(torrent, usage);
|
|
192
229
|
};
|
|
193
230
|
}
|
|
194
231
|
|
|
232
|
+
/**
|
|
233
|
+
* Schedule removal of a torrent (with its on-disk store) after
|
|
234
|
+
* {@link TORRENT_IDLE_TTL_MS} of zero file refcount. Idempotent — replaces
|
|
235
|
+
* any existing timer for the torrent.
|
|
236
|
+
*
|
|
237
|
+
* @param {import("webtorrent").Torrent} torrent
|
|
238
|
+
* @returns {void}
|
|
239
|
+
*/
|
|
240
|
+
#scheduleIdleRemoval(torrent) {
|
|
241
|
+
if (!torrent) {
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
this.#cancelIdleRemoval(torrent);
|
|
245
|
+
const timer = setTimeout(() => {
|
|
246
|
+
this.#idleTimers.delete(torrent);
|
|
247
|
+
// Re-check: a new acquire since scheduling would have cancelled this
|
|
248
|
+
// timer, but guard anyway against a race.
|
|
249
|
+
const usage = this.fileUsageByTorrent.get(torrent);
|
|
250
|
+
if (usage && usage.size > 0) {
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
this.#removeTorrent(torrent);
|
|
254
|
+
}, TORRENT_IDLE_TTL_MS);
|
|
255
|
+
timer.unref?.();
|
|
256
|
+
this.#idleTimers.set(torrent, timer);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Cancel a pending idle-removal timer for a torrent, if any.
|
|
261
|
+
*
|
|
262
|
+
* @param {import("webtorrent").Torrent} torrent
|
|
263
|
+
* @returns {void}
|
|
264
|
+
*/
|
|
265
|
+
#cancelIdleRemoval(torrent) {
|
|
266
|
+
const timer = this.#idleTimers.get(torrent);
|
|
267
|
+
if (timer) {
|
|
268
|
+
clearTimeout(timer);
|
|
269
|
+
this.#idleTimers.delete(torrent);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Remove a torrent from the pool together with its on-disk store, freeing
|
|
275
|
+
* disk while the proxy keeps running. Best-effort.
|
|
276
|
+
*
|
|
277
|
+
* @param {import("webtorrent").Torrent} torrent
|
|
278
|
+
* @returns {void}
|
|
279
|
+
*/
|
|
280
|
+
#removeTorrent(torrent) {
|
|
281
|
+
if (!torrent) {
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
// Drop it from the source→torrent map so a later request re-adds it.
|
|
285
|
+
for (const [key, value] of this.torrents) {
|
|
286
|
+
if (value === torrent) {
|
|
287
|
+
this.torrents.delete(key);
|
|
288
|
+
break;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
this.fileUsageByTorrent.delete(torrent);
|
|
292
|
+
const name = typeof torrent.name === "string" ? torrent.name : "(unknown)";
|
|
293
|
+
try {
|
|
294
|
+
torrent.destroy({ destroyStore: true }, () => {
|
|
295
|
+
logger.info(`torrent-pool: removed idle torrent "${name}" and its store`);
|
|
296
|
+
});
|
|
297
|
+
} catch (error) {
|
|
298
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
299
|
+
logger.warn(`torrent-pool: failed to remove idle torrent "${name}": ${message}`);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
195
303
|
/**
|
|
196
304
|
* Return download statistics for a torrent and optionally a specific file.
|
|
197
305
|
*
|
|
@@ -438,6 +546,12 @@ export class TorrentPool {
|
|
|
438
546
|
* @returns {Promise<void>}
|
|
439
547
|
*/
|
|
440
548
|
async destroyAll() {
|
|
549
|
+
// Cancel any pending idle-removal timers — destroyAll handles teardown.
|
|
550
|
+
for (const timer of this.#idleTimers.values()) {
|
|
551
|
+
clearTimeout(timer);
|
|
552
|
+
}
|
|
553
|
+
this.#idleTimers.clear();
|
|
554
|
+
|
|
441
555
|
if (!this.client || this.client.destroyed) {
|
|
442
556
|
return;
|
|
443
557
|
}
|