Allow converting a movie folder to the correct series in admin.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
2532ddf93b
commit
ce278bbb70
3 changed files with 602 additions and 138 deletions
|
|
@ -3,8 +3,21 @@
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Nav, useAuth } from "@/components/Nav";
|
import { Nav, useAuth } from "@/components/Nav";
|
||||||
|
|
||||||
|
interface MovieFolder {
|
||||||
|
folder: string;
|
||||||
|
fileCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MovieNode {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
fileCount: number;
|
||||||
|
folders?: MovieFolder[];
|
||||||
|
}
|
||||||
|
|
||||||
interface MovieFile {
|
interface MovieFile {
|
||||||
id: string;
|
id: string;
|
||||||
|
nodeId?: string;
|
||||||
node: string;
|
node: string;
|
||||||
nodeStatus: string;
|
nodeStatus: string;
|
||||||
resolution: string | null;
|
resolution: string | null;
|
||||||
|
|
@ -12,7 +25,10 @@ interface MovieFile {
|
||||||
videoCodec: string | null;
|
videoCodec: string | null;
|
||||||
audioCodec: string | null;
|
audioCodec: string | null;
|
||||||
releaseName: string;
|
releaseName: string;
|
||||||
|
path?: string | null;
|
||||||
|
folder?: string | null;
|
||||||
available: boolean;
|
available: boolean;
|
||||||
|
looksLikeSeries?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Movie {
|
interface Movie {
|
||||||
|
|
@ -26,9 +42,16 @@ interface Movie {
|
||||||
fileCount: number;
|
fileCount: number;
|
||||||
nodeCount: number;
|
nodeCount: number;
|
||||||
qualities: string[];
|
qualities: string[];
|
||||||
|
nodes?: MovieNode[];
|
||||||
files: MovieFile[];
|
files: MovieFile[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface NodeOption {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
status?: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface MetaHit {
|
interface MetaHit {
|
||||||
tmdbId: number;
|
tmdbId: number;
|
||||||
title: string;
|
title: string;
|
||||||
|
|
@ -54,25 +77,55 @@ function formatBytes(bytes: string | number | null | undefined): string {
|
||||||
export default function MoviesPage() {
|
export default function MoviesPage() {
|
||||||
useAuth();
|
useAuth();
|
||||||
const [movies, setMovies] = useState<Movie[]>([]);
|
const [movies, setMovies] = useState<Movie[]>([]);
|
||||||
|
const [nodes, setNodes] = useState<NodeOption[]>([]);
|
||||||
|
const [nodeId, setNodeId] = useState("");
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
|
const [pathSearch, setPathSearch] = useState("");
|
||||||
const [expanded, setExpanded] = useState<string | null>(null);
|
const [expanded, setExpanded] = useState<string | null>(null);
|
||||||
|
const [msg, setMsg] = useState("");
|
||||||
|
const [busyFileId, setBusyFileId] = useState<string | null>(null);
|
||||||
const [linkMovieId, setLinkMovieId] = useState<string | null>(null);
|
const [linkMovieId, setLinkMovieId] = useState<string | null>(null);
|
||||||
const [linkQuery, setLinkQuery] = useState("");
|
const [linkQuery, setLinkQuery] = useState("");
|
||||||
const [linkHits, setLinkHits] = useState<MetaHit[]>([]);
|
const [linkHits, setLinkHits] = useState<MetaHit[]>([]);
|
||||||
const [linkBusy, setLinkBusy] = useState(false);
|
const [linkBusy, setLinkBusy] = useState(false);
|
||||||
const [linkMsg, setLinkMsg] = useState("");
|
const [linkMsg, setLinkMsg] = useState("");
|
||||||
|
const [folderRelink, setFolderRelink] = useState<{
|
||||||
|
nodeId: string;
|
||||||
|
nodeName: string;
|
||||||
|
folder: string;
|
||||||
|
fileCount: number;
|
||||||
|
} | null>(null);
|
||||||
|
const [folderQuery, setFolderQuery] = useState("");
|
||||||
|
const [folderHits, setFolderHits] = useState<MetaHit[]>([]);
|
||||||
|
const [folderBusy, setFolderBusy] = useState(false);
|
||||||
|
const [folderMsg, setFolderMsg] = useState("");
|
||||||
|
|
||||||
function load(q = search) {
|
function load(q = search, filterNodeId = nodeId, pathQ = pathSearch) {
|
||||||
const qs = q ? `?search=${encodeURIComponent(q)}` : "";
|
const params = new URLSearchParams();
|
||||||
|
if (q.trim()) params.set("search", q.trim());
|
||||||
|
if (filterNodeId) params.set("nodeId", filterNodeId);
|
||||||
|
if (pathQ.trim()) params.set("path", pathQ.trim());
|
||||||
|
const qs = params.toString() ? `?${params}` : "";
|
||||||
fetch(`/api/v1/admin/movies${qs}`, { credentials: "include" })
|
fetch(`/api/v1/admin/movies${qs}`, { credentials: "include" })
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((d) => setMovies(d.movies ?? []));
|
.then((d) => setMovies(d.movies ?? []));
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
fetch("/api/v1/admin/nodes", { credentials: "include" })
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((d) => {
|
||||||
|
const list = (d.nodes ?? []) as NodeOption[];
|
||||||
|
setNodes(list.filter((n) => n.id).sort((a, b) => a.name.localeCompare(b.name)));
|
||||||
|
});
|
||||||
load();
|
load();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load(search, nodeId, pathSearch);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [nodeId]);
|
||||||
|
|
||||||
async function ignoreFile(fileId: string) {
|
async function ignoreFile(fileId: string) {
|
||||||
if (!confirm("Dit bestand negeren?")) return;
|
if (!confirm("Dit bestand negeren?")) return;
|
||||||
await fetch(`/api/v1/admin/media-files/${fileId}/match`, {
|
await fetch(`/api/v1/admin/media-files/${fileId}/match`, {
|
||||||
|
|
@ -84,12 +137,27 @@ export default function MoviesPage() {
|
||||||
load();
|
load();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshMeta(fileId: string) {
|
async function rematchFile(fileId: string) {
|
||||||
await fetch(`/api/v1/admin/media-files/${fileId}/refresh-metadata`, {
|
setBusyFileId(fileId);
|
||||||
method: "POST",
|
setMsg("");
|
||||||
credentials: "include",
|
try {
|
||||||
});
|
const res = await fetch(`/api/v1/admin/media-files/${fileId}/rematch`, {
|
||||||
load();
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
setMsg(data.error?.message ?? "Rematch mislukt");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const label = data.series
|
||||||
|
? `serie ${data.series.title} S${String(data.series.seasonNumber).padStart(2, "0")}E${String(data.series.episodeNumber).padStart(2, "0")}`
|
||||||
|
: data.movie?.title ?? "onbekend";
|
||||||
|
setMsg(`Opnieuw gekoppeld → ${label}`);
|
||||||
|
load();
|
||||||
|
} finally {
|
||||||
|
setBusyFileId(null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function openLink(m: Movie) {
|
function openLink(m: Movie) {
|
||||||
|
|
@ -100,6 +168,20 @@ export default function MoviesPage() {
|
||||||
void searchMeta(m.year ? `${m.title} ${m.year}` : m.title);
|
void searchMeta(m.year ? `${m.title} ${m.year}` : m.title);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openFolderAsSeries(opts: {
|
||||||
|
nodeId: string;
|
||||||
|
nodeName: string;
|
||||||
|
folder: string;
|
||||||
|
fileCount: number;
|
||||||
|
}) {
|
||||||
|
const folderName = opts.folder.split("/").filter(Boolean).pop() || opts.folder;
|
||||||
|
setFolderRelink(opts);
|
||||||
|
setFolderQuery(folderName);
|
||||||
|
setFolderHits([]);
|
||||||
|
setFolderMsg("");
|
||||||
|
void searchSeriesMeta(folderName);
|
||||||
|
}
|
||||||
|
|
||||||
async function searchMeta(q = linkQuery) {
|
async function searchMeta(q = linkQuery) {
|
||||||
const query = q.trim();
|
const query = q.trim();
|
||||||
if (!query) return;
|
if (!query) return;
|
||||||
|
|
@ -123,6 +205,29 @@ export default function MoviesPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function searchSeriesMeta(q = folderQuery) {
|
||||||
|
const query = q.trim();
|
||||||
|
if (!query) return;
|
||||||
|
setFolderBusy(true);
|
||||||
|
setFolderMsg("");
|
||||||
|
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) {
|
||||||
|
setFolderMsg(data.error?.message ?? "Zoeken mislukt");
|
||||||
|
setFolderHits([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setFolderHits(data.results ?? []);
|
||||||
|
if (!(data.results ?? []).length) setFolderMsg("Geen resultaten");
|
||||||
|
} finally {
|
||||||
|
setFolderBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function applyLink(hit: MetaHit) {
|
async function applyLink(hit: MetaHit) {
|
||||||
if (!linkMovieId) return;
|
if (!linkMovieId) return;
|
||||||
setLinkBusy(true);
|
setLinkBusy(true);
|
||||||
|
|
@ -184,6 +289,87 @@ export default function MoviesPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function applyFolderAsSeries(hit: MetaHit) {
|
||||||
|
if (!folderRelink) return;
|
||||||
|
if (
|
||||||
|
!confirm(
|
||||||
|
`Alle ${folderRelink.fileCount} bestanden in\n${folderRelink.folder}\nop ${folderRelink.nodeName}\nals SERIE herkoppelen naar “${hit.title}${hit.year ? ` (${hit.year})` : ""}”?\n\nZe verdwijnen dan uit Films.`
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setFolderBusy(true);
|
||||||
|
setFolderMsg("Map herkoppelen als serie…");
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/v1/admin/library/relink-folder`, {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
nodeId: folderRelink.nodeId,
|
||||||
|
folder: folderRelink.folder,
|
||||||
|
tmdbId: hit.tmdbId,
|
||||||
|
...(hit.imdbId ? { imdbId: hit.imdbId } : {}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
setFolderMsg(data.error?.message ?? "Herkoppelen mislukt");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setMsg(
|
||||||
|
`Map → serie “${data.seriesTitle}”: ${data.moved}/${data.total} bestanden` +
|
||||||
|
(data.failed ? ` (${data.failed} mislukt — check SxxExx / Season-map)` : "")
|
||||||
|
);
|
||||||
|
setFolderRelink(null);
|
||||||
|
load();
|
||||||
|
} finally {
|
||||||
|
setFolderBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyFolderImdbAsSeries() {
|
||||||
|
const imdb = folderQuery.trim();
|
||||||
|
if (!/^tt\d+$/i.test(imdb) || !folderRelink) {
|
||||||
|
setFolderMsg("Vul een IMDb-id van de serie in (tt…) of zoek op titel.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!confirm(
|
||||||
|
`Alle ${folderRelink.fileCount} bestanden in\n${folderRelink.folder}\nals SERIE herkoppelen via ${imdb}?`
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setFolderBusy(true);
|
||||||
|
setFolderMsg("Map herkoppelen 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: folderRelink.nodeId,
|
||||||
|
folder: folderRelink.folder,
|
||||||
|
imdbId: imdb,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
setFolderMsg(data.error?.message ?? "Herkoppelen mislukt");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setMsg(
|
||||||
|
`Map → serie “${data.seriesTitle}”: ${data.moved}/${data.total} bestanden` +
|
||||||
|
(data.failed ? ` (${data.failed} mislukt)` : "")
|
||||||
|
);
|
||||||
|
setFolderRelink(null);
|
||||||
|
load();
|
||||||
|
} finally {
|
||||||
|
setFolderBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const activeMovie = movies.find((m) => m.id === linkMovieId);
|
const activeMovie = movies.find((m) => m.id === linkMovieId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -200,106 +386,221 @@ export default function MoviesPage() {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
load();
|
load();
|
||||||
}}
|
}}
|
||||||
style={{ display: "flex", gap: "0.5rem" }}
|
style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap", alignItems: "center" }}
|
||||||
>
|
>
|
||||||
|
<select
|
||||||
|
value={nodeId}
|
||||||
|
onChange={(e) => setNodeId(e.target.value)}
|
||||||
|
style={{ marginBottom: 0, minWidth: 160 }}
|
||||||
|
aria-label="Filter op node"
|
||||||
|
>
|
||||||
|
<option value="">Alle nodes</option>
|
||||||
|
{nodes.map((n) => (
|
||||||
|
<option key={n.id} value={n.id}>
|
||||||
|
{n.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
<input
|
<input
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
placeholder="Zoeken..."
|
placeholder="Zoek titel..."
|
||||||
style={{ width: 240, marginBottom: 0 }}
|
style={{ width: 180, marginBottom: 0 }}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={pathSearch}
|
||||||
|
onChange={(e) => setPathSearch(e.target.value)}
|
||||||
|
placeholder="Zoek map/pad…"
|
||||||
|
style={{ width: 220, marginBottom: 0 }}
|
||||||
/>
|
/>
|
||||||
<button type="submit">Zoek</button>
|
<button type="submit">Zoek</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{movies.map((m) => (
|
<p className="muted page-lead">
|
||||||
<div key={m.id} className="card">
|
Serie per ongeluk als film? Open de titel → bij de map{" "}
|
||||||
<div
|
<strong style={{ color: "#a1a1aa", fontWeight: 600 }}>Als serie herkoppelen</strong>. Vereist
|
||||||
style={{ cursor: "pointer", display: "flex", justifyContent: "space-between", gap: "1rem" }}
|
node 1.3.7+ met paden (anders eerst upgraden + rescan).
|
||||||
onClick={() => setExpanded(expanded === m.id ? null : m.id)}
|
</p>
|
||||||
>
|
|
||||||
<div style={{ display: "flex", gap: "0.75rem", alignItems: "flex-start" }}>
|
{msg && (
|
||||||
{m.posterUrl && (
|
<p className="muted" style={{ marginBottom: "1rem" }}>
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
{msg}
|
||||||
<img
|
</p>
|
||||||
src={m.posterUrl}
|
)}
|
||||||
alt=""
|
|
||||||
width={40}
|
{movies.map((m) => {
|
||||||
height={60}
|
const looksSeries = m.files.some((f) => f.looksLikeSeries);
|
||||||
style={{ objectFit: "cover", borderRadius: 4, flexShrink: 0 }}
|
return (
|
||||||
/>
|
<div key={m.id} className="card">
|
||||||
)}
|
<div
|
||||||
<div>
|
style={{ cursor: "pointer", display: "flex", justifyContent: "space-between", gap: "1rem" }}
|
||||||
<strong>{m.title}</strong>
|
onClick={() => setExpanded(expanded === m.id ? null : m.id)}
|
||||||
{m.year ? ` (${m.year})` : ""}
|
>
|
||||||
<div style={{ color: "#71717a", fontSize: "0.875rem", marginTop: 4 }}>
|
<div style={{ display: "flex", gap: "0.75rem", alignItems: "flex-start" }}>
|
||||||
{m.qualities.join(", ") || "geen kwaliteit"} · {m.nodeCount} node(s) · {m.fileCount}{" "}
|
{m.posterUrl && (
|
||||||
bestand(en)
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
{m.imdbId ? ` · ${m.imdbId}` : " · geen IMDb"}
|
<img
|
||||||
{m.tmdbId ? ` · TMDB ${m.tmdbId}` : ""}
|
src={m.posterUrl}
|
||||||
|
alt=""
|
||||||
|
width={40}
|
||||||
|
height={60}
|
||||||
|
style={{ objectFit: "cover", borderRadius: 4, flexShrink: 0 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<strong>{m.title}</strong>
|
||||||
|
{m.year ? ` (${m.year})` : ""}
|
||||||
|
{looksSeries && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
marginLeft: 8,
|
||||||
|
fontSize: "0.7rem",
|
||||||
|
color: "#fbbf24",
|
||||||
|
border: "1px solid #854d0e",
|
||||||
|
borderRadius: 999,
|
||||||
|
padding: "1px 8px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
lijkt op serie
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<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>
|
||||||
|
{(m.nodes?.length ?? 0) > 0 && (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 8, marginTop: 10 }}>
|
||||||
|
{m.nodes!.map((n) => (
|
||||||
|
<div key={n.id}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
padding: "2px 8px",
|
||||||
|
borderRadius: 999,
|
||||||
|
border: "1px solid #2a2d3a",
|
||||||
|
background: "#12141c",
|
||||||
|
color: "#a1a1aa",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{n.name} · {n.fileCount} files
|
||||||
|
</span>
|
||||||
|
{(n.folders?.length ?? 0) > 0 ? (
|
||||||
|
<div className="muted mono tiny" style={{ marginTop: 4, paddingLeft: 4 }}>
|
||||||
|
{n.folders!.map((f) => (
|
||||||
|
<div
|
||||||
|
key={f.folder}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
gap: 8,
|
||||||
|
flexWrap: "wrap",
|
||||||
|
alignItems: "center",
|
||||||
|
marginBottom: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
{f.folder}{" "}
|
||||||
|
<span style={{ color: "#52525b" }}>({f.fileCount})</span>
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
style={{ fontSize: "0.7rem", padding: "2px 8px" }}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
openFolderAsSeries({
|
||||||
|
nodeId: n.id,
|
||||||
|
nodeName: n.name,
|
||||||
|
folder: f.folder,
|
||||||
|
fileCount: f.fileCount,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Als serie herkoppelen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="muted tiny" style={{ marginTop: 4, paddingLeft: 4 }}>
|
||||||
|
Map onbekend — node 1.3.7+ + rescan
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<span style={{ color: "#71717a" }}>{expanded === m.id ? "▲" : "▼"}</span>
|
||||||
</div>
|
</div>
|
||||||
<span style={{ color: "#71717a" }}>{expanded === m.id ? "▲" : "▼"}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{expanded === m.id && (
|
{expanded === m.id && (
|
||||||
<div style={{ marginTop: "1rem" }}>
|
<div style={{ marginTop: "1rem" }}>
|
||||||
{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" }}>
|
<div style={{ marginBottom: "1rem" }}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
openLink(m);
|
openLink(m);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Juiste content zoeken
|
Juiste film zoeken
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Node</th>
|
<th>Node</th>
|
||||||
<th>Release</th>
|
<th>Pad / release</th>
|
||||||
<th>Kwaliteit</th>
|
<th>Kwaliteit</th>
|
||||||
<th>Grootte</th>
|
<th>Grootte</th>
|
||||||
<th>Codec</th>
|
<th>Status</th>
|
||||||
<th>Status</th>
|
<th>Acties</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>
|
</tr>
|
||||||
))}
|
</thead>
|
||||||
</tbody>
|
<tbody>
|
||||||
</table>
|
{m.files.map((f) => (
|
||||||
</div>
|
<tr key={f.id}>
|
||||||
)}
|
<td>{f.node}</td>
|
||||||
</div>
|
<td style={{ maxWidth: 360, overflow: "hidden", textOverflow: "ellipsis" }}>
|
||||||
))}
|
<div className="mono tiny">{f.path || f.releaseName}</div>
|
||||||
|
{f.looksLikeSeries && (
|
||||||
|
<div style={{ color: "#fbbf24", fontSize: "0.7rem" }}>pad lijkt op serie</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>{f.resolution ?? "-"}</td>
|
||||||
|
<td>{formatBytes(f.sizeBytes)}</td>
|
||||||
|
<td>
|
||||||
|
<span className={`status ${f.available ? "online" : "offline"}`}>
|
||||||
|
{f.available ? "AVAILABLE" : "UNAVAILABLE"}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busyFileId === f.id}
|
||||||
|
onClick={() => void rematchFile(f.id)}
|
||||||
|
>
|
||||||
|
{busyFileId === f.id ? "…" : "Rematch"}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="danger" onClick={() => void ignoreFile(f.id)}>
|
||||||
|
Ignore
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
{movies.length === 0 && (
|
{movies.length === 0 && (
|
||||||
<div className="card" style={{ textAlign: "center", color: "#71717a" }}>
|
<div className="card" style={{ textAlign: "center", color: "#71717a" }}>
|
||||||
|
|
@ -407,21 +708,6 @@ export default function MoviesPage() {
|
||||||
TMDB {hit.tmdbId}
|
TMDB {hit.tmdbId}
|
||||||
{hit.imdbId ? ` · ${hit.imdbId}` : ""}
|
{hit.imdbId ? ` · ${hit.imdbId}` : ""}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
|
@ -434,6 +720,126 @@ export default function MoviesPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{folderRelink && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
inset: 0,
|
||||||
|
background: "rgba(0,0,0,0.65)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
zIndex: 50,
|
||||||
|
padding: "1rem",
|
||||||
|
}}
|
||||||
|
onClick={() => !folderBusy && setFolderRelink(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" }}>
|
||||||
|
Map als serie herkoppelen
|
||||||
|
</h2>
|
||||||
|
<p style={{ color: "#a1a1aa", fontSize: "0.85rem", marginBottom: "1rem" }}>
|
||||||
|
Node: <strong style={{ color: "#e4e4e7" }}>{folderRelink.nodeName}</strong>
|
||||||
|
<br />
|
||||||
|
Map: <span className="mono">{folderRelink.folder}</span>
|
||||||
|
<br />
|
||||||
|
{folderRelink.fileCount} bestanden verhuizen van Films → Series. S/E uit pad (Season 01 /
|
||||||
|
SxxExx).
|
||||||
|
</p>
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
void searchSeriesMeta();
|
||||||
|
}}
|
||||||
|
style={{ display: "flex", gap: "0.5rem", marginBottom: "0.75rem", flexWrap: "wrap" }}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
value={folderQuery}
|
||||||
|
onChange={(e) => setFolderQuery(e.target.value)}
|
||||||
|
placeholder="Serietitel of IMDb-id (tt…)"
|
||||||
|
style={{ flex: 1, minWidth: 200, marginBottom: 0 }}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<button type="submit" disabled={folderBusy}>
|
||||||
|
Zoek serie
|
||||||
|
</button>
|
||||||
|
<button type="button" disabled={folderBusy} onClick={() => void applyFolderImdbAsSeries()}>
|
||||||
|
Koppel IMDb-id
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{folderMsg && (
|
||||||
|
<p style={{ color: "#a1a1aa", fontSize: "0.85rem", marginBottom: "0.75rem" }}>{folderMsg}</p>
|
||||||
|
)}
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
|
||||||
|
{folderHits.map((hit) => (
|
||||||
|
<button
|
||||||
|
key={hit.tmdbId}
|
||||||
|
type="button"
|
||||||
|
disabled={folderBusy}
|
||||||
|
onClick={() => void applyFolderAsSeries(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>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: "1rem", display: "flex", justifyContent: "flex-end" }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="danger"
|
||||||
|
disabled={folderBusy}
|
||||||
|
onClick={() => setFolderRelink(null)}
|
||||||
|
>
|
||||||
|
Annuleren
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import {
|
||||||
parseMovieFromPath,
|
parseMovieFromPath,
|
||||||
parseSeriesFromPath,
|
parseSeriesFromPath,
|
||||||
seriesFolderFromPath,
|
seriesFolderFromPath,
|
||||||
|
libraryFolderFromPath,
|
||||||
} from "../metadata/filename-parser";
|
} from "../metadata/filename-parser";
|
||||||
|
|
||||||
export async function registerAdminRoutes(app: FastifyInstance, config: Config) {
|
export async function registerAdminRoutes(app: FastifyInstance, config: Config) {
|
||||||
|
|
@ -135,11 +136,27 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get("/api/v1/admin/movies", { preHandler: requireAdmin }, async (request) => {
|
app.get("/api/v1/admin/movies", { preHandler: requireAdmin }, async (request) => {
|
||||||
const query = request.query as { search?: string };
|
const query = request.query as { search?: string; nodeId?: string; path?: string };
|
||||||
|
const nodeId = query.nodeId?.trim() || undefined;
|
||||||
|
const pathQ = query.path?.trim() || undefined;
|
||||||
|
const searchQ = query.search?.trim() || undefined;
|
||||||
|
|
||||||
const movies = await prisma.movie.findMany({
|
const movies = await prisma.movie.findMany({
|
||||||
where: query.search
|
where: {
|
||||||
? { title: { contains: query.search, mode: "insensitive" } }
|
AND: [
|
||||||
: undefined,
|
searchQ ? { title: { contains: searchQ, mode: "insensitive" } } : {},
|
||||||
|
nodeId || pathQ
|
||||||
|
? {
|
||||||
|
mediaFiles: {
|
||||||
|
some: {
|
||||||
|
...(nodeId ? { nodeId } : {}),
|
||||||
|
...(pathQ ? { path: { contains: pathQ, mode: "insensitive" } } : {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {},
|
||||||
|
],
|
||||||
|
},
|
||||||
orderBy: { title: "asc" },
|
orderBy: { title: "asc" },
|
||||||
include: {
|
include: {
|
||||||
mediaFiles: {
|
mediaFiles: {
|
||||||
|
|
@ -150,31 +167,67 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
movies: movies.map((m) => ({
|
movies: movies.map((m) => {
|
||||||
id: m.id,
|
const files = m.mediaFiles.filter((f) => {
|
||||||
title: m.title,
|
if (nodeId && f.nodeId !== nodeId) return false;
|
||||||
year: m.year,
|
if (pathQ && !(f.path ?? "").toLowerCase().includes(pathQ.toLowerCase())) return false;
|
||||||
imdbId: m.imdbId,
|
return true;
|
||||||
tmdbId: m.tmdbId,
|
});
|
||||||
overview: m.overview,
|
|
||||||
posterUrl: m.posterUrl,
|
const nodeMap = new Map<
|
||||||
fileCount: m.mediaFiles.length,
|
string,
|
||||||
nodeCount: new Set(m.mediaFiles.map((f) => f.nodeId)).size,
|
{ id: string; name: string; fileCount: number; folders: Map<string, number> }
|
||||||
qualities: [...new Set(m.mediaFiles.map((f) => f.resolution).filter(Boolean))],
|
>();
|
||||||
files: m.mediaFiles.map((f) => ({
|
for (const f of m.mediaFiles) {
|
||||||
id: f.id,
|
let cur = nodeMap.get(f.nodeId);
|
||||||
nodeId: f.nodeId,
|
if (!cur) {
|
||||||
node: f.node.name,
|
cur = { id: f.nodeId, name: f.node.name, fileCount: 0, folders: new Map() };
|
||||||
nodeStatus: f.node.status,
|
nodeMap.set(f.nodeId, cur);
|
||||||
resolution: f.resolution,
|
}
|
||||||
sizeBytes: f.sizeBytes.toString(),
|
cur.fileCount += 1;
|
||||||
videoCodec: f.videoCodec,
|
const folder = f.path ? libraryFolderFromPath(f.path) : null;
|
||||||
audioCodec: f.audioCodec,
|
if (folder) cur.folders.set(folder, (cur.folders.get(folder) ?? 0) + 1);
|
||||||
hdrType: f.hdrType,
|
}
|
||||||
releaseName: f.releaseName,
|
|
||||||
available: f.available,
|
return {
|
||||||
})),
|
id: m.id,
|
||||||
})),
|
title: m.title,
|
||||||
|
year: m.year,
|
||||||
|
imdbId: m.imdbId,
|
||||||
|
tmdbId: m.tmdbId,
|
||||||
|
overview: m.overview,
|
||||||
|
posterUrl: m.posterUrl,
|
||||||
|
fileCount: files.length,
|
||||||
|
nodeCount: nodeMap.size,
|
||||||
|
qualities: [...new Set(files.map((f) => f.resolution).filter(Boolean))],
|
||||||
|
nodes: [...nodeMap.values()]
|
||||||
|
.map((n) => ({
|
||||||
|
id: n.id,
|
||||||
|
name: n.name,
|
||||||
|
fileCount: n.fileCount,
|
||||||
|
folders: [...n.folders.entries()]
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.map(([folder, fileCount]) => ({ folder, fileCount })),
|
||||||
|
}))
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name)),
|
||||||
|
files: files.map((f) => ({
|
||||||
|
id: f.id,
|
||||||
|
nodeId: f.nodeId,
|
||||||
|
node: f.node.name,
|
||||||
|
nodeStatus: f.node.status,
|
||||||
|
resolution: f.resolution,
|
||||||
|
sizeBytes: f.sizeBytes.toString(),
|
||||||
|
videoCodec: f.videoCodec,
|
||||||
|
audioCodec: f.audioCodec,
|
||||||
|
hdrType: f.hdrType,
|
||||||
|
releaseName: f.releaseName,
|
||||||
|
path: f.path,
|
||||||
|
folder: f.path ? libraryFolderFromPath(f.path) : null,
|
||||||
|
available: f.available,
|
||||||
|
looksLikeSeries: f.path ? Boolean(parseSeriesFromPath(f.path)) : false,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -162,6 +162,11 @@ export function seriesFolderFromPath(filePath: string): string | null {
|
||||||
return dir === "." ? null : dir;
|
return dir === "." ? null : dir;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Best-effort library folder for admin grouping (series-aware). */
|
||||||
|
export function libraryFolderFromPath(filePath: string): string | null {
|
||||||
|
return seriesFolderFromPath(filePath);
|
||||||
|
}
|
||||||
|
|
||||||
export function detectMediaType(filename: string): "movie" | "episode" | null {
|
export function detectMediaType(filename: string): "movie" | "episode" | null {
|
||||||
if (!isVideoFile(filename)) return null;
|
if (!isVideoFile(filename)) return null;
|
||||||
if (parseSeriesFilename(filename) || parseSeriesFromPath(filename)) return "episode";
|
if (parseSeriesFilename(filename) || parseSeriesFromPath(filename)) return "episode";
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue