diff --git a/js/magnetShared.js b/js/magnetShared.js index 6e4400f5..01f3b4b3 100644 --- a/js/magnetShared.js +++ b/js/magnetShared.js @@ -421,3 +421,38 @@ export function extractMagnetHints(rawValue) { } return hints; } + +/** + * Extract every web seed (`ws=`) URL from a magnet, in order, deduped + * case-insensitively. Unlike {@link extractMagnetHints} (which returns only the + * first `ws`), this surfaces the full set so callers can round-trip a magnet + * that carries multiple independent webseeds without silently dropping the + * extras. + * + * @param {string} rawValue + * @returns {string[]} Deduped web seed URLs in magnet order. + */ +export function extractAllWebSeeds(rawValue) { + const { params } = normalizeMagnetInput(rawValue); + const seeds = []; + const seen = new Set(); + for (const param of params) { + if (param.lowerKey !== "ws") { + continue; + } + const value = + typeof (param.decoded || param.value) === "string" + ? (param.decoded || param.value).trim() + : ""; + if (!value) { + continue; + } + const key = value.toLowerCase(); + if (seen.has(key)) { + continue; + } + seen.add(key); + seeds.push(value); + } + return seeds; +} diff --git a/js/services/videoNotePayload.js b/js/services/videoNotePayload.js index 1496c6ef..9c7a5551 100644 --- a/js/services/videoNotePayload.js +++ b/js/services/videoNotePayload.js @@ -34,6 +34,42 @@ function normalizeString(value) { return String(value ?? "").trim(); } +/** + * Normalize a web seed field into an ordered, deduped list of trimmed URLs. + * Accepts an array (multi-webseed forms) or a string that may carry several + * URLs separated by newlines/commas (a single textarea field). Preserving the + * full list lets a video declare multiple independent webseeds (e.g. a CDN + * plus a backup origin) instead of collapsing to a single `ws`. + * + * @param {string|string[]} value + * @returns {string[]} + */ +function normalizeWebSeedList(value) { + const out = []; + const seen = new Set(); + const push = (entry) => { + if (typeof entry !== "string") { + return; + } + const trimmed = entry.trim(); + if (!trimmed) { + return; + } + const key = trimmed.toLowerCase(); + if (seen.has(key)) { + return; + } + seen.add(key); + out.push(trimmed); + }; + if (Array.isArray(value)) { + value.forEach(push); + } else if (typeof value === "string") { + value.split(/[\r\n,]+/).forEach(push); + } + return out; +} + function normalizeSha256Hex(value) { if (value === undefined || value === null) { return ""; @@ -342,7 +378,7 @@ export function normalizeVideoNotePayload(input) { const magnet = normalizeString(legacyPayload?.magnet || ""); const thumbnail = normalizeString(legacyPayload?.thumbnail || ""); const description = normalizeString(legacyPayload?.description || ""); - const ws = normalizeString(legacyPayload?.ws || ""); + const wsValues = normalizeWebSeedList(legacyPayload?.ws); const xs = normalizeString(legacyPayload?.xs || ""); const storagePointer = normalizeStoragePointer( legacyPayload?.storagePointer || legacyPayload?.storage || "" @@ -415,10 +451,7 @@ export function normalizeVideoNotePayload(input) { } if (legacyFormData.magnet) { - const wsList = []; - if (ws) { - wsList.push(ws); - } + const wsList = [...wsValues]; if (legacyFormData.url && /^https?:\/\//i.test(legacyFormData.url)) { wsList.push(legacyFormData.url); } diff --git a/tests/multi-webseed.test.mjs b/tests/multi-webseed.test.mjs new file mode 100644 index 00000000..2f56721f --- /dev/null +++ b/tests/multi-webseed.test.mjs @@ -0,0 +1,97 @@ +// A video may legitimately declare more than one web seed (e.g. the primary CDN +// plus an independent backup origin) so the WebTorrent swarm can pull pieces +// from either when one is overloaded. Multiple `ws=` params are the standard +// BEP19 carrier and playback already attaches every one of them, so the +// authoring/normalization layer must preserve the full set — never collapse it +// to a single seed. + +import test from "node:test"; +import assert from "node:assert/strict"; + +import { extractAllWebSeeds } from "../js/magnetShared.js"; +import { normalizeVideoNotePayload } from "../js/services/videoNotePayload.js"; + +const BTIH = "a".repeat(40); +const MAGNET = `magnet:?xt=urn:btih:${BTIH}`; +const SEED_A = "https://cdn-a.example.com/video.mp4"; +const SEED_B = "https://cdn-b.example.com/video.mp4"; + +test("extractAllWebSeeds returns every ws= in order, deduped", () => { + const magnet = `${MAGNET}&ws=${encodeURIComponent(SEED_A)}&ws=${encodeURIComponent( + SEED_B, + )}&ws=${encodeURIComponent(SEED_A)}`; + assert.deepEqual(extractAllWebSeeds(magnet), [SEED_A, SEED_B]); +}); + +test("extractAllWebSeeds returns [] when there are no web seeds", () => { + assert.deepEqual(extractAllWebSeeds(MAGNET), []); + assert.deepEqual(extractAllWebSeeds(""), []); + assert.deepEqual(extractAllWebSeeds(null), []); +}); + +test("an array of web seeds all survive into the published magnet", () => { + const { payload, errors } = normalizeVideoNotePayload({ + title: "Multi seed", + magnet: MAGNET, + ws: [SEED_A, SEED_B], + }); + + assert.deepEqual(errors, []); + const seeds = extractAllWebSeeds(payload.legacyFormData.magnet); + assert.ok(seeds.includes(SEED_A), "first webseed must be carried in the magnet"); + assert.ok(seeds.includes(SEED_B), "second webseed must be carried in the magnet"); +}); + +test("a newline/comma-delimited ws field is split into multiple seeds", () => { + const { payload } = normalizeVideoNotePayload({ + title: "Multi seed text", + magnet: MAGNET, + ws: `${SEED_A}\n${SEED_B}`, + }); + + const seeds = extractAllWebSeeds(payload.legacyFormData.magnet); + assert.ok(seeds.includes(SEED_A)); + assert.ok(seeds.includes(SEED_B)); +}); + +test("the hosted CDN url is added as a webseed alongside the explicit ones", () => { + const cdn = "https://cdn.example.com/video.mp4"; + const { payload } = normalizeVideoNotePayload({ + title: "Cdn plus seed", + url: cdn, + magnet: MAGNET, + ws: SEED_A, + }); + + const seeds = extractAllWebSeeds(payload.legacyFormData.magnet); + assert.ok(seeds.includes(SEED_A), "explicit webseed preserved"); + assert.ok(seeds.includes(cdn), "hosted url also attached as a webseed"); +}); + +test("a single webseed string still works (back-compat)", () => { + const { payload } = normalizeVideoNotePayload({ + title: "Single seed", + magnet: MAGNET, + ws: SEED_A, + }); + + const seeds = extractAllWebSeeds(payload.legacyFormData.magnet); + assert.deepEqual(seeds, [SEED_A]); + // The single content `ws` field still reflects the primary seed. + assert.equal(payload.legacyFormData.ws, SEED_A); +}); + +test("duplicate webseeds are not written twice", () => { + const { payload } = normalizeVideoNotePayload({ + title: "Dupe seed", + magnet: MAGNET, + ws: [SEED_A, SEED_A, " " + SEED_A + " "], + }); + + const seeds = extractAllWebSeeds(payload.legacyFormData.magnet); + assert.equal( + seeds.filter((s) => s === SEED_A).length, + 1, + "the same webseed must appear at most once", + ); +});