mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
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:
1 parent
618525b412
commit
a1bf7b151f
3 files changed
+104
-3
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user