mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
[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]>
This commit is contained in:
1 parent
4591d4a260
commit
a6a6d4b0f7
4 files changed
+183
-34
No files matched your search
@@ -195,7 +195,6 @@ const DEFAULT_FLAGS = Object.freeze({
|
||||
// the admin instance config so a deployer flips it in one place.
|
||||
FEATURE_NIP71_INGEST: CONFIG_FEATURE_NIP71_INGEST !== false,
|
||||
FEATURE_HASHTAG_PREFERENCES: false,
|
||||
FEATURE_SEARCH_FILTERS: Boolean(IS_DEV_MODE),
|
||||
FEATURE_TRUST_SEEDS: true, // Rollback: disable to drop baseline trust seeds without code changes.
|
||||
FEATURE_TRUSTED_HIDE_CONTROLS: true,
|
||||
FEATURE_IMPROVED_COMMENT_FETCHING: true, // Default true for comment persistence fixes
|
||||
@@ -218,7 +217,6 @@ const runtimeFlags = (() => {
|
||||
FEATURE_NIP71_MIRROR: DEFAULT_FLAGS.FEATURE_NIP71_MIRROR,
|
||||
FEATURE_NIP71_INGEST: DEFAULT_FLAGS.FEATURE_NIP71_INGEST,
|
||||
FEATURE_HASHTAG_PREFERENCES: DEFAULT_FLAGS.FEATURE_HASHTAG_PREFERENCES,
|
||||
FEATURE_SEARCH_FILTERS: DEFAULT_FLAGS.FEATURE_SEARCH_FILTERS,
|
||||
FEATURE_TRUST_SEEDS: DEFAULT_FLAGS.FEATURE_TRUST_SEEDS,
|
||||
FEATURE_TRUSTED_HIDE_CONTROLS: DEFAULT_FLAGS.FEATURE_TRUSTED_HIDE_CONTROLS,
|
||||
FEATURE_IMPROVED_COMMENT_FETCHING:
|
||||
@@ -264,11 +262,6 @@ export let FEATURE_HASHTAG_PREFERENCES = coerceBoolean(
|
||||
DEFAULT_FLAGS.FEATURE_HASHTAG_PREFERENCES
|
||||
);
|
||||
|
||||
export let FEATURE_SEARCH_FILTERS = coerceBoolean(
|
||||
runtimeFlags.FEATURE_SEARCH_FILTERS,
|
||||
DEFAULT_FLAGS.FEATURE_SEARCH_FILTERS
|
||||
);
|
||||
|
||||
export let FEATURE_TRENDING_FEED = coerceBoolean(
|
||||
runtimeFlags.FEATURE_TRENDING_FEED,
|
||||
DEFAULT_FLAGS.FEATURE_TRENDING_FEED
|
||||
@@ -356,20 +349,6 @@ Object.defineProperty(runtimeFlags, "FEATURE_HASHTAG_PREFERENCES", {
|
||||
},
|
||||
});
|
||||
|
||||
Object.defineProperty(runtimeFlags, "FEATURE_SEARCH_FILTERS", {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
get() {
|
||||
return FEATURE_SEARCH_FILTERS;
|
||||
},
|
||||
set(next) {
|
||||
FEATURE_SEARCH_FILTERS = coerceBoolean(
|
||||
next,
|
||||
DEFAULT_FLAGS.FEATURE_SEARCH_FILTERS
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
Object.defineProperty(runtimeFlags, "FEATURE_TRUST_SEEDS", {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
@@ -458,7 +437,6 @@ runtimeFlags.URL_FIRST_ENABLED = URL_FIRST_ENABLED;
|
||||
runtimeFlags.FEATURE_WATCH_HISTORY_V2 = FEATURE_WATCH_HISTORY_V2;
|
||||
runtimeFlags.FEATURE_PUBLISH_NIP71 = FEATURE_PUBLISH_NIP71;
|
||||
runtimeFlags.FEATURE_HASHTAG_PREFERENCES = FEATURE_HASHTAG_PREFERENCES;
|
||||
runtimeFlags.FEATURE_SEARCH_FILTERS = FEATURE_SEARCH_FILTERS;
|
||||
runtimeFlags.FEATURE_TRUST_SEEDS = FEATURE_TRUST_SEEDS;
|
||||
runtimeFlags.FEATURE_TRUSTED_HIDE_CONTROLS = FEATURE_TRUSTED_HIDE_CONTROLS;
|
||||
runtimeFlags.FEATURE_IMPROVED_COMMENT_FETCHING =
|
||||
@@ -555,16 +533,10 @@ export function setHashtagPreferencesEnabled(next) {
|
||||
return FEATURE_HASHTAG_PREFERENCES;
|
||||
}
|
||||
|
||||
export function setSearchFiltersEnabled(next) {
|
||||
runtimeFlags.FEATURE_SEARCH_FILTERS = next;
|
||||
return FEATURE_SEARCH_FILTERS;
|
||||
}
|
||||
|
||||
export function resetRuntimeFlags() {
|
||||
setUrlFirstEnabled(DEFAULT_FLAGS.URL_FIRST_ENABLED);
|
||||
setWatchHistoryV2Enabled(DEFAULT_FLAGS.FEATURE_WATCH_HISTORY_V2);
|
||||
setHashtagPreferencesEnabled(DEFAULT_FLAGS.FEATURE_HASHTAG_PREFERENCES);
|
||||
setSearchFiltersEnabled(DEFAULT_FLAGS.FEATURE_SEARCH_FILTERS);
|
||||
setTrustSeedsEnabled(DEFAULT_FLAGS.FEATURE_TRUST_SEEDS);
|
||||
setTrustedHideControlsEnabled(DEFAULT_FLAGS.FEATURE_TRUSTED_HIDE_CONTROLS);
|
||||
setImprovedCommentFetchingEnabled(
|
||||
|
||||
@@ -56,7 +56,11 @@ export function buildVideoSearchFilterMatcher(filters = {}, options = {}) {
|
||||
if (hasUrl && !video.url) return false;
|
||||
|
||||
if (Number.isFinite(minDuration) || Number.isFinite(maxDuration)) {
|
||||
const duration = Number(video.nip71?.duration);
|
||||
// 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;
|
||||
@@ -78,3 +82,37 @@ export function buildVideoSearchFilterMatcher(filters = {}, options = {}) {
|
||||
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));
|
||||
}
|
||||
}
|
||||
+26
-5
@@ -661,10 +661,14 @@ async function performVideoSearch(query, token, filters = {}) {
|
||||
matches = video.tags.some(t => Array.isArray(t) && t[0] === "t" && t[1].toLowerCase() === lowerTerm);
|
||||
}
|
||||
} else {
|
||||
// Check title/description
|
||||
// Check title/description, honoring the textScope filter
|
||||
// (all | title | description).
|
||||
const scope = filters?.textScope || "all";
|
||||
const title = video.title ? video.title.toLowerCase() : "";
|
||||
const desc = video.description ? video.description.toLowerCase() : "";
|
||||
if (title.includes(lowerTerm) || desc.includes(lowerTerm)) {
|
||||
const titleHit = scope !== "description" && title.includes(lowerTerm);
|
||||
const descHit = scope !== "title" && desc.includes(lowerTerm);
|
||||
if (titleHit || descHit) {
|
||||
matches = true;
|
||||
}
|
||||
}
|
||||
@@ -681,7 +685,8 @@ async function performVideoSearch(query, token, filters = {}) {
|
||||
|
||||
// 2. Relay Search
|
||||
let relayVideos = [];
|
||||
const relays = nostrClient.relays || [];
|
||||
// relay: filter scopes the relay-side search to that single relay.
|
||||
const relays = filters?.relay ? [filters.relay] : nostrClient.relays || [];
|
||||
|
||||
if (relays.length > 0 && nostrClient.pool) {
|
||||
const filter = {
|
||||
@@ -733,6 +738,22 @@ async function performVideoSearch(query, token, filters = {}) {
|
||||
if (!unique.has(v.id)) unique.set(v.id, v);
|
||||
}
|
||||
|
||||
// Sort by creation date (newest first)
|
||||
return Array.from(unique.values()).sort((a, b) => (b.created_at || 0) - (a.created_at || 0));
|
||||
// Sort per the active filter (recent/views/trending/longest); "recent" is
|
||||
// the default. Views come from the shared deduped view-count cache.
|
||||
const { sortSearchResults } = await import("./search/searchFilterMatchers.js");
|
||||
const { getVideoViewCountSnapshot } = await import("./viewCounter.js");
|
||||
return sortSearchResults(Array.from(unique.values()), filters?.sort, {
|
||||
getViews: (video) => {
|
||||
try {
|
||||
const snapshot = getVideoViewCountSnapshot([
|
||||
"a",
|
||||
30078,
|
||||
video.videoRootId || video.id,
|
||||
]);
|
||||
return Number(snapshot?.total) || 0;
|
||||
} catch (error) {
|
||||
return 0;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
// Search pipeline coverage (was zero): the token parser, the video filter
|
||||
// matcher, and the result sorter — the pure core of search.
|
||||
//
|
||||
// test_integrity_note:
|
||||
// change_type: ["new_tests"]
|
||||
// scenarios:
|
||||
// - id: SCN-search-core
|
||||
// given: "query strings / videos with varying metadata"
|
||||
// when: "parseFilterQuery / buildVideoSearchFilterMatcher / sortSearchResults run"
|
||||
// then: "tokens parse into filters; matcher enforces them; sort honors the selected mode"
|
||||
// observable_outcomes:
|
||||
// - "tag:/has:/duration:< tokens land in filters + free text preserved"
|
||||
// - "matcher enforces tags, date range, has:magnet, duration bounds"
|
||||
// - "sort: views ranks by injected view counts; longest by duration; recent by created_at"
|
||||
// determinism_controls:
|
||||
// - "pure functions; injected getViews + fixed now"
|
||||
// anti_cheat_rationale:
|
||||
// prevents: ["hard-coded return value", "over-mocking internal logic"]
|
||||
// relaxation:
|
||||
// did_relax_any_assertion: false
|
||||
|
||||
import "./test-helpers/setup-localstorage.mjs";
|
||||
import test from "node:test";
|
||||
import { strict as assert } from "node:assert";
|
||||
import { parseFilterQuery } from "../js/search/searchFilters.js";
|
||||
import {
|
||||
buildVideoSearchFilterMatcher,
|
||||
sortSearchResults,
|
||||
} from "../js/search/searchFilterMatchers.js";
|
||||
|
||||
test("parser: tokens land in filters, free text preserved", () => {
|
||||
const { text, filters, errors } = parseFilterQuery(
|
||||
"cat videos tag:nostr has:magnet duration:<120",
|
||||
);
|
||||
assert.equal(errors.length, 0);
|
||||
assert.equal(text, "cat videos");
|
||||
assert.deepEqual(filters.tags, ["nostr"]);
|
||||
assert.equal(filters.hasMagnet, true);
|
||||
assert.equal(filters.duration.maxSeconds, 120);
|
||||
});
|
||||
|
||||
test("matcher enforces tags, date range, has:magnet, duration", () => {
|
||||
const video = {
|
||||
pubkey: "a".repeat(64),
|
||||
created_at: 1_700_000_000,
|
||||
tags: [["t", "nostr"]],
|
||||
magnet: "magnet:?xt=urn:btih:abc",
|
||||
nip71: { duration: 90 }, // ingested NIP-71 shape (bitvid kind uses top-level)
|
||||
};
|
||||
const pass = buildVideoSearchFilterMatcher({
|
||||
tags: ["nostr"],
|
||||
hasMagnet: true,
|
||||
duration: { minSeconds: 60, maxSeconds: 120 },
|
||||
dateRange: { after: 1_699_999_999, before: 1_700_000_001 },
|
||||
});
|
||||
assert.equal(pass(video), true);
|
||||
assert.equal(pass({ ...video, tags: [] }), false, "missing tag rejected");
|
||||
assert.equal(pass({ ...video, magnet: "" }), false, "has:magnet rejected");
|
||||
assert.equal(
|
||||
pass({ ...video, nip71: { duration: 30 } }),
|
||||
false,
|
||||
"too short rejected",
|
||||
);
|
||||
// bitvid's own kind-30078 videos carry duration TOP-LEVEL — both video types
|
||||
// must satisfy duration filters (spec: two video kinds, more coming for
|
||||
// live/shorts per the dev plans).
|
||||
assert.equal(
|
||||
pass({ ...video, nip71: undefined, duration: 90 }),
|
||||
true,
|
||||
"top-level duration (bitvid kind) matches too",
|
||||
);
|
||||
assert.equal(
|
||||
pass({ ...video, created_at: 1_690_000_000 }),
|
||||
false,
|
||||
"outside date range rejected",
|
||||
);
|
||||
});
|
||||
|
||||
test("sortSearchResults honors views / longest / recent (and defaults to recent)", () => {
|
||||
const videos = [
|
||||
{ id: "old-popular", created_at: 100, duration: 10 },
|
||||
{ id: "new-quiet", created_at: 300, duration: 50 },
|
||||
{ id: "mid-long", created_at: 200, duration: 500 },
|
||||
];
|
||||
const viewsById = { "old-popular": 900, "new-quiet": 5, "mid-long": 40 };
|
||||
const getViews = (v) => viewsById[v.id];
|
||||
|
||||
assert.deepEqual(
|
||||
sortSearchResults(videos, "views", { getViews }).map((v) => v.id),
|
||||
["old-popular", "mid-long", "new-quiet"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
sortSearchResults(videos, "longest", { getViews }).map((v) => v.id),
|
||||
["mid-long", "new-quiet", "old-popular"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
sortSearchResults(videos, "recent", { getViews }).map((v) => v.id),
|
||||
["new-quiet", "mid-long", "old-popular"],
|
||||
);
|
||||
// Unknown/default -> recent; input array is not mutated.
|
||||
const copy = [...videos];
|
||||
sortSearchResults(videos, undefined, { getViews });
|
||||
assert.deepEqual(videos, copy);
|
||||
});
|
||||
|
||||
test("sort: trending weights views by recency", () => {
|
||||
const now = 1_700_000_000_000; // ms
|
||||
const daySec = 86400;
|
||||
const videos = [
|
||||
{ id: "old-hit", created_at: now / 1000 - 30 * daySec }, // 900 views, 30d old
|
||||
{ id: "fresh-riser", created_at: now / 1000 - 1 * daySec }, // 60 views, 1d old
|
||||
];
|
||||
const getViews = (v) => (v.id === "old-hit" ? 900 : 60);
|
||||
const ranked = sortSearchResults(videos, "trending", { getViews, now }).map(
|
||||
(v) => v.id,
|
||||
);
|
||||
assert.deepEqual(ranked, ["fresh-riser", "old-hit"]);
|
||||
});
|
||||
Reference in new issue
Block a user