mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
Audit found the search pipeline largely real (token parser, local + NIP-50 relay search, enforced author/tag/date/has/nsfw/kind filters, wired modal) with dead spots, now fixed: - sort: offered in the modal but never applied. New pure sortSearchResults — recent (default), views (shared view-count cache), trending (views weighted by recency), longest — applied in searchView. - duration filter only read nip71.duration, so bitvid's OWN kind-30078 videos (top-level duration) never matched. Both video types now satisfy duration filters; same shapes cover the planned live (30311) / shorts kinds. - textScope now enforced in the local title/description match (all|title|description). - relay: now scopes the video relay-search to that relay (was parsed-but-unused). - FEATURE_SEARCH_FILTERS removed: dev-only flag consumed by NOTHING while the filter UI ships and works everywhere (plumbing + setter deleted, no consumers). Tests (tests/search-filters.test.mjs, 4 — surface previously had zero): parser tokens; matcher enforces tags/date/has:magnet/duration for BOTH duration shapes; views/longest/recent sorts (no input mutation); trending ranks fresh-riser above stale-hit. Lint + build green. Co-Authored-By: Claude Opus 4.8 <[email protected]>
119 lines
4.4 KiB
JavaScript
119 lines
4.4 KiB
JavaScript
import { ALLOW_NSFW_CONTENT } from "../config.js";
|
|
|
|
export function buildVideoSearchFilterMatcher(filters = {}, options = {}) {
|
|
const allowNsfw = options.allowNsfw ?? ALLOW_NSFW_CONTENT;
|
|
const authorSet = new Set(
|
|
Array.isArray(filters?.authorPubkeys)
|
|
? filters.authorPubkeys.map((author) => author.toLowerCase())
|
|
: [],
|
|
);
|
|
const tagSet = new Set(
|
|
Array.isArray(filters?.tags) ? filters.tags.map((tag) => tag.toLowerCase()) : [],
|
|
);
|
|
const afterDate = Number.isFinite(filters?.dateRange?.after)
|
|
? filters.dateRange.after
|
|
: null;
|
|
const beforeDate = Number.isFinite(filters?.dateRange?.before)
|
|
? filters.dateRange.before
|
|
: null;
|
|
const minDuration = Number.isFinite(filters?.duration?.minSeconds)
|
|
? filters.duration.minSeconds
|
|
: null;
|
|
const maxDuration = Number.isFinite(filters?.duration?.maxSeconds)
|
|
? filters.duration.maxSeconds
|
|
: null;
|
|
const hasMagnet = filters?.hasMagnet === true;
|
|
const hasUrl = filters?.hasUrl === true;
|
|
const nsfwFilter = typeof filters?.nsfw === "string" ? filters.nsfw : "any";
|
|
|
|
return (video) => {
|
|
if (!video) return false;
|
|
|
|
if (authorSet.size > 0) {
|
|
const pubkey = typeof video.pubkey === "string" ? video.pubkey.toLowerCase() : "";
|
|
if (!pubkey || !authorSet.has(pubkey)) return false;
|
|
}
|
|
|
|
if (tagSet.size > 0) {
|
|
const tags = Array.isArray(video.tags) ? video.tags : [];
|
|
const hasTag = tags.some((tag) => {
|
|
if (Array.isArray(tag)) {
|
|
return tag[0] === "t" && tagSet.has(String(tag[1] || "").toLowerCase());
|
|
}
|
|
return tagSet.has(String(tag || "").toLowerCase());
|
|
});
|
|
if (!hasTag) return false;
|
|
}
|
|
|
|
if (Number.isFinite(afterDate) && (!Number.isFinite(video.created_at) || video.created_at < afterDate)) {
|
|
return false;
|
|
}
|
|
if (Number.isFinite(beforeDate) && (!Number.isFinite(video.created_at) || video.created_at > beforeDate)) {
|
|
return false;
|
|
}
|
|
|
|
if (hasMagnet && !video.magnet) return false;
|
|
if (hasUrl && !video.url) return false;
|
|
|
|
if (Number.isFinite(minDuration) || Number.isFinite(maxDuration)) {
|
|
// Both video types carry duration differently: bitvid's kind-30078 puts
|
|
// it top-level (captured at upload); ingested NIP-71 (21/22/34235/34236)
|
|
// nests it in nip71 metadata. Future live (30311) / shorts use the same
|
|
// shapes, so check both.
|
|
const duration = Number(video.duration) || Number(video.nip71?.duration);
|
|
if (!Number.isFinite(duration)) return false;
|
|
if (Number.isFinite(minDuration) && duration < minDuration) return false;
|
|
if (Number.isFinite(maxDuration) && duration > maxDuration) return false;
|
|
}
|
|
|
|
if (allowNsfw !== true && video.isNsfw === true) {
|
|
return false;
|
|
}
|
|
|
|
if (allowNsfw === true) {
|
|
if ((nsfwFilter === "true" || nsfwFilter === "only") && video.isNsfw !== true) {
|
|
return false;
|
|
}
|
|
if (nsfwFilter === "false" && video.isNsfw === true) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
};
|
|
}
|
|
|
|
// Sort search results per the modal's Sort By options. Pure + injectable
|
|
// (getViews) so it is unit-testable. "recent" (and the "relevance" default)
|
|
// keep newest-first; "views" ranks by the shared view-count cache; "trending"
|
|
// weights views by recency (views / age-in-days^1.5, same spirit as the
|
|
// Trending tab); "longest" ranks by duration.
|
|
export function sortSearchResults(videos, sort, { getViews, now = Date.now() } = {}) {
|
|
const list = Array.isArray(videos) ? [...videos] : [];
|
|
const views = (video) => {
|
|
if (typeof getViews !== "function") return 0;
|
|
const value = getViews(video);
|
|
return Number.isFinite(value) ? value : 0;
|
|
};
|
|
const createdAt = (video) => Number(video?.created_at) || 0;
|
|
const duration = (video) =>
|
|
Number(video?.duration) || Number(video?.nip71?.duration) || 0;
|
|
|
|
switch (sort) {
|
|
case "views":
|
|
return list.sort((a, b) => views(b) - views(a) || createdAt(b) - createdAt(a));
|
|
case "trending": {
|
|
const score = (video) => {
|
|
const ageDays = Math.max((now / 1000 - createdAt(video)) / 86400, 0.25);
|
|
return views(video) / Math.pow(ageDays, 1.5);
|
|
};
|
|
return list.sort((a, b) => score(b) - score(a) || createdAt(b) - createdAt(a));
|
|
}
|
|
case "longest":
|
|
return list.sort((a, b) => duration(b) - duration(a) || createdAt(b) - createdAt(a));
|
|
case "recent":
|
|
default:
|
|
return list.sort((a, b) => createdAt(b) - createdAt(a));
|
|
}
|
|
}
|