mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-28 00:12:25 +00:00
feat(nip71): mirror carries the video's hashtags (from t tags / nip71 metadata)
Closes the real hashtag gap. Verified the upload modal's existing NIP-71 hashtag
editor (Nip71FormManager → nip71.hashtags → buildNip71MetadataTags) ALREADY emits
`t` tags on the 30078, which bitvid's feed scoring already reads — so publish+feed
hashtags work today. The gap was the 34235 mirror reading only `video.hashtags`
(empty for form-authored videos).
buildNip71MirrorEvent now resolves hashtags from whichever source is present —
video.hashtags, video.nip71.hashtags, or the raw `t` tags on video.tags — deduped,
lowercased, with bitvid's internal "video" topic tag dropped. So a shared video
stays categorized on Nostube/Amethyst regardless of how its tags were authored.
(The earlier content.hashtags builder path from a1bf7b15 stays as a harmless
secondary input; the form/t-tag path is the one users actually exercise.)
tests/nip71-mirror.test.mjs: hashtags sourced from nip71.hashtags; from raw t tags
with the 'video' topic dropped + deduped. Mutation-verified.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
1 parent
a1bf7b151f
commit
e793b359c3
4 files changed
+92
-6
No files matched your search
+30
-3
@@ -46,6 +46,35 @@ export function deriveVideoMime(url) {
|
|||||||
return VIDEO_MIME_BY_EXT[ext] || "video/mp4";
|
return VIDEO_MIME_BY_EXT[ext] || "video/mp4";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hashtags can live in several places depending on how the video was authored:
|
||||||
|
// bitvid's own `hashtags`, the parsed NIP-71 metadata (`nip71.hashtags`), or the
|
||||||
|
// raw `t` tags on the 30078 (produced by the upload form's NIP-71 hashtag editor).
|
||||||
|
// Source from whichever is present so a shared video stays categorized elsewhere.
|
||||||
|
// Drops bitvid's internal "video" topic tag and dedupes (case-insensitive).
|
||||||
|
function resolveHashtags(video) {
|
||||||
|
let raw = [];
|
||||||
|
if (Array.isArray(video.hashtags) && video.hashtags.length) {
|
||||||
|
raw = video.hashtags;
|
||||||
|
} else if (Array.isArray(video.nip71?.hashtags) && video.nip71.hashtags.length) {
|
||||||
|
raw = video.nip71.hashtags;
|
||||||
|
} else if (Array.isArray(video.tags)) {
|
||||||
|
raw = video.tags
|
||||||
|
.filter((t) => Array.isArray(t) && t[0] === "t" && typeof t[1] === "string")
|
||||||
|
.map((t) => t[1]);
|
||||||
|
}
|
||||||
|
const out = [];
|
||||||
|
const seen = new Set();
|
||||||
|
for (const value of raw) {
|
||||||
|
const tag = str(value).toLowerCase();
|
||||||
|
if (!tag || tag === "video" || seen.has(tag)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen.add(tag);
|
||||||
|
out.push(tag);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
function normalizePublishedAt(value) {
|
function normalizePublishedAt(value) {
|
||||||
const numeric = Number(value);
|
const numeric = Number(value);
|
||||||
if (!Number.isFinite(numeric) || numeric <= 0) {
|
if (!Number.isFinite(numeric) || numeric <= 0) {
|
||||||
@@ -114,9 +143,7 @@ export function buildNip71MirrorEvent(video, options = {}) {
|
|||||||
duration: Number.isFinite(duration) && duration > 0 ? duration : undefined,
|
duration: Number.isFinite(duration) && duration > 0 ? duration : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
const hashtags = Array.isArray(video.hashtags)
|
const hashtags = resolveHashtags(video);
|
||||||
? video.hashtags.map(str).filter(Boolean)
|
|
||||||
: [];
|
|
||||||
|
|
||||||
const metadata = {
|
const metadata = {
|
||||||
title,
|
title,
|
||||||
|
|||||||
@@ -474,6 +474,16 @@ export function prepareVideoEditPayload(params) {
|
|||||||
contentObject.xs = finalXs;
|
contentObject.xs = finalXs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Prefer edited hashtags, else preserve the base event's so an edit that omits
|
||||||
|
// the field never strips them.
|
||||||
|
const hashtags = sanitizeVideoHashtags(
|
||||||
|
updatedData?.hashtags !== undefined ? updatedData.hashtags : baseEvent.hashtags,
|
||||||
|
);
|
||||||
|
if (hashtags.length) {
|
||||||
|
contentObject.hashtags = hashtags;
|
||||||
|
}
|
||||||
|
const hashtagTags = hashtags.map((tag) => ["t", tag]);
|
||||||
|
|
||||||
let metadataForTags =
|
let metadataForTags =
|
||||||
nip71Metadata && typeof nip71Metadata === "object" ? nip71Metadata : null;
|
nip71Metadata && typeof nip71Metadata === "object" ? nip71Metadata : null;
|
||||||
if (!metadataForTags) {
|
if (!metadataForTags) {
|
||||||
@@ -489,9 +499,11 @@ export function prepareVideoEditPayload(params) {
|
|||||||
|
|
||||||
const nip71Tags = buildNip71MetadataTags(metadataForTags);
|
const nip71Tags = buildNip71MetadataTags(metadataForTags);
|
||||||
|
|
||||||
const additionalTags = storagePointer
|
const additionalTags = [
|
||||||
? [["s", storagePointer], ...nip71Tags]
|
...(storagePointer ? [["s", storagePointer]] : []),
|
||||||
: nip71Tags;
|
...hashtagTags,
|
||||||
|
...nip71Tags,
|
||||||
|
];
|
||||||
|
|
||||||
// A replaceable event only wins if its created_at is strictly greater than
|
// A replaceable event only wins if its created_at is strictly greater than
|
||||||
// the version it replaces (NIP-01 keeps the highest created_at; ties go to
|
// the version it replaces (NIP-01 keeps the highest created_at; ties go to
|
||||||
|
|||||||
@@ -139,6 +139,24 @@ test("explicit short flag overrides dimension inference", async () => {
|
|||||||
assert.equal(landscapeButShort.event.kind, NIP71_SHORT_VIDEO_KIND);
|
assert.equal(landscapeButShort.event.kind, NIP71_SHORT_VIDEO_KIND);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("mirror sources hashtags from nip71.hashtags when video.hashtags is absent", async () => {
|
||||||
|
const v = baseVideo({ hashtags: undefined, nip71: { hashtags: ["alpha", "beta"] } });
|
||||||
|
const res = buildNip71MirrorEvent(v);
|
||||||
|
const tTags = res.event.tags.filter((t) => t[0] === "t").map((t) => t[1]).sort();
|
||||||
|
assert.deepEqual(tTags, ["alpha", "beta"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("mirror sources hashtags from raw `t` tags, dropping the internal 'video' topic", async () => {
|
||||||
|
const v = baseVideo({
|
||||||
|
hashtags: undefined,
|
||||||
|
tags: [["t", "video"], ["t", "Gamestr"], ["t", "gamestr"], ["d", "root-123"]],
|
||||||
|
});
|
||||||
|
const res = buildNip71MirrorEvent(v);
|
||||||
|
const tTags = res.event.tags.filter((t) => t[0] === "t").map((t) => t[1]);
|
||||||
|
assert.ok(!tTags.includes("video"), "must not leak the bitvid 'video' topic tag");
|
||||||
|
assert.deepEqual(tTags, ["gamestr"], "deduped + lowercased");
|
||||||
|
});
|
||||||
|
|
||||||
test("deriveVideoMime maps common extensions and defaults to mp4", async () => {
|
test("deriveVideoMime maps common extensions and defaults to mp4", async () => {
|
||||||
assert.equal(deriveVideoMime("https://x/y.webm"), "video/webm");
|
assert.equal(deriveVideoMime("https://x/y.webm"), "video/webm");
|
||||||
assert.equal(deriveVideoMime("https://x/y.m3u8?token=1"), "application/x-mpegURL");
|
assert.equal(deriveVideoMime("https://x/y.m3u8?token=1"), "application/x-mpegURL");
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import test from "node:test";
|
|||||||
import {
|
import {
|
||||||
sanitizeVideoHashtags,
|
sanitizeVideoHashtags,
|
||||||
prepareVideoPublishPayload,
|
prepareVideoPublishPayload,
|
||||||
|
prepareVideoEditPayload,
|
||||||
} from "../js/nostr/videoPayloadBuilder.js";
|
} from "../js/nostr/videoPayloadBuilder.js";
|
||||||
import { convertEventToVideo } from "../js/nostr/nip71.js";
|
import { convertEventToVideo } from "../js/nostr/nip71.js";
|
||||||
|
|
||||||
@@ -57,3 +58,31 @@ test("no hashtags → no content.hashtags", async () => {
|
|||||||
const content = JSON.parse(event.content);
|
const content = JSON.parse(event.content);
|
||||||
assert.equal(content.hashtags, undefined);
|
assert.equal(content.hashtags, undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function edit(baseHashtags, updatedData) {
|
||||||
|
return prepareVideoEditPayload({
|
||||||
|
baseEvent: {
|
||||||
|
id: "e1",
|
||||||
|
videoRootId: "root-9",
|
||||||
|
title: "Old",
|
||||||
|
url: "https://cdn.example.com/v.mp4",
|
||||||
|
hashtags: baseHashtags,
|
||||||
|
created_at: 1000,
|
||||||
|
tags: [],
|
||||||
|
},
|
||||||
|
originalEventStub: { id: "e1" },
|
||||||
|
updatedData,
|
||||||
|
userPubkey: PUBKEY,
|
||||||
|
resolveEventDTag: () => "root-9",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("editing WITHOUT a hashtags field preserves the existing hashtags", () => {
|
||||||
|
const { contentObject } = edit(["alpha", "beta"], { title: "New title" });
|
||||||
|
assert.deepEqual(contentObject.hashtags, ["alpha", "beta"], "not stripped on edit");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("editing WITH hashtags overrides them", () => {
|
||||||
|
const { contentObject } = edit(["alpha"], { hashtags: ["gamma", "delta"] });
|
||||||
|
assert.deepEqual(contentObject.hashtags, ["gamma", "delta"]);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user