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
+56 -9
View File
@@ -5,6 +5,7 @@ import { Modal } from './ui/Modal'
import { Button } from './ui/Button'
import { Input, Select } from './ui/Input'
import { automatikWarnung, externWarnung, freigabenAusMapping, sprachName } from '../lib/encoder'
import { useBetrieb } from '../lib/useBetrieb'
interface TargetConfig {
id: string
@@ -157,12 +158,16 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
return () => clearInterval(interval)
}, [scanStatus, deviceId])
// Welcher Betrieb? Ohne diese Auskunft stand hier „Container-Platte" —
// auf einem Windows-PC eine Ortsangabe fuer einen Ort, den es nicht gibt.
const betrieb = useBetrieb()
const [targets, setTargets] = useState<TargetConfig[]>([
{ id: '1', name: 'Filme', path: '/app/media/movies', type: 'movies', isActive: true },
{ id: '2', name: 'Serien', path: '/app/media/series', type: 'series', isActive: true },
{ id: '3', name: 'Musik', path: '/app/media/music', type: 'music', isActive: true },
])
const [browsePath, setBrowsePath] = useState<string>('/app/media')
const [browsePath, setBrowsePath] = useState<string>('')
const [browseParent, setBrowseParent] = useState<string | null>(null)
const [browseDirs, setBrowseDirs] = useState<BrowseDir[]>([])
const [customPath, setCustomPath] = useState('')
@@ -181,6 +186,10 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
const [arbeitsZiele, setArbeitsZiele] = useState<StorageZiel[]>([])
const [arbeitsDir, setArbeitsDir] = useState('')
const [standardArbeitsDir, setStandardArbeitsDir] = useState('')
// Ein frei erblätterter Arbeitsordner. Er braucht einen eigenen Platz in
// der Liste, sonst zeigt das Auswahlfeld einen Wert an, den es nicht kennt
// — und stünde leer da, obwohl etwas gewählt ist.
const [eigenesArbeitsZiel, setEigenesArbeitsZiel] = useState('')
useEffect(() => {
if (!isOpen) return
@@ -194,6 +203,7 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
setGewaehlt(new Set())
setEncoderNode('')
setArbeitsDir('')
setEigenesArbeitsZiel('')
setBrowserOffen(false)
setDiscSprachen(null)
setAudioWahl(new Set())
@@ -213,7 +223,7 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
.split(',').map((t: string) => t.trim().toLowerCase()).filter(Boolean)
setStandardAudio(codes(s.audioSprachen))
setStandardUntertitel(codes(s.untertitelSprachen))
const basis = s.outputDir || '/app/media'
const basis = s.outputDir || betrieb.ablage_vorgabe || '/app/media'
setTargets([
{ id: '1', name: 'Filme', path: `${basis}/${s.movieDir || 'movies'}`, type: 'movies', isActive: true },
{ id: '2', name: 'Serien', path: `${basis}/${s.seriesDir || 'series'}`, type: 'series', isActive: true },
@@ -240,6 +250,17 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
// Das effektive Arbeitsverzeichnis: Wahl für diesen Rip → Einstellung →
// leer (= Container-Platte /app/temp, für externe Worker unerreichbar).
const arbeitsPfadEffektiv = arbeitsDir || standardArbeitsDir
/*
* Was WIRKLICH benutzt wird, wenn niemand etwas wählt.
*
* Commander am 28.08.2026: „Warum heißt das hier noch container platte? Er
* holt sich das Arbeitsverzeichnis ja von der Installation."
*
* Genau so ist es — nur stand hier fest verdrahtet „(Container-Platte)",
* sobald die Einstellung leer war. Auf seinem PC war das die Beschreibung
* eines Ortes, den es nicht gibt. Jetzt sagt der Betrieb, wohin es geht.
*/
const arbeitsVorgabe = standardArbeitsDir || betrieb.arbeits_vorgabe
// Warnung VOR dem Start, wenn der gewählte externe Encoder die Pfade nicht
// erreicht (Punkt 6 des Savepoints v3.16). Am 26.07.2026 fiel genau das erst
// NACH dem Rip auf, weil nur der Worker selbst prüfte.
@@ -378,8 +399,7 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
onChange={e => setArbeitsDir(e.target.value)}
>
<option value="">
Standard aus den Einstellungen
{standardArbeitsDir ? ` (${standardArbeitsDir})` : ' (Container-Platte)'}
Standard{arbeitsVorgabe ? `${arbeitsVorgabe}` : ''}
</option>
{arbeitsZiele.map(z => (
<option key={z.path} value={z.path}>
@@ -387,10 +407,13 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
{z.free_gb != null ? `${z.free_gb} GB frei` : ''}
</option>
))}
{eigenesArbeitsZiel && (
<option value={eigenesArbeitsZiel}>{eigenesArbeitsZiel}</option>
)}
</Select>
<p className="text-xs mt-1.5 text-slate-500 dark:text-slate-400 flex items-center gap-1.5">
<HardDrive size={13} />
Bei 4K-UHD bis zu 100 GB nimm eine Freigabe mit Platz, am besten dieselbe wie das Ziel oben.
Bei 4K-UHD bis zu 100 GB nimm ein Laufwerk mit Platz, am besten dasselbe wie das Ziel oben.
Dann muss Rippy am Ende nur umhängen statt zu kopieren.
</p>
</div>
@@ -588,12 +611,14 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
onClick={() => {
const auf = !browserOffen
setBrowserOffen(auf)
if (auf && browseDirs.length === 0) laden('/app/media')
if (auf && browseDirs.length === 0) laden('')
}}
className="flex items-center gap-1.5 text-xs text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200"
>
{browserOffen ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
Anderen Zielordner wählen (nur für diesen Rip)
{selectedType === 'music'
? 'Anderen Zielordner wählen (nur für diesen Rip)'
: 'Ordner durchsuchen — Ziel oder Arbeitsordner (nur für diesen Rip)'}
</button>
{browserOffen && (
@@ -607,14 +632,36 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
>
<ArrowUp size={16} />
</button>
<span className="text-xs font-mono truncate flex-1 text-slate-500 dark:text-slate-400">{browsePath}</span>
<span className="text-xs font-mono truncate flex-1 text-slate-500 dark:text-slate-400">
{browsePath || 'Laufwerke'}
</span>
<Button
variant={customPath === browsePath ? 'amber' : 'secondary'}
size="sm"
disabled={!browsePath}
onClick={() => setCustomPath(customPath === browsePath ? '' : browsePath)}
>
{customPath === browsePath ? '✓ gewählt' : 'Diesen Ordner nutzen'}
{customPath === browsePath ? '✓ Ziel' : 'Als Ziel'}
</Button>
{/* Der zweite Knopf ist der Grund, warum es diesen Browser für
das Arbeitsverzeichnis überhaupt braucht: Die Schnellwahl
darüber kennt nur Laufwerke und Unterordner der Ablage. Ein
beliebiger Ordner — etwa D:\Rippy-Arbeit — ging bisher gar
nicht („momentan geht das nicht", 28.08.2026). */}
{selectedType !== 'music' && (
<Button
variant={arbeitsDir === browsePath ? 'amber' : 'secondary'}
size="sm"
disabled={!browsePath}
onClick={() => {
if (arbeitsDir === browsePath) { setArbeitsDir(''); return }
setEigenesArbeitsZiel(browsePath)
setArbeitsDir(browsePath)
}}
>
{arbeitsDir === browsePath ? '✓ Arbeitsordner' : 'Als Arbeitsordner'}
</Button>
)}
</div>
<div className="max-h-36 overflow-y-auto p-1">
{browseDirs.map(d => (
+8
View File
@@ -45,6 +45,8 @@ export interface BetriebsFaehigkeiten {
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 {
@@ -54,6 +56,8 @@ export interface Betrieb {
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
@@ -71,8 +75,10 @@ const VORGABE: Betrieb = {
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,
}
@@ -100,8 +106,10 @@ export function BetriebProvider({ children }: { children: ReactNode }) {
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,
})
+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", "")
+101
View File
@@ -121,3 +121,104 @@ def test_ohne_windows_kein_meldungsfenster():
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?
+18 -2
View File
@@ -98,6 +98,12 @@ class Fortschritt:
"abschnitt": self.abschnitt}
#: pywebview.FOLDER_DIALOG. Steht hier doppelt, damit `ordner_waehlen`
#: auch ohne pywebview pruefbar bleibt (webview/util.py: OPEN=10,
#: FOLDER=20, SAVE=30).
FOLDER_DIALOG = 20
class Bruecke:
"""Was die Seite aufrufen darf. Bewusst klein und ohne Zustand im HTML.
@@ -159,11 +165,21 @@ class Bruecke:
def ordner_waehlen(self, vorschlag: str = "") -> str:
"""Ein echter Ordner-Dialog. Leer, wenn abgebrochen wurde."""
import webview
# Der Rueckfall ist NICHT fuer den Betrieb — dort ist pywebview immer
# da. Er ist fuer die Ampel: Ohne ihn scheiterten diese zwei Tests in
# jeder Umgebung ohne pywebview mit ModuleNotFoundError. Ein Test, der
# nur an einer Stelle greift, ist eine halbe Zusage (AGENTS.md) — und
# am 28.08.2026 hielt ich die beiden erst faelschlich fuer meinen
# eigenen Fehler.
try:
import webview
art = webview.FOLDER_DIALOG
except ImportError:
art = FOLDER_DIALOG
try:
gewaehlt = self._fenster.create_file_dialog(
webview.FOLDER_DIALOG, directory=vorschlag or "")
art, directory=vorschlag or "")
except Exception: # noqa: BLE001
return ""
if not gewaehlt:
+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
+3 -2
View File
@@ -140,9 +140,10 @@ def test_die_auskunft_ist_vollstaendig():
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"}
"ablage_vorgabe", "arbeits_vorgabe", "hilfe_befehl"}
assert set(a["kann"]) == {"externe_worker", "freigaben_einhaengen",
"container_pfade", "werkzeuge_verwalten"}
"container_pfade", "werkzeuge_verwalten",
"frei_blaettern"}
assert all(isinstance(v, bool) for v in a["kann"].values())
+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()