diff --git a/docker/api/main.py b/docker/api/main.py index aaab9dd..271f5c2 100644 --- a/docker/api/main.py +++ b/docker/api/main.py @@ -238,7 +238,7 @@ async def _auto_rip_wenn_aktiviert(pfad: str): ) return - basis = einstellungen.get("outputDir") or MEDIA_ROOT + basis = einstellungen.get("outputDir") or medien_wurzel() meta = disc.get("metadata") or {} if disc.get("disc_type") == "CD": unterordner = einstellungen.get("musicDir") or "music" @@ -247,7 +247,7 @@ async def _auto_rip_wenn_aktiviert(pfad: str): else: unterordner = einstellungen.get("movieDir") or "movies" ziel = os.path.normpath(os.path.join(basis, unterordner)) - if not unter_wurzel(ziel, MEDIA_ROOT): + if not pfad_erlaubt(ziel): ziel = None job_id = str(uuid.uuid4()) @@ -978,18 +978,78 @@ class JobCreateRequest(BaseModel): work_dir: Optional[str] = None +# Der Pfad IM Container. Bleibt als Rückfall stehen — aber er ist NICHT mehr +# die Antwort auf „wo liegt die Ablage": die gibt `medien_wurzel()`. MEDIA_ROOT = "/app/media" +def _betriebswerte() -> dict: + """Die Konfiguration. Unlesbar heißt Vorgaben, nicht Absturz.""" + from rippy import config as rippy_config + try: + return rippy_config.laden() + except Exception: # noqa: BLE001 + return {} + + +def medien_wurzel() -> str: + """Wo dieser Betrieb ablegt — `/app/media` nur, wenn es ein Container ist. + + ## Der Befund des Commanders (28.08.2026) + + > „Warum heißt das hier noch container platte? Er holt sich das + > Arbeitsverzeichnis ja von der Installation. Wäre es möglich das + > Arbeitsverzeichnis zu ändern? momentan geht das nicht." + + Es ging nicht, und zwar aus einem Grund: `MEDIA_ROOT` war fest + `/app/media`. Auf seinem PC gibt es den Ordner nicht, also warf + `os.listdir` in `/storage-targets`, also blieb die Liste leer — im + Auswahlfeld stand genau ein Eintrag, und der hieß „Container-Platte". + Kein Fehler, keine Meldung, nur eine Auswahl ohne Auswahl. + + Dieselbe Konstante war zugleich die Pfadgrenze für `/browse`. Auch der + Ordner-Browser konnte auf Windows also nichts anzeigen. + """ + from rippy import betrieb + try: + return betrieb.medien_wurzel(_betriebswerte()) or MEDIA_ROOT + except Exception: # noqa: BLE001 + return MEDIA_ROOT + + +def frei_blaettern() -> bool: + """Darf außerhalb der Wurzel geblättert werden? Begründung in betrieb.py.""" + from rippy import betrieb + try: + return betrieb.frei_blaettern(_betriebswerte()) + except Exception: # noqa: BLE001 + return False + + +def pfad_erlaubt(pfad: str, wurzel: str = None, frei: bool = None) -> bool: + """Darf die API diesen Pfad anfassen? (pure Funktion, einspritzbar) + + Im Container und im verteilten Betrieb gilt die Wurzel — die API hängt + dort im Netz. In der nativen App gilt sie nicht: Sie bedient den Menschen, + der vor dem Rechner sitzt, und dessen Ziel ist eine UNC-Freigabe, die + unter gar keiner lokalen Wurzel liegt. + """ + if not pfad: + return False + if frei_blaettern() if frei is None else frei: + return True + return unter_wurzel(pfad, medien_wurzel() if wurzel is None else wurzel) + + def _validiere_ziel(target_dir: Optional[str]) -> Optional[str]: - """Ziel muss unter /app/media liegen — Pfad-Ausbrüche (..) fliegen raus.""" + """Ziel muss erlaubt sein — Pfad-Ausbrüche (..) fliegen raus.""" if not target_dir: return None normalisiert = os.path.normpath(target_dir) - if not unter_wurzel(normalisiert, MEDIA_ROOT): + if not pfad_erlaubt(normalisiert): raise HTTPException( status_code=422, - detail=f"Ziel muss unter {MEDIA_ROOT} liegen (Shares dort einhängen)", + detail=f"Ziel muss unter {medien_wurzel()} liegen (Shares dort einhängen)", ) return normalisiert @@ -1111,10 +1171,10 @@ def unter_wurzel(pfad: str, wurzel: str) -> bool: def _job_ausgabeordner(job: dict) -> str: - """Validierter Ausgabeordner eines Jobs — strikt unter /app/media.""" + """Validierter Ausgabeordner eines Jobs — muss erlaubt sein.""" ausgabe = os.path.normpath(job.get("output_path") or "") - if not unter_wurzel(ausgabe, MEDIA_ROOT): - raise HTTPException(status_code=404, detail="Job hat keinen Ausgabeordner unter /app/media") + if not pfad_erlaubt(ausgabe): + raise HTTPException(status_code=404, detail="Job hat keinen erlaubten Ausgabeordner") return ausgabe @@ -1170,7 +1230,7 @@ async def download_job_file(job_id: str, dateiname: str): pfad = os.path.join(ausgabe, dateiname) def pruefe(): - return os.path.isfile(pfad) and unter_wurzel(os.path.realpath(pfad), MEDIA_ROOT) + return os.path.isfile(pfad) and pfad_erlaubt(os.path.realpath(pfad)) if not await asyncio.to_thread(pruefe): raise HTTPException(status_code=404, detail="Datei nicht gefunden") @@ -1179,19 +1239,44 @@ async def download_job_file(job_id: str, dateiname: str): @app.get("/storage-targets") async def storage_targets(): - """Verfügbare Ablageziele: Verzeichnisse unter /app/media inkl. Mounts. + """Verfügbare Ablageziele: Verzeichnisse unter der Medien-Wurzel. NFS/SMB-Shares, die auf der VM unter /srv/rippy/media eingehängt werden, tauchen hier automatisch auf (rslave-Bind in docker-compose). + + ## Warum hier Laufwerke dazukommen (Commander-Befund 28.08.2026) + + Auf Windows war diese Liste IMMER leer: `os.listdir("/app/media")` warf, + und der `except OSError` gab still `[]` zurück. Im Auswahlfeld für das + Arbeitsverzeichnis stand dann genau ein Eintrag — „Standard aus den + Einstellungen (Container-Platte)". Eine Auswahl ohne Auswahl. + + Die Wurzel allein reicht dort auch nicht: Der Roh-Rip einer 4K-UHD ist bis + zu 100 GB groß, und die Antwort darauf ist fast immer ein ANDERES + Laufwerk. Deshalb kommen im nativen Betrieb die Laufwerke dazu — mit dem + freien Platz daneben, damit die Wahl eine informierte ist. """ def sammle(): + wurzel = medien_wurzel() ziele = [] + + def platz(pfad): + try: + return round(shutil.disk_usage(pfad).free / 1024**3, 1) + except OSError: + return None + + # Die Wurzel selbst — im Container war sie nie ein Eintrag, weil dort + # die Unterordner die Ziele sind. Nativ IST sie ein gültiges Ziel. + if frei_blaettern() and os.path.isdir(wurzel): + ziele.append({"name": "Ablage (%s)" % wurzel, "path": wurzel, + "is_mount": False, "free_gb": platz(wurzel)}) try: - eintraege = sorted(os.listdir(MEDIA_ROOT)) + eintraege = sorted(os.listdir(wurzel)) except OSError: - return ziele + eintraege = [] for name in eintraege: - pfad = os.path.join(MEDIA_ROOT, name) + pfad = os.path.join(wurzel, name) try: ist_mount = os.path.ismount(pfad) except OSError: @@ -1213,6 +1298,20 @@ async def storage_targets(): "is_mount": ist_mount, "free_gb": frei_gb, }) + + # Die Laufwerke — nur nativ. Im Container gäbe es nichts zu holen. + if frei_blaettern(): + from rippy import betrieb + bekannt = {z["path"].rstrip("\\/").lower() for z in ziele} + for laufwerk in betrieb.windows_laufwerke(): + if laufwerk.rstrip("\\/").lower() in bekannt: + continue + ziele.append({ + "name": "Laufwerk %s" % laufwerk.rstrip("\\/"), + "path": laufwerk, + "is_mount": False, + "free_gb": platz(laufwerk), + }) return ziele return await asyncio.to_thread(sammle) @@ -1299,7 +1398,7 @@ async def retry_transcode(job_id: str): raw_dir = gefunden[0] # Zielordner: der Worker schreibt das geplante Ziel beim Rip-Start nach # output_path (sprechender Name statt UUID) — alter Fallback bleibt. - basis = job.get("target_dir") or f"{MEDIA_ROOT}/{job.get('disc_type') or 'bluray'}" + basis = job.get("target_dir") or f"{medien_wurzel()}/{job.get('disc_type') or 'bluray'}" final_dir = job.get("output_path") or f"{basis}/{job_id}" # An den (beim Rip gewählten) Encoder-Worker routen, sonst geteilte Queue @@ -1809,11 +1908,23 @@ async def delete_storage_mount(name: str): @app.get("/browse") -async def browse(path: str = MEDIA_ROOT): - """Server-seitiger Ordner-Browser für die Ziel-Auswahl (nur unter /app/media).""" +async def browse(path: str = ""): + """Server-seitiger Ordner-Browser für die Ziel- und Arbeitsordner-Wahl. + + Leerer Pfad heißt „ganz oben": im Container die Medien-Wurzel, nativ die + Liste der Laufwerke. Ohne diese oberste Ebene käme man auf Windows nie zu + einem anderen Laufwerk — und genau dort ist Platz für 100 GB Rohdaten. + """ + if not path.strip(): + laufwerke = betrieb_laufwerke() if frei_blaettern() else [] + if laufwerke: + return {"path": "", "parent": None, "dirs": laufwerke, "files": []} + path = medien_wurzel() + normalisiert = os.path.normpath(path) - if not unter_wurzel(normalisiert, MEDIA_ROOT): - raise HTTPException(status_code=422, detail=f"Nur Pfade unter {MEDIA_ROOT}") + if not pfad_erlaubt(normalisiert): + raise HTTPException(status_code=422, + detail=f"Nur Pfade unter {medien_wurzel()}") def liste(): try: @@ -1840,8 +1951,37 @@ async def browse(path: str = MEDIA_ROOT): if ergebnis is None: raise HTTPException(status_code=404, detail="Ordner nicht lesbar") ordner, dateien = ergebnis - eltern = os.path.dirname(normalisiert) if normalisiert != MEDIA_ROOT else None - return {"path": normalisiert, "parent": eltern, "dirs": ordner, "files": dateien} + return {"path": normalisiert, "parent": eltern_von(normalisiert), + "dirs": ordner, "files": dateien} + + +def eltern_von(pfad: str, wurzel: str = None, frei: bool = None) -> Optional[str]: + """Wohin führt „eine Ebene höher"? — `None` heißt: hier ist oben. + + Zwei Fallen, beide nur auf Windows sichtbar: + + 1. `os.path.dirname("C:\\\\")` ist wieder `"C:\\\\"`. Ein Knopf „nach oben", + der auf denselben Ordner zeigt, sieht aus wie ein Fehler. + 2. Über der Laufwerkswurzel steht nicht *nichts*, sondern die Liste der + Laufwerke — der leere Pfad. Sonst käme man von `D:\\` nie zu `C:\\`. + """ + frei = frei_blaettern() if frei is None else frei + wurzel = (medien_wurzel() if wurzel is None else wurzel) + if not frei: + return os.path.dirname(pfad) if pfad != wurzel else None + oben = os.path.dirname(pfad) + # Laufwerkswurzel (dirname zeigt auf sich selbst) -> die Laufwerksliste. + return "" if oben == pfad else oben + + +def betrieb_laufwerke() -> list: + """Die Laufwerke als Ordner-Einträge für die oberste Ebene des Browsers.""" + from rippy import betrieb + try: + return [{"name": "Laufwerk " + laufwerk.rstrip("\\/"), "path": laufwerk} + for laufwerk in betrieb.windows_laufwerke()] + except Exception: # noqa: BLE001 + return [] class MkdirRequest(BaseModel): @@ -1851,10 +1991,11 @@ class MkdirRequest(BaseModel): @app.post("/browse/mkdir", status_code=201) async def browse_mkdir(request: MkdirRequest): - """Neuen Ordner unter /app/media anlegen (Speicherziele-Verwaltung).""" + """Neuen Ordner anlegen (Speicherziele-Verwaltung, Arbeitsordner).""" basis = os.path.normpath(request.path) - if not unter_wurzel(basis, MEDIA_ROOT): - raise HTTPException(status_code=422, detail=f"Nur Pfade unter {MEDIA_ROOT}") + if not pfad_erlaubt(basis): + raise HTTPException(status_code=422, + detail=f"Nur Pfade unter {medien_wurzel()}") name = request.name.strip() if not name or "/" in name or "\\" in name or name.startswith("."): raise HTTPException(status_code=422, detail="Ungültiger Ordnername") diff --git a/docker/api/prescan/prescan.py b/docker/api/prescan/prescan.py index b4b86ef..eea8cf4 100644 --- a/docker/api/prescan/prescan.py +++ b/docker/api/prescan/prescan.py @@ -25,6 +25,13 @@ from cache.keys import generate_prescan_key # test_treiberwahl.py. detection = _laufwerks_schicht.treiber() +# Wie viele Treffer eine Suche hoechstens liefern darf, damit sie als +# SPEZIFISCH gilt. Gemessen am 28.08.2026: Der volle Disc-Titel +# „Evangelion 2.22" ergab bei TMDB 1 Treffer (den richtigen), die gekuerzte +# Variante „Evangelion" ergab 20 (beliebige). Drei ist grosszuegig genug fuer +# Neuauflagen und Regie-Fassungen desselben Films. +WENIGE_TREFFER = 3 + def normalize_disc_label(label: str) -> str: """Disc-Labels wie 'PULP_FICTION_DE' → 'Pulp Fiction De' (pure Funktion). @@ -447,11 +454,13 @@ class PreScan: # probieren — Disc-Titel sind selten API-freundlich formatiert. kandidaten = titel_kandidaten(title) or [title] movies = [] + movies_kandidat = "" # WELCHE Variante hat sie geliefert? for kandidat in kandidaten: treffer = self.tmdb.search_movie(kandidat) if treffer and not movies: movies = treffer # bester Rohtreffer für den Vorschlags-Fallback + movies_kandidat = kandidat for movie in treffer: if movie.get("title", "").lower() == kandidat.lower(): confidence = 0.95 @@ -498,6 +507,55 @@ class PreScan: if matched: break + # ── Ein spezifischer Treffer zählt, auch ohne exakten Titel ───────── + # + # ## Der Befund des Commanders (28.08.2026) + # + # > „Was ist mit dem Cover auf Windows Rippy? In der Web Version haben + # > wir ein cover." + # + # Es gab keins, weil es gar keinen Treffer gab — und der Grund war + # nicht Windows, sondern die Bedingung oben: `movie["title"].lower() + # == kandidat.lower()`. An seiner Disc gemessen: + # + # 'Evangelion 2.22' 1 Treffer Evangelion: 2.0 You Can (Not) Advance + # 'Evangelion' 20 Treffer irgendein Evangelion + # + # Der EINZIGE Treffer auf den vollen Disc-Titel war der richtige Film, + # mit Poster — und wurde verworfen, weil der Titel nicht wörtlich + # gleich war. Danach gewann eine schlechtere Quelle oder gar keine. + # + # ## Warum die Trefferzahl das bessere Kriterium ist als Ähnlichkeit + # + # Die Ähnlichkeit hilft hier nicht: „Evangelion 2.22" gegen + # „Evangelion: 2.0 You Can (Not) Advance" ergibt 0,51 — das steht als + # Messung schon im Jikan-Absatz unten und fällt durch jedes sinnvolle + # Gatter. Die SPEZIFITÄT der Anfrage sagt mehr: Wer auf den vollen, + # ungekürzten Disc-Titel eine Handvoll Treffer bekommt, hat gefragt + # wie jemand, der weiß, was er sucht. Wer 20 bekommt, hat geraten. + # + # Deshalb: nur der UNGEKÜRZTE Titel (kandidaten[0]) und nur wenige + # Treffer. Confidence 0,8 — sicherer als ein Vorschlag (0,6), aber + # ehrlich unter einem wörtlichen Treffer (0,95). + if not matched and movies and movies_kandidat == kandidaten[0] \ + and len(movies) <= WENIGE_TREFFER: + details = self.tmdb.get_movie_details(movies[0]["id"]) + if details: + confidence = 0.8 + metadata = { + "type": "movie", + "id": movies[0]["id"], + "title": details.get("title", title), + "year": int(details.get("release_date", "0")[:4]) if details.get("release_date") else None, + "overview": details.get("overview", ""), + "poster_path": details.get("poster_path", ""), + "backdrop_path": details.get("backdrop_path", ""), + "runtime": details.get("runtime", 0), + "genres": [g["name"] for g in details.get("genres", [])], + "source": "tmdb", + } + matched = True + # Fallback 1: Jikan/MyAnimeList (kostenlos, KEIN Key) — für Anime die # präziseste Quelle; wählt per Titel-Ähnlichkeit, nicht Treffer #1. # diff --git a/docker/api/test_api_smoke.py b/docker/api/test_api_smoke.py index ef09161..87cff29 100644 --- a/docker/api/test_api_smoke.py +++ b/docker/api/test_api_smoke.py @@ -401,7 +401,12 @@ def test_betrieb_meldet_faehigkeiten_statt_nur_einen_namen(): 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"} + "container_pfade", "werkzeuge_verwalten", + "frei_blaettern"} + # Beide Orte muessen dabei sein. Fehlte einer, muesste die Oberflaeche + # wieder raten — und genau daraus wurde „Container-Platte" auf einem PC, + # der keinen Container hat (Befund 28.08.2026). + assert daten["ablage_vorgabe"] and daten["arbeits_vorgabe"] 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"]: diff --git a/docker/api/test_pfadgrenze.py b/docker/api/test_pfadgrenze.py new file mode 100644 index 0000000..ea19379 --- /dev/null +++ b/docker/api/test_pfadgrenze.py @@ -0,0 +1,82 @@ +"""Welche Pfade darf die API anfassen — und wo ist „oben"? + +## Warum diese Grenze verschiebbar sein muss (Befund 28.08.2026) + +`MEDIA_ROOT = "/app/media"` war zugleich Vorgabe UND Pfadgrenze. Im Container +ist beides richtig. Auf dem Windows-PC des Commanders war beides falsch, und +die Folge war eine Oberflaeche, die stillschweigend nichts konnte: + +* `/storage-targets` lieferte `[]` (der Ordner existiert dort nicht), +* `/browse` antwortete auf JEDEN Pfad mit 422, +* im Auswahlfeld fuer das Arbeitsverzeichnis stand genau ein Eintrag. + +Sein Befund dazu: „Wäre es möglich das Arbeitsverzeichnis zu ändern? +momentan geht das nicht." + +Die Grenze faellt aber NICHT einfach weg. Im Container haengt die API im +Netz — eine Weboberflaeche, die jeden Pfad des Wirts ausliefern kann, ist ein +Loch. Sie gilt nur dort nicht, wo Rippy den Menschen bedient, der vor dem +Rechner sitzt. Diese Tests halten beide Haelften fest. +""" + +import main + + +# ── Die Grenze gilt, wo zugehoert wird ────────────────────────────────── +def test_im_container_gilt_die_wurzel_weiter(): + """Der Docker-Weg darf sich durch die Reparatur NICHT lockern.""" + assert main.pfad_erlaubt("/app/media/movies", "/app/media", frei=False) + assert not main.pfad_erlaubt("/etc/passwd", "/app/media", frei=False) + # Der Klassiker: beginnt mit der Wurzel, liegt aber ausserhalb. + assert not main.pfad_erlaubt("/app/media-boese/x", "/app/media", frei=False) + + +def test_nativ_ist_auch_eine_freigabe_erlaubt(): + """Sein Ziel ist `\\\\192.168.179.62\\rippy\\movies` — das liegt unter gar + keiner lokalen Wurzel. Mit der alten Regel war es unerreichbar.""" + assert main.pfad_erlaubt(r"\\192.168.179.62\rippy\movies", "/app/media", frei=True) + assert main.pfad_erlaubt(r"D:\Rippy-Arbeit", "/app/media", frei=True) + + +def test_ein_leerer_pfad_ist_nie_erlaubt(): + """Sonst wuerde aus einem vergessenen Feld ein Zugriff auf `/`.""" + assert not main.pfad_erlaubt("", "/app/media", frei=True) + assert not main.pfad_erlaubt("", "/app/media", frei=False) + + +# ── Wo ist oben ───────────────────────────────────────────────────────── +def test_an_der_wurzel_ist_schluss(): + """Im Container. `None` heisst: kein Knopf „nach oben".""" + assert main.eltern_von("/app/media", "/app/media", frei=False) is None + assert main.eltern_von("/app/media/movies", "/app/media", frei=False) == "/app/media" + + +def test_ueber_der_laufwerkswurzel_steht_die_laufwerksliste(): + """Zwei Fallen auf einmal. + + `os.path.dirname("C:\\\\")` ist wieder `"C:\\\\"` — ein Knopf „nach oben", + der auf denselben Ordner zeigt, sieht aus wie ein Fehler. Und ueber der + Laufwerkswurzel steht nicht *nichts*, sondern die Liste der Laufwerke: + Sonst kaeme man von `D:\\` nie zu `C:\\`, und genau dort ist Platz fuer + 100 GB Rohdaten. + """ + import ntpath + import os + + echt = os.path.dirname + os.path.dirname = ntpath.dirname # Windows-Pfade auch auf Linux + try: + assert main.eltern_von("C:\\", "D:\\Rippy", frei=True) == "" + assert main.eltern_von("C:\\Users\\Tobi", "D:\\Rippy", frei=True) == "C:\\Users" + finally: + os.path.dirname = echt + + +def test_die_wurzel_kommt_aus_dem_betrieb_und_ist_nie_leer(): + """Eine leere Wurzel wuerde jede Pruefung durchwinken.""" + assert main.medien_wurzel() + + +def test_laufwerke_ohne_windows_sind_eine_leere_liste(): + """Auf dem Linux-Runner der Ampel darf das kein Fehler sein.""" + assert isinstance(main.betrieb_laufwerke(), list) diff --git a/docker/api/test_prescan_treffer.py b/docker/api/test_prescan_treffer.py new file mode 100644 index 0000000..f443994 --- /dev/null +++ b/docker/api/test_prescan_treffer.py @@ -0,0 +1,156 @@ +"""Wann gilt ein TMDB-Treffer als sicher genug? — ohne Netz geprueft. + +## Der Befund des Commanders (28.08.2026) + +> „Was ist mit dem Cover auf Windows Rippy? In der Web Version haben wir ein +> cover." + +Es gab keins, weil es gar keinen Treffer gab. Der Grund war nicht Windows, +sondern eine Bedingung im Pre-Scan: + + if movie.get("title", "").lower() == kandidat.lower(): + +An seiner Disc gemessen: + + 'Evangelion 2.22' 1 Treffer Evangelion: 2.0 You Can (Not) Advance + 'Evangelion' 20 Treffer irgendein Evangelion + +Der EINZIGE Treffer auf den vollen Disc-Titel war der richtige Film, mit +Poster — und wurde verworfen, weil der Titel nicht woertlich gleich war. + +## Warum die Trefferzahl und nicht die Aehnlichkeit + +„Evangelion 2.22" gegen „Evangelion: 2.0 You Can (Not) Advance" ergibt 0,51 +Aehnlichkeit — das steht als Messung schon laenger im Code und faellt durch +jedes sinnvolle Gatter. Die SPEZIFITAET der Anfrage sagt mehr: Wer auf den +vollen Disc-Titel eine Handvoll Treffer bekommt, hat gefragt wie jemand, der +weiss was er sucht. Wer 20 bekommt, hat geraten. + +Diese Tests spritzen die Clients ein — kein Netz, keine Schluessel, keine +Disc. +""" + +import pytest + + +class FakeTMDB: + """Ein TMDB, das genau die gemessenen Antworten liefert.""" + + def __init__(self, treffer_je_suche, details=None): + self.treffer = treffer_je_suche + self.details = details or {} + self.gefragt = [] + + def search_movie(self, begriff): + self.gefragt.append(begriff) + return self.treffer.get(begriff, []) + + def search_tv(self, begriff): + return [] + + def get_movie_details(self, kennung): + return self.details.get(kennung) + + def get_tv_details(self, kennung): + return None + + def find_by_imdb(self, kennung): + return None + + +class StummerClient: + def lookup(self, *a, **k): + return None + + +DETAILS_2_0 = { + 22843: { + "title": "Evangelion: 2.0 You Can (Not) Advance", + "release_date": "2009-06-27", + "overview": "Der Pilotin Mari gelingt es …", + "poster_path": "/hpChtHPoXGTfhphnHGXj2kGXZeH.jpg", + "backdrop_path": "/pzsVGcufDdBLmvagKyLFKaeA4O5.jpg", + "runtime": 112, + "genres": [{"name": "Animation"}], + } +} + + +def _prescan(tmdb): + from prescan.prescan import PreScan + + p = PreScan() + p.tmdb = tmdb + p.jikan = StummerClient() + p.omdb = StummerClient() + return p + + +def _toc(titel): + return {"title": titel, "year": None, "tracks": [], "duration": 0, + "disc_type": "Blu-ray", "fingerprint": "TEST|1"} + + +def test_der_einzige_treffer_auf_den_vollen_titel_gilt(): + """DER Fall des Commanders. Vorher: kein Treffer, kein Cover, 30 %.""" + tmdb = FakeTMDB( + {"Evangelion 2.22": [{"id": 22843, + "title": "Evangelion: 2.0 You Can (Not) Advance"}]}, + DETAILS_2_0) + ergebnis = _prescan(tmdb)._scan_video("/dev/x", _toc("Evangelion 2.22")) + + assert ergebnis.confidence == 0.8 + assert ergebnis.title == "Evangelion: 2.0 You Can (Not) Advance" + assert ergebnis.year == 2009 + assert ergebnis.metadata["poster_path"], "ohne poster_path gibt es kein Cover" + assert ergebnis.metadata["source"] == "tmdb" + + +def test_woertlich_gleicher_titel_bleibt_sicherer(): + """Ein exakter Treffer muss weiterhin hoeher stehen als ein spezifischer.""" + tmdb = FakeTMDB( + {"Logan": [{"id": 22843, "title": "Logan"}]}, + {22843: dict(DETAILS_2_0[22843], title="Logan")}) + ergebnis = _prescan(tmdb)._scan_video("/dev/x", _toc("Logan")) + assert ergebnis.confidence == 0.95 + + +def test_zwanzig_treffer_gelten_NICHT_als_sicher(): + """Wer 20 Treffer bekommt, hat geraten — das darf kein 80-Prozent-Fund + werden. Es bleibt beim ehrlichen Vorschlag.""" + viele = [{"id": 1000 + i, "title": "Evangelion %d" % i} for i in range(20)] + tmdb = FakeTMDB({"Evangelion": viele}, + {1000: dict(DETAILS_2_0[22843], title="Irgendein Evangelion")}) + ergebnis = _prescan(tmdb)._scan_video("/dev/x", _toc("Evangelion")) + assert ergebnis.confidence == 0.6, "20 Treffer sind kein sicherer Fund" + + +def test_treffer_auf_eine_GEKUERZTE_variante_gilt_nicht_als_sicher(): + """Die Spezifitaet zaehlt nur, wenn der VOLLE Disc-Titel gefragt wurde. + + Sonst wuerde „Der Herr der Ringe: Die zwei Tuerme" ueber die Kuerzung + „Der Herr" zu einem 80-Prozent-Fund fuer irgendetwas. + """ + tmdb = FakeTMDB( + {"Evangelion": [{"id": 22843, "title": "Neon Genesis Evangelion"}]}, + {22843: dict(DETAILS_2_0[22843], title="Neon Genesis Evangelion")}) + ergebnis = _prescan(tmdb)._scan_video("/dev/x", _toc("Evangelion 2.22")) + # Der volle Titel lieferte nichts, die Kuerzung schon -> nur Vorschlag. + assert ergebnis.confidence == 0.6 + + +def test_ohne_jeden_treffer_bleibt_es_ehrlich_bei_dreissig_prozent(): + ergebnis = _prescan(FakeTMDB({}))._scan_video("/dev/x", _toc("Gibt Es Nicht")) + assert ergebnis.confidence == 0.3 + assert ergebnis.metadata["type"] == "unknown" + + +@pytest.mark.parametrize("anzahl,erwartet", [(1, 0.8), (3, 0.8), (4, 0.6)]) +def test_die_schwelle_liegt_bei_drei(anzahl, erwartet): + """Drei ist grosszuegig genug fuer Neuauflagen und Regie-Fassungen + desselben Films, aber eng genug, um Raten auszuschliessen.""" + treffer = [{"id": 22843 + i, "title": "Film %d" % i} for i in range(anzahl)] + details = {22843: dict(DETAILS_2_0[22843], title="Film 0")} + tmdb = FakeTMDB({"Ein Sehr Genauer Titel": treffer}, details) + ergebnis = _prescan(tmdb)._scan_video("/dev/x", _toc("Ein Sehr Genauer Titel")) + assert ergebnis.confidence == erwartet diff --git a/docker/ui/src/components/RipTargetModal.tsx b/docker/ui/src/components/RipTargetModal.tsx index bcd9b5e..cd7e228 100644 --- a/docker/ui/src/components/RipTargetModal.tsx +++ b/docker/ui/src/components/RipTargetModal.tsx @@ -1,658 +1,705 @@ -import { useState, useEffect } from 'react' -import { Folder, FolderOpen, ArrowUp, CheckCircle, Film, Tv, Music, Cpu, HardDrive, ChevronRight, ChevronDown } from 'lucide-react' -import { api } from '../lib/api' -import { Modal } from './ui/Modal' -import { Button } from './ui/Button' -import { Input, Select } from './ui/Input' -import { automatikWarnung, externWarnung, freigabenAusMapping, sprachName } from '../lib/encoder' - -interface TargetConfig { - id: string - name: string - path: string - type: 'movies' | 'series' | 'music' - isActive: boolean -} - -export interface RipOptionen { - series?: string - season?: number - mainFeatureOnly?: boolean - titles?: number[] - transcodeNode?: string // gewählter Encoder-Worker (Celery-Node) oder leer = auto - workDir?: string // Arbeitsverzeichnis für die Rohdaten; leer = Einstellung - // Sprachauswahl (ISO-639-2). Leer = alles behalten. Wirkt bei der - // KOMPRESSION — der Rip bleibt vollständig und verlustfrei. - audioSprachen?: string[] - untertitelSprachen?: string[] -} - -// Eine Sprache, die die Disc anbietet (aus dem Titel-Scan, MakeMKV SINFO). -interface DiscSprache { - lang: string // ISO-639-2, z. B. "deu" - sprache: string // Klartext, z. B. "German" - spuren: number -} - -// Ein Ablageziel aus GET /storage-targets. -interface StorageZiel { - name: string - path: string - is_mount: boolean - free_gb: number | null -} - -interface WorkerWahl { - name: string - node: string | null - online?: boolean - encoders: string[] - // extern: 'ja' = läuft AUSSERHALB des Rippy-Containers und erreicht die - // Container-Pfade nur über eine Freigabe plus pfad_map. Der Worker meldet - // beides selbst (worker/caps.py). - info?: { extern?: string, pfad_map?: string } -} - -const ENCODER_KURZ: Record = { - 'cpu-x264': 'H.264', 'cpu-x265': 'H.265', 'cpu-av1': 'AV1', - 'vaapi': 'VAAPI⚡', 'nvenc': 'NVENC⚡', -} - -interface TitelInfo { - nr: number - dauer_s: number - groesse_bytes: number - kapitel: number -} - -interface RipTargetModalProps { - isOpen: boolean - initialType?: TargetConfig['type'] - discTitle?: string - deviceId?: string - onClose: () => void - onSave: (target: TargetConfig, optionen: RipOptionen) => void -} - -function dauerText(s: number): string { - const h = Math.floor(s / 3600) - const m = Math.floor((s % 3600) / 60) - return h > 0 ? `${h}:${String(m).padStart(2, '0')} h` : `${m} min` -} - -interface BrowseDir { - name: string - path: string -} - -export default function RipTargetModal({ isOpen, initialType, discTitle, deviceId, onClose, onSave }: RipTargetModalProps) { - const [selectedType, setSelectedType] = useState('movies') - const [serienName, setSerienName] = useState('') - const [staffel, setStaffel] = useState(1) - const [nurHauptfilm, setNurHauptfilm] = useState(false) - const [scanStatus, setScanStatus] = useState<'idle' | 'running' | 'done' | 'error'>('idle') - const [scanFehler, setScanFehler] = useState('') - const [titelListe, setTitelListe] = useState([]) - const [gewaehlt, setGewaehlt] = useState>(new Set()) - const [workers, setWorkers] = useState([]) - const [encoderNode, setEncoderNode] = useState('') // '' = automatisch - /* - * Sprachen der Disc (Commander-Anforderung 26.07.2026: „Die Disc hat Material - * in X Sprachen und X Untertiteln — Rippy muss VOR dem Rip fragen: Was genau - * willst du haben?"). - * - * Die Auskunft kommt aus demselben Titel-Scan, der schon für die - * Titel-Auswahl läuft — MakeMKV liefert sie in derselben Ausgabe mit, sie - * wurde bisher nur weggeworfen. - * - * Leere Auswahl heißt bewusst „alles behalten": Wer nichts anklickt, bekommt - * das Verhalten von vorher, und niemand verliert versehentlich seine Tonspur. - */ - const [discSprachen, setDiscSprachen] = useState<{ audio: DiscSprache[], untertitel: DiscSprache[] } | null>(null) - const [audioWahl, setAudioWahl] = useState>(new Set()) - const [untertitelWahl, setUntertitelWahl] = useState>(new Set()) - // Wunschsprachen aus den Einstellungen — sie werden beim Scan vorausgewählt, - // soweit die Disc sie überhaupt hat. - const [standardAudio, setStandardAudio] = useState([]) - const [standardUntertitel, setStandardUntertitel] = useState([]) - - const scanStarten = async () => { - if (!deviceId) return - setScanStatus('running') - setScanFehler('') - try { - await api.post(`/devices/${deviceId}/scan-tracks`) - } catch (e: any) { - setScanStatus('error') - setScanFehler(e?.response?.data?.detail || 'Scan konnte nicht gestartet werden') - } - } - - useEffect(() => { - if (scanStatus !== 'running' || !deviceId) return - const interval = setInterval(async () => { - try { - const r = await api.get(`/devices/${deviceId}/tracks`) - if (r.data.status === 'done') { - const tracks: TitelInfo[] = r.data.tracks || [] - setTitelListe(tracks) - setGewaehlt(new Set(tracks.filter(t => t.dauer_s >= 300).map(t => t.nr))) - const spr = r.data.sprachen || null - setDiscSprachen(spr) - // Wunschsprachen vorauswählen — aber nur, was die Disc wirklich hat. - // Sonst stünde da eine Auswahl, die nichts bewirkt. - if (spr) { - const vorhanden = (liste: DiscSprache[], wunsch: string[]) => - new Set(liste.filter(s => wunsch.includes(s.lang)).map(s => s.lang)) - setAudioWahl(vorhanden(spr.audio || [], standardAudio)) - setUntertitelWahl(vorhanden(spr.untertitel || [], standardUntertitel)) - } - setScanStatus('done') - } else if (r.data.status === 'error') { - setScanFehler(r.data.error || 'Scan fehlgeschlagen') - setScanStatus('error') - } - } catch { /* API kurz weg — weiter pollen */ } - }, 3000) - return () => clearInterval(interval) - }, [scanStatus, deviceId]) - - const [targets, setTargets] = useState([ - { id: '1', name: 'Filme', path: '/app/media/movies', type: 'movies', isActive: true }, - { id: '2', name: 'Serien', path: '/app/media/series', type: 'series', isActive: true }, - { id: '3', name: 'Musik', path: '/app/media/music', type: 'music', isActive: true }, - ]) - const [browsePath, setBrowsePath] = useState('/app/media') - const [browseParent, setBrowseParent] = useState(null) - const [browseDirs, setBrowseDirs] = useState([]) - const [customPath, setCustomPath] = useState('') - // Der Ordner-Browser ist EINGEKLAPPT (Commander 26.07.2026: „Warum wird hier - // der Datei Browser noch angezeigt — das ist doch quatsch"). Er ist nicht - // wirklich redundant: nur über ihn lässt sich für DIESEN einen Rip ein - // beliebiger Zielordner wählen. Aber der Normalfall ist die Schnellwahl - // darüber, und der Browser überschrieb sie stillschweigend, sobald man auf - // „Diesen Ordner nutzen" klickte. Jetzt muss man ihn aufklappen — dann ist - // die Wahl bewusst. Nebenbefund beim Aufräumen: `browseFiles` wurde geladen - // und NIE angezeigt (samt ungenutztem File-Icon) — beides entfernt. - const [browserOffen, setBrowserOffen] = useState(false) - // Arbeitsverzeichnis dieses Rips (Commander-Wunsch 25.07.2026: hier wählbar, - // nicht global vorgegeben). '' = der Wert aus den Einstellungen, der auch - // bei Vollautomatik-Rips gilt, weil dort niemand gefragt wird. - const [arbeitsZiele, setArbeitsZiele] = useState([]) - const [arbeitsDir, setArbeitsDir] = useState('') - const [standardArbeitsDir, setStandardArbeitsDir] = useState('') - - useEffect(() => { - if (!isOpen) return - setSelectedType(initialType || 'movies') - setCustomPath('') - setSerienName(discTitle || '') - setStaffel(1) - setScanStatus('idle') - setScanFehler('') - setTitelListe([]) - setGewaehlt(new Set()) - setEncoderNode('') - setArbeitsDir('') - setBrowserOffen(false) - setDiscSprachen(null) - setAudioWahl(new Set()) - setUntertitelWahl(new Set()) - api.get('/storage-targets') - .then(r => setArbeitsZiele(Array.isArray(r.data) ? r.data : [])) - .catch(() => setArbeitsZiele([])) - // Online-Worker für die Encoder-Wahl (nur relevant, wenn ≥2 verfügbar) - api.get('/capabilities').then(r => { - setWorkers((r.data.workers || []).filter((w: WorkerWahl) => w.online && w.node)) - }).catch(() => setWorkers([])) - api.get('/settings').then(r => { - const s = r.data || {} - setNurHauptfilm(!!s.mainFeatureOnly) - setStandardArbeitsDir((s.workDir || '').trim()) - const codes = (wert: any) => String(wert || '') - .split(',').map((t: string) => t.trim().toLowerCase()).filter(Boolean) - setStandardAudio(codes(s.audioSprachen)) - setStandardUntertitel(codes(s.untertitelSprachen)) - const basis = s.outputDir || '/app/media' - setTargets([ - { id: '1', name: 'Filme', path: `${basis}/${s.movieDir || 'movies'}`, type: 'movies', isActive: true }, - { id: '2', name: 'Serien', path: `${basis}/${s.seriesDir || 'series'}`, type: 'series', isActive: true }, - { id: '3', name: 'Musik', path: `${basis}/${s.musicDir || 'music'}`, type: 'music', isActive: true }, - ]) - }).catch(() => {}) - // /browse wird erst beim Aufklappen geholt — der Dialog braucht es im - // Normalfall gar nicht. - }, [isOpen]) - - const laden = async (pfad: string) => { - try { - const r = await api.get('/browse', { params: { path: pfad } }) - setBrowsePath(r.data.path) - setBrowseParent(r.data.parent) - setBrowseDirs(r.data.dirs) - } catch { - setBrowseDirs([]) - } - } - - // Der effektive Zielpfad dieses Rips — Schnellwahl oder eigener Ordner. - const zielPfad = customPath || targets.find(t => t.type === selectedType)?.path || '' - // Das effektive Arbeitsverzeichnis: Wahl für diesen Rip → Einstellung → - // leer (= Container-Platte /app/temp, für externe Worker unerreichbar). - const arbeitsPfadEffektiv = arbeitsDir || standardArbeitsDir - // Warnung VOR dem Start, wenn der gewählte externe Encoder die Pfade nicht - // erreicht (Punkt 6 des Savepoints v3.16). Am 26.07.2026 fiel genau das erst - // NACH dem Rip auf, weil nur der Worker selbst prüfte. - const gewaehlterWorker = workers.find(w => w.node === encoderNode) - /* - * Ein-Klick-Abhilfe: dasselbe Ziel, aber auf der Freigabe, die der gewählte - * Worker erreicht. Aus /app/media/movies wird /app/media//movies. - * - * Nur wenn es überhaupt eine Freigabe gibt und das Ziel noch nicht darauf - * liegt — sonst stünde ein Knopf da, der nichts tut. - */ - const zielAufFreigabe = (() => { - const freigabe = freigabenAusMapping(gewaehlterWorker?.info?.pfad_map)[0] - if (!freigabe || !zielPfad) return '' - const unterordner = zielPfad.split('/').filter(Boolean).pop() || '' - const neu = `/app/media/${freigabe}/${unterordner}` - return neu === zielPfad ? '' : neu - })() - const pfadWarnung = selectedType === 'music' - ? null - : encoderNode - ? externWarnung(gewaehlterWorker, zielPfad, arbeitsPfadEffektiv) - // „Automatisch": die geteilte Queue nimmt den ersten freien Worker — auch - // einen, der die Pfade nicht erreicht. - : automatikWarnung(workers, zielPfad, arbeitsPfadEffektiv) - - const handleSave = () => { - const optionen: RipOptionen = { mainFeatureOnly: nurHauptfilm } - if (selectedType === 'series' && serienName.trim()) { - optionen.series = serienName.trim() - optionen.season = Math.max(1, staffel || 1) - } - if (scanStatus === 'done' && gewaehlt.size > 0 && gewaehlt.size < titelListe.length) { - optionen.titles = [...gewaehlt].sort((a, b) => a - b) - } - if (encoderNode) optionen.transcodeNode = encoderNode - if (arbeitsDir) optionen.workDir = arbeitsDir - if (audioWahl.size > 0) optionen.audioSprachen = [...audioWahl] - if (untertitelWahl.size > 0) optionen.untertitelSprachen = [...untertitelWahl] - const target = targets.find(t => t.type === selectedType) - if (customPath) { - onSave({ id: 'custom', name: 'Eigener Ordner', path: customPath, type: selectedType, isActive: true }, optionen) - } else if (target) { - onSave(target, optionen) - } - onClose() - } - - return ( - -
-

