@torrent-tv/proxy 2.9.21 → 2.9.23

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,11 @@
1
+ ## 2.9.25
2
+
3
+ - **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); ship together.
4
+
5
+ ## 2.9.24
6
+
7
+ - **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.
8
+
1
9
  ## 2.9.23
2
10
 
3
11
  - **New**: Symmetric-NAT port prediction for WebRTC (roadmap step 4; `webrtc-manager.js` + `nat-classifier.js` delta + `cli.js` wiring). When the startup NAT classification reports a **symmetric** NAT, for each real IPv4 `srflx` candidate the proxy also offers predicted-port candidates at `base + delta*k` (k = 1..16, `delta` = the per-destination external-port step measured at startup), each with a unique ICE foundation. The browser probes these too; if one matches the external port the NAT assigns for the proxy→browser path, ICE connects — the practical, signalling-only form of the birthday-paradox trick (no node-datachannel changes, no extra sockets). **Scope**: covers sequential/predictable symmetric NATs; a fully-random symmetric NAT (where the true 256-socket birthday would be needed) is not solved by this and is out of reach on the node-datachannel stack. No-op for cone NATs (the fixed-port mapping already suffices) and IPv6 (no NAT). Diagnostics: logs the injected predicted ports per session (`symmetric NAT (delta=D) — injecting N predicted srflx candidates: …`); combined with the existing `selected pair local=[…]` log this shows whether a predicted port won. NOTE: could not be exercised end-to-end — the dev's home NAT is cone; needs a symmetric-NAT vantage to verify in the field (the logging is there to diagnose it when it appears).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.9.21",
3
+ "version": "2.9.23",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -35,7 +35,11 @@ export async function handleApiPlaybackPlanPost(req, reply, { playbackPlanner })
35
35
  }
36
36
 
37
37
  try {
38
- const plan = await playbackPlanner.getPlan({ sourceKey, fileIndex, userAgent });
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
- * @returns {Promise<PlaybackPlan>}
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() + 60_000;
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
  }
@@ -13,7 +13,10 @@ import nodeDataChannel from "node-datachannel";
13
13
 
14
14
  /** @import { WebRtcSignal } from './tunnel-client.js' */
15
15
 
16
- const ICE_SERVERS = ["stun:stun.l.google.com:19302"];
16
+ // Two STUN servers from different operators, both with IPv6 (AAAA) records, so
17
+ // the proxy gathers a server-reflexive candidate over BOTH v4 and (when the
18
+ // host has global v6) v6 — enabling a direct IPv6 path on v6-native networks.
19
+ const ICE_SERVERS = ["stun:stun.l.google.com:19302", "stun:stun.cloudflare.com:3478"];
17
20
 
18
21
  // Symmetric-NAT port prediction window. For each real srflx candidate we offer
19
22
  // this many extra candidates at ports base + delta*k (k = 1..N), because the
@@ -82,46 +85,47 @@ function buildPredictedSrflxCandidates(candidate, delta, windowSize = PORT_PREDI
82
85
  }
83
86
 
84
87
  /**
85
- * Return true when the ICE candidate string describes a `typ host` candidate
86
- * with a private (RFC 1918 / ULA / loopback) IP address.
88
+ * Classify an ICE candidate by its address family and scope, for diagnostics.
87
89
  *
88
- * Browsers enforce Private Network Access (PNA) and show a permission dialog
89
- * when a page served from a public origin (e.g. webauth.courses) attempts a
90
- * WebRTC connection to a private-network address. Filtering these candidates
91
- * out before forwarding them to the browser lets the connection proceed via the
92
- * server-reflexive (srflx) candidate the proxy's public IP as seen by STUN —
93
- * which does not trigger PNA.
90
+ * Returns one of: `v4-private`, `v4-public`, `v6-global`, `v6-ula`,
91
+ * `v6-linklocal`, `v6-loopback`, or `unknown`. This is logged for every
92
+ * candidate so the field log shows whether a **global IPv6** path is being
93
+ * offered (IPv6 has no NAT if both sides have a global v6 address the
94
+ * connection can go direct, which matters for v6-native mobile networks).
94
95
  *
95
- * Edge case: if no srflx candidate is available (STUN unreachable, symmetric
96
- * NAT that maps differently per destination, etc.) all host candidates are
97
- * suppressed and the connection will fail. We accept this trade-off; STUN is
98
- * a hard dependency of the WebRTC path in any case.
96
+ * Note on PNA: a `v4-private` host candidate triggers the browser's Private
97
+ * Network Access permission prompt; the connection otherwise proceeds via the
98
+ * public srflx candidate. We forward all candidates regardless (the browser
99
+ * decides) this only labels them.
99
100
  *
100
- * @param {string} candidate - Raw candidate attribute string from node-datachannel.
101
- * @returns {boolean}
101
+ * @param {string} candidate - Raw candidate attribute string (with or without `a=`).
102
+ * @returns {"v4-private"|"v4-public"|"v6-global"|"v6-ula"|"v6-linklocal"|"v6-loopback"|"unknown"}
102
103
  */
