From 62fe113bf4cd8f47dec4ec5b57d32bc8da0ebdf4 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 4 Oct 2025 21:14:41 -0400 Subject: [PATCH] Add NIP-71 video builder and dual publish workflow --- docs/nostr-event-schemas.md | 12 + js/constants.js | 22 ++ js/nostr.js | 576 +++++++++++++++++++++++++++-------- js/nostrEventSchemas.js | 26 ++ js/services/nostrService.js | 23 +- tests/nip71-builder.test.mjs | 164 ++++++++++ 6 files changed, 695 insertions(+), 128 deletions(-) create mode 100644 tests/nip71-builder.test.mjs diff --git a/docs/nostr-event-schemas.md b/docs/nostr-event-schemas.md index a52e034b..f1c1e049 100644 --- a/docs/nostr-event-schemas.md +++ b/docs/nostr-event-schemas.md @@ -47,6 +47,8 @@ read/write split. | --- | --- | --- | --- | | Video post (`NOTE_TYPES.VIDEO_POST`) | `30078` | `['t','video']`, `['d', ]` plus optional schema append tags | JSON payload using Content Schema v3 (`version`, `title`, optional `url`, `magnet`, `thumbnail`, `description`, `mode`, `videoRootId`, `deleted`, `isPrivate`, `enableComments`, `ws`, `xs`) | | NIP-94 mirror (`NOTE_TYPES.VIDEO_MIRROR`) | `1063` | Tags forwarded from `publishVideo` (URL, mime type, thumbnail, alt text, magnet) | Plain text alt description | +| NIP-71 video (`NOTE_TYPES.NIP71_VIDEO`) | `21` | `['title', ]`, optional `['published_at', <unix seconds>]`, optional `['alt', <text>]`, repeated `['imeta', ...]` entries describing NIP-92 media variants, optional `['duration', <seconds>]`, repeated `['text-track', <url>, <kind>, <language>]`, optional `['content-warning', <reason>]`, repeated `['segment', <start>, <end>, <title>, <thumbnail>]`, repeated hashtags `['t', <tag>]`, repeated participants `['p', <pubkey>, <relay?>]`, repeated references `['r', <url>]` | Plain text summary carried in the content field. Publishing is gated by the `FEATURE_PUBLISH_NIP71` runtime flag while the rollout stabilizes. | +| NIP-71 short video (`NOTE_TYPES.NIP71_SHORT_VIDEO`) | `22` | Same as `NOTE_TYPES.NIP71_VIDEO`; the kind differentiates short-form presentations. | Plain text summary; gated by `FEATURE_PUBLISH_NIP71`. | | Relay list (`NOTE_TYPES.RELAY_LIST`) | `10002` | Repeating `['r', <relay url>]` tags, optionally with a marker of `'read'` or `'write'` to scope the relay; marker omitted for read/write relays | Empty content | | View counter (`NOTE_TYPES.VIEW_EVENT`) | `WATCH_HISTORY_KIND` (default `30079`, clients also read legacy `30078`) | Canonical tag set: `['t','view']`, a pointer tag (`['e', <eventId>]` or `['a', <address>]`), and a stable dedupe tag `['d', <view identifier>]`, with optional `['session','true']` when a session actor signs; schema overrides may append extra tags. `['video', ...]` is supported for legacy overrides only. | Optional plaintext message | | Watch history index (`NOTE_TYPES.WATCH_HISTORY_INDEX`) | `WATCH_HISTORY_KIND` (default `30079`, clients also read legacy `30078`) | `['d', WATCH_HISTORY_LIST_IDENTIFIER]`, `['snapshot', <id>]`, `['chunks', <total>]`, repeated `['a', <chunk address>]` pointers plus schema append tags | JSON payload `{ snapshot, totalChunks }` (may be empty when using tags only) | @@ -61,6 +63,16 @@ If you introduce a new Nostr feature, add its schema to `js/nostrEventSchemas.js` so that the catalogue stays complete and so existing builders inherit the same debugging knobs. +### NIP-71 rollout + +BitVid now emits a paired NIP-71 event (kind `21` for long-form, `22` for short +form) whenever the `FEATURE_PUBLISH_NIP71` flag is enabled. The builder converts +structured upload metadata—image variants, captions, segments, hashtags, and +participant pointers—into canonical tags so other clients can discover the same +video. The legacy kind `30078` post remains the source of truth during the +transition, and schema overrides can adjust either kind if relays need +experimentation. + ### Watch history identifiers The encrypted watch history pipeline is gated by the `FEATURE_WATCH_HISTORY_V2` diff --git a/js/constants.js b/js/constants.js index c13c4ab2..069333a0 100644 --- a/js/constants.js +++ b/js/constants.js @@ -3,6 +3,7 @@ const DEFAULT_FLAGS = Object.freeze({ ACCEPT_LEGACY_V1: true, // accept v1 magnet-only notes VIEW_FILTER_INCLUDE_LEGACY_VIDEO: false, FEATURE_WATCH_HISTORY_V2: false, + FEATURE_PUBLISH_NIP71: false, WSS_TRACKERS: Object.freeze([ "wss://tracker.openwebtorrent.com", "wss://tracker.fastcast.nz", @@ -23,6 +24,7 @@ const runtimeFlags = (() => { VIEW_FILTER_INCLUDE_LEGACY_VIDEO: DEFAULT_FLAGS.VIEW_FILTER_INCLUDE_LEGACY_VIDEO, FEATURE_WATCH_HISTORY_V2: DEFAULT_FLAGS.FEATURE_WATCH_HISTORY_V2, + FEATURE_PUBLISH_NIP71: DEFAULT_FLAGS.FEATURE_PUBLISH_NIP71, WSS_TRACKERS: [...DEFAULT_FLAGS.WSS_TRACKERS], }; if (globalScope) { @@ -108,6 +110,11 @@ export let FEATURE_WATCH_HISTORY_V2 = coerceBoolean( DEFAULT_FLAGS.FEATURE_WATCH_HISTORY_V2 ); +export let FEATURE_PUBLISH_NIP71 = coerceBoolean( + runtimeFlags.FEATURE_PUBLISH_NIP71, + DEFAULT_FLAGS.FEATURE_PUBLISH_NIP71 +); + export let WSS_TRACKERS = freezeTrackers( sanitizeTrackerList(runtimeFlags.WSS_TRACKERS) ); @@ -162,6 +169,20 @@ Object.defineProperty(runtimeFlags, "FEATURE_WATCH_HISTORY_V2", { }, }); +Object.defineProperty(runtimeFlags, "FEATURE_PUBLISH_NIP71", { + configurable: true, + enumerable: true, + get() { + return FEATURE_PUBLISH_NIP71; + }, + set(next) { + FEATURE_PUBLISH_NIP71 = coerceBoolean( + next, + DEFAULT_FLAGS.FEATURE_PUBLISH_NIP71 + ); + }, +}); + Object.defineProperty(runtimeFlags, "WSS_TRACKERS", { configurable: true, enumerable: true, @@ -178,6 +199,7 @@ runtimeFlags.URL_FIRST_ENABLED = URL_FIRST_ENABLED; runtimeFlags.ACCEPT_LEGACY_V1 = ACCEPT_LEGACY_V1; runtimeFlags.VIEW_FILTER_INCLUDE_LEGACY_VIDEO = VIEW_FILTER_INCLUDE_LEGACY_VIDEO; runtimeFlags.FEATURE_WATCH_HISTORY_V2 = FEATURE_WATCH_HISTORY_V2; +runtimeFlags.FEATURE_PUBLISH_NIP71 = FEATURE_PUBLISH_NIP71; runtimeFlags.WSS_TRACKERS = WSS_TRACKERS; export function setUrlFirstEnabled(next) { diff --git a/js/nostr.js b/js/nostr.js index a5d20de7..c7080b70 100644 --- a/js/nostr.js +++ b/js/nostr.js @@ -16,6 +16,7 @@ import { } from "./config.js"; import { ACCEPT_LEGACY_V1, + FEATURE_PUBLISH_NIP71, VIEW_FILTER_INCLUDE_LEGACY_VIDEO, } from "./constants.js"; import { accessControl } from "./accessControl.js"; @@ -194,6 +195,329 @@ function withRequestTimeout(promise, timeoutMs, onTimeout, message = "Request ti }); } +function stringFromInput(value) { + if (value === null || value === undefined) { + return ""; + } + if (typeof value === "string") { + return value.trim(); + } + if (typeof value === "number") { + return Number.isFinite(value) ? String(value) : ""; + } + return String(value).trim(); +} + +function normalizeUnixSeconds(value) { + if (value === null || value === undefined || value === "") { + return ""; + } + if (typeof value === "number" && Number.isFinite(value)) { + const normalized = value > 1e12 ? value / 1000 : value; + return String(Math.floor(normalized)); + } + if (typeof value === "string") { + const trimmed = value.trim(); + if (!trimmed) { + return ""; + } + const numeric = Number(trimmed); + if (Number.isFinite(numeric)) { + const normalized = numeric > 1e12 ? numeric / 1000 : numeric; + return String(Math.floor(normalized)); + } + const parsed = Date.parse(trimmed); + if (!Number.isNaN(parsed)) { + return String(Math.floor(parsed / 1000)); + } + } + return ""; +} + +function normalizeDurationSeconds(value) { + if (value === null || value === undefined || value === "") { + return ""; + } + if (typeof value === "number" && Number.isFinite(value)) { + return String(Math.max(0, Math.floor(value))); + } + if (typeof value === "string") { + const trimmed = value.trim(); + if (!trimmed) { + return ""; + } + const numeric = Number(trimmed); + if (Number.isFinite(numeric)) { + return String(Math.max(0, Math.floor(numeric))); + } + } + return ""; +} + +function normalizeNip71Kind(value) { + const numeric = + typeof value === "string" + ? Number(value.trim()) + : typeof value === "number" + ? value + : Number.NaN; + if (numeric === 22) { + return 22; + } + return 21; +} + +function trimTrailingEmpty(values) { + const trimmed = [...values]; + while (trimmed.length && !trimmed[trimmed.length - 1]) { + trimmed.pop(); + } + return trimmed; +} + +function buildImetaTags(variants) { + if (!Array.isArray(variants)) { + return []; + } + const tags = []; + for (const variant of variants) { + if (!variant || typeof variant !== "object") { + continue; + } + const entries = ["imeta"]; + const dim = stringFromInput(variant.dim); + if (dim) { + entries.push(`dim ${dim}`); + } + const url = stringFromInput(variant.url); + if (url) { + entries.push(`url ${url}`); + } + const x = stringFromInput(variant.x); + if (x) { + entries.push(`x ${x}`); + } + const mime = stringFromInput(variant.m); + if (mime) { + entries.push(`m ${mime}`); + } + if (Array.isArray(variant.image)) { + variant.image + .map(stringFromInput) + .filter(Boolean) + .forEach((imageUrl) => { + entries.push(`image ${imageUrl}`); + }); + } + if (Array.isArray(variant.fallback)) { + variant.fallback + .map(stringFromInput) + .filter(Boolean) + .forEach((fallbackUrl) => { + entries.push(`fallback ${fallbackUrl}`); + }); + } + if (Array.isArray(variant.service)) { + variant.service + .map(stringFromInput) + .filter(Boolean) + .forEach((service) => { + entries.push(`service ${service}`); + }); + } + if (entries.length > 1) { + tags.push(entries); + } + } + return tags; +} + +function buildTextTrackTag(track) { + if (!track || typeof track !== "object") { + return null; + } + const url = stringFromInput(track.url); + const type = stringFromInput(track.type); + const language = stringFromInput(track.language); + if (!url && !type && !language) { + return null; + } + const values = trimTrailingEmpty([url, type, language]); + return ["text-track", ...values]; +} + +function buildSegmentTag(segment) { + if (!segment || typeof segment !== "object") { + return null; + } + const start = stringFromInput(segment.start); + const end = stringFromInput(segment.end); + const title = stringFromInput(segment.title); + const thumbnail = stringFromInput(segment.thumbnail); + if (!start && !end && !title && !thumbnail) { + return null; + } + const values = trimTrailingEmpty([start, end, title, thumbnail]); + return ["segment", ...values]; +} + +function buildParticipantTag(participant) { + if (!participant || typeof participant !== "object") { + return null; + } + const pubkey = stringFromInput(participant.pubkey); + if (!pubkey) { + return null; + } + const relay = stringFromInput(participant.relay); + const values = ["p", pubkey]; + if (relay) { + values.push(relay); + } + return values; +} + +function extractVideoPublishPayload(rawPayload) { + let videoData = rawPayload; + let nip71Metadata = null; + + if (rawPayload && typeof rawPayload === "object") { + if (rawPayload.nip71 && typeof rawPayload.nip71 === "object") { + nip71Metadata = rawPayload.nip71; + } + if ( + rawPayload.legacyFormData && + typeof rawPayload.legacyFormData === "object" + ) { + videoData = rawPayload.legacyFormData; + } else if ( + Object.prototype.hasOwnProperty.call(rawPayload, "legacyFormData") + ) { + videoData = rawPayload.legacyFormData || {}; + } + } + + if (!videoData || typeof videoData !== "object") { + videoData = {}; + } + + return { videoData, nip71Metadata }; +} + +export function buildNip71VideoEvent({ + metadata, + pubkey = "", + title, + summaryFallback = "", + createdAt = Math.floor(Date.now() / 1000), +} = {}) { + if (!metadata || typeof metadata !== "object") { + return null; + } + + const normalizedTitle = stringFromInput(title); + if (!normalizedTitle) { + return null; + } + + const summaryCandidates = [ + stringFromInput(metadata.summary), + stringFromInput(summaryFallback), + normalizedTitle, + ]; + const summary = summaryCandidates.find((value) => Boolean(value)) || ""; + + const tags = []; + tags.push(["title", normalizedTitle]); + + const publishedAt = normalizeUnixSeconds(metadata.publishedAt); + if (publishedAt) { + tags.push(["published_at", publishedAt]); + } + + const alt = stringFromInput(metadata.alt); + if (alt) { + tags.push(["alt", alt]); + } + + const imetaTags = buildImetaTags(metadata.imeta); + if (imetaTags.length) { + tags.push(...imetaTags); + } + + const duration = normalizeDurationSeconds(metadata.duration); + if (duration) { + tags.push(["duration", duration]); + } + + if (Array.isArray(metadata.textTracks)) { + metadata.textTracks.forEach((track) => { + const tag = buildTextTrackTag(track); + if (tag) { + tags.push(tag); + } + }); + } + + const contentWarning = stringFromInput(metadata.contentWarning); + if (contentWarning) { + tags.push(["content-warning", contentWarning]); + } + + if (Array.isArray(metadata.segments)) { + metadata.segments.forEach((segment) => { + const tag = buildSegmentTag(segment); + if (tag) { + tags.push(tag); + } + }); + } + + if (Array.isArray(metadata.hashtags)) { + metadata.hashtags + .map(stringFromInput) + .filter(Boolean) + .forEach((value) => { + tags.push(["t", value]); + }); + } + + if (Array.isArray(metadata.participants)) { + metadata.participants.forEach((participant) => { + const tag = buildParticipantTag(participant); + if (tag) { + tags.push(tag); + } + }); + } + + if (Array.isArray(metadata.references)) { + metadata.references + .map(stringFromInput) + .filter(Boolean) + .forEach((url) => { + tags.push(["r", url]); + }); + } + + if (!tags.length) { + return null; + } + + const normalizedPubkey = stringFromInput(pubkey); + const timestamp = Number.isFinite(createdAt) + ? Math.floor(createdAt) + : Math.floor(Date.now() / 1000); + + return { + kind: normalizeNip71Kind(metadata.kind), + pubkey: normalizedPubkey, + created_at: timestamp, + tags, + content: summary, + }; +} + function sanitizeRelayList(list) { const seen = new Set(); const sanitized = []; @@ -4048,30 +4372,72 @@ class NostrClient { /** * Publish a new video using the v3 content schema. */ + async signAndPublishEvent( + event, + { + context = "event", + logName = context, + devLogLabel = logName, + rejectionLogLevel = "error", + } = {} + ) { + const signedEvent = await window.nostr.signEvent(event); + if (isDevMode) { + console.log(`Signed ${devLogLabel} event:`, signedEvent); + } + + const publishResults = await publishEventToRelays( + this.pool, + this.relays, + signedEvent + ); + + let publishSummary; + try { + publishSummary = assertAnyRelayAccepted(publishResults, { context }); + } catch (publishError) { + if (publishError?.relayFailures?.length) { + const logLevel = rejectionLogLevel === "warn" ? "warn" : "error"; + publishError.relayFailures.forEach( + ({ url, error: relayError, reason }) => { + console[logLevel]( + `[nostr] ${logName} rejected by ${url}: ${reason}`, + relayError || reason + ); + } + ); + } + throw publishError; + } + + if (isDevMode) { + publishSummary.accepted.forEach(({ url }) => { + console.log(`${logName} published to ${url}`); + }); + } + + if (publishSummary.failed.length) { + publishSummary.failed.forEach(({ url, error: relayError }) => { + const reason = + relayError instanceof Error + ? relayError.message + : relayError + ? String(relayError) + : "publish failed"; + console.warn( + `[nostr] ${logName} not accepted by ${url}: ${reason}`, + relayError + ); + }); + } + + return { signedEvent, summary: publishSummary }; + } + 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 = {}; - } - } + const { videoData, nip71Metadata } = extractVideoPublishPayload(videoPayload); if (isDevMode) { console.log("Publishing new video with data:", videoData); @@ -4148,56 +4514,11 @@ class NostrClient { } try { - const signedEvent = await window.nostr.signEvent(event); - if (isDevMode) console.log("Signed event:", signedEvent); - - const publishResults = await publishEventToRelays( - this.pool, - this.relays, - signedEvent - ); - - let publishSummary; - try { - publishSummary = assertAnyRelayAccepted(publishResults, { - context: "video note", - }); - } catch (publishError) { - if (publishError?.relayFailures?.length) { - publishError.relayFailures.forEach( - ({ url, error: relayError, reason }) => { - console.error( - `[nostr] Video note rejected by ${url}: ${reason}`, - relayError || reason - ); - } - ); - } - throw publishError; - } - - const { accepted: acceptedRelays, failed: failedRelays } = publishSummary; - - if (isDevMode) { - acceptedRelays.forEach(({ url }) => - console.log(`Video published to ${url}`) - ); - } - - if (failedRelays.length) { - failedRelays.forEach(({ url, error: relayError }) => { - const reason = - relayError instanceof Error - ? relayError.message - : relayError - ? String(relayError) - : "publish failed"; - console.warn( - `[nostr] Video note not accepted by ${url}: ${reason}`, - relayError - ); - }); - } + const { signedEvent } = await this.signAndPublishEvent(event, { + context: "video note", + logName: "Video note", + devLogLabel: "video note", + }); if (finalUrl) { const inferredMimeType = inferMimeTypeFromUrl(finalUrl); @@ -4234,59 +4555,12 @@ class NostrClient { } try { - const signedMirrorEvent = await window.nostr.signEvent(mirrorEvent); - if (isDevMode) { - console.log("Signed NIP-94 mirror event:", signedMirrorEvent); - } - - const mirrorResults = await publishEventToRelays( - this.pool, - this.relays, - signedMirrorEvent - ); - - try { - const mirrorSummary = assertAnyRelayAccepted(mirrorResults, { - context: "NIP-94 mirror", - }); - - if (isDevMode) { - mirrorSummary.accepted.forEach(({ url }) => - console.log(`NIP-94 mirror published to ${url}`) - ); - } - - if (mirrorSummary.failed.length) { - mirrorSummary.failed.forEach(({ url, error: relayError }) => { - const reason = - relayError instanceof Error - ? relayError.message - : relayError - ? String(relayError) - : "publish failed"; - console.warn( - `[nostr] NIP-94 mirror not accepted by ${url}: ${reason}`, - relayError - ); - }); - } - } catch (mirrorError) { - if (mirrorError?.relayFailures?.length) { - mirrorError.relayFailures.forEach( - ({ url, error: relayError, reason }) => { - console.warn( - `[nostr] NIP-94 mirror rejected by ${url}: ${reason}`, - relayError || reason - ); - } - ); - } else if (isDevMode) { - console.warn( - "[nostr] NIP-94 mirror rejected by all relays:", - mirrorError - ); - } - } + await this.signAndPublishEvent(mirrorEvent, { + context: "NIP-94 mirror", + logName: "NIP-94 mirror", + devLogLabel: "NIP-94 mirror", + rejectionLogLevel: "warn", + }); if (isDevMode) { console.log( @@ -4296,8 +4570,8 @@ class NostrClient { } } catch (mirrorError) { if (isDevMode) { - console.error( - "Failed to sign/publish NIP-94 mirror event:", + console.warn( + "[nostr] NIP-94 mirror rejected by all relays:", mirrorError ); } @@ -4312,6 +4586,56 @@ class NostrClient { } } + async publishNip71Video(videoPayload, pubkey) { + if (!FEATURE_PUBLISH_NIP71) { + return null; + } + + if (!pubkey) { + throw new Error("Not logged in to publish video."); + } + + const { videoData, nip71Metadata } = extractVideoPublishPayload(videoPayload); + + if (!nip71Metadata || typeof nip71Metadata !== "object") { + if (isDevMode) { + console.log("[nostr] Skipping NIP-71 publish: metadata missing."); + } + return null; + } + + const title = stringFromInput(videoData?.title); + const description = stringFromInput(videoData?.description); + + const event = buildNip71VideoEvent({ + metadata: nip71Metadata, + pubkey, + title, + summaryFallback: description, + createdAt: Math.floor(Date.now() / 1000), + }); + + if (!event) { + if (isDevMode) { + console.warn("[nostr] Skipping NIP-71 publish: builder produced no event."); + } + return null; + } + + if (isDevMode) { + console.log("Prepared NIP-71 video event:", event); + } + + const { signedEvent } = await this.signAndPublishEvent(event, { + context: "NIP-71 video", + logName: "NIP-71 video", + devLogLabel: "NIP-71 video", + rejectionLogLevel: "warn", + }); + + return signedEvent; + } + /** * Edits a video by creating a *new event* with a brand-new d tag, * but reuses the same videoRootId as the original. diff --git a/js/nostrEventSchemas.js b/js/nostrEventSchemas.js index f07eae8d..6144e05e 100644 --- a/js/nostrEventSchemas.js +++ b/js/nostrEventSchemas.js @@ -8,6 +8,8 @@ import { export const NOTE_TYPES = Object.freeze({ VIDEO_POST: "videoPost", VIDEO_MIRROR: "videoMirror", + NIP71_VIDEO: "nip71Video", + NIP71_SHORT_VIDEO: "nip71ShortVideo", RELAY_LIST: "relayList", VIEW_EVENT: "viewEvent", WATCH_HISTORY_INDEX: "watchHistoryIndex", @@ -131,6 +133,30 @@ const BASE_SCHEMAS = { description: "Optional alt text carried alongside hosted URL metadata.", }, }, + [NOTE_TYPES.NIP71_VIDEO]: { + type: NOTE_TYPES.NIP71_VIDEO, + label: "NIP-71 video (normal)", + kind: 21, + featureFlag: "FEATURE_PUBLISH_NIP71", + appendTags: DEFAULT_APPEND_TAGS, + content: { + format: "text", + description: + "Summary or description for the video body; tags carry structured metadata.", + }, + }, + [NOTE_TYPES.NIP71_SHORT_VIDEO]: { + type: NOTE_TYPES.NIP71_SHORT_VIDEO, + label: "NIP-71 video (short)", + kind: 22, + featureFlag: "FEATURE_PUBLISH_NIP71", + appendTags: DEFAULT_APPEND_TAGS, + content: { + format: "text", + description: + "Summary or description for the short-form video; structured fields live in tags.", + }, + }, [NOTE_TYPES.RELAY_LIST]: { type: NOTE_TYPES.RELAY_LIST, label: "Relay list metadata", diff --git a/js/services/nostrService.js b/js/services/nostrService.js index 6ad4a29d..05269cc7 100644 --- a/js/services/nostrService.js +++ b/js/services/nostrService.js @@ -357,8 +357,7 @@ export class NostrService { } async publishVideoNote(publishPayload, pubkey) { - const result = await this.nostrClient.publishVideo(publishPayload, pubkey); - const detail = { pubkey, result }; + const detail = { pubkey }; if (publishPayload && typeof publishPayload === "object") { detail.payload = publishPayload; if (publishPayload.legacyFormData) { @@ -370,6 +369,26 @@ export class NostrService { } else { detail.formData = publishPayload; } + + let nip71Result = null; + let legacyResult = null; + + try { + nip71Result = await this.nostrClient.publishNip71Video( + publishPayload, + pubkey + ); + legacyResult = await this.nostrClient.publishVideo( + publishPayload, + pubkey + ); + } catch (error) { + detail.error = error; + throw error; + } + + const result = { legacy: legacyResult, nip71: nip71Result }; + detail.result = result; this.emit("videos:published", detail); return result; } diff --git a/tests/nip71-builder.test.mjs b/tests/nip71-builder.test.mjs new file mode 100644 index 00000000..ba68cd8d --- /dev/null +++ b/tests/nip71-builder.test.mjs @@ -0,0 +1,164 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { buildNip71VideoEvent } from "../js/nostr.js"; + +test("buildNip71VideoEvent assembles rich metadata", () => { + const metadata = { + kind: 21, + summary: "Custom summary", + publishedAt: "1700000000", + alt: "Alt text for accessibility", + duration: 123, + contentWarning: "Flashing lights", + imeta: [ + { + dim: "1920x1080", + url: "https://cdn.example/video-1080.mp4", + x: "abc123", + m: "video/mp4", + image: [ + "https://cdn.example/video-1080.jpg", + "https://backup.example/video-1080.jpg", + ], + fallback: ["https://fallback.example/video-1080.mp4"], + service: ["nip96"], + }, + { + url: "https://cdn.example/video-hls.m3u8", + m: "application/x-mpegURL", + }, + ], + textTracks: [ + { + url: "https://cdn.example/captions-en.vtt", + type: "captions", + language: "en", + }, + { + url: "https://cdn.example/captions-es.vtt", + language: "es", + }, + ], + segments: [ + { start: "00:00:00", end: "00:01:00", title: "Intro" }, + { start: 120, title: "Middle" }, + ], + hashtags: ["bitvid", "nostr"], + participants: [ + { pubkey: "a".repeat(64), relay: "wss://relay.example" }, + { pubkey: "b".repeat(64) }, + ], + references: [ + "https://example.com/info", + "https://example.com/docs", + ], + }; + + const event = buildNip71VideoEvent({ + metadata, + pubkey: "f".repeat(64), + title: "Test Video", + summaryFallback: "Fallback summary", + createdAt: 42, + }); + + assert.ok(event, "builder should return an event"); + assert.equal(event.kind, 21, "kind should honor metadata"); + assert.equal(event.pubkey, "f".repeat(64)); + assert.equal(event.created_at, 42); + assert.equal(event.content, "Custom summary"); + + const tags = new Map(event.tags.map((tag) => [tag[0], tag])); + assert.deepEqual(tags.get("title"), ["title", "Test Video"]); + assert.deepEqual(tags.get("published_at"), ["published_at", "1700000000"]); + assert.deepEqual(tags.get("alt"), ["alt", "Alt text for accessibility"]); + assert.deepEqual(tags.get("duration"), ["duration", "123"]); + assert.deepEqual(tags.get("content-warning"), [ + "content-warning", + "Flashing lights", + ]); + + const imetaTags = event.tags.filter((tag) => tag[0] === "imeta"); + assert.equal(imetaTags.length, 2, "should include both imeta variants"); + assert.deepEqual(imetaTags[0], [ + "imeta", + "dim 1920x1080", + "url https://cdn.example/video-1080.mp4", + "x abc123", + "m video/mp4", + "image https://cdn.example/video-1080.jpg", + "image https://backup.example/video-1080.jpg", + "fallback https://fallback.example/video-1080.mp4", + "service nip96", + ]); + assert.deepEqual(imetaTags[1], [ + "imeta", + "url https://cdn.example/video-hls.m3u8", + "m application/x-mpegURL", + ]); + + const textTrackTags = event.tags.filter((tag) => tag[0] === "text-track"); + assert.deepEqual(textTrackTags, [ + [ + "text-track", + "https://cdn.example/captions-en.vtt", + "captions", + "en", + ], + ["text-track", "https://cdn.example/captions-es.vtt", "", "es"], + ]); + + const segmentTags = event.tags.filter((tag) => tag[0] === "segment"); + assert.deepEqual(segmentTags, [ + ["segment", "00:00:00", "00:01:00", "Intro"], + ["segment", "120", "", "Middle"], + ]); + + const hashtagTags = event.tags.filter((tag) => tag[0] === "t"); + assert.deepEqual(hashtagTags, [ + ["t", "bitvid"], + ["t", "nostr"], + ]); + + const participantTags = event.tags.filter((tag) => tag[0] === "p"); + assert.deepEqual(participantTags, [ + ["p", "a".repeat(64), "wss://relay.example"], + ["p", "b".repeat(64)], + ]); + + const referenceTags = event.tags.filter((tag) => tag[0] === "r"); + assert.deepEqual(referenceTags, [ + ["r", "https://example.com/info"], + ["r", "https://example.com/docs"], + ]); +}); + +test("buildNip71VideoEvent falls back to summary and selects kind", () => { + const metadata = { + kind: "22", + publishedAt: "2024-01-01T12:00:00Z", + imeta: [{ url: "https://cdn.example/video.mp4" }], + }; + + const event = buildNip71VideoEvent({ + metadata, + pubkey: "c".repeat(64), + title: "Fallback Title", + summaryFallback: "Fallback description", + createdAt: 100, + }); + + assert.ok(event, "builder should create minimal event"); + assert.equal(event.kind, 22, "string kind should coerce to number"); + const expectedPublished = String(Math.floor(Date.parse("2024-01-01T12:00:00Z") / 1000)); + assert.deepEqual(event.tags.find((tag) => tag[0] === "published_at"), [ + "published_at", + expectedPublished, + ]); + assert.equal( + event.content, + "Fallback description", + "should use fallback summary when metadata summary missing" + ); +});