mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
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]>
168 lines
5.8 KiB
JavaScript
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 }),
|
|
};
|
|
}
|