diff --git a/js/search/searchFilterMatchers.js b/js/search/searchFilterMatchers.js new file mode 100644 index 00000000..13628efe --- /dev/null +++ b/js/search/searchFilterMatchers.js @@ -0,0 +1,80 @@ +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)) { + const 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; + }; +} diff --git a/js/searchView.js b/js/searchView.js index f606781c..5e1be8e6 100644 --- a/js/searchView.js +++ b/js/searchView.js @@ -7,7 +7,6 @@ import { escapeHTML } from "./utils/domUtils.js"; import { formatShortNpub, truncateMiddle } from "./utils/formatters.js"; import { sanitizeProfileMediaUrl } from "./utils/profileMedia.js"; import { devLogger } from "./utils/logger.js"; -import { ALLOW_NSFW_CONTENT } from "./config.js"; import { attachFeedInfoPopover } from "./ui/components/FeedInfoPopover.js"; import { setHashView } from "./hashView.js"; import { @@ -17,6 +16,7 @@ import { setSearchFilterState, syncSearchFilterStateFromHash, } from "./search/searchFilterState.js"; +import { buildVideoSearchFilterMatcher } from "./search/searchFilterMatchers.js"; function getApp() { return getApplication(); @@ -771,77 +771,7 @@ async function performVideoSearch(query, token, filters = {}) { const applyAccessFilters = (videos) => nostrService?.filterVideos ? nostrService.filterVideos(videos, filterOptions) : videos; - 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"; - - const matchesCustomFilters = (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)) { - const 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 (ALLOW_NSFW_CONTENT !== true && video.isNsfw === true) { - return false; - } - - if (ALLOW_NSFW_CONTENT === true) { - if ((nsfwFilter === "true" || nsfwFilter === "only") && video.isNsfw !== true) { - return false; - } - if (nsfwFilter === "false" && video.isNsfw === true) { - return false; - } - } - - return true; - }; + const matchesCustomFilters = buildVideoSearchFilterMatcher(filters); // 1. Local Search (Cache) const localMatches = []; diff --git a/tests/unit/searchFilters.test.mjs b/tests/unit/searchFilters.test.mjs new file mode 100644 index 00000000..44227691 --- /dev/null +++ b/tests/unit/searchFilters.test.mjs @@ -0,0 +1,117 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { + parseFilterQuery, + serializeFiltersToQuery, +} from "../../js/search/searchFilters.js"; +import { buildVideoSearchFilterMatcher } from "../../js/search/searchFilterMatchers.js"; + +test("parseFilterQuery collects filters and text tokens", () => { + const parsed = parseFilterQuery( + "author:abc123 tag:#news duration:<=5m has:magnet nsfw:false hello world", + ); + + assert.deepEqual(parsed.filters.authorPubkeys, ["abc123"]); + assert.deepEqual(parsed.filters.tags, ["news"]); + assert.equal(parsed.filters.duration.maxSeconds, 300); + assert.equal(parsed.filters.hasMagnet, true); + assert.equal(parsed.filters.nsfw, "false"); + assert.equal(parsed.text, "hello world"); + assert.equal(parsed.errors.length, 0); +}); + +test("serializeFiltersToQuery emits filter tokens in order", () => { + const serialized = serializeFiltersToQuery({ + authorPubkeys: ["npub123"], + tags: ["clips"], + kind: 30078, + relay: "wss://relay.example", + dateRange: { after: null, before: null }, + duration: { minSeconds: 60, maxSeconds: 120 }, + hasMagnet: true, + hasUrl: true, + nsfw: "false", + }); + + assert.equal( + serialized, + "author:npub123 tag:clips kind:30078 relay:wss://relay.example duration:>=1m duration:<=2m has:magnet has:url nsfw:false", + ); +}); + +test("video search filter matcher enforces tags, duration, and nsfw when allowed", () => { + const matcher = buildVideoSearchFilterMatcher( + { + tags: ["cats"], + duration: { minSeconds: 60, maxSeconds: 180 }, + hasMagnet: true, + nsfw: "only", + }, + { allowNsfw: true }, + ); + + const videos = [ + { + id: "a", + tags: [["t", "cats"]], + nip71: { duration: 120 }, + magnet: "magnet:?xt=urn:btih:abc", + isNsfw: true, + }, + { + id: "b", + tags: [["t", "cats"]], + nip71: { duration: 240 }, + magnet: "magnet:?xt=urn:btih:def", + isNsfw: true, + }, + { + id: "c", + tags: [["t", "dogs"]], + nip71: { duration: 120 }, + magnet: "magnet:?xt=urn:btih:ghi", + isNsfw: true, + }, + { + id: "d", + tags: [["t", "cats"]], + nip71: { duration: 120 }, + magnet: "magnet:?xt=urn:btih:jkl", + isNsfw: false, + }, + { + id: "e", + tags: [["t", "cats"]], + nip71: { duration: 120 }, + url: "https://cdn.example/video.mp4", + isNsfw: true, + }, + ]; + + const matches = videos.filter(matcher); + + assert.deepEqual( + matches.map((video) => video.id), + ["a"], + ); +}); + +test("video search filter matcher blocks nsfw when disallowed and requires url", () => { + const matcher = buildVideoSearchFilterMatcher( + { hasUrl: true, nsfw: "true" }, + { allowNsfw: false }, + ); + + const videos = [ + { id: "a", url: "https://cdn.example/a.mp4", isNsfw: true }, + { id: "b", url: "https://cdn.example/b.mp4", isNsfw: false }, + ]; + + const matches = videos.filter(matcher); + + assert.deepEqual( + matches.map((video) => video.id), + ["b"], + ); +});