fix(ui): Datum 1.1.1970, Disc nicht erkannt, kein Abbrechen — eine Ursache je Fall
Ampel / ampel (push) Successful in 1m16s

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 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-08-29 15:00:11 +02:00
co-authored by Claude Opus 5
parent b2acddbdfa
commit 7b0c41ddfe
8 changed files with 361 additions and 37 deletions
+46 -11
View File
@@ -121,11 +121,14 @@ async def startup_event():
ereignis_waechter = Waechter( ereignis_waechter = Waechter(
db, ereignis_bus, db, ereignis_bus,
laufwerke_lesen=lambda: [ # MIT der erkannten Disc — sonst meldet die Server-Status-Kachel
device_discovery.device_info(p) # „kein Datentraeger", waehrend die Disc erkannt ist (29.08.2026).
for p in device_discovery.list_optical_devices() laufwerke_lesen=laufwerke_mit_disc,
],
system_lesen=system_stand_sync, 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()) 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 — # Laufwerke getrennt: device_info macht ioctls, die hängen können —
# ein defektes Laufwerk darf den Snapshot nicht aufhalten. # ein defektes Laufwerk darf den Snapshot nicht aufhalten.
try: 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( zustand["devices"] = await asyncio.wait_for(
asyncio.to_thread( asyncio.to_thread(laufwerke_mit_disc), timeout=5)
lambda: [device_discovery.device_info(p)
for p in device_discovery.list_optical_devices()]),
timeout=5,
)
except (asyncio.TimeoutError, OSError): except (asyncio.TimeoutError, OSError):
# „konnte nicht nachsehen" ist etwas anderes als „es gibt keine". # „konnte nicht nachsehen" ist etwas anderes als „es gibt keine".
# Deshalb None und nicht [] — das UI behält dann seinen alten Stand. # Deshalb None und nicht [] — das UI behält dann seinen alten Stand.
@@ -2363,6 +2368,12 @@ async def system_info():
continue continue
info["plaetze"].append({ info["plaetze"].append({
"name": ort["name"], "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), "frei_gb": round(nutzung.free / 1024**3, 1),
"gesamt_gb": round(nutzung.total / 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, prinzipbedingt nichts: kein udevd, keine udev-Datenbank, kein Daemon,
der Symlinks anlegt. Jetzt: /sys fürs Modell, ioctl für den Disc-Status. 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 = [] geraete = []
for pfad in device_discovery.list_optical_devices(): 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) disc = DISC_CACHE.get(pfad)
if disc and not disc.get("_laeuft"): if disc and not disc.get("_laeuft"):
info["disc"] = disc info["disc"] = disc
geraete.append(Device(**info)) geraete.append(info)
return geraete return geraete
+127
View File
@@ -411,3 +411,130 @@ def test_betrieb_meldet_faehigkeiten_statt_nur_einen_namen():
# Ein Hinweis auf docker compose darf NUR im Container erscheinen. # Ein Hinweis auf docker compose darf NUR im Container erscheinen.
if not daten["im_container"]: if not daten["im_container"]:
assert daten["hilfe_befehl"] == "" 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]
+71 -3
View File
@@ -44,7 +44,8 @@ interface Job {
interface SystemInfo { interface SystemInfo {
api_version: string 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[] }[] workers: { name: string; encoders: string[] }[]
} }
@@ -584,6 +585,38 @@ export default function Dashboard() {
Details Details
</Button> </Button>
{/*
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) && (
<Button
variant="danger"
size="sm"
onClick={() => api.post(`/jobs/${job.id}/cancel`)
.then(() => toast('info', 'Abbruch angefordert — der Rip haelt beim naechsten Zwischenschritt an.'))
.catch((e: any) => toast('error',
e?.response?.data?.detail || 'Abbrechen ging nicht'))}
>
Abbrechen
</Button>
)}
{job.status === 'canceling' && (
<span className="text-xs font-mono text-slate-400">bricht ab </span>
)}
{job.status === 'completed' && ( {job.status === 'completed' && (
<Button variant="amber" size="sm" onClick={() => setDetailJobId(job.id)}> <Button variant="amber" size="sm" onClick={() => setDetailJobId(job.id)}>
<Download size={13} /> Download <Download size={13} /> Download
@@ -749,10 +782,45 @@ export default function Dashboard() {
wollte, ob die NAS noch Platz für eine Disc hat, sah die wollte, ob die NAS noch Platz für eine Disc hat, sah die
falsche Zahl (Commander-Befund 26.07.2026). */} falsche Zahl (Commander-Befund 26.07.2026). */}
<div> <div>
<div className="flex items-center justify-between text-xs font-mono mb-1"> {/*
Jeder Ort mit seinem PFAD (Commander 29.08.2026): „bei
Platz für rippy' sollte eher das arbeitsverzeichnis und
der Ablagepfad sein." Vorher stand hier eine nackte Zahl
für den ERSTEN Ort — welcher Ordner das war, sah man nicht.
*/}
{!betrieb.kann.container_pfade && (systemInfo?.plaetze || []).map(ort => (
<div key={ort.name + ort.pfad} className="mb-1.5">
<div className="flex items-center justify-between text-xs font-mono">
<span className="text-slate-400 flex items-center gap-1.5">
<HardDrive size={14} className="text-amber-400" />
{ort.name}:
</span>
<span className={ort.frei_gb > 0 && ort.frei_gb < 60
? 'text-amber-400 font-bold' : 'text-slate-200'}>
{ort.frei_gb > 0 ? `${ort.frei_gb} von ${ort.gesamt_gb} GB frei` : 'unbekannt'}
</span>
</div>
{ort.pfad && (
<p className="text-[11px] font-mono text-slate-500 truncate" title={ort.pfad}>
{ort.pfad}
</p>
)}
</div>
))}
{!betrieb.kann.container_pfade
&& (systemInfo?.plaetze || []).length > 1
&& systemInfo?.plaetze?.every(o => o.gleiches_laufwerk) && (
/* Ehrlich sagen, dass die Zahl zweimal dieselbe ist —
statt die zweite Zeile wegzulassen. */
<p className="text-[11px] font-mono text-slate-500 mb-1">
Beide auf demselben Laufwerk der Platz wird geteilt.
</p>
)}
<div className={`flex items-center justify-between text-xs font-mono mb-1 ${
betrieb.kann.container_pfade ? '' : 'hidden'}`}>
<span className="text-slate-400 flex items-center gap-1.5"> <span className="text-slate-400 flex items-center gap-1.5">
<HardDrive size={14} className="text-amber-400" /> <HardDrive size={14} className="text-amber-400" />
{betrieb.kann.container_pfade ? 'Container-Platte:' : 'Platz für Rippy:'} Container-Platte:
</span> </span>
<span className={freiGb > 0 && freiGb < 60 ? 'text-amber-400 font-bold' : 'text-slate-200'}> <span className={freiGb > 0 && freiGb < 60 ? 'text-amber-400 font-bold' : 'text-slate-200'}>
{freiGb > 0 ? `${freiGb} von ${gesamtGb} GB frei` : 'unbekannt'} {freiGb > 0 ? `${freiGb} von ${gesamtGb} GB frei` : 'unbekannt'}
+17 -7
View File
@@ -233,14 +233,24 @@ def platz_orte(werte: dict, container: bool = None,
ablage = ablage_vorgabe(werte, container, system) ablage = ablage_vorgabe(werte, container, system)
temp = arbeits_vorgabe(werte, container, system) temp = arbeits_vorgabe(werte, container, system)
orte = [{"name": "Ablage", "pfad": ablage}] orte = [{"name": "Ablage", "pfad": ablage}]
# Nur wenn es ein ANDERES Laufwerk ist, lohnt eine zweite Zeile. Zweimal # ## Warum beide Orte IMMER dastehen (Commander 29.08.2026)
# dieselbe Zahl unter zwei Namen sieht aus wie zwei Auskünfte und ist eine.
# #
# Ueber `pfade`, nicht ueber `os.path`: Auf dem Linux-Runner der Ampel # > „bei Platz für rippy' sollte eher das arbeitsverzeichnis und der
# kannte `splitdrive` kein `D:` und hielt zwei verschiedene Laufwerke # > Ablagepfad sein."
# fuer eines. #
if not pfade.gleiches_laufwerk(temp, ablage): # Hier stand vorher: zweite Zeile nur, wenn es ein ANDERES Laufwerk ist —
orte.append({"name": "Arbeitsverzeichnis", "pfad": temp}) # mit der Begründung, zweimal dieselbe Zahl unter zwei Namen sehe aus wie
# zwei Auskünfte und sei eine. Das stimmt für die ZAHL und war der falsche
# Schluss für den ORT: Auf seinem Bildschirm stand nur „Platz für Rippy:
# 59,9 von 232 GB frei" — welche Ordner das sind, war nicht zu sehen.
#
# Beides gehört hin, und beides mit Pfad. Dass die Zahl dieselbe ist,
# steht jetzt ausdrücklich dabei (`gleiches_laufwerk`), statt die Zeile
# wegzulassen.
gleich = pfade.gleiches_laufwerk(temp, ablage)
orte.append({"name": "Arbeitsverzeichnis", "pfad": temp,
"gleiches_laufwerk": gleich})
orte[0]["gleiches_laufwerk"] = gleich
return orte return orte
+25 -1
View File
@@ -301,13 +301,37 @@ def test_laufwerke_werden_seltener_abgefragt_als_jobs():
def _db_zeile(): def _db_zeile():
"""Eine Job-Zeile, wie die API sie liefert (Felder aus /jobs).""" """Eine Job-Zeile, NACHDEM `job_form` sie umgewandelt hat.
Hier stand dieselbe Zeile mit denselben Feldnamen nur habe ich sie
mir ausgedacht. Die echte Datenbankzeile heisst `disc_type` und
`created_at`, nicht `type` und `startTime`. Der Test hat meine Annahme
also bestaetigt statt sie zu pruefen, und beim Commander stand danach
1.1.1970" in der Jobliste.
Der Waechter bekommt seit dem 29.08.2026 die UMGEWANDELTE Zeile die
Umwandlung selbst prueft `test_api_smoke.py`, wo sie wohnt.
"""
return {"id": "j1", "type": "bluray", "device": r"\\.\G:", return {"id": "j1", "type": "bluray", "device": r"\\.\G:",
"startTime": "2026-08-29T12:07:02", "endTime": None, "startTime": "2026-08-29T12:07:02", "endTime": None,
"status": "processing", "progress": 12, "title": "Evangelion 2.22", "status": "processing", "progress": 12, "title": "Evangelion 2.22",
"error": None, "meta": {"confidence": 0.3}} "error": None, "meta": {"confidence": 0.3}}
def test_ohne_umwandlung_kommt_die_rohe_zeile_NICHT_durch():
"""Der Waechter darf DB-Feldnamen nicht weiterreichen.
Die rohe Zeile hat `disc_type`/`created_at`. Wer sie ungewandelt
durchreicht, schickt dem UI lauter leere Pflichtfelder und leer ist
schlimmer als fehlend: `new Date(null)` ist der 1.1.1970, sieht also aus
wie eine Auskunft.
"""
roh = {"id": "j1", "disc_type": "bluray", "status": "running",
"created_at": "2026-08-29T12:07:02", "progress": 12}
kurz = _job_kurz(roh)
assert kurz["type"] is None and kurz["startTime"] is None
def test_ein_job_ereignis_traegt_alles_was_die_oberflaeche_braucht(): def test_ein_job_ereignis_traegt_alles_was_die_oberflaeche_braucht():
"""DER Waechter. Fehlt hier ein Feld, ist der Bildschirm beim Commander """DER Waechter. Fehlt hier ein Feld, ist der Bildschirm beim Commander
leer ohne Fehlermeldung.""" leer ohne Fehlermeldung."""
+31 -2
View File
@@ -106,6 +106,27 @@ def _job_kurz(zeile: dict) -> dict:
Empfänger zum Raten.** `type` und `device` ändern sich über die Lebenszeit Empfänger zum Raten.** `type` und `device` ändern sich über die Lebenszeit
eines Jobs nie sie kosten hier nichts und ersparen dem Empfänger die eines Jobs nie sie kosten hier nichts und ersparen dem Empfänger die
Frage, ob er gerade einen ganzen oder einen halben Job vor sich hat. Frage, ob er gerade einen ganzen oder einen halben Job vor sich hat.
## ⚠️ NACHTRAG 29.08.2026 — beim ersten Anlauf falsch gemacht
Hier stand `zeile.get("startTime")` und `zeile.get("type")`. **Diese
Felder gibt es in der Datenbankzeile nicht.** Sie heißen dort `created_at`
und `disc_type`; die Übersetzung macht `_job_row_to_model` in `main.py`,
und sie bildet auch `running` auf `processing` ab.
Die Folge war schlimmer als das Problem davor: Statt eines fehlenden
Feldes kam ein leeres. Im Bildschirmfoto des Commanders stand
TYP DISC" STARTZEIT „1.1.1970, 01:00:00" STATUS running"
`new Date(null)` ist der 1. Januar 1970, und DISC" war der Rückfall,
den ich zwei Stunden vorher für 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
geschrieben habe mit meinen erfundenen Feldnamen. Ein Test, der dieselbe
Annahme macht wie der Code, prüft nichts. Deshalb wird die Zeile jetzt
zuerst durch dieselbe Umwandlung geschickt, die auch `/jobs` benutzt.
""" """
return { return {
"type": zeile.get("type"), "type": zeile.get("type"),
@@ -214,7 +235,14 @@ class Waechter:
def __init__(self, store, bus, takt: float = TAKT_SEKUNDEN, def __init__(self, store, bus, takt: float = TAKT_SEKUNDEN,
laufwerke_lesen=None, laufwerks_takt: float = LAUFWERKS_TAKT_SEKUNDEN, laufwerke_lesen=None, laufwerks_takt: float = LAUFWERKS_TAKT_SEKUNDEN,
system_lesen=None, system_takt: float = SYSTEM_TAKT_SEKUNDEN): system_lesen=None, system_takt: float = SYSTEM_TAKT_SEKUNDEN,
job_form=None):
# `job_form` uebersetzt eine DB-Zeile in die Form, die das UI kennt
# (disc_type -> type, created_at -> startTime, running -> processing).
# Eingespritzt, weil die Uebersetzung in main.py wohnt und dieses
# Paket nichts von der API weiss. Ohne sie schickte der Waechter die
# ROHE Zeile — mit Feldnamen, die es im UI nicht gibt (29.08.2026).
self._job_form = job_form or (lambda z: z)
self._store = store self._store = store
self._bus = bus self._bus = bus
self._takt = takt self._takt = takt
@@ -267,7 +295,8 @@ class Waechter:
""" """
gesendet = 0 gesendet = 0
jetzt = {z["id"]: _job_kurz(z) for z in self._store.list_jobs(limit=100)} jetzt = {z["id"]: _job_kurz(self._job_form(z))
for z in self._store.list_jobs(limit=100)}
erster_lauf = not self._jobs and self._letzte_log_id is None erster_lauf = not self._jobs and self._letzte_log_id is None
if not erster_lauf: if not erster_lauf:
for typ, job_id, daten in unterschiede(self._jobs, jetzt): for typ, job_id, daten in unterschiede(self._jobs, jetzt):
+13 -8
View File
@@ -120,13 +120,18 @@ def test_ohne_windows_gibt_es_keine_laufwerke():
assert betrieb.windows_laufwerke(art=None, buchstaben=[]) == [] assert betrieb.windows_laufwerke(art=None, buchstaben=[]) == []
@pytest.mark.parametrize("werte,container,erwartet", [ @pytest.mark.parametrize("werte,container", [
({}, True, 2), # Media + /app/temp ({}, True), # Media + /app/temp
({"storage": {"medien": "D:\\M"}}, False, 1), # gleiches Laufwerk ({"storage": {"medien": "D:\\M"}}, False), # gleiches Laufwerk
({"storage": {"medien": "D:\\M", "temp": "E:\\R"}}, False, 2), ({"storage": {"medien": "D:\\M", "temp": "E:\\R"}}, False),
]) ])
def test_platz_orte_zaehlen_nicht_doppelt(werte, container, erwartet): def test_es_stehen_immer_beide_orte_da(werte, container):
"""Zweimal dieselbe Zahl unter zwei Namen sieht aus wie zwei Auskuenfte """Ablage UND Arbeitsverzeichnis — in jedem Betrieb, mit Pfad.
und ist eine."""
Geaendert am 29.08.2026 auf Wunsch des Commanders: bei Platz für rippy'
sollte eher das arbeitsverzeichnis und der Ablagepfad sein." Vorher fiel
die zweite Zeile weg, wenn beide auf derselben Platte lagen.
"""
orte = betrieb.platz_orte(werte, container=container, plattform_name="win32") orte = betrieb.platz_orte(werte, container=container, plattform_name="win32")
assert len(orte) == erwartet assert len(orte) == 2
assert all(o["pfad"] for o in orte), orte
+31 -5
View File
@@ -165,19 +165,45 @@ def test_docker_misst_weiter_die_container_pfade():
assert [o["pfad"] for o in orte] == ["/app/media", "/app/temp"] assert [o["pfad"] for o in orte] == ["/app/media", "/app/temp"]
def test_gleiches_laufwerk_wird_nicht_doppelt_gezaehlt(): def test_beide_orte_stehen_da_MIT_pfad():
"""Zweimal dieselbe Zahl unter zwei Namen sieht aus wie zwei Auskuenfte """Geaenderte Regel (Commander 29.08.2026).
und ist eine."""
> bei Platz für rippy' sollte eher das arbeitsverzeichnis und der
> Ablagepfad sein."
Vorher fiel die zweite Zeile weg, wenn beide auf demselben Laufwerk
lagen mit der Begruendung, zweimal dieselbe Zahl sei eine Auskunft. Das
stimmt fuer die ZAHL und war der falsche Schluss fuer den ORT: Auf seinem
Bildschirm stand nur Platz für Rippy: 59,9 von 232 GB frei", und welche
Ordner gemeint waren, sah man nicht.
"""
orte = betrieb.platz_orte({"storage": {"medien": r"D:\Filme"}}, orte = betrieb.platz_orte({"storage": {"medien": r"D:\Filme"}},
container=False, plattform_name="win32") container=False, plattform_name="win32")
assert len(orte) == 1, orte assert [o["name"] for o in orte] == ["Ablage", "Arbeitsverzeichnis"]
assert orte[0]["pfad"] == r"D:\Filme"
assert orte[1]["pfad"] == r"D:\Filme\_arbeit"
def test_zweites_laufwerk_bekommt_eine_eigene_zeile(): def test_gleiches_laufwerk_wird_als_solches_GEMELDET():
"""Statt die Zeile wegzulassen: ausdruecklich sagen, dass es dieselbe
Platte ist. Sonst wirken zwei gleiche Zahlen wie ein Fehler."""
orte = betrieb.platz_orte({"storage": {"medien": r"D:\Filme"}},
container=False, plattform_name="win32")
assert all(o["gleiches_laufwerk"] for o in orte)
def test_zwei_laufwerke_werden_als_getrennt_gemeldet():
orte = betrieb.platz_orte( orte = betrieb.platz_orte(
{"storage": {"medien": r"D:\Filme", "temp": r"E:\Arbeit"}}, {"storage": {"medien": r"D:\Filme", "temp": r"E:\Arbeit"}},
container=False, plattform_name="win32") container=False, plattform_name="win32")
assert len(orte) == 2, orte assert len(orte) == 2, orte
assert not any(o["gleiches_laufwerk"] for o in orte)
def test_im_container_bleibt_es_bei_den_zwei_container_orten():
"""Der Docker-Weg darf sich durch die Aenderung NICHT verschieben."""
orte = betrieb.platz_orte({}, container=True, plattform_name="linux")
assert [o["pfad"] for o in orte] == ["/app/media", "/app/temp"]
def test_noch_nicht_angelegter_ordner_faellt_auf_das_laufwerk_zurueck(): def test_noch_nicht_angelegter_ordner_faellt_auf_das_laufwerk_zurueck():