"""Media-Server-Aufbereitung: sprechende Ordnernamen + NFO + Poster. Commander-Wunsch 24.07.: Rippy soll sich auf das Zielsystem (Jellyfin, Plex, Emby, Kodi) vorbereiten. Das Setting `mediaServer` steuert: - ALLE Systeme: Zielordner heißt „ (Jahr)" statt Job-UUID — daran erkennen Jellyfin & Co. den Film (Namensschema laut jellyfin.org/docs/ general/server/media/movies bzw. support.plex.tv „Naming and Organizing"). - jellyfin / emby / kodi: zusätzlich movie.nfo bzw. tvshow.nfo (Kodi-Schema, kodi.wiki/view/NFO_files) + poster.jpg — diese Server lesen NFO nativ. - plex: NUR Benennung (Plex liest NFO ohne Zusatz-Agent nicht). - none: Verhalten wie bisher (UUID-Ordner, keine Extras). """ import os import re from xml.sax.saxutils import escape import requests NFO_SERVER = ("jellyfin", "emby", "kodi") # Zeichen, die in Ordnernamen auf SMB/NTFS/ext4 Ärger machen _VERBOTEN = re.compile(r'[<>:"/\\|?*\x00-\x1f]') def sicherer_name(titel: str, jahr=None) -> str: """Pure Funktion (testbar): Titel → Dateisystem-tauglicher Ordnername.""" name = _VERBOTEN.sub("", titel or "").strip().rstrip(".") name = re.sub(r"\s+", " ", name)[:150].strip() if not name: return "" if jahr: name = f"{name} ({jahr})" return name def zielordner(basis: str, titel: str, jahr, job_id: str) -> str: """Zielordner unter `basis`: sprechender Name, UUID nur als Fallback. Existiert der Ordner schon (Disc doppelt gerippt), wird die Job-ID angehängt statt fremde Dateien zu überschreiben. """ name = sicherer_name(titel, jahr) if not name: return os.path.join(basis, job_id) pfad = os.path.join(basis, name) if os.path.exists(pfad): pfad = os.path.join(basis, f"{name} [{job_id[:8]}]") return pfad def baue_nfo(meta: dict, titel: str, jahr=None) -> str: """Pure Funktion (testbar): minimales movie.nfo/tvshow.nfo im Kodi-Schema.""" ist_serie = (meta.get("type") == "tv") wurzel = "tvshow" if ist_serie else "movie" zeilen = [ '', f"<{wurzel}>", f" {escape(titel or '')}", ] if jahr: zeilen.append(f" {escape(str(jahr))}") if meta.get("overview"): zeilen.append(f" {escape(meta['overview'])}") for genre in meta.get("genres") or []: zeilen.append(f" {escape(str(genre))}") if meta.get("runtime"): zeilen.append(f" {escape(str(meta['runtime']))}") quelle = meta.get("source") or "Rippy" zeilen.append(f"
Source: {escape(quelle)}
") zeilen.append(f"") return "\n".join(zeilen) + "\n" def aufbereiten(ordner: str, media_server: str, titel: str, jahr, meta: dict) -> list: """Schreibt NFO + Poster in den fertigen Ordner (best effort). Rückgabe: Liste von Meldungen fürs Log. Wirft NIE — ein fehlendes Poster darf einen gelungenen Rip nicht auf 'failed' drehen. """ meldungen = [] if media_server not in NFO_SERVER: return meldungen meta = meta or {} try: ist_serie = (meta.get("type") == "tv") nfo_name = "tvshow.nfo" if ist_serie else "movie.nfo" with open(os.path.join(ordner, nfo_name), "w", encoding="utf-8") as f: f.write(baue_nfo(meta, titel, jahr)) meldungen.append(f"{nfo_name} geschrieben") except OSError as e: meldungen.append(f"NFO fehlgeschlagen: {e}") poster = meta.get("poster_path") or "" if poster: if not poster.startswith("http"): poster = f"https://image.tmdb.org/t/p/w500{poster}" try: antwort = requests.get(poster, timeout=15) if antwort.status_code == 200 and antwort.content: with open(os.path.join(ordner, "poster.jpg"), "wb") as f: f.write(antwort.content) meldungen.append("poster.jpg gespeichert") except requests.RequestException as e: meldungen.append(f"Poster fehlgeschlagen: {e}") return meldungen