Rework Curator into a Jellyfin-first library manager
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
"""Series naming + scanning for the Jellyfin TV layout.
|
||||
|
||||
Jellyfin wants series laid out as::
|
||||
|
||||
Seriály/Series (year) [tmdbid-N]/
|
||||
tvshow.nfo (Jellyfin writes it)
|
||||
Season 01/
|
||||
Series (year) S01E01.ext
|
||||
Specials/ (season 0)
|
||||
|
||||
Episodes are matched by ``SxxEyy``. These helpers parse the episode number out of
|
||||
a raw filename, build the canonical episode/season/series names, and scan a
|
||||
series library into a ``Series → Season → Episode`` tree with a naming status.
|
||||
"""
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from .naming import canonical_pool_stem, parse_pool_stem, parse_tmdb_id
|
||||
|
||||
# SxxEyy / s1e2 / 1x02 — the episode marker in a filename. First match wins.
|
||||
_EPISODE_RE = re.compile(r"(?:[Ss](\d{1,2})[\s._-]*[Ee](\d{1,3}))|(?:(\d{1,2})x(\d{1,3}))")
|
||||
|
||||
# Files next to an episode that are not video (NFO / artwork / subtitles).
|
||||
_SIDECARS = {
|
||||
".nfo", ".jpg", ".jpeg", ".png", ".webp", ".bmp", ".gif", ".tbn",
|
||||
".srt", ".ass", ".ssa", ".sub", ".idx", ".vtt",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Episode:
|
||||
path: Path
|
||||
season: Optional[int]
|
||||
episode: Optional[int]
|
||||
|
||||
@property
|
||||
def code(self) -> str:
|
||||
if self.season is None or self.episode is None:
|
||||
return "?"
|
||||
return f"S{self.season:02d}E{self.episode:02d}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Series:
|
||||
folder: Path
|
||||
title: str
|
||||
year: Optional[str]
|
||||
tmdb_id: Optional[int]
|
||||
library: str
|
||||
episodes: list[Episode] = field(default_factory=list)
|
||||
|
||||
def seasons(self) -> dict[int, list[Episode]]:
|
||||
"""Episodes grouped by season number (``-1`` = unrecognised)."""
|
||||
grouped: dict[int, list[Episode]] = {}
|
||||
for ep in self.episodes:
|
||||
grouped.setdefault(ep.season if ep.season is not None else -1, []).append(ep)
|
||||
for eps in grouped.values():
|
||||
eps.sort(key=lambda e: (e.episode if e.episode is not None else 1_000_000))
|
||||
return dict(sorted(grouped.items()))
|
||||
|
||||
|
||||
def parse_episode(name: str) -> Optional[tuple[int, int]]:
|
||||
"""Extract ``(season, episode)`` from a filename, or ``None``."""
|
||||
match = _EPISODE_RE.search(name)
|
||||
if not match:
|
||||
return None
|
||||
if match.group(1) is not None:
|
||||
return int(match.group(1)), int(match.group(2))
|
||||
return int(match.group(3)), int(match.group(4))
|
||||
|
||||
|
||||
def season_folder_name(season: int) -> str:
|
||||
"""Folder name for a season — ``Specials`` for 0, else ``Season NN``."""
|
||||
return "Specials" if season == 0 else f"Season {season:02d}"
|
||||
|
||||
|
||||
def episode_filename(series_title: str, season: int, episode: int, ext: str) -> str:
|
||||
"""Canonical episode filename ``Series SxxEyy.ext``."""
|
||||
return f"{series_title} S{season:02d}E{episode:02d}{ext}"
|
||||
|
||||
|
||||
def series_folder_stem(title: str, year: object = None, tmdb_id: object = None) -> str:
|
||||
"""Series folder name: ``Title (year) [tmdbid-N]`` (year/id optional).
|
||||
|
||||
Reuses the movie convention when a year is present; otherwise falls back to
|
||||
``Title [tmdbid-N]`` or just ``Title`` so a year-less series still works.
|
||||
"""
|
||||
title = (title or "").strip()
|
||||
if not title:
|
||||
raise ValueError("Název seriálu nesmí být prázdný.")
|
||||
year_s = str(year).strip() if year is not None else ""
|
||||
if re.fullmatch(r"\d{4}", year_s):
|
||||
return canonical_pool_stem(title, year_s, tmdb_id)
|
||||
id_s = str(tmdb_id).strip() if tmdb_id is not None else ""
|
||||
return f"{title} [tmdbid-{id_s}]" if id_s.isdigit() else title
|
||||
|
||||
|
||||
def is_canonical_episode(path: Path, series_title: str) -> bool:
|
||||
"""True when ``path`` already follows ``Series SxxEyy`` in the right season."""
|
||||
parsed = parse_episode(path.name)
|
||||
if parsed is None:
|
||||
return False
|
||||
season, episode = parsed
|
||||
expected = episode_filename(series_title, season, episode, path.suffix)
|
||||
if path.name != expected:
|
||||
return False
|
||||
return path.parent.name == season_folder_name(season)
|
||||
|
||||
|
||||
def scan_series(lib_dir: Path, library: str) -> list[Series]:
|
||||
"""Scan one series library into a list of :class:`Series`.
|
||||
|
||||
Each immediate subfolder of ``lib_dir`` is a series; its name yields the
|
||||
title/year/tmdbid. Video files anywhere below it are episodes (season/episode
|
||||
parsed from the filename). Non-video sidecars are skipped.
|
||||
"""
|
||||
lib_dir = Path(lib_dir)
|
||||
if not lib_dir.is_dir():
|
||||
return []
|
||||
series_list: list[Series] = []
|
||||
for folder in sorted(p for p in lib_dir.iterdir() if p.is_dir()):
|
||||
parsed = parse_pool_stem(folder.name)
|
||||
if parsed is not None:
|
||||
title, year = parsed[0], str(parsed[1])
|
||||
else:
|
||||
title, year = folder.name, None
|
||||
tmdb_id = parse_tmdb_id(folder.name)
|
||||
|
||||
episodes: list[Episode] = []
|
||||
for f in sorted(folder.rglob("*")):
|
||||
if not f.is_file() or f.suffix.lower() in _SIDECARS:
|
||||
continue
|
||||
code = parse_episode(f.name)
|
||||
episodes.append(Episode(
|
||||
f, code[0] if code else None, code[1] if code else None))
|
||||
series_list.append(Series(folder, title, year, tmdb_id, library, episodes))
|
||||
return series_list
|
||||
Reference in New Issue
Block a user