diff --git a/js/app.js b/js/app.js index d880145a..0651d222 100644 --- a/js/app.js +++ b/js/app.js @@ -3483,21 +3483,279 @@ class Application { return false; } - const title = (payload?.title || "").trim(); - const url = (payload?.url || "").trim(); - const magnet = (payload?.magnet || "").trim(); - const thumbnail = (payload?.thumbnail || "").trim(); - const description = (payload?.description || "").trim(); - const ws = (payload?.ws || "").trim(); - const xs = (payload?.xs || "").trim(); + const normalizeString = (value) => { + if (typeof value === "string") { + return value.trim(); + } + if (typeof value === "number") { + return Number.isFinite(value) ? String(value).trim() : ""; + } + return String(value ?? "").trim(); + }; + + const parseNumberOrNull = (value) => { + if (value === null || value === undefined || value === "") { + return null; + } + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; + }; + + const 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; + }; + + const normalizeStringArray = (input) => { + if (!Array.isArray(input)) { + return []; + } + return input + .map((entry) => normalizeString(entry)) + .filter((entry) => Boolean(entry)); + }; + + const normalizeImetaVariant = (variant) => { + if (!variant || typeof variant !== "object") { + return null; + } + const normalized = {}; + const m = normalizeString(variant.m); + if (m) normalized.m = m; + 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 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; + }; + + const 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); + }; + + const 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); + }; + + const 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); + }; + + const 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; + } + + 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; + }; + + const rawPayload = payload && typeof payload === "object" ? payload : {}; + 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 ws = normalizeString(legacyPayload?.ws || ""); + const xs = normalizeString(legacyPayload?.xs || ""); const enableComments = - payload?.enableComments === false + legacyPayload?.enableComments === false ? false - : payload?.enableComments === true + : legacyPayload?.enableComments === true ? true : true; - const formData = { + const legacyFormData = { version: 3, title, url, @@ -3508,32 +3766,64 @@ class Application { enableComments, }; - if (!formData.title || (!formData.url && !formData.magnet)) { - this.showError("Title and at least one of URL or Magnet is required."); + 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) + ) + ) + ); + + if (!legacyFormData.title) { + this.showError("Title is required."); return false; } - if (formData.url && !/^https:\/\//i.test(formData.url)) { + if (!hasLegacySource && !hasImetaVariant) { + this.showError( + "Provide a hosted URL, magnet link, or an imeta variant before publishing." + ); + return false; + } + + if (legacyFormData.url && !/^https:\/\//i.test(legacyFormData.url)) { this.showError("Hosted video URLs must use HTTPS."); return false; } - if (formData.magnet) { - const normalizedMagnet = normalizeAndAugmentMagnet(formData.magnet, { + if (legacyFormData.magnet) { + const normalizedMagnet = normalizeAndAugmentMagnet(legacyFormData.magnet, { ws, xs, }); - formData.magnet = normalizedMagnet; + legacyFormData.magnet = normalizedMagnet; const hints = extractMagnetHints(normalizedMagnet); - formData.ws = hints.ws; - formData.xs = hints.xs; + legacyFormData.ws = hints.ws; + legacyFormData.xs = hints.xs; } else { - formData.ws = ""; - formData.xs = ""; + legacyFormData.ws = ""; + legacyFormData.xs = ""; + } + + const publishPayload = { + legacyFormData, + }; + + if (normalizedNip71) { + publishPayload.nip71 = normalizedNip71; } try { - await this.nostrService.publishVideoNote(formData, this.pubkey); + await this.nostrService.publishVideoNote(publishPayload, this.pubkey); if (typeof onSuccess === "function") { await onSuccess(); } diff --git a/js/nostr.js b/js/nostr.js index c57d4ec1..a5d20de7 100644 --- a/js/nostr.js +++ b/js/nostr.js @@ -4048,11 +4048,36 @@ class NostrClient { /** * Publish a new video using the v3 content schema. */ - async publishVideo(videoData, pubkey) { + async publishVideo(videoPayload, pubkey) { if (!pubkey) throw new Error("Not logged in to publish video."); + let nip71Metadata = null; + let videoData = videoPayload; + + if ( + videoPayload && + typeof videoPayload === "object" && + (Object.prototype.hasOwnProperty.call(videoPayload, "legacyFormData") || + Object.prototype.hasOwnProperty.call(videoPayload, "nip71")) + ) { + if (videoPayload.nip71 && typeof videoPayload.nip71 === "object") { + nip71Metadata = videoPayload.nip71; + } + if ( + videoPayload.legacyFormData && + typeof videoPayload.legacyFormData === "object" + ) { + videoData = videoPayload.legacyFormData; + } else { + videoData = {}; + } + } + if (isDevMode) { console.log("Publishing new video with data:", videoData); + if (nip71Metadata) { + console.log("Including NIP-71 metadata:", nip71Metadata); + } } const rawMagnet = typeof videoData.magnet === "string" ? videoData.magnet : ""; diff --git a/js/services/nostrService.js b/js/services/nostrService.js index 99a6fbba..6ad4a29d 100644 --- a/js/services/nostrService.js +++ b/js/services/nostrService.js @@ -356,9 +356,21 @@ export class NostrService { return filtered; } - async publishVideoNote(formData, pubkey) { - const result = await this.nostrClient.publishVideo(formData, pubkey); - this.emit("videos:published", { formData, pubkey, result }); + async publishVideoNote(publishPayload, pubkey) { + const result = await this.nostrClient.publishVideo(publishPayload, pubkey); + const detail = { pubkey, result }; + if (publishPayload && typeof publishPayload === "object") { + detail.payload = publishPayload; + if (publishPayload.legacyFormData) { + detail.legacyFormData = publishPayload.legacyFormData; + } + if (publishPayload.nip71) { + detail.nip71 = publishPayload.nip71; + } + } else { + detail.formData = publishPayload; + } + this.emit("videos:published", detail); return result; } diff --git a/js/services/r2Service.js b/js/services/r2Service.js index 3ca2d143..2e45869c 100644 --- a/js/services/r2Service.js +++ b/js/services/r2Service.js @@ -125,6 +125,73 @@ class R2Service { this.emit("uploadProgress", { fraction }); } + buildNip71MetadataForUpload(existingMetadata, { publicUrl = "", file = null } = {}) { + const base = + existingMetadata && typeof existingMetadata === "object" + ? { ...existingMetadata } + : {}; + + const cloneImetaVariant = (variant) => { + if (!variant || typeof variant !== "object") { + return { + m: "", + dim: "", + url: "", + x: "", + image: [], + fallback: [], + service: [], + autoGenerated: true, + }; + } + return { + ...variant, + image: Array.isArray(variant.image) ? [...variant.image] : [], + fallback: Array.isArray(variant.fallback) ? [...variant.fallback] : [], + service: Array.isArray(variant.service) ? [...variant.service] : [], + }; + }; + + const imetaList = Array.isArray(base.imeta) + ? base.imeta.map((variant) => cloneImetaVariant(variant)) + : []; + + if (!imetaList.length) { + imetaList.push({ + m: "", + dim: "", + url: "", + x: "", + image: [], + fallback: [], + service: [], + autoGenerated: true, + }); + } else { + const primary = imetaList[0]; + if (!Array.isArray(primary.image)) primary.image = []; + if (!Array.isArray(primary.fallback)) primary.fallback = []; + if (!Array.isArray(primary.service)) primary.service = []; + } + + const primaryVariant = imetaList[0]; + const normalizedUrl = + typeof publicUrl === "string" ? publicUrl.trim() : String(publicUrl || "").trim(); + if (normalizedUrl && (!primaryVariant.url || !String(primaryVariant.url).trim())) { + primaryVariant.url = normalizedUrl; + } + + const mimeType = + file && typeof file.type === "string" ? file.type.trim() : ""; + if (mimeType && (!primaryVariant.m || !String(primaryVariant.m).trim())) { + primaryVariant.m = mimeType; + } + + base.imeta = imetaList; + + return base; + } + populateCloudflareSettingsInputs(settings) { const data = { accountId: settings?.accountId || "", @@ -704,6 +771,14 @@ class R2Service { enableComments, }; + const mergedNip71 = this.buildNip71MetadataForUpload(metadata?.nip71, { + publicUrl, + file, + }); + if (mergedNip71 && Object.keys(mergedNip71).length) { + payload.nip71 = mergedNip71; + } + const published = await publishVideoNote(payload, { onSuccess: () => { if (typeof onReset === "function") { diff --git a/tests/nostr-publish-rejection.test.mjs b/tests/nostr-publish-rejection.test.mjs index e02ba6a2..89a18a6b 100644 --- a/tests/nostr-publish-rejection.test.mjs +++ b/tests/nostr-publish-rejection.test.mjs @@ -87,9 +87,19 @@ try { await assert.rejects( nostrClient.publishVideo( { - title: "Test video", - magnet: "magnet:?xt=urn:btih:TESTVIDEO1234567890", - mode: "live", + legacyFormData: { + title: "Test video", + magnet: "magnet:?xt=urn:btih:TESTVIDEO1234567890", + mode: "live", + }, + nip71: { + imeta: [ + { + m: "video/mp4", + url: "https://cdn.example/test.mp4", + }, + ], + }, }, "f".repeat(64) ),