e165e2a0d3
Ampel / ampel (push) Failing after 29s
- Versionsanzeige für den Windows-Worker im UI inkl. Prüfung - Absicherung der install.sh gegen fehlendes systemd - Serien-Episoden-Erkennung und Laufzeitabgleich anhand TMDB-Daten (Heuristik) - Umstellung der Windows-Worker-Installation auf SchTasks (Dienst-Ersatz) - Kodi-Bibliotheks-Refresh über JSON-RPC integriert
257 lines
10 KiB
Python
257 lines
10 KiB
Python
"""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 „<Titel> (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: <basis>/<Serie>/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/Kodi).
|
|
|
|
Jellyfin/Emby: POST /Library/Refresh, Auth über X-Emby-Token.
|
|
Kodi: POST /jsonrpc, VideoLibrary.Scan, Auth über Basic Auth (api_key = user:pass).
|
|
Wirft nie — der Refresh ist Komfort, nicht Teil des Rips.
|
|
"""
|
|
if not url or media_server not in ("jellyfin", "emby", "kodi"):
|
|
return ""
|
|
try:
|
|
if media_server in ("jellyfin", "emby") and api_key:
|
|
antwort = requests.post(
|
|
url.rstrip("/") + "/Library/Refresh",
|
|
headers={"X-Emby-Token": api_key}, timeout=15,
|
|
)
|
|
if antwort.status_code < 300:
|
|
return f"{media_server.title()}-Bibliothek aktualisiert ({url})"
|
|
return f"Bibliotheks-Refresh: HTTP {antwort.status_code} von {url}"
|
|
|
|
elif media_server == "kodi":
|
|
auth = None
|
|
if api_key and ":" in api_key:
|
|
user, pw = api_key.split(":", 1)
|
|
auth = (user, pw)
|
|
|
|
payload = {"jsonrpc": "2.0", "method": "VideoLibrary.Scan", "id": 1}
|
|
antwort = requests.post(
|
|
url.rstrip("/") + "/jsonrpc",
|
|
json=payload, auth=auth, timeout=15,
|
|
)
|
|
if antwort.status_code < 300:
|
|
return f"Kodi-Bibliothek aktualisiert ({url})"
|
|
return f"Kodi-Refresh: HTTP {antwort.status_code} von {url}"
|
|
|
|
except requests.RequestException as e:
|
|
return f"Bibliotheks-Refresh fehlgeschlagen: {e}"
|
|
|
|
return ""
|
|
|
|
|
|
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 = [
|
|
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>',
|
|
f"<{wurzel}>",
|
|
f" <title>{escape(titel or '')}</title>",
|
|
]
|
|
if jahr:
|
|
zeilen.append(f" <year>{escape(str(jahr))}</year>")
|
|
if meta.get("overview"):
|
|
zeilen.append(f" <plot>{escape(meta['overview'])}</plot>")
|
|
for genre in meta.get("genres") or []:
|
|
zeilen.append(f" <genre>{escape(str(genre))}</genre>")
|
|
if meta.get("runtime"):
|
|
zeilen.append(f" <runtime>{escape(str(meta['runtime']))}</runtime>")
|
|
quelle = meta.get("source") or "Rippy"
|
|
zeilen.append(f" <details>Source: {escape(quelle)}</details>")
|
|
zeilen.append(f"</{wurzel}>")
|
|
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
|