[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:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-07-02 23:07:22 -04:00
1 parent 4591d4a260
commit a6a6d4b0f7
4 files changed
+183 -34

No files matched your search

-28
View File
@@ -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(
+39 -1
View File
@@ -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
View File
@@ -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;
}
},
});
}
+118
View File
@@ -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"]);
});