diff --git a/apps/admin-ui/src/app/nodes/page.tsx b/apps/admin-ui/src/app/nodes/page.tsx index b1b7cfc..ff0fc33 100644 --- a/apps/admin-ui/src/app/nodes/page.tsx +++ b/apps/admin-ui/src/app/nodes/page.tsx @@ -163,6 +163,27 @@ export default function NodesPage() { alert("Herstart aangevraagd"); } + async function upgradeNode(id: string, name: string) { + const res = await fetch(`/api/v1/admin/nodes/${id}/upgrade-command`, { + credentials: "include", + }); + if (!res.ok) { + const data = await res.json(); + alert(data.error?.message ?? "Upgrade-commando ophalen mislukt"); + return; + } + const data = (await res.json()) as { + command: string; + currentVersion: string | null; + targetVersion: string; + platform: string; + }; + await navigator.clipboard.writeText(data.command); + alert( + `Upgrade-commando gekopieerd voor "${name}".\n\nPlak in SSH/terminal op de ${data.platform === "synology" ? "Synology" : "Unraid"}-host:\n\n${data.command}\n\nHuidig: ${data.currentVersion ?? "?"} → ${data.targetVersion}` + ); + } + async function revokeNode(id: string) { if (!confirm("Weet je zeker dat je deze node wilt intrekken?")) return; await fetch(`/api/v1/admin/nodes/${id}/revoke`, { @@ -513,10 +534,20 @@ export default function NodesPage() { {node.libraryFileCount} {node.activeStreams} - {node.version ?? "-"} + + {node.version ?? "-"} + {node.version && node.version !== "1.3.2" && ( + update + )} + + {node.version !== "1.3.2" && ( + + )} diff --git a/apps/master-api/src/admin/routes.ts b/apps/master-api/src/admin/routes.ts index 9f30091..ccec73c 100644 --- a/apps/master-api/src/admin/routes.ts +++ b/apps/master-api/src/admin/routes.ts @@ -520,6 +520,64 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config) return result; }); + app.get("/api/v1/admin/library/lookup", { preHandler: requireAdmin }, async (request) => { + const query = request.query as { q?: string }; + const q = (query.q ?? "").trim(); + if (!q) throw new AppError("INVALID_REQUEST", "q is verplicht", 400); + + const [movies, mediaFiles] = await Promise.all([ + prisma.movie.findMany({ + where: { title: { contains: q, mode: "insensitive" } }, + orderBy: { title: "asc" }, + take: 20, + include: { + mediaFiles: { + select: { + id: true, + releaseName: true, + available: true, + createdAt: true, + nodeId: true, + }, + orderBy: { createdAt: "desc" }, + }, + }, + }), + prisma.mediaFile.findMany({ + where: { releaseName: { contains: q, mode: "insensitive" } }, + orderBy: { createdAt: "desc" }, + take: 30, + include: { + movie: { select: { id: true, title: true, year: true, tmdbId: true, imdbId: true } }, + node: { select: { id: true, name: true } }, + }, + }), + ]); + + return { + query: q, + movies: movies.map((m) => ({ + id: m.id, + title: m.title, + year: m.year, + fileCount: m.mediaFiles.length, + availableFiles: m.mediaFiles.filter((f) => f.available).length, + files: m.mediaFiles, + })), + mediaFiles: mediaFiles.map((f) => ({ + id: f.id, + releaseName: f.releaseName, + available: f.available, + movieId: f.movieId, + movieTitle: f.movie?.title ?? null, + nodeName: f.node?.name ?? f.nodeId, + createdAt: f.createdAt, + matched: Boolean(f.movieId || f.episodeId), + })), + unmatchedFiles: mediaFiles.filter((f) => !f.movieId && !f.episodeId).length, + }; + }); + app.post("/api/v1/admin/library/repair", { preHandler: requireAdmin }, async (request) => { request.log.info("library repair started"); const started = Date.now(); diff --git a/apps/master-api/src/install/routes.ts b/apps/master-api/src/install/routes.ts index 27b2ea2..0e5d18d 100644 --- a/apps/master-api/src/install/routes.ts +++ b/apps/master-api/src/install/routes.ts @@ -12,7 +12,8 @@ function installDir(): string { for (const dir of candidates) { if ( fs.existsSync(path.join(dir, "unraid.sh")) || - fs.existsSync(path.join(dir, "synology.sh")) + fs.existsSync(path.join(dir, "synology.sh")) || + fs.existsSync(path.join(dir, "upgrade-node.sh")) ) { return dir; } @@ -51,6 +52,10 @@ export async function registerInstallRoutes(app: FastifyInstance, config: Config return sendInstallScript(reply, dir, "synology.sh", publicUrl); }); + app.get("/install/upgrade-node.sh", async (_request, reply) => { + return sendInstallScript(reply, dir, "upgrade-node.sh", publicUrl); + }); + for (const bin of ["media-node-linux-amd64", "media-node-linux-arm64"] as const) { app.get(`/install/${bin}`, async (_request, reply) => { const file = path.join(dir, bin); diff --git a/apps/master-api/src/media/sync.ts b/apps/master-api/src/media/sync.ts index dd3584e..59befa9 100644 --- a/apps/master-api/src/media/sync.ts +++ b/apps/master-api/src/media/sync.ts @@ -12,7 +12,14 @@ export class LibrarySyncService { switch (event.type) { case "FILE_ADDED": case "FILE_UPDATED": - await this.metadata.matchMediaFile(nodeId, event.file); + try { + await this.metadata.matchMediaFile(nodeId, event.file); + } catch (err) { + console.error( + `Library sync: failed to match ${event.file.releaseName} on node ${nodeId}:`, + err + ); + } break; case "FILE_REMOVED": await prisma.mediaFile.updateMany({ @@ -48,7 +55,14 @@ export class LibrarySyncService { ): Promise { for (const file of files) { options.fileIdsSoFar.add(file.localFileId); - await this.metadata.matchMediaFile(nodeId, file); + try { + await this.metadata.matchMediaFile(nodeId, file); + } catch (err) { + console.error( + `Library full sync: failed to match ${file.releaseName} on node ${nodeId}:`, + err + ); + } } if (!options.isLast) { diff --git a/apps/master-api/src/metadata/service.ts b/apps/master-api/src/metadata/service.ts index db34895..7d1ce90 100644 --- a/apps/master-api/src/metadata/service.ts +++ b/apps/master-api/src/metadata/service.ts @@ -495,26 +495,7 @@ export class MetadataService { if (this.tmdbApiKey) { const tmdb = await this.searchTmdbMovie(title, year); if (tmdb) { - movie = await prisma.movie.upsert({ - where: { tmdbId: tmdb.id }, - create: { - title: tmdb.title, - year: tmdb.year, - overview: tmdb.overview, - posterUrl: tmdb.posterUrl, - backdropUrl: tmdb.backdropUrl, - tmdbId: tmdb.id, - ...(tmdb.imdbId ? { imdbId: tmdb.imdbId } : {}), - }, - update: { - title: tmdb.title, - overview: tmdb.overview, - posterUrl: tmdb.posterUrl, - backdropUrl: tmdb.backdropUrl, - year: tmdb.year, - ...(tmdb.imdbId ? { imdbId: tmdb.imdbId } : {}), - }, - }); + movie = await this.upsertMovieFromTmdb(tmdb); await this.absorbOrphanMovies(movie, title, year); } } @@ -1612,6 +1593,46 @@ export class MetadataService { } } + /** TMDB upsert with safe IMDb linking (unique conflicts must not block file matching). */ + private async upsertMovieFromTmdb(tmdb: TmdbMeta): Promise { + const data = { + title: tmdb.title, + year: tmdb.year, + overview: tmdb.overview, + posterUrl: tmdb.posterUrl, + backdropUrl: tmdb.backdropUrl, + tmdbId: tmdb.id, + }; + try { + return await prisma.movie.upsert({ + where: { tmdbId: tmdb.id }, + create: { ...data, ...(tmdb.imdbId ? { imdbId: tmdb.imdbId } : {}) }, + update: { ...data, ...(tmdb.imdbId ? { imdbId: tmdb.imdbId } : {}) }, + }); + } catch { + const owner = await prisma.movie.findFirst({ + where: { + OR: [ + { tmdbId: tmdb.id }, + ...(tmdb.imdbId ? [{ imdbId: tmdb.imdbId }] : []), + ], + }, + }); + if (owner) { + return prisma.movie.update({ + where: { id: owner.id }, + data: { + ...data, + ...(tmdb.imdbId && !owner.imdbId ? { imdbId: tmdb.imdbId } : {}), + }, + }); + } + return prisma.movie.create({ + data: { ...data, ...(tmdb.imdbId ? { imdbId: tmdb.imdbId } : {}) }, + }); + } + } + private async upsertMediaFile( nodeId: string, file: MediaFileInfo, diff --git a/apps/master-api/src/nodes/routes.ts b/apps/master-api/src/nodes/routes.ts index 0d845f4..2e0febb 100644 --- a/apps/master-api/src/nodes/routes.ts +++ b/apps/master-api/src/nodes/routes.ts @@ -1,5 +1,6 @@ import type { FastifyInstance } from "fastify"; import { prisma } from "../database/client"; +import { loadConfig } from "../config"; import { generateApiKey, generateEnrollmentToken, @@ -368,6 +369,34 @@ export async function registerNodeRoutes(app: FastifyInstance) { return { ok: true }; }); + app.get("/api/v1/admin/nodes/:id/upgrade-command", { preHandler: requireAdmin }, async (request) => { + const { id } = request.params as { id: string }; + const node = await prisma.node.findUnique({ + where: { id }, + include: { scanRoots: { select: { path: true } } }, + }); + if (!node) throw new AppError("NOT_FOUND", "Node not found", 404); + + const paths = [ + ...node.scanRoots.map((r) => r.path), + ...node.moviesPaths, + ...node.seriesPaths, + ]; + const isSynology = paths.some((p) => p.startsWith("/volume1")); + const appdata = isSynology ? "/volume1/docker/media-node" : "/mnt/user/appdata/media-node"; + const masterUrl = loadConfig().PUBLIC_URL.replace(/\/$/, ""); + const command = `curl -fsSL ${masterUrl}/install/upgrade-node.sh | bash -s -- --appdata=${appdata}`; + + return { + command, + appdata, + platform: isSynology ? "synology" : "unraid", + currentVersion: node.version, + targetVersion: "1.3.2", + needsUpgrade: node.version !== "1.3.2", + }; + }); + app.post("/api/v1/admin/nodes/:id/revoke", { preHandler: requireAdmin }, async (request) => { const { id } = request.params as { id: string }; const node = await prisma.node.update({ diff --git a/deploy/docker/Dockerfile.master-api b/deploy/docker/Dockerfile.master-api index 8594fa8..dd378bd 100644 --- a/deploy/docker/Dockerfile.master-api +++ b/deploy/docker/Dockerfile.master-api @@ -44,6 +44,7 @@ COPY --from=node-build /media-node-linux-amd64 /app/install/media-node-linux-amd COPY --from=node-build /media-node-linux-arm64 /app/install/media-node-linux-arm64 COPY deploy/node/install-unraid.sh /app/install/unraid.sh COPY deploy/node/install-synology.sh /app/install/synology.sh +COPY deploy/node/upgrade-node.sh /app/install/upgrade-node.sh # Remove workspace protocol deps before npm install RUN node -e "\ diff --git a/deploy/node/upgrade-node.sh b/deploy/node/upgrade-node.sh new file mode 100644 index 0000000..d64359e --- /dev/null +++ b/deploy/node/upgrade-node.sh @@ -0,0 +1,67 @@ +#!/bin/bash +# Upgrade media-node binary (download latest from Master + restart container). +set -euo pipefail + +MASTER_URL="${MASTER_URL:-__MASTER_PUBLIC_URL__}" +APPDATA="${APPDATA:-/mnt/user/appdata/media-node}" +CONTAINER="${CONTAINER:-media-node}" + +usage() { + cat </dev/null 2>&1; then + echo "Docker niet gevonden." + exit 1 +fi + +ARCH="$(uname -m)" +case "$ARCH" in + x86_64|amd64) BIN_NAME="media-node-linux-amd64" ;; + aarch64|arm64) BIN_NAME="media-node-linux-arm64" ;; + *) echo "Niet-ondersteunde architectuur: $ARCH"; exit 1 ;; +esac + +BIN_DIR="$APPDATA/bin" +BIN_PATH="$BIN_DIR/media-node" +mkdir -p "$BIN_DIR" + +echo "=== media-node upgrade ===" +echo "Master: $MASTER_URL" +echo "Binary: $BIN_PATH" +echo "Container: $CONTAINER" +echo + +echo "Downloaden ($BIN_NAME)..." +curl -fsSL "$MASTER_URL/install/$BIN_NAME" -o "$BIN_PATH.new" +chmod +x "$BIN_PATH.new" +mv "$BIN_PATH.new" "$BIN_PATH" + +if docker ps -a --format '{{.Names}}' | grep -qx "$CONTAINER"; then + echo "Container herstarten..." + docker restart "$CONTAINER" >/dev/null + echo "Klaar. Controleer versie in Admin → Nodes (verwacht 1.3.2+)." +else + echo "Waarschuwing: container '$CONTAINER' niet gevonden — binary is wel bijgewerkt." +fi diff --git a/docs/unraid-node.md b/docs/unraid-node.md index f3ded17..e98766d 100644 --- a/docs/unraid-node.md +++ b/docs/unraid-node.md @@ -11,3 +11,13 @@ Na Master-redeploy (met install-assets) heb je **geen** Compose of image build n Het script downloadt de binary van de Master en start één Alpine-container. Controle: `docker logs -f media-node` en in Admin of de node **online** is. + +## Node upgraden (zonder herinstall) + +Na een Master-redeploy staat de nieuwste binary op de Master. Op de Unraid-host (SSH): + +```bash +curl -fsSL https://master.vonas.nl/install/upgrade-node.sh | bash -s -- --appdata=/mnt/user/appdata/media-node +``` + +Of via Admin → Nodes → **Upgrade** (kopieert het commando). Controleer daarna of de versie **1.3.2** is.