From eb8ae1e85ab5d54744cfb55dca7f30905843e268 Mon Sep 17 00:00:00 2001 From: Jos Vooges | STH Date: Tue, 25 Aug 2026 02:16:46 +0200 Subject: [PATCH] Fix Stremio playback, folder-aware scan, and remove STH branding. Parse Plex-style Title (Year) and Show/Season folders; restore notWebReady with direct node stream URLs; hide unavailable titles by default. Co-authored-by: Cursor --- apps/master-api/.env.example | 2 +- apps/master-api/src/config.ts | 2 +- .../src/metadata/filename-parser.test.ts | 15 ++ .../src/metadata/filename-parser.ts | 165 +++++++++---- apps/master-api/src/metadata/service.ts | 15 +- apps/master-api/src/playback/service.ts | 2 +- apps/master-api/src/stremio/routes.ts | 99 +++++--- apps/master-api/src/websocket/manager.ts | 6 +- deploy/docker/docker-compose.dokploy.yml | 2 +- deploy/dokploy/.env.example | 2 +- deploy/master/.env.example | 2 +- deploy/node/install-unraid.sh | 2 +- deploy/node/install.sh | 2 +- deploy/node/media-node.service | 2 +- docs/dokploy.md | 2 +- node/media-node/cmd/media-node/main.go | 4 +- node/media-node/internal/scanner/parser.go | 218 +++++++++++++++--- .../internal/scanner/parser_test.go | 45 ++++ node/media-node/internal/scanner/scanner.go | 99 +++++--- 19 files changed, 530 insertions(+), 156 deletions(-) diff --git a/apps/master-api/.env.example b/apps/master-api/.env.example index bb4c631..c0e09ef 100644 --- a/apps/master-api/.env.example +++ b/apps/master-api/.env.example @@ -18,4 +18,4 @@ PLAYBACK_IDLE_TIMEOUT_SECONDS=1200 PLAYBACK_ABSOLUTE_LIFETIME_SECONDS=43200 NODE_HEARTBEAT_INTERVAL_SECONDS=30 NODE_OFFLINE_THRESHOLD_SECONDS=90 -HIDE_UNAVAILABLE_TITLES=false +HIDE_UNAVAILABLE_TITLES=true diff --git a/apps/master-api/src/config.ts b/apps/master-api/src/config.ts index 0d013df..917b58f 100644 --- a/apps/master-api/src/config.ts +++ b/apps/master-api/src/config.ts @@ -15,7 +15,7 @@ const configSchema = z.object({ PLAYBACK_ABSOLUTE_LIFETIME_SECONDS: z.coerce.number().default(43200), NODE_HEARTBEAT_INTERVAL_SECONDS: z.coerce.number().default(30), NODE_OFFLINE_THRESHOLD_SECONDS: z.coerce.number().default(90), - HIDE_UNAVAILABLE_TITLES: z.coerce.boolean().default(false), + HIDE_UNAVAILABLE_TITLES: z.coerce.boolean().default(true), }); export type Config = z.infer; diff --git a/apps/master-api/src/metadata/filename-parser.test.ts b/apps/master-api/src/metadata/filename-parser.test.ts index 9e7571b..467423b 100644 --- a/apps/master-api/src/metadata/filename-parser.test.ts +++ b/apps/master-api/src/metadata/filename-parser.test.ts @@ -1,7 +1,9 @@ import { detectMediaType, parseMovieFilename, + parseMovieFromPath, parseSeriesFilename, + parseSeriesFromPath, } from "./filename-parser"; function assert(condition: unknown, message: string): asserts condition { @@ -30,6 +32,19 @@ function run() { assert(detectMediaType("Show.S01E01.mkv") === "episode", "detect episode"); assert(detectMediaType("readme.txt") === null, "detect non-video"); + const folderMovie = parseMovieFromPath("/media/movies/Inception (2010)/Inception.mkv"); + assert(folderMovie?.title === "Inception" && folderMovie.year === 2010, "folder movie"); + + const folderSeries = parseSeriesFromPath( + "/media/series/Breaking Bad/Season 01/Breaking.Bad.S01E01.mkv" + ); + assert( + folderSeries?.title === "Breaking Bad" && + folderSeries.season === 1 && + folderSeries.episode === 1, + "folder series" + ); + console.log("filename-parser tests passed"); } diff --git a/apps/master-api/src/metadata/filename-parser.ts b/apps/master-api/src/metadata/filename-parser.ts index 283ff9e..6514a06 100644 --- a/apps/master-api/src/metadata/filename-parser.ts +++ b/apps/master-api/src/metadata/filename-parser.ts @@ -1,36 +1,60 @@ import type { MediaFileInfo, ParsedMovieFilename, ParsedSeriesFilename } from "@media-cluster/shared-types"; +import path from "node:path"; const VIDEO_EXTENSIONS = [".mkv", ".mp4", ".m4v", ".avi", ".ts", ".m2ts"]; +const YEAR_SEP = /[\.\s_-]((19|20)\d{2})[\.\s_-]/; +const YEAR_PAREN = /^(.+?)\s*\(((?:19|20)\d{2})\)\s*(?:\[.*\])?$/i; +const YEAR_END = /^(.+?)[\.\s_-]((?:19|20)\d{2})$/; +const SEASON_DIR = /^(?:season|seizoen)\s*0*(\d{1,2})$|^s(\d{1,2})$/i; +const EPISODE_ONLY = /(?:^|[\.\s_-])(?:e|ep|episode)[\.\s_-]?0*(\d{1,3})(?:[\.\s_-]|$)/i; +const JUNK = /\b(sample|trailer|preview|featurette|extras?)\b/i; + export function isVideoFile(filename: string): boolean { const lower = filename.toLowerCase(); return VIDEO_EXTENSIONS.some((ext) => lower.endsWith(ext)); } -export function parseMovieFilename(filename: string): ParsedMovieFilename | null { - const base = filename.replace(/\.[^.]+$/, ""); - const yearMatch = base.match(/[\.\s_-]((19|20)\d{2})[\.\s_-]/); - const year = yearMatch ? parseInt(yearMatch[1], 10) : undefined; +function cleanTitle(s: string): string { + return s.replace(/[._-]+/g, " ").replace(/\s+/g, " ").trim(); +} - let titlePart = base; - if (yearMatch?.index !== undefined) { - titlePart = base.slice(0, yearMatch.index); +function extractTags(base: string) { + return { + resolution: base.match(/\b(2160p|1080p|720p|480p|4K)\b/i)?.[1], + source: base.match(/\b(UHD\s*BluRay|BluRay|WEB-DL|WEBRip|HDTV|DVDRip)\b/i)?.[1], + releaseType: base.match(/\b(REMUX|PROPER|REPACK)\b/i)?.[1], + videoCodec: base.match(/\b(HEVC|H\.?264|H\.?265|x264|x265|AV1|XviD)\b/i)?.[1], + audio: base.match(/\b(TrueHD(\s*Atmos)?|DTS-HD|DTS|AAC|AC3|Atmos|EAC3)\b/i)?.[0], + }; +} + +function extractMovieTitleYear(base: string): { title: string; year?: number } { + const paren = base.match(YEAR_PAREN); + if (paren) return { title: cleanTitle(paren[1]), year: parseInt(paren[2], 10) }; + + const sep = base.match(YEAR_SEP); + if (sep?.index !== undefined) { + return { title: cleanTitle(base.slice(0, sep.index)), year: parseInt(sep[1], 10) }; } - const title = titlePart.replace(/[\._-]+/g, " ").trim(); + const end = base.match(YEAR_END); + if (end) return { title: cleanTitle(end[1]), year: parseInt(end[2], 10) }; + + return { title: cleanTitle(base) }; +} + +export function parseMovieFilename(filename: string): ParsedMovieFilename | null { + const base = filename.replace(/\.[^.]+$/, ""); + if (JUNK.test(base)) return null; + const { title, year } = extractMovieTitleYear(base); if (!title) return null; - - const resolution = base.match(/\b(2160p|1080p|720p|480p|4K)\b/i)?.[1]; - const source = base.match(/\b(UHD\s*BluRay|BluRay|WEB-DL|WEBRip|HDTV|DVDRip)\b/i)?.[1]; - const releaseType = base.match(/\b(REMUX|PROPER|REPACK)\b/i)?.[1]; - const videoCodec = base.match(/\b(HEVC|H\.?264|H\.?265|x264|x265|AV1|XviD)\b/i)?.[1]; - const audio = base.match(/\b(TrueHD(\s*Atmos)?|DTS-HD|DTS|AAC|AC3|Atmos|EAC3)\b/i)?.[0]; - - return { title, year, resolution, source, releaseType, videoCodec, audio }; + return { title, year, ...extractTags(base) }; } export function parseSeriesFilename(filename: string): ParsedSeriesFilename | null { const base = filename.replace(/\.[^.]+$/, ""); + if (JUNK.test(base)) return null; const patterns = [ /(.+?)[\.\s_-]S(\d{1,2})E(\d{1,2})(?:E\d{1,2})?/i, @@ -40,29 +64,90 @@ export function parseSeriesFilename(filename: string): ParsedSeriesFilename | nu for (const pattern of patterns) { const match = base.match(pattern); if (match) { - const title = match[1].replace(/[\._-]+/g, " ").trim(); + const title = cleanTitle(match[1]); const season = parseInt(match[2], 10); const episode = parseInt(match[3], 10); if (!title || isNaN(season) || isNaN(episode)) continue; - - const resolution = base.match(/\b(2160p|1080p|720p|480p|4K)\b/i)?.[1]; - const source = base.match(/\b(UHD\s*BluRay|BluRay|WEB-DL|WEBRip|HDTV)\b/i)?.[1]; - const videoCodec = base.match(/\b(HEVC|H\.?264|H\.?265|x264|x265)\b/i)?.[1]; - const audio = base.match(/\b(TrueHD|DTS-HD|DTS|AAC|AC3|Atmos)\b/i)?.[0]; - - return { title, season, episode, resolution, source, videoCodec, audio }; + return { title, season, episode, ...extractTags(base) }; } } return null; } +function parseSeasonFolder(name: string): number | undefined { + const m = name.trim().match(SEASON_DIR); + if (!m) return undefined; + return parseInt(m[1] || m[2], 10); +} + +function parseEpisodeNumber(base: string): number | undefined { + const m = base.match(EPISODE_ONLY); + if (m) return parseInt(m[1], 10); + const bare = base.match(/^0*(\d{1,3})(?:[\.\s_-]|$)/); + if (bare) { + const n = parseInt(bare[1], 10); + if (n > 0 && n < 1000) return n; + } + return undefined; +} + +function stripYearParen(s: string): string { + const m = s.match(YEAR_PAREN); + return m ? m[1] : s; +} + +/** Prefer Plex-style folder layout: Title (Year)/file.mkv */ +export function parseMovieFromPath(filePath: string): ParsedMovieFilename | null { + const base = path.basename(filePath); + const parent = path.basename(path.dirname(filePath)); + const fromFile = parseMovieFilename(base); + const generic = /^(movies|films|movie|film|media|video|videos)$/i; + const fromFolder = generic.test(parent) ? null : parseMovieFilename(parent); + + if (!fromFolder && !fromFile) return null; + if (!fromFolder) return fromFile; + if (!fromFile) return fromFolder; + return { + ...fromFile, + title: fromFolder.title, + year: fromFolder.year ?? fromFile.year, + }; +} + +/** Prefer Show/Season 01/... layout, fall back to SxxExx in filename. */ +export function parseSeriesFromPath(filePath: string): ParsedSeriesFilename | null { + const baseName = path.basename(filePath); + const base = baseName.replace(/\.[^.]+$/, ""); + if (JUNK.test(base)) return null; + + const parent = path.basename(path.dirname(filePath)); + const grandparent = path.basename(path.dirname(path.dirname(filePath))); + const seasonFromDir = parseSeasonFolder(parent); + const showFromDir = seasonFromDir + ? cleanTitle(stripYearParen(grandparent)) + : undefined; + + const fromFile = parseSeriesFilename(baseName); + if (fromFile) { + return { + ...fromFile, + title: showFromDir || fromFile.title, + season: seasonFromDir ?? fromFile.season, + }; + } + + const episode = parseEpisodeNumber(base); + const season = seasonFromDir; + const title = showFromDir || cleanTitle(stripYearParen(parent)); + if (!title || !season || !episode) return null; + return { title, season, episode, ...extractTags(base) }; +} + export function detectMediaType(filename: string): "movie" | "episode" | null { if (!isVideoFile(filename)) return null; - const series = parseSeriesFilename(filename); - if (series) return "episode"; - const movie = parseMovieFilename(filename); - if (movie) return "movie"; + if (parseSeriesFilename(filename) || parseSeriesFromPath(filename)) return "episode"; + if (parseMovieFilename(filename) || parseMovieFromPath(filename)) return "movie"; return null; } @@ -80,26 +165,20 @@ export function mediaFileInfoFromFilename( const mediaType = detectMediaType(filename); if (!mediaType) return null; + const parsedMovie = mediaType === "movie" ? parseMovieFromPath(filename) ?? undefined : undefined; + const parsedEpisode = mediaType === "episode" ? parseSeriesFromPath(filename) ?? undefined : undefined; + return { localFileId, sizeBytes, container: extractContainer(filename), - releaseName: filename, + releaseName: path.basename(filename), modifiedAt: modifiedAt.toISOString(), mediaType, - parsedMovie: mediaType === "movie" ? parseMovieFilename(filename) ?? undefined : undefined, - parsedEpisode: mediaType === "episode" ? parseSeriesFilename(filename) ?? undefined : undefined, - resolution: - (mediaType === "movie" - ? parseMovieFilename(filename)?.resolution - : parseSeriesFilename(filename)?.resolution) ?? undefined, - videoCodec: - (mediaType === "movie" - ? parseMovieFilename(filename)?.videoCodec - : parseSeriesFilename(filename)?.videoCodec) ?? undefined, - audioCodec: - (mediaType === "movie" - ? parseMovieFilename(filename)?.audio - : parseSeriesFilename(filename)?.audio) ?? undefined, + parsedMovie, + parsedEpisode, + resolution: (parsedMovie?.resolution ?? parsedEpisode?.resolution) || undefined, + videoCodec: (parsedMovie?.videoCodec ?? parsedEpisode?.videoCodec) || undefined, + audioCodec: (parsedMovie?.audio ?? parsedEpisode?.audio) || undefined, }; } diff --git a/apps/master-api/src/metadata/service.ts b/apps/master-api/src/metadata/service.ts index eb93958..3d4fc45 100644 --- a/apps/master-api/src/metadata/service.ts +++ b/apps/master-api/src/metadata/service.ts @@ -182,11 +182,18 @@ export class MetadataService { } private async findExistingMovie(title: string, year?: number) { + if (year) { + const withYear = await prisma.movie.findFirst({ + where: { + title: { equals: title, mode: "insensitive" }, + year, + }, + }); + if (withYear) return withYear; + } + // Title-only fallback avoids duplicates when older rows lacked a year. return prisma.movie.findFirst({ - where: { - title: { equals: title, mode: "insensitive" }, - ...(year ? { year } : {}), - }, + where: { title: { equals: title, mode: "insensitive" } }, }); } diff --git a/apps/master-api/src/playback/service.ts b/apps/master-api/src/playback/service.ts index 8447174..d17194e 100644 --- a/apps/master-api/src/playback/service.ts +++ b/apps/master-api/src/playback/service.ts @@ -105,7 +105,7 @@ export class PlaybackService { audioCodec?: string | null; videoCodec?: string | null; }): string { - const parts = ["STH Media"]; + const parts = ["Media Cluster"]; if (mediaFile.resolution) parts.push(mediaFile.resolution); if (mediaFile.videoCodec) parts.push(mediaFile.videoCodec); if (mediaFile.hdrType) parts.push(mediaFile.hdrType); diff --git a/apps/master-api/src/stremio/routes.ts b/apps/master-api/src/stremio/routes.ts index e5a3213..7d6fb4a 100644 --- a/apps/master-api/src/stremio/routes.ts +++ b/apps/master-api/src/stremio/routes.ts @@ -4,10 +4,12 @@ import { validateAddonToken } from "../auth/routes"; import { PlaybackService } from "../playback/service"; import type { Config } from "../config"; +const ID_PREFIX = "mc"; + const MANIFEST = { - id: "com.sthmedia.cluster", - version: "1.0.0", - name: "STH Media Cluster", + id: "com.mediacluster.library", + version: "1.1.0", + name: "Media Cluster", description: "Private distributed media library", catalogs: [ { type: "movie", id: "movies", name: "Movies", extra: [{ name: "search", isRequired: false }] }, @@ -18,9 +20,24 @@ const MANIFEST = { ], resources: ["catalog", "meta", "stream"], types: ["movie", "series"], - idPrefixes: ["tt", "sth"], + idPrefixes: ["tt", ID_PREFIX, "sth"], }; +function movieId(imdbId: string | null | undefined, uuid: string): string { + return imdbId ?? `${ID_PREFIX}:movie:${uuid}`; +} + +function seriesId(imdbId: string | null | undefined, uuid: string): string { + return imdbId ?? `${ID_PREFIX}:series:${uuid}`; +} + +/** Strip mc:/sth: prefixes used for non-IMDb titles. */ +function unwrapLocalId(id: string, kind: "movie" | "series"): string { + return id + .replace(new RegExp(`^${ID_PREFIX}:${kind}:`), "") + .replace(new RegExp(`^sth:${kind}:`), ""); +} + export async function registerStremioRoutes(app: FastifyInstance, config: Config) { const playback = new PlaybackService(config); @@ -110,11 +127,11 @@ export async function registerStremioRoutes(app: FastifyInstance, config: Config return reply.status(401).send({ error: "Invalid addon token" }); } - const streams = await getStreams(type, id, token, playback); + const streams = await getStreams(type, id, userId, playback); return { streams }; }); - // Lazy session: Stremio opens this URL only when actually playing (or prefetching media). + // Fallback play URL (direct node redirect) — prefer stream.json eager URLs. app.route({ method: ["GET", "HEAD"], url: "/stremio/:token/play/:mediaFileId", @@ -192,7 +209,7 @@ async function getMovieCatalog(catalogId: string, config: Config, search?: strin return movies .filter((m) => m.mediaFiles.length > 0 || !config.HIDE_UNAVAILABLE_TITLES) .map((m) => ({ - id: m.imdbId ?? `sth:movie:${m.id}`, + id: movieId(m.imdbId, m.id), type: "movie" as const, name: m.title, poster: m.posterUrl ?? undefined, @@ -223,13 +240,20 @@ async function getSeriesCatalog(catalogId: string, config: Config, search?: stri if (catalogId === "recent-series" && !search) { series = await prisma.series.findMany({ + where: { + seasons: { + some: { + episodes: { some: { mediaFiles: { some: { available: true } } } }, + }, + }, + }, orderBy: { createdAt: "desc" }, take: 50, }); } return series.map((s) => ({ - id: s.imdbId ?? `sth:series:${s.id}`, + id: seriesId(s.imdbId, s.id), type: "series" as const, name: s.title, poster: s.posterUrl ?? undefined, @@ -243,12 +267,12 @@ async function getSeriesCatalog(catalogId: string, config: Config, search?: stri async function getMovieMeta(id: string) { const movie = await prisma.movie.findFirst({ where: { - OR: [{ imdbId: id }, { id: id.replace("sth:movie:", "") }], + OR: [{ imdbId: id }, { id: unwrapLocalId(id, "movie") }], }, }); if (!movie) return null; return { - id: movie.imdbId ?? `sth:movie:${movie.id}`, + id: movieId(movie.imdbId, movie.id), type: "movie" as const, name: movie.title, poster: movie.posterUrl ?? undefined, @@ -264,7 +288,7 @@ async function getMovieMeta(id: string) { async function getSeriesMeta(id: string) { const series = await prisma.series.findFirst({ where: { - OR: [{ imdbId: id }, { id: id.replace("sth:series:", "") }], + OR: [{ imdbId: id }, { id: unwrapLocalId(id, "series") }], }, include: { seasons: { @@ -280,11 +304,12 @@ async function getSeriesMeta(id: string) { }); if (!series) return null; + const sid = seriesId(series.imdbId, series.id); const videos = series.seasons.flatMap((season) => season.episodes .filter((ep) => ep.mediaFiles.length > 0) .map((ep) => ({ - id: `${series.imdbId ?? `sth:series:${series.id}`}:${ep.seasonNumber}:${ep.episodeNumber}`, + id: `${sid}:${ep.seasonNumber}:${ep.episodeNumber}`, title: ep.title ?? `Episode ${ep.episodeNumber}`, season: ep.seasonNumber, episode: ep.episodeNumber, @@ -294,7 +319,7 @@ async function getSeriesMeta(id: string) { ); return { - id: series.imdbId ?? `sth:series:${series.id}`, + id: sid, type: "series" as const, name: series.title, poster: series.posterUrl ?? undefined, @@ -309,12 +334,12 @@ async function getSeriesMeta(id: string) { async function getStreams( type: string, id: string, - addonToken: string, + userId: string, playback: PlaybackService ) { if (type === "movie") { const movie = await prisma.movie.findFirst({ - where: { OR: [{ imdbId: id }, { id: id.replace("sth:movie:", "") }] }, + where: { OR: [{ imdbId: id }, { id: unwrapLocalId(id, "movie") }] }, include: { mediaFiles: { where: { available: true }, @@ -327,12 +352,20 @@ async function getStreams( const streams = []; for (const file of movie.mediaFiles) { if (file.node.status !== "ONLINE" || file.node.revoked) continue; - streams.push({ - name: playback.formatStreamLabel(file), - title: playback.formatStreamLabel(file), - url: playback.resolveStreamUrl(addonToken, file.id), - behaviorHints: { bingeGroup: `sth-movie-${movie.id}` }, - }); + try { + const session = await playback.createSession(file.id, userId); + streams.push({ + name: playback.formatStreamLabel(file), + title: playback.formatStreamLabel(file), + url: session.streamUrl, + behaviorHints: { + notWebReady: true, + bingeGroup: `mc-movie-${movie.id}`, + }, + }); + } catch { + // Skip files whose node session could not be created + } } return streams; } @@ -340,13 +373,13 @@ async function getStreams( if (type === "series") { const match = id.match(/^(.+):(\d+):(\d+)$/); if (!match) return []; - const [, seriesId, seasonStr, episodeStr] = match; + const [, seriesKey, seasonStr, episodeStr] = match; const seasonNumber = parseInt(seasonStr, 10); const episodeNumber = parseInt(episodeStr, 10); const series = await prisma.series.findFirst({ where: { - OR: [{ imdbId: seriesId }, { id: seriesId.replace("sth:series:", "") }], + OR: [{ imdbId: seriesKey }, { id: unwrapLocalId(seriesKey, "series") }], }, }); if (!series) return []; @@ -371,12 +404,20 @@ async function getStreams( const streams = []; for (const file of episode.mediaFiles) { if (file.node.status !== "ONLINE" || file.node.revoked) continue; - streams.push({ - name: playback.formatStreamLabel(file), - title: playback.formatStreamLabel(file), - url: playback.resolveStreamUrl(addonToken, file.id), - behaviorHints: { bingeGroup: `sth-series-${series.id}-S${seasonNumber}` }, - }); + try { + const session = await playback.createSession(file.id, userId); + streams.push({ + name: playback.formatStreamLabel(file), + title: playback.formatStreamLabel(file), + url: session.streamUrl, + behaviorHints: { + notWebReady: true, + bingeGroup: `mc-series-${series.id}-S${seasonNumber}`, + }, + }); + } catch { + // Skip unavailable node sessions + } } return streams; } diff --git a/apps/master-api/src/websocket/manager.ts b/apps/master-api/src/websocket/manager.ts index e640b98..c6731c3 100644 --- a/apps/master-api/src/websocket/manager.ts +++ b/apps/master-api/src/websocket/manager.ts @@ -326,15 +326,15 @@ class NodeConnectionManager { async createPlaybackSessionAcked( nodeId: string, payload: CreatePlaybackSessionPayload, - timeoutMs = 800 + timeoutMs = 3000 ): Promise { if (!this.isOnline(nodeId)) return false; const acked = new Promise((resolve) => { const timer = setTimeout(() => { - // Older nodes may not send ACK — proceed optimistically; node has grace retry. + // No ACK in time — fail closed so Stremio does not hang on a dead redirect. this.pendingSessionAcks.delete(payload.sessionId); - resolve(true); + resolve(false); }, timeoutMs); this.pendingSessionAcks.set(payload.sessionId, { resolve, timer }); }); diff --git a/deploy/docker/docker-compose.dokploy.yml b/deploy/docker/docker-compose.dokploy.yml index 5b3ebff..806b3dc 100644 --- a/deploy/docker/docker-compose.dokploy.yml +++ b/deploy/docker/docker-compose.dokploy.yml @@ -56,7 +56,7 @@ services: PLAYBACK_ABSOLUTE_LIFETIME_SECONDS: ${PLAYBACK_ABSOLUTE_LIFETIME_SECONDS:-43200} NODE_HEARTBEAT_INTERVAL_SECONDS: ${NODE_HEARTBEAT_INTERVAL_SECONDS:-30} NODE_OFFLINE_THRESHOLD_SECONDS: ${NODE_OFFLINE_THRESHOLD_SECONDS:-90} - HIDE_UNAVAILABLE_TITLES: ${HIDE_UNAVAILABLE_TITLES:-false} + HIDE_UNAVAILABLE_TITLES: ${HIDE_UNAVAILABLE_TITLES:-true} depends_on: postgres: condition: service_healthy diff --git a/deploy/dokploy/.env.example b/deploy/dokploy/.env.example index 9346d59..c6a0833 100644 --- a/deploy/dokploy/.env.example +++ b/deploy/dokploy/.env.example @@ -28,4 +28,4 @@ PLAYBACK_IDLE_TIMEOUT_SECONDS=1200 PLAYBACK_ABSOLUTE_LIFETIME_SECONDS=43200 NODE_HEARTBEAT_INTERVAL_SECONDS=30 NODE_OFFLINE_THRESHOLD_SECONDS=90 -HIDE_UNAVAILABLE_TITLES=false +HIDE_UNAVAILABLE_TITLES=true diff --git a/deploy/master/.env.example b/deploy/master/.env.example index aff67b1..60b464f 100644 --- a/deploy/master/.env.example +++ b/deploy/master/.env.example @@ -20,4 +20,4 @@ PLAYBACK_IDLE_TIMEOUT_SECONDS=1200 PLAYBACK_ABSOLUTE_LIFETIME_SECONDS=43200 NODE_HEARTBEAT_INTERVAL_SECONDS=30 NODE_OFFLINE_THRESHOLD_SECONDS=90 -HIDE_UNAVAILABLE_TITLES=false +HIDE_UNAVAILABLE_TITLES=true diff --git a/deploy/node/install-unraid.sh b/deploy/node/install-unraid.sh index 13f08d7..793058e 100644 --- a/deploy/node/install-unraid.sh +++ b/deploy/node/install-unraid.sh @@ -69,7 +69,7 @@ prompt() { exit 1 } -echo "=== STH Media Node — Unraid install ===" +echo "=== Media Node — Unraid install ===" echo "Master: $MASTER_URL" echo diff --git a/deploy/node/install.sh b/deploy/node/install.sh index 0a6af31..0ca6759 100644 --- a/deploy/node/install.sh +++ b/deploy/node/install.sh @@ -17,7 +17,7 @@ esac BINARY="media-node-linux-${GOARCH}" DOWNLOAD_URL="${MEDIA_NODE_DOWNLOAD_URL:-https://releases.example.com/media-node/${VERSION}/${BINARY}}" -echo "=== STH Media Node Installer ===" +echo "=== Media Node Installer ===" echo "Version: $VERSION" echo "Architecture: $GOARCH" diff --git a/deploy/node/media-node.service b/deploy/node/media-node.service index 9505102..78a417c 100644 --- a/deploy/node/media-node.service +++ b/deploy/node/media-node.service @@ -1,5 +1,5 @@ [Unit] -Description=STH Media Node +Description=Media Node After=network-online.target Wants=network-online.target diff --git a/docs/dokploy.md b/docs/dokploy.md index c01a5c4..8b03ccb 100644 --- a/docs/dokploy.md +++ b/docs/dokploy.md @@ -64,7 +64,7 @@ PLAYBACK_IDLE_TIMEOUT_SECONDS=1200 PLAYBACK_ABSOLUTE_LIFETIME_SECONDS=43200 NODE_HEARTBEAT_INTERVAL_SECONDS=30 NODE_OFFLINE_THRESHOLD_SECONDS=90 -HIDE_UNAVAILABLE_TITLES=false +HIDE_UNAVAILABLE_TITLES=true ``` Belangrijk: `DATABASE_URL` wachtwoord moet **exact** gelijk zijn aan `POSTGRES_PASSWORD`. diff --git a/node/media-node/cmd/media-node/main.go b/node/media-node/cmd/media-node/main.go index 04af533..d669cb8 100644 --- a/node/media-node/cmd/media-node/main.go +++ b/node/media-node/cmd/media-node/main.go @@ -148,7 +148,7 @@ func runService() { } func runInstall() { - fmt.Println("=== STH Media Node Installer ===") + fmt.Println("=== Media Node Installer ===") fmt.Println() masterURL := prompt("Master URL (https://master.media.example.com): ") @@ -234,7 +234,7 @@ func installSystemd() { _ = exec.Command("chown", "-R", "root:media-node", "/etc/media-node").Run() unit := `[Unit] -Description=STH Media Node +Description=Media Node After=network-online.target Wants=network-online.target diff --git a/node/media-node/internal/scanner/parser.go b/node/media-node/internal/scanner/parser.go index 606b040..2891387 100644 --- a/node/media-node/internal/scanner/parser.go +++ b/node/media-node/internal/scanner/parser.go @@ -1,12 +1,15 @@ package scanner import ( + "path/filepath" "regexp" "strings" ) var ( - yearRe = regexp.MustCompile(`[\.\s_-]((19|20)\d{2})[\.\s_-]`) + yearSepRe = regexp.MustCompile(`[\.\s_-]((19|20)\d{2})[\.\s_-]`) + yearParenRe = regexp.MustCompile(`(?i)^(.+?)\s*\(((?:19|20)\d{2})\)\s*(?:\[.*\])?$`) + yearEndRe = regexp.MustCompile(`^(.+?)[\.\s_-]((?:19|20)\d{2})$`) resolutionRe = regexp.MustCompile(`(?i)\b(2160p|1080p|720p|480p|4K)\b`) sourceRe = regexp.MustCompile(`(?i)\b(UHD\s*BluRay|BluRay|WEB-DL|WEBRip|HDTV|DVDRip)\b`) releaseRe = regexp.MustCompile(`(?i)\b(REMUX|PROPER|REPACK)\b`) @@ -14,19 +17,13 @@ var ( audioRe = regexp.MustCompile(`(?i)\b(TrueHD(\s*Atmos)?|DTS-HD|DTS|AAC|AC3|Atmos|EAC3)\b`) seriesRe1 = regexp.MustCompile(`(?i)(.+?)[\.\s_-]S(\d{1,2})E(\d{1,2})`) seriesRe2 = regexp.MustCompile(`(?i)(.+?)[\.\s_-](\d{1,2})x(\d{1,2})`) + seasonDirRe = regexp.MustCompile(`(?i)^(?:season|seizoen)\s*0*(\d{1,2})$|^s(\d{1,2})$`) + episodeOnly = regexp.MustCompile(`(?i)(?:^|[\.\s_-])(?:e|ep|episode)[\.\s_-]?0*(\d{1,3})(?:[\.\s_-]|$)`) + junkNameRe = regexp.MustCompile(`(?i)\b(sample|trailer|preview|featurette|extras?)\b`) ) func parseMovie(base string) map[string]interface{} { - yearMatch := yearRe.FindStringSubmatchIndex(base) - var titlePart string - var year int - if len(yearMatch) >= 4 { - titlePart = base[:yearMatch[0]] - year = atoi(base[yearMatch[2]:yearMatch[3]]) - } else { - titlePart = base - } - title := cleanTitle(titlePart) + title, year := extractMovieTitleYear(base) if title == "" { return nil } @@ -34,21 +31,7 @@ func parseMovie(base string) map[string]interface{} { if year > 0 { result["year"] = year } - if m := resolutionRe.FindStringSubmatch(base); len(m) > 1 { - result["resolution"] = m[1] - } - if m := sourceRe.FindStringSubmatch(base); len(m) > 1 { - result["source"] = m[1] - } - if m := releaseRe.FindStringSubmatch(base); len(m) > 1 { - result["releaseType"] = m[1] - } - if m := videoRe.FindStringSubmatch(base); len(m) > 1 { - result["videoCodec"] = m[1] - } - if m := audioRe.FindStringSubmatch(base); len(m) > 0 { - result["audio"] = m[0] - } + attachTags(result, base) return result } @@ -67,23 +50,186 @@ func parseSeries(base string) map[string]interface{} { "season": season, "episode": episode, } - if rm := resolutionRe.FindStringSubmatch(base); len(rm) > 1 { - result["resolution"] = rm[1] - } - if vm := videoRe.FindStringSubmatch(base); len(vm) > 1 { - result["videoCodec"] = vm[1] - } - if am := audioRe.FindStringSubmatch(base); len(am) > 0 { - result["audio"] = am[0] - } + attachTags(result, base) return result } } return nil } +// parseMovieFromPath prefers Plex-style "Title (Year)/file.mkv" folders. +func parseMovieFromPath(path string) map[string]interface{} { + base := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) + if isJunkName(base) { + return nil + } + parent := filepath.Base(filepath.Dir(path)) + + fromFile := parseMovie(base) + fromFolder := parseMovieFolder(parent) + + if fromFolder == nil && fromFile == nil { + return nil + } + if fromFolder == nil { + return fromFile + } + if fromFile == nil { + return fromFolder + } + + // Folder title/year are authoritative for library layout; keep quality tags from file. + merged := map[string]interface{}{} + for k, v := range fromFile { + merged[k] = v + } + merged["title"] = fromFolder["title"] + if y, ok := fromFolder["year"]; ok { + merged["year"] = y + } + return merged +} + +// parseSeriesFromPath supports "Show/Season 01/..." and classic SxxExx filenames. +func parseSeriesFromPath(path string) map[string]interface{} { + base := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) + if isJunkName(base) { + return nil + } + + dir := filepath.Dir(path) + parent := filepath.Base(dir) + grandparent := filepath.Base(filepath.Dir(dir)) + + seasonFromDir := parseSeasonFolder(parent) + showFromDir := "" + if seasonFromDir > 0 { + showFromDir = cleanTitle(stripYearParen(grandparent)) + } + + fromFile := parseSeries(base) + if fromFile != nil { + if showFromDir != "" { + fromFile["title"] = showFromDir + } + if seasonFromDir > 0 { + fromFile["season"] = seasonFromDir + } + return fromFile + } + + episode := parseEpisodeNumber(base) + season := seasonFromDir + title := showFromDir + + // Flat: Show Name/S01E02.mkv (no season folder) + if title == "" { + title = cleanTitle(stripYearParen(parent)) + } + if season <= 0 && fromFile == nil { + // Try SxxExx-less season in parent already handled; leave 0 + } + + if title == "" || season <= 0 || episode <= 0 { + return nil + } + result := map[string]interface{}{ + "title": title, + "season": season, + "episode": episode, + } + attachTags(result, base) + return result +} + +func parseMovieFolder(name string) map[string]interface{} { + name = strings.TrimSpace(name) + if name == "" || name == "." || name == string(filepath.Separator) { + return nil + } + // Skip generic root-ish folder names + lower := strings.ToLower(name) + if lower == "movies" || lower == "films" || lower == "movie" || lower == "film" || + lower == "media" || lower == "video" || lower == "videos" { + return nil + } + return parseMovie(name) +} + +func extractMovieTitleYear(base string) (string, int) { + if m := yearParenRe.FindStringSubmatch(base); len(m) >= 3 { + return cleanTitle(m[1]), atoi(m[2]) + } + if yearMatch := yearSepRe.FindStringSubmatchIndex(base); len(yearMatch) >= 4 { + return cleanTitle(base[:yearMatch[0]]), atoi(base[yearMatch[2]:yearMatch[3]]) + } + if m := yearEndRe.FindStringSubmatch(base); len(m) >= 3 { + return cleanTitle(m[1]), atoi(m[2]) + } + title := cleanTitle(base) + return title, 0 +} + +func parseSeasonFolder(name string) int { + m := seasonDirRe.FindStringSubmatch(strings.TrimSpace(name)) + if len(m) == 0 { + return 0 + } + if m[1] != "" { + return atoi(m[1]) + } + return atoi(m[2]) +} + +func parseEpisodeNumber(base string) int { + if m := episodeOnly.FindStringSubmatch(base); len(m) >= 2 { + return atoi(m[1]) + } + // Bare leading number: "01 - Pilot" / "1. Pilot" + re := regexp.MustCompile(`^0*(\d{1,3})(?:[\.\s_-]|$)`) + if m := re.FindStringSubmatch(base); len(m) >= 2 { + n := atoi(m[1]) + if n > 0 && n < 1000 { + return n + } + } + return 0 +} + +func stripYearParen(s string) string { + if m := yearParenRe.FindStringSubmatch(s); len(m) >= 2 { + return m[1] + } + return s +} + +func attachTags(result map[string]interface{}, base string) { + if m := resolutionRe.FindStringSubmatch(base); len(m) > 1 { + result["resolution"] = m[1] + } + if m := sourceRe.FindStringSubmatch(base); len(m) > 1 { + result["source"] = m[1] + } + if m := releaseRe.FindStringSubmatch(base); len(m) > 1 { + result["releaseType"] = m[1] + } + if m := videoRe.FindStringSubmatch(base); len(m) > 1 { + result["videoCodec"] = m[1] + } + if m := audioRe.FindStringSubmatch(base); len(m) > 0 { + result["audio"] = m[0] + } +} + +func isJunkName(s string) bool { + return junkNameRe.MatchString(s) +} + func cleanTitle(s string) string { - return strings.TrimSpace(strings.ReplaceAll(strings.ReplaceAll(s, ".", " "), "_", " ")) + s = strings.ReplaceAll(s, ".", " ") + s = strings.ReplaceAll(s, "_", " ") + s = strings.ReplaceAll(s, "-", " ") + return strings.Join(strings.Fields(s), " ") } func atoi(s string) int { diff --git a/node/media-node/internal/scanner/parser_test.go b/node/media-node/internal/scanner/parser_test.go index 5b4bb47..2a552d8 100644 --- a/node/media-node/internal/scanner/parser_test.go +++ b/node/media-node/internal/scanner/parser_test.go @@ -15,6 +15,19 @@ func TestParseMovie(t *testing.T) { } } +func TestParseMovieParenFolder(t *testing.T) { + p := parseMovieFromPath(`/media/movies/Inception (2010)/Inception.mkv`) + if p == nil { + t.Fatal("expected parse result") + } + if p["title"] != "Inception" { + t.Fatalf("title=%v", p["title"]) + } + if p["year"] != 2010 { + t.Fatalf("year=%v", p["year"]) + } +} + func TestParseSeries(t *testing.T) { p := parseSeries("Breaking.Bad.S03E04.1080p.BluRay") if p == nil { @@ -34,3 +47,35 @@ func TestParseSeriesAlt(t *testing.T) { t.Fatalf("unexpected: %#v", p) } } + +func TestParseSeriesSeasonFolder(t *testing.T) { + p := parseSeriesFromPath(`/media/series/Breaking Bad/Season 01/Breaking.Bad.S01E01.mkv`) + if p == nil { + t.Fatal("expected parse result") + } + if p["title"] != "Breaking Bad" { + t.Fatalf("title=%v", p["title"]) + } + if p["season"] != 1 || p["episode"] != 1 { + t.Fatalf("season/episode=%v/%v", p["season"], p["episode"]) + } +} + +func TestParseSeriesSeasonFolderEpisodeOnly(t *testing.T) { + p := parseSeriesFromPath(`/media/series/The Office/Season 2/E05 - Episode.mkv`) + if p == nil { + t.Fatal("expected parse result") + } + if p["title"] != "The Office" { + t.Fatalf("title=%v", p["title"]) + } + if p["season"] != 2 || p["episode"] != 5 { + t.Fatalf("season/episode=%v/%v", p["season"], p["episode"]) + } +} + +func TestSkipSample(t *testing.T) { + if parseMovieFromPath(`/media/movies/Foo (2020)/Foo-sample.mkv`) != nil { + t.Fatal("sample should be skipped") + } +} diff --git a/node/media-node/internal/scanner/scanner.go b/node/media-node/internal/scanner/scanner.go index c06ef1e..2767853 100644 --- a/node/media-node/internal/scanner/scanner.go +++ b/node/media-node/internal/scanner/scanner.go @@ -91,23 +91,12 @@ func (s *Scanner) FullScan() { log.Println("Starting full media scan...") found := make(map[string]bool) - dirs := append(s.cfg.Media.Movies, s.cfg.Media.Series...) - for _, dir := range dirs { - if dir == "" { - continue - } - filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { - if err != nil || info.IsDir() { - return nil - } - if !isVideo(path) { - return nil - } - found[path] = true - s.indexFile(path, info, "FILE_UPDATED") - return nil - }) + for _, dir := range s.cfg.Media.Movies { + s.walkRoot(dir, "movie", found) + } + for _, dir := range s.cfg.Media.Series { + s.walkRoot(dir, "episode", found) } existing, _ := s.store.AllFiles() @@ -126,6 +115,23 @@ func (s *Scanner) FullScan() { log.Println("Full scan completed") } +func (s *Scanner) walkRoot(dir, mediaType string, found map[string]bool) { + if dir == "" { + return + } + filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { + if err != nil || info.IsDir() { + return nil + } + if !isVideo(path) { + return nil + } + found[path] = true + s.indexFile(path, info, "FILE_UPDATED", mediaType) + return nil + }) +} + func (s *Scanner) watchFilesystem() { watcher, err := fsnotify.NewWatcher() if err != nil { @@ -134,11 +140,24 @@ func (s *Scanner) watchFilesystem() { } defer watcher.Close() - dirs := append(s.cfg.Media.Movies, s.cfg.Media.Series...) - for _, dir := range dirs { + rootType := map[string]string{} + for _, dir := range s.cfg.Media.Movies { if dir == "" { continue } + rootType[filepath.Clean(dir)] = "movie" + filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { + if err == nil && info.IsDir() { + _ = watcher.Add(path) + } + return nil + }) + } + for _, dir := range s.cfg.Media.Series { + if dir == "" { + continue + } + rootType[filepath.Clean(dir)] = "episode" filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { if err == nil && info.IsDir() { _ = watcher.Add(path) @@ -168,14 +187,17 @@ func (s *Scanner) watchFilesystem() { s.handleRemove(event.Name) } if !debounce.Stop() { - <-debounce.C + select { + case <-debounce.C: + default: + } } debounce.Reset(2 * time.Second) case <-debounce.C: for path := range pending { info, err := os.Stat(path) if err == nil && !info.IsDir() { - s.indexFile(path, info, "FILE_ADDED") + s.indexFile(path, info, "FILE_ADDED", s.detectRootType(path, rootType)) } } pending = make(map[string]struct{}) @@ -188,9 +210,30 @@ func (s *Scanner) watchFilesystem() { } } -func (s *Scanner) indexFile(path string, info os.FileInfo, eventType string) { - mediaType := detectMediaType(filepath.Base(path)) +func (s *Scanner) detectRootType(path string, rootType map[string]string) string { + clean := filepath.Clean(path) + for root, kind := range rootType { + if clean == root || strings.HasPrefix(clean, root+string(filepath.Separator)) { + return kind + } + } + return detectMediaType(path) +} + +func (s *Scanner) indexFile(path string, info os.FileInfo, eventType, preferredType string) { + mediaType := preferredType if mediaType == "" { + mediaType = detectMediaType(path) + } + if mediaType == "" { + return + } + + // Validate we can actually parse this path for the chosen type + if mediaType == "movie" && parseMovieFromPath(path) == nil { + return + } + if mediaType == "episode" && parseSeriesFromPath(path) == nil { return } @@ -247,12 +290,11 @@ func isVideo(path string) bool { return videoExtensions[ext] } -func detectMediaType(filename string) string { - base := strings.TrimSuffix(filename, filepath.Ext(filename)) - if parseSeries(base) != nil { +func detectMediaType(path string) string { + if parseSeriesFromPath(path) != nil { return "episode" } - if parseMovie(base) != nil { + if parseMovieFromPath(path) != nil { return "movie" } return "" @@ -267,16 +309,15 @@ func buildMediaInfo(f database.LocalFile) MediaFileInfo { ModifiedAt: f.ModifiedAt.UTC().Format(time.RFC3339), MediaType: f.MediaType, } - base := strings.TrimSuffix(f.ReleaseName, filepath.Ext(f.ReleaseName)) if f.MediaType == "movie" { - if p := parseMovie(base); p != nil { + if p := parseMovieFromPath(f.Path); p != nil { info.ParsedMovie = p info.Resolution = strVal(p, "resolution") info.VideoCodec = strVal(p, "videoCodec") info.AudioCodec = strVal(p, "audio") } } else { - if p := parseSeries(base); p != nil { + if p := parseSeriesFromPath(f.Path); p != nil { info.ParsedEpisode = p info.Resolution = strVal(p, "resolution") info.VideoCodec = strVal(p, "videoCodec")