diff --git a/js/nostr/nip71.js b/js/nostr/nip71.js index 0fb18a06..cea83e9c 100644 --- a/js/nostr/nip71.js +++ b/js/nostr/nip71.js @@ -176,6 +176,12 @@ function normalizeNip71Kind(value) { if (numeric === 22) { return 22; } + if (numeric === 34235) { + return 34235; + } + if (numeric === 34236) { + return 34236; + } return 21; } @@ -199,6 +205,9 @@ function buildImetaTags(variants) { const dim = stringFromInput(variant.dim); const url = stringFromInput(variant.url); const x = stringFromInput(variant.x); + const ox = stringFromInput(variant.ox); + const magnet = stringFromInput(variant.magnet); + const infohash = stringFromInput(variant.i); const rawMime = stringFromInput(variant.m); const mime = rawMime ? rawMime.toLowerCase() : ""; const duration = formatNonNegativeNumber(variant.duration, { @@ -221,6 +230,9 @@ function buildImetaTags(variants) { !dim && !url && !x && + !ox && + !magnet && + !infohash && !mime && !duration && !bitrate && @@ -241,9 +253,19 @@ function buildImetaTags(variants) { if (x) { entries.push(`x ${x}`); } + if (ox) { + entries.push(`ox ${ox}`); + } if (mime) { entries.push(`m ${mime}`); } + // WebTorrent rides standard NIP-94 imeta fields so torrent-aware clients can use it. + if (magnet) { + entries.push(`magnet ${magnet}`); + } + if (infohash) { + entries.push(`i ${infohash}`); + } if (duration) { entries.push(`duration ${duration}`); } @@ -456,6 +478,15 @@ function parseImetaTag(tag) { case "x": variant.x = parsed.value; break; + case "ox": + variant.ox = parsed.value; + break; + case "magnet": + variant.magnet = parsed.value; + break; + case "i": + variant.i = parsed.value; + break; case "m": variant.m = parsed.value; break; @@ -489,6 +520,9 @@ function parseImetaTag(tag) { Boolean(variant.dim) || Boolean(variant.url) || Boolean(variant.x) || + Boolean(variant.ox) || + Boolean(variant.magnet) || + Boolean(variant.i) || Boolean(variant.m) || (variant.duration !== undefined && variant.duration !== null && variant.duration !== "") || (variant.bitrate !== undefined && variant.bitrate !== null && variant.bitrate !== "") || diff --git a/js/nostr/nip71Mirror.js b/js/nostr/nip71Mirror.js new file mode 100644 index 00000000..ff1c4a5a --- /dev/null +++ b/js/nostr/nip71Mirror.js @@ -0,0 +1,169 @@ +// Phase 0 of the NIP-71 interop plan (docs/nip71-migration-plan.md). +// +// Maps a bitvid video (the kind-30078 content shape) to an opt-in, addressable +// NIP-71 *mirror* event (kind 34235 normal / 34236 short). The 30078 stays the +// canonical record; this mirror is additive and lets the video show up on other +// Nostr video clients (Nostube, Amethyst, …). +// +// Pure / no UX wiring (gated by FEATURE_PUBLISH_NIP71 at the call sites later). +// Hard rules baked in: private videos are NEVER mirrored, and a publicly-playable +// HTTPS url is required (foreign clients can't play magnet-only). bitvid's +// WebTorrent rides standard NIP-94 imeta fields (magnet, i). + +import { buildNip71MetadataTags, buildVideoPointerValue } from "./nip71.js"; +import { extractBtihFromMagnet } from "../magnetShared.js"; + +export const NIP71_NORMAL_VIDEO_KIND = 34235; +export const NIP71_SHORT_VIDEO_KIND = 34236; + +const VIDEO_MIME_BY_EXT = { + mp4: "video/mp4", + m4v: "video/mp4", + webm: "video/webm", + ogv: "video/ogg", + ogg: "video/ogg", + mov: "video/quicktime", + mkv: "video/x-matroska", + m3u8: "application/x-mpegURL", +}; + +function str(value) { + return typeof value === "string" ? value.trim() : ""; +} + +function isHttpsUrl(value) { + return /^https:\/\//i.test(str(value)); +} + +// Best-effort MIME from the url extension; bitvid doesn't persist a MIME today. +export function deriveVideoMime(url) { + const value = str(url).toLowerCase(); + if (!value) { + return "video/mp4"; + } + const path = value.split(/[?#]/)[0]; + const ext = path.includes(".") ? path.slice(path.lastIndexOf(".") + 1) : ""; + return VIDEO_MIME_BY_EXT[ext] || "video/mp4"; +} + +function normalizePublishedAt(value) { + const numeric = Number(value); + if (!Number.isFinite(numeric) || numeric <= 0) { + return Math.floor(Date.now() / 1000); + } + // Tolerate millisecond timestamps. + return numeric > 10_000_000_000 ? Math.floor(numeric / 1000) : Math.floor(numeric); +} + +/** + * Build an addressable NIP-71 mirror event from a bitvid video object. + * + * @param {object} video - bitvid video (30078 content shape) + `pubkey`. + * @param {object} [options] + * @param {boolean} [options.short] - Force 34236 (short) / 34235 (normal). When + * omitted, inferred from dimensions (portrait => short). + * @param {number} [options.createdAt] - Signing time (unix seconds). Default now. + * @param {number|string} [options.publishedAt] - First-publish time for the + * `published_at` tag; kept stable across edits. Defaults to the video's + * published_at/created_at, else now. + * @param {string} [options.watchUrl] - Canonical bitvid watch URL for `origin`. + * @returns {{ok:true, event:object} | {ok:false, reason:string}} + */ +export function buildNip71MirrorEvent(video, options = {}) { + if (!video || typeof video !== "object") { + return { ok: false, reason: "invalid" }; + } + + const videoRootId = str(video.videoRootId); + const title = str(video.title); + const pubkey = str(video.pubkey).toLowerCase(); + if (!videoRootId || !title || !pubkey) { + return { ok: false, reason: "invalid" }; + } + + // Hard rule 1: private videos are never mirrored (no NIP-71 encryption). + if (video.isPrivate === true) { + return { ok: false, reason: "private" }; + } + + // Hard rule 2: foreign clients need a hosted, publicly-playable HTTPS url. + const url = str(video.url); + if (!isHttpsUrl(url)) { + return { ok: false, reason: "no-url" }; + } + + const magnet = str(video.magnet); + const infohash = + str(video.infoHash) || (magnet ? str(extractBtihFromMagnet(magnet)) : ""); + + const width = Number(video.width); + const height = Number(video.height); + const hasDims = + Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0; + const duration = Number(video.duration); + + const variant = { + url, + m: deriveVideoMime(url), + image: str(video.thumbnail) ? [str(video.thumbnail)] : [], + x: str(video.fileSha256) || undefined, + ox: str(video.originalFileSha256) || undefined, + magnet: magnet || undefined, + i: infohash || undefined, + dim: hasDims ? `${width}x${height}` : undefined, + duration: Number.isFinite(duration) && duration > 0 ? duration : undefined, + }; + + const hashtags = Array.isArray(video.hashtags) + ? video.hashtags.map(str).filter(Boolean) + : []; + + const metadata = { + title, + // Short accessibility text — NOT a dump of the full description. + alt: title, + publishedAt: normalizePublishedAt( + options.publishedAt ?? video.published_at ?? video.created_at, + ), + imeta: [variant], + hashtags, + contentWarning: + video.isNsfw === true ? str(video.nsfwReason) || "nsfw" : undefined, + }; + + const tags = buildNip71MetadataTags(metadata); + + // Addressable identity + bitvid linkage (ignored by foreign clients). + tags.push(["d", videoRootId]); + const aValue = buildVideoPointerValue(pubkey, videoRootId); + if (aValue) { + tags.push(["a", aValue]); // back-pointer to the canonical kind 30078 + } + const origin = ["origin", "bitvid", videoRootId]; + const watchUrl = str(options.watchUrl); + if (watchUrl) { + origin.push(watchUrl); + } + tags.push(origin); + tags.push(["client", "bitvid"]); + + const short = + options.short === true || + (options.short !== false && hasDims && height > width); + + const createdAt = Number.isFinite(options.createdAt) + ? Math.floor(options.createdAt) + : Math.floor(Date.now() / 1000); + + const event = { + kind: short ? NIP71_SHORT_VIDEO_KIND : NIP71_NORMAL_VIDEO_KIND, + pubkey, + created_at: createdAt, + tags, + content: str(video.description), + }; + + return { ok: true, event }; +} + +export default buildNip71MirrorEvent; diff --git a/tests/nip71-mirror.test.mjs b/tests/nip71-mirror.test.mjs new file mode 100644 index 00000000..fb93a57b --- /dev/null +++ b/tests/nip71-mirror.test.mjs @@ -0,0 +1,146 @@ +// Phase 0 of the NIP-71 interop plan: bitvid video (kind 30078 shape) -> addressable +// NIP-71 mirror event (kind 34235/34236). Scenarios assert the observable event +// shape AND that bitvid's own NIP-71 parser reads it back (round-trip), plus the +// two hard rules (private never mirrored, HTTPS url required) and short selection. + +import "./test-helpers/setup-localstorage.mjs"; +import assert from "node:assert/strict"; +import test from "node:test"; +import { + buildNip71MirrorEvent, + deriveVideoMime, + NIP71_NORMAL_VIDEO_KIND, + NIP71_SHORT_VIDEO_KIND, +} from "../js/nostr/nip71Mirror.js"; +import { extractNip71MetadataFromTags } from "../js/nostr/nip71.js"; + +const PUBKEY = "a".repeat(64); +const INFOHASH = "b".repeat(40); + +function baseVideo(overrides = {}) { + return { + videoRootId: "root-123", + pubkey: PUBKEY, + title: "Crystalline Bliss", + description: "A lo-fi voxel puzzle journey.", + url: "https://cdn.example.com/u/abc/video.mp4", + thumbnail: "https://cdn.example.com/u/abc/thumb.jpg", + magnet: `magnet:?xt=urn:btih:${INFOHASH}&ws=https://cdn.example.com/u/abc/video.mp4`, + infoHash: INFOHASH, + fileSha256: "c".repeat(64), + originalFileSha256: "d".repeat(64), + isPrivate: false, + isNsfw: false, + hashtags: ["gamestr", "voxel"], + duration: 123, + ...overrides, + }; +} + +// Find the imeta tag and parse "key value" entries into an object (last wins, +// images/magnet collected). +function imetaFields(event) { + const tag = event.tags.find((t) => t[0] === "imeta"); + assert.ok(tag, "event must have an imeta tag"); + const fields = {}; + for (let i = 1; i < tag.length; i += 1) { + const sp = tag[i].indexOf(" "); + const k = tag[i].slice(0, sp); + const v = tag[i].slice(sp + 1); + fields[k] = v; + } + return fields; +} + +function tagValue(event, name) { + const tag = event.tags.find((t) => t[0] === name); + return tag ? tag[1] : undefined; +} + +test("maps a public bitvid video to a spec-shaped addressable 34235 event", async () => { + const res = buildNip71MirrorEvent(baseVideo(), { createdAt: 1000, publishedAt: 900 }); + assert.equal(res.ok, true); + const ev = res.event; + + assert.equal(ev.kind, NIP71_NORMAL_VIDEO_KIND, "landscape/default => 34235"); + assert.equal(ev.pubkey, PUBKEY); + assert.equal(ev.content, "A lo-fi voxel puzzle journey."); + + // Required + addressable identity + assert.equal(tagValue(ev, "d"), "root-123", "addressable d-tag = videoRootId"); + assert.equal(tagValue(ev, "title"), "Crystalline Bliss"); + assert.equal(tagValue(ev, "published_at"), "900", "stable first-publish time"); + + // Back-pointer + attribution + hashtags + assert.equal(tagValue(ev, "a"), `30078:${PUBKEY}:root-123`, "links to canonical 30078"); + const origin = ev.tags.find((t) => t[0] === "origin"); + assert.deepEqual(origin.slice(0, 3), ["origin", "bitvid", "root-123"]); + const tTags = ev.tags.filter((t) => t[0] === "t").map((t) => t[1]).sort(); + assert.deepEqual(tTags, ["gamestr", "voxel"]); + + // imeta: HTTPS url + standard NIP-94 magnet/infohash/hashes + const f = imetaFields(ev); + assert.equal(f.url, "https://cdn.example.com/u/abc/video.mp4"); + assert.equal(f.m, "video/mp4"); + assert.equal(f.image, "https://cdn.example.com/u/abc/thumb.jpg"); + assert.equal(f.x, "c".repeat(64)); + assert.equal(f.ox, "d".repeat(64)); + assert.equal(f.i, INFOHASH, "infohash rides standard NIP-94 'i'"); + assert.ok(f.magnet.startsWith("magnet:?xt=urn:btih:"), "magnet rides standard NIP-94 'magnet'"); + assert.equal(f.duration, "123"); +}); + +test("round-trips: the produced event parses back via bitvid's NIP-71 parser", async () => { + const res = buildNip71MirrorEvent(baseVideo()); + const parsed = extractNip71MetadataFromTags(res.event); + assert.ok(parsed, "parser must read the mirror event"); + const meta = parsed.metadata; + assert.equal(meta.kind, NIP71_NORMAL_VIDEO_KIND, "addressable kind preserved"); + assert.equal(parsed.source.kind, NIP71_NORMAL_VIDEO_KIND, "source kind preserved"); + assert.equal(meta.title, "Crystalline Bliss"); + assert.deepEqual(meta.hashtags.sort(), ["gamestr", "voxel"]); + const v = meta.imeta[0]; + assert.equal(v.url, "https://cdn.example.com/u/abc/video.mp4"); + assert.equal(v.magnet, baseVideo().magnet, "magnet survives the round-trip"); + assert.equal(v.i, INFOHASH, "infohash survives the round-trip"); + assert.equal(v.ox, "d".repeat(64), "original-file hash survives the round-trip"); +}); + +test("private videos are NEVER mirrored", async () => { + const res = buildNip71MirrorEvent(baseVideo({ isPrivate: true })); + assert.equal(res.ok, false); + assert.equal(res.reason, "private"); +}); + +test("a video without an HTTPS url cannot be mirrored", async () => { + // magnet-only: bitvid plays it, foreign clients can't. + const res = buildNip71MirrorEvent(baseVideo({ url: "" })); + assert.equal(res.ok, false); + assert.equal(res.reason, "no-url"); + + const httpRes = buildNip71MirrorEvent(baseVideo({ url: "http://insecure.example/v.mp4" })); + assert.equal(httpRes.ok, false, "non-HTTPS url is refused"); +}); + +test("portrait dimensions select the short kind 34236; content-warning for NSFW", async () => { + const res = buildNip71MirrorEvent( + baseVideo({ width: 1080, height: 1920, isNsfw: true, nsfwReason: "mature" }), + ); + assert.equal(res.event.kind, NIP71_SHORT_VIDEO_KIND, "portrait => 34236 short"); + assert.equal(tagValue(res.event, "content-warning"), "mature"); + assert.equal(imetaFields(res.event).dim, "1080x1920"); +}); + +test("explicit short flag overrides dimension inference", async () => { + const landscapeButShort = buildNip71MirrorEvent( + baseVideo({ width: 1920, height: 1080 }), + { short: true }, + ); + assert.equal(landscapeButShort.event.kind, NIP71_SHORT_VIDEO_KIND); +}); + +test("deriveVideoMime maps common extensions and defaults to mp4", async () => { + assert.equal(deriveVideoMime("https://x/y.webm"), "video/webm"); + assert.equal(deriveVideoMime("https://x/y.m3u8?token=1"), "application/x-mpegURL"); + assert.equal(deriveVideoMime("https://x/y"), "video/mp4"); +});