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); }