fix(windows): Drueberinstallieren, Linux-Reste im Windows-Betrieb, Ordner-Waehler
Ampel / ampel (push) Successful in 1m27s

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 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-08-29 14:28:01 +02:00
co-authored by Claude Opus 5
parent 21b1757aa4
commit b2acddbdfa
9 changed files with 440 additions and 39 deletions
+5 -3
View File
@@ -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])
+51 -13
View File
@@ -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)
+14 -7
View File
@@ -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
+162
View File
@@ -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<string | null>(null)
const [ordner, setOrdner] = useState<Eintrag[]>([])
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 (
<div className="space-y-2">
<div className="flex items-end gap-2">
<div className="flex-1">
<Input
label={label}
value={value}
placeholder={placeholder}
onChange={(e) => onChange(e.target.value)}
/>
</div>
<Button variant="secondary" onClick={() => setOffen(true)}>
Durchsuchen
</Button>
</div>
<Modal
isOpen={offen}
onClose={() => setOffen(false)}
title={label}
maxWidth="lg"
>
<div className="space-y-3">
<div className="flex items-center gap-2 px-3 py-2 rounded-xl border border-slate-200 dark:border-slate-800 bg-slate-100/50 dark:bg-slate-900/50">
<button
onClick={() => eltern !== null && laden(eltern)}
disabled={eltern === null}
className="p-1 rounded-lg disabled:opacity-30 hover:bg-slate-200 dark:hover:bg-slate-800 text-slate-500 dark:text-slate-400"
title="Eine Ebene höher"
>
<ArrowUp size={16} />
</button>
<span className="text-xs font-mono truncate flex-1 text-slate-600 dark:text-slate-300">
{pfad || 'Laufwerke'}
</span>
</div>
<div className="max-h-72 overflow-y-auto rounded-xl border border-slate-200 dark:border-slate-800 p-1">
{laedt && (
<p className="px-3 py-2 text-xs text-slate-400">wird gelesen </p>
)}
{!laedt && fehler && (
<p className="px-3 py-2 text-xs text-amber-600 dark:text-amber-400">{fehler}</p>
)}
{!laedt && !fehler && ordner.length === 0 && (
<p className="px-3 py-2 text-xs text-slate-400">Keine Unterordner.</p>
)}
{ordner.map((o) => (
<button
key={o.path}
onClick={() => laden(o.path)}
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-left rounded-lg transition-colors text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800"
>
<Folder size={14} className="text-slate-400" />
{o.name}
</button>
))}
</div>
<div className="flex items-center justify-between gap-2">
<p className="text-xs text-slate-500 dark:text-slate-400 flex items-center gap-1.5">
<FolderOpen size={13} />
Ein Netzwerkpfad lässt sich auch direkt ins Feld schreiben.
</p>
<div className="flex gap-2">
<Button variant="secondary" onClick={() => setOffen(false)}>
Abbrechen
</Button>
<Button
variant="amber"
disabled={!pfad}
onClick={() => {
onChange(pfad)
setOffen(false)
}}
>
Diesen Ordner nutzen
</Button>
</div>
</div>
</div>
</Modal>
</div>
)
}
+5 -4
View File
@@ -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.
*/
<Input
<OrdnerWaehler
label="Arbeitsverzeichnis für Roh-Rips (Standard)"
value={settings.workDir}
placeholder={betrieb.arbeits_vorgabe}
onChange={(e) => handleChange('workDir', e.target.value)}
onChange={(pfad) => handleChange('workDir', pfad)}
/>
) : (
<Select
@@ -1097,11 +1098,11 @@ export default function SettingsPage() {
der man waehlen koennte — es gibt Laufwerke, Ordner und
UNC-Pfade. Eine Auswahl mit einem Eintrag waere eine
Bedienung, die nichts bedient. */
<Input
<OrdnerWaehler
label="Ordner"
value={settings.outputDir}
placeholder={betrieb.ablage_vorgabe}
onChange={(e) => handleChange('outputDir', e.target.value)}
onChange={(pfad) => handleChange('outputDir', pfad)}
/>
) : (
<Select
+53 -4
View File
@@ -8,13 +8,38 @@ Remote-GPU-Worker meldet sich hier genauso wie der eingebaute CPU-Worker.
import os
import platform
import re
import shutil
import subprocess
from rippy.platform.winlauf import OHNE_FENSTER
from rippy.tools import katalog as werkzeuge
def _extern(werte=None, container=None) -> 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:
+30
View File
@@ -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"
+45
View File
@@ -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"
+75 -8
View File
@@ -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: