mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-11-03 11:22:25 +00:00
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:
2 files changed
+289
-107
No files matched your search
+206
-107
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
Reference in new issue
Block a user