fix(ui+api): das "Neuladen" war HTTP 429 - und "Neu" komprimierte immer
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:
Hitonabi
2026-07-26 16:49:10 +02:00
parent f7555a17d1
commit bfb13f44a5
11 changed files with 811 additions and 42 deletions
+76
View File
@@ -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