diff --git a/apps/admin-ui/src/app/movies/page.tsx b/apps/admin-ui/src/app/movies/page.tsx index 94cc66f..33e69da 100644 --- a/apps/admin-ui/src/app/movies/page.tsx +++ b/apps/admin-ui/src/app/movies/page.tsx @@ -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(null); const [msg, setMsg] = useState(""); const [busyFileId, setBusyFileId] = useState(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() {
{ 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() { setSearch(e.target.value)} - placeholder="Zoek titel..." + placeholder="Zoek titel / IMDb…" style={{ width: 180, marginBottom: 0 }} + autoFocus /> - +

- 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{" "} Als serie herkoppelen. Vereist node 1.3.7+ met paden (anders eerst upgraden + rescan).

@@ -604,9 +632,18 @@ export default function MoviesPage() { {movies.length === 0 && (
- Geen movies gevonden + {loading + ? "Zoeken…" + : searched + ? "Geen movies gevonden" + : "Typ een titel of pad en klik Zoek"}
)} + {limitHit && ( +

+ Max. 50 resultaten — verfijn je zoekopdracht als je titel er niet tussen staat. +

+ )} {linkMovieId && ( diff --git a/apps/admin-ui/src/app/series/page.tsx b/apps/admin-ui/src/app/series/page.tsx index 605966e..23ff673 100644 --- a/apps/admin-ui/src/app/series/page.tsx +++ b/apps/admin-ui/src/app/series/page.tsx @@ -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(null); const [busyFileId, setBusyFileId] = useState(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() {
{ 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() { setSearch(e.target.value)} - placeholder="Zoek titel..." + placeholder="Zoek titel / IMDb…" style={{ width: 180, marginBottom: 0 }} + autoFocus /> - +

- Per node zie je de show-map. Padzoeken toont ook losse bestanden om foutmatches - opnieuw te koppelen.{" "} - {nodeId - ? `Filter: ${selectedNodeName ?? "node"} · ${series.length} series.` - : `${series.length} series.`} - {pathSearch.trim() ? ` Padfilter: “${pathSearch.trim()}”.` : ""} + 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() && searched ? ` Padfilter: “${pathSearch.trim()}”.` : ""}

{msg && ( @@ -660,11 +693,20 @@ export default function SeriesPage() { {series.length === 0 && (
- {nodeId || pathSearch - ? "Geen series voor dit filter. Check scan-roots / pad / rescan." - : "Geen series gevonden"} + {loading + ? "Zoeken…" + : searched + ? nodeId || pathSearch + ? "Geen series voor dit filter. Check scan-roots / pad / rescan." + : "Geen series gevonden" + : "Typ een titel of pad en klik Zoek"}
)} + {limitHit && ( +

+ Max. 50 resultaten — verfijn je zoekopdracht als je titel er niet tussen staat. +

+ )} {linkSeriesId && ( diff --git a/apps/master-api/src/admin/routes.ts b/apps/master-api/src/admin/routes.ts index bc55bd4..122d49b 100644 --- a/apps/master-api/src/admin/routes.ts +++ b/apps/master-api/src/admin/routes.ts @@ -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, }; });