Files
bitvid/js/nostr/nip71.js
T
google-labs-jules[bot] 4df81f7f19 Remove legacy V1 magnet and unstructured event support
Removed the `ACCEPT_LEGACY_V1` flag and associated logic that supported parsing magnet links from unstructured text notes or tags, as well as support for video events with version < 2.
This simplifies `js/videoEventUtils.js` and `js/nostr/nip71.js`, and removes legacy regex scanning.
Deleted `tests/legacy-infohash.test.mjs` which tested this deprecated functionality.
2026-01-06 21:34:19 +00:00

1429 lines
34 KiB
JavaScript

import { deriveTitleFromEvent } 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;
}
return "";
};
let magnet = normalizeMagnetCandidate(directMagnetRaw);
let rawMagnet = magnet ? directMagnetRaw : "";
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) {
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) {
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 };