Show series folders per node and allow path search/rematch.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jos Vooges | STH 2026-09-09 01:40:01 +02:00
parent 705a01f034
commit a6b0ec15f2
13 changed files with 495 additions and 73 deletions

View file

@ -65,7 +65,7 @@ export default function NodesPage() {
const [editRoots, setEditRoots] = useState<ScanRootRow[]>([]); const [editRoots, setEditRoots] = useState<ScanRootRow[]>([]);
const [editBusy, setEditBusy] = useState(false); const [editBusy, setEditBusy] = useState(false);
const [upgradingId, setUpgradingId] = useState<string | null>(null); const [upgradingId, setUpgradingId] = useState<string | null>(null);
const [targetNodeVersion, setTargetNodeVersion] = useState("1.3.6"); const [targetNodeVersion, setTargetNodeVersion] = useState("1.3.7");
function loadNodes() { function loadNodes() {
fetch("/api/v1/admin/nodes", { credentials: "include" }) fetch("/api/v1/admin/nodes", { credentials: "include" })

View file

@ -3,10 +3,26 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Nav, useAuth } from "@/components/Nav"; import { Nav, useAuth } from "@/components/Nav";
interface SeriesFolder {
folder: string;
fileCount: number;
}
interface SeriesNode { interface SeriesNode {
id: string; id: string;
name: string; name: string;
fileCount: number; 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 { interface Series {
@ -25,7 +41,7 @@ interface Series {
seasonNumber: number; seasonNumber: number;
episodeNumber: number; episodeNumber: number;
title: string | null; title: string | null;
files: Array<{ node: string; resolution: string | null; available: boolean }>; files: SeriesFile[];
}>; }>;
}>; }>;
} }
@ -36,6 +52,24 @@ interface NodeOption {
status?: string; 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 { interface MetaHit {
tmdbId: number; tmdbId: number;
title: string; title: string;
@ -48,26 +82,50 @@ interface MetaHit {
export default function SeriesPage() { export default function SeriesPage() {
useAuth(); useAuth();
const [series, setSeries] = useState<Series[]>([]); const [series, setSeries] = useState<Series[]>([]);
const [pathFiles, setPathFiles] = useState<PathFile[] | null>(null);
const [nodes, setNodes] = useState<NodeOption[]>([]); const [nodes, setNodes] = useState<NodeOption[]>([]);
const [nodeId, setNodeId] = useState(""); 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 [busyFileId, setBusyFileId] = useState<string | null>(null);
const [msg, setMsg] = useState("");
const [linkSeriesId, setLinkSeriesId] = useState<string | null>(null); const [linkSeriesId, setLinkSeriesId] = 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("");
function load(q = search, filterNodeId = nodeId) { function loadSeries(q = search, filterNodeId = nodeId, pathQ = pathSearch) {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (q.trim()) params.set("search", q.trim()); if (q.trim()) params.set("search", q.trim());
if (filterNodeId) params.set("nodeId", filterNodeId); if (filterNodeId) params.set("nodeId", filterNodeId);
if (pathQ.trim()) params.set("path", pathQ.trim());
const qs = params.toString() ? `?${params}` : ""; const qs = params.toString() ? `?${params}` : "";
fetch(`/api/v1/admin/series${qs}`, { credentials: "include" }) fetch(`/api/v1/admin/series${qs}`, { credentials: "include" })
.then((r) => r.json()) .then((r) => r.json())
.then((d) => setSeries(d.series ?? [])); .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(() => { useEffect(() => {
fetch("/api/v1/admin/nodes", { credentials: "include" }) fetch("/api/v1/admin/nodes", { credentials: "include" })
.then((r) => r.json()) .then((r) => r.json())
@ -79,10 +137,33 @@ export default function SeriesPage() {
}, []); }, []);
useEffect(() => { useEffect(() => {
load(search, nodeId); load(search, nodeId, pathSearch);
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [nodeId]); }, [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) { function openLink(s: Series) {
setLinkSeriesId(s.id); setLinkSeriesId(s.id);
setLinkQuery(s.title); setLinkQuery(s.title);
@ -211,19 +292,87 @@ export default function SeriesPage() {
<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… (bv. Breaking Bad)"
style={{ width: 260, marginBottom: 0 }}
/> />
<button type="submit">Zoek</button> <button type="submit">Zoek</button>
</form> </form>
</div> </div>
<p className="muted page-lead"> <p className="muted page-lead">
Per node zie je de show-map. Padzoeken toont ook losse bestanden om foutmatches
opnieuw te koppelen.{" "}
{nodeId {nodeId
? `Alleen series met bestanden op ${selectedNodeName ?? "deze node"} (${series.length}).` ? `Filter: ${selectedNodeName ?? "node"} · ${series.length} series.`
: `${series.length} series over alle nodes. Kies een node om per locatie te bekijken.`} : `${series.length} series.`}
{pathSearch.trim() ? ` Padfilter: “${pathSearch.trim()}”.` : ""}
</p> </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) => ( {series.map((s) => (
<div key={s.id} className="card"> <div key={s.id} className="card">
<div <div
@ -251,10 +400,10 @@ export default function SeriesPage() {
{s.tmdbId ? ` · TMDB ${s.tmdbId}` : ""} {s.tmdbId ? ` · TMDB ${s.tmdbId}` : ""}
</div> </div>
{(s.nodes?.length ?? 0) > 0 && ( {(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) => ( {s.nodes!.map((n) => (
<div key={n.id}>
<span <span
key={n.id}
style={{ style={{
fontSize: "0.75rem", fontSize: "0.75rem",
padding: "2px 8px", padding: "2px 8px",
@ -264,8 +413,23 @@ export default function SeriesPage() {
color: nodeId === n.id ? "#e2e8f0" : "#a1a1aa", color: nodeId === n.id ? "#e2e8f0" : "#a1a1aa",
}} }}
> >
{n.name} · {n.fileCount} {n.name} · {n.fileCount} files
</span> </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> </div>
)} )}
@ -276,7 +440,7 @@ export default function SeriesPage() {
{expanded === s.id && ( {expanded === s.id && (
<div style={{ marginTop: "1rem" }}> <div style={{ marginTop: "1rem" }}>
<div style={{ marginBottom: "1rem" }}> <div style={{ marginBottom: "1rem", display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
<button <button
type="button" type="button"
onClick={(e) => { onClick={(e) => {
@ -293,15 +457,42 @@ export default function SeriesPage() {
Season {season.seasonNumber} Season {season.seasonNumber}
</h3> </h3>
{season.episodes.map((ep) => ( {season.episodes.map((ep) => (
<div key={ep.episodeNumber} style={{ fontSize: "0.875rem", padding: "0.25rem 0" }}> <div key={ep.episodeNumber} style={{ fontSize: "0.875rem", padding: "0.35rem 0" }}>
<div>
S{String(ep.seasonNumber).padStart(2, "0")}E S{String(ep.seasonNumber).padStart(2, "0")}E
{String(ep.episodeNumber).padStart(2, "0")} {ep.title ?? ""} {String(ep.episodeNumber).padStart(2, "0")} {ep.title ?? ""}
{ep.files.length > 0 && ( </div>
<span style={{ color: "#71717a" }}> {ep.files.map((f) => (
{" "} <div
— {ep.files.map((f) => `${f.node} (${f.resolution ?? "?"})`).join(", ")} 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> </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>
))} ))}
</div> </div>
@ -313,8 +504,8 @@ export default function SeriesPage() {
{series.length === 0 && ( {series.length === 0 && (
<div className="card" style={{ textAlign: "center", color: "#71717a" }}> <div className="card" style={{ textAlign: "center", color: "#71717a" }}>
{nodeId {nodeId || pathSearch
? "Geen series met bestanden op deze node. Check scan-roots (series-pad) en doe een rescan." ? "Geen series voor dit filter. Check scan-roots / pad / rescan."
: "Geen series gevonden"} : "Geen series gevonden"}
</div> </div>
)} )}

View file

@ -501,6 +501,8 @@ model MediaFile {
audioChannels String? @map("audio_channels") audioChannels String? @map("audio_channels")
audioLanguage String? @map("audio_language") audioLanguage String? @map("audio_language")
releaseName String @map("release_name") 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) available Boolean @default(true)
lastSeen DateTime @default(now()) @map("last_seen") lastSeen DateTime @default(now()) @map("last_seen")
/// Eerste keer dat dit bestand in de library kwam (wijzigt niet bij rematch/upsert). /// Eerste keer dat dit bestand in de library kwam (wijzigt niet bij rematch/upsert).

View file

@ -11,6 +11,11 @@ import { nodeConnectionManager } from "../websocket/manager";
import { MetadataService } from "../metadata/service"; import { MetadataService } from "../metadata/service";
import type { Config } from "../config"; import type { Config } from "../config";
import { listActiveIptvWatches } from "../iptv/active-watches"; import { listActiveIptvWatches } from "../iptv/active-watches";
import {
parseMovieFromPath,
parseSeriesFromPath,
seriesFolderFromPath,
} from "../metadata/filename-parser";
export async function registerAdminRoutes(app: FastifyInstance, config: Config) { export async function registerAdminRoutes(app: FastifyInstance, config: Config) {
const metadata = new MetadataService(config.TMDB_API_KEY); 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) => { 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 nodeId = query.nodeId?.trim() || undefined;
const pathQ = query.path?.trim() || undefined;
const searchQ = query.search?.trim() || undefined;
const series = await prisma.series.findMany({ const series = await prisma.series.findMany({
where: { where: {
AND: [ AND: [
query.search searchQ ? { title: { contains: searchQ, mode: "insensitive" } } : {},
? { title: { contains: query.search, mode: "insensitive" } } nodeId || pathQ
: {},
nodeId
? { ? {
seasons: { seasons: {
some: { some: {
episodes: { 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) => const allFiles = s.seasons.flatMap((season) =>
season.episodes.flatMap((ep) => ep.mediaFiles) season.episodes.flatMap((ep) => ep.mediaFiles)
); );
const scopedFiles = nodeId ? allFiles.filter((f) => f.nodeId === nodeId) : allFiles; const scopedFiles = allFiles.filter((f) => {
const nodeMap = new Map<string, { id: string; name: string; fileCount: number }>(); 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) { for (const f of allFiles) {
const cur = nodeMap.get(f.nodeId); let cur = nodeMap.get(f.nodeId);
if (cur) cur.fileCount += 1; if (!cur) {
else nodeMap.set(f.nodeId, { id: f.nodeId, name: f.node.name, fileCount: 1 }); 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) => { const episodesWithFiles = s.seasons.reduce((n, season) => {
return ( return (
n + n +
season.episodes.filter((ep) => { season.episodes.filter((ep) => {
const files = nodeId const files = ep.mediaFiles.filter((f) => {
? ep.mediaFiles.filter((f) => f.nodeId === nodeId) if (nodeId && f.nodeId !== nodeId) return false;
: ep.mediaFiles; if (pathQ && !(f.path ?? "").toLowerCase().includes(pathQ.toLowerCase())) {
return false;
}
return true;
});
return files.length > 0; return files.length > 0;
}).length }).length
); );
@ -263,15 +299,29 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
posterUrl: s.posterUrl, posterUrl: s.posterUrl,
episodesWithFiles, episodesWithFiles,
fileCount: scopedFiles.length, 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) => ({ seasons: s.seasons.map((season) => ({
seasonNumber: season.seasonNumber, seasonNumber: season.seasonNumber,
episodes: season.episodes episodes: season.episodes
.map((ep) => { .map((ep) => {
const files = (nodeId const files = ep.mediaFiles
? ep.mediaFiles.filter((f) => f.nodeId === nodeId) .filter((f) => {
: ep.mediaFiles if (nodeId && f.nodeId !== nodeId) return false;
).map((f) => ({ if (pathQ && !(f.path ?? "").toLowerCase().includes(pathQ.toLowerCase())) {
return false;
}
return true;
})
.map((f) => ({
id: f.id, id: f.id,
nodeId: f.nodeId, nodeId: f.nodeId,
node: f.node.name, node: f.node.name,
@ -279,6 +329,8 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
resolution: f.resolution, resolution: f.resolution,
available: f.available, available: f.available,
releaseName: f.releaseName, releaseName: f.releaseName,
path: f.path,
folder: f.path ? seriesFolderFromPath(f.path) : null,
})); }));
return { return {
id: ep.id, id: ep.id,
@ -288,8 +340,8 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
files, files,
}; };
}) })
.filter((ep) => !nodeId || ep.files.length > 0), .filter((ep) => (!nodeId && !pathQ) || ep.files.length > 0),
})).filter((season) => !nodeId || season.episodes.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 // 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) => { app.post("/api/v1/admin/media-files/:id/match", { preHandler: requireAdmin }, async (request) => {
const { id } = request.params as { id: string }; const { id } = request.params as { id: string };
const body = request.body as { const body = request.body as {
@ -652,11 +780,83 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
return { ok: true }; 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) => { app.post("/api/v1/admin/media-files/:id/refresh-metadata", { preHandler: requireAdmin }, async (request) => {
const { id } = request.params as { id: string }; const { id } = request.params as { id: string };
const file = await prisma.mediaFile.findUnique({ where: { id } }); const file = await prisma.mediaFile.findUnique({ where: { id } });
if (!file) throw new AppError("NOT_FOUND", "Media file not found", 404); 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, { await metadata.matchMediaFile(file.nodeId, {
localFileId: file.localFileId, localFileId: file.localFileId,
sizeBytes: Number(file.sizeBytes), sizeBytes: Number(file.sizeBytes),
@ -665,14 +865,16 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
videoCodec: file.videoCodec ?? undefined, videoCodec: file.videoCodec ?? undefined,
audioCodec: file.audioCodec ?? undefined, audioCodec: file.audioCodec ?? undefined,
releaseName: file.releaseName, releaseName: file.releaseName,
path: file.path ?? undefined,
modifiedAt: file.lastSeen.toISOString(), modifiedAt: file.lastSeen.toISOString(),
mediaType: file.movieId ? "movie" : "episode", mediaType: file.movieId ? "movie" : file.episodeId ? "episode" : asSeries ? "episode" : "movie",
parsedMovie: file.movieId parsedMovie: asMovie ?? (file.movieId
? { title: file.releaseName.replace(/\.[^.]+$/, "").replace(/[._]/g, " ") } ? { title: file.releaseName.replace(/\.[^.]+$/, "").replace(/[._]/g, " ") }
: undefined, : undefined),
parsedEpisode: !file.movieId parsedEpisode: asSeries ?? (!file.movieId
? { title: file.releaseName, season: 1, episode: 1 } ? { title: file.releaseName, season: 1, episode: 1 }
: undefined, : undefined),
shelfId: file.shelfId ?? undefined,
}); });
return { ok: true }; return { ok: true };

View file

@ -216,6 +216,12 @@ async function main() {
await prisma.$executeRawUnsafe( await prisma.$executeRawUnsafe(
`ALTER TABLE "media_files" ADD COLUMN IF NOT EXISTS "shelf_id" TEXT` `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(` await prisma.$executeRawUnsafe(`
INSERT INTO "library_shelves" ("id", "name", "slug", "kind", "sort_order", "enabled", "created_at", "updated_at") INSERT INTO "library_shelves" ("id", "name", "slug", "kind", "sort_order", "enabled", "created_at", "updated_at")
VALUES VALUES

View file

@ -149,6 +149,19 @@ export function parseSeriesFromPath(filePath: string): ParsedSeriesFilename | nu
return { title, season, episode, ...extractTags(base) }; 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 { 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";
@ -178,6 +191,7 @@ export function mediaFileInfoFromFilename(
sizeBytes, sizeBytes,
container: extractContainer(filename), container: extractContainer(filename),
releaseName: path.basename(filename), releaseName: path.basename(filename),
path: filename.includes("/") || filename.includes("\\") ? filename : undefined,
modifiedAt: modifiedAt.toISOString(), modifiedAt: modifiedAt.toISOString(),
mediaType, mediaType,
parsedMovie, parsedMovie,

View file

@ -299,7 +299,7 @@ export class MetadataService {
where: { where: {
nodeId_localFileId: { nodeId, localFileId: file.localFileId }, 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)) { if (existing && (existing.movieId || existing.episodeId)) {
const nextShelf = file.shelfId || null; const nextShelf = file.shelfId || null;
@ -316,6 +316,7 @@ export class MetadataService {
videoCodec: file.videoCodec, videoCodec: file.videoCodec,
audioCodec: file.audioCodec, audioCodec: file.audioCodec,
releaseName: file.releaseName, releaseName: file.releaseName,
...(file.path ? { path: file.path } : {}),
lastSeen: new Date(file.modifiedAt), lastSeen: new Date(file.modifiedAt),
...(sizeChanged ? { discoveredAt: new Date() } : {}), ...(sizeChanged ? { discoveredAt: new Date() } : {}),
}, },
@ -1830,6 +1831,7 @@ export class MetadataService {
videoCodec: file.videoCodec, videoCodec: file.videoCodec,
audioCodec: file.audioCodec, audioCodec: file.audioCodec,
releaseName: file.releaseName, releaseName: file.releaseName,
path: file.path || null,
available: true, available: true,
lastSeen: mtime, lastSeen: mtime,
// Eerste indexering = nu (ook als bestand-mtime ouder is) // Eerste indexering = nu (ook als bestand-mtime ouder is)
@ -1845,6 +1847,7 @@ export class MetadataService {
videoCodec: file.videoCodec, videoCodec: file.videoCodec,
audioCodec: file.audioCodec, audioCodec: file.audioCodec,
releaseName: file.releaseName, releaseName: file.releaseName,
...(file.path ? { path: file.path } : {}),
available: true, available: true,
lastSeen: mtime, lastSeen: mtime,
// Alleen opnieuw “recent” als het bestand inhoudelijk vernieuwd is // Alleen opnieuw “recent” als het bestand inhoudelijk vernieuwd is

View file

@ -1,6 +1,6 @@
/** Target media-node version served from /install/media-node-linux-* */ /** Target media-node version served from /install/media-node-linux-* */
export const MEDIA_NODE_TARGET_VERSION = 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 */ /** Minimum node version that handles UPGRADE over WebSocket */
const REMOTE_UPGRADE_MIN_VERSION = "1.3.3"; const REMOTE_UPGRADE_MIN_VERSION = "1.3.3";

View file

@ -4,7 +4,7 @@ WORKDIR /app
FROM golang:1.22-alpine AS node-build FROM golang:1.22-alpine AS node-build
WORKDIR /src WORKDIR /src
ARG MEDIA_NODE_VERSION=1.3.6 ARG MEDIA_NODE_VERSION=1.3.7
RUN apk add --no-cache git ca-certificates RUN apk add --no-cache git ca-certificates
COPY node/media-node/ ./ COPY node/media-node/ ./
RUN go mod tidy \ RUN go mod tidy \

View file

@ -74,7 +74,7 @@ mv "$BIN_PATH.new" "$BIN_PATH"
if docker_cmd ps -a --format '{{.Names}}' | grep -qx "$CONTAINER"; then if docker_cmd ps -a --format '{{.Names}}' | grep -qx "$CONTAINER"; then
echo "Container herstarten..." echo "Container herstarten..."
docker_cmd restart "$CONTAINER" >/dev/null 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 else
echo "Waarschuwing: container '$CONTAINER' niet gevonden — binary is wel bijgewerkt." echo "Waarschuwing: container '$CONTAINER' niet gevonden — binary is wel bijgewerkt."
fi fi

View file

@ -24,7 +24,7 @@ import (
"github.com/sthmedia/media-node/internal/streaming" "github.com/sthmedia/media-node/internal/streaming"
) )
var version = "1.3.6" var version = "1.3.7"
func main() { func main() {
if len(os.Args) < 2 { if len(os.Args) < 2 {

View file

@ -26,6 +26,7 @@ type MediaFileInfo struct {
VideoCodec string `json:"videoCodec,omitempty"` VideoCodec string `json:"videoCodec,omitempty"`
AudioCodec string `json:"audioCodec,omitempty"` AudioCodec string `json:"audioCodec,omitempty"`
ReleaseName string `json:"releaseName"` ReleaseName string `json:"releaseName"`
Path string `json:"path,omitempty"`
ModifiedAt string `json:"modifiedAt"` ModifiedAt string `json:"modifiedAt"`
MediaType string `json:"mediaType"` MediaType string `json:"mediaType"`
ShelfID string `json:"shelfId,omitempty"` ShelfID string `json:"shelfId,omitempty"`
@ -712,6 +713,7 @@ func buildMediaInfo(f database.LocalFile) MediaFileInfo {
SizeBytes: f.SizeBytes, SizeBytes: f.SizeBytes,
Container: strings.TrimPrefix(filepath.Ext(f.ReleaseName), "."), Container: strings.TrimPrefix(filepath.Ext(f.ReleaseName), "."),
ReleaseName: f.ReleaseName, ReleaseName: f.ReleaseName,
Path: f.Path,
ModifiedAt: f.ModifiedAt.UTC().Format(time.RFC3339), ModifiedAt: f.ModifiedAt.UTC().Format(time.RFC3339),
MediaType: f.MediaType, MediaType: f.MediaType,
} }

View file

@ -68,6 +68,8 @@ export interface MediaFileInfo {
videoCodec?: string; videoCodec?: string;
audioCodec?: string; audioCodec?: string;
releaseName: string; releaseName: string;
/** Absolute host path on the media-node (for admin diagnostics / rematch). */
path?: string;
modifiedAt: string; modifiedAt: string;
mediaType: "movie" | "episode"; mediaType: "movie" | "episode";
/** Library shelf this file belongs to (cross-node catalog) */ /** Library shelf this file belongs to (cross-node catalog) */