fix(ui+api): das "Neuladen" war HTTP 429 - und "Neu" komprimierte immer
Ampel / ampel (push) Successful in 30s
Ampel / ampel (push) Successful in 30s
Zwei Commander-Befunde, beide mit derselben Wurzel: Rippy hat sich selbst
ausgebremst und dann geschwiegen.
## "Wenn der Worker installiert ist, wird dieser Bereich oft neu geladen"
Gemessen statt geraten. Die Antworten von /jobs und /capabilities waren ueber
zwanzig Sekunden byteweise identisch, alle Endpunkte antworteten unter 30 ms -
es wurde also gar nichts neu geladen. Im nginx-Log standen dagegen 97 Antworten
mit HTTP 429.
Drei Fehler griffen ineinander:
1. Das Limit war zu klein fuer Rippy selbst: 100 Anfragen/min, waehrend ein
offener Tab 111/min verursacht (Dashboard 75 + Log-Kasten 24 + Laufwerke 12)
und der Windows-Tray weitere 12/min dazulegt.
2. Der nginx gab die Client-Adresse nicht weiter. Fuer die API kam damit ALLES
von 172.19.0.6 - Browser, zweiter Tab und Tray teilten sich einen Eimer
(812 von 876 Anfragen). Das erklaert die Kopplung an den Worker: tray.py
fragt /api/jobs ueber Port 80, also durch denselben Proxy.
3. Ein abgewiesener Abruf leerte das UI. `catch(() => [])` heisst "es gibt
keine Jobs" - richtig waere "ich weiss gerade nichts Neues". Fuer einen Takt
stand "Keine Jobs", die Zaehler sprangen auf (0), vier Sekunden spaeter war
alles zurueck.
Behoben: X-Real-IP im nginx, Grenze auf 600/min mit vorgerechneter Herleitung,
jeder Fehlschlag laesst den alten Stand stehen (null statt []), axios bekommt
eine Zeitgrenze, und das Dashboard trennt schnelle Daten (Jobs/Laufwerke, 4 s)
von langsamen (Hardware/Worker/Ablagen, 12 s) - 75/min werden zu 30/min.
Ein greifendes Limit steht ab jetzt im Log, gedrosselt auf eine Meldung pro
Client und Minute.
## "Hier gibt es den Button 'neu' aber WAS wird dann gemacht?"
Immer die Komprimierung - auch bei einem Job, dessen RIP abgebrochen war. Am
26.07.2026 waeren aus 5,1 GB Bruchstueck (von rund 40 GB) brav ein Film
geworden, der bei 12 % aufhoert.
Die Phase war nach `status = "failed"` nicht mehr feststellbar, also wird sie
jetzt vermerkt (rip_fertig in den Job-Metadaten: false beim Rip-Start, true bei
der Uebergabe an die Kompression). Daraus folgt die Beschriftung: "Neu
komprimieren", "Neu rippen" - oder bei Bestandsjobs ohne Vermerk ein Dialog,
der beide Wege erklaert und die Groesse der Rohdaten als Entscheidungshilfe
nennt. Geraten wird nicht. Fuer den Rip-Fall gibt es POST
/jobs/{id}/retry-rip: neuer Job mit neuer ID (sonst laege das Bruchstueck im
Roh-Verzeichnis des neuen Rips), Titel/Ablage/Sprachwahl uebernommen, mit
ehrlicher Absage wenn keine Disc im Laufwerk liegt.
10 neue Tests (289 gruen), darunter eine Kopplungspruefung: der Name der
Phasen-Marke muss in worker/tasks.py und api/phasen.py zusammenpassen - genau
diese Sorte Auseinanderdriften hat die Zombie-Erkennung ein Release lang blind
gemacht.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+114
-2
@@ -18,6 +18,7 @@ import makemkv_daten
|
|||||||
import makemkv_key
|
import makemkv_key
|
||||||
import mounts as mount_verwaltung
|
import mounts as mount_verwaltung
|
||||||
import notify
|
import notify
|
||||||
|
import phasen
|
||||||
import presets as preset_auswahl
|
import presets as preset_auswahl
|
||||||
import rohdaten
|
import rohdaten
|
||||||
from celery_client import celery_client, start_rip
|
from celery_client import celery_client, start_rip
|
||||||
@@ -25,7 +26,13 @@ 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 get as cache_get, init_cache, set as cache_set
|
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 (
|
||||||
|
MAX_REQUESTS_PER_MINUTE,
|
||||||
|
check_rate_limit,
|
||||||
|
client_kennung,
|
||||||
|
darf_melden,
|
||||||
|
get_rate_limit_remaining,
|
||||||
|
)
|
||||||
from prescan import PreScan
|
from prescan import PreScan
|
||||||
|
|
||||||
# Auth (JWT/Login/API-Keys) KOMPLETT entfernt — Commander-Entscheid 24.07.2026:
|
# Auth (JWT/Login/API-Keys) KOMPLETT entfernt — Commander-Entscheid 24.07.2026:
|
||||||
@@ -225,10 +232,29 @@ async def disc_watcher():
|
|||||||
@app.middleware("http")
|
@app.middleware("http")
|
||||||
async def rate_limit_middleware(request: Request, call_next):
|
async def rate_limit_middleware(request: Request, call_next):
|
||||||
"""Rate-Limiting Middleware."""
|
"""Rate-Limiting Middleware."""
|
||||||
client_ip = request.client.host
|
# Nicht `request.client.host` allein: Der Browser spricht über den nginx im
|
||||||
|
# UI-Container, dessen Adresse sonst für ALLE Clients gälte (Herleitung in
|
||||||
|
# ratelimit.client_kennung).
|
||||||
|
client_ip = client_kennung(
|
||||||
|
request.client.host if request.client else "",
|
||||||
|
request.headers.get("x-real-ip") or request.headers.get("x-forwarded-for"),
|
||||||
|
)
|
||||||
|
|
||||||
# Rate Limit prüfen
|
# Rate Limit prüfen
|
||||||
if not check_rate_limit(client_ip):
|
if not check_rate_limit(client_ip):
|
||||||
|
# Ein 429 war lange unsichtbar: Das UI verbuchte ihn als „nichts da".
|
||||||
|
# Jetzt steht er im Log, damit die Bremse nicht wieder heimlich greift —
|
||||||
|
# gedrosselt, damit ein Amok-Skript nicht das Log-Fenster flutet.
|
||||||
|
try:
|
||||||
|
if darf_melden(client_ip):
|
||||||
|
db.add_log(
|
||||||
|
"warning", "api",
|
||||||
|
f"Rate-Limit erreicht für {client_ip} — Anfragen werden "
|
||||||
|
f"abgewiesen (Grenze: {MAX_REQUESTS_PER_MINUTE}/min). Läuft "
|
||||||
|
"dort ein Skript in einer Schleife?",
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
return Response(
|
return Response(
|
||||||
content=json.dumps({"error": "Rate limit exceeded"}),
|
content=json.dumps({"error": "Rate limit exceeded"}),
|
||||||
status_code=429,
|
status_code=429,
|
||||||
@@ -263,6 +289,10 @@ class Job(BaseModel):
|
|||||||
title: Optional[str] = None
|
title: Optional[str] = None
|
||||||
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
|
||||||
|
# Welche Wiederholung passt zu DIESEM Fehlschlag? "transcode" | "rip" |
|
||||||
|
# "unklar" | "" — Herleitung und Begründung in phasen.py. Ohne dieses Feld
|
||||||
|
# hieß der Knopf nur „Neu" und komprimierte immer, auch ein Rip-Bruchstück.
|
||||||
|
retry_art: str = ""
|
||||||
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" —
|
# Restzeit-Schätzung (siehe eta.py). -1/"" heißt „noch keine Aussage" —
|
||||||
# bewusst ehrlich statt einer erfundenen Minutenzahl.
|
# bewusst ehrlich statt einer erfundenen Minutenzahl.
|
||||||
@@ -577,6 +607,7 @@ async def get_jobs():
|
|||||||
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)
|
||||||
|
modell.retry_art = phasen.retry_art(z)
|
||||||
schaetzung = eta.aktualisiere_und_schaetze(
|
schaetzung = eta.aktualisiere_und_schaetze(
|
||||||
z["id"], z.get("status") or "", z.get("progress") or 0,
|
z["id"], z.get("status") or "", z.get("progress") or 0,
|
||||||
jetzt, cache_get, cache_set,
|
jetzt, cache_get, cache_set,
|
||||||
@@ -790,6 +821,7 @@ async def get_job_detail(job_id: str):
|
|||||||
detail = _job_row_to_model(job).dict()
|
detail = _job_row_to_model(job).dict()
|
||||||
detail["target_dir"] = job.get("target_dir")
|
detail["target_dir"] = job.get("target_dir")
|
||||||
detail["output_path"] = job.get("output_path")
|
detail["output_path"] = job.get("output_path")
|
||||||
|
detail["retry_art"] = phasen.retry_art(job)
|
||||||
try:
|
try:
|
||||||
detail["meta"] = json.loads(job["meta"]) if job.get("meta") else None
|
detail["meta"] = json.loads(job["meta"]) if job.get("meta") else None
|
||||||
except ValueError:
|
except ValueError:
|
||||||
@@ -1024,6 +1056,86 @@ async def retry_transcode(job_id: str):
|
|||||||
return {"id": job_id, "status": "transcoding"}
|
return {"id": job_id, "status": "transcoding"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/jobs/{job_id}/retry-rip", status_code=201)
|
||||||
|
async def retry_rip(job_id: str):
|
||||||
|
"""Rippt die Disc dieses Jobs NOCH EINMAL — als frischer Job.
|
||||||
|
|
||||||
|
Der Gegenpart zu `retry-transcode`. Nötig, weil es für einen mitten im Rip
|
||||||
|
gestorbenen Job vorher überhaupt keinen richtigen Knopf gab: Das UI bot nur
|
||||||
|
„Neu" an, und das war immer die Kompression. Am 26.07.2026 hätte das aus
|
||||||
|
5,1 GB Bruchstück brav einen Film gemacht, der bei 12 % aufhört.
|
||||||
|
|
||||||
|
Bewusst ein NEUER Job mit neuer ID, nicht ein Wiederbeleben des alten:
|
||||||
|
|
||||||
|
* Das Roh-Verzeichnis heißt <Arbeitsverzeichnis>/<job_id>. Bei gleicher ID
|
||||||
|
läge das alte Bruchstück im neuen Verzeichnis, und die Kompression
|
||||||
|
sammelt am Ende ALLE MKV-Dateien darin ein — sie würde das Bruchstück
|
||||||
|
mitverarbeiten.
|
||||||
|
* Der Fehlschlag bleibt in der Liste nachlesbar, statt überschrieben zu
|
||||||
|
werden.
|
||||||
|
|
||||||
|
Übernommen werden Titel, Ablageziel und alle Metadaten des alten Jobs
|
||||||
|
(Jahr/Poster, Titel-Auswahl, Sprachwunsch, Arbeitsverzeichnis, gewählter
|
||||||
|
Encoder-Worker) — der Nutzer soll seine Wahl nicht neu treffen müssen. Die
|
||||||
|
Phasen-Marke wird NICHT übernommen; die setzt der Rip selbst.
|
||||||
|
"""
|
||||||
|
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"] in ("running", "pending", "transcoding", "canceling"):
|
||||||
|
raise HTTPException(status_code=409, detail="Dieser Job läuft noch")
|
||||||
|
|
||||||
|
device_path = job.get("device")
|
||||||
|
vorhandene = device_discovery.list_optical_devices()
|
||||||
|
if not device_path or device_path not in vorhandene:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail=(
|
||||||
|
f"Das Laufwerk dieses Jobs ({device_path or 'unbekannt'}) ist nicht "
|
||||||
|
'mehr da. Disc in ein vorhandenes Laufwerk legen und den Rip über '
|
||||||
|
'„Rip starten" neu anstoßen.'
|
||||||
|
),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
status = await asyncio.to_thread(drive_status, device_path)
|
||||||
|
except OSError as e:
|
||||||
|
raise HTTPException(status_code=409, detail=f"Laufwerk {device_path} antwortet nicht: {e}")
|
||||||
|
if status != CDS_DISC_OK:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail=(
|
||||||
|
f'Es liegt keine (lesbare) Disc in {device_path}. Ein neuer Rip '
|
||||||
|
'braucht die Disc — sie wurde nach dem Fehlschlag vermutlich '
|
||||||
|
'ausgeworfen. Disc einlegen, dann noch einmal.'
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
meta = json.loads(job.get("meta") or "{}")
|
||||||
|
except ValueError:
|
||||||
|
meta = {}
|
||||||
|
if not isinstance(meta, dict):
|
||||||
|
meta = {}
|
||||||
|
meta.pop(phasen.RIP_FERTIG, None)
|
||||||
|
meta_json = json.dumps(meta) if meta else None
|
||||||
|
|
||||||
|
neue_id = str(uuid.uuid4())
|
||||||
|
ziel = job.get("target_dir")
|
||||||
|
await asyncio.to_thread(
|
||||||
|
db.insert_job, neue_id, device_path, job.get("disc_type"),
|
||||||
|
job.get("title"), ziel, meta_json,
|
||||||
|
)
|
||||||
|
await asyncio.to_thread(
|
||||||
|
db.add_log, "info", "api",
|
||||||
|
f"Job {neue_id} ist der Neu-Rip von {job_id} "
|
||||||
|
f"({job.get('title') or 'ohne Titel'}) auf {device_path}. "
|
||||||
|
"Das unvollständige Rohmaterial des alten Jobs bleibt liegen — es kann "
|
||||||
|
"über den Papierkorb am alten Job mitgelöscht werden.",
|
||||||
|
)
|
||||||
|
start_rip(device_path, neue_id, ziel)
|
||||||
|
return {"id": neue_id, "status": "pending", "device": device_path, "vorher": job_id}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/jobs/{job_id}/cancel")
|
@app.post("/jobs/{job_id}/cancel")
|
||||||
async def cancel_job(job_id: str):
|
async def cancel_job(job_id: str):
|
||||||
"""Bittet den Worker, den Job abzubrechen (kooperativ über die DB).
|
"""Bittet den Worker, den Job abzubrechen (kooperativ über die DB).
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"""In welcher Phase ist ein Job gestorben — und was hilft danach wirklich?
|
||||||
|
|
||||||
|
Commander-Befund 26.07.2026: „Hier gibt es den Button ‚neu' aber WAS wird dann
|
||||||
|
gemacht? Komprimierung? Neu Gerippt? Das muss ja je nach fehlgeschlagenem Job
|
||||||
|
eine Option anbieten."
|
||||||
|
|
||||||
|
Die Frage war berechtigt und der Knopf war falsch: Er hieß nur „Neu" und rief
|
||||||
|
immer `POST /jobs/{id}/retry-transcode` — also immer die KOMPRESSION. Für einen
|
||||||
|
abgebrochenen Transcode ist das goldrichtig (die Roh-MKV liegt vollständig da,
|
||||||
|
man spart eine Stunde Rippen). Für einen abgebrochenen RIP ist es Unsinn: Am
|
||||||
|
26.07.2026 lagen 5,1 GB von rund 40 GB da, und „Neu" hätte daraus brav einen
|
||||||
|
Film komprimiert, der bei 12 % aufhört.
|
||||||
|
|
||||||
|
## Warum es dafür eine MARKE braucht
|
||||||
|
|
||||||
|
Sobald `status = "failed"` steht, ist der vorherige Zustand fort — die Spalte
|
||||||
|
hat nur einen Wert. `zombies.war_im_rip()` im Worker kann die Phase noch sehen,
|
||||||
|
weil sie dort im Moment des Aufräumens vorliegt; die API sieht später nur noch
|
||||||
|
„failed". Deshalb schreibt der Worker die Phase in die Job-Metadaten:
|
||||||
|
|
||||||
|
* Rip-Start → `rip_fertig = false`
|
||||||
|
* Rip fertig (Übergabe an die Kompression) → `rip_fertig = true`
|
||||||
|
* Start eines Transcodes → `rip_fertig = true` (heilt Bestandsjobs mit)
|
||||||
|
|
||||||
|
## Drei Antworten, nicht zwei
|
||||||
|
|
||||||
|
Die Marke FEHLT bei jedem Job, der vor dieser Änderung lief. Diese Jobs
|
||||||
|
„rip" zu nennen wäre geraten — und würde dem Commander an einem
|
||||||
|
Bestandsjob mit 79,6 GB intakter Rohdaten das „Neu komprimieren" wegnehmen,
|
||||||
|
das dort genau richtig ist. Wer die Phase nicht kennt, sagt das: `unklar`
|
||||||
|
lässt das UI beide Wege anbieten, jeden mit einem ehrlichen Satz.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
# Schlüssel in den Job-Metadaten. Muss identisch in worker/tasks.py stehen —
|
||||||
|
# es gibt kein geteiltes Paket zwischen den Containern.
|
||||||
|
RIP_FERTIG = "rip_fertig"
|
||||||
|
|
||||||
|
# Die drei möglichen Antworten.
|
||||||
|
NEU_KOMPRIMIEREN = "transcode"
|
||||||
|
NEU_RIPPEN = "rip"
|
||||||
|
UNKLAR = "unklar"
|
||||||
|
NICHTS = ""
|
||||||
|
|
||||||
|
|
||||||
|
def _meta(meta_json) -> dict:
|
||||||
|
"""Metadaten lesen, ohne an kaputtem JSON zu scheitern."""
|
||||||
|
if isinstance(meta_json, dict):
|
||||||
|
return meta_json
|
||||||
|
if not meta_json:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
gelesen = json.loads(meta_json)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return {}
|
||||||
|
return gelesen if isinstance(gelesen, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def retry_art(job) -> str:
|
||||||
|
"""Was soll der Knopf an DIESEM Job anbieten?
|
||||||
|
|
||||||
|
* `NICHTS` — der Job ist nicht fehlgeschlagen, es gibt nichts zu
|
||||||
|
wiederholen.
|
||||||
|
* `NEU_KOMPRIMIEREN`— der Rip war fertig, nur die Kompression starb.
|
||||||
|
* `NEU_RIPPEN` — der Rip selbst starb; alles Rohe ist ein Bruchstück.
|
||||||
|
* `UNKLAR` — kein Vermerk (Bestandsjob), beide Wege anbieten.
|
||||||
|
"""
|
||||||
|
if (job.get("status") or "") != "failed":
|
||||||
|
return NICHTS
|
||||||
|
marke = _meta(job.get("meta")).get(RIP_FERTIG)
|
||||||
|
if marke is True:
|
||||||
|
return NEU_KOMPRIMIEREN
|
||||||
|
if marke is False:
|
||||||
|
return NEU_RIPPEN
|
||||||
|
return UNKLAR
|
||||||
+92
-7
@@ -2,34 +2,119 @@
|
|||||||
|
|
||||||
Der API-Key-Store, der hier früher lebte, ist mit dem Auth-Rückbau
|
Der API-Key-Store, der hier früher lebte, ist mit dem Auth-Rückbau
|
||||||
(Commander-Entscheid 24.07.2026) entfernt — Heimnetz-only, siehe KONZEPT §10.
|
(Commander-Entscheid 24.07.2026) entfernt — Heimnetz-only, siehe KONZEPT §10.
|
||||||
|
|
||||||
|
## Was diese Bremse ist — und was nicht
|
||||||
|
|
||||||
|
Sie soll ein Amok-Skript stoppen (Endlosschleife, tausend Anfragen pro Sekunde).
|
||||||
|
Sie ist KEIN Schutz gegen Angreifer; dafür wäre Rippy die falsche Stelle.
|
||||||
|
|
||||||
|
Daraus folgt die Zahl unten: Sie muss deutlich über dem liegen, was Rippy im
|
||||||
|
Normalbetrieb selbst verursacht. Am 26.07.2026 tat sie das nicht — und die
|
||||||
|
Folgen waren dem Commander als „wird oft neu geladen" aufgefallen.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import ipaddress
|
||||||
import time
|
import time
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from typing import Dict
|
from typing import Dict
|
||||||
|
|
||||||
# Default Rate Limit
|
# Wie viele Anfragen pro Minute und Client durchgehen.
|
||||||
MAX_REQUESTS_PER_MINUTE = 100
|
#
|
||||||
|
# ⚠️ Bis zum 26.07.2026 stand hier 100 — WENIGER, als das eigene Dashboard
|
||||||
|
# braucht. Nachgerechnet an den tatsächlichen Taktgebern im UI:
|
||||||
|
#
|
||||||
|
# Dashboard (Dashboard.tsx) 5 Endpunkte alle 4 s → 75/min
|
||||||
|
# Log-Kasten (LiveLogSection.tsx) 2 Endpunkte alle 5 s → 24/min
|
||||||
|
# Laufwerke (DeviceDiscovery.tsx) 1 Endpunkt alle 5 s → 12/min
|
||||||
|
# Windows-Tray (tray.py) /jobs alle 5 s → 12/min je Worker
|
||||||
|
# ─────────
|
||||||
|
# EIN offener Tab plus ein Worker 123/min
|
||||||
|
#
|
||||||
|
# Das Limit war also im Normalbetrieb um ein Viertel überschritten: Etwa jede
|
||||||
|
# vierte Anfrage bekam 429, und weil das UI einen Fehlschlag damals als „es gibt
|
||||||
|
# keine Jobs" verbuchte, leerte sich die Liste im Sekundentakt. Zwei offene Tabs
|
||||||
|
# hätten es verdoppelt.
|
||||||
|
#
|
||||||
|
# 600/min = 10 Anfragen pro Sekunde: Platz für mehrere Tabs und Worker, während
|
||||||
|
# eine Endlosschleife (Hunderte pro Sekunde) weiterhin sofort gebremst wird.
|
||||||
|
MAX_REQUESTS_PER_MINUTE = 600
|
||||||
|
|
||||||
# In-Memory Rate Limit Store (in Produktion mit Redis)
|
# In-Memory Rate Limit Store (in Produktion mit Redis)
|
||||||
rate_limit_store: Dict[str, list] = defaultdict(list)
|
rate_limit_store: Dict[str, list] = defaultdict(list)
|
||||||
|
|
||||||
|
|
||||||
|
def _ist_privat(adresse: str) -> bool:
|
||||||
|
"""Steckt hinter dieser Adresse das eigene Netz (bzw. ein Container)?"""
|
||||||
|
try:
|
||||||
|
ip = ipaddress.ip_address(adresse)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
return ip.is_private or ip.is_loopback
|
||||||
|
|
||||||
|
|
||||||
|
def client_kennung(peer: str, weitergegeben: str = None) -> str:
|
||||||
|
"""Welcher Eimer gilt für diese Anfrage? (pure Funktion, testbar)
|
||||||
|
|
||||||
|
`peer` ist der direkte Absender, `weitergegeben` der Inhalt von
|
||||||
|
`X-Real-IP` (setzt der nginx im UI-Container, siehe ui/nginx.conf).
|
||||||
|
|
||||||
|
Warum überhaupt: Der Browser spricht nie direkt mit der API, sondern über
|
||||||
|
den nginx — für die API sah deshalb JEDE Anfahrt aus dem UI gleich aus. Ein
|
||||||
|
zweiter Tab und der Windows-Tray teilten sich den Eimer mit dem Dashboard,
|
||||||
|
obwohl es drei unabhängige Clients sind.
|
||||||
|
|
||||||
|
Der Kopfzeile wird nur geglaubt, wenn der direkte Absender aus dem privaten
|
||||||
|
Netz kommt — also unser eigener Proxy. Das ist keine Härtung gegen
|
||||||
|
Angreifer (Rippy ist Heimnetz-only, KONZEPT §10), sondern verhindert, dass
|
||||||
|
eine beliebige Kopfzeile die Bremse aushebelt.
|
||||||
|
"""
|
||||||
|
peer = (peer or "").strip()
|
||||||
|
kandidat = (weitergegeben or "").split(",")[0].strip()
|
||||||
|
if kandidat and _ist_privat(peer) and _ist_privat(kandidat):
|
||||||
|
return kandidat
|
||||||
|
return peer or "unbekannt"
|
||||||
|
|
||||||
|
|
||||||
|
# Wann wurde für einen Client zuletzt eine Rate-Limit-Meldung geschrieben?
|
||||||
|
_letzte_meldung: Dict[str, float] = {}
|
||||||
|
|
||||||
|
# Abstand zwischen zwei Meldungen pro Client.
|
||||||
|
MELDE_ABSTAND_SEKUNDEN = 60
|
||||||
|
|
||||||
|
|
||||||
|
def darf_melden(client_id: str, jetzt: float = None) -> bool:
|
||||||
|
"""Soll dieser abgewiesene Aufruf ins Log? Höchstens einmal pro Minute.
|
||||||
|
|
||||||
|
Ein 429 war bisher völlig unsichtbar — das UI verbuchte ihn als „nichts
|
||||||
|
da", und niemand erfuhr, dass die Bremse greift. Jede Abweisung zu
|
||||||
|
protokollieren wäre aber die Ecke ins Gegenteil: Genau der Fall, für den die
|
||||||
|
Bremse gebaut ist (ein Skript in einer Endlosschleife), würde damit das
|
||||||
|
Log-Fenster zumüllen. Also: eine Meldung pro Client und Minute.
|
||||||
|
"""
|
||||||
|
if jetzt is None:
|
||||||
|
jetzt = time.time()
|
||||||
|
vorher = _letzte_meldung.get(client_id, 0.0)
|
||||||
|
if jetzt - vorher < MELDE_ABSTAND_SEKUNDEN:
|
||||||
|
return False
|
||||||
|
_letzte_meldung[client_id] = jetzt
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def check_rate_limit(client_id: str, max_requests: int = MAX_REQUESTS_PER_MINUTE, window_seconds: int = 60) -> bool:
|
def check_rate_limit(client_id: str, max_requests: int = MAX_REQUESTS_PER_MINUTE, window_seconds: int = 60) -> bool:
|
||||||
"""Prüfe ob Client rate-limited ist."""
|
"""Prüfe ob Client rate-limited ist."""
|
||||||
current_time = time.time()
|
current_time = time.time()
|
||||||
window_start = current_time - window_seconds
|
window_start = current_time - window_seconds
|
||||||
|
|
||||||
# Bereinige alte Einträge
|
# Bereinige alte Einträge
|
||||||
rate_limit_store[client_id] = [
|
rate_limit_store[client_id] = [
|
||||||
timestamp for timestamp in rate_limit_store[client_id]
|
timestamp for timestamp in rate_limit_store[client_id]
|
||||||
if timestamp > window_start
|
if timestamp > window_start
|
||||||
]
|
]
|
||||||
|
|
||||||
# Prüfe ob Limit erreicht
|
# Prüfe ob Limit erreicht
|
||||||
if len(rate_limit_store[client_id]) >= max_requests:
|
if len(rate_limit_store[client_id]) >= max_requests:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Füge neuen Request hinzu
|
# Füge neuen Request hinzu
|
||||||
rate_limit_store[client_id].append(current_time)
|
rate_limit_store[client_id].append(current_time)
|
||||||
return True
|
return True
|
||||||
@@ -39,12 +124,12 @@ def get_rate_limit_remaining(client_id: str, max_requests: int = MAX_REQUESTS_PE
|
|||||||
"""Hole verbleibende Requests."""
|
"""Hole verbleibende Requests."""
|
||||||
current_time = time.time()
|
current_time = time.time()
|
||||||
window_start = current_time - 60
|
window_start = current_time - 60
|
||||||
|
|
||||||
current_count = len([
|
current_count = len([
|
||||||
timestamp for timestamp in rate_limit_store[client_id]
|
timestamp for timestamp in rate_limit_store[client_id]
|
||||||
if timestamp > window_start
|
if timestamp > window_start
|
||||||
])
|
])
|
||||||
|
|
||||||
return max(0, max_requests - current_count)
|
return max(0, max_requests - current_count)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"""Tests für die Phasen-Erkennung — was darf der „Neu"-Knopf anbieten?"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
import phasen
|
||||||
|
|
||||||
|
|
||||||
|
def test_laufender_job_bietet_nichts():
|
||||||
|
assert phasen.retry_art({"status": "running"}) == phasen.NICHTS
|
||||||
|
assert phasen.retry_art({"status": "completed"}) == phasen.NICHTS
|
||||||
|
|
||||||
|
|
||||||
|
def test_toter_transcode_bietet_komprimieren():
|
||||||
|
job = {"status": "failed", "meta": json.dumps({"rip_fertig": True})}
|
||||||
|
assert phasen.retry_art(job) == phasen.NEU_KOMPRIMIEREN
|
||||||
|
|
||||||
|
|
||||||
|
def test_toter_rip_bietet_rippen():
|
||||||
|
"""Der Vorfall vom 26.07.2026: 5,1 GB von 40 GB. Komprimieren wäre falsch."""
|
||||||
|
job = {"status": "failed", "meta": json.dumps({"rip_fertig": False, "year": 1988})}
|
||||||
|
assert phasen.retry_art(job) == phasen.NEU_RIPPEN
|
||||||
|
|
||||||
|
|
||||||
|
def test_bestandsjob_ohne_marke_ist_unklar():
|
||||||
|
"""Jobs von VOR dieser Änderung dürfen nicht geraten werden."""
|
||||||
|
assert phasen.retry_art({"status": "failed", "meta": None}) == phasen.UNKLAR
|
||||||
|
assert phasen.retry_art({"status": "failed"}) == phasen.UNKLAR
|
||||||
|
assert phasen.retry_art(
|
||||||
|
{"status": "failed", "meta": json.dumps({"year": 1988})}
|
||||||
|
) == phasen.UNKLAR
|
||||||
|
|
||||||
|
|
||||||
|
def test_kaputtes_json_ist_unklar_statt_absturz():
|
||||||
|
"""/jobs darf an einer krummen meta-Zeile nicht scheitern."""
|
||||||
|
assert phasen.retry_art({"status": "failed", "meta": "{kein json"}) == phasen.UNKLAR
|
||||||
|
assert phasen.retry_art({"status": "failed", "meta": "[1,2]"}) == phasen.UNKLAR
|
||||||
|
assert phasen.retry_art({"status": "failed", "meta": 7}) == phasen.UNKLAR
|
||||||
|
|
||||||
|
|
||||||
|
def test_marke_wird_auch_als_dict_gelesen():
|
||||||
|
"""Der Detail-Endpunkt hat die Metadaten schon geparst."""
|
||||||
|
job = {"status": "failed", "meta": {"rip_fertig": True}}
|
||||||
|
assert phasen.retry_art(job) == phasen.NEU_KOMPRIMIEREN
|
||||||
|
|
||||||
|
|
||||||
|
def test_der_schluessel_heisst_im_worker_genauso():
|
||||||
|
"""Zwei Container, kein geteiltes Paket — die Marke muss zusammenpassen.
|
||||||
|
|
||||||
|
Ohne diese Prüfung ist ein Tippfehler in einer der beiden Dateien lautlos:
|
||||||
|
Der Worker schreibt `rip_fertig`, die API liest `ripFertig`, und JEDER Job
|
||||||
|
wäre für immer „unklar". Genau diese Sorte Auseinanderdriften hat die
|
||||||
|
Zombie-Erkennung ein Release lang blind gemacht („running" vs. „ripping").
|
||||||
|
"""
|
||||||
|
import pathlib
|
||||||
|
import re
|
||||||
|
|
||||||
|
quelle = (
|
||||||
|
pathlib.Path(__file__).resolve().parents[1] / "worker" / "tasks.py"
|
||||||
|
).read_text(encoding="utf-8")
|
||||||
|
# Der Worker schreibt die Marke über eine Konstante — deren Wert muss hier
|
||||||
|
# ankommen.
|
||||||
|
treffer = re.search(r'^RIP_FERTIG\s*=\s*"([^"]+)"', quelle, re.MULTILINE)
|
||||||
|
assert treffer, "worker/tasks.py definiert RIP_FERTIG nicht mehr"
|
||||||
|
assert treffer.group(1) == phasen.RIP_FERTIG
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
"""Tests für die Anfrage-Bremse.
|
||||||
|
|
||||||
|
Sie hatte bis zum 26.07.2026 keine — und dabei war sie seit Wochen die Ursache
|
||||||
|
eines gemeldeten Fehlers („dieser Bereich wird oft neu geladen"): Das Limit lag
|
||||||
|
mit 100/min UNTER dem, was Rippys eigenes Dashboard verursacht, und weil hinter
|
||||||
|
dem nginx alle Clients dieselbe Adresse hatten, galt es auch noch gemeinsam.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import ratelimit
|
||||||
|
|
||||||
|
|
||||||
|
def test_hinter_dem_proxy_zaehlt_der_echte_client():
|
||||||
|
"""Der ganze Grund für den Fehler: ein Eimer für alle."""
|
||||||
|
assert ratelimit.client_kennung("172.19.0.6", "192.168.178.20") == "192.168.178.20"
|
||||||
|
assert ratelimit.client_kennung("172.19.0.6", "192.168.178.99") == "192.168.178.99"
|
||||||
|
# Zwei Clients hinter demselben Proxy sind zwei Eimer.
|
||||||
|
assert (
|
||||||
|
ratelimit.client_kennung("172.19.0.6", "192.168.178.20")
|
||||||
|
!= ratelimit.client_kennung("172.19.0.6", "192.168.178.99")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ohne_kopfzeile_gilt_der_direkte_absender():
|
||||||
|
assert ratelimit.client_kennung("192.168.178.20", None) == "192.168.178.20"
|
||||||
|
assert ratelimit.client_kennung("192.168.178.20", "") == "192.168.178.20"
|
||||||
|
|
||||||
|
|
||||||
|
def test_erste_adresse_der_kette_gewinnt():
|
||||||
|
"""X-Forwarded-For kann eine Liste sein — der Ursprung steht vorn."""
|
||||||
|
assert ratelimit.client_kennung(
|
||||||
|
"172.19.0.6", "192.168.178.20, 172.19.0.6"
|
||||||
|
) == "192.168.178.20"
|
||||||
|
|
||||||
|
|
||||||
|
def test_muell_in_der_kopfzeile_wird_ignoriert():
|
||||||
|
"""Sonst könnte ein Tippfehler beliebig viele Eimer aufmachen."""
|
||||||
|
assert ratelimit.client_kennung("172.19.0.6", "nicht-ip") == "172.19.0.6"
|
||||||
|
assert ratelimit.client_kennung("172.19.0.6", "<script>") == "172.19.0.6"
|
||||||
|
|
||||||
|
|
||||||
|
def test_oeffentliche_adresse_in_der_kopfzeile_wird_nicht_geglaubt():
|
||||||
|
"""Heimnetz-only: Was nicht aus dem privaten Netz kommt, zählt nicht.
|
||||||
|
|
||||||
|
Keine Härtung gegen Angreifer (dafür ist Rippy die falsche Stelle), aber die
|
||||||
|
Bremse soll sich nicht mit einer beliebigen Kopfzeile aushebeln lassen.
|
||||||
|
"""
|
||||||
|
assert ratelimit.client_kennung("172.19.0.6", "8.8.8.8") == "172.19.0.6"
|
||||||
|
|
||||||
|
|
||||||
|
def test_ohne_jede_angabe_bleibt_ein_eimer_uebrig():
|
||||||
|
assert ratelimit.client_kennung("", None) == "unbekannt"
|
||||||
|
|
||||||
|
|
||||||
|
def test_limit_greift_erst_nach_der_grenze():
|
||||||
|
ratelimit.reset_rate_limit("test-a")
|
||||||
|
for i in range(10):
|
||||||
|
assert ratelimit.check_rate_limit("test-a", max_requests=10), f"Anfrage {i}"
|
||||||
|
assert not ratelimit.check_rate_limit("test-a", max_requests=10)
|
||||||
|
|
||||||
|
|
||||||
|
def test_eimer_sind_getrennt():
|
||||||
|
ratelimit.reset_rate_limit("test-b")
|
||||||
|
ratelimit.reset_rate_limit("test-c")
|
||||||
|
for _ in range(5):
|
||||||
|
ratelimit.check_rate_limit("test-b", max_requests=5)
|
||||||
|
assert not ratelimit.check_rate_limit("test-b", max_requests=5)
|
||||||
|
assert ratelimit.check_rate_limit("test-c", max_requests=5)
|
||||||
|
|
||||||
|
|
||||||
|
def test_grenze_deckt_die_eigene_last_ab():
|
||||||
|
"""Die Bremse darf Rippy nicht selbst ausbremsen.
|
||||||
|
|
||||||
|
Nachgerechnet aus den Taktgebern im UI (Zahlen im Kopf von ratelimit.py):
|
||||||
|
Dashboard 75/min + Log-Kasten 24/min + Laufwerke 12/min + Tray 12/min je
|
||||||
|
Worker = 123/min für EINEN Tab. Mit 100 war das garantiert rot. Diese Prüfung
|
||||||
|
hält fest, dass die Grenze mit Luft darüber liegt — wer den Wert senkt, muss
|
||||||
|
hier vorbei.
|
||||||
|
"""
|
||||||
|
eigene_last_pro_tab = 75 + 24 + 12
|
||||||
|
tray_pro_worker = 12
|
||||||
|
assert ratelimit.MAX_REQUESTS_PER_MINUTE >= 2 * eigene_last_pro_tab + 3 * tray_pro_worker
|
||||||
|
|
||||||
|
|
||||||
|
def test_meldung_wird_gedrosselt():
|
||||||
|
"""Ein Amok-Skript darf nicht das Log-Fenster fluten."""
|
||||||
|
ratelimit._letzte_meldung.pop("test-d", None)
|
||||||
|
assert ratelimit.darf_melden("test-d", jetzt=1000.0)
|
||||||
|
assert not ratelimit.darf_melden("test-d", jetzt=1001.0)
|
||||||
|
assert not ratelimit.darf_melden("test-d", jetzt=1059.0)
|
||||||
|
assert ratelimit.darf_melden("test-d", jetzt=1061.0)
|
||||||
@@ -14,6 +14,15 @@ server {
|
|||||||
proxy_pass http://api:8000/;
|
proxy_pass http://api:8000/;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
|
# ⚠️ OHNE DIESE ZEILEN TEILEN SICH ALLE CLIENTS EIN RATE-LIMIT
|
||||||
|
#
|
||||||
|
# Die API begrenzt Anfragen pro Client-IP (ratelimit.py). Ohne
|
||||||
|
# weitergegebene Adresse sah sie als Absender immer DIESEN Container —
|
||||||
|
# Browser, zweiter Tab und der Windows-Tray landeten also in einem
|
||||||
|
# gemeinsamen Eimer. Gemessen am 26.07.2026: 812 von 876 Anfragen kamen
|
||||||
|
# scheinbar von 172.19.0.6, und das UI bekam laufend HTTP 429.
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
# SSE (/api/stream/jobs) braucht ungepufferte, lange Verbindungen:
|
# SSE (/api/stream/jobs) braucht ungepufferte, lange Verbindungen:
|
||||||
proxy_buffering off;
|
proxy_buffering off;
|
||||||
proxy_read_timeout 3600s;
|
proxy_read_timeout 3600s;
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import { AlertTriangle, HelpCircle, RotateCcw, Disc } from 'lucide-react'
|
||||||
|
import { Modal } from './ui/Modal'
|
||||||
|
import { Button } from './ui/Button'
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Was wird bei „Neu" eigentlich wiederholt?
|
||||||
|
*
|
||||||
|
* Commander-Frage 26.07.2026: „Hier gibt es den Button ‚neu' aber WAS wird dann
|
||||||
|
* gemacht? Komprimierung? Neu Gerippt? Das muss ja je nach fehlgeschlagenem Job
|
||||||
|
* eine Option anbieten." Bis dahin hieß der Knopf nur „Neu" und rief immer die
|
||||||
|
* Kompression — auch bei einem Job, dessen RIP abgebrochen war. Aus 5,1 GB
|
||||||
|
* Bruchstück (von rund 40 GB) wäre brav ein Film geworden, der bei 12 % endet.
|
||||||
|
*
|
||||||
|
* Dieser Dialog nennt beim Namen, was passiert, und in welchen Punkten sich die
|
||||||
|
* beiden Wege unterscheiden: Braucht es die Disc? Wie lange dauert es? Was
|
||||||
|
* passiert mit dem, was schon auf der Platte liegt?
|
||||||
|
*
|
||||||
|
* Die Phase kommt aus api/phasen.py. Kennt Rippy sie nicht (Jobs von vor der
|
||||||
|
* Einführung der Marke), wird nicht geraten — dann stehen beide Wege da, und
|
||||||
|
* die Größe der Rohdaten ist die Entscheidungshilfe: Wer ungefähr eine
|
||||||
|
* Disc-Größe daliegen sieht, hatte einen fertigen Rip.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface RetryDialogProps {
|
||||||
|
offen: boolean
|
||||||
|
titel: string
|
||||||
|
plan: 'transcode' | 'rip' | 'unklar'
|
||||||
|
rohdaten: { gb: number, dateien: number, pfade: string[] } | null
|
||||||
|
onClose: () => void
|
||||||
|
onStart: (art: 'transcode' | 'rip') => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function Rohdaten({ rohdaten }: { rohdaten: RetryDialogProps['rohdaten'] }) {
|
||||||
|
if (!rohdaten) return null
|
||||||
|
if (!rohdaten.gb) {
|
||||||
|
return (
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
Auf der Platte liegt zu diesem Job nichts (mehr) — neu rippen ist damit
|
||||||
|
der einzige Weg.
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
Auf der Platte liegen <strong>{rohdaten.gb} GB</strong> Rohdaten
|
||||||
|
{rohdaten.dateien > 0 ? ` in ${rohdaten.dateien} Datei${rohdaten.dateien === 1 ? '' : 'en'}` : ''}.
|
||||||
|
{' '}Eine Blu-ray bringt roh etwa 25–45 GB mit, eine 4K-UHD 50–100 GB, eine
|
||||||
|
DVD 4–8 GB. Passt die Zahl dazu, war der Rip fertig.
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RetryDialog({
|
||||||
|
offen, titel, plan, rohdaten, onClose, onStart,
|
||||||
|
}: RetryDialogProps) {
|
||||||
|
const name = titel || 'Dieser Job'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal isOpen={offen} onClose={onClose} maxWidth="lg">
|
||||||
|
{plan === 'transcode' && (
|
||||||
|
<>
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<RotateCcw className="text-indigo-500 w-8 h-8 flex-shrink-0" />
|
||||||
|
<div className="space-y-2 flex-1">
|
||||||
|
<h2 className="text-lg font-bold text-slate-900 dark:text-slate-100">
|
||||||
|
„{name}" neu komprimieren?
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm leading-relaxed text-slate-600 dark:text-slate-300">
|
||||||
|
Der Rip war fertig — abgebrochen ist erst die Kompression. Rippy
|
||||||
|
nimmt die vorhandene Roh-Datei und komprimiert sie noch einmal.
|
||||||
|
<strong> Die Disc wird nicht gebraucht</strong>, das Laufwerk
|
||||||
|
bleibt frei, und die Stunde fürs Rippen fällt nicht erneut an.
|
||||||
|
</p>
|
||||||
|
<Rohdaten rohdaten={rohdaten} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-6 flex flex-wrap justify-end gap-3 border-t border-slate-200 dark:border-slate-800 pt-4">
|
||||||
|
<Button variant="secondary" onClick={onClose}>Abbrechen</Button>
|
||||||
|
<Button variant="ghost" onClick={() => onStart('rip')}>
|
||||||
|
<Disc size={14} /> Doch von der Disc neu rippen
|
||||||
|
</Button>
|
||||||
|
<Button variant="primary" onClick={() => onStart('transcode')}>
|
||||||
|
<RotateCcw size={14} /> Neu komprimieren
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{plan === 'rip' && (
|
||||||
|
<>
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<AlertTriangle className="text-amber-500 w-8 h-8 flex-shrink-0" />
|
||||||
|
<div className="space-y-2 flex-1">
|
||||||
|
<h2 className="text-lg font-bold text-slate-900 dark:text-slate-100">
|
||||||
|
„{name}" neu rippen?
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm leading-relaxed text-slate-600 dark:text-slate-300">
|
||||||
|
Hier ist der <strong>Rip selbst</strong> abgebrochen. Was davon
|
||||||
|
auf der Platte liegt, ist ein Bruchstück — komprimieren würde
|
||||||
|
daraus einen Film machen, der mitten drin aufhört. Rippy liest
|
||||||
|
die Disc deshalb von vorn.
|
||||||
|
</p>
|
||||||
|
<p className="text-sm leading-relaxed text-slate-600 dark:text-slate-300">
|
||||||
|
<strong>Die Disc muss dafür im Laufwerk liegen.</strong> Nach dem
|
||||||
|
Fehlschlag wurde sie möglicherweise ausgeworfen. Titel, Ablage,
|
||||||
|
Sprachwahl und Encoder-Worker werden vom alten Job übernommen.
|
||||||
|
</p>
|
||||||
|
<Rohdaten rohdaten={rohdaten} />
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
Der alte Eintrag bleibt zum Nachlesen stehen; das Bruchstück
|
||||||
|
lässt sich über den Papierkorb daran mitlöschen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-6 flex justify-end gap-3 border-t border-slate-200 dark:border-slate-800 pt-4">
|
||||||
|
<Button variant="secondary" onClick={onClose}>Abbrechen</Button>
|
||||||
|
<Button variant="amber" onClick={() => onStart('rip')}>
|
||||||
|
<Disc size={14} /> Neu rippen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{plan === 'unklar' && (
|
||||||
|
<>
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<HelpCircle className="text-indigo-500 w-8 h-8 flex-shrink-0" />
|
||||||
|
<div className="space-y-2 flex-1">
|
||||||
|
<h2 className="text-lg font-bold text-slate-900 dark:text-slate-100">
|
||||||
|
„{name}": was soll wiederholt werden?
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm leading-relaxed text-slate-600 dark:text-slate-300">
|
||||||
|
Bei diesem Job kann Rippy nicht mehr feststellen, ob der Rip
|
||||||
|
fertig war oder mitten drin abbrach — er ist älter als der
|
||||||
|
Vermerk dafür. Deshalb wird hier nicht geraten:
|
||||||
|
</p>
|
||||||
|
<ul className="text-sm leading-relaxed text-slate-600 dark:text-slate-300 space-y-1 list-disc pl-5">
|
||||||
|
<li>
|
||||||
|
<strong>Neu komprimieren</strong> — schnell, braucht die Disc
|
||||||
|
nicht. Richtig, wenn der Rip durchlief.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Neu rippen</strong> — dauert wieder eine Weile und
|
||||||
|
braucht die Disc im Laufwerk. Immer richtig, nur teurer.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<Rohdaten rohdaten={rohdaten} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-6 flex flex-wrap justify-end gap-3 border-t border-slate-200 dark:border-slate-800 pt-4">
|
||||||
|
<Button variant="secondary" onClick={onClose}>Abbrechen</Button>
|
||||||
|
{!!rohdaten?.gb && (
|
||||||
|
<Button variant="primary" onClick={() => onStart('transcode')}>
|
||||||
|
<RotateCcw size={14} /> Neu komprimieren
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button variant="amber" onClick={() => onStart('rip')}>
|
||||||
|
<Disc size={14} /> Neu rippen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -4,4 +4,9 @@ import axios from 'axios'
|
|||||||
// den api-Container weiter. Vorher stand in vier Dateien http://localhost:8000
|
// den api-Container weiter. Vorher stand in vier Dateien http://localhost:8000
|
||||||
// hartkodiert: vom PC aus fragte der Browser damit den PC SELBST nach
|
// hartkodiert: vom PC aus fragte der Browser damit den PC SELBST nach
|
||||||
// Laufwerken und Jobs — deshalb blieb das UI immer leer.
|
// Laufwerken und Jobs — deshalb blieb das UI immer leer.
|
||||||
export const api = axios.create({ baseURL: '/api' })
|
// Zeitgrenze, damit ein hängender Aufruf nicht ewig offen bleibt und sich die
|
||||||
|
// Abfragen des 4-Sekunden-Takts stapeln (Befund 26.07.2026: axios wartet ohne
|
||||||
|
// `timeout` unbegrenzt). 20 s sind reichlich für jeden Endpunkt — die
|
||||||
|
// langsamsten sind der Update-Check und der Titel-Scan-Start, alles andere
|
||||||
|
// antwortet in Millisekunden.
|
||||||
|
export const api = axios.create({ baseURL: '/api', timeout: 20000 })
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import ConfirmDialog from '../components/ConfirmDialog'
|
|||||||
import DeviceDiscovery from '../components/DeviceDiscovery'
|
import DeviceDiscovery from '../components/DeviceDiscovery'
|
||||||
import JobDetailModal from '../components/JobDetailModal'
|
import JobDetailModal from '../components/JobDetailModal'
|
||||||
import LiveLogSection from '../components/LiveLogSection'
|
import LiveLogSection from '../components/LiveLogSection'
|
||||||
|
import RetryDialog from '../components/RetryDialog'
|
||||||
|
|
||||||
interface JobMeta {
|
interface JobMeta {
|
||||||
year?: number
|
year?: number
|
||||||
@@ -30,6 +31,8 @@ interface Job {
|
|||||||
progress: number
|
progress: number
|
||||||
title?: string
|
title?: string
|
||||||
can_retry?: boolean
|
can_retry?: boolean
|
||||||
|
// "transcode" | "rip" | "unklar" | "" — welche Wiederholung passt (phasen.py)
|
||||||
|
retry_art?: string
|
||||||
meta?: JobMeta | null
|
meta?: JobMeta | null
|
||||||
// Restzeit von der API (eta.py). '' = noch keine Aussage möglich — dann wird
|
// Restzeit von der API (eta.py). '' = noch keine Aussage möglich — dann wird
|
||||||
// bewusst nichts angezeigt statt einer erfundenen Zahl.
|
// bewusst nichts angezeigt statt einer erfundenen Zahl.
|
||||||
@@ -83,12 +86,26 @@ function posterUrl(meta?: JobMeta | null): string | null {
|
|||||||
return p.startsWith('http') ? p : `https://image.tmdb.org/t/p/w342${p}`
|
return p.startsWith('http') ? p : `https://image.tmdb.org/t/p/w342${p}`
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchJobs(): Promise<Job[]> {
|
/*
|
||||||
|
* Ein fehlgeschlagener Abruf gibt `null` — NICHT eine leere Liste.
|
||||||
|
*
|
||||||
|
* Commander-Befund 26.07.2026: „Wenn der Worker installiert ist, wird dieser
|
||||||
|
* Bereich oft neu geladen." Gemessen war es kein Neuladen: Die Antworten von
|
||||||
|
* /jobs und /capabilities waren über zwanzig Sekunden byteweise IDENTISCH, alle
|
||||||
|
* Endpunkte antworteten in unter 30 ms. Der Grund lag im Fehlerzweig — hier
|
||||||
|
* stand `return []`, und jeder einzelne verpasste Abruf setzte damit die
|
||||||
|
* Job-Liste auf LEER. Für einen Takt zeigte die Tabelle „Keine Jobs", die Zähler
|
||||||
|
* sprangen auf (0), vier Sekunden später war alles wieder da.
|
||||||
|
*
|
||||||
|
* Ein verpasster Abruf ist keine Nachricht über die Welt. Wer nichts Neues
|
||||||
|
* weiß, behält, was er wusste.
|
||||||
|
*/
|
||||||
|
async function fetchJobs(): Promise<Job[] | null> {
|
||||||
try {
|
try {
|
||||||
const response = await api.get('/jobs')
|
const response = await api.get('/jobs')
|
||||||
return response.data
|
return Array.isArray(response.data) ? response.data : null
|
||||||
} catch {
|
} catch {
|
||||||
return []
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,6 +119,9 @@ export default function Dashboard() {
|
|||||||
const [loeschKandidat, setLoeschKandidat] = useState<Job | null>(null)
|
const [loeschKandidat, setLoeschKandidat] = useState<Job | null>(null)
|
||||||
const [rohdaten, setRohdaten] = useState<{ gb: number, dateien: number, pfade: string[] } | null>(null)
|
const [rohdaten, setRohdaten] = useState<{ gb: number, dateien: number, pfade: string[] } | null>(null)
|
||||||
const [rohdatenMitloeschen, setRohdatenMitloeschen] = useState(false)
|
const [rohdatenMitloeschen, setRohdatenMitloeschen] = useState(false)
|
||||||
|
// Job, für den ein neuer Versuch bestätigt werden soll (siehe wiederholen()).
|
||||||
|
const [neuKandidat, setNeuKandidat] = useState<Job | null>(null)
|
||||||
|
const [neuRohdaten, setNeuRohdaten] = useState<{ gb: number, dateien: number, pfade: string[] } | null>(null)
|
||||||
const [activeTab, setActiveTab] = useState<'all' | 'active' | 'queue' | 'completed' | 'failed'>('all')
|
const [activeTab, setActiveTab] = useState<'all' | 'active' | 'queue' | 'completed' | 'failed'>('all')
|
||||||
const [workersLive, setWorkersLive] = useState<WorkerLive[]>([])
|
const [workersLive, setWorkersLive] = useState<WorkerLive[]>([])
|
||||||
const [laufwerke, setLaufwerke] = useState<LaufwerkLive[]>([])
|
const [laufwerke, setLaufwerke] = useState<LaufwerkLive[]>([])
|
||||||
@@ -130,6 +150,57 @@ export default function Dashboard() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Der „Neu"-Knopf sagt jetzt, WAS er tut.
|
||||||
|
*
|
||||||
|
* Commander-Frage 26.07.2026: „Hier gibt es den Button ‚neu' aber WAS wird
|
||||||
|
* dann gemacht? Komprimierung? Neu Gerippt?" — Antwort damals: immer
|
||||||
|
* Komprimierung, egal wie der Job gestorben war. Für einen abgebrochenen Rip
|
||||||
|
* war das falsch (5,1 GB Bruchstück von rund 40 GB).
|
||||||
|
*
|
||||||
|
* `job.retry_art` kommt aus api/phasen.py und kennt drei Fälle:
|
||||||
|
* 'transcode' → der Rip war fertig → „Neu komprimieren"
|
||||||
|
* 'rip' → der Rip starb → „Neu rippen" (Disc muss drin sein)
|
||||||
|
* 'unklar' → Bestandsjob → fragen, mit der Rohdaten-Größe als
|
||||||
|
* Entscheidungshilfe
|
||||||
|
*
|
||||||
|
* Sonderfall: Phase 'transcode', aber die Rohdaten sind fort (`can_retry`
|
||||||
|
* false) — dann hilft auch hier nur ein neuer Rip.
|
||||||
|
*/
|
||||||
|
const retryPlan = (job: Job): 'transcode' | 'rip' | 'unklar' => {
|
||||||
|
if (job.retry_art === 'transcode') return job.can_retry ? 'transcode' : 'rip'
|
||||||
|
if (job.retry_art === 'rip') return 'rip'
|
||||||
|
return 'unklar'
|
||||||
|
}
|
||||||
|
|
||||||
|
const wiederholen = async (job: Job) => {
|
||||||
|
setNeuKandidat(job)
|
||||||
|
setNeuRohdaten(null)
|
||||||
|
try {
|
||||||
|
const r = await api.get(`/jobs/${job.id}/rohdaten`)
|
||||||
|
setNeuRohdaten(r.data)
|
||||||
|
} catch {
|
||||||
|
setNeuRohdaten(null) // dann eben ohne Zahl fragen
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const neuStarten = async (art: 'transcode' | 'rip') => {
|
||||||
|
const job = neuKandidat
|
||||||
|
if (!job) return
|
||||||
|
setNeuKandidat(null)
|
||||||
|
const weg = art === 'rip' ? 'retry-rip' : 'retry-transcode'
|
||||||
|
try {
|
||||||
|
await api.post(`/jobs/${job.id}/${weg}`)
|
||||||
|
toast('success', art === 'rip'
|
||||||
|
? 'Neuer Rip gestartet — der alte Eintrag bleibt zum Nachlesen stehen'
|
||||||
|
: 'Kompression neu eingereiht — die Disc wird nicht gebraucht')
|
||||||
|
const frisch = await fetchJobs()
|
||||||
|
if (frisch) setJobs(frisch)
|
||||||
|
} catch (e: any) {
|
||||||
|
toast('error', e?.response?.data?.detail || 'Fehlgeschlagen')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const jobLoeschen = async () => {
|
const jobLoeschen = async () => {
|
||||||
const job = loeschKandidat
|
const job = loeschKandidat
|
||||||
if (!job) return
|
if (!job) return
|
||||||
@@ -141,7 +212,8 @@ export default function Dashboard() {
|
|||||||
const befreit = r.data?.rohdaten_geloescht_gb
|
const befreit = r.data?.rohdaten_geloescht_gb
|
||||||
toast('success', `„${job.title || job.id.slice(0, 8)}" aus der Liste entfernt`
|
toast('success', `„${job.title || job.id.slice(0, 8)}" aus der Liste entfernt`
|
||||||
+ (befreit ? ` — ${befreit} GB Rohdaten gelöscht` : ''))
|
+ (befreit ? ` — ${befreit} GB Rohdaten gelöscht` : ''))
|
||||||
setJobs(await fetchJobs())
|
const frisch = await fetchJobs()
|
||||||
|
if (frisch) setJobs(frisch)
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
toast('error', e?.response?.data?.detail || 'Entfernen fehlgeschlagen')
|
toast('error', e?.response?.data?.detail || 'Entfernen fehlgeschlagen')
|
||||||
}
|
}
|
||||||
@@ -152,43 +224,71 @@ export default function Dashboard() {
|
|||||||
try {
|
try {
|
||||||
const r = await api.delete('/jobs')
|
const r = await api.delete('/jobs')
|
||||||
toast('success', `${r.data.deleted} erledigte Jobs entfernt — Dateien bleiben liegen`)
|
toast('success', `${r.data.deleted} erledigte Jobs entfernt — Dateien bleiben liegen`)
|
||||||
setJobs(await fetchJobs())
|
const frisch = await fetchJobs()
|
||||||
|
if (frisch) setJobs(frisch)
|
||||||
} catch {
|
} catch {
|
||||||
toast('error', 'Aufräumen fehlgeschlagen')
|
toast('error', 'Aufräumen fehlgeschlagen')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* ZWEI TAKTE statt einem.
|
||||||
|
*
|
||||||
|
* ⚠️ JEDER Abruf hier gibt bei Fehlschlag `null` — und `null` bedeutet „nichts
|
||||||
|
* Neues erfahren", nicht „es gibt nichts". Vor dem 26.07.2026 stand überall
|
||||||
|
* `catch(() => [])`: ein einziger verpasster Abruf leerte Job-Liste,
|
||||||
|
* Laufwerke und Ablagen, vier Sekunden später war alles wieder da. Genau das
|
||||||
|
* hat der Commander als „wird oft neu geladen" gemeldet. Siehe `fetchJobs`.
|
||||||
|
*
|
||||||
|
* Und verpasst wurde reichlich: Fünf Endpunkte alle vier Sekunden sind 75
|
||||||
|
* Anfragen pro Minute, dazu Log-Kasten und Laufwerks-Suche — die API wies bei
|
||||||
|
* ihrer damaligen Grenze von 100/min laufend mit HTTP 429 ab (gemessen: 97
|
||||||
|
* Abweisungen im Log). Die Grenze ist jetzt der echten Last angemessen
|
||||||
|
* (ratelimit.py), aber das rechtfertigt keine Verschwendung: Jobs und
|
||||||
|
* Laufwerke ändern sich sekündlich, die drei anderen praktisch nie.
|
||||||
|
* 75/min sind damit 15 + 15/min geworden.
|
||||||
|
*/
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadData = async () => {
|
const nichts = () => null
|
||||||
|
|
||||||
|
// Schnell (4 s): was sich während eines Rips wirklich bewegt.
|
||||||
|
const schnellLaden = async () => {
|
||||||
try {
|
try {
|
||||||
const [jobsData, sysData, capsData, devData, ablData] = await Promise.all([
|
const [jobsData, devData] = await Promise.all([
|
||||||
fetchJobs(),
|
fetchJobs(),
|
||||||
api.get('/system/info').then(r => r.data).catch(() => null),
|
|
||||||
// Für die ECHTE Online-Zahl: /capabilities kennt den Celery-Ping,
|
|
||||||
// /system/info nur die Registrierung. Der Endpunkt ist seit v3.15
|
|
||||||
// schnell (0,003 s, Ping läuft im Hintergrund) — er darf hier also
|
|
||||||
// im 4-Sekunden-Takt mitlaufen.
|
|
||||||
api.get('/capabilities').then(r => r.data?.workers || []).catch(() => []),
|
|
||||||
// Die Laufwerke: Ohne sie behauptete die Server-Status-Karte
|
// Die Laufwerke: Ohne sie behauptete die Server-Status-Karte
|
||||||
// „keine Disc in Arbeit", während oben die erkannte Disc stand.
|
// „keine Disc in Arbeit", während oben die erkannte Disc stand.
|
||||||
api.get('/devices').then(r => r.data || []).catch(() => []),
|
api.get('/devices').then(r => Array.isArray(r.data) ? r.data : null).catch(nichts),
|
||||||
// Die Ablageziele inkl. eingehängter Freigaben — ohne sie zeigte der
|
|
||||||
// Server-Status nur die Container-Platte (Commander-Befund).
|
|
||||||
api.get('/storage-targets').then(r => Array.isArray(r.data) ? r.data : []).catch(() => []),
|
|
||||||
])
|
])
|
||||||
setJobs(jobsData)
|
if (jobsData) setJobs(jobsData)
|
||||||
if (sysData) setSystemInfo(sysData)
|
if (devData) setLaufwerke(devData)
|
||||||
setWorkersLive(capsData)
|
|
||||||
setLaufwerke(devData)
|
|
||||||
setAblagen(ablData)
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
loadData()
|
// Langsam (12 s): Hardware, Worker-Liste, Ablagen. Ein Worker, der online
|
||||||
const interval = setInterval(loadData, 4000)
|
// geht, oder eine Freigabe, die wegbricht, darf drei Takte später auffallen.
|
||||||
return () => clearInterval(interval)
|
const langsamLaden = async () => {
|
||||||
|
const [sysData, capsData, ablData] = await Promise.all([
|
||||||
|
api.get('/system/info').then(r => r.data).catch(nichts),
|
||||||
|
// Für die ECHTE Online-Zahl: /capabilities kennt den Celery-Ping,
|
||||||
|
// /system/info nur die Registrierung.
|
||||||
|
api.get('/capabilities').then(r => r.data?.workers ?? null).catch(nichts),
|
||||||
|
// Die Ablageziele inkl. eingehängter Freigaben — ohne sie zeigte der
|
||||||
|
// Server-Status nur die Container-Platte (Commander-Befund).
|
||||||
|
api.get('/storage-targets').then(r => Array.isArray(r.data) ? r.data : null).catch(nichts),
|
||||||
|
])
|
||||||
|
if (sysData) setSystemInfo(sysData)
|
||||||
|
if (capsData) setWorkersLive(capsData)
|
||||||
|
if (ablData) setAblagen(ablData)
|
||||||
|
}
|
||||||
|
|
||||||
|
schnellLaden()
|
||||||
|
langsamLaden()
|
||||||
|
const schnell = setInterval(schnellLaden, 4000)
|
||||||
|
const langsam = setInterval(langsamLaden, 12000)
|
||||||
|
return () => { clearInterval(schnell); clearInterval(langsam) }
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const aktiverJob = jobs.find(j => j.status === 'processing' || j.status === 'transcoding')
|
const aktiverJob = jobs.find(j => j.status === 'processing' || j.status === 'transcoding')
|
||||||
@@ -505,17 +605,17 @@ export default function Dashboard() {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{job.status === 'failed' && job.can_retry && (
|
{job.status === 'failed' && (
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => {
|
onClick={() => wiederholen(job)}
|
||||||
api.post(`/jobs/${job.id}/retry-transcode`)
|
title="Fragt vorher, was genau wiederholt wird"
|
||||||
.then(() => toast('success', 'Kompression neu eingereiht'))
|
|
||||||
.catch((e: any) => toast('error', e?.response?.data?.detail || 'Fehlgeschlagen'))
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<RotateCcw size={13} /> Neu
|
<RotateCcw size={13} />
|
||||||
|
{retryPlan(job) === 'transcode' ? 'Neu komprimieren'
|
||||||
|
: retryPlan(job) === 'rip' ? 'Neu rippen'
|
||||||
|
: 'Neu …'}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -773,6 +873,16 @@ export default function Dashboard() {
|
|||||||
type="warning"
|
type="warning"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* „Neu" nennt jetzt beim Namen, was es tut (siehe wiederholen()). */}
|
||||||
|
<RetryDialog
|
||||||
|
offen={neuKandidat !== null}
|
||||||
|
titel={neuKandidat?.title || neuKandidat?.id.slice(0, 8) || ''}
|
||||||
|
plan={neuKandidat ? retryPlan(neuKandidat) : 'unklar'}
|
||||||
|
rohdaten={neuRohdaten}
|
||||||
|
onClose={() => setNeuKandidat(null)}
|
||||||
|
onStart={neuStarten}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Entfernen eines EINZELNEN Jobs — mit der Zahl, die vorher fehlte. */}
|
{/* Entfernen eines EINZELNEN Jobs — mit der Zahl, die vorher fehlte. */}
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
isOpen={loeschKandidat !== null}
|
isOpen={loeschKandidat !== null}
|
||||||
|
|||||||
@@ -217,6 +217,38 @@ def zaehle_online_worker(sekunden: int = 120) -> int:
|
|||||||
return int(anzahl or 0)
|
return int(anzahl or 0)
|
||||||
|
|
||||||
|
|
||||||
|
def meta_merken(job_id: str, **felder) -> None:
|
||||||
|
"""Ergänzt EINZELNE Schlüssel in den Job-Metadaten. Wirft nie.
|
||||||
|
|
||||||
|
`update_job(meta=...)` würde die Spalte ersetzen — Poster, Jahr, Titel-Wahl
|
||||||
|
und Sprachwunsch dieses Rips wären damit fort. Also lesen, mischen,
|
||||||
|
schreiben.
|
||||||
|
|
||||||
|
Fehler werden geschluckt: Diese Funktion vermerkt nur, in welcher Phase ein
|
||||||
|
Job steht (api/phasen.py). Ein Rip darf daran nicht scheitern — im
|
||||||
|
schlimmsten Fall fehlt die Marke und der „Neu"-Knopf fragt nach.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
|
||||||
|
try:
|
||||||
|
zeile = get_job(job_id)
|
||||||
|
if not zeile:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
vorher = json.loads(zeile.get("meta") or "{}")
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
vorher = {}
|
||||||
|
if not isinstance(vorher, dict):
|
||||||
|
vorher = {}
|
||||||
|
vorher.update(felder)
|
||||||
|
update_job(job_id, meta=json.dumps(vorher))
|
||||||
|
except Exception as e:
|
||||||
|
try:
|
||||||
|
add_log("warning", "worker", f"Job {job_id}: Metadaten-Vermerk fehlgeschlagen: {e}")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def update_job(job_id: str, **fields) -> None:
|
def update_job(job_id: str, **fields) -> None:
|
||||||
with engine.begin() as conn:
|
with engine.begin() as conn:
|
||||||
conn.execute(jobs.update().where(jobs.c.id == job_id).values(**fields))
|
conn.execute(jobs.update().where(jobs.c.id == job_id).values(**fields))
|
||||||
|
|||||||
@@ -54,6 +54,17 @@ from ripping import (
|
|||||||
|
|
||||||
API_URL = os.getenv("API_URL", "http://api:8000")
|
API_URL = os.getenv("API_URL", "http://api:8000")
|
||||||
|
|
||||||
|
# Phasen-Marke in den Job-Metadaten: war der RIP fertig, als es schiefging?
|
||||||
|
#
|
||||||
|
# Sobald `status = "failed"` in der Zeile steht, ist die Phase sonst
|
||||||
|
# unwiederbringlich fort — und genau die entscheidet, was danach hilft: Nach
|
||||||
|
# einem toten Transcode genügt „Neu komprimieren"; nach einem toten Rip liegt
|
||||||
|
# nur ein Bruchstück da (Vorfall 26.07.2026: 5,1 GB von rund 40 GB) und es muss
|
||||||
|
# neu gerippt werden. Gelesen wird die Marke in api/phasen.py — der Name steht
|
||||||
|
# in beiden Dateien und wird von test_phasen.py gegeneinander geprüft, weil es
|
||||||
|
# kein geteiltes Paket zwischen den Containern gibt.
|
||||||
|
RIP_FERTIG = "rip_fertig"
|
||||||
|
|
||||||
|
|
||||||
def _transcode_queue(node: str):
|
def _transcode_queue(node: str):
|
||||||
"""Ziel-Queue für die Kompression (siehe celery_client.transcode_queue):
|
"""Ziel-Queue für die Kompression (siehe celery_client.transcode_queue):
|
||||||
@@ -473,6 +484,9 @@ def rip_disc(self, device_path: str, job_id: str, target_dir: str = None):
|
|||||||
return {"status": "error", "error": fehler, "disc_type": disc_type}
|
return {"status": "error", "error": fehler, "disc_type": disc_type}
|
||||||
|
|
||||||
db.update_job(job_id, status="running", disc_type=disc_type)
|
db.update_job(job_id, status="running", disc_type=disc_type)
|
||||||
|
# Ab hier gilt: der Rip läuft, ist aber NICHT fertig. Stirbt der Job jetzt,
|
||||||
|
# ist jede Roh-Datei ein Bruchstück (siehe RIP_FERTIG oben).
|
||||||
|
db.meta_merken(job_id, **{RIP_FERTIG: False})
|
||||||
db.add_log("info", "worker", f"Job {job_id}: {disc_type}-Rip gestartet ({device_path})")
|
db.add_log("info", "worker", f"Job {job_id}: {disc_type}-Rip gestartet ({device_path})")
|
||||||
|
|
||||||
letzter = [-1]
|
letzter = [-1]
|
||||||
@@ -655,6 +669,9 @@ def rip_disc(self, device_path: str, job_id: str, target_dir: str = None):
|
|||||||
# freier Worker, inkl. Remote-GPU).
|
# freier Worker, inkl. Remote-GPU).
|
||||||
ziel_queue = _transcode_queue(meta.get("transcode_node"))
|
ziel_queue = _transcode_queue(meta.get("transcode_node"))
|
||||||
db.update_job(job_id, status="transcoding", progress=0)
|
db.update_job(job_id, status="transcoding", progress=0)
|
||||||
|
# Der Rip ist durch. Ab jetzt ist ein Fehlschlag mit „Neu komprimieren"
|
||||||
|
# zu heilen, ohne die Disc noch einmal zu lesen.
|
||||||
|
db.meta_merken(job_id, **{RIP_FERTIG: True})
|
||||||
gezielt = meta.get("transcode_node") and ziel_queue != "transcode"
|
gezielt = meta.get("transcode_node") and ziel_queue != "transcode"
|
||||||
db.add_log("info", "worker",
|
db.add_log("info", "worker",
|
||||||
f"Job {job_id}: Rip fertig, Kompression eingereiht"
|
f"Job {job_id}: Rip fertig, Kompression eingereiht"
|
||||||
@@ -843,6 +860,10 @@ def transcode_files(self, job_id: str, raw_dir: str, final_dir: str):
|
|||||||
|
|
||||||
os.makedirs(final_dir, exist_ok=True)
|
os.makedirs(final_dir, exist_ok=True)
|
||||||
db.update_job(job_id, status="transcoding", progress=0, error=None)
|
db.update_job(job_id, status="transcoding", progress=0, error=None)
|
||||||
|
# Wer hier ankommt, hat vollständige Quelldateien — sonst wäre oben schon
|
||||||
|
# abgebrochen worden. Das vermerkt die Marke auch für BESTANDSJOBS, die vor
|
||||||
|
# ihrer Einführung gerippt wurden und sie darum noch nicht tragen.
|
||||||
|
db.meta_merken(job_id, **{RIP_FERTIG: True})
|
||||||
db.add_log(
|
db.add_log(
|
||||||
"info", "worker",
|
"info", "worker",
|
||||||
f"Job {job_id}: Kompression gestartet ({len(quellen)} Datei(en), "
|
f"Job {job_id}: Kompression gestartet ({len(quellen)} Datei(en), "
|
||||||
|
|||||||
Reference in New Issue
Block a user