"""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 .naming import ( canonical_pool_stem, is_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}))" ) # Jellyfin's own "stacking" tokens for one episode split across several # physical files (an old rip cut into CDs, a two-tape recording, …) — as # opposed to E01-E02, which is *two different* episodes in one file. _PART_TYPES = r"cd|dvd|part|pt|disc|disk" # Scanning a real filename: the token must sit at its own boundary right # before the end of the stem, so a title that happens to contain "cd" or # "part" as part of a word is never misread as a stacking suffix. _PART_RE = re.compile( rf"(? str: if self.season is None or self.episode is None: return "?" base = f"S{self.season:02d}E{self.episode:02d}" return f"{base}.pt{self.part}" if self.part else base @dataclass class Series: folder: Path title: str year: str | None tmdb_id: int | None 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) -> tuple[int, int] | None: """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 parse_episode_part(name: str) -> int | None: """Part number implied by a Jellyfin stacking suffix in a filename, or ``None``. Looks for *cd*, *dvd*, *part*, *pt*, *disc* or *disk* + a number sitting right before the extension (``Show S01E02.pt2.mkv``, ``Show S01E02-part-2.mkv``, …) — the same tokens Jellyfin itself scans for. """ match = _PART_RE.search(Path(name).stem) return int(match.group(1)) if match else None def parse_episode_code(text: str) -> tuple[int, int, int | None] | None: """Parse a compact user-typed code: ``SxxEyy``, optionally + a part. Accepts everything :func:`parse_episode` does, plus a trailing part marker right after it — ``S01E02P3``, ``S01E02 pt3``, ``S01E02.part.3``, ``S01E02 cd2`` — for a file that is only *one part* of that episode. Returns ``(season, episode, part)`` with ``part`` ``None`` when absent, or ``None`` when no ``SxxEyy`` is found at all. """ text = (text or "").strip() match = _EPISODE_RE.search(text) if not match: return None if match.group(1) is not None: season, episode = int(match.group(1)), int(match.group(2)) else: season, episode = int(match.group(3)), int(match.group(4)) part_match = _PART_RE_LOOSE.match(text[match.end() :]) return season, episode, int(part_match.group(1)) if part_match else None def collect_episode_files(folder: Path) -> list[Path]: """Every video file under ``folder`` (recursively), sidecars excluded, sorted. Lets the episode-import dialog take a whole ``Series/Season NN/`` tree (or any nested layout) in one pick instead of file-by-file. Season/episode numbers are still read per file from its name by :func:`parse_episode`. """ folder = Path(folder) if not folder.is_dir(): return [] return sorted( f for f in folder.rglob("*") if f.is_file() and f.suffix.lower() not in _SIDECARS ) 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 is_season_folder(name: str) -> bool: """True for a season folder name — ``Specials`` or ``Season `` (any padding).""" return name == "Specials" or re.fullmatch(r"Season \d{1,3}", name) is not None def looks_like_series_folder(folder: Path) -> bool: """True when ``folder`` holds at least one ``Season NN``/``Specials`` subfolder. That's the shape Curator always gives a series (episodes always land in a season folder, never loose at the series root) — a movie's version folder never has one, its files sit flat inside it. This is how a ``mixed`` library tells the two apart even though both use the identical outer ``Title (rok) [tmdbid]/`` name: :meth:`FileManager.is_movie_group_member` and :func:`scan_series` both check it before deciding which one a canonical-shaped subfolder is. """ folder = Path(folder) if not folder.is_dir(): return False return any(p.is_dir() and is_season_folder(p.name) for p in folder.iterdir()) # Characters not allowed in a filename on the common filesystems. _FS_UNSAFE = re.compile(r'[<>:"/\\|?*\x00-\x1f]') def sanitize_title(title: str) -> str: """A series/episode title made safe to sit in a filename (spaces collapsed).""" cleaned = _FS_UNSAFE.sub("", title or "") return re.sub(r"\s+", " ", cleaned).strip(" .") def episode_filename( season: int, episode: int, series_title: str, ext: str, *, part: int | None = None, ) -> str: """Canonical episode filename ``SxxEyy - {series title}.ext``. The release string is deliberately **not** kept — identity is the ``SxxEyy`` code, and Jellyfin fetches the episode title/metadata from TMDb (matched by the code + the series folder's ``[tmdbid-N]``). An empty title collapses to ``SxxEyy.ext``. ``part`` marks one physical part of that episode — an old recording split across several files — with Jellyfin's own ``.ptN`` stacking suffix right before the extension. """ code = f"S{season:02d}E{episode:02d}" body = sanitize_title(series_title) name = f"{code} - {body}" if body else code if part: name += f".pt{part}" return f"{name}{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`` is already ``SxxEyy - {series title}.ext`` in its season.""" parsed = parse_episode(path.name) if parsed is None: return False season, episode = parsed part = parse_episode_part(path.name) expected = episode_filename(season, episode, series_title, path.suffix, part=part) if path.name != expected: return False return path.parent.name == season_folder_name(season) def scan_series( lib_dir: Path, library: str, *, skip_movie_folders: bool = False, ) -> 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. ``skip_movie_folders=True`` (a ``mixed`` library, where a movie's version folder and a series folder both use the identical ``Title (rok) [tmdbid]/`` shape) additionally skips a canonical-shaped folder with no ``Season NN``/``Specials`` inside it — that's a movie's version folder instead, left for :meth:`FileManager.load_pool_movies` to read. Left ``False`` (the default, a plain ``series``-kind library, which never holds movies) every subfolder is read as a series regardless of shape — a raw, not-yet-renamed import may have loose episode files sitting at the series root, with no season folder yet. """ 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()): if ( skip_movie_folders and is_canonical_pool_stem(folder.name) and not looks_like_series_folder(folder) ): continue 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, parse_episode_part(f.name), ) ) series_list.append(Series(folder, title, year, tmdb_id, library, episodes)) return series_list