mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
[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]>
This commit is contained in:
1 parent
a20388c5d6
commit
cf6a830183
4 files changed
+479
-1
No files matched your search
@@ -1259,13 +1259,70 @@ export class UploadModal {
|
||||
throw new Error("Please provide at least a Video URL or Magnet Link.");
|
||||
}
|
||||
|
||||
if (!hasUrl && !hasImeta && hasMagnet) {
|
||||
// Auto-derive a webseed torrent from the external URL so the link keeps the
|
||||
// P2P benefit (fetch + hash the remote file → magnet with ws=url). Best-effort
|
||||
// and size-capped: on CORS / too-large / any error it silently degrades to
|
||||
// URL-only. Only runs when the user didn't already paste their own magnet.
|
||||
if (hasUrl && !hasMagnet) {
|
||||
const derived = await this.deriveExternalWebseed(metadata.url);
|
||||
if (derived?.magnet) {
|
||||
metadata.magnet = derived.magnet;
|
||||
metadata.infoHash = derived.infoHash || metadata.infoHash || "";
|
||||
metadata.ws = [metadata.url, this.inputs.ws?.value || ""];
|
||||
if (derived.torrentUrl) metadata.xs = derived.torrentUrl;
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasUrl && !hasImeta && metadata.magnet.length > 0) {
|
||||
if (!(await showConfirm("Magnet-only uploads require active seeding. Proceed?"))) return;
|
||||
}
|
||||
|
||||
await this.publish(metadata);
|
||||
}
|
||||
|
||||
// Best-effort webseed derivation for an external URL (TODO: external-URL P2P).
|
||||
// Hosts the tiny .torrent (xs=) only when storage is actually unlocked; otherwise
|
||||
// returns a ws=-only magnet. Never throws — returns null so the caller publishes
|
||||
// URL-only.
|
||||
async deriveExternalWebseed(url) {
|
||||
if (typeof this.mediaUploader?.deriveTorrentForExternalUrl !== "function") {
|
||||
return null;
|
||||
}
|
||||
const pubkey = this.getCurrentPubkey ? this.getCurrentPubkey() : null;
|
||||
const canHostTorrent = Boolean(
|
||||
this.activeProvider &&
|
||||
this.activeCredentials &&
|
||||
pubkey &&
|
||||
this.storageService?.isUnlocked?.(pubkey),
|
||||
);
|
||||
const previousLabel = this.submitButton?.textContent;
|
||||
if (this.submitButton) {
|
||||
this.submitButton.disabled = true;
|
||||
this.submitButton.textContent = "Computing torrent hash…";
|
||||
}
|
||||
try {
|
||||
return await this.mediaUploader.deriveTorrentForExternalUrl(url, {
|
||||
provider: canHostTorrent ? this.activeProvider : undefined,
|
||||
credentials: canHostTorrent ? this.activeCredentials : undefined,
|
||||
onProgress: ({ label }) => {
|
||||
if (this.submitStatus && label) this.submitStatus.textContent = label;
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
userLogger.warn(
|
||||
"[UploadModal] External URL webseed derivation skipped:",
|
||||
error?.message || error,
|
||||
);
|
||||
return null;
|
||||
} finally {
|
||||
if (this.submitButton) {
|
||||
this.submitButton.disabled = false;
|
||||
if (previousLabel) this.submitButton.textContent = previousLabel;
|
||||
}
|
||||
if (this.submitStatus) this.submitStatus.textContent = "";
|
||||
}
|
||||
}
|
||||
|
||||
async publish(metadata) {
|
||||
// Normalize & Publish
|
||||
const { payload, errors } = normalizeVideoNotePayload(metadata);
|
||||
|
||||
@@ -12,6 +12,11 @@ import {
|
||||
calculateTorrentInfoHash,
|
||||
createTorrentMetadata,
|
||||
} from "../../utils/torrentHash.js";
|
||||
import {
|
||||
deriveExternalUrlTorrent,
|
||||
buildWebseedMagnet,
|
||||
DEFAULT_EXTERNAL_URL_HASH_MAX_BYTES,
|
||||
} from "../../utils/externalUrlTorrent.js";
|
||||
import { buildR2Key, buildPublicUrl } from "../../r2.js";
|
||||
import { buildS3ObjectUrl } from "../../services/s3Service.js";
|
||||
import { PROVIDERS } from "../../services/storageService.js";
|
||||
@@ -307,6 +312,92 @@ export class MediaUploader {
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a WebTorrent webseed for an EXTERNALLY-hosted video URL so an external
|
||||
* link keeps the P2P benefit. Best-effort: fetches the remote file (CORS-
|
||||
* permitting), streams it under a size cap, computes the infoHash, and builds a
|
||||
* magnet with the URL as the webseed (ws=). If storage credentials are available
|
||||
* it also hosts the tiny .torrent so the webseed can bootstrap P2P (xs=);
|
||||
* otherwise it returns a ws=-only magnet. Any failure throws (coded) so the
|
||||
* caller degrades to URL-only.
|
||||
*
|
||||
* @returns {Promise<{ infoHash, magnet, torrentUrl, torrentFile, name, hasValidInfoHash }>}
|
||||
*/
|
||||
async deriveTorrentForExternalUrl(
|
||||
url,
|
||||
{ provider, credentials, onProgress, maxBytes = DEFAULT_EXTERNAL_URL_HASH_MAX_BYTES } = {},
|
||||
) {
|
||||
const emit = (fraction, label) => {
|
||||
if (typeof onProgress === "function") onProgress({ fraction, label });
|
||||
};
|
||||
|
||||
emit(0, "Fetching the file to compute its hash…");
|
||||
const derived = await deriveExternalUrlTorrent(url, {
|
||||
maxBytes,
|
||||
onProgress: ({ received, total }) => {
|
||||
emit(total ? Math.min(1, received / total) : null, "Computing torrent hash…");
|
||||
},
|
||||
});
|
||||
|
||||
const result = {
|
||||
infoHash: derived.infoHash,
|
||||
magnet: derived.magnet,
|
||||
torrentUrl: "",
|
||||
torrentFile: derived.torrentFile,
|
||||
name: derived.name,
|
||||
hasValidInfoHash: Boolean(derived.infoHash),
|
||||
};
|
||||
|
||||
// Host the tiny .torrent (metadata) so the webseed can actually bootstrap P2P.
|
||||
// Requires storage; best-effort — a failure keeps the ws=-only magnet.
|
||||
const effectiveProvider =
|
||||
credentials?.provider || credentials?.meta?.provider || provider;
|
||||
const npub = this.safeEncodeNpub(this.getCurrentPubkey());
|
||||
if (credentials && effectiveProvider && derived.torrentFile && npub) {
|
||||
try {
|
||||
const service = this.serviceFor(effectiveProvider);
|
||||
const { settings, bucketEntry } = await service.prepareUpload(npub, {
|
||||
credentials,
|
||||
});
|
||||
const torrentKey = `${npub}/external/${derived.infoHash}.torrent`;
|
||||
const torrentPublicUrl = this.publicUrlFor(
|
||||
effectiveProvider,
|
||||
bucketEntry.publicBaseUrl,
|
||||
torrentKey,
|
||||
);
|
||||
emit(null, "Hosting torrent metadata…");
|
||||
await service.uploadFile({
|
||||
file: derived.torrentFile,
|
||||
bucket: bucketEntry.bucket,
|
||||
key: torrentKey,
|
||||
accountId: settings.accountId,
|
||||
endpoint: settings.endpoint,
|
||||
provider: settings.provider || effectiveProvider || "cloudflare_r2",
|
||||
region: settings.region,
|
||||
accessKeyId: settings.accessKeyId,
|
||||
secretAccessKey: settings.secretAccessKey,
|
||||
forcePathStyle: settings.forcePathStyle,
|
||||
createBucketIfMissing: true,
|
||||
});
|
||||
result.torrentUrl = torrentPublicUrl;
|
||||
result.magnet = buildWebseedMagnet({
|
||||
infoHash: derived.infoHash,
|
||||
url,
|
||||
name: derived.name,
|
||||
torrentUrl: torrentPublicUrl,
|
||||
});
|
||||
} catch (torrentHostError) {
|
||||
userLogger.warn(
|
||||
"[mediaUploader] Could not host .torrent for external URL; publishing ws=-only:",
|
||||
torrentHostError,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
emit(1, "Ready to publish!");
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
export { isR2Provider, normalizeInfoHash, isValidInfoHash };
|
||||
@@ -0,0 +1,167 @@
|
||||
// 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 }),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
// Derive a webseed magnet from an external URL (external-URL P2P). We fetch the
|
||||
// remote file (best-effort, CORS-permitting), stream it under a hard size cap,
|
||||
// compute the infoHash, and build a ws= magnet. Any failure must degrade to
|
||||
// URL-only (the caller catches).
|
||||
//
|
||||
// test_integrity_note:
|
||||
// change_type: ["new_tests"]
|
||||
// scenarios:
|
||||
// - id: SCN-external-url-torrent
|
||||
// given: "a remote URL + an injected fetch/hash"
|
||||
// when: "deriveExternalUrlTorrent / fetchBlobWithCap / buildWebseedMagnet run"
|
||||
// then: "magnet carries the infoHash + ws=url; oversize/fetch failures throw coded errors"
|
||||
// observable_outcomes:
|
||||
// - "buildWebseedMagnet: xt/dn/ws present; xs only when torrentUrl given; empty on missing infoHash/url"
|
||||
// - "deriveNameFromUrl extracts + decodes the filename"
|
||||
// - "fetchBlobWithCap streams under the cap and aborts past it (code too-large)"
|
||||
// - "content-length over cap short-circuits before downloading"
|
||||
// - "non-ok response -> fetch-failed"
|
||||
// - "deriveExternalUrlTorrent returns { infoHash, magnet(ws=url) } via injected hash"
|
||||
// determinism_controls:
|
||||
// - "injected fetchImpl (fake Response) + createMetadata stub + fileFactory; no network/WebTorrent"
|
||||
// anti_cheat_rationale:
|
||||
// prevents: ["hard-coded return value", "over-mocking internal logic"]
|
||||
// relaxation:
|
||||
// did_relax_any_assertion: false
|
||||
|
||||
import test from "node:test";
|
||||
import { strict as assert } from "node:assert";
|
||||
import {
|
||||
buildWebseedMagnet,
|
||||
deriveNameFromUrl,
|
||||
fetchBlobWithCap,
|
||||
deriveExternalUrlTorrent,
|
||||
} from "../js/utils/externalUrlTorrent.js";
|
||||
|
||||
function streamResponse(bytes, { contentLength, contentType = "video/mp4", ok = true, status = 200, chunkSize = 4 } = {}) {
|
||||
const headers = new Map();
|
||||
if (contentLength !== null) {
|
||||
headers.set("content-length", String(contentLength ?? bytes.length));
|
||||
}
|
||||
if (contentType) headers.set("content-type", contentType);
|
||||
let offset = 0;
|
||||
return {
|
||||
ok,
|
||||
status,
|
||||
headers: { get: (k) => headers.get(k.toLowerCase()) ?? null },
|
||||
body: {
|
||||
getReader() {
|
||||
return {
|
||||
async read() {
|
||||
if (offset >= bytes.length) return { done: true, value: undefined };
|
||||
const end = Math.min(offset + chunkSize, bytes.length);
|
||||
const value = bytes.slice(offset, end);
|
||||
offset = end;
|
||||
return { done: false, value };
|
||||
},
|
||||
async cancel() {},
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("buildWebseedMagnet: xt/dn/ws, xs only when torrentUrl is given", () => {
|
||||
const m = buildWebseedMagnet({ infoHash: "abc123", url: "https://h/v.mp4", name: "v.mp4" });
|
||||
assert.match(m, /^magnet:\?xt=urn:btih:abc123&dn=v\.mp4&ws=https%3A%2F%2Fh%2Fv\.mp4$/);
|
||||
const withXs = buildWebseedMagnet({ infoHash: "abc", url: "https://h/v.mp4", torrentUrl: "https://h/v.torrent" });
|
||||
assert.match(withXs, /&xs=https%3A%2F%2Fh%2Fv\.torrent$/);
|
||||
});
|
||||
|
||||
test("buildWebseedMagnet: empty when infoHash or url is missing", () => {
|
||||
assert.equal(buildWebseedMagnet({ url: "https://h/v.mp4" }), "");
|
||||
assert.equal(buildWebseedMagnet({ infoHash: "abc" }), "");
|
||||
assert.equal(buildWebseedMagnet({}), "");
|
||||
});
|
||||
|
||||
test("deriveNameFromUrl extracts + decodes the filename", () => {
|
||||
assert.equal(deriveNameFromUrl("https://h/path/My%20Clip.mp4"), "My Clip.mp4");
|
||||
assert.equal(deriveNameFromUrl("https://h/path/"), "");
|
||||
assert.equal(deriveNameFromUrl("not a url"), "");
|
||||
});
|
||||
|
||||
test("fetchBlobWithCap streams the whole body under the cap", async () => {
|
||||
const bytes = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
|
||||
const progress = [];
|
||||
const blob = await fetchBlobWithCap("https://h/v.mp4", {
|
||||
maxBytes: 1000,
|
||||
onProgress: (p) => progress.push(p.received),
|
||||
fetchImpl: async () => streamResponse(bytes, { chunkSize: 4 }),
|
||||
});
|
||||
assert.equal(blob.size, 10);
|
||||
assert.deepEqual(progress, [4, 8, 10]);
|
||||
});
|
||||
|
||||
test("fetchBlobWithCap aborts a stream that exceeds the cap (too-large)", async () => {
|
||||
const bytes = new Uint8Array(20);
|
||||
await assert.rejects(
|
||||
() =>
|
||||
fetchBlobWithCap("https://h/v.mp4", {
|
||||
maxBytes: 8,
|
||||
// no content-length header → only the streaming guard can catch it
|
||||
fetchImpl: async () => streamResponse(bytes, { contentLength: null, chunkSize: 4 }),
|
||||
}),
|
||||
(err) => err.code === "too-large",
|
||||
);
|
||||
});
|
||||
|
||||
test("fetchBlobWithCap short-circuits on content-length over the cap (no download)", async () => {
|
||||
let readerMade = false;
|
||||
await assert.rejects(
|
||||
() =>
|
||||
fetchBlobWithCap("https://h/big.mp4", {
|
||||
maxBytes: 100,
|
||||
fetchImpl: async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: { get: (k) => (k.toLowerCase() === "content-length" ? "999999" : null) },
|
||||
body: { getReader() { readerMade = true; return { read: async () => ({ done: true }) }; } },
|
||||
}),
|
||||
}),
|
||||
(err) => err.code === "too-large" && err.total === 999999,
|
||||
);
|
||||
assert.equal(readerMade, false, "must not start reading the body when the size is known-too-big");
|
||||
});
|
||||
|
||||
test("fetchBlobWithCap: non-ok response -> fetch-failed", async () => {
|
||||
await assert.rejects(
|
||||
() => fetchBlobWithCap("https://h/404", { fetchImpl: async () => ({ ok: false, status: 403, headers: { get: () => null } }) }),
|
||||
(err) => err.code === "fetch-failed" && err.status === 403,
|
||||
);
|
||||
});
|
||||
|
||||
test("deriveExternalUrlTorrent returns infoHash + ws= magnet via the injected hasher", async () => {
|
||||
const bytes = new Uint8Array([1, 2, 3, 4]);
|
||||
const seen = { fileName: null, urlList: null };
|
||||
const result = await deriveExternalUrlTorrent("https://host/clips/My%20Clip.mp4", {
|
||||
maxBytes: 1000,
|
||||
fetchImpl: async () => streamResponse(bytes),
|
||||
fileFactory: (blob, name) => ({ blob, name }),
|
||||
createMetadata: async (file, urlList) => {
|
||||
seen.fileName = file.name;
|
||||
seen.urlList = urlList;
|
||||
return { infoHash: "deadbeef", torrentFile: new Uint8Array([9]) };
|
||||
},
|
||||
});
|
||||
assert.equal(result.infoHash, "deadbeef");
|
||||
assert.equal(seen.fileName, "My Clip.mp4", "file name derived from the URL");
|
||||
assert.deepEqual(seen.urlList, ["https://host/clips/My%20Clip.mp4"], "url passed as the webseed");
|
||||
assert.match(result.magnet, /xt=urn:btih:deadbeef/);
|
||||
assert.match(result.magnet, /ws=https%3A%2F%2Fhost%2Fclips%2FMy%2520Clip\.mp4/);
|
||||
assert.ok(result.torrentFile, "torrentFile returned for optional .torrent hosting");
|
||||
});
|
||||
|
||||
test("deriveExternalUrlTorrent propagates a fetch failure (caller degrades to URL-only)", async () => {
|
||||
await assert.rejects(
|
||||
() =>
|
||||
deriveExternalUrlTorrent("https://host/blocked.mp4", {
|
||||
fetchImpl: async () => ({ ok: false, status: 0, headers: { get: () => null } }),
|
||||
createMetadata: async () => ({ infoHash: "x" }),
|
||||
}),
|
||||
(err) => err.code === "fetch-failed",
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user