Queue unmatched series folders for manual linking instead of auto-creating.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jos Vooges | STH 2026-09-09 02:30:41 +02:00
parent ce278bbb70
commit 3b9b95469b
4 changed files with 478 additions and 19 deletions

View file

@ -0,0 +1,318 @@
"use client";
import { useEffect, useState } from "react";
import { Nav, useAuth } from "@/components/Nav";
interface PendingFolder {
key: string;
nodeId: string;
nodeName: string;
folder: string;
parsedTitle: string | null;
fileCount: number;
seasons: number[];
samplePaths: string[];
}
interface MetaHit {
tmdbId: number;
title: string;
year: number | null;
overview: string | null;
posterUrl: string | null;
imdbId: string | null;
}
export default function PendingSeriesPage() {
useAuth();
const [pending, setPending] = useState<PendingFolder[]>([]);
const [totalFiles, setTotalFiles] = useState(0);
const [msg, setMsg] = useState("");
const [link, setLink] = useState<PendingFolder | null>(null);
const [query, setQuery] = useState("");
const [hits, setHits] = useState<MetaHit[]>([]);
const [busy, setBusy] = useState(false);
const [linkMsg, setLinkMsg] = useState("");
function load() {
fetch("/api/v1/admin/library/pending-series", { credentials: "include" })
.then((r) => r.json())
.then((d) => {
setPending(d.pending ?? []);
setTotalFiles(d.totalFiles ?? 0);
});
}
useEffect(() => {
load();
}, []);
function openLink(row: PendingFolder) {
setLink(row);
setQuery(row.parsedTitle || row.folder.split("/").filter(Boolean).pop() || "");
setHits([]);
setLinkMsg("");
void searchMeta(row.parsedTitle || row.folder.split("/").filter(Boolean).pop() || "");
}
async function searchMeta(q = query) {
const term = q.trim();
if (!term) return;
setBusy(true);
setLinkMsg("");
try {
const res = await fetch(
`/api/v1/admin/metadata/search?type=series&q=${encodeURIComponent(term)}`,
{ credentials: "include" }
);
const data = await res.json();
if (!res.ok) {
setLinkMsg(data.error?.message ?? "Zoeken mislukt");
setHits([]);
return;
}
setHits(data.results ?? []);
if (!(data.results ?? []).length) setLinkMsg("Geen resultaten");
} finally {
setBusy(false);
}
}
async function applyHit(hit: MetaHit) {
if (!link) return;
if (
!confirm(
`${link.fileCount} bestanden in\n${link.folder}\n(${link.nodeName})\nkoppelen aan “${hit.title}${hit.year ? ` (${hit.year})` : ""}”?`
)
) {
return;
}
setBusy(true);
setLinkMsg("Koppelen…");
try {
const res = await fetch("/api/v1/admin/library/relink-folder", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
nodeId: link.nodeId,
folder: link.folder,
tmdbId: hit.tmdbId,
...(hit.imdbId ? { imdbId: hit.imdbId } : {}),
}),
});
const data = await res.json();
if (!res.ok) {
setLinkMsg(data.error?.message ?? "Koppelen mislukt");
return;
}
setMsg(
`Gekoppeld → ${data.seriesTitle}: ${data.moved}/${data.total}` +
(data.failed ? ` (${data.failed} mislukt)` : "")
);
setLink(null);
load();
} finally {
setBusy(false);
}
}
async function applyImdb() {
const imdb = query.trim();
if (!/^tt\d+$/i.test(imdb) || !link) {
setLinkMsg("Vul een IMDb-id in (tt…) of zoek op titel.");
return;
}
setBusy(true);
setLinkMsg("Koppelen via IMDb…");
try {
const res = await fetch("/api/v1/admin/library/relink-folder", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
nodeId: link.nodeId,
folder: link.folder,
imdbId: imdb,
}),
});
const data = await res.json();
if (!res.ok) {
setLinkMsg(data.error?.message ?? "Koppelen mislukt");
return;
}
setMsg(`Gekoppeld → ${data.seriesTitle}: ${data.moved}/${data.total}`);
setLink(null);
load();
} finally {
setBusy(false);
}
}
return (
<>
<Nav />
<div className="container">
<div className="page-header">
<div>
<div className="page-kicker">Library</div>
<h1>Nog te koppelen</h1>
</div>
<button type="button" onClick={() => load()}>
Vernieuwen
</button>
</div>
<p className="muted page-lead">
Series-mappen zonder betrouwbare TMDB-match. Ze staan niet in de catalogus tot jij ze
handmatig koppelt. {pending.length} mappen · {totalFiles} bestanden.
</p>
{msg && (
<p className="muted" style={{ marginBottom: "1rem" }}>
{msg}
</p>
)}
{pending.map((row) => (
<div key={row.key} className="card">
<div style={{ display: "flex", justifyContent: "space-between", gap: "1rem", flexWrap: "wrap" }}>
<div>
<strong>{row.parsedTitle || "Onbekende serie"}</strong>
<div className="muted" style={{ fontSize: "0.875rem", marginTop: 4 }}>
{row.nodeName} · {row.fileCount} bestanden
{row.seasons.length ? ` · seizoen ${row.seasons.join(", ")}` : ""}
</div>
<div className="mono tiny" style={{ marginTop: 8, color: "#a1a1aa" }}>
{row.folder}
</div>
{row.samplePaths.length > 0 && (
<div className="muted tiny" style={{ marginTop: 6 }}>
{row.samplePaths.map((p) => (
<div key={p}>{p}</div>
))}
</div>
)}
</div>
<button type="button" onClick={() => openLink(row)}>
Koppelen…
</button>
</div>
</div>
))}
{pending.length === 0 && (
<div className="card" style={{ textAlign: "center", color: "#71717a" }}>
Geen openstaande series-mappen. Alles is gekoppeld of nog niet gescand.
</div>
)}
</div>
{link && (
<div
style={{
position: "fixed",
inset: 0,
background: "rgba(0,0,0,0.65)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 50,
padding: "1rem",
}}
onClick={() => !busy && setLink(null)}
>
<div
className="card"
style={{ width: "min(640px, 100%)", maxHeight: "90vh", overflow: "auto", margin: 0 }}
onClick={(e) => e.stopPropagation()}
>
<h2 style={{ fontSize: "1.1rem", color: "#fafafa", textTransform: "none", marginBottom: "0.5rem" }}>
Serie koppelen
</h2>
<p style={{ color: "#a1a1aa", fontSize: "0.85rem", marginBottom: "1rem" }}>
{link.nodeName}
<br />
<span className="mono">{link.folder}</span>
<br />
{link.fileCount} bestanden
</p>
<form
onSubmit={(e) => {
e.preventDefault();
void searchMeta();
}}
style={{ display: "flex", gap: "0.5rem", marginBottom: "0.75rem", flexWrap: "wrap" }}
>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Titel of IMDb-id (tt…)"
style={{ flex: 1, minWidth: 200, marginBottom: 0 }}
autoFocus
/>
<button type="submit" disabled={busy}>
Zoek
</button>
<button type="button" disabled={busy} onClick={() => void applyImdb()}>
Koppel IMDb-id
</button>
</form>
{linkMsg && (
<p style={{ color: "#a1a1aa", fontSize: "0.85rem", marginBottom: "0.75rem" }}>{linkMsg}</p>
)}
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
{hits.map((hit) => (
<button
key={hit.tmdbId}
type="button"
disabled={busy}
onClick={() => void applyHit(hit)}
style={{
display: "flex",
gap: "0.75rem",
textAlign: "left",
background: "#0f1117",
border: "1px solid #2a2d3a",
borderRadius: 8,
padding: "0.75rem",
cursor: "pointer",
color: "#fafafa",
}}
>
{hit.posterUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={hit.posterUrl}
alt=""
width={48}
height={72}
style={{ objectFit: "cover", borderRadius: 4, flexShrink: 0 }}
/>
) : (
<div style={{ width: 48, height: 72, background: "#1f2937", borderRadius: 4 }} />
)}
<div>
<div>
<strong>{hit.title}</strong>
{hit.year ? ` (${hit.year})` : ""}
</div>
<div style={{ color: "#71717a", fontSize: "0.8rem" }}>
TMDB {hit.tmdbId}
{hit.imdbId ? ` · ${hit.imdbId}` : ""}
</div>
</div>
</button>
))}
</div>
<div style={{ marginTop: "1rem", display: "flex", justifyContent: "flex-end" }}>
<button type="button" className="danger" disabled={busy} onClick={() => setLink(null)}>
Annuleren
</button>
</div>
</div>
</div>
)}
</>
);
}

