mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
feat(nip71): Phase 0 — addressable 34235/36 mirror builder + magnet/i/ox imeta
First build step of the NIP-71 interop plan (no UX; FEATURE_PUBLISH_NIP71 stays
off). Pure mapping from a bitvid video (kind-30078 shape) to an opt-in addressable
NIP-71 mirror event.
- js/nostr/nip71.js (foundational):
- normalizeNip71Kind now recognizes the addressable kinds 34235/34236 (so the
parser preserves them), alongside 21/22.
- imeta builder + parser gained standard NIP-94 fields magnet, i (infohash), ox
— bitvid's WebTorrent now rides the standard, round-trippable.
- js/nostr/nip71Mirror.js (new): buildNip71MirrorEvent(video, options) →
{ ok:true, event } | { ok:false, reason }. Builds kind 34235 (normal) / 34236
(short, by dims or explicit flag), d=videoRootId, title/published_at(stable)/alt,
imeta (url/m/image/x/ox/magnet/i/dim/duration), content-warning (NSFW), t
hashtags, origin + a back-pointer to the canonical 30078, client=bitvid.
Hard rules enforced: private videos refused; non-HTTPS-url refused.
tests/nip71-mirror.test.mjs: spec-shape mapping, round-trip via
extractNip71MetadataFromTags (magnet/i/ox survive), private + url-less refusals,
portrait→34236 + content-warning, explicit short override, MIME derivation.
Mutation-verified (private refusal, magnet imeta, d-tag all load-bearing).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
1 parent
80dce87195
commit
0e2f683b62
3 files changed
+349
No files matched your search
@@ -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 !== "") ||
|
||||
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
});
|
||||
Reference in new issue
Block a user