Queue unmatched series folders for manual linking instead of auto-creating.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
ce278bbb70
commit
3b9b95469b
4 changed files with 478 additions and 19 deletions
318
apps/admin-ui/src/app/pending/page.tsx
Normal file
318
apps/admin-ui/src/app/pending/page.tsx
Normal 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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -10,6 +10,7 @@ const desktopLinks = [
|
||||||
{ href: "/nodes", label: "Nodes" },
|
{ href: "/nodes", label: "Nodes" },
|
||||||
{ href: "/movies", label: "Movies" },
|
{ href: "/movies", label: "Movies" },
|
||||||
{ href: "/series", label: "Series" },
|
{ href: "/series", label: "Series" },
|
||||||
|
{ href: "/pending", label: "Te koppelen" },
|
||||||
{ href: "/downloads", label: "Downloads" },
|
{ href: "/downloads", label: "Downloads" },
|
||||||
{ href: "/streams", label: "Streams" },
|
{ href: "/streams", label: "Streams" },
|
||||||
{ href: "/viewers", label: "Gebruikers" },
|
{ href: "/viewers", label: "Gebruikers" },
|
||||||
|
|
|
||||||
|
|
@ -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 () => {
|
app.get("/api/v1/admin/library/shelves", { preHandler: requireAdmin }, async () => {
|
||||||
const { ensureDefaultShelves } = await import("../library/shelves");
|
const { ensureDefaultShelves } = await import("../library/shelves");
|
||||||
await ensureDefaultShelves();
|
await ensureDefaultShelves();
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,30 @@ function sleep(ms: number): Promise<void> {
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
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> {
|
async function fetchTmdbJson(url: string, retries = 4): Promise<Response | null> {
|
||||||
for (let attempt = 0; attempt < retries; attempt++) {
|
for (let attempt = 0; attempt < retries; attempt++) {
|
||||||
const res = await fetch(url);
|
const res = await fetch(url);
|
||||||
|
|
@ -562,9 +586,11 @@ export class MetadataService {
|
||||||
private async matchEpisode(nodeId: string, file: MediaFileInfo): Promise<string | null> {
|
private async matchEpisode(nodeId: string, file: MediaFileInfo): Promise<string | null> {
|
||||||
const parsed = file.parsedEpisode!;
|
const parsed = file.parsedEpisode!;
|
||||||
const title = parsed.title.trim();
|
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);
|
const tmdb = await this.searchTmdbSeries(title);
|
||||||
if (tmdb) {
|
if (tmdb) {
|
||||||
series = await prisma.series.upsert({
|
series = await prisma.series.upsert({
|
||||||
|
|
@ -601,10 +627,6 @@ export class MetadataService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!series) {
|
|
||||||
series = await this.findExistingSeries(title);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
series?.tmdbId &&
|
series?.tmdbId &&
|
||||||
this.tmdbApiKey &&
|
this.tmdbApiKey &&
|
||||||
|
|
@ -614,10 +636,10 @@ export class MetadataService {
|
||||||
this.refreshedSeriesIds.add(series.id);
|
this.refreshedSeriesIds.add(series.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Geen betrouwbare match → bestand indexeren zonder serie (Admin: Nog te koppelen).
|
||||||
if (!series) {
|
if (!series) {
|
||||||
series = await prisma.series.create({
|
await this.upsertMediaFile(nodeId, file, {});
|
||||||
data: { title },
|
return null;
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let season = await prisma.season.findUnique({
|
let season = await prisma.season.findUnique({
|
||||||
|
|
@ -664,7 +686,7 @@ export class MetadataService {
|
||||||
episode = await this.applyTmdbEpisodeMeta(series.tmdbId, episode);
|
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;
|
return episode.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1995,7 +2017,7 @@ export class MetadataService {
|
||||||
private async upsertMediaFile(
|
private async upsertMediaFile(
|
||||||
nodeId: string,
|
nodeId: string,
|
||||||
file: MediaFileInfo,
|
file: MediaFileInfo,
|
||||||
refs: { movieId?: string; episodeId?: string }
|
refs: { movieId?: string | null; episodeId?: string | null }
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const mtime = new Date(file.modifiedAt);
|
const mtime = new Date(file.modifiedAt);
|
||||||
const sizeBytes = BigInt(file.sizeBytes);
|
const sizeBytes = BigInt(file.sizeBytes);
|
||||||
|
|
@ -2012,8 +2034,8 @@ export class MetadataService {
|
||||||
create: {
|
create: {
|
||||||
nodeId,
|
nodeId,
|
||||||
localFileId: file.localFileId,
|
localFileId: file.localFileId,
|
||||||
movieId: refs.movieId,
|
movieId: refs.movieId ?? null,
|
||||||
episodeId: refs.episodeId,
|
episodeId: refs.episodeId ?? null,
|
||||||
shelfId: file.shelfId || null,
|
shelfId: file.shelfId || null,
|
||||||
sizeBytes,
|
sizeBytes,
|
||||||
container: file.container,
|
container: file.container,
|
||||||
|
|
@ -2028,8 +2050,8 @@ export class MetadataService {
|
||||||
discoveredAt: new Date(),
|
discoveredAt: new Date(),
|
||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
movieId: refs.movieId,
|
...("movieId" in refs ? { movieId: refs.movieId ?? null } : {}),
|
||||||
episodeId: refs.episodeId,
|
...("episodeId" in refs ? { episodeId: refs.episodeId ?? null } : {}),
|
||||||
...(file.shelfId !== undefined ? { shelfId: file.shelfId || null } : {}),
|
...(file.shelfId !== undefined ? { shelfId: file.shelfId || null } : {}),
|
||||||
sizeBytes,
|
sizeBytes,
|
||||||
container: file.container,
|
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) {
|
private async searchTmdbMovie(title: string, year?: number) {
|
||||||
if (!this.tmdbApiKey) return null;
|
if (!this.tmdbApiKey) return null;
|
||||||
const cacheKey = `${title.toLowerCase()}::${year ?? ""}`;
|
const cacheKey = `${title.toLowerCase()}::${year ?? ""}`;
|
||||||
|
|
@ -2128,13 +2165,30 @@ export class MetadataService {
|
||||||
this.seriesSearchCache.set(cacheKey, null);
|
this.seriesSearchCache.set(cacheKey, null);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const data = (await res.json()) as TmdbSearchResult;
|
const data = (await res.json()) as {
|
||||||
const item = data.results[0];
|
results?: Array<{
|
||||||
if (!item) {
|
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);
|
this.seriesSearchCache.set(cacheKey, null);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const meta = await this.getTmdbSeriesDetails(item.id);
|
const meta = await this.getTmdbSeriesDetails(hit.id);
|
||||||
this.seriesSearchCache.set(cacheKey, meta);
|
this.seriesSearchCache.set(cacheKey, meta);
|
||||||
return meta;
|
return meta;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue