From ba4c73360f64273e14dc264a1325aadc86504921 Mon Sep 17 00:00:00 2001 From: Duplicake-fyi Date: Tue, 3 Mar 2026 00:30:07 +0000 Subject: [PATCH 1/2] add fuzzy search --- src/backend/metadata/search.ts | 82 ++++++++++++++++++++++++++++++++-- 1 file changed, 79 insertions(+), 3 deletions(-) diff --git a/src/backend/metadata/search.ts b/src/backend/metadata/search.ts index 778b416f..83f76b03 100644 --- a/src/backend/metadata/search.ts +++ b/src/backend/metadata/search.ts @@ -1,3 +1,5 @@ +import Fuse from "fuse.js"; + import { SimpleCache } from "@/utils/cache"; import { MediaItem } from "@/utils/mediaTypes"; @@ -8,7 +10,11 @@ import { getMediaPoster, multiSearch, } from "./tmdb"; -import { TMDBContentTypes } from "./types/tmdb"; +import { + TMDBContentTypes, + TMDBMovieSearchResult, + TMDBShowSearchResult, +} from "./types/tmdb"; export interface MWQuery { searchQuery: string; @@ -22,6 +28,71 @@ cache.initialize(); // detect "tmdb:123456" or "tmdb:123456:movie" or "tmdb:123456:tv" const tmdbIdPattern = /^tmdb:(\d+)(?::(movie|tv))?$/i; +const trailingYearPattern = /\s+\b(19|20)\d{2}\b$/; + +function normalizeQuery(input: string): string { + return input + .toLowerCase() + .replace(/[^\p{L}\p{N}\s]/gu, " ") + .replace(/\s+/g, " ") + .trim(); +} + +function getLenientQueries(searchQuery: string): string[] { + const base = searchQuery.trim(); + const normalized = normalizeQuery(base); + const withoutTrailingYear = base.replace(trailingYearPattern, "").trim(); + const normalizedWithoutYear = normalizeQuery(withoutTrailingYear); + + return [ + ...new Set([base, normalized, withoutTrailingYear, normalizedWithoutYear]), + ].filter((q) => q.length > 0); +} + +function dedupeTMDBResults( + items: (TMDBMovieSearchResult | TMDBShowSearchResult)[], +): (TMDBMovieSearchResult | TMDBShowSearchResult)[] { + const deduped = new Map< + string, + TMDBMovieSearchResult | TMDBShowSearchResult + >(); + + items.forEach((item) => { + deduped.set(`${item.media_type}:${item.id}`, item); + }); + + return Array.from(deduped.values()); +} + +function rankTMDBResultsFuzzy( + items: (TMDBMovieSearchResult | TMDBShowSearchResult)[], + query: string, +): (TMDBMovieSearchResult | TMDBShowSearchResult)[] { + if (items.length <= 1) return items; + + const fuse = new Fuse(items, { + includeScore: true, + ignoreLocation: true, + threshold: 0.45, + minMatchCharLength: 2, + keys: [ + { name: "title", weight: 0.6 }, + { name: "name", weight: 0.6 }, + { name: "original_title", weight: 0.2 }, + { name: "original_name", weight: 0.2 }, + ], + }); + + const ranked = fuse.search(query).map((result) => result.item); + const rankedSet = new Set( + ranked.map((item) => `${item.media_type}:${item.id}`), + ); + const remainder = items.filter( + (item) => !rankedSet.has(`${item.media_type}:${item.id}`), + ); + + return ranked.concat(remainder); +} export async function searchForMedia(query: MWQuery): Promise { if (cache.has(query)) return cache.get(query) as MediaItem[]; @@ -69,9 +140,14 @@ export async function searchForMedia(query: MWQuery): Promise { } } - const data = await multiSearch(searchQuery); + const queryVariants = getLenientQueries(searchQuery); + const resultSets = await Promise.all( + queryVariants.map((q) => multiSearch(q)), + ); + const data = dedupeTMDBResults(resultSets.flat()); + const rankedData = rankTMDBResultsFuzzy(data, searchQuery); - const results = data.map((v) => { + const results = rankedData.map((v) => { const formattedResult = formatTMDBSearchResult(v, v.media_type); return formatTMDBMetaToMediaItem(formattedResult); }); From c47cd0616d0da4537d931762af64f52edd225eb0 Mon Sep 17 00:00:00 2001 From: Duplicake-fyi Date: Tue, 3 Mar 2026 00:42:06 +0000 Subject: [PATCH 2/2] fixes qodo stuff --- src/backend/metadata/search.ts | 25 ++++++++++-- src/components/form/SearchBar.tsx | 2 +- src/hooks/useSearchQuery.ts | 2 + src/pages/parts/search/SearchListPart.tsx | 49 ++++++++++++++++++----- src/utils/cache.ts | 30 +++++++++----- 5 files changed, 84 insertions(+), 24 deletions(-) diff --git a/src/backend/metadata/search.ts b/src/backend/metadata/search.ts index 83f76b03..61feffa3 100644 --- a/src/backend/metadata/search.ts +++ b/src/backend/metadata/search.ts @@ -40,13 +40,18 @@ function normalizeQuery(input: string): string { function getLenientQueries(searchQuery: string): string[] { const base = searchQuery.trim(); + if (base.length < 3) return [base]; + const normalized = normalizeQuery(base); const withoutTrailingYear = base.replace(trailingYearPattern, "").trim(); const normalizedWithoutYear = normalizeQuery(withoutTrailingYear); - return [ + const variants = [ ...new Set([base, normalized, withoutTrailingYear, normalizedWithoutYear]), ].filter((q) => q.length > 0); + + // Keep fanout small to avoid TMDB rate-limit pressure. + return variants.slice(0, 2); } function dedupeTMDBResults( @@ -141,10 +146,24 @@ export async function searchForMedia(query: MWQuery): Promise { } const queryVariants = getLenientQueries(searchQuery); - const resultSets = await Promise.all( + const settledResults = await Promise.allSettled( queryVariants.map((q) => multiSearch(q)), ); - const data = dedupeTMDBResults(resultSets.flat()); + const fulfilledResults = settledResults + .filter( + ( + result, + ): result is PromiseFulfilledResult< + (TMDBMovieSearchResult | TMDBShowSearchResult)[] + > => result.status === "fulfilled", + ) + .map((result) => result.value); + + if (fulfilledResults.length === 0) { + return []; + } + + const data = dedupeTMDBResults(fulfilledResults.flat()); const rankedData = rankTMDBResultsFuzzy(data, searchQuery); const results = rankedData.map((v) => { diff --git a/src/components/form/SearchBar.tsx b/src/components/form/SearchBar.tsx index f1308300..72ee2a91 100644 --- a/src/components/form/SearchBar.tsx +++ b/src/components/form/SearchBar.tsx @@ -26,7 +26,7 @@ export const SearchBarInput = forwardRef( const [showTooltip, setShowTooltip] = useState(false); function setSearch(value: string) { - props.onChange(value, true); + props.onChange(value, false); } useEffect(() => { diff --git a/src/hooks/useSearchQuery.ts b/src/hooks/useSearchQuery.ts index 93cfad49..47752bcc 100644 --- a/src/hooks/useSearchQuery.ts +++ b/src/hooks/useSearchQuery.ts @@ -21,6 +21,8 @@ export function useSearchQuery(): [ const updateParams = (inp: string, commitToUrl = false) => { setSearch(inp); if (!commitToUrl) return; + const current = decode(params.query); + if (inp === current) return; if (inp.length === 0) { navigate("/", { replace: true }); return; diff --git a/src/pages/parts/search/SearchListPart.tsx b/src/pages/parts/search/SearchListPart.tsx index b9980fc9..9052b4f6 100644 --- a/src/pages/parts/search/SearchListPart.tsx +++ b/src/pages/parts/search/SearchListPart.tsx @@ -1,7 +1,6 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; -import { useAsyncFn } from "react-use"; import { searchForMedia } from "@/backend/metadata/search"; import { MWQuery } from "@/backend/metadata/types/mw"; @@ -10,6 +9,7 @@ import { Icons } from "@/components/Icon"; import { SectionHeading } from "@/components/layout/SectionHeading"; import { MediaGrid } from "@/components/media/MediaGrid"; import { WatchedMediaCard } from "@/components/media/WatchedMediaCard"; +import { useDebounce } from "@/hooks/useDebounce"; import { Button } from "@/pages/About"; import { SearchLoadingPart } from "@/pages/parts/search/SearchLoadingPart"; import { MediaItem } from "@/utils/mediaTypes"; @@ -67,20 +67,47 @@ export function SearchListPart({ const { t } = useTranslation(); const [results, setResults] = useState([]); - const [state, exec] = useAsyncFn((query: MWQuery) => searchForMedia(query)); + const [loading, setLoading] = useState(false); + const [failed, setFailed] = useState(false); + const requestIdRef = useRef(0); + const debouncedSearchQuery = useDebounce(searchQuery, 300); useEffect(() => { - async function runSearch(query: MWQuery) { - const searchResults = await exec(query); - if (!searchResults) return; - setResults(searchResults); + async function runSearch(query: MWQuery, requestId: number) { + setLoading(true); + setFailed(false); + + let nextResults: MediaItem[] = []; + let didFail = false; + try { + nextResults = (await searchForMedia(query)) ?? []; + } catch { + didFail = true; + } + + // Ignore stale responses from older requests. + if (requestIdRef.current !== requestId) { + return; + } + + setFailed(didFail); + if (!didFail) setResults(nextResults); + setLoading(false); } - if (searchQuery !== "") runSearch({ searchQuery }); - }, [searchQuery, exec]); + if (debouncedSearchQuery === "") { + setResults([]); + setLoading(false); + setFailed(false); + return; + } - if (state.loading) return ; - if (state.error) return ; + requestIdRef.current += 1; + runSearch({ searchQuery: debouncedSearchQuery }, requestIdRef.current); + }, [debouncedSearchQuery]); + + if (loading) return ; + if (failed) return ; if (!results) return null; return ( diff --git a/src/utils/cache.ts b/src/utils/cache.ts index 295916ae..8b999e46 100644 --- a/src/utils/cache.ts +++ b/src/utils/cache.ts @@ -7,17 +7,23 @@ export class SimpleCache { protected _storage: { key: Key; value: Value; expiry: Date }[] = []; + private static isExpired(entry: { expiry: Date }): boolean { + return entry.expiry.getTime() <= Date.now(); + } + + private pruneExpired(): void { + this._storage = this._storage.filter( + (entry) => !SimpleCache.isExpired(entry), + ); + } + /* ** initialize store, will start the interval */ public initialize(): void { if (this._interval) throw new Error("cache is already initialized"); this._interval = setInterval(() => { - const now = new Date(); - this._storage.filter((val) => { - if (val.expiry < now) return false; // remove if expiry date is in the past - return true; - }); + this.pruneExpired(); }, this.INTERVAL_MS); } @@ -26,6 +32,7 @@ export class SimpleCache { */ public destroy(): void { if (this._interval) clearInterval(this._interval); + this._interval = null; this.clear(); } @@ -48,10 +55,15 @@ export class SimpleCache { */ public get(key: Key): Value | undefined { if (!this._compare) throw new Error("Compare function not set"); + this.pruneExpired(); const foundValue = this._storage.find( (item) => this._compare && this._compare(item.key, key), ); if (!foundValue) return undefined; + if (SimpleCache.isExpired(foundValue)) { + this.remove(key); + return undefined; + } return foundValue.value; } @@ -60,6 +72,7 @@ export class SimpleCache { */ public set(key: Key, value: Value, expirySeconds: number): void { if (!this._compare) throw new Error("Compare function not set"); + this.pruneExpired(); const foundValue = this._storage.find( (item) => this._compare && this._compare(item.key, key), ); @@ -86,10 +99,9 @@ export class SimpleCache { */ public remove(key: Key): void { if (!this._compare) throw new Error("Compare function not set"); - this._storage.filter((val) => { - if (this._compare && this._compare(val.key, key)) return false; // remove if compare is success - return true; - }); + this._storage = this._storage.filter( + (val) => !(this._compare && this._compare(val.key, key)), + ); } /*