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
|
||||
|
||||
basis = einstellungen.get("outputDir") or MEDIA_ROOT
|
||||
basis = einstellungen.get("outputDir") or medien_wurzel()
|
||||
meta = disc.get("metadata") or {}
|
||||
if disc.get("disc_type") == "CD":
|
||||
unterordner = einstellungen.get("musicDir") or "music"
|
||||
@@ -247,7 +247,7 @@ async def _auto_rip_wenn_aktiviert(pfad: str):
|
||||
else:
|
||||
unterordner = einstellungen.get("movieDir") or "movies"
|
||||
ziel = os.path.normpath(os.path.join(basis, unterordner))
|
||||
if not unter_wurzel(ziel, MEDIA_ROOT):
|
||||
if not pfad_erlaubt(ziel):
|
||||
ziel = None
|
||||
|
||||
job_id = str(uuid.uuid4())
|
||||
@@ -978,18 +978,78 @@ class JobCreateRequest(BaseModel):
|
||||
work_dir: Optional[str] = None
|
||||
|
||||
|
||||
# Der Pfad IM Container. Bleibt als Rückfall stehen — aber er ist NICHT mehr
|
||||
# die Antwort auf „wo liegt die Ablage": die gibt `medien_wurzel()`.
|
||||
MEDIA_ROOT = "/app/media"
|
||||
|
||||
|
||||
def _betriebswerte() -> dict:
|
||||
"""Die Konfiguration. Unlesbar heißt Vorgaben, nicht Absturz."""
|
||||
from rippy import config as rippy_config
|
||||
try:
|
||||
return rippy_config.laden()
|
||||
except Exception: # noqa: BLE001
|
||||
return {}
|
||||
|
||||
|
||||
def medien_wurzel() -> str:
|
||||
"""Wo dieser Betrieb ablegt — `/app/media` nur, wenn es ein Container ist.
|
||||
|
||||
## Der Befund des Commanders (28.08.2026)
|
||||
|
||||
> „Warum heißt das hier noch container platte? Er holt sich das
|
||||
> Arbeitsverzeichnis ja von der Installation. Wäre es möglich das
|
||||
> Arbeitsverzeichnis zu ändern? momentan geht das nicht."
|
||||
|
||||
Es ging nicht, und zwar aus einem Grund: `MEDIA_ROOT` war fest
|
||||
`/app/media`. Auf seinem PC gibt es den Ordner nicht, also warf
|
||||
`os.listdir` in `/storage-targets`, also blieb die Liste leer — im
|
||||
Auswahlfeld stand genau ein Eintrag, und der hieß „Container-Platte".
|
||||
Kein Fehler, keine Meldung, nur eine Auswahl ohne Auswahl.
|
||||
|
||||
Dieselbe Konstante war zugleich die Pfadgrenze für `/browse`. Auch der
|
||||
Ordner-Browser konnte auf Windows also nichts anzeigen.
|
||||
"""
|
||||
from rippy import betrieb
|
||||
try:
|
||||
return betrieb.medien_wurzel(_betriebswerte()) or MEDIA_ROOT
|
||||
except Exception: # noqa: BLE001
|
||||
return MEDIA_ROOT
|
||||
|
||||
|
||||
def frei_blaettern() -> bool:
|
||||
"""Darf außerhalb der Wurzel geblättert werden? Begründung in betrieb.py."""
|
||||
from rippy import betrieb
|
||||
try:
|
||||
return betrieb.frei_blaettern(_betriebswerte())
|
||||
except Exception: # noqa: BLE001
|
||||
return False
|
||||
|
||||
|
||||
def pfad_erlaubt(pfad: str, wurzel: str = None, frei: bool = None) -> bool:
|
||||
"""Darf die API diesen Pfad anfassen? (pure Funktion, einspritzbar)
|
||||
|
||||
Im Container und im verteilten Betrieb gilt die Wurzel — die API hängt
|
||||
dort im Netz. In der nativen App gilt sie nicht: Sie bedient den Menschen,
|
||||
der vor dem Rechner sitzt, und dessen Ziel ist eine UNC-Freigabe, die
|
||||
unter gar keiner lokalen Wurzel liegt.
|
||||
"""
|
||||
if not pfad:
|
||||
return False
|
||||
if frei_blaettern() if frei is None else frei:
|
||||
return True
|
||||
return unter_wurzel(pfad, medien_wurzel() if wurzel is None else wurzel)
|
||||
|
||||
|
||||
def _validiere_ziel(target_dir: Optional[str]) -> Optional[str]:
|
||||
"""Ziel muss unter /app/media liegen — Pfad-Ausbrüche (..) fliegen raus."""
|
||||
"""Ziel muss erlaubt sein — Pfad-Ausbrüche (..) fliegen raus."""
|
||||
if not target_dir:
|
||||
return None
|
||||
normalisiert = os.path.normpath(target_dir)
|
||||
if not unter_wurzel(normalisiert, MEDIA_ROOT):
|
||||
if not pfad_erlaubt(normalisiert):
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=f"Ziel muss unter {MEDIA_ROOT} liegen (Shares dort einhängen)",
|
||||
detail=f"Ziel muss unter {medien_wurzel()} liegen (Shares dort einhängen)",
|
||||
)
|
||||
return normalisiert
|
||||
|
||||
@@ -1111,10 +1171,10 @@ def unter_wurzel(pfad: str, wurzel: str) -> bool:
|
||||
|
||||
|
||||
def _job_ausgabeordner(job: dict) -> str:
|
||||
"""Validierter Ausgabeordner eines Jobs — strikt unter /app/media."""
|
||||
"""Validierter Ausgabeordner eines Jobs — muss erlaubt sein."""
|
||||
ausgabe = os.path.normpath(job.get("output_path") or "")
|
||||
if not unter_wurzel(ausgabe, MEDIA_ROOT):
|
||||
raise HTTPException(status_code=404, detail="Job hat keinen Ausgabeordner unter /app/media")
|
||||
if not pfad_erlaubt(ausgabe):
|
||||
raise HTTPException(status_code=404, detail="Job hat keinen erlaubten Ausgabeordner")
|
||||
return ausgabe
|
||||
|
||||
|
||||
@@ -1170,7 +1230,7 @@ async def download_job_file(job_id: str, dateiname: str):
|
||||
pfad = os.path.join(ausgabe, dateiname)
|
||||
|
||||
def pruefe():
|
||||
return os.path.isfile(pfad) and unter_wurzel(os.path.realpath(pfad), MEDIA_ROOT)
|
||||
return os.path.isfile(pfad) and pfad_erlaubt(os.path.realpath(pfad))
|
||||
|
||||
if not await asyncio.to_thread(pruefe):
|
||||
raise HTTPException(status_code=404, detail="Datei nicht gefunden")
|
||||
@@ -1179,19 +1239,44 @@ async def download_job_file(job_id: str, dateiname: str):
|
||||
|
||||
@app.get("/storage-targets")
|
||||
async def storage_targets():
|
||||
"""Verfügbare Ablageziele: Verzeichnisse unter /app/media inkl. Mounts.
|
||||
"""Verfügbare Ablageziele: Verzeichnisse unter der Medien-Wurzel.
|
||||
|
||||
NFS/SMB-Shares, die auf der VM unter /srv/rippy/media eingehängt werden,
|
||||
tauchen hier automatisch auf (rslave-Bind in docker-compose).
|
||||
|
||||
## Warum hier Laufwerke dazukommen (Commander-Befund 28.08.2026)
|
||||
|
||||
Auf Windows war diese Liste IMMER leer: `os.listdir("/app/media")` warf,
|
||||
und der `except OSError` gab still `[]` zurück. Im Auswahlfeld für das
|
||||
Arbeitsverzeichnis stand dann genau ein Eintrag — „Standard aus den
|
||||
Einstellungen (Container-Platte)". Eine Auswahl ohne Auswahl.
|
||||
|
||||
Die Wurzel allein reicht dort auch nicht: Der Roh-Rip einer 4K-UHD ist bis
|
||||
zu 100 GB groß, und die Antwort darauf ist fast immer ein ANDERES
|
||||
Laufwerk. Deshalb kommen im nativen Betrieb die Laufwerke dazu — mit dem
|
||||
freien Platz daneben, damit die Wahl eine informierte ist.
|
||||
"""
|
||||
def sammle():
|
||||
wurzel = medien_wurzel()
|
||||
ziele = []
|
||||
|
||||
def platz(pfad):
|
||||
try:
|
||||
return round(shutil.disk_usage(pfad).free / 1024**3, 1)
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
# Die Wurzel selbst — im Container war sie nie ein Eintrag, weil dort
|
||||
# die Unterordner die Ziele sind. Nativ IST sie ein gültiges Ziel.
|
||||
if frei_blaettern() and os.path.isdir(wurzel):
|
||||
ziele.append({"name": "Ablage (%s)" % wurzel, "path": wurzel,
|
||||
"is_mount": False, "free_gb": platz(wurzel)})
|
||||
try:
|
||||
eintraege = sorted(os.listdir(MEDIA_ROOT))
|
||||
eintraege = sorted(os.listdir(wurzel))
|
||||
except OSError:
|
||||
return ziele
|
||||
eintraege = []
|
||||
for name in eintraege:
|
||||
pfad = os.path.join(MEDIA_ROOT, name)
|
||||
pfad = os.path.join(wurzel, name)
|
||||
try:
|
||||
ist_mount = os.path.ismount(pfad)
|
||||
except OSError:
|
||||
@@ -1213,6 +1298,20 @@ async def storage_targets():
|
||||
"is_mount": ist_mount,
|
||||
"free_gb": frei_gb,
|
||||
})
|
||||
|
||||
# Die Laufwerke — nur nativ. Im Container gäbe es nichts zu holen.
|
||||
if frei_blaettern():
|
||||
from rippy import betrieb
|
||||
bekannt = {z["path"].rstrip("\\/").lower() for z in ziele}
|
||||
for laufwerk in betrieb.windows_laufwerke():
|
||||
if laufwerk.rstrip("\\/").lower() in bekannt:
|
||||
continue
|
||||
ziele.append({
|
||||
"name": "Laufwerk %s" % laufwerk.rstrip("\\/"),
|
||||
"path": laufwerk,
|
||||
"is_mount": False,
|
||||
"free_gb": platz(laufwerk),
|
||||
})
|
||||
return ziele
|
||||
|
||||
return await asyncio.to_thread(sammle)
|
||||
@@ -1299,7 +1398,7 @@ async def retry_transcode(job_id: str):
|
||||
raw_dir = gefunden[0]
|
||||
# Zielordner: der Worker schreibt das geplante Ziel beim Rip-Start nach
|
||||
# output_path (sprechender Name statt UUID) — alter Fallback bleibt.
|
||||
basis = job.get("target_dir") or f"{MEDIA_ROOT}/{job.get('disc_type') or 'bluray'}"
|
||||
basis = job.get("target_dir") or f"{medien_wurzel()}/{job.get('disc_type') or 'bluray'}"
|
||||
final_dir = job.get("output_path") or f"{basis}/{job_id}"
|
||||
|
||||
# An den (beim Rip gewählten) Encoder-Worker routen, sonst geteilte Queue
|
||||
@@ -1809,11 +1908,23 @@ async def delete_storage_mount(name: str):
|
||||
|
||||
|
||||
@app.get("/browse")
|
||||
async def browse(path: str = MEDIA_ROOT):
|
||||
"""Server-seitiger Ordner-Browser für die Ziel-Auswahl (nur unter /app/media)."""
|
||||
async def browse(path: str = ""):
|
||||
"""Server-seitiger Ordner-Browser für die Ziel- und Arbeitsordner-Wahl.
|
||||
|
||||
Leerer Pfad heißt „ganz oben": im Container die Medien-Wurzel, nativ die
|
||||
Liste der Laufwerke. Ohne diese oberste Ebene käme man auf Windows nie zu
|
||||
einem anderen Laufwerk — und genau dort ist Platz für 100 GB Rohdaten.
|
||||
"""
|
||||
if not path.strip():
|
||||
laufwerke = betrieb_laufwerke() if frei_blaettern() else []
|
||||
if laufwerke:
|
||||
return {"path": "", "parent": None, "dirs": laufwerke, "files": []}
|
||||
path = medien_wurzel()
|
||||
|
||||
normalisiert = os.path.normpath(path)
|
||||
if not unter_wurzel(normalisiert, MEDIA_ROOT):
|
||||
raise HTTPException(status_code=422, detail=f"Nur Pfade unter {MEDIA_ROOT}")
|
||||
if not pfad_erlaubt(normalisiert):
|
||||
raise HTTPException(status_code=422,
|
||||
detail=f"Nur Pfade unter {medien_wurzel()}")
|
||||
|
||||
def liste():
|
||||
try:
|
||||
@@ -1840,8 +1951,37 @@ async def browse(path: str = MEDIA_ROOT):
|
||||
if ergebnis is None:
|
||||
raise HTTPException(status_code=404, detail="Ordner nicht lesbar")
|
||||
ordner, dateien = ergebnis
|
||||
eltern = os.path.dirname(normalisiert) if normalisiert != MEDIA_ROOT else None
|
||||
return {"path": normalisiert, "parent": eltern, "dirs": ordner, "files": dateien}
|
||||
return {"path": normalisiert, "parent": eltern_von(normalisiert),
|
||||
"dirs": ordner, "files": dateien}
|
||||
|
||||
|
||||
def eltern_von(pfad: str, wurzel: str = None, frei: bool = None) -> Optional[str]:
|
||||
"""Wohin führt „eine Ebene höher"? — `None` heißt: hier ist oben.
|
||||
|
||||
Zwei Fallen, beide nur auf Windows sichtbar:
|
||||
|
||||
1. `os.path.dirname("C:\\\\")` ist wieder `"C:\\\\"`. Ein Knopf „nach oben",
|
||||
der auf denselben Ordner zeigt, sieht aus wie ein Fehler.
|
||||
2. Über der Laufwerkswurzel steht nicht *nichts*, sondern die Liste der
|
||||
Laufwerke — der leere Pfad. Sonst käme man von `D:\\` nie zu `C:\\`.
|
||||
"""
|
||||
frei = frei_blaettern() if frei is None else frei
|
||||
wurzel = (medien_wurzel() if wurzel is None else wurzel)
|
||||
if not frei:
|
||||
return os.path.dirname(pfad) if pfad != wurzel else None
|
||||
oben = os.path.dirname(pfad)
|
||||
# Laufwerkswurzel (dirname zeigt auf sich selbst) -> die Laufwerksliste.
|
||||
return "" if oben == pfad else oben
|
||||
|
||||
|
||||
def betrieb_laufwerke() -> list:
|
||||
"""Die Laufwerke als Ordner-Einträge für die oberste Ebene des Browsers."""
|
||||
from rippy import betrieb
|
||||
try:
|
||||
return [{"name": "Laufwerk " + laufwerk.rstrip("\\/"), "path": laufwerk}
|
||||
for laufwerk in betrieb.windows_laufwerke()]
|
||||
except Exception: # noqa: BLE001
|
||||
return []
|
||||
|
||||
|
||||
class MkdirRequest(BaseModel):
|
||||
@@ -1851,10 +1991,11 @@ class MkdirRequest(BaseModel):
|
||||
|
||||
@app.post("/browse/mkdir", status_code=201)
|
||||
async def browse_mkdir(request: MkdirRequest):
|
||||
"""Neuen Ordner unter /app/media anlegen (Speicherziele-Verwaltung)."""
|
||||
"""Neuen Ordner anlegen (Speicherziele-Verwaltung, Arbeitsordner)."""
|
||||
basis = os.path.normpath(request.path)
|
||||
if not unter_wurzel(basis, MEDIA_ROOT):
|
||||
raise HTTPException(status_code=422, detail=f"Nur Pfade unter {MEDIA_ROOT}")
|
||||
if not pfad_erlaubt(basis):
|
||||
raise HTTPException(status_code=422,
|
||||
detail=f"Nur Pfade unter {medien_wurzel()}")
|
||||
name = request.name.strip()
|
||||
if not name or "/" in name or "\\" in name or name.startswith("."):
|
||||
raise HTTPException(status_code=422, detail="Ungültiger Ordnername")
|
||||
|
||||
@@ -25,6 +25,13 @@ from cache.keys import generate_prescan_key
|
||||
# test_treiberwahl.py.
|
||||
detection = _laufwerks_schicht.treiber()
|
||||
|
||||
# Wie viele Treffer eine Suche hoechstens liefern darf, damit sie als
|
||||
# SPEZIFISCH gilt. Gemessen am 28.08.2026: Der volle Disc-Titel
|
||||
# „Evangelion 2.22" ergab bei TMDB 1 Treffer (den richtigen), die gekuerzte
|
||||
# Variante „Evangelion" ergab 20 (beliebige). Drei ist grosszuegig genug fuer
|
||||
# Neuauflagen und Regie-Fassungen desselben Films.
|
||||
WENIGE_TREFFER = 3
|
||||
|
||||
def normalize_disc_label(label: str) -> str:
|
||||
"""Disc-Labels wie 'PULP_FICTION_DE' → 'Pulp Fiction De' (pure Funktion).
|
||||
|
||||
@@ -447,11 +454,13 @@ class PreScan:
|
||||
# probieren — Disc-Titel sind selten API-freundlich formatiert.
|
||||
kandidaten = titel_kandidaten(title) or [title]
|
||||
movies = []
|
||||
movies_kandidat = "" # WELCHE Variante hat sie geliefert?
|
||||
|
||||
for kandidat in kandidaten:
|
||||
treffer = self.tmdb.search_movie(kandidat)
|
||||
if treffer and not movies:
|
||||
movies = treffer # bester Rohtreffer für den Vorschlags-Fallback
|
||||
movies_kandidat = kandidat
|
||||
for movie in treffer:
|
||||
if movie.get("title", "").lower() == kandidat.lower():
|
||||
confidence = 0.95
|
||||
@@ -498,6 +507,55 @@ class PreScan:
|
||||
if matched:
|
||||
break
|
||||
|
||||
# ── Ein spezifischer Treffer zählt, auch ohne exakten Titel ─────────
|
||||
#
|
||||
# ## Der Befund des Commanders (28.08.2026)
|
||||
#
|
||||
# > „Was ist mit dem Cover auf Windows Rippy? In der Web Version haben
|
||||
# > wir ein cover."
|
||||
#
|
||||
# Es gab keins, weil es gar keinen Treffer gab — und der Grund war
|
||||
# nicht Windows, sondern die Bedingung oben: `movie["title"].lower()
|
||||
# == kandidat.lower()`. An seiner Disc gemessen:
|
||||
#
|
||||
# 'Evangelion 2.22' 1 Treffer Evangelion: 2.0 You Can (Not) Advance
|
||||
# 'Evangelion' 20 Treffer irgendein Evangelion
|
||||
#
|
||||
# Der EINZIGE Treffer auf den vollen Disc-Titel war der richtige Film,
|
||||
# mit Poster — und wurde verworfen, weil der Titel nicht wörtlich
|
||||
# gleich war. Danach gewann eine schlechtere Quelle oder gar keine.
|
||||
#
|
||||
# ## Warum die Trefferzahl das bessere Kriterium ist als Ähnlichkeit
|
||||
#
|
||||
# Die Ähnlichkeit hilft hier nicht: „Evangelion 2.22" gegen
|
||||
# „Evangelion: 2.0 You Can (Not) Advance" ergibt 0,51 — das steht als
|
||||
# Messung schon im Jikan-Absatz unten und fällt durch jedes sinnvolle
|
||||
# Gatter. Die SPEZIFITÄT der Anfrage sagt mehr: Wer auf den vollen,
|
||||
# ungekürzten Disc-Titel eine Handvoll Treffer bekommt, hat gefragt
|
||||
# wie jemand, der weiß, was er sucht. Wer 20 bekommt, hat geraten.
|
||||
#
|
||||
# Deshalb: nur der UNGEKÜRZTE Titel (kandidaten[0]) und nur wenige
|
||||
# Treffer. Confidence 0,8 — sicherer als ein Vorschlag (0,6), aber
|
||||
# ehrlich unter einem wörtlichen Treffer (0,95).
|
||||
if not matched and movies and movies_kandidat == kandidaten[0] \
|
||||
and len(movies) <= WENIGE_TREFFER:
|
||||
details = self.tmdb.get_movie_details(movies[0]["id"])
|
||||
if details:
|
||||
confidence = 0.8
|
||||
metadata = {
|
||||
"type": "movie",
|
||||
"id": movies[0]["id"],
|
||||
"title": details.get("title", title),
|
||||
"year": int(details.get("release_date", "0")[:4]) if details.get("release_date") else None,
|
||||
"overview": details.get("overview", ""),
|
||||
"poster_path": details.get("poster_path", ""),
|
||||
"backdrop_path": details.get("backdrop_path", ""),
|
||||
"runtime": details.get("runtime", 0),
|
||||
"genres": [g["name"] for g in details.get("genres", [])],
|
||||
"source": "tmdb",
|
||||
}
|
||||
matched = True
|
||||
|
||||
# Fallback 1: Jikan/MyAnimeList (kostenlos, KEIN Key) — für Anime die
|
||||
# präziseste Quelle; wählt per Titel-Ähnlichkeit, nicht Treffer #1.
|
||||
#
|
||||
|
||||
@@ -401,7 +401,12 @@ def test_betrieb_meldet_faehigkeiten_statt_nur_einen_namen():
|
||||
assert daten["modus"] in ("standalone", "verteilt")
|
||||
assert daten["plattform"] in ("windows", "linux", "macos")
|
||||
assert set(daten["kann"]) == {"externe_worker", "freigaben_einhaengen",
|
||||
"container_pfade", "werkzeuge_verwalten"}
|
||||
"container_pfade", "werkzeuge_verwalten",
|
||||
"frei_blaettern"}
|
||||
# Beide Orte muessen dabei sein. Fehlte einer, muesste die Oberflaeche
|
||||
# wieder raten — und genau daraus wurde „Container-Platte" auf einem PC,
|
||||
# der keinen Container hat (Befund 28.08.2026).
|
||||
assert daten["ablage_vorgabe"] and daten["arbeits_vorgabe"]
|
||||
assert all(isinstance(w, bool) for w in daten["kann"].values())
|
||||
# Ein Hinweis auf docker compose darf NUR im Container erscheinen.
|
||||
if not daten["im_container"]:
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user