From 66642b8d936f0eeb0736ffde579743db69296483 Mon Sep 17 00:00:00 2001 From: Hitonabi Date: Fri, 28 Aug 2026 13:45:00 +0200 Subject: [PATCH] feat(ui): die Oberflaeche weiss jetzt, worauf sie laeuft MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Commander-Befund 28.08.2026, zum Windows-Fenster: Worker erreichbar: 0 von 1 Kein Worker antwortet — Pruefen: docker compose ps Container-Platte: unbekannt Freigaben: keine eingehaengt Kein Satz davon ergibt auf einem Windows-PC einen Sinn. Sein Urteil: "Du hast ja quasi nur rippy genommen und die docker installation fuer Windows gebaut. Das gilt fuer die ganze standalone version fuer Windows, auch fuer die settings und die Anleitung usw." ## Die Ursache war nicht die Anzeige Die naheliegende Reparatur waere ein `if (windows)` an dreissig Stellen gewesen -- dieselbe Falle noch einmal, nur mit einer zweiten Sorte Vermutung. Es fehlte etwas anderes: Das UI hat nie erfahren, worauf es laeuft. config.py kennt das Profil seit V2-2, weitergegeben wurde es nie. Also hat das UI angenommen. Neu: GET /betrieb meldet FAEHIGKEITEN, keinen Modus-Namen. externe_worker Gibt es andere Maschinen, die Jobs uebernehmen? freigaben_einhaengen Kann Rippy Netzwerk-Freigaben selbst einhaengen? container_pfade Sind Pfade wie /app/media ueberhaupt gemeint? werkzeuge_verwalten Kann Rippy MakeMKV/HandBrake selbst beschaffen? Ein Modus-Name wuerde das UI zwingen, aus einem Namen auf Verhalten zu schliessen -- und das bricht beim naechsten Betriebsfall: Ein Docker-All-in-One hat Container-Pfade, aber keinen zweiten Worker. ## Was sich sichtbar aendert (auf Windows nachgemessen) Server-Status "Rippy arbeitet: auf diesem Rechner" statt Worker-Zaehler "Platz fuer Rippy: 59.2 von 232 GB frei" statt "unbekannt" Freigaben-Block faellt weg Einstellungen kein Reiter "Worker"; Speicherziele BLEIBT (dort steht die Ablage -- "wo will ich das hinspeichern" war die Frage), aber mit Pfadfeld statt Container-Auswahl und ohne die Maske zum Einhaengen Anleitung kein docker, kein Encoding-Worker-Abschnitt, stattdessen der echte Ordner (C:\Users\...\Videos\Rippy) ## Zwei Fehler, die dabei aufgefallen sind * MEDIA_ROOT = "/app/media" war in main.py fest verdrahtet. shutil.disk_usage warf unter Windows, die Liste blieb leer -- daher "unbekannt", obwohl auf dem Laufwerk 59 GB frei waren. Eine Nichtauskunft, die wie eine Auskunft aussieht. platz_orte() liefert die Orte jetzt je Betrieb, und ein noch nicht angelegter Ordner faellt auf das naechste vorhandene Elternteil zurueck. * Der SSE-Schnappschuss enthielt den Server-Zustand NICHT, und der Waechter schickt ihn nur alle 15 Sekunden. Nach jedem Neuladen stand deshalb bis zu eine Viertelminute "unbekannt" da. Jetzt ist er im Schnappschuss, und das UI uebernimmt ihn auch von dort. Dazu: der Windows-Skip in test_api_smoke.py ist weg. Er stammte aus der Zeit vor V2-4, als main.py fcntl brauchte; seit der Treiberwahl ueber den Port laedt es auf beiden Plattformen (57 Routen, gemessen). Damit laufen 20 Tests mehr auch lokal statt nur auf der Ampel. Ampel lokal: 654 gruen, ruff sauber. Docker-Zweig durch Unit-Tests gedeckt, am echten Container noch nicht gegengeprueft -- das kommt beim Deploy. Co-Authored-By: Claude Opus 5 --- docker/api/main.py | 109 ++++++++++++-- docker/api/test_api_smoke.py | 94 ++++++++++-- docker/ui/src/App.tsx | 12 +- docker/ui/src/lib/useBetrieb.tsx | 133 +++++++++++++++++ docker/ui/src/lib/useEventStream.tsx | 11 ++ docker/ui/src/pages/Anleitung.tsx | 51 +++++-- docker/ui/src/pages/Dashboard.tsx | 49 ++++++- docker/ui/src/pages/Settings.tsx | 93 +++++++++--- src/rippy/betrieb.py | 208 +++++++++++++++++++++++++++ src/rippy/test_betrieb.py | 192 +++++++++++++++++++++++++ 10 files changed, 899 insertions(+), 53 deletions(-) create mode 100644 docker/ui/src/lib/useBetrieb.tsx create mode 100644 src/rippy/betrieb.py create mode 100644 src/rippy/test_betrieb.py diff --git a/docker/api/main.py b/docker/api/main.py index 0f5edb1..aaab9dd 100644 --- a/docker/api/main.py +++ b/docker/api/main.py @@ -453,9 +453,45 @@ async def _snapshot() -> dict: # „konnte nicht nachsehen" ist etwas anderes als „es gibt keine". # Deshalb None und nicht [] — das UI behält dann seinen alten Stand. zustand["devices"] = None + + # ⚠️ 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 +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: """Ein Ereignis im SSE-Format. `id:` ist die Basis der Wiederaufnahme.""" nutzlast = json.dumps(ereignis, ensure_ascii=False) @@ -1846,6 +1882,45 @@ async def browse_mkdir(request: MkdirRequest): # 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") async def system_werkzeuge(): """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.""" def sammle(): info = {"api_version": app.version, "plaetze": [], "workers": db.list_workers()} - for name, pfad in (("Media (/app/media)", MEDIA_ROOT), - ("Arbeitsverzeichnis (/app/temp)", "/app/temp")): + # ⚠️ NICHT fest /app/media und /app/temp (Befund 28.08.2026). + # + # 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: nutzung = shutil.disk_usage(pfad) - info["plaetze"].append({ - "name": name, - "frei_gb": round(nutzung.free / 1024**3, 1), - "gesamt_gb": round(nutzung.total / 1024**3, 1), - }) except OSError: - pass + continue + info["plaetze"].append({ + "name": ort["name"], + "frei_gb": round(nutzung.free / 1024**3, 1), + "gesamt_gb": round(nutzung.total / 1024**3, 1), + }) einstellungen = db.get_settings() info["makemkv_key_ui"] = bool((einstellungen.get("makemkvAppKey") or "").strip()) info["webhook_gesetzt"] = bool((einstellungen.get("notificationWebhook") or "").strip()) diff --git a/docker/api/test_api_smoke.py b/docker/api/test_api_smoke.py index 801c4b2..ef09161 100644 --- a/docker/api/test_api_smoke.py +++ b/docker/api/test_api_smoke.py @@ -2,16 +2,20 @@ 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). -Läuft nur unter Linux (detection.py nutzt fcntl/ioctl), also genau dort, -wo auch die Ampel läuft. +## Warum das hier nicht mehr uebersprungen wird (28.08.2026) + +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 - -if sys.platform == "win32": # pragma: no cover - pytest.skip("detection.py braucht fcntl (Linux)", allow_module_level=True) +import pytest # noqa: F401 (einzelne Tests brauchen ihn fuer skipif) 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 # unter Linux nie selbst, sie kommen von Hand über diesen Endpunkt. "/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" @@ -317,9 +324,18 @@ def test_snapshot_meldet_unlesbare_laufwerke_als_none(monkeypatch): 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 - 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 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_logs", lambda limit=50: []) 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()) - 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["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"] == "" diff --git a/docker/ui/src/App.tsx b/docker/ui/src/App.tsx index f64baf8..3943d69 100644 --- a/docker/ui/src/App.tsx +++ b/docker/ui/src/App.tsx @@ -7,6 +7,7 @@ import AnleitungPage from './pages/Anleitung' import FirstRunWizard from './components/FirstRunWizard' import { api } from './lib/api' import { EventStreamProvider, useStrom } from './lib/useEventStream' +import { BetriebProvider } from './lib/useBetrieb' 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. // Ein Hook je Komponente waere eine Verbindung je Komponente — also das // 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 ( - - - + + + + + ) } diff --git a/docker/ui/src/lib/useBetrieb.tsx b/docker/ui/src/lib/useBetrieb.tsx new file mode 100644 index 0000000..9eb59fc --- /dev/null +++ b/docker/ui/src/lib/useBetrieb.tsx @@ -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(VORGABE) + +export function BetriebProvider({ children }: { children: ReactNode }) { + const [betrieb, setBetrieb] = useState(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 {children} +} + +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 +} diff --git a/docker/ui/src/lib/useEventStream.tsx b/docker/ui/src/lib/useEventStream.tsx index 286e733..117c62d 100644 --- a/docker/ui/src/lib/useEventStream.tsx +++ b/docker/ui/src/lib/useEventStream.tsx @@ -123,6 +123,17 @@ export function EventStreamProvider({ children }: { children: ReactNode }) { devices: d.devices === undefined ? null : d.devices, workers: d.workers ?? [], 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, }) }) diff --git a/docker/ui/src/pages/Anleitung.tsx b/docker/ui/src/pages/Anleitung.tsx index 2f7fc7a..803fcfb 100644 --- a/docker/ui/src/pages/Anleitung.tsx +++ b/docker/ui/src/pages/Anleitung.tsx @@ -2,6 +2,7 @@ import { ReactNode } from 'react' import { Disc, Tv, HardDrive, Bell, Wrench, Cpu, Download, HelpCircle } from 'lucide-react' import { PageHeader } from '../components/ui/PageHeader' 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 }) { return ( @@ -21,6 +22,18 @@ function Abschnitt({ icon: Icon, titel, children }: { icon: any, titel: string, export default function AnleitungPage() { 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 (
@@ -67,18 +80,28 @@ export default function AnleitungPage() {

- + + {betrieb.kann.freigaben_einhaengen ? ( +

+ Einstellungen → Speicherziele: Rechner/NAS eintragen, Benutzer + + Passwort angeben (Windows und die meisten NAS lehnen Gast-Zugriffe ab!), „Freigaben + auflisten", Freigabe wählen, „Einhängen & speichern". Danach taucht das Ziel im + „Rippen starten"-Dialog auf und wird beim Start automatisch wieder verbunden. +

+ ) : ( +

+ Einstellungen → Speicherziele: Bei Ablage steht der Ordner, + in dem die fertigen Filme landen. Das darf ein normaler Ordner sein + {betrieb.ablage_vorgabe ? <> (Vorgabe: {betrieb.ablage_vorgabe}) : null} oder + ein Netzwerkpfad wie \NAS\Filme — Rippy schreibt einfach dorthin. + Ein Einhängen wie unter Linux gibt es hier nicht und ist auch nicht nötig. +

+ )}

- Einstellungen → Speicherziele: Rechner/NAS eintragen, Benutzer + - Passwort angeben (Windows und die meisten NAS lehnen Gast-Zugriffe ab!), „Freigaben - auflisten", Freigabe wählen, „Einhängen & speichern". Danach taucht das Ziel im - „Rippen starten"-Dialog auf und wird beim Start automatisch wieder verbunden. -

-

- 4K-UHD-Tipp: Rohdaten sind bis 100 GB groß. Wenn die - Rippy-Platte knapp ist (Dashboard zeigt den freien Platz), das Arbeitsverzeichnis unter - Einstellungen → Verarbeitung auf eine große Freigabe legen. Rippy prüft den Platz vor - jedem Rip und bricht sonst mit Klartext ab. + 4K-UHD-Tipp: 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.

@@ -104,6 +127,11 @@ export default function AnleitungPage() {

+ {/* 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 && (

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).

+ )}

diff --git a/docker/ui/src/pages/Dashboard.tsx b/docker/ui/src/pages/Dashboard.tsx index 5050135..08f6ae0 100644 --- a/docker/ui/src/pages/Dashboard.tsx +++ b/docker/ui/src/pages/Dashboard.tsx @@ -11,6 +11,7 @@ import JobDetailModal from '../components/JobDetailModal' import LiveLogSection from '../components/LiveLogSection' import RetryDialog from '../components/RetryDialog' import { useStrom } from '../lib/useEventStream' +import { useBetrieb } from '../lib/useBetrieb' interface JobMeta { year?: number @@ -128,6 +129,9 @@ export default function Dashboard() { const [laufwerke, setLaufwerke] = useState([]) const [ablagen, setAblagen] = useState([]) 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() /* @@ -678,7 +682,30 @@ export default function Dashboard() { )}

- {/* 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 ? ( +
+
+ + Rippy arbeitet: + + + auf diesem Rechner + +
+

+ {aktiverJob + ? 'Rippen und Komprimieren laufen hier — kein zweiter Rechner nötig.' + : 'Rippen und Komprimieren erledigt Rippy selbst.'} +

+
+ ) : (
@@ -692,7 +719,9 @@ export default function Dashboard() { {workerOnline.length === 0 ? (

Kein Worker antwortet — ohne ihn läuft kein Rip. - Prüfen: docker compose ps + {betrieb.hilfe_befehl && ( + <> Prüfen: {betrieb.hilfe_befehl} + )}

) : (
@@ -713,6 +742,7 @@ export default function Dashboard() {
)}
+ )} {/* 3. Platz — Container-Platte UND die eingehängten Freigaben. Vorher stand hier nur die Container-Platte; wer wissen @@ -721,7 +751,8 @@ export default function Dashboard() {
- Container-Platte: + + {betrieb.kann.container_pfade ? 'Container-Platte:' : 'Platz für Rippy:'} 0 && freiGb < 60 ? 'text-amber-400 font-bold' : 'text-slate-200'}> {freiGb > 0 ? `${freiGb} von ${gesamtGb} GB frei` : 'unbekannt'} @@ -744,7 +775,16 @@ export default function Dashboard() {
{/* 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 && (
@@ -778,6 +818,7 @@ export default function Dashboard() {
)}
+ )}
diff --git a/docker/ui/src/pages/Settings.tsx b/docker/ui/src/pages/Settings.tsx index a31bf28..ff55eca 100644 --- a/docker/ui/src/pages/Settings.tsx +++ b/docker/ui/src/pages/Settings.tsx @@ -4,6 +4,7 @@ import { api } from '../lib/api' import { useToast } from '../context/ToastContext' import StorageMounts from '../components/StorageMounts' import WorkerVerwaltung from '../components/WorkerVerwaltung' +import { useBetrieb } from '../lib/useBetrieb' import { PageHeader } from '../components/ui/PageHeader' import { Card, CardHeader, CardTitle, CardContent } from '../components/ui/Card' import { Button } from '../components/ui/Button' @@ -48,7 +49,10 @@ const defaultSettings: SettingsState = { tmdbApiKey: '', tvdbApiKey: '', 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', seriesDir: 'series', musicDir: 'music', @@ -187,6 +191,7 @@ function zeitLesbar(iso: string): string { export default function SettingsPage() { const [settings, setSettings] = useState(defaultSettings) + const betrieb = useBetrieb() const [activeTab, setActiveTab] = useState('ripping') const [workers, setWorkers] = useState([]) const [systemInfo, setSystemInfo] = useState(null) @@ -485,7 +490,12 @@ export default function SettingsPage() { const istFreigabe = (pfad: string) => ziele.some(z => z.path === pfad && z.is_mount) 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 if (!arbeit) { return 'Das Arbeitsverzeichnis steht auf der Container-Platte. Ein Encoder auf ' @@ -505,16 +515,45 @@ export default function SettingsPage() { 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 }[] = [ { id: 'ripping', label: 'Ripping', icon: Disc }, { 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: 'apis', label: 'APIs', icon: Globe }, { id: 'benachrichtigungen', label: 'Benachrichtigungen', icon: AlertCircle }, { 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 (
{tabs.map((tab) => { - const isActive = activeTab === tab.id + const isActive = aktiverReiter === tab.id return (
- + {/* 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 && } )} {/* APIs */} - {activeTab === 'apis' && ( + {aktiverReiter === 'apis' && (

@@ -1070,7 +1131,7 @@ export default function SettingsPage() { )} {/* Benachrichtigungen */} - {activeTab === 'benachrichtigungen' && ( + {aktiverReiter === 'benachrichtigungen' && (

@@ -1120,7 +1181,7 @@ export default function SettingsPage() { )} {/* System */} - {activeTab === 'system' && ( + {aktiverReiter === 'system' && (

diff --git a/src/rippy/betrieb.py b/src/rippy/betrieb.py new file mode 100644 index 0000000..efba49c --- /dev/null +++ b/src/rippy/betrieb.py @@ -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") diff --git a/src/rippy/test_betrieb.py b/src/rippy/test_betrieb.py new file mode 100644 index 0000000..65fb079 --- /dev/null +++ b/src/rippy/test_betrieb.py @@ -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) == ""