diff --git a/docs/nostr-event-schemas.md b/docs/nostr-event-schemas.md index f30a32cb..eb2feb11 100644 --- a/docs/nostr-event-schemas.md +++ b/docs/nostr-event-schemas.md @@ -150,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`, `infoHash`, `fileSha256`, `originalFileSha256`, `ws`, `xs`) | +| Video post (`NOTE_TYPES.VIDEO_POST`) | `30078` | `['t','video']`, `['d', ]`, `['s', ]` 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. | @@ -163,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 now include a required storage pointer tag (`['s', ':']`). The `` should resolve to the public storage base for the video assets (for example, a public bucket URL plus the object key without its extension). Clients derive `info.json` by appending `.info.json` to the prefix (or by combining a path-style prefix with the hosted URL origin if needed). + 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. | diff --git a/js/nostr/client.js b/js/nostr/client.js index 26e29bb9..96c3c4b1 100644 --- a/js/nostr/client.js +++ b/js/nostr/client.js @@ -36,6 +36,12 @@ import { buildVideoPointerValue, stringFromInput, } from "./nip71.js"; +import { + buildStoragePointerValue, + deriveStoragePointerFromUrl, + getStoragePointerFromTags, + normalizeStoragePointer, +} from "../utils/storagePointer.js"; import { createWatchHistoryManager, normalizePointerInput, @@ -818,6 +824,40 @@ function normalizeHexHash(candidate) { return HEX64_REGEX.test(trimmed) ? trimmed : ""; } +function resolveStoragePointerValue({ + storagePointer, + url, + infoHash, + fallbackId, + provider, +} = {}) { + const normalized = normalizeStoragePointer(storagePointer); + if (normalized) { + return normalized; + } + + const derivedFromUrl = deriveStoragePointerFromUrl(url, provider || "url"); + if (derivedFromUrl) { + return derivedFromUrl; + } + + if (typeof infoHash === "string" && infoHash.trim()) { + return buildStoragePointerValue({ + provider: "btih", + prefix: infoHash.trim().toLowerCase(), + }); + } + + if (typeof fallbackId === "string" && fallbackId.trim()) { + return buildStoragePointerValue({ + provider: "nostr", + prefix: fallbackId.trim(), + }); + } + + return ""; +} + const BlobConstructor = typeof Blob !== "undefined" ? Blob : null; let sharedTextEncoder = null; @@ -5365,6 +5405,13 @@ export class NostrClient { } const fileSha256 = normalizeHexHash(videoData.fileSha256); const originalFileSha256 = normalizeHexHash(videoData.originalFileSha256); + const storagePointer = resolveStoragePointerValue({ + storagePointer: videoData.storagePointer, + url: finalUrl, + infoHash, + fallbackId: videoRootId, + provider: videoData.storageProvider, + }); const contentObject = { version: 3, @@ -5406,12 +5453,15 @@ export class NostrClient { nip71Metadata && typeof nip71Metadata === "object" ? nip71Metadata : null ); + const additionalTags = storagePointer + ? [["s", storagePointer], ...nip71Tags] + : nip71Tags; const event = buildVideoPostEvent({ pubkey: normalizedPubkey, created_at: createdAt, dTagValue, content: contentObject, - additionalTags: nip71Tags, + additionalTags, }); devLogger.log("Publish event with series identifier:", videoRootId); @@ -5782,6 +5832,16 @@ export class NostrClient { // Use the existing videoRootId (or fall back to the base event's ID) const oldRootId = baseEvent.videoRootId || baseEvent.id; + const storagePointer = resolveStoragePointerValue({ + storagePointer: + updatedData?.storagePointer || + baseEvent.storagePointer || + getStoragePointerFromTags(baseEvent.tags), + url: finalUrl, + infoHash, + fallbackId: oldRootId, + provider: updatedData?.storageProvider || baseEvent.storageProvider, + }); const preservedDTag = this.resolveEventDTag(baseEvent, originalEventStub); const fallbackDTag = @@ -5847,12 +5907,15 @@ export class NostrClient { const nip71Tags = buildNip71MetadataTags(metadataForTags); + const additionalTags = storagePointer + ? [["s", storagePointer], ...nip71Tags] + : nip71Tags; const event = buildVideoPostEvent({ pubkey: userPubkeyLower, created_at: Math.floor(Date.now() / 1000), dTagValue: finalDTagValue, content: contentObject, - additionalTags: nip71Tags, + additionalTags, }); devLogger.log("Creating edited event with root ID:", oldRootId); @@ -6022,11 +6085,19 @@ export class NostrClient { mode: oldContent.mode || "live", }; + const storagePointer = resolveStoragePointerValue({ + storagePointer: getStoragePointerFromTags(baseEvent.tags), + url: oldContent.url, + infoHash: oldContent.infoHash, + fallbackId: finalRootId, + }); + const additionalTags = storagePointer ? [["s", storagePointer]] : []; const event = buildVideoPostEvent({ pubkey, created_at: Math.floor(Date.now() / 1000), dTagValue: stableDTag, content: contentObject, + additionalTags, }); await this.ensureActiveSignerForPubkey(pubkey); diff --git a/js/nostr/nip71.js b/js/nostr/nip71.js index a20ef173..514242ed 100644 --- a/js/nostr/nip71.js +++ b/js/nostr/nip71.js @@ -1,6 +1,10 @@ import { deriveTitleFromEvent } from "../videoEventUtils.js"; import { extractBtihFromMagnet, extractMagnetHints } from "../magnetShared.js"; import { devLogger } from "../utils/logger.js"; +import { + getStoragePointerFromTags, + resolveInfoJsonUrl, +} from "../utils/storagePointer.js"; import { getCachedNostrTools } from "./toolkit.js"; function stringFromInput(value) { @@ -1476,6 +1480,11 @@ export function convertEventToVideo(event = {}) { : { ws: "", xs: "" }; const ws = wsField || magnetHints.ws || ""; const xs = xsField || magnetHints.xs || ""; + const storagePointer = getStoragePointerFromTags(tags); + const infoJsonUrl = resolveInfoJsonUrl({ + storagePointer, + url, + }); return { id: event.id, @@ -1497,6 +1506,8 @@ export function convertEventToVideo(event = {}) { deleted, ws, xs, + storagePointer, + infoJsonUrl, enableComments, pubkey: event.pubkey, created_at: event.created_at, diff --git a/js/nostrEventSchemas.js b/js/nostrEventSchemas.js index 47df5e49..8a111d55 100644 --- a/js/nostrEventSchemas.js +++ b/js/nostrEventSchemas.js @@ -265,6 +265,7 @@ const BASE_SCHEMAS = { kind: 30078, topicTag: { name: "t", value: "video" }, identifierTag: { name: "d" }, + requiredTagNames: ["s"], appendTags: DEFAULT_APPEND_TAGS, content: { format: "json", @@ -2521,6 +2522,19 @@ function hasTag(tags, tagName, tagValue = null) { ); } +function hasTagName(tags, tagName) { + if (!Array.isArray(tags)) return false; + return tags.some((tag) => { + if (!Array.isArray(tag) || tag[0] !== tagName) { + return false; + } + if (tag.length < 2 || typeof tag[1] !== "string") { + return false; + } + return tag[1].trim().length > 0; + }); +} + export function validateEventStructure(type, event) { const schema = getNostrEventSchema(type); const errors = []; @@ -2558,6 +2572,14 @@ export function validateEventStructure(type, event) { } } + if (Array.isArray(schema.requiredTagNames)) { + schema.requiredTagNames.forEach((tagName) => { + if (!hasTagName(event.tags, tagName)) { + errors.push(`Missing required tag for ${type}: ${tagName}`); + } + }); + } + if (schema.appendTags) { schema.appendTags.forEach((appendTag) => { if (Array.isArray(appendTag) && appendTag.length >= 2) { diff --git a/js/services/r2Service.js b/js/services/r2Service.js index 6ae7e1be..16677194 100644 --- a/js/services/r2Service.js +++ b/js/services/r2Service.js @@ -38,6 +38,10 @@ import { import { ensureS3SdkLoaded, makeS3Client } from "../storage/s3-client.js"; import { truncateMiddle } from "../utils/formatters.js"; import { userLogger, devLogger } from "../utils/logger.js"; +import { + buildStoragePointerValue, + buildStoragePrefixFromKey, +} from "../utils/storagePointer.js"; import { getVideoNoteErrorMessage, normalizeVideoNotePayload, @@ -1033,12 +1037,22 @@ class R2Service { ); } + const storagePrefix = buildStoragePrefixFromKey({ + publicBaseUrl: bucketEntry.publicBaseUrl, + key, + }); + const storagePointer = buildStoragePointerValue({ + provider: "r2", + prefix: storagePrefix, + }); + const rawVideoPayload = { title, url: publicUrl, // Primary URL magnet: generatedMagnet || (metadata?.magnet ?? ""), thumbnail: metadata?.thumbnail ?? "", description: metadata?.description ?? "", + storagePointer, ws: generatedWs || (metadata?.ws ?? ""), xs: torrentUrl || (metadata?.xs ?? ""), infoHash: hasValidInfoHash ? normalizedInfoHash : "", diff --git a/js/services/s3UploadService.js b/js/services/s3UploadService.js index 4e950bb9..58264b2d 100644 --- a/js/services/s3UploadService.js +++ b/js/services/s3UploadService.js @@ -8,6 +8,10 @@ import { validateS3Connection, } from "./s3Service.js"; import { userLogger } from "../utils/logger.js"; +import { + buildStoragePointerValue, + buildStoragePrefixFromKey, +} from "../utils/storagePointer.js"; import { getVideoNoteErrorMessage, normalizeVideoNotePayload, @@ -370,12 +374,22 @@ class S3UploadService { ); } + const storagePrefix = buildStoragePrefixFromKey({ + publicBaseUrl: normalized.publicBaseUrl, + key, + }); + const storagePointer = buildStoragePointerValue({ + provider: "s3", + prefix: storagePrefix, + }); + const rawVideoPayload = { title, url: publicUrl, magnet: generatedMagnet || (metadata?.magnet ?? ""), thumbnail: metadata?.thumbnail ?? "", description: metadata?.description ?? "", + storagePointer, ws: generatedWs || (metadata?.ws ?? ""), xs: torrentUrl || (metadata?.xs ?? ""), infoHash: hasValidInfoHash ? normalizedInfoHash : "", diff --git a/js/services/videoNotePayload.js b/js/services/videoNotePayload.js index f7663094..1496c6ef 100644 --- a/js/services/videoNotePayload.js +++ b/js/services/videoNotePayload.js @@ -1,6 +1,10 @@ import { extractMagnetHints } from "../magnetShared.js"; import { normalizeAndAugmentMagnet } from "../magnetUtils.js"; import { infoHashFromMagnet } from "../magnets.js"; +import { + deriveStoragePointerFromUrl, + normalizeStoragePointer, +} from "../utils/storagePointer.js"; export const VIDEO_NOTE_ERROR_CODES = { MISSING_TITLE: "missing_title", @@ -340,6 +344,12 @@ export function normalizeVideoNotePayload(input) { const description = normalizeString(legacyPayload?.description || ""); const ws = normalizeString(legacyPayload?.ws || ""); const xs = normalizeString(legacyPayload?.xs || ""); + const storagePointer = normalizeStoragePointer( + legacyPayload?.storagePointer || legacyPayload?.storage || "" + ); + const storageProvider = normalizeString( + legacyPayload?.storageProvider || "" + ); const infoHash = normalizeInfoHash(legacyPayload?.infoHash || ""); const fileSha256 = normalizeSha256Hex(legacyPayload?.fileSha256 || ""); const originalFileSha256 = normalizeSha256Hex( @@ -426,6 +436,16 @@ export function normalizeVideoNotePayload(input) { legacyFormData.xs = ""; } + const derivedStoragePointer = + storagePointer || + deriveStoragePointerFromUrl( + legacyFormData.url, + storageProvider || "url" + ); + if (derivedStoragePointer) { + legacyFormData.storagePointer = derivedStoragePointer; + } + const derivedInfoHash = infoHash || normalizeInfoHash(legacyFormData.magnet) || ""; if (derivedInfoHash) { diff --git a/js/ui/components/UploadModal.js b/js/ui/components/UploadModal.js index 32f4d226..a00be258 100644 --- a/js/ui/components/UploadModal.js +++ b/js/ui/components/UploadModal.js @@ -14,6 +14,11 @@ import { sanitizeBucketName } from "../../storage/r2-mgmt.js"; import { buildR2Key, buildPublicUrl } from "../../r2.js"; import { buildS3ObjectUrl } from "../../services/s3Service.js"; import { PROVIDERS } from "../../services/storageService.js"; +import { + buildStoragePointerValue, + buildStoragePrefixFromKey, + deriveStoragePointerFromUrl, +} from "../../utils/storagePointer.js"; import { getActiveSigner, requestDefaultExtensionPermissions, @@ -594,6 +599,18 @@ export class UploadModal { this.videoUploadState.status = 'complete'; this.videoUploadState.url = videoPublicUrl; this.videoUploadState.key = videoKey; + const storagePrefix = buildStoragePrefixFromKey({ + publicBaseUrl: baseDomain, + key: videoKey, + }); + const providerLabel = + this.activeProvider === PROVIDERS.R2 || this.activeProvider === "cloudflare_r2" + ? "r2" + : "s3"; + this.videoUploadState.storagePointer = buildStoragePointerValue({ + provider: providerLabel, + prefix: storagePrefix, + }); this.results.videoUrl.value = videoPublicUrl; this.updateVideoProgress(1, "Video uploaded."); @@ -1023,6 +1040,7 @@ export class UploadModal { // These might be empty depending on mode, filled below url: "", magnet: "", + storagePointer: "", ws: this.inputs.ws?.value?.trim() || "", xs: this.inputs.xs?.value?.trim() || "", infoHash: this.torrentState.infoHash || "", @@ -1039,6 +1057,13 @@ export class UploadModal { // We already have the URLs in state metadata.url = this.videoUploadState.url; metadata.magnet = this.torrentState.magnet || ""; + const providerLabel = + this.activeProvider === PROVIDERS.R2 || this.activeProvider === "cloudflare_r2" + ? "r2" + : "s3"; + metadata.storagePointer = + this.videoUploadState.storagePointer || + deriveStoragePointerFromUrl(metadata.url, providerLabel); metadata.ws = this.videoUploadState.url; // WebSeed is same as R2 URL metadata.xs = this.torrentState.url || ""; @@ -1112,6 +1137,8 @@ export class UploadModal { async handleExternalFlow(metadata) { metadata.url = this.inputs.url?.value?.trim() || ""; metadata.magnet = this.inputs.magnet?.value?.trim() || ""; + metadata.storagePointer = + metadata.storagePointer || deriveStoragePointerFromUrl(metadata.url, "url"); const hasUrl = metadata.url.length > 0; const hasMagnet = metadata.magnet.length > 0; @@ -1162,6 +1189,7 @@ export class UploadModal { progress: 0, url: '', key: '', + storagePointer: '', file: null, }; this.thumbnailUploadState = { diff --git a/js/ui/components/VideoCard.js b/js/ui/components/VideoCard.js index b241cefb..4e36c461 100644 --- a/js/ui/components/VideoCard.js +++ b/js/ui/components/VideoCard.js @@ -2667,6 +2667,13 @@ export class VideoCard { if (!el) return; el.dataset.playUrl = encodeURIComponent(this.playbackUrl || ""); el.dataset.playMagnet = this.playbackMagnet || ""; + const infoJsonUrl = + typeof this.video?.infoJsonUrl === "string" ? this.video.infoJsonUrl : ""; + if (infoJsonUrl) { + el.dataset.infoJson = encodeURIComponent(infoJsonUrl); + } else if (el.dataset.infoJson) { + delete el.dataset.infoJson; + } if (this.magnetProvided) { el.dataset.torrentSupported = this.magnetSupported ? "true" : "false"; } else if (el.dataset.torrentSupported) { diff --git a/js/ui/views/VideoListView.js b/js/ui/views/VideoListView.js index f8cf031e..b6965f95 100644 --- a/js/ui/views/VideoListView.js +++ b/js/ui/views/VideoListView.js @@ -1041,6 +1041,10 @@ export class VideoListView { (target?.dataset && typeof target.dataset.playMagnet === "string" ? target.dataset.playMagnet : null) ?? target?.getAttribute?.("data-play-magnet") ?? ""; + const rawInfoJsonValue = + (target?.dataset && typeof target.dataset.infoJson === "string" + ? target.dataset.infoJson + : null) ?? target?.getAttribute?.("data-info-json") ?? ""; let url = ""; if (rawUrlValue) { @@ -1052,10 +1056,18 @@ export class VideoListView { } const magnet = typeof rawMagnetValue === "string" ? rawMagnetValue : ""; + let infoJsonUrl = ""; + if (rawInfoJsonValue) { + try { + infoJsonUrl = decodeURIComponent(rawInfoJsonValue); + } catch (error) { + infoJsonUrl = rawInfoJsonValue; + } + } const videoId = target?.dataset?.videoId || target?.getAttribute?.("data-video-id") || video?.id || ""; - return { videoId, url, magnet, video, trigger: element }; + return { videoId, url, magnet, infoJsonUrl, video, trigger: element }; } emitSelected(detail) { diff --git a/js/utils/storagePointer.js b/js/utils/storagePointer.js new file mode 100644 index 00000000..5155cec9 --- /dev/null +++ b/js/utils/storagePointer.js @@ -0,0 +1,123 @@ +const INFO_JSON_SUFFIX = ".info.json"; + +function safeTrim(value) { + return typeof value === "string" ? value.trim() : ""; +} + +export function normalizeStoragePointer(value) { + return safeTrim(value); +} + +export function parseStoragePointer(value) { + const normalized = normalizeStoragePointer(value); + if (!normalized) { + return null; + } + + const separatorIndex = normalized.indexOf(":"); + if (separatorIndex <= 0) { + return null; + } + + const provider = normalized.slice(0, separatorIndex).trim().toLowerCase(); + const prefix = normalized.slice(separatorIndex + 1).trim(); + if (!provider || !prefix) { + return null; + } + + return { provider, prefix }; +} + +export function buildStoragePointerValue({ provider, prefix } = {}) { + const normalizedProvider = safeTrim(provider).toLowerCase(); + const normalizedPrefix = safeTrim(prefix); + if (!normalizedProvider || !normalizedPrefix) { + return ""; + } + + return `${normalizedProvider}:${normalizedPrefix}`; +} + +export function deriveStoragePrefixFromUrl(url) { + const trimmed = safeTrim(url); + if (!trimmed || !/^https?:\/\//i.test(trimmed)) { + return ""; + } + + try { + const parsed = new URL(trimmed); + const path = parsed.pathname || ""; + if (!path || path === "/") { + return ""; + } + const normalizedPath = path.replace(/\/+$/, ""); + const withoutExtension = normalizedPath.replace(/\.[^/.]+$/, ""); + const cleanedPath = withoutExtension.startsWith("/") + ? withoutExtension + : `/${withoutExtension}`; + return `${parsed.origin}${cleanedPath}`; + } catch (error) { + return ""; + } +} + +export function deriveStoragePointerFromUrl(url, provider = "url") { + const prefix = deriveStoragePrefixFromUrl(url); + return buildStoragePointerValue({ provider, prefix }); +} + +export function buildStoragePrefixFromKey({ publicBaseUrl, key } = {}) { + const base = safeTrim(publicBaseUrl).replace(/\/+$/, ""); + const normalizedKey = safeTrim(key).replace(/^\//, ""); + if (!base || !normalizedKey) { + return ""; + } + const baseKey = normalizedKey.replace(/\.[^/.]+$/, ""); + if (!baseKey) { + return ""; + } + return `${base}/${baseKey}`; +} + +export function getStoragePointerFromTags(tags = []) { + if (!Array.isArray(tags)) { + return ""; + } + const tag = tags.find( + (entry) => + Array.isArray(entry) && + entry[0] === "s" && + typeof entry[1] === "string" + ); + return normalizeStoragePointer(tag?.[1]); +} + +function normalizeInfoJsonBase(base) { + const trimmed = safeTrim(base); + if (!trimmed) { + return ""; + } + if (trimmed.toLowerCase().endsWith(INFO_JSON_SUFFIX)) { + return trimmed; + } + return `${trimmed.replace(/\/+$/, "")}${INFO_JSON_SUFFIX}`; +} + +export function resolveInfoJsonUrl({ storagePointer, url } = {}) { + const parsed = parseStoragePointer(storagePointer); + if (!parsed) { + return ""; + } + + const prefix = parsed.prefix; + if (/^https?:\/\//i.test(prefix)) { + return normalizeInfoJsonBase(prefix); + } + + const fallbackPrefix = deriveStoragePrefixFromUrl(url); + if (fallbackPrefix) { + return normalizeInfoJsonBase(fallbackPrefix); + } + + return normalizeInfoJsonBase(prefix); +}