fix(windows): Drueberinstallieren, Linux-Reste im Windows-Betrieb, Ordner-Waehler
Ampel / ampel (push) Successful in 1m27s

Vier Fragen des Commanders vom 29.08.2026, drei davon mit Codefolge.

## 1. „Was passiert wenn man die Setup.exe einfach drueber installiert?"

Bis hierher: nicht zuverlaessig. Rippy startet mit Windows, laeuft also fast
immer — dann ist `Rippy.exe` gesperrt, `shutil.copy2` warf PermissionError,
und die neue Fassung landete als `Rippy.exe.neu` daneben. Dazu die Meldung
„wird beim naechsten Start uebernommen".

**Diese Zusage hat niemand eingeloest.** `.neu` kam im ganzen Projekt genau
einmal vor: an der Stelle, die es schrieb. Wer drueberinstallierte, behielt
still die alte Fassung, und das Setup meldete Erfolg.

Jetzt wird der laufende Rippy vorher beendet (`dienst_beenden` gibt es seit
der Deinstallation und wartet auch die zwei Sekunden ab, die Windows fuer die
Dateihandles braucht). Eine von einer aelteren Setup-Fassung liegengelassene
`.neu` wird dabei uebernommen. Bleibt die Datei DANN noch gesperrt, gibt es
einen klaren Fehler statt einer Zusage — Rippy im Infobereich beenden und das
Setup erneut starten.

Der Tausch laeuft bewusst im SETUP und nicht beim Dienststart: Windows sperrt
eine laufende .exe, und `Rippy.exe` waere genau die zu ersetzende Datei.

## 3. Linux-Reste im Windows-Betrieb (Docker/Headless unveraendert)

**`caps.py`: `os.path.isdir("/app")`.** Damit hielt sich der eigenstaendige
Windows-Rippy fuer einen FREMDEN Worker — und das UI warnte vor fehlender
Pfad-Uebersetzung auf einer Maschine ohne Container und ohne Freigabe.
„Extern" heisst jetzt, was es meint: Rippy laeuft woanders als dieser Worker.

**`caps.py`: `shutil.which("makemkvcon")` + `os.path.ismount(daten_dir)`.**
Beide unter Windows immer falsch (Programme liegen nicht im PATH, ein
normaler Ordner ist kein Mount). Die Schluessel-Auskunft blieb dauerhaft
„unbekannt", obwohl MakeMKV samt Datenverzeichnis da war. Der Mount-Test
bleibt fuer den Container, wo er einen Zweck hat.

**`rohdaten.py`: `/app/temp/raw` und `/app/media` fest.** Dieses Modul findet
die Rohdaten eines Jobs wieder — fuer den Wiederholen-Dialog und fuer
„Rohdaten mitloeschen". Unter Windows fand es NIE etwas: Der Dialog meldete
„keine Rohdaten", das Aufraeumen loeschte nichts, und die Bruchstuecke eines
abgebrochenen Rips blieben liegen (bei 4K-UHD bis 100 GB).

Sieben Tests wurden dabei rot, und zwar zu Recht: Sie pruefen Container-Regeln,
liefen aber unter Windows. Die Wurzeln sind jetzt einspritzbar — beide
Betriebsfaelle auf jedem Rechner pruefbar statt vom laufenden abhaengig.

## 4. „Der Durchsuchen button fehlt. Wie es der Installer auch macht"

Neu: `OrdnerWaehler` — Pfadfeld plus „Durchsuchen …", benutzt fuer Ablage und
Arbeitsverzeichnis. Es waere der DRITTE fest eingebaute Ordner-Browser
geworden (RipTargetModal, StorageMounts); dieser hier ist wiederverwendbar.
`/browse` weiss seit dem 28.08. selbst, in welchem Betrieb es laeuft.

⚠️ Beim Einbau fiel der Import unter den Tisch. `vite` pruefte das NICHT — das
Buendel blieb byte-gleich gross, und zur Laufzeit waere es der naechste leere
Bildschirm gewesen. Aufgefallen nur, weil die erwartete Anzahl Ersetzungen
nicht stimmte. Im Browser gegengeprueft: alle sieben Laufwerke, Navigation in
D:\, keine Konsolenfehler.

