"""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 "" # Kein doppeltes Jahr: OMDb-Titel tragen es teils schon im Namen — # "X (2009) (2009)" sah man sonst im Log UND im Ordnernamen (Fund 24.07.). if jahr and not name.endswith(f"({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 serien_ordner(basis: str, serie: str, staffel: int) -> str: """Jellyfin-Konvention: //Season NN (pure Funktion, testbar). Namensschema laut jellyfin.org/docs/general/server/media/shows — Emby, Kodi und Plex verstehen dieselbe Struktur. """ name = sicherer_name(serie) or "Unbekannte Serie" return os.path.join(basis, name, f"Season {int(staffel):02d}") def matche_episoden(datei_sekunden: list, episoden: list, toleranz_prozent: float = 8.0, toleranz_min_s: int = 180): """Ordnet Dateien Episoden zu — über Laufzeiten, ordnungserhaltend. datei_sekunden: Laufzeiten der MKVs in Disc-Reihenfolge (Sekunden). episoden: [(episoden_nr, laufzeit_sekunden), …] in Serienreihenfolge. Rückgabe: Liste der Episoden-Nummern je Datei — oder None, wenn die Zuordnung nicht EINDEUTIG gelingt (dann lieber gar nicht umbenennen als falsch; ARMs offene Wunde #395 war genau das Ratespiel). Zwei Stufen: 1. Komplette Staffel auf der Disc (Dateizahl == Episodenzahl, Anime-Box): Sequenz-Zuordnung 1:1 in Reihenfolge — auch bei uniformen Laufzeiten eindeutig, weil nichts übrig bleibt. 2. Sonst Einzel-Zuordnung über DISTINKTE Laufzeiten; passt eine Datei auf mehrere Episoden ähnlich gut → None. Teil-Discs mit lauter gleich langen Episoden sind über Laufzeit prinzipiell nicht zuordenbar — das gibt ehrlich None statt einer Ratenummer. """ if not datei_sekunden or not episoden: return None def toleranz(dauer): return max(toleranz_min_s, dauer * toleranz_prozent / 100) # Stufe 1: komplette Staffel in Disc-Reihenfolge if len(datei_sekunden) == len(episoden): if all( ep_dauer and abs(dauer - ep_dauer) <= toleranz(dauer) for dauer, (_, ep_dauer) in zip(datei_sekunden, episoden) ): return [nr for nr, _ in episoden] # Stufe 2: eindeutige Einzel-Zuordnung, ordnungserhaltend zuordnung = [] start = 0 for dauer in datei_sekunden: kandidaten = [ (nr, ep_dauer) for nr, ep_dauer in episoden[start:] if ep_dauer and abs(dauer - ep_dauer) <= toleranz(dauer) ] if len(kandidaten) == 0: return None if len(kandidaten) > 1: # Nur eindeutig, wenn der beste Kandidat KLAR besser passt als # der zweitbeste (doppelt so nah) — sonst wäre es geraten. kandidaten.sort(key=lambda k: abs(dauer - k[1])) if not (abs(dauer - kandidaten[0][1]) * 2 < abs(dauer - kandidaten[1][1])): return None nr = kandidaten[0][0] zuordnung.append(nr) start = next(i for i, (e_nr, _) in enumerate(episoden) if e_nr == nr) + 1 return zuordnung def episoden_umbenennen(ordner: str, serie: str, staffel: int, zuordnung: list) -> list: """Benennt die MKVs im Ordner nach Jellyfin-Schema um: 'Serie S01E02.mkv'. Reihenfolge = sortierte Dateinamen (MakeMKV nummeriert title_t00…tNN in Disc-Reihenfolge — dieselbe Reihenfolge, in der gematcht wurde). """ meldungen = [] dateien = sorted(f for f in os.listdir(ordner) if f.endswith(".mkv")) if len(dateien) != len(zuordnung): return [f"Umbenennen übersprungen: {len(dateien)} Dateien, {len(zuordnung)} Zuordnungen"] serien_name = sicherer_name(serie) or "Serie" for alt, ep_nr in zip(dateien, zuordnung): neu = f"{serien_name} S{int(staffel):02d}E{int(ep_nr):02d}.mkv" if alt == neu: continue ziel = os.path.join(ordner, neu) if os.path.exists(ziel): meldungen.append(f"Umbenennen übersprungen ({neu} existiert schon)") continue os.rename(os.path.join(ordner, alt), ziel) meldungen.append(f"{alt} → {neu}") return meldungen def bibliothek_refresh(media_server: str, url: str, api_key: str) -> str: """Stößt den Bibliotheks-Scan des Media-Servers an (Jellyfin/Emby). API laut jellyfin.org/docs (Emby-kompatibel): POST /Library/Refresh, Auth über den Header X-Emby-Token. Rückgabe: Klartext fürs Log. Wirft nie — der Refresh ist Komfort, nicht Teil des Rips. """ if media_server not in ("jellyfin", "emby") or not url or not api_key: return "" try: antwort = requests.post( url.rstrip("/") + "/Library/Refresh", headers={"X-Emby-Token": api_key}, timeout=15, ) if antwort.status_code < 300: return f"{media_server}-Bibliothek aktualisiert ({url})" return f"Bibliotheks-Refresh: HTTP {antwort.status_code} von {url}" except requests.RequestException as e: return f"Bibliotheks-Refresh fehlgeschlagen: {e}" 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, serien_root: str = None) -> list: """Schreibt NFO + Poster in den fertigen Ordner (best effort). serien_root: bei Staffel-Rips der Serien-Ordner (Parent von "Season NN") — tvshow.nfo + poster.jpg gehören laut Kodi-Schema dorthin, nicht in die Staffel. Bestehende Dateien werden dort NIE überschrieben (Staffel 2 einer schon aufbereiteten Serie). 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 {} if serien_root: ziel_ordner = serien_root nfo_name = "tvshow.nfo" nfo_meta = {**meta, "type": "tv"} ueberschreiben = False else: ziel_ordner = ordner nfo_name = "tvshow.nfo" if meta.get("type") == "tv" else "movie.nfo" nfo_meta = meta ueberschreiben = True nfo_pfad = os.path.join(ziel_ordner, nfo_name) try: if ueberschreiben or not os.path.exists(nfo_pfad): with open(nfo_pfad, "w", encoding="utf-8") as f: f.write(baue_nfo(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 "" poster_pfad = os.path.join(ziel_ordner, "poster.jpg") if poster and (ueberschreiben or not os.path.exists(poster_pfad)): 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(poster_pfad, "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