diff --git a/apps/admin-ui/src/app/nodes/page.tsx b/apps/admin-ui/src/app/nodes/page.tsx index 65cbc62..e7e7075 100644 --- a/apps/admin-ui/src/app/nodes/page.tsx +++ b/apps/admin-ui/src/app/nodes/page.tsx @@ -65,7 +65,7 @@ export default function NodesPage() { const [editRoots, setEditRoots] = useState([]); const [editBusy, setEditBusy] = useState(false); const [upgradingId, setUpgradingId] = useState(null); - const [targetNodeVersion, setTargetNodeVersion] = useState("1.3.10"); + const [targetNodeVersion, setTargetNodeVersion] = useState("1.3.11"); function loadNodes() { fetch("/api/v1/admin/nodes", { credentials: "include" }) diff --git a/apps/master-api/src/metadata/service.ts b/apps/master-api/src/metadata/service.ts index b3cf8d1..1a0257e 100644 --- a/apps/master-api/src/metadata/service.ts +++ b/apps/master-api/src/metadata/service.ts @@ -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 { for (let attempt = 0; attempt < retries; attempt++) { const res = await fetch(url); @@ -438,6 +445,9 @@ export class MetadataService { nodeId: string, file: MediaFileInfo ): Promise { + // 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 { 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. */ diff --git a/apps/master-api/src/nodes/version.ts b/apps/master-api/src/nodes/version.ts index 93b1053..e324daf 100644 --- a/apps/master-api/src/nodes/version.ts +++ b/apps/master-api/src/nodes/version.ts @@ -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"; diff --git a/deploy/docker/Dockerfile.master-api b/deploy/docker/Dockerfile.master-api index ef46f91..a42216f 100644 --- a/deploy/docker/Dockerfile.master-api +++ b/deploy/docker/Dockerfile.master-api @@ -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 \ diff --git a/deploy/node/upgrade-node.sh b/deploy/node/upgrade-node.sh index 140316e..a2af753 100644 --- a/deploy/node/upgrade-node.sh +++ b/deploy/node/upgrade-node.sh @@ -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 diff --git a/node/media-node/cmd/media-node/main.go b/node/media-node/cmd/media-node/main.go index 972b2c0..529f3b8 100644 --- a/node/media-node/cmd/media-node/main.go +++ b/node/media-node/cmd/media-node/main.go @@ -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 { diff --git a/node/media-node/internal/scanner/scanner.go b/node/media-node/internal/scanner/scanner.go index e835dc4..06cf9e4 100644 --- a/node/media-node/internal/scanner/scanner.go +++ b/node/media-node/internal/scanner/scanner.go @@ -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() {