From b2acddbdfa15b20915cbcd3797b8af13d09b2b48 Mon Sep 17 00:00:00 2001 From: Hitonabi Date: Sat, 29 Aug 2026 14:28:01 +0200 Subject: [PATCH] fix(windows): Drueberinstallieren, Linux-Reste im Windows-Betrieb, Ordner-Waehler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vier Fragen des Commanders vom 29.08.2026, drei davon mit Codefolge. ## 1. „Was passiert wenn man die Setup.exe einfach drueber installiert?" Bis hierher: nicht zuverlaessig. Rippy startet mit Windows, laeuft also fast immer — dann ist `Rippy.exe` gesperrt, `shutil.copy2` warf PermissionError, und die neue Fassung landete als `Rippy.exe.neu` daneben. Dazu die Meldung „wird beim naechsten Start uebernommen". **Diese Zusage hat niemand eingeloest.** `.neu` kam im ganzen Projekt genau einmal vor: an der Stelle, die es schrieb. Wer drueberinstallierte, behielt still die alte Fassung, und das Setup meldete Erfolg. Jetzt wird der laufende Rippy vorher beendet (`dienst_beenden` gibt es seit der Deinstallation und wartet auch die zwei Sekunden ab, die Windows fuer die Dateihandles braucht). Eine von einer aelteren Setup-Fassung liegengelassene `.neu` wird dabei uebernommen. Bleibt die Datei DANN noch gesperrt, gibt es einen klaren Fehler statt einer Zusage — Rippy im Infobereich beenden und das Setup erneut starten. Der Tausch laeuft bewusst im SETUP und nicht beim Dienststart: Windows sperrt eine laufende .exe, und `Rippy.exe` waere genau die zu ersetzende Datei. ## 3. Linux-Reste im Windows-Betrieb (Docker/Headless unveraendert) **`caps.py`: `os.path.isdir("/app")`.** Damit hielt sich der eigenstaendige Windows-Rippy fuer einen FREMDEN Worker — und das UI warnte vor fehlender Pfad-Uebersetzung auf einer Maschine ohne Container und ohne Freigabe. „Extern" heisst jetzt, was es meint: Rippy laeuft woanders als dieser Worker. **`caps.py`: `shutil.which("makemkvcon")` + `os.path.ismount(daten_dir)`.** Beide unter Windows immer falsch (Programme liegen nicht im PATH, ein normaler Ordner ist kein Mount). Die Schluessel-Auskunft blieb dauerhaft „unbekannt", obwohl MakeMKV samt Datenverzeichnis da war. Der Mount-Test bleibt fuer den Container, wo er einen Zweck hat. **`rohdaten.py`: `/app/temp/raw` und `/app/media` fest.** Dieses Modul findet die Rohdaten eines Jobs wieder — fuer den Wiederholen-Dialog und fuer „Rohdaten mitloeschen". Unter Windows fand es NIE etwas: Der Dialog meldete „keine Rohdaten", das Aufraeumen loeschte nichts, und die Bruchstuecke eines abgebrochenen Rips blieben liegen (bei 4K-UHD bis 100 GB). Sieben Tests wurden dabei rot, und zwar zu Recht: Sie pruefen Container-Regeln, liefen aber unter Windows. Die Wurzeln sind jetzt einspritzbar — beide Betriebsfaelle auf jedem Rechner pruefbar statt vom laufenden abhaengig. ## 4. „Der Durchsuchen button fehlt. Wie es der Installer auch macht" Neu: `OrdnerWaehler` — Pfadfeld plus „Durchsuchen …", benutzt fuer Ablage und Arbeitsverzeichnis. Es waere der DRITTE fest eingebaute Ordner-Browser geworden (RipTargetModal, StorageMounts); dieser hier ist wiederverwendbar. `/browse` weiss seit dem 28.08. selbst, in welchem Betrieb es laeuft. ⚠️ Beim Einbau fiel der Import unter den Tisch. `vite` pruefte das NICHT — das Buendel blieb byte-gleich gross, und zur Laufzeit waere es der naechste leere Bildschirm gewesen. Aufgefallen nur, weil die erwartete Anzahl Ersetzungen nicht stimmte. Im Browser gegengeprueft: alle sieben Laufwerke, Navigation in D:\, keine Konsolenfehler. 843 Tests gruen, ruff sauber. Co-Authored-By: Claude Opus 5 --- docker/api/main.py | 8 +- docker/api/rohdaten.py | 64 ++++++-- docker/api/test_rohdaten.py | 21 ++- docker/ui/src/components/OrdnerWaehler.tsx | 162 +++++++++++++++++++++ docker/ui/src/pages/Settings.tsx | 9 +- docker/worker/caps.py | 57 +++++++- docker/worker/test_caps.py | 30 ++++ src/rippy/test_windows_app.py | 45 ++++++ src/rippy/windows_app.py | 83 ++++++++++- 9 files changed, 440 insertions(+), 39 deletions(-) create mode 100644 docker/ui/src/components/OrdnerWaehler.tsx diff --git a/docker/api/main.py b/docker/api/main.py index d962dc5..318b5f7 100644 --- a/docker/api/main.py +++ b/docker/api/main.py @@ -6,7 +6,6 @@ from typing import List, Optional, Dict import asyncio import json import os -import posixpath import shutil import time import uuid @@ -836,8 +835,11 @@ def _kann_neu_komprimieren(job: dict, work_dir: str) -> bool: if job["id"] in vorrat: return bool(vorrat[job["id"]]) # Nur der lokale Ort: /app/temp ist ein Docker-Volume, os.path.isdir kann - # dort nicht hängen (im Gegensatz zu allem unter /app/media). - return os.path.isdir(posixpath.join(rohdaten.RAW_STANDARD, job["id"])) + # dort nicht hängen (im Gegensatz zu allem unter /app/media). Die Wurzel + # kommt vom Betrieb — auf Windows gibt es kein /app/temp (29.08.2026). + from rippy import pfade + + return os.path.isdir(pfade.verbinden(rohdaten.wurzeln()[0], job["id"])) @app.get("/jobs", response_model=List[Job]) diff --git a/docker/api/rohdaten.py b/docker/api/rohdaten.py index c4fcef1..da28fe7 100644 --- a/docker/api/rohdaten.py +++ b/docker/api/rohdaten.py @@ -41,15 +41,44 @@ Job-ID (vollständige UUID), fertige Ablagen heißen `Titel (Jahr) [kurz-id]`. import posixpath import subprocess -# Container-Standard für Roh-Rips (RAW_DIR im Worker). +# Container-Standard für Roh-Rips (RAW_DIR im Worker). Bleibt als Rueckfall +# stehen — die WURZELN dieses Betriebs liefert `wurzeln()`. RAW_STANDARD = "/app/temp/raw" MEDIA_ROOT = "/app/media" + +def wurzeln(werte=None) -> tuple: + """`(roh_standard, medien_wurzel, frei)` fuer DIESEN Betrieb. + + ## Warum das nicht fest sein darf (Befund 29.08.2026) + + Dieses Modul findet die Rohdaten eines Jobs wieder — fuer den + Wiederholen-Dialog („auf der Platte liegen X GB Rohdaten") und fuer + „Rohdaten mitloeschen". Es suchte fest unter `/app/temp/raw` und + `/app/media`. + + Auf einem Windows-PC gibt es beides nicht. Also fand es NIE etwas: Der + Dialog meldete „keine Rohdaten", das Aufraeumen loeschte nichts, und die + Bruchstuecke eines abgebrochenen Rips blieben unbemerkt liegen — bei einer + 4K-UHD bis zu 100 GB. + """ + from rippy import betrieb, config + + if werte is None: + try: + werte = config.laden() + except Exception: # noqa: BLE001 + werte = {} + return (betrieb.arbeits_vorgabe(werte) or RAW_STANDARD, + betrieb.medien_wurzel(werte) or MEDIA_ROOT, + betrieb.frei_blaettern(werte)) + # Harte Obergrenze für EINE Verzeichnis-Prüfung. Siehe verzeichnis_da(). PRUEF_TIMEOUT_SEKUNDEN = 4 -def kandidaten(job_id: str, work_dir: str, media_unterordner) -> list: +def kandidaten(job_id: str, work_dir: str, media_unterordner, + orte_wurzeln=None) -> list: """Alle Orte, an denen die Roh-MKVs dieses Jobs liegen KÖNNTEN (pure). `media_unterordner` sind die Namen der obersten Ebene unter /app/media @@ -65,13 +94,18 @@ def kandidaten(job_id: str, work_dir: str, media_unterordner) -> list: """ if not job_id: return [] - orte = [posixpath.join(RAW_STANDARD, job_id)] - wahl = (work_dir or "").strip().rstrip("/") - if wahl and (wahl == MEDIA_ROOT or wahl.startswith(MEDIA_ROOT + "/")): - orte.append(posixpath.join(wahl, job_id)) + roh, medien, frei = orte_wurzeln or (RAW_STANDARD, MEDIA_ROOT, False) + from rippy import pfade + + orte = [pfade.verbinden(roh, job_id)] + wahl = (work_dir or "").strip().rstrip("/\\") + # Nativ zaehlt jede Wahl — dort liegt der Arbeitsordner oft auf einem + # ganz anderen Laufwerk und damit unter gar keiner Wurzel. + if wahl and (frei or wahl == medien or wahl.startswith(medien + "/")): + orte.append(pfade.verbinden(wahl, job_id)) for name in media_unterordner or []: if name: - orte.append(posixpath.join(MEDIA_ROOT, name, job_id)) + orte.append(pfade.verbinden(pfade.verbinden(medien, name), job_id)) gesehen, eindeutig = set(), [] for ort in orte: if ort not in gesehen: @@ -145,7 +179,8 @@ def verzeichnis_da(pfad: str, laufen=None) -> bool: return pruefen(pfad, laufen) == "da" -def suche(job_id: str, work_dir: str, listdir, isdir) -> list: +def suche(job_id: str, work_dir: str, listdir, isdir, + orte_wurzeln=None) -> list: """Die Orte, an denen wirklich etwas liegt. `listdir` und `isdir` werden übergeben statt importiert — so ist die Suche @@ -156,12 +191,13 @@ def suche(job_id: str, work_dir: str, listdir, isdir) -> list: `listdir` darf os.listdir bleiben: Gelistet wird nur /app/media selbst, und das ist ein lokales Verzeichnis — die Freigaben sind Unterordner davon. """ + orte_wurzeln = orte_wurzeln or wurzeln() try: - unterordner = sorted(listdir(MEDIA_ROOT)) + unterordner = sorted(listdir(orte_wurzeln[1])) except OSError: unterordner = [] gefunden = [] - for ort in kandidaten(job_id, work_dir, unterordner): + for ort in kandidaten(job_id, work_dir, unterordner, orte_wurzeln): try: if isdir(ort): gefunden.append(ort) @@ -172,7 +208,8 @@ def suche(job_id: str, work_dir: str, listdir, isdir) -> list: return gefunden -def suche_mit_status(job_id: str, work_dir: str, listdir, pruefer=None) -> dict: +def suche_mit_status(job_id: str, work_dir: str, listdir, pruefer=None, + orte_wurzeln=None) -> dict: """Wie suche(), aber sagt auch, ob etwas UNGEPRÜFT geblieben ist. Rückgabe: {"pfade": [...], "unklar": bool}. `unklar` heißt: Mindestens ein @@ -181,12 +218,13 @@ def suche_mit_status(job_id: str, work_dir: str, listdir, pruefer=None) -> dict: behalten, statt Abwesenheit zu behaupten (siehe pruefen()). """ pruefe = pruefer or pruefen + orte_wurzeln = orte_wurzeln or wurzeln() try: - unterordner = sorted(listdir(MEDIA_ROOT)) + unterordner = sorted(listdir(orte_wurzeln[1])) except OSError: unterordner = [] gefunden, unklar = [], False - for ort in kandidaten(job_id, work_dir, unterordner): + for ort in kandidaten(job_id, work_dir, unterordner, orte_wurzeln): antwort = pruefe(ort) if antwort == "da": gefunden.append(ort) diff --git a/docker/api/test_rohdaten.py b/docker/api/test_rohdaten.py index f7428d4..08649b6 100644 --- a/docker/api/test_rohdaten.py +++ b/docker/api/test_rohdaten.py @@ -19,13 +19,20 @@ def test_der_echte_fall_wird_gefunden(): assert orte[0] == f"/app/temp/raw/{JOB}" +# Die Wurzeln des CONTAINER-Betriebs, eingespritzt. Ohne sie hingen diese +# Tests am laufenden Rechner: Unter Windows liefert `wurzeln()` echte +# Windows-Pfade, und die Erwartungen hier gelten dort nicht (am 29.08.2026 +# prompt sieben Tests rot geworden). +CONTAINER = ("/app/temp/raw", "/app/media", False) + + def test_suche_liefert_nur_was_existiert(): vorhanden = {f"/app/media/rippy/{JOB}"} gefunden = rohdaten.suche( JOB, "", listdir=lambda p: ["movies", "rippy"], isdir=lambda p: p in vorhanden, - ) + orte_wurzeln=CONTAINER) assert gefunden == [f"/app/media/rippy/{JOB}"] @@ -64,7 +71,7 @@ def test_kaputter_mount_reisst_die_suche_nicht_mit(): return p == f"/app/temp/raw/{JOB}" gefunden = rohdaten.suche( - JOB, "", listdir=lambda p: ["totes-nas", "movies"], isdir=isdir_kaputt) + JOB, "", listdir=lambda p: ["totes-nas", "movies"], isdir=isdir_kaputt, orte_wurzeln=CONTAINER) assert gefunden == [f"/app/temp/raw/{JOB}"] @@ -73,7 +80,7 @@ def test_listdir_kaputt_faellt_auf_den_standard_zurueck(): raise OSError("kein /app/media") gefunden = rohdaten.suche( - JOB, "", listdir=listdir_kaputt, isdir=lambda p: True) + JOB, "", listdir=listdir_kaputt, isdir=lambda p: True, orte_wurzeln=CONTAINER) assert gefunden == [f"/app/temp/raw/{JOB}"] @@ -177,7 +184,7 @@ def test_suche_mit_der_zeitgrenze_findet_den_echten_fall(): JOB, "", listdir=lambda p: ["bluray", "movies", "rippy"], isdir=lambda p: rohdaten.verzeichnis_da(p, laufen), - ) + orte_wurzeln=CONTAINER) assert gefunden == [f"/app/media/rippy/{JOB}"] @@ -206,7 +213,7 @@ def test_suche_mit_status_meldet_ungepruefte_orte(): return "unklar" if pfad.startswith("/app/media/rippy/") else "weg" e = rohdaten.suche_mit_status( - JOB, "", listdir=lambda p: ["rippy"], pruefer=pruefe) + JOB, "", listdir=lambda p: ["rippy"], pruefer=pruefe, orte_wurzeln=CONTAINER) assert e == {"pfade": [], "unklar": True} @@ -215,7 +222,7 @@ def test_suche_mit_status_ohne_zweifel(): return "da" if pfad == f"/app/media/rippy/{JOB}" else "weg" e = rohdaten.suche_mit_status( - JOB, "", listdir=lambda p: ["movies", "rippy"], pruefer=pruefe) + JOB, "", listdir=lambda p: ["movies", "rippy"], pruefer=pruefe, orte_wurzeln=CONTAINER) assert e == {"pfade": [f"/app/media/rippy/{JOB}"], "unklar": False} @@ -227,6 +234,6 @@ def test_suche_mit_status_findet_trotz_unklarem_anderen_ort(): return "unklar" if "totes-nas" in pfad else "weg" e = rohdaten.suche_mit_status( - JOB, "", listdir=lambda p: ["rippy", "totes-nas"], pruefer=pruefe) + JOB, "", listdir=lambda p: ["rippy", "totes-nas"], pruefer=pruefe, orte_wurzeln=CONTAINER) assert e["pfade"] == [f"/app/media/rippy/{JOB}"] assert e["unklar"] is True diff --git a/docker/ui/src/components/OrdnerWaehler.tsx b/docker/ui/src/components/OrdnerWaehler.tsx new file mode 100644 index 0000000..17e87f6 --- /dev/null +++ b/docker/ui/src/components/OrdnerWaehler.tsx @@ -0,0 +1,162 @@ +/* + * Ein Pfadfeld mit „Durchsuchen …" — wie im Installer. + * + * ## Der Wunsch des Commanders (29.08.2026) + * + * > „Wenn man im Nachhinein noch die Verzeichnisse ändern will, geht das + * > heute nur manuell über die einstellungen. Der Durchsuchen button fehlt. + * > Wie es der Installer auch macht" + * + * Er hat recht, und der Unterschied ist größer als ein Knopf: Im Setup wählt + * man den Ordner, danach musste man ihn abtippen. Einen Pfad wie + * `\\192.168.179.62\rippy\movies` tippt niemand zweimal richtig. + * + * ## Warum eine eigene Datei + * + * Weil es der DRITTE Ordner-Browser in diesem Projekt gewesen wäre: + * `RipTargetModal` hat einen, `StorageMounts` hat einen. Beide sind fest in + * ihre Karte eingebaut. Diesen hier kann jedes Feld benutzen. + * + * Der Browser selbst ist die API — `/browse` weiß seit dem 28.08.2026, in + * welchem Betrieb es läuft: im Container die Medien-Wurzel, nativ die Liste + * der Laufwerke. Diese Komponente muss darüber nichts wissen. + */ + +import { useEffect, useState } from 'react' +import { ArrowUp, Folder, FolderOpen } from 'lucide-react' + +import { api } from '../lib/api' +import { Modal } from './ui/Modal' +import { Button } from './ui/Button' +import { Input } from './ui/Input' + +interface Eintrag { + name: string + path: string +} + +interface Props { + label: string + value: string + placeholder?: string + onChange: (pfad: string) => void +} + +export function OrdnerWaehler({ label, value, placeholder, onChange }: Props) { + const [offen, setOffen] = useState(false) + const [pfad, setPfad] = useState('') + const [eltern, setEltern] = useState(null) + const [ordner, setOrdner] = useState([]) + const [fehler, setFehler] = useState('') + const [laedt, setLaedt] = useState(false) + + const laden = async (ziel: string) => { + setLaedt(true) + setFehler('') + try { + const r = await api.get('/browse', { params: { path: ziel } }) + setPfad(r.data.path || '') + setEltern(r.data.parent) + setOrdner(Array.isArray(r.data.dirs) ? r.data.dirs : []) + } catch (e: any) { + // Ein nicht lesbarer Ordner ist kein Grund, den Dialog zu schließen — + // man kommt mit „nach oben" wieder heraus. + setFehler(e?.response?.data?.detail || 'Ordner nicht lesbar') + setOrdner([]) + } finally { + setLaedt(false) + } + } + + useEffect(() => { + if (!offen) return + // Beim eingetragenen Wert einsteigen, sonst ganz oben. Leerer Pfad heißt + // für /browse „oberste Ebene" — nativ also die Laufwerksliste. + laden(value || '') + }, [offen]) + + return ( +
+
+
+ onChange(e.target.value)} + /> +
+ +
+ + setOffen(false)} + title={label} + maxWidth="lg" + > +
+
+ + + {pfad || 'Laufwerke'} + +
+ +
+ {laedt && ( +

wird gelesen …

+ )} + {!laedt && fehler && ( +

{fehler}

+ )} + {!laedt && !fehler && ordner.length === 0 && ( +

Keine Unterordner.

+ )} + {ordner.map((o) => ( + + ))} +
+ +
+

