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