Compare commits

3 Commits
Author SHA1 Message Date
HitonabiandClaude Opus 5 b47d21510d docs: Entscheid 6 — Faehigkeiten statt Modus-Name, und ein Setup das fragt
Ampel / ampel (push) Failing after 54s
Beide Befunde des Commanders vom 28.08.2026 festgehalten, mitsamt der
Begruendung, warum ein 'if (windows)' an dreissig Stellen die falsche
Reparatur gewesen waere: Die Oberflaeche haette weiterhin nichts ueber ihren
Betrieb gewusst, nur eine zweite Sorte Vermutung gehabt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 13:56:41 +02:00
HitonabiandClaude Opus 5 a4a045f303 feat(windows): ein echtes Setup — Voraussetzungs-Pruefung, Zielordner, Ablage
Commander: "Dann haette ich beim 'Setup' auch erwartet das nen echtes setup
passiert - wo will ich das hinspeichern, nen pre requirement check usw. Da
kommt garnichts Rippy geht einfach auf."

Er hatte recht. Ein Doppelklick installierte stillschweigend nach
%LOCALAPPDATA%\Rippy und oeffnete das Fenster. Der Nutzer wurde nichts
gefragt und erfuhr nichts -- auch nicht, ob sein Rechner ueberhaupt kann, was
Rippy braucht.

Jetzt: ein Assistent mit acht Pruefungen, zwei Ordner-Wahlen und drei
Schaltern. Auf diesem Rechner nachgemessen:

    OK  Betriebssystem      Windows 11 (Build 26200)
    OK  Fenster (WebView2)  151.0.4129.107
    OK  Optisches Laufwerk  \.\G:
    OK  HandBrakeCLI        1.11.2
     !  MakeMKV             nicht gefunden
                            -> wird von makemkv.com geholt
    OK  Speicherplatz       59.1 GB frei
    OK  Schreibrecht        C:\Users\...\AppData\Local\Rippy
    OK  Netzwerk-Port       7788 ist frei

Drei Entwurfsentscheidungen, die dazugehoeren:

* Nur ein FEHLER blockiert, eine Warnung nicht. Eine Warnung, die den Knopf
  sperrt, ist eine Bevormundung; ein Fehler, der nur warnt, ist eine Falle.
  Kein Laufwerk = Warnung (reine Komprimier-Maschine ist ein vorgesehener
  Betriebsfall). Kein Schreibrecht = Fehler.
* Jeder Befund sagt, was zu tun ist. Ein Befund ohne Abhilfe laesst den
  Nutzer ratlos zurueck -- ein Test haelt das fuer jede Pruefung fest.
* Die Pruefungen stehen in einrichtung.py, komplett ohne Fenster pruefbar.
  Im Fenstermodul steht nur Aufbau und Bruecke.

WebView2 statt Win32-Dialog: Es ist ohnehin da (Entscheid 4), der Assistent
kostet damit NULL zusaetzliche Bytes. Die Seite laedt nichts aus dem Netz --
sie muss auf einem Rechner ohne Internet aufgehen, dort wird sie am
dringendsten gebraucht. Ein Test haelt das fest.

--still und --ohne-assistent gehen weiterhin den stummen Weg. Geht das
Fenster nicht auf, wird mit den Vorgaben installiert und gesagt warum -- ein
Setup, das gar nichts tut, weil sein Fenster nicht aufging, waere schlimmer
als eines, das nicht fragt.

fix(tools): %ProgramFiles(x86)% wurde auf echten Rechnern NIE aufgeloest

Beim Bauen der Pruefung aufgefallen, und es ist ein Lehrstueck:

    Testumgebung   {"ProgramFiles(x86)": ...}  ->  C:\Program Files (x86)\...
    echtes Windows {"PROGRAMFILES(X86)": ...}  ->  ''

Windows behandelt Umgebungsvariablen ohne Ruecksicht auf die Schreibweise und
legt sie in os.environ GROSS ab. Der Vergleich in _entfalten war exakt, fand
nie eine Uebereinstimmung, und die bekannten Installationsorte fielen aus der
Kandidatenliste. MakeMKV wurde also NUR ueber die Registry-Rueckfallebene
gefunden -- wo die fehlt oder anders aussieht, fand Rippy nichts.

Aufgefallen ist es nur, weil hier zum ersten Mal eine ECHTE Umgebung benutzt
wurde. Der Test spritzte genau die Schreibweise ein, die im Muster stand: Er
war gruener als die Wirklichkeit. Beide Tests nehmen jetzt die echte
Schreibweise, und einer prueft direkt gegen os.environ. Beim Zurueckdrehen
des Fehlers rot gesehen (5 Fehlschlaege).

Ampel lokal: 691 gruen, ruff sauber. Assistent im Bildschirmfoto belegt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 13:54:59 +02:00
HitonabiandClaude Opus 5 66642b8d93 feat(ui): die Oberflaeche weiss jetzt, worauf sie laeuft
Commander-Befund 28.08.2026, zum Windows-Fenster:

    Worker erreichbar:   0 von 1
    Kein Worker antwortet — Pruefen: docker compose ps
    Container-Platte:    unbekannt
    Freigaben:           keine eingehaengt

Kein Satz davon ergibt auf einem Windows-PC einen Sinn. Sein Urteil: "Du hast
ja quasi nur rippy genommen und die docker installation fuer Windows gebaut.
Das gilt fuer die ganze standalone version fuer Windows, auch fuer die
settings und die Anleitung usw."

## Die Ursache war nicht die Anzeige

Die naheliegende Reparatur waere ein `if (windows)` an dreissig Stellen
gewesen -- dieselbe Falle noch einmal, nur mit einer zweiten Sorte Vermutung.

Es fehlte etwas anderes: Das UI hat nie erfahren, worauf es laeuft. config.py
kennt das Profil seit V2-2, weitergegeben wurde es nie. Also hat das UI
angenommen.

Neu: GET /betrieb meldet FAEHIGKEITEN, keinen Modus-Namen.

    externe_worker        Gibt es andere Maschinen, die Jobs uebernehmen?
    freigaben_einhaengen  Kann Rippy Netzwerk-Freigaben selbst einhaengen?
    container_pfade       Sind Pfade wie /app/media ueberhaupt gemeint?
    werkzeuge_verwalten   Kann Rippy MakeMKV/HandBrake selbst beschaffen?

Ein Modus-Name wuerde das UI zwingen, aus einem Namen auf Verhalten zu
schliessen -- und das bricht beim naechsten Betriebsfall: Ein
Docker-All-in-One hat Container-Pfade, aber keinen zweiten Worker.

## Was sich sichtbar aendert (auf Windows nachgemessen)

    Server-Status  "Rippy arbeitet: auf diesem Rechner" statt Worker-Zaehler
                   "Platz fuer Rippy: 59.2 von 232 GB frei" statt "unbekannt"
                   Freigaben-Block faellt weg
    Einstellungen  kein Reiter "Worker"; Speicherziele BLEIBT (dort steht die
                   Ablage -- "wo will ich das hinspeichern" war die Frage),
                   aber mit Pfadfeld statt Container-Auswahl und ohne die
                   Maske zum Einhaengen
    Anleitung      kein docker, kein Encoding-Worker-Abschnitt, stattdessen
                   der echte Ordner (C:\Users\...\Videos\Rippy)

## Zwei Fehler, die dabei aufgefallen sind

* MEDIA_ROOT = "/app/media" war in main.py fest verdrahtet. shutil.disk_usage
  warf unter Windows, die Liste blieb leer -- daher "unbekannt", obwohl auf
  dem Laufwerk 59 GB frei waren. Eine Nichtauskunft, die wie eine Auskunft
  aussieht. platz_orte() liefert die Orte jetzt je Betrieb, und ein noch
  nicht angelegter Ordner faellt auf das naechste vorhandene Elternteil
  zurueck.
* Der SSE-Schnappschuss enthielt den Server-Zustand NICHT, und der Waechter
  schickt ihn nur alle 15 Sekunden. Nach jedem Neuladen stand deshalb bis zu
  eine Viertelminute "unbekannt" da. Jetzt ist er im Schnappschuss, und das
  UI uebernimmt ihn auch von dort.

Dazu: der Windows-Skip in test_api_smoke.py ist weg. Er stammte aus der Zeit
vor V2-4, als main.py fcntl brauchte; seit der Treiberwahl ueber den Port
laedt es auf beiden Plattformen (57 Routen, gemessen). Damit laufen 20 Tests
mehr auch lokal statt nur auf der Ampel.

