From db29ce2cc512971c8c6827b7b955c5b3a0f7dc69 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 22 Oct 2025 15:02:55 -0400 Subject: [PATCH] Refactor NIP-71 helpers into dedicated module --- js/nostr.js | 1381 ++----------------------------------- js/nostr/nip71.js | 1320 +++++++++++++++++++++++++++++++++++ tests/nostr/nip71.test.js | 245 +++++++ 3 files changed, 1616 insertions(+), 1330 deletions(-) create mode 100644 js/nostr/nip71.js create mode 100644 tests/nostr/nip71.test.js diff --git a/js/nostr.js b/js/nostr.js index 853690db..02f9a938 100644 --- a/js/nostr.js +++ b/js/nostr.js @@ -6,19 +6,26 @@ import { VIEW_COUNT_BACKFILL_MAX_DAYS, ENSURE_PRESENCE_REBROADCAST_COOLDOWN_SECONDS, } from "./config.js"; -import { - ACCEPT_LEGACY_V1, - FEATURE_PUBLISH_NIP71, - VIEW_FILTER_INCLUDE_LEGACY_VIDEO, -} from "./constants.js"; +import { FEATURE_PUBLISH_NIP71, VIEW_FILTER_INCLUDE_LEGACY_VIDEO } from "./constants.js"; import { accessControl } from "./accessControl.js"; import { registerNostrClient, requestDefaultExtensionPermissions as requestRegisteredPermissions, } from "./nostrClientRegistry.js"; // 🔧 merged conflicting changes from codex/update-video-publishing-and-parsing-logic vs unstable -import { deriveTitleFromEvent, magnetFromText } from "./videoEventUtils.js"; -import { extractMagnetHints } from "./magnet.js"; +import { + buildNip71MetadataTags, + buildNip71VideoEvent, + collectNip71PointerRequests, + convertEventToVideo, + extractNip71MetadataFromTags, + getDTagValueFromTags, + mergeNip71MetadataIntoVideo as mergeNip71MetadataIntoVideoHelper, + populateNip71MetadataForVideos as populateNip71MetadataForVideosHelper, + processNip71Events as processNip71EventsHelper, + buildVideoPointerValue, + stringFromInput, +} from "./nostr/nip71.js"; import { createWatchHistoryManager, normalizePointerInput, @@ -732,273 +739,6 @@ 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; -} - -export function buildNip71MetadataTags(metadata) { - if (!metadata || typeof metadata !== "object") { - return []; - } - - const tags = []; - - const normalizedTitle = stringFromInput(metadata.title); - if (normalizedTitle) { - 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]); - }); - } - - return tags; -} - function extractVideoPublishPayload(rawPayload) { let videoData = rawPayload; let nip71Metadata = null; @@ -1032,498 +772,6 @@ function extractVideoPublishPayload(rawPayload) { return { videoData: normalizedVideoData, nip71Metadata }; } -function buildVideoPointerValue(pubkey, videoRootId) { - const normalizedRoot = stringFromInput(videoRootId); - const normalizedPubkey = stringFromInput(pubkey).toLowerCase(); - if (!normalizedRoot || !normalizedPubkey) { - return ""; - } - return `30078:${normalizedPubkey}:${normalizedRoot}`; -} - -function buildNip71PointerTags({ - pubkey = "", - videoRootId = "", - videoEventId = "", - dTag = "", -} = {}) { - const pointerTags = []; - - const normalizedRoot = stringFromInput(videoRootId); - const normalizedEventId = stringFromInput(videoEventId); - const normalizedDTag = stringFromInput(dTag); - - if (normalizedRoot) { - const pointerValue = buildVideoPointerValue(pubkey, normalizedRoot); - if (pointerValue) { - pointerTags.push(["a", pointerValue]); - } - pointerTags.push(["video-root", normalizedRoot]); - } - - if (normalizedEventId) { - pointerTags.push(["e", normalizedEventId]); - } - - if (normalizedDTag) { - pointerTags.push(["d", normalizedDTag]); - } - - return pointerTags; -} - -export function buildNip71VideoEvent({ - metadata, - pubkey = "", - title, - summaryFallback = "", - createdAt = Math.floor(Date.now() / 1000), - pointerIdentifiers = {}, -} = {}) { - 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 = buildNip71MetadataTags({ - ...metadata, - title: normalizedTitle, - }); - - if (!tags.length) { - return null; - } - - const normalizedPubkey = stringFromInput(pubkey); - const timestamp = Number.isFinite(createdAt) - ? Math.floor(createdAt) - : Math.floor(Date.now() / 1000); - - const pointerTags = buildNip71PointerTags({ - pubkey, - videoRootId: pointerIdentifiers.videoRootId, - videoEventId: pointerIdentifiers.eventId, - dTag: pointerIdentifiers.dTag, - }); - - if (pointerTags.length) { - tags.push(...pointerTags); - } - - return { - kind: normalizeNip71Kind(metadata.kind), - pubkey: normalizedPubkey, - created_at: timestamp, - tags, - content: summary, - }; -} - -function parseKeyValuePair(entry) { - if (typeof entry !== "string") { - return null; - } - const trimmed = entry.trim(); - if (!trimmed) { - return null; - } - const spaceIndex = trimmed.indexOf(" "); - if (spaceIndex === -1) { - return null; - } - const key = trimmed.slice(0, spaceIndex).trim().toLowerCase(); - const value = trimmed.slice(spaceIndex + 1).trim(); - if (!key || !value) { - return null; - } - return { key, value }; -} - -function parseImetaTag(tag) { - if (!Array.isArray(tag) || tag[0] !== "imeta") { - return null; - } - - const variant = { - image: [], - fallback: [], - service: [], - }; - - for (let i = 1; i < tag.length; i += 1) { - const parsed = parseKeyValuePair(tag[i]); - if (!parsed) { - continue; - } - - switch (parsed.key) { - case "dim": - variant.dim = parsed.value; - break; - case "url": - variant.url = parsed.value; - break; - case "x": - variant.x = parsed.value; - break; - case "m": - variant.m = parsed.value; - break; - case "image": - variant.image.push(parsed.value); - break; - case "fallback": - variant.fallback.push(parsed.value); - break; - case "service": - variant.service.push(parsed.value); - break; - default: - break; - } - } - - const hasContent = - Boolean(variant.dim) || - Boolean(variant.url) || - Boolean(variant.x) || - Boolean(variant.m) || - variant.image.length > 0 || - variant.fallback.length > 0 || - variant.service.length > 0; - - if (!hasContent) { - return null; - } - - if (!variant.image.length) { - delete variant.image; - } - if (!variant.fallback.length) { - delete variant.fallback; - } - if (!variant.service.length) { - delete variant.service; - } - - return variant; -} - -function parseTextTrackTag(tag) { - if (!Array.isArray(tag) || tag[0] !== "text-track") { - return null; - } - const url = stringFromInput(tag[1]); - const type = stringFromInput(tag[2]); - const language = stringFromInput(tag[3]); - if (!url && !type && !language) { - return null; - } - const track = {}; - if (url) { - track.url = url; - } - if (type) { - track.type = type; - } - if (language) { - track.language = language; - } - return track; -} - -function parseSegmentTag(tag) { - if (!Array.isArray(tag) || tag[0] !== "segment") { - return null; - } - - const values = []; - for (let i = 1; i < tag.length; i += 1) { - const value = stringFromInput(tag[i]); - values.push(value); - } - - while (values.length < 4) { - values.push(""); - } - - const [start, end, title, thumbnail] = values; - const hasContent = start || end || title || thumbnail; - if (!hasContent) { - return null; - } - const segment = {}; - if (start) { - segment.start = start; - } - if (end) { - segment.end = end; - } - if (title) { - segment.title = title; - } - if (thumbnail) { - segment.thumbnail = thumbnail; - } - return segment; -} - -function parseParticipantTag(tag) { - if (!Array.isArray(tag) || tag[0] !== "p") { - return null; - } - const pubkey = stringFromInput(tag[1]); - if (!pubkey) { - return null; - } - const participant = { pubkey }; - const relay = stringFromInput(tag[2]); - if (relay) { - participant.relay = relay; - } - return participant; -} - -function parseReferenceTag(tag) { - if (!Array.isArray(tag) || tag[0] !== "r") { - return null; - } - const url = stringFromInput(tag[1]); - return url ? url : null; -} - -function parseHashtagTag(tag) { - if (!Array.isArray(tag) || tag[0] !== "t") { - return null; - } - const value = stringFromInput(tag[1]); - return value || null; -} - -export function extractNip71MetadataFromTags(event) { - if (!event || typeof event !== "object") { - return null; - } - - const tags = Array.isArray(event.tags) ? event.tags : []; - const metadata = { kind: normalizeNip71Kind(event.kind) }; - - const summary = stringFromInput(event.content); - if (summary) { - metadata.summary = summary; - } - - const imeta = []; - const textTracks = []; - const segments = []; - const hashtags = []; - const participants = []; - const references = []; - - const pointerValues = new Set(); - const videoRootIds = new Set(); - const videoEventIds = new Set(); - const dTags = new Set(); - - tags.forEach((tag) => { - if (!Array.isArray(tag) || tag.length < 2) { - return; - } - - const name = tag[0]; - switch (name) { - case "title": { - const value = stringFromInput(tag[1]); - if (value) { - metadata.title = value; - } - break; - } - case "published_at": { - const value = stringFromInput(tag[1]); - if (value) { - metadata.publishedAt = value; - } - break; - } - case "alt": { - const value = stringFromInput(tag[1]); - if (value) { - metadata.alt = value; - } - break; - } - case "duration": { - const value = stringFromInput(tag[1]); - if (value) { - const parsed = Number(value); - metadata.duration = Number.isFinite(parsed) ? parsed : value; - } - break; - } - case "content-warning": { - const value = stringFromInput(tag[1]); - if (value) { - metadata.contentWarning = value; - } - break; - } - case "imeta": { - const variant = parseImetaTag(tag); - if (variant) { - imeta.push(variant); - } - break; - } - case "text-track": { - const track = parseTextTrackTag(tag); - if (track) { - textTracks.push(track); - } - break; - } - case "segment": { - const segment = parseSegmentTag(tag); - if (segment) { - segments.push(segment); - } - break; - } - case "t": { - const hashtag = parseHashtagTag(tag); - if (hashtag) { - hashtags.push(hashtag); - } - break; - } - case "p": { - const participant = parseParticipantTag(tag); - if (participant) { - participants.push(participant); - } - break; - } - case "r": { - const reference = parseReferenceTag(tag); - if (reference) { - references.push(reference); - } - break; - } - case "a": { - const pointerValue = stringFromInput(tag[1]).toLowerCase(); - if (pointerValue) { - pointerValues.add(pointerValue); - const parts = pointerValue.split(":"); - if (parts.length === 3 && parts[0] === "30078") { - const root = parts[2]; - if (root) { - videoRootIds.add(root); - } - } - } - break; - } - case "video-root": { - const value = stringFromInput(tag[1]); - if (value) { - videoRootIds.add(value); - } - break; - } - case "e": { - const value = stringFromInput(tag[1]); - if (value) { - videoEventIds.add(value); - } - break; - } - case "d": { - const value = stringFromInput(tag[1]); - if (value) { - dTags.add(value); - } - break; - } - default: - break; - } - }); - - if (imeta.length) { - metadata.imeta = imeta; - } - if (textTracks.length) { - metadata.textTracks = textTracks; - } - if (segments.length) { - metadata.segments = segments; - } - if (hashtags.length) { - metadata.hashtags = hashtags; - metadata.t = hashtags; - } - if (participants.length) { - metadata.participants = participants; - } - if (references.length) { - metadata.references = references; - } - - return { - metadata, - pointers: { - pointerValues, - videoRootIds, - videoEventIds, - dTags, - }, - source: { - id: typeof event.id === "string" ? event.id : "", - created_at: Number.isFinite(event.created_at) ? event.created_at : null, - kind: Number.isFinite(event.kind) ? event.kind : normalizeNip71Kind(event.kind), - }, - }; -} - -function cloneNip71Metadata(metadata) { - if (!metadata || typeof metadata !== "object") { - return null; - } - try { - return JSON.parse(JSON.stringify(metadata)); - } catch (error) { - devLogger.warn("[nostr] Failed to clone NIP-71 metadata", error); - return { ...metadata }; - } -} - -function getDTagValueFromTags(tags) { - if (!Array.isArray(tags)) { - return ""; - } - for (const tag of tags) { - if (!Array.isArray(tag) || tag[0] !== "d") { - continue; - } - if (typeof tag[1] === "string" && tag[1]) { - return tag[1]; - } - } - return ""; -} - function eventToAddressPointer(event) { if (!event || typeof event !== "object") { return ""; @@ -1790,237 +1038,6 @@ function inferMimeTypeFromUrl(url) { return EXTENSION_MIME_MAP[extension] || ""; } -/** - * Convert a raw Nostr event into bitvid's canonical "video" object. - * - * The converter intentionally centralises all of the quirky legacy handling so - * that feed rendering, subscriptions, and deep links rely on the exact same - * rules. Any future regression around magnet-only posts or malformed JSON - * should be solved by updating this function (and its tests) instead of - * sprinkling ad-hoc checks elsewhere in the UI. - * - * Also accepts legacy ( (typeof value === "string" ? value.trim() : ""); - - const rawContent = typeof event.content === "string" ? event.content : ""; - const tags = Array.isArray(event.tags) ? event.tags : []; - - let parsedContent = {}; - let parseError = null; - if (rawContent) { - try { - const parsed = JSON.parse(rawContent); - if (parsed && typeof parsed === "object") { - parsedContent = parsed; - } - } catch (err) { - parseError = err; - parsedContent = {}; - } - } - - const directUrl = safeTrim(parsedContent.url); - const directMagnetRaw = safeTrim(parsedContent.magnet); - - const normalizeMagnetCandidate = (value) => { - if (typeof value !== "string") { - return ""; - } - const trimmed = value.trim(); - if (!trimmed) { - return ""; - } - if (trimmed.toLowerCase().startsWith("magnet:?")) { - return trimmed; - } - const extracted = magnetFromText(trimmed); - return extracted ? extracted.trim() : ""; - }; - - let magnet = normalizeMagnetCandidate(directMagnetRaw); - let rawMagnet = magnet ? directMagnetRaw : ""; - - if (!magnet && ACCEPT_LEGACY_V1) { - const inlineMagnet = normalizeMagnetCandidate(rawContent); - if (inlineMagnet) { - magnet = inlineMagnet; - } - - if (!magnet) { - outer: for (const tag of tags) { - if (!Array.isArray(tag) || tag.length < 2) { - continue; - } - - const key = - typeof tag[0] === "string" ? tag[0].trim().toLowerCase() : ""; - - const startIndex = key === "magnet" ? 1 : 0; - for (let i = startIndex; i < tag.length; i += 1) { - const candidate = normalizeMagnetCandidate(tag[i]); - if (candidate) { - magnet = candidate; - break outer; - } - } - } - } - - if (!magnet) { - const recoveredFromRaw = magnetFromText(rawContent); - if (recoveredFromRaw) { - magnet = safeTrim(recoveredFromRaw); - } - } - } - - if (!rawMagnet && magnet) { - rawMagnet = magnet; - } - - const url = directUrl; - - if (!url && !magnet) { - return { id: event.id, invalid: true, reason: "missing playable source" }; - } - - const thumbnail = safeTrim(parsedContent.thumbnail); - const description = safeTrim(parsedContent.description); - const rawMode = safeTrim(parsedContent.mode); - const mode = rawMode || "live"; - const deleted = parsedContent.deleted === true; - const isPrivate = parsedContent.isPrivate === true; - const isNsfw = parsedContent.isNsfw === true; - const isForKids = parsedContent.isForKids === true && !isNsfw; - const videoRootId = safeTrim(parsedContent.videoRootId) || event.id; - const wsField = safeTrim(parsedContent.ws); - const xsField = safeTrim(parsedContent.xs); - const enableComments = - parsedContent.enableComments === false ? false : true; - - let infoHash = ""; - const pushInfoHash = (candidate) => { - if (typeof candidate !== "string") { - return false; - } - const normalized = candidate.trim().toLowerCase(); - if (/^[0-9a-f]{40}$/.test(normalized)) { - infoHash = normalized; - return true; - } - return false; - }; - - pushInfoHash(parsedContent.infoHash); - - if (!infoHash && magnet) { - const match = magnet.match(/xt=urn:btih:([0-9a-z]+)/i); - if (match && match[1]) { - pushInfoHash(match[1]); - } - } - - const searchInfoHashInString = (value) => { - if (infoHash || typeof value !== "string") { - return; - } - const match = value.match(/[0-9a-f]{40}/i); - if (match && match[0]) { - pushInfoHash(match[0]); - } - }; - - if (!infoHash && ACCEPT_LEGACY_V1) { - searchInfoHashInString(rawContent); - for (const tag of tags) { - if (infoHash) { - break; - } - if (!Array.isArray(tag)) { - continue; - } - for (let i = 0; i < tag.length; i += 1) { - searchInfoHashInString(tag[i]); - if (infoHash) { - break; - } - } - } - } - - const declaredTitle = safeTrim(parsedContent.title); - const derivedTitle = deriveTitleFromEvent({ - parsedContent, - tags, - primaryTitle: declaredTitle, - }); - - let title = safeTrim(derivedTitle); - if (!title && ACCEPT_LEGACY_V1 && (magnet || infoHash)) { - title = infoHash - ? `Legacy Video ${infoHash.slice(0, 8)}` - : "Legacy BitTorrent Video"; - } - - if (!title) { - const reason = parseError - ? "missing title (json parse error)" - : "missing title"; - return { id: event.id, invalid: true, reason }; - } - - const rawVersion = parsedContent.version; - let version = rawVersion === undefined ? 2 : Number(rawVersion); - if (!Number.isFinite(version)) { - version = rawVersion === undefined ? 2 : 1; - } - - if (version < 2 && !ACCEPT_LEGACY_V1) { - return { - id: event.id, - invalid: true, - reason: `unsupported version ${version}`, - }; - } - - const magnetHints = magnet - ? extractMagnetHints(magnet) - : { ws: "", xs: "" }; - const ws = wsField || magnetHints.ws || ""; - const xs = xsField || magnetHints.xs || ""; - - return { - id: event.id, - videoRootId, - version, - isPrivate, - isNsfw, - isForKids, - title, - url, - magnet, - rawMagnet, - infoHash, - thumbnail, - description, - mode, - deleted, - ws, - xs, - enableComments, - pubkey: event.pubkey, - created_at: event.created_at, - tags, - invalid: false, - }; -} - -/** - * If the video has videoRootId => use that as the “group key”. - * Otherwise fallback to (pubkey + dTag), or if no dTag => “LEGACY:id” - */ function getActiveKey(video) { if (video.videoRootId) { return `ROOT:${video.videoRootId}`; @@ -2032,7 +1049,13 @@ function getActiveKey(video) { return `LEGACY:${video.id}`; } -export { convertEventToVideo, Nip46RpcClient }; +export { + convertEventToVideo, + Nip46RpcClient, + buildNip71MetadataTags, + buildNip71VideoEvent, + extractNip71MetadataFromTags, +}; export class NostrClient { constructor() { @@ -6551,266 +5574,32 @@ export class NostrClient { return sub; } - collectNip71PointerRequests(videos = []) { - const pointerMap = new Map(); - if (!Array.isArray(videos)) { - return pointerMap; - } - - videos.forEach((video) => { - if (!video || typeof video !== "object") { - return; - } - - const rootId = typeof video.videoRootId === "string" ? video.videoRootId : ""; - const pubkey = - typeof video.pubkey === "string" ? video.pubkey.toLowerCase() : ""; - if (!rootId || !pubkey) { - return; - } - - const pointerValue = buildVideoPointerValue(pubkey, rootId); - if (!pointerValue) { - return; - } - - let info = pointerMap.get(pointerValue); - if (!info) { - info = { - videoRootId: rootId, - pointerValue, - videoEventIds: new Set(), - dTags: new Set(), - }; - pointerMap.set(pointerValue, info); - } - - if (typeof video.id === "string" && video.id) { - info.videoEventIds.add(video.id); - } - - const dTag = getDTagValueFromTags(video.tags); - if (dTag) { - info.dTags.add(dTag); - } - }); - - return pointerMap; - } - - ensureNip71CacheEntry(videoRootId) { - const rootId = typeof videoRootId === "string" ? videoRootId : ""; - if (!rootId) { - return null; - } - - let entry = this.nip71Cache.get(rootId); - if (!entry) { - entry = { - byVideoEventId: new Map(), - byDTag: new Map(), - fallback: null, - fetchedPointers: new Set(), - }; - this.nip71Cache.set(rootId, entry); - } - return entry; - } - - storeNip71RecordForRoot(videoRootId, parsedRecord) { - const entry = this.ensureNip71CacheEntry(videoRootId); - if (!entry || !parsedRecord || !parsedRecord.metadata) { - return; - } - - const storedRecord = { - metadata: cloneNip71Metadata(parsedRecord.metadata), - nip71EventId: parsedRecord.source?.id || "", - created_at: parsedRecord.source?.created_at || 0, - pointerValues: new Set(parsedRecord.pointers?.pointerValues || []), - videoEventIds: new Set(parsedRecord.pointers?.videoEventIds || []), - dTags: new Set(parsedRecord.pointers?.dTags || []), - }; - - if (!storedRecord.metadata) { - return; - } - - storedRecord.pointerValues.forEach((pointerValue) => { - if (pointerValue) { - entry.fetchedPointers.add(pointerValue); - } - }); - - storedRecord.videoEventIds.forEach((eventId) => { - if (eventId) { - entry.byVideoEventId.set(eventId, storedRecord); - } - }); - - storedRecord.dTags.forEach((dTag) => { - if (dTag) { - entry.byDTag.set(dTag, storedRecord); - } - }); - - if ( - !entry.fallback || - (storedRecord.created_at || 0) >= (entry.fallback.created_at || 0) - ) { - entry.fallback = storedRecord; - } - } - processNip71Events(events, pointerMap = null) { - if (!Array.isArray(events) || !events.length) { - return; - } - - events.forEach((event) => { - const parsed = extractNip71MetadataFromTags(event); - if (!parsed || !parsed.metadata) { - return; - } - - const rootIds = new Set(parsed.pointers?.videoRootIds || []); - if (!rootIds.size && pointerMap instanceof Map) { - parsed.pointers?.pointerValues?.forEach?.((pointerValue) => { - const info = pointerMap.get(pointerValue); - if (info?.videoRootId) { - rootIds.add(info.videoRootId); - } - }); - } - - if (!rootIds.size) { - return; - } - - rootIds.forEach((rootId) => { - this.storeNip71RecordForRoot(rootId, parsed); - }); + processNip71EventsHelper(events, { + nip71Cache: this.nip71Cache, + pointerMap, }); } mergeNip71MetadataIntoVideo(video) { - if (!video || typeof video !== "object") { - return video; + return mergeNip71MetadataIntoVideoHelper(video, { + nip71Cache: this.nip71Cache, + }); + } + + async populateNip71MetadataForVideos(videos = []) { + if (!Array.isArray(videos) || !videos.length) { + return; } - const rootId = typeof video.videoRootId === "string" ? video.videoRootId : ""; - if (!rootId) { - return video; - } + const pointerMap = collectNip71PointerRequests(videos); - const cacheEntry = this.nip71Cache.get(rootId); - - let appliedMetadata = null; - let sourceEventId = ""; - let sourceCreatedAt = 0; - - if (cacheEntry) { - let record = null; - const eventId = typeof video.id === "string" ? video.id : ""; - if (eventId && cacheEntry.byVideoEventId.has(eventId)) { - record = cacheEntry.byVideoEventId.get(eventId); - } - - if (!record) { - const dTag = getDTagValueFromTags(video.tags); - if (dTag && cacheEntry.byDTag.has(dTag)) { - record = cacheEntry.byDTag.get(dTag); - } - } - - if (!record && cacheEntry.fallback) { - record = cacheEntry.fallback; - } - - if (record?.metadata) { - const cloned = cloneNip71Metadata(record.metadata); - if (cloned) { - appliedMetadata = cloned; - sourceEventId = record.nip71EventId || ""; - sourceCreatedAt = Number.isFinite(record.created_at) - ? Math.floor(record.created_at) - : 0; - } - } - } - - if (!appliedMetadata) { - const extracted = extractNip71MetadataFromTags(video); - if (extracted?.metadata) { - const cloned = cloneNip71Metadata(extracted.metadata); - if (cloned) { - if (Array.isArray(video.tags)) { - const hasVideoTopicTag = video.tags.some( - (tag) => Array.isArray(tag) && tag[0] === "t" && tag[1] === "video" - ); - if (hasVideoTopicTag) { - if (Array.isArray(cloned.hashtags)) { - const filteredHashtags = cloned.hashtags.filter((value) => { - if (typeof value !== "string") { - return false; - } - const trimmed = value.trim(); - return trimmed && trimmed.toLowerCase() !== "video"; - }); - if (filteredHashtags.length) { - cloned.hashtags = filteredHashtags; - } else { - delete cloned.hashtags; - } - } - - if (Array.isArray(cloned.t)) { - const filteredTopics = cloned.t.filter((value) => { - if (typeof value !== "string") { - return false; - } - const trimmed = value.trim(); - return trimmed && trimmed.toLowerCase() !== "video"; - }); - if (filteredTopics.length) { - cloned.t = filteredTopics; - } else { - delete cloned.t; - } - } - } - } - - appliedMetadata = cloned; - const extractedSource = extracted.source || {}; - const fallbackId = typeof video.id === "string" ? video.id : ""; - sourceEventId = extractedSource.id || fallbackId; - const candidateCreatedAt = Number.isFinite(extractedSource.created_at) - ? Math.floor(extractedSource.created_at) - : Number.isFinite(video.created_at) - ? Math.floor(video.created_at) - : 0; - sourceCreatedAt = candidateCreatedAt; - } - } - } - - if (appliedMetadata) { - video.nip71 = appliedMetadata; - video.nip71Source = { - eventId: sourceEventId, - created_at: sourceCreatedAt, - }; - } else { - if (video.nip71) { - delete video.nip71; - } - if (video.nip71Source) { - delete video.nip71Source; - } - } - - return video; + await populateNip71MetadataForVideosHelper(videos, { + nip71Cache: this.nip71Cache, + pointerMap, + fetchMetadata: (map, pointerValues) => + this.fetchAndCacheNip71Metadata(map, pointerValues), + }); } async fetchAndCacheNip71Metadata(pointerMap, pointerValues) { @@ -6819,16 +5608,6 @@ export class NostrClient { } if (!this.pool || !Array.isArray(this.relays) || !this.relays.length) { - pointerValues.forEach((pointerValue) => { - const info = pointerMap.get(pointerValue); - if (!info) { - return; - } - const entry = this.ensureNip71CacheEntry(info.videoRootId); - if (entry) { - entry.fetchedPointers.add(pointerValue); - } - }); return; } @@ -6843,14 +5622,14 @@ export class NostrClient { try { const events = await this.pool.list([url], [filter]); return Array.isArray(events) ? events : []; - } catch (error) { - devLogger.warn( - `[nostr] NIP-71 fetch failed on ${url}:`, - error, - ); - return []; - } - }) + } catch (error) { + devLogger.warn( + `[nostr] NIP-71 fetch failed on ${url}:`, + error, + ); + return []; + } + }), ); const deduped = new Map(); @@ -6860,73 +5639,15 @@ export class NostrClient { } }); - this.processNip71Events(Array.from(deduped.values()), pointerMap); + processNip71EventsHelper(Array.from(deduped.values()), { + nip71Cache: this.nip71Cache, + pointerMap, + }); } catch (error) { devLogger.warn("[nostr] Failed to fetch NIP-71 metadata:", error); - } finally { - pointerValues.forEach((pointerValue) => { - const info = pointerMap.get(pointerValue); - if (!info) { - return; - } - const entry = this.ensureNip71CacheEntry(info.videoRootId); - if (entry) { - entry.fetchedPointers.add(pointerValue); - } - }); } } - async populateNip71MetadataForVideos(videos = []) { - if (!Array.isArray(videos) || !videos.length) { - return; - } - - const pointerMap = this.collectNip71PointerRequests(videos); - const pointersToFetch = []; - - pointerMap.forEach((info, pointerValue) => { - const entry = this.ensureNip71CacheEntry(info.videoRootId); - if (!entry) { - return; - } - - let needsFetch = false; - - info.videoEventIds.forEach((eventId) => { - if (eventId && !entry.byVideoEventId.has(eventId)) { - needsFetch = true; - } - }); - - if (!needsFetch) { - info.dTags.forEach((dTag) => { - if (dTag && !entry.byDTag.has(dTag)) { - needsFetch = true; - } - }); - } - - if (!needsFetch && !entry.fallback) { - needsFetch = true; - } - - if (needsFetch && !entry.fetchedPointers.has(pointerValue)) { - pointersToFetch.push(pointerValue); - } else { - entry.fetchedPointers.add(pointerValue); - } - }); - - if (pointersToFetch.length) { - await this.fetchAndCacheNip71Metadata(pointerMap, pointersToFetch); - } - - videos.forEach((video) => { - this.mergeNip71MetadataIntoVideo(video); - }); - } - /** * fetchVideos => old approach */ diff --git a/js/nostr/nip71.js b/js/nostr/nip71.js new file mode 100644 index 00000000..26bc5fc9 --- /dev/null +++ b/js/nostr/nip71.js @@ -0,0 +1,1320 @@ +import { ACCEPT_LEGACY_V1 } from "../constants.js"; +import { deriveTitleFromEvent, magnetFromText } from "../videoEventUtils.js"; +import { extractMagnetHints } from "../magnet.js"; +import { devLogger } from "../utils/logger.js"; + +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 dim = stringFromInput(variant.dim); + const url = stringFromInput(variant.url); + const x = stringFromInput(variant.x); + const mime = stringFromInput(variant.m); + const image = Array.isArray(variant.image) + ? variant.image.map(stringFromInput).filter(Boolean) + : []; + const fallback = Array.isArray(variant.fallback) + ? variant.fallback.map(stringFromInput).filter(Boolean) + : []; + const service = Array.isArray(variant.service) + ? variant.service.map(stringFromInput).filter(Boolean) + : []; + + if (!dim && !url && !x && !mime && !image.length && !fallback.length && !service.length) { + continue; + } + + const entries = ["imeta"]; + if (dim) { + entries.push(`dim ${dim}`); + } + if (url) { + entries.push(`url ${url}`); + } + if (x) { + entries.push(`x ${x}`); + } + if (mime) { + entries.push(`m ${mime}`); + } + image.forEach((value) => entries.push(`image ${value}`)); + fallback.forEach((value) => entries.push(`fallback ${value}`)); + service.forEach((value) => entries.push(`service ${value}`)); + + 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; +} + +export function buildNip71MetadataTags(metadata) { + if (!metadata || typeof metadata !== "object") { + return []; + } + + const tags = []; + + const normalizedTitle = stringFromInput(metadata.title); + if (normalizedTitle) { + 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]); + }); + } + + return tags; +} + +function parseKeyValuePair(entry) { + if (typeof entry !== "string") { + return null; + } + const trimmed = entry.trim(); + if (!trimmed) { + return null; + } + const spaceIndex = trimmed.indexOf(" "); + if (spaceIndex === -1) { + return null; + } + const key = trimmed.slice(0, spaceIndex).trim().toLowerCase(); + const value = trimmed.slice(spaceIndex + 1).trim(); + if (!key || !value) { + return null; + } + return { key, value }; +} + +function parseImetaTag(tag) { + if (!Array.isArray(tag) || tag[0] !== "imeta") { + return null; + } + + const variant = { + image: [], + fallback: [], + service: [], + }; + + for (let i = 1; i < tag.length; i += 1) { + const parsed = parseKeyValuePair(tag[i]); + if (!parsed) { + continue; + } + + switch (parsed.key) { + case "dim": + variant.dim = parsed.value; + break; + case "url": + variant.url = parsed.value; + break; + case "x": + variant.x = parsed.value; + break; + case "m": + variant.m = parsed.value; + break; + case "image": + variant.image.push(parsed.value); + break; + case "fallback": + variant.fallback.push(parsed.value); + break; + case "service": + variant.service.push(parsed.value); + break; + default: + break; + } + } + + const hasContent = + Boolean(variant.dim) || + Boolean(variant.url) || + Boolean(variant.x) || + Boolean(variant.m) || + variant.image.length > 0 || + variant.fallback.length > 0 || + variant.service.length > 0; + + if (!hasContent) { + return null; + } + + if (!variant.image.length) { + delete variant.image; + } + if (!variant.fallback.length) { + delete variant.fallback; + } + if (!variant.service.length) { + delete variant.service; + } + + return variant; +} + +function parseTextTrackTag(tag) { + if (!Array.isArray(tag) || tag[0] !== "text-track") { + return null; + } + const url = stringFromInput(tag[1]); + const type = stringFromInput(tag[2]); + const language = stringFromInput(tag[3]); + if (!url && !type && !language) { + return null; + } + const track = {}; + if (url) { + track.url = url; + } + if (type) { + track.type = type; + } + if (language) { + track.language = language; + } + return track; +} + +function parseSegmentTag(tag) { + if (!Array.isArray(tag) || tag[0] !== "segment") { + return null; + } + + const values = []; + for (let i = 1; i < tag.length; i += 1) { + const value = stringFromInput(tag[i]); + values.push(value); + } + + while (values.length < 4) { + values.push(""); + } + + const [start, end, title, thumbnail] = values; + const hasContent = start || end || title || thumbnail; + if (!hasContent) { + return null; + } + const segment = {}; + if (start) { + segment.start = start; + } + if (end) { + segment.end = end; + } + if (title) { + segment.title = title; + } + if (thumbnail) { + segment.thumbnail = thumbnail; + } + return segment; +} + +function parseParticipantTag(tag) { + if (!Array.isArray(tag) || tag[0] !== "p") { + return null; + } + const pubkey = stringFromInput(tag[1]); + if (!pubkey) { + return null; + } + const participant = { pubkey }; + const relay = stringFromInput(tag[2]); + if (relay) { + participant.relay = relay; + } + return participant; +} + +function parseReferenceTag(tag) { + if (!Array.isArray(tag) || tag[0] !== "r") { + return null; + } + const url = stringFromInput(tag[1]); + return url ? url : null; +} + +function parseHashtagTag(tag) { + if (!Array.isArray(tag) || tag[0] !== "t") { + return null; + } + const value = stringFromInput(tag[1]); + return value || null; +} + +export function extractNip71MetadataFromTags(event) { + if (!event || typeof event !== "object") { + return null; + } + + const tags = Array.isArray(event.tags) ? event.tags : []; + const metadata = { kind: normalizeNip71Kind(event.kind) }; + + const summary = stringFromInput(event.content); + if (summary) { + metadata.summary = summary; + } + + const imeta = []; + const textTracks = []; + const segments = []; + const hashtags = []; + const participants = []; + const references = []; + + const pointerValues = new Set(); + const videoRootIds = new Set(); + const videoEventIds = new Set(); + const dTags = new Set(); + + tags.forEach((tag) => { + if (!Array.isArray(tag) || tag.length < 2) { + return; + } + + const name = tag[0]; + switch (name) { + case "title": { + const value = stringFromInput(tag[1]); + if (value) { + metadata.title = value; + } + break; + } + case "published_at": { + const value = stringFromInput(tag[1]); + if (value) { + metadata.publishedAt = value; + } + break; + } + case "alt": { + const value = stringFromInput(tag[1]); + if (value) { + metadata.alt = value; + } + break; + } + case "duration": { + const value = stringFromInput(tag[1]); + if (value) { + const parsed = Number(value); + metadata.duration = Number.isFinite(parsed) ? parsed : value; + } + break; + } + case "content-warning": { + const value = stringFromInput(tag[1]); + if (value) { + metadata.contentWarning = value; + } + break; + } + case "imeta": { + const variant = parseImetaTag(tag); + if (variant) { + imeta.push(variant); + } + break; + } + case "text-track": { + const track = parseTextTrackTag(tag); + if (track) { + textTracks.push(track); + } + break; + } + case "segment": { + const segment = parseSegmentTag(tag); + if (segment) { + segments.push(segment); + } + break; + } + case "t": { + const hashtag = parseHashtagTag(tag); + if (hashtag) { + hashtags.push(hashtag); + } + break; + } + case "p": { + const participant = parseParticipantTag(tag); + if (participant) { + participants.push(participant); + } + break; + } + case "r": { + const reference = parseReferenceTag(tag); + if (reference) { + references.push(reference); + } + break; + } + case "a": { + const pointerValue = stringFromInput(tag[1]).toLowerCase(); + if (pointerValue) { + pointerValues.add(pointerValue); + const parts = pointerValue.split(":"); + if (parts.length === 3 && parts[0] === "30078") { + const root = parts[2]; + if (root) { + videoRootIds.add(root); + } + } + } + break; + } + case "video-root": { + const value = stringFromInput(tag[1]); + if (value) { + videoRootIds.add(value); + } + break; + } + case "e": { + const value = stringFromInput(tag[1]); + if (value) { + videoEventIds.add(value); + } + break; + } + case "d": { + const value = stringFromInput(tag[1]); + if (value) { + dTags.add(value); + } + break; + } + default: + break; + } + }); + + if (imeta.length) { + metadata.imeta = imeta; + } + if (textTracks.length) { + metadata.textTracks = textTracks; + } + if (segments.length) { + metadata.segments = segments; + } + if (hashtags.length) { + metadata.hashtags = hashtags; + metadata.t = hashtags; + } + if (participants.length) { + metadata.participants = participants; + } + if (references.length) { + metadata.references = references; + } + + return { + metadata, + pointers: { + pointerValues, + videoRootIds, + videoEventIds, + dTags, + }, + source: { + id: typeof event.id === "string" ? event.id : "", + created_at: Number.isFinite(event.created_at) ? event.created_at : null, + kind: Number.isFinite(event.kind) ? event.kind : normalizeNip71Kind(event.kind), + }, + }; +} + +function cloneNip71Metadata(metadata) { + if (!metadata || typeof metadata !== "object") { + return null; + } + try { + return JSON.parse(JSON.stringify(metadata)); + } catch (error) { + devLogger.warn("[nostr] Failed to clone NIP-71 metadata", error); + return { ...metadata }; + } +} + +export function getDTagValueFromTags(tags) { + if (!Array.isArray(tags)) { + return ""; + } + for (const tag of tags) { + if (!Array.isArray(tag) || tag[0] !== "d") { + continue; + } + if (typeof tag[1] === "string" && tag[1]) { + return tag[1]; + } + } + return ""; +} + +export function buildVideoPointerValue(pubkey, videoRootId) { + const normalizedRoot = stringFromInput(videoRootId); + const normalizedPubkey = stringFromInput(pubkey).toLowerCase(); + if (!normalizedRoot || !normalizedPubkey) { + return ""; + } + return `30078:${normalizedPubkey}:${normalizedRoot}`; +} + +export function buildNip71PointerTags({ + pubkey = "", + videoRootId = "", + videoEventId = "", + dTag = "", +} = {}) { + const pointerTags = []; + + const normalizedRoot = stringFromInput(videoRootId); + const normalizedEventId = stringFromInput(videoEventId); + const normalizedDTag = stringFromInput(dTag); + + if (normalizedRoot) { + const pointerValue = buildVideoPointerValue(pubkey, normalizedRoot); + if (pointerValue) { + pointerTags.push(["a", pointerValue]); + } + pointerTags.push(["video-root", normalizedRoot]); + } + + if (normalizedEventId) { + pointerTags.push(["e", normalizedEventId]); + } + + if (normalizedDTag) { + pointerTags.push(["d", normalizedDTag]); + } + + return pointerTags; +} + +export function buildNip71VideoEvent({ + metadata, + pubkey = "", + title, + summaryFallback = "", + createdAt = Math.floor(Date.now() / 1000), + pointerIdentifiers = {}, +} = {}) { + 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 = buildNip71MetadataTags({ + ...metadata, + title: normalizedTitle, + }); + + if (!tags.length) { + return null; + } + + const normalizedPubkey = stringFromInput(pubkey); + const timestamp = Number.isFinite(createdAt) + ? Math.floor(createdAt) + : Math.floor(Date.now() / 1000); + + const pointerTags = buildNip71PointerTags({ + pubkey, + videoRootId: pointerIdentifiers.videoRootId, + videoEventId: pointerIdentifiers.eventId, + dTag: pointerIdentifiers.dTag, + }); + + if (pointerTags.length) { + tags.push(...pointerTags); + } + + return { + kind: normalizeNip71Kind(metadata.kind), + pubkey: normalizedPubkey, + created_at: timestamp, + tags, + content: summary, + }; +} + +export function collectNip71PointerRequests(videos = []) { + const pointerMap = new Map(); + if (!Array.isArray(videos)) { + return pointerMap; + } + + videos.forEach((video) => { + if (!video || typeof video !== "object") { + return; + } + + const rootId = typeof video.videoRootId === "string" ? video.videoRootId : ""; + const pubkey = typeof video.pubkey === "string" ? video.pubkey.toLowerCase() : ""; + if (!rootId || !pubkey) { + return; + } + + const pointerValue = buildVideoPointerValue(pubkey, rootId); + if (!pointerValue) { + return; + } + + let info = pointerMap.get(pointerValue); + if (!info) { + info = { + videoRootId: rootId, + pointerValue, + videoEventIds: new Set(), + dTags: new Set(), + }; + pointerMap.set(pointerValue, info); + } + + if (typeof video.id === "string" && video.id) { + info.videoEventIds.add(video.id); + } + + const dTag = getDTagValueFromTags(video.tags); + if (dTag) { + info.dTags.add(dTag); + } + }); + + return pointerMap; +} + +function ensureNip71CacheEntry(nip71Cache, videoRootId) { + if (!(nip71Cache instanceof Map)) { + return null; + } + const rootId = typeof videoRootId === "string" ? videoRootId : ""; + if (!rootId) { + return null; + } + + let entry = nip71Cache.get(rootId); + if (!entry) { + entry = { + byVideoEventId: new Map(), + byDTag: new Map(), + fallback: null, + fetchedPointers: new Set(), + }; + nip71Cache.set(rootId, entry); + } + return entry; +} + +function storeNip71RecordForRoot(nip71Cache, videoRootId, parsedRecord) { + const entry = ensureNip71CacheEntry(nip71Cache, videoRootId); + if (!entry || !parsedRecord || !parsedRecord.metadata) { + return; + } + + const storedRecord = { + metadata: cloneNip71Metadata(parsedRecord.metadata), + nip71EventId: parsedRecord.source?.id || "", + created_at: parsedRecord.source?.created_at || 0, + pointerValues: new Set(parsedRecord.pointers?.pointerValues || []), + videoEventIds: new Set(parsedRecord.pointers?.videoEventIds || []), + dTags: new Set(parsedRecord.pointers?.dTags || []), + }; + + if (!storedRecord.metadata) { + return; + } + + storedRecord.pointerValues.forEach((pointerValue) => { + if (pointerValue) { + entry.fetchedPointers.add(pointerValue); + } + }); + + storedRecord.videoEventIds.forEach((eventId) => { + if (eventId) { + entry.byVideoEventId.set(eventId, storedRecord); + } + }); + + storedRecord.dTags.forEach((dTag) => { + if (dTag) { + entry.byDTag.set(dTag, storedRecord); + } + }); + + if ( + !entry.fallback || + (storedRecord.created_at || 0) >= (entry.fallback.created_at || 0) + ) { + entry.fallback = storedRecord; + } +} + +export function processNip71Events(events, { nip71Cache, pointerMap = null } = {}) { + if (!Array.isArray(events) || !events.length || !(nip71Cache instanceof Map)) { + return; + } + + events.forEach((event) => { + const parsed = extractNip71MetadataFromTags(event); + if (!parsed || !parsed.metadata) { + return; + } + + const rootIds = new Set(parsed.pointers?.videoRootIds || []); + if (!rootIds.size && pointerMap instanceof Map) { + const pointerValues = parsed.pointers?.pointerValues; + if (pointerValues instanceof Set) { + pointerValues.forEach((pointerValue) => { + const info = pointerMap.get(pointerValue); + if (info?.videoRootId) { + rootIds.add(info.videoRootId); + } + }); + } + } + + if (!rootIds.size) { + return; + } + + rootIds.forEach((rootId) => { + storeNip71RecordForRoot(nip71Cache, rootId, parsed); + }); + }); +} + +export function mergeNip71MetadataIntoVideo(video, { nip71Cache } = {}) { + if (!video || typeof video !== "object") { + return video; + } + + const rootId = typeof video.videoRootId === "string" ? video.videoRootId : ""; + if (!rootId) { + return video; + } + + const cacheEntry = nip71Cache instanceof Map ? nip71Cache.get(rootId) : null; + + let appliedMetadata = null; + let sourceEventId = ""; + let sourceCreatedAt = 0; + + if (cacheEntry) { + let record = null; + const eventId = typeof video.id === "string" ? video.id : ""; + if (eventId && cacheEntry.byVideoEventId.has(eventId)) { + record = cacheEntry.byVideoEventId.get(eventId); + } + + if (!record) { + const dTag = getDTagValueFromTags(video.tags); + if (dTag && cacheEntry.byDTag.has(dTag)) { + record = cacheEntry.byDTag.get(dTag); + } + } + + if (!record && cacheEntry.fallback) { + record = cacheEntry.fallback; + } + + if (record?.metadata) { + const cloned = cloneNip71Metadata(record.metadata); + if (cloned) { + appliedMetadata = cloned; + sourceEventId = record.nip71EventId || ""; + sourceCreatedAt = Number.isFinite(record.created_at) + ? Math.floor(record.created_at) + : 0; + } + } + } + + if (!appliedMetadata) { + const extracted = extractNip71MetadataFromTags(video); + if (extracted?.metadata) { + const cloned = cloneNip71Metadata(extracted.metadata); + if (cloned) { + appliedMetadata = cloned; + const extractedSource = extracted.source || {}; + const fallbackId = typeof video.id === "string" ? video.id : ""; + sourceEventId = extractedSource.id || fallbackId; + const candidateCreatedAt = Number.isFinite(extractedSource.created_at) + ? Math.floor(extractedSource.created_at) + : Number.isFinite(video.created_at) + ? Math.floor(video.created_at) + : 0; + sourceCreatedAt = candidateCreatedAt; + } + } + } + + if (appliedMetadata && Array.isArray(video.tags)) { + const hasVideoTopicTag = video.tags.some( + (tag) => Array.isArray(tag) && tag[0] === "t" && tag[1] === "video", + ); + if (hasVideoTopicTag) { + if (Array.isArray(appliedMetadata.hashtags)) { + const filteredHashtags = appliedMetadata.hashtags.filter((value) => { + if (typeof value !== "string") { + return false; + } + const trimmed = value.trim(); + return trimmed && trimmed.toLowerCase() !== "video"; + }); + if (filteredHashtags.length) { + appliedMetadata.hashtags = filteredHashtags; + } else { + delete appliedMetadata.hashtags; + } + } + + if (Array.isArray(appliedMetadata.t)) { + const filteredTopics = appliedMetadata.t.filter((value) => { + if (typeof value !== "string") { + return false; + } + const trimmed = value.trim(); + return trimmed && trimmed.toLowerCase() !== "video"; + }); + if (filteredTopics.length) { + appliedMetadata.t = filteredTopics; + } else { + delete appliedMetadata.t; + } + } + } + } + + if (appliedMetadata) { + video.nip71 = appliedMetadata; + video.nip71Source = { + eventId: sourceEventId, + created_at: sourceCreatedAt, + }; + } else { + if (video.nip71) { + delete video.nip71; + } + if (video.nip71Source) { + delete video.nip71Source; + } + } + + return video; +} + +export async function populateNip71MetadataForVideos(videos = [], { + nip71Cache, + pointerMap = null, + fetchMetadata, +} = {}) { + if (!Array.isArray(videos) || !videos.length || !(nip71Cache instanceof Map)) { + return; + } + + const effectivePointerMap = + pointerMap instanceof Map ? pointerMap : collectNip71PointerRequests(videos); + + const pointersToFetch = []; + + effectivePointerMap.forEach((info, pointerValue) => { + const entry = ensureNip71CacheEntry(nip71Cache, info.videoRootId); + if (!entry) { + return; + } + + let needsFetch = false; + + info.videoEventIds.forEach((eventId) => { + if (eventId && !entry.byVideoEventId.has(eventId)) { + needsFetch = true; + } + }); + + if (!needsFetch) { + info.dTags.forEach((dTag) => { + if (dTag && !entry.byDTag.has(dTag)) { + needsFetch = true; + } + }); + } + + if (!needsFetch && !entry.fallback) { + needsFetch = true; + } + + if (needsFetch && !entry.fetchedPointers.has(pointerValue)) { + pointersToFetch.push(pointerValue); + } else { + entry.fetchedPointers.add(pointerValue); + } + }); + + if (pointersToFetch.length && typeof fetchMetadata === "function") { + await fetchMetadata(effectivePointerMap, pointersToFetch); + } + + effectivePointerMap.forEach((info, pointerValue) => { + const entry = ensureNip71CacheEntry(nip71Cache, info.videoRootId); + if (entry) { + entry.fetchedPointers.add(pointerValue); + } + }); + + videos.forEach((video) => { + mergeNip71MetadataIntoVideo(video, { nip71Cache }); + }); +} + +const EXTENSION_MIME_MAP = { + mp4: "video/mp4", + m4v: "video/x-m4v", + webm: "video/webm", + mkv: "video/x-matroska", + mov: "video/quicktime", + avi: "video/x-msvideo", + ogv: "video/ogg", + ogg: "video/ogg", + m3u8: "application/x-mpegURL", + mpd: "application/dash+xml", + ts: "video/mp2t", + mpg: "video/mpeg", + mpeg: "video/mpeg", + flv: "video/x-flv", + "3gp": "video/3gpp", +}; + +function inferMimeTypeFromUrl(url) { + if (!url || typeof url !== "string") { + return ""; + } + + let pathname = ""; + try { + const parsed = new URL(url); + pathname = parsed.pathname || ""; + } catch (err) { + const sanitized = url.split("?")[0].split("#")[0]; + pathname = sanitized || ""; + } + + const lastSegment = pathname.split("/").pop() || ""; + const match = lastSegment.match(/\.([a-z0-9]+)$/i); + if (!match) { + return ""; + } + + const extension = match[1].toLowerCase(); + return EXTENSION_MIME_MAP[extension] || ""; +} + +export function convertEventToVideo(event = {}) { + const safeTrim = (value) => (typeof value === "string" ? value.trim() : ""); + + const rawContent = typeof event.content === "string" ? event.content : ""; + const tags = Array.isArray(event.tags) ? event.tags : []; + + let parsedContent = {}; + let parseError = null; + if (rawContent) { + try { + const parsed = JSON.parse(rawContent); + if (parsed && typeof parsed === "object") { + parsedContent = parsed; + } + } catch (err) { + parseError = err; + parsedContent = {}; + } + } + + const directUrl = safeTrim(parsedContent.url); + const directMagnetRaw = safeTrim(parsedContent.magnet); + + const normalizeMagnetCandidate = (value) => { + if (typeof value !== "string") { + return ""; + } + const trimmed = value.trim(); + if (!trimmed) { + return ""; + } + if (trimmed.toLowerCase().startsWith("magnet:?")) { + return trimmed; + } + const extracted = magnetFromText(trimmed); + return extracted ? extracted.trim() : ""; + }; + + let magnet = normalizeMagnetCandidate(directMagnetRaw); + let rawMagnet = magnet ? directMagnetRaw : ""; + + if (!magnet && ACCEPT_LEGACY_V1) { + const inlineMagnet = normalizeMagnetCandidate(rawContent); + if (inlineMagnet) { + magnet = inlineMagnet; + } + + if (!magnet) { + outer: for (const tag of tags) { + if (!Array.isArray(tag) || tag.length < 2) { + continue; + } + + const key = + typeof tag[0] === "string" ? tag[0].trim().toLowerCase() : ""; + + const startIndex = key === "magnet" ? 1 : 0; + for (let i = startIndex; i < tag.length; i += 1) { + const candidate = normalizeMagnetCandidate(tag[i]); + if (candidate) { + magnet = candidate; + break outer; + } + } + } + } + + if (!magnet) { + const recoveredFromRaw = magnetFromText(rawContent); + if (recoveredFromRaw) { + magnet = safeTrim(recoveredFromRaw); + } + } + } + + if (!rawMagnet && magnet) { + rawMagnet = magnet; + } + + const url = directUrl; + + if (!url && !magnet) { + return { id: event.id, invalid: true, reason: "missing playable source" }; + } + + const thumbnail = safeTrim(parsedContent.thumbnail); + const description = safeTrim(parsedContent.description); + const rawMode = safeTrim(parsedContent.mode); + const mode = rawMode || "live"; + const deleted = parsedContent.deleted === true; + const isPrivate = parsedContent.isPrivate === true; + const isNsfw = parsedContent.isNsfw === true; + const isForKids = parsedContent.isForKids === true && !isNsfw; + const videoRootId = safeTrim(parsedContent.videoRootId) || event.id; + const wsField = safeTrim(parsedContent.ws); + const xsField = safeTrim(parsedContent.xs); + const enableComments = + parsedContent.enableComments === false ? false : true; + + let infoHash = ""; + const pushInfoHash = (candidate) => { + if (typeof candidate !== "string") { + return false; + } + const normalized = candidate.trim().toLowerCase(); + if (/^[0-9a-f]{40}$/.test(normalized)) { + infoHash = normalized; + return true; + } + return false; + }; + + pushInfoHash(parsedContent.infoHash); + + if (!infoHash && magnet) { + const match = magnet.match(/xt=urn:btih:([0-9a-z]+)/i); + if (match && match[1]) { + pushInfoHash(match[1]); + } + } + + let derivedTitle = typeof parsedContent.title === "string" ? parsedContent.title : ""; + if (!derivedTitle) { + derivedTitle = deriveTitleFromEvent({ + id: event.id, + pubkey: event.pubkey, + content: rawContent, + tags, + }); + } + + let title = safeTrim(derivedTitle); + if (!title && ACCEPT_LEGACY_V1 && (magnet || infoHash)) { + title = infoHash + ? `Legacy Video ${infoHash.slice(0, 8)}` + : "Legacy BitTorrent Video"; + } + + if (!title) { + const reason = parseError + ? "missing title (json parse error)" + : "missing title"; + return { id: event.id, invalid: true, reason }; + } + + const rawVersion = parsedContent.version; + let version = rawVersion === undefined ? 2 : Number(rawVersion); + if (!Number.isFinite(version)) { + version = rawVersion === undefined ? 2 : 1; + } + + if (version < 2 && !ACCEPT_LEGACY_V1) { + return { + id: event.id, + invalid: true, + reason: `unsupported version ${version}`, + }; + } + + const magnetHints = magnet + ? extractMagnetHints(magnet) + : { ws: "", xs: "" }; + const ws = wsField || magnetHints.ws || ""; + const xs = xsField || magnetHints.xs || ""; + + return { + id: event.id, + videoRootId, + version, + isPrivate, + isNsfw, + isForKids, + title, + url, + magnet, + rawMagnet, + infoHash, + thumbnail, + description, + mode, + deleted, + ws, + xs, + enableComments, + pubkey: event.pubkey, + created_at: event.created_at, + tags, + invalid: false, + }; +} + +export { stringFromInput }; diff --git a/tests/nostr/nip71.test.js b/tests/nostr/nip71.test.js new file mode 100644 index 00000000..d6169e04 --- /dev/null +++ b/tests/nostr/nip71.test.js @@ -0,0 +1,245 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + buildNip71MetadataTags, + buildNip71VideoEvent, + collectNip71PointerRequests, + extractNip71MetadataFromTags, + mergeNip71MetadataIntoVideo, + populateNip71MetadataForVideos, + processNip71Events, + buildVideoPointerValue, +} from "../../js/nostr/nip71.js"; + +test("buildNip71MetadataTags normalizes structured fields", () => { + const metadata = { + title: " Demo Title ", + publishedAt: " 1700000000 ", + alt: " alt text ", + imeta: [ + { + dim: " 1920x1080 ", + url: " https://cdn.example/thumb.jpg ", + image: [" https://cdn.example/fallback-1.jpg ", ""], + fallback: [" https://cdn.example/fallback-2.jpg "], + service: [" thumb "], + }, + ], + duration: " 123 ", + textTracks: [ + { + url: " https://cdn.example/captions.vtt ", + type: " text/vtt ", + language: " en ", + }, + ], + contentWarning: " flashing lights ", + segments: [ + { start: " 00:00 ", end: " 00:30 ", title: " Intro ", thumbnail: " https://cdn.example/intro.jpg " }, + ], + hashtags: [" video ", "nostr ", " "], + participants: [{ pubkey: "A".repeat(64), relay: " wss://relay.example " }], + references: [" https://example.com/info "], + }; + + const tags = buildNip71MetadataTags(metadata); + + assert.ok(tags.some((tag) => tag[0] === "title" && tag[1] === "Demo Title")); + assert.ok(tags.some((tag) => tag[0] === "published_at" && tag[1] === "1700000000")); + assert.ok(tags.some((tag) => tag[0] === "alt" && tag[1] === "alt text")); + assert.ok( + tags.some( + (tag) => + tag[0] === "imeta" && + tag.includes("dim 1920x1080") && + tag.includes("url https://cdn.example/thumb.jpg") && + tag.includes("image https://cdn.example/fallback-1.jpg") && + tag.includes("fallback https://cdn.example/fallback-2.jpg") && + tag.includes("service thumb"), + ), + ); + assert.ok(tags.some((tag) => tag[0] === "duration" && tag[1] === "123")); + assert.ok( + tags.some( + (tag) => + tag[0] === "text-track" && + tag[1] === "https://cdn.example/captions.vtt" && + tag[2] === "text/vtt" && + tag[3] === "en", + ), + ); + assert.ok(tags.some((tag) => tag[0] === "content-warning" && tag[1] === "flashing lights")); + assert.ok( + tags.some( + (tag) => + tag[0] === "segment" && + tag[1] === "00:00" && + tag[2] === "00:30" && + tag[3] === "Intro" && + tag[4] === "https://cdn.example/intro.jpg", + ), + ); + const hashtagValues = tags + .filter((tag) => tag[0] === "t") + .map((tag) => tag[1]) + .sort(); + assert.deepEqual(hashtagValues, ["nostr", "video"].sort()); + assert.ok( + tags.some( + (tag) => + tag[0] === "p" && + tag[1] === "A".repeat(64) && + tag[2] === "wss://relay.example", + ), + ); + assert.ok(tags.some((tag) => tag[0] === "r" && tag[1] === "https://example.com/info")); +}); + +test("collectNip71PointerRequests aggregates events and tags", () => { + const videos = [ + { + id: "evt-1", + videoRootId: "root-1", + pubkey: "PUB1", + tags: [["d", "slug-1"]], + }, + { + id: "evt-2", + videoRootId: "root-1", + pubkey: "PUB1", + tags: [["d", "slug-2"]], + }, + ]; + + const pointerMap = collectNip71PointerRequests(videos); + assert.equal(pointerMap.size, 1); + const [[pointerValue, info]] = Array.from(pointerMap.entries()); + assert.equal(pointerValue, buildVideoPointerValue("pub1", "root-1")); + assert.deepEqual(Array.from(info.videoEventIds).sort(), ["evt-1", "evt-2"].sort()); + assert.deepEqual(Array.from(info.dTags).sort(), ["slug-1", "slug-2"].sort()); +}); + +test("processNip71Events reconciles pointers and filters video hashtags", () => { + const nip71Cache = new Map(); + const video = { + id: "evt-1", + videoRootId: "root-1", + pubkey: "PUB1", + tags: [ + ["d", "slug-1"], + ["t", "video"], + ], + }; + const pointerMap = collectNip71PointerRequests([video]); + const pointerValue = Array.from(pointerMap.keys())[0]; + + const nip71Event = { + id: "nip71-1", + kind: 21, + pubkey: "pub1", + created_at: 1_700_000_001, + tags: [ + ["a", pointerValue], + ["title", " Better Title "], + ["t", "Video"], + ["t", "Other"], + ], + content: "Summary", + }; + + processNip71Events([nip71Event], { nip71Cache, pointerMap }); + mergeNip71MetadataIntoVideo(video, { nip71Cache }); + + assert.ok(video.nip71, "metadata should attach to the video"); + assert.equal(video.nip71.title, "Better Title"); + assert.deepEqual(video.nip71.hashtags, ["Other"]); + assert.deepEqual(video.nip71.t, ["Other"]); + assert.equal(video.nip71Source?.eventId, "nip71-1"); +}); + +test("populateNip71MetadataForVideos fetches missing records once", async () => { + const nip71Cache = new Map(); + const video = { + id: "evt-1", + videoRootId: "root-1", + pubkey: "PUB1", + tags: [["d", "slug-1"], ["t", "video"]], + }; + const pointerMap = collectNip71PointerRequests([video]); + const pointerValue = Array.from(pointerMap.keys())[0]; + + const nip71Event = { + id: "nip71-1", + kind: 21, + pubkey: "pub1", + created_at: 1_700_000_100, + tags: [ + ["a", pointerValue], + ["title", "Stream Title"], + ["t", "Video"], + ["t", "Stream"], + ], + content: "Live summary", + }; + + let fetchCalls = 0; + const fetchMetadata = async (map, pointerValues) => { + fetchCalls += 1; + assert.deepEqual(pointerValues, [pointerValue]); + assert.strictEqual(map, pointerMap); + processNip71Events([nip71Event], { nip71Cache, pointerMap: map }); + }; + + await populateNip71MetadataForVideos([video], { + nip71Cache, + pointerMap, + fetchMetadata, + }); + + assert.equal(fetchCalls, 1); + assert.equal(video.nip71?.title, "Stream Title"); + assert.deepEqual(video.nip71?.hashtags, ["Stream"]); + + const cacheEntry = nip71Cache.get("root-1"); + assert.ok(cacheEntry?.fetchedPointers.has(pointerValue)); + + const pointerMapReload = collectNip71PointerRequests([video]); + await populateNip71MetadataForVideos([video], { + nip71Cache, + pointerMap: pointerMapReload, + fetchMetadata, + }); + + assert.equal(fetchCalls, 1, "cached pointer should skip subsequent fetches"); +}); + +test("buildNip71VideoEvent composes pointer tags", () => { + const metadata = extractNip71MetadataFromTags({ + kind: 21, + tags: [ + ["title", "Demo"], + ["t", "video"], + ["t", "nostr"], + ], + content: "Summary", + }).metadata; + + const event = buildNip71VideoEvent({ + metadata, + pubkey: "PUB1", + title: "Demo", + pointerIdentifiers: { + videoRootId: "root-1", + eventId: "evt-1", + dTag: "slug-1", + }, + }); + + assert.ok(event, "video event should be produced"); + const tags = event.tags; + assert.ok(tags.some((tag) => tag[0] === "a" && tag[1] === buildVideoPointerValue("PUB1", "root-1"))); + assert.ok(tags.some((tag) => tag[0] === "video-root" && tag[1] === "root-1")); + assert.ok(tags.some((tag) => tag[0] === "e" && tag[1] === "evt-1")); + assert.ok(tags.some((tag) => tag[0] === "d" && tag[1] === "slug-1")); +});