Files
bitvid/js/magnetShared.js
thePR0M3TH3ANandClaude Opus 4.8 c62114557c 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]>
2026-06-19 08:52:11 -04:00

525 lines
13 KiB
JavaScript

import { WSS_TRACKERS } from "./constants.js";
import {
safeDecodeURIComponent,
safeDecodeURIComponentLoose,
} from "./utils/safeDecode.js";
const HEX_INFO_HASH = /^[0-9a-f]{40}$/i;
const BASE32_INFO_HASH = /^[a-z2-7]{32}$/i;
const BTIH_PREFIX = "urn:btih:";
const MAGNET_SCHEME = "magnet:";
const ENCODED_BTih_PATTERN = /xt=urn%3Abtih%3A([0-9a-z]+)/gi;
const BASE32_ALPHABET = "abcdefghijklmnopqrstuvwxyz234567";
export function safeDecodeMagnet(value) {
if (typeof value !== "string") {
return "";
}
let decoded = value.trim();
if (!decoded) {
return "";
}
for (let i = 0; i < 2; i += 1) {
if (!decoded.includes("%")) {
break;
}
const candidate = safeDecodeURIComponent(decoded);
if (!candidate || candidate === decoded) {
break;
}
decoded = candidate.trim();
}
return decoded;
}
function normalizeForComparison(value) {
if (typeof value !== "string") {
return "";
}
const trimmed = value.trim();
if (!trimmed) {
return "";
}
try {
const parsed = new URL(trimmed);
const pathname = parsed.pathname.replace(/\/?$/, "");
const normalizedPath = pathname ? pathname : "";
return (
`${parsed.protocol}//${parsed.host}${normalizedPath}${parsed.search}${parsed.hash}`
.trim()
.toLowerCase()
);
} catch (err) {
return trimmed.replace(/\/?$/, "").toLowerCase();
}
}
function decodeBase32ToHex(value) {
if (typeof value !== "string" || !value) {
return "";
}
const normalized = value.toLowerCase();
let bits = 0;
let buffer = 0;
const bytes = [];
for (const char of normalized) {
const index = BASE32_ALPHABET.indexOf(char);
if (index === -1) {
return "";
}
buffer = (buffer << 5) | index;
bits += 5;
if (bits >= 8) {
bits -= 8;
const byte = (buffer >> bits) & 0xff;
bytes.push(byte);
}
}
if (!bytes.length) {
return "";
}
return bytes.map((byte) => byte.toString(16).padStart(2, "0")).join("");
}
export function normalizeInfoHash(value) {
const trimmed = typeof value === "string" ? value.trim() : "";
if (!trimmed) {
return "";
}
if (HEX_INFO_HASH.test(trimmed)) {
return trimmed.toLowerCase();
}
if (BASE32_INFO_HASH.test(trimmed)) {
const hex = decodeBase32ToHex(trimmed);
return hex ? hex.toLowerCase() : "";
}
return "";
}
function extractInfoHashFromXt(value) {
if (typeof value !== "string") {
return "";
}
const trimmed = value.trim();
if (!trimmed) {
return "";
}
const lower = trimmed.toLowerCase();
if (!lower.startsWith(BTIH_PREFIX)) {
return "";
}
return normalizeInfoHash(trimmed.slice(BTIH_PREFIX.length));
}
export function extractBtihFromMagnet(rawValue) {
const decoded = safeDecodeMagnet(rawValue);
const { initial, isMagnet, params } = normalizeMagnetInput(decoded);
if (!initial) {
return "";
}
if (!isMagnet) {
if (initial.toLowerCase().startsWith(BTIH_PREFIX)) {
return normalizeInfoHash(initial.slice(BTIH_PREFIX.length));
}
return normalizeInfoHash(initial);
}
for (const param of params) {
if (param.lowerKey !== "xt") {
continue;
}
const candidate = param.decoded || param.value;
const normalized = extractInfoHashFromXt(candidate);
if (normalized) {
return normalized;
}
}
return "";
}
function createParam(key, value) {
const trimmedValue = typeof value === "string" ? value.trim() : "";
const decoded = safeDecodeURIComponentLoose(trimmedValue);
const comparisonBasis = decoded || trimmedValue;
return {
key,
value: trimmedValue,
lowerKey: key.toLowerCase(),
decoded,
comparison: normalizeForComparison(comparisonBasis),
};
}
export function normalizeMagnetInput(rawValue) {
const initial = typeof rawValue === "string" ? rawValue.trim() : "";
if (!initial) {
return {
initial,
canonicalValue: "",
didMutate: false,
isMagnet: false,
normalizedScheme: "",
fragment: "",
params: [],
};
}
let working = initial;
let didMutate = false;
if (HEX_INFO_HASH.test(working)) {
working = `${MAGNET_SCHEME}?xt=${BTIH_PREFIX}${working.toLowerCase()}`;
didMutate = true;
}
ENCODED_BTih_PATTERN.lastIndex = 0;
const decodedXt = working.replace(ENCODED_BTih_PATTERN, (_, hash) => {
didMutate = true;
return `xt=${BTIH_PREFIX}${hash}`;
});
working = decodedXt;
let fragment = "";
const hashIndex = working.indexOf("#");
if (hashIndex !== -1) {
fragment = working.slice(hashIndex);
working = working.slice(0, hashIndex);
}
const canonicalValue = working;
if (!/^magnet:/i.test(working)) {
return {
initial,
canonicalValue,
didMutate: didMutate || canonicalValue !== initial,
isMagnet: false,
normalizedScheme: "",
fragment: "",
params: [],
};
}
const [schemePart, queryPart = ""] = working.split("?", 2);
const normalizedScheme = MAGNET_SCHEME;
if (schemePart !== normalizedScheme) {
didMutate = true;
}
const rawParams = queryPart
.split("&")
.map((part) => part.trim())
.filter(Boolean);
const params = [];
for (const rawParam of rawParams) {
const separatorIndex = rawParam.indexOf("=");
const rawKey = separatorIndex === -1 ? rawParam : rawParam.slice(0, separatorIndex);
const rawVal = separatorIndex === -1 ? "" : rawParam.slice(separatorIndex + 1);
const key = rawKey.trim();
if (!key) {
continue;
}
const lowerKey = key.toLowerCase();
let value = rawVal.trim();
if (lowerKey === "xt" && value) {
const decoded = safeDecodeURIComponentLoose(value);
if (decoded && decoded !== value) {
value = decoded;
didMutate = true;
}
}
const parsedParam = createParam(key, value);
params.push(parsedParam);
}
return {
initial,
canonicalValue,
didMutate,
isMagnet: true,
normalizedScheme,
fragment,
params,
};
}
export function buildMagnetUri(normalizedScheme, params, fragment) {
const queryString = params
.map(({ key, value }) => (value ? `${key}=${value}` : key))
.join("&");
return `${normalizedScheme}${queryString ? `?${queryString}` : ""}${fragment || ""}`;
}
function appendUniqueParam(params, key, value) {
if (!Array.isArray(params)) {
return false;
}
const candidate = createParam(key, value);
if (!candidate.value && candidate.value !== "") {
return false;
}
if (candidate.comparison) {
const exists = params.some(
(param) => param.lowerKey === candidate.lowerKey && param.comparison === candidate.comparison
);
if (exists) {
return false;
}
} else {
const exists = params.some(
(param) => param.lowerKey === candidate.lowerKey && param.value === candidate.value
);
if (exists) {
return false;
}
}
params.push(candidate);
return true;
}
export function ensureTrackers(params, trackers = WSS_TRACKERS) {
let didMutate = false;
for (const tracker of trackers) {
if (typeof tracker !== "string") {
continue;
}
const trimmedTracker = tracker.trim();
if (!trimmedTracker) {
continue;
}
if (!/^wss:\/\//i.test(trimmedTracker)) {
continue;
}
if (appendUniqueParam(params, "tr", trimmedTracker)) {
didMutate = true;
}
}
return didMutate;
}
function sanitizeHttpUrl(candidate) {
if (typeof candidate !== "string") {
return "";
}
const trimmed = candidate.trim();
if (!trimmed) {
return "";
}
try {
const parsed = new URL(trimmed);
if (parsed.protocol === "http:" || parsed.protocol === "https:") {
return parsed.toString();
}
} catch (err) {
return "";
}
return "";
}
export function ensureTorrentHint(params, candidate, { requireHttp = true } = {}) {
if (requireHttp) {
const sanitized = sanitizeHttpUrl(candidate);
if (!sanitized) {
return false;
}
return appendUniqueParam(params, "xs", sanitized);
}
if (typeof candidate !== "string") {
return false;
}
const trimmed = candidate.trim();
if (!trimmed) {
return false;
}
return appendUniqueParam(params, "xs", trimmed);
}
export function resolveAppProtocol(explicitProtocol) {
if (typeof explicitProtocol === "string" && explicitProtocol.trim()) {
return explicitProtocol.trim().toLowerCase();
}
if (typeof window !== "undefined" && window.location?.protocol) {
return window.location.protocol.toLowerCase();
}
return "https:";
}
export function ensureWebSeeds(
params,
seeds,
{ allowHttp = false, allowUnparsed = false, logger } = {}
) {
const list = Array.isArray(seeds)
? seeds
: typeof seeds === "string"
? [seeds]
: [];
let didMutate = false;
for (const seed of list) {
if (typeof seed !== "string") {
continue;
}
const trimmedSeed = seed.trim();
if (!trimmedSeed) {
continue;
}
let finalValue = trimmedSeed;
let parsed;
try {
parsed = new URL(trimmedSeed);
const protocol = parsed.protocol;
if (protocol === "https:") {
finalValue = parsed.toString();
} else if (protocol === "http:") {
if (!allowHttp) {
if (typeof logger === "function") {
logger(`[normalizeAndAugmentMagnet] Skipping insecure web seed: ${trimmedSeed}`);
}
continue;
}
finalValue = parsed.toString();
} else if (!allowUnparsed) {
continue;
}
} catch (err) {
if (!allowUnparsed) {
continue;
}
}
if (appendUniqueParam(params, "ws", finalValue)) {
didMutate = true;
}
}
return didMutate;
}
export function extractMagnetHints(rawValue) {
const { params } = normalizeMagnetInput(rawValue);
const hints = { ws: "", xs: "" };
for (const param of params) {
if (param.lowerKey === "ws" && !hints.ws) {
hints.ws = param.decoded || param.value;
} else if (param.lowerKey === "xs" && !hints.xs) {
hints.xs = param.decoded || param.value;
}
if (hints.ws && hints.xs) {
break;
}
}
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;
}
/**
* 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);
}