diff --git a/docker/ui/src/components/FirstRunWizard.tsx b/docker/ui/src/components/FirstRunWizard.tsx index 2f59cf5..1dbad08 100644 --- a/docker/ui/src/components/FirstRunWizard.tsx +++ b/docker/ui/src/components/FirstRunWizard.tsx @@ -1,6 +1,7 @@ import { useState, useEffect } from 'react' import { Disc, Cpu, Globe, Tv, CheckCircle, ArrowRight, HardDrive, AlertTriangle, KeyRound } from 'lucide-react' import { api } from '../lib/api' +import { useBetrieb } from '../lib/useBetrieb' import { MEDIA_SERVER_OPTIONEN } from '../lib/mediaServer' import { PRESET_KEINE, schwacheEncoderCpu } from '../lib/encoder' import { Button } from './ui/Button' @@ -74,6 +75,7 @@ export default function FirstRunWizard({ onDone }: { onDone: () => void }) { const [makemkvKey, setMakemkvKey] = useState('') const [mediaServer, setMediaServer] = useState('jellyfin') const [transcodeEnabled, setTranscodeEnabled] = useState(true) + const betrieb = useBetrieb() const [workers, setWorkers] = useState([]) const [geraete, setGeraete] = useState([]) const [plaetze, setPlaetze] = useState([]) @@ -197,7 +199,11 @@ export default function FirstRunWizard({ onDone }: { onDone: () => void }) { schlechtText="Kein optisches Laufwerk gefunden. Rippy kann dann nur komprimieren, nicht rippen. Prüfe, ob das Laufwerk angeschlossen und (bei einer VM) durchgereicht ist." /> 0} gutText={encoderWorker.map(w => { @@ -207,7 +213,9 @@ export default function FirstRunWizard({ onDone }: { onDone: () => void }) { .filter(Boolean).join(', ') return zusatz ? `${w.name} (${zusatz})` : w.name }).join(' · ')} - schlechtText="Noch kein Worker gemeldet. Beim ersten Start dauert das bis zu einer Minute — diese Anzeige aktualisiert sich selbst. Bleibt es dabei, läuft der Worker-Container nicht." + schlechtText={betrieb.kann.externe_worker + ? 'Noch kein Worker gemeldet. Beim ersten Start dauert das bis zu einer Minute — diese Anzeige aktualisiert sich selbst. Bleibt es dabei, läuft der Worker-Container nicht.' + : 'Rippy meldet noch keine Encoder. Beim ersten Start dauert das bis zu einer Minute — diese Anzeige aktualisiert sich selbst.'} /> void }) { Rippy hat deine Rechenleistung gemessen und passt die Wahl an.

- Keiner deiner Worker kann AVX2 — die Vektorbefehle, von denen H.265 + {betrieb.kann.externe_worker ? 'Keiner deiner Worker' : 'Dieser Rechner'} kann kein AVX2 — die Vektorbefehle, von denen H.265 lebt. 4K in H.265 würde hier ein bis zwei Tage pro Film {' '}dauern. Deshalb wird eingestellt:

diff --git a/src/rippy/config.py b/src/rippy/config.py index 3a737b8..9ed5a6f 100644 --- a/src/rippy/config.py +++ b/src/rippy/config.py @@ -223,13 +223,47 @@ def datenbank_url(werte: dict, standard_pfad: str = None) -> str: def standard_datenbankpfad() -> str: - """Wo die SQLite-Datei liegt, wenn niemand etwas anderes sagt. + r"""Wo die SQLite-Datei liegt, wenn niemand etwas anderes sagt. - Je Plattform dort, wo Dienste ihre Daten ablegen — nicht neben dem - Programm. Ein Programmordner ist unter Windows für einen Dienst nicht - zuverlässig beschreibbar, und unter Linux widerspräche es dem FHS. + ## Warum unter Windows NICHT mehr %ProgramData% (28.08.2026) + + Hier stand `%ProgramData%\Rippy\rippy.db`, mit der Begründung: „Ein + Programmordner ist unter Windows für einen DIENST nicht zuverlässig + beschreibbar." Das stimmte — für den Windows-Dienst, den es nie gab. + Rippy läuft als der angemeldete Benutzer (Autostart unter `HKCU`, siehe + `windows_app.py`), und der Grund ist damit hinfällig. + + Geblieben war der Schaden. Der Commander meldete: **„Es fehlt der 1st run + wizzard wenn man es installiert."** Nachgemessen: + + Installation %LOCALAPPDATA%\Rippy (wird deinstalliert) + Datenbank %ProgramData%\Rippy (bleibt liegen) + + Eine „frische" Installation erbte also die alte Datenbank samt + `setup.done = true`, und der Ersteinrichtungs-Assistent erschien nie + wieder. Ein Deinstallieren, das den Zustand stehen lässt, ist kein + Deinstallieren. + + Jetzt liegt alles, was Rippy gehört, in EINEM Ordner: Programm, UI, + Werkzeuge, Protokoll, WebView2-Zwischenspeicher — und die Datenbank. + + Unter Linux bleibt `/var/lib/rippy`: Dort läuft Rippy wirklich als + Systemdienst, und der FHS sagt genau das. """ if os.name == "nt": - basis = os.environ.get("PROGRAMDATA") or os.path.expanduser("~") + basis = os.environ.get("LOCALAPPDATA") or os.path.expanduser("~") return os.path.join(basis, "Rippy", "rippy.db") return "/var/lib/rippy/rippy.db" + + +def alter_datenbankpfad() -> str: + r"""Der Ort VOR dem 28.08.2026 — nur noch zum Umziehen und Aufräumen. + + Wer Rippy schon installiert hatte, hat seine Daten dort. Sie kommentarlos + liegen zu lassen wäre ein stiller Verlust; sie zu löschen wäre schlimmer. + `windows_app.datenbank_umziehen()` holt sie ab. + """ + if os.name != "nt": + return "" + basis = os.environ.get("PROGRAMDATA") or "" + return os.path.join(basis, "Rippy", "rippy.db") if basis else "" diff --git a/src/rippy/test_windows_app.py b/src/rippy/test_windows_app.py index c98057d..1f3871e 100644 --- a/src/rippy/test_windows_app.py +++ b/src/rippy/test_windows_app.py @@ -233,6 +233,80 @@ def test_die_testsuite_ruehrt_den_ECHTEN_eintrag_nicht_an(tmp_path, monkeypatch) ) +# ── Die Datenbank am richtigen Ort ────────────────────────────────────── +def test_datenbank_liegt_im_installationsordner(): + """DER Befund vom 28.08.2026: „Es fehlt der 1st run wizzard wenn man es + installiert." + + Die Datenbank lag unter %ProgramData%\\Rippy, die Installation unter + %LOCALAPPDATA%\\Rippy. Beim Deinstallieren blieb sie liegen, und die + naechste Installation erbte `setup.done = true` — der + Ersteinrichtungs-Assistent erschien nie wieder. + + Der Grund fuer den alten Ort war ein Windows-DIENST, den es nie gab. + """ + from rippy import config + + pfad = config.standard_datenbankpfad() + if os.name != "nt": + assert pfad == "/var/lib/rippy/rippy.db" + return + assert "ProgramData" not in pfad, ( + "Die Datenbank liegt wieder ausserhalb des Installationsordners — " + "dann ueberlebt sie jede Deinstallation." + ) + assert pfad.lower().startswith( + (os.environ.get("LOCALAPPDATA") or "").lower()) + + +@nur_windows +def test_alte_datenbank_wird_uebernommen(tmp_path, monkeypatch): + """Wer Rippy schon benutzt hat, soll Jobs und Einstellungen behalten. + Die Datei wird verschoben, nicht ignoriert und nicht geloescht.""" + from rippy import config + + alt = tmp_path / "alt" / "Rippy" / "rippy.db" + alt.parent.mkdir(parents=True) + alt.write_bytes(b"alte-daten") + neu = tmp_path / "neu" / "Rippy" / "rippy.db" + + monkeypatch.setattr(config, "alter_datenbankpfad", lambda: str(alt)) + monkeypatch.setattr(config, "standard_datenbankpfad", lambda: str(neu)) + + assert windows_app.datenbank_umziehen() == str(neu) + assert neu.read_bytes() == b"alte-daten" + assert not alt.exists() + + +@nur_windows +def test_vorhandene_datenbank_wird_NICHT_ueberschrieben(tmp_path, monkeypatch): + """Die BENUTZTE gewinnt, nicht die aeltere.""" + from rippy import config + + alt = tmp_path / "alt" / "rippy.db" + alt.parent.mkdir(parents=True) + alt.write_bytes(b"alt") + neu = tmp_path / "neu" / "rippy.db" + neu.parent.mkdir(parents=True) + neu.write_bytes(b"in-benutzung") + + monkeypatch.setattr(config, "alter_datenbankpfad", lambda: str(alt)) + monkeypatch.setattr(config, "standard_datenbankpfad", lambda: str(neu)) + + assert windows_app.datenbank_umziehen() == "" + assert neu.read_bytes() == b"in-benutzung" + + +def test_alte_orte_fassen_nur_rippy_an(monkeypatch): + """Ein `%ProgramData%` ohne unseren Namen darin waere ein fremder Ordner.""" + from rippy import config + + monkeypatch.setattr(config, "alter_datenbankpfad", + lambda: "C:" + BACKSLASH + "ProgramData" + BACKSLASH + + "Fremd" + BACKSLASH + "rippy.db") + assert windows_app.alte_orte() == [] + + # ── Rueckmeldung ohne Konsole ─────────────────────────────────────────── def test_ohne_konsole_kommt_ein_fenster(monkeypatch): """Seit die EXE ohne Konsole gebaut wird (Wunsch des Commanders am diff --git a/src/rippy/windows_app.py b/src/rippy/windows_app.py index c68f0c2..c699bda 100644 --- a/src/rippy/windows_app.py +++ b/src/rippy/windows_app.py @@ -147,6 +147,7 @@ def installieren(ziel: str = None, autostart: bool = True, pass ui_auspacken(ziel) + datenbank_umziehen() # Mitgelieferte Werkzeuge (HandBrakeCLI) neben das Programm legen. Dort # sucht katalog.kandidaten() VOR allen Systempfaden. @@ -183,6 +184,55 @@ def installieren(ziel: str = None, autostart: bool = True, return ziel +def alte_orte() -> list: + """Ordner ausserhalb des Installationsordners, die Rippy einmal angelegt hat. + + Reine Aufzaehlung, damit `deinstallieren()` pruefbar bleibt und niemand + versehentlich einen fremden Ordner in die Liste schreibt. + """ + from rippy import config + + alt = config.alter_datenbankpfad() + if not alt: + return [] + ordner = os.path.dirname(alt) + # Nur wenn er wirklich Rippy gehoert. Ein `%ProgramData%` ohne unseren + # Namen darin waere ein fremder Ordner, und den fasst niemand an. + if os.path.basename(ordner).lower() != "rippy" or not os.path.isdir(ordner): + return [] + return [ordner] + + +def datenbank_umziehen() -> str: + r"""Holt eine Datenbank vom alten Ort ab. Gibt den Zielpfad zurück, sonst "". + + Bis zum 28.08.2026 lag sie unter `%ProgramData%\Rippy\rippy.db` — also + AUSSERHALB des Installationsordners. Die Deinstallation räumte sie damit + nicht weg, und eine „frische" Installation erbte den alten Zustand samt + `setup.done = true`. Genau deshalb fehlte dem Commander der + Ersteinrichtungs-Assistent. + + Wer Rippy schon benutzt hat, soll seine Jobs und Einstellungen behalten: + Die Datei wird verschoben, nicht ignoriert und nicht gelöscht. Gibt es am + neuen Ort schon eine, bleibt sie unangetastet — die neuere gewinnt nicht + automatisch, aber die BENUTZTE. + """ + from rippy import config + + alt = config.alter_datenbankpfad() + neu = config.standard_datenbankpfad() + if not alt or not os.path.isfile(alt) or os.path.isfile(neu): + return "" + try: + os.makedirs(os.path.dirname(neu), exist_ok=True) + shutil.move(alt, neu) + except OSError as e: + melden(" Alte Datenbank konnte nicht umgezogen werden (%s)." % e) + return "" + melden(" Datenbank uebernommen: %s -> %s" % (alt, neu)) + return neu + + def ui_auspacken(ziel: str) -> str: r"""Legt die Weboberfläche NEBEN das Programm. Gibt den Ordner zurück. @@ -282,6 +332,19 @@ def deinstallieren(still: bool = False) -> None: fenster_modul.helfer_beenden(os.path.join(ordner, "fenster")) + # Der ALTE Datenbank-Ort (bis 28.08.2026 %ProgramData%\Rippy) liegt + # ausserhalb des Installationsordners. Blieb er stehen, erbte die naechste + # Installation den alten Zustand — genau daran fehlte dem Commander der + # Ersteinrichtungs-Assistent. Ein Deinstallieren, das Zustand stehen + # laesst, ist kein Deinstallieren. + for rest in alte_orte(): + try: + shutil.rmtree(rest) + if not still: + melden(" Alter Datenordner entfernt: %s" % rest) + except OSError: + pass + laeuft_von_hier = os.path.abspath(_eigene_datei()).lower().startswith( os.path.abspath(ordner).lower())