Files
Curator/src/core/series.py
T

311 lines
11 KiB
Python

"""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"(?<![A-Za-z0-9])(?:{_PART_TYPES})[\s._-]?(\d{{1,2}})$", re.IGNORECASE
)
# The compact SxxEyy field a user types by hand (e.g. "S01E05P2") may also use
# a bare "P" and no separator at all — safe only because it is matched against
# just the tail *after* the recognised SxxEyy, never against a whole filename.
_PART_RE_LOOSE = re.compile(
rf"[\s._-]?(?:{_PART_TYPES}|p)[\s._-]?(\d{{1,2}})$", re.IGNORECASE
)
# 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: int | None
episode: int | None
# Which physical part of the episode this file is (Jellyfin stacking,
# e.g. an old recording split into two files) — None for a whole episode.
part: int | None = None
@property
def code(self) -> 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 <n>`` (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