diff --git a/components/edit-video-modal.html b/components/edit-video-modal.html index 91cd83f0..1a5c5ffb 100644 --- a/components/edit-video-modal.html +++ b/components/edit-video-modal.html @@ -676,15 +676,15 @@
- + rows="2" + class="flex-1 textarea" + placeholder="https://cdn.example.com/video.mp4 https://backup.example.com/video.mp4" + >
+

+ Add a backup origin so the swarm keeps streaming if the primary is + overloaded. Each must serve the exact same file. +

diff --git a/js/magnetShared.js b/js/magnetShared.js index 01f3b4b3..983561b5 100644 --- a/js/magnetShared.js +++ b/js/magnetShared.js @@ -456,3 +456,69 @@ export function extractAllWebSeeds(rawValue) { } return seeds; } + +/** + * Normalize a web seed field into an ordered, deduped list of trimmed URLs. + * Accepts an array (e.g. [primaryUrl, textareaValue]) or a string, and splits + * every entry on newlines/commas so a single multi-line textarea expands into + * the full set. Lets a video declare multiple independent webseeds instead of + * collapsing to one. + * + * @param {string|string[]} value + * @returns {string[]} + */ +export function normalizeWebSeedList(value) { + const out = []; + const seen = new Set(); + const push = (entry) => { + if (typeof entry !== "string") { + return; + } + for (const piece of entry.split(/[\r\n,]+/)) { + const trimmed = piece.trim(); + if (!trimmed) { + continue; + } + const key = trimmed.toLowerCase(); + if (seen.has(key)) { + continue; + } + seen.add(key); + out.push(trimmed); + } + }; + if (Array.isArray(value)) { + value.forEach(push); + } else if (typeof value === "string") { + push(value); + } + return out; +} + +/** + * Replace the complete set of web seeds (`ws=`) on a magnet with exactly the + * provided list. Unlike {@link ensureWebSeeds} (append-only), this first drops + * every existing `ws=` param, giving the edit form true add/remove semantics — + * a seed the user deleted from the list is actually removed from the magnet + * instead of lingering. + * + * @param {string} rawValue + * @param {string|string[]} seeds + * @param {{ allowHttp?: boolean, logger?: Function }} [options] + * @returns {string} The rebuilt magnet (or the canonical input when not a magnet). + */ +export function setMagnetWebSeeds(rawValue, seeds, { allowHttp = false, logger } = {}) { + const { initial, canonicalValue, isMagnet, normalizedScheme, fragment, params } = + normalizeMagnetInput(rawValue); + if (!initial) { + return ""; + } + if (!isMagnet) { + return canonicalValue; + } + const kept = params.filter((param) => param.lowerKey !== "ws"); + params.length = 0; + params.push(...kept); + ensureWebSeeds(params, normalizeWebSeedList(seeds), { allowHttp, logger }); + return buildMagnetUri(normalizedScheme, params, fragment); +} diff --git a/js/services/videoNotePayload.js b/js/services/videoNotePayload.js index bb6dc370..b06e1e77 100644 --- a/js/services/videoNotePayload.js +++ b/js/services/videoNotePayload.js @@ -1,4 +1,4 @@ -import { extractMagnetHints } from "../magnetShared.js"; +import { extractMagnetHints, normalizeWebSeedList } from "../magnetShared.js"; import { normalizeAndAugmentMagnet } from "../magnetUtils.js"; import { infoHashFromMagnet } from "../magnets.js"; import { @@ -34,47 +34,6 @@ 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(); - // Each entry may itself carry several URLs separated by newlines/commas (a - // textarea field), so split every entry regardless of whether the caller - // passed an array or a single string. - const push = (entry) => { - if (typeof entry !== "string") { - return; - } - for (const piece of entry.split(/[\r\n,]+/)) { - const trimmed = piece.trim(); - if (!trimmed) { - continue; - } - const key = trimmed.toLowerCase(); - if (seen.has(key)) { - continue; - } - seen.add(key); - out.push(trimmed); - } - }; - if (Array.isArray(value)) { - value.forEach(push); - } else if (typeof value === "string") { - push(value); - } - return out; -} - function normalizeSha256Hex(value) { if (value === undefined || value === null) { return ""; diff --git a/js/ui/components/EditModal.js b/js/ui/components/EditModal.js index 90e2bd80..d8fa7f55 100644 --- a/js/ui/components/EditModal.js +++ b/js/ui/components/EditModal.js @@ -1,4 +1,9 @@ -import { extractMagnetHints } from "../../magnetShared.js"; +import { + extractMagnetHints, + extractAllWebSeeds, + normalizeWebSeedList, + setMagnetWebSeeds, +} from "../../magnetShared.js"; import { normalizeAndAugmentMagnet } from "../../magnetUtils.js"; import { createModalAccessibility } from "./modalAccessibility.js"; import { Nip71FormManager } from "./nip71FormManager.js"; @@ -413,7 +418,13 @@ export class EditModal { const magnetSource = video.magnet || video.rawMagnet || ""; const magnetHints = extractMagnetHints(magnetSource); - const effectiveWs = video.ws || magnetHints.ws || ""; + // Surface every web seed the magnet carries (one per line), not just the + // first, so the editor can see and manage backup origins without silently + // dropping them. + const magnetWebSeeds = extractAllWebSeeds(magnetSource); + const effectiveWs = magnetWebSeeds.length + ? magnetWebSeeds.join("\n") + : video.ws || magnetHints.ws || ""; const effectiveXs = video.xs || magnetHints.xs || ""; const enableCommentsValue = typeof video.enableComments === "boolean" ? video.enableComments : true; @@ -1039,13 +1050,23 @@ export class EditModal { } if (finalMagnet) { + // When the web seed list was manually edited, replace the magnet's full + // ws= set with exactly what's in the field — this is what makes removal + // actually stick (append-only normalization would leave deleted seeds + // behind). Otherwise leave whatever seeds the magnet already carries + // (including those from a freshly pasted magnet) untouched. + if (wsWasManuallyEdited) { + finalMagnet = setMagnetWebSeeds(finalMagnet, normalizeWebSeedList(newWs)); + } const result = normalizeAndAugmentMagnet(finalMagnet, { - webSeed: finalWs, + webSeed: [], xs: finalXs, }); finalMagnet = result.magnet; + // The content `ws` field stores the primary (first) seed; the magnet + // carries the complete set. + finalWs = extractAllWebSeeds(finalMagnet)[0] || ""; const hints = extractMagnetHints(finalMagnet); - finalWs = hints.ws; finalXs = hints.xs; } else { finalWs = ""; diff --git a/tests/multi-webseed.test.mjs b/tests/multi-webseed.test.mjs index 79001e05..aa1a6069 100644 --- a/tests/multi-webseed.test.mjs +++ b/tests/multi-webseed.test.mjs @@ -8,7 +8,11 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { extractAllWebSeeds } from "../js/magnetShared.js"; +import { + extractAllWebSeeds, + normalizeWebSeedList, + setMagnetWebSeeds, +} from "../js/magnetShared.js"; import { normalizeVideoNotePayload } from "../js/services/videoNotePayload.js"; const BTIH = "a".repeat(40); @@ -100,6 +104,44 @@ test("upload-modal shape ([primaryUrl, textarea]) yields primary-first, all spli assert.equal(payload.legacyFormData.ws, cdn); }); +test("setMagnetWebSeeds adds seeds to a magnet that has none", () => { + const out = setMagnetWebSeeds(MAGNET, [SEED_A, SEED_B]); + assert.deepEqual(extractAllWebSeeds(out), [SEED_A, SEED_B]); +}); + +test("setMagnetWebSeeds replaces the full set — a removed seed is gone", () => { + const withBoth = `${MAGNET}&ws=${encodeURIComponent(SEED_A)}&ws=${encodeURIComponent( + SEED_B, + )}`; + // Edit the list down to just SEED_A: SEED_B must not linger. + const out = setMagnetWebSeeds(withBoth, [SEED_A]); + const seeds = extractAllWebSeeds(out); + assert.deepEqual(seeds, [SEED_A]); + assert.ok(!seeds.includes(SEED_B), "removed webseed must be dropped from the magnet"); +}); + +test("setMagnetWebSeeds with an empty list strips every webseed", () => { + const withBoth = `${MAGNET}&ws=${encodeURIComponent(SEED_A)}&ws=${encodeURIComponent( + SEED_B, + )}`; + assert.deepEqual(extractAllWebSeeds(setMagnetWebSeeds(withBoth, [])), []); +}); + +test("setMagnetWebSeeds preserves the info hash and accepts a multi-line string", () => { + const out = setMagnetWebSeeds(MAGNET, `${SEED_A}\n${SEED_B}`); + assert.ok(out.includes(`btih:${BTIH}`), "info hash must survive the rebuild"); + assert.deepEqual(extractAllWebSeeds(out), [SEED_A, SEED_B]); +}); + +test("normalizeWebSeedList splits arrays and strings, deduped and ordered", () => { + assert.deepEqual(normalizeWebSeedList([SEED_A, `${SEED_B}\n${SEED_A}`]), [ + SEED_A, + SEED_B, + ]); + assert.deepEqual(normalizeWebSeedList(`${SEED_A}, ${SEED_B}`), [SEED_A, SEED_B]); + assert.deepEqual(normalizeWebSeedList(""), []); +}); + test("duplicate webseeds are not written twice", () => { const { payload } = normalizeVideoNotePayload({ title: "Dupe seed",