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:
parent
af1f471b29
commit
7e2dd68da9
7 changed files with 110 additions and 28 deletions
|
|
@ -65,7 +65,7 @@ export default function NodesPage() {
|
||||||
const [editRoots, setEditRoots] = useState<ScanRootRow[]>([]);
|
const [editRoots, setEditRoots] = useState<ScanRootRow[]>([]);
|
||||||
const [editBusy, setEditBusy] = useState(false);
|
const [editBusy, setEditBusy] = useState(false);
|
||||||
const [upgradingId, setUpgradingId] = useState<string | null>(null);
|
const [upgradingId, setUpgradingId] = useState<string | null>(null);
|
||||||
const [targetNodeVersion, setTargetNodeVersion] = useState("1.3.10");
|
const [targetNodeVersion, setTargetNodeVersion] = useState("1.3.11");
|
||||||
|
|
||||||
function loadNodes() {
|
function loadNodes() {
|
||||||
fetch("/api/v1/admin/nodes", { credentials: "include" })
|
fetch("/api/v1/admin/nodes", { credentials: "include" })
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { prisma } from "../database/client";
|
import { prisma } from "../database/client";
|
||||||
import type { MediaFileInfo } from "@media-cluster/shared-types";
|
import type { MediaFileInfo } from "@media-cluster/shared-types";
|
||||||
import { parseSeriesFromPath } from "./filename-parser";
|
import { parseMovieFromPath, parseSeriesFromPath } from "./filename-parser";
|
||||||
import { OmdbClient } from "./omdb";
|
import { OmdbClient } from "./omdb";
|
||||||
|
|
||||||
function normalizeFolderPrefix(folder: string): string {
|
function normalizeFolderPrefix(folder: string): string {
|
||||||
|
|
@ -119,6 +119,13 @@ export function seriesTitlesMatchConfident(local: string, remote: string): boole
|
||||||
return false;
|
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> {
|
async function fetchTmdbJson(url: string, retries = 4): Promise<Response | null> {
|
||||||
for (let attempt = 0; attempt < retries; attempt++) {
|
for (let attempt = 0; attempt < retries; attempt++) {
|
||||||
const res = await fetch(url);
|
const res = await fetch(url);
|
||||||
|
|
@ -438,6 +445,9 @@ export class MetadataService {
|
||||||
nodeId: string,
|
nodeId: string,
|
||||||
file: MediaFileInfo
|
file: MediaFileInfo
|
||||||
): Promise<string | null> {
|
): 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).
|
// Fast path: already matched → only refresh shelf / availability (no TMDB).
|
||||||
const existing = await prisma.mediaFile.findUnique({
|
const existing = await prisma.mediaFile.findUnique({
|
||||||
where: {
|
where: {
|
||||||
|
|
@ -482,9 +492,54 @@ export class MetadataService {
|
||||||
if (file.mediaType === "episode" && file.parsedEpisode) {
|
if (file.mediaType === "episode" && file.parsedEpisode) {
|
||||||
return this.matchEpisode(nodeId, file);
|
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;
|
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). */
|
/** Merge duplicate movies/series that share a title (prefer TMDB canonical). */
|
||||||
async dedupeLibrary(): Promise<{ moviesMerged: number; seriesMerged: number; emptied: number }> {
|
async dedupeLibrary(): Promise<{ moviesMerged: number; seriesMerged: number; emptied: number }> {
|
||||||
let moviesMerged = 0;
|
let moviesMerged = 0;
|
||||||
|
|
@ -707,7 +762,13 @@ export class MetadataService {
|
||||||
|
|
||||||
private async matchEpisode(nodeId: string, file: MediaFileInfo): Promise<string | null> {
|
private async matchEpisode(nodeId: string, file: MediaFileInfo): Promise<string | null> {
|
||||||
const parsed = file.parsedEpisode!;
|
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.
|
// Alleen koppelen aan een al-geïdentificeerde serie (TMDB/IMDb), nooit aan orphan-titels.
|
||||||
let series: SeriesRow | null = await this.findExistingCanonicalSeries(title);
|
let series: SeriesRow | null = await this.findExistingCanonicalSeries(title);
|
||||||
|
|
@ -769,52 +830,52 @@ export class MetadataService {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
let season = await prisma.season.findUnique({
|
let seasonRow = await prisma.season.findUnique({
|
||||||
where: {
|
where: {
|
||||||
seriesId_seasonNumber: {
|
seriesId_seasonNumber: {
|
||||||
seriesId: series.id,
|
seriesId: series.id,
|
||||||
seasonNumber: parsed.season,
|
seasonNumber: season,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!season) {
|
if (!seasonRow) {
|
||||||
season = await prisma.season.create({
|
seasonRow = await prisma.season.create({
|
||||||
data: {
|
data: {
|
||||||
seriesId: series.id,
|
seriesId: series.id,
|
||||||
seasonNumber: parsed.season,
|
seasonNumber: season,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let episode = await prisma.episode.findUnique({
|
let episodeRow = await prisma.episode.findUnique({
|
||||||
where: {
|
where: {
|
||||||
seriesId_seasonNumber_episodeNumber: {
|
seriesId_seasonNumber_episodeNumber: {
|
||||||
seriesId: series.id,
|
seriesId: series.id,
|
||||||
seasonNumber: parsed.season,
|
seasonNumber: season,
|
||||||
episodeNumber: parsed.episode,
|
episodeNumber: episode,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!episode) {
|
if (!episodeRow) {
|
||||||
episode = await prisma.episode.create({
|
episodeRow = await prisma.episode.create({
|
||||||
data: {
|
data: {
|
||||||
seriesId: series.id,
|
seriesId: series.id,
|
||||||
seasonId: season.id,
|
seasonId: seasonRow.id,
|
||||||
seasonNumber: parsed.season,
|
seasonNumber: season,
|
||||||
episodeNumber: parsed.episode,
|
episodeNumber: episode,
|
||||||
title: `Episode ${parsed.episode}`,
|
title: `Episode ${episode}`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (series.tmdbId && this.tmdbApiKey) {
|
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 });
|
await this.upsertMediaFile(nodeId, file, { episodeId: episodeRow.id, movieId: null });
|
||||||
return episode.id;
|
return episodeRow.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Ensure series has logo/poster from TMDB (clearlogo for Stremio detail page). */
|
/** 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. */
|
/** Alleen series met TMDB of IMDb — orphan titel-records tellen niet als match. */
|
||||||
private async findExistingCanonicalSeries(title: string) {
|
private async findExistingCanonicalSeries(title: string) {
|
||||||
return prisma.series.findFirst({
|
const cleaned = stripTrailingSeasonLabel(title.trim());
|
||||||
|
const direct = await prisma.series.findFirst({
|
||||||
where: {
|
where: {
|
||||||
title: { equals: title, mode: "insensitive" },
|
title: { equals: cleaned, mode: "insensitive" },
|
||||||
OR: [{ tmdbId: { not: null } }, { imdbId: { not: null } }],
|
OR: [{ tmdbId: { not: null } }, { imdbId: { not: null } }],
|
||||||
},
|
},
|
||||||
orderBy: [
|
orderBy: [
|
||||||
|
|
@ -2553,6 +2615,20 @@ export class MetadataService {
|
||||||
{ createdAt: "asc" },
|
{ 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. */
|
/** OMDb titel-match voor scanner (na TMDB). Alleen bij confident title match. */
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
/** Target media-node version served from /install/media-node-linux-* */
|
/** Target media-node version served from /install/media-node-linux-* */
|
||||||
export const MEDIA_NODE_TARGET_VERSION =
|
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 */
|
/** Minimum node version that handles UPGRADE over WebSocket */
|
||||||
const REMOTE_UPGRADE_MIN_VERSION = "1.3.3";
|
const REMOTE_UPGRADE_MIN_VERSION = "1.3.3";
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ WORKDIR /app
|
||||||
|
|
||||||
FROM golang:1.22-alpine AS node-build
|
FROM golang:1.22-alpine AS node-build
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
ARG MEDIA_NODE_VERSION=1.3.10
|
ARG MEDIA_NODE_VERSION=1.3.11
|
||||||
RUN apk add --no-cache git ca-certificates
|
RUN apk add --no-cache git ca-certificates
|
||||||
COPY node/media-node/ ./
|
COPY node/media-node/ ./
|
||||||
RUN go mod tidy \
|
RUN go mod tidy \
|
||||||
|
|
|
||||||
|
|
@ -74,7 +74,7 @@ mv "$BIN_PATH.new" "$BIN_PATH"
|
||||||
if docker_cmd ps -a --format '{{.Names}}' | grep -qx "$CONTAINER"; then
|
if docker_cmd ps -a --format '{{.Names}}' | grep -qx "$CONTAINER"; then
|
||||||
echo "Container herstarten..."
|
echo "Container herstarten..."
|
||||||
docker_cmd restart "$CONTAINER" >/dev/null
|
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
|
else
|
||||||
echo "Waarschuwing: container '$CONTAINER' niet gevonden — binary is wel bijgewerkt."
|
echo "Waarschuwing: container '$CONTAINER' niet gevonden — binary is wel bijgewerkt."
|
||||||
fi
|
fi
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ import (
|
||||||
"github.com/sthmedia/media-node/internal/streaming"
|
"github.com/sthmedia/media-node/internal/streaming"
|
||||||
)
|
)
|
||||||
|
|
||||||
var version = "1.3.10"
|
var version = "1.3.11"
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
if len(os.Args) < 2 {
|
if len(os.Args) < 2 {
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,7 @@ type Scanner struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
scanning bool
|
scanning bool
|
||||||
rescanQueued bool
|
rescanQueued bool
|
||||||
|
startupScanDone bool
|
||||||
status string
|
status string
|
||||||
resetSchedule chan struct{}
|
resetSchedule chan struct{}
|
||||||
reloadWatch chan struct{}
|
reloadWatch chan struct{}
|
||||||
|
|
@ -79,11 +80,11 @@ func (s *Scanner) Status() string {
|
||||||
return s.status
|
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() {
|
func (s *Scanner) WaitUntilIdle() {
|
||||||
for {
|
for {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
busy := s.scanning || s.rescanQueued
|
busy := s.scanning || s.rescanQueued || !s.startupScanDone
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
if !busy {
|
if !busy {
|
||||||
return
|
return
|
||||||
|
|
@ -94,6 +95,8 @@ func (s *Scanner) WaitUntilIdle() {
|
||||||
|
|
||||||
func (s *Scanner) Start() {
|
func (s *Scanner) Start() {
|
||||||
// One inventory pass at boot, then realtime watch + scheduled catch-up.
|
// 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.runStartupScan()
|
||||||
go s.runNightlyFullScan()
|
go s.runNightlyFullScan()
|
||||||
go s.runIntervalFullScan()
|
go s.runIntervalFullScan()
|
||||||
|
|
@ -181,6 +184,9 @@ func mediaRootsEqual(a, b []config.MediaRoot) bool {
|
||||||
func (s *Scanner) runStartupScan() {
|
func (s *Scanner) runStartupScan() {
|
||||||
log.Println("Startup library inventory scan...")
|
log.Println("Startup library inventory scan...")
|
||||||
s.FullScan()
|
s.FullScan()
|
||||||
|
s.mu.Lock()
|
||||||
|
s.startupScanDone = true
|
||||||
|
s.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Scanner) runNightlyFullScan() {
|
func (s *Scanner) runNightlyFullScan() {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue