soulsync/core/video/sidecars.py
BoulderBadgeDad efa64db04d Video downloads: best-in-class post-process pipeline
End-to-end import for video grabs, mirroring the music side's rigor and the
Radarr/Sonarr standard, fully isolated in core/video + api/video.

- Importer: parse release -> ffprobe-verify (true resolution, reject corrupt/
  samples) -> templated rename into Movie (Year)/ + Show/Season NN/ -> copy or
  move, carry subtitles, upgrade-replace a worse copy.
- Library Organization settings: editable $token path templates + toggles
  (transfer mode, verify, replace, carry subs, save artwork, write NFO,
  download subtitles + langs). Stored in video.db; matches the music File
  Organization section's look.
- Sidecar writer: movie.nfo / tvshow.nfo + full artwork set (poster, fanart,
  clearlogo, season posters) from on-demand TMDB detail, and external .srt from
  OpenSubtitles. Owned re-grabs resolve their library tmdb_id; tmdb_full_detail
  bypasses the owned->library redirect so they enrich too.
- Import page: surfaces import_failed downloads, resolve by hand (library-first
  -> TMDB picker -> force-place) or dismiss; fires a library refresh on place.
- "Grab whole season": episode-level batch (reuses searchInto + _autoPick).
- Brutalist redesign of the download modal sources + result cards.

All new logic has seam-level tests (pure parsers/planners + injected I/O);
sidecars/subtitles are best-effort and never break an import.
2026-06-21 01:44:35 -07:00

229 lines
8.3 KiB
Python

"""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 = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\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</%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 += ' <uniqueid type="tmdb"%s>%s</uniqueid>\n' % (
' default="true"' if tmdb_default else "", _xesc(str(tmdb)))
if meta.get("tvdb_id"):
out += ' <uniqueid type="tvdb"%s>%s</uniqueid>\n' % (
' default="true"' if not tmdb_default else "", _xesc(str(meta["tvdb_id"])))
if meta.get("imdb_id"):
out += ' <uniqueid type="imdb">%s</uniqueid>\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 += " <actor>\n <name>%s</name>\n" % _xesc(str(c["name"]))
if c.get("character"):
out += " <role>%s</role>\n" % _xesc(str(c["character"]))
out += " <order>%d</order>\n </actor>\n" % i
return out
def _artwork_tags(meta: dict) -> str:
out = ""
if meta.get("poster_url"):
out += ' <thumb aspect="poster">%s</thumb>\n' % _xesc(str(meta["poster_url"]))
if meta.get("logo"):
out += ' <thumb aspect="clearlogo">%s</thumb>\n' % _xesc(str(meta["logo"]))
if meta.get("backdrop_url"):
out += " <fanart>\n <thumb>%s</thumb>\n </fanart>\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 + "<movie>\n" + body + "</movie>\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 + "<tvshow>\n" + body + "</tvshow>\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"]