From 7b0c41ddfe9e6fa8412c77e33806b165686ec1ce Mon Sep 17 00:00:00 2001 From: Hitonabi Date: Sat, 29 Aug 2026 15:00:11 +0200 Subject: [PATCH] =?UTF-8?q?fix(ui):=20Datum=201.1.1970,=20Disc=20nicht=20e?= =?UTF-8?q?rkannt,=20kein=20Abbrechen=20=E2=80=94=20eine=20Ursache=20je=20?= =?UTF-8?q?Fall?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vier Befunde aus einem Bildschirmfoto vom 29.08.2026. ## 1. „das Datum ist falsch" — und der Typ auch TYP „DISC" STARTZEIT „1.1.1970, 01:00:00" STATUS „running" ⚠️ Das war MEIN Fehler von heute Vormittag. Ich hatte `type`, `device` und `startTime` in das Job-Ereignis aufgenommen — mit Feldnamen, **die es in der Datenbankzeile nicht gibt**. Dort heissen sie `disc_type` und `created_at`; die Uebersetzung macht `_job_row_to_model`, und sie bildet auch `running` auf `processing` ab. Die Folge war schlimmer als das Problem davor: Statt eines FEHLENDEN Feldes kam ein LEERES. `new Date(null)` ist der 1.1.1970, und „DISC" war der Rueckfall, den ich zwei Stunden vorher fuer genau diesen Fall eingebaut hatte. Aus „offensichtlich kaputt" war „sieht plausibel aus" geworden. Und mein Test hat es nicht gefunden, weil ich seine Beispielzeile selbst erfunden habe — mit meinen falschen Feldnamen. Ein Test, der dieselbe Annahme macht wie der Code, prueft nichts. Der neue geht von der ECHTEN Zeile aus. Der Waechter bekommt jetzt dieselbe Uebersetzung eingespritzt, die auch `/jobs` benutzt. ## 2. „Es ist eine Disk im laufwerk, aber er erkennt sie dort nicht" Die Laufwerksliste wurde an DREI Stellen gebaut: `/devices` haengte die erkannte Disc an, der Ereignis-Waechter und der SSE-Schnappschuss nicht. Das Dashboard liest den Schnappschuss — und schloss aus der fehlenden Disc auf ein leeres Laufwerk, waehrend ihr Titel eine Zeile weiter oben stand. Jetzt gibt es `laufwerke_mit_disc()`, und ein Test zaehlt die Aufrufe von `device_info` — bei zwei ist er rot. ## 3. „man kann einen rip garnicht abbrechen" Dieselbe Ursache wie 1: Es gab genau EINEN Abbrechen-Knopf, in der Kachel „laufender Job". Die erscheint nur bei Status `processing`; der Strom lieferte `running`. Keine Kachel, kein Knopf, kein Weg zurueck — und aus demselben Grund stand „Aktiv (0)", waehrend der Rip lief. Zusaetzlich gibt es den Knopf jetzt in der Job-Zeile selbst, wo man ihn sucht. ## 4. „bei ‚Platz für rippy' sollte eher das arbeitsverzeichnis und der ## Ablagepfad sein" Dort stand eine nackte Zahl fuer den ERSTEN Ort. Welcher Ordner das war, sah man nicht — und die zweite Zeile fiel ganz weg, wenn beide auf derselben Platte lagen. Das stimmt fuer die ZAHL und war der falsche Schluss fuer den ORT. Jetzt stehen beide da, mit Pfad, plus der Hinweis, dass der Platz geteilt wird. Im Browser gegengeprueft: „Disc erkannt — wartet auf ‚Rippen starten'", BLU-RAY mit richtigem Datum, beide Pfade, keine Konsolenfehler. 852 Tests gruen, ruff sauber. Co-Authored-By: Claude Opus 5 --- docker/api/main.py | 57 +++++++++++--- docker/api/test_api_smoke.py | 127 ++++++++++++++++++++++++++++++ docker/ui/src/pages/Dashboard.tsx | 74 ++++++++++++++++- src/rippy/betrieb.py | 24 ++++-- src/rippy/bus/test_waechter.py | 26 +++++- src/rippy/bus/waechter.py | 33 +++++++- src/rippy/test_arbeitsort.py | 21 +++-- src/rippy/test_betrieb.py | 36 +++++++-- 8 files changed, 361 insertions(+), 37 deletions(-) diff --git a/docker/api/main.py b/docker/api/main.py index 318b5f7..a0e6175 100644 --- a/docker/api/main.py +++ b/docker/api/main.py @@ -121,11 +121,14 @@ async def startup_event(): ereignis_waechter = Waechter( db, ereignis_bus, - laufwerke_lesen=lambda: [ - device_discovery.device_info(p) - for p in device_discovery.list_optical_devices() - ], + # MIT der erkannten Disc — sonst meldet die Server-Status-Kachel + # „kein Datentraeger", waehrend die Disc erkannt ist (29.08.2026). + laufwerke_lesen=laufwerke_mit_disc, system_lesen=system_stand_sync, + # DIESELBE Uebersetzung wie /jobs — sonst tragen die Live-Ereignisse + # andere Feldnamen als der Schnappschuss (Befund 29.08.2026: + # „1.1.1970" und „DISC" in der Jobliste). + job_form=lambda z: _job_row_to_model(z).model_dump(), ) asyncio.create_task(ereignis_waechter.schleife()) @@ -442,12 +445,14 @@ async def _snapshot() -> dict: # Laufwerke getrennt: device_info macht ioctls, die hängen können — # ein defektes Laufwerk darf den Snapshot nicht aufhalten. try: + # MIT der erkannten Disc. Hier stand dieselbe Schleife ein DRITTES + # Mal (Befund 29.08.2026) — `/devices` hängte die Disc an, der + # Ereignis-Wächter und dieser Schnappschuss nicht. Das Dashboard + # liest genau diesen Schnappschuss und schloss aus der fehlenden + # Disc auf ein leeres Laufwerk: „Bereit — kein Datenträger im + # Laufwerk", während der Titel eine Zeile weiter oben stand. zustand["devices"] = await asyncio.wait_for( - asyncio.to_thread( - lambda: [device_discovery.device_info(p) - for p in device_discovery.list_optical_devices()]), - timeout=5, - ) + asyncio.to_thread(laufwerke_mit_disc), timeout=5) except (asyncio.TimeoutError, OSError): # „konnte nicht nachsehen" ist etwas anderes als „es gibt keine". # Deshalb None und nicht [] — das UI behält dann seinen alten Stand. @@ -2363,6 +2368,12 @@ async def system_info(): continue info["plaetze"].append({ "name": ort["name"], + # Der PFAD gehoert dazu (Commander 29.08.2026): „bei ‚Platz + # fuer rippy' sollte eher das arbeitsverzeichnis und der + # Ablagepfad sein." Eine Zahl ohne Ort sagt nicht, WO der + # Platz knapp wird. + "pfad": ort["pfad"], + "gleiches_laufwerk": bool(ort.get("gleiches_laufwerk")), "frei_gb": round(nutzung.free / 1024**3, 1), "gesamt_gb": round(nutzung.total / 1024**3, 1), }) @@ -2723,13 +2734,37 @@ async def get_devices(): prinzipbedingt nichts: kein udevd, keine udev-Datenbank, kein Daemon, der Symlinks anlegt. Jetzt: /sys fürs Modell, ioctl für den Disc-Status. """ + return [Device(**info) for info in + await asyncio.to_thread(laufwerke_mit_disc)] + + +def laufwerke_mit_disc() -> list: + """Laufwerke SAMT erkannter Disc — der eine Weg für beide Abnehmer. + + ## Der Befund des Commanders (29.08.2026) + + > „Es ist eine Disk im laufwerk, aber er erkennt sie dort nicht" + + Auf der Server-Status-Kachel stand „Bereit — kein Datenträger im + Laufwerk", während die Disc erkannt war und ihr Titel eine Zeile weiter + oben in der Jobliste stand. + + Die Ursache: Es gab ZWEI Wege zu den Laufwerken. `/devices` hängte die + erkannte Disc aus `DISC_CACHE` an; der Ereignis-Wächter rief nur + `device_info()` und ließ sie weg. Das UI liest seit V2-3 den Ereignisstrom + — also die Fassung ohne Disc. Es fragte `l.disc?.title`, bekam nichts, und + schloss daraus auf ein leeres Laufwerk. + + Eine Auskunft in zwei Fassungen ist eine Auskunft zu viel. Jetzt gibt es + nur diese hier. + """ geraete = [] for pfad in device_discovery.list_optical_devices(): - info = await asyncio.to_thread(device_discovery.device_info, pfad) + info = device_discovery.device_info(pfad) disc = DISC_CACHE.get(pfad) if disc and not disc.get("_laeuft"): info["disc"] = disc - geraete.append(Device(**info)) + geraete.append(info) return geraete diff --git a/docker/api/test_api_smoke.py b/docker/api/test_api_smoke.py index 87cff29..025b049 100644 --- a/docker/api/test_api_smoke.py +++ b/docker/api/test_api_smoke.py @@ -411,3 +411,130 @@ def test_betrieb_meldet_faehigkeiten_statt_nur_einen_namen(): # Ein Hinweis auf docker compose darf NUR im Container erscheinen. if not daten["im_container"]: assert daten["hilfe_befehl"] == "" + + +# ── Die Grenze DB → Oberflaeche (Befund 29.08.2026) ───────────────────── +# +# Commander mit Bildschirmfoto: „schau mal hier, das Datum ist falsch." +# +# TYP „DISC" STARTZEIT „1.1.1970, 01:00:00" STATUS „running" +# +# Der Waechter schickte die ROHE Datenbankzeile ueber den Ereignisstrom. Dort +# heissen die Spalten `disc_type` und `created_at`, im UI aber `type` und +# `startTime` — die Uebersetzung macht `_job_row_to_model`, und sie bildet +# auch `running` auf `processing` ab. +# +# Der Test, den ich beim ersten Anlauf geschrieben habe, hat das NICHT +# gefunden: Ich hatte seine Beispielzeile selbst erfunden, mit meinen +# falschen Feldnamen. Ein Test, der dieselbe Annahme macht wie der Code, +# prueft nichts. Dieser hier geht von der ECHTEN Zeile aus. + + +def _echte_db_zeile(): + """Eine Zeile mit den Spaltennamen, die wirklich in der Datenbank stehen.""" + from datetime import datetime + + return { + "id": "j1", + "disc_type": "bluray", # NICHT "type" + "created_at": datetime(2026, 8, 29, 12, 7, 2), # NICHT "startTime" + "finished_at": None, + "status": "running", # wird zu "processing" + "device": r"\.\G:", + "progress": 12, + "title": "Evangelion 2.22", + "error": None, + "meta": None, + } + + +def test_die_umwandlung_liefert_die_namen_die_das_ui_kennt(): + import main + + ui = main._job_row_to_model(_echte_db_zeile()).model_dump() + assert ui["type"] == "bluray" + assert ui["startTime"].startswith("2026-08-29") + assert ui["status"] == "processing", "der Worker-Status heisst im UI anders" + + +def test_das_live_ereignis_traegt_dieselben_namen_wie_die_jobliste(): + """DER Waechter gegen „1.1.1970". Ohne die Umwandlung sind alle + Pflichtfelder leer — und leer sieht im Browser aus wie eine Auskunft.""" + import main + from rippy.bus.waechter import UI_PFLICHTFELDER, _job_kurz + + ui = main._job_row_to_model(_echte_db_zeile()).model_dump() + kurz = _job_kurz(ui) + for feld in UI_PFLICHTFELDER: + assert kurz.get(feld) is not None, "%s waere im Browser leer" % feld + assert kurz["type"] == "bluray" + assert kurz["status"] == "processing" + + +def test_der_waechter_ist_mit_der_umwandlung_verdrahtet(): + """Sonst waere die Reparatur wieder nur eine Moeglichkeit, keine Tatsache.""" + import inspect + + import main + + quelle = inspect.getsource(main.startup_event) + assert "job_form=" in quelle, "Waechter bekommt die Umwandlung nicht" + assert "_job_row_to_model" in quelle + + +def test_die_laufwerksliste_kommt_aus_EINER_quelle(): + """Waechter gegen die vierte Kopie (Befund 29.08.2026). + + Commander: „Es ist eine Disk im laufwerk, aber er erkennt sie dort nicht." + + Die Laufwerksliste wurde an DREI Stellen gebaut: im `/devices`-Endpunkt + (mit angehaengter Disc), im Ereignis-Waechter und im SSE-Schnappschuss + (beide ohne). Das Dashboard liest den Schnappschuss — und schloss aus der + fehlenden Disc auf ein leeres Laufwerk, waehrend ihr Titel eine Zeile + weiter oben stand. + + Eine Auskunft in drei Fassungen ist zwei Fassungen zu viel. + """ + import inspect + + import main + + quelle = inspect.getsource(main) + # Die eine erlaubte Fundstelle ist die Funktion selbst. + stellen = quelle.count("device_discovery.device_info(") + assert stellen == 1, ( + "device_info wird an %d Stellen aufgerufen — die Liste gehoert in " + "laufwerke_mit_disc(), sonst fehlt irgendwo die Disc" % stellen) + for name in ("_snapshot", "startup_event", "get_devices"): + text = inspect.getsource(getattr(main, name)) + assert "device_info(" not in text, \ + "%s baut die Laufwerksliste selbst" % name + + +def test_laufwerke_mit_disc_haengt_die_erkannte_disc_an(monkeypatch): + import main + + monkeypatch.setattr(main.device_discovery, "list_optical_devices", + lambda: [r"\.\G:"]) + monkeypatch.setattr(main.device_discovery, "device_info", + lambda p: {"id": "G", "name": "Laufwerk G:", "path": p, + "type": "bluray", "status": "ready"}) + monkeypatch.setitem(main.DISC_CACHE, r"\.\G:", {"title": "Evangelion 2.22"}) + + geraete = main.laufwerke_mit_disc() + assert geraete[0]["disc"]["title"] == "Evangelion 2.22" + + +def test_eine_noch_laufende_erkennung_wird_NICHT_als_disc_gemeldet(monkeypatch): + """`_laeuft` heisst „wird gerade erkannt" — das ist noch keine Auskunft.""" + import main + + monkeypatch.setattr(main.device_discovery, "list_optical_devices", + lambda: [r"\.\G:"]) + monkeypatch.setattr(main.device_discovery, "device_info", + lambda p: {"id": "G", "name": "Laufwerk G:", "path": p, + "type": "bluray", "status": "ready"}) + monkeypatch.setitem(main.DISC_CACHE, r"\.\G:", + {"_laeuft": True, "title": "Wird erkannt…"}) + + assert "disc" not in main.laufwerke_mit_disc()[0] diff --git a/docker/ui/src/pages/Dashboard.tsx b/docker/ui/src/pages/Dashboard.tsx index 08f6ae0..7d6a885 100644 --- a/docker/ui/src/pages/Dashboard.tsx +++ b/docker/ui/src/pages/Dashboard.tsx @@ -44,7 +44,8 @@ interface Job { interface SystemInfo { api_version: string - plaetze: { name: string; frei_gb: number; gesamt_gb: number }[] + plaetze: { name: string; pfad?: string; gleiches_laufwerk?: boolean; + frei_gb: number; gesamt_gb: number }[] workers: { name: string; encoders: string[] }[] } @@ -584,6 +585,38 @@ export default function Dashboard() { Details + {/* + Abbrechen DIREKT in der Zeile (Commander + 29.08.2026: „man kann einen rip garnicht + abbrechen"). + + Es gab genau einen Abbrechen-Knopf, und der + steckte in der Kachel „laufender Job". Die + erschien nur bei Status `processing` — der + Ereignisstrom lieferte aber den rohen + DB-Status `running`. Also keine Kachel, also + kein Knopf, also kein Weg zurueck. + Die Ursache ist behoben; ein zweiter Weg zum + Abbrechen ist trotzdem richtig, denn genau + hier sucht man ihn. + */} + {['processing', 'transcoding', 'pending'].includes(job.status) && ( + + )} + + {job.status === 'canceling' && ( + bricht ab … + )} + {job.status === 'completed' && (