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:
+111
-15
@@ -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?"""
|
||||
|
||||
Reference in New Issue
Block a user