mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
1474 lines
36 KiB
JavaScript
1474 lines
36 KiB
JavaScript
import { ACCEPT_LEGACY_V1 } from "../constants.js";
|
|
import { deriveTitleFromEvent, magnetFromText } from "../videoEventUtils.js";
|
|
import { extractMagnetHints } from "../magnet.js";
|
|
import { devLogger } from "../utils/logger.js";
|
|
import { getCachedNostrTools } from "./toolkit.js";
|
|
|
|
function stringFromInput(value) {
|
|
if (value === null || value === undefined) {
|
|
return "";
|
|
}
|
|
if (typeof value === "string") {
|
|
return value.trim();
|
|
}
|
|
if (typeof value === "number") {
|
|
return Number.isFinite(value) ? String(value) : "";
|
|
}
|
|
return String(value).trim();
|
|
}
|
|
|
|
const HEX_32_BYTE_REGEX = /^[0-9a-f]{64}$/i;
|
|
|
|
function decodeNpubToHex(candidate) {
|
|
const globalDecoder =
|
|
typeof window !== "undefined" ? window?.NostrTools?.nip19?.decode : null;
|
|
const toolkit = getCachedNostrTools();
|
|
const toolkitDecoder = toolkit?.nip19?.decode;
|
|
const decoder =
|
|
typeof globalDecoder === "function" ? globalDecoder : toolkitDecoder;
|
|
if (typeof decoder !== "function") {
|
|
return null;
|
|
}
|
|
try {
|
|
const decoded = decoder(candidate);
|
|
if (decoded?.type === "npub" && typeof decoded.data === "string") {
|
|
return decoded.data.toLowerCase();
|
|
}
|
|
if (decoded?.type === "npub" && typeof decoded.data?.pubkey === "string") {
|
|
return decoded.data.pubkey.toLowerCase();
|
|
}
|
|
} catch (error) {
|
|
devLogger.warn("[nostr] Failed to decode npub participant pubkey", error);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function normalizeNostrPubkey(candidate) {
|
|
const value = stringFromInput(candidate);
|
|
if (!value) {
|
|
return null;
|
|
}
|
|
if (HEX_32_BYTE_REGEX.test(value)) {
|
|
return value.toLowerCase();
|
|
}
|
|
if (value.toLowerCase().startsWith("npub1")) {
|
|
const decoded = decodeNpubToHex(value);
|
|
if (decoded && HEX_32_BYTE_REGEX.test(decoded)) {
|
|
return decoded;
|
|
}
|
|
return null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function normalizeUnixSeconds(value) {
|
|
if (value === null || value === undefined || value === "") {
|
|
return "";
|
|
}
|
|
if (typeof value === "number" && Number.isFinite(value)) {
|
|
const normalized = value > 1e12 ? value / 1000 : value;
|
|
return String(Math.floor(normalized));
|
|
}
|
|
if (typeof value === "string") {
|
|
const trimmed = value.trim();
|
|
if (!trimmed) {
|
|
return "";
|
|
}
|
|
const numeric = Number(trimmed);
|
|
if (Number.isFinite(numeric)) {
|
|
const normalized = numeric > 1e12 ? numeric / 1000 : numeric;
|
|
return String(Math.floor(normalized));
|
|
}
|
|
const parsed = Date.parse(trimmed);
|
|
if (!Number.isNaN(parsed)) {
|
|
return String(Math.floor(parsed / 1000));
|
|
}
|
|
}
|
|
return "";
|
|
}
|
|
|
|
function parseNonNegativeNumber(value, { allowFloat = false } = {}) {
|
|
if (typeof value === "number" && Number.isFinite(value)) {
|
|
if (!allowFloat && !Number.isInteger(value)) {
|
|
return null;
|
|
}
|
|
return value >= 0 ? value : null;
|
|
}
|
|
if (typeof value === "string") {
|
|
const trimmed = value.trim();
|
|
if (!trimmed) {
|
|
return null;
|
|
}
|
|
const numeric = Number(trimmed);
|
|
if (!Number.isFinite(numeric)) {
|
|
return null;
|
|
}
|
|
if (!allowFloat && !Number.isInteger(numeric)) {
|
|
return null;
|
|
}
|
|
return numeric >= 0 ? numeric : null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function formatNonNegativeNumber(value, { allowFloat = false } = {}) {
|
|
if (value === null || value === undefined || value === "") {
|
|
return "";
|
|
}
|
|
|
|
if (typeof value === "string") {
|
|
const trimmed = value.trim();
|
|
if (!trimmed) {
|
|
return "";
|
|
}
|
|
const numeric = Number(trimmed);
|
|
if (!Number.isFinite(numeric) || numeric < 0) {
|
|
return "";
|
|
}
|
|
if (!allowFloat && !Number.isInteger(numeric)) {
|
|
return "";
|
|
}
|
|
if (/^\d+(?:\.\d+)?$/.test(trimmed)) {
|
|
return trimmed;
|
|
}
|
|
return allowFloat ? String(numeric) : String(Math.trunc(numeric));
|
|
}
|
|
|
|
if (typeof value === "number" && Number.isFinite(value) && value >= 0) {
|
|
if (!allowFloat && !Number.isInteger(value)) {
|
|
return "";
|
|
}
|
|
return String(value);
|
|
}
|
|
|
|
return "";
|
|
}
|
|
|
|
function normalizeNip71Kind(value) {
|
|
const numeric =
|
|
typeof value === "string"
|
|
? Number(value.trim())
|
|
: typeof value === "number"
|
|
? value
|
|
: Number.NaN;
|
|
if (numeric === 22) {
|
|
return 22;
|
|
}
|
|
return 21;
|
|
}
|
|
|
|
function trimTrailingEmpty(values) {
|
|
const trimmed = [...values];
|
|
while (trimmed.length && !trimmed[trimmed.length - 1]) {
|
|
trimmed.pop();
|
|
}
|
|
return trimmed;
|
|
}
|
|
|
|
function buildImetaTags(variants) {
|
|
if (!Array.isArray(variants)) {
|
|
return [];
|
|
}
|
|
const tags = [];
|
|
for (const variant of variants) {
|
|
if (!variant || typeof variant !== "object") {
|
|
continue;
|
|
}
|
|
const dim = stringFromInput(variant.dim);
|
|
const url = stringFromInput(variant.url);
|
|
const x = stringFromInput(variant.x);
|
|
const rawMime = stringFromInput(variant.m);
|
|
const mime = rawMime ? rawMime.toLowerCase() : "";
|
|
const duration = formatNonNegativeNumber(variant.duration, {
|
|
allowFloat: true,
|
|
});
|
|
const bitrate = formatNonNegativeNumber(variant.bitrate, {
|
|
allowFloat: false,
|
|
});
|
|
const image = Array.isArray(variant.image)
|
|
? variant.image.map(stringFromInput).filter(Boolean)
|
|
: [];
|
|
const fallback = Array.isArray(variant.fallback)
|
|
? variant.fallback.map(stringFromInput).filter(Boolean)
|
|
: [];
|
|
const service = Array.isArray(variant.service)
|
|
? variant.service.map(stringFromInput).filter(Boolean)
|
|
: [];
|
|
|
|
if (
|
|
!dim &&
|
|
!url &&
|
|
!x &&
|
|
!mime &&
|
|
!duration &&
|
|
!bitrate &&
|
|
!image.length &&
|
|
!fallback.length &&
|
|
!service.length
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
const entries = ["imeta"];
|
|
if (dim) {
|
|
entries.push(`dim ${dim}`);
|
|
}
|
|
if (url) {
|
|
entries.push(`url ${url}`);
|
|
}
|
|
if (x) {
|
|
entries.push(`x ${x}`);
|
|
}
|
|
if (mime) {
|
|
entries.push(`m ${mime}`);
|
|
}
|
|
if (duration) {
|
|
entries.push(`duration ${duration}`);
|
|
}
|
|
if (bitrate) {
|
|
entries.push(`bitrate ${bitrate}`);
|
|
}
|
|
image.forEach((value) => entries.push(`image ${value}`));
|
|
fallback.forEach((value) => entries.push(`fallback ${value}`));
|
|
service.forEach((value) => entries.push(`service ${value}`));
|
|
|
|
tags.push(entries);
|
|
}
|
|
return tags;
|
|
}
|
|
|
|
function buildTextTrackTag(track) {
|
|
if (!track || typeof track !== "object") {
|
|
return null;
|
|
}
|
|
const url = stringFromInput(track.url);
|
|
const type = stringFromInput(track.type);
|
|
const language = stringFromInput(track.language);
|
|
if (!url && !type && !language) {
|
|
return null;
|
|
}
|
|
const values = trimTrailingEmpty([url, type, language]);
|
|
return ["text-track", ...values];
|
|
}
|
|
|
|
function buildSegmentTag(segment) {
|
|
if (!segment || typeof segment !== "object") {
|
|
return null;
|
|
}
|
|
const start = stringFromInput(segment.start);
|
|
const end = stringFromInput(segment.end);
|
|
const title = stringFromInput(segment.title);
|
|
const thumbnail = stringFromInput(segment.thumbnail);
|
|
if (!start && !end && !title && !thumbnail) {
|
|
return null;
|
|
}
|
|
const values = trimTrailingEmpty([start, end, title, thumbnail]);
|
|
return ["segment", ...values];
|
|
}
|
|
|
|
function buildParticipantTag(participant) {
|
|
if (!participant || typeof participant !== "object") {
|
|
return null;
|
|
}
|
|
const rawPubkey = participant.pubkey;
|
|
const pubkey = normalizeNostrPubkey(rawPubkey);
|
|
if (!pubkey) {
|
|
const candidate = stringFromInput(rawPubkey);
|
|
if (candidate) {
|
|
devLogger.warn(
|
|
"[nostr] Dropping NIP-71 participant tag with invalid pubkey",
|
|
{ pubkey: candidate }
|
|
);
|
|
}
|
|
return null;
|
|
}
|
|
const relay = stringFromInput(participant.relay);
|
|
// NIP-71 "Other tags" mandates 32-byte hex participant pubkeys.
|
|
const values = ["p", pubkey];
|
|
if (relay) {
|
|
values.push(relay);
|
|
}
|
|
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);
|
|
}
|
|
|
|
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 parseKeyValuePair(entry) {
|
|
if (typeof entry !== "string") {
|
|
return null;
|
|
}
|
|
const trimmed = entry.trim();
|
|
if (!trimmed) {
|
|
return null;
|
|
}
|
|
const spaceIndex = trimmed.indexOf(" ");
|
|
if (spaceIndex === -1) {
|
|
return null;
|
|
}
|
|
const key = trimmed.slice(0, spaceIndex).trim().toLowerCase();
|
|
const value = trimmed.slice(spaceIndex + 1).trim();
|
|
if (!key || !value) {
|
|
return null;
|
|
}
|
|
return { key, value };
|
|
}
|
|
|
|
function parseImetaTag(tag) {
|
|
if (!Array.isArray(tag) || tag[0] !== "imeta") {
|
|
return null;
|
|
}
|
|
|
|
const variant = {
|
|
image: [],
|
|
fallback: [],
|
|
service: [],
|
|
};
|
|
|
|
for (let i = 1; i < tag.length; i += 1) {
|
|
const parsed = parseKeyValuePair(tag[i]);
|
|
if (!parsed) {
|
|
continue;
|
|
}
|
|
|
|
switch (parsed.key) {
|
|
case "dim":
|
|
variant.dim = parsed.value;
|
|
break;
|
|
case "url":
|
|
variant.url = parsed.value;
|
|
break;
|
|
case "x":
|
|
variant.x = parsed.value;
|
|
break;
|
|
case "m":
|
|
variant.m = parsed.value;
|
|
break;
|
|
case "duration": {
|
|
const numeric = parseNonNegativeNumber(parsed.value, {
|
|
allowFloat: true,
|
|
});
|
|
variant.duration = numeric ?? parsed.value;
|
|
break;
|
|
}
|
|
case "bitrate": {
|
|
const numeric = parseNonNegativeNumber(parsed.value);
|
|
variant.bitrate = numeric ?? parsed.value;
|
|
break;
|
|
}
|
|
case "image":
|
|
variant.image.push(parsed.value);
|
|
break;
|
|
case "fallback":
|
|
variant.fallback.push(parsed.value);
|
|
break;
|
|
case "service":
|
|
variant.service.push(parsed.value);
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
const hasContent =
|
|
Boolean(variant.dim) ||
|
|
Boolean(variant.url) ||
|
|
Boolean(variant.x) ||
|
|
Boolean(variant.m) ||
|
|
(variant.duration !== undefined && variant.duration !== null && variant.duration !== "") ||
|
|
(variant.bitrate !== undefined && variant.bitrate !== null && variant.bitrate !== "") ||
|
|
variant.image.length > 0 ||
|
|
variant.fallback.length > 0 ||
|
|
variant.service.length > 0;
|
|
|
|
if (!hasContent) {
|
|
return null;
|
|
}
|
|
|
|
if (!variant.image.length) {
|
|
delete variant.image;
|
|
}
|
|
if (!variant.fallback.length) {
|
|
delete variant.fallback;
|
|
}
|
|
if (!variant.service.length) {
|
|
delete variant.service;
|
|
}
|
|
if (variant.duration === "" || variant.duration === undefined) {
|
|
delete variant.duration;
|
|
}
|
|
if (variant.bitrate === "" || variant.bitrate === undefined) {
|
|
delete variant.bitrate;
|
|
}
|
|
|
|
return variant;
|
|
}
|
|
|
|
function parseTextTrackTag(tag) {
|
|
if (!Array.isArray(tag) || tag[0] !== "text-track") {
|
|
return null;
|
|
}
|
|
const url = stringFromInput(tag[1]);
|
|
const type = stringFromInput(tag[2]);
|
|
const language = stringFromInput(tag[3]);
|
|
if (!url && !type && !language) {
|
|
return null;
|
|
}
|
|
const track = {};
|
|
if (url) {
|
|
track.url = url;
|
|
}
|
|
if (type) {
|
|
track.type = type;
|
|
}
|
|
if (language) {
|
|
track.language = language;
|
|
}
|
|
return track;
|
|
}
|
|
|
|
function parseSegmentTag(tag) {
|
|
if (!Array.isArray(tag) || tag[0] !== "segment") {
|
|
return null;
|
|
}
|
|
|
|
const values = [];
|
|
for (let i = 1; i < tag.length; i += 1) {
|
|
const value = stringFromInput(tag[i]);
|
|
values.push(value);
|
|
}
|
|
|
|
while (values.length < 4) {
|
|
values.push("");
|
|
}
|
|
|
|
const [start, end, title, thumbnail] = values;
|
|
const hasContent = start || end || title || thumbnail;
|
|
if (!hasContent) {
|
|
return null;
|
|
}
|
|
const segment = {};
|
|
if (start) {
|
|
segment.start = start;
|
|
}
|
|
if (end) {
|
|
segment.end = end;
|
|
}
|
|
if (title) {
|
|
segment.title = title;
|
|
}
|
|
if (thumbnail) {
|
|
segment.thumbnail = thumbnail;
|
|
}
|
|
return segment;
|
|
}
|
|
|
|
function parseParticipantTag(tag) {
|
|
if (!Array.isArray(tag) || tag[0] !== "p") {
|
|
return null;
|
|
}
|
|
const pubkey = stringFromInput(tag[1]);
|
|
if (!pubkey) {
|
|
return null;
|
|
}
|
|
const participant = { pubkey };
|
|
const relay = stringFromInput(tag[2]);
|
|
if (relay) {
|
|
participant.relay = relay;
|
|
}
|
|
return participant;
|
|
}
|
|
|
|
function parseReferenceTag(tag) {
|
|
if (!Array.isArray(tag) || tag[0] !== "r") {
|
|
return null;
|
|
}
|
|
const url = stringFromInput(tag[1]);
|
|
return url ? url : null;
|
|
}
|
|
|
|
function parseHashtagTag(tag) {
|
|
if (!Array.isArray(tag) || tag[0] !== "t") {
|
|
return null;
|
|
}
|
|
const value = stringFromInput(tag[1]);
|
|
return value || null;
|
|
}
|
|
|
|
export function extractNip71MetadataFromTags(event) {
|
|
if (!event || typeof event !== "object") {
|
|
return null;
|
|
}
|
|
|
|
const tags = Array.isArray(event.tags) ? event.tags : [];
|
|
const metadata = { kind: normalizeNip71Kind(event.kind) };
|
|
|
|
const summary = stringFromInput(event.content);
|
|
if (summary) {
|
|
metadata.summary = summary;
|
|
}
|
|
|
|
const imeta = [];
|
|
const textTracks = [];
|
|
const segments = [];
|
|
const hashtags = [];
|
|
const participants = [];
|
|
const references = [];
|
|
|
|
const pointerValues = new Set();
|
|
const videoRootIds = new Set();
|
|
const videoEventIds = new Set();
|
|
const dTags = new Set();
|
|
|
|
tags.forEach((tag) => {
|
|
if (!Array.isArray(tag) || tag.length < 2) {
|
|
return;
|
|
}
|
|
|
|
const name = tag[0];
|
|
switch (name) {
|
|
case "title": {
|
|
const value = stringFromInput(tag[1]);
|
|
if (value) {
|
|
metadata.title = value;
|
|
}
|
|
break;
|
|
}
|
|
case "published_at": {
|
|
const value = stringFromInput(tag[1]);
|
|
if (value) {
|
|
metadata.publishedAt = value;
|
|
}
|
|
break;
|
|
}
|
|
case "alt": {
|
|
const value = stringFromInput(tag[1]);
|
|
if (value) {
|
|
metadata.alt = value;
|
|
}
|
|
break;
|
|
}
|
|
case "duration": {
|
|
const value = stringFromInput(tag[1]);
|
|
if (value) {
|
|
const parsed = Number(value);
|
|
metadata.duration = Number.isFinite(parsed) ? parsed : value;
|
|
}
|
|
break;
|
|
}
|
|
case "content-warning": {
|
|
const value = stringFromInput(tag[1]);
|
|
if (value) {
|
|
metadata.contentWarning = value;
|
|
}
|
|
break;
|
|
}
|
|
case "imeta": {
|
|
const variant = parseImetaTag(tag);
|
|
if (variant) {
|
|
imeta.push(variant);
|
|
}
|
|
break;
|
|
}
|
|
case "text-track": {
|
|
const track = parseTextTrackTag(tag);
|
|
if (track) {
|
|
textTracks.push(track);
|
|
}
|
|
break;
|
|
}
|
|
case "segment": {
|
|
const segment = parseSegmentTag(tag);
|
|
if (segment) {
|
|
segments.push(segment);
|
|
}
|
|
break;
|
|
}
|
|
case "t": {
|
|
const hashtag = parseHashtagTag(tag);
|
|
if (hashtag) {
|
|
hashtags.push(hashtag);
|
|
}
|
|
break;
|
|
}
|
|
case "p": {
|
|
const participant = parseParticipantTag(tag);
|
|
if (participant) {
|
|
participants.push(participant);
|
|
}
|
|
break;
|
|
}
|
|
case "r": {
|
|
const reference = parseReferenceTag(tag);
|
|
if (reference) {
|
|
references.push(reference);
|
|
}
|
|
break;
|
|
}
|
|
case "a": {
|
|
const pointerValue = stringFromInput(tag[1]).toLowerCase();
|
|
if (pointerValue) {
|
|
pointerValues.add(pointerValue);
|
|
const parts = pointerValue.split(":");
|
|
if (parts.length === 3 && parts[0] === "30078") {
|
|
const root = parts[2];
|
|
if (root) {
|
|
videoRootIds.add(root);
|
|
}
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
case "video-root": {
|
|
const value = stringFromInput(tag[1]);
|
|
if (value) {
|
|
videoRootIds.add(value);
|
|
}
|
|
break;
|
|
}
|
|
case "e": {
|
|
const value = stringFromInput(tag[1]);
|
|
if (value) {
|
|
videoEventIds.add(value);
|
|
}
|
|
break;
|
|
}
|
|
case "d": {
|
|
const value = stringFromInput(tag[1]);
|
|
if (value) {
|
|
dTags.add(value);
|
|
}
|
|
break;
|
|
}
|
|
default:
|
|
break;
|
|
}
|
|
});
|
|
|
|
if (imeta.length) {
|
|
metadata.imeta = imeta;
|
|
|
|
const legacyDuration = parseNonNegativeNumber(metadata.duration, {
|
|
allowFloat: true,
|
|
});
|
|
|
|
if (legacyDuration !== null) {
|
|
metadata.duration = legacyDuration;
|
|
} else {
|
|
const variantDurations = imeta
|
|
.map((variant) => parseNonNegativeNumber(variant?.duration, { allowFloat: true }))
|
|
.filter((value) => value !== null);
|
|
|
|
if (variantDurations.length) {
|
|
metadata.duration = Math.max(...variantDurations);
|
|
}
|
|
}
|
|
}
|
|
if (textTracks.length) {
|
|
metadata.textTracks = textTracks;
|
|
}
|
|
if (segments.length) {
|
|
metadata.segments = segments;
|
|
}
|
|
if (hashtags.length) {
|
|
metadata.hashtags = hashtags;
|
|
metadata.t = hashtags;
|
|
}
|
|
if (participants.length) {
|
|
metadata.participants = participants;
|
|
}
|
|
if (references.length) {
|
|
metadata.references = references;
|
|
}
|
|
|
|
return {
|
|
metadata,
|
|
pointers: {
|
|
pointerValues,
|
|
videoRootIds,
|
|
videoEventIds,
|
|
dTags,
|
|
},
|
|
source: {
|
|
id: typeof event.id === "string" ? event.id : "",
|
|
created_at: Number.isFinite(event.created_at) ? event.created_at : null,
|
|
kind: Number.isFinite(event.kind) ? event.kind : normalizeNip71Kind(event.kind),
|
|
},
|
|
};
|
|
}
|
|
|
|
function cloneNip71Metadata(metadata) {
|
|
if (!metadata || typeof metadata !== "object") {
|
|
return null;
|
|
}
|
|
try {
|
|
return JSON.parse(JSON.stringify(metadata));
|
|
} catch (error) {
|
|
devLogger.warn("[nostr] Failed to clone NIP-71 metadata", error);
|
|
return { ...metadata };
|
|
}
|
|
}
|
|
|
|
export function getDTagValueFromTags(tags) {
|
|
if (!Array.isArray(tags)) {
|
|
return "";
|
|
}
|
|
for (const tag of tags) {
|
|
if (!Array.isArray(tag) || tag[0] !== "d") {
|
|
continue;
|
|
}
|
|
if (typeof tag[1] === "string" && tag[1]) {
|
|
return tag[1];
|
|
}
|
|
}
|
|
return "";
|
|
}
|
|
|
|
export function buildVideoPointerValue(pubkey, videoRootId) {
|
|
const normalizedRoot = stringFromInput(videoRootId);
|
|
const normalizedPubkey = stringFromInput(pubkey).toLowerCase();
|
|
if (!normalizedRoot || !normalizedPubkey) {
|
|
return "";
|
|
}
|
|
return `30078:${normalizedPubkey}:${normalizedRoot}`;
|
|
}
|
|
|
|
export function buildNip71PointerTags({
|
|
pubkey = "",
|
|
videoRootId = "",
|
|
videoEventId = "",
|
|
dTag = "",
|
|
} = {}) {
|
|
const pointerTags = [];
|
|
|
|
const normalizedRoot = stringFromInput(videoRootId);
|
|
const normalizedEventId = stringFromInput(videoEventId);
|
|
const normalizedDTag = stringFromInput(dTag);
|
|
|
|
if (normalizedRoot) {
|
|
const pointerValue = buildVideoPointerValue(pubkey, normalizedRoot);
|
|
if (pointerValue) {
|
|
pointerTags.push(["a", pointerValue]);
|
|
}
|
|
pointerTags.push(["video-root", normalizedRoot]);
|
|
}
|
|
|
|
if (normalizedEventId) {
|
|
pointerTags.push(["e", normalizedEventId]);
|
|
}
|
|
|
|
if (normalizedDTag) {
|
|
pointerTags.push(["d", normalizedDTag]);
|
|
}
|
|
|
|
return pointerTags;
|
|
}
|
|
|
|
export function buildNip71VideoEvent({
|
|
metadata,
|
|
pubkey = "",
|
|
title,
|
|
summaryFallback = "",
|
|
createdAt = Math.floor(Date.now() / 1000),
|
|
pointerIdentifiers = {},
|
|
} = {}) {
|
|
if (!metadata || typeof metadata !== "object") {
|
|
return null;
|
|
}
|
|
|
|
const normalizedTitle = stringFromInput(title);
|
|
if (!normalizedTitle) {
|
|
return null;
|
|
}
|
|
|
|
const summaryCandidates = [
|
|
stringFromInput(metadata.summary),
|
|
stringFromInput(summaryFallback),
|
|
normalizedTitle,
|
|
];
|
|
const summary = summaryCandidates.find((value) => Boolean(value)) || "";
|
|
|
|
const tags = buildNip71MetadataTags({
|
|
...metadata,
|
|
title: normalizedTitle,
|
|
});
|
|
|
|
if (!tags.length) {
|
|
return null;
|
|
}
|
|
|
|
const normalizedPubkey = stringFromInput(pubkey);
|
|
const timestamp = Number.isFinite(createdAt)
|
|
? Math.floor(createdAt)
|
|
: Math.floor(Date.now() / 1000);
|
|
|
|
const pointerTags = buildNip71PointerTags({
|
|
pubkey,
|
|
videoRootId: pointerIdentifiers.videoRootId,
|
|
videoEventId: pointerIdentifiers.eventId,
|
|
dTag: pointerIdentifiers.dTag,
|
|
});
|
|
|
|
if (pointerTags.length) {
|
|
tags.push(...pointerTags);
|
|
}
|
|
|
|
return {
|
|
kind: normalizeNip71Kind(metadata.kind),
|
|
pubkey: normalizedPubkey,
|
|
created_at: timestamp,
|
|
tags,
|
|
content: summary,
|
|
};
|
|
}
|
|
|
|
export function collectNip71PointerRequests(videos = []) {
|
|
const pointerMap = new Map();
|
|
if (!Array.isArray(videos)) {
|
|
return pointerMap;
|
|
}
|
|
|
|
videos.forEach((video) => {
|
|
if (!video || typeof video !== "object") {
|
|
return;
|
|
}
|
|
|
|
const rootId = typeof video.videoRootId === "string" ? video.videoRootId : "";
|
|
const pubkey = typeof video.pubkey === "string" ? video.pubkey.toLowerCase() : "";
|
|
if (!rootId || !pubkey) {
|
|
return;
|
|
}
|
|
|
|
const pointerValue = buildVideoPointerValue(pubkey, rootId);
|
|
if (!pointerValue) {
|
|
return;
|
|
}
|
|
|
|
let info = pointerMap.get(pointerValue);
|
|
if (!info) {
|
|
info = {
|
|
videoRootId: rootId,
|
|
pointerValue,
|
|
videoEventIds: new Set(),
|
|
dTags: new Set(),
|
|
};
|
|
pointerMap.set(pointerValue, info);
|
|
}
|
|
|
|
if (typeof video.id === "string" && video.id) {
|
|
info.videoEventIds.add(video.id);
|
|
}
|
|
|
|
const dTag = getDTagValueFromTags(video.tags);
|
|
if (dTag) {
|
|
info.dTags.add(dTag);
|
|
}
|
|
});
|
|
|
|
return pointerMap;
|
|
}
|
|
|
|
function ensureNip71CacheEntry(nip71Cache, videoRootId) {
|
|
if (!(nip71Cache instanceof Map)) {
|
|
return null;
|
|
}
|
|
const rootId = typeof videoRootId === "string" ? videoRootId : "";
|
|
if (!rootId) {
|
|
return null;
|
|
}
|
|
|
|
let entry = nip71Cache.get(rootId);
|
|
if (!entry) {
|
|
entry = {
|
|
byVideoEventId: new Map(),
|
|
byDTag: new Map(),
|
|
fallback: null,
|
|
fetchedPointers: new Set(),
|
|
};
|
|
nip71Cache.set(rootId, entry);
|
|
}
|
|
return entry;
|
|
}
|
|
|
|
function storeNip71RecordForRoot(nip71Cache, videoRootId, parsedRecord) {
|
|
const entry = ensureNip71CacheEntry(nip71Cache, videoRootId);
|
|
if (!entry || !parsedRecord || !parsedRecord.metadata) {
|
|
return;
|
|
}
|
|
|
|
const storedRecord = {
|
|
metadata: cloneNip71Metadata(parsedRecord.metadata),
|
|
nip71EventId: parsedRecord.source?.id || "",
|
|
created_at: parsedRecord.source?.created_at || 0,
|
|
pointerValues: new Set(parsedRecord.pointers?.pointerValues || []),
|
|
videoEventIds: new Set(parsedRecord.pointers?.videoEventIds || []),
|
|
dTags: new Set(parsedRecord.pointers?.dTags || []),
|
|
};
|
|
|
|
if (!storedRecord.metadata) {
|
|
return;
|
|
}
|
|
|
|
storedRecord.pointerValues.forEach((pointerValue) => {
|
|
if (pointerValue) {
|
|
entry.fetchedPointers.add(pointerValue);
|
|
}
|
|
});
|
|
|
|
storedRecord.videoEventIds.forEach((eventId) => {
|
|
if (eventId) {
|
|
entry.byVideoEventId.set(eventId, storedRecord);
|
|
}
|
|
});
|
|
|
|
storedRecord.dTags.forEach((dTag) => {
|
|
if (dTag) {
|
|
entry.byDTag.set(dTag, storedRecord);
|
|
}
|
|
});
|
|
|
|
if (
|
|
!entry.fallback ||
|
|
(storedRecord.created_at || 0) >= (entry.fallback.created_at || 0)
|
|
) {
|
|
entry.fallback = storedRecord;
|
|
}
|
|
}
|
|
|
|
export function processNip71Events(events, { nip71Cache, pointerMap = null } = {}) {
|
|
if (!Array.isArray(events) || !events.length || !(nip71Cache instanceof Map)) {
|
|
return;
|
|
}
|
|
|
|
events.forEach((event) => {
|
|
const parsed = extractNip71MetadataFromTags(event);
|
|
if (!parsed || !parsed.metadata) {
|
|
return;
|
|
}
|
|
|
|
const rootIds = new Set(parsed.pointers?.videoRootIds || []);
|
|
if (!rootIds.size && pointerMap instanceof Map) {
|
|
const pointerValues = parsed.pointers?.pointerValues;
|
|
if (pointerValues instanceof Set) {
|
|
pointerValues.forEach((pointerValue) => {
|
|
const info = pointerMap.get(pointerValue);
|
|
if (info?.videoRootId) {
|
|
rootIds.add(info.videoRootId);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
if (!rootIds.size) {
|
|
return;
|
|
}
|
|
|
|
rootIds.forEach((rootId) => {
|
|
storeNip71RecordForRoot(nip71Cache, rootId, parsed);
|
|
});
|
|
});
|
|
}
|
|
|
|
export function mergeNip71MetadataIntoVideo(video, { nip71Cache } = {}) {
|
|
if (!video || typeof video !== "object") {
|
|
return video;
|
|
}
|
|
|
|
const rootId = typeof video.videoRootId === "string" ? video.videoRootId : "";
|
|
if (!rootId) {
|
|
return video;
|
|
}
|
|
|
|
const cacheEntry = nip71Cache instanceof Map ? nip71Cache.get(rootId) : null;
|
|
|
|
let appliedMetadata = null;
|
|
let sourceEventId = "";
|
|
let sourceCreatedAt = 0;
|
|
|
|
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 (!appliedMetadata) {
|
|
const extracted = extractNip71MetadataFromTags(video);
|
|
if (extracted?.metadata) {
|
|
const cloned = cloneNip71Metadata(extracted.metadata);
|
|
if (cloned) {
|
|
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 (appliedMetadata && Array.isArray(video.tags)) {
|
|
const hasVideoTopicTag = video.tags.some(
|
|
(tag) => Array.isArray(tag) && tag[0] === "t" && tag[1] === "video",
|
|
);
|
|
if (hasVideoTopicTag) {
|
|
if (Array.isArray(appliedMetadata.hashtags)) {
|
|
const filteredHashtags = appliedMetadata.hashtags.filter((value) => {
|
|
if (typeof value !== "string") {
|
|
return false;
|
|
}
|
|
const trimmed = value.trim();
|
|
return trimmed && trimmed.toLowerCase() !== "video";
|
|
});
|
|
if (filteredHashtags.length) {
|
|
appliedMetadata.hashtags = filteredHashtags;
|
|
} else {
|
|
delete appliedMetadata.hashtags;
|
|
}
|
|
}
|
|
|
|
if (Array.isArray(appliedMetadata.t)) {
|
|
const filteredTopics = appliedMetadata.t.filter((value) => {
|
|
if (typeof value !== "string") {
|
|
return false;
|
|
}
|
|
const trimmed = value.trim();
|
|
return trimmed && trimmed.toLowerCase() !== "video";
|
|
});
|
|
if (filteredTopics.length) {
|
|
appliedMetadata.t = filteredTopics;
|
|
} else {
|
|
delete appliedMetadata.t;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
export async function populateNip71MetadataForVideos(videos = [], {
|
|
nip71Cache,
|
|
pointerMap = null,
|
|
fetchMetadata,
|
|
} = {}) {
|
|
if (!Array.isArray(videos) || !videos.length || !(nip71Cache instanceof Map)) {
|
|
return;
|
|
}
|
|
|
|
const effectivePointerMap =
|
|
pointerMap instanceof Map ? pointerMap : collectNip71PointerRequests(videos);
|
|
|
|
const pointersToFetch = [];
|
|
|
|
effectivePointerMap.forEach((info, pointerValue) => {
|
|
const entry = ensureNip71CacheEntry(nip71Cache, info.videoRootId);
|
|
if (!entry) {
|
|
return;
|
|
}
|
|
|
|
let needsFetch = false;
|
|
|
|
info.videoEventIds.forEach((eventId) => {
|
|
if (eventId && !entry.byVideoEventId.has(eventId)) {
|
|
needsFetch = true;
|
|
}
|
|
});
|
|
|
|
if (!needsFetch) {
|
|
info.dTags.forEach((dTag) => {
|
|
if (dTag && !entry.byDTag.has(dTag)) {
|
|
needsFetch = true;
|
|
}
|
|
});
|
|
}
|
|
|
|
if (!needsFetch && !entry.fallback) {
|
|
needsFetch = true;
|
|
}
|
|
|
|
if (needsFetch && !entry.fetchedPointers.has(pointerValue)) {
|
|
pointersToFetch.push(pointerValue);
|
|
} else {
|
|
entry.fetchedPointers.add(pointerValue);
|
|
}
|
|
});
|
|
|
|
if (pointersToFetch.length && typeof fetchMetadata === "function") {
|
|
await fetchMetadata(effectivePointerMap, pointersToFetch);
|
|
}
|
|
|
|
effectivePointerMap.forEach((info, pointerValue) => {
|
|
const entry = ensureNip71CacheEntry(nip71Cache, info.videoRootId);
|
|
if (entry) {
|
|
entry.fetchedPointers.add(pointerValue);
|
|
}
|
|
});
|
|
|
|
videos.forEach((video) => {
|
|
mergeNip71MetadataIntoVideo(video, { nip71Cache });
|
|
});
|
|
}
|
|
|
|
const EXTENSION_MIME_MAP = Object.freeze(
|
|
Object.fromEntries(
|
|
Object.entries({
|
|
mp4: "video/mp4",
|
|
m4v: "video/x-m4v",
|
|
webm: "video/webm",
|
|
mkv: "video/x-matroska",
|
|
mov: "video/quicktime",
|
|
avi: "video/x-msvideo",
|
|
ogv: "video/ogg",
|
|
ogg: "video/ogg",
|
|
m3u8: "application/x-mpegurl",
|
|
mpd: "application/dash+xml",
|
|
ts: "video/mp2t",
|
|
mpg: "video/mpeg",
|
|
mpeg: "video/mpeg",
|
|
flv: "video/x-flv",
|
|
"3gp": "video/3gpp",
|
|
}).map(([extension, mimeType]) => [
|
|
extension,
|
|
typeof mimeType === "string" ? mimeType.toLowerCase() : "",
|
|
]),
|
|
),
|
|
);
|
|
|
|
function inferMimeTypeFromUrl(url) {
|
|
if (!url || typeof url !== "string") {
|
|
return "";
|
|
}
|
|
|
|
let pathname = "";
|
|
try {
|
|
const parsed = new URL(url);
|
|
pathname = parsed.pathname || "";
|
|
} catch (err) {
|
|
const sanitized = url.split("?")[0].split("#")[0];
|
|
pathname = sanitized || "";
|
|
}
|
|
|
|
const lastSegment = pathname.split("/").pop() || "";
|
|
const match = lastSegment.match(/\.([a-z0-9]+)$/i);
|
|
if (!match) {
|
|
return "";
|
|
}
|
|
|
|
const extension = match[1].toLowerCase();
|
|
const mimeType = EXTENSION_MIME_MAP[extension];
|
|
return typeof mimeType === "string" ? mimeType : "";
|
|
}
|
|
|
|
export function convertEventToVideo(event = {}) {
|
|
const safeTrim = (value) => (typeof value === "string" ? value.trim() : "");
|
|
|
|
const rawContent = typeof event.content === "string" ? event.content : "";
|
|
const tags = Array.isArray(event.tags) ? event.tags : [];
|
|
|
|
let parsedContent = {};
|
|
let parseError = null;
|
|
if (rawContent) {
|
|
try {
|
|
const parsed = JSON.parse(rawContent);
|
|
if (parsed && typeof parsed === "object") {
|
|
parsedContent = parsed;
|
|
}
|
|
} catch (err) {
|
|
parseError = err;
|
|
parsedContent = {};
|
|
}
|
|
}
|
|
|
|
const directUrl = safeTrim(parsedContent.url);
|
|
const directMagnetRaw = safeTrim(parsedContent.magnet);
|
|
|
|
const normalizeMagnetCandidate = (value) => {
|
|
if (typeof value !== "string") {
|
|
return "";
|
|
}
|
|
const trimmed = value.trim();
|
|
if (!trimmed) {
|
|
return "";
|
|
}
|
|
if (trimmed.toLowerCase().startsWith("magnet:?")) {
|
|
return trimmed;
|
|
}
|
|
const extracted = magnetFromText(trimmed);
|
|
return extracted ? extracted.trim() : "";
|
|
};
|
|
|
|
let magnet = normalizeMagnetCandidate(directMagnetRaw);
|
|
let rawMagnet = magnet ? directMagnetRaw : "";
|
|
|
|
if (!magnet && ACCEPT_LEGACY_V1) {
|
|
const inlineMagnet = normalizeMagnetCandidate(rawContent);
|
|
if (inlineMagnet) {
|
|
magnet = inlineMagnet;
|
|
}
|
|
|
|
if (!magnet) {
|
|
outer: for (const tag of tags) {
|
|
if (!Array.isArray(tag) || tag.length < 2) {
|
|
continue;
|
|
}
|
|
|
|
const key =
|
|
typeof tag[0] === "string" ? tag[0].trim().toLowerCase() : "";
|
|
|
|
const startIndex = key === "magnet" ? 1 : 0;
|
|
for (let i = startIndex; i < tag.length; i += 1) {
|
|
const candidate = normalizeMagnetCandidate(tag[i]);
|
|
if (candidate) {
|
|
magnet = candidate;
|
|
break outer;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!magnet) {
|
|
const recoveredFromRaw = magnetFromText(rawContent);
|
|
if (recoveredFromRaw) {
|
|
magnet = safeTrim(recoveredFromRaw);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!rawMagnet && magnet) {
|
|
rawMagnet = magnet;
|
|
}
|
|
|
|
const url = directUrl;
|
|
|
|
if (!url && !magnet) {
|
|
return { id: event.id, invalid: true, reason: "missing playable source" };
|
|
}
|
|
|
|
const thumbnail = safeTrim(parsedContent.thumbnail);
|
|
const description = safeTrim(parsedContent.description);
|
|
const rawMode = safeTrim(parsedContent.mode);
|
|
const mode = rawMode || "live";
|
|
const deleted = parsedContent.deleted === true;
|
|
const isPrivate = parsedContent.isPrivate === true;
|
|
const isNsfw = parsedContent.isNsfw === true;
|
|
const isForKids = parsedContent.isForKids === true && !isNsfw;
|
|
const videoRootId = safeTrim(parsedContent.videoRootId) || event.id;
|
|
const wsField = safeTrim(parsedContent.ws);
|
|
const xsField = safeTrim(parsedContent.xs);
|
|
const enableComments =
|
|
parsedContent.enableComments === false ? false : true;
|
|
|
|
let infoHash = "";
|
|
const pushInfoHash = (candidate) => {
|
|
if (typeof candidate !== "string") {
|
|
return false;
|
|
}
|
|
const normalized = candidate.trim().toLowerCase();
|
|
if (/^[0-9a-f]{40}$/.test(normalized)) {
|
|
infoHash = normalized;
|
|
return true;
|
|
}
|
|
return false;
|
|
};
|
|
|
|
pushInfoHash(parsedContent.infoHash);
|
|
|
|
if (!infoHash && magnet) {
|
|
const match = magnet.match(/xt=urn:btih:([0-9a-z]+)/i);
|
|
if (match && match[1]) {
|
|
pushInfoHash(match[1]);
|
|
}
|
|
}
|
|
|
|
let derivedTitle = typeof parsedContent.title === "string" ? parsedContent.title : "";
|
|
if (!derivedTitle) {
|
|
derivedTitle = deriveTitleFromEvent({
|
|
id: event.id,
|
|
pubkey: event.pubkey,
|
|
content: rawContent,
|
|
tags,
|
|
});
|
|
}
|
|
|
|
let title = safeTrim(derivedTitle);
|
|
if (!title && ACCEPT_LEGACY_V1 && (magnet || infoHash)) {
|
|
title = infoHash
|
|
? `Legacy Video ${infoHash.slice(0, 8)}`
|
|
: "Legacy BitTorrent Video";
|
|
}
|
|
|
|
if (!title) {
|
|
const reason = parseError
|
|
? "missing title (json parse error)"
|
|
: "missing title";
|
|
return { id: event.id, invalid: true, reason };
|
|
}
|
|
|
|
const rawVersion = parsedContent.version;
|
|
let version = rawVersion === undefined ? 2 : Number(rawVersion);
|
|
if (!Number.isFinite(version)) {
|
|
version = rawVersion === undefined ? 2 : 1;
|
|
}
|
|
|
|
if (version < 2 && !ACCEPT_LEGACY_V1) {
|
|
return {
|
|
id: event.id,
|
|
invalid: true,
|
|
reason: `unsupported version ${version}`,
|
|
};
|
|
}
|
|
|
|
const magnetHints = magnet
|
|
? extractMagnetHints(magnet)
|
|
: { ws: "", xs: "" };
|
|
const ws = wsField || magnetHints.ws || "";
|
|
const xs = xsField || magnetHints.xs || "";
|
|
|
|
return {
|
|
id: event.id,
|
|
videoRootId,
|
|
version,
|
|
isPrivate,
|
|
isNsfw,
|
|
isForKids,
|
|
title,
|
|
url,
|
|
magnet,
|
|
rawMagnet,
|
|
infoHash,
|
|
thumbnail,
|
|
description,
|
|
mode,
|
|
deleted,
|
|
ws,
|
|
xs,
|
|
enableComments,
|
|
pubkey: event.pubkey,
|
|
created_at: event.created_at,
|
|
tags,
|
|
invalid: false,
|
|
};
|
|
}
|
|
|
|
export { stringFromInput };
|