127 lines
4.5 KiB
Python
127 lines
4.5 KiB
Python
"""NFO reader — Curator's metadata source after ČSFD.
|
||
|
||
Jellyfin (configured to save metadata into the media folders) writes a Kodi-style
|
||
``.nfo`` next to each movie plus artwork. Curator only **reads** it — to drive the
|
||
miniDLNA genre tree and to fill the year/rating used by folder grouping. It never
|
||
writes NFO (Jellyfin owns that).
|
||
|
||
For a flat pool file ``Title (rok) [tmdbid].mkv`` the sidecar is
|
||
``Title (rok) [tmdbid].nfo``; a per-movie folder may instead hold ``movie.nfo``.
|
||
Both are found. Missing or unparsable NFO → ``None`` (the movie simply has no
|
||
genres until Jellyfin has scanned it).
|
||
"""
|
||
|
||
import xml.etree.ElementTree as ET
|
||
from dataclasses import dataclass, field
|
||
from pathlib import Path
|
||
|
||
|
||
@dataclass
|
||
class NfoData:
|
||
"""The bits of a movie ``.nfo`` Curator uses."""
|
||
|
||
title: str | None = None
|
||
year: str | None = None # four-digit string
|
||
genres: list[str] = field(default_factory=list)
|
||
countries: list[str] = field(default_factory=list)
|
||
rating: int | None = None # 0–100 (NFO 0–10 scaled up), or None
|
||
tmdb_id: int | None = None
|
||
csfd_url: str | None = None # ČSFD film URL, if the NFO carries a csfd id
|
||
|
||
def field_values(self, name: str) -> list[str]:
|
||
"""Values for a tag-schema source field (genres/countries/year/rating)."""
|
||
if name == "genres":
|
||
return list(self.genres)
|
||
if name == "countries":
|
||
return list(self.countries)
|
||
if name == "year":
|
||
return [self.year] if self.year else []
|
||
if name == "rating":
|
||
return [str(self.rating)] if self.rating is not None else []
|
||
return []
|
||
|
||
|
||
def find_nfo(video_path: Path) -> Path | None:
|
||
"""Locate the ``.nfo`` for a movie: sibling ``<stem>.nfo`` or ``movie.nfo``."""
|
||
video_path = Path(video_path)
|
||
sibling = video_path.with_suffix(".nfo")
|
||
if sibling.is_file():
|
||
return sibling
|
||
movie_nfo = video_path.parent / "movie.nfo"
|
||
if movie_nfo.is_file():
|
||
return movie_nfo
|
||
return None
|
||
|
||
|
||
def _texts(root: ET.Element, tag: str) -> list[str]:
|
||
return [e.text.strip() for e in root.findall(tag) if e.text and e.text.strip()]
|
||
|
||
|
||
def _parse_rating(root: ET.Element) -> int | None:
|
||
"""Read a 0–10 NFO rating and scale it to 0–100 (to match the old bands)."""
|
||
raw = root.findtext("rating")
|
||
if raw is None:
|
||
value = root.find("ratings/rating/value")
|
||
raw = value.text if value is not None else None
|
||
if not raw:
|
||
return None
|
||
try:
|
||
return round(float(raw.strip().replace(",", ".")) * 10)
|
||
except ValueError:
|
||
return None
|
||
|
||
|
||
def _parse_tmdb_id(root: ET.Element) -> int | None:
|
||
for uid in root.findall("uniqueid"):
|
||
if uid.get("type") == "tmdb" and uid.text and uid.text.strip().isdigit():
|
||
return int(uid.text.strip())
|
||
raw = root.findtext("tmdbid")
|
||
return int(raw.strip()) if raw and raw.strip().isdigit() else None
|
||
|
||
|
||
def _parse_csfd_url(root: ET.Element) -> str | None:
|
||
"""A ČSFD film URL from the NFO, if any.
|
||
|
||
Czech Kodi scrapers record ČSFD as ``<uniqueid type="csfd">`` (a bare id or a
|
||
full URL) or a legacy ``<csfdid>`` / ``<csfd>`` element. A bare id is expanded
|
||
to ``https://www.csfd.cz/film/<id>/``.
|
||
"""
|
||
candidates: list[str] = []
|
||
for uid in root.findall("uniqueid"):
|
||
if uid.get("type") == "csfd" and uid.text and uid.text.strip():
|
||
candidates.append(uid.text.strip())
|
||
for tag in ("csfdid", "csfd"):
|
||
raw = root.findtext(tag)
|
||
if raw and raw.strip():
|
||
candidates.append(raw.strip())
|
||
for value in candidates:
|
||
if value.isdigit():
|
||
return f"https://www.csfd.cz/film/{value}/"
|
||
if "csfd.cz" in value:
|
||
return value if value.startswith("http") else f"https://{value.lstrip('/')}"
|
||
return None
|
||
|
||
|
||
def read_nfo(video_path: Path) -> NfoData | None:
|
||
"""Read the movie's ``.nfo`` into :class:`NfoData`, or None if absent/broken."""
|
||
path = find_nfo(video_path)
|
||
if path is None:
|
||
return None
|
||
try:
|
||
root = ET.parse(path).getroot()
|
||
except ET.ParseError, OSError:
|
||
return None
|
||
|
||
year = root.findtext("year")
|
||
year = year.strip() if year and year.strip().isdigit() else None
|
||
title = root.findtext("title")
|
||
return NfoData(
|
||
title=title.strip() if title and title.strip() else None,
|
||
year=year,
|
||
genres=_texts(root, "genre"),
|
||
countries=_texts(root, "country"),
|
||
rating=_parse_rating(root),
|
||
tmdb_id=_parse_tmdb_id(root),
|
||
csfd_url=_parse_csfd_url(root),
|
||
)
|