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 [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" })

View file

@ -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>
)}

View file

@ -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).

View file

@ -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 };

View file

@ -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

View file

@ -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,

View file

@ -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

View file

@ -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";

View file

@ -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 \

View file

@ -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

View file

@ -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 {

View file

@ -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,
}

View file

@ -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) */