Make admin movies/series search-first instead of loading the full library.

Avoids heavy mount queries so lookup stays fast and server load stays low.
This commit is contained in:
Jos Vooges | STH 2026-09-16 21:45:16 +02:00
parent 14556c82f3
commit 23c81f2b83
3 changed files with 159 additions and 29 deletions

View file

@ -81,6 +81,9 @@ export default function MoviesPage() {
const [nodeId, setNodeId] = useState(""); const [nodeId, setNodeId] = useState("");
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [pathSearch, setPathSearch] = useState(""); const [pathSearch, setPathSearch] = useState("");
const [loading, setLoading] = useState(false);
const [searched, setSearched] = useState(false);
const [limitHit, setLimitHit] = useState(false);
const [expanded, setExpanded] = useState<string | null>(null); const [expanded, setExpanded] = useState<string | null>(null);
const [msg, setMsg] = useState(""); const [msg, setMsg] = useState("");
const [busyFileId, setBusyFileId] = useState<string | null>(null); const [busyFileId, setBusyFileId] = useState<string | null>(null);
@ -100,15 +103,31 @@ export default function MoviesPage() {
const [folderBusy, setFolderBusy] = useState(false); const [folderBusy, setFolderBusy] = useState(false);
const [folderMsg, setFolderMsg] = useState(""); const [folderMsg, setFolderMsg] = useState("");
function hasSearchQuery(q = search, pathQ = pathSearch) {
return q.trim().length >= 2 || pathQ.trim().length >= 2;
}
function load(q = search, filterNodeId = nodeId, pathQ = pathSearch) { function load(q = search, filterNodeId = nodeId, pathQ = pathSearch) {
if (!hasSearchQuery(q, pathQ)) {
setMovies([]);
setSearched(false);
setLimitHit(false);
return;
}
const params = new URLSearchParams(); const params = new URLSearchParams();
if (q.trim()) params.set("search", q.trim()); if (q.trim()) params.set("search", q.trim());
if (filterNodeId) params.set("nodeId", filterNodeId); if (filterNodeId) params.set("nodeId", filterNodeId);
if (pathQ.trim()) params.set("path", pathQ.trim()); if (pathQ.trim()) params.set("path", pathQ.trim());
const qs = params.toString() ? `?${params}` : ""; setLoading(true);
fetch(`/api/v1/admin/movies${qs}`, { credentials: "include" }) setSearched(true);
fetch(`/api/v1/admin/movies?${params}`, { credentials: "include" })
.then((r) => r.json()) .then((r) => r.json())
.then((d) => setMovies(d.movies ?? [])); .then((d) => {
const list = d.movies ?? [];
setMovies(list);
setLimitHit(Boolean(d.limit && list.length >= d.limit));
})
.finally(() => setLoading(false));
} }
useEffect(() => { useEffect(() => {
@ -118,10 +137,10 @@ export default function MoviesPage() {
const list = (d.nodes ?? []) as NodeOption[]; const list = (d.nodes ?? []) as NodeOption[];
setNodes(list.filter((n) => n.id).sort((a, b) => a.name.localeCompare(b.name))); setNodes(list.filter((n) => n.id).sort((a, b) => a.name.localeCompare(b.name)));
}); });
load();
}, []); }, []);
useEffect(() => { useEffect(() => {
if (!searched) return;
load(search, nodeId, pathSearch); load(search, nodeId, pathSearch);
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [nodeId]); }, [nodeId]);
@ -384,6 +403,11 @@ export default function MoviesPage() {
<form <form
onSubmit={(e) => { onSubmit={(e) => {
e.preventDefault(); e.preventDefault();
if (!hasSearchQuery()) {
setMsg("Typ minstens 2 tekens in titel of pad.");
return;
}
setMsg("");
load(); load();
}} }}
style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap", alignItems: "center" }} style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap", alignItems: "center" }}
@ -404,8 +428,9 @@ export default function MoviesPage() {
<input <input
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
placeholder="Zoek titel..." placeholder="Zoek titel / IMDb…"
style={{ width: 180, marginBottom: 0 }} style={{ width: 180, marginBottom: 0 }}
autoFocus
/> />
<input <input
value={pathSearch} value={pathSearch}
@ -413,12 +438,15 @@ export default function MoviesPage() {
placeholder="Zoek map/pad…" placeholder="Zoek map/pad…"
style={{ width: 220, marginBottom: 0 }} style={{ width: 220, marginBottom: 0 }}
/> />
<button type="submit">Zoek</button> <button type="submit" disabled={loading}>
{loading ? "Zoeken…" : "Zoek"}
</button>
</form> </form>
</div> </div>
<p className="muted page-lead"> <p className="muted page-lead">
Serie per ongeluk als film? Open de titel → bij de map{" "} Zoek op titel of pad (min. 2 tekens) — de hele bibliotheek wordt niet meer geladen. Serie per
ongeluk als film? Open de titel → bij de map{" "}
<strong style={{ color: "#a1a1aa", fontWeight: 600 }}>Als serie herkoppelen</strong>. Vereist <strong style={{ color: "#a1a1aa", fontWeight: 600 }}>Als serie herkoppelen</strong>. Vereist
node 1.3.7+ met paden (anders eerst upgraden + rescan). node 1.3.7+ met paden (anders eerst upgraden + rescan).
</p> </p>
@ -604,9 +632,18 @@ export default function MoviesPage() {
{movies.length === 0 && ( {movies.length === 0 && (
<div className="card" style={{ textAlign: "center", color: "#71717a" }}> <div className="card" style={{ textAlign: "center", color: "#71717a" }}>
Geen movies gevonden {loading
? "Zoeken…"
: searched
? "Geen movies gevonden"
: "Typ een titel of pad en klik Zoek"}
</div> </div>
)} )}
{limitHit && (
<p className="muted" style={{ marginTop: "0.75rem", textAlign: "center" }}>
Max. 50 resultaten — verfijn je zoekopdracht als je titel er niet tussen staat.
</p>
)}
</div> </div>
{linkMovieId && ( {linkMovieId && (

View file

@ -87,6 +87,9 @@ export default function SeriesPage() {
const [nodeId, setNodeId] = useState(""); const [nodeId, setNodeId] = useState("");
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [pathSearch, setPathSearch] = useState(""); const [pathSearch, setPathSearch] = useState("");
const [loading, setLoading] = useState(false);
const [searched, setSearched] = useState(false);
const [limitHit, setLimitHit] = useState(false);
const [expanded, setExpanded] = useState<string | null>(null); const [expanded, setExpanded] = useState<string | null>(null);
const [busyFileId, setBusyFileId] = useState<string | null>(null); const [busyFileId, setBusyFileId] = useState<string | null>(null);
const [msg, setMsg] = useState(""); const [msg, setMsg] = useState("");
@ -106,15 +109,31 @@ export default function SeriesPage() {
const [folderBusy, setFolderBusy] = useState(false); const [folderBusy, setFolderBusy] = useState(false);
const [folderMsg, setFolderMsg] = useState(""); const [folderMsg, setFolderMsg] = useState("");
function hasSearchQuery(q = search, pathQ = pathSearch) {
return q.trim().length >= 2 || pathQ.trim().length >= 2;
}
function loadSeries(q = search, filterNodeId = nodeId, pathQ = pathSearch) { function loadSeries(q = search, filterNodeId = nodeId, pathQ = pathSearch) {
if (!hasSearchQuery(q, pathQ)) {
setSeries([]);
setSearched(false);
setLimitHit(false);
return;
}
const params = new URLSearchParams(); const params = new URLSearchParams();
if (q.trim()) params.set("search", q.trim()); if (q.trim()) params.set("search", q.trim());
if (filterNodeId) params.set("nodeId", filterNodeId); if (filterNodeId) params.set("nodeId", filterNodeId);
if (pathQ.trim()) params.set("path", pathQ.trim()); if (pathQ.trim()) params.set("path", pathQ.trim());
const qs = params.toString() ? `?${params}` : ""; setLoading(true);
fetch(`/api/v1/admin/series${qs}`, { credentials: "include" }) setSearched(true);
fetch(`/api/v1/admin/series?${params}`, { credentials: "include" })
.then((r) => r.json()) .then((r) => r.json())
.then((d) => setSeries(d.series ?? [])); .then((d) => {
const list = d.series ?? [];
setSeries(list);
setLimitHit(Boolean(d.limit && list.length >= d.limit));
})
.finally(() => setLoading(false));
} }
function loadPathFiles(pathQ = pathSearch, filterNodeId = nodeId) { function loadPathFiles(pathQ = pathSearch, filterNodeId = nodeId) {
@ -122,6 +141,10 @@ export default function SeriesPage() {
setPathFiles(null); setPathFiles(null);
return; return;
} }
if (!pathQ.trim()) {
setPathFiles(null);
return;
}
const params = new URLSearchParams(); const params = new URLSearchParams();
if (pathQ.trim()) params.set("path", pathQ.trim()); if (pathQ.trim()) params.set("path", pathQ.trim());
if (filterNodeId) params.set("nodeId", filterNodeId); if (filterNodeId) params.set("nodeId", filterNodeId);
@ -143,10 +166,10 @@ export default function SeriesPage() {
const list = (d.nodes ?? []) as NodeOption[]; const list = (d.nodes ?? []) as NodeOption[];
setNodes(list.filter((n) => n.id).sort((a, b) => a.name.localeCompare(b.name))); setNodes(list.filter((n) => n.id).sort((a, b) => a.name.localeCompare(b.name)));
}); });
load();
}, []); }, []);
useEffect(() => { useEffect(() => {
if (!searched) return;
load(search, nodeId, pathSearch); load(search, nodeId, pathSearch);
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [nodeId]); }, [nodeId]);
@ -401,6 +424,11 @@ export default function SeriesPage() {
<form <form
onSubmit={(e) => { onSubmit={(e) => {
e.preventDefault(); e.preventDefault();
if (!hasSearchQuery()) {
setMsg("Typ minstens 2 tekens in titel of pad.");
return;
}
setMsg("");
load(); load();
}} }}
style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap", alignItems: "center" }} style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap", alignItems: "center" }}
@ -422,8 +450,9 @@ export default function SeriesPage() {
<input <input
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
placeholder="Zoek titel..." placeholder="Zoek titel / IMDb…"
style={{ width: 180, marginBottom: 0 }} style={{ width: 180, marginBottom: 0 }}
autoFocus
/> />
<input <input
value={pathSearch} value={pathSearch}
@ -431,17 +460,21 @@ export default function SeriesPage() {
placeholder="Zoek map/pad… (bv. Breaking Bad)" placeholder="Zoek map/pad… (bv. Breaking Bad)"
style={{ width: 260, marginBottom: 0 }} style={{ width: 260, marginBottom: 0 }}
/> />
<button type="submit">Zoek</button> <button type="submit" disabled={loading}>
{loading ? "Zoeken…" : "Zoek"}
</button>
</form> </form>
</div> </div>
<p className="muted page-lead"> <p className="muted page-lead">
Per node zie je de show-map. Padzoeken toont ook losse bestanden om foutmatches Zoek op titel of pad (min. 2 tekens) — de hele bibliotheek wordt niet meer geladen. Per node
opnieuw te koppelen.{" "} zie je de show-map. Padzoeken toont ook losse bestanden om foutmatches opnieuw te koppelen.
{nodeId {searched
? nodeId
? ` Filter: ${selectedNodeName ?? "node"} · ${series.length} series.` ? ` Filter: ${selectedNodeName ?? "node"} · ${series.length} series.`
: `${series.length} series.`} : ` ${series.length} series.`
{pathSearch.trim() ? ` Padfilter: “${pathSearch.trim()}”.` : ""} : ""}
{pathSearch.trim() && searched ? ` Padfilter: “${pathSearch.trim()}”.` : ""}
</p> </p>
{msg && ( {msg && (
@ -660,11 +693,20 @@ export default function SeriesPage() {
{series.length === 0 && ( {series.length === 0 && (
<div className="card" style={{ textAlign: "center", color: "#71717a" }}> <div className="card" style={{ textAlign: "center", color: "#71717a" }}>
{nodeId || pathSearch {loading
? "Zoeken…"
: searched
? nodeId || pathSearch
? "Geen series voor dit filter. Check scan-roots / pad / rescan." ? "Geen series voor dit filter. Check scan-roots / pad / rescan."
: "Geen series gevonden"} : "Geen series gevonden"
: "Typ een titel of pad en klik Zoek"}
</div> </div>
)} )}
{limitHit && (
<p className="muted" style={{ marginTop: "0.75rem", textAlign: "center" }}>
Max. 50 resultaten — verfijn je zoekopdracht als je titel er niet tussen staat.
</p>
)}
</div> </div>
{linkSeriesId && ( {linkSeriesId && (

View file

@ -136,15 +136,38 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
}); });
app.get("/api/v1/admin/movies", { preHandler: requireAdmin }, async (request) => { app.get("/api/v1/admin/movies", { preHandler: requireAdmin }, async (request) => {
const query = request.query as { search?: string; nodeId?: string; path?: string }; const query = request.query as {
search?: string;
nodeId?: string;
path?: string;
limit?: string;
};
const nodeId = query.nodeId?.trim() || undefined; const nodeId = query.nodeId?.trim() || undefined;
const pathQ = query.path?.trim() || undefined; const pathQ = query.path?.trim() || undefined;
const searchQ = query.search?.trim() || undefined; const searchQ = query.search?.trim() || undefined;
const hasQuery =
(searchQ != null && searchQ.length >= 2) || (pathQ != null && pathQ.length >= 2);
if (!hasQuery) {
return { movies: [], total: 0, requiresQuery: true };
}
const limitRaw = Number.parseInt(query.limit ?? "50", 10);
const take = Number.isFinite(limitRaw)
? Math.min(Math.max(limitRaw, 1), 100)
: 50;
const movies = await prisma.movie.findMany({ const movies = await prisma.movie.findMany({
where: { where: {
AND: [ AND: [
searchQ ? { title: { contains: searchQ, mode: "insensitive" } } : {}, searchQ
? {
OR: [
{ title: { contains: searchQ, mode: "insensitive" } },
{ originalTitle: { contains: searchQ, mode: "insensitive" } },
{ imdbId: { contains: searchQ, mode: "insensitive" } },
],
}
: {},
nodeId || pathQ nodeId || pathQ
? { ? {
mediaFiles: { mediaFiles: {
@ -163,7 +186,7 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
include: { node: { select: { id: true, name: true, status: true } } }, include: { node: { select: { id: true, name: true, status: true } } },
}, },
}, },
take: 5000, take,
}); });
return { return {
@ -228,6 +251,9 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
})), })),
}; };
}), }),
total: movies.length,
limit: take,
requiresQuery: false,
}; };
}); });
@ -244,15 +270,37 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
}); });
app.get("/api/v1/admin/series", { preHandler: requireAdmin }, async (request) => { app.get("/api/v1/admin/series", { preHandler: requireAdmin }, async (request) => {
const query = request.query as { search?: string; nodeId?: string; path?: string }; const query = request.query as {
search?: string;
nodeId?: string;
path?: string;
limit?: string;
};
const nodeId = query.nodeId?.trim() || undefined; const nodeId = query.nodeId?.trim() || undefined;
const pathQ = query.path?.trim() || undefined; const pathQ = query.path?.trim() || undefined;
const searchQ = query.search?.trim() || undefined; const searchQ = query.search?.trim() || undefined;
const hasQuery =
(searchQ != null && searchQ.length >= 2) || (pathQ != null && pathQ.length >= 2);
if (!hasQuery) {
return { series: [], total: 0, requiresQuery: true };
}
const limitRaw = Number.parseInt(query.limit ?? "50", 10);
const take = Number.isFinite(limitRaw)
? Math.min(Math.max(limitRaw, 1), 100)
: 50;
const series = await prisma.series.findMany({ const series = await prisma.series.findMany({
where: { where: {
AND: [ AND: [
searchQ ? { title: { contains: searchQ, mode: "insensitive" } } : {}, searchQ
? {
OR: [
{ title: { contains: searchQ, mode: "insensitive" } },
{ imdbId: { contains: searchQ, mode: "insensitive" } },
],
}
: {},
nodeId || pathQ nodeId || pathQ
? { ? {
seasons: { seasons: {
@ -293,7 +341,7 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
orderBy: { seasonNumber: "asc" }, orderBy: { seasonNumber: "asc" },
}, },
}, },
take: 2000, take,
}); });
return { return {
@ -397,6 +445,9 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
})).filter((season) => (!nodeId && !pathQ) || season.episodes.length > 0), })).filter((season) => (!nodeId && !pathQ) || season.episodes.length > 0),
}; };
}), }),
total: series.length,
limit: take,
requiresQuery: false,
}; };
}); });