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) == ""