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:
+148
-11
@@ -13,12 +13,15 @@ komprimiert danach auf Arbeitsgröße. Die Rohdatei liegt nur temporär in
|
||||
"""
|
||||
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
|
||||
import db
|
||||
import medien
|
||||
import notify
|
||||
from celery_app import celery_app
|
||||
from detection import detect_disc_type
|
||||
from detection import detect_disc_type, disc_size_bytes
|
||||
from ripping import (
|
||||
DEFAULT_HB_PRESET,
|
||||
RIP_OUTPUT_DIR,
|
||||
@@ -41,37 +44,136 @@ def _zielbasis(target_dir, disc_type: str) -> str:
|
||||
return os.path.join(RIP_OUTPUT_DIR, disc_type)
|
||||
|
||||
|
||||
def _arbeitsverzeichnis(einstellungen: dict) -> str:
|
||||
"""Basis für Roh-Rips: UI-Setting `workDir` (unter /app/media, z. B. eine
|
||||
NAS-Freigabe) schlägt den Container-Default /app/temp/raw.
|
||||
|
||||
Hintergrund (Commander 24.07.): Die VM-Platte (150 GB) reicht für BD-50,
|
||||
aber eine 4K-UHD (bis 100 GB roh + Kompression daneben) sprengt sie —
|
||||
das Arbeitsverzeichnis muss deshalb auf ein großes Ziel umlegbar sein.
|
||||
"""
|
||||
work_dir = (einstellungen.get("workDir") or "").strip()
|
||||
if work_dir:
|
||||
normalisiert = os.path.normpath(work_dir)
|
||||
if normalisiert.startswith(MEDIA_ROOT):
|
||||
return normalisiert
|
||||
return RAW_DIR
|
||||
|
||||
|
||||
def _frei_bytes(pfad: str) -> int:
|
||||
"""Freier Platz am Pfad (nächster existierender Elternordner zählt)."""
|
||||
kandidat = pfad
|
||||
while kandidat and not os.path.exists(kandidat):
|
||||
kandidat = os.path.dirname(kandidat)
|
||||
try:
|
||||
return shutil.disk_usage(kandidat or "/").free
|
||||
except OSError:
|
||||
return -1
|
||||
|
||||
|
||||
def _platz_pruefen(pfad: str, benoetigt: int, zweck: str) -> str:
|
||||
"""Gibt eine Klartext-Fehlermeldung zurück, wenn der Platz nicht reicht —
|
||||
sonst leeren String. VOR dem Rip geprüft: eine volle Platte nach 40 GB
|
||||
ist der teuerste Fehlschlag, den es gibt."""
|
||||
frei = _frei_bytes(pfad)
|
||||
if frei < 0 or benoetigt <= 0:
|
||||
return "" # unbekannt → nicht raten, lieber rippen lassen
|
||||
if frei >= benoetigt:
|
||||
return ""
|
||||
return (
|
||||
f"Zu wenig Platz für {zweck} in {pfad}: "
|
||||
f"{frei / 1024**3:.1f} GB frei, benötigt ~{benoetigt / 1024**3:.1f} GB. "
|
||||
"Abhilfe: anderes Ziel wählen oder das Arbeitsverzeichnis unter "
|
||||
"Einstellungen → Verarbeitung auf eine große Freigabe legen."
|
||||
)
|
||||
|
||||
|
||||
def _makemkv_key_anwenden(einstellungen: dict) -> None:
|
||||
"""MakeMKV-Beta-Key aus den UI-Einstellungen aktivieren (schlägt Env).
|
||||
|
||||
Damit ist der Monats-Key ohne Rebuild/Neustart aktualisierbar
|
||||
(Einstellungen → System). Format wie entrypoint.sh: settings.conf.
|
||||
"""
|
||||
key = (einstellungen.get("makemkvAppKey") or "").strip()
|
||||
if not key:
|
||||
return
|
||||
ordner = os.path.expanduser("~/.MakeMKV")
|
||||
try:
|
||||
os.makedirs(ordner, exist_ok=True)
|
||||
with open(os.path.join(ordner, "settings.conf"), "w") as f:
|
||||
f.write(f'app_Key = "{key}"\n')
|
||||
except OSError as e:
|
||||
db.add_log("warning", "worker", f"MakeMKV-Key konnte nicht gesetzt werden: {e}")
|
||||
|
||||
|
||||
def _abbruch_angefordert(job_id: str) -> bool:
|
||||
"""Kooperativer Abbruch: hat der Nutzer über die API abgebrochen?"""
|
||||
return db.get_job_status(job_id) == "canceling"
|
||||
|
||||
|
||||
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()
|
||||
url = (einstellungen.get("notificationWebhook") or "").strip()
|
||||
if not url:
|
||||
return
|
||||
try:
|
||||
notify.sende(url, betreff, text, level)
|
||||
except Exception as e:
|
||||
db.add_log("warning", "notify", f"Job {job_id}: Benachrichtigung fehlgeschlagen: {e}")
|
||||
|
||||
|
||||
def _job_abschliessen(job_id: str, ergebnis: dict) -> None:
|
||||
"""Schreibt den Endzustand eines Jobs (completed/failed) nach Postgres."""
|
||||
"""Schreibt den Endzustand eines Jobs (completed/failed) nach Postgres,
|
||||
bereitet bei Erfolg fürs Medien-System auf (NFO/Poster) und meldet das
|
||||
Ende per Webhook (falls konfiguriert)."""
|
||||
job = db.get_job(job_id) or {}
|
||||
titel = job.get("title") or job_id[:8]
|
||||
|
||||
if ergebnis.get("status") == "cancelled":
|
||||
db.update_job(
|
||||
job_id, status="failed", error="Abgebrochen durch Nutzer",
|
||||
finished_at=db.utcnow(),
|
||||
)
|
||||
db.add_log("warning", "worker", f"Job {job_id}: abgebrochen — Rohdaten bleiben erhalten")
|
||||
_benachrichtigen(job_id, f'⚠️ Rippy: „{titel}" abgebrochen',
|
||||
"Der Job wurde auf Wunsch abgebrochen — Rohdaten bleiben erhalten.",
|
||||
"warning")
|
||||
return
|
||||
if ergebnis.get("status") == "success":
|
||||
ausgabe = ergebnis.get("output_dir")
|
||||
if ausgabe and (job.get("disc_type") in ("dvd", "bluray")):
|
||||
# Media-Server-Aufbereitung (Jellyfin/Emby/Kodi: NFO + Poster)
|
||||
einstellungen = db.get_settings()
|
||||
try:
|
||||
meta = json.loads(job.get("meta") or "{}")
|
||||
except ValueError:
|
||||
meta = {}
|
||||
for meldung in medien.aufbereiten(
|
||||
ausgabe, einstellungen.get("mediaServer") or "none",
|
||||
job.get("title") or "", meta.get("year"), meta,
|
||||
):
|
||||
db.add_log("info", "worker", f"Job {job_id}: {meldung}")
|
||||
db.update_job(
|
||||
job_id,
|
||||
status="completed",
|
||||
progress=100,
|
||||
output_path=ergebnis.get("output_dir"),
|
||||
output_path=ausgabe,
|
||||
finished_at=db.utcnow(),
|
||||
)
|
||||
db.add_log("success", "worker", f"Job {job_id}: abgeschlossen → {ergebnis.get('output_dir')}")
|
||||
db.add_log("success", "worker", f"Job {job_id}: abgeschlossen → {ausgabe}")
|
||||
_benachrichtigen(job_id, f'✅ Rippy: „{titel}" ist fertig',
|
||||
f"Abgelegt unter: {ausgabe}", "success")
|
||||
else:
|
||||
fehler = ergebnis.get("error", "unbekannter Fehler")
|
||||
db.update_job(
|
||||
job_id,
|
||||
status="failed",
|
||||
error=ergebnis.get("error", "unbekannter Fehler"),
|
||||
error=fehler,
|
||||
finished_at=db.utcnow(),
|
||||
)
|
||||
db.add_log("error", "worker", f"Job {job_id}: {ergebnis.get('error', 'unbekannter Fehler')}")
|
||||
db.add_log("error", "worker", f"Job {job_id}: {fehler}")
|
||||
_benachrichtigen(job_id, f'❌ Rippy: „{titel}" fehlgeschlagen', fehler, "error")
|
||||
|
||||
|
||||
@celery_app.task(bind=True, name="worker.tasks.rip_disc")
|
||||
@@ -122,16 +224,51 @@ def rip_disc(self, device_path: str, job_id: str, target_dir: str = None):
|
||||
and einstellungen.get("transcodeEnabled", True)
|
||||
)
|
||||
|
||||
final_dir = os.path.join(_zielbasis(target_dir, disc_type), job_id)
|
||||
if disc_type in ("dvd", "bluray"):
|
||||
# UI-Key schlägt Env-Key — Monats-Key ohne Rebuild aktualisierbar
|
||||
_makemkv_key_anwenden(einstellungen)
|
||||
|
||||
# Sprechender Zielordner „<Titel> (Jahr)" statt Job-UUID — Jellyfin & Co.
|
||||
# erkennen den Film am Ordnernamen. UUID bleibt Fallback ohne Titel.
|
||||
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,
|
||||
)
|
||||
# Geplantes Ziel sofort sichtbar machen (UI-Detail + retry-transcode)
|
||||
db.update_job(job_id, output_path=final_dir)
|
||||
|
||||
raw_dir = os.path.join(_arbeitsverzeichnis(einstellungen), job_id)
|
||||
|
||||
# Platz-Check VOR dem Rip: Disc-Größe ist per ioctl bekannt — eine volle
|
||||
# Platte nach 40 GB wäre der teuerste Fehlschlag (4K-UHD: bis 100 GB roh).
|
||||
try:
|
||||
disc_bytes = disc_size_bytes(device_path)
|
||||
except OSError:
|
||||
disc_bytes = 0
|
||||
marge = 1024**3 # 1 GB Sicherheitsabstand
|
||||
if disc_type in ("dvd", "bluray"):
|
||||
rip_ziel = raw_dir if transcode_an else final_dir
|
||||
fehler = _platz_pruefen(rip_ziel, disc_bytes + marge, "den Roh-Rip")
|
||||
if not fehler and transcode_an and einstellungen.get("keepOriginal", False):
|
||||
fehler = _platz_pruefen(final_dir, disc_bytes + marge, "das Original (keepOriginal)")
|
||||
if fehler:
|
||||
ergebnis = {"status": "error", "error": fehler}
|
||||
_job_abschliessen(job_id, ergebnis)
|
||||
return ergebnis
|
||||
|
||||
if disc_type == "cd":
|
||||
ergebnis = rip_cd(device_path, job_id, progress_cb=fortschritt, output_dir=final_dir)
|
||||
elif transcode_an:
|
||||
# Roh-Rip nach /app/temp (wird nach erfolgreicher Kompression gelöscht)
|
||||
# Roh-Rip ins Arbeitsverzeichnis (wird nach erfolgreicher Kompression gelöscht)
|
||||
ergebnis = rip_video(
|
||||
device_path, job_id, disc_type,
|
||||
progress_cb=fortschritt,
|
||||
output_dir=os.path.join(RAW_DIR, job_id),
|
||||
output_dir=raw_dir,
|
||||
)
|
||||
else:
|
||||
ergebnis = rip_video(
|
||||
@@ -145,10 +282,10 @@ def rip_disc(self, device_path: str, job_id: str, target_dir: str = None):
|
||||
db.update_job(job_id, status="transcoding", progress=0)
|
||||
db.add_log("info", "worker", f"Job {job_id}: Rip fertig, Kompression eingereiht")
|
||||
transcode_files.apply_async(
|
||||
args=[job_id, os.path.join(RAW_DIR, job_id), final_dir],
|
||||
args=[job_id, raw_dir, final_dir],
|
||||
queue="transcode",
|
||||
)
|
||||
return {"status": "ripped", "raw_dir": os.path.join(RAW_DIR, job_id)}
|
||||
return {"status": "ripped", "raw_dir": raw_dir}
|
||||
|
||||
_job_abschliessen(job_id, ergebnis)
|
||||
return ergebnis
|
||||
|
||||
Reference in New Issue
Block a user