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 [search, setSearch] = 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 [msg, setMsg] = useState("");
const [busyFileId, setBusyFileId] = useState<string | null>(null);
@ -100,15 +103,31 @@ export default function MoviesPage() {
const [folderBusy, setFolderBusy] = useState(false);
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) {
if (!hasSearchQuery(q, pathQ)) {
setMovies([]);
setSearched(false);
setLimitHit(false);
return;
}
const params = new URLSearchParams();
if (q.trim()) params.set("search", q.trim());
if (filterNodeId) params.set("nodeId", filterNodeId);
if (pathQ.trim()) params.set("path", pathQ.trim());
const qs = params.toString() ? `?${params}` : "";
fetch(`/api/v1/admin/movies${qs}`, { credentials: "include" })
setLoading(true);
setSearched(true);
fetch(`/api/v1/admin/movies?${params}`, { credentials: "include" })
.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(() => {
@ -118,10 +137,10 @@ export default function MoviesPage() {
const list = (d.nodes ?? []) as NodeOption[];
setNodes(list.filter((n) => n.id).sort((a, b) => a.name.localeCompare(b.name)));
});
load();
}, []);
useEffect(() => {
if (!searched) return;
load(search, nodeId, pathSearch);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [nodeId]);
@ -384,6 +403,11 @@ export default function MoviesPage() {
<form
onSubmit={(e) => {
e.preventDefault();
if (!hasSearchQuery()) {
setMsg("Typ minstens 2 tekens in titel of pad.");
return;
}
setMsg("");
load();
}}
style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap", alignItems: "center" }}
@ -404,8 +428,9 @@ export default function MoviesPage() {
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Zoek titel..."
placeholder="Zoek titel / IMDb…"
style={{ width: 180, marginBottom: 0 }}
autoFocus
/>
<input
value={pathSearch}
@ -413,12 +438,15 @@ export default function MoviesPage() {
placeholder="Zoek map/pad…"
style={{ width: 220, marginBottom: 0 }}
/>
<button type="submit">Zoek</button>
<button type="submit" disabled={loading}>
{loading ? "Zoeken…" : "Zoek"}
</button>
</form>
</div>
<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
node 1.3.7+ met paden (anders eerst upgraden + rescan).
</p>
@ -604,9 +632,18 @@ export default function MoviesPage() {
{movies.length === 0 && (
<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>
)}
{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>
{linkMovieId && (

View file

@ -87,6 +87,9 @@ export default function SeriesPage() {
const [nodeId, setNodeId] = useState("");
const [search, setSearch] = 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 [busyFileId, setBusyFileId] = useState<string | null>(null);
const [msg, setMsg] = useState("");
@ -106,15 +109,31 @@ export default function SeriesPage() {
const [folderBusy, setFolderBusy] = useState(false);
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) {
if (!hasSearchQuery(q, pathQ)) {
setSeries([]);
setSearched(false);
setLimitHit(false);
return;
}
const params = new URLSearchParams();
if (q.trim()) params.set("search", q.trim());
if (filterNodeId) params.set("nodeId", filterNodeId);
if (pathQ.trim()) params.set("path", pathQ.trim());
const qs = params.toString() ? `?${params}` : "";
fetch(`/api/v1/admin/series${qs}`, { credentials: "include" })
setLoading(true);
setSearched(true);
fetch(`/api/v1/admin/series?${params}`, { credentials: "include" })
.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) {
@ -122,6 +141,10 @@ export default function SeriesPage() {
setPathFiles(null);
return;
}
if (!pathQ.trim()) {
setPathFiles(null);
return;
}
const params = new URLSearchParams();
if (pathQ.trim()) params.set("path", pathQ.trim());
if (filterNodeId) params.set("nodeId", filterNodeId);
@ -143,10 +166,10 @@ export default function SeriesPage() {
const list = (d.nodes ?? []) as NodeOption[];
setNodes(list.filter((n) => n.id).sort((a, b) => a.name.localeCompare(b.name)));
});
load();
}, []);
useEffect(() => {
if (!searched) return;
load(search, nodeId, pathSearch);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [nodeId]);
@ -401,6 +424,11 @@ export default function SeriesPage() {
<form
onSubmit={(e) => {
e.preventDefault();
if (!hasSearchQuery()) {
setMsg("Typ minstens 2 tekens in titel of pad.");
return;
}
setMsg("");
load();
}}
style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap", alignItems: "center" }}
@ -422,8 +450,9 @@ export default function SeriesPage() {
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Zoek titel..."
placeholder="Zoek titel / IMDb…"
style={{ width: 180, marginBottom: 0 }}
autoFocus
/>
<input
value={pathSearch}
@ -431,17 +460,21 @@ export default function SeriesPage() {
placeholder="Zoek map/pad… (bv. Breaking Bad)"
style={{ width: 260, marginBottom: 0 }}
/>
<button type="submit">Zoek</button>
<button type="submit" disabled={loading}>
{loading ? "Zoeken…" : "Zoek"}
</button>
</form>
</div>
<p className="muted page-lead">
Per node zie je de show-map. Padzoeken toont ook losse bestanden om foutmatches
opnieuw te koppelen.{" "}
{nodeId
Zoek op titel of pad (min. 2 tekens) — de hele bibliotheek wordt niet meer geladen. Per node
zie je de show-map. Padzoeken toont ook losse bestanden om foutmatches opnieuw te koppelen.
{searched
? nodeId
? ` Filter: ${selectedNodeName ?? "node"} · ${series.length} series.`
: `${series.length} series.`}
{pathSearch.trim() ? ` Padfilter: “${pathSearch.trim()}”.` : ""}
: ` ${series.length} series.`
: ""}
{pathSearch.trim() && searched ? ` Padfilter: “${pathSearch.trim()}”.` : ""}
</p>
{msg && (
@ -660,11 +693,20 @@ export default function SeriesPage() {
{series.length === 0 && (
<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 gevonden"}
: "Geen series gevonden"
: "Typ een titel of pad en klik Zoek"}
</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>
{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) => {
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 pathQ = query.path?.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({
where: {
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
? {
mediaFiles: {
@ -163,7 +186,7 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
include: { node: { select: { id: true, name: true, status: true } } },
},
},
take: 5000,
take,
});
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) => {
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 pathQ = query.path?.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({
where: {
AND: [
searchQ ? { title: { contains: searchQ, mode: "insensitive" } } : {},
searchQ
? {
OR: [
{ title: { contains: searchQ, mode: "insensitive" } },
{ imdbId: { contains: searchQ, mode: "insensitive" } },
],
}
: {},
nodeId || pathQ
? {
seasons: {
@ -293,7 +341,7 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
orderBy: { seasonNumber: "asc" },
},
},
take: 2000,
take,
});
return {
@ -397,6 +445,9 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
})).filter((season) => (!nodeId && !pathQ) || season.episodes.length > 0),
};
}),
total: series.length,
limit: take,
requiresQuery: false,
};
});