From 0569755901d850d97bd867dd7e24b8ee2a1584e4 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Tue, 23 Sep 2025 10:30:21 -0400 Subject: [PATCH] Support legacy Nostr video payloads --- js/channelProfile.js | 60 ++++++++++++-------------- js/nostr.js | 71 +++++++++++++------------------ js/subscriptions.js | 57 ++++++++++++++----------- js/videoEventUtils.js | 99 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 190 insertions(+), 97 deletions(-) create mode 100644 js/videoEventUtils.js diff --git a/js/channelProfile.js b/js/channelProfile.js index 2093fdc4..cb683f59 100644 --- a/js/channelProfile.js +++ b/js/channelProfile.js @@ -5,6 +5,7 @@ import { app } from "./app.js"; import { subscriptions } from "./subscriptions.js"; // <-- NEW import import { initialBlacklist, initialWhitelist } from "./lists.js"; import { isWhitelistEnabled } from "./config.js"; +import { parseVideoEventPayload } from "./videoEventUtils.js"; /** * Initialize the channel profile view. @@ -467,40 +468,35 @@ function dedupeToNewestByRoot(videos) { * Convert raw event => "video" object. */ function localConvertEventToVideo(event) { - try { - const content = JSON.parse(event.content || "{}"); - const isSupportedVersion = content.version >= 2; - const hasRequiredFields = !!( - content.title && (content.url || content.magnet) - ); + const { parsedContent, parseError, title, url, magnet, version } = + parseVideoEventPayload(event); - if (!isSupportedVersion) { - return { id: event.id, invalid: true, reason: "version <2" }; - } - if (!hasRequiredFields) { - return { id: event.id, invalid: true, reason: "missing title/url+magnet" }; - } - - return { - id: event.id, - videoRootId: content.videoRootId || event.id, - version: content.version, - isPrivate: content.isPrivate ?? false, - title: content.title ?? "", - url: content.url ?? "", - magnet: content.magnet ?? "", - thumbnail: content.thumbnail ?? "", - description: content.description ?? "", - mode: content.mode ?? "live", - deleted: content.deleted === true, - pubkey: event.pubkey, - created_at: event.created_at, - tags: event.tags, - invalid: false, - }; - } catch (err) { - return { id: event.id, invalid: true, reason: "json parse error" }; + if (!title) { + const reason = parseError ? "missing title (json parse error)" : "missing title"; + return { id: event.id, invalid: true, reason }; } + + if (!url && !magnet) { + return { id: event.id, invalid: true, reason: "missing playable source" }; + } + + return { + id: event.id, + videoRootId: parsedContent.videoRootId || event.id, + version, + isPrivate: parsedContent.isPrivate ?? false, + title, + url, + magnet, + thumbnail: parsedContent.thumbnail ?? "", + description: parsedContent.description ?? "", + mode: parsedContent.mode ?? "live", + deleted: parsedContent.deleted === true, + pubkey: event.pubkey, + created_at: event.created_at, + tags: event.tags, + invalid: false, + }; } /** diff --git a/js/nostr.js b/js/nostr.js index 2f11b765..eed46479 100644 --- a/js/nostr.js +++ b/js/nostr.js @@ -2,6 +2,7 @@ import { isDevMode } from "./config.js"; import { accessControl } from "./accessControl.js"; +import { parseVideoEventPayload } from "./videoEventUtils.js"; /** * The usual relays @@ -90,51 +91,39 @@ function inferMimeTypeFromUrl(url) { * CHANGED: skip if version <2 */ function convertEventToVideo(event) { - try { - const content = JSON.parse(event.content || "{}"); + const { parsedContent, parseError, title, url, magnet, version } = + parseVideoEventPayload(event); - // Example checks: - const isSupportedVersion = content.version >= 2; - const hasRequiredFields = !!( - content.title && (content.url || content.magnet) - ); - - if (!isSupportedVersion) { - return { - id: event.id, - invalid: true, - reason: "version <2", - }; - } - if (!hasRequiredFields) { - return { - id: event.id, - invalid: true, - reason: "missing title/url+magnet", - }; - } + if (!title) { + const reason = parseError ? "missing title (json parse error)" : "missing title"; + return { id: event.id, invalid: true, reason }; + } + if (!url && !magnet) { return { id: event.id, - videoRootId: content.videoRootId || event.id, - version: content.version, - isPrivate: content.isPrivate ?? false, - title: content.title ?? "", - url: content.url ?? "", - magnet: content.magnet ?? "", - thumbnail: content.thumbnail ?? "", - description: content.description ?? "", - mode: content.mode ?? "live", - deleted: content.deleted === true, - pubkey: event.pubkey, - created_at: event.created_at, - tags: event.tags, - invalid: false, + invalid: true, + reason: "missing playable source", }; - } catch (err) { - // JSON parse error - return { id: event.id, invalid: true, reason: "json parse error" }; } + + return { + id: event.id, + videoRootId: parsedContent.videoRootId || event.id, + version, + isPrivate: parsedContent.isPrivate ?? false, + title, + url, + magnet, + thumbnail: parsedContent.thumbnail ?? "", + description: parsedContent.description ?? "", + mode: parsedContent.mode ?? "live", + deleted: parsedContent.deleted === true, + pubkey: event.pubkey, + created_at: event.created_at, + tags: event.tags, + invalid: false, + }; } /** @@ -787,7 +776,7 @@ class NostrClient { sub.on("eose", () => { if (isDevMode && invalidDuringSub.length > 0) { console.warn( - `[subscribeVideos] found ${invalidDuringSub.length} invalid v2 notes:`, + `[subscribeVideos] found ${invalidDuringSub.length} invalid video notes (with reasons):`, invalidDuringSub ); } @@ -854,7 +843,7 @@ class NostrClient { // OPTIONAL: Log invalid stats if (invalidNotes.length > 0 && isDevMode) { console.warn( - `Skipped ${invalidNotes.length} invalid v2 notes:\n`, + `Skipped ${invalidNotes.length} invalid video notes:\n`, invalidNotes.map((n) => `${n.id.slice(0, 8)}.. => ${n.reason}`) ); } diff --git a/js/subscriptions.js b/js/subscriptions.js index fd00d36e..29386149 100644 --- a/js/subscriptions.js +++ b/js/subscriptions.js @@ -1,5 +1,6 @@ // js/subscriptions.js import { nostrClient } from "./nostr.js"; +import { parseVideoEventPayload } from "./videoEventUtils.js"; /** * Manages the user's subscription list (kind=30002) *privately*, @@ -500,34 +501,42 @@ class SubscriptionsManager { } convertEventToVideo(evt) { - try { - const content = JSON.parse(evt.content || "{}"); - const hasFields = !!( - content.title && (content.url || content.magnet) - ); - const versionOk = content.version >= 2; - if (!versionOk || !hasFields) { - return { id: evt.id, invalid: true }; - } + const { parsedContent, parseError, title, url, magnet, version } = + parseVideoEventPayload(evt); + + if (!title) { return { id: evt.id, - pubkey: evt.pubkey, - created_at: evt.created_at, - videoRootId: content.videoRootId || evt.id, - version: content.version, - deleted: content.deleted === true, - isPrivate: content.isPrivate === true, - title: content.title || "", - url: content.url || "", - magnet: content.magnet || "", - thumbnail: content.thumbnail || "", - description: content.description || "", - tags: evt.tags || [], - invalid: false, + invalid: true, + reason: parseError ? "missing title (json parse error)" : "missing title", }; - } catch (err) { - return { id: evt.id, invalid: true }; } + + if (!url && !magnet) { + return { + id: evt.id, + invalid: true, + reason: "missing playable source", + }; + } + + return { + id: evt.id, + pubkey: evt.pubkey, + created_at: evt.created_at, + videoRootId: parsedContent.videoRootId || evt.id, + version, + deleted: parsedContent.deleted === true, + isPrivate: parsedContent.isPrivate ?? false, + title, + url, + magnet, + thumbnail: parsedContent.thumbnail ?? "", + description: parsedContent.description ?? "", + mode: parsedContent.mode ?? "live", + tags: evt.tags || [], + invalid: false, + }; } dedupeToNewestByRoot(videos) { diff --git a/js/videoEventUtils.js b/js/videoEventUtils.js new file mode 100644 index 00000000..b445a085 --- /dev/null +++ b/js/videoEventUtils.js @@ -0,0 +1,99 @@ +// js/videoEventUtils.js + +/** + * Extracts normalized fields from a Bitvid video event while + * tolerating legacy payloads that may omit version >= 2 metadata. + */ +export function parseVideoEventPayload(event = {}) { + const rawContent = typeof event.content === "string" ? event.content : ""; + + 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 title = typeof parsedContent.title === "string" + ? parsedContent.title.trim() + : ""; + const thumbnail = typeof parsedContent.thumbnail === "string" + ? parsedContent.thumbnail.trim() + : ""; + + const magnetCandidates = []; + const urlCandidates = []; + + const pushUnique = (arr, value) => { + if (!value || typeof value !== "string") return; + const trimmed = value.trim(); + if (!trimmed || arr.includes(trimmed)) return; + arr.push(trimmed); + }; + + if (typeof parsedContent.magnet === "string") { + pushUnique(magnetCandidates, parsedContent.magnet); + } + if (typeof parsedContent.url === "string") { + const parsedUrl = parsedContent.url.trim(); + if (parsedUrl && parsedUrl !== thumbnail) { + pushUnique(urlCandidates, parsedUrl); + } + } + + const tags = Array.isArray(event.tags) ? event.tags : []; + const urlTagKeys = new Set(["r", "url", "u"]); + for (const tag of tags) { + if (!Array.isArray(tag) || tag.length < 2) continue; + const key = typeof tag[0] === "string" ? tag[0] : ""; + const value = typeof tag[1] === "string" ? tag[1] : ""; + if (!value) continue; + + if (value.toLowerCase().startsWith("magnet:")) { + pushUnique(magnetCandidates, value); + continue; + } + + if (urlTagKeys.has(key) && /^https?:\/\//i.test(value)) { + pushUnique(urlCandidates, value); + } + } + + const magnetMatch = rawContent.match(/magnet:\?xt=urn:[^"'\s<>]+/i); + if (magnetMatch) { + pushUnique(magnetCandidates, magnetMatch[0]); + } + + const magnet = magnetCandidates.find(Boolean) || ""; + const url = + urlCandidates.find( + (candidate) => candidate && !candidate.toLowerCase().startsWith("magnet:") + ) || ""; + + const rawVersion = parsedContent.version; + let version = 0; + if (typeof rawVersion === "number" && Number.isFinite(rawVersion)) { + version = rawVersion; + } else if (typeof rawVersion === "string") { + const parsedVersion = Number(rawVersion); + if (!Number.isNaN(parsedVersion)) { + version = parsedVersion; + } + } + + return { + parsedContent, + parseError, + title, + url, + magnet, + version, + }; +}