feat(backend): Media-Server-Aufbereitung, echte Webhooks, SMB-Klartext, UHD-Arbeitsverzeichnis, MakeMKV-Key via UI

- jobs.meta (Migration in beiden db.py): Disc-Metadaten wandern in den Job —
  Quelle fuer Job-Detail-Popup (GET /jobs/{id}/detail), Ordner-Benennung, NFO.
- medien.py (Worker, mit Tests): Zielordner 'Titel (Jahr)' statt Job-UUID;
  movie.nfo/tvshow.nfo (Kodi-Schema, kodi.wiki/view/NFO_files) + poster.jpg
  fuer jellyfin/emby/kodi; plex nur Benennung; Kollision -> Job-ID-Suffix.
- notify.py (identisch in API+Worker, mit Tests): Webhook bei Job-Ende —
  Discord ({content}, discord.com/developers), Slack ({text},
  api.slack.com/messaging/webhooks), ntfy (Rohtext + ?title=, docs.ntfy.sh),
  generisches JSON. Vorher war das Setting ein Placebo: nichts sendete je.
  POST /notifications/test beweist die Anbindung sofort.
- mounts.py: NT_STATUS-Fehler -> handelbarer Klartext (ACCESS_DENIED ohne
  Credentials = Gast-Abfrage verweigert), Timeout-Meldung, mount-Hinweis
  bei error(13). Mit Tests.
- tasks.py: Platz-Check per Disc-Groesse (ioctl BLKGETSIZE64) VOR dem Rip;
  Arbeitsverzeichnis workDir (unter /app/media, z.B. NAS) statt fix
  /app/temp/raw — 4K-UHD-Rohdaten (bis 100 GB) sprengen sonst die VM-Platte;
  MakeMKV-Key aus UI-Settings (~/.MakeMKV/settings.conf, Format wie
  entrypoint.sh) gilt ab dem naechsten Rip ohne Rebuild.
- caps.py: Werkzeug-Versionen (MakeMKV aus ENV MAKEMKV_VERSION im Image,
  makemkvcon hat keinen --version-Schalter lt. usage.txt; HandBrakeCLI
  --version lt. handbrake.fr/docs) + Key-Quelle; workers.info-Spalte.
- /browse liefert jetzt auch DATEIEN (Name + Groesse) — der 'leere'
  bluray-Ordner war voll, der Browser zeigte nur Unterordner.