View file

@ -10,6 +10,7 @@ const desktopLinks = [
{ href: "/nodes", label: "Nodes" },
{ href: "/movies", label: "Movies" },
{ href: "/series", label: "Series" },
{ href: "/pending", label: "Te koppelen" },
{ href: "/downloads", label: "Downloads" },
{ href: "/streams", label: "Streams" },
{ href: "/viewers", label: "Gebruikers" },

View file

@ -1140,6 +1140,92 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
}
});
/**
* Series-achtige bestanden zonder match (geen episode/movie), gegroepeerd per node+map.
*/
app.get("/api/v1/admin/library/pending-series", { preHandler: requireAdmin }, async (request) => {
const query = request.query as { nodeId?: string };
const nodeId = query.nodeId?.trim() || undefined;
const files = await prisma.mediaFile.findMany({
where: {
movieId: null,
episodeId: null,
available: true,
...(nodeId ? { nodeId } : {}),
OR: [
{ path: { not: null } },
{ shelf: { kind: "SERIES" } },
],
},
orderBy: [{ path: "asc" }, { releaseName: "asc" }],
take: 5000,
include: {
node: { select: { id: true, name: true, status: true } },
shelf: { select: { id: true, name: true, kind: true } },
},
});
type Group = {
key: string;
nodeId: string;
nodeName: string;
folder: string;
parsedTitle: string | null;
fileCount: number;
samplePaths: string[];
seasonsHint: Set<number>;
};
const groups = new Map<string, Group>();
for (const f of files) {
const parsed = f.path ? parseSeriesFromPath(f.path) : null;
const isSeriesShelf = f.shelf?.kind === "SERIES";
if (!parsed && !isSeriesShelf) continue;
const folder =
(f.path ? seriesFolderFromPath(f.path) : null) ||
(f.path ? f.path.replace(/\\/g, "/").split("/").slice(0, -1).join("/") : null) ||
`(geen pad) ${f.releaseName}`;
const key = `${f.nodeId}::${folder}`;
let g = groups.get(key);
if (!g) {
g = {
key,
nodeId: f.nodeId,
nodeName: f.node.name,
folder,
parsedTitle: parsed?.title ?? null,
fileCount: 0,
samplePaths: [],
seasonsHint: new Set(),
};
groups.set(key, g);
}
g.fileCount += 1;
if (parsed?.season) g.seasonsHint.add(parsed.season);
if (g.samplePaths.length < 3) {
g.samplePaths.push(f.path || f.releaseName);
}
if (!g.parsedTitle && parsed?.title) g.parsedTitle = parsed.title;
}
const pending = [...groups.values()]
.map((g) => ({
key: g.key,
nodeId: g.nodeId,
nodeName: g.nodeName,
folder: g.folder,
parsedTitle: g.parsedTitle,
fileCount: g.fileCount,
seasons: [...g.seasonsHint].sort((a, b) => a - b),
samplePaths: g.samplePaths,
}))
.sort((a, b) => a.nodeName.localeCompare(b.nodeName) || a.folder.localeCompare(b.folder));
return { pending, totalFolders: pending.length, totalFiles: pending.reduce((n, p) => n + p.fileCount, 0) };
});
app.get("/api/v1/admin/library/shelves", { preHandler: requireAdmin }, async () => {
const { ensureDefaultShelves } = await import("../library/shelves");
await ensureDefaultShelves();

View file

@ -38,6 +38,30 @@ function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/** Normaliseer titel voor confident match (geen fuzzy first-hit). */
export function normalizeMatchTitle(s: string): string {
return s
.toLowerCase()
.normalize("NFD")
.replace(/\p{M}/gu, "")
.replace(/&/g, " and ")
.replace(/[^a-z0-9]+/g, " ")
.replace(/\s+/g, " ")
.trim();
}
/** Alleen auto-matchen als lokale mapnaam en TMDB-titel echt overeenkomen. */
export function seriesTitlesMatchConfident(local: string, remote: string): boolean {
const a = normalizeMatchTitle(local);
const b = normalizeMatchTitle(remote);
if (!a || !b) return false;
if (a === b) return true;
// optioneel: "The X" ↔ "X"
if (a.startsWith("the ") && a.slice(4) === b) return true;
if (b.startsWith("the ") && b.slice(4) === a) return true;
return false;
}
async function fetchTmdbJson(url: string, retries = 4): Promise<Response | null> {
for (let attempt = 0; attempt < retries; attempt++) {
const res = await fetch(url);
@ -562,9 +586,11 @@ export class MetadataService {
private async matchEpisode(nodeId: string, file: MediaFileInfo): Promise<string | null> {
const parsed = file.parsedEpisode!;
const title = parsed.title.trim();
let series: SeriesRow | null = await this.findExistingSeries(title);
if (this.tmdbApiKey && (!series || series.tmdbId == null)) {
// Alleen koppelen aan een al-geïdentificeerde serie (TMDB/IMDb), nooit aan orphan-titels.
let series: SeriesRow | null = await this.findExistingCanonicalSeries(title);
if (this.tmdbApiKey && !series) {
const tmdb = await this.searchTmdbSeries(title);
if (tmdb) {
series = await prisma.series.upsert({
@ -601,10 +627,6 @@ export class MetadataService {
}
}
if (!series) {
series = await this.findExistingSeries(title);
}
if (
series?.tmdbId &&
this.tmdbApiKey &&
@ -614,10 +636,10 @@ export class MetadataService {
this.refreshedSeriesIds.add(series.id);
}
// Geen betrouwbare match → bestand indexeren zonder serie (Admin: Nog te koppelen).
if (!series) {
series = await prisma.series.create({
data: { title },
});
await this.upsertMediaFile(nodeId, file, {});
return null;
}
let season = await prisma.season.findUnique({
@ -664,7 +686,7 @@ export class MetadataService {
episode = await this.applyTmdbEpisodeMeta(series.tmdbId, episode);
}
await this.upsertMediaFile(nodeId, file, { episodeId: episode.id });
await this.upsertMediaFile(nodeId, file, { episodeId: episode.id, movieId: null });
return episode.id;
}
@ -1995,7 +2017,7 @@ export class MetadataService {
private async upsertMediaFile(
nodeId: string,
file: MediaFileInfo,
refs: { movieId?: string; episodeId?: string }
refs: { movieId?: string | null; episodeId?: string | null }
): Promise<void> {
const mtime = new Date(file.modifiedAt);
const sizeBytes = BigInt(file.sizeBytes);
@ -2012,8 +2034,8 @@ export class MetadataService {
create: {
nodeId,
localFileId: file.localFileId,
movieId: refs.movieId,
episodeId: refs.episodeId,
movieId: refs.movieId ?? null,
episodeId: refs.episodeId ?? null,
shelfId: file.shelfId || null,
sizeBytes,
container: file.container,
@ -2028,8 +2050,8 @@ export class MetadataService {
discoveredAt: new Date(),
},
update: {
movieId: refs.movieId,
episodeId: refs.episodeId,
...("movieId" in refs ? { movieId: refs.movieId ?? null } : {}),
...("episodeId" in refs ? { episodeId: refs.episodeId ?? null } : {}),
...(file.shelfId !== undefined ? { shelfId: file.shelfId || null } : {}),
sizeBytes,
container: file.container,
@ -2082,6 +2104,21 @@ export class MetadataService {
});
}
/** Alleen series met TMDB of IMDb — orphan titel-records tellen niet als match. */
private async findExistingCanonicalSeries(title: string) {
return prisma.series.findFirst({
where: {
title: { equals: title, mode: "insensitive" },
OR: [{ tmdbId: { not: null } }, { imdbId: { not: null } }],
},
orderBy: [
{ imdbId: { sort: "desc", nulls: "last" } },
{ tmdbId: { sort: "desc", nulls: "last" } },
{ createdAt: "asc" },
],
});
}
private async searchTmdbMovie(title: string, year?: number) {
if (!this.tmdbApiKey) return null;
const cacheKey = `${title.toLowerCase()}::${year ?? ""}`;
@ -2128,13 +2165,30 @@ export class MetadataService {
this.seriesSearchCache.set(cacheKey, null);
return null;
}
const data = (await res.json()) as TmdbSearchResult;
const item = data.results[0];
if (!item) {
const data = (await res.json()) as {
results?: Array<{
id: number;
name?: string;
original_name?: string;
first_air_date?: string;
overview?: string;
poster_path?: string;
backdrop_path?: string;
}>;
};
const hit = (data.results ?? []).find((item) => {
const name = item.name || "";
const original = item.original_name || "";
return (
seriesTitlesMatchConfident(title, name) ||
(original ? seriesTitlesMatchConfident(title, original) : false)
);
});
if (!hit) {
this.seriesSearchCache.set(cacheKey, null);
return null;
}
const meta = await this.getTmdbSeriesDetails(item.id);
const meta = await this.getTmdbSeriesDetails(hit.id);
this.seriesSearchCache.set(cacheKey, meta);
return meta;
}