Files
bitvid/js/utils/externalUrlTorrent.js
T
thePR0M3TH3ANandClaude Opus 4.8 cf6a830183 [upload] Derive a webseed torrent for external-URL posts (P2P for external links)
Posting an external URL used to skip the torrent/webseed benefit entirely. Now the
upload modal's external tab auto-derives a WebTorrent webseed from the URL at
publish time: fetch the remote file (CORS-permitting), stream it under a hard size
cap, compute the infoHash, and publish a magnet with the URL as the webseed (ws=).
Best-effort — CORS failure / over the cap / any error silently degrades to
URL-only. Only runs when the user didn't paste their own magnet.

- js/utils/externalUrlTorrent.js: fetchBlobWithCap (streams with a 500 MB cap,
  aborts past it, short-circuits on content-length), buildWebseedMagnet, and
  deriveExternalUrlTorrent (reuses createTorrentMetadata). Pure + injectable → unit
  tested without network/WebTorrent.
- mediaUploader.deriveTorrentForExternalUrl: derives the ws= magnet and, when
  storage is unlocked, also hosts the tiny .torrent so the webseed can actually
  bootstrap P2P (xs=) — best-effort, falls back to ws=-only.
- UploadModal.handleExternalFlow calls it; deriveExternalWebseed only hosts the
  .torrent when storage is unlocked, shows progress on the submit button, and
  never throws.

Tests: tests/external-url-torrent.test.mjs (9 — magnet assembly, name parsing,
streamed size-cap enforcement + short-circuit, fetch-failed, derive-with-injected-
hash, failure propagation). Lint + build green.

Next: the same for editing a URL in the Edit modal.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-02 09:08:32 -04:00

168 lines
5.8 KiB
JavaScript

// Derive a WebTorrent webseed magnet from an externally-hosted video URL (TODO
// #45-adjacent / external-URL P2P). Posting a plain URL skips the torrent/webseed
// benefit; here we (best-effort) fetch the remote file, compute its infoHash, and
// build a magnet with the URL as the webseed (ws=). Fully optional: any failure
// (CORS, over the size cap, network) leaves the caller to fall back to URL-only.
//
// Constraints baked in:
// - CORS: fetch()-ing the remote requires the host to allow it (same requirement
// as webseed playback), so callers must treat failure as "URL-only".
// - Whole-file download: an infoHash requires reading every byte, so we stream
// with a hard size cap and abort past it (never buffer an unbounded download).
import { createTorrentMetadata as defaultCreateTorrentMetadata } from "./torrentHash.js";
export const DEFAULT_EXTERNAL_URL_HASH_MAX_BYTES = 500 * 1024 * 1024; // 500 MB
export function deriveNameFromUrl(url) {
try {
const parsed = new URL(url);
// A path ending in "/" has no filename.
if (parsed.pathname.endsWith("/")) {
return "";
}
const last = parsed.pathname.split("/").filter(Boolean).pop() || "";
const decoded = decodeURIComponent(last);
return decoded && /\S/.test(decoded) ? decoded : "";
} catch (error) {
return "";
}
}
// magnet:?xt=urn:btih:<infoHash>&dn=<name>&ws=<url>[&xs=<torrentUrl>]
export function buildWebseedMagnet({ infoHash, url, name, torrentUrl } = {}) {
const hash = typeof infoHash === "string" ? infoHash.trim() : "";
const webseed = typeof url === "string" ? url.trim() : "";
if (!hash || !webseed) {
return "";
}
const dn = encodeURIComponent(name || deriveNameFromUrl(webseed) || "video");
let magnet = `magnet:?xt=urn:btih:${hash}&dn=${dn}&ws=${encodeURIComponent(webseed)}`;
if (typeof torrentUrl === "string" && torrentUrl.trim()) {
magnet += `&xs=${encodeURIComponent(torrentUrl.trim())}`;
}
return magnet;
}
// Fetch a URL into a Blob, streaming with a hard byte cap. Throws a coded error
// on any failure so the caller can distinguish + degrade:
// fetch-failed | too-large | (network error from fetchImpl)
export async function fetchBlobWithCap(
url,
{
maxBytes = DEFAULT_EXTERNAL_URL_HASH_MAX_BYTES,
onProgress,
fetchImpl = typeof fetch === "function" ? fetch : null,
} = {},
) {
if (typeof fetchImpl !== "function") {
const error = new Error("fetch is unavailable in this environment.");
error.code = "fetch-unavailable";
throw error;
}
const response = await fetchImpl(url);
if (!response || !response.ok) {
const error = new Error(
`Could not fetch the URL (${response?.status ?? "no response"}).`,
);
error.code = "fetch-failed";
error.status = response?.status;
throw error;
}
const declaredTotal = Number(response.headers?.get?.("content-length")) || 0;
if (declaredTotal && declaredTotal > maxBytes) {
const error = new Error("The file is larger than the size cap.");
error.code = "too-large";
error.total = declaredTotal;
throw error;
}
const contentType = response.headers?.get?.("content-type") || "";
// Prefer streaming so we can abort a too-large download mid-flight.
if (response.body && typeof response.body.getReader === "function") {
const reader = response.body.getReader();
const chunks = [];
let received = 0;
for (;;) {
// eslint-disable-next-line no-await-in-loop
const { done, value } = await reader.read();
if (done) {
break;
}
received += value.byteLength;
if (received > maxBytes) {
try {
await reader.cancel();
} catch (cancelError) {
// ignore cancel failures
}
const error = new Error("The file is larger than the size cap.");
error.code = "too-large";
error.total = declaredTotal || received;
throw error;
}
chunks.push(value);
if (typeof onProgress === "function") {
onProgress({ received, total: declaredTotal });
}
}
return new Blob(chunks, contentType ? { type: contentType } : undefined);
}
// No streaming body: fall back to blob() and enforce the cap after the fact.
const blob = await response.blob();
if (blob.size > maxBytes) {
const error = new Error("The file is larger than the size cap.");
error.code = "too-large";
error.total = blob.size;
throw error;
}
return blob;
}
// Best-effort: fetch + hash a remote URL and return { infoHash, magnet, torrentFile,
// name }. Throws (coded) on CORS/size/network failure — the caller degrades to
// URL-only. `createMetadata` + `fileFactory` are injectable for tests.
export async function deriveExternalUrlTorrent(
url,
{
maxBytes = DEFAULT_EXTERNAL_URL_HASH_MAX_BYTES,
onProgress,
fetchImpl,
createMetadata = defaultCreateTorrentMetadata,
fileFactory,
} = {},
) {
const trimmed = typeof url === "string" ? url.trim() : "";
if (!trimmed) {
const error = new Error("A URL is required.");
error.code = "no-url";
throw error;
}
const blob = await fetchBlobWithCap(trimmed, { maxBytes, onProgress, fetchImpl });
const name = deriveNameFromUrl(trimmed) || "video";
const file =
typeof fileFactory === "function"
? fileFactory(blob, name)
: new File([blob], name, blob.type ? { type: blob.type } : undefined);
const metadata = await createMetadata(file, [trimmed]);
const infoHash = typeof metadata?.infoHash === "string" ? metadata.infoHash : "";
if (!infoHash) {
const error = new Error("Failed to compute the torrent info hash.");
error.code = "hash-failed";
throw error;
}
return {
infoHash,
name,
torrentFile: metadata?.torrentFile || null,
magnet: buildWebseedMagnet({ infoHash, url: trimmed, name }),
};
}