"""Write Kodi/Jellyfin-style sidecars next to imported video — NFO metadata + the full artwork set — so the library is self-describing on disk (server-agnostic + portable). The metadata comes from the on-demand TMDB detail fetch (``engine.tmdb_detail``), passed in as ``meta``. This module is pure — NFO XML building + a write plan — with the filesystem injected, so it's unit-tested without disk or network. Best-effort BY CONTRACT: the caller treats every failure as non-fatal (a missing poster or a flaky fetch never breaks an import). Layout written: Movie folder: movie.nfo, poster.jpg, fanart.jpg, clearlogo.png Show root: tvshow.nfo, poster.jpg, fanart.jpg, clearlogo.png, seasonNN-poster.jpg Isolated: stdlib only; no music imports. """ from __future__ import annotations from typing import Any from xml.sax.saxutils import escape as _xesc _HEAD = '\n' def _t(tag: str, value: Any) -> str: """A single XML element, or '' when the value is empty (so absent fields are simply omitted rather than written blank).""" if value is None: return "" s = str(value).strip() if not s: return "" return " <%s>%s\n" % (tag, _xesc(s), tag) def _year(meta: dict) -> str: y = meta.get("year") return str(y) if y else "" def _uniqueids(meta: dict, *, tmdb_default: bool) -> str: out = "" tmdb = meta.get("tmdb_id") if tmdb: out += ' %s\n' % ( ' default="true"' if tmdb_default else "", _xesc(str(tmdb))) if meta.get("tvdb_id"): out += ' %s\n' % ( ' default="true"' if not tmdb_default else "", _xesc(str(meta["tvdb_id"]))) if meta.get("imdb_id"): out += ' %s\n' % _xesc(str(meta["imdb_id"])) return out def _genres(meta: dict) -> str: return "".join(_t("genre", g) for g in (meta.get("genres") or []) if g) def _actors(meta: dict, limit: int = 20) -> str: out = "" for i, c in enumerate((meta.get("cast") or [])[:limit]): if not isinstance(c, dict) or not c.get("name"): continue out += " \n %s\n" % _xesc(str(c["name"])) if c.get("character"): out += " %s\n" % _xesc(str(c["character"])) out += " %d\n \n" % i return out def _artwork_tags(meta: dict) -> str: out = "" if meta.get("poster_url"): out += ' %s\n' % _xesc(str(meta["poster_url"])) if meta.get("logo"): out += ' %s\n' % _xesc(str(meta["logo"])) if meta.get("backdrop_url"): out += " \n %s\n \n" % _xesc(str(meta["backdrop_url"])) return out def nfo_movie(meta: dict) -> str: """A Kodi/Jellyfin-compatible ``movie.nfo`` from a TMDB detail dict.""" meta = meta if isinstance(meta, dict) else {} body = ( _t("title", meta.get("title")) + _t("originaltitle", meta.get("original_title")) + _t("year", _year(meta)) + _t("plot", meta.get("overview")) + _t("outline", meta.get("overview")) + _t("tagline", meta.get("tagline")) + _t("runtime", meta.get("runtime_minutes")) + _t("mpaa", meta.get("content_rating")) + _t("studio", meta.get("studio")) + _t("premiered", meta.get("release_date")) + _t("status", meta.get("status")) + _t("rating", meta.get("rating")) + _genres(meta) + _uniqueids(meta, tmdb_default=True) + _artwork_tags(meta) + _actors(meta) ) return _HEAD + "\n" + body + "\n" def nfo_tvshow(meta: dict) -> str: """A Kodi/Jellyfin-compatible ``tvshow.nfo`` from a TMDB detail dict.""" meta = meta if isinstance(meta, dict) else {} body = ( _t("title", meta.get("title")) + _t("year", _year(meta)) + _t("plot", meta.get("overview")) + _t("outline", meta.get("overview")) + _t("tagline", meta.get("tagline")) + _t("runtime", meta.get("runtime_minutes")) + _t("mpaa", meta.get("content_rating")) + _t("studio", meta.get("network")) + _t("premiered", meta.get("first_air_date")) + _t("status", meta.get("status")) + _t("rating", meta.get("rating")) + _genres(meta) + _uniqueids(meta, tmdb_default=True) + _artwork_tags(meta) + _actors(meta) ) return _HEAD + "\n" + body + "\n" def _season_art(meta: dict) -> list: """(url, filename) pairs for season posters — seasonNN-poster.jpg (Specials → 00).""" out = [] for s in (meta.get("_seasons") or []): if not isinstance(s, dict) or not s.get("poster_url"): continue try: n = int(s.get("season_number")) except (TypeError, ValueError): continue out.append((s["poster_url"], "season%02d-poster.jpg" % n)) return out def plan_sidecars(scope: Any, meta: dict, settings: dict) -> dict: """Decide what to write for one imported item. Returns ``{"nfo": (filename, content) | None, "art": [(url, filename), ...]}`` — gated by the ``write_nfo`` / ``save_artwork`` settings. Pure.""" meta = meta if isinstance(meta, dict) else {} settings = settings if isinstance(settings, dict) else {} sc = str(scope or "").lower() nfo = None art = [] if settings.get("write_nfo"): if sc == "movie": nfo = ("movie.nfo", nfo_movie(meta)) elif sc == "episode": nfo = ("tvshow.nfo", nfo_tvshow(meta)) if settings.get("save_artwork"): if meta.get("poster_url"): art.append((meta["poster_url"], "poster.jpg")) if meta.get("backdrop_url"): art.append((meta["backdrop_url"], "fanart.jpg")) if meta.get("logo"): art.append((meta["logo"], "clearlogo.png")) if sc == "episode": art.extend(_season_art(meta)) return {"nfo": nfo, "art": art} def write(folder: str, scope: Any, meta: dict, settings: dict, fs: Any) -> None: """Write the planned sidecars into ``folder`` via the injected ``fs`` (``list_dir``, ``makedirs``, ``write_text(path, str)``, ``save_url(url, path)``). Idempotent: skips any file already present so upgrades/re-imports don't refetch. Best-effort: each file is independent and a failure is swallowed.""" import os plan = plan_sidecars(scope, meta, settings) if not plan["nfo"] and not plan["art"]: return try: existing = {str(n).lower() for n in (fs.list_dir(folder) or [])} except Exception: # noqa: BLE001 existing = set() try: fs.makedirs(folder) except Exception: # noqa: BLE001 return if plan["nfo"]: name, content = plan["nfo"] if name.lower() not in existing: try: fs.write_text(os.path.join(folder, name), content) except Exception: # noqa: BLE001 pass for url, name in plan["art"]: if name.lower() in existing: continue try: fs.save_url(url, os.path.join(folder, name)) except Exception: # noqa: BLE001 pass def write_for(dest_path: str, scope: Any, poster_url: Any, detail: Any, settings: dict, fs: Any) -> None: """Resolve the sidecar folder from a finished file's path and write into it. The folder is the movie folder (parent of the file) or, for an episode, the SHOW root (parent of the Season folder) so show-level art/NFO land once. ``poster_url`` is the baseline from the download row; ``detail`` (the TMDB fetch, or None) fills the rest — so a poster still lands even when the detail fetch is unavailable.""" import os sc = str(scope or "").lower() if sc == "movie": folder = os.path.dirname(dest_path) elif sc == "episode": folder = os.path.dirname(os.path.dirname(dest_path)) # show root, above Season NN else: return if not folder: return meta = {"poster_url": poster_url} if poster_url else {} if isinstance(detail, dict): meta.update(detail) write(folder, sc, meta, settings, fs) __all__ = ["nfo_movie", "nfo_tvshow", "plan_sidecars", "write", "write_for"]