- Schnellwahl oder eigenen Ordner wählen — Netzwerk-Ziele (NAS, PC) sind unter Einstellungen → Speicherziele einhängbar. -

- - {/* Schnellwahl */} -
- {targets.map((t) => { - const isSelected = selectedType === t.type && !customPath - return ( - - ) - })} -
- - {/* Serien-Flow */} - {selectedType === 'series' && ( -
-
- setSerienName(e.target.value)} - placeholder="z. B. Neon Genesis Evangelion" - /> - setStaffel(parseInt(e.target.value) || 1)} - /> -
-

- Ablage: {serienName.trim() || ''}/Season {String(Math.max(1, staffel || 1)).padStart(2, '0')} -

-
- )} - - {/* Hauptfilm-Wahl */} - {selectedType === 'movies' && scanStatus !== 'done' && ( - - )} - - {/* - Arbeitsverzeichnis für DIESEN Rip (Commander-Wunsch 25.07.2026). - Warum es hier steht: Der Roh-Rip einer 4K-UHD ist bis zu 100 GB groß - und lag bisher immer auf der Container-Platte — am 25.07. lief sie - damit voll (74 GB Rohschnitt auf 148 GB Platte). Die Wahl gehört zur - Disc, nicht in eine globale Einstellung. Leer = der Wert aus - Einstellungen → Verarbeitung; genau der greift auch bei - Vollautomatik-Rips, weil dort niemand gefragt wird. - Musik-Rips gehen direkt als FLAC ins Ziel, ohne Roh-Zwischenstufe. - */} - {selectedType !== 'music' && ( -
- -

- - Bei 4K-UHD bis zu 100 GB — nimm eine Freigabe mit Platz, am besten dieselbe wie das Ziel oben. - Dann muss Rippy am Ende nur umhängen statt zu kopieren. -

-
- )} - - {/* Encoder-/Worker-Wahl — nur wenn mehrere Worker online sind - (sonst gibt es nichts zu wählen). Musik wird nicht komprimiert. */} - {selectedType !== 'music' && workers.length >= 2 && ( -
- -

- Wähle gezielt eine Maschine (z. B. die mit GPU) — sonst nimmt der erste freie Worker. -

-
- )} - - {/* Die Warnung, die am 26.07.2026 gefehlt hat: Der gewählte externe - Encoder nahm die Aufgabe an und lehnte sie 182 ms später ab, weil er - die Container-Pfade nicht sieht. Sichtbar war das erst NACH dem Rip. */} - {pfadWarnung && ( -
- ⚠️ Dieser Encoder kann so nicht arbeiten. -

{pfadWarnung}

- {/* Ein Knopf statt einer Wegbeschreibung: Er legt das Ziel NUR FÜR - DIESEN RIP auf die Freigabe, die der Worker erreicht — ohne die - globale Ablage anzufassen. „Geh in die Einstellungen" mitten im - Dialog ist lästig, und wer den Rip jetzt starten will, will - jetzt eine Lösung. */} - {zielAufFreigabe && ( - - )} -
- )} - - {/* Titel-Auswahl (Track-Tabelle) */} - {deviceId && selectedType !== 'music' && ( -
-
-
- Titel-Auswahl (optional) - {scanStatus === 'idle' && ( -

- Disc scannen und einzelne Titel an-/abwählen. -

- )} -
- {scanStatus === 'running' ? ( - - - Disc wird gelesen (20–120 s)… - - ) : ( - - )} -
- - {scanStatus === 'error' && ( -

{scanFehler}

- )} - - {scanStatus === 'done' && titelListe.length > 0 && ( - <> -
- {titelListe.map(t => ( - - ))} -
-

- {gewaehlt.size} von {titelListe.length} Titeln gewählt -

- - )} -
- )} - - {/* Sprachen der Disc — steht direkt unter der Titel-Auswahl, weil beides - aus DEMSELBEN Scan kommt. Vorher warf Rippy diese Auskunft weg. */} - {scanStatus === 'done' && discSprachen - && (discSprachen.audio.length > 0 || discSprachen.untertitel.length > 0) && ( -
-
- - Sprachen auf dieser Disc - -

- {discSprachen.audio.length} Tonsprache{discSprachen.audio.length === 1 ? '' : 'n'} - {discSprachen.untertitel.length > 0 - && `, ${discSprachen.untertitel.length} Untertitelsprache${discSprachen.untertitel.length === 1 ? '' : 'n'}`} - {' — '}nichts angeklickt heißt alles behalten. -

-
- -
- {(['audio', 'untertitel'] as const).map(art => { - const liste = discSprachen[art] - if (liste.length === 0) return null - const wahl = art === 'audio' ? audioWahl : untertitelWahl - const setzen = art === 'audio' ? setAudioWahl : setUntertitelWahl - return ( -
-

- {art === 'audio' ? 'Tonspuren' : 'Untertitel'} -

-
- {liste.map(s => { - const an = wahl.has(s.lang) - return ( - - ) - })} -
-
- ) - })} - - {/* Der wichtigste Satz: WANN die Auswahl greift. Sonst glaubt man, - die Disc würde unvollständig gerippt. */} -

- {audioWahl.size > 0 || untertitelWahl.size > 0 ? ( - <> - Der Rip selbst bleibt vollständig und verlustfrei — die - Auswahl wirkt beim Komprimieren. Du kannst also später jederzeit - „Neu komprimieren" mit anderen Sprachen wählen, ohne die Disc noch - einmal einzulegen. - - ) : ( - <>Ohne Auswahl behält Rippy alle Sprachen — wie bisher. - )} -

-
-
- )} - - {/* Ordner-Browser — eingeklappt, siehe Begründung bei browserOffen */} -
- - - {browserOffen && ( -
-
- - {browsePath} - -
-
- {browseDirs.map(d => ( - - ))} - {browseDirs.length === 0 && ( -

Keine Unterordner.

- )} -
-
- )} -
- - {/* Gewähltes Ziel Display */} -
- {customPath ? : } - - {zielPfad} - -
- - {/* Footer */} -
- - -
-
-
- ) -} +import { useState, useEffect } from 'react' +import { Folder, FolderOpen, ArrowUp, CheckCircle, Film, Tv, Music, Cpu, HardDrive, ChevronRight, ChevronDown } from 'lucide-react' +import { api } from '../lib/api' +import { Modal } from './ui/Modal' +import { Button } from './ui/Button' +import { Input, Select } from './ui/Input' +import { automatikWarnung, externWarnung, freigabenAusMapping, sprachName } from '../lib/encoder' +import { useBetrieb } from '../lib/useBetrieb' + +interface TargetConfig { + id: string + name: string + path: string + type: 'movies' | 'series' | 'music' + isActive: boolean +} + +export interface RipOptionen { + series?: string + season?: number + mainFeatureOnly?: boolean + titles?: number[] + transcodeNode?: string // gewählter Encoder-Worker (Celery-Node) oder leer = auto + workDir?: string // Arbeitsverzeichnis für die Rohdaten; leer = Einstellung + // Sprachauswahl (ISO-639-2). Leer = alles behalten. Wirkt bei der + // KOMPRESSION — der Rip bleibt vollständig und verlustfrei. + audioSprachen?: string[] + untertitelSprachen?: string[] +} + +// Eine Sprache, die die Disc anbietet (aus dem Titel-Scan, MakeMKV SINFO). +interface DiscSprache { + lang: string // ISO-639-2, z. B. "deu" + sprache: string // Klartext, z. B. "German" + spuren: number +} + +// Ein Ablageziel aus GET /storage-targets. +interface StorageZiel { + name: string + path: string + is_mount: boolean + free_gb: number | null +} + +interface WorkerWahl { + name: string + node: string | null + online?: boolean + encoders: string[] + // extern: 'ja' = läuft AUSSERHALB des Rippy-Containers und erreicht die + // Container-Pfade nur über eine Freigabe plus pfad_map. Der Worker meldet + // beides selbst (worker/caps.py). + info?: { extern?: string, pfad_map?: string } +} + +const ENCODER_KURZ: Record = { + 'cpu-x264': 'H.264', 'cpu-x265': 'H.265', 'cpu-av1': 'AV1', + 'vaapi': 'VAAPI⚡', 'nvenc': 'NVENC⚡', +} + +interface TitelInfo { + nr: number + dauer_s: number + groesse_bytes: number + kapitel: number +} + +interface RipTargetModalProps { + isOpen: boolean + initialType?: TargetConfig['type'] + discTitle?: string + deviceId?: string + onClose: () => void + onSave: (target: TargetConfig, optionen: RipOptionen) => void +} + +function dauerText(s: number): string { + const h = Math.floor(s / 3600) + const m = Math.floor((s % 3600) / 60) + return h > 0 ? `${h}:${String(m).padStart(2, '0')} h` : `${m} min` +} + +interface BrowseDir { + name: string + path: string +} + +export default function RipTargetModal({ isOpen, initialType, discTitle, deviceId, onClose, onSave }: RipTargetModalProps) { + const [selectedType, setSelectedType] = useState('movies') + const [serienName, setSerienName] = useState('') + const [staffel, setStaffel] = useState(1) + const [nurHauptfilm, setNurHauptfilm] = useState(false) + const [scanStatus, setScanStatus] = useState<'idle' | 'running' | 'done' | 'error'>('idle') + const [scanFehler, setScanFehler] = useState('') + const [titelListe, setTitelListe] = useState([]) + const [gewaehlt, setGewaehlt] = useState>(new Set()) + const [workers, setWorkers] = useState([]) + const [encoderNode, setEncoderNode] = useState('') // '' = automatisch + /* + * Sprachen der Disc (Commander-Anforderung 26.07.2026: „Die Disc hat Material + * in X Sprachen und X Untertiteln — Rippy muss VOR dem Rip fragen: Was genau + * willst du haben?"). + * + * Die Auskunft kommt aus demselben Titel-Scan, der schon für die + * Titel-Auswahl läuft — MakeMKV liefert sie in derselben Ausgabe mit, sie + * wurde bisher nur weggeworfen. + * + * Leere Auswahl heißt bewusst „alles behalten": Wer nichts anklickt, bekommt + * das Verhalten von vorher, und niemand verliert versehentlich seine Tonspur. + */ + const [discSprachen, setDiscSprachen] = useState<{ audio: DiscSprache[], untertitel: DiscSprache[] } | null>(null) + const [audioWahl, setAudioWahl] = useState>(new Set()) + const [untertitelWahl, setUntertitelWahl] = useState>(new Set()) + // Wunschsprachen aus den Einstellungen — sie werden beim Scan vorausgewählt, + // soweit die Disc sie überhaupt hat. + const [standardAudio, setStandardAudio] = useState([]) + const [standardUntertitel, setStandardUntertitel] = useState([]) + + const scanStarten = async () => { + if (!deviceId) return + setScanStatus('running') + setScanFehler('') + try { + await api.post(`/devices/${deviceId}/scan-tracks`) + } catch (e: any) { + setScanStatus('error') + setScanFehler(e?.response?.data?.detail || 'Scan konnte nicht gestartet werden') + } + } + + useEffect(() => { + if (scanStatus !== 'running' || !deviceId) return + const interval = setInterval(async () => { + try { + const r = await api.get(`/devices/${deviceId}/tracks`) + if (r.data.status === 'done') { + const tracks: TitelInfo[] = r.data.tracks || [] + setTitelListe(tracks) + setGewaehlt(new Set(tracks.filter(t => t.dauer_s >= 300).map(t => t.nr))) + const spr = r.data.sprachen || null + setDiscSprachen(spr) + // Wunschsprachen vorauswählen — aber nur, was die Disc wirklich hat. + // Sonst stünde da eine Auswahl, die nichts bewirkt. + if (spr) { + const vorhanden = (liste: DiscSprache[], wunsch: string[]) => + new Set(liste.filter(s => wunsch.includes(s.lang)).map(s => s.lang)) + setAudioWahl(vorhanden(spr.audio || [], standardAudio)) + setUntertitelWahl(vorhanden(spr.untertitel || [], standardUntertitel)) + } + setScanStatus('done') + } else if (r.data.status === 'error') { + setScanFehler(r.data.error || 'Scan fehlgeschlagen') + setScanStatus('error') + } + } catch { /* API kurz weg — weiter pollen */ } + }, 3000) + return () => clearInterval(interval) + }, [scanStatus, deviceId]) + + // Welcher Betrieb? Ohne diese Auskunft stand hier „Container-Platte" — + // auf einem Windows-PC eine Ortsangabe fuer einen Ort, den es nicht gibt. + const betrieb = useBetrieb() + + const [targets, setTargets] = useState([ + { id: '1', name: 'Filme', path: '/app/media/movies', type: 'movies', isActive: true }, + { id: '2', name: 'Serien', path: '/app/media/series', type: 'series', isActive: true }, + { id: '3', name: 'Musik', path: '/app/media/music', type: 'music', isActive: true }, + ]) + const [browsePath, setBrowsePath] = useState('') + const [browseParent, setBrowseParent] = useState(null) + const [browseDirs, setBrowseDirs] = useState([]) + const [customPath, setCustomPath] = useState('') + // Der Ordner-Browser ist EINGEKLAPPT (Commander 26.07.2026: „Warum wird hier + // der Datei Browser noch angezeigt — das ist doch quatsch"). Er ist nicht + // wirklich redundant: nur über ihn lässt sich für DIESEN einen Rip ein + // beliebiger Zielordner wählen. Aber der Normalfall ist die Schnellwahl + // darüber, und der Browser überschrieb sie stillschweigend, sobald man auf + // „Diesen Ordner nutzen" klickte. Jetzt muss man ihn aufklappen — dann ist + // die Wahl bewusst. Nebenbefund beim Aufräumen: `browseFiles` wurde geladen + // und NIE angezeigt (samt ungenutztem File-Icon) — beides entfernt. + const [browserOffen, setBrowserOffen] = useState(false) + // Arbeitsverzeichnis dieses Rips (Commander-Wunsch 25.07.2026: hier wählbar, + // nicht global vorgegeben). '' = der Wert aus den Einstellungen, der auch + // bei Vollautomatik-Rips gilt, weil dort niemand gefragt wird. + const [arbeitsZiele, setArbeitsZiele] = useState([]) + const [arbeitsDir, setArbeitsDir] = useState('') + const [standardArbeitsDir, setStandardArbeitsDir] = useState('') + // Ein frei erblätterter Arbeitsordner. Er braucht einen eigenen Platz in + // der Liste, sonst zeigt das Auswahlfeld einen Wert an, den es nicht kennt + // — und stünde leer da, obwohl etwas gewählt ist. + const [eigenesArbeitsZiel, setEigenesArbeitsZiel] = useState('') + + useEffect(() => { + if (!isOpen) return + setSelectedType(initialType || 'movies') + setCustomPath('') + setSerienName(discTitle || '') + setStaffel(1) + setScanStatus('idle') + setScanFehler('') + setTitelListe([]) + setGewaehlt(new Set()) + setEncoderNode('') + setArbeitsDir('') + setEigenesArbeitsZiel('') + setBrowserOffen(false) + setDiscSprachen(null) + setAudioWahl(new Set()) + setUntertitelWahl(new Set()) + api.get('/storage-targets') + .then(r => setArbeitsZiele(Array.isArray(r.data) ? r.data : [])) + .catch(() => setArbeitsZiele([])) + // Online-Worker für die Encoder-Wahl (nur relevant, wenn ≥2 verfügbar) + api.get('/capabilities').then(r => { + setWorkers((r.data.workers || []).filter((w: WorkerWahl) => w.online && w.node)) + }).catch(() => setWorkers([])) + api.get('/settings').then(r => { + const s = r.data || {} + setNurHauptfilm(!!s.mainFeatureOnly) + setStandardArbeitsDir((s.workDir || '').trim()) + const codes = (wert: any) => String(wert || '') + .split(',').map((t: string) => t.trim().toLowerCase()).filter(Boolean) + setStandardAudio(codes(s.audioSprachen)) + setStandardUntertitel(codes(s.untertitelSprachen)) + const basis = s.outputDir || betrieb.ablage_vorgabe || '/app/media' + setTargets([ + { id: '1', name: 'Filme', path: `${basis}/${s.movieDir || 'movies'}`, type: 'movies', isActive: true }, + { id: '2', name: 'Serien', path: `${basis}/${s.seriesDir || 'series'}`, type: 'series', isActive: true }, + { id: '3', name: 'Musik', path: `${basis}/${s.musicDir || 'music'}`, type: 'music', isActive: true }, + ]) + }).catch(() => {}) + // /browse wird erst beim Aufklappen geholt — der Dialog braucht es im + // Normalfall gar nicht. + }, [isOpen]) + + const laden = async (pfad: string) => { + try { + const r = await api.get('/browse', { params: { path: pfad } }) + setBrowsePath(r.data.path) + setBrowseParent(r.data.parent) + setBrowseDirs(r.data.dirs) + } catch { + setBrowseDirs([]) + } + } + + // Der effektive Zielpfad dieses Rips — Schnellwahl oder eigener Ordner. + const zielPfad = customPath || targets.find(t => t.type === selectedType)?.path || '' + // Das effektive Arbeitsverzeichnis: Wahl für diesen Rip → Einstellung → + // leer (= Container-Platte /app/temp, für externe Worker unerreichbar). + const arbeitsPfadEffektiv = arbeitsDir || standardArbeitsDir + /* + * Was WIRKLICH benutzt wird, wenn niemand etwas wählt. + * + * Commander am 28.08.2026: „Warum heißt das hier noch container platte? Er + * holt sich das Arbeitsverzeichnis ja von der Installation." + * + * Genau so ist es — nur stand hier fest verdrahtet „(Container-Platte)", + * sobald die Einstellung leer war. Auf seinem PC war das die Beschreibung + * eines Ortes, den es nicht gibt. Jetzt sagt der Betrieb, wohin es geht. + */ + const arbeitsVorgabe = standardArbeitsDir || betrieb.arbeits_vorgabe + // Warnung VOR dem Start, wenn der gewählte externe Encoder die Pfade nicht + // erreicht (Punkt 6 des Savepoints v3.16). Am 26.07.2026 fiel genau das erst + // NACH dem Rip auf, weil nur der Worker selbst prüfte. + const gewaehlterWorker = workers.find(w => w.node === encoderNode) + /* + * Ein-Klick-Abhilfe: dasselbe Ziel, aber auf der Freigabe, die der gewählte + * Worker erreicht. Aus /app/media/movies wird /app/media//movies. + * + * Nur wenn es überhaupt eine Freigabe gibt und das Ziel noch nicht darauf + * liegt — sonst stünde ein Knopf da, der nichts tut. + */ + const zielAufFreigabe = (() => { + const freigabe = freigabenAusMapping(gewaehlterWorker?.info?.pfad_map)[0] + if (!freigabe || !zielPfad) return '' + const unterordner = zielPfad.split('/').filter(Boolean).pop() || '' + const neu = `/app/media/${freigabe}/${unterordner}` + return neu === zielPfad ? '' : neu + })() + const pfadWarnung = selectedType === 'music' + ? null + : encoderNode + ? externWarnung(gewaehlterWorker, zielPfad, arbeitsPfadEffektiv) + // „Automatisch": die geteilte Queue nimmt den ersten freien Worker — auch + // einen, der die Pfade nicht erreicht. + : automatikWarnung(workers, zielPfad, arbeitsPfadEffektiv) + + const handleSave = () => { + const optionen: RipOptionen = { mainFeatureOnly: nurHauptfilm } + if (selectedType === 'series' && serienName.trim()) { + optionen.series = serienName.trim() + optionen.season = Math.max(1, staffel || 1) + } + if (scanStatus === 'done' && gewaehlt.size > 0 && gewaehlt.size < titelListe.length) { + optionen.titles = [...gewaehlt].sort((a, b) => a - b) + } + if (encoderNode) optionen.transcodeNode = encoderNode + if (arbeitsDir) optionen.workDir = arbeitsDir + if (audioWahl.size > 0) optionen.audioSprachen = [...audioWahl] + if (untertitelWahl.size > 0) optionen.untertitelSprachen = [...untertitelWahl] + const target = targets.find(t => t.type === selectedType) + if (customPath) { + onSave({ id: 'custom', name: 'Eigener Ordner', path: customPath, type: selectedType, isActive: true }, optionen) + } else if (target) { + onSave(target, optionen) + } + onClose() + } + + return ( + +
+

+ Schnellwahl oder eigenen Ordner wählen — Netzwerk-Ziele (NAS, PC) sind unter Einstellungen → Speicherziele einhängbar. +

+ + {/* Schnellwahl */} +
+ {targets.map((t) => { + const isSelected = selectedType === t.type && !customPath + return ( + + ) + })} +
+ + {/* Serien-Flow */} + {selectedType === 'series' && ( +
+
+ setSerienName(e.target.value)} + placeholder="z. B. Neon Genesis Evangelion" + /> + setStaffel(parseInt(e.target.value) || 1)} + /> +
+

+ Ablage: {serienName.trim() || ''}/Season {String(Math.max(1, staffel || 1)).padStart(2, '0')} +

+
+ )} + + {/* Hauptfilm-Wahl */} + {selectedType === 'movies' && scanStatus !== 'done' && ( + + )} + + {/* + Arbeitsverzeichnis für DIESEN Rip (Commander-Wunsch 25.07.2026). + Warum es hier steht: Der Roh-Rip einer 4K-UHD ist bis zu 100 GB groß + und lag bisher immer auf der Container-Platte — am 25.07. lief sie + damit voll (74 GB Rohschnitt auf 148 GB Platte). Die Wahl gehört zur + Disc, nicht in eine globale Einstellung. Leer = der Wert aus + Einstellungen → Verarbeitung; genau der greift auch bei + Vollautomatik-Rips, weil dort niemand gefragt wird. + Musik-Rips gehen direkt als FLAC ins Ziel, ohne Roh-Zwischenstufe. + */} + {selectedType !== 'music' && ( +
+ +

+ + Bei 4K-UHD bis zu 100 GB — nimm ein Laufwerk mit Platz, am besten dasselbe wie das Ziel oben. + Dann muss Rippy am Ende nur umhängen statt zu kopieren. +

+
+ )} + + {/* Encoder-/Worker-Wahl — nur wenn mehrere Worker online sind + (sonst gibt es nichts zu wählen). Musik wird nicht komprimiert. */} + {selectedType !== 'music' && workers.length >= 2 && ( +
+ +

+ Wähle gezielt eine Maschine (z. B. die mit GPU) — sonst nimmt der erste freie Worker. +

+
+ )} + + {/* Die Warnung, die am 26.07.2026 gefehlt hat: Der gewählte externe + Encoder nahm die Aufgabe an und lehnte sie 182 ms später ab, weil er + die Container-Pfade nicht sieht. Sichtbar war das erst NACH dem Rip. */} + {pfadWarnung && ( +
+ ⚠️ Dieser Encoder kann so nicht arbeiten. +

{pfadWarnung}

+ {/* Ein Knopf statt einer Wegbeschreibung: Er legt das Ziel NUR FÜR + DIESEN RIP auf die Freigabe, die der Worker erreicht — ohne die + globale Ablage anzufassen. „Geh in die Einstellungen" mitten im + Dialog ist lästig, und wer den Rip jetzt starten will, will + jetzt eine Lösung. */} + {zielAufFreigabe && ( + + )} +
+ )} + + {/* Titel-Auswahl (Track-Tabelle) */} + {deviceId && selectedType !== 'music' && ( +
+
+
+ Titel-Auswahl (optional) + {scanStatus === 'idle' && ( +

+ Disc scannen und einzelne Titel an-/abwählen. +

+ )} +
+ {scanStatus === 'running' ? ( + + + Disc wird gelesen (20–120 s)… + + ) : ( + + )} +
+ + {scanStatus === 'error' && ( +

{scanFehler}

+ )} + + {scanStatus === 'done' && titelListe.length > 0 && ( + <> +
+ {titelListe.map(t => ( + + ))} +
+

+ {gewaehlt.size} von {titelListe.length} Titeln gewählt +

+ + )} +
+ )} + + {/* Sprachen der Disc — steht direkt unter der Titel-Auswahl, weil beides + aus DEMSELBEN Scan kommt. Vorher warf Rippy diese Auskunft weg. */} + {scanStatus === 'done' && discSprachen + && (discSprachen.audio.length > 0 || discSprachen.untertitel.length > 0) && ( +
+
+ + Sprachen auf dieser Disc + +

+ {discSprachen.audio.length} Tonsprache{discSprachen.audio.length === 1 ? '' : 'n'} + {discSprachen.untertitel.length > 0 + && `, ${discSprachen.untertitel.length} Untertitelsprache${discSprachen.untertitel.length === 1 ? '' : 'n'}`} + {' — '}nichts angeklickt heißt alles behalten. +

+
+ +
+ {(['audio', 'untertitel'] as const).map(art => { + const liste = discSprachen[art] + if (liste.length === 0) return null + const wahl = art === 'audio' ? audioWahl : untertitelWahl + const setzen = art === 'audio' ? setAudioWahl : setUntertitelWahl + return ( +
+

+ {art === 'audio' ? 'Tonspuren' : 'Untertitel'} +

+
+ {liste.map(s => { + const an = wahl.has(s.lang) + return ( + + ) + })} +
+
+ ) + })} + + {/* Der wichtigste Satz: WANN die Auswahl greift. Sonst glaubt man, + die Disc würde unvollständig gerippt. */} +

+ {audioWahl.size > 0 || untertitelWahl.size > 0 ? ( + <> + Der Rip selbst bleibt vollständig und verlustfrei — die + Auswahl wirkt beim Komprimieren. Du kannst also später jederzeit + „Neu komprimieren" mit anderen Sprachen wählen, ohne die Disc noch + einmal einzulegen. + + ) : ( + <>Ohne Auswahl behält Rippy alle Sprachen — wie bisher. + )} +

+
+
+ )} + + {/* Ordner-Browser — eingeklappt, siehe Begründung bei browserOffen */} +
+ + + {browserOffen && ( +
+
+ + + {browsePath || 'Laufwerke'} + + + {/* Der zweite Knopf ist der Grund, warum es diesen Browser für + das Arbeitsverzeichnis überhaupt braucht: Die Schnellwahl + darüber kennt nur Laufwerke und Unterordner der Ablage. Ein + beliebiger Ordner — etwa D:\Rippy-Arbeit — ging bisher gar + nicht („momentan geht das nicht", 28.08.2026). */} + {selectedType !== 'music' && ( + + )} +
+
+ {browseDirs.map(d => ( + + ))} + {browseDirs.length === 0 && ( +

Keine Unterordner.

+ )} +
+
+ )} +
+ + {/* Gewähltes Ziel Display */} +
+ {customPath ? : } + + {zielPfad} + +
+ + {/* Footer */} +
+ + +
+
+
+ ) +} diff --git a/docker/ui/src/lib/useBetrieb.tsx b/docker/ui/src/lib/useBetrieb.tsx index 9eb59fc..a4f9aa4 100644 --- a/docker/ui/src/lib/useBetrieb.tsx +++ b/docker/ui/src/lib/useBetrieb.tsx @@ -1,133 +1,141 @@ -/* - * 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 -} +/* + * 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 + /** Darf die Oberflaeche ausserhalb der Medien-Wurzel blaettern? */ + frei_blaettern: 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 + /** Wohin die Rohdaten wandern, wenn niemand etwas anderes waehlt. */ + arbeits_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, + frei_blaettern: false, + }, + ablage_vorgabe: '/app/media', + arbeits_vorgabe: '/app/temp', + 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, + frei_blaettern: !!d.kann.frei_blaettern, + }, + ablage_vorgabe: d.ablage_vorgabe || '', + arbeits_vorgabe: d.arbeits_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/pages/Settings.tsx b/docker/ui/src/pages/Settings.tsx index ff55eca..e6ce294 100644 --- a/docker/ui/src/pages/Settings.tsx +++ b/docker/ui/src/pages/Settings.tsx @@ -940,15 +940,40 @@ export default function SettingsPage() { /storage-targets — dieselbe Liste wie bei den Speicherzielen, inklusive freiem Platz. */} + {!betrieb.kann.container_pfade ? ( + /* + * Ein echtes Pfadfeld statt einer Auswahl — dieselbe + * Entscheidung wie bei der Ablage darüber, und aus demselben + * Grund. + * + * Commander am 28.08.2026: „Wäre es möglich das + * Arbeitsverzeichnis zu ändern? momentan geht das nicht." + * + * Es ging nicht, weil `ziele` auf Windows LEER war: Die Liste + * kommt aus /storage-targets, und das las `/app/media` — ein + * Ordner, den es dort nicht gibt. Übrig blieb eine Auswahl mit + * genau einem Eintrag, und der hieß „Container-Platte". + * + * Die Liste ist inzwischen repariert (sie bringt jetzt die + * Laufwerke mit freiem Platz). Aber die richtige Antwort auf + * „wohin mit 100 GB" ist auf Windows oft ein Ort, den keine + * Liste kennt — D:\Rippy-Arbeit oder eine UNC-Freigabe. + * Deshalb hier ein Feld, in das man ihn schreiben kann. + */ + handleChange('workDir', e.target.value)} + /> + ) : ( + )} + {!betrieb.kann.container_pfade && !!ziele.length && ( + /* Die Laufwerke als Ein-Klick-Wahl. Wer den Platz sieht, + wählt anders — genau daran ist am 25.07. eine Platte + vollgelaufen. */ +
+ {ziele.map(z => ( + + ))} +
+ )}

Der Standard — beim „Rippen starten" kannst du für jede Disc etwas anderes wählen. diff --git a/src/rippy/betrieb.py b/src/rippy/betrieb.py index a4360b7..84ed97c 100644 --- a/src/rippy/betrieb.py +++ b/src/rippy/betrieb.py @@ -131,14 +131,74 @@ def auskunft(werte: dict, container: bool = None, # Im Image steckt alles fest; ein Update ist ein Rebuild. Nur die # native Installation kann ihre Werkzeuge selbst pflegen. "werkzeuge_verwalten": not container, + # Darf die Oberfläche außerhalb der Medien-Wurzel blättern? + "frei_blaettern": frei_blaettern(werte, 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), + "arbeits_vorgabe": arbeits_vorgabe(werte, container, system), "hilfe_befehl": "docker compose -p rippy ps" if container else "", } +def frei_blaettern(werte: dict, container: bool = None) -> bool: + """Darf die Oberfläche das ganze Dateisystem sehen? (Befund 28.08.2026) + + ## Warum diese Frage überhaupt gestellt werden muss + + `MEDIA_ROOT = "/app/media"` war in `main.py` zugleich Vorgabe UND + Pfadgrenze: `/browse`, `/storage-targets` und jede Datei-Auslieferung + prüften `unter_wurzel(pfad, MEDIA_ROOT)`. + + Im Container ist das genau richtig. Die API hängt dort im Netz, und eine + Weboberfläche, die jeden Pfad des Wirts ausliefern kann, ist ein Loch. + + Auf dem Windows-PC des Commanders war dieselbe Zeile gleich doppelt + falsch: Den Ordner `/app/media` gibt es nicht (also war die Liste der + Arbeitsverzeichnisse LEER — sein Befund „momentan geht das nicht"), und + sein Ziel ist eine UNC-Freigabe, die unter gar keiner lokalen Wurzel + liegt. + + Die Unterscheidung ist nicht „Windows" — sie ist **wer hört zu**. Ein + Container und der Kopflos-Betrieb bedienen ein Netz; die native App + bedient den Menschen, der vor dem Rechner sitzt und dessen eigene Ordner + das sind. Deshalb: kein Container UND kein verteilter Betrieb. + """ + container = im_container() if container is None else container + return not container and modus(werte, container) == "standalone" + + +def medien_wurzel(werte: dict, container: bool = None, + plattform_name: str = None) -> str: + """Die Wurzel, unter der Rippy ablegt — im Container `/app/media`. + + Eigener Name statt `ablage_vorgabe`, weil die Rolle eine andere ist: Die + Vorgabe füllt ein Eingabefeld, die Wurzel begrenzt Pfade. Dass beide + denselben Ort meinen, ist Absicht und kein Zufall. + """ + return ablage_vorgabe(werte, im_container() if container is None else container, + plattform(plattform_name)) + + +def arbeits_vorgabe(werte: dict, container: bool = None, + plattform_name: str = None) -> str: + """Wohin die Rohdaten wandern, wenn niemand etwas anderes wählt. + + Der Roh-Rip einer 4K-UHD ist bis zu 100 GB groß. Wo der landet, ist keine + Nebensache — am 25.07.2026 lief damit die Container-Platte voll. + """ + werte = werte or {} + container = im_container() if container is None else container + system = plattform(plattform_name) + eigen = (werte.get("storage", {}) or {}).get("temp") + if eigen: + return eigen + if container: + return "/app/temp" + return pfade.verbinden(ablage_vorgabe(werte, container, system), "_arbeit") + + def naechster_vorhandener(pfad: str, existiert=None) -> str: """Der naechste vorhandene Ordner nach oben — siehe `rippy.pfade`.""" return pfade.naechster_vorhandener(pfad, existiert) @@ -165,14 +225,13 @@ def platz_orte(werte: dict, container: bool = None, 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 pfade.verbinden(ablage, "_arbeit") + temp = arbeits_vorgabe(werte, container, system) 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. @@ -185,6 +244,44 @@ def platz_orte(werte: dict, container: bool = None, return orte +#: Rückgabewerte von GetDriveTypeW, die als Ablage taugen (learn.microsoft.com +#: /windows/win32/api/fileapi/nf-fileapi-getdrivetypew): 3 = feste Platte, +#: 4 = Netzlaufwerk. NICHT dabei: 2 (Wechselmedium — ein USB-Stick als +#: Arbeitsverzeichnis für 100 GB Rohdaten ist eine Falle) und 5 (CD/DVD — +#: dort liegt die Disc, die gerade gerippt wird). +LAUFWERKSARTEN = (3, 4) + + +def windows_laufwerke(art=None, buchstaben=None) -> list: + """Welche Laufwerksbuchstaben taugen als Ablage? — gemessen, nicht geraten. + + Warum das gebraucht wird: Auf Windows ist die Antwort auf „wohin mit 100 GB + Rohdaten" fast immer *ein anderes Laufwerk*. Ohne diese Liste stand im + Auswahlfeld nur ein einziger Eintrag, und der Commander konnte nichts + ändern. + + `art` und `buchstaben` sind einspritzbar, damit das auf dem Linux-Runner + der Ampel prüfbar bleibt — dort gibt es kein `GetDriveTypeW`. + """ + if buchstaben is None: + buchstaben = [c + ":\\" for c in "ABCDEFGHIJKLMNOPQRSTUVWXYZ"] + if art is None: + try: + import ctypes + art = ctypes.WinDLL("kernel32").GetDriveTypeW + art.argtypes = [ctypes.c_wchar_p] + except (OSError, AttributeError): + return [] + gefunden = [] + for pfad in buchstaben: + try: + if art(pfad) in LAUFWERKSARTEN: + gefunden.append(pfad) + except OSError: + continue + return gefunden + + 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", "") diff --git a/src/rippy/platform/test_winlauf.py b/src/rippy/platform/test_winlauf.py index 3f0cb8b..33f9f76 100644 --- a/src/rippy/platform/test_winlauf.py +++ b/src/rippy/platform/test_winlauf.py @@ -1,123 +1,224 @@ -"""Kind-Prozesse ohne Fenster — und die Ausgabe ohne Konsole. - -Ein Konsolenfenster laesst sich in einem Test nicht ansehen. Geprueft wird -deshalb das, woran es beim ersten Anlauf gescheitert ist: die ENTSCHEIDUNG, -wohin die Ausgabe geht, wenn es keine Konsole gibt. - -## Warum das nicht Nebensache ist - -Die EXE wird seit dem 28.08.2026 als Fenster-Programm gebaut, damit beim -Doppelklick keine schwarze Box aufgeht. Damit ist `sys.stdout` aber `None`. -Ohne Umleitung wuerde jede Bibliothek, die auf `stderr` schreibt, mit -`AttributeError: 'NoneType' object has no attribute 'write'` sterben — mitten -im Start, und nirgends stuende etwas. Genau der stille Fehlschlag, vor dem -AGENTS.md warnt. -""" - -import os -import subprocess -import sys - -import pytest - -from rippy.platform import winlauf - -nur_windows = pytest.mark.skipif(os.name != "nt", reason="nur unter Windows") - - -# ── Kind-Prozesse ─────────────────────────────────────────────────────── -def test_flag_ist_auf_windows_gesetzt_und_sonst_null(): - """Auf Linux ist CREATE_NO_WINDOW nicht vorhanden; `creationflags=0` wird - dort akzeptiert und ignoriert. So laeuft derselbe Code auf beiden Seiten.""" - if os.name == "nt": - assert winlauf.OHNE_FENSTER == subprocess.CREATE_NO_WINDOW - else: - assert winlauf.OHNE_FENSTER == 0 - - -# ── Gibt es ueberhaupt eine Ausgabe? ──────────────────────────────────── -def test_fehlende_standardausgabe_wird_erkannt(monkeypatch): - """Der Fall in der fertigen EXE: PyInstaller setzt `sys.stdout` auf None.""" - monkeypatch.setattr(sys, "stdout", None) - assert winlauf.ohne_konsole() is True - - -def test_ein_strom_ohne_dateinummer_zaehlt_auch_als_ohne_konsole(monkeypatch): - """pytest ersetzt stdout durch einen Auffang-Puffer. Der hat kein - `fileno` — und ein Meldungsfenster waere in einem Testlauf das Letzte, - was jemand gebrauchen kann.""" - class Puffer: - write = staticmethod(lambda _: None) - - monkeypatch.setattr(sys, "stdout", Puffer()) - assert winlauf.ohne_konsole() is True - - -def test_mit_echter_ausgabe_ist_alles_in_ordnung(monkeypatch, tmp_path): - datei = tmp_path / "echt.txt" - with open(datei, "w", encoding="utf-8") as f: - monkeypatch.setattr(sys, "stdout", f) - assert winlauf.ohne_konsole() is False - - -# ── Die Protokolldatei ────────────────────────────────────────────────── -def test_protokoll_liegt_bei_rippy(): - """Nicht im TEMP: Was Rippy ueber sich aufschreibt, gehoert zu Rippy und - verschwindet beim Deinstallieren mit.""" - pfad = winlauf.protokolldatei(os.path.join("C:" + os.sep, "Basis")) - assert pfad.endswith(os.path.join("Rippy", "rippy.log")) - assert pfad.startswith(os.path.join("C:" + os.sep, "Basis")) - - -def test_umleiten_schreibt_wirklich(tmp_path, monkeypatch): - ziel = str(tmp_path / "unterordner" / "rippy.log") - vorher_out, vorher_err = sys.stdout, sys.stderr - try: - assert winlauf.ausgabe_umleiten(ziel) == ziel - print("eine Zeile mit Umlaut: ä") - sys.stdout.flush() - finally: - sys.stdout, sys.stderr = vorher_out, vorher_err - assert "eine Zeile mit Umlaut" in open(ziel, encoding="utf-8").read() - - -def test_umleiten_haengt_an_statt_zu_ueberschreiben(tmp_path): - """Sonst waere nach jedem Neustart das Protokoll des letzten Fehlers weg - — also genau das, was man dann sucht.""" - ziel = str(tmp_path / "rippy.log") - vorher_out, vorher_err = sys.stdout, sys.stderr - try: - winlauf.ausgabe_umleiten(ziel) - print("erster Start") - sys.stdout.flush() - winlauf.ausgabe_umleiten(ziel) - print("zweiter Start") - sys.stdout.flush() - finally: - sys.stdout, sys.stderr = vorher_out, vorher_err - inhalt = open(ziel, encoding="utf-8").read() - assert "erster Start" in inhalt and "zweiter Start" in inhalt - - -def test_unbeschreibbarer_ort_laesst_den_start_nicht_platzen(monkeypatch): - """Lieber ins Nichts schreiben als beim Start sterben. Ein Programm, das - wegen seiner Protokolldatei nicht hochkommt, ist schlimmer als eines - ohne Protokoll.""" - def geht_nicht(*a, **k): - raise OSError("kein Platz") - - monkeypatch.setattr(winlauf.os, "makedirs", geht_nicht) - vorher_out, vorher_err = sys.stdout, sys.stderr - try: - winlauf.ausgabe_umleiten("/gibt/es/nicht/rippy.log") - print("das darf nicht werfen") - finally: - sys.stdout, sys.stderr = vorher_out, vorher_err - - -# ── Das Meldungsfenster ───────────────────────────────────────────────── -def test_ohne_windows_kein_meldungsfenster(): - if os.name == "nt": - pytest.skip("prueft das Verhalten auf Nicht-Windows") - assert winlauf.meldung_zeigen("egal") is False - assert winlauf.an_elternkonsole_haengen() is False +"""Kind-Prozesse ohne Fenster — und die Ausgabe ohne Konsole. + +Ein Konsolenfenster laesst sich in einem Test nicht ansehen. Geprueft wird +deshalb das, woran es beim ersten Anlauf gescheitert ist: die ENTSCHEIDUNG, +wohin die Ausgabe geht, wenn es keine Konsole gibt. + +## Warum das nicht Nebensache ist + +Die EXE wird seit dem 28.08.2026 als Fenster-Programm gebaut, damit beim +Doppelklick keine schwarze Box aufgeht. Damit ist `sys.stdout` aber `None`. +Ohne Umleitung wuerde jede Bibliothek, die auf `stderr` schreibt, mit +`AttributeError: 'NoneType' object has no attribute 'write'` sterben — mitten +im Start, und nirgends stuende etwas. Genau der stille Fehlschlag, vor dem +AGENTS.md warnt. +""" + +import os +import subprocess +import sys + +import pytest + +from rippy.platform import winlauf + +nur_windows = pytest.mark.skipif(os.name != "nt", reason="nur unter Windows") + + +# ── Kind-Prozesse ─────────────────────────────────────────────────────── +def test_flag_ist_auf_windows_gesetzt_und_sonst_null(): + """Auf Linux ist CREATE_NO_WINDOW nicht vorhanden; `creationflags=0` wird + dort akzeptiert und ignoriert. So laeuft derselbe Code auf beiden Seiten.""" + if os.name == "nt": + assert winlauf.OHNE_FENSTER == subprocess.CREATE_NO_WINDOW + else: + assert winlauf.OHNE_FENSTER == 0 + + +# ── Gibt es ueberhaupt eine Ausgabe? ──────────────────────────────────── +def test_fehlende_standardausgabe_wird_erkannt(monkeypatch): + """Der Fall in der fertigen EXE: PyInstaller setzt `sys.stdout` auf None.""" + monkeypatch.setattr(sys, "stdout", None) + assert winlauf.ohne_konsole() is True + + +def test_ein_strom_ohne_dateinummer_zaehlt_auch_als_ohne_konsole(monkeypatch): + """pytest ersetzt stdout durch einen Auffang-Puffer. Der hat kein + `fileno` — und ein Meldungsfenster waere in einem Testlauf das Letzte, + was jemand gebrauchen kann.""" + class Puffer: + write = staticmethod(lambda _: None) + + monkeypatch.setattr(sys, "stdout", Puffer()) + assert winlauf.ohne_konsole() is True + + +def test_mit_echter_ausgabe_ist_alles_in_ordnung(monkeypatch, tmp_path): + datei = tmp_path / "echt.txt" + with open(datei, "w", encoding="utf-8") as f: + monkeypatch.setattr(sys, "stdout", f) + assert winlauf.ohne_konsole() is False + + +# ── Die Protokolldatei ────────────────────────────────────────────────── +def test_protokoll_liegt_bei_rippy(): + """Nicht im TEMP: Was Rippy ueber sich aufschreibt, gehoert zu Rippy und + verschwindet beim Deinstallieren mit.""" + pfad = winlauf.protokolldatei(os.path.join("C:" + os.sep, "Basis")) + assert pfad.endswith(os.path.join("Rippy", "rippy.log")) + assert pfad.startswith(os.path.join("C:" + os.sep, "Basis")) + + +def test_umleiten_schreibt_wirklich(tmp_path, monkeypatch): + ziel = str(tmp_path / "unterordner" / "rippy.log") + vorher_out, vorher_err = sys.stdout, sys.stderr + try: + assert winlauf.ausgabe_umleiten(ziel) == ziel + print("eine Zeile mit Umlaut: ä") + sys.stdout.flush() + finally: + sys.stdout, sys.stderr = vorher_out, vorher_err + assert "eine Zeile mit Umlaut" in open(ziel, encoding="utf-8").read() + + +def test_umleiten_haengt_an_statt_zu_ueberschreiben(tmp_path): + """Sonst waere nach jedem Neustart das Protokoll des letzten Fehlers weg + — also genau das, was man dann sucht.""" + ziel = str(tmp_path / "rippy.log") + vorher_out, vorher_err = sys.stdout, sys.stderr + try: + winlauf.ausgabe_umleiten(ziel) + print("erster Start") + sys.stdout.flush() + winlauf.ausgabe_umleiten(ziel) + print("zweiter Start") + sys.stdout.flush() + finally: + sys.stdout, sys.stderr = vorher_out, vorher_err + inhalt = open(ziel, encoding="utf-8").read() + assert "erster Start" in inhalt and "zweiter Start" in inhalt + + +def test_unbeschreibbarer_ort_laesst_den_start_nicht_platzen(monkeypatch): + """Lieber ins Nichts schreiben als beim Start sterben. Ein Programm, das + wegen seiner Protokolldatei nicht hochkommt, ist schlimmer als eines + ohne Protokoll.""" + def geht_nicht(*a, **k): + raise OSError("kein Platz") + + monkeypatch.setattr(winlauf.os, "makedirs", geht_nicht) + vorher_out, vorher_err = sys.stdout, sys.stderr + try: + winlauf.ausgabe_umleiten("/gibt/es/nicht/rippy.log") + print("das darf nicht werfen") + finally: + sys.stdout, sys.stderr = vorher_out, vorher_err + + +# ── Das Meldungsfenster ───────────────────────────────────────────────── +def test_ohne_windows_kein_meldungsfenster(): + if os.name == "nt": + pytest.skip("prueft das Verhalten auf Nicht-Windows") + assert winlauf.meldung_zeigen("egal") is False + assert winlauf.an_elternkonsole_haengen() is False + + +# ── Der Auspack-Ordner (Befund 28.08.2026) ────────────────────────────── +# +# Commander, mit Bildschirmfoto: +# +# Failed to remove temporary directory: +# C:\Users\TobisPC\AppData\Local\Temp\_MEI0000b0882 +# +# „Und manchmal kommt dieser fehler." +# +# GEMESSEN in seinem Temp-Ordner: 20 zurueckgelassene _MEI-Ordner, zusammen +# 1,1 GB. Der aus der Meldung liess sich hinterher anstandslos loeschen — die +# Sperre war also voruebergehend, es ist ein Wettlauf. +# +# Ursache: `starte_hintergrund` und `starte_fensterprozess` starten Rippy.exe +# erneut. `Popen` ohne `env=` reicht PyInstallers Auspack-Zeiger weiter, also +# laufen beide Kinder im Ordner des Elternprozesses. Der beendet sich zuerst, +# will loeschen — und die Kinder halten die DLLs offen. + + +def test_der_auspack_zeiger_wird_dem_kind_NICHT_mitgegeben(): + """Der Test, der 1,1 GB Reste verhindert haette.""" + eltern = {"PATH": "/usr/bin", "_MEIPASS2": "/tmp/_MEI123", + "_PYI_APPLICATION_HOME_DIR": "/tmp/_MEI123", + "_PYI_ARCHIVE_FILE": "/x/Rippy.exe", + "_PYI_PARENT_PROCESS_LEVEL": "1"} + kind = winlauf.umgebung_ohne_bundle(eltern) + for name in winlauf.PYI_ZEIGER: + assert name not in kind, "%s wuerde das Kind in den Ordner der Eltern schicken" % name + assert kind["PATH"] == "/usr/bin", "der Rest der Umgebung muss bleiben" + + +def test_die_elternumgebung_wird_nicht_veraendert(): + """Ein `del os.environ[...]` haette den eigenen Prozess beschaedigt.""" + eltern = {"_MEIPASS2": "/tmp/_MEI123"} + winlauf.umgebung_ohne_bundle(eltern) + assert eltern == {"_MEIPASS2": "/tmp/_MEI123"} + + +def test_beide_startwege_reichen_den_zeiger_nicht_weiter(): + """Der Waechter: Wer kuenftig einen dritten Startweg baut, muss `env=` + setzen. Sonst kommen die Reste zurueck, und zwar still.""" + import re + + quelle = os.path.join(os.path.dirname(__file__), "..", "windows_app.py") + with open(os.path.abspath(quelle), encoding="utf-8") as f: + text = f.read() + stellen = [m.start() for m in re.finditer(r"subprocess\.Popen\(\[programm", text)] + assert len(stellen) == 2, "Startwege haben sich geaendert: %d gefunden" % len(stellen) + for stelle in stellen: + aufruf = text[stelle:stelle + 300] + assert "env=umgebung_ohne_bundle()" in aufruf, ( + "Dieser Start reicht den Auspack-Zeiger weiter:\n" + aufruf[:120]) + + +def test_aufraeumen_laesst_einen_BENUTZTEN_ordner_unangetastet(tmp_path): + """Die wichtigste Eigenschaft: Ein laufender Rippy darf nichts verlieren. + + Ein blindes `rmtree(ignore_errors=True)` haette ihm die halbe Bibliothek + weggeraeumt, bevor es an der gesperrten DLL scheitert. + """ + lebt = tmp_path / "_MEI111111" + lebt.mkdir() + (lebt / "python312.dll").write_bytes(b"MZ") + (lebt / "wichtig.pyd").write_bytes(b"x") + + def gesperrt(pfad): + raise OSError(32, "Datei wird von einem anderen Prozess verwendet") + + assert winlauf.reste_aufraeumen(str(tmp_path), eigener="", + jetzt_loeschen=gesperrt) == 0 + assert (lebt / "wichtig.pyd").exists(), "an einem benutzten Ordner wird NICHTS angefasst" + + +def test_aufraeumen_entfernt_die_reste(tmp_path): + for name in ("_MEI000035002", "_MEI0000b0882"): + ordner = tmp_path / name + ordner.mkdir() + (ordner / "python312.dll").write_bytes(b"MZ") + (ordner / "base_library.zip").write_bytes(b"PK") + + assert winlauf.reste_aufraeumen(str(tmp_path), eigener="") == 2 + assert not list(tmp_path.glob("_MEI*")) + + +def test_aufraeumen_raeumt_den_EIGENEN_ordner_nicht_weg(tmp_path): + """Sonst saegte Rippy waehrend des Startens an seinem eigenen Ast.""" + eigen = tmp_path / "_MEI999999" + eigen.mkdir() + (eigen / "python312.dll").write_bytes(b"MZ") + + assert winlauf.reste_aufraeumen(str(tmp_path), eigener=str(eigen)) == 0 + assert eigen.exists() + + +def test_aufraeumen_ignoriert_fremde_ordner(tmp_path): + """Was nicht `_MEI` heisst, geht uns nichts an.""" + (tmp_path / "wichtige-daten").mkdir() + assert winlauf.reste_aufraeumen(str(tmp_path), eigener="") == 0 + assert (tmp_path / "wichtige-daten").exists() diff --git a/src/rippy/platform/winlauf.py b/src/rippy/platform/winlauf.py index a6b0db3..971ce8e 100644 --- a/src/rippy/platform/winlauf.py +++ b/src/rippy/platform/winlauf.py @@ -71,6 +71,99 @@ MB_ICONINFORMATION = 0x40 MB_ICONERROR = 0x10 +#: PyInstaller schreibt den Auspack-Ordner in die eigene Umgebung. Namen je +#: nach Fassung: `_MEIPASS2` bis 5.x, ab 6.x die drei `_PYI_*`-Variablen +#: (pyinstaller/PyInstaller/loader/pyiboot01_bootstrap.py). Hier stehen alle, +#: damit ein Fassungswechsel den Fehler nicht stillschweigend zurückholt. +PYI_ZEIGER = ("_MEIPASS2", "_PYI_ARCHIVE_FILE", "_PYI_APPLICATION_HOME_DIR", + "_PYI_PARENT_PROCESS_LEVEL") + + +def umgebung_ohne_bundle(basis: dict = None) -> dict: + """Die eigene Umgebung, aber ohne PyInstallers Auspack-Zeiger. + + ## Der Befund des Commanders (28.08.2026) + + Failed to remove temporary directory: + C:\\Users\\TobisPC\\AppData\\Local\\Temp\\_MEI0000b0882 + + „Und manchmal kommt dieser fehler." — Manchmal stimmt: Es ist ein + Wettlauf. + + ## Was gemessen wurde + + In seinem Temp-Ordner lagen **20 zurückgelassene `_MEI`-Ordner mit + zusammen 1,1 GB**. Einer davon war der aus der Meldung, und er ließ sich + hinterher anstandslos löschen — die Sperre war also vorübergehend. + + ## Die Ursache + + Eine Onefile-EXE packt sich beim Start nach `%TEMP%\\_MEIxxxxxx` aus und + räumt beim Beenden auf. Den Ordner findet der Prozess über eine + Umgebungsvariable, die PyInstaller sich selbst setzt. + + `starte_hintergrund` und `starte_fensterprozess` starten `Rippy.exe` + erneut — mit `--dienst` und mit `--oeffnen`, denn Tray und Fenster + brauchen je einen eigenen Haupt-Thread. `subprocess.Popen` ohne `env=` + reicht die ganze Umgebung weiter, also **auch diesen Zeiger**. Die beiden + Kinder packen daraufhin gar nichts mehr aus: Sie laufen im Ordner des + Elternprozesses. + + Der Eltern-Prozess beendet sich als Erster und will seinen Ordner + löschen. Die Kinder haben die DLLs darin noch offen — Windows verweigert. + Meldung. Und weil danach niemand mehr zuständig ist, bleibt der Ordner + für immer liegen; beim nächsten Start derselbe Ablauf. + + Das war nicht nur unschön: Wäre das Löschen TEILWEISE geglückt, hätten + Dienst und Fenster mitten im Betrieb ihre eigenen Dateien verloren. + + Ohne den Zeiger packt sich jedes Kind seinen eigenen Ordner aus und räumt + ihn selbst wieder weg. Das kostet je Start etwa eine Sekunde und ein paar + Dutzend MB kurzzeitig — deutlich billiger als 1,1 GB Reste. + """ + umgebung = dict(os.environ if basis is None else basis) + for name in PYI_ZEIGER: + umgebung.pop(name, None) + return umgebung + + +def reste_aufraeumen(ordner: str = None, eigener: str = None, + jetzt_loeschen=None) -> int: + """Räumt zurückgelassene `_MEI`-Ordner weg. Gibt die Anzahl zurück. + + Der Netzfang für alles, was der Zeiger-Fix nicht mehr erzeugt, aber schon + liegen ließ — und für abgestürzte Läufe, die es immer geben wird. + + ## Warum das gefahrlos ist + + Ein LAUFENDER Rippy hat seine `python312.dll` offen, und Windows lässt + eine offene Datei nicht löschen. Deshalb wird genau die zuerst versucht: + Geht sie nicht weg, gehört der Ordner einem lebenden Prozess, und es wird + **nichts weiter angefasst**. Ein blindes `rmtree(ignore_errors=True)` + hätte einem laufenden Rippy die halbe Bibliothek unter den Füßen + weggeräumt, bevor es an der gesperrten DLL scheitert. + """ + import glob + import shutil + import tempfile + + ordner = ordner or tempfile.gettempdir() + eigener = eigener if eigener is not None else getattr(sys, "_MEIPASS", "") + weg = 0 + for pfad in glob.glob(os.path.join(ordner, "_MEI*")): + if not os.path.isdir(pfad) or os.path.normcase(pfad) == os.path.normcase(eigener or "\0"): + continue + wache = [d for d in glob.glob(os.path.join(pfad, "python3*.dll"))] + try: + for datei in wache: + (jetzt_loeschen or os.remove)(datei) + except OSError: + continue # In Benutzung — Finger weg vom ganzen Ordner. + shutil.rmtree(pfad, ignore_errors=True) + weg += 1 + return weg + + def ohne_konsole() -> bool: """Läuft dieser Prozess ohne Standardausgabe? diff --git a/src/rippy/setup_fenster.py b/src/rippy/setup_fenster.py index 1695fe5..529de1e 100644 --- a/src/rippy/setup_fenster.py +++ b/src/rippy/setup_fenster.py @@ -1,584 +1,600 @@ -"""Das Fenster des Einrichtungs-Assistenten. - -## Warum es das gibt - -Commander am 28.08.2026: - -> „Dann hätte ich beim ‚Setup' auch erwartet das nen echtes setup passiert — -> wo will ich das hinspeichern, nen pre requirement check usw. Da kommt -> garnichts Rippy geht einfach auf." - -## Warum WebView2 und nicht ein Windows-Dialog - -Weil es schon da ist. Rippy bringt WebView2 ohnehin für sein Hauptfenster mit -(Entscheid 4 in `KONZEPT-V2.md` § 10) — der Assistent kostet damit **null -zusätzliche Bytes**. Ein Dialog aus Win32-Bausteinen wäre hunderte Zeilen -ctypes für etwas, das hier fünfzig Zeilen HTML sind; `tkinter` ist im Bau -ausdrücklich ausgeschlossen. - -## Warum die Prüfungen NICHT hier stehen - -Sie stehen in `einrichtung.py`, und zwar vollständig ohne Fenster prüfbar. -Was hier steht, lässt sich nicht testen — also steht hier so wenig wie -möglich: Aufbau, Anzeige, und die Brücke zwischen beidem. - -## Was passiert, wenn das Fenster nicht aufgeht - -Dann wird ohne Assistent installiert, mit den Vorgaben, und gesagt warum. Ein -Setup, das gar nichts tut, weil sein Fenster nicht aufging, wäre schlimmer -als eines, das nicht fragt. -""" - -import threading - -from rippy import einrichtung - -TITEL = "Rippy einrichten" -BREITE = 860 -HOEHE = 760 - - -# Die Abschnitte der Installation und was sie am Balken belegen. -# -# Die Gewichte sind gemessen, nicht geraten: Das Ablegen der Dateien dauert -# rund drei Sekunden (56 MB kopieren, UI und HandBrake auspacken), das Holen -# von MakeMKV je nach Quelle sechs Sekunden bis mehrere Minuten. Der -# Werkzeug-Abschnitt bekommt deshalb den Löwenanteil — ein Balken, der bei -# 90 % minutenlang steht, ist schlimmer als gar keiner. -ABSCHNITTE = { - "dateien": (0.00, 0.08, "Dateien werden abgelegt"), - "ablage": (0.08, 0.10, "Ablage wird gespeichert"), - "werkzeuge": (0.10, 0.97, "Werkzeuge werden geprüft"), - "fertig": (0.97, 1.00, "Fertig"), -} - - -class Fortschritt: - """Rechnet Abschnitt + Teil-Anteil in einen Gesamtstand um. - - Reine Rechnerei, damit sie ohne Fenster prüfbar ist — und weil ein - Fortschrittsbalken, der springt oder zurückläuft, schlimmer aussieht als - keiner. - - Zwei Regeln: - - * **Er läuft nie zurück.** Der Werkzeug-Abschnitt kann mehrere Downloads - enthalten, jeder mit eigenem 0-bis-1. Ohne diese Regel spränge der - Balken bei jedem neuen Werkzeug zurück an den Abschnittsanfang. - * **Ohne Anteil bleibt er stehen.** Eine Textmeldung ohne Zahl ist keine - Aussage über den Fortschritt. - """ - - def __init__(self): - self.prozent = 0 - self.abschnitt = "" - self.zeilen = [] - - def beginne(self, name: str) -> None: - von, _bis, titel = ABSCHNITTE[name] - self.abschnitt = titel - self._von, self._bis, _ = ABSCHNITTE[name] - self.setze(von) - - def melde(self, text, anteil=None) -> None: - if text: - self.zeilen.append(str(text)) - if anteil is None: - return - anteil = min(1.0, max(0.0, float(anteil))) - self.setze(self._von + (self._bis - self._von) * anteil) - - def setze(self, anteil: float) -> None: - self.prozent = max(self.prozent, int(round(anteil * 100))) - - def abholen(self) -> dict: - """Was seit dem letzten Abruf passiert ist. Die Zeilen leeren sich.""" - zeilen, self.zeilen = self.zeilen, [] - return {"zeilen": zeilen, "prozent": self.prozent, - "abschnitt": self.abschnitt} - - -class Bruecke: - """Was die Seite aufrufen darf. Bewusst klein und ohne Zustand im HTML. - - Jede Methode gibt Daten zurück, keine Erfolgsmeldungen — dieselbe Regel - wie überall sonst in diesem Projekt. - """ - - def __init__(self): - self.ergebnis = None - self._fenster = None - self._lauf = Fortschritt() - - # ── Auskunft ──────────────────────────────────────────────────────── - def vorgaben(self) -> dict: - return einrichtung.vorgaben() - - def pruefen(self, ziel: str = "", port: int = 0) -> dict: - """Der Voraussetzungs-Bericht für die angegebenen Werte.""" - vorgabe = einrichtung.vorgaben() - befunde = einrichtung.alle_pruefungen(ziel or vorgabe["ziel"], - int(port or vorgabe["port"])) - return { - "befunde": befunde, - "darf": einrichtung.darf_installieren(befunde), - "text": einrichtung.zusammenfassung(befunde), - } - - def updates(self) -> dict: - """Gibt es für die installierten Werkzeuge etwas Neueres? - - ## Warum das ein EIGENER Aufruf ist (28.08.2026) - - Commander: *„bitte baue ein, das wenn MakeMKV oder Handbrake schon - installiert sind ein update verfügbar ist."* - - Die Auskunft kostet Netz — und makemkv.com braucht dafür im - schlimmsten Fall gut zwanzig Sekunden (gemessen). In der - Voraussetzungs-Prüfung würde das Fenster so lange leer bleiben. - Deshalb getrennt: Die Prüfung zeigt sofort, was installiert ist, und - diese Auskunft trägt die Update-Hinweise nach. - - Leere Werte heißen „nicht erreichbar", nicht „kein Update" — eine - Nichtauskunft ist keine Aussage. - """ - from rippy.tools import beschaffen, einrichten as werkzeuge, katalog - - try: - bestand = katalog.bestand() - neueste = beschaffen.neueste_versionen() - except Exception: # noqa: BLE001 - return {"erreichbar": False, "offen": []} - - offen = [{"name": name, - "titel": eintrag.get("titel", name), - "installiert": eintrag.get("version", ""), - "neueste": neu} - for name, eintrag, neu in werkzeuge.veraltete(bestand, neueste)] - return {"erreichbar": any(neueste.values()), "offen": offen} - - def ordner_waehlen(self, vorschlag: str = "") -> str: - """Ein echter Ordner-Dialog. Leer, wenn abgebrochen wurde.""" - import webview - - try: - gewaehlt = self._fenster.create_file_dialog( - webview.FOLDER_DIALOG, directory=vorschlag or "") - except Exception: # noqa: BLE001 - return "" - if not gewaehlt: - return "" - return gewaehlt[0] if isinstance(gewaehlt, (list, tuple)) else str(gewaehlt) - - def fortschritt(self) -> dict: - """Was seit dem letzten Abruf passiert ist — Zeilen UND Stand. - - Gibt ein Wörterbuch zurück, keine Liste: Der Balken braucht eine Zahl, - und zwei getrennte Abrufe dafür wären zwei Gelegenheiten, dass Text - und Stand auseinanderlaufen. - """ - return self._lauf.abholen() - - # ── Handeln ───────────────────────────────────────────────────────── - def installieren(self, optionen: dict) -> dict: - """Installiert im Hintergrund-Thread und gibt sofort zurück. - - Sofort, weil das Holen von MakeMKV Minuten dauern kann: Ein blockierter - Aufruf ließe das Fenster einfrieren, und ein eingefrorenes Fenster - sieht aus wie ein Absturz. - """ - threading.Thread(target=self._installieren_wirklich, - args=(optionen or {},), daemon=True).start() - return {"gestartet": True} - - def _installieren_wirklich(self, optionen: dict) -> None: - from rippy import windows_app - - melden = self._lauf.melde - - try: - self._lauf.beginne("dateien") - melden("Dateien werden abgelegt …") - ziel = windows_app.installieren( - ziel=optionen.get("ziel") or None, - autostart=bool(optionen.get("autostart", True)), - starten_danach=False, - verknuepfen=bool(optionen.get("verknuepfungen", True)), - ) - if optionen.get("ablage"): - self._lauf.beginne("ablage") - melden("Ablage wird gespeichert …") - _ablage_speichern(optionen["ablage"]) - self._lauf.beginne("werkzeuge") - if optionen.get("werkzeuge_holen", True): - melden("Werkzeuge werden geprüft …") - from rippy.tools import einrichten as werkzeug_einrichtung - - bericht = werkzeug_einrichtung.sicherstellen( - fortschritt=melden, - aktualisieren=bool(optionen.get("aktualisieren", False))) - melden(werkzeug_einrichtung.bericht(bericht)) - bereit = bericht["bereit"] - else: - bereit = True - self._lauf.beginne("fertig") - self.ergebnis = {"ok": True, "ziel": ziel, "bereit": bereit} - melden("Fertig.", 1.0) - except Exception as ex: # noqa: BLE001 - # Nicht verschlucken: Ohne Konsole waere ein Absturz hier voellig - # unsichtbar, und der Nutzer saehe ein Fenster, in dem nichts mehr - # passiert. - self.ergebnis = {"ok": False, - "fehler": "%s: %s" % (type(ex).__name__, ex)} - melden("Fehlgeschlagen: %s" % ex) - - def stand(self) -> dict: - """None, solange es läuft. Danach das Ergebnis.""" - return self.ergebnis or {} - - def als_admin(self) -> dict: - """Startet das Setup mit Administratorrechten neu. - - Nur auf Knopfdruck, nie von selbst. Der Grund steht in - `winlauf.als_admin_neu_starten`: Erhöht sieht Rippy die eingebundenen - Netzlaufwerke NICHT mehr — für eine Ablage auf einer Freigabe wäre das - genau der falsche Weg. - - Gibt zurück, ob der Neustart angenommen wurde. Ein „nein" ist der - Normalfall, wenn der Nutzer die UAC-Abfrage wegklickt — dann bleibt - das Fenster einfach offen. - """ - from rippy import windows_app - from rippy.platform import winlauf - - geklappt = winlauf.als_admin_neu_starten(windows_app._eigene_datei()) - if geklappt: - self.schliessen() - return {"gestartet": geklappt} - - def schliessen(self) -> None: - try: - self._fenster.destroy() - except Exception: # noqa: BLE001 - pass - - -def _ablage_speichern(pfad: str) -> None: - """Die gewählte Ablage in die Einstellungen schreiben. - - ## Der Fehler, der das Setup abgebrochen hat (28.08.2026) - - Hier stand `import db as api_db`, nachdem `daemon._api_pfad()` in den - Suchpfad gehängt worden war. Beim Commander brach die Installation ab mit: - - Fehlgeschlagen: ModuleNotFoundError: No module named 'db' - - Und zwar zu Recht: **`docker/api/db.py` gibt es nicht.** Seit der - Zusammenlegung in V2-1 ist der Store `rippy.store`; `main.py` schreibt - seitdem `from rippy import store as db`. Ich habe ein Modul importiert, - das ich mir aus einem Alias zusammengereimt habe — genau die Sorte - erfundener Schnittstelle, vor der AGENTS.md Regel D warnt, nur diesmal - im eigenen Code. - - Der Store wird direkt benutzt. Ein Umweg über den Suchpfad der API war - ohnehin überflüssig. - """ - from rippy import config, store - - werte = config.laden() - store.verbinden(config.datenbank_url(werte)) - store.init_db() - aktuell = store.get_settings() or {} - aktuell["outputDir"] = pfad - store.save_settings(aktuell) - - -def starten() -> dict: - """Öffnet den Assistenten. Gibt zurück, was daraus geworden ist. - - `{}` heißt: abgebrochen oder nicht möglich. Der Aufrufer entscheidet dann, - ob er ohne Assistent installiert. - """ - import webview - - bruecke = Bruecke() - fenster = webview.create_window(TITEL, html=SEITE, js_api=bruecke, - width=BREITE, height=HOEHE, - min_size=(720, 560), text_select=True) - bruecke._fenster = fenster - webview.start(gui="edgechromium", private_mode=False) - return bruecke.ergebnis or {} - - -def moeglich() -> tuple: - """Kann der Assistent überhaupt aufgehen? (ja/nein, Begründung)""" - from rippy import fenster as fenster_modul - - return fenster_modul.verfuegbar() - - -# ── Die Seite ─────────────────────────────────────────────────────────── -# Bewusst EINE Datei ohne Abhängigkeiten: kein Vite-Bau, kein Nachladen, keine -# Schriftart aus dem Netz. Der Assistent muss auch auf einem Rechner ohne -# Internet aufgehen — dort wird er am dringendsten gebraucht. -SEITE = r""" - - -

-

Rippy einrichten

-
Wird geprüft, was dein Rechner mitbringt — danach entscheidest du, wohin.
-
-
-
-

Voraussetzungen

-
wird geprüft …
-
- -
-

Wohin

- - - -
- -
-

Sonst noch

- - - - -
- -
-

Läuft

-
- wird vorbereitet … - 0 % -
-
-
-
-
-
-
wird geprüft …
- - - -
- - -""" - -# Damit die Seite auch ohne laufendes Fenster prüfbar ist (siehe Tests). -SEITE_LAENGE = len(SEITE) - - -def seite_enthaelt(text: str) -> bool: - """Kleine Hilfe für Tests: Steht etwas in der Seite?""" - return text in SEITE +"""Das Fenster des Einrichtungs-Assistenten. + +## Warum es das gibt + +Commander am 28.08.2026: + +> „Dann hätte ich beim ‚Setup' auch erwartet das nen echtes setup passiert — +> wo will ich das hinspeichern, nen pre requirement check usw. Da kommt +> garnichts Rippy geht einfach auf." + +## Warum WebView2 und nicht ein Windows-Dialog + +Weil es schon da ist. Rippy bringt WebView2 ohnehin für sein Hauptfenster mit +(Entscheid 4 in `KONZEPT-V2.md` § 10) — der Assistent kostet damit **null +zusätzliche Bytes**. Ein Dialog aus Win32-Bausteinen wäre hunderte Zeilen +ctypes für etwas, das hier fünfzig Zeilen HTML sind; `tkinter` ist im Bau +ausdrücklich ausgeschlossen. + +## Warum die Prüfungen NICHT hier stehen + +Sie stehen in `einrichtung.py`, und zwar vollständig ohne Fenster prüfbar. +Was hier steht, lässt sich nicht testen — also steht hier so wenig wie +möglich: Aufbau, Anzeige, und die Brücke zwischen beidem. + +## Was passiert, wenn das Fenster nicht aufgeht + +Dann wird ohne Assistent installiert, mit den Vorgaben, und gesagt warum. Ein +Setup, das gar nichts tut, weil sein Fenster nicht aufging, wäre schlimmer +als eines, das nicht fragt. +""" + +import threading + +from rippy import einrichtung + +TITEL = "Rippy einrichten" +BREITE = 860 +HOEHE = 760 + + +# Die Abschnitte der Installation und was sie am Balken belegen. +# +# Die Gewichte sind gemessen, nicht geraten: Das Ablegen der Dateien dauert +# rund drei Sekunden (56 MB kopieren, UI und HandBrake auspacken), das Holen +# von MakeMKV je nach Quelle sechs Sekunden bis mehrere Minuten. Der +# Werkzeug-Abschnitt bekommt deshalb den Löwenanteil — ein Balken, der bei +# 90 % minutenlang steht, ist schlimmer als gar keiner. +ABSCHNITTE = { + "dateien": (0.00, 0.08, "Dateien werden abgelegt"), + "ablage": (0.08, 0.10, "Ablage wird gespeichert"), + "werkzeuge": (0.10, 0.97, "Werkzeuge werden geprüft"), + "fertig": (0.97, 1.00, "Fertig"), +} + + +class Fortschritt: + """Rechnet Abschnitt + Teil-Anteil in einen Gesamtstand um. + + Reine Rechnerei, damit sie ohne Fenster prüfbar ist — und weil ein + Fortschrittsbalken, der springt oder zurückläuft, schlimmer aussieht als + keiner. + + Zwei Regeln: + + * **Er läuft nie zurück.** Der Werkzeug-Abschnitt kann mehrere Downloads + enthalten, jeder mit eigenem 0-bis-1. Ohne diese Regel spränge der + Balken bei jedem neuen Werkzeug zurück an den Abschnittsanfang. + * **Ohne Anteil bleibt er stehen.** Eine Textmeldung ohne Zahl ist keine + Aussage über den Fortschritt. + """ + + def __init__(self): + self.prozent = 0 + self.abschnitt = "" + self.zeilen = [] + + def beginne(self, name: str) -> None: + von, _bis, titel = ABSCHNITTE[name] + self.abschnitt = titel + self._von, self._bis, _ = ABSCHNITTE[name] + self.setze(von) + + def melde(self, text, anteil=None) -> None: + if text: + self.zeilen.append(str(text)) + if anteil is None: + return + anteil = min(1.0, max(0.0, float(anteil))) + self.setze(self._von + (self._bis - self._von) * anteil) + + def setze(self, anteil: float) -> None: + self.prozent = max(self.prozent, int(round(anteil * 100))) + + def abholen(self) -> dict: + """Was seit dem letzten Abruf passiert ist. Die Zeilen leeren sich.""" + zeilen, self.zeilen = self.zeilen, [] + return {"zeilen": zeilen, "prozent": self.prozent, + "abschnitt": self.abschnitt} + + +#: pywebview.FOLDER_DIALOG. Steht hier doppelt, damit `ordner_waehlen` +#: auch ohne pywebview pruefbar bleibt (webview/util.py: OPEN=10, +#: FOLDER=20, SAVE=30). +FOLDER_DIALOG = 20 + + +class Bruecke: + """Was die Seite aufrufen darf. Bewusst klein und ohne Zustand im HTML. + + Jede Methode gibt Daten zurück, keine Erfolgsmeldungen — dieselbe Regel + wie überall sonst in diesem Projekt. + """ + + def __init__(self): + self.ergebnis = None + self._fenster = None + self._lauf = Fortschritt() + + # ── Auskunft ──────────────────────────────────────────────────────── + def vorgaben(self) -> dict: + return einrichtung.vorgaben() + + def pruefen(self, ziel: str = "", port: int = 0) -> dict: + """Der Voraussetzungs-Bericht für die angegebenen Werte.""" + vorgabe = einrichtung.vorgaben() + befunde = einrichtung.alle_pruefungen(ziel or vorgabe["ziel"], + int(port or vorgabe["port"])) + return { + "befunde": befunde, + "darf": einrichtung.darf_installieren(befunde), + "text": einrichtung.zusammenfassung(befunde), + } + + def updates(self) -> dict: + """Gibt es für die installierten Werkzeuge etwas Neueres? + + ## Warum das ein EIGENER Aufruf ist (28.08.2026) + + Commander: *„bitte baue ein, das wenn MakeMKV oder Handbrake schon + installiert sind ein update verfügbar ist."* + + Die Auskunft kostet Netz — und makemkv.com braucht dafür im + schlimmsten Fall gut zwanzig Sekunden (gemessen). In der + Voraussetzungs-Prüfung würde das Fenster so lange leer bleiben. + Deshalb getrennt: Die Prüfung zeigt sofort, was installiert ist, und + diese Auskunft trägt die Update-Hinweise nach. + + Leere Werte heißen „nicht erreichbar", nicht „kein Update" — eine + Nichtauskunft ist keine Aussage. + """ + from rippy.tools import beschaffen, einrichten as werkzeuge, katalog + + try: + bestand = katalog.bestand() + neueste = beschaffen.neueste_versionen() + except Exception: # noqa: BLE001 + return {"erreichbar": False, "offen": []} + + offen = [{"name": name, + "titel": eintrag.get("titel", name), + "installiert": eintrag.get("version", ""), + "neueste": neu} + for name, eintrag, neu in werkzeuge.veraltete(bestand, neueste)] + return {"erreichbar": any(neueste.values()), "offen": offen} + + def ordner_waehlen(self, vorschlag: str = "") -> str: + """Ein echter Ordner-Dialog. Leer, wenn abgebrochen wurde.""" + # Der Rueckfall ist NICHT fuer den Betrieb — dort ist pywebview immer + # da. Er ist fuer die Ampel: Ohne ihn scheiterten diese zwei Tests in + # jeder Umgebung ohne pywebview mit ModuleNotFoundError. Ein Test, der + # nur an einer Stelle greift, ist eine halbe Zusage (AGENTS.md) — und + # am 28.08.2026 hielt ich die beiden erst faelschlich fuer meinen + # eigenen Fehler. + try: + import webview + art = webview.FOLDER_DIALOG + except ImportError: + art = FOLDER_DIALOG + + try: + gewaehlt = self._fenster.create_file_dialog( + art, directory=vorschlag or "") + except Exception: # noqa: BLE001 + return "" + if not gewaehlt: + return "" + return gewaehlt[0] if isinstance(gewaehlt, (list, tuple)) else str(gewaehlt) + + def fortschritt(self) -> dict: + """Was seit dem letzten Abruf passiert ist — Zeilen UND Stand. + + Gibt ein Wörterbuch zurück, keine Liste: Der Balken braucht eine Zahl, + und zwei getrennte Abrufe dafür wären zwei Gelegenheiten, dass Text + und Stand auseinanderlaufen. + """ + return self._lauf.abholen() + + # ── Handeln ───────────────────────────────────────────────────────── + def installieren(self, optionen: dict) -> dict: + """Installiert im Hintergrund-Thread und gibt sofort zurück. + + Sofort, weil das Holen von MakeMKV Minuten dauern kann: Ein blockierter + Aufruf ließe das Fenster einfrieren, und ein eingefrorenes Fenster + sieht aus wie ein Absturz. + """ + threading.Thread(target=self._installieren_wirklich, + args=(optionen or {},), daemon=True).start() + return {"gestartet": True} + + def _installieren_wirklich(self, optionen: dict) -> None: + from rippy import windows_app + + melden = self._lauf.melde + + try: + self._lauf.beginne("dateien") + melden("Dateien werden abgelegt …") + ziel = windows_app.installieren( + ziel=optionen.get("ziel") or None, + autostart=bool(optionen.get("autostart", True)), + starten_danach=False, + verknuepfen=bool(optionen.get("verknuepfungen", True)), + ) + if optionen.get("ablage"): + self._lauf.beginne("ablage") + melden("Ablage wird gespeichert …") + _ablage_speichern(optionen["ablage"]) + self._lauf.beginne("werkzeuge") + if optionen.get("werkzeuge_holen", True): + melden("Werkzeuge werden geprüft …") + from rippy.tools import einrichten as werkzeug_einrichtung + + bericht = werkzeug_einrichtung.sicherstellen( + fortschritt=melden, + aktualisieren=bool(optionen.get("aktualisieren", False))) + melden(werkzeug_einrichtung.bericht(bericht)) + bereit = bericht["bereit"] + else: + bereit = True + self._lauf.beginne("fertig") + self.ergebnis = {"ok": True, "ziel": ziel, "bereit": bereit} + melden("Fertig.", 1.0) + except Exception as ex: # noqa: BLE001 + # Nicht verschlucken: Ohne Konsole waere ein Absturz hier voellig + # unsichtbar, und der Nutzer saehe ein Fenster, in dem nichts mehr + # passiert. + self.ergebnis = {"ok": False, + "fehler": "%s: %s" % (type(ex).__name__, ex)} + melden("Fehlgeschlagen: %s" % ex) + + def stand(self) -> dict: + """None, solange es läuft. Danach das Ergebnis.""" + return self.ergebnis or {} + + def als_admin(self) -> dict: + """Startet das Setup mit Administratorrechten neu. + + Nur auf Knopfdruck, nie von selbst. Der Grund steht in + `winlauf.als_admin_neu_starten`: Erhöht sieht Rippy die eingebundenen + Netzlaufwerke NICHT mehr — für eine Ablage auf einer Freigabe wäre das + genau der falsche Weg. + + Gibt zurück, ob der Neustart angenommen wurde. Ein „nein" ist der + Normalfall, wenn der Nutzer die UAC-Abfrage wegklickt — dann bleibt + das Fenster einfach offen. + """ + from rippy import windows_app + from rippy.platform import winlauf + + geklappt = winlauf.als_admin_neu_starten(windows_app._eigene_datei()) + if geklappt: + self.schliessen() + return {"gestartet": geklappt} + + def schliessen(self) -> None: + try: + self._fenster.destroy() + except Exception: # noqa: BLE001 + pass + + +def _ablage_speichern(pfad: str) -> None: + """Die gewählte Ablage in die Einstellungen schreiben. + + ## Der Fehler, der das Setup abgebrochen hat (28.08.2026) + + Hier stand `import db as api_db`, nachdem `daemon._api_pfad()` in den + Suchpfad gehängt worden war. Beim Commander brach die Installation ab mit: + + Fehlgeschlagen: ModuleNotFoundError: No module named 'db' + + Und zwar zu Recht: **`docker/api/db.py` gibt es nicht.** Seit der + Zusammenlegung in V2-1 ist der Store `rippy.store`; `main.py` schreibt + seitdem `from rippy import store as db`. Ich habe ein Modul importiert, + das ich mir aus einem Alias zusammengereimt habe — genau die Sorte + erfundener Schnittstelle, vor der AGENTS.md Regel D warnt, nur diesmal + im eigenen Code. + + Der Store wird direkt benutzt. Ein Umweg über den Suchpfad der API war + ohnehin überflüssig. + """ + from rippy import config, store + + werte = config.laden() + store.verbinden(config.datenbank_url(werte)) + store.init_db() + aktuell = store.get_settings() or {} + aktuell["outputDir"] = pfad + store.save_settings(aktuell) + + +def starten() -> dict: + """Öffnet den Assistenten. Gibt zurück, was daraus geworden ist. + + `{}` heißt: abgebrochen oder nicht möglich. Der Aufrufer entscheidet dann, + ob er ohne Assistent installiert. + """ + import webview + + bruecke = Bruecke() + fenster = webview.create_window(TITEL, html=SEITE, js_api=bruecke, + width=BREITE, height=HOEHE, + min_size=(720, 560), text_select=True) + bruecke._fenster = fenster + webview.start(gui="edgechromium", private_mode=False) + return bruecke.ergebnis or {} + + +def moeglich() -> tuple: + """Kann der Assistent überhaupt aufgehen? (ja/nein, Begründung)""" + from rippy import fenster as fenster_modul + + return fenster_modul.verfuegbar() + + +# ── Die Seite ─────────────────────────────────────────────────────────── +# Bewusst EINE Datei ohne Abhängigkeiten: kein Vite-Bau, kein Nachladen, keine +# Schriftart aus dem Netz. Der Assistent muss auch auf einem Rechner ohne +# Internet aufgehen — dort wird er am dringendsten gebraucht. +SEITE = r""" + + +
+

Rippy einrichten

+
Wird geprüft, was dein Rechner mitbringt — danach entscheidest du, wohin.
+
+
+
+

Voraussetzungen

+
wird geprüft …
+
+ +
+

Wohin

+ + + +
+ +
+

Sonst noch

+ + + + +
+ +
+

Läuft

+
+ wird vorbereitet … + 0 % +
+
+
+
+
+
+
wird geprüft …
+ + + +
+ + +""" + +# Damit die Seite auch ohne laufendes Fenster prüfbar ist (siehe Tests). +SEITE_LAENGE = len(SEITE) + + +def seite_enthaelt(text: str) -> bool: + """Kleine Hilfe für Tests: Steht etwas in der Seite?""" + return text in SEITE diff --git a/src/rippy/test_arbeitsort.py b/src/rippy/test_arbeitsort.py new file mode 100644 index 0000000..830ee20 --- /dev/null +++ b/src/rippy/test_arbeitsort.py @@ -0,0 +1,132 @@ +"""Wo liegt die Ablage, wo die Rohdaten — und wer darf wohin blaettern? + +## Der Befund des Commanders (28.08.2026) + +> „Warum heißt das hier noch container platte? Er holt sich das +> Arbeitsverzeichnis ja von der Installation. Wäre es möglich das +> Arbeitsverzeichnis zu ändern? momentan geht das nicht." + +Beide Haelften desselben Fehlers, und beide gehen auf EINE Zeile zurueck: + + MEDIA_ROOT = "/app/media" + +Sie war zugleich Vorgabe UND Pfadgrenze. Auf einem Windows-PC gibt es den +Ordner nicht: + +* `/storage-targets` machte `os.listdir("/app/media")`, fing den `OSError` + und gab still `[]` zurueck. Im Auswahlfeld stand genau ein Eintrag — eine + Auswahl ohne Auswahl. **Das ist „momentan geht das nicht".** +* Der Text dieses einen Eintrags war fest verdrahtet „(Container-Platte)". + **Das ist „warum heisst das hier noch container platte".** +* `/browse` prueft `unter_wurzel(pfad, MEDIA_ROOT)` — auf Windows also 422 + fuer jeden Pfad. Der Ordner-Browser konnte gar nichts anzeigen. + +Kein Absturz, keine Meldung. Nur eine Oberflaeche, die stillschweigend +nichts kann. + +## Warum die Grenze nicht einfach faellt + +Im Container ist sie richtig. Die API haengt dort im Netz, und eine +Weboberflaeche, die jeden Pfad des Wirts ausliefert, ist ein Loch. Die +Unterscheidung ist deshalb nicht „Windows", sondern **wer zuhoert**: Container +und Kopflos-Betrieb bedienen ein Netz, die native App den Menschen davor. + +Alles hier ist eingespritzt — kein Container, kein Windows, keine Platte +noetig. +""" + +import pytest + +from rippy import betrieb + + +CONTAINER = {"profil": "standalone"} +WINDOWS = {"profil": "standalone"} +VERTEILT = {"profil": "api", "queue": {"treiber": "celery"}} + + +# ── Wer darf frei blaettern ───────────────────────────────────────────── +def test_im_container_gilt_die_wurzel(): + """Die API haengt dort im Netz — freies Blaettern waere ein Loch.""" + assert betrieb.frei_blaettern(CONTAINER, container=True) is False + + +def test_die_native_app_darf_frei_blaettern(): + """Sie bedient den Menschen, der vor dem Rechner sitzt. Sein Ziel ist + eine UNC-Freigabe, die unter gar keiner lokalen Wurzel liegt.""" + assert betrieb.frei_blaettern(WINDOWS, container=False) is True + + +def test_der_verteilte_betrieb_darf_NICHT_frei_blaettern(): + """Kopflos (V2-6) laeuft nativ, bedient aber ein Netz. Der Fall, an dem + ein blosses `not container` falsch waere.""" + assert betrieb.frei_blaettern(VERTEILT, container=False) is False + + +# ── Wo liegt was ──────────────────────────────────────────────────────── +def test_im_container_bleibt_alles_wie_es_war(): + """Der Docker-Weg darf sich durch diese Reparatur NICHT aendern.""" + assert betrieb.medien_wurzel({}, container=True) == "/app/media" + assert betrieb.arbeits_vorgabe({}, container=True) == "/app/temp" + + +def test_auf_windows_kommt_der_ort_aus_der_installation(): + """Genau das, was der Commander erwartet hat: „Er holt sich das + Arbeitsverzeichnis ja von der Installation.""" + werte = {"storage": {"medien": "D:\\Rippy\\Medien"}} + assert betrieb.medien_wurzel(werte, container=False, + plattform_name="win32") == "D:\\Rippy\\Medien" + assert betrieb.arbeits_vorgabe(werte, container=False, + plattform_name="win32") == "D:\\Rippy\\Medien\\_arbeit" + + +def test_ein_eigenes_arbeitsverzeichnis_gewinnt(): + """Wer es eingestellt hat, bekommt es — auf ein anderes Laufwerk zeigend.""" + werte = {"storage": {"medien": "D:\\Rippy", "temp": "E:\\Roh"}} + assert betrieb.arbeits_vorgabe(werte, container=False, + plattform_name="win32") == "E:\\Roh" + + +def test_die_auskunft_nennt_beide_orte(): + """Ohne `arbeits_vorgabe` in der Antwort muesste die Oberflaeche wieder + raten — und genau daraus wurde „Container-Platte".""" + antwort = betrieb.auskunft({"storage": {"medien": "D:\\Rippy"}}, + container=False, plattform_name="win32") + assert antwort["ablage_vorgabe"] == "D:\\Rippy" + assert antwort["arbeits_vorgabe"] == "D:\\Rippy\\_arbeit" + assert antwort["kann"]["frei_blaettern"] is True + + +def test_die_auskunft_im_container_nennt_die_container_orte(): + antwort = betrieb.auskunft({}, container=True, plattform_name="linux") + assert antwort["ablage_vorgabe"] == "/app/media" + assert antwort["arbeits_vorgabe"] == "/app/temp" + assert antwort["kann"]["frei_blaettern"] is False + + +# ── Die Laufwerke ─────────────────────────────────────────────────────── +def test_nur_feste_platten_und_netzlaufwerke(): + """Ein CD-Laufwerk als Arbeitsverzeichnis waere Unsinn — dort liegt die + Disc, die gerade gerippt wird. Ein USB-Stick fuer 100 GB eine Falle.""" + arten = {"C:\\": 3, "D:\\": 3, "E:\\": 5, "F:\\": 2, "Z:\\": 4} + gefunden = betrieb.windows_laufwerke(art=lambda p: arten.get(p, 1), + buchstaben=list(arten)) + assert gefunden == ["C:\\", "D:\\", "Z:\\"] + + +def test_ohne_windows_gibt_es_keine_laufwerke(): + """Auf dem Linux-Runner der Ampel gibt es kein GetDriveTypeW. Das muss + eine leere Liste sein, kein Fehler.""" + assert betrieb.windows_laufwerke(art=None, buchstaben=[]) == [] + + +@pytest.mark.parametrize("werte,container,erwartet", [ + ({}, True, 2), # Media + /app/temp + ({"storage": {"medien": "D:\\M"}}, False, 1), # gleiches Laufwerk + ({"storage": {"medien": "D:\\M", "temp": "E:\\R"}}, False, 2), +]) +def test_platz_orte_zaehlen_nicht_doppelt(werte, container, erwartet): + """Zweimal dieselbe Zahl unter zwei Namen sieht aus wie zwei Auskuenfte + und ist eine.""" + orte = betrieb.platz_orte(werte, container=container, plattform_name="win32") + assert len(orte) == erwartet diff --git a/src/rippy/test_betrieb.py b/src/rippy/test_betrieb.py index 65fb079..5bd1ce3 100644 --- a/src/rippy/test_betrieb.py +++ b/src/rippy/test_betrieb.py @@ -1,192 +1,193 @@ -"""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) == "" +"""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", "arbeits_vorgabe", "hilfe_befehl"} + assert set(a["kann"]) == {"externe_worker", "freigaben_einhaengen", + "container_pfade", "werkzeuge_verwalten", + "frei_blaettern"} + 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) == "" diff --git a/src/rippy/windows_app.py b/src/rippy/windows_app.py index bf88647..1d63af7 100644 --- a/src/rippy/windows_app.py +++ b/src/rippy/windows_app.py @@ -352,10 +352,12 @@ def ui_auspacken(ziel: str) -> str: def starte_hintergrund(programm: str) -> None: """Startet Rippy als Hintergrundprozess — ohne aufblitzendes Fenster.""" - from rippy.platform.winlauf import OHNE_FENSTER + from rippy.platform.winlauf import OHNE_FENSTER, umgebung_ohne_bundle + # `env=` ist hier kein Beiwerk: Ohne die Bereinigung liefe dieses Kind im + # Auspack-Ordner des Elternprozesses. Begruendung in winlauf.py. subprocess.Popen([programm, "--dienst"], creationflags=OHNE_FENSTER, - close_fds=True) + close_fds=True, env=umgebung_ohne_bundle()) def starte_fensterprozess(programm: str = None) -> None: @@ -370,10 +372,11 @@ def starte_fensterprozess(programm: str = None) -> None: Fenster ist der Client davor. Beide heissen im Taskmanager „Rippy.exe", und das Fenster laesst sich schliessen, ohne den Dienst mitzureissen. """ - from rippy.platform.winlauf import OHNE_FENSTER + from rippy.platform.winlauf import OHNE_FENSTER, umgebung_ohne_bundle subprocess.Popen([programm or _eigene_datei(), "--oeffnen"], - creationflags=OHNE_FENSTER, close_fds=True) + creationflags=OHNE_FENSTER, close_fds=True, + env=umgebung_ohne_bundle()) def deinstallieren(still: bool = False) -> None: @@ -584,6 +587,18 @@ class Dienst: return False +def _reste_wegraeumen() -> None: + """Zurueckgelassene Auspack-Ordner entfernen. Darf nie stoeren.""" + try: + from rippy.platform.winlauf import reste_aufraeumen + + anzahl = reste_aufraeumen() + if anzahl: + melden(" %d zurueckgelassene Auspack-Ordner entfernt." % anzahl) + except Exception: # noqa: BLE001 + pass # Aufraeumen ist Kuer. Es darf den Dienst nicht kosten. + + def dienst_starten(port: int = STANDARD_PORT) -> int: """Server im Hintergrund-Thread, Tray-Symbol im Vordergrund. @@ -591,6 +606,13 @@ def dienst_starten(port: int = STANDARD_PORT) -> int: laufen, sonst bekommt es keine Fensternachrichten und das Symbol erscheint nie. """ + # Reste frueherer Laeufe wegraeumen — nebenher, damit der Start nicht + # darauf wartet. Beim Commander lagen 20 solcher Ordner mit 1,1 GB + # herum (Befund 28.08.2026); die Ursache ist mit `umgebung_ohne_bundle` + # behoben, das Liegengebliebene raeumt niemand sonst weg. + threading.Thread(target=_reste_wegraeumen, daemon=True, + name="rippy-aufraeumen").start() + dienst = Dienst(port) threading.Thread(target=dienst.server_starten, daemon=True, name="rippy-server").start()