feat(api): drei Endpunkte, die das Raten beenden - plus Restzeit in /jobs
GET /worker-setup/pfad-map - was ein externer Worker fuer RIPPY_PATH_MAP
eintragen muss, abgeleitet aus Rippys eigenen Mounts. DAS war der stille
Blocker: /app/media/... sind Container-Pfade, ein externer Worker sieht sie nur
uebersetzt, und dieses Mapping setzte NIEMAND. Externes Encoden konnte deshalb
nie funktionieren, obwohl das Celery-Routing einwandfrei arbeitete (Job
95afdc89: angenommen, 182 ms spaeter abgelehnt). Hat Rippy keine Freigabe, sagt
der Endpunkt das im Klartext samt Abhilfe - statt ein leeres Mapping zu liefern.
GET /presets - Preset-Namen der Worker plus Empfehlung MIT Begruendung. Die
Namen standen bisher fest verdrahtet an vier Stellen im UI.
GET /jobs/{id}/rohdaten + DELETE /jobs/{id}?rohdaten=true - was liegen bleibt,
wenn ein Job aus der Liste fliegt. Grund (v3.14): Entfernen loescht bewusst
keine Dateien, aber Job und Rohdaten haengen nur an der Job-ID - der Rohschnitt
ist danach UNERREICHBAR. Damals verwaisten so 75 GB unsichtbar, gefunden erst
per SSH.
/jobs liefert jetzt eta_sekunden + eta_text. Die Schaetzung entsteht in der API
und nicht im Browser, aus drei Gruenden: ein Seitenwechsel setzte die Messreihe
zurueck, zwei offene Tabs zeigten verschiedene Zahlen, und fuer einen externen
Encoder-Worker gaebe es gar keine - genau dort wollte der Commander sie sehen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+164
-6
@@ -12,15 +12,17 @@ import uuid
|
||||
|
||||
import db
|
||||
import devices as device_discovery
|
||||
import eta
|
||||
import makemkv_daten
|
||||
import makemkv_key
|
||||
import mounts as mount_verwaltung
|
||||
import notify
|
||||
import presets as preset_auswahl
|
||||
from celery_client import celery_client, start_rip
|
||||
from detection import CDS_DISC_OK, CDS_NO_DISC, CDS_TRAY_OPEN, drive_status
|
||||
|
||||
from config_validation import validate_config, ConfigValidationError
|
||||
from cache import init_cache
|
||||
from cache import get as cache_get, init_cache, set as cache_set
|
||||
from ratelimit import check_rate_limit, get_rate_limit_remaining
|
||||
from prescan import PreScan
|
||||
|
||||
@@ -253,6 +255,10 @@ class Job(BaseModel):
|
||||
error: Optional[str] = None
|
||||
can_retry: bool = False # Rohdaten vorhanden → „Neu komprimieren" sinnvoll
|
||||
meta: Optional[Dict] = None # Disc-Metadaten (Poster/Jahr/Plot) — fürs Thumbnail in der Jobliste + aktivem Rip-Header
|
||||
# Restzeit-Schätzung (siehe eta.py). -1/"" heißt „noch keine Aussage" —
|
||||
# bewusst ehrlich statt einer erfundenen Minutenzahl.
|
||||
eta_sekunden: int = -1
|
||||
eta_text: str = ""
|
||||
|
||||
class Device(BaseModel):
|
||||
id: str
|
||||
@@ -314,30 +320,119 @@ def _kann_neu_komprimieren(job: dict, work_dir: str) -> bool:
|
||||
|
||||
@app.get("/jobs", response_model=List[Job])
|
||||
async def get_jobs():
|
||||
"""Holt alle Jobs aus der Datenbank (neueste zuerst)."""
|
||||
"""Holt alle Jobs aus der Datenbank (neueste zuerst) — inkl. Restzeit.
|
||||
|
||||
Die ETA entsteht HIER, weil hier der Fortschritt vorbeikommt: jeder Aufruf
|
||||
schreibt die Messreihe im Cache fort (eta.py). Im Browser zu rechnen wäre
|
||||
einfacher gewesen und dreifach schlechter — ein Seitenwechsel setzte die
|
||||
Reihe zurück, zwei Tabs zeigten verschiedene Zahlen, und für einen externen
|
||||
Encoder-Worker gäbe es gar keine (genau dort wollte der Commander sie).
|
||||
"""
|
||||
def sammle():
|
||||
work_dir = os.path.normpath((db.get_settings().get("workDir") or "").strip() or "/")
|
||||
jetzt = time.monotonic()
|
||||
modelle = []
|
||||
for z in db.list_jobs():
|
||||
modell = _job_row_to_model(z)
|
||||
modell.can_retry = _kann_neu_komprimieren(z, work_dir)
|
||||
schaetzung = eta.aktualisiere_und_schaetze(
|
||||
z["id"], z.get("status") or "", z.get("progress") or 0,
|
||||
jetzt, cache_get, cache_set,
|
||||
)
|
||||
modell.eta_sekunden = schaetzung["sekunden"]
|
||||
modell.eta_text = schaetzung["text"]
|
||||
modelle.append(modell)
|
||||
return modelle
|
||||
|
||||
return await asyncio.to_thread(sammle)
|
||||
|
||||
|
||||
def _rohdaten_verzeichnisse(job_id: str, work_dir: str) -> list:
|
||||
"""Wo könnten Roh-MKVs dieses Jobs liegen? (beide möglichen Orte)"""
|
||||
kandidaten = [os.path.join("/app/temp/raw", job_id)]
|
||||
if unter_wurzel(work_dir, MEDIA_ROOT):
|
||||
kandidaten.append(os.path.join(work_dir, job_id))
|
||||
return [p for p in kandidaten if os.path.isdir(p)]
|
||||
|
||||
|
||||
def _rohdaten_groesse(pfade: list) -> tuple:
|
||||
"""(Bytes, Dateizahl) der Roh-MKVs — nur die Dateien, nicht rekursiv tief."""
|
||||
bytes_gesamt, dateien = 0, 0
|
||||
for pfad in pfade:
|
||||
try:
|
||||
for name in os.listdir(pfad):
|
||||
voll = os.path.join(pfad, name)
|
||||
if os.path.isfile(voll):
|
||||
bytes_gesamt += os.path.getsize(voll)
|
||||
dateien += 1
|
||||
except OSError:
|
||||
continue
|
||||
return bytes_gesamt, dateien
|
||||
|
||||
|
||||
@app.get("/jobs/{job_id}/rohdaten")
|
||||
async def job_rohdaten(job_id: str):
|
||||
"""Was bleibt liegen, wenn dieser Job aus der Liste fliegt?
|
||||
|
||||
⚠️ Der Grund für diesen Endpunkt (Vorfall 25.07.2026, v3.14): „Job aus der
|
||||
Liste entfernen" löscht bewusst keine Dateien — richtig, aber der Rohschnitt
|
||||
ist danach UNERREICHBAR, weil Job und Dateien nur über die Job-ID verbunden
|
||||
sind. Ein 75-GB-Rohschnitt verwaiste so unsichtbar auf der Platte: kein
|
||||
Eintrag zeigte mehr darauf, „Neu komprimieren" war unmöglich, und im UI war
|
||||
nichts davon zu sehen. Erst ein Blick per SSH brachte es zutage.
|
||||
|
||||
Jetzt fragt das UI vor dem Entfernen hier nach und sagt die Zahl.
|
||||
"""
|
||||
job = await asyncio.to_thread(db.get_job, job_id)
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="Job nicht gefunden")
|
||||
|
||||
def sammle():
|
||||
work_dir = os.path.normpath((db.get_settings().get("workDir") or "").strip() or "/")
|
||||
pfade = _rohdaten_verzeichnisse(job_id, work_dir)
|
||||
bytes_gesamt, dateien = _rohdaten_groesse(pfade)
|
||||
return {
|
||||
"pfade": pfade,
|
||||
"dateien": dateien,
|
||||
"bytes": bytes_gesamt,
|
||||
"gb": round(bytes_gesamt / 1024**3, 1),
|
||||
}
|
||||
|
||||
return await asyncio.to_thread(sammle)
|
||||
|
||||
|
||||
@app.delete("/jobs/{job_id}")
|
||||
async def delete_job(job_id: str):
|
||||
"""Entfernt einen erledigten Job aus der Liste (Dateien bleiben liegen)."""
|
||||
async def delete_job(job_id: str, rohdaten: bool = False):
|
||||
"""Entfernt einen erledigten Job aus der Liste.
|
||||
|
||||
`rohdaten=true` löscht zusätzlich die Roh-MKVs. Ohne den Schalter bleiben
|
||||
sie liegen (Standard wie bisher) — das UI nennt vorher die Größe, damit die
|
||||
Entscheidung bewusst fällt und kein Rohschnitt unsichtbar verwaist.
|
||||
"""
|
||||
job = await asyncio.to_thread(db.get_job, job_id)
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="Job nicht gefunden")
|
||||
if job["status"] not in ("completed", "failed"):
|
||||
raise HTTPException(status_code=409, detail="Job läuft noch — erst abbrechen")
|
||||
|
||||
geloescht_gb = 0.0
|
||||
if rohdaten:
|
||||
def raeume():
|
||||
work_dir = os.path.normpath((db.get_settings().get("workDir") or "").strip() or "/")
|
||||
pfade = _rohdaten_verzeichnisse(job_id, work_dir)
|
||||
bytes_gesamt, _ = _rohdaten_groesse(pfade)
|
||||
for pfad in pfade:
|
||||
shutil.rmtree(pfad, ignore_errors=True)
|
||||
return round(bytes_gesamt / 1024**3, 1)
|
||||
|
||||
geloescht_gb = await asyncio.to_thread(raeume)
|
||||
|
||||
await asyncio.to_thread(db.delete_job, job_id)
|
||||
await asyncio.to_thread(db.add_log, "info", "api", f"Job {job_id} aus der Liste entfernt")
|
||||
return {"status": "deleted"}
|
||||
vermerk = f" (inkl. {geloescht_gb} GB Rohdaten gelöscht)" if rohdaten else ""
|
||||
await asyncio.to_thread(
|
||||
db.add_log, "info", "api", f"Job {job_id} aus der Liste entfernt{vermerk}"
|
||||
)
|
||||
return {"status": "deleted", "rohdaten_geloescht_gb": geloescht_gb}
|
||||
|
||||
|
||||
@app.delete("/jobs")
|
||||
@@ -768,6 +863,24 @@ async def capabilities():
|
||||
return {"workers": await asyncio.to_thread(sammle)}
|
||||
|
||||
|
||||
@app.get("/presets")
|
||||
async def preset_uebersicht():
|
||||
"""Welche HandBrake-Presets es WIRKLICH gibt — und welches das beste ist.
|
||||
|
||||
Die Namen kommen von den Workern selbst (`HandBrakeCLI --preset-list`, siehe
|
||||
worker/caps.py), die Staffelung aus presets.py. Damit endet das Raten:
|
||||
vorher standen die Namen fest verdrahtet im UI, und ein Name, den das
|
||||
jeweilige HandBrake nicht kennt, ließ die Kompression scheitern.
|
||||
|
||||
Commander-Anforderung 26.07.2026: „Bei den Presets soll IMMER das Beste
|
||||
ausgewählt werden" — die Begründung steht bei jeder Empfehlung mit dabei.
|
||||
"""
|
||||
def sammle():
|
||||
return preset_auswahl.uebersicht(db.list_workers())
|
||||
|
||||
return await asyncio.to_thread(sammle)
|
||||
|
||||
|
||||
@app.delete("/workers/{name}")
|
||||
async def delete_worker(name: str):
|
||||
"""Verwaisten Worker-Eintrag entfernen (alte Container-IDs nach Rebuilds).
|
||||
@@ -1201,6 +1314,51 @@ async def worker_setup_windows_exe():
|
||||
)
|
||||
|
||||
|
||||
@app.get("/worker-setup/pfad-map")
|
||||
async def worker_setup_pfad_map():
|
||||
"""Was ein externer Worker für `RIPPY_PATH_MAP` eintragen muss.
|
||||
|
||||
DER fehlende Anschluss (Befund 26.07.2026): Ein Windows-Worker bekommt von
|
||||
Rippy Container-Pfade (`/app/media/rippy/<job>`). Ohne Übersetzung auf eine
|
||||
Freigabe sieht er sie nicht — und das Mapping setzte niemand. Externes
|
||||
Encoden konnte deshalb nie funktionieren, obwohl das Celery-Routing
|
||||
einwandfrei arbeitete (Job 95afdc89: angenommen, 182 ms später abgelehnt).
|
||||
|
||||
Geraten wird hier nichts: Rippy hat die Freigabe selbst eingehängt und
|
||||
kennt ihre Quelle. Der Installer holt den Vorschlag von hier und schreibt
|
||||
ihn in start-tray.bat — der Nutzer muss nichts über Container-Pfade wissen.
|
||||
"""
|
||||
def sammle():
|
||||
vorschlaege = mount_verwaltung.pfad_map_vorschlag(db.list_mounts())
|
||||
mapping = mount_verwaltung.pfad_map_zeile(vorschlaege)
|
||||
if mapping:
|
||||
hinweis = (
|
||||
"Diese Freigabe(n) hängt Rippy selbst ein — der Worker erreicht "
|
||||
"sie unter demselben Namen im Netzwerk. Wichtig: Arbeits"
|
||||
"verzeichnis UND Ablage müssen darunter liegen, sonst kann der "
|
||||
"Worker lesen, aber nicht schreiben (oder umgekehrt)."
|
||||
)
|
||||
elif vorschlaege:
|
||||
hinweis = (
|
||||
'Es sind nur NFS-Ziele eingehängt. Windows kann NFS zwar über '
|
||||
'die Funktion „Client für NFS" einbinden, die Pfad-Schreibweise '
|
||||
'lässt sich aber nicht zuverlässig ableiten — bitte selbst '
|
||||
'eintragen, Format: /app/media/<name>=Z:\\ (oder UNC-Pfad).'
|
||||
)
|
||||
else:
|
||||
hinweis = (
|
||||
"Rippy hat keine Netzwerk-Freigabe eingehängt. Ein externer "
|
||||
"Worker kann dann NICHTS komprimieren: Rohdaten und Ziel liegen "
|
||||
"auf der Platte der Rippy-Maschine, und dorthin gibt es keinen "
|
||||
"Netzwerk-Zugang. Abhilfe: unter Einstellungen → Speicherziele "
|
||||
"eine Freigabe (NAS oder PC) einhängen und sie als "
|
||||
"Arbeitsverzeichnis UND Ablage wählen."
|
||||
)
|
||||
return {"mapping": mapping, "eintraege": vorschlaege, "hinweis": hinweis}
|
||||
|
||||
return await asyncio.to_thread(sammle)
|
||||
|
||||
|
||||
@app.get("/worker-setup/paket")
|
||||
async def worker_setup_paket():
|
||||
"""Worker-Quellcode als Zip — der Windows-Installer lädt ihn von hier.
|
||||
|
||||
Reference in New Issue
Block a user