tangled
alpha
login
or
join now
dunkirk.sh
/
pstream-ng
1
fork
atom
pstream is dead; long live pstream
taciturnaxolotl.github.io/pstream-ng/
1
fork
atom
overview
issues
pulls
pipelines
fixes qodo stuff
Duplicake-fyi
2 weeks ago
c47cd061
ba4c7336
+84
-24
5 changed files
expand all
collapse all
unified
split
src
backend
metadata
search.ts
components
form
SearchBar.tsx
hooks
useSearchQuery.ts
pages
parts
search
SearchListPart.tsx
utils
cache.ts
+22
-3
src/backend/metadata/search.ts
···
40
40
41
41
function getLenientQueries(searchQuery: string): string[] {
42
42
const base = searchQuery.trim();
43
43
+
if (base.length < 3) return [base];
44
44
+
43
45
const normalized = normalizeQuery(base);
44
46
const withoutTrailingYear = base.replace(trailingYearPattern, "").trim();
45
47
const normalizedWithoutYear = normalizeQuery(withoutTrailingYear);
46
48
47
47
-
return [
49
49
+
const variants = [
48
50
...new Set([base, normalized, withoutTrailingYear, normalizedWithoutYear]),
49
51
].filter((q) => q.length > 0);
52
52
+
53
53
+
// Keep fanout small to avoid TMDB rate-limit pressure.
54
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
144
-
const resultSets = await Promise.all(
149
149
+
const settledResults = await Promise.allSettled(
145
150
queryVariants.map((q) => multiSearch(q)),
146
151
);
147
147
-
const data = dedupeTMDBResults(resultSets.flat());
152
152
+
const fulfilledResults = settledResults
153
153
+
.filter(
154
154
+
(
155
155
+
result,
156
156
+
): result is PromiseFulfilledResult<
157
157
+
(TMDBMovieSearchResult | TMDBShowSearchResult)[]
158
158
+
> => result.status === "fulfilled",
159
159
+
)
160
160
+
.map((result) => result.value);
161
161
+
162
162
+
if (fulfilledResults.length === 0) {
163
163
+
return [];
164
164
+
}
165
165
+
166
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
29
-
props.onChange(value, true);
29
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
24
+
const current = decode(params.query);
25
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
1
-
import { useEffect, useState } from "react";
1
1
+
import { useEffect, useRef, useState } from "react";
2
2
import { useTranslation } from "react-i18next";
3
3
import { useNavigate } from "react-router-dom";
4
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
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
70
-
const [state, exec] = useAsyncFn((query: MWQuery) => searchForMedia(query));
70
70
+
const [loading, setLoading] = useState(false);
71
71
+
const [failed, setFailed] = useState(false);
72
72
+
const requestIdRef = useRef(0);
73
73
+
const debouncedSearchQuery = useDebounce(searchQuery, 300);
71
74
72
75
useEffect(() => {
73
73
-
async function runSearch(query: MWQuery) {
74
74
-
const searchResults = await exec(query);
75
75
-
if (!searchResults) return;
76
76
-
setResults(searchResults);
76
76
+
async function runSearch(query: MWQuery, requestId: number) {
77
77
+
setLoading(true);
78
78
+
setFailed(false);
79
79
+
80
80
+
let nextResults: MediaItem[] = [];
81
81
+
let didFail = false;
82
82
+
try {
83
83
+
nextResults = (await searchForMedia(query)) ?? [];
84
84
+
} catch {
85
85
+
didFail = true;
86
86
+
}
87
87
+
88
88
+
// Ignore stale responses from older requests.
89
89
+
if (requestIdRef.current !== requestId) {
90
90
+
return;
91
91
+
}
92
92
+
93
93
+
setFailed(didFail);
94
94
+
if (!didFail) setResults(nextResults);
95
95
+
setLoading(false);
77
96
}
78
97
79
79
-
if (searchQuery !== "") runSearch({ searchQuery });
80
80
-
}, [searchQuery, exec]);
98
98
+
if (debouncedSearchQuery === "") {
99
99
+
setResults([]);
100
100
+
setLoading(false);
101
101
+
setFailed(false);
102
102
+
return;
103
103
+
}
81
104
82
82
-
if (state.loading) return <SearchLoadingPart />;
83
83
-
if (state.error) return <SearchSuffix failed />;
105
105
+
requestIdRef.current += 1;
106
106
+
runSearch({ searchQuery: debouncedSearchQuery }, requestIdRef.current);
107
107
+
}, [debouncedSearchQuery]);
108
108
+
109
109
+
if (loading) return <SearchLoadingPart />;
110
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
10
+
private static isExpired(entry: { expiry: Date }): boolean {
11
11
+
return entry.expiry.getTime() <= Date.now();
12
12
+
}
13
13
+
14
14
+
private pruneExpired(): void {
15
15
+
this._storage = this._storage.filter(
16
16
+
(entry) => !SimpleCache.isExpired(entry),
17
17
+
);
18
18
+
}
19
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
16
-
const now = new Date();
17
17
-
this._storage.filter((val) => {
18
18
-
if (val.expiry < now) return false; // remove if expiry date is in the past
19
19
-
return true;
20
20
-
});
26
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
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
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
63
+
if (SimpleCache.isExpired(foundValue)) {
64
64
+
this.remove(key);
65
65
+
return undefined;
66
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
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
89
-
this._storage.filter((val) => {
90
90
-
if (this._compare && this._compare(val.key, key)) return false; // remove if compare is success
91
91
-
return true;
92
92
-
});
102
102
+
this._storage = this._storage.filter(
103
103
+
(val) => !(this._compare && this._compare(val.key, key)),
104
104
+
);
93
105
}
94
106
95
107
/*