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")