From 82986650cee31699fc0b5664aa761529a77854b9 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 26 Jan 2026 12:25:54 -0500 Subject: [PATCH] Store video file fingerprints alongside series IDs --- docs/nostr-event-schemas.md | 11 +++++-- js/nostr/client.js | 52 +++++++++++++++++++++++++++++++++ js/nostr/nip71.js | 15 ++++++++++ js/nostrEventSchemas.js | 3 ++ js/services/r2Service.js | 1 + js/services/s3UploadService.js | 1 + js/services/videoNotePayload.js | 39 +++++++++++++++++++++++++ js/ui/components/UploadModal.js | 1 + 8 files changed, 121 insertions(+), 2 deletions(-) diff --git a/docs/nostr-event-schemas.md b/docs/nostr-event-schemas.md index cd521229..f30a32cb 100644 --- a/docs/nostr-event-schemas.md +++ b/docs/nostr-event-schemas.md @@ -33,7 +33,12 @@ const event = buildVideoPostEvent({ pubkey, created_at: Math.floor(Date.now() / 1000), dTagValue: "debug-video", - content: { version: 3, title: "Test", videoRootId: "debug" }, + content: { + version: 3, + title: "Test", + videoRootId: "debug", + infoHash: "0123456789abcdef0123456789abcdef01234567", + }, }); ``` @@ -145,7 +150,7 @@ import { updateWatchHistoryListWithDefaultClient } from "./nostrWatchHistoryFaca | Note | Kind (default) | Tags | Content format | | --- | --- | --- | --- | -| Video post (`NOTE_TYPES.VIDEO_POST`) | `30078` | `['t','video']`, `['d', ]` plus optional schema append tags | JSON payload using Content Schema v3 (`version`, `title`, optional `url`, `magnet`, `thumbnail`, `description`, `mode`, `videoRootId`, `deleted`, `isPrivate`, `isNsfw`, `isForKids`, `enableComments`, `ws`, `xs`) | +| Video post (`NOTE_TYPES.VIDEO_POST`) | `30078` | `['t','video']`, `['d', ]` plus optional schema append tags | JSON payload using Content Schema v3 (`version`, `title`, optional `url`, `magnet`, `thumbnail`, `description`, `mode`, `videoRootId`, `deleted`, `isPrivate`, `isNsfw`, `isForKids`, `enableComments`, `infoHash`, `fileSha256`, `originalFileSha256`, `ws`, `xs`) | | NIP-94 mirror (`NOTE_TYPES.VIDEO_MIRROR`) | `1063` | Tags forwarded from `publishVideo` (URL, mime type, thumbnail, alt text, magnet) | Plain text alt description | | Repost (`NOTE_TYPES.REPOST`) | `6` | `['e', , ]` with optional address pointer `['a', , ]`, and `['p', ]` when the origin author is known; inherits schema append tags | JSON-serialized event being reposted (or empty if unavailable) | | Generic Repost (`NOTE_TYPES.GENERIC_REPOST`) | `16` | Same as Repost; specific for non-text kinds (e.g. videos). | Same as Repost. | @@ -158,6 +163,8 @@ import { updateWatchHistoryListWithDefaultClient } from "./nostrWatchHistoryFaca > **Publishing note:** Session actors only emit passive telemetry (for example, view counters) and must **not** sign video comments. Require a logged-in Nostr signer for comment publishing via [`commentEvents`](../js/nostr/commentEvents.js). +Video posts should treat `videoRootId` as the stable series identifier that remains unchanged across edits and deletes. File fingerprints such as `infoHash` or `fileSha256` live alongside the content payload so media swaps do not break the version chain. + | NIP-71 video (`NOTE_TYPES.NIP71_VIDEO`) | `21` | `['title', ]`, optional `['published_at', <unix seconds>]`, optional `['alt', <text>]`, repeated `['imeta', ...]` entries describing NIP-92 media variants, optional `['duration', <seconds>]`, repeated `['text-track', <url>, <kind>, <language>]`, optional `['content-warning', <reason>]`, repeated `['segment', <start>, <end>, <title>, <thumbnail>]`, repeated hashtags `['t', <tag>]`, repeated participants `['p', <pubkey>, <relay?>]`, repeated references `['r', <url>]` | Plain text summary carried in the content field. Publishing is gated by the `FEATURE_PUBLISH_NIP71` runtime flag while the rollout stabilizes. | | NIP-71 short video (`NOTE_TYPES.NIP71_SHORT_VIDEO`) | `22` | Same as `NOTE_TYPES.NIP71_VIDEO`; the kind differentiates short-form presentations. | Plain text summary; gated by `FEATURE_PUBLISH_NIP71`. | | Relay list (`NOTE_TYPES.RELAY_LIST`) | `10002` | Repeating `['r', <relay url>]` tags, optionally with a marker of `'read'` or `'write'` to scope the relay; marker omitted for read/write relays | Empty content | diff --git a/js/nostr/client.js b/js/nostr/client.js index 303913bd..c66becd8 100644 --- a/js/nostr/client.js +++ b/js/nostr/client.js @@ -21,6 +21,7 @@ import { isDevMode } from "../config.js"; import { FEATURE_PUBLISH_NIP71 } from "../constants.js"; import { accessControl } from "../accessControl.js"; import { bytesToHex, sha256 } from "../../vendor/crypto-helpers.bundle.min.js"; +import { infoHashFromMagnet } from "../magnets.js"; // 🔧 merged conflicting changes from codex/update-video-publishing-and-parsing-logic vs unstable import { buildNip71MetadataTags, @@ -5415,6 +5416,20 @@ export class NostrClient { typeof videoData.ws === "string" ? videoData.ws.trim() : ""; const finalXs = typeof videoData.xs === "string" ? videoData.xs.trim() : ""; + const infoHashCandidates = [videoData.infoHash]; + let infoHash = ""; + for (const candidate of infoHashCandidates) { + const normalized = infoHashFromMagnet(candidate); + if (normalized) { + infoHash = normalized; + break; + } + } + if (!infoHash && finalMagnet) { + infoHash = infoHashFromMagnet(finalMagnet) || ""; + } + const fileSha256 = normalizeHexHash(videoData.fileSha256); + const originalFileSha256 = normalizeHexHash(videoData.originalFileSha256); const contentObject = { version: 3, @@ -5432,6 +5447,18 @@ export class NostrClient { enableComments: finalEnableComments, }; + if (infoHash) { + contentObject.infoHash = infoHash; + } + + if (fileSha256) { + contentObject.fileSha256 = fileSha256; + } + + if (originalFileSha256) { + contentObject.originalFileSha256 = originalFileSha256; + } + if (finalWs) { contentObject.ws = finalWs; } @@ -5805,6 +5832,19 @@ export class NostrClient { ? false : true; + const updatedInfoHash = infoHashFromMagnet(updatedData?.infoHash || ""); + let infoHash = + updatedInfoHash || + infoHashFromMagnet(finalMagnet) || + infoHashFromMagnet(baseEvent.infoHash) || + ""; + const fileSha256 = + normalizeHexHash(updatedData?.fileSha256) || + normalizeHexHash(baseEvent.fileSha256); + const originalFileSha256 = + normalizeHexHash(updatedData?.originalFileSha256) || + normalizeHexHash(baseEvent.originalFileSha256); + // Use the existing videoRootId (or fall back to the base event's ID) const oldRootId = baseEvent.videoRootId || baseEvent.id; @@ -5837,6 +5877,18 @@ export class NostrClient { enableComments: finalEnableComments, }; + if (infoHash) { + contentObject.infoHash = infoHash; + } + + if (fileSha256) { + contentObject.fileSha256 = fileSha256; + } + + if (originalFileSha256) { + contentObject.originalFileSha256 = originalFileSha256; + } + if (finalWs) { contentObject.ws = finalWs; } diff --git a/js/nostr/nip71.js b/js/nostr/nip71.js index 7696d8f9..2d02c751 100644 --- a/js/nostr/nip71.js +++ b/js/nostr/nip71.js @@ -1403,6 +1403,17 @@ export function convertEventToVideo(event = {}) { const enableComments = parsedContent.enableComments === false ? false : true; + const normalizeSha256 = (candidate) => { + if (typeof candidate !== "string") { + return ""; + } + const normalized = candidate.trim().toLowerCase(); + if (!normalized) { + return ""; + } + return /^[0-9a-f]{64}$/.test(normalized) ? normalized : ""; + }; + let infoHash = ""; const pushInfoHash = (candidate) => { if (typeof candidate !== "string") { @@ -1417,6 +1428,8 @@ export function convertEventToVideo(event = {}) { }; pushInfoHash(parsedContent.infoHash); + const fileSha256 = normalizeSha256(parsedContent.fileSha256); + const originalFileSha256 = normalizeSha256(parsedContent.originalFileSha256); if (!infoHash && magnet) { const match = magnet.match(/xt=urn:btih:([0-9a-z]+)/i); @@ -1476,6 +1489,8 @@ export function convertEventToVideo(event = {}) { magnet, rawMagnet, infoHash, + fileSha256, + originalFileSha256, thumbnail, description, mode, diff --git a/js/nostrEventSchemas.js b/js/nostrEventSchemas.js index 86d5843b..47df5e49 100644 --- a/js/nostrEventSchemas.js +++ b/js/nostrEventSchemas.js @@ -283,6 +283,9 @@ const BASE_SCHEMAS = { { key: "isNsfw", type: "boolean", required: false }, { key: "isForKids", type: "boolean", required: false }, { key: "enableComments", type: "boolean", required: false }, + { key: "infoHash", type: "string", required: false }, + { key: "fileSha256", type: "string", required: false }, + { key: "originalFileSha256", type: "string", required: false }, { key: "ws", type: "string", required: false }, { key: "xs", type: "string", required: false }, ], diff --git a/js/services/r2Service.js b/js/services/r2Service.js index bcb9241b..c17500c6 100644 --- a/js/services/r2Service.js +++ b/js/services/r2Service.js @@ -1018,6 +1018,7 @@ class R2Service { description: metadata?.description ?? "", ws: generatedWs || (metadata?.ws ?? ""), xs: torrentUrl || (metadata?.xs ?? ""), + infoHash: hasValidInfoHash ? normalizedInfoHash : "", enableComments: metadata?.enableComments, isNsfw: metadata?.isNsfw, isForKids: metadata?.isForKids, diff --git a/js/services/s3UploadService.js b/js/services/s3UploadService.js index 5ab61c58..b438e396 100644 --- a/js/services/s3UploadService.js +++ b/js/services/s3UploadService.js @@ -355,6 +355,7 @@ class S3UploadService { description: metadata?.description ?? "", ws: generatedWs || (metadata?.ws ?? ""), xs: torrentUrl || (metadata?.xs ?? ""), + infoHash: hasValidInfoHash ? normalizedInfoHash : "", enableComments: metadata?.enableComments, isNsfw: metadata?.isNsfw, isForKids: metadata?.isForKids, diff --git a/js/services/videoNotePayload.js b/js/services/videoNotePayload.js index 4dab6e33..f7663094 100644 --- a/js/services/videoNotePayload.js +++ b/js/services/videoNotePayload.js @@ -1,5 +1,6 @@ import { extractMagnetHints } from "../magnetShared.js"; import { normalizeAndAugmentMagnet } from "../magnetUtils.js"; +import { infoHashFromMagnet } from "../magnets.js"; export const VIDEO_NOTE_ERROR_CODES = { MISSING_TITLE: "missing_title", @@ -29,6 +30,25 @@ function normalizeString(value) { return String(value ?? "").trim(); } +function normalizeSha256Hex(value) { + if (value === undefined || value === null) { + return ""; + } + const normalized = normalizeString(value).toLowerCase(); + if (!normalized) { + return ""; + } + return /^[0-9a-f]{64}$/.test(normalized) ? normalized : ""; +} + +function normalizeInfoHash(value) { + if (value === undefined || value === null) { + return ""; + } + const normalized = infoHashFromMagnet(normalizeString(value)); + return typeof normalized === "string" && normalized ? normalized : ""; +} + function parseNumberOrNull(value) { if (value === null || value === undefined || value === "") { return null; @@ -320,6 +340,11 @@ export function normalizeVideoNotePayload(input) { const description = normalizeString(legacyPayload?.description || ""); const ws = normalizeString(legacyPayload?.ws || ""); const xs = normalizeString(legacyPayload?.xs || ""); + const infoHash = normalizeInfoHash(legacyPayload?.infoHash || ""); + const fileSha256 = normalizeSha256Hex(legacyPayload?.fileSha256 || ""); + const originalFileSha256 = normalizeSha256Hex( + legacyPayload?.originalFileSha256 || "" + ); const rawMode = normalizeString(legacyPayload?.mode || ""); const normalizedMode = rawMode && rawMode.toLowerCase() === "dev" ? "dev" : "live"; const enableComments = normalizeBooleanFlag(legacyPayload?.enableComments, true); @@ -401,6 +426,20 @@ export function normalizeVideoNotePayload(input) { legacyFormData.xs = ""; } + const derivedInfoHash = + infoHash || normalizeInfoHash(legacyFormData.magnet) || ""; + if (derivedInfoHash) { + legacyFormData.infoHash = derivedInfoHash; + } + + if (fileSha256) { + legacyFormData.fileSha256 = fileSha256; + } + + if (originalFileSha256) { + legacyFormData.originalFileSha256 = originalFileSha256; + } + const publishPayload = { legacyFormData, }; diff --git a/js/ui/components/UploadModal.js b/js/ui/components/UploadModal.js index bc25afef..3680a591 100644 --- a/js/ui/components/UploadModal.js +++ b/js/ui/components/UploadModal.js @@ -1017,6 +1017,7 @@ export class UploadModal { magnet: "", ws: this.inputs.ws?.value?.trim() || "", xs: this.inputs.xs?.value?.trim() || "", + infoHash: this.torrentState.infoHash || "", }; // NIP-71 Advanced Data