103
- function isPrivateHostCandidate(candidate) {
104
- // Only care about "typ host" — srflx and relay are already public/relay.
105
- if (!candidate.includes("typ host")) {
106
- return false;
104
+ function candidateAddrKind(candidate) {
105
+ const parts = candidate.replace(/^a=/, "").split(" ");
106
+ const ip = parts.length > 4 ? parts[4] : "";
107
+ if (!ip) {
108
+ return "unknown";
107
109
  }
108
- // RFC 1918 IPv4 private ranges.
109
- if (/\b(10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.|169\.254\.)/.test(candidate)) {
110
- return true;
110
+ if (!ip.includes(":")) {
111
+ // IPv4: RFC 1918 / loopback / link-local are private.
112
+ if (/^(10\.|127\.|169\.254\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)/.test(ip)) {
113
+ return "v4-private";
114
+ }
115
+ return "v4-public";
111
116
  }
112
- // Docker / typical private subnets not covered above (100.64–127 are special).
113
- if (/\b127\./.test(candidate)) {
114
- return true;
117
+ const low = ip.toLowerCase();
118
+ if (low === "::1") {
119
+ return "v6-loopback";
115
120
  }
116
- // IPv6 loopback.
117
- if (/\s::1\s/.test(candidate)) {
118
- return true;
121
+ if (low.startsWith("fe80")) {
122
+ return "v6-linklocal";
119
123
  }
120
- // IPv6 Unique Local Addresses (ULA): fc00::/7 — starts with fc or fd.
121
- if (/\s(?:fc|fd)[0-9a-f]{2}:/i.test(candidate)) {
122
- return true;
124
+ if (/^f[cd][0-9a-f]{2}:/.test(low)) {
125
+ // Unique Local Address (fc00::/7).
126
+ return "v6-ula";
123
127
  }
124
- return false;
128
+ return "v6-global";
125
129
  }
126
130
 
127
131
  /**
@@ -245,11 +249,12 @@ export function createWebRtcManager({ sendSignal, onDataChannel, onLog, udpPort,
245
249
  // Network Access (PNA) permission dialog once; after the user allows it
246
250
  // the connection proceeds via the local LAN path.
247
251
  pc.onLocalCandidate((candidate, mid) => {
248
- const isPrivate = isPrivateHostCandidate(candidate);
249
- // Log the full candidate (addr:port typ …) so we can confirm WebRTC is
250
- // pinned to the mapped UDP port and diagnose which paths are offered.
252
+ const kind = candidateAddrKind(candidate);
253
+ // Log the full candidate (addr:port typ …) with its address kind so we
254
+ // can confirm WebRTC is pinned to the mapped UDP port, see whether a
255
+ // global IPv6 path is offered, and diagnose which routes are available.
251
256
  log(
252
- `[webrtc] Session ${sessionId.slice(0, 8)}: sending ${isPrivate ? "private" : "public"} candidate: ${candidate.replace(/^a=/, "")}`
257
+ `[webrtc] Session ${sessionId.slice(0, 8)}: sending ${kind} candidate: ${candidate.replace(/^a=/, "")}`
253
258
  );
254
259
  sendSignal(sessionId, { type: "candidate", candidate, mid });
255
260