pstream is dead; long live pstream taciturnaxolotl.github.io/pstream-ng/

fixes qodo stuff

+84 -24
+22 -3
src/backend/metadata/search.ts
··· 40 40 41 41 function getLenientQueries(searchQuery: string): string[] { 42 42 const base = searchQuery.trim(); 43 + if (base.length < 3) return [base]; 44 + 43 45 const normalized = normalizeQuery(base); 44 46 const withoutTrailingYear = base.replace(trailingYearPattern, "").trim(); 45 47 const normalizedWithoutYear = normalizeQuery(withoutTrailingYear); 46 48 47 - return [ 49 + const variants = [ 48 50 ...new Set([base, normalized, withoutTrailingYear, normalizedWithoutYear]), 49 51 ].filter((q) => q.length > 0); 52 + 53 + // Keep fanout small to avoid TMDB rate-limit pressure. 54 + return variants.slice(0, 2); 50 55 } 51 56 52 57 function dedupeTMDBResults( ··· 141 146 } 142 147 143 148 const queryVariants = getLenientQueries(searchQuery); 144 - const resultSets = await Promise.all( 149 + const settledResults = await Promise.allSettled( 145 150 queryVariants.map((q) => multiSearch(q)), 146 151 ); 147 - const data = dedupeTMDBResults(resultSets.flat()); 152 + const fulfilledResults = settledResults 153 + .filter( 154 + ( 155 + result, 156 + ): result is PromiseFulfilledResult< 157 + (TMDBMovieSearchResult | TMDBShowSearchResult)[] 158 + > => result.status === "fulfilled", 159 + ) 160 + .map((result) => result.value); 161 + 162 + if (fulfilledResults.length === 0) { 163 + return []; 164 + } 165 + 166 + const data = dedupeTMDBResults(fulfilledResults.flat()); 148 167 const rankedData = rankTMDBResultsFuzzy(data, searchQuery); 149 168 150 169 const results = rankedData.map((v) => {
+1 -1
src/components/form/SearchBar.tsx
··· 26 26 const [showTooltip, setShowTooltip] = useState(false); 27 27 28 28 function setSearch(value: string) { 29 - props.onChange(value, true); 29 + props.onChange(value, false); 30 30 } 31 31 32 32 useEffect(() => {
+2
src/hooks/useSearchQuery.ts
··· 21 21 const updateParams = (inp: string, commitToUrl = false) => { 22 22 setSearch(inp); 23 23 if (!commitToUrl) return; 24 + const current = decode(params.query); 25 + if (inp === current) return; 24 26 if (inp.length === 0) { 25 27 navigate("/", { replace: true }); 26 28 return;
+38 -11
src/pages/parts/search/SearchListPart.tsx
··· 1 - import { useEffect, useState } from "react"; 1 + import { useEffect, useRef, useState } from "react"; 2 2 import { useTranslation } from "react-i18next"; 3 3 import { useNavigate } from "react-router-dom"; 4 - import { useAsyncFn } from "react-use"; 5 4 6 5 import { searchForMedia } from "@/backend/metadata/search"; 7 6 import { MWQuery } from "@/backend/metadata/types/mw"; ··· 10 9 import { SectionHeading } from "@/components/layout/SectionHeading"; 11 10 import { MediaGrid } from "@/components/media/MediaGrid"; 12 11 import { WatchedMediaCard } from "@/components/media/WatchedMediaCard"; 12 + import { useDebounce } from "@/hooks/useDebounce"; 13 13 import { Button } from "@/pages/About"; 14 14 import { SearchLoadingPart } from "@/pages/parts/search/SearchLoadingPart"; 15 15 import { MediaItem } from "@/utils/mediaTypes"; ··· 67 67 const { t } = useTranslation(); 68 68 69 69 const [results, setResults] = useState<MediaItem[]>([]); 70 - const [state, exec] = useAsyncFn((query: MWQuery) => searchForMedia(query)); 70 + const [loading, setLoading] = useState(false); 71 + const [failed, setFailed] = useState(false); 72 + const requestIdRef = useRef(0); 73 + const debouncedSearchQuery = useDebounce(searchQuery, 300); 71 74 72 75 useEffect(() => { 73 - async function runSearch(query: MWQuery) { 74 - const searchResults = await exec(query); 75 - if (!searchResults) return; 76 - setResults(searchResults); 76 + async function runSearch(query: MWQuery, requestId: number) { 77 + setLoading(true); 78 + setFailed(false); 79 + 80 + let nextResults: MediaItem[] = []; 81 + let didFail = false; 82 + try { 83 + nextResults = (await searchForMedia(query)) ?? []; 84 + } catch { 85 + didFail = true; 86 + } 87 + 88 + // Ignore stale responses from older requests. 89 + if (requestIdRef.current !== requestId) { 90 + return; 91 + } 92 + 93 + setFailed(didFail); 94 + if (!didFail) setResults(nextResults); 95 + setLoading(false); 77 96 } 78 97 79 - if (searchQuery !== "") runSearch({ searchQuery }); 80 - }, [searchQuery, exec]); 98 + if (debouncedSearchQuery === "") { 99 + setResults([]); 100 + setLoading(false); 101 + setFailed(false); 102 + return; 103 + } 81 104 82 - if (state.loading) return <SearchLoadingPart />; 83 - if (state.error) return <SearchSuffix failed />; 105 + requestIdRef.current += 1; 106 + runSearch({ searchQuery: debouncedSearchQuery }, requestIdRef.current); 107 + }, [debouncedSearchQuery]); 108 + 109 + if (loading) return <SearchLoadingPart />; 110 + if (failed) return <SearchSuffix failed />; 84 111 if (!results) return null; 85 112 86 113 return (
+21 -9
src/utils/cache.ts
··· 7 7 8 8 protected _storage: { key: Key; value: Value; expiry: Date }[] = []; 9 9 10 + private static isExpired(entry: { expiry: Date }): boolean { 11 + return entry.expiry.getTime() <= Date.now(); 12 + } 13 + 14 + private pruneExpired(): void { 15 + this._storage = this._storage.filter( 16 + (entry) => !SimpleCache.isExpired(entry), 17 + ); 18 + } 19 + 10 20 /* 11 21 ** initialize store, will start the interval 12 22 */ 13 23 public initialize(): void { 14 24 if (this._interval) throw new Error("cache is already initialized"); 15 25 this._interval = setInterval(() => { 16 - const now = new Date(); 17 - this._storage.filter((val) => { 18 - if (val.expiry < now) return false; // remove if expiry date is in the past 19 - return true; 20 - }); 26 + this.pruneExpired(); 21 27 }, this.INTERVAL_MS); 22 28 } 23 29 ··· 26 32 */ 27 33 public destroy(): void { 28 34 if (this._interval) clearInterval(this._interval); 35 + this._interval = null; 29 36 this.clear(); 30 37 } 31 38 ··· 48 55 */ 49 56 public get(key: Key): Value | undefined { 50 57 if (!this._compare) throw new Error("Compare function not set"); 58 + this.pruneExpired(); 51 59 const foundValue = this._storage.find( 52 60 (item) => this._compare && this._compare(item.key, key), 53 61 ); 54 62 if (!foundValue) return undefined; 63 + if (SimpleCache.isExpired(foundValue)) { 64 + this.remove(key); 65 + return undefined; 66 + } 55 67 return foundValue.value; 56 68 } 57 69 ··· 60 72 */ 61 73 public set(key: Key, value: Value, expirySeconds: number): void { 62 74 if (!this._compare) throw new Error("Compare function not set"); 75 + this.pruneExpired(); 63 76 const foundValue = this._storage.find( 64 77 (item) => this._compare && this._compare(item.key, key), 65 78 ); ··· 86 99 */ 87 100 public remove(key: Key): void { 88 101 if (!this._compare) throw new Error("Compare function not set"); 89 - this._storage.filter((val) => { 90 - if (this._compare && this._compare(val.key, key)) return false; // remove if compare is success 91 - return true; 92 - }); 102 + this._storage = this._storage.filter( 103 + (val) => !(this._compare && this._compare(val.key, key)), 104 + ); 93 105 } 94 106 95 107 /*