From e67138e9cae35dfc78c4d28ddae2cced8233c6b3 Mon Sep 17 00:00:00 2001 From: Hitonabi Date: Sat, 29 Aug 2026 15:12:35 +0200 Subject: [PATCH] feat(ui): Laufende Disc-Erkennung ist jetzt sichtbar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Commander: „das die disc erkennung noch läuft muss sichtbar sein" Er hat recht, und die Wartezeit ist gemessen: `makemkvcon info` laeuft an einer Blu-ray in seine 120-Sekunden-Grenze (Disc nach 119 s erkannt). Zwei Minuten, in denen NICHTS zu sehen war — weder die Disc noch ein Hinweis. Das sah aus wie ein leeres Laufwerk, also wie ein Fehler. ## Der Zustand war da, er wurde nur weggeworfen `_auto_prescan` legt beim Start `{"_laeuft": True}` in den Vorrat. `laufwerke_mit_disc` liess so einen Eintrag KOMPLETT weg — damit keine halbfertige Disc-Karte mit „Wird erkannt…" als Titel und einem „Rippen starten"-Knopf erscheint. Richtig gedacht, falsch geloest. Jetzt gibt es ein eigenes Feld `disc_wird_erkannt`. Die Oberflaeche kann „wird gelesen" zeigen, ohne einen Titel zu behaupten, den es noch nicht gibt: eine Karte mit Spinner ueber der Laufwerksliste und eine eigene Zeile im Server-Status. ## Und der Haken dahinter Genau waehrend der Erkennung ist die Laufwerks-Auskunft am teuersten: makemkvcon haelt das Laufwerk, und `device_info` wartet mit. Gemessen: /devices waehrend des Scans 14,0 s Zeitgrenze des Schnappschusses 5,0 s -> devices = None `None` heisst „konnte nicht nachsehen", und die Oberflaeche behaelt dann ihren Stand — nach einem frischen Laden also GAR NICHTS. Die Anzeige waere ausgerechnet in ihrer eigenen Phase leer geblieben. `laufwerke_notdurft()` antwortet deshalb ohne ioctl: letzter bekannter Stand plus die aktuelle Erkennungs-Marke aus dem Vorrat. Beim Kaltstart wenigstens der Laufwerksname — die LISTE der Laufwerke ist billig, teuer ist erst das Anfassen. Nichts wird erfunden: kein Typ, kein Modell. Und wer noch nie erfolgreich gelesen hat, schweigt weiter mit `None`. Im Browser gegengeprueft: „Disc wird gelesen — Laufwerk G:" mit Spinner, Server-Status „Disc wird gelesen — das dauert bis zu zwei Minuten", danach der richtige Titel. 859 Tests gruen, ruff sauber. Co-Authored-By: Claude Opus 5 --- docker/api/main.py | 93 ++++++++++++++- docker/api/test_api_smoke.py | 113 +++++++++++++++++++ docker/ui/src/components/DeviceDiscovery.tsx | 30 +++++ docker/ui/src/pages/Dashboard.tsx | 11 +- 4 files changed, 244 insertions(+), 3 deletions(-) diff --git a/docker/api/main.py b/docker/api/main.py index a0e6175..fa917f2 100644 --- a/docker/api/main.py +++ b/docker/api/main.py @@ -383,6 +383,24 @@ class Device(BaseModel): model: Optional[str] = None serial: Optional[str] = None disc: Optional[Dict] = None # Auto-Pre-Scan-Ergebnis (Titel/Jahr/Poster) + # Läuft die Erkennung gerade noch? (Commander 29.08.2026: „das die disc + # erkennung noch läuft muss sichtbar sein") + # + # Der Zustand war schon da — `_auto_prescan` legt beim Start + # `{"_laeuft": True}` in den Vorrat. Nur wurde er nie herausgegeben: + # `laufwerke_mit_disc` ließ so einen Eintrag KOMPLETT weg, damit keine + # halbfertige Disc-Karte mit „Wird erkannt…" als Titel und einem + # „Rippen starten"-Knopf erscheint. Richtig gedacht, falsch gelöst — für + # die Oberfläche sah es dadurch aus, als läge gar keine Disc im Laufwerk. + # + # Und das dauert: `makemkvcon info` läuft an einer Blu-ray in seine + # 120-Sekunden-Grenze (gemessen 29.08.2026 — Disc nach 119 s erkannt). + # Zwei Minuten Schweigen sehen aus wie ein Fehler. + # + # Ein eigenes Feld statt der halben Disc: Die Oberfläche kann „wird + # erkannt" zeigen, ohne einen Titel behaupten zu müssen, den es noch + # nicht gibt. + disc_wird_erkannt: bool = False def _job_row_to_model(zeile: dict) -> Job: @@ -456,7 +474,14 @@ async def _snapshot() -> dict: 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. - zustand["devices"] = None + # + # Vorher stand hier NUR `None`. Waehrend der Disc-Erkennung ist das + # aber der Normalfall (makemkvcon haelt das Laufwerk, /devices + # braucht dann 14 s statt 5) — und nach einem frischen Laden hatte + # das UI keinen alten Stand, den es haette behalten koennen. Der + # Bildschirm blieb leer, ausgerechnet in der Phase, die sichtbar + # sein soll. `laufwerke_notdurft` antwortet ohne ioctl. + zustand["devices"] = laufwerke_notdurft() # ⚠️ Der Server-Zustand gehört MIT in den Schnappschuss (Befund 28.08.2026). # @@ -2762,12 +2787,76 @@ def laufwerke_mit_disc() -> list: for pfad in device_discovery.list_optical_devices(): info = device_discovery.device_info(pfad) disc = DISC_CACHE.get(pfad) - if disc and not disc.get("_laeuft"): + if disc and disc.get("_laeuft"): + # NICHT als Disc ausgeben — es gibt noch keinen Titel. Aber + # sagen, dass gerade gearbeitet wird: Zwei Minuten Schweigen + # sehen aus wie ein leeres Laufwerk (Commander 29.08.2026). + info["disc_wird_erkannt"] = True + elif disc: info["disc"] = disc geraete.append(info) + LETZTE_LAUFWERKE[:] = geraete return geraete +#: Der zuletzt vollständig gelesene Laufwerks-Stand. Siehe `laufwerke_notdurft`. +LETZTE_LAUFWERKE: List[dict] = [] + + +def laufwerke_notdurft() -> Optional[list]: + """Was sich OHNE Laufwerks-Abfrage sagen lässt — oder None. + + ## Warum es diesen Rückfall gibt (Befund 29.08.2026) + + Der Commander wollte sehen, dass die Disc-Erkennung noch läuft. Genau + dann ist die Auskunft aber am teuersten: Während `makemkvcon info` die + Disc liest, hält es das Laufwerk, und `device_info` wartet mit. Gemessen: + + /devices waehrend des Scans 14,0 s + Zeitgrenze des Schnappschusses 5,0 s -> devices = None + + `None` heißt „konnte nicht nachsehen", und die Oberfläche behält dann + ihren Stand — nach einem frischen Laden also **gar nichts**. Ausgerechnet + in der Phase, die sichtbar werden sollte, war der Bildschirm leer. + + Der Ausweg braucht kein ioctl: Dass gerade erkannt wird, steht im + Vorrat (`DISC_CACHE`), und wie die Laufwerke heißen, wissen wir vom + letzten vollständigen Lauf. Beides zusammen ist eine ehrliche Auskunft — + „das sahen wir zuletzt, und an DIESEM Laufwerk arbeiten wir gerade". + + `None`, wenn wir noch nie erfolgreich gelesen haben: Dann ist Schweigen + richtig, denn behaupten ließe sich nichts. + """ + bekannt = LETZTE_LAUFWERKE + if not bekannt: + # Kaltstart mitten in der Erkennung: Wir haben noch keinen Stand. + # Die LISTE der Laufwerke ist trotzdem billig (sie zaehlt nur + # Buchstaben bzw. /dev-Knoten auf) — teuer ist erst `device_info`, + # das das Laufwerk selbst anfasst. Also das Wenige melden, das + # sicher ist, und nichts erfinden: kein Typ, kein Modell. + try: + pfade_jetzt = device_discovery.list_optical_devices() + except OSError: + return None + bekannt = [{"id": p.rstrip(":\\").rsplit("\\", 1)[-1].rstrip(":") or p, + "name": p, "path": p, "type": "unknown", "status": "ready"} + for p in pfade_jetzt if (DISC_CACHE.get(p) or {}).get("_laeuft")] + if not bekannt: + return None + frisch = [] + for alt in bekannt: + eintrag = dict(alt) + disc = DISC_CACHE.get(eintrag.get("path")) or {} + eintrag.pop("disc_wird_erkannt", None) + eintrag.pop("disc", None) + if disc.get("_laeuft"): + eintrag["disc_wird_erkannt"] = True + elif disc: + eintrag["disc"] = disc + frisch.append(eintrag) + return frisch + + # GET /stream/jobs (SSE) entfernt am 25.07.2026. Der Stream war zweimal falsch: # erst ein Placebo (er sendete nur, wenn eine Liste `sse_connections` gefüllt # war, und nichts füllte sie je), dann am 23.07. funktionsfähig gemacht — aber diff --git a/docker/api/test_api_smoke.py b/docker/api/test_api_smoke.py index 025b049..23d68e7 100644 --- a/docker/api/test_api_smoke.py +++ b/docker/api/test_api_smoke.py @@ -538,3 +538,116 @@ def test_eine_noch_laufende_erkennung_wird_NICHT_als_disc_gemeldet(monkeypatch): {"_laeuft": True, "title": "Wird erkannt…"}) assert "disc" not in main.laufwerke_mit_disc()[0] + + +def test_eine_laufende_erkennung_ist_SICHTBAR(monkeypatch): + """Commander 29.08.2026: „das die disc erkennung noch läuft muss sichtbar + sein." + + Vorher liess `laufwerke_mit_disc` einen laufenden Eintrag KOMPLETT weg — + damit keine halbfertige Disc-Karte mit „Wird erkannt…" als Titel und + einem „Rippen starten"-Knopf erscheint. Richtig gedacht, falsch geloest: + Fuer die Oberflaeche sah es aus wie ein leeres Laufwerk. Und das dauert: + `makemkvcon info` laeuft an einer Blu-ray in seine 120-Sekunden-Grenze + (gemessen: Disc nach 119 s erkannt). + """ + 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…"}) + + geraet = main.laufwerke_mit_disc()[0] + assert geraet["disc_wird_erkannt"] is True + # UND weiterhin keinen Titel behaupten, den es noch nicht gibt. + assert "disc" not in geraet + + +def test_nach_der_erkennung_ist_die_marke_wieder_weg(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"}) + + geraet = main.laufwerke_mit_disc()[0] + assert not geraet.get("disc_wird_erkannt") + assert geraet["disc"]["title"] == "Evangelion 2.22" + + +def test_das_geraetemodell_gibt_die_marke_auch_heraus(): + """Ohne Feld im Modell schneidet FastAPI sie weg — und das UI saehe + wieder nichts (derselbe Fehler wie 25.07. bei `meta`).""" + import main + + assert "disc_wird_erkannt" in main.Device.model_fields + + +# ── Die Notdurft-Auskunft (Befund 29.08.2026) ────────────────────────── +# +# Waehrend der Disc-Erkennung haelt makemkvcon das Laufwerk. Gemessen: +# /devices braucht dann 14,0 s, die Zeitgrenze des Schnappschusses ist 5,0 s. +# Der lieferte deshalb `devices = None` („konnte nicht nachsehen") — und nach +# einem frischen Laden hatte das UI keinen alten Stand, den es haette behalten +# koennen. Der Bildschirm blieb leer, ausgerechnet in der Phase, die sichtbar +# sein soll. + + +def test_notdurft_meldet_den_letzten_stand_plus_die_erkennung(monkeypatch): + import main + + monkeypatch.setattr(main, "LETZTE_LAUFWERKE", + [{"id": "G", "name": "Laufwerk G:", "path": r"\.\G:", + "type": "bluray", "status": "ready"}]) + monkeypatch.setitem(main.DISC_CACHE, r"\.\G:", {"_laeuft": True}) + + geraete = main.laufwerke_notdurft() + assert geraete[0]["disc_wird_erkannt"] is True + assert geraete[0]["type"] == "bluray", "der letzte bekannte Typ bleibt" + + +def test_notdurft_ohne_jeden_stand_erfindet_nichts(monkeypatch): + """Wer nie erfolgreich gelesen hat, soll schweigen — `None` heisst + „behalte deinen Stand", und das ist die ehrliche Antwort.""" + import main + + monkeypatch.setattr(main, "LETZTE_LAUFWERKE", []) + monkeypatch.setattr(main.device_discovery, "list_optical_devices", lambda: []) + assert main.laufwerke_notdurft() is None + + +def test_notdurft_beim_kaltstart_nennt_wenigstens_das_laufwerk(monkeypatch): + """Kaltstart mitten in der Erkennung: Die LISTE der Laufwerke ist billig, + nur das Abfragen des Laufwerks ist teuer.""" + import main + + monkeypatch.setattr(main, "LETZTE_LAUFWERKE", []) + monkeypatch.setattr(main.device_discovery, "list_optical_devices", + lambda: [r"\.\G:"]) + monkeypatch.setitem(main.DISC_CACHE, r"\.\G:", {"_laeuft": True}) + + geraete = main.laufwerke_notdurft() + assert geraete[0]["disc_wird_erkannt"] is True + assert geraete[0]["type"] == "unknown", "kein Typ wird erfunden" + + +def test_ein_fertiges_ergebnis_ueberschreibt_die_alte_marke(monkeypatch): + """Sonst bliebe „wird erkannt" stehen, nachdem die Disc laengst da ist.""" + import main + + monkeypatch.setattr(main, "LETZTE_LAUFWERKE", + [{"id": "G", "name": "Laufwerk G:", "path": r"\.\G:", + "type": "bluray", "status": "ready", + "disc_wird_erkannt": True}]) + monkeypatch.setitem(main.DISC_CACHE, r"\.\G:", {"title": "Evangelion 2.22"}) + + geraet = main.laufwerke_notdurft()[0] + assert "disc_wird_erkannt" not in geraet + assert geraet["disc"]["title"] == "Evangelion 2.22" diff --git a/docker/ui/src/components/DeviceDiscovery.tsx b/docker/ui/src/components/DeviceDiscovery.tsx index f3d1fa6..8537b82 100644 --- a/docker/ui/src/components/DeviceDiscovery.tsx +++ b/docker/ui/src/components/DeviceDiscovery.tsx @@ -36,6 +36,8 @@ interface Device { serial?: string model?: string disc?: DiscInfo + /** Erkennung laeuft — noch kein Titel, aber auch kein leeres Laufwerk. */ + disc_wird_erkannt?: boolean } function posterUrl(disc?: DiscInfo): string | null { @@ -179,6 +181,34 @@ export default function DeviceDiscovery() { + {/* + Die Erkennung LÄUFT NOCH — das muss man sehen. + + Commander 29.08.2026: „das die disc erkennung noch läuft muss + sichtbar sein". An einer Blu-ray dauert sie rund zwei Minuten + (`makemkvcon info` läuft in seine 120-Sekunden-Grenze; gemessen: + Disc nach 119 s erkannt). Vorher war in dieser Zeit NICHTS zu + sehen — weder die Disc noch ein Hinweis. Das sah aus wie ein + leeres Laufwerk, also wie ein Fehler. + */} + {devices.filter(d => d.disc_wird_erkannt).map(device => ( +
+ +
+

+ Disc wird gelesen — {device.name} +

+

+ Rippy liest Titel und Laufzeiten von der Disc. Das dauert bis + zu zwei Minuten; danach steht hier, was drin ist. +

+
+
+ ))} + {/* Disc-Karte: WAS liegt gerade im Laufwerk (Auto-Pre-Scan) */} {devices.filter(d => d.status === 'ready' && d.disc).map(device => (
l.disc_wird_erkannt) + ? 'Disc wird gelesen — das dauert bis zu zwei Minuten' + : 'Bereit — kein Datenträger im Laufwerk' const filteredJobs = jobs.filter(j => { if (activeTab === 'active') return j.status === 'processing' || j.status === 'transcoding'