Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b47d21510d | ||
|
|
a4a045f303 | ||
|
|
66642b8d93 |
@@ -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
|
||||||
|
|||||||
@@ -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 →
|
||||||
|
|||||||
+98
-5
@@ -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)
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
info["plaetze"].append({
|
info["plaetze"].append({
|
||||||
"name": name,
|
"name": ort["name"],
|
||||||
"frei_gb": round(nutzung.free / 1024**3, 1),
|
"frei_gb": round(nutzung.free / 1024**3, 1),
|
||||||
"gesamt_gb": round(nutzung.total / 1024**3, 1),
|
"gesamt_gb": round(nutzung.total / 1024**3, 1),
|
||||||
})
|
})
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
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())
|
||||||
|
|||||||
@@ -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"] == ""
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<BetriebProvider>
|
||||||
<EventStreamProvider>
|
<EventStreamProvider>
|
||||||
<AppInhalt />
|
<AppInhalt />
|
||||||
</EventStreamProvider>
|
</EventStreamProvider>
|
||||||
|
</BetriebProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 & Speicherziele">
|
<Abschnitt icon={HardDrive} titel="Wohin die Filme kommen">
|
||||||
|
{betrieb.kann.freigaben_einhaengen ? (
|
||||||
<p>
|
<p>
|
||||||
Einstellungen → Speicherziele: Rechner/NAS eintragen, <span className={fett}>Benutzer +
|
Einstellungen → Speicherziele: Rechner/NAS eintragen, <span className={fett}>Benutzer +
|
||||||
Passwort</span> angeben (Windows und die meisten NAS lehnen Gast-Zugriffe ab!), „Freigaben
|
Passwort</span> angeben (Windows und die meisten NAS lehnen Gast-Zugriffe ab!), „Freigaben
|
||||||
auflisten", Freigabe wählen, „Einhängen & speichern". Danach taucht das Ziel im
|
auflisten", Freigabe wählen, „Einhängen & speichern". Danach taucht das Ziel im
|
||||||
„Rippen starten"-Dialog auf und wird beim Start automatisch wieder verbunden.
|
„Rippen starten"-Dialog auf und wird beim Start automatisch wieder verbunden.
|
||||||
</p>
|
</p>
|
||||||
|
) : (
|
||||||
<p>
|
<p>
|
||||||
<span className={fett}>4K-UHD-Tipp:</span> Rohdaten sind bis 100 GB groß. Wenn die
|
Einstellungen → Speicherziele: Bei <span className={fett}>Ablage</span> steht der Ordner,
|
||||||
Rippy-Platte knapp ist (Dashboard zeigt den freien Platz), das Arbeitsverzeichnis unter
|
in dem die fertigen Filme landen. Das darf ein normaler Ordner sein
|
||||||
Einstellungen → Verarbeitung auf eine große Freigabe legen. Rippy prüft den Platz vor
|
{betrieb.ablage_vorgabe ? <> (Vorgabe: <code>{betrieb.ablage_vorgabe}</code>)</> : null} oder
|
||||||
jedem Rip und bricht sonst mit Klartext ab.
|
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>
|
||||||
|
<span className={fett}>4K-UHD-Tipp:</span> Rohdaten sind bis 100 GB groß. Wenn der Platz
|
||||||
|
knapp wird (das Dashboard zeigt ihn), das Arbeitsverzeichnis unter Einstellungen →
|
||||||
|
Verarbeitung auf {betrieb.kann.freigaben_einhaengen ? 'eine große Freigabe' : 'ein großes Laufwerk'} 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 & System">
|
<Abschnitt icon={Wrench} titel="MakeMKV-Beta-Key & System">
|
||||||
<p>
|
<p>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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>◉</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: '✓', warnung: '!', fehler: '✗' };
|
||||||
|
$('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
|
||||||
@@ -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) == ""
|
||||||
@@ -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"
|
||||||
@@ -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()
|
||||||
@@ -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 ""
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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."
|
||||||
|
)
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user