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