311 lines
11 KiB
Python
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
|