mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-31 18:02:27 +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 isPrivate = parsedContent.isPrivate === true;
|
||||||
const isNsfw = parsedContent.isNsfw === true;
|
const isNsfw = parsedContent.isNsfw === true;
|
||||||
const isForKids = parsedContent.isForKids === true && !isNsfw;
|
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 videoRootId = safeTrim(parsedContent.videoRootId) || event.id;
|
||||||
const wsField = safeTrim(parsedContent.ws);
|
const wsField = safeTrim(parsedContent.ws);
|
||||||
const xsField = safeTrim(parsedContent.xs);
|
const xsField = safeTrim(parsedContent.xs);
|
||||||
@@ -1618,6 +1623,7 @@ export function convertEventToVideo(event = {}) {
|
|||||||
description,
|
description,
|
||||||
mode,
|
mode,
|
||||||
deleted,
|
deleted,
|
||||||
|
hashtags,
|
||||||
ws,
|
ws,
|
||||||
xs,
|
xs,
|
||||||
storagePointer,
|
storagePointer,
|
||||||
|
|||||||
@@ -5,8 +5,33 @@ import { buildNip71MetadataTags, extractNip71MetadataFromTags } from "./nip71.js
|
|||||||
import { buildVideoPostEvent } from "../nostrEventSchemas.js";
|
import { buildVideoPostEvent } from "../nostrEventSchemas.js";
|
||||||
import { computeSha256HexFromValue } from "../utils/cryptoUtils.js";
|
import { computeSha256HexFromValue } from "../utils/cryptoUtils.js";
|
||||||
import { inferMimeTypeFromUrl } from "../utils/mime.js";
|
import { inferMimeTypeFromUrl } from "../utils/mime.js";
|
||||||
|
import { normalizeHashtag } from "../utils/hashtagNormalization.js";
|
||||||
import { devLogger } from "../utils/logger.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) {
|
export function extractVideoPublishPayload(rawPayload) {
|
||||||
let videoData = rawPayload;
|
let videoData = rawPayload;
|
||||||
let nip71Metadata = null;
|
let nip71Metadata = null;
|
||||||
@@ -161,13 +186,24 @@ export async function prepareVideoPublishPayload(videoPayload, pubkey, { timesta
|
|||||||
contentObject.xs = finalXs;
|
contentObject.xs = finalXs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hashtags = sanitizeVideoHashtags(videoData.hashtags);
|
||||||
|
if (hashtags.length) {
|
||||||
|
contentObject.hashtags = hashtags;
|
||||||
|
}
|
||||||
|
|
||||||
const nip71Tags = buildNip71MetadataTags(
|
const nip71Tags = buildNip71MetadataTags(
|
||||||
nip71Metadata && typeof nip71Metadata === "object" ? nip71Metadata : null
|
nip71Metadata && typeof nip71Metadata === "object" ? nip71Metadata : null
|
||||||
);
|
);
|
||||||
|
|
||||||
const additionalTags = storagePointer
|
// Emit hashtags as `t` tags on the 30078 so bitvid's own feed scoring (and
|
||||||
? [["s", storagePointer], ...nip71Tags]
|
// relay queries) pick them up, in addition to persisting them in content.
|
||||||
: nip71Tags;
|
const hashtagTags = hashtags.map((tag) => ["t", tag]);
|
||||||
|
|
||||||
|
const additionalTags = [
|
||||||
|
...(storagePointer ? [["s", storagePointer]] : []),
|
||||||
|
...hashtagTags,
|
||||||
|
...nip71Tags,
|
||||||
|
];
|
||||||
|
|
||||||
const event = buildVideoPostEvent({
|
const event = buildVideoPostEvent({
|
||||||
pubkey: normalizedPubkey,
|
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