Restefeger: Auth komplett raus, Serien-Flow + Episoden-Matching, Jellyfin-Refresh, Duplikat-Warnung, echtes Nur-Hauptfilm
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:
Hitonabi
2026-07-24 14:22:19 +02:00
parent e7a7d5fdb4
commit 8eb5653848
28 changed files with 934 additions and 892 deletions
+139 -9
View File
@@ -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:
+81 -7
View File
@@ -40,7 +40,7 @@ def check_cdparanoia_installed() -> bool:
return shutil.which("cdparanoia") is not None
def build_makemkv_cmd(device_path: str, output_dir: str) -> list:
def build_makemkv_cmd(device_path: str, output_dir: str, titel: str = "all") -> list:
"""Baut das MakeMKV-Kommando (pure Funktion, testbar).
-r Robot-Mode: maschinenlesbare Ausgabe (PRGV/MSG-Zeilen)
@@ -48,7 +48,7 @@ def build_makemkv_cmd(device_path: str, output_dir: str) -> list:
Container zum Hängen (1.18.4) bzw. Segfault (1.17.7):
/sys zeigt dort auch Geräte ohne /dev-Knoten (Befund 23.07.)
--progress=-same Fortschritt in denselben Stream wie die Meldungen
mkv dev:<pfad> all <ziel> alle Titel der Disc verlustfrei als MKV
mkv dev:<pfad> <titel> <ziel> 'all' oder eine Titel-Nummer (Hauptfilm)
"""
return [
"makemkvcon",
@@ -57,11 +57,74 @@ def build_makemkv_cmd(device_path: str, output_dir: str) -> list:
"--progress=-same",
"mkv",
f"dev:{device_path}",
"all",
str(titel),
output_dir,
]
def parse_tinfo_dauern(ausgabe: str) -> dict:
"""TINFO-Zeilen → {titel_nr: dauer_sekunden} (pure Funktion, testbar).
makemkvcon-Robot-Format laut makemkv.com/developers/usage.txt:
TINFO:<titel>,<attr_id>,<code>,"<wert>" — Attribut 9 ist die Laufzeit
als "H:MM:SS" (ap_iaDuration).
"""
dauern = {}
for zeile in ausgabe.splitlines():
treffer = re.match(r'TINFO:(\d+),9,\d+,"(\d+):(\d{2}):(\d{2})"', zeile.strip())
if treffer:
nr, h, m, s = (int(g) for g in treffer.groups())
dauern[nr] = h * 3600 + m * 60 + s
return dauern
def laengster_titel(dauern: dict):
"""Titel-Nummer mit der längsten Laufzeit — der Hauptfilm (pure Funktion)."""
if not dauern:
return None
return max(dauern, key=dauern.get)
def lies_titel_dauern(device_path: str, timeout: int = 300) -> dict:
"""Fragt die Titel-Laufzeiten der Disc ab (makemkvcon info, Robot-Mode)."""
try:
ergebnis = subprocess.run(
["makemkvcon", "-r", "--noscan", "info", f"dev:{device_path}"],
capture_output=True, text=True, timeout=timeout,
)
except (OSError, subprocess.TimeoutExpired):
return {}
return parse_tinfo_dauern(ergebnis.stdout or "")
def parse_scan_dauer(ausgabe: str) -> int:
"""HandBrake-Scan-Ausgabe → Dauer in Sekunden (pure Funktion, testbar).
HandBrakeCLI --scan schreibt je Titel "+ duration: HH:MM:SS"
(handbrake.fr/docs, Scan-Ausgabe); bei einer MKV-Datei gibt es genau
einen Titel.
"""
treffer = re.search(r"\+ duration: (\d+):(\d{2}):(\d{2})", ausgabe)
if not treffer:
return 0
h, m, s = (int(g) for g in treffer.groups())
return h * 3600 + m * 60 + s
def lies_datei_dauer(pfad: str, timeout: int = 120) -> int:
"""Laufzeit einer MKV-Datei in Sekunden (HandBrakeCLI --scan)."""
if not check_handbrake_installed():
return 0
try:
ergebnis = subprocess.run(
["HandBrakeCLI", "--scan", "-i", pfad],
capture_output=True, text=True, timeout=timeout,
)
except (OSError, subprocess.TimeoutExpired):
return 0
return parse_scan_dauer((ergebnis.stdout or "") + (ergebnis.stderr or ""))
def get_progress_from_prgv(line: str) -> int:
"""Extrahiert Gesamt-Fortschritt (0-100) aus einer PRGV-Zeile.
@@ -178,7 +241,7 @@ def write_abcde_config(output_dir: str) -> str:
return tmp.name
def run_makemkv(device_path: str, output_dir: str, progress_cb=None) -> dict:
def run_makemkv(device_path: str, output_dir: str, progress_cb=None, titel: str = "all") -> dict:
"""Rippt eine DVD/Blu-ray verlustfrei mit makemkvcon; meldet Fortschritt."""
if not check_makemkv_installed():
return {"status": "error", "error": "makemkvcon ist nicht installiert"}
@@ -187,7 +250,7 @@ def run_makemkv(device_path: str, output_dir: str, progress_cb=None) -> dict:
try:
process = subprocess.Popen(
build_makemkv_cmd(device_path, output_dir),
build_makemkv_cmd(device_path, output_dir, titel),
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
@@ -252,16 +315,27 @@ def run_makemkv(device_path: str, output_dir: str, progress_cb=None) -> dict:
return {"status": "error", "error": str(e)}
def rip_video(device_path: str, disc_id: str, disc_type: str = "dvd", progress_cb=None, output_dir: str = None) -> dict:
def rip_video(device_path: str, disc_id: str, disc_type: str = "dvd", progress_cb=None,
output_dir: str = None, nur_hauptfilm: bool = False) -> dict:
"""Rippt eine DVD oder Blu-ray verlustfrei mit MakeMKV.
Bewusst KEIN eigener Celery-Task: der einzige Task ist worker.tasks.rip_disc,
der hier mit seinem eigenen Fortschritts-Callback durchgreift. output_dir
überschreibt das Ziel (Transcode-Fall: Roh-Rip nach /app/temp).
nur_hauptfilm: Vorab-Info-Lauf, dann nur den LÄNGSTEN Titel rippen —
das löst das eingestellte (vorher wirkungslose) mainFeatureOnly ein.
"""
if output_dir is None:
output_dir = os.path.join(RIP_OUTPUT_DIR, disc_type, disc_id)
return run_makemkv(device_path, output_dir, progress_cb=progress_cb)
titel = "all"
if nur_hauptfilm:
dauern = lies_titel_dauern(device_path)
haupt = laengster_titel(dauern)
if haupt is not None:
titel = str(haupt)
# Kein Titel ermittelbar → ehrlich auf 'all' zurückfallen statt raten
return run_makemkv(device_path, output_dir, progress_cb=progress_cb, titel=titel)
def rip_cd(device_path: str, disc_id: str, progress_cb=None, output_dir: str = None) -> dict:
+89 -6
View File
@@ -17,6 +17,8 @@ import json
import os
import shutil
import requests
import db
import medien
import notify
@@ -26,11 +28,14 @@ from ripping import (
DEFAULT_HB_PRESET,
RIP_OUTPUT_DIR,
RipAbbruch,
lies_datei_dauer,
rip_cd,
rip_video,
run_handbrake,
)
API_URL = os.getenv("API_URL", "http://api:8000")
RAW_DIR = os.getenv("RAW_DIR", "/app/temp/raw")
MEDIA_ROOT = "/app/media"
@@ -111,6 +116,47 @@ def _abbruch_angefordert(job_id: str) -> bool:
return db.get_job_status(job_id) == "canceling"
def _serien_episoden_zuordnen(ausgabe: str, serie: str, staffel, meta: dict) -> list:
"""Episoden-Zuordnung per Laufzeitabgleich (ARM-Wunde #395, besser gelöst).
Laufzeiten der Staffel kommen von der API (TMDB); die der Dateien vom
HandBrake-Scan. Nur bei EINDEUTIGEM Treffer wird umbenannt. Wirft nie.
"""
if meta.get("source") != "tmdb" or meta.get("type") != "tv" or not meta.get("id"):
return ["Episoden-Zuordnung übersprungen (nur mit TMDB-Serien-Treffer möglich)"]
try:
antwort = requests.get(
f"{API_URL}/metadata/tv/{meta['id']}/season/{int(staffel)}", timeout=30
)
if antwort.status_code != 200:
return [f"Episoden-Laufzeiten nicht abrufbar (HTTP {antwort.status_code})"]
episoden = [
(e["episode"], (e.get("runtime") or 0) * 60)
for e in antwort.json().get("episodes", [])
]
except (requests.RequestException, ValueError) as e:
return [f"Episoden-Laufzeiten nicht abrufbar: {e}"]
if not episoden:
return ["Keine Episoden-Laufzeiten bei TMDB hinterlegt — Dateinamen bleiben"]
dateien = sorted(
os.path.join(ausgabe, f) for f in os.listdir(ausgabe) if f.endswith(".mkv")
)
if not dateien:
return []
dauern = [lies_datei_dauer(p) for p in dateien]
if 0 in dauern:
return ["Datei-Laufzeiten nicht lesbar — Dateinamen bleiben"]
zuordnung = medien.matche_episoden(dauern, episoden)
if not zuordnung:
return [
"Keine EINDEUTIGE Episoden-Zuordnung über die Laufzeiten — "
"Dateinamen bleiben (lieber ehrlich als falsch benannt)"
]
return medien.episoden_umbenennen(ausgabe, serie, staffel, zuordnung)
def _benachrichtigen(job_id: str, betreff: str, text: str, level: str) -> None:
"""Webhook-Meldung bei Job-Ende — best effort, nie job-entscheidend."""
einstellungen = db.get_settings()
@@ -143,17 +189,38 @@ def _job_abschliessen(job_id: str, ergebnis: dict) -> None:
if ergebnis.get("status") == "success":
ausgabe = ergebnis.get("output_dir")
if ausgabe and (job.get("disc_type") in ("dvd", "bluray", "uhd")):
# Media-Server-Aufbereitung (Jellyfin/Emby/Kodi: NFO + Poster)
einstellungen = db.get_settings()
try:
meta = json.loads(job.get("meta") or "{}")
except ValueError:
meta = {}
serie = meta.get("series")
staffel = meta.get("season")
# Serien-Rips: Episoden per Laufzeit zuordnen und umbenennen
# (Show S01E02.mkv) — nur bei EINDEUTIGER Zuordnung, sonst
# bleiben die MakeMKV-Namen (ehrlich geloggt).
if serie and staffel:
for meldung in _serien_episoden_zuordnen(ausgabe, serie, staffel, meta):
db.add_log("info", "worker", f"Job {job_id}: {meldung}")
# Media-Server-Aufbereitung (Jellyfin/Emby/Kodi: NFO + Poster);
# bei Serien wandern tvshow.nfo/poster in den Serien-Ordner.
for meldung in medien.aufbereiten(
ausgabe, einstellungen.get("mediaServer") or "none",
job.get("title") or "", meta.get("year"), meta,
serie or job.get("title") or "", meta.get("year"), meta,
serien_root=os.path.dirname(ausgabe) if serie and staffel else None,
):
db.add_log("info", "worker", f"Job {job_id}: {meldung}")
# Bibliotheks-Refresh (Jellyfin/Emby): der Server scannt sofort
meldung = medien.bibliothek_refresh(
einstellungen.get("mediaServer") or "none",
(einstellungen.get("jellyfinUrl") or "").strip(),
(einstellungen.get("jellyfinApiKey") or "").strip(),
)
if meldung:
db.add_log("info", "worker", f"Job {job_id}: {meldung}")
db.update_job(
job_id,
status="completed",
@@ -228,15 +295,29 @@ def rip_disc(self, device_path: str, job_id: str, target_dir: str = None):
# Sprechender Zielordner „<Titel> (Jahr)" statt Job-UUID — Jellyfin & Co.
# erkennen den Film am Ordnernamen. UUID bleibt Fallback ohne Titel.
# Serien-Rips landen stattdessen in <Serie>/Season NN (Staffel-Flow).
job = db.get_job(job_id) or {}
try:
meta = json.loads(job.get("meta") or "{}")
except ValueError:
meta = {}
final_dir = medien.zielordner(
_zielbasis(target_dir, disc_type),
job.get("title") or "", meta.get("year"), job_id,
)
if meta.get("series") and meta.get("season"):
final_dir = medien.serien_ordner(
_zielbasis(target_dir, disc_type), meta["series"], meta["season"]
)
else:
final_dir = medien.zielordner(
_zielbasis(target_dir, disc_type),
job.get("title") or "", meta.get("year"), job_id,
)
# Nur Hauptfilm (längster Titel): pro Rip wählbar, sonst Setting;
# bei Serien-Discs sinnlos (dort zählen ALLE Episoden-Titel).
nur_hauptfilm = meta.get("main_feature_only")
if nur_hauptfilm is None:
nur_hauptfilm = bool(einstellungen.get("mainFeatureOnly", False))
if meta.get("series"):
nur_hauptfilm = False
# Geplantes Ziel sofort sichtbar machen (UI-Detail + retry-transcode)
db.update_job(job_id, output_path=final_dir)
@@ -267,11 +348,13 @@ def rip_disc(self, device_path: str, job_id: str, target_dir: str = None):
device_path, job_id, disc_type,
progress_cb=fortschritt,
output_dir=raw_dir,
nur_hauptfilm=nur_hauptfilm,
)
else:
ergebnis = rip_video(
device_path, job_id, disc_type,
progress_cb=fortschritt, output_dir=final_dir,
nur_hauptfilm=nur_hauptfilm,
)
# UHD-Fehler in Klartext übersetzen — "Failed to open disc" allein hilft
+49 -2
View File
@@ -1,8 +1,8 @@
"""Tests für die Media-Server-Aufbereitung (Benennung + NFO)."""
"""Tests für die Media-Server-Aufbereitung (Benennung, NFO, Serien-Matching)."""
import os
from medien import baue_nfo, sicherer_name, zielordner
from medien import baue_nfo, matche_episoden, serien_ordner, sicherer_name, zielordner
def test_sicherer_name_entfernt_verbotene_zeichen():
@@ -55,3 +55,50 @@ def test_baue_nfo_film_mit_plot_und_genres():
def test_baue_nfo_serie_bekommt_tvshow_wurzel():
nfo = baue_nfo({"type": "tv"}, "Neon Genesis Evangelion", 1995)
assert "<tvshow>" in nfo and "</tvshow>" in nfo
def test_sicherer_name_haengt_jahr_nicht_doppelt_an():
"""OMDb-Titel tragen das Jahr teils schon — kein 'X (2009) (2009)' mehr."""
assert sicherer_name("Summer Wars (2009)", 2009) == "Summer Wars (2009)"
assert sicherer_name("Summer Wars", 2009) == "Summer Wars (2009)"
def test_serien_ordner_jellyfin_schema():
assert serien_ordner("/app/media/series", "Neon Genesis: Evangelion", 2) == os.path.join(
"/app/media/series", "Neon Genesis Evangelion", "Season 02"
)
def test_matche_episoden_komplette_staffel_auch_bei_uniformen_laufzeiten():
# Anime-Standardfall: ganze Staffel auf der Disc, alle Episoden ~24 min
dateien = [1440, 1452, 1431]
episoden = [(1, 1440), (2, 1440), (3, 1440)]
assert matche_episoden(dateien, episoden) == [1, 2, 3]
def test_matche_episoden_teil_disc_mit_distinkten_laufzeiten():
# Disc 2 einer Staffel: Laufzeiten unterscheiden sich klar → zuordenbar
dateien = [1440, 2650]
episoden = [(1, 700), (2, 1440), (3, 2650)]
assert matche_episoden(dateien, episoden) == [2, 3]
def test_matche_episoden_haelt_reihenfolge():
# Datei 2 ist ein Doppellänger → muss auf die SPÄTERE lange Episode fallen
dateien = [1440, 2880]
episoden = [(1, 1440), (2, 2880)]
assert matche_episoden(dateien, episoden) == [1, 2]
def test_matche_episoden_mehrdeutig_gibt_none():
# Alle Episoden gleich lang und mehr Kandidaten als Dateien in Toleranz →
# Datei 1 passt auf Ep 1 UND Ep 2 gleich gut → keine eindeutige Wahl
dateien = [1440]
episoden = [(1, 1440), (2, 1440)]
assert matche_episoden(dateien, episoden) is None
def test_matche_episoden_ohne_treffer_gibt_none():
dateien = [600] # 10 min passt auf keine 24-min-Episode
episoden = [(1, 1440), (2, 1440)]
assert matche_episoden(dateien, episoden) is None
+45
View File
@@ -0,0 +1,45 @@
"""Tests für die Hauptfilm-Auswahl (TINFO-Parsing) und Datei-Laufzeiten."""
from ripping import (
build_makemkv_cmd,
laengster_titel,
parse_scan_dauer,
parse_tinfo_dauern,
)
# Echte Robot-Mode-Zeilenform laut makemkv.com/developers/usage.txt:
# TINFO:<titel>,<attr>,<code>,"wert" — Attribut 9 = Laufzeit "H:MM:SS"
TINFO_BEISPIEL = """
TINFO:0,2,0,"Hauptfilm"
TINFO:0,9,0,"1:52:29"
TINFO:1,9,0,"0:03:12"
TINFO:2,9,0,"0:24:01"
MSG:1011,0,1,"Using LibreDrive mode","%1","x"
"""
def test_parse_tinfo_dauern():
dauern = parse_tinfo_dauern(TINFO_BEISPIEL)
assert dauern == {0: 6749, 1: 192, 2: 1441}
def test_laengster_titel_ist_der_hauptfilm():
assert laengster_titel({0: 6749, 1: 192, 2: 1441}) == 0
assert laengster_titel({}) is None
def test_makemkv_cmd_mit_titelwahl():
cmd = build_makemkv_cmd("/dev/sr0", "/out", titel="7")
assert cmd[-2:] == ["7", "/out"]
assert "all" not in cmd
def test_makemkv_cmd_default_bleibt_all():
cmd = build_makemkv_cmd("/dev/sr0", "/out")
assert cmd[-2:] == ["all", "/out"]
def test_parse_scan_dauer():
ausgabe = "\n + duration: 01:52:29\n"
assert parse_scan_dauer(ausgabe) == 6749
assert parse_scan_dauer("kein treffer") == 0