"""Kodi-style NFO sidecars. Video descriptions are hostile input — they contain ampersands, angle brackets, emoji, ASCII art and control characters — so these are always built with ElementTree's serialiser and never by string formatting. """ from __future__ import annotations import re import xml.etree.ElementTree as ET from pathlib import Path # XML 1.0 forbids most control characters outright; ElementTree will happily # serialise them and produce a document no parser will read back. Written as a # raw string so `re` interprets the escapes, not Python. _ILLEGAL_XML = re.compile( r"[^\x09\x0a\x0d\x20-퟿-�\U00010000-\U0010ffff]" ) def clean_text(value: str | None) -> str: return _ILLEGAL_XML.sub("", value or "") def _child(parent: ET.Element, tag: str, text: str | None) -> ET.Element: element = ET.SubElement(parent, tag) element.text = clean_text(text) return element def _serialise(root: ET.Element) -> bytes: ET.indent(root, space=" ") return ET.tostring(root, encoding="utf-8", xml_declaration=True) def tvshow_nfo(title: str, plot: str | None, channel_id: str) -> bytes: root = ET.Element("tvshow") _child(root, "title", title) _child(root, "plot", plot) _child(root, "studio", "YouTube") unique = _child(root, "uniqueid", channel_id) unique.set("type", "youtube") unique.set("default", "true") return _serialise(root) def episode_nfo( *, title: str, show_title: str, season: int, episode: int, plot: str | None, aired: str, duration_seconds: int | None, video_id: str, ) -> bytes: root = ET.Element("episodedetails") _child(root, "title", title) _child(root, "showtitle", show_title) _child(root, "season", str(season)) _child(root, "episode", str(episode)) _child(root, "plot", plot) _child(root, "aired", aired) if duration_seconds: # Kodi/Jellyfin expect in whole minutes. _child(root, "runtime", str(max(1, round(duration_seconds / 60)))) _child(root, "studio", "YouTube") unique = _child(root, "uniqueid", video_id) unique.set("type", "youtube") unique.set("default", "true") return _serialise(root) def write(path: Path, payload: bytes) -> None: """Write atomically so a crash never leaves Jellyfin a half-written NFO.""" path.parent.mkdir(parents=True, exist_ok=True) temporary = path.with_name(path.name + ".tmp") temporary.write_bytes(payload) temporary.replace(path)