Files
bitvid/js/nostr/nip71Mirror.js
T
thePR0M3TH3ANandClaude Opus 4.8 e793b359c3 feat(nip71): mirror carries the video's hashtags (from t tags / nip71 metadata)
Closes the real hashtag gap. Verified the upload modal's existing NIP-71 hashtag
editor (Nip71FormManager → nip71.hashtags → buildNip71MetadataTags) ALREADY emits
`t` tags on the 30078, which bitvid's feed scoring already reads — so publish+feed
hashtags work today. The gap was the 34235 mirror reading only `video.hashtags`
(empty for form-authored videos).

buildNip71MirrorEvent now resolves hashtags from whichever source is present —
video.hashtags, video.nip71.hashtags, or the raw `t` tags on video.tags — deduped,
lowercased, with bitvid's internal "video" topic tag dropped. So a shared video
stays categorized on Nostube/Amethyst regardless of how its tags were authored.

(The earlier content.hashtags builder path from a1bf7b15 stays as a harmless
secondary input; the form/t-tag path is the one users actually exercise.)

tests/nip71-mirror.test.mjs: hashtags sourced from nip71.hashtags; from raw t tags
with the 'video' topic dropped + deduped. Mutation-verified.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-22 11:30:12 -04:00

197 lines
6.5 KiB
JavaScript

// 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";
}
// Hashtags can live in several places depending on how the video was authored:
// bitvid's own `hashtags`, the parsed NIP-71 metadata (`nip71.hashtags`), or the
// raw `t` tags on the 30078 (produced by the upload form's NIP-71 hashtag editor).
// Source from whichever is present so a shared video stays categorized elsewhere.
// Drops bitvid's internal "video" topic tag and dedupes (case-insensitive).
function resolveHashtags(video) {
let raw = [];
if (Array.isArray(video.hashtags) && video.hashtags.length) {
raw = video.hashtags;
} else if (Array.isArray(video.nip71?.hashtags) && video.nip71.hashtags.length) {
raw = video.nip71.hashtags;
} else if (Array.isArray(video.tags)) {
raw = video.tags
.filter((t) => Array.isArray(t) && t[0] === "t" && typeof t[1] === "string")
.map((t) => t[1]);
}
const out = [];
const seen = new Set();
for (const value of raw) {
const tag = str(value).toLowerCase();
if (!tag || tag === "video" || seen.has(tag)) {
continue;
}
seen.add(tag);
out.push(tag);
}
return out;
}
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 = resolveHashtags(video);
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;