fix(windows): Cover, Arbeitsverzeichnis und die zurueckgelassenen _MEI-Ordner

Drei Befunde des Commanders vom 28.08.2026, alle drei gemessen.

## 1. „Was ist mit dem Cover auf Windows Rippy?"

Es gab keins, weil es keinen Treffer gab. `_scan_video` verlangte woertliche
Gleichheit:

    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. Jetzt zaehlt die SPEZIFITAET der Anfrage: Wer
auf den vollen Disc-Titel hoechstens drei Treffer bekommt, hat gefragt wie
jemand, der weiss was er sucht. Ergebnis an derselben Disc:

    Evangelion: 2.0 You Can (Not) Advance / 2009 / 80 % / Poster + dt. Text

## 2. „Warum heisst das hier noch container platte? … Waere es moeglich das
##     Arbeitsverzeichnis zu aendern? momentan geht das nicht."

Beide Haelften gehen auf EINE Zeile zurueck: `MEDIA_ROOT = "/app/media"` war
zugleich Vorgabe UND Pfadgrenze. Auf Windows gibt es den Ordner nicht:

* `/storage-targets` fing den OSError und gab still [] zurueck — die Auswahl
  hatte genau einen Eintrag. Das ist „momentan geht das nicht".
* Dessen Text war fest verdrahtet „(Container-Platte)".
* `/browse` antwortete auf jeden Pfad mit 422.

Die Grenze faellt nicht weg, sie wird betriebsabhaengig: Container und
Kopflos-Betrieb bedienen ein Netz, die native App den Menschen davor.
Gemessen auf seinem PC: 7 Laufwerke zur Auswahl, X/Y/Z mit je 2,2 TB frei.

## 3. „Failed to remove temporary directory: …_MEI0000b0882"

GEMESSEN: 20 zurueckgelassene _MEI-Ordner mit 1,1 GB. Ursache: `Popen` ohne
`env=` reicht PyInstallers Auspack-Zeiger an die Kinder weiter (--dienst und
--oeffnen). Beide laufen dann im Ordner des Elternprozesses, der sich zuerst
beendet und ihn loeschen will. Waere das TEILWEISE geglueckt, haetten Dienst
und Fenster mitten im Betrieb ihre Dateien verloren.

Nebenbefund: test_setup_fenster scheiterte in jeder Umgebung ohne pywebview.

794 Tests gruen, ruff sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-08-28 16:43:41 +02:00
co-authored by Claude Opus 5
parent 11001a443e
commit 35370fd552
15 changed files with 2728 additions and 1723 deletions
+164 -23
View File
@@ -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")
+58
View File
@@ -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.
#
+6 -1
View File
@@ -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"]:
+82
View File
@@ -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)
+156
View File
@@ -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
File diff suppressed because it is too large Load Diff
+141 -133
View File
@@ -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<Betrieb>(VORGABE)
export function BetriebProvider({ children }: { children: ReactNode }) {
const [betrieb, setBetrieb] = useState<Betrieb>(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 <BetriebContext.Provider value={betrieb}>{children}</BetriebContext.Provider>
}
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<Betrieb>(VORGABE)
export function BetriebProvider({ children }: { children: ReactNode }) {
const [betrieb, setBetrieb] = useState<Betrieb>(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 <BetriebContext.Provider value={betrieb}>{children}</BetriebContext.Provider>
}
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
}
+49 -3
View File
@@ -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.
*/
<Input
label="Arbeitsverzeichnis für Roh-Rips (Standard)"
value={settings.workDir}
placeholder={betrieb.arbeits_vorgabe}
onChange={(e) => handleChange('workDir', e.target.value)}
/>
) : (
<Select
label="Arbeitsverzeichnis für Roh-Rips (Standard)"
value={settings.workDir}
onChange={(e) => handleChange('workDir', e.target.value)}
>
<option value="">
{betrieb.kann.container_pfade
? 'Container-Platte (Standard) — klein, nur für DVD/Blu-ray'
: `Standard (${betrieb.ablage_vorgabe || 'neben der Ablage'})`}
Container-Platte (Standard) klein, nur für DVD/Blu-ray
</option>
{ziele.map(z => (
<option key={z.path} value={z.path}>
@@ -957,6 +982,27 @@ export default function SettingsPage() {
</option>
))}
</Select>
)}
{!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. */
<div className="flex flex-wrap gap-1.5">
{ziele.map(z => (
<button
key={z.path}
onClick={() => handleChange('workDir', z.path)}
className={`px-2.5 py-1 text-xs rounded-lg border transition-colors ${
settings.workDir === z.path
? 'border-amber-500 bg-amber-500/10 text-amber-700 dark:text-amber-300'
: 'border-slate-200 dark:border-slate-700 text-slate-500 dark:text-slate-400 hover:border-slate-400'
}`}
>
{z.name}{z.free_gb != null ? `${z.free_gb} GB frei` : ''}
</button>
))}
</div>
)}
<p className="text-xs text-slate-500 dark:text-slate-400">
<strong className="text-slate-700 dark:text-slate-300">Der Standard</strong> beim
Rippen starten" kannst du für jede Disc etwas anderes wählen. <strong className="text-slate-700 dark:text-slate-300">
+99 -2
View File
@@ -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", "")
+224 -123
View File
@@ -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()
+93
View File
@@ -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?
+600 -584
View File
File diff suppressed because it is too large Load Diff
+132
View File
@@ -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
+193 -192
View File
@@ -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) == ""
+26 -4
View File
@@ -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()