stremio/apps/admin-ui/src/app/dashboard/page.tsx
Jos Vooges | STH 5749e41148 Fix IMDb backfill timing out by processing in batches with auto-retry in admin UI.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 03:25:58 +02:00

341 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useCallback, useEffect, useState } from "react";
import { Nav, useAuth } from "@/components/Nav";
interface Dashboard {
nodesOnline: number;
nodesOffline: number;
totalStorage: string;
freeStorage: string;
movies: number;
series: number;
episodes: number;
availableMovies?: number;
availableSeries?: number;
mediaFiles?: number;
availableFiles?: number;
moviesMissingImdb?: number;
moviesWithImdbStreams?: number;
seriesMissingImdb?: number;
seriesWithImdb?: number;
scannedFiles?: number;
activeStreams: number;
currentBandwidth: string;
}
function formatBytes(bytes: string | number | null | undefined): string {
if (bytes === null || bytes === undefined || bytes === "") return "0 B";
let n: bigint;
try {
n = typeof bytes === "bigint" ? bytes : BigInt(String(bytes));
} catch {
return "0 B";
}
const units = ["B", "KB", "MB", "GB", "TB"];
let val = Number(n);
if (!Number.isFinite(val)) return "0 B";
let i = 0;
while (val >= 1024 && i < units.length - 1) {
val /= 1024;
i++;
}
return `${val.toFixed(1)} ${units[i]}`;
}
export default function DashboardPage() {
useAuth();
const [data, setData] = useState<Dashboard | null>(null);
const [error, setError] = useState("");
const [dedupeMsg, setDedupeMsg] = useState("");
const [imdbMsg, setImdbMsg] = useState("");
const [repairMsg, setRepairMsg] = useState("");
const [repairBusy, setRepairBusy] = useState(false);
const [dedupeBusy, setDedupeBusy] = useState(false);
const [imdbBusy, setImdbBusy] = useState(false);
const load = useCallback(() => {
fetch("/api/v1/admin/dashboard", { credentials: "include" })
.then(async (r) => {
const json = await r.json();
if (!r.ok) {
throw new Error(json.error?.message ?? `HTTP ${r.status}`);
}
setData(json);
})
.catch((err) => {
console.error(err);
setError(err instanceof Error ? err.message : "Laden mislukt");
});
}, []);
useEffect(() => {
load();
}, [load]);
async function repairLibrary() {
if (
!confirm(
"Bibliotheek herstellen na pad-migratie?\n\n• Duplicaten samenvoegen naar IMDb/TMDB-records\n• Dode (unavailable) bestanden verwijderen\n• Dubbele file-rijen opruimen\n• Ontbrekende IMDb-IDs backfillen (max ~400 per run)\n\nDuurt meestal 1–3 minuten. Herhaal indien nodig."
)
) {
return;
}
setRepairBusy(true);
setRepairMsg("Bezig — duplicaten en dode files opruimen…");
try {
const res = await fetch("/api/v1/admin/library/repair", {
method: "POST",
credentials: "include",
});
const json = await res.json().catch(() => ({}));
if (!res.ok) {
setRepairMsg(json.error?.message ?? `Mislukt (HTTP ${res.status})`);
return;
}
const imdb = json.imdb ?? {};
const imdbRemaining =
(imdb.movies?.remaining ?? 0) + (imdb.series?.remaining ?? 0);
setRepairMsg(
`Klaar: ${json.moviesMerged ?? 0} film-merges, ${json.seriesMerged ?? 0} serie-merges, ${json.staleFilesRemoved ?? 0} dode files, ${json.duplicateFilesRemoved ?? 0} dubbele files · IMDb +${imdb.movies?.updated ?? 0} films, +${imdb.series?.updated ?? 0} series${imdbRemaining > 0 ? ` · nog ${imdbRemaining} IMDb open (herhaal of gebruik backfill-knop)` : ""}. Herlaad daarna de Stremio-addon.`
);
load();
} catch (err) {
setRepairMsg(
err instanceof Error
? `Verbinding mislukt: ${err.message}`
: "Verbinding mislukt — timeout of server offline?"
);
} finally {
setRepairBusy(false);
}
}
async function backfillImdb() {
if (
!confirm(
"IMDb-IDs ophalen via TMDB voor films en series die die nog missen? Dit gebeurt in batches (duurt even bij grote bibliotheken)."
)
) {
return;
}
setImdbBusy(true);
setImdbMsg("Batch 1 bezig…");
let batch = 0;
let totalMoviesUpdated = 0;
let totalSeriesUpdated = 0;
let prevRemaining = Number.POSITIVE_INFINITY;
try {
while (batch < 300) {
batch += 1;
setImdbMsg(`Batch ${batch} bezig…`);
const res = await fetch("/api/v1/admin/library/backfill-imdb", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ limit: 100 }),
});
const json = await res.json().catch(() => ({}));
if (!res.ok) {
setImdbMsg(json.error?.message ?? "Mislukt");
return;
}
const movies = json.movies ?? {};
const series = json.series ?? {};
const checked = (movies.checked ?? 0) + (series.checked ?? 0);
const updated = (movies.updated ?? 0) + (series.updated ?? 0);
totalMoviesUpdated += movies.updated ?? 0;
totalSeriesUpdated += series.updated ?? 0;
const remaining = (movies.remaining ?? 0) + (series.remaining ?? 0);
if (remaining === 0) {
setImdbMsg(
`Klaar: films +${totalMoviesUpdated}, series +${totalSeriesUpdated} · alles ingevuld`
);
load();
return;
}
if (checked === 0) {
setImdbMsg(
`Klaar: films +${totalMoviesUpdated}, series +${totalSeriesUpdated} · nog ${remaining} open (geen IMDb op TMDB)`
);
load();
return;
}
if (remaining >= prevRemaining && updated === 0) {
setImdbMsg(
`Gestopt na batch ${batch}: films +${totalMoviesUpdated}, series +${totalSeriesUpdated} · nog ${remaining} zonder IMDb (niet beschikbaar op TMDB)`
);
load();
return;
}
prevRemaining = remaining;
setImdbMsg(
`Batch ${batch}: +${movies.updated ?? 0} films, +${series.updated ?? 0} series · nog ${remaining} open…`
);
}
setImdbMsg(
`Batchlimiet bereikt: films +${totalMoviesUpdated}, series +${totalSeriesUpdated} · druk opnieuw op de knop om verder te gaan`
);
load();
} catch (err) {
setImdbMsg(err instanceof Error ? `Mislukt: ${err.message}` : "Mislukt");
} finally {
setImdbBusy(false);
}
}
async function dedupe() {
if (
!confirm(
"Duplicaten samenvoegen? Titels met dezelfde naam worden samengevoegd (TMDB-versie wint)."
)
) {
return;
}
setDedupeBusy(true);
setDedupeMsg("Bezig…");
try {
const res = await fetch("/api/v1/admin/library/dedupe", {
method: "POST",
credentials: "include",
});
const json = await res.json().catch(() => ({}));
if (!res.ok) {
setDedupeMsg(json.error?.message ?? "Mislukt");
return;
}
setDedupeMsg(
`Klaar: ${json.moviesMerged} film-duplicaten, ${json.seriesMerged} serie-duplicaten, ${json.emptied} lege records opgeruimd.`
);
load();
} catch (err) {
setDedupeMsg(err instanceof Error ? `Mislukt: ${err.message}` : "Mislukt");
} finally {
setDedupeBusy(false);
}
}
return (
<>
<Nav />
<div className="container">
<div className="page-header">
<div>
<div className="page-kicker">Cluster telemetry</div>
<h1>Dashboard</h1>
</div>
</div>
{error && <div className="error">{error}</div>}
{data && (
<>
<section className="stack-section">
<div className="section-head">
<h2 className="section-title">Cluster</h2>
</div>
<div className="grid">
<div className="card">
<h2>Nodes online</h2>
<div className="value">{data.nodesOnline ?? 0}</div>
</div>
<div className="card">
<h2>Nodes offline</h2>
<div className="value">{data.nodesOffline ?? 0}</div>
</div>
<div className="card">
<h2>Live streams</h2>
<div className="value">{data.activeStreams ?? 0}</div>
</div>
<div className="card">
<h2>Total storage</h2>
<div className="value" style={{ fontSize: "1.35rem" }}>
{formatBytes(data.totalStorage)}
</div>
</div>
<div className="card">
<h2>Free storage</h2>
<div className="value" style={{ fontSize: "1.35rem" }}>
{formatBytes(data.freeStorage)}
</div>
</div>
</div>
</section>
<section className="stack-section">
<div className="section-head">
<h2 className="section-title">Bibliotheek</h2>
</div>
<div className="grid">
<div className="card">
<h2>Films</h2>
<div className="value">{data.availableMovies ?? data.movies ?? 0}</div>
<p className="muted tiny" style={{ marginTop: "0.5rem" }}>
{data.movies ?? 0} rijen totaal
</p>
</div>
<div className="card">
<h2>Series</h2>
<div className="value">{data.availableSeries ?? data.series ?? 0}</div>
<p className="muted tiny" style={{ marginTop: "0.5rem" }}>
{data.series ?? 0} rijen totaal
</p>
</div>
<div className="card">
<h2>Bestanden</h2>
<div className="value">{data.availableFiles ?? 0}</div>
<p className="muted tiny" style={{ marginTop: "0.5rem" }}>
{data.scannedFiles ?? 0} gescand
</p>
</div>
<div className="card">
<h2>Episodes</h2>
<div className="value">{data.episodes ?? 0}</div>
</div>
</div>
</section>
<section className="stack-section">
<div className="section-head">
<h2 className="section-title">Onderhoud</h2>
<p className="muted">Alleen gebruiken bij migratie of catalogusproblemen.</p>
</div>
<div className="card tools-card">
<div className="tool-block">
<h3 className="card-subtitle">Herstel na pad-wijziging</h3>
<p className="muted">
Duplicaten + dode files + IMDb-backfill na Unraid pad-migratie.
</p>
<button type="button" onClick={repairLibrary} disabled={repairBusy}>
{repairBusy ? "Bezig…" : "Bibliotheek herstellen"}
</button>
{repairMsg && <p className="muted tiny">{repairMsg}</p>}
</div>
<div className="tool-block">
<h3 className="card-subtitle">Duplicaten</h3>
<p className="muted">Zelfde titel samenvoegen (TMDB wint).</p>
<button type="button" onClick={dedupe} disabled={dedupeBusy}>
{dedupeBusy ? "Bezig…" : "Duplicaten samenvoegen"}
</button>
{dedupeMsg && <p className="muted tiny">{dedupeMsg}</p>}
</div>
<div className="tool-block">
<h3 className="card-subtitle">IMDb-backfill</h3>
<p className="muted">
Films IMDb: {data.moviesWithImdbStreams ?? "?"} · zonder:{" "}
{data.moviesMissingImdb ?? "?"} · Series: {data.seriesWithImdb ?? "?"} / zonder{" "}
{data.seriesMissingImdb ?? "?"}
</p>
<button type="button" onClick={backfillImdb} disabled={imdbBusy}>
{imdbBusy ? "Bezig…" : "IMDb-IDs backfillen"}
</button>
{imdbMsg && <p className="muted tiny">{imdbMsg}</p>}
</div>
</div>
</section>
</>
)}
{!data && !error && <p className="muted">Laden...</p>}
</div>
</>
);
}