Add manual TMDB/IMDb rematch for movies and series in admin.
This commit is contained in:
parent
887f750f92
commit
788cfa1255
4 changed files with 891 additions and 33 deletions
|
|
@ -29,6 +29,15 @@ interface Movie {
|
||||||
files: MovieFile[];
|
files: MovieFile[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface MetaHit {
|
||||||
|
tmdbId: number;
|
||||||
|
title: string;
|
||||||
|
year: number | null;
|
||||||
|
overview: string | null;
|
||||||
|
posterUrl: string | null;
|
||||||
|
imdbId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
function formatBytes(bytes: string | number | null | undefined): string {
|
function formatBytes(bytes: string | number | null | undefined): string {
|
||||||
const valNum = Number(bytes ?? 0);
|
const valNum = Number(bytes ?? 0);
|
||||||
if (!Number.isFinite(valNum) || valNum < 0) return "0 B";
|
if (!Number.isFinite(valNum) || valNum < 0) return "0 B";
|
||||||
|
|
@ -47,6 +56,11 @@ export default function MoviesPage() {
|
||||||
const [movies, setMovies] = useState<Movie[]>([]);
|
const [movies, setMovies] = useState<Movie[]>([]);
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [expanded, setExpanded] = useState<string | null>(null);
|
const [expanded, setExpanded] = useState<string | null>(null);
|
||||||
|
const [linkMovieId, setLinkMovieId] = useState<string | null>(null);
|
||||||
|
const [linkQuery, setLinkQuery] = useState("");
|
||||||
|
const [linkHits, setLinkHits] = useState<MetaHit[]>([]);
|
||||||
|
const [linkBusy, setLinkBusy] = useState(false);
|
||||||
|
const [linkMsg, setLinkMsg] = useState("");
|
||||||
|
|
||||||
function load(q = search) {
|
function load(q = search) {
|
||||||
const qs = q ? `?search=${encodeURIComponent(q)}` : "";
|
const qs = q ? `?search=${encodeURIComponent(q)}` : "";
|
||||||
|
|
@ -78,6 +92,100 @@ export default function MoviesPage() {
|
||||||
load();
|
load();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openLink(m: Movie) {
|
||||||
|
setLinkMovieId(m.id);
|
||||||
|
setLinkQuery(m.year ? `${m.title} ${m.year}` : m.title);
|
||||||
|
setLinkHits([]);
|
||||||
|
setLinkMsg("");
|
||||||
|
void searchMeta(m.year ? `${m.title} ${m.year}` : m.title);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function searchMeta(q = linkQuery) {
|
||||||
|
const query = q.trim();
|
||||||
|
if (!query) return;
|
||||||
|
setLinkBusy(true);
|
||||||
|
setLinkMsg("");
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/v1/admin/metadata/search?type=movie&q=${encodeURIComponent(query)}`,
|
||||||
|
{ credentials: "include" }
|
||||||
|
);
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
setLinkMsg(data.error?.message ?? "Zoeken mislukt");
|
||||||
|
setLinkHits([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLinkHits(data.results ?? []);
|
||||||
|
if (!(data.results ?? []).length) setLinkMsg("Geen resultaten");
|
||||||
|
} finally {
|
||||||
|
setLinkBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyLink(hit: MetaHit) {
|
||||||
|
if (!linkMovieId) return;
|
||||||
|
setLinkBusy(true);
|
||||||
|
setLinkMsg("Koppelen…");
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/v1/admin/movies/${linkMovieId}/link`, {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
tmdbId: hit.tmdbId,
|
||||||
|
...(hit.imdbId ? { imdbId: hit.imdbId } : {}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
setLinkMsg(data.error?.message ?? "Opslaan mislukt");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLinkMsg(
|
||||||
|
data.merged
|
||||||
|
? "Gekoppeld (samengevoegd met bestaande film). Metadata vernieuwd."
|
||||||
|
: "Gekoppeld. Metadata vernieuwd."
|
||||||
|
);
|
||||||
|
setLinkMovieId(null);
|
||||||
|
setExpanded(data.movieId ?? linkMovieId);
|
||||||
|
load();
|
||||||
|
} finally {
|
||||||
|
setLinkBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyImdbDirect() {
|
||||||
|
const imdb = linkQuery.trim();
|
||||||
|
if (!/^tt\d+$/i.test(imdb) || !linkMovieId) {
|
||||||
|
setLinkMsg("Vul een IMDb-id in (bijv. tt1375666) of zoek eerst op titel.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLinkBusy(true);
|
||||||
|
setLinkMsg("Koppelen via IMDb…");
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/v1/admin/movies/${linkMovieId}/link`, {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ imdbId: imdb }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
setLinkMsg(data.error?.message ?? "Opslaan mislukt");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLinkMsg("Gekoppeld via IMDb. Metadata vernieuwd.");
|
||||||
|
setLinkMovieId(null);
|
||||||
|
setExpanded(data.movieId ?? linkMovieId);
|
||||||
|
load();
|
||||||
|
} finally {
|
||||||
|
setLinkBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeMovie = movies.find((m) => m.id === linkMovieId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Nav />
|
<Nav />
|
||||||
|
|
@ -107,12 +215,26 @@ export default function MoviesPage() {
|
||||||
style={{ cursor: "pointer", display: "flex", justifyContent: "space-between", gap: "1rem" }}
|
style={{ cursor: "pointer", display: "flex", justifyContent: "space-between", gap: "1rem" }}
|
||||||
onClick={() => setExpanded(expanded === m.id ? null : m.id)}
|
onClick={() => setExpanded(expanded === m.id ? null : m.id)}
|
||||||
>
|
>
|
||||||
|
<div style={{ display: "flex", gap: "0.75rem", alignItems: "flex-start" }}>
|
||||||
|
{m.posterUrl && (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={m.posterUrl}
|
||||||
|
alt=""
|
||||||
|
width={40}
|
||||||
|
height={60}
|
||||||
|
style={{ objectFit: "cover", borderRadius: 4, flexShrink: 0 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<div>
|
<div>
|
||||||
<strong>{m.title}</strong>
|
<strong>{m.title}</strong>
|
||||||
{m.year ? ` (${m.year})` : ""}
|
{m.year ? ` (${m.year})` : ""}
|
||||||
<div style={{ color: "#71717a", fontSize: "0.875rem", marginTop: 4 }}>
|
<div style={{ color: "#71717a", fontSize: "0.875rem", marginTop: 4 }}>
|
||||||
{m.qualities.join(", ") || "geen kwaliteit"} · {m.nodeCount} node(s) · {m.fileCount} bestand(en)
|
{m.qualities.join(", ") || "geen kwaliteit"} · {m.nodeCount} node(s) · {m.fileCount}{" "}
|
||||||
{m.imdbId ? ` · ${m.imdbId}` : ""}
|
bestand(en)
|
||||||
|
{m.imdbId ? ` · ${m.imdbId}` : " · geen IMDb"}
|
||||||
|
{m.tmdbId ? ` · TMDB ${m.tmdbId}` : ""}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span style={{ color: "#71717a" }}>{expanded === m.id ? "▲" : "▼"}</span>
|
<span style={{ color: "#71717a" }}>{expanded === m.id ? "▲" : "▼"}</span>
|
||||||
|
|
@ -123,6 +245,17 @@ export default function MoviesPage() {
|
||||||
{m.overview && (
|
{m.overview && (
|
||||||
<p style={{ color: "#a1a1aa", fontSize: "0.875rem", marginBottom: "1rem" }}>{m.overview}</p>
|
<p style={{ color: "#a1a1aa", fontSize: "0.875rem", marginBottom: "1rem" }}>{m.overview}</p>
|
||||||
)}
|
)}
|
||||||
|
<div style={{ marginBottom: "1rem" }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
openLink(m);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Juiste content zoeken
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
|
@ -139,7 +272,9 @@ export default function MoviesPage() {
|
||||||
{m.files.map((f) => (
|
{m.files.map((f) => (
|
||||||
<tr key={f.id}>
|
<tr key={f.id}>
|
||||||
<td>{f.node}</td>
|
<td>{f.node}</td>
|
||||||
<td style={{ maxWidth: 280, overflow: "hidden", textOverflow: "ellipsis" }}>{f.releaseName}</td>
|
<td style={{ maxWidth: 280, overflow: "hidden", textOverflow: "ellipsis" }}>
|
||||||
|
{f.releaseName}
|
||||||
|
</td>
|
||||||
<td>{f.resolution ?? "-"}</td>
|
<td>{f.resolution ?? "-"}</td>
|
||||||
<td>{formatBytes(f.sizeBytes)}</td>
|
<td>{formatBytes(f.sizeBytes)}</td>
|
||||||
<td>{[f.videoCodec, f.audioCodec].filter(Boolean).join(" / ") || "-"}</td>
|
<td>{[f.videoCodec, f.audioCodec].filter(Boolean).join(" / ") || "-"}</td>
|
||||||
|
|
@ -150,7 +285,9 @@ export default function MoviesPage() {
|
||||||
</td>
|
</td>
|
||||||
<td style={{ display: "flex", gap: 8 }}>
|
<td style={{ display: "flex", gap: 8 }}>
|
||||||
<button onClick={() => refreshMeta(f.id)}>Refresh</button>
|
<button onClick={() => refreshMeta(f.id)}>Refresh</button>
|
||||||
<button className="danger" onClick={() => ignoreFile(f.id)}>Ignore</button>
|
<button className="danger" onClick={() => ignoreFile(f.id)}>
|
||||||
|
Ignore
|
||||||
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|
@ -167,6 +304,133 @@ export default function MoviesPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{linkMovieId && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
inset: 0,
|
||||||
|
background: "rgba(0,0,0,0.65)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
zIndex: 50,
|
||||||
|
padding: "1rem",
|
||||||
|
}}
|
||||||
|
onClick={() => !linkBusy && setLinkMovieId(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" }}>
|
||||||
|
Juiste film zoeken
|
||||||
|
</h2>
|
||||||
|
<p style={{ color: "#a1a1aa", fontSize: "0.85rem", marginBottom: "1rem" }}>
|
||||||
|
Huidig: <strong style={{ color: "#e4e4e7" }}>{activeMovie?.title}</strong>
|
||||||
|
{activeMovie?.year ? ` (${activeMovie.year})` : ""}
|
||||||
|
{activeMovie?.imdbId ? ` · ${activeMovie.imdbId}` : " · geen IMDb"}
|
||||||
|
</p>
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
void searchMeta();
|
||||||
|
}}
|
||||||
|
style={{ display: "flex", gap: "0.5rem", marginBottom: "0.75rem", flexWrap: "wrap" }}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
value={linkQuery}
|
||||||
|
onChange={(e) => setLinkQuery(e.target.value)}
|
||||||
|
placeholder="Titel of IMDb-id (tt…)"
|
||||||
|
style={{ flex: 1, minWidth: 200, marginBottom: 0 }}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<button type="submit" disabled={linkBusy}>
|
||||||
|
Zoek
|
||||||
|
</button>
|
||||||
|
<button type="button" disabled={linkBusy} onClick={() => void applyImdbDirect()}>
|
||||||
|
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" }}>
|
||||||
|
{linkHits.map((hit) => (
|
||||||
|
<button
|
||||||
|
key={hit.tmdbId}
|
||||||
|
type="button"
|
||||||
|
disabled={linkBusy}
|
||||||
|
onClick={() => void applyLink(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,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<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>
|
||||||
|
{hit.overview && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
color: "#a1a1aa",
|
||||||
|
fontSize: "0.8rem",
|
||||||
|
marginTop: 4,
|
||||||
|
display: "-webkit-box",
|
||||||
|
WebkitLineClamp: 2,
|
||||||
|
WebkitBoxOrient: "vertical",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{hit.overview}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: "1rem", display: "flex", justifyContent: "flex-end" }}>
|
||||||
|
<button type="button" className="danger" disabled={linkBusy} onClick={() => setLinkMovieId(null)}>
|
||||||
|
Annuleren
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@ interface Series {
|
||||||
title: string;
|
title: string;
|
||||||
year: number | null;
|
year: number | null;
|
||||||
imdbId: string | null;
|
imdbId: string | null;
|
||||||
|
tmdbId?: number | null;
|
||||||
|
posterUrl?: string | null;
|
||||||
episodesWithFiles?: number;
|
episodesWithFiles?: number;
|
||||||
fileCount?: number;
|
fileCount?: number;
|
||||||
seasons: Array<{
|
seasons: Array<{
|
||||||
|
|
@ -21,46 +23,211 @@ interface Series {
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface MetaHit {
|
||||||
|
tmdbId: number;
|
||||||
|
title: string;
|
||||||
|
year: number | null;
|
||||||
|
overview: string | null;
|
||||||
|
posterUrl: string | null;
|
||||||
|
imdbId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export default function SeriesPage() {
|
export default function SeriesPage() {
|
||||||
useAuth();
|
useAuth();
|
||||||
const [series, setSeries] = useState<Series[]>([]);
|
const [series, setSeries] = useState<Series[]>([]);
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
const [expanded, setExpanded] = useState<string | null>(null);
|
const [expanded, setExpanded] = useState<string | null>(null);
|
||||||
|
const [linkSeriesId, setLinkSeriesId] = useState<string | null>(null);
|
||||||
|
const [linkQuery, setLinkQuery] = useState("");
|
||||||
|
const [linkHits, setLinkHits] = useState<MetaHit[]>([]);
|
||||||
|
const [linkBusy, setLinkBusy] = useState(false);
|
||||||
|
const [linkMsg, setLinkMsg] = useState("");
|
||||||
|
|
||||||
useEffect(() => {
|
function load(q = search) {
|
||||||
fetch("/api/v1/admin/series", { credentials: "include" })
|
const qs = q ? `?search=${encodeURIComponent(q)}` : "";
|
||||||
|
fetch(`/api/v1/admin/series${qs}`, { credentials: "include" })
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((d) => setSeries(d.series ?? []));
|
.then((d) => setSeries(d.series ?? []));
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
function openLink(s: Series) {
|
||||||
|
setLinkSeriesId(s.id);
|
||||||
|
setLinkQuery(s.title);
|
||||||
|
setLinkHits([]);
|
||||||
|
setLinkMsg("");
|
||||||
|
void searchMeta(s.title);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function searchMeta(q = linkQuery) {
|
||||||
|
const query = q.trim();
|
||||||
|
if (!query) return;
|
||||||
|
setLinkBusy(true);
|
||||||
|
setLinkMsg("");
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/v1/admin/metadata/search?type=series&q=${encodeURIComponent(query)}`,
|
||||||
|
{ credentials: "include" }
|
||||||
|
);
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
setLinkMsg(data.error?.message ?? "Zoeken mislukt");
|
||||||
|
setLinkHits([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLinkHits(data.results ?? []);
|
||||||
|
if (!(data.results ?? []).length) setLinkMsg("Geen resultaten");
|
||||||
|
} finally {
|
||||||
|
setLinkBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyLink(hit: MetaHit) {
|
||||||
|
if (!linkSeriesId) return;
|
||||||
|
setLinkBusy(true);
|
||||||
|
setLinkMsg("Koppelen…");
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/v1/admin/series/${linkSeriesId}/link`, {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
tmdbId: hit.tmdbId,
|
||||||
|
...(hit.imdbId ? { imdbId: hit.imdbId } : {}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
setLinkMsg(data.error?.message ?? "Opslaan mislukt");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLinkMsg(
|
||||||
|
data.merged
|
||||||
|
? "Gekoppeld (samengevoegd). Metadata + afleveringen vernieuwd."
|
||||||
|
: "Gekoppeld. Metadata + afleveringen vernieuwd."
|
||||||
|
);
|
||||||
|
setLinkSeriesId(null);
|
||||||
|
setExpanded(data.seriesId ?? linkSeriesId);
|
||||||
|
load();
|
||||||
|
} finally {
|
||||||
|
setLinkBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyImdbDirect() {
|
||||||
|
const imdb = linkQuery.trim();
|
||||||
|
if (!/^tt\d+$/i.test(imdb) || !linkSeriesId) {
|
||||||
|
setLinkMsg("Vul een IMDb-id in (bijv. tt0903747) of zoek eerst op titel.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLinkBusy(true);
|
||||||
|
setLinkMsg("Koppelen via IMDb…");
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/v1/admin/series/${linkSeriesId}/link`, {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ imdbId: imdb }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
setLinkMsg(data.error?.message ?? "Opslaan mislukt");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLinkMsg("Gekoppeld via IMDb. Metadata vernieuwd.");
|
||||||
|
setLinkSeriesId(null);
|
||||||
|
setExpanded(data.seriesId ?? linkSeriesId);
|
||||||
|
load();
|
||||||
|
} finally {
|
||||||
|
setLinkBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeSeries = series.find((s) => s.id === linkSeriesId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Nav />
|
<Nav />
|
||||||
<div className="container">
|
<div className="container">
|
||||||
<h1 style={{ marginBottom: "2rem", fontSize: "1.75rem" }}>Series</h1>
|
<div style={{ display: "flex", justifyContent: "space-between", gap: "1rem", marginBottom: "2rem" }}>
|
||||||
|
<h1 style={{ fontSize: "1.75rem" }}>Series</h1>
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
load();
|
||||||
|
}}
|
||||||
|
style={{ display: "flex", gap: "0.5rem" }}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
placeholder="Zoeken..."
|
||||||
|
style={{ width: 240, marginBottom: 0 }}
|
||||||
|
/>
|
||||||
|
<button type="submit">Zoek</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
{series.map((s) => (
|
{series.map((s) => (
|
||||||
<div key={s.id} className="card">
|
<div key={s.id} className="card">
|
||||||
<div
|
<div
|
||||||
style={{ cursor: "pointer", display: "flex", justifyContent: "space-between" }}
|
style={{ cursor: "pointer", display: "flex", justifyContent: "space-between", gap: "1rem" }}
|
||||||
onClick={() => setExpanded(expanded === s.id ? null : s.id)}
|
onClick={() => setExpanded(expanded === s.id ? null : s.id)}
|
||||||
>
|
>
|
||||||
|
<div style={{ display: "flex", gap: "0.75rem", alignItems: "flex-start" }}>
|
||||||
|
{s.posterUrl && (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={s.posterUrl}
|
||||||
|
alt=""
|
||||||
|
width={40}
|
||||||
|
height={60}
|
||||||
|
style={{ objectFit: "cover", borderRadius: 4, flexShrink: 0 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
<strong>{s.title}</strong>
|
<strong>{s.title}</strong>
|
||||||
<span style={{ color: "#71717a" }}>
|
{s.year ? ` (${s.year})` : ""}
|
||||||
{s.episodesWithFiles ?? 0} afleveringen · {s.fileCount ?? 0} bestanden ·{" "}
|
<div style={{ color: "#71717a", fontSize: "0.875rem", marginTop: 4 }}>
|
||||||
{s.seasons.length} seizoen(en) · {expanded === s.id ? "▲" : "▼"}
|
{s.episodesWithFiles ?? 0} afleveringen · {s.fileCount ?? 0} bestanden · {s.seasons.length}{" "}
|
||||||
</span>
|
seizoen(en)
|
||||||
|
{s.imdbId ? ` · ${s.imdbId}` : " · geen IMDb"}
|
||||||
|
{s.tmdbId ? ` · TMDB ${s.tmdbId}` : ""}
|
||||||
</div>
|
</div>
|
||||||
{expanded === s.id && s.seasons.map((season) => (
|
</div>
|
||||||
|
</div>
|
||||||
|
<span style={{ color: "#71717a" }}>{expanded === s.id ? "▲" : "▼"}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{expanded === s.id && (
|
||||||
|
<div style={{ marginTop: "1rem" }}>
|
||||||
|
<div style={{ marginBottom: "1rem" }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
openLink(s);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Juiste content zoeken
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{s.seasons.map((season) => (
|
||||||
<div key={season.seasonNumber} style={{ marginTop: "1rem", paddingLeft: "1rem" }}>
|
<div key={season.seasonNumber} style={{ marginTop: "1rem", paddingLeft: "1rem" }}>
|
||||||
<h3 style={{ fontSize: "0.875rem", color: "#a1a1aa", marginBottom: "0.5rem" }}>
|
<h3 style={{ fontSize: "0.875rem", color: "#a1a1aa", marginBottom: "0.5rem" }}>
|
||||||
Season {season.seasonNumber}
|
Season {season.seasonNumber}
|
||||||
</h3>
|
</h3>
|
||||||
{season.episodes.map((ep) => (
|
{season.episodes.map((ep) => (
|
||||||
<div key={ep.episodeNumber} style={{ fontSize: "0.875rem", padding: "0.25rem 0" }}>
|
<div key={ep.episodeNumber} style={{ fontSize: "0.875rem", padding: "0.25rem 0" }}>
|
||||||
S{String(ep.seasonNumber).padStart(2, "0")}E{String(ep.episodeNumber).padStart(2, "0")}
|
S{String(ep.seasonNumber).padStart(2, "0")}E
|
||||||
{" "}{ep.title ?? ""}
|
{String(ep.episodeNumber).padStart(2, "0")} {ep.title ?? ""}
|
||||||
{ep.files.length > 0 && (
|
{ep.files.length > 0 && (
|
||||||
<span style={{ color: "#71717a" }}>
|
<span style={{ color: "#71717a" }}>
|
||||||
{" "}— {ep.files.map((f) => `${f.node} (${f.resolution ?? "?"})`).join(", ")}
|
{" "}
|
||||||
|
— {ep.files.map((f) => `${f.node} (${f.resolution ?? "?"})`).join(", ")}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -68,11 +235,143 @@ export default function SeriesPage() {
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
|
||||||
{series.length === 0 && (
|
|
||||||
<div className="card" style={{ textAlign: "center", color: "#71717a" }}>Geen series gevonden</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{series.length === 0 && (
|
||||||
|
<div className="card" style={{ textAlign: "center", color: "#71717a" }}>
|
||||||
|
Geen series gevonden
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{linkSeriesId && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
inset: 0,
|
||||||
|
background: "rgba(0,0,0,0.65)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
zIndex: 50,
|
||||||
|
padding: "1rem",
|
||||||
|
}}
|
||||||
|
onClick={() => !linkBusy && setLinkSeriesId(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" }}>
|
||||||
|
Juiste serie zoeken
|
||||||
|
</h2>
|
||||||
|
<p style={{ color: "#a1a1aa", fontSize: "0.85rem", marginBottom: "1rem" }}>
|
||||||
|
Huidig: <strong style={{ color: "#e4e4e7" }}>{activeSeries?.title}</strong>
|
||||||
|
{activeSeries?.year ? ` (${activeSeries.year})` : ""}
|
||||||
|
{activeSeries?.imdbId ? ` · ${activeSeries.imdbId}` : " · geen IMDb"}
|
||||||
|
</p>
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
void searchMeta();
|
||||||
|
}}
|
||||||
|
style={{ display: "flex", gap: "0.5rem", marginBottom: "0.75rem", flexWrap: "wrap" }}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
value={linkQuery}
|
||||||
|
onChange={(e) => setLinkQuery(e.target.value)}
|
||||||
|
placeholder="Titel of IMDb-id (tt…)"
|
||||||
|
style={{ flex: 1, minWidth: 200, marginBottom: 0 }}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<button type="submit" disabled={linkBusy}>
|
||||||
|
Zoek
|
||||||
|
</button>
|
||||||
|
<button type="button" disabled={linkBusy} onClick={() => void applyImdbDirect()}>
|
||||||
|
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" }}>
|
||||||
|
{linkHits.map((hit) => (
|
||||||
|
<button
|
||||||
|
key={hit.tmdbId}
|
||||||
|
type="button"
|
||||||
|
disabled={linkBusy}
|
||||||
|
onClick={() => void applyLink(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,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<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>
|
||||||
|
{hit.overview && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
color: "#a1a1aa",
|
||||||
|
fontSize: "0.8rem",
|
||||||
|
marginTop: 4,
|
||||||
|
display: "-webkit-box",
|
||||||
|
WebkitLineClamp: 2,
|
||||||
|
WebkitBoxOrient: "vertical",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{hit.overview}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: "1rem", display: "flex", justifyContent: "flex-end" }}>
|
||||||
|
<button type="button" className="danger" disabled={linkBusy} onClick={() => setLinkSeriesId(null)}>
|
||||||
|
Annuleren
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -464,6 +464,54 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
|
||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get("/api/v1/admin/metadata/search", { preHandler: requireAdmin }, async (request) => {
|
||||||
|
const query = request.query as { type?: string; q?: string };
|
||||||
|
const type = query.type === "series" ? "series" : "movie";
|
||||||
|
const q = (query.q ?? "").trim();
|
||||||
|
if (!q) throw new AppError("INVALID_REQUEST", "q is verplicht", 400);
|
||||||
|
if (!config.TMDB_API_KEY) {
|
||||||
|
throw new AppError("CONFIG", "TMDB_API_KEY ontbreekt", 503);
|
||||||
|
}
|
||||||
|
const results = await metadata.searchCatalog(type, q);
|
||||||
|
return { results };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/v1/admin/movies/:id/link", { preHandler: requireAdmin }, async (request) => {
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const body = request.body as { tmdbId?: number; imdbId?: string };
|
||||||
|
if (body.tmdbId == null && !body.imdbId?.trim()) {
|
||||||
|
throw new AppError("INVALID_REQUEST", "tmdbId of imdbId verplicht", 400);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await metadata.linkMovie(id, {
|
||||||
|
tmdbId: body.tmdbId,
|
||||||
|
imdbId: body.imdbId?.trim(),
|
||||||
|
});
|
||||||
|
return { ok: true, ...result };
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : "Koppelen mislukt";
|
||||||
|
throw new AppError("LINK_FAILED", message, 400);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/v1/admin/series/:id/link", { preHandler: requireAdmin }, async (request) => {
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const body = request.body as { tmdbId?: number; imdbId?: string };
|
||||||
|
if (body.tmdbId == null && !body.imdbId?.trim()) {
|
||||||
|
throw new AppError("INVALID_REQUEST", "tmdbId of imdbId verplicht", 400);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await metadata.linkSeries(id, {
|
||||||
|
tmdbId: body.tmdbId,
|
||||||
|
imdbId: body.imdbId?.trim(),
|
||||||
|
});
|
||||||
|
return { ok: true, ...result };
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : "Koppelen mislukt";
|
||||||
|
throw new AppError("LINK_FAILED", message, 400);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
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();
|
||||||
|
|
|
||||||
|
|
@ -937,6 +937,253 @@ export class MetadataService {
|
||||||
return { moviesMerged, seriesMerged, staleFilesRemoved, imdb };
|
return { moviesMerged, seriesMerged, staleFilesRemoved, imdb };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Admin: search TMDB by title (or resolve a single IMDb id). */
|
||||||
|
async searchCatalog(
|
||||||
|
type: "movie" | "series",
|
||||||
|
query: string
|
||||||
|
): Promise<
|
||||||
|
Array<{
|
||||||
|
tmdbId: number;
|
||||||
|
title: string;
|
||||||
|
year: number | null;
|
||||||
|
overview: string | null;
|
||||||
|
posterUrl: string | null;
|
||||||
|
imdbId: string | null;
|
||||||
|
}>
|
||||||
|
> {
|
||||||
|
if (!this.tmdbApiKey) return [];
|
||||||
|
const q = query.trim();
|
||||||
|
if (!q) return [];
|
||||||
|
|
||||||
|
// Direct IMDb id → resolve via TMDB find
|
||||||
|
if (/^tt\d+$/i.test(q)) {
|
||||||
|
const found = await this.findTmdbByImdb(type, q);
|
||||||
|
return found ? [found] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
api_key: this.tmdbApiKey,
|
||||||
|
query: q,
|
||||||
|
language: TMDB_LANG,
|
||||||
|
include_adult: "false",
|
||||||
|
});
|
||||||
|
const path = type === "movie" ? "search/movie" : "search/tv";
|
||||||
|
const res = await fetch(`https://api.themoviedb.org/3/${path}?${params}`);
|
||||||
|
if (!res.ok) return [];
|
||||||
|
const data = (await res.json()) as TmdbSearchResult;
|
||||||
|
return (data.results ?? []).slice(0, 12).map((item) => {
|
||||||
|
const date = type === "movie" ? item.release_date : item.first_air_date;
|
||||||
|
const year = date ? parseInt(date.slice(0, 4), 10) : null;
|
||||||
|
return {
|
||||||
|
tmdbId: item.id,
|
||||||
|
title: (type === "movie" ? item.title : item.name) || "Onbekend",
|
||||||
|
year: Number.isFinite(year) ? year : null,
|
||||||
|
overview: item.overview || null,
|
||||||
|
posterUrl: item.poster_path ? `${TMDB_IMAGE_BASE}${item.poster_path}` : null,
|
||||||
|
imdbId: null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin: bind a library movie to a TMDB/IMDb title and refresh metadata.
|
||||||
|
* Merges into an existing TMDB row when one already exists.
|
||||||
|
*/
|
||||||
|
async linkMovie(
|
||||||
|
movieId: string,
|
||||||
|
opts: { tmdbId?: number; imdbId?: string }
|
||||||
|
): Promise<{ movieId: string; merged: boolean }> {
|
||||||
|
if (!this.tmdbApiKey) throw new Error("TMDB_API_KEY niet geconfigureerd");
|
||||||
|
const source = await prisma.movie.findUnique({ where: { id: movieId } });
|
||||||
|
if (!source) throw new Error("Film niet gevonden");
|
||||||
|
|
||||||
|
let tmdbId = opts.tmdbId;
|
||||||
|
if (!tmdbId && opts.imdbId) {
|
||||||
|
const hit = await this.findTmdbByImdb("movie", opts.imdbId.trim());
|
||||||
|
tmdbId = hit?.tmdbId;
|
||||||
|
}
|
||||||
|
if (!tmdbId) throw new Error("Geef een TMDB-id of IMDb-id (tt…) op");
|
||||||
|
|
||||||
|
const meta = await this.getTmdbMovieDetails(tmdbId);
|
||||||
|
if (!meta) throw new Error("TMDB-film niet gevonden");
|
||||||
|
|
||||||
|
const logoUrl = await this.fetchTmdbMovieLogo(tmdbId);
|
||||||
|
const extras = await this.getStremioMetaExtras("movie", meta.id, meta.imdbId, logoUrl);
|
||||||
|
|
||||||
|
let target = await prisma.movie.findFirst({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ tmdbId: meta.id },
|
||||||
|
...(meta.imdbId ? [{ imdbId: meta.imdbId }] : []),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let merged = false;
|
||||||
|
if (!target) {
|
||||||
|
target = await prisma.movie.update({
|
||||||
|
where: { id: movieId },
|
||||||
|
data: {
|
||||||
|
title: meta.title,
|
||||||
|
year: meta.year ?? null,
|
||||||
|
overview: meta.overview ?? null,
|
||||||
|
posterUrl: meta.posterUrl ?? null,
|
||||||
|
backdropUrl: meta.backdropUrl ?? null,
|
||||||
|
tmdbId: meta.id,
|
||||||
|
...(meta.imdbId ? { imdbId: meta.imdbId } : {}),
|
||||||
|
...(extras.genres?.length ? { genres: extras.genres } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
target = await prisma.movie.update({
|
||||||
|
where: { id: target.id },
|
||||||
|
data: {
|
||||||
|
title: meta.title,
|
||||||
|
year: meta.year ?? null,
|
||||||
|
overview: meta.overview ?? null,
|
||||||
|
posterUrl: meta.posterUrl ?? null,
|
||||||
|
backdropUrl: meta.backdropUrl ?? null,
|
||||||
|
tmdbId: meta.id,
|
||||||
|
...(meta.imdbId ? { imdbId: meta.imdbId } : {}),
|
||||||
|
...(extras.genres?.length ? { genres: extras.genres } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (target.id !== movieId) {
|
||||||
|
await prisma.mediaFile.updateMany({
|
||||||
|
where: { movieId },
|
||||||
|
data: { movieId: target.id },
|
||||||
|
});
|
||||||
|
await prisma.movie.delete({ where: { id: movieId } }).catch(() => undefined);
|
||||||
|
merged = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.refreshedMovieIds.delete(target.id);
|
||||||
|
await this.refreshMovieNl(target.id, meta.id).catch(() => undefined);
|
||||||
|
return { movieId: target.id, merged };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin: bind a library series to TMDB/IMDb and refresh artwork + episode titles.
|
||||||
|
*/
|
||||||
|
async linkSeries(
|
||||||
|
seriesId: string,
|
||||||
|
opts: { tmdbId?: number; imdbId?: string }
|
||||||
|
): Promise<{ seriesId: string; merged: boolean }> {
|
||||||
|
if (!this.tmdbApiKey) throw new Error("TMDB_API_KEY niet geconfigureerd");
|
||||||
|
const source = await prisma.series.findUnique({ where: { id: seriesId } });
|
||||||
|
if (!source) throw new Error("Serie niet gevonden");
|
||||||
|
|
||||||
|
let tmdbId = opts.tmdbId;
|
||||||
|
if (!tmdbId && opts.imdbId) {
|
||||||
|
const hit = await this.findTmdbByImdb("series", opts.imdbId.trim());
|
||||||
|
tmdbId = hit?.tmdbId;
|
||||||
|
}
|
||||||
|
if (!tmdbId) throw new Error("Geef een TMDB-id of IMDb-id (tt…) op");
|
||||||
|
|
||||||
|
const meta = await this.getTmdbSeriesDetails(tmdbId);
|
||||||
|
if (!meta) throw new Error("TMDB-serie niet gevonden");
|
||||||
|
|
||||||
|
const extras = await this.getStremioMetaExtras(
|
||||||
|
"series",
|
||||||
|
meta.id,
|
||||||
|
meta.imdbId,
|
||||||
|
meta.logoUrl
|
||||||
|
);
|
||||||
|
|
||||||
|
let target = await prisma.series.findFirst({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ tmdbId: meta.id },
|
||||||
|
...(meta.imdbId ? [{ imdbId: meta.imdbId }] : []),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let merged = false;
|
||||||
|
if (!target) {
|
||||||
|
target = await prisma.series.update({
|
||||||
|
where: { id: seriesId },
|
||||||
|
data: {
|
||||||
|
title: meta.title,
|
||||||
|
year: meta.year ?? null,
|
||||||
|
overview: meta.overview ?? null,
|
||||||
|
posterUrl: meta.posterUrl ?? null,
|
||||||
|
backdropUrl: meta.backdropUrl ?? null,
|
||||||
|
logoUrl: meta.logoUrl ?? null,
|
||||||
|
tmdbId: meta.id,
|
||||||
|
...(meta.imdbId ? { imdbId: meta.imdbId } : {}),
|
||||||
|
...(extras.genres?.length ? { genres: extras.genres } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
target = await prisma.series.update({
|
||||||
|
where: { id: target.id },
|
||||||
|
data: {
|
||||||
|
title: meta.title,
|
||||||
|
year: meta.year ?? null,
|
||||||
|
overview: meta.overview ?? null,
|
||||||
|
posterUrl: meta.posterUrl ?? null,
|
||||||
|
backdropUrl: meta.backdropUrl ?? null,
|
||||||
|
logoUrl: meta.logoUrl ?? null,
|
||||||
|
tmdbId: meta.id,
|
||||||
|
...(meta.imdbId ? { imdbId: meta.imdbId } : {}),
|
||||||
|
...(extras.genres?.length ? { genres: extras.genres } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (target.id !== seriesId) {
|
||||||
|
await this.mergeSeriesInto(target.id, seriesId);
|
||||||
|
merged = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.refreshedSeriesIds.delete(target.id);
|
||||||
|
await this.refreshSeriesNl(target.id, meta.id).catch(() => undefined);
|
||||||
|
await this.ensureSeriesArtwork(target.id).catch(() => undefined);
|
||||||
|
await this.enrichSeriesEpisodes(target.id).catch(() => undefined);
|
||||||
|
return { seriesId: target.id, merged };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findTmdbByImdb(
|
||||||
|
type: "movie" | "series",
|
||||||
|
imdbId: string
|
||||||
|
): Promise<{
|
||||||
|
tmdbId: number;
|
||||||
|
title: string;
|
||||||
|
year: number | null;
|
||||||
|
overview: string | null;
|
||||||
|
posterUrl: string | null;
|
||||||
|
imdbId: string | null;
|
||||||
|
} | null> {
|
||||||
|
if (!this.tmdbApiKey || !/^tt\d+$/i.test(imdbId)) return null;
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
api_key: this.tmdbApiKey,
|
||||||
|
external_source: "imdb_id",
|
||||||
|
language: TMDB_LANG,
|
||||||
|
});
|
||||||
|
const res = await fetch(
|
||||||
|
`https://api.themoviedb.org/3/find/${encodeURIComponent(imdbId)}?${params}`
|
||||||
|
);
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const data = (await res.json()) as {
|
||||||
|
movie_results?: TmdbSearchResult["results"];
|
||||||
|
tv_results?: TmdbSearchResult["results"];
|
||||||
|
};
|
||||||
|
const item =
|
||||||
|
type === "movie" ? data.movie_results?.[0] : data.tv_results?.[0];
|
||||||
|
if (!item) return null;
|
||||||
|
const date = type === "movie" ? item.release_date : item.first_air_date;
|
||||||
|
const year = date ? parseInt(date.slice(0, 4), 10) : null;
|
||||||
|
return {
|
||||||
|
tmdbId: item.id,
|
||||||
|
title: (type === "movie" ? item.title : item.name) || "Onbekend",
|
||||||
|
year: Number.isFinite(year) ? year : null,
|
||||||
|
overview: item.overview || null,
|
||||||
|
posterUrl: item.poster_path ? `${TMDB_IMAGE_BASE}${item.poster_path}` : null,
|
||||||
|
imdbId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** Backfill IMDb id from TMDB so catalog can use tt + Cinemeta. */
|
/** Backfill IMDb id from TMDB so catalog can use tt + Cinemeta. */
|
||||||
async ensureMovieImdbId(movieId: string): Promise<boolean> {
|
async ensureMovieImdbId(movieId: string): Promise<boolean> {
|
||||||
if (!this.tmdbApiKey) return false;
|
if (!this.tmdbApiKey) return false;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue