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 (
+ 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.'}
+
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() {
{/* 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 (
-
+ {/* 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' && (
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) == ""