stremio/apps/admin-ui/src/app/movies/page.tsx
Jos Vooges | STH 0f7b566626 Add Plex-like live streams with viewer labels and progress, plus admin UI redesign.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-29 01:24:55 +02:00

439 lines
14 KiB
TypeScript

"use client";
import { useEffect, useState } from "react";
import { Nav, useAuth } from "@/components/Nav";
interface MovieFile {
id: string;
node: string;
nodeStatus: string;
resolution: string | null;
sizeBytes: string;
videoCodec: string | null;
audioCodec: string | null;
releaseName: string;
available: boolean;
}
interface Movie {
id: string;
title: string;
year: number | null;
imdbId: string | null;
tmdbId: number | null;
overview: string | null;
posterUrl: string | null;
fileCount: number;
nodeCount: number;
qualities: string[];
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 {
const valNum = Number(bytes ?? 0);
if (!Number.isFinite(valNum) || valNum < 0) return "0 B";
let val = valNum;
const units = ["B", "KB", "MB", "GB", "TB"];
let i = 0;
while (val >= 1024 && i < units.length - 1) {
val /= 1024;
i++;
}
return `${val.toFixed(1)} ${units[i]}`;
}
export default function MoviesPage() {
useAuth();
const [movies, setMovies] = useState<Movie[]>([]);
const [search, setSearch] = useState("");
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) {
const qs = q ? `?search=${encodeURIComponent(q)}` : "";
fetch(`/api/v1/admin/movies${qs}`, { credentials: "include" })
.then((r) => r.json())
.then((d) => setMovies(d.movies ?? []));
}
useEffect(() => {
load();
}, []);
async function ignoreFile(fileId: string) {
if (!confirm("Dit bestand negeren?")) return;
await fetch(`/api/v1/admin/media-files/${fileId}/match`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ignore: true }),
});
load();
}
async function refreshMeta(fileId: string) {
await fetch(`/api/v1/admin/media-files/${fileId}/refresh-metadata`, {
method: "POST",
credentials: "include",
});
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 (
<>
<Nav />
<div className="container">
<div className="page-header">
<div>
<div className="page-kicker">Library</div>
<h1>Movies</h1>
</div>
<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>
{movies.map((m) => (
<div key={m.id} className="card">
<div
style={{ cursor: "pointer", display: "flex", justifyContent: "space-between", gap: "1rem" }}
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>
<strong>{m.title}</strong>
{m.year ? ` (${m.year})` : ""}
<div style={{ color: "#71717a", fontSize: "0.875rem", marginTop: 4 }}>
{m.qualities.join(", ") || "geen kwaliteit"} · {m.nodeCount} node(s) · {m.fileCount}{" "}
bestand(en)
{m.imdbId ? ` · ${m.imdbId}` : " · geen IMDb"}
{m.tmdbId ? ` · TMDB ${m.tmdbId}` : ""}
</div>
</div>
</div>
<span style={{ color: "#71717a" }}>{expanded === m.id ? "▲" : "▼"}</span>
</div>
{expanded === m.id && (
<div style={{ marginTop: "1rem" }}>
{m.overview && (
<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>
<thead>
<tr>
<th>Node</th>
<th>Release</th>
<th>Kwaliteit</th>
<th>Grootte</th>
<th>Codec</th>
<th>Status</th>
<th>Acties</th>
</tr>
</thead>
<tbody>
{m.files.map((f) => (
<tr key={f.id}>
<td>{f.node}</td>
<td style={{ maxWidth: 280, overflow: "hidden", textOverflow: "ellipsis" }}>
{f.releaseName}
</td>
<td>{f.resolution ?? "-"}</td>
<td>{formatBytes(f.sizeBytes)}</td>
<td>{[f.videoCodec, f.audioCodec].filter(Boolean).join(" / ") || "-"}</td>
<td>
<span className={`status ${f.available ? "online" : "offline"}`}>
{f.available ? "AVAILABLE" : "UNAVAILABLE"}
</span>
</td>
<td style={{ display: "flex", gap: 8 }}>
<button onClick={() => refreshMeta(f.id)}>Refresh</button>
<button className="danger" onClick={() => ignoreFile(f.id)}>
Ignore
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
))}
{movies.length === 0 && (
<div className="card" style={{ textAlign: "center", color: "#71717a" }}>
Geen movies gevonden
</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>
)}
</>
);
}