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:
parent
93dbf44fb8
commit
eb8ae1e85a
19 changed files with 530 additions and 156 deletions
|
|
@ -18,4 +18,4 @@ PLAYBACK_IDLE_TIMEOUT_SECONDS=1200
|
||||||
PLAYBACK_ABSOLUTE_LIFETIME_SECONDS=43200
|
PLAYBACK_ABSOLUTE_LIFETIME_SECONDS=43200
|
||||||
NODE_HEARTBEAT_INTERVAL_SECONDS=30
|
NODE_HEARTBEAT_INTERVAL_SECONDS=30
|
||||||
NODE_OFFLINE_THRESHOLD_SECONDS=90
|
NODE_OFFLINE_THRESHOLD_SECONDS=90
|
||||||
HIDE_UNAVAILABLE_TITLES=false
|
HIDE_UNAVAILABLE_TITLES=true
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ const configSchema = z.object({
|
||||||
PLAYBACK_ABSOLUTE_LIFETIME_SECONDS: z.coerce.number().default(43200),
|
PLAYBACK_ABSOLUTE_LIFETIME_SECONDS: z.coerce.number().default(43200),
|
||||||
NODE_HEARTBEAT_INTERVAL_SECONDS: z.coerce.number().default(30),
|
NODE_HEARTBEAT_INTERVAL_SECONDS: z.coerce.number().default(30),
|
||||||
NODE_OFFLINE_THRESHOLD_SECONDS: z.coerce.number().default(90),
|
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>;
|
export type Config = z.infer<typeof configSchema>;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
import {
|
import {
|
||||||
detectMediaType,
|
detectMediaType,
|
||||||
parseMovieFilename,
|
parseMovieFilename,
|
||||||
|
parseMovieFromPath,
|
||||||
parseSeriesFilename,
|
parseSeriesFilename,
|
||||||
|
parseSeriesFromPath,
|
||||||
} from "./filename-parser";
|
} from "./filename-parser";
|
||||||
|
|
||||||
function assert(condition: unknown, message: string): asserts condition {
|
function assert(condition: unknown, message: string): asserts condition {
|
||||||
|
|
@ -30,6 +32,19 @@ function run() {
|
||||||
assert(detectMediaType("Show.S01E01.mkv") === "episode", "detect episode");
|
assert(detectMediaType("Show.S01E01.mkv") === "episode", "detect episode");
|
||||||
assert(detectMediaType("readme.txt") === null, "detect non-video");
|
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");
|
console.log("filename-parser tests passed");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,36 +1,60 @@
|
||||||
import type { MediaFileInfo, ParsedMovieFilename, ParsedSeriesFilename } from "@media-cluster/shared-types";
|
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 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 {
|
export function isVideoFile(filename: string): boolean {
|
||||||
const lower = filename.toLowerCase();
|
const lower = filename.toLowerCase();
|
||||||
return VIDEO_EXTENSIONS.some((ext) => lower.endsWith(ext));
|
return VIDEO_EXTENSIONS.some((ext) => lower.endsWith(ext));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseMovieFilename(filename: string): ParsedMovieFilename | null {
|
function cleanTitle(s: string): string {
|
||||||
const base = filename.replace(/\.[^.]+$/, "");
|
return s.replace(/[._-]+/g, " ").replace(/\s+/g, " ").trim();
|
||||||
const yearMatch = base.match(/[\.\s_-]((19|20)\d{2})[\.\s_-]/);
|
}
|
||||||
const year = yearMatch ? parseInt(yearMatch[1], 10) : undefined;
|
|
||||||
|
|
||||||
let titlePart = base;
|
function extractTags(base: string) {
|
||||||
if (yearMatch?.index !== undefined) {
|
return {
|
||||||
titlePart = base.slice(0, yearMatch.index);
|
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;
|
if (!title) return null;
|
||||||
|
return { title, year, ...extractTags(base) };
|
||||||
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 };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseSeriesFilename(filename: string): ParsedSeriesFilename | null {
|
export function parseSeriesFilename(filename: string): ParsedSeriesFilename | null {
|
||||||
const base = filename.replace(/\.[^.]+$/, "");
|
const base = filename.replace(/\.[^.]+$/, "");
|
||||||
|
if (JUNK.test(base)) return null;
|
||||||
|
|
||||||
const patterns = [
|
const patterns = [
|
||||||
/(.+?)[\.\s_-]S(\d{1,2})E(\d{1,2})(?:E\d{1,2})?/i,
|
/(.+?)[\.\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) {
|
for (const pattern of patterns) {
|
||||||
const match = base.match(pattern);
|
const match = base.match(pattern);
|
||||||
if (match) {
|
if (match) {
|
||||||
const title = match[1].replace(/[\._-]+/g, " ").trim();
|
const title = cleanTitle(match[1]);
|
||||||
const season = parseInt(match[2], 10);
|
const season = parseInt(match[2], 10);
|
||||||
const episode = parseInt(match[3], 10);
|
const episode = parseInt(match[3], 10);
|
||||||
if (!title || isNaN(season) || isNaN(episode)) continue;
|
if (!title || isNaN(season) || isNaN(episode)) continue;
|
||||||
|
return { title, season, episode, ...extractTags(base) };
|
||||||
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 null;
|
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 {
|
export function detectMediaType(filename: string): "movie" | "episode" | null {
|
||||||
if (!isVideoFile(filename)) return null;
|
if (!isVideoFile(filename)) return null;
|
||||||
const series = parseSeriesFilename(filename);
|
if (parseSeriesFilename(filename) || parseSeriesFromPath(filename)) return "episode";
|
||||||
if (series) return "episode";
|
if (parseMovieFilename(filename) || parseMovieFromPath(filename)) return "movie";
|
||||||
const movie = parseMovieFilename(filename);
|
|
||||||
if (movie) return "movie";
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -80,26 +165,20 @@ export function mediaFileInfoFromFilename(
|
||||||
const mediaType = detectMediaType(filename);
|
const mediaType = detectMediaType(filename);
|
||||||
if (!mediaType) return null;
|
if (!mediaType) return null;
|
||||||
|
|
||||||
|
const parsedMovie = mediaType === "movie" ? parseMovieFromPath(filename) ?? undefined : undefined;
|
||||||
|
const parsedEpisode = mediaType === "episode" ? parseSeriesFromPath(filename) ?? undefined : undefined;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
localFileId,
|
localFileId,
|
||||||
sizeBytes,
|
sizeBytes,
|
||||||
container: extractContainer(filename),
|
container: extractContainer(filename),
|
||||||
releaseName: filename,
|
releaseName: path.basename(filename),
|
||||||
modifiedAt: modifiedAt.toISOString(),
|
modifiedAt: modifiedAt.toISOString(),
|
||||||
mediaType,
|
mediaType,
|
||||||
parsedMovie: mediaType === "movie" ? parseMovieFilename(filename) ?? undefined : undefined,
|
parsedMovie,
|
||||||
parsedEpisode: mediaType === "episode" ? parseSeriesFilename(filename) ?? undefined : undefined,
|
parsedEpisode,
|
||||||
resolution:
|
resolution: (parsedMovie?.resolution ?? parsedEpisode?.resolution) || undefined,
|
||||||
(mediaType === "movie"
|
videoCodec: (parsedMovie?.videoCodec ?? parsedEpisode?.videoCodec) || undefined,
|
||||||
? parseMovieFilename(filename)?.resolution
|
audioCodec: (parsedMovie?.audio ?? parsedEpisode?.audio) || undefined,
|
||||||
: parseSeriesFilename(filename)?.resolution) ?? undefined,
|
|
||||||
videoCodec:
|
|
||||||
(mediaType === "movie"
|
|
||||||
? parseMovieFilename(filename)?.videoCodec
|
|
||||||
: parseSeriesFilename(filename)?.videoCodec) ?? undefined,
|
|
||||||
audioCodec:
|
|
||||||
(mediaType === "movie"
|
|
||||||
? parseMovieFilename(filename)?.audio
|
|
||||||
: parseSeriesFilename(filename)?.audio) ?? undefined,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -182,11 +182,18 @@ export class MetadataService {
|
||||||
}
|
}
|
||||||
|
|
||||||
private async findExistingMovie(title: string, year?: number) {
|
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({
|
return prisma.movie.findFirst({
|
||||||
where: {
|
where: { title: { equals: title, mode: "insensitive" } },
|
||||||
title: { equals: title, mode: "insensitive" },
|
|
||||||
...(year ? { year } : {}),
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -105,7 +105,7 @@ export class PlaybackService {
|
||||||
audioCodec?: string | null;
|
audioCodec?: string | null;
|
||||||
videoCodec?: string | null;
|
videoCodec?: string | null;
|
||||||
}): string {
|
}): string {
|
||||||
const parts = ["STH Media"];
|
const parts = ["Media Cluster"];
|
||||||
if (mediaFile.resolution) parts.push(mediaFile.resolution);
|
if (mediaFile.resolution) parts.push(mediaFile.resolution);
|
||||||
if (mediaFile.videoCodec) parts.push(mediaFile.videoCodec);
|
if (mediaFile.videoCodec) parts.push(mediaFile.videoCodec);
|
||||||
if (mediaFile.hdrType) parts.push(mediaFile.hdrType);
|
if (mediaFile.hdrType) parts.push(mediaFile.hdrType);
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,12 @@ import { validateAddonToken } from "../auth/routes";
|
||||||
import { PlaybackService } from "../playback/service";
|
import { PlaybackService } from "../playback/service";
|
||||||
import type { Config } from "../config";
|
import type { Config } from "../config";
|
||||||
|
|
||||||
|
const ID_PREFIX = "mc";
|
||||||
|
|
||||||
const MANIFEST = {
|
const MANIFEST = {
|
||||||
id: "com.sthmedia.cluster",
|
id: "com.mediacluster.library",
|
||||||
version: "1.0.0",
|
version: "1.1.0",
|
||||||
name: "STH Media Cluster",
|
name: "Media Cluster",
|
||||||
description: "Private distributed media library",
|
description: "Private distributed media library",
|
||||||
catalogs: [
|
catalogs: [
|
||||||
{ type: "movie", id: "movies", name: "Movies", extra: [{ name: "search", isRequired: false }] },
|
{ type: "movie", id: "movies", name: "Movies", extra: [{ name: "search", isRequired: false }] },
|
||||||
|
|
@ -18,9 +20,24 @@ const MANIFEST = {
|
||||||
],
|
],
|
||||||
resources: ["catalog", "meta", "stream"],
|
resources: ["catalog", "meta", "stream"],
|
||||||
types: ["movie", "series"],
|
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) {
|
export async function registerStremioRoutes(app: FastifyInstance, config: Config) {
|
||||||
const playback = new PlaybackService(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" });
|
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 };
|
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({
|
app.route({
|
||||||
method: ["GET", "HEAD"],
|
method: ["GET", "HEAD"],
|
||||||
url: "/stremio/:token/play/:mediaFileId",
|
url: "/stremio/:token/play/:mediaFileId",
|
||||||
|
|
@ -192,7 +209,7 @@ async function getMovieCatalog(catalogId: string, config: Config, search?: strin
|
||||||
return movies
|
return movies
|
||||||
.filter((m) => m.mediaFiles.length > 0 || !config.HIDE_UNAVAILABLE_TITLES)
|
.filter((m) => m.mediaFiles.length > 0 || !config.HIDE_UNAVAILABLE_TITLES)
|
||||||
.map((m) => ({
|
.map((m) => ({
|
||||||
id: m.imdbId ?? `sth:movie:${m.id}`,
|
id: movieId(m.imdbId, m.id),
|
||||||
type: "movie" as const,
|
type: "movie" as const,
|
||||||
name: m.title,
|
name: m.title,
|
||||||
poster: m.posterUrl ?? undefined,
|
poster: m.posterUrl ?? undefined,
|
||||||
|
|
@ -223,13 +240,20 @@ async function getSeriesCatalog(catalogId: string, config: Config, search?: stri
|
||||||
|
|
||||||
if (catalogId === "recent-series" && !search) {
|
if (catalogId === "recent-series" && !search) {
|
||||||
series = await prisma.series.findMany({
|
series = await prisma.series.findMany({
|
||||||
|
where: {
|
||||||
|
seasons: {
|
||||||
|
some: {
|
||||||
|
episodes: { some: { mediaFiles: { some: { available: true } } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
take: 50,
|
take: 50,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return series.map((s) => ({
|
return series.map((s) => ({
|
||||||
id: s.imdbId ?? `sth:series:${s.id}`,
|
id: seriesId(s.imdbId, s.id),
|
||||||
type: "series" as const,
|
type: "series" as const,
|
||||||
name: s.title,
|
name: s.title,
|
||||||
poster: s.posterUrl ?? undefined,
|
poster: s.posterUrl ?? undefined,
|
||||||
|
|
@ -243,12 +267,12 @@ async function getSeriesCatalog(catalogId: string, config: Config, search?: stri
|
||||||
async function getMovieMeta(id: string) {
|
async function getMovieMeta(id: string) {
|
||||||
const movie = await prisma.movie.findFirst({
|
const movie = await prisma.movie.findFirst({
|
||||||
where: {
|
where: {
|
||||||
OR: [{ imdbId: id }, { id: id.replace("sth:movie:", "") }],
|
OR: [{ imdbId: id }, { id: unwrapLocalId(id, "movie") }],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!movie) return null;
|
if (!movie) return null;
|
||||||
return {
|
return {
|
||||||
id: movie.imdbId ?? `sth:movie:${movie.id}`,
|
id: movieId(movie.imdbId, movie.id),
|
||||||
type: "movie" as const,
|
type: "movie" as const,
|
||||||
name: movie.title,
|
name: movie.title,
|
||||||
poster: movie.posterUrl ?? undefined,
|
poster: movie.posterUrl ?? undefined,
|
||||||
|
|
@ -264,7 +288,7 @@ async function getMovieMeta(id: string) {
|
||||||
async function getSeriesMeta(id: string) {
|
async function getSeriesMeta(id: string) {
|
||||||
const series = await prisma.series.findFirst({
|
const series = await prisma.series.findFirst({
|
||||||
where: {
|
where: {
|
||||||
OR: [{ imdbId: id }, { id: id.replace("sth:series:", "") }],
|
OR: [{ imdbId: id }, { id: unwrapLocalId(id, "series") }],
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
seasons: {
|
seasons: {
|
||||||
|
|
@ -280,11 +304,12 @@ async function getSeriesMeta(id: string) {
|
||||||
});
|
});
|
||||||
if (!series) return null;
|
if (!series) return null;
|
||||||
|
|
||||||
|
const sid = seriesId(series.imdbId, series.id);
|
||||||
const videos = series.seasons.flatMap((season) =>
|
const videos = series.seasons.flatMap((season) =>
|
||||||
season.episodes
|
season.episodes
|
||||||
.filter((ep) => ep.mediaFiles.length > 0)
|
.filter((ep) => ep.mediaFiles.length > 0)
|
||||||
.map((ep) => ({
|
.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}`,
|
title: ep.title ?? `Episode ${ep.episodeNumber}`,
|
||||||
season: ep.seasonNumber,
|
season: ep.seasonNumber,
|
||||||
episode: ep.episodeNumber,
|
episode: ep.episodeNumber,
|
||||||
|
|
@ -294,7 +319,7 @@ async function getSeriesMeta(id: string) {
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: series.imdbId ?? `sth:series:${series.id}`,
|
id: sid,
|
||||||
type: "series" as const,
|
type: "series" as const,
|
||||||
name: series.title,
|
name: series.title,
|
||||||
poster: series.posterUrl ?? undefined,
|
poster: series.posterUrl ?? undefined,
|
||||||
|
|
@ -309,12 +334,12 @@ async function getSeriesMeta(id: string) {
|
||||||
async function getStreams(
|
async function getStreams(
|
||||||
type: string,
|
type: string,
|
||||||
id: string,
|
id: string,
|
||||||
addonToken: string,
|
userId: string,
|
||||||
playback: PlaybackService
|
playback: PlaybackService
|
||||||
) {
|
) {
|
||||||
if (type === "movie") {
|
if (type === "movie") {
|
||||||
const movie = await prisma.movie.findFirst({
|
const movie = await prisma.movie.findFirst({
|
||||||
where: { OR: [{ imdbId: id }, { id: id.replace("sth:movie:", "") }] },
|
where: { OR: [{ imdbId: id }, { id: unwrapLocalId(id, "movie") }] },
|
||||||
include: {
|
include: {
|
||||||
mediaFiles: {
|
mediaFiles: {
|
||||||
where: { available: true },
|
where: { available: true },
|
||||||
|
|
@ -327,12 +352,20 @@ async function getStreams(
|
||||||
const streams = [];
|
const streams = [];
|
||||||
for (const file of movie.mediaFiles) {
|
for (const file of movie.mediaFiles) {
|
||||||
if (file.node.status !== "ONLINE" || file.node.revoked) continue;
|
if (file.node.status !== "ONLINE" || file.node.revoked) continue;
|
||||||
streams.push({
|
try {
|
||||||
name: playback.formatStreamLabel(file),
|
const session = await playback.createSession(file.id, userId);
|
||||||
title: playback.formatStreamLabel(file),
|
streams.push({
|
||||||
url: playback.resolveStreamUrl(addonToken, file.id),
|
name: playback.formatStreamLabel(file),
|
||||||
behaviorHints: { bingeGroup: `sth-movie-${movie.id}` },
|
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;
|
return streams;
|
||||||
}
|
}
|
||||||
|
|
@ -340,13 +373,13 @@ async function getStreams(
|
||||||
if (type === "series") {
|
if (type === "series") {
|
||||||
const match = id.match(/^(.+):(\d+):(\d+)$/);
|
const match = id.match(/^(.+):(\d+):(\d+)$/);
|
||||||
if (!match) return [];
|
if (!match) return [];
|
||||||
const [, seriesId, seasonStr, episodeStr] = match;
|
const [, seriesKey, seasonStr, episodeStr] = match;
|
||||||
const seasonNumber = parseInt(seasonStr, 10);
|
const seasonNumber = parseInt(seasonStr, 10);
|
||||||
const episodeNumber = parseInt(episodeStr, 10);
|
const episodeNumber = parseInt(episodeStr, 10);
|
||||||
|
|
||||||
const series = await prisma.series.findFirst({
|
const series = await prisma.series.findFirst({
|
||||||
where: {
|
where: {
|
||||||
OR: [{ imdbId: seriesId }, { id: seriesId.replace("sth:series:", "") }],
|
OR: [{ imdbId: seriesKey }, { id: unwrapLocalId(seriesKey, "series") }],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!series) return [];
|
if (!series) return [];
|
||||||
|
|
@ -371,12 +404,20 @@ async function getStreams(
|
||||||
const streams = [];
|
const streams = [];
|
||||||
for (const file of episode.mediaFiles) {
|
for (const file of episode.mediaFiles) {
|
||||||
if (file.node.status !== "ONLINE" || file.node.revoked) continue;
|
if (file.node.status !== "ONLINE" || file.node.revoked) continue;
|
||||||
streams.push({
|
try {
|
||||||
name: playback.formatStreamLabel(file),
|
const session = await playback.createSession(file.id, userId);
|
||||||
title: playback.formatStreamLabel(file),
|
streams.push({
|
||||||
url: playback.resolveStreamUrl(addonToken, file.id),
|
name: playback.formatStreamLabel(file),
|
||||||
behaviorHints: { bingeGroup: `sth-series-${series.id}-S${seasonNumber}` },
|
title: playback.formatStreamLabel(file),
|
||||||
});
|
url: session.streamUrl,
|
||||||
|
behaviorHints: {
|
||||||
|
notWebReady: true,
|
||||||
|
bingeGroup: `mc-series-${series.id}-S${seasonNumber}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Skip unavailable node sessions
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return streams;
|
return streams;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -326,15 +326,15 @@ class NodeConnectionManager {
|
||||||
async createPlaybackSessionAcked(
|
async createPlaybackSessionAcked(
|
||||||
nodeId: string,
|
nodeId: string,
|
||||||
payload: CreatePlaybackSessionPayload,
|
payload: CreatePlaybackSessionPayload,
|
||||||
timeoutMs = 800
|
timeoutMs = 3000
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
if (!this.isOnline(nodeId)) return false;
|
if (!this.isOnline(nodeId)) return false;
|
||||||
|
|
||||||
const acked = new Promise<boolean>((resolve) => {
|
const acked = new Promise<boolean>((resolve) => {
|
||||||
const timer = setTimeout(() => {
|
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);
|
this.pendingSessionAcks.delete(payload.sessionId);
|
||||||
resolve(true);
|
resolve(false);
|
||||||
}, timeoutMs);
|
}, timeoutMs);
|
||||||
this.pendingSessionAcks.set(payload.sessionId, { resolve, timer });
|
this.pendingSessionAcks.set(payload.sessionId, { resolve, timer });
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,7 @@ services:
|
||||||
PLAYBACK_ABSOLUTE_LIFETIME_SECONDS: ${PLAYBACK_ABSOLUTE_LIFETIME_SECONDS:-43200}
|
PLAYBACK_ABSOLUTE_LIFETIME_SECONDS: ${PLAYBACK_ABSOLUTE_LIFETIME_SECONDS:-43200}
|
||||||
NODE_HEARTBEAT_INTERVAL_SECONDS: ${NODE_HEARTBEAT_INTERVAL_SECONDS:-30}
|
NODE_HEARTBEAT_INTERVAL_SECONDS: ${NODE_HEARTBEAT_INTERVAL_SECONDS:-30}
|
||||||
NODE_OFFLINE_THRESHOLD_SECONDS: ${NODE_OFFLINE_THRESHOLD_SECONDS:-90}
|
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:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
|
||||||
|
|
@ -28,4 +28,4 @@ PLAYBACK_IDLE_TIMEOUT_SECONDS=1200
|
||||||
PLAYBACK_ABSOLUTE_LIFETIME_SECONDS=43200
|
PLAYBACK_ABSOLUTE_LIFETIME_SECONDS=43200
|
||||||
NODE_HEARTBEAT_INTERVAL_SECONDS=30
|
NODE_HEARTBEAT_INTERVAL_SECONDS=30
|
||||||
NODE_OFFLINE_THRESHOLD_SECONDS=90
|
NODE_OFFLINE_THRESHOLD_SECONDS=90
|
||||||
HIDE_UNAVAILABLE_TITLES=false
|
HIDE_UNAVAILABLE_TITLES=true
|
||||||
|
|
|
||||||
|
|
@ -20,4 +20,4 @@ PLAYBACK_IDLE_TIMEOUT_SECONDS=1200
|
||||||
PLAYBACK_ABSOLUTE_LIFETIME_SECONDS=43200
|
PLAYBACK_ABSOLUTE_LIFETIME_SECONDS=43200
|
||||||
NODE_HEARTBEAT_INTERVAL_SECONDS=30
|
NODE_HEARTBEAT_INTERVAL_SECONDS=30
|
||||||
NODE_OFFLINE_THRESHOLD_SECONDS=90
|
NODE_OFFLINE_THRESHOLD_SECONDS=90
|
||||||
HIDE_UNAVAILABLE_TITLES=false
|
HIDE_UNAVAILABLE_TITLES=true
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,7 @@ prompt() {
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
echo "=== STH Media Node — Unraid install ==="
|
echo "=== Media Node — Unraid install ==="
|
||||||
echo "Master: $MASTER_URL"
|
echo "Master: $MASTER_URL"
|
||||||
echo
|
echo
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ esac
|
||||||
BINARY="media-node-linux-${GOARCH}"
|
BINARY="media-node-linux-${GOARCH}"
|
||||||
DOWNLOAD_URL="${MEDIA_NODE_DOWNLOAD_URL:-https://releases.example.com/media-node/${VERSION}/${BINARY}}"
|
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 "Version: $VERSION"
|
||||||
echo "Architecture: $GOARCH"
|
echo "Architecture: $GOARCH"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=STH Media Node
|
Description=Media Node
|
||||||
After=network-online.target
|
After=network-online.target
|
||||||
Wants=network-online.target
|
Wants=network-online.target
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,7 @@ PLAYBACK_IDLE_TIMEOUT_SECONDS=1200
|
||||||
PLAYBACK_ABSOLUTE_LIFETIME_SECONDS=43200
|
PLAYBACK_ABSOLUTE_LIFETIME_SECONDS=43200
|
||||||
NODE_HEARTBEAT_INTERVAL_SECONDS=30
|
NODE_HEARTBEAT_INTERVAL_SECONDS=30
|
||||||
NODE_OFFLINE_THRESHOLD_SECONDS=90
|
NODE_OFFLINE_THRESHOLD_SECONDS=90
|
||||||
HIDE_UNAVAILABLE_TITLES=false
|
HIDE_UNAVAILABLE_TITLES=true
|
||||||
```
|
```
|
||||||
|
|
||||||
Belangrijk: `DATABASE_URL` wachtwoord moet **exact** gelijk zijn aan `POSTGRES_PASSWORD`.
|
Belangrijk: `DATABASE_URL` wachtwoord moet **exact** gelijk zijn aan `POSTGRES_PASSWORD`.
|
||||||
|
|
|
||||||
|
|
@ -148,7 +148,7 @@ func runService() {
|
||||||
}
|
}
|
||||||
|
|
||||||
func runInstall() {
|
func runInstall() {
|
||||||
fmt.Println("=== STH Media Node Installer ===")
|
fmt.Println("=== Media Node Installer ===")
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
|
|
||||||
masterURL := prompt("Master URL (https://master.media.example.com): ")
|
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()
|
_ = exec.Command("chown", "-R", "root:media-node", "/etc/media-node").Run()
|
||||||
|
|
||||||
unit := `[Unit]
|
unit := `[Unit]
|
||||||
Description=STH Media Node
|
Description=Media Node
|
||||||
After=network-online.target
|
After=network-online.target
|
||||||
Wants=network-online.target
|
Wants=network-online.target
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,15 @@
|
||||||
package scanner
|
package scanner
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
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`)
|
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`)
|
sourceRe = regexp.MustCompile(`(?i)\b(UHD\s*BluRay|BluRay|WEB-DL|WEBRip|HDTV|DVDRip)\b`)
|
||||||
releaseRe = regexp.MustCompile(`(?i)\b(REMUX|PROPER|REPACK)\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`)
|
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})`)
|
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})`)
|
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{} {
|
func parseMovie(base string) map[string]interface{} {
|
||||||
yearMatch := yearRe.FindStringSubmatchIndex(base)
|
title, year := extractMovieTitleYear(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)
|
|
||||||
if title == "" {
|
if title == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -34,21 +31,7 @@ func parseMovie(base string) map[string]interface{} {
|
||||||
if year > 0 {
|
if year > 0 {
|
||||||
result["year"] = year
|
result["year"] = year
|
||||||
}
|
}
|
||||||
if m := resolutionRe.FindStringSubmatch(base); len(m) > 1 {
|
attachTags(result, base)
|
||||||
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]
|
|
||||||
}
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -67,23 +50,186 @@ func parseSeries(base string) map[string]interface{} {
|
||||||
"season": season,
|
"season": season,
|
||||||
"episode": episode,
|
"episode": episode,
|
||||||
}
|
}
|
||||||
if rm := resolutionRe.FindStringSubmatch(base); len(rm) > 1 {
|
attachTags(result, base)
|
||||||
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]
|
|
||||||
}
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
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 {
|
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 {
|
func atoi(s string) int {
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
func TestParseSeries(t *testing.T) {
|
||||||
p := parseSeries("Breaking.Bad.S03E04.1080p.BluRay")
|
p := parseSeries("Breaking.Bad.S03E04.1080p.BluRay")
|
||||||
if p == nil {
|
if p == nil {
|
||||||
|
|
@ -34,3 +47,35 @@ func TestParseSeriesAlt(t *testing.T) {
|
||||||
t.Fatalf("unexpected: %#v", p)
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -91,23 +91,12 @@ func (s *Scanner) FullScan() {
|
||||||
|
|
||||||
log.Println("Starting full media scan...")
|
log.Println("Starting full media scan...")
|
||||||
found := make(map[string]bool)
|
found := make(map[string]bool)
|
||||||
dirs := append(s.cfg.Media.Movies, s.cfg.Media.Series...)
|
|
||||||
|
|
||||||
for _, dir := range dirs {
|
for _, dir := range s.cfg.Media.Movies {
|
||||||
if dir == "" {
|
s.walkRoot(dir, "movie", found)
|
||||||
continue
|
}
|
||||||
}
|
for _, dir := range s.cfg.Media.Series {
|
||||||
filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
|
s.walkRoot(dir, "episode", found)
|
||||||
if err != nil || info.IsDir() {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if !isVideo(path) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
found[path] = true
|
|
||||||
s.indexFile(path, info, "FILE_UPDATED")
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
existing, _ := s.store.AllFiles()
|
existing, _ := s.store.AllFiles()
|
||||||
|
|
@ -126,6 +115,23 @@ func (s *Scanner) FullScan() {
|
||||||
log.Println("Full scan completed")
|
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() {
|
func (s *Scanner) watchFilesystem() {
|
||||||
watcher, err := fsnotify.NewWatcher()
|
watcher, err := fsnotify.NewWatcher()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -134,11 +140,24 @@ func (s *Scanner) watchFilesystem() {
|
||||||
}
|
}
|
||||||
defer watcher.Close()
|
defer watcher.Close()
|
||||||
|
|
||||||
dirs := append(s.cfg.Media.Movies, s.cfg.Media.Series...)
|
rootType := map[string]string{}
|
||||||
for _, dir := range dirs {
|
for _, dir := range s.cfg.Media.Movies {
|
||||||
if dir == "" {
|
if dir == "" {
|
||||||
continue
|
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 {
|
filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
|
||||||
if err == nil && info.IsDir() {
|
if err == nil && info.IsDir() {
|
||||||
_ = watcher.Add(path)
|
_ = watcher.Add(path)
|
||||||
|
|
@ -168,14 +187,17 @@ func (s *Scanner) watchFilesystem() {
|
||||||
s.handleRemove(event.Name)
|
s.handleRemove(event.Name)
|
||||||
}
|
}
|
||||||
if !debounce.Stop() {
|
if !debounce.Stop() {
|
||||||
<-debounce.C
|
select {
|
||||||
|
case <-debounce.C:
|
||||||
|
default:
|
||||||
|
}
|
||||||
}
|
}
|
||||||
debounce.Reset(2 * time.Second)
|
debounce.Reset(2 * time.Second)
|
||||||
case <-debounce.C:
|
case <-debounce.C:
|
||||||
for path := range pending {
|
for path := range pending {
|
||||||
info, err := os.Stat(path)
|
info, err := os.Stat(path)
|
||||||
if err == nil && !info.IsDir() {
|
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{})
|
pending = make(map[string]struct{})
|
||||||
|
|
@ -188,9 +210,30 @@ func (s *Scanner) watchFilesystem() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Scanner) indexFile(path string, info os.FileInfo, eventType string) {
|
func (s *Scanner) detectRootType(path string, rootType map[string]string) string {
|
||||||
mediaType := detectMediaType(filepath.Base(path))
|
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 == "" {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -247,12 +290,11 @@ func isVideo(path string) bool {
|
||||||
return videoExtensions[ext]
|
return videoExtensions[ext]
|
||||||
}
|
}
|
||||||
|
|
||||||
func detectMediaType(filename string) string {
|
func detectMediaType(path string) string {
|
||||||
base := strings.TrimSuffix(filename, filepath.Ext(filename))
|
if parseSeriesFromPath(path) != nil {
|
||||||
if parseSeries(base) != nil {
|
|
||||||
return "episode"
|
return "episode"
|
||||||
}
|
}
|
||||||
if parseMovie(base) != nil {
|
if parseMovieFromPath(path) != nil {
|
||||||
return "movie"
|
return "movie"
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
|
|
@ -267,16 +309,15 @@ func buildMediaInfo(f database.LocalFile) MediaFileInfo {
|
||||||
ModifiedAt: f.ModifiedAt.UTC().Format(time.RFC3339),
|
ModifiedAt: f.ModifiedAt.UTC().Format(time.RFC3339),
|
||||||
MediaType: f.MediaType,
|
MediaType: f.MediaType,
|
||||||
}
|
}
|
||||||
base := strings.TrimSuffix(f.ReleaseName, filepath.Ext(f.ReleaseName))
|
|
||||||
if f.MediaType == "movie" {
|
if f.MediaType == "movie" {
|
||||||
if p := parseMovie(base); p != nil {
|
if p := parseMovieFromPath(f.Path); p != nil {
|
||||||
info.ParsedMovie = p
|
info.ParsedMovie = p
|
||||||
info.Resolution = strVal(p, "resolution")
|
info.Resolution = strVal(p, "resolution")
|
||||||
info.VideoCodec = strVal(p, "videoCodec")
|
info.VideoCodec = strVal(p, "videoCodec")
|
||||||
info.AudioCodec = strVal(p, "audio")
|
info.AudioCodec = strVal(p, "audio")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if p := parseSeries(base); p != nil {
|
if p := parseSeriesFromPath(f.Path); p != nil {
|
||||||
info.ParsedEpisode = p
|
info.ParsedEpisode = p
|
||||||
info.Resolution = strVal(p, "resolution")
|
info.Resolution = strVal(p, "resolution")
|
||||||
info.VideoCodec = strVal(p, "videoCodec")
|
info.VideoCodec = strVal(p, "videoCodec")
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue