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",