feat(video): hashtags data layer (content + t tags + parser)

Polish toward NIP-71 parity + better native discovery. Hashtags now flow through
the video data layer (the input UI in the upload/edit modals is the remaining
step):
- videoPayloadBuilder: sanitizeVideoHashtags (normalize/dedupe, drop reserved
  "video"); persists hashtags in 30078 content AND emits ["t", tag] so bitvid's
  own feed scoring (exploreScoring/kidsScoring already read t tags) and relay
  queries pick them up.
- convertEventToVideo: exposes video.hashtags from content → the NIP-71 mirror
  builder already carries them as t tags, so a shared video is categorized on
  other apps too.

tests/video-hashtags.test.mjs: sanitize rules, content+t-tag emission, round-trip
to video.hashtags, empty case. Mutation-verified (t-tag emission load-bearing).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-06-22 10:41:46 -04:00
1 parent 618525b412
commit a1bf7b151f
3 files changed
+104 -3

No files matched your search

+6
View File
@@ -1515,6 +1515,11 @@ export function convertEventToVideo(event = {}) {
const isPrivate = parsedContent.isPrivate === true;
const isNsfw = parsedContent.isNsfw === true;
const isForKids = parsedContent.isForKids === true && !isNsfw;
const hashtags = Array.isArray(parsedContent.hashtags)
? parsedContent.hashtags
.filter((value) => typeof value === "string" && value.trim())
.map((value) => value.trim())
: [];
const videoRootId = safeTrim(parsedContent.videoRootId) || event.id;
const wsField = safeTrim(parsedContent.ws);
const xsField = safeTrim(parsedContent.xs);
@@ -1618,6 +1623,7 @@ export function convertEventToVideo(event = {}) {
description,
mode,
deleted,
hashtags,
ws,
xs,
storagePointer,
+39 -3
View File
@@ -5,8 +5,33 @@ import { buildNip71MetadataTags, extractNip71MetadataFromTags } from "./nip71.js
import { buildVideoPostEvent } from "../nostrEventSchemas.js";
import { computeSha256HexFromValue } from "../utils/cryptoUtils.js";
import { inferMimeTypeFromUrl } from "../utils/mime.js";
import { normalizeHashtag } from "../utils/hashtagNormalization.js";
import { devLogger } from "../utils/logger.js";
// Normalize free-form hashtag input (array or comma/space string) to a deduped,
// lowercased list. Drops the reserved "video" topic tag and caps the count.
export function sanitizeVideoHashtags(input) {
const raw = Array.isArray(input)
? input
: typeof input === "string"
? input.split(/[\s,]+/)
: [];
const out = [];
const seen = new Set();
for (const item of raw) {
const tag = normalizeHashtag(item);
if (!tag || tag === "video" || seen.has(tag)) {
continue;
}
seen.add(tag);
out.push(tag);
if (out.length >= 20) {
break;
}
}
return out;
}
export function extractVideoPublishPayload(rawPayload) {
let videoData = rawPayload;
let nip71Metadata = null;
@@ -161,13 +186,24 @@ export async function prepareVideoPublishPayload(videoPayload, pubkey, { timesta
contentObject.xs = finalXs;
}
const hashtags = sanitizeVideoHashtags(videoData.hashtags);
if (hashtags.length) {
contentObject.hashtags = hashtags;
}
const nip71Tags = buildNip71MetadataTags(
nip71Metadata && typeof nip71Metadata === "object" ? nip71Metadata : null
);
const additionalTags = storagePointer
? [["s", storagePointer], ...nip71Tags]
: nip71Tags;
// Emit hashtags as `t` tags on the 30078 so bitvid's own feed scoring (and
// relay queries) pick them up, in addition to persisting them in content.
const hashtagTags = hashtags.map((tag) => ["t", tag]);
const additionalTags = [
...(storagePointer ? [["s", storagePointer]] : []),
...hashtagTags,
...nip71Tags,
];
const event = buildVideoPostEvent({
pubkey: normalizedPubkey,
+59
View File
@@ -0,0 +1,59 @@
// Hashtags polish: a video's hashtags are persisted in the 30078 content AND
// emitted as `t` tags (so bitvid's feed scoring + relay queries see them), and
// round-trip back through convertEventToVideo onto `video.hashtags` (which the
// NIP-71 mirror builder then carries as `t` tags too).
import "./test-helpers/setup-localstorage.mjs";
import assert from "node:assert/strict";
import test from "node:test";
import {
sanitizeVideoHashtags,
prepareVideoPublishPayload,
} from "../js/nostr/videoPayloadBuilder.js";
import { convertEventToVideo } from "../js/nostr/nip71.js";
const PUBKEY = "a".repeat(64);
function publish(extra) {
return prepareVideoPublishPayload(
{ title: "Hello", url: "https://cdn.example.com/v.mp4", ...extra },
PUBKEY,
{ timestamp: 1000 },
);
}
test("sanitizeVideoHashtags: normalizes, dedupes, drops the reserved 'video' tag", () => {
assert.deepEqual(
sanitizeVideoHashtags(["#GameStr", "gamestr", " Voxel ", "video", ""]),
["gamestr", "voxel"],
);
assert.deepEqual(sanitizeVideoHashtags("#ai, blender cartoon"), ["ai", "blender", "cartoon"]);
assert.deepEqual(sanitizeVideoHashtags(null), []);
});
test("publish payload persists hashtags in content AND emits matching `t` tags", async () => {
const { event } = await publish({
videoRootId: "root-1",
hashtags: ["#GameStr", "voxel", "voxel"],
});
const content = JSON.parse(event.content);
assert.deepEqual(content.hashtags, ["gamestr", "voxel"], "stored in content (normalized/deduped)");
const tTags = event.tags.filter((t) => t[0] === "t").map((t) => t[1]);
assert.ok(tTags.includes("gamestr"));
assert.ok(tTags.includes("voxel"));
});
test("round-trip: convertEventToVideo exposes video.hashtags from content", async () => {
const { event } = await publish({ videoRootId: "root-2", hashtags: ["nostr", "p2p"] });
const video = convertEventToVideo(event);
assert.equal(video.invalid, false);
assert.deepEqual(video.hashtags.sort(), ["nostr", "p2p"]);
});
test("no hashtags → no content.hashtags", async () => {
const { event } = await publish({ videoRootId: "root-3" });
const content = JSON.parse(event.content);
assert.equal(content.hashtags, undefined);
});