Prevent HELLO sync before startup scan finishes and reparse paths on master.

Bump media-node to 1.3.11 so Season 10 Plex-style files are indexed and synced reliably after upgrade.
This commit is contained in:
Jos Vooges | STH 2026-09-17 02:24:52 +02:00
parent af1f471b29
commit 7e2dd68da9
7 changed files with 110 additions and 28 deletions

View file

@ -65,7 +65,7 @@ export default function NodesPage() {
const [editRoots, setEditRoots] = useState<ScanRootRow[]>([]);
const [editBusy, setEditBusy] = useState(false);
const [upgradingId, setUpgradingId] = useState<string | null>(null);
const [targetNodeVersion, setTargetNodeVersion] = useState("1.3.10");
const [targetNodeVersion, setTargetNodeVersion] = useState("1.3.11");
function loadNodes() {
fetch("/api/v1/admin/nodes", { credentials: "include" })

View file

@ -1,6 +1,6 @@
import { prisma } from "../database/client";
import type { MediaFileInfo } from "@media-cluster/shared-types";
import { parseSeriesFromPath } from "./filename-parser";
import { parseMovieFromPath, parseSeriesFromPath } from "./filename-parser";
import { OmdbClient } from "./omdb";
function normalizeFolderPrefix(folder: string): string {
@ -119,6 +119,13 @@ export function seriesTitlesMatchConfident(local: string, remote: string): boole
return false;
}
/** "The Blacklist Season 10" → "The Blacklist" */
export function stripTrailingSeasonLabel(title: string): string {
return title
.replace(/\s*[-–—:]?\s*(?:season|seizoen)\s*\d{1,2}\s*$/i, "")
.trim();
}
async function fetchTmdbJson(url: string, retries = 4): Promise<Response | null> {
for (let attempt = 0; attempt < retries; attempt++) {
const res = await fetch(url);
@ -438,6 +445,9 @@ export class MetadataService {
nodeId: string,
file: MediaFileInfo
): Promise<string | null> {
// Master herparst het pad (Plex S10E01 Title.mkv e.d.) — betrouwbaarder dan alleen node-parse.
file = this.enrichParsedFromPath(file);
// Fast path: already matched → only refresh shelf / availability (no TMDB).
const existing = await prisma.mediaFile.findUnique({
where: {
@ -482,9 +492,54 @@ export class MetadataService {
if (file.mediaType === "episode" && file.parsedEpisode) {
return this.matchEpisode(nodeId, file);
}
// Geen parse → toch indexeren zodat Admin → Nog te koppelen het ziet.
if (file.path || file.releaseName) {
await this.upsertMediaFile(nodeId, file, {});
}
return null;
}
/** Re-parse absolute path on master (fixes Plex-style names even if node missed them). */
private enrichParsedFromPath(file: MediaFileInfo): MediaFileInfo {
const source = file.path?.trim() || file.releaseName?.trim();
if (!source) return file;
const asEpisode = parseSeriesFromPath(source);
if (asEpisode) {
const season = Number(asEpisode.season);
const episode = Number(asEpisode.episode);
if (Number.isFinite(season) && Number.isFinite(episode) && season > 0 && episode > 0) {
return {
...file,
mediaType: "episode",
parsedEpisode: {
...asEpisode,
season,
episode,
},
parsedMovie: undefined,
resolution: asEpisode.resolution ?? file.resolution,
videoCodec: asEpisode.videoCodec ?? file.videoCodec,
audioCodec: asEpisode.audio ?? file.audioCodec,
};
}
}
if (file.mediaType === "movie" || !file.parsedEpisode) {
const asMovie = parseMovieFromPath(source);
if (asMovie) {
return {
...file,
mediaType: "movie",
parsedMovie: asMovie,
parsedEpisode: undefined,
resolution: asMovie.resolution ?? file.resolution,
videoCodec: asMovie.videoCodec ?? file.videoCodec,
audioCodec: asMovie.audio ?? file.audioCodec,
};
}
}
return file;
}
/** Merge duplicate movies/series that share a title (prefer TMDB canonical). */
async dedupeLibrary(): Promise<{ moviesMerged: number; seriesMerged: number; emptied: number }> {
let moviesMerged = 0;
@ -707,7 +762,13 @@ export class MetadataService {
private async matchEpisode(nodeId: string, file: MediaFileInfo): Promise<string | null> {
const parsed = file.parsedEpisode!;
const title = parsed.title.trim();
const title = stripTrailingSeasonLabel(parsed.title.trim());
const season = Number(parsed.season);
const episode = Number(parsed.episode);
if (!title || !Number.isFinite(season) || !Number.isFinite(episode) || season <= 0 || episode <= 0) {
await this.upsertMediaFile(nodeId, file, {});
return null;
}
// Alleen koppelen aan een al-geïdentificeerde serie (TMDB/IMDb), nooit aan orphan-titels.
let series: SeriesRow | null = await this.findExistingCanonicalSeries(title);
@ -769,52 +830,52 @@ export class MetadataService {
return null;
}
let season = await prisma.season.findUnique({
let seasonRow = await prisma.season.findUnique({
where: {
seriesId_seasonNumber: {
seriesId: series.id,
seasonNumber: parsed.season,
seasonNumber: season,
},
},
});
if (!season) {
season = await prisma.season.create({
if (!seasonRow) {
seasonRow = await prisma.season.create({
data: {
seriesId: series.id,
seasonNumber: parsed.season,
seasonNumber: season,
},
});
}
let episode = await prisma.episode.findUnique({
let episodeRow = await prisma.episode.findUnique({
where: {
seriesId_seasonNumber_episodeNumber: {
seriesId: series.id,
seasonNumber: parsed.season,
episodeNumber: parsed.episode,
seasonNumber: season,
episodeNumber: episode,
},
},
});
if (!episode) {
episode = await prisma.episode.create({
if (!episodeRow) {
episodeRow = await prisma.episode.create({
data: {
seriesId: series.id,
seasonId: season.id,
seasonNumber: parsed.season,
episodeNumber: parsed.episode,
title: `Episode ${parsed.episode}`,
seasonId: seasonRow.id,
seasonNumber: season,
episodeNumber: episode,
title: `Episode ${episode}`,
},
});
}
if (series.tmdbId && this.tmdbApiKey) {
episode = await this.applyTmdbEpisodeMeta(series.tmdbId, episode);
episodeRow = await this.applyTmdbEpisodeMeta(series.tmdbId, episodeRow);
}
await this.upsertMediaFile(nodeId, file, { episodeId: episode.id, movieId: null });
return episode.id;
await this.upsertMediaFile(nodeId, file, { episodeId: episodeRow.id, movieId: null });
return episodeRow.id;
}
/** Ensure series has logo/poster from TMDB (clearlogo for Stremio detail page). */
@ -2542,9 +2603,10 @@ export class MetadataService {
/** Alleen series met TMDB of IMDb — orphan titel-records tellen niet als match. */
private async findExistingCanonicalSeries(title: string) {
return prisma.series.findFirst({
const cleaned = stripTrailingSeasonLabel(title.trim());
const direct = await prisma.series.findFirst({
where: {
title: { equals: title, mode: "insensitive" },
title: { equals: cleaned, mode: "insensitive" },
OR: [{ tmdbId: { not: null } }, { imdbId: { not: null } }],
},
orderBy: [
@ -2553,6 +2615,20 @@ export class MetadataService {
{ createdAt: "asc" },
],
});
if (direct) return direct;
const key = normalizeSeriesKey(cleaned);
if (!key) return null;
const candidates = await prisma.series.findMany({
where: { OR: [{ tmdbId: { not: null } }, { imdbId: { not: null } }] },
orderBy: [
{ imdbId: { sort: "desc", nulls: "last" } },
{ tmdbId: { sort: "desc", nulls: "last" } },
{ createdAt: "asc" },
],
take: 5000,
});
return candidates.find((s) => normalizeSeriesKey(s.title) === key) ?? null;
}
/** OMDb titel-match voor scanner (na TMDB). Alleen bij confident title match. */

View file

@ -1,6 +1,6 @@
/** Target media-node version served from /install/media-node-linux-* */
export const MEDIA_NODE_TARGET_VERSION =
process.env.MEDIA_NODE_VERSION?.trim() || "1.3.10";
process.env.MEDIA_NODE_VERSION?.trim() || "1.3.11";
/** Minimum node version that handles UPGRADE over WebSocket */
const REMOTE_UPGRADE_MIN_VERSION = "1.3.3";

View file

@ -4,7 +4,7 @@ WORKDIR /app
FROM golang:1.22-alpine AS node-build
WORKDIR /src
ARG MEDIA_NODE_VERSION=1.3.10
ARG MEDIA_NODE_VERSION=1.3.11
RUN apk add --no-cache git ca-certificates
COPY node/media-node/ ./
RUN go mod tidy \

View file

@ -74,7 +74,7 @@ mv "$BIN_PATH.new" "$BIN_PATH"
if docker_cmd ps -a --format '{{.Names}}' | grep -qx "$CONTAINER"; then
echo "Container herstarten..."
docker_cmd restart "$CONTAINER" >/dev/null
echo "Klaar. Controleer versie in Admin → Nodes (verwacht ${MEDIA_NODE_VERSION:-1.3.10}+)."
echo "Klaar. Controleer versie in Admin → Nodes (verwacht ${MEDIA_NODE_VERSION:-1.3.11}+)."
else
echo "Waarschuwing: container '$CONTAINER' niet gevonden — binary is wel bijgewerkt."
fi

View file

@ -24,7 +24,7 @@ import (
"github.com/sthmedia/media-node/internal/streaming"
)
var version = "1.3.10"
var version = "1.3.11"
func main() {
if len(os.Args) < 2 {

View file

@ -50,6 +50,7 @@ type Scanner struct {
mu sync.Mutex
scanning bool
rescanQueued bool
startupScanDone bool
status string
resetSchedule chan struct{}
reloadWatch chan struct{}
@ -79,11 +80,11 @@ func (s *Scanner) Status() string {
return s.status
}
// WaitUntilIdle blocks until no full scan is running or queued.
// WaitUntilIdle blocks until no full scan is running/queued and the startup inventory finished once.
func (s *Scanner) WaitUntilIdle() {
for {
s.mu.Lock()
busy := s.scanning || s.rescanQueued
busy := s.scanning || s.rescanQueued || !s.startupScanDone
s.mu.Unlock()
if !busy {
return
@ -94,6 +95,8 @@ func (s *Scanner) WaitUntilIdle() {
func (s *Scanner) Start() {
// One inventory pass at boot, then realtime watch + scheduled catch-up.
// startupScanDone stays false until that pass finishes so HELLO sync cannot
// race ahead with a stale/empty inventory.
go s.runStartupScan()
go s.runNightlyFullScan()
go s.runIntervalFullScan()
@ -181,6 +184,9 @@ func mediaRootsEqual(a, b []config.MediaRoot) bool {
func (s *Scanner) runStartupScan() {
log.Println("Startup library inventory scan...")
s.FullScan()
s.mu.Lock()
s.startupScanDone = true
s.mu.Unlock()
}
func (s *Scanner) runNightlyFullScan() {