feat(ui): Laufende Disc-Erkennung ist jetzt sichtbar
Ampel / ampel (push) Successful in 1m26s

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 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-08-29 15:12:35 +02:00
co-authored by Claude Opus 5
parent dbb934d146
commit e67138e9ca
4 changed files with 244 additions and 3 deletions
+91 -2
View File
@@ -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
+113
View File
@@ -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"
@@ -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() {
</CardHeader>
<CardContent>
{/*
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 => (
<div
key={`erkennung-${device.id}`}
className="mb-6 rounded-2xl border border-slate-700 bg-slate-900/60 px-5 py-4 flex items-center gap-3"
>
<RefreshCw size={18} className="animate-spin text-amber-400 flex-shrink-0" />
<div>
<p className="font-semibold text-slate-200">
Disc wird gelesen {device.name}
</p>
<p className="text-xs text-slate-400 mt-0.5">
Rippy liest Titel und Laufzeiten von der Disc. Das dauert bis
zu zwei Minuten; danach steht hier, was drin ist.
</p>
</div>
</div>
))}
{/* Disc-Karte: WAS liegt gerade im Laufwerk (Auto-Pre-Scan) */}
{devices.filter(d => d.status === 'ready' && d.disc).map(device => (
<div
+10 -1
View File
@@ -66,6 +66,9 @@ interface LaufwerkLive {
id: string
status: string
disc?: { title?: string, year?: number | null, disc_type?: string }
/** Die Erkennung laeuft noch es gibt noch keinen Titel, aber auch kein
* leeres Laufwerk. Dauert an einer Blu-ray rund zwei Minuten. */
disc_wird_erkannt?: boolean
}
// Ein Ablageziel aus GET /storage-targets — Verzeichnisse unter /app/media
@@ -329,7 +332,13 @@ export default function Dashboard() {
: 'Rip läuft (MakeMKV, verlustfrei)')
: discImLaufwerk
? 'Disc erkannt — wartet auf „Rippen starten"'
: 'Bereit — kein Datenträger im Laufwerk'
// Zwischen „Disc eingelegt" und „erkannt" liegen rund zwei Minuten
// (makemkvcon info laeuft in seine 120-Sekunden-Grenze). Ohne diese
// Zeile stand dort „kein Datentraeger", und das sah aus wie ein
// Fehler (Commander 29.08.2026).
: laufwerke.some(l => 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'