- GET /system/info: Versionen, Plattenplatz, Key-/Webhook-Status.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-07-24 08:55:22 +02:00
parent 048562a6ba
commit e032a9df1c
14 changed files with 787 additions and 36 deletions
+15 -1
View File
@@ -43,6 +43,7 @@ jobs = Table(
Column("output_path", Text),
Column("target_dir", String(255)),
Column("error", Text),
Column("meta", Text), # Disc-Metadaten (JSON: Jahr/Poster/Plot) für Detail-Popup + NFO
Column("created_at", DateTime(timezone=True)),
Column("finished_at", DateTime(timezone=True)),
)
@@ -69,6 +70,7 @@ workers = Table(
metadata,
Column("name", String(128), primary_key=True),
Column("encoders", Text),
Column("info", Text), # Werkzeug-Versionen (JSON: makemkv/handbrake/key-Quelle)
Column("last_seen", DateTime(timezone=True)),
)
@@ -96,6 +98,10 @@ def list_workers() -> list:
eintrag["encoders"] = json.loads(eintrag.get("encoders") or "[]")
except ValueError:
eintrag["encoders"] = []
try:
eintrag["info"] = json.loads(eintrag.get("info") or "{}")
except ValueError:
eintrag["info"] = {}
if eintrag.get("last_seen"):
eintrag["last_seen"] = eintrag["last_seen"].isoformat()
ergebnis.append(eintrag)
@@ -134,10 +140,17 @@ def init_db() -> None:
conn.exec_driver_sql(
"ALTER TABLE jobs ADD COLUMN IF NOT EXISTS target_dir VARCHAR(255)"
)
conn.exec_driver_sql(
"ALTER TABLE jobs ADD COLUMN IF NOT EXISTS meta TEXT"
)
conn.exec_driver_sql(
"ALTER TABLE workers ADD COLUMN IF NOT EXISTS info TEXT"
)
def insert_job(
job_id: str, device: str, disc_type: str = None, title: str = None, target_dir: str = None
job_id: str, device: str, disc_type: str = None, title: str = None,
target_dir: str = None, meta: str = None,
) -> None:
with engine.begin() as conn:
conn.execute(
@@ -147,6 +160,7 @@ def insert_job(
disc_type=disc_type,
title=title,
target_dir=target_dir,
meta=meta,
status="pending",
progress=0,
created_at=utcnow(),
+111 -15
View File
@@ -13,6 +13,7 @@ import uuid
import db
import devices as device_discovery
import mounts as mount_verwaltung
import notify
from celery_client import celery_client, start_rip
from detection import CDS_DISC_OK, CDS_NO_DISC, CDS_TRAY_OPEN, drive_status
@@ -262,15 +263,23 @@ async def create_job(request: JobCreateRequest):
raise HTTPException(status_code=404, detail=f"Laufwerk {device_path} nicht gefunden")
ziel = _validiere_ziel(request.target_dir)
# Titel aus der Disc-Erkennung übernehmen — sonst steht im Job "Unbekannt"
# Titel + Metadaten aus der Disc-Erkennung übernehmen — der Worker nutzt
# sie für den Ordnernamen und die Media-Server-Aufbereitung (NFO/Poster),
# das UI fürs Job-Detail-Popup.
titel = request.title
if not titel:
disc = DISC_CACHE.get(device_path)
if disc and not disc.get("_laeuft"):
meta_json = None
disc = DISC_CACHE.get(device_path)
if disc and not disc.get("_laeuft"):
if not titel:
titel = disc.get("title")
meta_json = json.dumps({
"year": disc.get("year"),
"confidence": disc.get("confidence"),
**(disc.get("metadata") or {}),
})
job_id = str(uuid.uuid4())
await asyncio.to_thread(db.insert_job, job_id, device_path, None, titel, ziel)
await asyncio.to_thread(db.insert_job, job_id, device_path, None, titel, ziel, meta_json)
await asyncio.to_thread(
db.add_log, "info", "api",
f"Job {job_id} angelegt für {device_path}" + (f"{ziel}" if ziel else ""),
@@ -279,6 +288,23 @@ async def create_job(request: JobCreateRequest):
return {"id": job_id, "status": "pending", "device": device_path, "target_dir": ziel}
@app.get("/jobs/{job_id}/detail")
async def get_job_detail(job_id: str):
"""Alles zu EINEM Job — fürs Klick-Popup auf den Titel in „Neueste Jobs":
Metadaten (Poster/Jahr/Beschreibung), Ziel, Ausgabepfad, Fehler."""
job = await asyncio.to_thread(db.get_job, job_id)
if not job:
raise HTTPException(status_code=404, detail="Job nicht gefunden")
detail = _job_row_to_model(job).dict()
detail["target_dir"] = job.get("target_dir")
detail["output_path"] = job.get("output_path")
try:
detail["meta"] = json.loads(job["meta"]) if job.get("meta") else None
except ValueError:
detail["meta"] = None
return detail
@app.get("/storage-targets")
async def storage_targets():
"""Verfügbare Ablageziele: Verzeichnisse unter /app/media inkl. Mounts.
@@ -343,9 +369,16 @@ async def retry_transcode(job_id: str):
if job["status"] in ("running", "pending"):
raise HTTPException(status_code=409, detail="Job rippt noch")
raw_dir = f"/app/temp/raw/{job_id}"
# Roh-Verzeichnis: respektiert das konfigurierbare Arbeitsverzeichnis
# (Einstellungen → Verarbeitung), sonst Container-Default /app/temp/raw.
einstellungen = await asyncio.to_thread(db.get_settings)
work_dir = os.path.normpath((einstellungen.get("workDir") or "").strip() or "/")
raw_basis = work_dir if work_dir.startswith(MEDIA_ROOT) else "/app/temp/raw"
raw_dir = f"{raw_basis}/{job_id}"
# Zielordner: der Worker schreibt das geplante Ziel beim Rip-Start nach
# output_path (sprechender Name statt UUID) — alter Fallback bleibt.
basis = job.get("target_dir") or f"{MEDIA_ROOT}/{job.get('disc_type') or 'bluray'}"
final_dir = f"{basis}/{job_id}"
final_dir = job.get("output_path") or f"{basis}/{job_id}"
celery_client.send_task(
"worker.tasks.transcode_files",
@@ -495,17 +528,28 @@ async def browse(path: str = MEDIA_ROOT):
eintraege = sorted(os.listdir(normalisiert))
except OSError:
return None
return [
{"name": name, "path": os.path.join(normalisiert, name)}
for name in eintraege
if os.path.isdir(os.path.join(normalisiert, name))
]
ordner, dateien = [], []
for name in eintraege:
voll = os.path.join(normalisiert, name)
if os.path.isdir(voll):
ordner.append({"name": name, "path": voll})
else:
# Dateien MIT anzeigen (Befund 24.07.: der Browser wirkte
# „leer", weil er nur Ordner listete — die MKVs im
# bluray-Ordner waren unsichtbar).
try:
groesse_mb = round(os.path.getsize(voll) / 1024**2, 1)
except OSError:
groesse_mb = None
dateien.append({"name": name, "size_mb": groesse_mb})
return ordner, dateien
ordner = await asyncio.to_thread(liste)
if ordner is None:
ergebnis = await asyncio.to_thread(liste)
if ergebnis is None:
raise HTTPException(status_code=404, detail="Ordner nicht lesbar")
ordner, dateien = ergebnis
eltern = os.path.dirname(normalisiert) if normalisiert != MEDIA_ROOT else None
return {"path": normalisiert, "parent": eltern, "dirs": ordner}
return {"path": normalisiert, "parent": eltern, "dirs": ordner, "files": dateien}
class MkdirRequest(BaseModel):
@@ -530,6 +574,58 @@ async def browse_mkdir(request: MkdirRequest):
return {"path": ziel}
@app.get("/system/info")
async def system_info():
"""System-Selbstauskunft (Einstellungen → System): Werkzeug-Versionen der
Worker, freier Platz auf Media- und Arbeits-Volume, MakeMKV-Key-Status."""
def sammle():
info = {"api_version": app.version, "plaetze": [], "workers": db.list_workers()}
for name, pfad in (("Media (/app/media)", MEDIA_ROOT),
("Arbeitsverzeichnis (/app/temp)", "/app/temp")):
try:
nutzung = shutil.disk_usage(pfad)
info["plaetze"].append({
"name": name,
"frei_gb": round(nutzung.free / 1024**3, 1),
"gesamt_gb": round(nutzung.total / 1024**3, 1),
})
except OSError:
pass
einstellungen = db.get_settings()
info["makemkv_key_ui"] = bool((einstellungen.get("makemkvAppKey") or "").strip())
info["webhook_gesetzt"] = bool((einstellungen.get("notificationWebhook") or "").strip())
return info
return await asyncio.to_thread(sammle)
class NotificationTestRequest(BaseModel):
url: str
@app.post("/notifications/test")
async def notification_test(request: NotificationTestRequest):
"""Test-Nachricht an den Webhook — beweist die Anbindung SOFORT statt
erst beim ersten Job-Ende."""
url = request.url.strip()
if not url.startswith(("http://", "https://")):
raise HTTPException(status_code=422, detail="Webhook-URL muss mit http(s):// beginnen")
try:
await asyncio.to_thread(
notify.sende, url,
"🔔 Rippy: Test-Benachrichtigung",
"Wenn du das liest, funktioniert die Anbindung. Rippy meldet sich "
"hier, sobald ein Job fertig ist oder fehlschlägt.",
"info",
)
except RuntimeError as e:
raise HTTPException(status_code=400, detail=str(e))
await asyncio.to_thread(
db.add_log, "info", "notify", f"Test-Benachrichtigung gesendet ({notify.erkenne_webhook_typ(url)})"
)
return {"status": "sent", "typ": notify.erkenne_webhook_typ(url)}
@app.get("/setup")
async def setup_status():
"""First-Run-Erkennung: wurde der Einrichtungs-Assistent abgeschlossen?"""
+53 -3
View File
@@ -49,6 +49,40 @@ def schreibtest(pfad: str) -> bool:
return False
def uebersetze_smb_fehler(fehler: str, mit_credentials: bool) -> str:
"""Pure Funktion (testbar): NT_STATUS-Kauderwelsch → handelbarer Klartext.
Befund 24.07.: „session setup failed: NT_STATUS_ACCESS_DENIED" hieß in
Wahrheit nur „Windows/NAS erlauben keine Gast-Abfrage" — ohne Übersetzung
rät niemand, dass oben Benutzer/Passwort fehlen.
"""
if "NT_STATUS_ACCESS_DENIED" in fehler:
if not mit_credentials:
return (
"Zugriff verweigert — dieser Rechner erlaubt keine Gast-Abfrage. "
"Benutzername + Passwort eintragen (Windows: ein Konto mit "
"Zugriff auf die Freigabe), dann erneut auflisten."
)
return (
"Zugriff verweigert — Benutzername/Passwort stimmen nicht oder das "
"Konto darf die Freigaben nicht auflisten."
)
if "NT_STATUS_LOGON_FAILURE" in fehler:
return "Anmeldung fehlgeschlagen — Benutzername oder Passwort falsch."
if (
"NT_STATUS_HOST_UNREACHABLE" in fehler
or "NT_STATUS_IO_TIMEOUT" in fehler
or "NT_STATUS_UNSUCCESSFUL" in fehler
or "NT_STATUS_CONNECTION_REFUSED" in fehler
or "Connection to" in fehler
):
return (
"Rechner nicht erreichbar — Name/IP prüfen; läuft dort ein "
"SMB-Dienst (Port 445, Datei- und Druckerfreigabe aktiv)?"
)
return fehler[:200]
def liste_smb_freigaben(host: str, username: str = "", passwort: str = "") -> list:
"""Listet die SMB-Freigaben eines Rechners (smbclient -L) — damit man
seinen PC/NAS per Klick wählt statt //host/share zu raten."""
@@ -57,7 +91,13 @@ def liste_smb_freigaben(host: str, username: str = "", passwort: str = "") -> li
cmd += ["-U", f"{username}%{passwort or ''}"]
else:
cmd += ["-N"]
ergebnis = subprocess.run(cmd, capture_output=True, text=True, timeout=20)
try:
ergebnis = subprocess.run(cmd, capture_output=True, text=True, timeout=20)
except subprocess.TimeoutExpired:
raise RuntimeError(
f"Freigaben-Abfrage fehlgeschlagen: {host} antwortet nicht "
"(Timeout nach 20 s) — Name/IP und Netzwerk prüfen."
)
freigaben = []
for zeile in (ergebnis.stdout or "").splitlines():
# -g-Format: Disk|Freigabename|Kommentar
@@ -66,7 +106,10 @@ def liste_smb_freigaben(host: str, username: str = "", passwort: str = "") -> li
freigaben.append(teile[1])
if not freigaben and ergebnis.returncode != 0:
fehler = (ergebnis.stderr or ergebnis.stdout or "").strip()
raise RuntimeError(f"Freigaben-Abfrage fehlgeschlagen: {fehler[:200]}")
raise RuntimeError(
"Freigaben-Abfrage fehlgeschlagen: "
+ uebersetze_smb_fehler(fehler, bool(username))
)
return freigaben
@@ -109,7 +152,14 @@ def mounten(name: str, typ: str, quelle: str, optionen: str = "",
ergebnis = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
if ergebnis.returncode != 0:
fehler = (ergebnis.stderr or ergebnis.stdout or "").strip()
raise RuntimeError(f"mount schlug fehl: {fehler[:300]}")
hinweis = ""
if "error(13)" in fehler or "Permission denied" in fehler:
hinweis = (
" — Zugriff verweigert: Benutzer/Passwort prüfen; ohne "
"Angaben versucht Rippy einen Gast-Zugriff, den Windows/"
"NAS meist ablehnen."
)
raise RuntimeError(f"mount schlug fehl: {fehler[:300]}{hinweis}")
return schreibtest(ziel)
finally:
if creds_datei:
+66
View File
@@ -0,0 +1,66 @@
"""Webhook-Benachrichtigungen bei Job-Ende (Discord, Slack, ntfy, generisch).
Bis 24.07. war das notificationWebhook-Setting ein Placebo: das UI speicherte
die URL, aber NICHTS hat je gesendet. Jetzt meldet der Worker Job-Ende
(fertig/fehlgeschlagen/abgebrochen) und die API bietet einen Test-Endpoint.
Das Modul existiert bewusst identisch in API und Worker
(docker/api/notify.py) — es gibt kein geteiltes Paket zwischen den Containern.
Wer es ändert, ändert BEIDE Dateien.
Payload-Formate (dokumentiert, nicht geraten — AGENTS Regel D):
- Discord: POST JSON {"content": "..."} — discord.com/developers/docs/resources/webhook
- Slack: POST JSON {"text": "..."} — api.slack.com/messaging/webhooks
- ntfy: POST Roh-Text an https://ntfy.sh/<topic>, Titel via ?title= —
docs.ntfy.sh/publish
- Generisch: POST JSON {"title", "message", "level"} für eigene Empfänger
(Home Assistant, n8n, eigene Skripte).
"""
import requests
TIMEOUT_SEKUNDEN = 10
def erkenne_webhook_typ(url: str) -> str:
"""Erkennt den Dienst an der URL — der Nutzer muss nichts konfigurieren."""
u = url.lower()
if "discord.com/api/webhooks" in u or "discordapp.com/api/webhooks" in u:
return "discord"
if "hooks.slack.com" in u:
return "slack"
if "ntfy" in u:
return "ntfy"
return "generisch"
def baue_payload(url: str, titel: str, text: str, level: str = "info"):
"""Pure Funktion (testbar): (typ, json_payload) — ntfy sendet Roh-Text."""
typ = erkenne_webhook_typ(url)
if typ == "discord":
return typ, {"content": f"**{titel}**\n{text}"}
if typ == "slack":
return typ, {"text": f"*{titel}*\n{text}"}
if typ == "ntfy":
return typ, None
return typ, {"title": titel, "message": text, "level": level}
def sende(url: str, titel: str, text: str, level: str = "info") -> None:
"""Schickt die Nachricht; wirft RuntimeError mit Klartext bei Fehlern."""
typ, payload = baue_payload(url, titel, text, level)
try:
if typ == "ntfy":
antwort = requests.post(
url, data=text.encode("utf-8"),
params={"title": titel}, timeout=TIMEOUT_SEKUNDEN,
)
else:
antwort = requests.post(url, json=payload, timeout=TIMEOUT_SEKUNDEN)
except requests.RequestException as e:
raise RuntimeError(f"Webhook nicht erreichbar: {e}") from e
if antwort.status_code >= 300:
raise RuntimeError(
f"Webhook antwortete mit HTTP {antwort.status_code}: "
f"{(antwort.text or '')[:200]}"
)
+41
View File
@@ -0,0 +1,41 @@
"""Tests für die SMB-Fehlerübersetzung (Speicherziele → Freigaben auflisten)."""
from mounts import uebersetze_smb_fehler, validiere_name
def test_access_denied_ohne_credentials_erklaert_gastproblem():
meldung = uebersetze_smb_fehler(
"session setup failed: NT_STATUS_ACCESS_DENIED", mit_credentials=False
)
assert "Gast" in meldung
assert "Benutzername + Passwort" in meldung
def test_access_denied_mit_credentials_verweist_auf_konto():
meldung = uebersetze_smb_fehler(
"session setup failed: NT_STATUS_ACCESS_DENIED", mit_credentials=True
)
assert "stimmen nicht" in meldung
def test_logon_failure_wird_uebersetzt():
meldung = uebersetze_smb_fehler("NT_STATUS_LOGON_FAILURE", mit_credentials=True)
assert "Passwort falsch" in meldung
def test_unerreichbar_wird_uebersetzt():
meldung = uebersetze_smb_fehler(
"do_connect: Connection to 10.0.0.9 failed (Error NT_STATUS_IO_TIMEOUT)",
mit_credentials=False,
)
assert "nicht erreichbar" in meldung
def test_unbekannter_fehler_bleibt_erhalten_und_gekappt():
meldung = uebersetze_smb_fehler("X" * 500, mit_credentials=False)
assert meldung == "X" * 200
def test_validiere_name_bleibt_streng():
assert validiere_name("nas-filme")
assert not validiere_name("NAS Filme")