Restefeger: Auth komplett raus, Serien-Flow + Episoden-Matching, Jellyfin-Refresh, Duplikat-Warnung, echtes Nur-Hauptfilm
Ampel / ampel (push) Successful in 55s
Ampel / ampel (push) Successful in 55s
AUTH ENTFERNT (Commander-Entscheid 24.07., KONZEPT §10): /token- und /api-keys-Endpoints, auth.py, test_auth.py, passlib/bcrypt/PyJWT/ python-multipart, JWT_SECRET_KEY-Pflicht. Heimnetz-only, das UI hatte nie einen Login — die Auth-Oberflaeche war Placebo und die passlib/bcrypt- Falle brach die Ampel. Rate-Limit pro IP bleibt. Schnellstart laeuft jetzt ganz ohne .env-Pflichtwerte. Serien-Flow (Etappe-12-Kern, ARM-Wunde #395): - Rip-Dialog: Serienname + Staffel -> Ablage <Serie>/Season NN (jellyfin.org/docs Naming-Schema); tvshow.nfo + poster.jpg im Serien-Ordner, bei Staffel 2 nicht ueberschrieben. - Episoden-Matching per Laufzeitabgleich: HandBrakeCLI --scan ('+ duration:', handbrake.fr/docs) je MKV gegen TMDB-Staffel-Laufzeiten (GET /metadata/tv/{id}/season/{n}; tv-season-details-API). Ordnungserhaltend; komplette Staffel auf einer Disc klappt auch bei uniformen Anime-Laufzeiten (Sequenz-Stufe). Umbenannt wird NUR bei eindeutiger Zuordnung — sonst ehrliches Log. Mit Tests. Weitere Punkte: - Jellyfin/Emby-Bibliotheks-Refresh nach jedem fertigen Rip (POST /Library/Refresh, X-Emby-Token lt. jellyfin.org/docs) — URL/Key + Test-Knopf in Einstellungen -> Ripping. - Duplikat-Warnung: Disc-Fingerabdruck (jetzt Teil des Prescan-Ergebnisses + der Job-Metadaten) gegen die Historie; Karte zeigt 'bereits gerippt', Vollautomatik ueberspringt Duplikate. - 'Nur Hauptfilm' ECHT: makemkvcon info -> TINFO-Attr-9-Laufzeiten (usage.txt) -> laengster Titel -> mkv dev:X <nr>. Vorher wirkungsloses Setting; pro Rip im Dialog uebersteuerbar. Mit Tests. - OMDb-Treffer eingedeutscht via TMDB /find (external_source=imdb_id, de-DE; find-by-id-API). - Dashboard: Speicherplatz-Anzeige (amber < 60 GB) + CSV-Export (GET /jobs/export, Semikolon+BOM fuer deutsches Excel). - Metadaten-Seite entfernt (Abnahme durch Commander-Auftrag) inkl. Placebo-Endpoints /metadata/lookup (scannte Dummy-Device) und /metadata/confirm (schrieb nie gelesenen Cache-Key). - Doppel-Jahr-Fix: 'X (2009) (2009)' in Log und Ordnernamen. - Remote-Worker-Blocker: redis (6379) + postgres (5432) waren NIE veroeffentlicht — kein Remote-Worker konnte sich je verbinden. Ports jetzt offen (Heimnetz-Kompromiss, kommentiert) + API_URL fuer Worker. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+139
-9
@@ -28,7 +28,9 @@ def sicherer_name(titel: str, jahr=None) -> str:
|
||||
name = re.sub(r"\s+", " ", name)[:150].strip()
|
||||
if not name:
|
||||
return ""
|
||||
if jahr:
|
||||
# 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
|
||||
|
||||
@@ -48,6 +50,116 @@ def zielordner(basis: str, titel: str, jahr, job_id: str) -> str:
|
||||
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).
|
||||
|
||||
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")
|
||||
@@ -71,9 +183,15 @@ def baue_nfo(meta: dict, titel: str, jahr=None) -> str:
|
||||
return "\n".join(zeilen) + "\n"
|
||||
|
||||
|
||||
def aufbereiten(ordner: str, media_server: str, titel: str, jahr, meta: dict) -> list:
|
||||
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.
|
||||
"""
|
||||
@@ -82,23 +200,35 @@ def aufbereiten(ordner: str, media_server: str, titel: str, jahr, meta: dict) ->
|
||||
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:
|
||||
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")
|
||||
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 ""
|
||||
if poster:
|
||||
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(os.path.join(ordner, "poster.jpg"), "wb") as f:
|
||||
with open(poster_pfad, "wb") as f:
|
||||
f.write(antwort.content)
|
||||
meldungen.append("poster.jpg gespeichert")
|
||||
except requests.RequestException as e:
|
||||
|
||||
Reference in New Issue
Block a user