Fix silent TMDB matching failures and add node upgrade tooling.

Survive IMDb unique conflicts during movie matching, log per-file sync errors, add library lookup diagnostic, and ship upgrade-node.sh plus Admin upgrade button for Unraid/Synology nodes still on 1.2.9.
This commit is contained in:
Jos Vooges | STH 2026-09-02 02:55:53 +02:00
parent 3b3d796452
commit d2046904a0
9 changed files with 260 additions and 24 deletions

View file

@ -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() {
</td>
<td>{node.libraryFileCount}</td>
<td>{node.activeStreams}</td>
<td>{node.version ?? "-"}</td>
<td>
{node.version ?? "-"}
{node.version && node.version !== "1.3.2" && (
<span style={{ color: "#f59e0b", fontSize: "0.7rem", marginLeft: 4 }}>update</span>
)}
</td>
<td style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
<button onClick={() => openEdit(node)}>Edit</button>
<button onClick={() => rescanNode(node.id)}>Rescan</button>
{node.version !== "1.3.2" && (
<button onClick={() => upgradeNode(node.id, node.name)} style={{ background: "#7c3aed" }}>
Upgrade
</button>
)}
<button onClick={() => restartNode(node.id)} style={{ background: "#0e7490" }}>
Herstart
</button>

View file

@ -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();

View file

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

View file

@ -12,7 +12,14 @@ export class LibrarySyncService {
switch (event.type) {
case "FILE_ADDED":
case "FILE_UPDATED":
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<void> {
for (const file of files) {
options.fileIdsSoFar.add(file.localFileId);
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) {

View file

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

View file

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

View file

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

View file

@ -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 <<EOF
Gebruik:
curl -fsSL ${MASTER_URL}/install/upgrade-node.sh | bash -s -- --appdata=/mnt/user/appdata/media-node
Opties:
--appdata= Appdata-map met bin/media-node (Unraid: /mnt/user/appdata/media-node, Synology: /volume1/docker/media-node)
--container= Docker-containernaam (default: media-node)
--master= Master-URL (default: ${MASTER_URL})
EOF
}
for arg in "$@"; do
case "$arg" in
--appdata=*) APPDATA="${arg#*=}" ;;
--container=*) CONTAINER="${arg#*=}" ;;
--master=*) MASTER_URL="${arg#*=}" ;;
-h|--help) usage; exit 0 ;;
*) echo "Onbekende optie: $arg"; usage; exit 1 ;;
esac
done
MASTER_URL="$(echo -n "$MASTER_URL" | sed 's:/*$::')"
APPDATA="$(echo -n "$APPDATA" | tr -d '\r\n')"
if ! command -v docker >/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

View file

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