diff --git a/js/nostr/nip71IngestAdapter.js b/js/nostr/nip71IngestAdapter.js new file mode 100644 index 00000000..fe308d79 --- /dev/null +++ b/js/nostr/nip71IngestAdapter.js @@ -0,0 +1,195 @@ +// Adapter: converts a *foreign* NIP-71 video event (published by another Nostr +// app — kinds 21/22 regular, 34235/34236 addressable) into a bitvid video +// object shaped like the output of convertEventToVideo, so it can flow through +// the existing feed pipeline (moderation, trust, NSFW gating, rendering). +// +// This is the inverse of nip71Mirror.js (which builds bitvid → NIP-71). It reads +// the imeta variant + standard NIP-71 tags rather than bitvid's JSON content, +// and maps the `content-warning` tag to bitvid's `isNsfw` flag. +// +// Pure + dependency-free so it is cheat-resistant to test. The ingest *service* +// (nip71IngestService.js) handles subscriptions, dedup, and own-mirror skipping. + +import { extractNip71MetadataFromTags } from "./nip71.js"; +import { extractBtihFromMagnet, extractMagnetHints } from "../magnetShared.js"; + +const NIP71_KINDS = new Set([21, 22, 34235, 34236]); + +function trimString(value) { + return typeof value === "string" ? value.trim() : ""; +} + +function parseDim(dim) { + const raw = trimString(dim); + const match = /^(\d+)x(\d+)$/i.exec(raw); + if (!match) { + return { width: undefined, height: undefined }; + } + const width = Number(match[1]); + const height = Number(match[2]); + return { + width: Number.isFinite(width) && width > 0 ? width : undefined, + height: Number.isFinite(height) && height > 0 ? height : undefined, + }; +} + +function dTagFromEvent(event) { + const tags = Array.isArray(event?.tags) ? event.tags : []; + for (const tag of tags) { + if (Array.isArray(tag) && tag[0] === "d") { + const value = trimString(tag[1]); + if (value) { + return value; + } + } + } + return ""; +} + +// bitvid stamps its own outbound mirrors with ["client","bitvid"]. The ingest +// service uses this to avoid re-listing our own content (which already exists as +// a canonical kind-30078 video). +export function isBitvidMirrorEvent(event) { + const tags = Array.isArray(event?.tags) ? event.tags : []; + return tags.some( + (tag) => + Array.isArray(tag) && + tag[0] === "client" && + trimString(tag[1]).toLowerCase() === "bitvid", + ); +} + +function pickPrimaryVariant(imeta) { + if (!Array.isArray(imeta) || !imeta.length) { + return null; + } + // Prefer a variant that advertises a video mimetype and a url; fall back to + // the first variant that has any playable source. + const withVideoUrl = imeta.find( + (variant) => + trimString(variant?.url) && + trimString(variant?.m).toLowerCase().startsWith("video/"), + ); + if (withVideoUrl) { + return withVideoUrl; + } + const withUrl = imeta.find((variant) => trimString(variant?.url)); + if (withUrl) { + return withUrl; + } + return imeta.find((variant) => trimString(variant?.magnet)) || null; +} + +/** + * Convert a foreign NIP-71 event into a bitvid video object. + * + * @param {object} event - The raw Nostr event (kind 21/22/34235/34236). + * @returns {object} A video object (invalid:false) or { invalid:true, reason }. + */ +export function buildVideoFromNip71Event(event = {}) { + if (!event || typeof event !== "object") { + return { invalid: true, reason: "missing event" }; + } + if (!NIP71_KINDS.has(Number(event.kind))) { + return { id: event.id, invalid: true, reason: "not a NIP-71 video kind" }; + } + if (isBitvidMirrorEvent(event)) { + // Our own outbound mirror — the canonical 30078 is already in the feed. + return { id: event.id, invalid: true, reason: "bitvid-mirror" }; + } + + const parsed = extractNip71MetadataFromTags(event); + const metadata = parsed?.metadata || {}; + const variant = pickPrimaryVariant(metadata.imeta); + + const url = trimString(variant?.url); + const rawMagnet = trimString(variant?.magnet); + const magnet = rawMagnet.toLowerCase().startsWith("magnet:?") ? rawMagnet : ""; + + if (!url && !magnet) { + return { id: event.id, invalid: true, reason: "missing playable source" }; + } + + const title = trimString(metadata.title); + if (!title) { + return { id: event.id, invalid: true, reason: "missing title" }; + } + + const { width, height } = parseDim(variant?.dim); + + const durationCandidate = Number(metadata.duration); + const duration = + Number.isFinite(durationCandidate) && durationCandidate > 0 + ? durationCandidate + : undefined; + + const thumbnail = + Array.isArray(variant?.image) && variant.image.length + ? trimString(variant.image[0]) + : ""; + + // content-warning → isNsfw so the standard NSFW gate (ALLOW_NSFW_CONTENT) and + // blur thresholds apply to ingested content too. + const isNsfw = Boolean(trimString(metadata.contentWarning)); + + const hashtags = Array.isArray(metadata.hashtags) + ? metadata.hashtags + .map((value) => trimString(value)) + .filter((value) => value) + : []; + + let infoHash = ""; + const iField = trimString(variant?.i).toLowerCase(); + if (/^[0-9a-f]{40}$/.test(iField)) { + infoHash = iField; + } else if (magnet) { + infoHash = extractBtihFromMagnet(magnet) || ""; + } + + const hints = magnet ? extractMagnetHints(magnet) : { ws: "", xs: "" }; + + // Addressable kinds (34235/36) are keyed by d-tag; regular kinds (21/22) use + // the event id as the stable root. + const dTag = dTagFromEvent(event); + const videoRootId = dTag || trimString(event.id); + + return { + id: event.id, + videoRootId, + version: 3, + isPrivate: false, + isNsfw, + isForKids: false, + title, + url, + magnet, + rawMagnet: magnet ? rawMagnet : "", + infoHash, + fileSha256: trimString(variant?.x), + originalFileSha256: trimString(variant?.ox), + thumbnail, + description: trimString(metadata.summary), + mode: "live", + deleted: false, + hashtags, + width, + height, + duration, + ws: hints.ws || "", + xs: hints.xs || "", + storagePointer: null, + infoJsonUrl: "", + enableComments: true, + pubkey: trimString(event.pubkey), + created_at: Number.isFinite(event.created_at) ? event.created_at : 0, + tags: Array.isArray(event.tags) ? event.tags : [], + // Provenance markers so the rest of the app can distinguish ingested videos + // from native bitvid uploads (e.g. to hide WebTorrent-only affordances). + source: "nip71-ingest", + foreign: true, + nip71Kind: Number(event.kind), + invalid: false, + }; +} + +export { NIP71_KINDS }; diff --git a/tests/nip71-ingest-adapter.test.mjs b/tests/nip71-ingest-adapter.test.mjs new file mode 100644 index 00000000..c1aaa68f --- /dev/null +++ b/tests/nip71-ingest-adapter.test.mjs @@ -0,0 +1,142 @@ +// Foreign NIP-71 video event -> bitvid video object adapter. Scenario-style: +// asserts the externally observable shape a feed item needs, and the safety +// behaviors (own-mirror skip, content-warning -> isNsfw, source provenance). + +import assert from "node:assert/strict"; +import test from "node:test"; +import { + buildVideoFromNip71Event, + isBitvidMirrorEvent, +} from "../js/nostr/nip71IngestAdapter.js"; + +const SHA = "a".repeat(64); + +function foreignEvent(overrides = {}) { + return { + id: "evt-1", + pubkey: "pk-foreign", + kind: 21, + created_at: 1_700_000_000, + content: "A description from another client", + tags: [ + ["title", "Foreign Video"], + [ + "imeta", + "url https://example.com/v.mp4", + "m video/mp4", + "dim 1920x1080", + "image https://example.com/thumb.jpg", + `x ${SHA}`, + ], + ["t", "music"], + ["duration", "240"], + ], + ...overrides, + }; +} + +test("maps a foreign NIP-71 event to a playable bitvid video", () => { + const v = buildVideoFromNip71Event(foreignEvent()); + assert.equal(v.invalid, false); + assert.equal(v.title, "Foreign Video"); + assert.equal(v.url, "https://example.com/v.mp4"); + assert.equal(v.thumbnail, "https://example.com/thumb.jpg"); + assert.equal(v.description, "A description from another client"); + assert.equal(v.width, 1920); + assert.equal(v.height, 1080); + assert.equal(v.duration, 240); + assert.deepEqual(v.hashtags, ["music"]); + assert.equal(v.fileSha256, SHA); + assert.equal(v.pubkey, "pk-foreign"); + // Provenance markers so the app can distinguish ingested content. + assert.equal(v.source, "nip71-ingest"); + assert.equal(v.foreign, true); + assert.equal(v.nip71Kind, 21); + // Never treat foreign content as private (no encryption in NIP-71). + assert.equal(v.isPrivate, false); +}); + +test("content-warning maps to isNsfw so the NSFW gate applies", () => { + const clean = buildVideoFromNip71Event(foreignEvent()); + assert.equal(clean.isNsfw, false); + + const flagged = buildVideoFromNip71Event( + foreignEvent({ + tags: [ + ["title", "Spicy"], + ["imeta", "url https://example.com/v.mp4", "m video/mp4"], + ["content-warning", "explicit"], + ], + }), + ); + assert.equal(flagged.isNsfw, true); +}); + +test("addressable kinds key the root on the d-tag; regular kinds on the event id", () => { + const regular = buildVideoFromNip71Event(foreignEvent({ id: "regular-id", kind: 22 })); + assert.equal(regular.videoRootId, "regular-id"); + + const addressable = buildVideoFromNip71Event( + foreignEvent({ + id: "addr-id", + kind: 34235, + tags: [ + ["d", "stable-root"], + ["title", "Addr"], + ["imeta", "url https://example.com/v.mp4", "m video/mp4"], + ], + }), + ); + assert.equal(addressable.videoRootId, "stable-root"); +}); + +test("skips bitvid's own outbound mirrors (avoids duplicate listings)", () => { + const mirror = foreignEvent({ + kind: 34235, + tags: [ + ["d", "root"], + ["client", "bitvid"], + ["title", "Our mirror"], + ["imeta", "url https://example.com/v.mp4", "m video/mp4"], + ], + }); + assert.equal(isBitvidMirrorEvent(mirror), true); + const v = buildVideoFromNip71Event(mirror); + assert.equal(v.invalid, true); + assert.equal(v.reason, "bitvid-mirror"); +}); + +test("rejects events with no playable source or no title", () => { + const noSource = buildVideoFromNip71Event( + foreignEvent({ tags: [["title", "No media"]] }), + ); + assert.equal(noSource.invalid, true); + assert.equal(noSource.reason, "missing playable source"); + + const noTitle = buildVideoFromNip71Event( + foreignEvent({ tags: [["imeta", "url https://example.com/v.mp4", "m video/mp4"]] }), + ); + assert.equal(noTitle.invalid, true); + assert.equal(noTitle.reason, "missing title"); +}); + +test("rejects non-NIP-71 kinds", () => { + const v = buildVideoFromNip71Event(foreignEvent({ kind: 30078 })); + assert.equal(v.invalid, true); + assert.equal(v.reason, "not a NIP-71 video kind"); +}); + +test("extracts a webtorrent magnet + infohash when present", () => { + const magnet = `magnet:?xt=urn:btih:${"b".repeat(40)}&dn=clip`; + const v = buildVideoFromNip71Event( + foreignEvent({ + tags: [ + ["title", "Torrent clip"], + ["imeta", `magnet ${magnet}`, "m video/mp4", `i ${"c".repeat(40)}`], + ], + }), + ); + assert.equal(v.invalid, false); + assert.equal(v.magnet, magnet); + assert.equal(v.infoHash, "c".repeat(40), "prefers the imeta `i` infohash field"); +});