"""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 ``.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 ```` (a bare id or a full URL) or a legacy ```` / ```` element. A bare id is expanded to ``https://www.csfd.cz/film//``. """ 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), )