+ + Ein Netzwerkpfad lässt sich auch direkt ins Feld schreiben. +

+
+ + +
+
+
+
+
+ ) +} diff --git a/docker/ui/src/pages/Settings.tsx b/docker/ui/src/pages/Settings.tsx index 5b89462..223abc3 100644 --- a/docker/ui/src/pages/Settings.tsx +++ b/docker/ui/src/pages/Settings.tsx @@ -9,6 +9,7 @@ import { PageHeader } from '../components/ui/PageHeader' import { Card, CardHeader, CardTitle, CardContent } from '../components/ui/Card' import { Button } from '../components/ui/Button' import { Input, Select, Toggle } from '../components/ui/Input' +import { OrdnerWaehler } from '../components/OrdnerWaehler' import { MEDIA_SERVER_OPTIONEN } from '../lib/mediaServer' import { ENCODER_BADGES } from '../lib/design' import { PRESET_KEINE, schwacheEncoderCpu, simdWarnung } from '../lib/encoder' @@ -1000,11 +1001,11 @@ export default function SettingsPage() { * Liste kennt — D:\Rippy-Arbeit oder eine UNC-Freigabe. * Deshalb hier ein Feld, in das man ihn schreiben kann. */ - handleChange('workDir', e.target.value)} + onChange={(pfad) => handleChange('workDir', pfad)} /> ) : ( str: + """Läuft Rippy woanders als dieser Worker? „ja"/„nein" (einspritzbar). + + Drei Fälle, und nur einer davon ist „ja": + + * **Im Container** — der Worker ist Teil von Rippy. Nein. + * **Nativ und eigenständig** (Windows-App) — dieser Prozess IST Rippy. + Nein. Es gibt hier weder Container-Pfade noch eine Freigabe, über die + etwas zu übersetzen wäre. + * **Nativ und verteilt** — ein Worker auf einem anderen Rechner, der sich + bei einem Docker-Rippy meldet. Ja; er braucht RIPPY_PATH_MAP. + """ + from rippy import betrieb, config + + if container is None: + container = betrieb.im_container() + if container: + return "nein" + if werte is None: + try: + werte = config.laden() + except Exception: # noqa: BLE001 + werte = {} + return "ja" if betrieb.modus(werte) == "verteilt" else "nein" + + def _hb() -> str: """Pfad zu HandBrakeCLI — "" wenn es nicht da ist. @@ -343,8 +368,18 @@ def werkzeug_versionen() -> dict: # Container-Pfade (/app/media, /app/temp) nur über eine Freigabe plus # RIPPY_PATH_MAP erreicht. Das UI kann damit VOR dem Rip warnen, statt # den Nutzer eine Stunde rippen zu lassen (Vorfall 25.07.2026). - # /app ist im Rippy-Image immer vorhanden — kein Ratespiel. - "extern": "nein" if os.path.isdir("/app") else "ja", + # + # ⚠️ Hier stand `os.path.isdir("/app")` (Befund 29.08.2026). Im Image + # stimmt das. Auf einem Windows-PC gibt es `/app` nicht — und damit + # hielt sich der eigenständige Windows-Rippy für einen FREMDEN Worker. + # Folge: Das UI warnte vor fehlender Pfad-Übersetzung auf einer + # Maschine, auf der es weder Container noch Freigabe gibt. + # + # „Extern" heißt: Rippy läuft woanders als dieser Worker. In der + # eigenständigen Installation IST dieser Worker Rippy — dort ist die + # Frage gegenstandslos. Deshalb entscheidet der Betrieb, nicht ein + # Ordnername. + "extern": _extern(), # Ist die Pfad-Übersetzung gesetzt? Ohne sie kann ein externer Worker # grundsätzlich nicht komprimieren. "pfad_map": os.getenv("RIPPY_PATH_MAP", ""), @@ -402,7 +437,21 @@ def werkzeug_versionen() -> dict: daten_dir = makemkv_daten.DATEN_DIR except Exception: daten_dir = "" - if shutil.which("makemkvcon") and daten_dir and os.path.ismount(daten_dir): + # ⚠️ `shutil.which` und `os.path.ismount` sind hier BEIDE Linux-Annahmen + # (Befund 29.08.2026): Unter Windows liegt makemkvcon in „Programme" und + # nicht im PATH, und ein normaler Ordner ist kein Mount. Beide Prüfungen + # waren dort also immer falsch — die Schlüssel-Auskunft blieb dauerhaft + # „unbekannt", obwohl MakeMKV samt Datenverzeichnis da war. + # + # Der Mount-Test bleibt für den Container: Dort teilen sich Rippy und ein + # reiner Encoder-Worker dasselbe Image, aber nur einer bekommt den Mount. + # Nativ zählt stattdessen, ob der Ordner überhaupt existiert. + from rippy import betrieb + + _im_container = betrieb.im_container() + daten_da = bool(daten_dir) and (os.path.ismount(daten_dir) if _im_container + else os.path.isdir(daten_dir)) + if werkzeuge.finden("makemkv") and daten_da: try: info["keydb"] = "ja" if makemkv_daten.keydb_status().get("vorhanden") else "nein" except Exception: diff --git a/docker/worker/test_caps.py b/docker/worker/test_caps.py index 5280998..a9c054c 100644 --- a/docker/worker/test_caps.py +++ b/docker/worker/test_caps.py @@ -214,3 +214,33 @@ def test_hardware_presets_stehen_auch_ohne_hardware_in_der_liste(): assert caps.leite_backends_ab(caps.parse_encoder_liste(HB_HILFE_ECHT)) == [ "cpu-x264", "cpu-x265", "cpu-av1", ] + + +# ── „Extern" heisst: Rippy laeuft woanders (Befund 29.08.2026) ────────── +# +# Hier stand `os.path.isdir("/app")`. Im Image stimmt das. Auf einem +# Windows-PC gibt es `/app` nicht — und damit hielt sich der eigenstaendige +# Windows-Rippy fuer einen FREMDEN Worker. Folge: Das UI warnte vor fehlender +# Pfad-Uebersetzung auf einer Maschine, auf der es weder Container noch +# Freigabe gibt. + + +def test_im_container_ist_nichts_extern(): + from caps import _extern + + assert _extern({}, container=True) == "nein" + + +def test_die_eigenstaendige_windows_app_ist_NICHT_extern(): + """Dieser Prozess IST Rippy — die Frage ist dort gegenstandslos.""" + from caps import _extern + + assert _extern({"profil": "standalone"}, container=False) == "nein" + + +def test_ein_worker_bei_einem_docker_rippy_ist_extern(): + """Der Fall, fuer den die Auskunft gemacht ist: Er braucht RIPPY_PATH_MAP.""" + from caps import _extern + + assert _extern({"profil": "node", "queue": {"treiber": "celery"}}, + container=False) == "ja" diff --git a/src/rippy/test_windows_app.py b/src/rippy/test_windows_app.py index efecc5f..02c70a9 100644 --- a/src/rippy/test_windows_app.py +++ b/src/rippy/test_windows_app.py @@ -513,3 +513,48 @@ def test_laufender_dienst_wird_nicht_noch_einmal_gestartet(monkeypatch): monkeypatch.setattr(windows_app, "starte_hintergrund", lambda p: pytest.fail("es laeuft doch schon einer")) assert windows_app.bereitstellen(7788) is True + + +# ── Drueberinstallieren (Befund 29.08.2026) ───────────────────────────── +# +# Commander: „Was passiert wenn man die Setup.exe einfach drüber installiert? +# Geht das, ist es dann die aktuellste version?" +# +# Bis hierher: nicht zuverlaessig. Rippy startet mit Windows, laeuft also fast +# immer — dann ist `Rippy.exe` gesperrt, und die neue Fassung landete als +# `Rippy.exe.neu` daneben, mit der Meldung „wird beim naechsten Start +# uebernommen". **Diese Zusage hat niemand eingeloest**: `.neu` kam im ganzen +# Projekt genau einmal vor, naemlich an der Stelle, die es schrieb. + + +def test_eine_liegengebliebene_fassung_wird_uebernommen(tmp_path): + from rippy import windows_app + + (tmp_path / "Rippy.exe").write_bytes(b"alt") + (tmp_path / "Rippy.exe.neu").write_bytes(b"neu") + + assert windows_app.ausstehende_fassung_uebernehmen(str(tmp_path)) + assert (tmp_path / "Rippy.exe").read_bytes() == b"neu" + assert not (tmp_path / "Rippy.exe.neu").exists() + + +def test_ohne_liegengebliebene_fassung_passiert_nichts(tmp_path): + from rippy import windows_app + + (tmp_path / "Rippy.exe").write_bytes(b"alt") + assert windows_app.ausstehende_fassung_uebernehmen(str(tmp_path)) == "" + assert (tmp_path / "Rippy.exe").read_bytes() == b"alt" + + +def test_ein_fehlschlag_beim_tausch_wirft_nicht(tmp_path): + """Rippy laeuft dann mit der alten Fassung weiter — besser als gar nicht.""" + from rippy import windows_app + + (tmp_path / "Rippy.exe").write_bytes(b"alt") + (tmp_path / "Rippy.exe.neu").write_bytes(b"neu") + + def gesperrt(a, b): + raise OSError(32, "in Benutzung") + + assert windows_app.ausstehende_fassung_uebernehmen(str(tmp_path), gesperrt) == "" + assert (tmp_path / "Rippy.exe").read_bytes() == b"alt" diff --git a/src/rippy/windows_app.py b/src/rippy/windows_app.py index 1d63af7..5e09564 100644 --- a/src/rippy/windows_app.py +++ b/src/rippy/windows_app.py @@ -127,17 +127,44 @@ def installieren(ziel: str = None, autostart: bool = True, quelle = _eigene_datei() programm = os.path.join(ziel, "Rippy.exe") - # Sich selbst kopieren. Läuft gerade eine ältere Fassung, ist die Datei - # gesperrt — dann daneben legen und beim nächsten Start tauschen. + # ⚠️ ERST den laufenden Rippy beenden (Befund 29.08.2026). + # + # Commander: „Was passiert wenn man die Setup.exe einfach drüber + # installiert? Geht das, ist es dann die aktuellste version?" + # + # Bis hierher: NEIN, nicht zuverlässig. Rippy startet mit Windows, läuft + # also fast immer — dann ist `Rippy.exe` gesperrt, `shutil.copy2` warf + # `PermissionError`, und die neue Fassung landete als `Rippy.exe.neu` + # daneben. Dazu die Meldung „wird beim nächsten Start übernommen". + # + # **Diese Zusage hat niemand eingelöst.** `Rippy.exe.neu` kam im ganzen + # Projekt genau einmal vor: hier. Wer drüberinstallierte, behielt still + # die alte Fassung — und das Setup meldete Erfolg. + # + # Jetzt wird der laufende Rippy vorher beendet. `dienst_beenden` gibt es + # seit der Deinstallation; es wartet auch die zwei Sekunden ab, die + # Windows braucht, um die Dateihandles freizugeben. + if os.path.isfile(programm) and \ + os.path.abspath(quelle).lower() != os.path.abspath(programm).lower(): + if dienst_beenden(ziel): + melden(" Laufender Rippy beendet — die Dateien sind jetzt frei.") + # Und aufräumen, was eine ÄLTERE Setup-Fassung liegen ließ. + ausstehende_fassung_uebernehmen(ziel) + + # Sich selbst kopieren. if os.path.abspath(quelle).lower() != os.path.abspath(programm).lower(): try: shutil.copy2(quelle, programm) - except PermissionError: - ersatz = programm + ".neu" - shutil.copy2(quelle, ersatz) - melden("Rippy läuft gerade — die neue Fassung wird beim nächsten " - "Start übernommen.") - programm = ersatz + except PermissionError as e: + # Ehrlich scheitern statt eine Zusage zu geben, die niemand + # einlöst. Hier lag bis zum 29.08.2026 der `.neu`-Rückfall — mit + # der Meldung „wird beim nächsten Start übernommen" und ohne + # jeden Code, der das getan hätte. + raise RuntimeError( + "Rippy.exe liess sich nicht ersetzen: %s\n" + "Rippy laeuft noch. Bitte im Infobereich rechts unten " + "beenden (Rechtsklick auf das Rippy-Symbol) und das Setup " + "erneut starten." % e) from e icon = _icon_pfad() if icon and os.path.isfile(icon): @@ -587,6 +614,46 @@ class Dienst: return False +def ausstehende_fassung_uebernehmen(ordner: str = None, ersetzen=None) -> str: + """Eine beim Update danebengelegte `Rippy.exe.neu` jetzt übernehmen. + + Gibt den übernommenen Pfad zurück, sonst "". + + ## Die uneingelöste Zusage (Befund 29.08.2026) + + Beim Drüberinstallieren schrieb `installieren()` bei gesperrter Datei eine + `Rippy.exe.neu` daneben und meldete: „wird beim nächsten Start + übernommen." **Diese Zeile war das einzige Vorkommen von `.neu` im ganzen + Projekt** — es gab niemanden, der sie übernommen hätte. Wer über eine + laufende Installation installierte, behielt still die alte Fassung, und + das Setup sagte „fertig". + + ## Warum das hier läuft und NICHT beim Dienststart + + Der naheliegende Ort wäre der Start von Rippy selbst. Er funktioniert + nicht: Windows sperrt eine laufende `.exe`, und `Rippy.exe` ist genau die + Datei, die ersetzt werden müsste. `os.replace` scheitert dort immer. + + Das Setup ist ein ANDERES Programm (`RippySetup.exe`) und hat den + laufenden Rippy vorher beendet — hier ist die Datei frei. Aufgeräumt wird + damit auch, was eine ältere Setup-Fassung liegen gelassen hat. + """ + ordner = ordner or os.path.dirname(_eigene_datei()) + ziel = os.path.join(ordner, "Rippy.exe") + neu = ziel + ".neu" + if not os.path.isfile(neu): + return "" + try: + (ersetzen or os.replace)(neu, ziel) + except OSError as e: + # Nicht werfen: Rippy laeuft mit der alten Fassung weiter, das ist + # besser als gar nicht zu starten. Gesagt wird es trotzdem. + melden(" Neue Fassung konnte nicht uebernommen werden (%s)." % e) + return "" + melden(" Neue Fassung uebernommen: %s" % ziel) + return ziel + + def _reste_wegraeumen() -> None: """Zurueckgelassene Auspack-Ordner entfernen. Darf nie stoeren.""" try: