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:
parent
3b3d796452
commit
d2046904a0
9 changed files with 260 additions and 24 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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 "\
|
||||
|
|
|
|||
67
deploy/node/upgrade-node.sh
Normal file
67
deploy/node/upgrade-node.sh
Normal 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
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue