144 lines
5.6 KiB
Python
144 lines
5.6 KiB
Python
"""Pool file naming convention.
|
|
|
|
Every movie in the pool is named ``Title (YYYY) [tmdbid-N].ext`` — a title, a
|
|
four-digit year in parentheses, and (when known) the TMDb id in brackets, which
|
|
is exactly the form Jellyfin matches on. The ``[tmdbid-N]`` part is optional: a
|
|
plain ``Title (YYYY)`` is still valid (Jellyfin then matches by name). The year
|
|
lets two same-named films coexist and makes the filename self-describing.
|
|
|
|
A 3D movie additionally carries a ``.3D.<FORMAT>`` tag right before the
|
|
extension (Jellyfin's own convention — https://jellyfin.org/docs/general/server/media/movies/):
|
|
``Title (YYYY) [tmdbid-N].3D.HSBS.ext``. ``FORMAT`` is one of
|
|
:data:`THREE_D_FORMATS`; Jellyfin reads it case-insensitively but Curator always
|
|
writes it upper-case for a single canonical form.
|
|
|
|
These helpers build, validate and parse the stem (the filename without its
|
|
extension).
|
|
"""
|
|
|
|
import re
|
|
|
|
# Jellyfin's recognised 3D format tags (case-insensitive on read; Curator
|
|
# always writes upper-case). hsbs/fsbs = half/full side-by-side, htab/ftab =
|
|
# half/full top-and-bottom, mvc = Multiview Video Coding.
|
|
THREE_D_FORMATS = ("HSBS", "FSBS", "HTAB", "FTAB", "MVC")
|
|
|
|
# A trailing four-digit year in parentheses, then an optional ``[tmdbid-N]`` and
|
|
# an optional ``.3D.<FORMAT>`` tag. The title is non-greedy so a title that
|
|
# itself contains parentheses keeps them: "Kurz (special) (2019)" → title
|
|
# "Kurz (special)", year 2019.
|
|
_POOL_STEM_RE = re.compile(
|
|
r"^(?P<title>.+?) \((?P<year>\d{4})\)(?: \[tmdbid-(?P<id>\d+)\])?"
|
|
r"(?:\.3D\.(?P<format3d>hsbs|fsbs|htab|ftab|mvc))?$",
|
|
re.IGNORECASE,
|
|
)
|
|
|
|
|
|
def is_valid_year(year: object) -> bool:
|
|
"""True when ``year`` is (or stringifies to) exactly four digits."""
|
|
return bool(re.fullmatch(r"\d{4}", str(year).strip()))
|
|
|
|
|
|
def canonical_pool_stem(
|
|
title: str,
|
|
year: object,
|
|
tmdb_id: object = None,
|
|
format_3d: object = None,
|
|
) -> str:
|
|
"""Build the canonical pool stem ``Title (YYYY)`` (+ `` [tmdbid-N]`` /
|
|
``.3D.<FORMAT>`` if given).
|
|
|
|
Raises:
|
|
ValueError: the title is empty, the year is not four digits, a given
|
|
``tmdb_id`` is not a positive integer, or ``format_3d`` isn't one
|
|
of :data:`THREE_D_FORMATS`.
|
|
"""
|
|
title = (title or "").strip()
|
|
year_s = str(year).strip()
|
|
if not title:
|
|
raise ValueError("Název filmu nesmí být prázdný.")
|
|
if not re.fullmatch(r"\d{4}", year_s):
|
|
raise ValueError(f"Rok musí být čtyřmístné číslo, ne „{year}“.")
|
|
stem = f"{title} ({year_s})"
|
|
if tmdb_id is not None and str(tmdb_id).strip() != "":
|
|
id_s = str(tmdb_id).strip()
|
|
if not id_s.isdigit():
|
|
raise ValueError(f"TMDb ID musí být číslo, ne „{tmdb_id}“.")
|
|
stem += f" [tmdbid-{id_s}]"
|
|
if format_3d is not None and str(format_3d).strip() != "":
|
|
fmt = str(format_3d).strip().upper()
|
|
if fmt not in THREE_D_FORMATS:
|
|
raise ValueError(
|
|
f"3D formát musí být jedno z {', '.join(THREE_D_FORMATS)}, "
|
|
f"ne „{format_3d}“."
|
|
)
|
|
stem += f".3D.{fmt}"
|
|
return stem
|
|
|
|
|
|
def parse_pool_stem(stem: str) -> tuple[str, int] | None:
|
|
"""Split a canonical stem into ``(title, year)``; ``None`` if it isn't one.
|
|
|
|
A trailing ``[tmdbid-N]`` is tolerated and dropped (use :func:`parse_tmdb_id`
|
|
to read it), so the returned title is always the clean title.
|
|
"""
|
|
match = _POOL_STEM_RE.match((stem or "").strip())
|
|
if not match:
|
|
return None
|
|
return match.group("title").strip(), int(match.group("year"))
|
|
|
|
|
|
def parse_tmdb_id(stem: str) -> int | None:
|
|
"""Read the ``[tmdbid-N]`` from a canonical stem, or ``None`` if absent."""
|
|
match = _POOL_STEM_RE.match((stem or "").strip())
|
|
if not match or not match.group("id"):
|
|
return None
|
|
return int(match.group("id"))
|
|
|
|
|
|
def parse_3d_format(stem: str) -> str | None:
|
|
"""Read the ``.3D.<FORMAT>`` tag from a canonical stem (upper-cased), or
|
|
``None`` if absent."""
|
|
match = _POOL_STEM_RE.match((stem or "").strip())
|
|
if not match or not match.group("format3d"):
|
|
return None
|
|
return match.group("format3d").upper()
|
|
|
|
|
|
def is_canonical_pool_stem(stem: str) -> bool:
|
|
"""True when ``stem`` matches the ``Title (YYYY)`` convention."""
|
|
return _POOL_STEM_RE.match((stem or "").strip()) is not None
|
|
|
|
|
|
def year_from_filename(name: str) -> str | None:
|
|
"""Best-effort four-digit year guessed from a raw (release) filename.
|
|
|
|
Prefers a parenthesised year (``… (1999) …``) and otherwise falls back to the
|
|
last standalone 19xx/20xx run. Used only to pre-fill the import dialog — the
|
|
user confirms it. Returns the year as a string, or ``None``.
|
|
"""
|
|
paren = re.search(r"\((19\d{2}|20\d{2})\)", name)
|
|
if paren:
|
|
return paren.group(1)
|
|
loose = re.findall(r"(?<!\d)(19\d{2}|20\d{2})(?!\d)", name)
|
|
return loose[-1] if loose else None
|
|
|
|
|
|
# "3D" + one of THREE_D_FORMATS, anywhere in a raw filename, bordered by a
|
|
# non-alphanumeric character (or the string's edge) on each side so it can't
|
|
# match inside an unrelated word.
|
|
_THREE_D_ANYWHERE_RE = re.compile(
|
|
rf"(?<![A-Za-z0-9])3d[\s._-]*({'|'.join(THREE_D_FORMATS)})(?![A-Za-z0-9])",
|
|
re.IGNORECASE,
|
|
)
|
|
|
|
|
|
def three_d_format_from_filename(name: str) -> str | None:
|
|
"""Best-effort 3D format guessed from a raw (release) filename, e.g.
|
|
``Avatar.2009.3D.HSBS.BluRay.mkv`` → ``"HSBS"``. Unlike :func:`parse_3d_format`
|
|
this doesn't require the rest of the name to be a canonical pool stem — used
|
|
to pre-fill a version label while importing. Returns ``None`` if absent.
|
|
"""
|
|
match = _THREE_D_ANYWHERE_RE.search(name or "")
|
|
return match.group(1).upper() if match else None
|