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:
co-authored by
Claude Opus 5
parent
11001a443e
commit
35370fd552
+164
-23
@@ -238,7 +238,7 @@ async def _auto_rip_wenn_aktiviert(pfad: str):
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
basis = einstellungen.get("outputDir") or MEDIA_ROOT
|
basis = einstellungen.get("outputDir") or medien_wurzel()
|
||||||
meta = disc.get("metadata") or {}
|
meta = disc.get("metadata") or {}
|
||||||
if disc.get("disc_type") == "CD":
|
if disc.get("disc_type") == "CD":
|
||||||
unterordner = einstellungen.get("musicDir") or "music"
|
unterordner = einstellungen.get("musicDir") or "music"
|
||||||
@@ -247,7 +247,7 @@ async def _auto_rip_wenn_aktiviert(pfad: str):
|
|||||||
else:
|
else:
|
||||||
unterordner = einstellungen.get("movieDir") or "movies"
|
unterordner = einstellungen.get("movieDir") or "movies"
|
||||||
ziel = os.path.normpath(os.path.join(basis, unterordner))
|
ziel = os.path.normpath(os.path.join(basis, unterordner))
|
||||||
if not unter_wurzel(ziel, MEDIA_ROOT):
|
if not pfad_erlaubt(ziel):
|
||||||
ziel = None
|
ziel = None
|
||||||
|
|
||||||
job_id = str(uuid.uuid4())
|
job_id = str(uuid.uuid4())
|
||||||
@@ -978,18 +978,78 @@ class JobCreateRequest(BaseModel):
|
|||||||
work_dir: Optional[str] = None
|
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"
|
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]:
|
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:
|
if not target_dir:
|
||||||
return None
|
return None
|
||||||
normalisiert = os.path.normpath(target_dir)
|
normalisiert = os.path.normpath(target_dir)
|
||||||
if not unter_wurzel(normalisiert, MEDIA_ROOT):
|
if not pfad_erlaubt(normalisiert):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=422,
|
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
|
return normalisiert
|
||||||
|
|
||||||
@@ -1111,10 +1171,10 @@ def unter_wurzel(pfad: str, wurzel: str) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def _job_ausgabeordner(job: dict) -> str:
|
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 "")
|
ausgabe = os.path.normpath(job.get("output_path") or "")
|
||||||
if not unter_wurzel(ausgabe, MEDIA_ROOT):
|
if not pfad_erlaubt(ausgabe):
|
||||||
raise HTTPException(status_code=404, detail="Job hat keinen Ausgabeordner unter /app/media")
|
raise HTTPException(status_code=404, detail="Job hat keinen erlaubten Ausgabeordner")
|
||||||
return ausgabe
|
return ausgabe
|
||||||
|
|
||||||
|
|
||||||
@@ -1170,7 +1230,7 @@ async def download_job_file(job_id: str, dateiname: str):
|
|||||||
pfad = os.path.join(ausgabe, dateiname)
|
pfad = os.path.join(ausgabe, dateiname)
|
||||||
|
|
||||||
def pruefe():
|
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):
|
if not await asyncio.to_thread(pruefe):
|
||||||
raise HTTPException(status_code=404, detail="Datei nicht gefunden")
|
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")
|
@app.get("/storage-targets")
|
||||||
async def 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,
|
NFS/SMB-Shares, die auf der VM unter /srv/rippy/media eingehängt werden,
|
||||||
tauchen hier automatisch auf (rslave-Bind in docker-compose).
|
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():
|
def sammle():
|
||||||
|
wurzel = medien_wurzel()
|
||||||
ziele = []
|
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:
|
try:
|
||||||
eintraege = sorted(os.listdir(MEDIA_ROOT))
|
eintraege = sorted(os.listdir(wurzel))
|
||||||
except OSError:
|
except OSError:
|
||||||
return ziele
|
eintraege = []
|
||||||
for name in eintraege:
|
for name in eintraege:
|
||||||
pfad = os.path.join(MEDIA_ROOT, name)
|
pfad = os.path.join(wurzel, name)
|
||||||
try:
|
try:
|
||||||
ist_mount = os.path.ismount(pfad)
|
ist_mount = os.path.ismount(pfad)
|
||||||
except OSError:
|
except OSError:
|
||||||
@@ -1213,6 +1298,20 @@ async def storage_targets():
|
|||||||
"is_mount": ist_mount,
|
"is_mount": ist_mount,
|
||||||
"free_gb": frei_gb,
|
"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 ziele
|
||||||
|
|
||||||
return await asyncio.to_thread(sammle)
|
return await asyncio.to_thread(sammle)
|
||||||
@@ -1299,7 +1398,7 @@ async def retry_transcode(job_id: str):
|
|||||||
raw_dir = gefunden[0]
|
raw_dir = gefunden[0]
|
||||||
# Zielordner: der Worker schreibt das geplante Ziel beim Rip-Start nach
|
# Zielordner: der Worker schreibt das geplante Ziel beim Rip-Start nach
|
||||||
# output_path (sprechender Name statt UUID) — alter Fallback bleibt.
|
# 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}"
|
final_dir = job.get("output_path") or f"{basis}/{job_id}"
|
||||||
|
|
||||||
# An den (beim Rip gewählten) Encoder-Worker routen, sonst geteilte Queue
|
# 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")
|
@app.get("/browse")
|
||||||
async def browse(path: str = MEDIA_ROOT):
|
async def browse(path: str = ""):
|
||||||
"""Server-seitiger Ordner-Browser für die Ziel-Auswahl (nur unter /app/media)."""
|
"""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)
|
normalisiert = os.path.normpath(path)
|
||||||
if not unter_wurzel(normalisiert, MEDIA_ROOT):
|
if not pfad_erlaubt(normalisiert):
|
||||||
raise HTTPException(status_code=422, detail=f"Nur Pfade unter {MEDIA_ROOT}")
|
raise HTTPException(status_code=422,
|
||||||
|
detail=f"Nur Pfade unter {medien_wurzel()}")
|
||||||
|
|
||||||
def liste():
|
def liste():
|
||||||
try:
|
try:
|
||||||
@@ -1840,8 +1951,37 @@ async def browse(path: str = MEDIA_ROOT):
|
|||||||
if ergebnis is None:
|
if ergebnis is None:
|
||||||
raise HTTPException(status_code=404, detail="Ordner nicht lesbar")
|
raise HTTPException(status_code=404, detail="Ordner nicht lesbar")
|
||||||
ordner, dateien = ergebnis
|
ordner, dateien = ergebnis
|
||||||
eltern = os.path.dirname(normalisiert) if normalisiert != MEDIA_ROOT else None
|
return {"path": normalisiert, "parent": eltern_von(normalisiert),
|
||||||
return {"path": normalisiert, "parent": eltern, "dirs": ordner, "files": dateien}
|
"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):
|
class MkdirRequest(BaseModel):
|
||||||
@@ -1851,10 +1991,11 @@ class MkdirRequest(BaseModel):
|
|||||||
|
|
||||||
@app.post("/browse/mkdir", status_code=201)
|
@app.post("/browse/mkdir", status_code=201)
|
||||||
async def browse_mkdir(request: MkdirRequest):
|
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)
|
basis = os.path.normpath(request.path)
|
||||||
if not unter_wurzel(basis, MEDIA_ROOT):
|
if not pfad_erlaubt(basis):
|
||||||
raise HTTPException(status_code=422, detail=f"Nur Pfade unter {MEDIA_ROOT}")
|
raise HTTPException(status_code=422,
|
||||||
|
detail=f"Nur Pfade unter {medien_wurzel()}")
|
||||||
name = request.name.strip()
|
name = request.name.strip()
|
||||||
if not name or "/" in name or "\\" in name or name.startswith("."):
|
if not name or "/" in name or "\\" in name or name.startswith("."):
|
||||||
raise HTTPException(status_code=422, detail="Ungültiger Ordnername")
|
raise HTTPException(status_code=422, detail="Ungültiger Ordnername")
|
||||||
|
|||||||
@@ -25,6 +25,13 @@ from cache.keys import generate_prescan_key
|
|||||||
# test_treiberwahl.py.
|
# test_treiberwahl.py.
|
||||||
detection = _laufwerks_schicht.treiber()
|
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:
|
def normalize_disc_label(label: str) -> str:
|
||||||
"""Disc-Labels wie 'PULP_FICTION_DE' → 'Pulp Fiction De' (pure Funktion).
|
"""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.
|
# probieren — Disc-Titel sind selten API-freundlich formatiert.
|
||||||
kandidaten = titel_kandidaten(title) or [title]
|
kandidaten = titel_kandidaten(title) or [title]
|
||||||
movies = []
|
movies = []
|
||||||
|
movies_kandidat = "" # WELCHE Variante hat sie geliefert?
|
||||||
|
|
||||||
for kandidat in kandidaten:
|
for kandidat in kandidaten:
|
||||||
treffer = self.tmdb.search_movie(kandidat)
|
treffer = self.tmdb.search_movie(kandidat)
|
||||||
if treffer and not movies:
|
if treffer and not movies:
|
||||||
movies = treffer # bester Rohtreffer für den Vorschlags-Fallback
|
movies = treffer # bester Rohtreffer für den Vorschlags-Fallback
|
||||||
|
movies_kandidat = kandidat
|
||||||
for movie in treffer:
|
for movie in treffer:
|
||||||
if movie.get("title", "").lower() == kandidat.lower():
|
if movie.get("title", "").lower() == kandidat.lower():
|
||||||
confidence = 0.95
|
confidence = 0.95
|
||||||
@@ -498,6 +507,55 @@ class PreScan:
|
|||||||
if matched:
|
if matched:
|
||||||
break
|
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
|
# Fallback 1: Jikan/MyAnimeList (kostenlos, KEIN Key) — für Anime die
|
||||||
# präziseste Quelle; wählt per Titel-Ähnlichkeit, nicht Treffer #1.
|
# präziseste Quelle; wählt per Titel-Ähnlichkeit, nicht Treffer #1.
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -401,7 +401,12 @@ def test_betrieb_meldet_faehigkeiten_statt_nur_einen_namen():
|
|||||||
assert daten["modus"] in ("standalone", "verteilt")
|
assert daten["modus"] in ("standalone", "verteilt")
|
||||||
assert daten["plattform"] in ("windows", "linux", "macos")
|
assert daten["plattform"] in ("windows", "linux", "macos")
|
||||||
assert set(daten["kann"]) == {"externe_worker", "freigaben_einhaengen",
|
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())
|
assert all(isinstance(w, bool) for w in daten["kann"].values())
|
||||||
# Ein Hinweis auf docker compose darf NUR im Container erscheinen.
|
# Ein Hinweis auf docker compose darf NUR im Container erscheinen.
|
||||||
if not daten["im_container"]:
|
if not daten["im_container"]:
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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
@@ -1,133 +1,141 @@
|
|||||||
/*
|
/*
|
||||||
* Worauf läuft Rippy — und was kann dieser Betrieb überhaupt?
|
* Worauf läuft Rippy — und was kann dieser Betrieb überhaupt?
|
||||||
*
|
*
|
||||||
* ## Der Befund des Commanders (28.08.2026)
|
* ## Der Befund des Commanders (28.08.2026)
|
||||||
*
|
*
|
||||||
* Im Windows-Fenster stand auf der Server-Status-Kachel:
|
* Im Windows-Fenster stand auf der Server-Status-Kachel:
|
||||||
*
|
*
|
||||||
* Worker erreichbar: 0 von 1
|
* Worker erreichbar: 0 von 1
|
||||||
* Kein Worker antwortet — ohne ihn läuft kein Rip. Prüfen: docker compose ps
|
* Kein Worker antwortet — ohne ihn läuft kein Rip. Prüfen: docker compose ps
|
||||||
* Container-Platte: unbekannt
|
* Container-Platte: unbekannt
|
||||||
* Freigaben: keine eingehängt
|
* Freigaben: keine eingehängt
|
||||||
*
|
*
|
||||||
* Kein Satz davon ergibt auf einem Windows-PC einen Sinn. Es gibt keinen
|
* Kein Satz davon ergibt auf einem Windows-PC einen Sinn. Es gibt keinen
|
||||||
* Container, kein `docker compose`, keinen zweiten Worker — Rippy rippt dort
|
* Container, kein `docker compose`, keinen zweiten Worker — Rippy rippt dort
|
||||||
* selbst. Sein Urteil: „Du hast ja quasi nur rippy genommen und die docker
|
* selbst. Sein Urteil: „Du hast ja quasi nur rippy genommen und die docker
|
||||||
* installation für Windows gebaut."
|
* installation für Windows gebaut."
|
||||||
*
|
*
|
||||||
* ## Warum ein Provider und kein Abruf je Seite
|
* ## Warum ein Provider und kein Abruf je Seite
|
||||||
*
|
*
|
||||||
* Dieselbe Begründung wie beim Ereignis-Strom nebenan: Vier Seiten, die
|
* Dieselbe Begründung wie beim Ereignis-Strom nebenan: Vier Seiten, die
|
||||||
* dasselbe abfragen, sind vier Abrufe — und vier Gelegenheiten, dass eine
|
* dasselbe abfragen, sind vier Abrufe — und vier Gelegenheiten, dass eine
|
||||||
* davon einen anderen Stand hat als die anderen. Der Betrieb ändert sich zur
|
* davon einen anderen Stand hat als die anderen. Der Betrieb ändert sich zur
|
||||||
* Laufzeit nicht; er wird EINMAL geholt.
|
* Laufzeit nicht; er wird EINMAL geholt.
|
||||||
*
|
*
|
||||||
* ## Warum die Vorgabe „Docker" ist
|
* ## Warum die Vorgabe „Docker" ist
|
||||||
*
|
*
|
||||||
* Solange die Antwort noch unterwegs ist, muss irgendetwas gelten. Die
|
* Solange die Antwort noch unterwegs ist, muss irgendetwas gelten. Die
|
||||||
* Docker-Annahme ist hier die richtige Vorgabe — sie zeigt MEHR, und ein kurz
|
* 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
|
* zu viel angezeigter Bereich ist harmloser als ein Bedienelement, das für
|
||||||
* einen Augenblick verschwindet und wieder auftaucht.
|
* einen Augenblick verschwindet und wieder auftaucht.
|
||||||
*
|
*
|
||||||
* Ein FEHLGESCHLAGENER Abruf ist etwas anderes als „noch unterwegs":
|
* Ein FEHLGESCHLAGENER Abruf ist etwas anderes als „noch unterwegs":
|
||||||
* `geladen` bleibt dann false, und wer das wissen will, kann es abfragen. Ein
|
* `geladen` bleibt dann false, und wer das wissen will, kann es abfragen. Ein
|
||||||
* Verbindungsabriss ist keine Aussage über die Welt (siehe useEventStream).
|
* Verbindungsabriss ist keine Aussage über die Welt (siehe useEventStream).
|
||||||
*/
|
*/
|
||||||
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
|
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
|
||||||
import { api } from './api'
|
import { api } from './api'
|
||||||
|
|
||||||
export interface BetriebsFaehigkeiten {
|
export interface BetriebsFaehigkeiten {
|
||||||
/** Gibt es andere Maschinen, die Jobs übernehmen? */
|
/** Gibt es andere Maschinen, die Jobs übernehmen? */
|
||||||
externe_worker: boolean
|
externe_worker: boolean
|
||||||
/** Kann Rippy Netzwerk-Freigaben selbst einhängen? */
|
/** Kann Rippy Netzwerk-Freigaben selbst einhängen? */
|
||||||
freigaben_einhaengen: boolean
|
freigaben_einhaengen: boolean
|
||||||
/** Sind Pfade wie /app/media überhaupt gemeint? */
|
/** Sind Pfade wie /app/media überhaupt gemeint? */
|
||||||
container_pfade: boolean
|
container_pfade: boolean
|
||||||
/** Kann Rippy MakeMKV/HandBrake selbst beschaffen? */
|
/** Kann Rippy MakeMKV/HandBrake selbst beschaffen? */
|
||||||
werkzeuge_verwalten: boolean
|
werkzeuge_verwalten: boolean
|
||||||
}
|
/** Darf die Oberflaeche ausserhalb der Medien-Wurzel blaettern? */
|
||||||
|
frei_blaettern: boolean
|
||||||
export interface Betrieb {
|
}
|
||||||
modus: 'standalone' | 'verteilt'
|
|
||||||
plattform: 'windows' | 'linux' | 'macos'
|
export interface Betrieb {
|
||||||
im_container: boolean
|
modus: 'standalone' | 'verteilt'
|
||||||
kann: BetriebsFaehigkeiten
|
plattform: 'windows' | 'linux' | 'macos'
|
||||||
/** Womit ein Ablage-Feld vorbelegt wird — je Betrieb ein anderer Ort. */
|
im_container: boolean
|
||||||
ablage_vorgabe: string
|
kann: BetriebsFaehigkeiten
|
||||||
/** Der Befehl zum Nachsehen. LEER heißt: es gibt keinen, den der Nutzer
|
/** Womit ein Ablage-Feld vorbelegt wird — je Betrieb ein anderer Ort. */
|
||||||
* ausführen könnte — dann darf auch keiner dastehen. */
|
ablage_vorgabe: string
|
||||||
hilfe_befehl: string
|
/** Wohin die Rohdaten wandern, wenn niemand etwas anderes waehlt. */
|
||||||
/** Ist die Auskunft schon da? False heißt „noch unterwegs ODER nicht
|
arbeits_vorgabe: string
|
||||||
* erreichbar" — nicht „es gibt keinen Betrieb". */
|
/** Der Befehl zum Nachsehen. LEER heißt: es gibt keinen, den der Nutzer
|
||||||
geladen: boolean
|
* ausführen könnte — dann darf auch keiner dastehen. */
|
||||||
}
|
hilfe_befehl: string
|
||||||
|
/** Ist die Auskunft schon da? False heißt „noch unterwegs ODER nicht
|
||||||
const VORGABE: Betrieb = {
|
* erreichbar" — nicht „es gibt keinen Betrieb". */
|
||||||
modus: 'verteilt',
|
geladen: boolean
|
||||||
plattform: 'linux',
|
}
|
||||||
im_container: true,
|
|
||||||
kann: {
|
const VORGABE: Betrieb = {
|
||||||
externe_worker: true,
|
modus: 'verteilt',
|
||||||
freigaben_einhaengen: true,
|
plattform: 'linux',
|
||||||
container_pfade: true,
|
im_container: true,
|
||||||
werkzeuge_verwalten: false,
|
kann: {
|
||||||
},
|
externe_worker: true,
|
||||||
ablage_vorgabe: '/app/media',
|
freigaben_einhaengen: true,
|
||||||
hilfe_befehl: 'docker compose -p rippy ps',
|
container_pfade: true,
|
||||||
geladen: false,
|
werkzeuge_verwalten: false,
|
||||||
}
|
frei_blaettern: false,
|
||||||
|
},
|
||||||
const BetriebContext = createContext<Betrieb>(VORGABE)
|
ablage_vorgabe: '/app/media',
|
||||||
|
arbeits_vorgabe: '/app/temp',
|
||||||
export function BetriebProvider({ children }: { children: ReactNode }) {
|
hilfe_befehl: 'docker compose -p rippy ps',
|
||||||
const [betrieb, setBetrieb] = useState<Betrieb>(VORGABE)
|
geladen: false,
|
||||||
|
}
|
||||||
useEffect(() => {
|
|
||||||
let abgemeldet = false
|
const BetriebContext = createContext<Betrieb>(VORGABE)
|
||||||
api.get('/betrieb')
|
|
||||||
.then(antwort => {
|
export function BetriebProvider({ children }: { children: ReactNode }) {
|
||||||
const d = antwort.data
|
const [betrieb, setBetrieb] = useState<Betrieb>(VORGABE)
|
||||||
// Nur übernehmen, was WIRKLICH ankommt. Ein halb gefülltes Objekt
|
|
||||||
// hieße in JavaScript `undefined` — und `undefined` ist falsch, also
|
useEffect(() => {
|
||||||
// verschwände ein Bereich stillschweigend.
|
let abgemeldet = false
|
||||||
if (abgemeldet || !d || !d.kann) return
|
api.get('/betrieb')
|
||||||
setBetrieb({
|
.then(antwort => {
|
||||||
modus: d.modus === 'verteilt' ? 'verteilt' : 'standalone',
|
const d = antwort.data
|
||||||
plattform: d.plattform || 'linux',
|
// Nur übernehmen, was WIRKLICH ankommt. Ein halb gefülltes Objekt
|
||||||
im_container: !!d.im_container,
|
// hieße in JavaScript `undefined` — und `undefined` ist falsch, also
|
||||||
kann: {
|
// verschwände ein Bereich stillschweigend.
|
||||||
externe_worker: !!d.kann.externe_worker,
|
if (abgemeldet || !d || !d.kann) return
|
||||||
freigaben_einhaengen: !!d.kann.freigaben_einhaengen,
|
setBetrieb({
|
||||||
container_pfade: !!d.kann.container_pfade,
|
modus: d.modus === 'verteilt' ? 'verteilt' : 'standalone',
|
||||||
werkzeuge_verwalten: !!d.kann.werkzeuge_verwalten,
|
plattform: d.plattform || 'linux',
|
||||||
},
|
im_container: !!d.im_container,
|
||||||
ablage_vorgabe: d.ablage_vorgabe || '',
|
kann: {
|
||||||
hilfe_befehl: d.hilfe_befehl || '',
|
externe_worker: !!d.kann.externe_worker,
|
||||||
geladen: true,
|
freigaben_einhaengen: !!d.kann.freigaben_einhaengen,
|
||||||
})
|
container_pfade: !!d.kann.container_pfade,
|
||||||
})
|
werkzeuge_verwalten: !!d.kann.werkzeuge_verwalten,
|
||||||
.catch(() => {
|
frei_blaettern: !!d.kann.frei_blaettern,
|
||||||
// Nichts tun. Ein misslungener Abruf ist keine Aussage über den
|
},
|
||||||
// Betrieb — die Vorgabe bleibt stehen, `geladen` bleibt false.
|
ablage_vorgabe: d.ablage_vorgabe || '',
|
||||||
})
|
arbeits_vorgabe: d.arbeits_vorgabe || '',
|
||||||
return () => { abgemeldet = true }
|
hilfe_befehl: d.hilfe_befehl || '',
|
||||||
}, [])
|
geladen: true,
|
||||||
|
})
|
||||||
return <BetriebContext.Provider value={betrieb}>{children}</BetriebContext.Provider>
|
})
|
||||||
}
|
.catch(() => {
|
||||||
|
// Nichts tun. Ein misslungener Abruf ist keine Aussage über den
|
||||||
export function useBetrieb(): Betrieb {
|
// Betrieb — die Vorgabe bleibt stehen, `geladen` bleibt false.
|
||||||
return useContext(BetriebContext)
|
})
|
||||||
}
|
return () => { abgemeldet = true }
|
||||||
|
}, [])
|
||||||
/**
|
|
||||||
* Kurzform für den häufigsten Fall: Läuft Rippy als eigenständige App
|
return <BetriebContext.Provider value={betrieb}>{children}</BetriebContext.Provider>
|
||||||
* (Windows-Client oder Docker-All-in-One)?
|
}
|
||||||
*
|
|
||||||
* Bewusst NICHT „ist Windows": Ein Docker-All-in-One hat auch keinen zweiten
|
export function useBetrieb(): Betrieb {
|
||||||
* Worker. Wer nach der Plattform fragt, obwohl er die Fähigkeit meint, baut
|
return useContext(BetriebContext)
|
||||||
* die nächste falsche Annahme ein.
|
}
|
||||||
*/
|
|
||||||
export function useAlleinbetrieb(): boolean {
|
/**
|
||||||
return !useBetrieb().kann.externe_worker
|
* 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
|
||||||
|
}
|
||||||
|
|||||||
@@ -940,15 +940,40 @@ export default function SettingsPage() {
|
|||||||
/storage-targets — dieselbe Liste wie bei den Speicherzielen,
|
/storage-targets — dieselbe Liste wie bei den Speicherzielen,
|
||||||
inklusive freiem Platz.
|
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
|
<Select
|
||||||
label="Arbeitsverzeichnis für Roh-Rips (Standard)"
|
label="Arbeitsverzeichnis für Roh-Rips (Standard)"
|
||||||
value={settings.workDir}
|
value={settings.workDir}
|
||||||
onChange={(e) => handleChange('workDir', e.target.value)}
|
onChange={(e) => handleChange('workDir', e.target.value)}
|
||||||
>
|
>
|
||||||
<option value="">
|
<option value="">
|
||||||
{betrieb.kann.container_pfade
|
Container-Platte (Standard) — klein, nur für DVD/Blu-ray
|
||||||
? 'Container-Platte (Standard) — klein, nur für DVD/Blu-ray'
|
|
||||||
: `Standard (${betrieb.ablage_vorgabe || 'neben der Ablage'})`}
|
|
||||||
</option>
|
</option>
|
||||||
{ziele.map(z => (
|
{ziele.map(z => (
|
||||||
<option key={z.path} value={z.path}>
|
<option key={z.path} value={z.path}>
|
||||||
@@ -957,6 +982,27 @@ export default function SettingsPage() {
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</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">
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
<strong className="text-slate-700 dark:text-slate-300">Der Standard</strong> — beim
|
<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">
|
„Rippen starten" kannst du für jede Disc etwas anderes wählen. <strong className="text-slate-700 dark:text-slate-300">
|
||||||
|
|||||||
+99
-2
@@ -131,14 +131,74 @@ def auskunft(werte: dict, container: bool = None,
|
|||||||
# Im Image steckt alles fest; ein Update ist ein Rebuild. Nur die
|
# Im Image steckt alles fest; ein Update ist ein Rebuild. Nur die
|
||||||
# native Installation kann ihre Werkzeuge selbst pflegen.
|
# native Installation kann ihre Werkzeuge selbst pflegen.
|
||||||
"werkzeuge_verwalten": not container,
|
"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
|
# Was in der Oberfläche als Vorgabe stehen soll. Ein Feld, das mit
|
||||||
# `/app/media` vorbelegt ist, ist auf Windows schlicht falsch.
|
# `/app/media` vorbelegt ist, ist auf Windows schlicht falsch.
|
||||||
"ablage_vorgabe": ablage_vorgabe(werte, container, system),
|
"ablage_vorgabe": ablage_vorgabe(werte, container, system),
|
||||||
|
"arbeits_vorgabe": arbeits_vorgabe(werte, container, system),
|
||||||
"hilfe_befehl": "docker compose -p rippy ps" if container else "",
|
"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:
|
def naechster_vorhandener(pfad: str, existiert=None) -> str:
|
||||||
"""Der naechste vorhandene Ordner nach oben — siehe `rippy.pfade`."""
|
"""Der naechste vorhandene Ordner nach oben — siehe `rippy.pfade`."""
|
||||||
return pfade.naechster_vorhandener(pfad, existiert)
|
return pfade.naechster_vorhandener(pfad, existiert)
|
||||||
@@ -165,14 +225,13 @@ def platz_orte(werte: dict, container: bool = None,
|
|||||||
werte = werte or {}
|
werte = werte or {}
|
||||||
container = im_container() if container is None else container
|
container = im_container() if container is None else container
|
||||||
system = plattform(plattform_name)
|
system = plattform(plattform_name)
|
||||||
lager = werte.get("storage", {}) or {}
|
|
||||||
|
|
||||||
if container:
|
if container:
|
||||||
return [{"name": "Media (/app/media)", "pfad": "/app/media"},
|
return [{"name": "Media (/app/media)", "pfad": "/app/media"},
|
||||||
{"name": "Arbeitsverzeichnis (/app/temp)", "pfad": "/app/temp"}]
|
{"name": "Arbeitsverzeichnis (/app/temp)", "pfad": "/app/temp"}]
|
||||||
|
|
||||||
ablage = ablage_vorgabe(werte, container, system)
|
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}]
|
orte = [{"name": "Ablage", "pfad": ablage}]
|
||||||
# Nur wenn es ein ANDERES Laufwerk ist, lohnt eine zweite Zeile. Zweimal
|
# 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.
|
# 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
|
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:
|
def ablage_vorgabe(werte: dict, container: bool, system: str) -> str:
|
||||||
"""Wohin Rippy standardmäßig ablegt — je Betrieb ein anderer Ort."""
|
"""Wohin Rippy standardmäßig ablegt — je Betrieb ein anderer Ort."""
|
||||||
eigen = ((werte or {}).get("storage", {}) or {}).get("medien", "")
|
eigen = ((werte or {}).get("storage", {}) or {}).get("medien", "")
|
||||||
|
|||||||
+224
-123
@@ -1,123 +1,224 @@
|
|||||||
"""Kind-Prozesse ohne Fenster — und die Ausgabe ohne Konsole.
|
"""Kind-Prozesse ohne Fenster — und die Ausgabe ohne Konsole.
|
||||||
|
|
||||||
Ein Konsolenfenster laesst sich in einem Test nicht ansehen. Geprueft wird
|
Ein Konsolenfenster laesst sich in einem Test nicht ansehen. Geprueft wird
|
||||||
deshalb das, woran es beim ersten Anlauf gescheitert ist: die ENTSCHEIDUNG,
|
deshalb das, woran es beim ersten Anlauf gescheitert ist: die ENTSCHEIDUNG,
|
||||||
wohin die Ausgabe geht, wenn es keine Konsole gibt.
|
wohin die Ausgabe geht, wenn es keine Konsole gibt.
|
||||||
|
|
||||||
## Warum das nicht Nebensache ist
|
## Warum das nicht Nebensache ist
|
||||||
|
|
||||||
Die EXE wird seit dem 28.08.2026 als Fenster-Programm gebaut, damit beim
|
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`.
|
Doppelklick keine schwarze Box aufgeht. Damit ist `sys.stdout` aber `None`.
|
||||||
Ohne Umleitung wuerde jede Bibliothek, die auf `stderr` schreibt, mit
|
Ohne Umleitung wuerde jede Bibliothek, die auf `stderr` schreibt, mit
|
||||||
`AttributeError: 'NoneType' object has no attribute 'write'` sterben — mitten
|
`AttributeError: 'NoneType' object has no attribute 'write'` sterben — mitten
|
||||||
im Start, und nirgends stuende etwas. Genau der stille Fehlschlag, vor dem
|
im Start, und nirgends stuende etwas. Genau der stille Fehlschlag, vor dem
|
||||||
AGENTS.md warnt.
|
AGENTS.md warnt.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from rippy.platform import winlauf
|
from rippy.platform import winlauf
|
||||||
|
|
||||||
nur_windows = pytest.mark.skipif(os.name != "nt", reason="nur unter Windows")
|
nur_windows = pytest.mark.skipif(os.name != "nt", reason="nur unter Windows")
|
||||||
|
|
||||||
|
|
||||||
# ── Kind-Prozesse ───────────────────────────────────────────────────────
|
# ── Kind-Prozesse ───────────────────────────────────────────────────────
|
||||||
def test_flag_ist_auf_windows_gesetzt_und_sonst_null():
|
def test_flag_ist_auf_windows_gesetzt_und_sonst_null():
|
||||||
"""Auf Linux ist CREATE_NO_WINDOW nicht vorhanden; `creationflags=0` wird
|
"""Auf Linux ist CREATE_NO_WINDOW nicht vorhanden; `creationflags=0` wird
|
||||||
dort akzeptiert und ignoriert. So laeuft derselbe Code auf beiden Seiten."""
|
dort akzeptiert und ignoriert. So laeuft derselbe Code auf beiden Seiten."""
|
||||||
if os.name == "nt":
|
if os.name == "nt":
|
||||||
assert winlauf.OHNE_FENSTER == subprocess.CREATE_NO_WINDOW
|
assert winlauf.OHNE_FENSTER == subprocess.CREATE_NO_WINDOW
|
||||||
else:
|
else:
|
||||||
assert winlauf.OHNE_FENSTER == 0
|
assert winlauf.OHNE_FENSTER == 0
|
||||||
|
|
||||||
|
|
||||||
# ── Gibt es ueberhaupt eine Ausgabe? ────────────────────────────────────
|
# ── Gibt es ueberhaupt eine Ausgabe? ────────────────────────────────────
|
||||||
def test_fehlende_standardausgabe_wird_erkannt(monkeypatch):
|
def test_fehlende_standardausgabe_wird_erkannt(monkeypatch):
|
||||||
"""Der Fall in der fertigen EXE: PyInstaller setzt `sys.stdout` auf None."""
|
"""Der Fall in der fertigen EXE: PyInstaller setzt `sys.stdout` auf None."""
|
||||||
monkeypatch.setattr(sys, "stdout", None)
|
monkeypatch.setattr(sys, "stdout", None)
|
||||||
assert winlauf.ohne_konsole() is True
|
assert winlauf.ohne_konsole() is True
|
||||||
|
|
||||||
|
|
||||||
def test_ein_strom_ohne_dateinummer_zaehlt_auch_als_ohne_konsole(monkeypatch):
|
def test_ein_strom_ohne_dateinummer_zaehlt_auch_als_ohne_konsole(monkeypatch):
|
||||||
"""pytest ersetzt stdout durch einen Auffang-Puffer. Der hat kein
|
"""pytest ersetzt stdout durch einen Auffang-Puffer. Der hat kein
|
||||||
`fileno` — und ein Meldungsfenster waere in einem Testlauf das Letzte,
|
`fileno` — und ein Meldungsfenster waere in einem Testlauf das Letzte,
|
||||||
was jemand gebrauchen kann."""
|
was jemand gebrauchen kann."""
|
||||||
class Puffer:
|
class Puffer:
|
||||||
write = staticmethod(lambda _: None)
|
write = staticmethod(lambda _: None)
|
||||||
|
|
||||||
monkeypatch.setattr(sys, "stdout", Puffer())
|
monkeypatch.setattr(sys, "stdout", Puffer())
|
||||||
assert winlauf.ohne_konsole() is True
|
assert winlauf.ohne_konsole() is True
|
||||||
|
|
||||||
|
|
||||||
def test_mit_echter_ausgabe_ist_alles_in_ordnung(monkeypatch, tmp_path):
|
def test_mit_echter_ausgabe_ist_alles_in_ordnung(monkeypatch, tmp_path):
|
||||||
datei = tmp_path / "echt.txt"
|
datei = tmp_path / "echt.txt"
|
||||||
with open(datei, "w", encoding="utf-8") as f:
|
with open(datei, "w", encoding="utf-8") as f:
|
||||||
monkeypatch.setattr(sys, "stdout", f)
|
monkeypatch.setattr(sys, "stdout", f)
|
||||||
assert winlauf.ohne_konsole() is False
|
assert winlauf.ohne_konsole() is False
|
||||||
|
|
||||||
|
|
||||||
# ── Die Protokolldatei ──────────────────────────────────────────────────
|
# ── Die Protokolldatei ──────────────────────────────────────────────────
|
||||||
def test_protokoll_liegt_bei_rippy():
|
def test_protokoll_liegt_bei_rippy():
|
||||||
"""Nicht im TEMP: Was Rippy ueber sich aufschreibt, gehoert zu Rippy und
|
"""Nicht im TEMP: Was Rippy ueber sich aufschreibt, gehoert zu Rippy und
|
||||||
verschwindet beim Deinstallieren mit."""
|
verschwindet beim Deinstallieren mit."""
|
||||||
pfad = winlauf.protokolldatei(os.path.join("C:" + os.sep, "Basis"))
|
pfad = winlauf.protokolldatei(os.path.join("C:" + os.sep, "Basis"))
|
||||||
assert pfad.endswith(os.path.join("Rippy", "rippy.log"))
|
assert pfad.endswith(os.path.join("Rippy", "rippy.log"))
|
||||||
assert pfad.startswith(os.path.join("C:" + os.sep, "Basis"))
|
assert pfad.startswith(os.path.join("C:" + os.sep, "Basis"))
|
||||||
|
|
||||||
|
|
||||||
def test_umleiten_schreibt_wirklich(tmp_path, monkeypatch):
|
def test_umleiten_schreibt_wirklich(tmp_path, monkeypatch):
|
||||||
ziel = str(tmp_path / "unterordner" / "rippy.log")
|
ziel = str(tmp_path / "unterordner" / "rippy.log")
|
||||||
vorher_out, vorher_err = sys.stdout, sys.stderr
|
vorher_out, vorher_err = sys.stdout, sys.stderr
|
||||||
try:
|
try:
|
||||||
assert winlauf.ausgabe_umleiten(ziel) == ziel
|
assert winlauf.ausgabe_umleiten(ziel) == ziel
|
||||||
print("eine Zeile mit Umlaut: ä")
|
print("eine Zeile mit Umlaut: ä")
|
||||||
sys.stdout.flush()
|
sys.stdout.flush()
|
||||||
finally:
|
finally:
|
||||||
sys.stdout, sys.stderr = vorher_out, vorher_err
|
sys.stdout, sys.stderr = vorher_out, vorher_err
|
||||||
assert "eine Zeile mit Umlaut" in open(ziel, encoding="utf-8").read()
|
assert "eine Zeile mit Umlaut" in open(ziel, encoding="utf-8").read()
|
||||||
|
|
||||||
|
|
||||||
def test_umleiten_haengt_an_statt_zu_ueberschreiben(tmp_path):
|
def test_umleiten_haengt_an_statt_zu_ueberschreiben(tmp_path):
|
||||||
"""Sonst waere nach jedem Neustart das Protokoll des letzten Fehlers weg
|
"""Sonst waere nach jedem Neustart das Protokoll des letzten Fehlers weg
|
||||||
— also genau das, was man dann sucht."""
|
— also genau das, was man dann sucht."""
|
||||||
ziel = str(tmp_path / "rippy.log")
|
ziel = str(tmp_path / "rippy.log")
|
||||||
vorher_out, vorher_err = sys.stdout, sys.stderr
|
vorher_out, vorher_err = sys.stdout, sys.stderr
|
||||||
try:
|
try:
|
||||||
winlauf.ausgabe_umleiten(ziel)
|
winlauf.ausgabe_umleiten(ziel)
|
||||||
print("erster Start")
|
print("erster Start")
|
||||||
sys.stdout.flush()
|
sys.stdout.flush()
|
||||||
winlauf.ausgabe_umleiten(ziel)
|
winlauf.ausgabe_umleiten(ziel)
|
||||||
print("zweiter Start")
|
print("zweiter Start")
|
||||||
sys.stdout.flush()
|
sys.stdout.flush()
|
||||||
finally:
|
finally:
|
||||||
sys.stdout, sys.stderr = vorher_out, vorher_err
|
sys.stdout, sys.stderr = vorher_out, vorher_err
|
||||||
inhalt = open(ziel, encoding="utf-8").read()
|
inhalt = open(ziel, encoding="utf-8").read()
|
||||||
assert "erster Start" in inhalt and "zweiter Start" in inhalt
|
assert "erster Start" in inhalt and "zweiter Start" in inhalt
|
||||||
|
|
||||||
|
|
||||||
def test_unbeschreibbarer_ort_laesst_den_start_nicht_platzen(monkeypatch):
|
def test_unbeschreibbarer_ort_laesst_den_start_nicht_platzen(monkeypatch):
|
||||||
"""Lieber ins Nichts schreiben als beim Start sterben. Ein Programm, das
|
"""Lieber ins Nichts schreiben als beim Start sterben. Ein Programm, das
|
||||||
wegen seiner Protokolldatei nicht hochkommt, ist schlimmer als eines
|
wegen seiner Protokolldatei nicht hochkommt, ist schlimmer als eines
|
||||||
ohne Protokoll."""
|
ohne Protokoll."""
|
||||||
def geht_nicht(*a, **k):
|
def geht_nicht(*a, **k):
|
||||||
raise OSError("kein Platz")
|
raise OSError("kein Platz")
|
||||||
|
|
||||||
monkeypatch.setattr(winlauf.os, "makedirs", geht_nicht)
|
monkeypatch.setattr(winlauf.os, "makedirs", geht_nicht)
|
||||||
vorher_out, vorher_err = sys.stdout, sys.stderr
|
vorher_out, vorher_err = sys.stdout, sys.stderr
|
||||||
try:
|
try:
|
||||||
winlauf.ausgabe_umleiten("/gibt/es/nicht/rippy.log")
|
winlauf.ausgabe_umleiten("/gibt/es/nicht/rippy.log")
|
||||||
print("das darf nicht werfen")
|
print("das darf nicht werfen")
|
||||||
finally:
|
finally:
|
||||||
sys.stdout, sys.stderr = vorher_out, vorher_err
|
sys.stdout, sys.stderr = vorher_out, vorher_err
|
||||||
|
|
||||||
|
|
||||||
# ── Das Meldungsfenster ─────────────────────────────────────────────────
|
# ── Das Meldungsfenster ─────────────────────────────────────────────────
|
||||||
def test_ohne_windows_kein_meldungsfenster():
|
def test_ohne_windows_kein_meldungsfenster():
|
||||||
if os.name == "nt":
|
if os.name == "nt":
|
||||||
pytest.skip("prueft das Verhalten auf Nicht-Windows")
|
pytest.skip("prueft das Verhalten auf Nicht-Windows")
|
||||||
assert winlauf.meldung_zeigen("egal") is False
|
assert winlauf.meldung_zeigen("egal") is False
|
||||||
assert winlauf.an_elternkonsole_haengen() 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()
|
||||||
|
|||||||
@@ -71,6 +71,99 @@ MB_ICONINFORMATION = 0x40
|
|||||||
MB_ICONERROR = 0x10
|
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:
|
def ohne_konsole() -> bool:
|
||||||
"""Läuft dieser Prozess ohne Standardausgabe?
|
"""Läuft dieser Prozess ohne Standardausgabe?
|
||||||
|
|
||||||
|
|||||||
+600
-584
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -1,192 +1,193 @@
|
|||||||
"""Der Betriebsmodus und seine Faehigkeiten.
|
"""Der Betriebsmodus und seine Faehigkeiten.
|
||||||
|
|
||||||
## Warum es diese Tests gibt
|
## Warum es diese Tests gibt
|
||||||
|
|
||||||
Commander am 28.08.2026, zum Windows-Fenster:
|
Commander am 28.08.2026, zum Windows-Fenster:
|
||||||
|
|
||||||
Worker erreichbar: 0 von 1
|
Worker erreichbar: 0 von 1
|
||||||
Kein Worker antwortet — Pruefen: docker compose ps
|
Kein Worker antwortet — Pruefen: docker compose ps
|
||||||
Container-Platte: unbekannt
|
Container-Platte: unbekannt
|
||||||
Freigaben: keine eingehaengt
|
Freigaben: keine eingehaengt
|
||||||
|
|
||||||
Kein Satz davon ergibt auf einem Windows-PC einen Sinn. Die Oberflaeche hatte
|
Kein Satz davon ergibt auf einem Windows-PC einen Sinn. Die Oberflaeche hatte
|
||||||
nie erfahren, worauf sie laeuft — also hat sie angenommen.
|
nie erfahren, worauf sie laeuft — also hat sie angenommen.
|
||||||
|
|
||||||
Alle Pruefungen hier sind eingespritzt: Jeder Betriebsfall ist pruefbar, ohne
|
Alle Pruefungen hier sind eingespritzt: Jeder Betriebsfall ist pruefbar, ohne
|
||||||
ihn herzustellen. Das ist der Punkt — sonst waere „laeuft im Container" nur
|
ihn herzustellen. Das ist der Punkt — sonst waere „laeuft im Container" nur
|
||||||
auf einem Container-Rechner testbar, und genau dort faellt der Windows-Fehler
|
auf einem Container-Rechner testbar, und genau dort faellt der Windows-Fehler
|
||||||
nie auf.
|
nie auf.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from rippy import betrieb
|
from rippy import betrieb
|
||||||
|
|
||||||
DOCKER = {"profil": "api", "queue": {"treiber": "celery", "broker": "redis://x"}}
|
DOCKER = {"profil": "api", "queue": {"treiber": "celery", "broker": "redis://x"}}
|
||||||
WINDOWS = {"profil": "standalone", "queue": {"treiber": "lokal"}}
|
WINDOWS = {"profil": "standalone", "queue": {"treiber": "lokal"}}
|
||||||
|
|
||||||
|
|
||||||
# ── Plattform ───────────────────────────────────────────────────────────
|
# ── Plattform ───────────────────────────────────────────────────────────
|
||||||
def test_plattform_wird_zu_einem_klaren_namen():
|
def test_plattform_wird_zu_einem_klaren_namen():
|
||||||
assert betrieb.plattform("win32") == "windows"
|
assert betrieb.plattform("win32") == "windows"
|
||||||
assert betrieb.plattform("linux") == "linux"
|
assert betrieb.plattform("linux") == "linux"
|
||||||
assert betrieb.plattform("darwin") == "macos"
|
assert betrieb.plattform("darwin") == "macos"
|
||||||
|
|
||||||
|
|
||||||
def test_unbekannte_plattform_gilt_als_linux():
|
def test_unbekannte_plattform_gilt_als_linux():
|
||||||
"""FreeBSD und Verwandte verhalten sich wie Linux — und ein Rueckfall auf
|
"""FreeBSD und Verwandte verhalten sich wie Linux — und ein Rueckfall auf
|
||||||
etwas Bekanntes ist besser als ein leeres Feld."""
|
etwas Bekanntes ist besser als ein leeres Feld."""
|
||||||
assert betrieb.plattform("freebsd14") == "linux"
|
assert betrieb.plattform("freebsd14") == "linux"
|
||||||
|
|
||||||
|
|
||||||
# ── Container ───────────────────────────────────────────────────────────
|
# ── Container ───────────────────────────────────────────────────────────
|
||||||
def test_container_wird_gemessen_nicht_geraten():
|
def test_container_wird_gemessen_nicht_geraten():
|
||||||
"""`/.dockerenv` legt Docker selbst an — verlaesslicher als eine
|
"""`/.dockerenv` legt Docker selbst an — verlaesslicher als eine
|
||||||
Umgebungsvariable, die jeder setzen und vergessen kann."""
|
Umgebungsvariable, die jeder setzen und vergessen kann."""
|
||||||
assert betrieb.im_container(lambda p: p == "/.dockerenv") is True
|
assert betrieb.im_container(lambda p: p == "/.dockerenv") is True
|
||||||
assert betrieb.im_container(lambda p: False) is False
|
assert betrieb.im_container(lambda p: False) is False
|
||||||
|
|
||||||
|
|
||||||
# ── Modus ───────────────────────────────────────────────────────────────
|
# ── Modus ───────────────────────────────────────────────────────────────
|
||||||
def test_celery_heisst_verteilt():
|
def test_celery_heisst_verteilt():
|
||||||
assert betrieb.modus(DOCKER) == "verteilt"
|
assert betrieb.modus(DOCKER) == "verteilt"
|
||||||
|
|
||||||
|
|
||||||
def test_lokale_queue_heisst_standalone():
|
def test_lokale_queue_heisst_standalone():
|
||||||
assert betrieb.modus(WINDOWS) == "standalone"
|
assert betrieb.modus(WINDOWS) == "standalone"
|
||||||
|
|
||||||
|
|
||||||
def test_ohne_angaben_gilt_standalone():
|
def test_ohne_angaben_gilt_standalone():
|
||||||
"""Der harmlosere Rueckfall: Ein standalone-Rippy zeigt nichts an, was es
|
"""Der harmlosere Rueckfall: Ein standalone-Rippy zeigt nichts an, was es
|
||||||
nicht gibt. Andersherum entstuenden die Phantom-Worker wieder."""
|
nicht gibt. Andersherum entstuenden die Phantom-Worker wieder."""
|
||||||
assert betrieb.modus({}) == "standalone"
|
assert betrieb.modus({}) == "standalone"
|
||||||
|
|
||||||
|
|
||||||
# ── Die Faehigkeiten — hier sass der gemeldete Fehler ───────────────────
|
# ── Die Faehigkeiten — hier sass der gemeldete Fehler ───────────────────
|
||||||
def test_windows_hat_KEINE_externen_worker():
|
def test_windows_hat_KEINE_externen_worker():
|
||||||
"""DER Befund: „Worker erreichbar: 0 von 1 — Kein Worker antwortet."
|
"""DER Befund: „Worker erreichbar: 0 von 1 — Kein Worker antwortet."
|
||||||
Unter Windows rippt Rippy selbst; es gibt keinen zweiten, auf den man
|
Unter Windows rippt Rippy selbst; es gibt keinen zweiten, auf den man
|
||||||
warten koennte. Die Meldung war nicht nur unpassend, sie war falsch."""
|
warten koennte. Die Meldung war nicht nur unpassend, sie war falsch."""
|
||||||
a = betrieb.auskunft(WINDOWS, container=False, plattform_name="win32")
|
a = betrieb.auskunft(WINDOWS, container=False, plattform_name="win32")
|
||||||
assert a["kann"]["externe_worker"] is False
|
assert a["kann"]["externe_worker"] is False
|
||||||
|
|
||||||
|
|
||||||
def test_windows_kennt_KEINE_container_pfade():
|
def test_windows_kennt_KEINE_container_pfade():
|
||||||
"""`/app/media` ist ein Pfad IM Container. Auf Windows heisst der Ordner
|
"""`/app/media` ist ein Pfad IM Container. Auf Windows heisst der Ordner
|
||||||
anders und liegt woanders — ein damit vorbelegtes Feld ist schlicht
|
anders und liegt woanders — ein damit vorbelegtes Feld ist schlicht
|
||||||
falsch."""
|
falsch."""
|
||||||
a = betrieb.auskunft(WINDOWS, container=False, plattform_name="win32")
|
a = betrieb.auskunft(WINDOWS, container=False, plattform_name="win32")
|
||||||
assert a["kann"]["container_pfade"] is False
|
assert a["kann"]["container_pfade"] is False
|
||||||
assert "/app/" not in a["ablage_vorgabe"]
|
assert "/app/" not in a["ablage_vorgabe"]
|
||||||
|
|
||||||
|
|
||||||
def test_windows_haengt_keine_freigaben_ein():
|
def test_windows_haengt_keine_freigaben_ein():
|
||||||
"""Unter Windows gibt man einen UNC-Pfad an, fertig. Eine Maske zum
|
"""Unter Windows gibt man einen UNC-Pfad an, fertig. Eine Maske zum
|
||||||
Einhaengen waere eine Bedienung, die ins Leere fuehrt."""
|
Einhaengen waere eine Bedienung, die ins Leere fuehrt."""
|
||||||
a = betrieb.auskunft(WINDOWS, container=False, plattform_name="win32")
|
a = betrieb.auskunft(WINDOWS, container=False, plattform_name="win32")
|
||||||
assert a["kann"]["freigaben_einhaengen"] is False
|
assert a["kann"]["freigaben_einhaengen"] is False
|
||||||
|
|
||||||
|
|
||||||
def test_windows_darf_seine_werkzeuge_selbst_pflegen():
|
def test_windows_darf_seine_werkzeuge_selbst_pflegen():
|
||||||
"""Im Image steckt alles fest, ein Update ist ein Rebuild. Nur die native
|
"""Im Image steckt alles fest, ein Update ist ein Rebuild. Nur die native
|
||||||
Installation kann MakeMKV/HandBrake selbst beschaffen."""
|
Installation kann MakeMKV/HandBrake selbst beschaffen."""
|
||||||
a = betrieb.auskunft(WINDOWS, container=False, plattform_name="win32")
|
a = betrieb.auskunft(WINDOWS, container=False, plattform_name="win32")
|
||||||
assert a["kann"]["werkzeuge_verwalten"] is True
|
assert a["kann"]["werkzeuge_verwalten"] is True
|
||||||
|
|
||||||
|
|
||||||
def test_docker_behaelt_alles_was_es_hatte():
|
def test_docker_behaelt_alles_was_es_hatte():
|
||||||
"""Die Windows-Reparatur darf dem Docker-Betrieb nichts wegnehmen."""
|
"""Die Windows-Reparatur darf dem Docker-Betrieb nichts wegnehmen."""
|
||||||
a = betrieb.auskunft(DOCKER, container=True, plattform_name="linux")
|
a = betrieb.auskunft(DOCKER, container=True, plattform_name="linux")
|
||||||
assert a["kann"]["externe_worker"] is True
|
assert a["kann"]["externe_worker"] is True
|
||||||
assert a["kann"]["container_pfade"] is True
|
assert a["kann"]["container_pfade"] is True
|
||||||
assert a["kann"]["freigaben_einhaengen"] is True
|
assert a["kann"]["freigaben_einhaengen"] is True
|
||||||
assert a["ablage_vorgabe"] == "/app/media"
|
assert a["ablage_vorgabe"] == "/app/media"
|
||||||
assert a["hilfe_befehl"] == "docker compose -p rippy ps"
|
assert a["hilfe_befehl"] == "docker compose -p rippy ps"
|
||||||
|
|
||||||
|
|
||||||
def test_der_docker_hinweis_taucht_ausserhalb_von_docker_NICHT_auf():
|
def test_der_docker_hinweis_taucht_ausserhalb_von_docker_NICHT_auf():
|
||||||
"""„Pruefen: docker compose ps" auf einem Windows-PC ist ein Rat, den
|
"""„Pruefen: docker compose ps" auf einem Windows-PC ist ein Rat, den
|
||||||
niemand befolgen kann."""
|
niemand befolgen kann."""
|
||||||
a = betrieb.auskunft(WINDOWS, container=False, plattform_name="win32")
|
a = betrieb.auskunft(WINDOWS, container=False, plattform_name="win32")
|
||||||
assert a["hilfe_befehl"] == ""
|
assert a["hilfe_befehl"] == ""
|
||||||
|
|
||||||
|
|
||||||
def test_docker_allinone_hat_container_aber_keine_externen_worker():
|
def test_docker_allinone_hat_container_aber_keine_externen_worker():
|
||||||
"""Der Fall, an dem ein blosser Modus-Name zerbrechen wuerde: EIN
|
"""Der Fall, an dem ein blosser Modus-Name zerbrechen wuerde: EIN
|
||||||
Container, der alles selbst macht. Er hat Container-Pfade, aber niemanden,
|
Container, der alles selbst macht. Er hat Container-Pfade, aber niemanden,
|
||||||
der von aussen Jobs uebernimmt."""
|
der von aussen Jobs uebernimmt."""
|
||||||
a = betrieb.auskunft({"profil": "standalone", "queue": {"treiber": "lokal"}},
|
a = betrieb.auskunft({"profil": "standalone", "queue": {"treiber": "lokal"}},
|
||||||
container=True, plattform_name="linux")
|
container=True, plattform_name="linux")
|
||||||
assert a["kann"]["container_pfade"] is True
|
assert a["kann"]["container_pfade"] is True
|
||||||
assert a["kann"]["externe_worker"] is False
|
assert a["kann"]["externe_worker"] is False
|
||||||
|
|
||||||
|
|
||||||
# ── Die Ablage-Vorgabe ──────────────────────────────────────────────────
|
# ── Die Ablage-Vorgabe ──────────────────────────────────────────────────
|
||||||
def test_eingestellte_ablage_schlaegt_jede_vorgabe():
|
def test_eingestellte_ablage_schlaegt_jede_vorgabe():
|
||||||
a = betrieb.auskunft({"storage": {"medien": "D:\\Filme"}},
|
a = betrieb.auskunft({"storage": {"medien": "D:\\Filme"}},
|
||||||
container=False, plattform_name="win32")
|
container=False, plattform_name="win32")
|
||||||
assert a["ablage_vorgabe"] == "D:\\Filme"
|
assert a["ablage_vorgabe"] == "D:\\Filme"
|
||||||
|
|
||||||
|
|
||||||
def test_windows_vorgabe_liegt_beim_nutzer(monkeypatch):
|
def test_windows_vorgabe_liegt_beim_nutzer(monkeypatch):
|
||||||
monkeypatch.setenv("USERPROFILE", "C:\\Users\\Test")
|
monkeypatch.setenv("USERPROFILE", "C:\\Users\\Test")
|
||||||
a = betrieb.auskunft(WINDOWS, container=False, plattform_name="win32")
|
a = betrieb.auskunft(WINDOWS, container=False, plattform_name="win32")
|
||||||
assert a["ablage_vorgabe"].startswith("C:\\Users\\Test")
|
assert a["ablage_vorgabe"].startswith("C:\\Users\\Test")
|
||||||
assert a["ablage_vorgabe"].endswith("Rippy")
|
assert a["ablage_vorgabe"].endswith("Rippy")
|
||||||
|
|
||||||
|
|
||||||
# ── Der Vertrag als Ganzes ──────────────────────────────────────────────
|
# ── Der Vertrag als Ganzes ──────────────────────────────────────────────
|
||||||
def test_die_auskunft_ist_vollstaendig():
|
def test_die_auskunft_ist_vollstaendig():
|
||||||
"""Ein fehlendes Feld waere im UI ein `undefined` — und `undefined` ist
|
"""Ein fehlendes Feld waere im UI ein `undefined` — und `undefined` ist
|
||||||
in JavaScript falsch, also verschwaende die Oberflaeche stillschweigend
|
in JavaScript falsch, also verschwaende die Oberflaeche stillschweigend
|
||||||
einen Bereich. Lieber hier rot."""
|
einen Bereich. Lieber hier rot."""
|
||||||
a = betrieb.auskunft(WINDOWS, container=False, plattform_name="win32")
|
a = betrieb.auskunft(WINDOWS, container=False, plattform_name="win32")
|
||||||
assert set(a) == {"modus", "plattform", "im_container", "kann",
|
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",
|
assert set(a["kann"]) == {"externe_worker", "freigaben_einhaengen",
|
||||||
"container_pfade", "werkzeuge_verwalten"}
|
"container_pfade", "werkzeuge_verwalten",
|
||||||
assert all(isinstance(v, bool) for v in a["kann"].values())
|
"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():
|
# ── Wo Platz gemessen wird ──────────────────────────────────────────────
|
||||||
"""DER Befund: Im Windows-Fenster stand „Platz fuer Rippy: unbekannt".
|
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
|
`main.py` mass fest an `/app/media` und `/app/temp`. Beide gibt es unter
|
||||||
einer Nichtauskunft wurde eine Anzeige, die wie eine Auskunft aussieht.
|
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"
|
orte = betrieb.platz_orte(WINDOWS, container=False, plattform_name="win32")
|
||||||
assert all("/app/" not in o["pfad"] for o in orte), orte
|
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")
|
def test_docker_misst_weiter_die_container_pfade():
|
||||||
assert [o["pfad"] for o in orte] == ["/app/media", "/app/temp"]
|
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
|
def test_gleiches_laufwerk_wird_nicht_doppelt_gezaehlt():
|
||||||
und ist eine."""
|
"""Zweimal dieselbe Zahl unter zwei Namen sieht aus wie zwei Auskuenfte
|
||||||
orte = betrieb.platz_orte({"storage": {"medien": r"D:\Filme"}},
|
und ist eine."""
|
||||||
container=False, plattform_name="win32")
|
orte = betrieb.platz_orte({"storage": {"medien": r"D:\Filme"}},
|
||||||
assert len(orte) == 1, orte
|
container=False, plattform_name="win32")
|
||||||
|
assert len(orte) == 1, orte
|
||||||
|
|
||||||
def test_zweites_laufwerk_bekommt_eine_eigene_zeile():
|
|
||||||
orte = betrieb.platz_orte(
|
def test_zweites_laufwerk_bekommt_eine_eigene_zeile():
|
||||||
{"storage": {"medien": r"D:\Filme", "temp": r"E:\Arbeit"}},
|
orte = betrieb.platz_orte(
|
||||||
container=False, plattform_name="win32")
|
{"storage": {"medien": r"D:\Filme", "temp": r"E:\Arbeit"}},
|
||||||
assert len(orte) == 2, orte
|
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
|
def test_noch_nicht_angelegter_ordner_faellt_auf_das_laufwerk_zurueck():
|
||||||
will trotzdem wissen, ob auf dem LAUFWERK Platz ist."""
|
"""Frisch installiert gibt es den Ablage-Ordner noch nicht. Der Nutzer
|
||||||
da = r"C:\Users\Test"
|
will trotzdem wissen, ob auf dem LAUFWERK Platz ist."""
|
||||||
assert betrieb.naechster_vorhandener(
|
da = r"C:\Users\Test"
|
||||||
r"C:\Users\Test\Videos\Rippy", existiert=lambda p: p == da) == da
|
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",
|
def test_wenn_gar_nichts_existiert_wird_nichts_behauptet():
|
||||||
existiert=lambda p: False) == ""
|
assert betrieb.naechster_vorhandener(r"Z:\gibt\es\nicht",
|
||||||
|
existiert=lambda p: False) == ""
|
||||||
|
|||||||
@@ -352,10 +352,12 @@ def ui_auspacken(ziel: str) -> str:
|
|||||||
|
|
||||||
def starte_hintergrund(programm: str) -> None:
|
def starte_hintergrund(programm: str) -> None:
|
||||||
"""Startet Rippy als Hintergrundprozess — ohne aufblitzendes Fenster."""
|
"""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,
|
subprocess.Popen([programm, "--dienst"], creationflags=OHNE_FENSTER,
|
||||||
close_fds=True)
|
close_fds=True, env=umgebung_ohne_bundle())
|
||||||
|
|
||||||
|
|
||||||
def starte_fensterprozess(programm: str = None) -> None:
|
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",
|
Fenster ist der Client davor. Beide heissen im Taskmanager „Rippy.exe",
|
||||||
und das Fenster laesst sich schliessen, ohne den Dienst mitzureissen.
|
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"],
|
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:
|
def deinstallieren(still: bool = False) -> None:
|
||||||
@@ -584,6 +587,18 @@ class Dienst:
|
|||||||
return False
|
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:
|
def dienst_starten(port: int = STANDARD_PORT) -> int:
|
||||||
"""Server im Hintergrund-Thread, Tray-Symbol im Vordergrund.
|
"""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
|
laufen, sonst bekommt es keine Fensternachrichten und das Symbol
|
||||||
erscheint nie.
|
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)
|
dienst = Dienst(port)
|
||||||
threading.Thread(target=dienst.server_starten, daemon=True,
|
threading.Thread(target=dienst.server_starten, daemon=True,
|
||||||
name="rippy-server").start()
|
name="rippy-server").start()
|
||||||
|
|||||||
Reference in New Issue
Block a user