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");
|
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) {
|
async function revokeNode(id: string) {
|
||||||
if (!confirm("Weet je zeker dat je deze node wilt intrekken?")) return;
|
if (!confirm("Weet je zeker dat je deze node wilt intrekken?")) return;
|
||||||
await fetch(`/api/v1/admin/nodes/${id}/revoke`, {
|
await fetch(`/api/v1/admin/nodes/${id}/revoke`, {
|
||||||
|
|
@ -513,10 +534,20 @@ export default function NodesPage() {
|
||||||
</td>
|
</td>
|
||||||
<td>{node.libraryFileCount}</td>
|
<td>{node.libraryFileCount}</td>
|
||||||
<td>{node.activeStreams}</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" }}>
|
<td style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
|
||||||
<button onClick={() => openEdit(node)}>Edit</button>
|
<button onClick={() => openEdit(node)}>Edit</button>
|
||||||
<button onClick={() => rescanNode(node.id)}>Rescan</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" }}>
|
<button onClick={() => restartNode(node.id)} style={{ background: "#0e7490" }}>
|
||||||
Herstart
|
Herstart
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -520,6 +520,64 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
|
||||||
return result;
|
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) => {
|
app.post("/api/v1/admin/library/repair", { preHandler: requireAdmin }, async (request) => {
|
||||||
request.log.info("library repair started");
|
request.log.info("library repair started");
|
||||||
const started = Date.now();
|
const started = Date.now();
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,8 @@ function installDir(): string {
|
||||||
for (const dir of candidates) {
|
for (const dir of candidates) {
|
||||||
if (
|
if (
|
||||||
fs.existsSync(path.join(dir, "unraid.sh")) ||
|
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;
|
return dir;
|
||||||
}
|
}
|
||||||
|
|
@ -51,6 +52,10 @@ export async function registerInstallRoutes(app: FastifyInstance, config: Config
|
||||||
return sendInstallScript(reply, dir, "synology.sh", publicUrl);
|
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) {
|
for (const bin of ["media-node-linux-amd64", "media-node-linux-arm64"] as const) {
|
||||||
app.get(`/install/${bin}`, async (_request, reply) => {
|
app.get(`/install/${bin}`, async (_request, reply) => {
|
||||||
const file = path.join(dir, bin);
|
const file = path.join(dir, bin);
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,14 @@ export class LibrarySyncService {
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case "FILE_ADDED":
|
case "FILE_ADDED":
|
||||||
case "FILE_UPDATED":
|
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;
|
break;
|
||||||
case "FILE_REMOVED":
|
case "FILE_REMOVED":
|
||||||
await prisma.mediaFile.updateMany({
|
await prisma.mediaFile.updateMany({
|
||||||
|
|
@ -48,7 +55,14 @@ export class LibrarySyncService {
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
options.fileIdsSoFar.add(file.localFileId);
|
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) {
|
if (!options.isLast) {
|
||||||
|
|
|
||||||
|
|
@ -495,26 +495,7 @@ export class MetadataService {
|
||||||
if (this.tmdbApiKey) {
|
if (this.tmdbApiKey) {
|
||||||
const tmdb = await this.searchTmdbMovie(title, year);
|
const tmdb = await this.searchTmdbMovie(title, year);
|
||||||
if (tmdb) {
|
if (tmdb) {
|
||||||
movie = await prisma.movie.upsert({
|
movie = await this.upsertMovieFromTmdb(tmdb);
|
||||||
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 } : {}),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await this.absorbOrphanMovies(movie, title, year);
|
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(
|
private async upsertMediaFile(
|
||||||
nodeId: string,
|
nodeId: string,
|
||||||
file: MediaFileInfo,
|
file: MediaFileInfo,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import { prisma } from "../database/client";
|
import { prisma } from "../database/client";
|
||||||
|
import { loadConfig } from "../config";
|
||||||
import {
|
import {
|
||||||
generateApiKey,
|
generateApiKey,
|
||||||
generateEnrollmentToken,
|
generateEnrollmentToken,
|
||||||
|
|
@ -368,6 +369,34 @@ export async function registerNodeRoutes(app: FastifyInstance) {
|
||||||
return { ok: true };
|
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) => {
|
app.post("/api/v1/admin/nodes/:id/revoke", { preHandler: requireAdmin }, async (request) => {
|
||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
const node = await prisma.node.update({
|
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 --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-unraid.sh /app/install/unraid.sh
|
||||||
COPY deploy/node/install-synology.sh /app/install/synology.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
|
# Remove workspace protocol deps before npm install
|
||||||
RUN node -e "\
|
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.
|
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.
|
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