Merge pull request #365 from PR0M3TH3AN/codex/implement-nip-71-tag-handling-in-nostr.js

Preserve NIP-71 metadata on base video posts
This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2025-10-08 16:19:09 -04:00
commit eec5b58175
2 files changed
+289 -107

No files matched your search

+206 -107
View File
@@ -641,6 +641,91 @@ function buildParticipantTag(participant) {
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;
@@ -732,78 +817,10 @@ export function buildNip71VideoEvent({
];
const summary = summaryCandidates.find((value) => Boolean(value)) || "";
const tags = [];
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]);
});
}
const tags = buildNip71MetadataTags({
...metadata,
title: normalizedTitle,
});
if (!tags.length) {
return null;
@@ -2442,7 +2459,7 @@ function getActiveKey(video) {
export { convertEventToVideo };
class NostrClient {
export class NostrClient {
constructor() {
this.pool = null;
this.poolPromise = null;
@@ -5319,11 +5336,16 @@ class NostrClient {
contentObject.xs = finalXs;
}
const nip71Tags = buildNip71MetadataTags(
nip71Metadata && typeof nip71Metadata === "object" ? nip71Metadata : null
);
const event = buildVideoPostEvent({
pubkey,
created_at: createdAt,
dTagValue,
content: contentObject,
additionalTags: nip71Tags,
});
if (isDevMode) {
@@ -5646,17 +5668,28 @@ class NostrClient {
contentObject.xs = finalXs;
}
const event = {
kind: 30078,
// Use the provided userPubkey (or you can also force it to lowercase here if desired)
let metadataForTags =
nip71Metadata && typeof nip71Metadata === "object" ? nip71Metadata : null;
if (!metadataForTags) {
if (baseEvent?.nip71 && typeof baseEvent.nip71 === "object") {
metadataForTags = baseEvent.nip71;
} else {
const extracted = extractNip71MetadataFromTags(baseEvent);
if (extracted?.metadata) {
metadataForTags = extracted.metadata;
}
}
}
const nip71Tags = buildNip71MetadataTags(metadataForTags);
const event = buildVideoPostEvent({
pubkey: userPubkeyLower,
created_at: Math.floor(Date.now() / 1000),
tags: [
["t", "video"],
["d", newD], // new share link tag
],
content: JSON.stringify(contentObject),
};
dTagValue: newD,
content: contentObject,
additionalTags: nip71Tags,
});
if (isDevMode) {
console.log("Creating edited event with root ID:", oldRootId);
@@ -6222,44 +6255,110 @@ class NostrClient {
}
const cacheEntry = this.nip71Cache.get(rootId);
if (!cacheEntry) {
return video;
}
let record = null;
const eventId = typeof video.id === "string" ? video.id : "";
if (eventId && cacheEntry.byVideoEventId.has(eventId)) {
record = cacheEntry.byVideoEventId.get(eventId);
}
let appliedMetadata = null;
let sourceEventId = "";
let sourceCreatedAt = 0;
if (!record) {
const dTag = getDTagValueFromTags(video.tags);
if (dTag && cacheEntry.byDTag.has(dTag)) {
record = cacheEntry.byDTag.get(dTag);
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 (!record && cacheEntry.fallback) {
record = cacheEntry.fallback;
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 (!record?.metadata) {
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;
}
const cloned = cloneNip71Metadata(record.metadata);
if (cloned) {
video.nip71 = cloned;
video.nip71Source = {
eventId: record.nip71EventId || "",
created_at: record.created_at || 0,
};
}
return video;
+83
View File
@@ -0,0 +1,83 @@
import assert from "node:assert/strict";
import test from "node:test";
import { buildVideoPostEvent } from "../js/nostrEventSchemas.js";
import { buildNip71MetadataTags, NostrClient } from "../js/nostr.js";
test("30078 events carry nip71 metadata tags and hydrate fallback metadata", () => {
const metadataInput = {
hashtags: [" nostr ", "BitVid "],
participants: [
{ pubkey: "a".repeat(64), relay: " wss://relay.example " },
],
segments: [
{ start: " 00:00 ", end: " 00:10 ", title: " Intro " },
],
references: [" https://example.com/info "],
};
const additionalTags = buildNip71MetadataTags(metadataInput);
const event = buildVideoPostEvent({
pubkey: "f".repeat(64),
created_at: 1_700_000_000,
dTagValue: "demo-d-tag",
content: {
version: 3,
title: "Demo Title",
url: "https://cdn.example/video.mp4",
magnet: "",
thumbnail: "",
description: "",
mode: "live",
videoRootId: "root-123",
deleted: false,
isPrivate: false,
enableComments: true,
},
additionalTags,
});
const hashtagValues = event.tags
.filter((tag) => tag[0] === "t")
.map((tag) => tag[1])
.filter((value) => value && value.toLowerCase() !== "video");
assert.deepEqual(hashtagValues, ["nostr", "BitVid"]);
const participantTag = event.tags.find((tag) => tag[0] === "p");
assert.deepEqual(participantTag, [
"p",
"a".repeat(64),
"wss://relay.example",
]);
const segmentTag = event.tags.find((tag) => tag[0] === "segment");
assert.deepEqual(segmentTag, ["segment", "00:00", "00:10", "Intro"]);
const referenceTag = event.tags.find((tag) => tag[0] === "r");
assert.deepEqual(referenceTag, ["r", "https://example.com/info"]);
const client = new NostrClient();
const parsedContent = JSON.parse(event.content);
const video = {
id: "event-123",
videoRootId: parsedContent.videoRootId,
tags: event.tags,
content: event.content,
kind: event.kind,
created_at: event.created_at,
};
client.mergeNip71MetadataIntoVideo(video);
assert.ok(video.nip71, "fallback merge should populate nip71 metadata");
assert.deepEqual(video.nip71.hashtags, ["nostr", "BitVid"]);
assert.deepEqual(video.nip71.participants, [
{ pubkey: "a".repeat(64), relay: "wss://relay.example" },
]);
assert.deepEqual(video.nip71.segments, [
{ start: "00:00", end: "00:10", title: "Intro" },
]);
assert.deepEqual(video.nip71.references, ["https://example.com/info"]);
assert.equal(video.nip71Source?.eventId, "event-123");
});