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
|
||||
NODE_HEARTBEAT_INTERVAL_SECONDS=30
|
||||
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),
|
||||
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>;
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -182,12 +182,19 @@ export class MetadataService {
|
|||
}
|
||||
|
||||
private async findExistingMovie(title: string, year?: number) {
|
||||
return prisma.movie.findFirst({
|
||||
if (year) {
|
||||
const withYear = await prisma.movie.findFirst({
|
||||
where: {
|
||||
title: { equals: title, mode: "insensitive" },
|
||||
...(year ? { year } : {}),
|
||||
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" } },
|
||||
});
|
||||
}
|
||||
|
||||
private async searchTmdbMovie(title: string, year?: number) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
try {
|
||||
const session = await playback.createSession(file.id, userId);
|
||||
streams.push({
|
||||
name: playback.formatStreamLabel(file),
|
||||
title: playback.formatStreamLabel(file),
|
||||
url: playback.resolveStreamUrl(addonToken, file.id),
|
||||
behaviorHints: { bingeGroup: `sth-movie-${movie.id}` },
|
||||
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;
|
||||
try {
|
||||
const session = await playback.createSession(file.id, userId);
|
||||
streams.push({
|
||||
name: playback.formatStreamLabel(file),
|
||||
title: playback.formatStreamLabel(file),
|
||||
url: playback.resolveStreamUrl(addonToken, file.id),
|
||||
behaviorHints: { bingeGroup: `sth-series-${series.id}-S${seasonNumber}` },
|
||||
url: session.streamUrl,
|
||||
behaviorHints: {
|
||||
notWebReady: true,
|
||||
bingeGroup: `mc-series-${series.id}-S${seasonNumber}`,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// Skip unavailable node sessions
|
||||
}
|
||||
}
|
||||
return streams;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ prompt() {
|
|||
exit 1
|
||||
}
|
||||
|
||||
echo "=== STH Media Node — Unraid install ==="
|
||||
echo "=== Media Node — Unraid install ==="
|
||||
echo "Master: $MASTER_URL"
|
||||
echo
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
[Unit]
|
||||
Description=STH Media Node
|
||||
Description=Media Node
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
for _, dir := range s.cfg.Media.Movies {
|
||||
s.walkRoot(dir, "movie", found)
|
||||
}
|
||||
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.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")
|
||||
|
|
|
|||
Loading…
Reference in a new issue