Add storage pointer tags to video posts

This commit is contained in:
thePR0M3TH3AN committed 2026-01-26 22:07:32 -05:00
1 parent da5b1107d7
commit 419a3f419c
11 files changed
+328 -4

No files matched your search

+3 -1
View File
@@ -150,7 +150,7 @@ import { updateWatchHistoryListWithDefaultClient } from "./nostrWatchHistoryFaca
| Note | Kind (default) | Tags | Content format |
| --- | --- | --- | --- |
| Video post (`NOTE_TYPES.VIDEO_POST`) | `30078` | `['t','video']`, `['d', <stable video identifier>]` 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', <stable video identifier>]`, `['s', <storage pointer>]` 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', <event id>, <relay?>]` with optional address pointer `['a', <kind:pubkey:identifier>, <relay?>]`, and `['p', <pubkey>]` 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', '<provider>:<prefix>']`). The `<prefix>` 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', <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. |
+73 -2
View File
@@ -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);
+11
View File
@@ -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,
+22
View File
@@ -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) {
+14
View File
@@ -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 : "",
+14
View File
@@ -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 : "",
+20
View File
@@ -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) {
+28
View File
@@ -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 = {
+7
View File
@@ -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) {
+13 -1
View File
@@ -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) {
+123
View File
@@ -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);
}