Ampel lokal: 654 gruen, ruff sauber. Docker-Zweig durch Unit-Tests gedeckt,
am echten Container noch nicht gegengeprueft -- das kommt beim Deploy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 13:45:00 +02:00
19 changed files with 2034 additions and 59 deletions
+54
View File
@@ -1362,8 +1362,62 @@ Black-Box-Trennung aus `KONZEPT.md` § 6. Beides bleibt gültig.
`vendor/`-Ordner für MakeMKV auf der VM. Ein Binärblob in git wäre bei jedem `vendor/`-Ordner für MakeMKV auf der VM. Ein Binärblob in git wäre bei jedem
Klon dabei und bei jedem Update ein zweites Mal. Klon dabei und bei jedem Update ein zweites Mal.
### Entscheid 6 — Die Oberfläche kennt ihren Betrieb, und das Setup fragt
Zwei Befunde des Commanders am 28.08.2026, beide am selben Punkt:
> „Du hast ja quasi nur rippy genommen und die docker installation für Windows
> gebaut. […] Das gilt für die ganze standalone version für Windows, auch für
> die settings und die Anleitung usw."
> „Dann hätte ich beim Setup' auch erwartet das nen echtes setup passiert —
> wo will ich das hinspeichern, nen pre requirement check usw. Da kommt
> garnichts Rippy geht einfach auf."
#### 6a — Fähigkeiten statt Modus-Name
Im Windows-Fenster stand `Worker erreichbar: 0 von 1`, `Container-Platte:
unbekannt` und `Prüfen: docker compose ps`. Kein Satz davon ergibt dort einen
Sinn.
Die naheliegende Reparatur wäre ein `if (windows)` an dreißig Stellen gewesen
— dieselbe Falle noch einmal, nur mit einer zweiten Sorte Vermutung. Es
fehlte etwas anderes: **Das UI hat nie erfahren, worauf es läuft.**
`GET /betrieb` meldet deshalb **Fähigkeiten**, keinen Namen:
externe_worker Gibt es andere Maschinen, die Jobs übernehmen?
freigaben_einhaengen Kann Rippy Netzwerk-Freigaben selbst einhängen?
container_pfade Sind Pfade wie /app/media überhaupt gemeint?
werkzeuge_verwalten Kann Rippy MakeMKV/HandBrake selbst beschaffen?
Ein Modus-Name würde das UI zwingen, aus einem Namen auf Verhalten zu
schließen — und das bricht beim nächsten Betriebsfall: Ein Docker-All-in-One
hat Container-Pfade, aber keinen zweiten Worker.
#### 6b — Ein Setup, das prüft und fragt
Acht Voraussetzungs-Prüfungen, zwei Ordner-Wahlen (Programm **und** Ablage),
Port, drei Schalter. Drei Regeln:
1. **Nur ein Fehler blockiert.** Eine Warnung, die den Knopf sperrt, ist eine
Bevormundung; ein Fehler, der nur warnt, ist eine Falle. Kein optisches
Laufwerk ist eine Warnung — eine reine Komprimier-Maschine ist ein
vorgesehener Betriebsfall.
2. **Jeder Befund sagt, was zu tun ist.** Ein Test hält das für jede Prüfung
fest.
3. **Die Prüfungen kennen kein Fenster.** Sie stehen in `einrichtung.py` und
sind vollständig ohne WebView2 prüfbar; im Fenstermodul steht nur Aufbau
und Brücke.
WebView2 statt Win32-Dialog: Es ist wegen Entscheid 4 ohnehin da — der
Assistent kostet **null zusätzliche Bytes**. Die Seite lädt nichts aus dem
Netz; sie muss auf einem Rechner ohne Internet aufgehen.
--- ---
### Was jetzt noch fehlt, bevor gebaut wird ### Was jetzt noch fehlt, bevor gebaut wird
- **`KONZEPT.md` § 10 fortschreiben** — Entscheid 2 widerspricht dem Eintrag vom - **`KONZEPT.md` § 10 fortschreiben** — Entscheid 2 widerspricht dem Eintrag vom
+8
View File
@@ -639,6 +639,14 @@ Medien.
- [x] Symbol mit allen Größen, die Windows holt (16/32/48/256) — vorher steckte - [x] Symbol mit allen Größen, die Windows holt (16/32/48/256) — vorher steckte
nur 256×256 in der `.ico`, und Desktop, Startmenü und Taskleiste zeigten nur 256×256 in der `.ico`, und Desktop, Startmenü und Taskleiste zeigten
ein leeres Blatt ein leeres Blatt
- [x] **Ein echtes Setup** (`einrichtung.py`, `setup_fenster.py`): acht
Voraussetzungs-Prüfungen, Zielordner UND Ablage zur Wahl, Port,
Autostart/Verknüpfungen/Werkzeuge als Schalter. Vorher installierte der
Doppelklick stillschweigend und öffnete das Fenster.
- [x] **Die Oberfläche kennt ihren Betrieb** (`betrieb.py`, `GET /betrieb`,
`useBetrieb.tsx`): keine Worker-Zähler, keine Container-Platte, kein
`docker compose ps` mehr im Windows-Client — und der Docker-Betrieb
behält alles.
- [ ] Standby blocken via `SetThreadExecutionState`, ohne `ES_DISPLAY_REQUIRED` - [ ] Standby blocken via `SetThreadExecutionState`, ohne `ES_DISPLAY_REQUIRED`
**offen** **offen**
- **Fertig, wenn:** auf einem frischen Win-11-Rechner gilt: Installer → - **Fertig, wenn:** auf einem frischen Win-11-Rechner gilt: Installer →
+101 -8
View File
@@ -453,9 +453,45 @@ async def _snapshot() -> dict:
# „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.
zustand["devices"] = None zustand["devices"] = None
# ⚠️ Der Server-Zustand gehört MIT in den Schnappschuss (Befund 28.08.2026).
#
# Er fehlte, und der Wächter schickt ihn nur alle 15 Sekunden. Ein frisch
# geladenes Dashboard stand deshalb bis zu einer Viertelminute auf
# „Platz für Rippy: unbekannt" — und das sieht aus wie eine Auskunft,
# obwohl es keine ist. Genau das hat der Commander im Windows-Fenster
# gesehen.
#
# Mit Zeitgrenze, aus demselben Grund wie bei den Laufwerken: `disk_usage`
# auf einer toten Netzfreigabe hängt im Kernel.
try:
zustand.update(await asyncio.wait_for(system_stand_fuer_snapshot(),
timeout=5))
except Exception: # noqa: BLE001
# Bewusst breit und bewusst still: Der Schnappschuss ist die Grundlage
# fuer ALLES im UI. Lieber ohne Platzangabe (das UI zeigt dann seinen
# alten Stand) als gar kein Schnappschuss.
pass
return zustand return zustand
async def system_stand_fuer_snapshot() -> dict:
"""Platz, Worker und Ablageziele — dieselben Felder wie im Wächter-Takt.
Getrennte Funktion, damit `_snapshot` und der Wächter dasselbe liefern.
Zwei Wege, die dieselbe Kachel füllen, aber verschiedene Felder schicken,
wären eine Anzeige, die je nach Zeitpunkt anders aussieht.
"""
info, ablagen = await asyncio.gather(
system_info(), storage_targets(), return_exceptions=True)
stand = {}
if not isinstance(info, Exception):
stand["info"] = info
if not isinstance(ablagen, Exception):
stand["ablagen"] = ablagen
return stand
def _sse_rahmen(ereignis: dict) -> str: def _sse_rahmen(ereignis: dict) -> str:
"""Ein Ereignis im SSE-Format. `id:` ist die Basis der Wiederaufnahme.""" """Ein Ereignis im SSE-Format. `id:` ist die Basis der Wiederaufnahme."""
nutzlast = json.dumps(ereignis, ensure_ascii=False) nutzlast = json.dumps(ereignis, ensure_ascii=False)
@@ -1846,6 +1882,45 @@ async def browse_mkdir(request: MkdirRequest):
# ehrlich beantworten. # ehrlich beantworten.
@app.get("/betrieb")
async def betrieb_auskunft():
"""In welchem Betrieb laeuft Rippy — und was kann dieser Betrieb?
## Warum es diese Route gibt (Commander-Befund 28.08.2026)
Im Windows-Fenster stand auf der Server-Status-Kachel:
Worker erreichbar: 0 von 1
Kein Worker antwortet — Pruefen: docker compose ps
Container-Platte: unbekannt
Freigaben: keine eingehaengt
Kein Satz davon ergibt auf einem Windows-PC einen Sinn. Es gibt keinen
Container, kein `docker compose`, keinen zweiten Worker — Rippy rippt
dort selbst.
Die Ursache war nicht die Anzeige, sondern was ihr fehlte: **Das UI hat
nie erfahren, worauf es laeuft.** `config.py` kennt das Profil seit
V2-2, weitergegeben wurde es nie. Also hat das UI angenommen.
Geantwortet wird mit FAEHIGKEITEN, nicht mit einem Modus-Namen — sonst
muesste die Oberflaeche aus einem Namen auf Verhalten schliessen, und
das bricht beim naechsten Betriebsfall. Begruendung vollstaendig in
`src/rippy/betrieb.py`.
"""
from rippy import betrieb as betriebs_auskunft
from rippy import config as rippy_config
try:
werte = rippy_config.laden()
except Exception: # noqa: BLE001
# Eine unlesbare Konfiguration darf die Oberflaeche nicht lahmlegen —
# sie bekommt dann die Vorgaben, und die stimmen fuer den haeufigsten
# Fall. Der Fehler faellt an anderer Stelle laut auf.
werte = {}
return betriebs_auskunft.auskunft(werte)
@app.get("/system/werkzeuge") @app.get("/system/werkzeuge")
async def system_werkzeuge(): async def system_werkzeuge():
"""Was ist installiert, wo, in welcher Fassung — und gibt es Neueres? """Was ist installiert, wo, in welcher Fassung — und gibt es Neueres?
@@ -2119,17 +2194,35 @@ async def system_info():
Worker, freier Platz auf Media- und Arbeits-Volume, MakeMKV-Key-Status.""" Worker, freier Platz auf Media- und Arbeits-Volume, MakeMKV-Key-Status."""
def sammle(): def sammle():
info = {"api_version": app.version, "plaetze": [], "workers": db.list_workers()} info = {"api_version": app.version, "plaetze": [], "workers": db.list_workers()}
for name, pfad in (("Media (/app/media)", MEDIA_ROOT), # ⚠️ NICHT fest /app/media und /app/temp (Befund 28.08.2026).
("Arbeitsverzeichnis (/app/temp)", "/app/temp")): #
# Beide Pfade gibt es unter Windows nicht. `shutil.disk_usage` warf,
# die Liste blieb leer, und im Windows-Fenster stand "Platz fuer
# Rippy: unbekannt" — obwohl auf dem Laufwerk natuerlich Platz war.
# Eine Nichtauskunft, die wie eine Auskunft aussieht.
from rippy import betrieb as betriebs_auskunft
from rippy import config as rippy_config
try:
werte = rippy_config.laden()
except Exception: # noqa: BLE001
werte = {}
for ort in betriebs_auskunft.platz_orte(werte):
# Frisch installiert gibt es den Ablage-Ordner noch nicht. Dann
# das naechste vorhandene Elternverzeichnis messen: Der Nutzer
# will wissen, ob auf dem LAUFWERK Platz ist.
pfad = betriebs_auskunft.naechster_vorhandener(ort["pfad"])
if not pfad:
continue
try: try:
nutzung = shutil.disk_usage(pfad) nutzung = shutil.disk_usage(pfad)
info["plaetze"].append({
"name": name,
"frei_gb": round(nutzung.free / 1024**3, 1),
"gesamt_gb": round(nutzung.total / 1024**3, 1),
})
except OSError: except OSError:
pass continue
info["plaetze"].append({
"name": ort["name"],
"frei_gb": round(nutzung.free / 1024**3, 1),
"gesamt_gb": round(nutzung.total / 1024**3, 1),
})
einstellungen = db.get_settings() einstellungen = db.get_settings()
info["makemkv_key_ui"] = bool((einstellungen.get("makemkvAppKey") or "").strip()) info["makemkv_key_ui"] = bool((einstellungen.get("makemkvAppKey") or "").strip())
info["webhook_gesetzt"] = bool((einstellungen.get("notificationWebhook") or "").strip()) info["webhook_gesetzt"] = bool((einstellungen.get("notificationWebhook") or "").strip())
+83 -11
View File
@@ -2,16 +2,20 @@
Warum: Kein anderer Test importiert main.py — ein Tippfehler dort fiele sonst Warum: Kein anderer Test importiert main.py — ein Tippfehler dort fiele sonst
erst beim Container-Start auf (und die Ampel bliebe fälschlich grün). erst beim Container-Start auf (und die Ampel bliebe fälschlich grün).
Läuft nur unter Linux (detection.py nutzt fcntl/ioctl), also genau dort, ## Warum das hier nicht mehr uebersprungen wird (28.08.2026)
wo auch die Ampel läuft.
Bis V2-4 stand hier ein `pytest.skip` fuer Windows: `main.py` importierte
`rippy.drives.linux` direkt, und das braucht `fcntl`. Seit die Treiberwahl
ueber den Port `rippy.ports.Drives` laeuft, laedt `main.py` auf BEIDEN
Plattformen — nachgemessen: 57 Routen, sauberer Import.
Das Ueberspringen war damit nicht mehr Vorsicht, sondern eine Luecke: Diese
siebzehn Tests liefen nur auf der Ampel. Und ein Test, der nur auf einer
Plattform greift, ist eine halbe Zusage — genau daran ist die Ampel am
28.08.2026 fuenf Laeufe lang unbemerkt rot gewesen.
""" """
import sys import pytest # noqa: F401 (einzelne Tests brauchen ihn fuer skipif)
import pytest
if sys.platform == "win32": # pragma: no cover
pytest.skip("detection.py braucht fcntl (Linux)", allow_module_level=True)
def test_main_importierbar_und_routen_verdrahtet(): def test_main_importierbar_und_routen_verdrahtet():
@@ -27,6 +31,9 @@ def test_main_importierbar_und_routen_verdrahtet():
# Der Hauptweg für 4K-UHD (25.07.2026): makemkvcon holt Disc-Schluessel # Der Hauptweg für 4K-UHD (25.07.2026): makemkvcon holt Disc-Schluessel
# unter Linux nie selbst, sie kommen von Hand über diesen Endpunkt. # unter Linux nie selbst, sie kommen von Hand über diesen Endpunkt.
"/system/keystore", "/system/keystore",
# Ohne die weiss das UI nicht, worauf es laeuft — und zeigt dann auf
# einem Windows-PC "Pruefen: docker compose ps" (Befund 28.08.2026).
"/betrieb",
): ):
assert pfad in routen, f"Route {pfad} fehlt" assert pfad in routen, f"Route {pfad} fehlt"
@@ -317,9 +324,18 @@ def test_snapshot_meldet_unlesbare_laufwerke_als_none(monkeypatch):
assert zustand["jobs"] == [] assert zustand["jobs"] == []
def test_snapshot_liefert_alle_vier_bereiche(monkeypatch): def test_snapshot_liefert_das_ganze_bild(monkeypatch):
"""Ein Client, der sich verbindet, bekommt das GANZE Bild — sonst muesste """Ein Client, der sich verbindet, bekommt das GANZE Bild — sonst muesste
er den Rest raten und faellt auf Polling zurueck.""" er den Rest raten und faellt auf Polling zurueck.
## Warum der Server-Zustand dazugehoert (Befund 28.08.2026)
Er fehlte im Schnappschuss, und der Waechter schickt ihn nur alle 15
Sekunden. Ein frisch geladenes Dashboard stand deshalb bis zu einer
Viertelminute auf „Platz fuer Rippy: unbekannt" — und das sieht aus wie
eine Auskunft, obwohl es keine ist. Genau das hat der Commander im
Windows-Fenster gesehen.
"""
import asyncio import asyncio
import main import main
@@ -328,9 +344,65 @@ def test_snapshot_liefert_alle_vier_bereiche(monkeypatch):
monkeypatch.setattr(main.db, "list_workers", lambda: [{"name": "pc"}]) monkeypatch.setattr(main.db, "list_workers", lambda: [{"name": "pc"}])
monkeypatch.setattr(main.db, "list_logs", lambda limit=50: []) monkeypatch.setattr(main.db, "list_logs", lambda limit=50: [])
monkeypatch.setattr(main.device_discovery, "list_optical_devices", lambda: []) monkeypatch.setattr(main.device_discovery, "list_optical_devices", lambda: [])
# Ohne DB liefe system_info() in eine Ausnahme, und die Ampel hat keine
# Datenbank (Lauf 170). Geprueft wird hier die FORM des Schnappschusses.
monkeypatch.setattr(main.db, "get_settings", lambda: {})
zustand = asyncio.run(main._snapshot()) zustand = asyncio.run(main._snapshot())
assert set(zustand) == {"jobs", "workers", "logs", "devices"} assert {"jobs", "workers", "logs", "devices"} <= set(zustand)
assert "info" in zustand, (
"Ohne den Server-Zustand steht das Dashboard nach jedem Neuladen "
"bis zu 15 Sekunden auf 'unbekannt'."
)
assert zustand["devices"] == [] # wirklich leer, nicht „unbekannt" assert zustand["devices"] == [] # wirklich leer, nicht „unbekannt"
assert zustand["workers"] == [{"name": "pc"}] assert zustand["workers"] == [{"name": "pc"}]
def test_ein_kaputter_server_zustand_kippt_den_schnappschuss_nicht(monkeypatch):
"""Der Schnappschuss ist die Grundlage fuer ALLES im UI. Lieber ohne
Platzangabe (das UI behaelt seinen Stand) als gar kein Schnappschuss."""
import asyncio
import main
monkeypatch.setattr(main.db, "list_jobs", lambda limit=50: [])
monkeypatch.setattr(main.db, "list_workers", lambda: [])
monkeypatch.setattr(main.db, "list_logs", lambda limit=50: [])
monkeypatch.setattr(main.device_discovery, "list_optical_devices", lambda: [])
async def platzt():
raise RuntimeError("Platte weg")
monkeypatch.setattr(main, "system_stand_fuer_snapshot", platzt)
zustand = asyncio.run(main._snapshot())
assert {"jobs", "workers", "logs", "devices"} <= set(zustand)
def test_betrieb_meldet_faehigkeiten_statt_nur_einen_namen():
"""Der Befund vom 28.08.2026: Das Windows-Fenster zeigte "Worker
erreichbar: 0 von 1", "Container-Platte" und "Pruefen: docker compose ps"
— auf einem PC ohne Container und ohne zweiten Worker.
Das UI muss FAEHIGKEITEN bekommen, keinen Modus-Namen: Aus einem Namen
auf Verhalten zu schliessen bricht beim naechsten Betriebsfall (ein
Docker-All-in-One hat Container-Pfade, aber keine externen Worker).
"""
from fastapi.testclient import TestClient
from main import app
# OHNE `with`: Der Kontextmanager loest den Lebenszyklus aus, und der legt
# Tabellen an — auf der Ampel gibt es keine Datenbank. Genau daran ist
# Lauf 170 rot geworden. /betrieb braucht keine.
antwort = TestClient(app).get("/betrieb")
assert antwort.status_code == 200
daten = antwort.json()
assert daten["modus"] in ("standalone", "verteilt")
assert daten["plattform"] in ("windows", "linux", "macos")
assert set(daten["kann"]) == {"externe_worker", "freigaben_einhaengen",
"container_pfade", "werkzeuge_verwalten"}
assert all(isinstance(w, bool) for w in daten["kann"].values())
# Ein Hinweis auf docker compose darf NUR im Container erscheinen.
if not daten["im_container"]:
assert daten["hilfe_befehl"] == ""
+9 -3
View File
@@ -7,6 +7,7 @@ import AnleitungPage from './pages/Anleitung'
import FirstRunWizard from './components/FirstRunWizard' import FirstRunWizard from './components/FirstRunWizard'
import { api } from './lib/api' import { api } from './lib/api'
import { EventStreamProvider, useStrom } from './lib/useEventStream' import { EventStreamProvider, useStrom } from './lib/useEventStream'
import { BetriebProvider } from './lib/useBetrieb'
type Page = 'dashboard' | 'anleitung' | 'logs' | 'settings' type Page = 'dashboard' | 'anleitung' | 'logs' | 'settings'
@@ -123,9 +124,14 @@ export default function App() {
// Der Provider haelt GENAU EINE SSE-Verbindung fuer die ganze Anwendung. // Der Provider haelt GENAU EINE SSE-Verbindung fuer die ganze Anwendung.
// Ein Hook je Komponente waere eine Verbindung je Komponente — also das // Ein Hook je Komponente waere eine Verbindung je Komponente — also das
// alte Problem in neuer Form. // alte Problem in neuer Form.
// BetriebProvider ganz aussen: Was Rippy anzeigen DARF, entscheidet sich
// vor allem anderen. Ohne ihn zeigte das Windows-Fenster "Pruefen: docker
// compose ps" — einen Rat, den dort niemand befolgen kann.
return ( return (
<EventStreamProvider> <BetriebProvider>
<AppInhalt /> <EventStreamProvider>
</EventStreamProvider> <AppInhalt />
</EventStreamProvider>
</BetriebProvider>
) )
} }
+133
View File
@@ -0,0 +1,133 @@
/*
* Worauf läuft Rippy — und was kann dieser Betrieb überhaupt?
*
* ## Der Befund des Commanders (28.08.2026)
*
* Im Windows-Fenster stand auf der Server-Status-Kachel:
*
* Worker erreichbar: 0 von 1
* Kein Worker antwortet — ohne ihn läuft kein Rip. Prüfen: docker compose ps
* Container-Platte: unbekannt
* Freigaben: keine eingehängt
*
* Kein Satz davon ergibt auf einem Windows-PC einen Sinn. Es gibt keinen
* Container, kein `docker compose`, keinen zweiten Worker — Rippy rippt dort
* selbst. Sein Urteil: „Du hast ja quasi nur rippy genommen und die docker
* installation für Windows gebaut."
*
* ## Warum ein Provider und kein Abruf je Seite
*
* Dieselbe Begründung wie beim Ereignis-Strom nebenan: Vier Seiten, die
* dasselbe abfragen, sind vier Abrufe — und vier Gelegenheiten, dass eine
* davon einen anderen Stand hat als die anderen. Der Betrieb ändert sich zur
* Laufzeit nicht; er wird EINMAL geholt.
*
* ## Warum die Vorgabe „Docker" ist
*
* Solange die Antwort noch unterwegs ist, muss irgendetwas gelten. Die
* Docker-Annahme ist hier die richtige Vorgabe — sie zeigt MEHR, und ein kurz
* zu viel angezeigter Bereich ist harmloser als ein Bedienelement, das für
* einen Augenblick verschwindet und wieder auftaucht.
*
* Ein FEHLGESCHLAGENER Abruf ist etwas anderes als „noch unterwegs":
* `geladen` bleibt dann false, und wer das wissen will, kann es abfragen. Ein
* Verbindungsabriss ist keine Aussage über die Welt (siehe useEventStream).
*/
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
import { api } from './api'
export interface BetriebsFaehigkeiten {
/** Gibt es andere Maschinen, die Jobs übernehmen? */
externe_worker: boolean
/** Kann Rippy Netzwerk-Freigaben selbst einhängen? */
freigaben_einhaengen: boolean
/** Sind Pfade wie /app/media überhaupt gemeint? */
container_pfade: boolean
/** Kann Rippy MakeMKV/HandBrake selbst beschaffen? */
werkzeuge_verwalten: boolean
}
export interface Betrieb {
modus: 'standalone' | 'verteilt'
plattform: 'windows' | 'linux' | 'macos'
im_container: boolean
kann: BetriebsFaehigkeiten
/** Womit ein Ablage-Feld vorbelegt wird — je Betrieb ein anderer Ort. */
ablage_vorgabe: string
/** Der Befehl zum Nachsehen. LEER heißt: es gibt keinen, den der Nutzer
* ausführen könnte — dann darf auch keiner dastehen. */
hilfe_befehl: string
/** Ist die Auskunft schon da? False heißt „noch unterwegs ODER nicht
* erreichbar" — nicht „es gibt keinen Betrieb". */
geladen: boolean
}
const VORGABE: Betrieb = {
modus: 'verteilt',
plattform: 'linux',
im_container: true,
kann: {
externe_worker: true,
freigaben_einhaengen: true,
container_pfade: true,
werkzeuge_verwalten: false,
},
ablage_vorgabe: '/app/media',
hilfe_befehl: 'docker compose -p rippy ps',
geladen: false,
}
const BetriebContext = createContext<Betrieb>(VORGABE)
export function BetriebProvider({ children }: { children: ReactNode }) {
const [betrieb, setBetrieb] = useState<Betrieb>(VORGABE)
useEffect(() => {
let abgemeldet = false
api.get('/betrieb')
.then(antwort => {
const d = antwort.data
// Nur übernehmen, was WIRKLICH ankommt. Ein halb gefülltes Objekt
// hieße in JavaScript `undefined` — und `undefined` ist falsch, also
// verschwände ein Bereich stillschweigend.
if (abgemeldet || !d || !d.kann) return
setBetrieb({
modus: d.modus === 'verteilt' ? 'verteilt' : 'standalone',
plattform: d.plattform || 'linux',
im_container: !!d.im_container,
kann: {
externe_worker: !!d.kann.externe_worker,
freigaben_einhaengen: !!d.kann.freigaben_einhaengen,
container_pfade: !!d.kann.container_pfade,
werkzeuge_verwalten: !!d.kann.werkzeuge_verwalten,
},
ablage_vorgabe: d.ablage_vorgabe || '',
hilfe_befehl: d.hilfe_befehl || '',
geladen: true,
})
})
.catch(() => {
// Nichts tun. Ein misslungener Abruf ist keine Aussage über den
// Betrieb — die Vorgabe bleibt stehen, `geladen` bleibt false.
})
return () => { abgemeldet = true }
}, [])
return <BetriebContext.Provider value={betrieb}>{children}</BetriebContext.Provider>
}
export function useBetrieb(): Betrieb {
return useContext(BetriebContext)
}
/**
* Kurzform für den häufigsten Fall: Läuft Rippy als eigenständige App
* (Windows-Client oder Docker-All-in-One)?
*
* Bewusst NICHT „ist Windows": Ein Docker-All-in-One hat auch keinen zweiten
* Worker. Wer nach der Plattform fragt, obwohl er die Fähigkeit meint, baut
* die nächste falsche Annahme ein.
*/
export function useAlleinbetrieb(): boolean {
return !useBetrieb().kann.externe_worker
}
+11
View File
@@ -123,6 +123,17 @@ export function EventStreamProvider({ children }: { children: ReactNode }) {
devices: d.devices === undefined ? null : d.devices, devices: d.devices === undefined ? null : d.devices,
workers: d.workers ?? [], workers: d.workers ?? [],
logs: d.logs ?? [], logs: d.logs ?? [],
// ⚠️ Server-Zustand MIT übernehmen (Befund 28.08.2026).
//
// Er stand hier nicht, obwohl der Schnappschuss ihn liefert — und
// `system.status` kommt erst nach bis zu 15 Sekunden. Nach jedem
// Neuladen stand deshalb „Platz für Rippy: unbekannt" auf dem
// Bildschirm. Das sieht aus wie eine Auskunft und ist keine.
//
// `undefined` heißt weiterhin „nicht mitgeschickt": dann bleibt der
// alte Stand, statt auf leer zu fallen.
systemInfo: d.info !== undefined ? d.info : null,
ablagen: d.ablagen !== undefined ? d.ablagen : null,
verbunden: true, verbunden: true,
}) })
}) })
+40 -11
View File
@@ -2,6 +2,7 @@ import { ReactNode } from 'react'
import { Disc, Tv, HardDrive, Bell, Wrench, Cpu, Download, HelpCircle } from 'lucide-react' import { Disc, Tv, HardDrive, Bell, Wrench, Cpu, Download, HelpCircle } from 'lucide-react'
import { PageHeader } from '../components/ui/PageHeader' import { PageHeader } from '../components/ui/PageHeader'
import { Card, CardHeader, CardTitle, CardContent } from '../components/ui/Card' import { Card, CardHeader, CardTitle, CardContent } from '../components/ui/Card'
import { useBetrieb } from '../lib/useBetrieb'
function Abschnitt({ icon: Icon, titel, children }: { icon: any, titel: string, children: ReactNode }) { function Abschnitt({ icon: Icon, titel, children }: { icon: any, titel: string, children: ReactNode }) {
return ( return (
@@ -21,6 +22,18 @@ function Abschnitt({ icon: Icon, titel, children }: { icon: any, titel: string,
export default function AnleitungPage() { export default function AnleitungPage() {
const fett = "font-semibold text-slate-100" const fett = "font-semibold text-slate-100"
/*
* Auch die Anleitung haengt am Betrieb.
*
* Commander-Befund 28.08.2026: „Das gilt fuer die ganze standalone version
* fuer Windows, auch fuer die settings und die Anleitung usw."
*
* Eine Anleitung, die einem Windows-Nutzer erklaert, wie er Freigaben
* einhaengt und weitere Encoding-Worker aufsetzt, beschreibt ein Programm,
* das er nicht hat. Das ist schlimmer als gar keine Anleitung: Sie laesst
* ihn glauben, er habe etwas falsch gemacht.
*/
const betrieb = useBetrieb()
return ( return (
<div className="space-y-6 max-w-6xl mx-auto"> <div className="space-y-6 max-w-6xl mx-auto">
@@ -67,18 +80,28 @@ export default function AnleitungPage() {
</p> </p>
</Abschnitt> </Abschnitt>
<Abschnitt icon={HardDrive} titel="NAS &amp; Speicherziele"> <Abschnitt icon={HardDrive} titel="Wohin die Filme kommen">
{betrieb.kann.freigaben_einhaengen ? (
<p>
Einstellungen → Speicherziele: Rechner/NAS eintragen, <span className={fett}>Benutzer +
Passwort</span> angeben (Windows und die meisten NAS lehnen Gast-Zugriffe ab!), „Freigaben
auflisten", Freigabe wählen, Einhängen &amp; speichern". Danach taucht das Ziel im
„Rippen starten"-Dialog auf und wird beim Start automatisch wieder verbunden.
</p>
) : (
<p>
Einstellungen Speicherziele: Bei <span className={fett}>Ablage</span> steht der Ordner,
in dem die fertigen Filme landen. Das darf ein normaler Ordner sein
{betrieb.ablage_vorgabe ? <> (Vorgabe: <code>{betrieb.ablage_vorgabe}</code>)</> : null} oder
ein Netzwerkpfad wie <code>\NAS\Filme</code> Rippy schreibt einfach dorthin.
Ein Einhängen wie unter Linux gibt es hier nicht und ist auch nicht nötig.
</p>
)}
<p> <p>
Einstellungen → Speicherziele: Rechner/NAS eintragen, <span className={fett}>Benutzer + <span className={fett}>4K-UHD-Tipp:</span> Rohdaten sind bis 100 GB groß. Wenn der Platz
Passwort</span> angeben (Windows und die meisten NAS lehnen Gast-Zugriffe ab!), „Freigaben knapp wird (das Dashboard zeigt ihn), das Arbeitsverzeichnis unter Einstellungen
auflisten", Freigabe wählen, Einhängen &amp; speichern". Danach taucht das Ziel im Verarbeitung auf {betrieb.kann.freigaben_einhaengen ? 'eine große Freigabe' : 'ein großes Laufwerk'} legen.
„Rippen starten"-Dialog auf und wird beim Start automatisch wieder verbunden. Rippy prüft den Platz vor jedem Rip und bricht sonst mit Klartext ab.
</p>
<p>
<span className={fett}>4K-UHD-Tipp:</span> Rohdaten sind bis 100 GB groß. Wenn die
Rippy-Platte knapp ist (Dashboard zeigt den freien Platz), das Arbeitsverzeichnis unter
Einstellungen Verarbeitung auf eine große Freigabe legen. Rippy prüft den Platz vor
jedem Rip und bricht sonst mit Klartext ab.
</p> </p>
</Abschnitt> </Abschnitt>
@@ -104,6 +127,11 @@ export default function AnleitungPage() {
</p> </p>
</Abschnitt> </Abschnitt>
{/* Nur im verteilten Betrieb. Einem Windows-Nutzer zu erklaeren,
wie er weitere Encoding-Worker aufsetzt, beschreibt ein Programm,
das er nicht hat — und laesst ihn glauben, er habe etwas falsch
gemacht (Commander-Befund 28.08.2026). */}
{betrieb.kann.externe_worker && (
<Abschnitt icon={Cpu} titel="Weitere Maschinen als Encoding-Worker"> <Abschnitt icon={Cpu} titel="Weitere Maschinen als Encoding-Worker">
<p> <p>
Die Kompression kann jede Maschine im Netz übernehmen Einstellungen Worker. Die Kompression kann jede Maschine im Netz übernehmen Einstellungen Worker.
@@ -126,6 +154,7 @@ export default function AnleitungPage() {
nie eine externe Domain (die läuft über den Reverse-Proxy und blockt). nie eine externe Domain (die läuft über den Reverse-Proxy und blockt).
</p> </p>
</Abschnitt> </Abschnitt>
)}
<Abschnitt icon={Wrench} titel="MakeMKV-Beta-Key &amp; System"> <Abschnitt icon={Wrench} titel="MakeMKV-Beta-Key &amp; System">
<p> <p>
+45 -4
View File
@@ -11,6 +11,7 @@ import JobDetailModal from '../components/JobDetailModal'
import LiveLogSection from '../components/LiveLogSection' import LiveLogSection from '../components/LiveLogSection'
import RetryDialog from '../components/RetryDialog' import RetryDialog from '../components/RetryDialog'
import { useStrom } from '../lib/useEventStream' import { useStrom } from '../lib/useEventStream'
import { useBetrieb } from '../lib/useBetrieb'
interface JobMeta { interface JobMeta {
year?: number year?: number
@@ -128,6 +129,9 @@ export default function Dashboard() {
const [laufwerke, setLaufwerke] = useState<LaufwerkLive[]>([]) const [laufwerke, setLaufwerke] = useState<LaufwerkLive[]>([])
const [ablagen, setAblagen] = useState<AblageZiel[]>([]) const [ablagen, setAblagen] = useState<AblageZiel[]>([])
const strom = useStrom() const strom = useStrom()
// Was hier ueberhaupt angezeigt werden DARF, haengt am Betrieb.
// Ohne das stand im Windows-Fenster "Pruefen: docker compose ps".
const betrieb = useBetrieb()
const { toast } = useToast() const { toast } = useToast()
/* /*
@@ -678,7 +682,30 @@ export default function Dashboard() {
)} )}
</div> </div>
{/* 2. Worker — die ECHTE Erreichbarkeit, plus was sie können */} {/* 2. Wer arbeitet hier — und das hängt am BETRIEB.
⚠️ Commander-Befund 28.08.2026: Im Windows-Fenster stand
„Worker erreichbar: 0 von 1 — Kein Worker antwortet.
Prüfen: docker compose ps". Auf einem Windows-PC gibt es
keinen zweiten Worker, auf den man warten könnte, und
kein docker compose, das man prüfen könnte. Die Meldung
war nicht nur unpassend, sie war falsch. */}
{!betrieb.kann.externe_worker ? (
<div>
<div className="flex items-center justify-between text-xs font-mono mb-1">
<span className="text-slate-400 flex items-center gap-1.5">
<Cpu size={14} className="text-amber-400" /> Rippy arbeitet:
</span>
<span className="text-slate-200">
auf diesem Rechner
</span>
</div>
<p className="text-[11px] font-mono text-slate-500">
{aktiverJob
? 'Rippen und Komprimieren laufen hier — kein zweiter Rechner nötig.'
: 'Rippen und Komprimieren erledigt Rippy selbst.'}
</p>
</div>
) : (
<div> <div>
<div className="flex items-center justify-between text-xs font-mono mb-1"> <div className="flex items-center justify-between text-xs font-mono mb-1">
<span className="text-slate-400 flex items-center gap-1.5"> <span className="text-slate-400 flex items-center gap-1.5">
@@ -692,7 +719,9 @@ export default function Dashboard() {
{workerOnline.length === 0 ? ( {workerOnline.length === 0 ? (
<p className="text-[11px] font-mono text-rose-400"> <p className="text-[11px] font-mono text-rose-400">
Kein Worker antwortet ohne ihn läuft kein Rip. Kein Worker antwortet ohne ihn läuft kein Rip.
Prüfen: <span className="text-slate-300">docker compose ps</span> {betrieb.hilfe_befehl && (
<> Prüfen: <span className="text-slate-300">{betrieb.hilfe_befehl}</span></>
)}
</p> </p>
) : ( ) : (
<div className="space-y-0.5"> <div className="space-y-0.5">
@@ -713,6 +742,7 @@ export default function Dashboard() {
</div> </div>
)} )}
</div> </div>
)}
{/* 3. Platz — Container-Platte UND die eingehängten Freigaben. {/* 3. Platz — Container-Platte UND die eingehängten Freigaben.
Vorher stand hier nur die Container-Platte; wer wissen Vorher stand hier nur die Container-Platte; wer wissen
@@ -721,7 +751,8 @@ export default function Dashboard() {
<div> <div>
<div className="flex items-center justify-between text-xs font-mono mb-1"> <div className="flex items-center justify-between text-xs font-mono mb-1">
<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" /> Container-Platte: <HardDrive size={14} className="text-amber-400" />
{betrieb.kann.container_pfade ? 'Container-Platte:' : 'Platz für Rippy:'}
</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'}
@@ -744,7 +775,16 @@ export default function Dashboard() {
</div> </div>
{/* Die eingehängten Freigaben. Sie sind der Ort, an dem die {/* Die eingehängten Freigaben. Sie sind der Ort, an dem die
Rohdaten liegen und — bei externem Encoden — liegen MÜSSEN. */} Rohdaten liegen und — bei externem Encoden — liegen MÜSSEN.
⚠️ NUR im Container. Unter Windows hängt Rippy nichts ein:
Dort gibt man einen Ordner oder einen UNC-Pfad an, fertig.
Der Satz „Ohne Freigabe kann ein externer Encoder nichts
tun — er sieht die Container-Platte nicht" stand am
28.08.2026 im Windows-Fenster und ergab dort keinen Sinn:
Es gibt weder einen externen Encoder noch eine
Container-Platte. */}
{betrieb.kann.freigaben_einhaengen && (
<div> <div>
<div className="flex items-center justify-between text-xs font-mono mb-1"> <div className="flex items-center justify-between text-xs font-mono mb-1">
<span className="text-slate-400 flex items-center gap-1.5"> <span className="text-slate-400 flex items-center gap-1.5">
@@ -778,6 +818,7 @@ export default function Dashboard() {
</div> </div>
)} )}
</div> </div>
)}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
+77 -16
View File
@@ -4,6 +4,7 @@ import { api } from '../lib/api'
import { useToast } from '../context/ToastContext' import { useToast } from '../context/ToastContext'
import StorageMounts from '../components/StorageMounts' import StorageMounts from '../components/StorageMounts'
import WorkerVerwaltung from '../components/WorkerVerwaltung' import WorkerVerwaltung from '../components/WorkerVerwaltung'
import { useBetrieb } from '../lib/useBetrieb'
import { PageHeader } from '../components/ui/PageHeader' import { PageHeader } from '../components/ui/PageHeader'
import { Card, CardHeader, CardTitle, CardContent } from '../components/ui/Card' import { Card, CardHeader, CardTitle, CardContent } from '../components/ui/Card'
import { Button } from '../components/ui/Button' import { Button } from '../components/ui/Button'
@@ -48,7 +49,10 @@ const defaultSettings: SettingsState = {
tmdbApiKey: '', tmdbApiKey: '',
tvdbApiKey: '', tvdbApiKey: '',
omdbApiKey: '', omdbApiKey: '',
outputDir: '/app/media', // Kein fester Container-Pfad: Auf Windows gibt es kein /app/media. Der
// echte Vorgabewert kommt aus GET /betrieb (ablage_vorgabe) und wird
// beim Laden gesetzt.
outputDir: '',
movieDir: 'movies', movieDir: 'movies',
seriesDir: 'series', seriesDir: 'series',
musicDir: 'music', musicDir: 'music',
@@ -187,6 +191,7 @@ function zeitLesbar(iso: string): string {
export default function SettingsPage() { export default function SettingsPage() {
const [settings, setSettings] = useState<SettingsState>(defaultSettings) const [settings, setSettings] = useState<SettingsState>(defaultSettings)
const betrieb = useBetrieb()
const [activeTab, setActiveTab] = useState<SettingsTab>('ripping') const [activeTab, setActiveTab] = useState<SettingsTab>('ripping')
const [workers, setWorkers] = useState<WorkerInfo[]>([]) const [workers, setWorkers] = useState<WorkerInfo[]>([])
const [systemInfo, setSystemInfo] = useState<SystemInfo | null>(null) const [systemInfo, setSystemInfo] = useState<SystemInfo | null>(null)
@@ -485,7 +490,12 @@ export default function SettingsPage() {
const istFreigabe = (pfad: string) => const istFreigabe = (pfad: string) =>
ziele.some(z => z.path === pfad && z.is_mount) ziele.some(z => z.path === pfad && z.is_mount)
const ablageWarnung = (() => { const ablageWarnung = (() => {
const ablage = settings.outputDir || '/app/media' // ⚠️ Die ganze Warnung dreht sich um einen Encoder auf einem ANDEREN
// Rechner, der die Container-Platte nicht sieht. Ohne externe Worker gibt
// es weder den einen noch die andere — der Satz stand am 28.08.2026
// trotzdem im Windows-Fenster und riet dort zu etwas, das es nicht gibt.
if (!betrieb.kann.externe_worker) return ''
const ablage = settings.outputDir || betrieb.ablage_vorgabe
const arbeit = settings.workDir const arbeit = settings.workDir
if (!arbeit) { if (!arbeit) {
return 'Das Arbeitsverzeichnis steht auf der Container-Platte. Ein Encoder auf ' return 'Das Arbeitsverzeichnis steht auf der Container-Platte. Ein Encoder auf '
@@ -505,16 +515,45 @@ export default function SettingsPage() {
return '' return ''
})() })()
/*
* Die Reiter haengen am BETRIEB.
*
* Commander-Befund 28.08.2026: „Das gilt fuer die ganze standalone version
* fuer Windows, auch fuer die settings und die Anleitung usw."
*
* Ein Reiter „Worker" fuehrt in einer Standalone-Installation zu einer
* Maske, die Rechner in einem Netz verwaltet, das es nicht gibt. Ein Reiter
* „Speicherziele" bietet dort das Einhaengen von Freigaben an — etwas, das
* unter Windows niemand braucht (dort gibt man einen Ordner oder UNC-Pfad
* an). Beides ist keine kosmetische Frage: Wer darauf klickt, bekommt
* Bedienelemente, die ins Leere fuehren.
*/
const tabs: { id: SettingsTab; label: string; icon: any }[] = [ const tabs: { id: SettingsTab; label: string; icon: any }[] = [
{ id: 'ripping', label: 'Ripping', icon: Disc }, { id: 'ripping', label: 'Ripping', icon: Disc },
{ id: 'verarbeitung', label: 'Verarbeitung', icon: Cpu }, { id: 'verarbeitung', label: 'Verarbeitung', icon: Cpu },
{ id: 'worker', label: 'Worker', icon: Cpu }, ...(betrieb.kann.externe_worker
? [{ id: 'worker' as SettingsTab, label: 'Worker', icon: Cpu }]
: []),
// Speicherziele bleibt IMMER. Hier steht die Ablage — „wo will ich das
// hinspeichern" war die ausdrueckliche Frage des Commanders. Nur das
// EINHAENGEN von Freigaben faellt weg, wenn der Betrieb es nicht kann;
// das steckt weiter unten in einer eigenen Bedingung.
{ id: 'speicherziele', label: 'Speicherziele', icon: HardDrive }, { id: 'speicherziele', label: 'Speicherziele', icon: HardDrive },
{ id: 'apis', label: 'APIs', icon: Globe }, { id: 'apis', label: 'APIs', icon: Globe },
{ id: 'benachrichtigungen', label: 'Benachrichtigungen', icon: AlertCircle }, { id: 'benachrichtigungen', label: 'Benachrichtigungen', icon: AlertCircle },
{ id: 'system', label: 'System', icon: Wrench }, { id: 'system', label: 'System', icon: Wrench },
] ]
/*
* Ein Reiter, den es nicht mehr gibt, darf nicht ausgewaehlt bleiben.
*
* Der Betrieb kommt ERST NACH dem ersten Rendern an (ein Abruf). Wer in der
* Zwischenzeit auf „Worker" steht — oder wer den Reiter beim Neuladen aus
* dem Zustand mitbringt — saehe sonst eine leere Seite ohne jeden Hinweis.
*/
const sichtbareReiter = tabs.map(t => t.id)
const aktiverReiter = sichtbareReiter.includes(activeTab) ? activeTab : 'ripping'
return ( return (
<div className="max-w-6xl mx-auto space-y-6"> <div className="max-w-6xl mx-auto space-y-6">
<PageHeader <PageHeader
@@ -533,7 +572,7 @@ export default function SettingsPage() {
{/* Navigation Tabs */} {/* Navigation Tabs */}
<div className="flex border-b border-slate-800 overflow-x-auto [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden bg-[#0c101d]"> <div className="flex border-b border-slate-800 overflow-x-auto [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden bg-[#0c101d]">
{tabs.map((tab) => { {tabs.map((tab) => {
const isActive = activeTab === tab.id const isActive = aktiverReiter === tab.id
return ( return (
<button <button
key={tab.id} key={tab.id}
@@ -554,7 +593,7 @@ export default function SettingsPage() {
{/* Tab Content */} {/* Tab Content */}
<CardContent className="p-6 space-y-8"> <CardContent className="p-6 space-y-8">
{/* Ripping Settings */} {/* Ripping Settings */}
{activeTab === 'ripping' && ( {aktiverReiter === 'ripping' && (
<section className="space-y-6"> <section className="space-y-6">
<div> <div>
<h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100"> <h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
@@ -697,7 +736,7 @@ export default function SettingsPage() {
)} )}
{/* Verarbeitung */} {/* Verarbeitung */}
{activeTab === 'verarbeitung' && ( {aktiverReiter === 'verarbeitung' && (
<section className="space-y-6"> <section className="space-y-6">
<h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100"> <h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
<Cpu size={20} className="text-amber-500" /> <Cpu size={20} className="text-amber-500" />
@@ -906,7 +945,11 @@ export default function SettingsPage() {
value={settings.workDir} value={settings.workDir}
onChange={(e) => handleChange('workDir', e.target.value)} onChange={(e) => handleChange('workDir', e.target.value)}
> >
<option value="">Container-Platte (Standard) — klein, nur für DVD/Blu-ray</option> <option value="">
{betrieb.kann.container_pfade
? 'Container-Platte (Standard) — klein, nur für DVD/Blu-ray'
: `Standard (${betrieb.ablage_vorgabe || 'neben der Ablage'})`}
</option>
{ziele.map(z => ( {ziele.map(z => (
<option key={z.path} value={z.path}> <option key={z.path} value={z.path}>
{z.name}{z.is_mount ? ' (Netzwerk-Freigabe)' : ''} {z.name}{z.is_mount ? ' (Netzwerk-Freigabe)' : ''}
@@ -918,9 +961,10 @@ export default function SettingsPage() {
<strong className="text-slate-700 dark:text-slate-300">Der Standard</strong> — beim <strong className="text-slate-700 dark:text-slate-300">Der Standard</strong> — beim
„Rippen starten" kannst du für jede Disc etwas anderes wählen. <strong className="text-slate-700 dark:text-slate-300"> „Rippen starten" kannst du für jede Disc etwas anderes wählen. <strong className="text-slate-700 dark:text-slate-300">
Läuft die Vollautomatik</strong>, fragt dich niemand: dann gilt genau dieser Wert. Läuft die Vollautomatik</strong>, fragt dich niemand: dann gilt genau dieser Wert.
Wichtig für 4K-UHD: Rohdaten sind bis 100 GB groß und passen selten auf die Wichtig für 4K-UHD: Rohdaten sind bis 100 GB groß.
Container-Platte. Am besten dieselbe Freigabe wie das Ziel dann muss Rippy die {betrieb.kann.container_pfade
Rohdatei am Ende nur umhängen statt sie zu kopieren. ? ' Auf die Container-Platte passen die selten. Am besten dieselbe Freigabe wie das Ziel — dann muss Rippy die Rohdatei am Ende nur umhängen statt sie zu kopieren.'
: ' Am besten dasselbe Laufwerk wie die Ablage — dann muss Rippy die Rohdatei am Ende nur umhängen statt sie über die Platte zu kopieren.'}
{systemInfo?.plaetze?.length ? ( {systemInfo?.plaetze?.length ? (
<> Aktuell frei: {systemInfo.plaetze.map(p => `${p.name}: ${p.frei_gb} GB`).join(' · ')}</> <> Aktuell frei: {systemInfo.plaetze.map(p => `${p.name}: ${p.frei_gb} GB`).join(' · ')}</>
) : null} ) : null}
@@ -930,7 +974,7 @@ export default function SettingsPage() {
)} )}
{/* Worker */} {/* Worker */}
{activeTab === 'worker' && ( {aktiverReiter === 'worker' && (
<section className="space-y-6"> <section className="space-y-6">
<h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100"> <h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
<Cpu size={20} className="text-amber-500" /> <Cpu size={20} className="text-amber-500" />
@@ -941,7 +985,7 @@ export default function SettingsPage() {
)} )}
{/* Speicherziele */} {/* Speicherziele */}
{activeTab === 'speicherziele' && ( {aktiverReiter === 'speicherziele' && (
<section className="space-y-6"> <section className="space-y-6">
<h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100"> <h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
<HardDrive size={20} className="text-amber-500" /> <HardDrive size={20} className="text-amber-500" />
@@ -961,6 +1005,19 @@ export default function SettingsPage() {
<h3 className="font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100"> <h3 className="font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
<HardDrive size={16} className="text-amber-500" /> Ablage wohin die fertigen Filme kommen <HardDrive size={16} className="text-amber-500" /> Ablage wohin die fertigen Filme kommen
</h3> </h3>
{!betrieb.kann.container_pfade ? (
/* Kein Auswahlfeld, sondern ein echtes Pfadfeld: Auf einem
Windows-PC gibt es keine Liste von Container-Zielen, aus
der man waehlen koennte — es gibt Laufwerke, Ordner und
UNC-Pfade. Eine Auswahl mit einem Eintrag waere eine
Bedienung, die nichts bedient. */
<Input
label="Ordner"
value={settings.outputDir}
placeholder={betrieb.ablage_vorgabe}
onChange={(e) => handleChange('outputDir', e.target.value)}
/>
) : (
<Select <Select
label="Ablage" label="Ablage"
value={settings.outputDir} value={settings.outputDir}
@@ -974,6 +1031,7 @@ export default function SettingsPage() {
</option> </option>
))} ))}
</Select> </Select>
)}
<p className="text-xs text-slate-500 dark:text-slate-400"> <p className="text-xs text-slate-500 dark:text-slate-400">
Gilt für die Schnellwahl im Rip-Dialog <strong className="text-slate-700 dark:text-slate-300">und Gilt für die Schnellwahl im Rip-Dialog <strong className="text-slate-700 dark:text-slate-300">und
für die Vollautomatik</strong> dort fragt niemand nach, also entscheidet dieser Wert. für die Vollautomatik</strong> dort fragt niemand nach, also entscheidet dieser Wert.
@@ -1005,12 +1063,15 @@ export default function SettingsPage() {
</div> </div>
</div> </div>
<StorageMounts /> {/* Freigaben EINHAENGEN kann nur der Container-Betrieb.
Unter Windows gibt man einen Ordner oder einen UNC-Pfad an —
eine Maske zum Einhaengen fuehrte dort ins Leere. */}
{betrieb.kann.freigaben_einhaengen && <StorageMounts />}
</section> </section>
)} )}
{/* APIs */} {/* APIs */}
{activeTab === 'apis' && ( {aktiverReiter === 'apis' && (
<section className="space-y-6"> <section className="space-y-6">
<h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100"> <h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
<Globe size={20} className="text-amber-500" /> <Globe size={20} className="text-amber-500" />
@@ -1070,7 +1131,7 @@ export default function SettingsPage() {
)} )}
{/* Benachrichtigungen */} {/* Benachrichtigungen */}
{activeTab === 'benachrichtigungen' && ( {aktiverReiter === 'benachrichtigungen' && (
<section className="space-y-6"> <section className="space-y-6">
<h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100"> <h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
<AlertCircle size={20} className="text-amber-500" /> <AlertCircle size={20} className="text-amber-500" />
@@ -1120,7 +1181,7 @@ export default function SettingsPage() {
)} )}
{/* System */} {/* System */}
{activeTab === 'system' && ( {aktiverReiter === 'system' && (
<section className="space-y-6"> <section className="space-y-6">
<h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100"> <h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
<Wrench size={20} className="text-amber-500" /> <Wrench size={20} className="text-amber-500" />
+208
View File
@@ -0,0 +1,208 @@
"""In welchem Betrieb läuft Rippy — und was kann dieser Betrieb überhaupt?
## Der Befund des Commanders (28.08.2026)
Im Windows-Fenster stand auf der Server-Status-Kachel:
Worker erreichbar: 0 von 1
Kein Worker antwortet — ohne ihn läuft kein Rip. Prüfen: docker compose ps
Container-Platte: unbekannt
Freigaben: keine eingehängt
Kein einziger dieser Sätze ergibt auf einem Windows-PC einen Sinn. Es gibt
keinen Container, kein `docker compose`, keinen zweiten Worker — Rippy rippt
dort selbst. Sein Urteil:
> „Du hast ja quasi nur rippy genommen und die docker installation für Windows
> gebaut. Das macht ja aber keinen sinn da auf Windows andere dinge relevant
> sind als für den Windows Client. Das gilt für die ganze standalone version
> für Windows, auch für die settings und die Anleitung usw."
## Warum das kein Anzeigefehler ist
Die naheliegende Reparatur wäre, an dreißig Stellen `if (windows)` einzubauen.
Das wäre dieselbe Falle noch einmal: Die Oberfläche wüsste weiterhin nichts
über ihren Betrieb, sie hätte nur eine zweite Sorte Vermutung.
Die Ursache liegt tiefer: **Die Oberfläche hat nie erfahren, worauf sie
läuft.** `config.py` kennt das Profil seit V2-2, die API hat es nie
weitergegeben. Also hat das UI angenommen — und Annahmen sind in diesem
Projekt schon oft genug teuer geworden.
## Warum FÄHIGKEITEN und nicht ein Modus-Name
Ein `modus == "standalone"` würde die Oberfläche zwingen, aus einem Namen auf
Verhalten zu schließen — genau die Sorte Ableitung, die bei jedem neuen
Betriebsfall bricht. Der Kopflos-Betrieb (V2-6) hat keinen Container, aber
sehr wohl externe Worker; ein Docker-All-in-One hat einen Container, aber
keinen zweiten Worker.
Deshalb sagt Rippy, **was geht**, nicht **wie es heißt**:
externe_worker Gibt es andere Maschinen, die Jobs übernehmen?
freigaben_einhaengen Kann Rippy Netzwerk-Freigaben selbst einhängen?
container_pfade Sind Pfade wie /app/media überhaupt gemeint?
werkzeuge_verwalten Kann Rippy MakeMKV/HandBrake selbst beschaffen?
Der Modus-Name bleibt trotzdem dabei — für Fehlerberichte und Überschriften.
Aber gerechnet wird mit den Fähigkeiten.
## Was hier GEMESSEN und was gelesen wird
Wo es geht, wird nachgesehen statt geglaubt:
im_container `/.dockerenv` existiert (Docker legt sie an)
plattform `sys.platform`
externe_worker Queue-Treiber `celery` (ohne Broker gibt es keine)
`profil` aus der Konfiguration ist der Rückfall, nicht die erste Quelle.
"""
import os
import sys
# Docker legt diese Datei in jedem Container an. Sie ist das verlässlichste
# Kennzeichen, das ohne Zusatzpakete zu haben ist — verlässlicher als eine
# Umgebungsvariable, die jeder setzen (und vergessen) kann.
DOCKER_KENNZEICHEN = "/.dockerenv"
def im_container(pruefen=None) -> bool:
"""Läuft dieser Prozess in einem Container? (gemessen, nicht geraten)"""
pruefen = pruefen or os.path.exists
return bool(pruefen(DOCKER_KENNZEICHEN))
def plattform(name: str = None) -> str:
"""`windows`, `linux` oder `macos` — nie der rohe sys.platform-String."""
roh = (name if name is not None else sys.platform).lower()
if roh.startswith("win"):
return "windows"
if roh == "darwin":
return "macos"
return "linux"
def modus(werte: dict, container: bool = None) -> str:
"""Der Name des Betriebs — für Überschriften und Fehlerberichte.
`standalone` Ein Prozess macht alles (Windows-App, Docker-All-in-One)
`verteilt` Mehrere Maschinen teilen sich die Arbeit (Celery/Redis)
"""
queue = (werte or {}).get("queue", {}) or {}
if queue.get("treiber") == "celery":
return "verteilt"
profil = (werte or {}).get("profil", "standalone")
return "verteilt" if profil in ("api", "node") else "standalone"
def auskunft(werte: dict, container: bool = None,
plattform_name: str = None) -> dict:
"""Was ist dieser Betrieb, und was kann er? — die Antwort für die API.
Reine Funktion: `container` und `plattform_name` sind einspritzbar, damit
jeder Betriebsfall prüfbar ist, ohne ihn herzustellen.
"""
werte = werte or {}
container = im_container() if container is None else container
system = plattform(plattform_name)
art = modus(werte, container)
verteilt = art == "verteilt"
return {
"modus": art,
"plattform": system,
"im_container": container,
"kann": {
# Ohne Celery gibt es niemanden, der von außen Jobs übernimmt.
# Die Worker-Liste, das Worker-Setup und jede Rede von
# „Kein Worker antwortet" gehören dann nicht auf den Bildschirm.
"externe_worker": verteilt,
# Einhängen macht seit dem Entscheid vom 28.08.2026 der HOST
# (KONZEPT-V2.md § 4.3) — im Container zeigt Rippy nur noch die
# Zeile zum Kopieren. Unter Windows hängt man gar nichts ein:
# Dort gibt man einen UNC-Pfad an, fertig.
"freigaben_einhaengen": container,
# `/app/media` ist ein Pfad IM Container. Auf Windows heißt der
# Ordner anders und liegt woanders.
"container_pfade": container,
# Im Image steckt alles fest; ein Update ist ein Rebuild. Nur die
# native Installation kann ihre Werkzeuge selbst pflegen.
"werkzeuge_verwalten": not container,
},
# Was in der Oberfläche als Vorgabe stehen soll. Ein Feld, das mit
# `/app/media` vorbelegt ist, ist auf Windows schlicht falsch.
"ablage_vorgabe": ablage_vorgabe(werte, container, system),
"hilfe_befehl": "docker compose -p rippy ps" if container else "",
}
def naechster_vorhandener(pfad: str, existiert=None) -> str:
"""Der nächste Ordner nach oben, den es WIRKLICH gibt. "" wenn keiner.
Frisch installiert gibt es den Ablage-Ordner noch nicht — `disk_usage`
wirft dann, und im Dashboard stand „unbekannt". Der Nutzer will aber
wissen, ob auf dem LAUFWERK Platz ist, und das lässt sich beantworten.
"""
existiert = existiert or os.path.isdir
pfad = (pfad or "").rstrip("\\/")
gesehen = set()
while pfad and pfad not in gesehen:
if existiert(pfad):
return pfad
gesehen.add(pfad)
eltern = os.path.dirname(pfad)
if eltern == pfad: # Wurzel erreicht
break
pfad = eltern
return ""
def platz_orte(werte: dict, container: bool = None,
plattform_name: str = None) -> list:
"""Wo Platz gemessen werden soll — je Betrieb andere Orte.
## Warum das nicht fest verdrahtet sein darf (28.08.2026)
In `main.py` stand:
for name, pfad in (("Media (/app/media)", MEDIA_ROOT),
("Arbeitsverzeichnis (/app/temp)", "/app/temp")):
Beide Pfade gibt es unter Windows nicht. `shutil.disk_usage` warf, die
Liste blieb leer, und im Windows-Fenster stand **„Platz für Rippy:
unbekannt"** — obwohl auf dem Laufwerk natürlich Platz war. Eine
Nichtauskunft, die wie eine Auskunft aussieht.
Gibt eine Liste von `{"name": …, "pfad": …}` zurück; der Aufrufer misst.
"""
werte = werte or {}
container = im_container() if container is None else container
system = plattform(plattform_name)
lager = werte.get("storage", {}) or {}
if container:
return [{"name": "Media (/app/media)", "pfad": "/app/media"},
{"name": "Arbeitsverzeichnis (/app/temp)", "pfad": "/app/temp"}]
ablage = ablage_vorgabe(werte, container, system)
temp = lager.get("temp") or os.path.join(ablage, "_arbeit")
orte = [{"name": "Ablage", "pfad": ablage}]
# Nur wenn es ein ANDERES Laufwerk ist, lohnt eine zweite Zeile. Zweimal
# dieselbe Zahl unter zwei Namen sieht aus wie zwei Auskünfte und ist eine.
if os.path.splitdrive(temp)[0].lower() != os.path.splitdrive(ablage)[0].lower():
orte.append({"name": "Arbeitsverzeichnis", "pfad": temp})
return orte
def ablage_vorgabe(werte: dict, container: bool, system: str) -> str:
"""Wohin Rippy standardmäßig ablegt — je Betrieb ein anderer Ort."""
eigen = ((werte or {}).get("storage", {}) or {}).get("medien", "")
if eigen:
return eigen
if container:
return "/app/media"
if system == "windows":
basis = os.environ.get("USERPROFILE") or os.path.expanduser("~")
return os.path.join(basis, "Videos", "Rippy")
return os.path.join(os.path.expanduser("~"), "Videos", "Rippy")
+260
View File
@@ -0,0 +1,260 @@
"""Der Einrichtungs-Assistent: prüfen, fragen, installieren.
## Der Befund des Commanders (28.08.2026)
> „Dann hätte ich beim Setup' auch erwartet das nen echtes setup passiert —
> wo will ich das hinspeichern, nen pre requirement check usw. Da kommt
> garnichts Rippy geht einfach auf."
Er hat recht. Ein Doppelklick auf `RippySetup.exe` installierte stillschweigend
nach `%LOCALAPPDATA%\\Rippy` und öffnete das Fenster. Der Nutzer wurde nichts
gefragt und erfuhr nichts — auch nicht, ob sein Rechner überhaupt kann, was
Rippy braucht. Wenn hinterher etwas fehlte, stand er davor und wusste nicht,
seit wann.
## Was hier passiert und was NICHT
Dieses Modul enthält die **Prüfungen und die Entscheidungen** — keine
Oberfläche. Das Fenster steckt in `setup_fenster.py`, damit hier alles ohne
WebView2, ohne Fenster und ohne Installation prüfbar bleibt.
## Die drei Sorten Befund
ok geht
warnung geht, aber der Nutzer sollte es wissen
fehler geht NICHT — die Installation würde nichts nützen
Nur `fehler` blockiert. Eine Warnung, die den Knopf sperrt, ist eine
Bevormundung; ein Fehler, der nur warnt, ist eine Falle. Ein Beispiel für
jede Sorte:
* **Kein optisches Laufwerk** ist eine WARNUNG. Rippy ohne Laufwerk ist eine
reine Komprimier-Maschine — ein vorgesehener Betriebsfall (`install.sh` sagt
das seit Langem: „läuft trotzdem durch").
* **Kein MakeMKV** ist eine WARNUNG, solange Rippy es holen kann.
* **Kein Schreibrecht am Zielordner** ist ein FEHLER. Da hilft kein Zureden.
## Warum jede Prüfung sagt, was zu tun ist
Ein Befund ohne Abhilfe lässt den Nutzer ratlos zurück. Deshalb hat jeder
Eintrag ein `abhilfe`-Feld — leer nur dann, wenn wirklich nichts zu tun ist.
"""
import os
import shutil
import socket
import sys
OK = "ok"
WARNUNG = "warnung"
FEHLER = "fehler"
# Was Rippy mindestens braucht. Gemessen, nicht geschätzt: Das Paket allein ist
# 56 MB, dazu die entpackte HandBrake-Fassung (~74 MB), die Datenbank und der
# WebView2-Zwischenspeicher. 500 MB sind reichlich und lassen Luft.
MINDESTPLATZ_MB = 500
STANDARD_PORT = 7788
def _eintrag(name, zustand, text, abhilfe=""):
return {"name": name, "zustand": zustand, "text": text, "abhilfe": abhilfe}
# ── Die einzelnen Prüfungen ─────────────────────────────────────────────
def pruefe_windows(version=None) -> dict:
"""Windows 10 oder neuer. Darunter gibt es kein WebView2."""
if os.name != "nt":
return _eintrag("Betriebssystem", FEHLER, "Kein Windows",
"Für Linux gibt es die Docker- und die Headless-Fassung.")
bau = version if version is not None else sys.getwindowsversion().build
if bau >= 22000:
return _eintrag("Betriebssystem", OK, "Windows 11 (Build %d)" % bau)
if bau >= 10240:
return _eintrag("Betriebssystem", OK, "Windows 10 (Build %d)" % bau)
return _eintrag("Betriebssystem", FEHLER, "Windows-Build %d ist zu alt" % bau,
"Rippy braucht mindestens Windows 10.")
def pruefe_webview(version=None) -> dict:
"""Ohne WebView2 gibt es kein Fenster — aber Rippy läuft trotzdem.
Deshalb WARNUNG und nicht Fehler: Der Rückfall auf den Browser
funktioniert, er ist nur weniger schön.
"""
if version is None:
from rippy import fenster
version = fenster.webview2_version()
if version:
return _eintrag("Fenster (WebView2)", OK, version)
return _eintrag(
"Fenster (WebView2)", WARNUNG, "nicht gefunden",
'Rippy öffnet sich dann im Browser. Die Laufzeit gibt es kostenlos '
'bei Microsoft (Suchwort „WebView2 Evergreen Runtime").')
def pruefe_platz(ziel: str, frei_mb=None) -> dict:
"""Genug Platz am Zielort? Gemessen am nächsten vorhandenen Ordner."""
from rippy import betrieb
if frei_mb is None:
pfad = betrieb.naechster_vorhandener(ziel)
if not pfad:
return _eintrag("Speicherplatz", FEHLER,
"Der Ort ist nicht erreichbar: %s" % ziel,
"Einen anderen Zielordner wählen.")
try:
frei_mb = shutil.disk_usage(pfad).free // (1024 * 1024)
except OSError as e:
return _eintrag("Speicherplatz", FEHLER, "Nicht lesbar (%s)" % e,
"Einen anderen Zielordner wählen.")
if frei_mb >= MINDESTPLATZ_MB:
return _eintrag("Speicherplatz", OK, "%.1f GB frei" % (frei_mb / 1024))
return _eintrag("Speicherplatz", FEHLER,
"nur %d MB frei, gebraucht werden %d MB"
% (frei_mb, MINDESTPLATZ_MB),
"Platz schaffen oder einen anderen Zielordner wählen.")
def pruefe_schreibrecht(ziel: str, probe=None) -> dict:
"""Darf Rippy dorthin schreiben? Wirklich probiert, nicht geraten.
Ein `os.access(..., W_OK)` lügt unter Windows: Es beantwortet die Frage
nach dem Schreibrecht anhand des Nur-Lesen-Flags, nicht anhand der
Zugriffsrechte. Also wird eine Datei angelegt und wieder gelöscht.
"""
if probe is not None:
geklappt, grund = probe
else:
geklappt, grund = _schreibprobe(ziel)
if geklappt:
return _eintrag("Schreibrecht", OK, ziel)
return _eintrag("Schreibrecht", FEHLER, "%s (%s)" % (ziel, grund),
'Einen Ordner unterhalb von „Benutzer" wählen — dort '
'braucht Rippy keine Administratorrechte.')
def _schreibprobe(ziel: str):
import tempfile
try:
os.makedirs(ziel, exist_ok=True)
with tempfile.NamedTemporaryFile(dir=ziel, prefix=".rippy-probe",
delete=True):
pass
return True, ""
except OSError as e:
return False, e.strerror or str(e)
def pruefe_port(port: int = STANDARD_PORT, belegt=None) -> dict:
"""Ist der Port frei? Wenn nicht, kann Rippy nicht bedienen."""
if belegt is None:
belegt = _port_belegt(port)
if not belegt:
return _eintrag("Netzwerk-Port", OK, "%d ist frei" % port)
# Kein Fehler: Sehr oft ist es ein bereits laufendes Rippy, und dann ist
# eine Neuinstallation genau richtig.
return _eintrag("Netzwerk-Port", WARNUNG, "%d ist belegt" % port,
"Läuft Rippy schon? Sonst im Setup einen anderen Port "
"wählen.")
def _port_belegt(port: int) -> bool:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.settimeout(0.5)
try:
s.bind(("127.0.0.1", port))
return False
except OSError:
return True
def pruefe_laufwerk(laufwerke=None) -> dict:
"""Ein optisches Laufwerk. WARNUNG, kein Fehler.
Rippy ohne Laufwerk ist eine reine Komprimier-Maschine — ein vorgesehener
Betriebsfall, den `install.sh` seit Langem ausdrücklich zulässt.
"""
if laufwerke is None:
try:
from rippy import drives
laufwerke = drives.treiber().list_optical_devices()
except Exception: # noqa: BLE001
laufwerke = []
if laufwerke:
return _eintrag("Optisches Laufwerk", OK,
", ".join(str(x) for x in laufwerke))
return _eintrag("Optisches Laufwerk", WARNUNG, "keins gefunden",
"Ohne Laufwerk kann Rippy nur komprimieren, nicht rippen. "
"Ein USB-Brenner reicht.")
def pruefe_werkzeuge(bestand=None) -> list:
"""MakeMKV und HandBrake — je ein eigener Eintrag.
MakeMKV fehlt => WARNUNG, weil das Setup es holen kann. Ein Fehler wäre
es erst, wenn es danach immer noch fehlt (siehe `tools/einrichten.py`).
"""
from rippy.tools import katalog
bestand = katalog.bestand() if bestand is None else bestand
ergebnis = []
for name, eintrag in sorted(bestand.items()):
titel = eintrag.get("titel", name)
if eintrag.get("da"):
ergebnis.append(_eintrag(titel, OK,
eintrag.get("version") or "gefunden"))
elif name == "handbrake":
ergebnis.append(_eintrag(titel, OK, "wird mitgeliefert"))
else:
ergebnis.append(_eintrag(
titel, WARNUNG, "nicht gefunden",
"Das Setup lädt es von makemkv.com und startet den Installer "
"des Herstellers. Ohne MakeMKV kann Rippy keine Disc lesen."))
return ergebnis
# ── Alles zusammen ──────────────────────────────────────────────────────
def alle_pruefungen(ziel: str, port: int = STANDARD_PORT) -> list:
"""Der vollständige Voraussetzungs-Bericht, in Anzeigereihenfolge."""
return ([pruefe_windows(), pruefe_webview(), pruefe_laufwerk()]
+ pruefe_werkzeuge()
+ [pruefe_platz(ziel), pruefe_schreibrecht(ziel), pruefe_port(port)])
def darf_installieren(pruefungen: list) -> bool:
"""Blockiert NUR bei einem echten Fehler. (reine Funktion)
Eine Warnung, die den Knopf sperrt, ist eine Bevormundung; ein Fehler,
der nur warnt, ist eine Falle.
"""
return not any(p["zustand"] == FEHLER for p in pruefungen)
def zusammenfassung(pruefungen: list) -> str:
"""Ein Satz über den Gesamtzustand. (reine Funktion)"""
fehler = [p for p in pruefungen if p["zustand"] == FEHLER]
warnungen = [p for p in pruefungen if p["zustand"] == WARNUNG]
if fehler:
return "Installation nicht möglich: " + ", ".join(p["name"] for p in fehler)
if warnungen:
return "Bereit — mit Hinweisen zu: " + ", ".join(p["name"] for p in warnungen)
return "Alles bereit."
def vorgaben() -> dict:
"""Womit die Maske vorbelegt wird."""
from rippy import betrieb
from rippy.platform import win_registry as reg
return {
"ziel": reg.standard_installationsordner(),
"ablage": betrieb.ablage_vorgabe({}, container=False, system="windows"),
"port": STANDARD_PORT,
"autostart": True,
"verknuepfungen": True,
"werkzeuge_holen": True,
}
+389
View File
@@ -0,0 +1,389 @@
"""Das Fenster des Einrichtungs-Assistenten.
## Warum es das gibt
Commander am 28.08.2026:
> „Dann hätte ich beim Setup' auch erwartet das nen echtes setup passiert —
> wo will ich das hinspeichern, nen pre requirement check usw. Da kommt
> garnichts Rippy geht einfach auf."
## Warum WebView2 und nicht ein Windows-Dialog
Weil es schon da ist. Rippy bringt WebView2 ohnehin für sein Hauptfenster mit
(Entscheid 4 in `KONZEPT-V2.md` § 10) — der Assistent kostet damit **null
zusätzliche Bytes**. Ein Dialog aus Win32-Bausteinen wäre hunderte Zeilen
ctypes für etwas, das hier fünfzig Zeilen HTML sind; `tkinter` ist im Bau
ausdrücklich ausgeschlossen.
## Warum die Prüfungen NICHT hier stehen
Sie stehen in `einrichtung.py`, und zwar vollständig ohne Fenster prüfbar.
Was hier steht, lässt sich nicht testen — also steht hier so wenig wie
möglich: Aufbau, Anzeige, und die Brücke zwischen beidem.
## Was passiert, wenn das Fenster nicht aufgeht
Dann wird ohne Assistent installiert, mit den Vorgaben, und gesagt warum. Ein
Setup, das gar nichts tut, weil sein Fenster nicht aufging, wäre schlimmer
als eines, das nicht fragt.
"""
import threading
from rippy import einrichtung
TITEL = "Rippy einrichten"
BREITE = 860
HOEHE = 760
class Bruecke:
"""Was die Seite aufrufen darf. Bewusst klein und ohne Zustand im HTML.
Jede Methode gibt Daten zurück, keine Erfolgsmeldungen — dieselbe Regel
wie überall sonst in diesem Projekt.
"""
def __init__(self):
self.ergebnis = None
self._fenster = None
self._fortschritt = []
# ── Auskunft ────────────────────────────────────────────────────────
def vorgaben(self) -> dict:
return einrichtung.vorgaben()
def pruefen(self, ziel: str = "", port: int = 0) -> dict:
"""Der Voraussetzungs-Bericht für die angegebenen Werte."""
vorgabe = einrichtung.vorgaben()
befunde = einrichtung.alle_pruefungen(ziel or vorgabe["ziel"],
int(port or vorgabe["port"]))
return {
"befunde": befunde,
"darf": einrichtung.darf_installieren(befunde),
"text": einrichtung.zusammenfassung(befunde),
}
def ordner_waehlen(self, vorschlag: str = "") -> str:
"""Ein echter Ordner-Dialog. Leer, wenn abgebrochen wurde."""
import webview
try:
gewaehlt = self._fenster.create_file_dialog(
webview.FOLDER_DIALOG, directory=vorschlag or "")
except Exception: # noqa: BLE001
return ""
if not gewaehlt:
return ""
return gewaehlt[0] if isinstance(gewaehlt, (list, tuple)) else str(gewaehlt)
def fortschritt(self) -> list:
"""Was seit dem letzten Abruf passiert ist. Leert sich dabei."""
zeilen, self._fortschritt = self._fortschritt, []
return zeilen
# ── Handeln ─────────────────────────────────────────────────────────
def installieren(self, optionen: dict) -> dict:
"""Installiert im Hintergrund-Thread und gibt sofort zurück.
Sofort, weil das Holen von MakeMKV Minuten dauern kann: Ein blockierter
Aufruf ließe das Fenster einfrieren, und ein eingefrorenes Fenster
sieht aus wie ein Absturz.
"""
threading.Thread(target=self._installieren_wirklich,
args=(optionen or {},), daemon=True).start()
return {"gestartet": True}
def _installieren_wirklich(self, optionen: dict) -> None:
from rippy import windows_app
def melden(text, anteil=None):
self._fortschritt.append(str(text))
try:
melden("Dateien werden abgelegt …")
ziel = windows_app.installieren(
ziel=optionen.get("ziel") or None,
autostart=bool(optionen.get("autostart", True)),
starten_danach=False,
verknuepfen=bool(optionen.get("verknuepfungen", True)),
)
if optionen.get("ablage"):
melden("Ablage wird gespeichert …")
_ablage_speichern(optionen["ablage"])
if optionen.get("werkzeuge_holen", True):
melden("Werkzeuge werden geprüft …")
from rippy.tools import einrichten as werkzeug_einrichtung
bericht = werkzeug_einrichtung.sicherstellen(fortschritt=melden)
melden(werkzeug_einrichtung.bericht(bericht))
bereit = bericht["bereit"]
else:
bereit = True
self.ergebnis = {"ok": True, "ziel": ziel, "bereit": bereit}
melden("Fertig.")
except Exception as ex: # noqa: BLE001
# Nicht verschlucken: Ohne Konsole waere ein Absturz hier voellig
# unsichtbar, und der Nutzer saehe ein Fenster, in dem nichts mehr
# passiert.
self.ergebnis = {"ok": False,
"fehler": "%s: %s" % (type(ex).__name__, ex)}
melden("Fehlgeschlagen: %s" % ex)
def stand(self) -> dict:
"""None, solange es läuft. Danach das Ergebnis."""
return self.ergebnis or {}
def schliessen(self) -> None:
try:
self._fenster.destroy()
except Exception: # noqa: BLE001
pass
def _ablage_speichern(pfad: str) -> None:
"""Die gewählte Ablage in die Einstellungen schreiben.
Über die laufende Datenbank, nicht über eine Konfigurationsdatei: Das UI
liest `outputDir` aus den Einstellungen, und genau dort muss der Wert
landen, damit die Wahl im Assistenten überhaupt etwas bewirkt.
"""
from rippy import config, daemon, store
werte = config.laden()
store.verbinden(config.datenbank_url(werte))
import sys
sys.path.insert(0, daemon._api_pfad())
import db as api_db # noqa: E402
api_db.init_db()
aktuell = api_db.get_settings() or {}
aktuell["outputDir"] = pfad
api_db.save_settings(aktuell)
def starten() -> dict:
"""Öffnet den Assistenten. Gibt zurück, was daraus geworden ist.
`{}` heißt: abgebrochen oder nicht möglich. Der Aufrufer entscheidet dann,
ob er ohne Assistent installiert.
"""
import webview
bruecke = Bruecke()
fenster = webview.create_window(TITEL, html=SEITE, js_api=bruecke,
width=BREITE, height=HOEHE,
min_size=(720, 560), text_select=True)
bruecke._fenster = fenster
webview.start(gui="edgechromium", private_mode=False)
return bruecke.ergebnis or {}
def moeglich() -> tuple:
"""Kann der Assistent überhaupt aufgehen? (ja/nein, Begründung)"""
from rippy import fenster as fenster_modul
return fenster_modul.verfuegbar()
# ── Die Seite ───────────────────────────────────────────────────────────
# Bewusst EINE Datei ohne Abhängigkeiten: kein Vite-Bau, kein Nachladen, keine
# Schriftart aus dem Netz. Der Assistent muss auch auf einem Rechner ohne
# Internet aufgehen — dort wird er am dringendsten gebraucht.
SEITE = r"""<!DOCTYPE html>
<html lang="de"><head><meta charset="utf-8">
<style>
:root { color-scheme: dark; }
* { box-sizing: border-box; }
body { margin:0; background:#0b0f19; color:#e2e8f0;
font:14px/1.55 "Segoe UI",system-ui,sans-serif; }
header { padding:22px 28px 16px; border-bottom:1px solid #1e293b; }
h1 { margin:0; font-size:19px; font-weight:650; }
h1 span { color:#f59e0b; }
.unter { color:#94a3b8; font-size:12.5px; margin-top:4px; }
main { padding:20px 28px 96px; }
section { margin-bottom:22px; }
h2 { font-size:12px; text-transform:uppercase; letter-spacing:.09em;
color:#94a3b8; margin:0 0 10px; font-weight:600; }
.zeile { display:flex; gap:10px; padding:7px 0;
border-bottom:1px solid #131c2e; align-items:flex-start; }
.zeile:last-child { border-bottom:0; }
.marke { width:17px; flex:0 0 17px; font-weight:700; text-align:center; }
.ok .marke { color:#34d399; } .warnung .marke { color:#fbbf24; }
.fehler .marke { color:#f87171; }
.inhalt { flex:1; min-width:0; }
.name { font-weight:600; }
.wert { color:#cbd5e1; font-family:ui-monospace,Consolas,monospace;
font-size:12.5px; word-break:break-all; }
.abhilfe { color:#94a3b8; font-size:12px; margin-top:2px; }
label.feld { display:block; margin-bottom:14px; }
label.feld > span { display:block; font-size:12.5px; color:#cbd5e1;
margin-bottom:5px; }
.mitknopf { display:flex; gap:8px; }
input[type=text], input[type=number] {
flex:1; background:#0f1626; border:1px solid #24324a; border-radius:7px;
color:#e2e8f0; padding:9px 11px; font-size:13px; font-family:inherit; }
input:focus { outline:2px solid #f59e0b55; border-color:#f59e0b; }
button { background:#1c2740; border:1px solid #2e3d5c; color:#e2e8f0;
border-radius:7px; padding:9px 15px; font-size:13px; cursor:pointer;
font-family:inherit; }
button:hover:not(:disabled) { background:#25324f; }
button.los { background:#f59e0b; border-color:#f59e0b; color:#1a1206;
font-weight:650; padding:11px 26px; }
button.los:hover:not(:disabled) { background:#fbbf24; }
button:disabled { opacity:.45; cursor:not-allowed; }
.schalter { display:flex; gap:9px; align-items:flex-start; margin-bottom:11px;
cursor:pointer; }
.schalter input { margin:3px 0 0; accent-color:#f59e0b; }
.schalter .hinweis { color:#94a3b8; font-size:12px; }
footer { position:fixed; left:0; right:0; bottom:0; background:#0b0f19f2;
border-top:1px solid #1e293b; padding:14px 28px;
display:flex; align-items:center; gap:16px;
backdrop-filter:blur(6px); }
#urteil { flex:1; font-size:12.5px; color:#94a3b8; }
#protokoll { background:#080c15; border:1px solid #1a2438; border-radius:8px;
padding:11px 13px; font-family:ui-monospace,Consolas,monospace;
font-size:12px; color:#94a3b8; max-height:190px; overflow:auto;
white-space:pre-wrap; }
.weg { display:none; }
</style></head><body>
<header>
<h1><span>&#9673;</span> Rippy einrichten</h1>
<div class="unter">Wird geprüft, was dein Rechner mitbringt — danach entscheidest du, wohin.</div>
</header>
<main>
<section id="teil-pruefung">
<h2>Voraussetzungen</h2>
<div id="pruefungen"><div class="zeile"><div class="inhalt wert">wird geprüft …</div></div></div>
</section>
<section id="teil-orte">
<h2>Wohin</h2>
<label class="feld"><span>Rippy selbst (Programmordner)</span>
<div class="mitknopf">
<input type="text" id="ziel">
<button onclick="waehlen('ziel')">Durchsuchen …</button>
</div>
</label>
<label class="feld"><span>Fertige Filme (Ablage) — darf auch ein Netzwerkpfad sein</span>
<div class="mitknopf">
<input type="text" id="ablage">
<button onclick="waehlen('ablage')">Durchsuchen …</button>
</div>
</label>
<label class="feld" style="max-width:190px"><span>Port</span>
<input type="number" id="port" min="1024" max="65535">
</label>
</section>
<section id="teil-optionen">
<h2>Sonst noch</h2>
<label class="schalter"><input type="checkbox" id="autostart" checked>
<span>Mit Windows starten<div class="hinweis">Rippy läuft dann im Hintergrund und erkennt eingelegte Discs von allein.</div></span></label>
<label class="schalter"><input type="checkbox" id="verknuepfungen" checked>
<span>Symbol auf dem Desktop und im Startmenü<div class="hinweis">Ohne das ist Rippy praktisch unauffindbar.</div></span></label>
<label class="schalter"><input type="checkbox" id="werkzeuge_holen" checked>
<span>Fehlende Werkzeuge holen<div class="hinweis">HandBrake liegt bei. MakeMKV wird von makemkv.com geladen und mit dem Installer des Herstellers installiert — ohne MakeMKV kann Rippy keine Disc lesen.</div></span></label>
</section>
<section id="teil-lauf" class="weg">
<h2>Läuft</h2>
<div id="protokoll"></div>
</section>
</main>
<footer>
<div id="urteil">wird geprüft …</div>
<button id="knopf-abbruch" onclick="pywebview.api.schliessen()">Abbrechen</button>
<button id="knopf-los" class="los" onclick="losgehen()" disabled>Installieren</button>
</footer>
<script>
const $ = (id) => document.getElementById(id);
let laeuft = false;
function zeichnePruefungen(bericht) {
const marken = { ok: '&#10003;', warnung: '!', fehler: '&#10007;' };
$('pruefungen').innerHTML = bericht.befunde.map(b =>
'<div class="zeile ' + b.zustand + '">'
+ '<div class="marke">' + (marken[b.zustand] || '?') + '</div>'
+ '<div class="inhalt"><div class="name">' + b.name + '</div>'
+ '<div class="wert">' + b.text + '</div>'
+ (b.abhilfe ? '<div class="abhilfe">' + b.abhilfe + '</div>' : '')
+ '</div></div>').join('');
$('urteil').textContent = bericht.text;
$('knopf-los').disabled = !bericht.darf || laeuft;
}
async function pruefen() {
if (laeuft) return;
zeichnePruefungen(await pywebview.api.pruefen($('ziel').value, Number($('port').value)));
}
async function waehlen(feld) {
const ordner = await pywebview.api.ordner_waehlen($(feld).value);
if (ordner) { $(feld).value = ordner; if (feld === 'ziel') pruefen(); }
}
async function losgehen() {
laeuft = true;
$('knopf-los').disabled = true;
$('knopf-los').textContent = 'Läuft …';
$('teil-lauf').classList.remove('weg');
$('urteil').textContent = 'Rippy wird eingerichtet …';
await pywebview.api.installieren({
ziel: $('ziel').value,
ablage: $('ablage').value,
port: Number($('port').value),
autostart: $('autostart').checked,
verknuepfungen: $('verknuepfungen').checked,
werkzeuge_holen: $('werkzeuge_holen').checked,
});
verfolgen();
}
async function verfolgen() {
const neue = await pywebview.api.fortschritt();
if (neue.length) {
const p = $('protokoll');
p.textContent += neue.join('\n') + '\n';
p.scrollTop = p.scrollHeight;
}
const stand = await pywebview.api.stand();
if (stand && stand.ok !== undefined) {
$('knopf-abbruch').classList.add('weg');
$('knopf-los').disabled = false;
$('knopf-los').textContent = 'Rippy öffnen';
$('knopf-los').onclick = () => pywebview.api.schliessen();
$('urteil').textContent = stand.ok
? (stand.bereit ? 'Fertig — Rippy ist einsatzbereit.'
: 'Installiert, aber es fehlt noch ein Werkzeug (siehe oben).')
: ('Fehlgeschlagen: ' + stand.fehler);
return;
}
setTimeout(verfolgen, 700);
}
window.addEventListener('pywebviewready', async () => {
const v = await pywebview.api.vorgaben();
$('ziel').value = v.ziel;
$('ablage').value = v.ablage;
$('port').value = v.port;
$('autostart').checked = v.autostart;
$('verknuepfungen').checked = v.verknuepfungen;
$('werkzeuge_holen').checked = v.werkzeuge_holen;
pruefen();
$('port').addEventListener('change', pruefen);
$('ziel').addEventListener('change', pruefen);
});
</script>
</body></html>
"""
# Damit die Seite auch ohne laufendes Fenster prüfbar ist (siehe Tests).
SEITE_LAENGE = len(SEITE)
def seite_enthaelt(text: str) -> bool:
"""Kleine Hilfe für Tests: Steht etwas in der Seite?"""
return text in SEITE
+192
View File
@@ -0,0 +1,192 @@
"""Der Betriebsmodus und seine Faehigkeiten.
## Warum es diese Tests gibt
Commander am 28.08.2026, zum Windows-Fenster:
Worker erreichbar: 0 von 1
Kein Worker antwortet — Pruefen: docker compose ps
Container-Platte: unbekannt
Freigaben: keine eingehaengt
Kein Satz davon ergibt auf einem Windows-PC einen Sinn. Die Oberflaeche hatte
nie erfahren, worauf sie laeuft — also hat sie angenommen.
Alle Pruefungen hier sind eingespritzt: Jeder Betriebsfall ist pruefbar, ohne
ihn herzustellen. Das ist der Punkt — sonst waere „laeuft im Container" nur
auf einem Container-Rechner testbar, und genau dort faellt der Windows-Fehler
nie auf.
"""
from rippy import betrieb
DOCKER = {"profil": "api", "queue": {"treiber": "celery", "broker": "redis://x"}}
WINDOWS = {"profil": "standalone", "queue": {"treiber": "lokal"}}
# ── Plattform ───────────────────────────────────────────────────────────
def test_plattform_wird_zu_einem_klaren_namen():
assert betrieb.plattform("win32") == "windows"
assert betrieb.plattform("linux") == "linux"
assert betrieb.plattform("darwin") == "macos"
def test_unbekannte_plattform_gilt_als_linux():
"""FreeBSD und Verwandte verhalten sich wie Linux — und ein Rueckfall auf
etwas Bekanntes ist besser als ein leeres Feld."""
assert betrieb.plattform("freebsd14") == "linux"
# ── Container ───────────────────────────────────────────────────────────
def test_container_wird_gemessen_nicht_geraten():
"""`/.dockerenv` legt Docker selbst an — verlaesslicher als eine
Umgebungsvariable, die jeder setzen und vergessen kann."""
assert betrieb.im_container(lambda p: p == "/.dockerenv") is True
assert betrieb.im_container(lambda p: False) is False
# ── Modus ───────────────────────────────────────────────────────────────
def test_celery_heisst_verteilt():
assert betrieb.modus(DOCKER) == "verteilt"
def test_lokale_queue_heisst_standalone():
assert betrieb.modus(WINDOWS) == "standalone"
def test_ohne_angaben_gilt_standalone():
"""Der harmlosere Rueckfall: Ein standalone-Rippy zeigt nichts an, was es
nicht gibt. Andersherum entstuenden die Phantom-Worker wieder."""
assert betrieb.modus({}) == "standalone"
# ── Die Faehigkeiten — hier sass der gemeldete Fehler ───────────────────
def test_windows_hat_KEINE_externen_worker():
"""DER Befund: „Worker erreichbar: 0 von 1 — Kein Worker antwortet."
Unter Windows rippt Rippy selbst; es gibt keinen zweiten, auf den man
warten koennte. Die Meldung war nicht nur unpassend, sie war falsch."""
a = betrieb.auskunft(WINDOWS, container=False, plattform_name="win32")
assert a["kann"]["externe_worker"] is False
def test_windows_kennt_KEINE_container_pfade():
"""`/app/media` ist ein Pfad IM Container. Auf Windows heisst der Ordner
anders und liegt woanders — ein damit vorbelegtes Feld ist schlicht
falsch."""
a = betrieb.auskunft(WINDOWS, container=False, plattform_name="win32")
assert a["kann"]["container_pfade"] is False
assert "/app/" not in a["ablage_vorgabe"]
def test_windows_haengt_keine_freigaben_ein():
"""Unter Windows gibt man einen UNC-Pfad an, fertig. Eine Maske zum
Einhaengen waere eine Bedienung, die ins Leere fuehrt."""
a = betrieb.auskunft(WINDOWS, container=False, plattform_name="win32")
assert a["kann"]["freigaben_einhaengen"] is False
def test_windows_darf_seine_werkzeuge_selbst_pflegen():
"""Im Image steckt alles fest, ein Update ist ein Rebuild. Nur die native
Installation kann MakeMKV/HandBrake selbst beschaffen."""
a = betrieb.auskunft(WINDOWS, container=False, plattform_name="win32")
assert a["kann"]["werkzeuge_verwalten"] is True
def test_docker_behaelt_alles_was_es_hatte():
"""Die Windows-Reparatur darf dem Docker-Betrieb nichts wegnehmen."""
a = betrieb.auskunft(DOCKER, container=True, plattform_name="linux")
assert a["kann"]["externe_worker"] is True
assert a["kann"]["container_pfade"] is True
assert a["kann"]["freigaben_einhaengen"] is True
assert a["ablage_vorgabe"] == "/app/media"
assert a["hilfe_befehl"] == "docker compose -p rippy ps"
def test_der_docker_hinweis_taucht_ausserhalb_von_docker_NICHT_auf():
"""„Pruefen: docker compose ps" auf einem Windows-PC ist ein Rat, den
niemand befolgen kann."""
a = betrieb.auskunft(WINDOWS, container=False, plattform_name="win32")
assert a["hilfe_befehl"] == ""
def test_docker_allinone_hat_container_aber_keine_externen_worker():
"""Der Fall, an dem ein blosser Modus-Name zerbrechen wuerde: EIN
Container, der alles selbst macht. Er hat Container-Pfade, aber niemanden,
der von aussen Jobs uebernimmt."""
a = betrieb.auskunft({"profil": "standalone", "queue": {"treiber": "lokal"}},
container=True, plattform_name="linux")
assert a["kann"]["container_pfade"] is True
assert a["kann"]["externe_worker"] is False
# ── Die Ablage-Vorgabe ──────────────────────────────────────────────────
def test_eingestellte_ablage_schlaegt_jede_vorgabe():
a = betrieb.auskunft({"storage": {"medien": "D:\\Filme"}},
container=False, plattform_name="win32")
assert a["ablage_vorgabe"] == "D:\\Filme"
def test_windows_vorgabe_liegt_beim_nutzer(monkeypatch):
monkeypatch.setenv("USERPROFILE", "C:\\Users\\Test")
a = betrieb.auskunft(WINDOWS, container=False, plattform_name="win32")
assert a["ablage_vorgabe"].startswith("C:\\Users\\Test")
assert a["ablage_vorgabe"].endswith("Rippy")
# ── Der Vertrag als Ganzes ──────────────────────────────────────────────
def test_die_auskunft_ist_vollstaendig():
"""Ein fehlendes Feld waere im UI ein `undefined` — und `undefined` ist
in JavaScript falsch, also verschwaende die Oberflaeche stillschweigend
einen Bereich. Lieber hier rot."""
a = betrieb.auskunft(WINDOWS, container=False, plattform_name="win32")
assert set(a) == {"modus", "plattform", "im_container", "kann",
"ablage_vorgabe", "hilfe_befehl"}
assert set(a["kann"]) == {"externe_worker", "freigaben_einhaengen",
"container_pfade", "werkzeuge_verwalten"}
assert all(isinstance(v, bool) for v in a["kann"].values())
# ── Wo Platz gemessen wird ──────────────────────────────────────────────
def test_windows_misst_NICHT_in_app_media():
"""DER Befund: Im Windows-Fenster stand „Platz fuer Rippy: unbekannt".
`main.py` mass fest an `/app/media` und `/app/temp`. Beide gibt es unter
Windows nicht, `shutil.disk_usage` warf, die Liste blieb leer — und aus
einer Nichtauskunft wurde eine Anzeige, die wie eine Auskunft aussieht.
"""
orte = betrieb.platz_orte(WINDOWS, container=False, plattform_name="win32")
assert orte, "es muss mindestens ein Ort gemessen werden"
assert all("/app/" not in o["pfad"] for o in orte), orte
def test_docker_misst_weiter_die_container_pfade():
orte = betrieb.platz_orte(DOCKER, container=True, plattform_name="linux")
assert [o["pfad"] for o in orte] == ["/app/media", "/app/temp"]
def test_gleiches_laufwerk_wird_nicht_doppelt_gezaehlt():
"""Zweimal dieselbe Zahl unter zwei Namen sieht aus wie zwei Auskuenfte
und ist eine."""
orte = betrieb.platz_orte({"storage": {"medien": r"D:\Filme"}},
container=False, plattform_name="win32")
assert len(orte) == 1, orte
def test_zweites_laufwerk_bekommt_eine_eigene_zeile():
orte = betrieb.platz_orte(
{"storage": {"medien": r"D:\Filme", "temp": r"E:\Arbeit"}},
container=False, plattform_name="win32")
assert len(orte) == 2, orte
def test_noch_nicht_angelegter_ordner_faellt_auf_das_laufwerk_zurueck():
"""Frisch installiert gibt es den Ablage-Ordner noch nicht. Der Nutzer
will trotzdem wissen, ob auf dem LAUFWERK Platz ist."""
da = r"C:\Users\Test"
assert betrieb.naechster_vorhandener(
r"C:\Users\Test\Videos\Rippy", existiert=lambda p: p == da) == da
def test_wenn_gar_nichts_existiert_wird_nichts_behauptet():
assert betrieb.naechster_vorhandener(r"Z:\gibt\es\nicht",
existiert=lambda p: False) == ""
+188
View File
@@ -0,0 +1,188 @@
"""Der Einrichtungs-Assistent — die Pruefungen, ohne etwas zu installieren.
## Warum es diese Tests gibt
Commander am 28.08.2026: *„Dann haette ich beim Setup' auch erwartet das nen
echtes setup passiert — wo will ich das hinspeichern, nen pre requirement
check usw. Da kommt garnichts Rippy geht einfach auf."*
Er hatte recht: Ein Doppelklick installierte stillschweigend und oeffnete das
Fenster. Der Nutzer wurde nichts gefragt und erfuhr nichts.
Alle Pruefungen sind eingespritzt — kein Fenster, keine Installation, kein
WebView2. Sonst waere „kein Laufwerk" nur auf einem Rechner ohne Laufwerk
pruefbar, und genau dort sieht es niemand.
"""
import os
from rippy import einrichtung as e
def _p(zustand, name="X"):
return {"name": name, "zustand": zustand, "text": "", "abhilfe": ""}
# ── Was blockiert und was nicht ─────────────────────────────────────────
def test_ohne_fehler_darf_installiert_werden():
assert e.darf_installieren([_p(e.OK), _p(e.WARNUNG)]) is True
def test_ein_fehler_blockiert():
assert e.darf_installieren([_p(e.OK), _p(e.FEHLER)]) is False
def test_eine_warnung_blockiert_NICHT():
"""Eine Warnung, die den Knopf sperrt, ist eine Bevormundung. Rippy ohne
optisches Laufwerk ist ein vorgesehener Betriebsfall — eine reine
Komprimier-Maschine."""
assert e.darf_installieren([_p(e.WARNUNG), _p(e.WARNUNG)]) is True
# ── Die einzelnen Pruefungen ────────────────────────────────────────────
def test_windows_10_und_11_gehen():
assert e.pruefe_windows(22631)["zustand"] == e.OK
assert e.pruefe_windows(19045)["zustand"] == e.OK
def test_zu_altes_windows_ist_ein_fehler():
befund = e.pruefe_windows(7601) # Windows 7
assert befund["zustand"] == e.FEHLER
assert befund["abhilfe"], "ein Befund ohne Abhilfe laesst den Nutzer ratlos"
def test_fehlendes_webview_ist_nur_eine_warnung():
"""Ohne WebView2 faellt Rippy auf den Browser zurueck. Das ist weniger
schoen, aber es LAEUFT — ein Fehler waere hier eine Luege."""
befund = e.pruefe_webview(version="")
assert befund["zustand"] == e.WARNUNG
assert "Browser" in befund["abhilfe"]
def test_vorhandenes_webview_ist_ok():
assert e.pruefe_webview(version="151.0.4129.107")["zustand"] == e.OK
def test_zu_wenig_platz_ist_ein_fehler():
befund = e.pruefe_platz("C:\\egal", frei_mb=100)
assert befund["zustand"] == e.FEHLER
assert "500" in befund["text"]
def test_genug_platz_ist_ok():
assert e.pruefe_platz("C:\\egal", frei_mb=5000)["zustand"] == e.OK
def test_fehlendes_schreibrecht_ist_ein_fehler():
"""`os.access(..., W_OK)` luegt unter Windows — es beantwortet die Frage
anhand des Nur-Lesen-Flags, nicht anhand der Zugriffsrechte. Deshalb wird
wirklich geschrieben."""
befund = e.pruefe_schreibrecht("C:\\Programme\\Rippy",
probe=(False, "Zugriff verweigert"))
assert befund["zustand"] == e.FEHLER
assert "Benutzer" in befund["abhilfe"]
def test_schreibprobe_am_echten_ordner(tmp_path):
befund = e.pruefe_schreibrecht(str(tmp_path))
assert befund["zustand"] == e.OK
# Und die Probe darf nichts hinterlassen.
assert list(tmp_path.iterdir()) == []
def test_belegter_port_ist_nur_eine_warnung():
"""Sehr oft ist es ein bereits laufendes Rippy — und dann ist eine
Neuinstallation genau richtig."""
befund = e.pruefe_port(7788, belegt=True)
assert befund["zustand"] == e.WARNUNG
assert "Rippy" in befund["abhilfe"]
def test_freier_port_ist_ok():
assert e.pruefe_port(7788, belegt=False)["zustand"] == e.OK
def test_kein_laufwerk_ist_nur_eine_warnung():
"""install.sh sagt seit Langem: ohne Laufwerk „laeuft trotzdem durch, dann
ist das eine reine KOMPRIMIER-Maschine"."""
befund = e.pruefe_laufwerk(laufwerke=[])
assert befund["zustand"] == e.WARNUNG
assert "komprimieren" in befund["abhilfe"]
def test_gefundenes_laufwerk_wird_genannt():
befund = e.pruefe_laufwerk(laufwerke=["\\\\.\\G:"])
assert befund["zustand"] == e.OK
assert "G:" in befund["text"]
# ── Die Werkzeuge ───────────────────────────────────────────────────────
def _bestand(makemkv, handbrake):
return {
"makemkv": {"titel": "MakeMKV", "da": makemkv, "version": "1.18.4",
"pflicht": True},
"handbrake": {"titel": "HandBrakeCLI", "da": handbrake,
"version": "1.11.2", "pflicht": False},
}
def test_fehlendes_makemkv_ist_eine_warnung_mit_ausweg():
befunde = {b["name"]: b for b in e.pruefe_werkzeuge(_bestand(False, True))}
assert befunde["MakeMKV"]["zustand"] == e.WARNUNG
assert "makemkv.com" in befunde["MakeMKV"]["abhilfe"]
def test_fehlendes_handbrake_ist_kein_problem():
"""Es liegt im Paket — das Setup legt es beim Installieren daneben."""
befunde = {b["name"]: b for b in e.pruefe_werkzeuge(_bestand(True, False))}
assert befunde["HandBrakeCLI"]["zustand"] == e.OK
assert "mitgeliefert" in befunde["HandBrakeCLI"]["text"]
def test_vorhandene_werkzeuge_zeigen_ihre_version():
befunde = {b["name"]: b for b in e.pruefe_werkzeuge(_bestand(True, True))}
assert befunde["MakeMKV"]["text"] == "1.18.4"
# ── Die Zusammenfassung ─────────────────────────────────────────────────
def test_zusammenfassung_nennt_die_fehler_beim_namen():
text = e.zusammenfassung([_p(e.FEHLER, "Speicherplatz"), _p(e.OK, "Port")])
assert "nicht möglich" in text
assert "Speicherplatz" in text
def test_zusammenfassung_nennt_die_hinweise():
text = e.zusammenfassung([_p(e.WARNUNG, "MakeMKV"), _p(e.OK, "Port")])
assert "MakeMKV" in text
assert "nicht möglich" not in text
def test_alles_gruen_sagt_es_auch_so():
assert e.zusammenfassung([_p(e.OK), _p(e.OK)]) == "Alles bereit."
def test_jede_pruefung_hat_dieselbe_form():
"""Ein fehlendes Feld waere in der Oberflaeche ein `undefined`."""
for befund in ([e.pruefe_windows(22631), e.pruefe_webview(version=""),
e.pruefe_laufwerk(laufwerke=[]),
e.pruefe_platz("C:\\x", frei_mb=9999),
e.pruefe_port(7788, belegt=False)]
+ e.pruefe_werkzeuge(_bestand(False, False))):
assert set(befund) == {"name", "zustand", "text", "abhilfe"}, befund
assert befund["zustand"] in (e.OK, e.WARNUNG, e.FEHLER)
if befund["zustand"] != e.OK:
assert befund["abhilfe"], (
"%s sagt nicht, was zu tun ist" % befund["name"])
# ── Die Vorgaben ────────────────────────────────────────────────────────
def test_vorgaben_sind_vollstaendig():
if os.name != "nt":
import pytest
pytest.skip("Registry-Vorgaben gibt es nur unter Windows")
v = e.vorgaben()
assert set(v) == {"ziel", "ablage", "port", "autostart",
"verknuepfungen", "werkzeuge_holen"}
assert v["port"] == 7788
assert v["ziel"] and v["ablage"]
assert "/app/" not in v["ablage"], "Container-Pfad in einer Windows-Vorgabe"
+122
View File
@@ -0,0 +1,122 @@
"""Der Einrichtungs-Assistent: Bruecke und Seite, ohne ein Fenster zu oeffnen.
Ein Fenster laesst sich im Test nicht bedienen. Geprueft wird deshalb das,
was DAHINTER haengt: was die Seite abfragen darf, was beim Installieren
passiert, und dass ein Fehlschlag sichtbar wird statt zu verschwinden.
Hinweis zu den Backslashes: Windows-Pfade stehen hier in ROHEN Zeichenketten
(`r"..."`). In einer normalen waere `C:\\Rippy` die ungueltige Escape-Sequenz
Backslash-R.
"""
from rippy import setup_fenster
class LeeresFenster:
def __init__(self, antwort=None):
self.antwort = antwort
self.zerstoert = False
def create_file_dialog(self, *a, **k):
if self.antwort is None:
raise RuntimeError("kein Dialog")
return self.antwort
def destroy(self):
self.zerstoert = True
# ── Die Seite ───────────────────────────────────────────────────────────
def test_die_seite_fragt_nach_dem_ablageort():
"""Die ausdrueckliche Frage des Commanders: „wo will ich das
hinspeichern"."""
assert setup_fenster.seite_enthaelt('id="ablage"')
assert setup_fenster.seite_enthaelt("Fertige Filme")
def test_die_seite_zeigt_die_voraussetzungen():
"""„nen pre requirement check usw." — vorher kam da gar nichts."""
assert setup_fenster.seite_enthaelt("Voraussetzungen")
assert setup_fenster.seite_enthaelt("pywebview.api.pruefen")
def test_die_seite_laedt_nichts_aus_dem_netz():
"""Der Assistent muss auf einem Rechner OHNE Internet aufgehen — dort
wird er am dringendsten gebraucht."""
for muster in ("http://", "https://", "<script src", "<link "):
assert not setup_fenster.seite_enthaelt(muster), muster
def test_die_seite_kann_den_ordner_waehlen_lassen():
assert setup_fenster.seite_enthaelt("pywebview.api.ordner_waehlen")
# ── Die Bruecke ─────────────────────────────────────────────────────────
def test_abgebrochener_ordner_dialog_gibt_leer_zurueck():
b = setup_fenster.Bruecke()
b._fenster = LeeresFenster(antwort=None)
assert b.ordner_waehlen("C:") == ""
def test_gewaehlter_ordner_kommt_als_text():
b = setup_fenster.Bruecke()
b._fenster = LeeresFenster(antwort=(r"D:\Filme",))
assert b.ordner_waehlen() == r"D:\Filme"
def test_fortschritt_leert_sich_beim_abholen():
"""Sonst waechst das Protokoll bei jedem Abruf um alles Bisherige."""
b = setup_fenster.Bruecke()
b._fortschritt = ["eins", "zwei"]
assert b.fortschritt() == ["eins", "zwei"]
assert b.fortschritt() == []
def test_stand_ist_leer_solange_nichts_fertig_ist():
assert setup_fenster.Bruecke().stand() == {}
def test_ein_fehlschlag_beim_installieren_wird_SICHTBAR(monkeypatch):
"""Ohne Konsole waere ein Absturz hier voellig unsichtbar — der Nutzer
saehe ein Fenster, in dem nichts mehr passiert."""
from rippy import windows_app
def platzt(**k):
raise OSError("Zugriff verweigert")
monkeypatch.setattr(windows_app, "installieren", platzt)
b = setup_fenster.Bruecke()
b._installieren_wirklich({"ziel": r"C:\x", "werkzeuge_holen": False})
assert b.ergebnis["ok"] is False
assert "Zugriff verweigert" in b.ergebnis["fehler"]
assert any("Fehlgeschlagen" in z for z in b._fortschritt)
def test_erfolgreiche_installation_meldet_den_ordner(monkeypatch):
from rippy import windows_app
monkeypatch.setattr(windows_app, "installieren", lambda **k: r"C:\Rippy")
b = setup_fenster.Bruecke()
b._installieren_wirklich({"ziel": r"C:\Rippy", "werkzeuge_holen": False})
assert b.ergebnis == {"ok": True, "ziel": r"C:\Rippy", "bereit": True}
def test_werkzeuge_koennen_uebersprungen_werden(monkeypatch):
"""Sonst wartete eine unbeaufsichtigte Installation auf einen Download."""
from rippy import windows_app
from rippy.tools import einrichten
def haette_nicht(**k):
raise AssertionError("haette nicht laufen duerfen")
monkeypatch.setattr(windows_app, "installieren", lambda **k: r"C:\Rippy")
monkeypatch.setattr(einrichten, "sicherstellen", haette_nicht)
b = setup_fenster.Bruecke()
b._installieren_wirklich({"werkzeuge_holen": False})
assert b.ergebnis["ok"] is True
def test_schliessen_ohne_fenster_wirft_nicht():
setup_fenster.Bruecke().schliessen()
+30 -3
View File
@@ -121,10 +121,37 @@ def ordner_von(datei: str) -> str:
def _entfalten(pfad: str, umgebung: dict) -> str: def _entfalten(pfad: str, umgebung: dict) -> str:
"""%ProgramFiles%\\X -> C:\\Program Files\\X (mit eigener Umgebung testbar).""" r"""%ProgramFiles%\X -> C:\Program Files\X (mit eigener Umgebung testbar).
## Warum das GROSS- UND KLEINSCHREIBUNG ignorieren muss (28.08.2026)
Windows behandelt Umgebungsvariablen ohne Rücksicht auf die Schreibweise,
und Python speichert sie unter Windows deshalb GROSS in `os.environ`.
Gemessen auf dem Rechner des Commanders:
os.environ PROGRAMFILES(X86) = C:\Program Files (x86)
WERKZEUGE["orte"] %ProgramFiles(x86)%\MakeMKV
Der Vergleich schlug fehl, `%` blieb stehen, und die Funktion gab ""
der Kandidat fiel aus der Liste. **Die bekannten Installationsorte haben
auf einer echten Maschine also nie funktioniert.** MakeMKV wurde nur über
die Registry-Rückfallebene gefunden; wo die fehlt oder anders aussieht,
fand Rippy nichts.
Aufgefallen ist es nur, weil hier eine ECHTE Umgebung eingesetzt wurde.
Der Test spritzte `{"ProgramFiles(x86)": …}` in genau der Schreibweise
ein, die im Muster steht — er war damit grüner als die Wirklichkeit.
"""
gross = {("%" + name + "%").upper(): wert for name, wert in umgebung.items()}
ergebnis = pfad ergebnis = pfad
for name, wert in umgebung.items(): for marke, wert in gross.items():
ergebnis = ergebnis.replace("%" + name + "%", wert) # Suchen ohne Rücksicht auf die Schreibweise, ersetzen an der
# gefundenen Stelle — `str.replace` kann das nicht von allein.
while True:
stelle = ergebnis.upper().find(marke)
if stelle < 0:
break
ergebnis = ergebnis[:stelle] + wert + ergebnis[stelle + len(marke):]
return ergebnis if "%" not in ergebnis else "" return ergebnis if "%" not in ergebnis else ""
+37 -2
View File
@@ -17,9 +17,17 @@ from rippy.tools import katalog
BACKSLASH = chr(92) BACKSLASH = chr(92)
# Eine Umgebung ohne echte Systemvariablen, damit die Pfade vorhersagbar sind. # Eine Umgebung ohne echte Systemvariablen, damit die Pfade vorhersagbar sind.
#
# ⚠️ Die Schluessel stehen GROSS — genau so, wie Windows sie in `os.environ`
# ablegt (am 28.08.2026 nachgesehen: `PROGRAMFILES(X86)`). Vorher stand hier
# die huebsche Schreibweise `ProgramFiles(x86)`, und damit war dieser Test
# gruener als die Wirklichkeit: Die Entfaltung verglich Zeichen fuer Zeichen,
# fand auf einer echten Maschine nie eine Uebereinstimmung und lieferte "".
# Die bekannten Installationsorte haben deshalb NIE funktioniert; MakeMKV
# wurde nur ueber die Registry-Rueckfallebene gefunden.
UMGEBUNG = { UMGEBUNG = {
"ProgramFiles": r"C:\Program Files", "PROGRAMFILES": r"C:\Program Files",
"ProgramFiles(x86)": r"C:\Program Files (x86)", "PROGRAMFILES(X86)": r"C:\Program Files (x86)",
} }
@@ -222,3 +230,30 @@ def test_programmdatei_aus_beiden_formen():
assert p(r"C:\R\x.exe") == r"C:\R\x.exe" assert p(r"C:\R\x.exe") == r"C:\R\x.exe"
assert p("") == "" assert p("") == ""
assert p("irgendwas ohne Programm") == "" assert p("irgendwas ohne Programm") == ""
def test_schreibweise_der_umgebungsvariablen_ist_egal():
"""DER Fehler vom 28.08.2026. Windows legt Umgebungsvariablen GROSS ab,
das Muster in WERKZEUGE steht huebsch geschrieben. Ohne diese Pruefung
faellt der Unterschied nie auf — der alte Test spritzte genau die
Schreibweise ein, die im Muster stand."""
for schreibweise in ("ProgramFiles(x86)", "PROGRAMFILES(X86)",
"programfiles(x86)"):
entfaltet = katalog._entfalten(
r"%ProgramFiles(x86)%\MakeMKV", {schreibweise: r"C:\PF86"})
assert entfaltet == r"C:\PF86\MakeMKV", (schreibweise, entfaltet)
def test_mit_der_ECHTEN_umgebung_kommen_die_systempfade_heraus():
"""Die Gegenprobe an os.environ statt an einer gepflegten Attrappe.
Genau die hat gefehlt."""
import os
if os.name != "nt":
import pytest
pytest.skip("ProgramFiles gibt es nur unter Windows")
pfade = katalog.kandidaten("makemkv", r"C:\Rippy ools", os.environ)
assert any("Program Files" in p for p in pfade), (
"Kein einziger Systempfad in der Kandidatenliste — dann findet Rippy "
"eine normale MakeMKV-Installation nur noch ueber die Registry."
)
+47 -1
View File
@@ -593,6 +593,7 @@ Optionen:
--kein-autostart --kein-autostart
--keine-verknuepfungen ohne Desktop-Symbol und Startmenue-Eintrag --keine-verknuepfungen ohne Desktop-Symbol und Startmenue-Eintrag
--ohne-werkzeuge MakeMKV/HandBrake NICHT pruefen und nicht holen --ohne-werkzeuge MakeMKV/HandBrake NICHT pruefen und nicht holen
--ohne-assistent ohne Einrichtungs-Fenster, mit den Vorgaben
--im-browser im Browser statt im eigenen Fenster --im-browser im Browser statt im eigenen Fenster
--still ohne Rückmeldungen --still ohne Rückmeldungen
""" """
@@ -636,7 +637,27 @@ def main(argv=None) -> int:
if "--dienst" in argv: if "--dienst" in argv:
return dienst_starten(int(wert("--port", STANDARD_PORT))) return dienst_starten(int(wert("--port", STANDARD_PORT)))
# Ohne Argument: installieren. Das ist der Doppelklick-Fall. # Ohne Argument: der Doppelklick-Fall — und der gehoert in den
# Einrichtungs-Assistenten.
#
# Commander am 28.08.2026: "Dann haette ich beim 'Setup' auch erwartet das
# nen echtes setup passiert - wo will ich das hinspeichern, nen pre
# requirement check usw. Da kommt garnichts Rippy geht einfach auf."
#
# `--still` und `--ohne-assistent` gehen weiterhin den stummen Weg: fuer
# unbeaufsichtigte Installationen und fuer die eigenen Tests.
if "--still" not in argv and "--ohne-assistent" not in argv:
ergebnis = assistent_starten()
if ergebnis.get("ok"):
return 0
if ergebnis.get("abgebrochen"):
melden("Abgebrochen — es wurde nichts veraendert.")
return 1
# Weder erledigt noch abgebrochen: Der Assistent ging gar nicht auf.
# Dann wird ohne ihn installiert, statt gar nichts zu tun.
melden("Der Einrichtungs-Assistent ging nicht auf (%s) — es wird mit "
"den Vorgaben installiert." % (ergebnis.get("grund") or "?"))
try: try:
ziel = installieren( ziel = installieren(
ziel=wert("--ziel"), ziel=wert("--ziel"),
@@ -671,6 +692,31 @@ def main(argv=None) -> int:
return 0 return 0
def assistent_starten() -> dict:
"""Oeffnet den Einrichtungs-Assistenten. Sagt, was daraus wurde.
Rueckgabe:
{"ok": True, ...} installiert
{"abgebrochen": True} Fenster zugemacht, nichts veraendert
{"grund": "..."} ging nicht auf — der Aufrufer entscheidet
Wirft nicht. Ein Setup, das gar nichts tut, weil sein Fenster nicht
aufging, waere schlimmer als eines, das nicht fragt.
"""
from rippy import setup_fenster
geht, grund = setup_fenster.moeglich()
if not geht:
return {"grund": grund}
try:
ergebnis = setup_fenster.starten()
except Exception as e: # noqa: BLE001
return {"grund": "%s: %s" % (type(e).__name__, e)}
if not ergebnis:
return {"abgebrochen": True}
return ergebnis
def werkzeuge_einrichten() -> str: def werkzeuge_einrichten() -> str:
"""Holt fehlende Werkzeuge und gibt den Bericht als Text zurück. """Holt fehlende Werkzeuge und gibt den Bericht als Text zurück.