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 <cursoragent@cursor.com>
This commit is contained in:
Jos Vooges | STH 2026-08-25 02:16:46 +02:00
parent 93dbf44fb8
commit eb8ae1e85a
19 changed files with 530 additions and 156 deletions

View file

@ -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

View file

@ -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<typeof configSchema>;

View file

@ -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");
}

View file

@ -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,
};
}

View file

@ -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" } },
});
}

View file

@ -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);

View file

@ -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;
}

View file

@ -326,15 +326,15 @@ class NodeConnectionManager {
async createPlaybackSessionAcked(
nodeId: string,
payload: CreatePlaybackSessionPayload,
timeoutMs = 800
timeoutMs = 3000
): Promise<boolean> {
if (!this.isOnline(nodeId)) return false;
const acked = new Promise<boolean>((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 });
});

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -69,7 +69,7 @@ prompt() {
exit 1
}
echo "=== STH Media Node — Unraid install ==="
echo "=== Media Node — Unraid install ==="
echo "Master: $MASTER_URL"
echo

View file

@ -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"

View file

@ -1,5 +1,5 @@
[Unit]
Description=STH Media Node
Description=Media Node
After=network-online.target
Wants=network-online.target

View file

@ -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`.

View file

@ -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

View file

@ -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 {

View file

@ -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")
}
}

View file

@ -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")