Add movie-keyed pool index with multi-version movies, ČSFD title bridge, subtitles and miniDLNA library scoping

This commit is contained in:
2026-09-13 17:16:48 +02:00
parent c0ad1c1553
commit c399a6aed3
26 changed files with 4635 additions and 221 deletions
+39 -18
View File
@@ -71,31 +71,52 @@ def parse_episode(name: str) -> Optional[tuple[int, int]]:
return int(match.group(3)), int(match.group(4))
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 episode_body(stem: str) -> str:
"""The descriptive part of an episode filename, minus a leading ``SxxEyy`` code.
Keeps the original (release) filename so it survives into the canonical name,
while stripping an already-applied ``SxxEyy - `` prefix so repeated renames
don't double it. Returns ``""`` when the stem was only the code.
"""
s = _EPISODE_RE.sub("", stem, count=1) # drop the SxxEyy/NxNN token
s = re.sub(r"([\s._-])[\s._-]+", r"\1", s) # collapse separator runs
return s.strip(" ._-")
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 episode_filename(season: int, episode: int, orig_stem: str, ext: str) -> str:
"""Canonical episode filename ``SxxEyy - {original name}.ext``.
# Characters not allowed in a filename on the common filesystems.
_FS_UNSAFE = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
Keeps the original filename after the code; when the original was only the
code (nothing descriptive left) it collapses to ``SxxEyy.ext``.
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) -> 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``.
"""
code = f"S{season:02d}E{episode:02d}"
body = episode_body(orig_stem)
body = sanitize_title(series_title)
return f"{code} - {body}{ext}" if body else f"{code}{ext}"
@@ -115,13 +136,13 @@ def series_folder_stem(title: str, year: object = None, tmdb_id: object = None)
return f"{title} [tmdbid-{id_s}]" if id_s.isdigit() else title
def is_canonical_episode(path: Path) -> bool:
"""True when ``path`` already follows ``SxxEyy - name`` in the right season."""
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
expected = episode_filename(season, episode, path.stem, path.suffix)
expected = episode_filename(season, episode, series_title, path.suffix)
if path.name != expected:
return False
return path.parent.name == season_folder_name(season)