feat: pass nip71 metadata through publish pipeline

This commit is contained in:
thePR0M3TH3AN committed 2025-10-04 18:49:43 -04:00
1 parent 1d8fa2d0c0
commit 9041f9c0b7
5 files changed
+440 -28

No files matched your search

+311 -21
View File
@@ -3483,21 +3483,279 @@ class Application {
return false;
}
const title = (payload?.title || "").trim();
const url = (payload?.url || "").trim();
const magnet = (payload?.magnet || "").trim();
const thumbnail = (payload?.thumbnail || "").trim();
const description = (payload?.description || "").trim();
const ws = (payload?.ws || "").trim();
const xs = (payload?.xs || "").trim();
const normalizeString = (value) => {
if (typeof value === "string") {
return value.trim();
}
if (typeof value === "number") {
return Number.isFinite(value) ? String(value).trim() : "";
}
return String(value ?? "").trim();
};
const parseNumberOrNull = (value) => {
if (value === null || value === undefined || value === "") {
return null;
}
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
};
const parseUnixSeconds = (value) => {
if (value === null || value === undefined || value === "") {
return null;
}
if (typeof value === "number" && Number.isFinite(value)) {
const normalized = value > 1e12 ? value / 1000 : value;
return Math.floor(normalized);
}
const trimmed = normalizeString(value);
if (!trimmed) {
return null;
}
const numeric = Number(trimmed);
if (Number.isFinite(numeric)) {
const normalized = numeric > 1e12 ? numeric / 1000 : numeric;
return Math.floor(normalized);
}
const timestamp = Date.parse(trimmed);
if (!Number.isNaN(timestamp)) {
return Math.floor(timestamp / 1000);
}
return null;
};
const normalizeStringArray = (input) => {
if (!Array.isArray(input)) {
return [];
}
return input
.map((entry) => normalizeString(entry))
.filter((entry) => Boolean(entry));
};
const normalizeImetaVariant = (variant) => {
if (!variant || typeof variant !== "object") {
return null;
}
const normalized = {};
const m = normalizeString(variant.m);
if (m) normalized.m = m;
const dim = normalizeString(variant.dim);
if (dim) normalized.dim = dim;
const url = normalizeString(variant.url);
if (url) normalized.url = url;
const x = normalizeString(variant.x);
if (x) normalized.x = x;
const image = normalizeStringArray(variant.image);
if (image.length) normalized.image = image;
const fallback = normalizeStringArray(variant.fallback);
if (fallback.length) normalized.fallback = fallback;
const service = normalizeStringArray(variant.service);
if (service.length) normalized.service = service;
if (variant.autoGenerated === true) {
normalized.autoGenerated = true;
}
return Object.keys(normalized).length ? normalized : null;
};
const normalizeSegments = (segments) => {
if (!Array.isArray(segments)) {
return [];
}
return segments
.map((segment) => {
if (!segment || typeof segment !== "object") {
return null;
}
const startRaw = segment.start;
const endRaw = segment.end;
const start = parseNumberOrNull(startRaw);
const end = parseNumberOrNull(endRaw);
const normalizedSegment = {};
if (start !== null) {
normalizedSegment.start = start;
} else {
const startString = normalizeString(startRaw);
if (startString) {
normalizedSegment.start = startString;
}
}
if (end !== null) {
normalizedSegment.end = end;
} else {
const endString = normalizeString(endRaw);
if (endString) {
normalizedSegment.end = endString;
}
}
const title = normalizeString(segment.title);
if (title) {
normalizedSegment.title = title;
}
const thumbnail = normalizeString(segment.thumbnail);
if (thumbnail) {
normalizedSegment.thumbnail = thumbnail;
}
return Object.keys(normalizedSegment).length ? normalizedSegment : null;
})
.filter(Boolean);
};
const normalizeTextTracks = (tracks) => {
if (!Array.isArray(tracks)) {
return [];
}
return tracks
.map((track) => {
if (!track || typeof track !== "object") {
return null;
}
const normalizedTrack = {};
const url = normalizeString(track.url);
if (url) normalizedTrack.url = url;
const type = normalizeString(track.type);
if (type) normalizedTrack.type = type;
const language = normalizeString(track.language);
if (language) normalizedTrack.language = language;
return Object.keys(normalizedTrack).length ? normalizedTrack : null;
})
.filter(Boolean);
};
const normalizeParticipants = (participants) => {
if (!Array.isArray(participants)) {
return [];
}
return participants
.map((participant) => {
if (!participant || typeof participant !== "object") {
return null;
}
const normalizedParticipant = {};
const pubkey = normalizeString(participant.pubkey);
if (pubkey) normalizedParticipant.pubkey = pubkey;
const relay = normalizeString(participant.relay);
if (relay) normalizedParticipant.relay = relay;
return Object.keys(normalizedParticipant).length
? normalizedParticipant
: null;
})
.filter(Boolean);
};
const normalizeNip71Metadata = (rawMetadata) => {
if (!rawMetadata || typeof rawMetadata !== "object") {
return null;
}
const normalized = {};
if (rawMetadata.kind !== undefined) {
const kindNumber = parseNumberOrNull(rawMetadata.kind);
if (kindNumber !== null) {
normalized.kind = kindNumber;
} else {
const kindString = normalizeString(rawMetadata.kind);
if (kindString) {
normalized.kind = kindString;
}
}
}
const summary = normalizeString(rawMetadata.summary);
if (summary) {
normalized.summary = summary;
}
const publishedAt = parseUnixSeconds(rawMetadata.publishedAt);
if (publishedAt !== null) {
normalized.publishedAt = publishedAt;
}
const alt = normalizeString(rawMetadata.alt);
if (alt) {
normalized.alt = alt;
}
const duration = parseNumberOrNull(rawMetadata.duration);
if (duration !== null) {
normalized.duration = duration;
}
const contentWarning = normalizeString(rawMetadata.contentWarning);
if (contentWarning) {
normalized.contentWarning = contentWarning;
}
const imeta = Array.isArray(rawMetadata.imeta)
? rawMetadata.imeta
.map((variant) => normalizeImetaVariant(variant))
.filter(Boolean)
: [];
if (imeta.length) {
normalized.imeta = imeta;
}
const textTracks = normalizeTextTracks(rawMetadata.textTracks);
if (textTracks.length) {
normalized.textTracks = textTracks;
}
const segments = normalizeSegments(rawMetadata.segments);
if (segments.length) {
normalized.segments = segments;
}
const hashtags = normalizeStringArray(rawMetadata.hashtags);
if (hashtags.length) {
normalized.hashtags = hashtags;
}
const participants = normalizeParticipants(rawMetadata.participants);
if (participants.length) {
normalized.participants = participants;
}
const references = normalizeStringArray(rawMetadata.references);
if (references.length) {
normalized.references = references;
}
return Object.keys(normalized).length ? normalized : null;
};
const rawPayload = payload && typeof payload === "object" ? payload : {};
const legacyPayload =
rawPayload.legacyFormData &&
typeof rawPayload.legacyFormData === "object"
? rawPayload.legacyFormData
: rawPayload;
const rawNip71 =
(rawPayload.nip71 && typeof rawPayload.nip71 === "object"
? rawPayload.nip71
: null) ||
(legacyPayload.nip71 && typeof legacyPayload.nip71 === "object"
? legacyPayload.nip71
: null);
const title = normalizeString(legacyPayload?.title || "");
const url = normalizeString(legacyPayload?.url || "");
const magnet = normalizeString(legacyPayload?.magnet || "");
const thumbnail = normalizeString(legacyPayload?.thumbnail || "");
const description = normalizeString(legacyPayload?.description || "");
const ws = normalizeString(legacyPayload?.ws || "");
const xs = normalizeString(legacyPayload?.xs || "");
const enableComments =
payload?.enableComments === false
legacyPayload?.enableComments === false
? false
: payload?.enableComments === true
: legacyPayload?.enableComments === true
? true
: true;
const formData = {
const legacyFormData = {
version: 3,
title,
url,
@@ -3508,32 +3766,64 @@ class Application {
enableComments,
};
if (!formData.title || (!formData.url && !formData.magnet)) {
this.showError("Title and at least one of URL or Magnet is required.");
const normalizedNip71 = normalizeNip71Metadata(rawNip71);
const hasLegacySource = Boolean(legacyFormData.url || legacyFormData.magnet);
const hasImetaVariant = Boolean(
normalizedNip71?.imeta?.some((variant) =>
Boolean(
(variant.url && variant.url.length) ||
(variant.m && variant.m.length) ||
(variant.x && variant.x.length) ||
(variant.dim && variant.dim.length) ||
(Array.isArray(variant.image) && variant.image.length > 0) ||
(Array.isArray(variant.fallback) && variant.fallback.length > 0) ||
(Array.isArray(variant.service) && variant.service.length > 0)
)
)
);
if (!legacyFormData.title) {
this.showError("Title is required.");
return false;
}
if (formData.url && !/^https:\/\//i.test(formData.url)) {
if (!hasLegacySource && !hasImetaVariant) {
this.showError(
"Provide a hosted URL, magnet link, or an imeta variant before publishing."
);
return false;
}
if (legacyFormData.url && !/^https:\/\//i.test(legacyFormData.url)) {
this.showError("Hosted video URLs must use HTTPS.");
return false;
}
if (formData.magnet) {
const normalizedMagnet = normalizeAndAugmentMagnet(formData.magnet, {
if (legacyFormData.magnet) {
const normalizedMagnet = normalizeAndAugmentMagnet(legacyFormData.magnet, {
ws,
xs,
});
formData.magnet = normalizedMagnet;
legacyFormData.magnet = normalizedMagnet;
const hints = extractMagnetHints(normalizedMagnet);
formData.ws = hints.ws;
formData.xs = hints.xs;
legacyFormData.ws = hints.ws;
legacyFormData.xs = hints.xs;
} else {
formData.ws = "";
formData.xs = "";
legacyFormData.ws = "";
legacyFormData.xs = "";
}
const publishPayload = {
legacyFormData,
};
if (normalizedNip71) {
publishPayload.nip71 = normalizedNip71;
}
try {
await this.nostrService.publishVideoNote(formData, this.pubkey);
await this.nostrService.publishVideoNote(publishPayload, this.pubkey);
if (typeof onSuccess === "function") {
await onSuccess();
}
+26 -1
View File
@@ -4048,11 +4048,36 @@ class NostrClient {
/**
* Publish a new video using the v3 content schema.
*/
async publishVideo(videoData, pubkey) {
async publishVideo(videoPayload, pubkey) {
if (!pubkey) throw new Error("Not logged in to publish video.");
let nip71Metadata = null;
let videoData = videoPayload;
if (
videoPayload &&
typeof videoPayload === "object" &&
(Object.prototype.hasOwnProperty.call(videoPayload, "legacyFormData") ||
Object.prototype.hasOwnProperty.call(videoPayload, "nip71"))
) {
if (videoPayload.nip71 && typeof videoPayload.nip71 === "object") {
nip71Metadata = videoPayload.nip71;
}
if (
videoPayload.legacyFormData &&
typeof videoPayload.legacyFormData === "object"
) {
videoData = videoPayload.legacyFormData;
} else {
videoData = {};
}
}
if (isDevMode) {
console.log("Publishing new video with data:", videoData);
if (nip71Metadata) {
console.log("Including NIP-71 metadata:", nip71Metadata);
}
}
const rawMagnet = typeof videoData.magnet === "string" ? videoData.magnet : "";
+15 -3
View File
@@ -356,9 +356,21 @@ export class NostrService {
return filtered;
}
async publishVideoNote(formData, pubkey) {
const result = await this.nostrClient.publishVideo(formData, pubkey);
this.emit("videos:published", { formData, pubkey, result });
async publishVideoNote(publishPayload, pubkey) {
const result = await this.nostrClient.publishVideo(publishPayload, pubkey);
const detail = { pubkey, result };
if (publishPayload && typeof publishPayload === "object") {
detail.payload = publishPayload;
if (publishPayload.legacyFormData) {
detail.legacyFormData = publishPayload.legacyFormData;
}
if (publishPayload.nip71) {
detail.nip71 = publishPayload.nip71;
}
} else {
detail.formData = publishPayload;
}
this.emit("videos:published", detail);
return result;
}
+75
View File
@@ -125,6 +125,73 @@ class R2Service {
this.emit("uploadProgress", { fraction });
}
buildNip71MetadataForUpload(existingMetadata, { publicUrl = "", file = null } = {}) {
const base =
existingMetadata && typeof existingMetadata === "object"
? { ...existingMetadata }
: {};
const cloneImetaVariant = (variant) => {
if (!variant || typeof variant !== "object") {
return {
m: "",
dim: "",
url: "",
x: "",
image: [],
fallback: [],
service: [],
autoGenerated: true,
};
}
return {
...variant,
image: Array.isArray(variant.image) ? [...variant.image] : [],
fallback: Array.isArray(variant.fallback) ? [...variant.fallback] : [],
service: Array.isArray(variant.service) ? [...variant.service] : [],
};
};
const imetaList = Array.isArray(base.imeta)
? base.imeta.map((variant) => cloneImetaVariant(variant))
: [];
if (!imetaList.length) {
imetaList.push({
m: "",
dim: "",
url: "",
x: "",
image: [],
fallback: [],
service: [],
autoGenerated: true,
});
} else {
const primary = imetaList[0];
if (!Array.isArray(primary.image)) primary.image = [];
if (!Array.isArray(primary.fallback)) primary.fallback = [];
if (!Array.isArray(primary.service)) primary.service = [];
}
const primaryVariant = imetaList[0];
const normalizedUrl =
typeof publicUrl === "string" ? publicUrl.trim() : String(publicUrl || "").trim();
if (normalizedUrl && (!primaryVariant.url || !String(primaryVariant.url).trim())) {
primaryVariant.url = normalizedUrl;
}
const mimeType =
file && typeof file.type === "string" ? file.type.trim() : "";
if (mimeType && (!primaryVariant.m || !String(primaryVariant.m).trim())) {
primaryVariant.m = mimeType;
}
base.imeta = imetaList;
return base;
}
populateCloudflareSettingsInputs(settings) {
const data = {
accountId: settings?.accountId || "",
@@ -704,6 +771,14 @@ class R2Service {
enableComments,
};
const mergedNip71 = this.buildNip71MetadataForUpload(metadata?.nip71, {
publicUrl,
file,
});
if (mergedNip71 && Object.keys(mergedNip71).length) {
payload.nip71 = mergedNip71;
}
const published = await publishVideoNote(payload, {
onSuccess: () => {
if (typeof onReset === "function") {
+13 -3
View File
@@ -87,9 +87,19 @@ try {
await assert.rejects(
nostrClient.publishVideo(
{
title: "Test video",
magnet: "magnet:?xt=urn:btih:TESTVIDEO1234567890",
mode: "live",
legacyFormData: {
title: "Test video",
magnet: "magnet:?xt=urn:btih:TESTVIDEO1234567890",
mode: "live",
},
nip71: {
imeta: [
{
m: "video/mp4",
url: "https://cdn.example/test.mp4",
},
],
},
},
"f".repeat(64)
),