439 lines
14 KiB
TypeScript
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>
|
|
)}
|
|
</>
|
|
);
|
|
}
|