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