843 Tests gruen, ruff sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-08-29 14:28:01 +02:00
co-authored by Claude Opus 5
parent 21b1757aa4
commit b2acddbdfa
9 changed files with 440 additions and 39 deletions
+5 -3
View File
@@ -6,7 +6,6 @@ from typing import List, Optional, Dict
import asyncio
import json
import os
import posixpath
import shutil
import time
import uuid
@@ -836,8 +835,11 @@ def _kann_neu_komprimieren(job: dict, work_dir: str) -> bool:
if job["id"] in vorrat:
return bool(vorrat[job["id"]])
# Nur der lokale Ort: /app/temp ist ein Docker-Volume, os.path.isdir kann
# dort nicht hängen (im Gegensatz zu allem unter /app/media).
return os.path.isdir(posixpath.join(rohdaten.RAW_STANDARD, job["id"]))
# dort nicht hängen (im Gegensatz zu allem unter /app/media). Die Wurzel
# kommt vom Betrieb — auf Windows gibt es kein /app/temp (29.08.2026).
from rippy import pfade
return os.path.isdir(pfade.verbinden(rohdaten.wurzeln()[0], job["id"]))
@app.get("/jobs", response_model=List[Job])
+51 -13
View File
@@ -41,15 +41,44 @@ Job-ID (vollständige UUID), fertige Ablagen heißen `Titel (Jahr) [kurz-id]`.
import posixpath
import subprocess
# Container-Standard für Roh-Rips (RAW_DIR im Worker).
# Container-Standard für Roh-Rips (RAW_DIR im Worker). Bleibt als Rueckfall
# stehen — die WURZELN dieses Betriebs liefert `wurzeln()`.
RAW_STANDARD = "/app/temp/raw"
MEDIA_ROOT = "/app/media"
def wurzeln(werte=None) -> tuple:
"""`(roh_standard, medien_wurzel, frei)` fuer DIESEN Betrieb.
## Warum das nicht fest sein darf (Befund 29.08.2026)
Dieses Modul findet die Rohdaten eines Jobs wieder — fuer den
Wiederholen-Dialog („auf der Platte liegen X GB Rohdaten") und fuer
„Rohdaten mitloeschen". Es suchte fest unter `/app/temp/raw` und
`/app/media`.
Auf einem Windows-PC gibt es beides nicht. Also fand es NIE etwas: Der
Dialog meldete „keine Rohdaten", das Aufraeumen loeschte nichts, und die
Bruchstuecke eines abgebrochenen Rips blieben unbemerkt liegen — bei einer
4K-UHD bis zu 100 GB.
"""
from rippy import betrieb, config
if werte is None:
try:
werte = config.laden()
except Exception: # noqa: BLE001
werte = {}
return (betrieb.arbeits_vorgabe(werte) or RAW_STANDARD,
betrieb.medien_wurzel(werte) or MEDIA_ROOT,
betrieb.frei_blaettern(werte))
# Harte Obergrenze für EINE Verzeichnis-Prüfung. Siehe verzeichnis_da().
PRUEF_TIMEOUT_SEKUNDEN = 4
def kandidaten(job_id: str, work_dir: str, media_unterordner) -> list:
def kandidaten(job_id: str, work_dir: str, media_unterordner,
orte_wurzeln=None) -> list:
"""Alle Orte, an denen die Roh-MKVs dieses Jobs liegen KÖNNTEN (pure).
`media_unterordner` sind die Namen der obersten Ebene unter /app/media
@@ -65,13 +94,18 @@ def kandidaten(job_id: str, work_dir: str, media_unterordner) -> list:
"""
if not job_id:
return []
orte = [posixpath.join(RAW_STANDARD, job_id)]
wahl = (work_dir or "").strip().rstrip("/")
if wahl and (wahl == MEDIA_ROOT or wahl.startswith(MEDIA_ROOT + "/")):
orte.append(posixpath.join(wahl, job_id))
roh, medien, frei = orte_wurzeln or (RAW_STANDARD, MEDIA_ROOT, False)
from rippy import pfade
orte = [pfade.verbinden(roh, job_id)]
wahl = (work_dir or "").strip().rstrip("/\\")
# Nativ zaehlt jede Wahl — dort liegt der Arbeitsordner oft auf einem
# ganz anderen Laufwerk und damit unter gar keiner Wurzel.
if wahl and (frei or wahl == medien or wahl.startswith(medien + "/")):
orte.append(pfade.verbinden(wahl, job_id))
for name in media_unterordner or []:
if name:
orte.append(posixpath.join(MEDIA_ROOT, name, job_id))
orte.append(pfade.verbinden(pfade.verbinden(medien, name), job_id))
gesehen, eindeutig = set(), []
for ort in orte:
if ort not in gesehen:
@@ -145,7 +179,8 @@ def verzeichnis_da(pfad: str, laufen=None) -> bool:
return pruefen(pfad, laufen) == "da"
def suche(job_id: str, work_dir: str, listdir, isdir) -> list:
def suche(job_id: str, work_dir: str, listdir, isdir,
orte_wurzeln=None) -> list:
"""Die Orte, an denen wirklich etwas liegt.
`listdir` und `isdir` werden übergeben statt importiert — so ist die Suche
@@ -156,12 +191,13 @@ def suche(job_id: str, work_dir: str, listdir, isdir) -> list:
`listdir` darf os.listdir bleiben: Gelistet wird nur /app/media selbst, und
das ist ein lokales Verzeichnis — die Freigaben sind Unterordner davon.
"""
orte_wurzeln = orte_wurzeln or wurzeln()
try:
unterordner = sorted(listdir(MEDIA_ROOT))
unterordner = sorted(listdir(orte_wurzeln[1]))
except OSError:
unterordner = []
gefunden = []
for ort in kandidaten(job_id, work_dir, unterordner):
for ort in kandidaten(job_id, work_dir, unterordner, orte_wurzeln):
try:
if isdir(ort):
gefunden.append(ort)
@@ -172,7 +208,8 @@ def suche(job_id: str, work_dir: str, listdir, isdir) -> list:
return gefunden
def suche_mit_status(job_id: str, work_dir: str, listdir, pruefer=None) -> dict:
def suche_mit_status(job_id: str, work_dir: str, listdir, pruefer=None,
orte_wurzeln=None) -> dict:
"""Wie suche(), aber sagt auch, ob etwas UNGEPRÜFT geblieben ist.
Rückgabe: {"pfade": [...], "unklar": bool}. `unklar` heißt: Mindestens ein
@@ -181,12 +218,13 @@ def suche_mit_status(job_id: str, work_dir: str, listdir, pruefer=None) -> dict:
behalten, statt Abwesenheit zu behaupten (siehe pruefen()).
"""
pruefe = pruefer or pruefen
orte_wurzeln = orte_wurzeln or wurzeln()
try:
unterordner = sorted(listdir(MEDIA_ROOT))
unterordner = sorted(listdir(orte_wurzeln[1]))
except OSError:
unterordner = []
gefunden, unklar = [], False
for ort in kandidaten(job_id, work_dir, unterordner):
for ort in kandidaten(job_id, work_dir, unterordner, orte_wurzeln):
antwort = pruefe(ort)
if antwort == "da":
gefunden.append(ort)
+14 -7
View File
@@ -19,13 +19,20 @@ def test_der_echte_fall_wird_gefunden():
assert orte[0] == f"/app/temp/raw/{JOB}"
# Die Wurzeln des CONTAINER-Betriebs, eingespritzt. Ohne sie hingen diese
# Tests am laufenden Rechner: Unter Windows liefert `wurzeln()` echte
# Windows-Pfade, und die Erwartungen hier gelten dort nicht (am 29.08.2026
# prompt sieben Tests rot geworden).
CONTAINER = ("/app/temp/raw", "/app/media", False)
def test_suche_liefert_nur_was_existiert():
vorhanden = {f"/app/media/rippy/{JOB}"}
gefunden = rohdaten.suche(
JOB, "",
listdir=lambda p: ["movies", "rippy"],
isdir=lambda p: p in vorhanden,
)
orte_wurzeln=CONTAINER)
assert gefunden == [f"/app/media/rippy/{JOB}"]
@@ -64,7 +71,7 @@ def test_kaputter_mount_reisst_die_suche_nicht_mit():
return p == f"/app/temp/raw/{JOB}"
gefunden = rohdaten.suche(
JOB, "", listdir=lambda p: ["totes-nas", "movies"], isdir=isdir_kaputt)
JOB, "", listdir=lambda p: ["totes-nas", "movies"], isdir=isdir_kaputt, orte_wurzeln=CONTAINER)
assert gefunden == [f"/app/temp/raw/{JOB}"]
@@ -73,7 +80,7 @@ def test_listdir_kaputt_faellt_auf_den_standard_zurueck():
raise OSError("kein /app/media")
gefunden = rohdaten.suche(
JOB, "", listdir=listdir_kaputt, isdir=lambda p: True)
JOB, "", listdir=listdir_kaputt, isdir=lambda p: True, orte_wurzeln=CONTAINER)
assert gefunden == [f"/app/temp/raw/{JOB}"]
@@ -177,7 +184,7 @@ def test_suche_mit_der_zeitgrenze_findet_den_echten_fall():
JOB, "",
listdir=lambda p: ["bluray", "movies", "rippy"],
isdir=lambda p: rohdaten.verzeichnis_da(p, laufen),
)
orte_wurzeln=CONTAINER)
assert gefunden == [f"/app/media/rippy/{JOB}"]
@@ -206,7 +213,7 @@ def test_suche_mit_status_meldet_ungepruefte_orte():
return "unklar" if pfad.startswith("/app/media/rippy/") else "weg"
e = rohdaten.suche_mit_status(
JOB, "", listdir=lambda p: ["rippy"], pruefer=pruefe)
JOB, "", listdir=lambda p: ["rippy"], pruefer=pruefe, orte_wurzeln=CONTAINER)
assert e == {"pfade": [], "unklar": True}
@@ -215,7 +222,7 @@ def test_suche_mit_status_ohne_zweifel():
return "da" if pfad == f"/app/media/rippy/{JOB}" else "weg"
e = rohdaten.suche_mit_status(
JOB, "", listdir=lambda p: ["movies", "rippy"], pruefer=pruefe)
JOB, "", listdir=lambda p: ["movies", "rippy"], pruefer=pruefe, orte_wurzeln=CONTAINER)
assert e == {"pfade": [f"/app/media/rippy/{JOB}"], "unklar": False}
@@ -227,6 +234,6 @@ def test_suche_mit_status_findet_trotz_unklarem_anderen_ort():
return "unklar" if "totes-nas" in pfad else "weg"
e = rohdaten.suche_mit_status(
JOB, "", listdir=lambda p: ["rippy", "totes-nas"], pruefer=pruefe)
JOB, "", listdir=lambda p: ["rippy", "totes-nas"], pruefer=pruefe, orte_wurzeln=CONTAINER)
assert e["pfade"] == [f"/app/media/rippy/{JOB}"]
assert e["unklar"] is True