Show series folders per node and allow path search/rematch.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
705a01f034
commit
a6b0ec15f2
13 changed files with 495 additions and 73 deletions
|
|
@ -65,7 +65,7 @@ export default function NodesPage() {
|
|||
const [editRoots, setEditRoots] = useState<ScanRootRow[]>([]);
|
||||
const [editBusy, setEditBusy] = useState(false);
|
||||
const [upgradingId, setUpgradingId] = useState<string | null>(null);
|
||||
const [targetNodeVersion, setTargetNodeVersion] = useState("1.3.6");
|
||||
const [targetNodeVersion, setTargetNodeVersion] = useState("1.3.7");
|
||||
|
||||
function loadNodes() {
|
||||
fetch("/api/v1/admin/nodes", { credentials: "include" })
|
||||
|
|
|
|||
|
|
@ -3,10 +3,26 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { Nav, useAuth } from "@/components/Nav";
|
||||
|
||||
interface SeriesFolder {
|
||||
folder: string;
|
||||
fileCount: number;
|
||||
}
|
||||
|
||||
interface SeriesNode {
|
||||
id: string;
|
||||
name: string;
|
||||
fileCount: number;
|
||||
folders?: SeriesFolder[];
|
||||
}
|
||||
|
||||
interface SeriesFile {
|
||||
id: string;
|
||||
node: string;
|
||||
resolution: string | null;
|
||||
available: boolean;
|
||||
releaseName?: string;
|
||||
path?: string | null;
|
||||
folder?: string | null;
|
||||
}
|
||||
|
||||
interface Series {
|
||||
|
|
@ -25,7 +41,7 @@ interface Series {
|
|||
seasonNumber: number;
|
||||
episodeNumber: number;
|
||||
title: string | null;
|
||||
files: Array<{ node: string; resolution: string | null; available: boolean }>;
|
||||
files: SeriesFile[];
|
||||
}>;
|
||||
}>;
|
||||
}
|
||||
|
|
@ -36,6 +52,24 @@ interface NodeOption {
|
|||
status?: string;
|
||||
}
|
||||
|
||||
interface PathFile {
|
||||
id: string;
|
||||
node: string;
|
||||
path: string | null;
|
||||
folder: string | null;
|
||||
releaseName: string;
|
||||
mediaType: string;
|
||||
movie: { id: string; title: string; year: number | null } | null;
|
||||
series: {
|
||||
id: string;
|
||||
title: string;
|
||||
year: number | null;
|
||||
seasonNumber: number;
|
||||
episodeNumber: number;
|
||||
} | null;
|
||||
parsedHint: { title?: string; season?: number; episode?: number; year?: number } | null;
|
||||
}
|
||||
|
||||
interface MetaHit {
|
||||
tmdbId: number;
|
||||
title: string;
|
||||
|
|
@ -48,26 +82,50 @@ interface MetaHit {
|
|||
export default function SeriesPage() {
|
||||
useAuth();
|
||||
const [series, setSeries] = useState<Series[]>([]);
|
||||
const [pathFiles, setPathFiles] = useState<PathFile[] | null>(null);
|
||||
const [nodes, setNodes] = useState<NodeOption[]>([]);
|
||||
const [nodeId, setNodeId] = useState("");
|
||||
const [search, setSearch] = useState("");
|
||||
const [pathSearch, setPathSearch] = useState("");
|
||||
const [expanded, setExpanded] = useState<string | null>(null);
|
||||
const [busyFileId, setBusyFileId] = useState<string | null>(null);
|
||||
const [msg, setMsg] = useState("");
|
||||
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("");
|
||||
|
||||
function load(q = search, filterNodeId = nodeId) {
|
||||
function loadSeries(q = search, filterNodeId = nodeId, pathQ = pathSearch) {
|
||||
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/series${qs}`, { credentials: "include" })
|
||||
.then((r) => r.json())
|
||||
.then((d) => setSeries(d.series ?? []));
|
||||
}
|
||||
|
||||
function loadPathFiles(pathQ = pathSearch, filterNodeId = nodeId) {
|
||||
if (!pathQ.trim() && !filterNodeId) {
|
||||
setPathFiles(null);
|
||||
return;
|
||||
}
|
||||
const params = new URLSearchParams();
|
||||
if (pathQ.trim()) params.set("path", pathQ.trim());
|
||||
if (filterNodeId) params.set("nodeId", filterNodeId);
|
||||
fetch(`/api/v1/admin/media-files?${params}`, { credentials: "include" })
|
||||
.then((r) => r.json())
|
||||
.then((d) => setPathFiles(d.files ?? []));
|
||||
}
|
||||
|
||||
function load(q = search, filterNodeId = nodeId, pathQ = pathSearch) {
|
||||
loadSeries(q, filterNodeId, pathQ);
|
||||
if (pathQ.trim()) loadPathFiles(pathQ, filterNodeId);
|
||||
else setPathFiles(null);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/v1/admin/nodes", { credentials: "include" })
|
||||
.then((r) => r.json())
|
||||
|
|
@ -79,10 +137,33 @@ export default function SeriesPage() {
|
|||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load(search, nodeId);
|
||||
load(search, nodeId, pathSearch);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [nodeId]);
|
||||
|
||||
async function rematchFile(fileId: string) {
|
||||
setBusyFileId(fileId);
|
||||
setMsg("");
|
||||
try {
|
||||
const res = await fetch(`/api/v1/admin/media-files/${fileId}/rematch`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
setMsg(data.error?.message ?? "Rematch mislukt");
|
||||
return;
|
||||
}
|
||||
const label = data.series
|
||||
? `${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(s: Series) {
|
||||
setLinkSeriesId(s.id);
|
||||
setLinkQuery(s.title);
|
||||
|
|
@ -211,19 +292,87 @@ export default function SeriesPage() {
|
|||
<input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Zoeken..."
|
||||
style={{ width: 240, marginBottom: 0 }}
|
||||
placeholder="Zoek titel..."
|
||||
style={{ width: 180, marginBottom: 0 }}
|
||||
/>
|
||||
<input
|
||||
value={pathSearch}
|
||||
onChange={(e) => setPathSearch(e.target.value)}
|
||||
placeholder="Zoek map/pad… (bv. Breaking Bad)"
|
||||
style={{ width: 260, marginBottom: 0 }}
|
||||
/>
|
||||
<button type="submit">Zoek</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<p className="muted page-lead">
|
||||
Per node zie je de show-map. Padzoeken toont ook losse bestanden om foutmatches
|
||||
opnieuw te koppelen.{" "}
|
||||
{nodeId
|
||||
? `Alleen series met bestanden op ${selectedNodeName ?? "deze node"} (${series.length}).`
|
||||
: `${series.length} series over alle nodes. Kies een node om per locatie te bekijken.`}
|
||||
? `Filter: ${selectedNodeName ?? "node"} · ${series.length} series.`
|
||||
: `${series.length} series.`}
|
||||
{pathSearch.trim() ? ` Padfilter: “${pathSearch.trim()}”.` : ""}
|
||||
</p>
|
||||
|
||||
{msg && (
|
||||
<p className="muted" style={{ marginBottom: "1rem" }}>
|
||||
{msg}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{pathFiles && (
|
||||
<div className="card" style={{ marginBottom: "1.25rem" }}>
|
||||
<h2 style={{ fontSize: "1rem", marginBottom: "0.75rem", color: "#fafafa", textTransform: "none" }}>
|
||||
Bestanden op pad ({pathFiles.length})
|
||||
</h2>
|
||||
{pathFiles.length === 0 ? (
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
Geen bestanden. Na node-upgrade 1.3.7+ eerst rescan zodat paden worden opgeslagen.
|
||||
</p>
|
||||
) : (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}>
|
||||
{pathFiles.map((f) => (
|
||||
<div
|
||||
key={f.id}
|
||||
style={{
|
||||
borderTop: "1px solid #2a2d3a",
|
||||
paddingTop: "0.75rem",
|
||||
fontSize: "0.875rem",
|
||||
}}
|
||||
>
|
||||
<div style={{ color: "#e4e4e7" }}>{f.path || f.releaseName}</div>
|
||||
<div className="muted tiny" style={{ marginTop: 4 }}>
|
||||
{f.node}
|
||||
{f.folder ? ` · map ${f.folder}` : ""}
|
||||
{" · "}
|
||||
{f.series
|
||||
? `gekoppeld: ${f.series.title} S${String(f.series.seasonNumber).padStart(2, "0")}E${String(f.series.episodeNumber).padStart(2, "0")}`
|
||||
: f.movie
|
||||
? `gekoppeld als film: ${f.movie.title}`
|
||||
: "niet gekoppeld"}
|
||||
{f.parsedHint?.title
|
||||
? ` · parse: ${f.parsedHint.title}${
|
||||
f.parsedHint.season != null
|
||||
? ` S${f.parsedHint.season}E${f.parsedHint.episode}`
|
||||
: ""
|
||||
}`
|
||||
: ""}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
style={{ marginTop: 8 }}
|
||||
disabled={busyFileId === f.id}
|
||||
onClick={() => void rematchFile(f.id)}
|
||||
>
|
||||
{busyFileId === f.id ? "Bezig…" : "Opnieuw matchen op pad"}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{series.map((s) => (
|
||||
<div key={s.id} className="card">
|
||||
<div
|
||||
|
|
@ -251,21 +400,36 @@ export default function SeriesPage() {
|
|||
{s.tmdbId ? ` · TMDB ${s.tmdbId}` : ""}
|
||||
</div>
|
||||
{(s.nodes?.length ?? 0) > 0 && (
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 6, marginTop: 8 }}>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8, marginTop: 10 }}>
|
||||
{s.nodes!.map((n) => (
|
||||
<span
|
||||
key={n.id}
|
||||
style={{
|
||||
fontSize: "0.75rem",
|
||||
padding: "2px 8px",
|
||||
borderRadius: 999,
|
||||
border: "1px solid #2a2d3a",
|
||||
background: nodeId === n.id ? "#1e293b" : "#12141c",
|
||||
color: nodeId === n.id ? "#e2e8f0" : "#a1a1aa",
|
||||
}}
|
||||
>
|
||||
{n.name} · {n.fileCount}
|
||||
</span>
|
||||
<div key={n.id}>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "0.75rem",
|
||||
padding: "2px 8px",
|
||||
borderRadius: 999,
|
||||
border: "1px solid #2a2d3a",
|
||||
background: nodeId === n.id ? "#1e293b" : "#12141c",
|
||||
color: nodeId === n.id ? "#e2e8f0" : "#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}>
|
||||
{f.folder}{" "}
|
||||
<span style={{ color: "#52525b" }}>({f.fileCount})</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="muted tiny" style={{ marginTop: 4, paddingLeft: 4 }}>
|
||||
Map onbekend — upgrade node naar 1.3.7+ en rescan
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -276,7 +440,7 @@ export default function SeriesPage() {
|
|||
|
||||
{expanded === s.id && (
|
||||
<div style={{ marginTop: "1rem" }}>
|
||||
<div style={{ marginBottom: "1rem" }}>
|
||||
<div style={{ marginBottom: "1rem", display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
|
|
@ -293,15 +457,42 @@ export default function SeriesPage() {
|
|||
Season {season.seasonNumber}
|
||||
</h3>
|
||||
{season.episodes.map((ep) => (
|
||||
<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")} {ep.title ?? ""}
|
||||
{ep.files.length > 0 && (
|
||||
<span style={{ color: "#71717a" }}>
|
||||
{" "}
|
||||
— {ep.files.map((f) => `${f.node} (${f.resolution ?? "?"})`).join(", ")}
|
||||
</span>
|
||||
)}
|
||||
<div key={ep.episodeNumber} style={{ fontSize: "0.875rem", padding: "0.35rem 0" }}>
|
||||
<div>
|
||||
S{String(ep.seasonNumber).padStart(2, "0")}E
|
||||
{String(ep.episodeNumber).padStart(2, "0")} {ep.title ?? ""}
|
||||
</div>
|
||||
{ep.files.map((f) => (
|
||||
<div
|
||||
key={f.id}
|
||||
style={{
|
||||
color: "#71717a",
|
||||
fontSize: "0.8rem",
|
||||
marginTop: 2,
|
||||
display: "flex",
|
||||
gap: "0.5rem",
|
||||
flexWrap: "wrap",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<span className="mono">
|
||||
{f.node} · {f.path || f.releaseName || "?"}
|
||||
{f.resolution ? ` (${f.resolution})` : ""}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="danger"
|
||||
style={{ fontSize: "0.75rem", padding: "2px 8px" }}
|
||||
disabled={busyFileId === f.id}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void rematchFile(f.id);
|
||||
}}
|
||||
>
|
||||
{busyFileId === f.id ? "…" : "Rematch"}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -313,8 +504,8 @@ export default function SeriesPage() {
|
|||
|
||||
{series.length === 0 && (
|
||||
<div className="card" style={{ textAlign: "center", color: "#71717a" }}>
|
||||
{nodeId
|
||||
? "Geen series met bestanden op deze node. Check scan-roots (series-pad) en doe een rescan."
|
||||
{nodeId || pathSearch
|
||||
? "Geen series voor dit filter. Check scan-roots / pad / rescan."
|
||||
: "Geen series gevonden"}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -501,6 +501,8 @@ model MediaFile {
|
|||
audioChannels String? @map("audio_channels")
|
||||
audioLanguage String? @map("audio_language")
|
||||
releaseName String @map("release_name")
|
||||
/** Absolute path on the media-node host (filled from node ≥1.3.7). */
|
||||
path String? @map("path")
|
||||
available Boolean @default(true)
|
||||
lastSeen DateTime @default(now()) @map("last_seen")
|
||||
/// Eerste keer dat dit bestand in de library kwam (wijzigt niet bij rematch/upsert).
|
||||
|
|
|
|||
|
|
@ -11,6 +11,11 @@ import { nodeConnectionManager } from "../websocket/manager";
|
|||
import { MetadataService } from "../metadata/service";
|
||||
import type { Config } from "../config";
|
||||
import { listActiveIptvWatches } from "../iptv/active-watches";
|
||||
import {
|
||||
parseMovieFromPath,
|
||||
parseSeriesFromPath,
|
||||
seriesFolderFromPath,
|
||||
} from "../metadata/filename-parser";
|
||||
|
||||
export async function registerAdminRoutes(app: FastifyInstance, config: Config) {
|
||||
const metadata = new MetadataService(config.TMDB_API_KEY);
|
||||
|
|
@ -186,21 +191,30 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
|
|||
});
|
||||
|
||||
app.get("/api/v1/admin/series", { preHandler: requireAdmin }, async (request) => {
|
||||
const query = request.query as { search?: string; nodeId?: 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 series = await prisma.series.findMany({
|
||||
where: {
|
||||
AND: [
|
||||
query.search
|
||||
? { title: { contains: query.search, mode: "insensitive" } }
|
||||
: {},
|
||||
nodeId
|
||||
searchQ ? { title: { contains: searchQ, mode: "insensitive" } } : {},
|
||||
nodeId || pathQ
|
||||
? {
|
||||
seasons: {
|
||||
some: {
|
||||
episodes: {
|
||||
some: { mediaFiles: { some: { nodeId } } },
|
||||
some: {
|
||||
mediaFiles: {
|
||||
some: {
|
||||
...(nodeId ? { nodeId } : {}),
|
||||
...(pathQ
|
||||
? { path: { contains: pathQ, mode: "insensitive" } }
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
@ -234,21 +248,43 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
|
|||
const allFiles = s.seasons.flatMap((season) =>
|
||||
season.episodes.flatMap((ep) => ep.mediaFiles)
|
||||
);
|
||||
const scopedFiles = nodeId ? allFiles.filter((f) => f.nodeId === nodeId) : allFiles;
|
||||
const nodeMap = new Map<string, { id: string; name: string; fileCount: number }>();
|
||||
const scopedFiles = allFiles.filter((f) => {
|
||||
if (nodeId && f.nodeId !== nodeId) return false;
|
||||
if (pathQ && !(f.path ?? "").toLowerCase().includes(pathQ.toLowerCase())) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const nodeMap = new Map<
|
||||
string,
|
||||
{ id: string; name: string; fileCount: number; folders: Map<string, number> }
|
||||
>();
|
||||
for (const f of allFiles) {
|
||||
const cur = nodeMap.get(f.nodeId);
|
||||
if (cur) cur.fileCount += 1;
|
||||
else nodeMap.set(f.nodeId, { id: f.nodeId, name: f.node.name, fileCount: 1 });
|
||||
let cur = nodeMap.get(f.nodeId);
|
||||
if (!cur) {
|
||||
cur = {
|
||||
id: f.nodeId,
|
||||
name: f.node.name,
|
||||
fileCount: 0,
|
||||
folders: new Map(),
|
||||
};
|
||||
nodeMap.set(f.nodeId, cur);
|
||||
}
|
||||
cur.fileCount += 1;
|
||||
const folder = f.path ? seriesFolderFromPath(f.path) : null;
|
||||
if (folder) cur.folders.set(folder, (cur.folders.get(folder) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const episodesWithFiles = s.seasons.reduce((n, season) => {
|
||||
return (
|
||||
n +
|
||||
season.episodes.filter((ep) => {
|
||||
const files = nodeId
|
||||
? ep.mediaFiles.filter((f) => f.nodeId === nodeId)
|
||||
: ep.mediaFiles;
|
||||
const files = ep.mediaFiles.filter((f) => {
|
||||
if (nodeId && f.nodeId !== nodeId) return false;
|
||||
if (pathQ && !(f.path ?? "").toLowerCase().includes(pathQ.toLowerCase())) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
return files.length > 0;
|
||||
}).length
|
||||
);
|
||||
|
|
@ -263,23 +299,39 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
|
|||
posterUrl: s.posterUrl,
|
||||
episodesWithFiles,
|
||||
fileCount: scopedFiles.length,
|
||||
nodes: [...nodeMap.values()].sort((a, b) => a.name.localeCompare(b.name)),
|
||||
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)),
|
||||
seasons: s.seasons.map((season) => ({
|
||||
seasonNumber: season.seasonNumber,
|
||||
episodes: season.episodes
|
||||
.map((ep) => {
|
||||
const files = (nodeId
|
||||
? ep.mediaFiles.filter((f) => f.nodeId === nodeId)
|
||||
: ep.mediaFiles
|
||||
).map((f) => ({
|
||||
id: f.id,
|
||||
nodeId: f.nodeId,
|
||||
node: f.node.name,
|
||||
nodeStatus: f.node.status,
|
||||
resolution: f.resolution,
|
||||
available: f.available,
|
||||
releaseName: f.releaseName,
|
||||
}));
|
||||
const files = ep.mediaFiles
|
||||
.filter((f) => {
|
||||
if (nodeId && f.nodeId !== nodeId) return false;
|
||||
if (pathQ && !(f.path ?? "").toLowerCase().includes(pathQ.toLowerCase())) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.map((f) => ({
|
||||
id: f.id,
|
||||
nodeId: f.nodeId,
|
||||
node: f.node.name,
|
||||
nodeStatus: f.node.status,
|
||||
resolution: f.resolution,
|
||||
available: f.available,
|
||||
releaseName: f.releaseName,
|
||||
path: f.path,
|
||||
folder: f.path ? seriesFolderFromPath(f.path) : null,
|
||||
}));
|
||||
return {
|
||||
id: ep.id,
|
||||
seasonNumber: ep.seasonNumber,
|
||||
|
|
@ -288,8 +340,8 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
|
|||
files,
|
||||
};
|
||||
})
|
||||
.filter((ep) => !nodeId || ep.files.length > 0),
|
||||
})).filter((season) => !nodeId || season.episodes.length > 0),
|
||||
.filter((ep) => (!nodeId && !pathQ) || ep.files.length > 0),
|
||||
})).filter((season) => (!nodeId && !pathQ) || season.episodes.length > 0),
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
|
@ -604,6 +656,82 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
|
|||
});
|
||||
|
||||
// Manual library matching
|
||||
app.get("/api/v1/admin/media-files", { preHandler: requireAdmin }, async (request) => {
|
||||
const query = request.query as {
|
||||
nodeId?: string;
|
||||
path?: string;
|
||||
q?: string;
|
||||
unmatched?: string;
|
||||
};
|
||||
const nodeId = query.nodeId?.trim() || undefined;
|
||||
const pathQ = query.path?.trim() || query.q?.trim() || undefined;
|
||||
if (!pathQ && !nodeId) {
|
||||
throw new AppError("INVALID_REQUEST", "path of nodeId verplicht", 400);
|
||||
}
|
||||
|
||||
const files = await prisma.mediaFile.findMany({
|
||||
where: {
|
||||
...(nodeId ? { nodeId } : {}),
|
||||
...(pathQ
|
||||
? {
|
||||
OR: [
|
||||
{ path: { contains: pathQ, mode: "insensitive" } },
|
||||
{ releaseName: { contains: pathQ, mode: "insensitive" } },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
...(query.unmatched === "1" || query.unmatched === "true"
|
||||
? { movieId: null, episodeId: null }
|
||||
: {}),
|
||||
},
|
||||
orderBy: [{ path: "asc" }, { releaseName: "asc" }],
|
||||
take: 200,
|
||||
include: {
|
||||
node: { select: { id: true, name: true } },
|
||||
movie: { select: { id: true, title: true, year: true, imdbId: true } },
|
||||
episode: {
|
||||
select: {
|
||||
id: true,
|
||||
seasonNumber: true,
|
||||
episodeNumber: true,
|
||||
title: true,
|
||||
series: { select: { id: true, title: true, year: true, imdbId: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
files: files.map((f) => ({
|
||||
id: f.id,
|
||||
nodeId: f.nodeId,
|
||||
node: f.node.name,
|
||||
path: f.path,
|
||||
folder: f.path ? seriesFolderFromPath(f.path) : null,
|
||||
releaseName: f.releaseName,
|
||||
resolution: f.resolution,
|
||||
available: f.available,
|
||||
mediaType: f.movieId ? "movie" : f.episodeId ? "episode" : "unmatched",
|
||||
movie: f.movie,
|
||||
series: f.episode?.series
|
||||
? {
|
||||
id: f.episode.series.id,
|
||||
title: f.episode.series.title,
|
||||
year: f.episode.series.year,
|
||||
imdbId: f.episode.series.imdbId,
|
||||
seasonNumber: f.episode.seasonNumber,
|
||||
episodeNumber: f.episode.episodeNumber,
|
||||
episodeTitle: f.episode.title,
|
||||
episodeId: f.episode.id,
|
||||
}
|
||||
: null,
|
||||
parsedHint: f.path
|
||||
? parseSeriesFromPath(f.path) || parseMovieFromPath(f.path)
|
||||
: null,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
app.post("/api/v1/admin/media-files/:id/match", { preHandler: requireAdmin }, async (request) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const body = request.body as {
|
||||
|
|
@ -652,11 +780,83 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
|
|||
return { ok: true };
|
||||
});
|
||||
|
||||
/** Wis match en match opnieuw op basis van opgeslagen pad (of releaseName). */
|
||||
app.post("/api/v1/admin/media-files/:id/rematch", { preHandler: requireAdmin }, async (request) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const file = await prisma.mediaFile.findUnique({ where: { id } });
|
||||
if (!file) throw new AppError("NOT_FOUND", "Media file not found", 404);
|
||||
|
||||
const sourcePath = file.path?.trim() || file.releaseName;
|
||||
const asSeries = parseSeriesFromPath(sourcePath);
|
||||
const asMovie = asSeries ? null : parseMovieFromPath(sourcePath);
|
||||
if (!asSeries && !asMovie) {
|
||||
throw new AppError(
|
||||
"INVALID_REQUEST",
|
||||
"Pad/bestandsnaam niet te parsen als serie of film — controleer mapstructuur (Show/Season 01/…)",
|
||||
400
|
||||
);
|
||||
}
|
||||
|
||||
await prisma.mediaFile.update({
|
||||
where: { id },
|
||||
data: { movieId: null, episodeId: null },
|
||||
});
|
||||
|
||||
const matched = await metadata.matchMediaFile(file.nodeId, {
|
||||
localFileId: file.localFileId,
|
||||
sizeBytes: Number(file.sizeBytes),
|
||||
container: file.container ?? undefined,
|
||||
resolution: file.resolution ?? undefined,
|
||||
videoCodec: file.videoCodec ?? undefined,
|
||||
audioCodec: file.audioCodec ?? undefined,
|
||||
releaseName: file.releaseName,
|
||||
path: file.path ?? undefined,
|
||||
modifiedAt: file.lastSeen.toISOString(),
|
||||
mediaType: asSeries ? "episode" : "movie",
|
||||
parsedMovie: asMovie ?? undefined,
|
||||
parsedEpisode: asSeries ?? undefined,
|
||||
shelfId: file.shelfId ?? undefined,
|
||||
});
|
||||
|
||||
const updated = await prisma.mediaFile.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
movie: { select: { id: true, title: true } },
|
||||
episode: {
|
||||
select: {
|
||||
seasonNumber: true,
|
||||
episodeNumber: true,
|
||||
series: { select: { id: true, title: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
matched: Boolean(matched),
|
||||
mediaType: asSeries ? "episode" : "movie",
|
||||
parsed: asSeries ?? asMovie,
|
||||
movie: updated?.movie ?? null,
|
||||
series: updated?.episode?.series
|
||||
? {
|
||||
...updated.episode.series,
|
||||
seasonNumber: updated.episode.seasonNumber,
|
||||
episodeNumber: updated.episode.episodeNumber,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
});
|
||||
|
||||
app.post("/api/v1/admin/media-files/:id/refresh-metadata", { preHandler: requireAdmin }, async (request) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const file = await prisma.mediaFile.findUnique({ where: { id } });
|
||||
if (!file) throw new AppError("NOT_FOUND", "Media file not found", 404);
|
||||
|
||||
const sourcePath = file.path?.trim() || file.releaseName;
|
||||
const asSeries = parseSeriesFromPath(sourcePath);
|
||||
const asMovie = asSeries ? null : parseMovieFromPath(sourcePath);
|
||||
|
||||
await metadata.matchMediaFile(file.nodeId, {
|
||||
localFileId: file.localFileId,
|
||||
sizeBytes: Number(file.sizeBytes),
|
||||
|
|
@ -665,14 +865,16 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
|
|||
videoCodec: file.videoCodec ?? undefined,
|
||||
audioCodec: file.audioCodec ?? undefined,
|
||||
releaseName: file.releaseName,
|
||||
path: file.path ?? undefined,
|
||||
modifiedAt: file.lastSeen.toISOString(),
|
||||
mediaType: file.movieId ? "movie" : "episode",
|
||||
parsedMovie: file.movieId
|
||||
mediaType: file.movieId ? "movie" : file.episodeId ? "episode" : asSeries ? "episode" : "movie",
|
||||
parsedMovie: asMovie ?? (file.movieId
|
||||
? { title: file.releaseName.replace(/\.[^.]+$/, "").replace(/[._]/g, " ") }
|
||||
: undefined,
|
||||
parsedEpisode: !file.movieId
|
||||
: undefined),
|
||||
parsedEpisode: asSeries ?? (!file.movieId
|
||||
? { title: file.releaseName, season: 1, episode: 1 }
|
||||
: undefined,
|
||||
: undefined),
|
||||
shelfId: file.shelfId ?? undefined,
|
||||
});
|
||||
|
||||
return { ok: true };
|
||||
|
|
|
|||
|
|
@ -216,6 +216,12 @@ async function main() {
|
|||
await prisma.$executeRawUnsafe(
|
||||
`ALTER TABLE "media_files" ADD COLUMN IF NOT EXISTS "shelf_id" TEXT`
|
||||
);
|
||||
await prisma.$executeRawUnsafe(
|
||||
`ALTER TABLE "media_files" ADD COLUMN IF NOT EXISTS "path" TEXT`
|
||||
);
|
||||
await prisma.$executeRawUnsafe(
|
||||
`CREATE INDEX IF NOT EXISTS "media_files_path_idx" ON "media_files"("path")`
|
||||
);
|
||||
await prisma.$executeRawUnsafe(`
|
||||
INSERT INTO "library_shelves" ("id", "name", "slug", "kind", "sort_order", "enabled", "created_at", "updated_at")
|
||||
VALUES
|
||||
|
|
|
|||
|
|
@ -149,6 +149,19 @@ export function parseSeriesFromPath(filePath: string): ParsedSeriesFilename | nu
|
|||
return { title, season, episode, ...extractTags(base) };
|
||||
}
|
||||
|
||||
/** Show-folder for a video path: …/Show/Season 01/file.mkv → …/Show */
|
||||
export function seriesFolderFromPath(filePath: string): string | null {
|
||||
const raw = filePath.trim();
|
||||
if (!raw) return null;
|
||||
const norm = raw.replace(/\\/g, "/");
|
||||
const dir = path.posix.dirname(norm);
|
||||
const parentName = path.posix.basename(dir);
|
||||
if (SEASON_DIR.test(parentName)) {
|
||||
return path.posix.dirname(dir);
|
||||
}
|
||||
return dir === "." ? null : dir;
|
||||
}
|
||||
|
||||
export function detectMediaType(filename: string): "movie" | "episode" | null {
|
||||
if (!isVideoFile(filename)) return null;
|
||||
if (parseSeriesFilename(filename) || parseSeriesFromPath(filename)) return "episode";
|
||||
|
|
@ -178,6 +191,7 @@ export function mediaFileInfoFromFilename(
|
|||
sizeBytes,
|
||||
container: extractContainer(filename),
|
||||
releaseName: path.basename(filename),
|
||||
path: filename.includes("/") || filename.includes("\\") ? filename : undefined,
|
||||
modifiedAt: modifiedAt.toISOString(),
|
||||
mediaType,
|
||||
parsedMovie,
|
||||
|
|
|
|||
|
|
@ -299,7 +299,7 @@ export class MetadataService {
|
|||
where: {
|
||||
nodeId_localFileId: { nodeId, localFileId: file.localFileId },
|
||||
},
|
||||
select: { id: true, movieId: true, episodeId: true, shelfId: true, sizeBytes: true },
|
||||
select: { id: true, movieId: true, episodeId: true, shelfId: true, sizeBytes: true, path: true },
|
||||
});
|
||||
if (existing && (existing.movieId || existing.episodeId)) {
|
||||
const nextShelf = file.shelfId || null;
|
||||
|
|
@ -316,6 +316,7 @@ export class MetadataService {
|
|||
videoCodec: file.videoCodec,
|
||||
audioCodec: file.audioCodec,
|
||||
releaseName: file.releaseName,
|
||||
...(file.path ? { path: file.path } : {}),
|
||||
lastSeen: new Date(file.modifiedAt),
|
||||
...(sizeChanged ? { discoveredAt: new Date() } : {}),
|
||||
},
|
||||
|
|
@ -1830,6 +1831,7 @@ export class MetadataService {
|
|||
videoCodec: file.videoCodec,
|
||||
audioCodec: file.audioCodec,
|
||||
releaseName: file.releaseName,
|
||||
path: file.path || null,
|
||||
available: true,
|
||||
lastSeen: mtime,
|
||||
// Eerste indexering = nu (ook als bestand-mtime ouder is)
|
||||
|
|
@ -1845,6 +1847,7 @@ export class MetadataService {
|
|||
videoCodec: file.videoCodec,
|
||||
audioCodec: file.audioCodec,
|
||||
releaseName: file.releaseName,
|
||||
...(file.path ? { path: file.path } : {}),
|
||||
available: true,
|
||||
lastSeen: mtime,
|
||||
// Alleen opnieuw “recent” als het bestand inhoudelijk vernieuwd is
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
/** Target media-node version served from /install/media-node-linux-* */
|
||||
export const MEDIA_NODE_TARGET_VERSION =
|
||||
process.env.MEDIA_NODE_VERSION?.trim() || "1.3.6";
|
||||
process.env.MEDIA_NODE_VERSION?.trim() || "1.3.7";
|
||||
|
||||
/** Minimum node version that handles UPGRADE over WebSocket */
|
||||
const REMOTE_UPGRADE_MIN_VERSION = "1.3.3";
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ WORKDIR /app
|
|||
|
||||
FROM golang:1.22-alpine AS node-build
|
||||
WORKDIR /src
|
||||
ARG MEDIA_NODE_VERSION=1.3.6
|
||||
ARG MEDIA_NODE_VERSION=1.3.7
|
||||
RUN apk add --no-cache git ca-certificates
|
||||
COPY node/media-node/ ./
|
||||
RUN go mod tidy \
|
||||
|
|
|
|||
|
|
@ -74,7 +74,7 @@ mv "$BIN_PATH.new" "$BIN_PATH"
|
|||
if docker_cmd ps -a --format '{{.Names}}' | grep -qx "$CONTAINER"; then
|
||||
echo "Container herstarten..."
|
||||
docker_cmd restart "$CONTAINER" >/dev/null
|
||||
echo "Klaar. Controleer versie in Admin → Nodes (verwacht ${MEDIA_NODE_VERSION:-1.3.6}+)."
|
||||
echo "Klaar. Controleer versie in Admin → Nodes (verwacht ${MEDIA_NODE_VERSION:-1.3.7}+)."
|
||||
else
|
||||
echo "Waarschuwing: container '$CONTAINER' niet gevonden — binary is wel bijgewerkt."
|
||||
fi
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ import (
|
|||
"github.com/sthmedia/media-node/internal/streaming"
|
||||
)
|
||||
|
||||
var version = "1.3.6"
|
||||
var version = "1.3.7"
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ type MediaFileInfo struct {
|
|||
VideoCodec string `json:"videoCodec,omitempty"`
|
||||
AudioCodec string `json:"audioCodec,omitempty"`
|
||||
ReleaseName string `json:"releaseName"`
|
||||
Path string `json:"path,omitempty"`
|
||||
ModifiedAt string `json:"modifiedAt"`
|
||||
MediaType string `json:"mediaType"`
|
||||
ShelfID string `json:"shelfId,omitempty"`
|
||||
|
|
@ -712,6 +713,7 @@ func buildMediaInfo(f database.LocalFile) MediaFileInfo {
|
|||
SizeBytes: f.SizeBytes,
|
||||
Container: strings.TrimPrefix(filepath.Ext(f.ReleaseName), "."),
|
||||
ReleaseName: f.ReleaseName,
|
||||
Path: f.Path,
|
||||
ModifiedAt: f.ModifiedAt.UTC().Format(time.RFC3339),
|
||||
MediaType: f.MediaType,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -68,6 +68,8 @@ export interface MediaFileInfo {
|
|||
videoCodec?: string;
|
||||
audioCodec?: string;
|
||||
releaseName: string;
|
||||
/** Absolute host path on the media-node (for admin diagnostics / rematch). */
|
||||
path?: string;
|
||||
modifiedAt: string;
|
||||
mediaType: "movie" | "episode";
|
||||
/** Library shelf this file belongs to (cross-node catalog) */
|
||||
|
|
|
|||
Loading…
Reference in a new issue