Migrate from Poetry and pyenv to uv
This commit is contained in:
+153
-21
@@ -12,42 +12,82 @@ Episodes are matched by ``SxxEyy``. These helpers parse the episode number out o
|
||||
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
|
||||
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}))")
|
||||
_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",
|
||||
".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]
|
||||
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 "?"
|
||||
return f"S{self.season:02d}E{self.episode:02d}"
|
||||
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: Optional[str]
|
||||
tmdb_id: Optional[int]
|
||||
year: str | None
|
||||
tmdb_id: int | None
|
||||
library: str
|
||||
episodes: list[Episode] = field(default_factory=list)
|
||||
|
||||
@@ -55,13 +95,15 @@ class Series:
|
||||
"""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)
|
||||
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))
|
||||
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]]:
|
||||
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:
|
||||
@@ -71,6 +113,38 @@ def parse_episode(name: str) -> Optional[tuple[int, int]]:
|
||||
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.
|
||||
|
||||
@@ -82,7 +156,8 @@ def collect_episode_files(folder: Path) -> list[Path]:
|
||||
if not folder.is_dir():
|
||||
return []
|
||||
return sorted(
|
||||
f for f in folder.rglob("*")
|
||||
f
|
||||
for f in folder.rglob("*")
|
||||
if f.is_file() and f.suffix.lower() not in _SIDECARS
|
||||
)
|
||||
|
||||
@@ -97,6 +172,23 @@ def is_season_folder(name: str) -> bool:
|
||||
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]')
|
||||
|
||||
@@ -107,17 +199,29 @@ def sanitize_title(title: str) -> str:
|
||||
return re.sub(r"\s+", " ", cleaned).strip(" .")
|
||||
|
||||
|
||||
def episode_filename(season: int, episode: int, series_title: str, ext: str) -> str:
|
||||
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``.
|
||||
``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)
|
||||
return f"{code} - {body}{ext}" if body else f"{code}{ext}"
|
||||
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:
|
||||
@@ -142,24 +246,46 @@ def is_canonical_episode(path: Path, series_title: str) -> bool:
|
||||
if parsed is None:
|
||||
return False
|
||||
season, episode = parsed
|
||||
expected = episode_filename(season, episode, series_title, path.suffix)
|
||||
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) -> list[Series]:
|
||||
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])
|
||||
@@ -172,7 +298,13 @@ def scan_series(lib_dir: Path, library: str) -> list[Series]:
|
||||
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))
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user