mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 16:02:26 +00:00
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]>
473 lines
14 KiB
JavaScript
473 lines
14 KiB
JavaScript
import { extractMagnetHints, normalizeWebSeedList } from "../magnetShared.js";
|
|
import { normalizeAndAugmentMagnet } from "../magnetUtils.js";
|
|
import { infoHashFromMagnet } from "../magnets.js";
|
|
import {
|
|
deriveStoragePointerFromUrl,
|
|
normalizeStoragePointer,
|
|
} from "../utils/storagePointer.js";
|
|
|
|
export const VIDEO_NOTE_ERROR_CODES = {
|
|
MISSING_TITLE: "missing_title",
|
|
MISSING_SOURCE: "missing_source",
|
|
INVALID_URL_PROTOCOL: "invalid_url_protocol",
|
|
};
|
|
|
|
export const VIDEO_NOTE_ERROR_MESSAGES = {
|
|
[VIDEO_NOTE_ERROR_CODES.MISSING_TITLE]: "Title is required.",
|
|
[VIDEO_NOTE_ERROR_CODES.MISSING_SOURCE]:
|
|
"Provide a hosted URL, magnet link, or an imeta variant before publishing.",
|
|
[VIDEO_NOTE_ERROR_CODES.INVALID_URL_PROTOCOL]:
|
|
"Hosted video URLs must use HTTPS.",
|
|
};
|
|
|
|
export function getVideoNoteErrorMessage(code) {
|
|
return VIDEO_NOTE_ERROR_MESSAGES[code] || "Unable to submit video. Please try again.";
|
|
}
|
|
|
|
function normalizeString(value) {
|
|
if (typeof value === "string") {
|
|
return value.trim();
|
|
}
|
|
if (typeof value === "number") {
|
|
return Number.isFinite(value) ? String(value).trim() : "";
|
|
}
|
|
return String(value ?? "").trim();
|
|
}
|
|
|
|
function normalizeSha256Hex(value) {
|
|
if (value === undefined || value === null) {
|
|
return "";
|
|
}
|
|
const normalized = normalizeString(value).toLowerCase();
|
|
if (!normalized) {
|
|
return "";
|
|
}
|
|
return /^[0-9a-f]{64}$/.test(normalized) ? normalized : "";
|
|
}
|
|
|
|
function normalizeInfoHash(value) {
|
|
if (value === undefined || value === null) {
|
|
return "";
|
|
}
|
|
const normalized = infoHashFromMagnet(normalizeString(value));
|
|
return typeof normalized === "string" && normalized ? normalized : "";
|
|
}
|
|
|
|
function parseNumberOrNull(value) {
|
|
if (value === null || value === undefined || value === "") {
|
|
return null;
|
|
}
|
|
const parsed = Number(value);
|
|
return Number.isFinite(parsed) ? parsed : null;
|
|
}
|
|
|
|
function parseUnixSeconds(value) {
|
|
if (value === null || value === undefined || value === "") {
|
|
return null;
|
|
}
|
|
if (typeof value === "number" && Number.isFinite(value)) {
|
|
const normalized = value > 1e12 ? value / 1000 : value;
|
|
return Math.floor(normalized);
|
|
}
|
|
const trimmed = normalizeString(value);
|
|
if (!trimmed) {
|
|
return null;
|
|
}
|
|
const numeric = Number(trimmed);
|
|
if (Number.isFinite(numeric)) {
|
|
const normalized = numeric > 1e12 ? numeric / 1000 : numeric;
|
|
return Math.floor(normalized);
|
|
}
|
|
const timestamp = Date.parse(trimmed);
|
|
if (!Number.isNaN(timestamp)) {
|
|
return Math.floor(timestamp / 1000);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function normalizeStringArray(input) {
|
|
if (!Array.isArray(input)) {
|
|
return [];
|
|
}
|
|
return input
|
|
.map((entry) => normalizeString(entry))
|
|
.filter((entry) => Boolean(entry));
|
|
}
|
|
|
|
function normalizeBooleanFlag(value, defaultValue = false) {
|
|
if (value === true) {
|
|
return true;
|
|
}
|
|
if (value === false) {
|
|
return false;
|
|
}
|
|
return Boolean(defaultValue);
|
|
}
|
|
|
|
function normalizeImetaVariant(variant) {
|
|
if (!variant || typeof variant !== "object") {
|
|
return null;
|
|
}
|
|
const normalized = {};
|
|
const m = normalizeString(variant.m);
|
|
if (m) normalized.m = m.toLowerCase();
|
|
const dim = normalizeString(variant.dim);
|
|
if (dim) normalized.dim = dim;
|
|
const url = normalizeString(variant.url);
|
|
if (url) normalized.url = url;
|
|
const x = normalizeString(variant.x);
|
|
if (x) normalized.x = x;
|
|
const durationValue = parseNumberOrNull(variant.duration);
|
|
if (durationValue !== null && durationValue >= 0) {
|
|
normalized.duration = durationValue;
|
|
} else {
|
|
const durationString = normalizeString(variant.duration);
|
|
if (durationString) {
|
|
normalized.duration = durationString;
|
|
}
|
|
}
|
|
const bitrateValue = parseNumberOrNull(variant.bitrate);
|
|
if (bitrateValue !== null && bitrateValue >= 0) {
|
|
normalized.bitrate = bitrateValue;
|
|
} else {
|
|
const bitrateString = normalizeString(variant.bitrate);
|
|
if (bitrateString) {
|
|
normalized.bitrate = bitrateString;
|
|
}
|
|
}
|
|
const image = normalizeStringArray(variant.image);
|
|
if (image.length) normalized.image = image;
|
|
const fallback = normalizeStringArray(variant.fallback);
|
|
if (fallback.length) normalized.fallback = fallback;
|
|
const service = normalizeStringArray(variant.service);
|
|
if (service.length) normalized.service = service;
|
|
if (variant.autoGenerated === true) {
|
|
normalized.autoGenerated = true;
|
|
}
|
|
|
|
return Object.keys(normalized).length ? normalized : null;
|
|
}
|
|
|
|
function normalizeSegments(segments) {
|
|
if (!Array.isArray(segments)) {
|
|
return [];
|
|
}
|
|
return segments
|
|
.map((segment) => {
|
|
if (!segment || typeof segment !== "object") {
|
|
return null;
|
|
}
|
|
const startRaw = segment.start;
|
|
const endRaw = segment.end;
|
|
const start = parseNumberOrNull(startRaw);
|
|
const end = parseNumberOrNull(endRaw);
|
|
const normalizedSegment = {};
|
|
if (start !== null) {
|
|
normalizedSegment.start = start;
|
|
} else {
|
|
const startString = normalizeString(startRaw);
|
|
if (startString) {
|
|
normalizedSegment.start = startString;
|
|
}
|
|
}
|
|
if (end !== null) {
|
|
normalizedSegment.end = end;
|
|
} else {
|
|
const endString = normalizeString(endRaw);
|
|
if (endString) {
|
|
normalizedSegment.end = endString;
|
|
}
|
|
}
|
|
const title = normalizeString(segment.title);
|
|
if (title) {
|
|
normalizedSegment.title = title;
|
|
}
|
|
const thumbnail = normalizeString(segment.thumbnail);
|
|
if (thumbnail) {
|
|
normalizedSegment.thumbnail = thumbnail;
|
|
}
|
|
return Object.keys(normalizedSegment).length ? normalizedSegment : null;
|
|
})
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function normalizeTextTracks(tracks) {
|
|
if (!Array.isArray(tracks)) {
|
|
return [];
|
|
}
|
|
return tracks
|
|
.map((track) => {
|
|
if (!track || typeof track !== "object") {
|
|
return null;
|
|
}
|
|
const normalizedTrack = {};
|
|
const url = normalizeString(track.url);
|
|
if (url) normalizedTrack.url = url;
|
|
const type = normalizeString(track.type);
|
|
if (type) normalizedTrack.type = type;
|
|
const language = normalizeString(track.language);
|
|
if (language) normalizedTrack.language = language;
|
|
return Object.keys(normalizedTrack).length ? normalizedTrack : null;
|
|
})
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function normalizeParticipants(participants) {
|
|
if (!Array.isArray(participants)) {
|
|
return [];
|
|
}
|
|
return participants
|
|
.map((participant) => {
|
|
if (!participant || typeof participant !== "object") {
|
|
return null;
|
|
}
|
|
const normalizedParticipant = {};
|
|
const pubkey = normalizeString(participant.pubkey);
|
|
if (pubkey) normalizedParticipant.pubkey = pubkey;
|
|
const relay = normalizeString(participant.relay);
|
|
if (relay) normalizedParticipant.relay = relay;
|
|
return Object.keys(normalizedParticipant).length
|
|
? normalizedParticipant
|
|
: null;
|
|
})
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function normalizeNip71Metadata(rawMetadata) {
|
|
if (!rawMetadata || typeof rawMetadata !== "object") {
|
|
return null;
|
|
}
|
|
|
|
const normalized = {};
|
|
|
|
if (rawMetadata.kind !== undefined) {
|
|
const kindNumber = parseNumberOrNull(rawMetadata.kind);
|
|
if (kindNumber !== null) {
|
|
normalized.kind = kindNumber;
|
|
} else {
|
|
const kindString = normalizeString(rawMetadata.kind);
|
|
if (kindString) {
|
|
normalized.kind = kindString;
|
|
}
|
|
}
|
|
}
|
|
|
|
const summary = normalizeString(rawMetadata.summary);
|
|
if (summary) {
|
|
normalized.summary = summary;
|
|
}
|
|
|
|
const publishedAt = parseUnixSeconds(rawMetadata.publishedAt);
|
|
if (publishedAt !== null) {
|
|
normalized.publishedAt = publishedAt;
|
|
}
|
|
|
|
const alt = normalizeString(rawMetadata.alt);
|
|
if (alt) {
|
|
normalized.alt = alt;
|
|
}
|
|
|
|
const duration = parseNumberOrNull(rawMetadata.duration);
|
|
if (duration !== null) {
|
|
normalized.duration = duration;
|
|
}
|
|
|
|
const contentWarning = normalizeString(rawMetadata.contentWarning);
|
|
if (contentWarning) {
|
|
normalized.contentWarning = contentWarning;
|
|
}
|
|
|
|
const imeta = Array.isArray(rawMetadata.imeta)
|
|
? rawMetadata.imeta
|
|
.map((variant) => normalizeImetaVariant(variant))
|
|
.filter(Boolean)
|
|
: [];
|
|
if (imeta.length) {
|
|
normalized.imeta = imeta;
|
|
if (normalized.duration === undefined) {
|
|
const variantDurations = imeta
|
|
.map((variant) => parseNumberOrNull(variant.duration))
|
|
.filter((value) => value !== null && value >= 0);
|
|
if (variantDurations.length) {
|
|
normalized.duration = Math.max(...variantDurations);
|
|
}
|
|
}
|
|
}
|
|
|
|
const textTracks = normalizeTextTracks(rawMetadata.textTracks);
|
|
if (textTracks.length) {
|
|
normalized.textTracks = textTracks;
|
|
}
|
|
|
|
const segments = normalizeSegments(rawMetadata.segments);
|
|
if (segments.length) {
|
|
normalized.segments = segments;
|
|
}
|
|
|
|
const hashtags = normalizeStringArray(rawMetadata.hashtags);
|
|
if (hashtags.length) {
|
|
normalized.hashtags = hashtags;
|
|
}
|
|
|
|
const participants = normalizeParticipants(rawMetadata.participants);
|
|
if (participants.length) {
|
|
normalized.participants = participants;
|
|
}
|
|
|
|
const references = normalizeStringArray(rawMetadata.references);
|
|
if (references.length) {
|
|
normalized.references = references;
|
|
}
|
|
|
|
return Object.keys(normalized).length ? normalized : null;
|
|
}
|
|
|
|
export function normalizeVideoNotePayload(input) {
|
|
const rawPayload = input && typeof input === "object" ? input : {};
|
|
const legacyPayload =
|
|
rawPayload.legacyFormData && typeof rawPayload.legacyFormData === "object"
|
|
? rawPayload.legacyFormData
|
|
: rawPayload;
|
|
|
|
const rawNip71 =
|
|
(rawPayload.nip71 && typeof rawPayload.nip71 === "object"
|
|
? rawPayload.nip71
|
|
: null) ||
|
|
(legacyPayload.nip71 && typeof legacyPayload.nip71 === "object"
|
|
? legacyPayload.nip71
|
|
: null);
|
|
|
|
const title = normalizeString(legacyPayload?.title || "");
|
|
const url = normalizeString(legacyPayload?.url || "");
|
|
const magnet = normalizeString(legacyPayload?.magnet || "");
|
|
const thumbnail = normalizeString(legacyPayload?.thumbnail || "");
|
|
const description = normalizeString(legacyPayload?.description || "");
|
|
const wsValues = normalizeWebSeedList(legacyPayload?.ws);
|
|
const xs = normalizeString(legacyPayload?.xs || "");
|
|
const storagePointer = normalizeStoragePointer(
|
|
legacyPayload?.storagePointer || legacyPayload?.storage || ""
|
|
);
|
|
const storageProvider = normalizeString(
|
|
legacyPayload?.storageProvider || ""
|
|
);
|
|
const infoHash = normalizeInfoHash(legacyPayload?.infoHash || "");
|
|
const fileSha256 = normalizeSha256Hex(legacyPayload?.fileSha256 || "");
|
|
const originalFileSha256 = normalizeSha256Hex(
|
|
legacyPayload?.originalFileSha256 || ""
|
|
);
|
|
const rawMode = normalizeString(legacyPayload?.mode || "");
|
|
const normalizedMode = rawMode && rawMode.toLowerCase() === "dev" ? "dev" : "live";
|
|
const enableComments = normalizeBooleanFlag(legacyPayload?.enableComments, true);
|
|
const rawIsNsfw = normalizeBooleanFlag(legacyPayload?.isNsfw, false);
|
|
const rawIsForKids = normalizeBooleanFlag(legacyPayload?.isForKids, false);
|
|
const isNsfw = rawIsNsfw;
|
|
const isForKids = rawIsNsfw ? false : rawIsForKids;
|
|
const isPrivate = normalizeBooleanFlag(legacyPayload?.isPrivate, false);
|
|
|
|
const legacyFormData = {
|
|
version: 3,
|
|
title,
|
|
url,
|
|
magnet,
|
|
thumbnail,
|
|
description,
|
|
mode: normalizedMode,
|
|
enableComments,
|
|
isNsfw,
|
|
isForKids,
|
|
};
|
|
|
|
if (isPrivate) {
|
|
legacyFormData.isPrivate = true;
|
|
}
|
|
|
|
const normalizedNip71 = normalizeNip71Metadata(rawNip71);
|
|
|
|
const hasLegacySource = Boolean(legacyFormData.url || legacyFormData.magnet);
|
|
const hasImetaVariant = Boolean(
|
|
normalizedNip71?.imeta?.some((variant) =>
|
|
Boolean(
|
|
(variant.url && variant.url.length) ||
|
|
(variant.m && variant.m.length) ||
|
|
(variant.x && variant.x.length) ||
|
|
(variant.dim && variant.dim.length) ||
|
|
(Array.isArray(variant.image) && variant.image.length > 0) ||
|
|
(Array.isArray(variant.fallback) && variant.fallback.length > 0) ||
|
|
(Array.isArray(variant.service) && variant.service.length > 0) ||
|
|
(variant.duration !== undefined && variant.duration !== null && variant.duration !== "") ||
|
|
(variant.bitrate !== undefined && variant.bitrate !== null && variant.bitrate !== ""),
|
|
),
|
|
),
|
|
);
|
|
|
|
const errors = [];
|
|
|
|
if (!legacyFormData.title) {
|
|
errors.push(VIDEO_NOTE_ERROR_CODES.MISSING_TITLE);
|
|
}
|
|
|
|
if (!hasLegacySource && !hasImetaVariant) {
|
|
errors.push(VIDEO_NOTE_ERROR_CODES.MISSING_SOURCE);
|
|
}
|
|
|
|
if (legacyFormData.url && !/^https:\/\//i.test(legacyFormData.url)) {
|
|
errors.push(VIDEO_NOTE_ERROR_CODES.INVALID_URL_PROTOCOL);
|
|
}
|
|
|
|
if (legacyFormData.magnet) {
|
|
const wsList = [...wsValues];
|
|
if (legacyFormData.url && /^https?:\/\//i.test(legacyFormData.url)) {
|
|
wsList.push(legacyFormData.url);
|
|
}
|
|
|
|
const result = normalizeAndAugmentMagnet(legacyFormData.magnet, {
|
|
webSeed: wsList,
|
|
xs,
|
|
});
|
|
legacyFormData.magnet = result.magnet;
|
|
const hints = extractMagnetHints(result.magnet);
|
|
legacyFormData.ws = hints.ws || "";
|
|
legacyFormData.xs = hints.xs || "";
|
|
} else {
|
|
legacyFormData.ws = "";
|
|
legacyFormData.xs = "";
|
|
}
|
|
|
|
const derivedStoragePointer =
|
|
storagePointer ||
|
|
deriveStoragePointerFromUrl(
|
|
legacyFormData.url,
|
|
storageProvider || "url"
|
|
);
|
|
if (derivedStoragePointer) {
|
|
legacyFormData.storagePointer = derivedStoragePointer;
|
|
}
|
|
|
|
const derivedInfoHash =
|
|
infoHash || normalizeInfoHash(legacyFormData.magnet) || "";
|
|
if (derivedInfoHash) {
|
|
legacyFormData.infoHash = derivedInfoHash;
|
|
}
|
|
|
|
if (fileSha256) {
|
|
legacyFormData.fileSha256 = fileSha256;
|
|
}
|
|
|
|
if (originalFileSha256) {
|
|
legacyFormData.originalFileSha256 = originalFileSha256;
|
|
}
|
|
|
|
const publishPayload = {
|
|
legacyFormData,
|
|
};
|
|
|
|
if (normalizedNip71) {
|
|
publishPayload.nip71 = normalizedNip71;
|
|
}
|
|
|
|
return {
|
|
payload: publishPayload,
|
|
errors,
|
|
};
|
|
}
|