diff --git a/AGENTS.md b/AGENTS.md index b06fd708..aebda66c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -91,10 +91,17 @@ This document tells AI agents how Bitvid works **now** and how to extend it goin ## 5) Key Client Functions (reference) -### 5.1 `augmentMagnet(raw, ws?, xs?)` +### 5.1 `normalizeAndAugmentMagnet(raw, { webSeed?, torrentUrl?, xs?, extraTrackers? })` -* Accepts a magnet URI and conditionally **appends** `ws` and `xs` query params. -* Returns the same magnet if not a `magnet:` scheme. +* Accepts a magnet URI or bare hex info hash and returns a **sanitized magnet**. +* Fixes accidentally encoded `xt=urn%3Abtih:` segments and tolerates bare hashes by + promoting them to `magnet:?xt=urn:btih:`. +* Appends only **browser-safe WSS trackers** plus optional extras, avoiding mixed + content. +* Adds HTTPS web seeds (`ws=`) and torrent hints (`xs=`) when provided, skipping + insecure `http://` web seeds unless the app itself is served via HTTP. +* Preserves existing parameters in their original form—no `new URL()` usage and no + percent re-encoding of the `xt` payload. ### 5.2 `probeUrl(url)` @@ -120,12 +127,36 @@ This document tells AI agents how Bitvid works **now** and how to extend it goin * **Cards:** add both attributes where applicable: * `data-play-url=""` - * `data-play-magnet=""` -* **Click delegate:** look for a closest element bearing either attribute; decode `data-play-url` prior to use. + * `data-play-magnet=""` +* **Click delegate:** look for a closest element bearing either attribute; decode + both `data-play-url` and `data-play-magnet` (if encoded upstream) **before** passing + them to playback logic. Never double-encode magnets when writing them into the DOM. --- -## 7) Server Requirements for Hosted URLs +## 7) Main vs Unstable — legacy WebTorrent compatibility lessons + +Legacy Bitvid posts (v1 / magnet-only) still play on **Main**. They broke on +**Unstable** because of double-encoded magnets, URL constructors, and strict +filters. Apply these rules to stay compatible: + +* **Raw magnets end-to-end.** Store the literal `magnet:?xt=urn:btih:...` in the DOM + and feed that same string into WebTorrent. If you must encode for transport, be + sure to decode once when handling clicks. +* **No `new URL()` for magnets.** Appending params with `URLSearchParams` or `new URL` + percent-encodes the `xt` value and can corrupt legacy hashes. When you need to add + trackers or cache-busting params, concatenate plain text (or use + `normalizeAndAugmentMagnet`, which already preserves the payload). +* **Converters should allow magnet-only posts.** A note is playable when it exposes + either a usable `magnet` **or** a direct `url`. Don’t require `version >= 2` to keep + legacy uploads alive. +* **Browser-safe announce list.** Ship WSS trackers for browser playback. Skip UDP + trackers in the client path and only add `ws=` hints when they’re HTTPS on HTTPS + pages (mixed content gets blocked anyway). + +--- + +## 8) Server Requirements for Hosted URLs * **CORS:** allow the app origin (or `*`). * **Range requests:** ensure `Accept-Ranges: bytes`. @@ -134,7 +165,7 @@ This document tells AI agents how Bitvid works **now** and how to extend it goin --- -## 8) P2P Notes for Agents +## 9) P2P Notes for Agents * **`ws=` (web seed):** HTTP(S) base path where the file is fetchable; enables WebTorrent to download pieces even with zero peers. * **`xs=`:** HTTP(S) URL of the `.torrent` metadata so the client can start promptly without DHT/peer discovery. @@ -142,7 +173,7 @@ This document tells AI agents how Bitvid works **now** and how to extend it goin --- -## 9) Interop & Extensions +## 10) Interop & Extensions * **Live streams:** future option via HLS URLs (MIME `application/x-mpegURL`). For Nostr live presence, consider **NIP‑53** events alongside VOD 1063 entries. * **Adaptive Bitrate + P2P:** if ABR is needed with P2P, consider a segment‑sharing overlay (e.g., p2p segment loaders) rather than WebTorrent. @@ -150,7 +181,7 @@ This document tells AI agents how Bitvid works **now** and how to extend it goin --- -## 10) Testing Checklist (agents) +## 11) Testing Checklist (agents) * **Form validation:** title present; ensure `(url || magnet)` is true; `ws` and `xs` appended only for magnet. * **Probe:** healthy URL returns `ok=true`; dead URL hides/badges card; cache prevents re‑probing every render. @@ -161,7 +192,7 @@ This document tells AI agents how Bitvid works **now** and how to extend it goin --- -## 11) Guardrails & Do/Don’t +## 12) Guardrails & Do/Don’t **Do** @@ -177,7 +208,7 @@ This document tells AI agents how Bitvid works **now** and how to extend it goin --- -## 12) Quick Reference (snippets) +## 13) Quick Reference (snippets) **Bitvid content object (30078)** @@ -193,17 +224,18 @@ const content = { }; ``` -**Magnet augmentation** +**Magnet normalization & augmentation** ```js -function augmentMagnet(raw, ws, xs) { - if (!raw) return ""; - const u = new URL(raw); - if (u.protocol !== "magnet:") return raw; - if (ws) u.searchParams.append("ws", ws); - if (xs) u.searchParams.append("xs", xs); - return u.toString(); -} +import { normalizeAndAugmentMagnet } from "./js/magnetUtils.js"; + +const { magnet } = normalizeAndAugmentMagnet(inputMagnetOrInfoHash, { + webSeed: hostedUrl ? [hostedUrl] : [], + torrentUrl: xsHint, + extraTrackers: ["wss://tracker.openwebtorrent.com"], +}); + +// Feed `magnet` directly to playVideoWithFallback / WebTorrent. ``` **Playback with fallback** @@ -218,7 +250,7 @@ async function playVideoWithFallback({ url, magnet }) { --- -## 13) Roadmap Hooks (where to extend) +## 14) Roadmap Hooks (where to extend) * **Analytics:** Keep using your existing 30078 analytics/logging. Add fields for `play_source: "url"|"torrent"` to measure cost savings. * **Integrity:** Optionally compute SHA‑256 of the hosted file (off‑thread) and publish as NIP‑94 `["x", hash]` tag. diff --git a/js/app.js b/js/app.js index cea4d77f..9a9a0ab6 100644 --- a/js/app.js +++ b/js/app.js @@ -2042,16 +2042,21 @@ class bitvidApp { ); } - let cacheBustedMagnet = trimmedCandidate; - try { - const parsed = new URL(trimmedCandidate); - parsed.searchParams.set("ts", Date.now().toString()); - cacheBustedMagnet = parsed.toString(); - } catch (err) { - const separator = trimmedCandidate.includes("?") ? "&" : "?"; - cacheBustedMagnet = `${trimmedCandidate}${separator}ts=${Date.now()}`; + const timestamp = Date.now().toString(); + const [magnetPrefix, magnetQuery = ""] = trimmedCandidate.split("?", 2); + let normalizedMagnet = magnetPrefix; + let queryParts = magnetQuery + .split("&") + .map((part) => part.trim()) + .filter((part) => part && !/^ts=\d+$/.test(part)); + + if (queryParts.length) { + normalizedMagnet = `${magnetPrefix}?${queryParts.join("&")}`; } + const separator = normalizedMagnet.includes("?") ? "&" : "?"; + const cacheBustedMagnet = `${normalizedMagnet}${separator}ts=${timestamp}`; + await torrentClient.cleanup(); this.resetTorrentStats(); diff --git a/js/magnetUtils.js b/js/magnetUtils.js index 7de9ab87..4cbcb737 100644 --- a/js/magnetUtils.js +++ b/js/magnetUtils.js @@ -108,27 +108,69 @@ export function normalizeAndAugmentMagnet( didMutate = true; } + const hashIndex = working.indexOf("#"); + let fragment = ""; + if (hashIndex !== -1) { + fragment = working.slice(hashIndex); + working = working.slice(0, hashIndex); + } + const decodedXt = working.replace(ENCODED_BTih_PATTERN, (_, hash) => { didMutate = true; return `xt=${BTIH_PREFIX}${hash}`; }); working = decodedXt; - let parsed; - try { - parsed = new URL(working); - } catch (err) { + if (!/^magnet:/i.test(working)) { return { magnet: working, didChange: didMutate || working !== initial, }; } - if (parsed.protocol !== "magnet:") { - return { - magnet: working, - didChange: didMutate || working !== initial, - }; + const [schemePart, queryPart = ""] = working.split("?", 2); + const normalizedScheme = "magnet:"; + if (schemePart !== normalizedScheme) { + didMutate = true; + } + + const rawParams = queryPart + .split("&") + .map((part) => part.trim()) + .filter(Boolean); + + const params = []; + + const decodeLoose = (value) => { + if (typeof value !== "string" || !value) { + return ""; + } + try { + return decodeURIComponent(value); + } catch (err) { + return value; + } + }; + + for (const rawParam of rawParams) { + const [rawKey, rawValue = ""] = rawParam.split("=", 2); + const key = rawKey.trim(); + if (!key) { + continue; + } + let value = rawValue.trim(); + if (key.toLowerCase() === "xt" && value) { + const decoded = decodeLoose(value); + if (decoded !== value) { + value = decoded; + didMutate = true; + } + } + params.push({ + key, + value, + decoded: decodeLoose(value), + }); } const torrentHint = typeof torrentUrl === "string" && torrentUrl.trim() @@ -151,9 +193,9 @@ export function normalizeAndAugmentMagnet( : "https:"; const existingTrackers = new Set( - parsed.searchParams - .getAll("tr") - .map((value) => normalizeForComparison(value)) + params + .filter((param) => param.key.toLowerCase() === "tr") + .map((param) => normalizeForComparison(param.decoded)) .filter(Boolean) ); @@ -177,30 +219,34 @@ export function normalizeAndAugmentMagnet( if (!normalizedTracker || existingTrackers.has(normalizedTracker)) { continue; } - parsed.searchParams.append("tr", trimmedTracker); + params.push({ key: "tr", value: trimmedTracker, decoded: trimmedTracker }); existingTrackers.add(normalizedTracker); didMutate = true; } if (safeTorrentHint) { const existingXs = new Set( - parsed.searchParams - .getAll("xs") - .map((value) => normalizeForComparison(value)) + params + .filter((param) => param.key.toLowerCase() === "xs") + .map((param) => normalizeForComparison(param.decoded)) .filter(Boolean) ); const normalizedXs = normalizeForComparison(safeTorrentHint); if (normalizedXs && !existingXs.has(normalizedXs)) { - parsed.searchParams.append("xs", safeTorrentHint); + params.push({ + key: "xs", + value: safeTorrentHint, + decoded: safeTorrentHint, + }); didMutate = true; } } if (seedInputs.length) { const existingWs = new Set( - parsed.searchParams - .getAll("ws") - .map((value) => normalizeForComparison(value)) + params + .filter((param) => param.key.toLowerCase() === "ws") + .map((param) => normalizeForComparison(param.decoded)) .filter(Boolean) ); for (const seedInput of seedInputs) { @@ -222,7 +268,7 @@ export function normalizeAndAugmentMagnet( const seedValue = parsedSeed.toString(); const normalizedSeed = normalizeForComparison(seedValue); if (normalizedSeed && !existingWs.has(normalizedSeed)) { - parsed.searchParams.append("ws", seedValue); + params.push({ key: "ws", value: seedValue, decoded: seedValue }); existingWs.add(normalizedSeed); didMutate = true; } @@ -237,15 +283,11 @@ export function normalizeAndAugmentMagnet( } } - let finalMagnet = parsed.toString(); - const decodedFinalMagnet = finalMagnet.replace( - ENCODED_BTih_PATTERN, - (_, hash) => `xt=${BTIH_PREFIX}${hash}` - ); - if (decodedFinalMagnet !== finalMagnet) { - finalMagnet = decodedFinalMagnet; - didMutate = true; - } + const queryString = params + .map(({ key, value }) => (value ? `${key}=${value}` : key)) + .join("&"); + + const finalMagnet = `${normalizedScheme}${queryString ? `?${queryString}` : ""}${fragment}`; return { magnet: finalMagnet,