Files
bitvid/js/search/searchFilterMatchers.js
T
thePR0M3TH3ANandClaude Opus 4.8 a6a6d4b0f7 [search] Wire sort/textScope/relay:, fix dual-type duration, drop dead flag, first tests
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]>
2026-07-02 23:07:22 -04:00

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));
}
}