mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
feat(webseed): manage multiple web seeds in the edit modal (slice 3/3)
Completes end-to-end multi-webseed support: the editor can now see, add, and remove every web seed a video carries. - magnetShared: promote normalizeWebSeedList to a shared export (videoNotePayload now imports it) and add setMagnetWebSeeds(), which replaces a magnet's COMPLETE ws= set with exactly the requested list. ensureWebSeeds is append-only, so without set-semantics a webseed a user deleted would linger in the magnet; setMagnetWebSeeds strips the existing ws= params first, making removal stick. - edit-video-modal.html: the single "Web seed (ws=)" input becomes a "Web seeds (one per line)" textarea with backup-origin help text. - EditModal: open() populates the field from extractAllWebSeeds() (every seed, not just the first). On save, when the field was manually edited the magnet's ws set is rebuilt from the field via setMagnetWebSeeds (true add/remove); otherwise existing seeds are preserved untouched. The content `ws` field still stores the primary (first) seed while the magnet carries the full set. Mutation-verified (no-strip and no-add both fail the removal/add tests); build, relevant lints (inline-styles, tokens, innerHTML), and full unit suite green. Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
1 parent
ce7ef9ef2d
commit
c62114557c
5 files changed
+145
-53
No files matched your search
@@ -676,15 +676,15 @@
|
||||
|
||||
<div class="space-y-1">
|
||||
<label for="editVideoWs" class="block text-sm font-medium text-text">
|
||||
Web seed (ws=)
|
||||
Web seeds (ws=, one per line)
|
||||
</label>
|
||||
<div class="flex flex-col gap-2 sm:flex-row">
|
||||
<input
|
||||
type="url"
|
||||
<textarea
|
||||
id="editVideoWs"
|
||||
class="flex-1 input"
|
||||
placeholder="https://cdn.example.com/video.mp4"
|
||||
/>
|
||||
rows="2"
|
||||
class="flex-1 textarea"
|
||||
placeholder="https://cdn.example.com/video.mp4 https://backup.example.com/video.mp4"
|
||||
></textarea>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-ghost focus-ring hidden text-xs font-semibold uppercase tracking-wide"
|
||||
@@ -693,6 +693,10 @@
|
||||
Edit field
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs text-muted">
|
||||
Add a backup origin so the swarm keeps streaming if the primary is
|
||||
overloaded. Each must serve the exact same file.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1">
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 "";
|
||||
|
||||
@@ -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 = "";
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user