From a6b0ec15f270735b7301076bc7b0076171546a15 Mon Sep 17 00:00:00 2001 From: Jos Vooges | STH Date: Wed, 9 Sep 2026 01:40:01 +0200 Subject: [PATCH] Show series folders per node and allow path search/rematch. Co-authored-by: Cursor --- apps/admin-ui/src/app/nodes/page.tsx | 2 +- apps/admin-ui/src/app/series/page.tsx | 257 ++++++++++++++--- apps/master-api/prisma/schema.prisma | 2 + apps/master-api/src/admin/routes.ts | 270 +++++++++++++++--- apps/master-api/src/app.ts | 6 + .../src/metadata/filename-parser.ts | 14 + apps/master-api/src/metadata/service.ts | 5 +- apps/master-api/src/nodes/version.ts | 2 +- deploy/docker/Dockerfile.master-api | 2 +- deploy/node/upgrade-node.sh | 2 +- node/media-node/cmd/media-node/main.go | 2 +- node/media-node/internal/scanner/scanner.go | 2 + packages/shared-types/src/index.ts | 2 + 13 files changed, 495 insertions(+), 73 deletions(-) diff --git a/apps/admin-ui/src/app/nodes/page.tsx b/apps/admin-ui/src/app/nodes/page.tsx index 6b1d35d..b0b560c 100644 --- a/apps/admin-ui/src/app/nodes/page.tsx +++ b/apps/admin-ui/src/app/nodes/page.tsx @@ -65,7 +65,7 @@ export default function NodesPage() { const [editRoots, setEditRoots] = useState([]); const [editBusy, setEditBusy] = useState(false); const [upgradingId, setUpgradingId] = useState(null); - const [targetNodeVersion, setTargetNodeVersion] = useState("1.3.6"); + const [targetNodeVersion, setTargetNodeVersion] = useState("1.3.7"); function loadNodes() { fetch("/api/v1/admin/nodes", { credentials: "include" }) diff --git a/apps/admin-ui/src/app/series/page.tsx b/apps/admin-ui/src/app/series/page.tsx index eb0b39a..bd69145 100644 --- a/apps/admin-ui/src/app/series/page.tsx +++ b/apps/admin-ui/src/app/series/page.tsx @@ -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([]); + const [pathFiles, setPathFiles] = useState(null); const [nodes, setNodes] = useState([]); const [nodeId, setNodeId] = useState(""); const [search, setSearch] = useState(""); + const [pathSearch, setPathSearch] = useState(""); const [expanded, setExpanded] = useState(null); + const [busyFileId, setBusyFileId] = useState(null); + const [msg, setMsg] = useState(""); const [linkSeriesId, setLinkSeriesId] = useState(null); const [linkQuery, setLinkQuery] = useState(""); const [linkHits, setLinkHits] = useState([]); 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() { setSearch(e.target.value)} - placeholder="Zoeken..." - style={{ width: 240, marginBottom: 0 }} + placeholder="Zoek titel..." + style={{ width: 180, marginBottom: 0 }} + /> + setPathSearch(e.target.value)} + placeholder="Zoek map/pad… (bv. Breaking Bad)" + style={{ width: 260, marginBottom: 0 }} />

+ 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()}”.` : ""}

+ {msg && ( +

+ {msg} +

+ )} + + {pathFiles && ( +
+

+ Bestanden op pad ({pathFiles.length}) +

+ {pathFiles.length === 0 ? ( +

+ Geen bestanden. Na node-upgrade 1.3.7+ eerst rescan zodat paden worden opgeslagen. +

+ ) : ( +
+ {pathFiles.map((f) => ( +
+
{f.path || f.releaseName}
+
+ {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}` + : "" + }` + : ""} +
+ +
+ ))} +
+ )} +
+ )} + {series.map((s) => (
{(s.nodes?.length ?? 0) > 0 && ( -
+
{s.nodes!.map((n) => ( - - {n.name} · {n.fileCount} - +
+ + {n.name} · {n.fileCount} files + + {(n.folders?.length ?? 0) > 0 ? ( +
+ {n.folders!.map((f) => ( +
+ {f.folder}{" "} + ({f.fileCount}) +
+ ))} +
+ ) : ( +
+ Map onbekend — upgrade node naar 1.3.7+ en rescan +
+ )} +
))}
)} @@ -276,7 +440,7 @@ export default function SeriesPage() { {expanded === s.id && (
-
+
+
+ ))}
))}
@@ -313,8 +504,8 @@ export default function SeriesPage() { {series.length === 0 && (
- {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"}
)} diff --git a/apps/master-api/prisma/schema.prisma b/apps/master-api/prisma/schema.prisma index d18cfe9..d70b617 100644 --- a/apps/master-api/prisma/schema.prisma +++ b/apps/master-api/prisma/schema.prisma @@ -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). diff --git a/apps/master-api/src/admin/routes.ts b/apps/master-api/src/admin/routes.ts index cbd1144..5bb85bd 100644 --- a/apps/master-api/src/admin/routes.ts +++ b/apps/master-api/src/admin/routes.ts @@ -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(); + 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 } + >(); 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 }; diff --git a/apps/master-api/src/app.ts b/apps/master-api/src/app.ts index 4b8433f..63fbd81 100644 --- a/apps/master-api/src/app.ts +++ b/apps/master-api/src/app.ts @@ -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 diff --git a/apps/master-api/src/metadata/filename-parser.ts b/apps/master-api/src/metadata/filename-parser.ts index 15246a0..12376d6 100644 --- a/apps/master-api/src/metadata/filename-parser.ts +++ b/apps/master-api/src/metadata/filename-parser.ts @@ -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, diff --git a/apps/master-api/src/metadata/service.ts b/apps/master-api/src/metadata/service.ts index 97a6b5c..510bed0 100644 --- a/apps/master-api/src/metadata/service.ts +++ b/apps/master-api/src/metadata/service.ts @@ -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 diff --git a/apps/master-api/src/nodes/version.ts b/apps/master-api/src/nodes/version.ts index 00e12f3..e6a167e 100644 --- a/apps/master-api/src/nodes/version.ts +++ b/apps/master-api/src/nodes/version.ts @@ -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"; diff --git a/deploy/docker/Dockerfile.master-api b/deploy/docker/Dockerfile.master-api index bbb88a9..e353a7b 100644 --- a/deploy/docker/Dockerfile.master-api +++ b/deploy/docker/Dockerfile.master-api @@ -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 \ diff --git a/deploy/node/upgrade-node.sh b/deploy/node/upgrade-node.sh index 24c4eff..9863b52 100644 --- a/deploy/node/upgrade-node.sh +++ b/deploy/node/upgrade-node.sh @@ -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 diff --git a/node/media-node/cmd/media-node/main.go b/node/media-node/cmd/media-node/main.go index 5d2ee48..0f0b05f 100644 --- a/node/media-node/cmd/media-node/main.go +++ b/node/media-node/cmd/media-node/main.go @@ -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 { diff --git a/node/media-node/internal/scanner/scanner.go b/node/media-node/internal/scanner/scanner.go index dd0bdab..e835dc4 100644 --- a/node/media-node/internal/scanner/scanner.go +++ b/node/media-node/internal/scanner/scanner.go @@ -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, } diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts index 87674a7..4adc0aa 100644 --- a/packages/shared-types/src/index.ts +++ b/packages/shared-types/src/index.ts @@ -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) */