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 db
|
||||||
import devices as device_discovery
|
import devices as device_discovery
|
||||||
|
import eta
|
||||||
import makemkv_daten
|
import makemkv_daten
|
||||||
import makemkv_key
|
import makemkv_key
|
||||||
import mounts as mount_verwaltung
|
import mounts as mount_verwaltung
|
||||||
import notify
|
import notify
|
||||||
|
import presets as preset_auswahl
|
||||||
from celery_client import celery_client, start_rip
|
from celery_client import celery_client, start_rip
|
||||||
from detection import CDS_DISC_OK, CDS_NO_DISC, CDS_TRAY_OPEN, drive_status
|
from detection import CDS_DISC_OK, CDS_NO_DISC, CDS_TRAY_OPEN, drive_status
|
||||||
|
|
||||||
from config_validation import validate_config, ConfigValidationError
|
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 ratelimit import check_rate_limit, get_rate_limit_remaining
|
||||||
from prescan import PreScan
|
from prescan import PreScan
|
||||||
|
|
||||||
@@ -253,6 +255,10 @@ class Job(BaseModel):
|
|||||||
error: Optional[str] = None
|
error: Optional[str] = None
|
||||||
can_retry: bool = False # Rohdaten vorhanden → „Neu komprimieren" sinnvoll
|
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
|
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):
|
class Device(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
@@ -314,30 +320,119 @@ def _kann_neu_komprimieren(job: dict, work_dir: str) -> bool:
|
|||||||
|
|
||||||
@app.get("/jobs", response_model=List[Job])
|
@app.get("/jobs", response_model=List[Job])
|
||||||
async def get_jobs():
|
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():
|
def sammle():
|
||||||
work_dir = os.path.normpath((db.get_settings().get("workDir") or "").strip() or "/")
|
work_dir = os.path.normpath((db.get_settings().get("workDir") or "").strip() or "/")
|
||||||
|
jetzt = time.monotonic()
|
||||||
modelle = []
|
modelle = []
|
||||||
for z in db.list_jobs():
|
for z in db.list_jobs():
|
||||||
modell = _job_row_to_model(z)
|
modell = _job_row_to_model(z)
|
||||||
modell.can_retry = _kann_neu_komprimieren(z, work_dir)
|
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)
|
modelle.append(modell)
|
||||||
return modelle
|
return modelle
|
||||||
|
|
||||||
return await asyncio.to_thread(sammle)
|
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}")
|
@app.delete("/jobs/{job_id}")
|
||||||
async def delete_job(job_id: str):
|
async def delete_job(job_id: str, rohdaten: bool = False):
|
||||||
"""Entfernt einen erledigten Job aus der Liste (Dateien bleiben liegen)."""
|
"""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)
|
job = await asyncio.to_thread(db.get_job, job_id)
|
||||||
if not job:
|
if not job:
|
||||||
raise HTTPException(status_code=404, detail="Job nicht gefunden")
|
raise HTTPException(status_code=404, detail="Job nicht gefunden")
|
||||||
if job["status"] not in ("completed", "failed"):
|
if job["status"] not in ("completed", "failed"):
|
||||||
raise HTTPException(status_code=409, detail="Job läuft noch — erst abbrechen")
|
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.delete_job, job_id)
|
||||||
await asyncio.to_thread(db.add_log, "info", "api", f"Job {job_id} aus der Liste entfernt")
|
vermerk = f" (inkl. {geloescht_gb} GB Rohdaten gelöscht)" if rohdaten else ""
|
||||||
return {"status": "deleted"}
|
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")
|
@app.delete("/jobs")
|
||||||
@@ -768,6 +863,24 @@ async def capabilities():
|
|||||||
return {"workers": await asyncio.to_thread(sammle)}
|
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}")
|
@app.delete("/workers/{name}")
|
||||||
async def delete_worker(name: str):
|
async def delete_worker(name: str):
|
||||||
"""Verwaisten Worker-Eintrag entfernen (alte Container-IDs nach Rebuilds).
|
"""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")
|
@app.get("/worker-setup/paket")
|
||||||
async def worker_setup_paket():
|
async def worker_setup_paket():
|
||||||
"""Worker-Quellcode als Zip — der Windows-Installer lädt ihn von hier.
|
"""Worker-Quellcode als Zip — der Windows-Installer lädt ihn von hier.
|
||||||
|
|||||||
Reference in New Issue
Block a user