fix(windows): Vollstaendiger Rundgang durch die Docker-Reste
Ampel / ampel (push) Successful in 1m20s
Ampel / ampel (push) Successful in 1m20s
Commander: „Bro, du musst alles was rippy jetzt im code hat für Windows
Bauen! Jeden pfad, alles wo die tools drauf zugreifen. Diese Rippy version
MUSS 100% Windows Kompatibel sein. Prüfe bitte den kompletten Quellcode nach
Docker Resten."
Systematisch gesucht statt Fundstelle fuer Fundstelle: feste POSIX-Pfade,
Linux-Programme, POSIX-eigene Aufrufe, `shutil.which`, `posixpath` auf echten
Pfaden, Container-Texte. Sechs echte Fehler dabei.
## 1. `/dev/{name}` in drei Endpunkten — der schwerste
Das UI ruft `/devices/{id}/eject`, `/scan-tracks` und `/tracks` mit der
Kennung aus der Geraeteliste auf, unter Windows also `G`. Gebaut wurde daraus
`/dev/G` — steht in keiner Laufwerksliste. **Auswerfen und „Disc scannen"
antworteten unter Windows IMMER mit 404**, ohne dass irgendwo stand, warum.
Hin- und Rueckweg gehoeren zusammen: Beide Treiber haben jetzt `kennung()`
und `pfad_zu_kennung()`. Wer die Kennung vergibt, loest sie auch auf.
## 2. `os.path.isdir("/app")` — zum zweiten Mal
Nach `caps.py` (heute frueh) auch in `ablauf.py`: Der eigenstaendige
Windows-Rippy hielt sich fuer einen FREMDEN Worker und haette sich selbst
vorgeworfen, Container-Pfade nicht zu erreichen — auf einer Maschine ohne
Container. Die Entscheidung ist jetzt einspritzbar; vorher hing der Test
daran, ob es einen Ordner `/app` gibt.
## 3. `shutil.which` in `schluessel.py`
Ausgerechnet im Modul, das es NUR unter Windows gibt: Es suchte makemkvcon im
PATH, wo unter Windows nie ein Programm aus „Programme" steht. Die
Schluessel-Automatik fuer 4K-UHD lief damit nie an.
## 4. `posixpath.join` auf echten Pfaden
`rohdaten.py` baute `C:\Roh/datei.mkv` — gemischte Trenner, die im UI falsch
aussehen und jeden Vergleich brechen.
## 5. Container-Pfad in einer Nutzermeldung
„Roh-Datei bleibt in /app/temp erhalten" nennt jetzt den echten Ordner. Wer
die Datei retten will, sucht sonst am falschen Ort.
## 6. Container-Pfade als UI-Vorbelegung
Rip-Dialog und `useBetrieb` starteten mit `/app/media`, bis die Antwort da
war. Leer ist ehrlicher: Es behauptet nichts.
## Und HandBrakes „Code 0"
Code 0 heisst ERFOLG. Rippy meldete trotzdem „fehlgeschlagen", weil die Datei
nicht am erwarteten Ort lag: **HandBrake bestimmt den Container aus dem
PRESET, nicht aus der Endung** — ein MP4-Preset schreibt `.mp4` neben das
verlangte `.mkv`. Jetzt erzwingt `--format` den Container passend zur Endung
(an HandBrake 1.11.2 gegengeprueft), und falls doch etwas daneben liegt, wird
es gefunden statt weggeworfen.
## Der Waechter
`test_keine_container_reste.py` prueft mechanisch, dass im Windows-Weg kein
Container-Pfad ohne Begruendung steht. Die Ausnahmen stehen namentlich mit
Grund da (Linux-Zweige, benannte Rueckfaelle) — und ein zweiter Test wirft
jede Ausnahme raus, die niemand mehr braucht.
Ueber den Tokenizer, nicht ueber „faengt mit Anfuehrungszeichen an": Der
erste Anlauf blieb prompt an seinem eigenen `r\"\"\"`-Docstring haengen.
887 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
1a529f4e75
commit
1b84ec2a45
+28
-7
@@ -1473,12 +1473,35 @@ async def storage_targets():
|
||||
return await asyncio.to_thread(sammle)
|
||||
|
||||
|
||||
def geraetepfad(name: str) -> str:
|
||||
"""Die Kennung aus der URL zum echten Geraetepfad — oder 404.
|
||||
|
||||
## Der Befund (29.08.2026)
|
||||
|
||||
Hier stand an DREI Stellen:
|
||||
|
||||
device_path = f"/dev/{name}"
|
||||
|
||||
Das UI ruft diese Endpunkte mit der Kennung aus der Geraeteliste auf —
|
||||
unter Windows also `G`. Gebaut wurde daraus `/dev/G`, und das steht in
|
||||
keiner Laufwerksliste. **Auswerfen und „Disc scannen" antworteten unter
|
||||
Windows also immer mit 404**, ohne dass irgendwo stuende, warum.
|
||||
|
||||
Aufgeloest wird jetzt vom TREIBER: Er vergibt die Kennung (`kennung`),
|
||||
also loest er sie auch wieder auf. Hin- und Rueckweg koennen damit nicht
|
||||
mehr auseinanderlaufen.
|
||||
"""
|
||||
pfad = device_discovery.pfad_zu_kennung(name)
|
||||
if not pfad:
|
||||
raise HTTPException(status_code=404,
|
||||
detail=f"Laufwerk {name} nicht gefunden")
|
||||
return pfad
|
||||
|
||||
|
||||
@app.post("/devices/{name}/eject")
|
||||
async def eject_device(name: str):
|
||||
"""Wirft die Disc aus. Verweigert, wenn auf dem Gerät gerade ein Job läuft."""
|
||||
device_path = f"/dev/{name}"
|
||||
if device_path not in device_discovery.list_optical_devices():
|
||||
raise HTTPException(status_code=404, detail=f"Laufwerk {device_path} nicht gefunden")
|
||||
device_path = geraetepfad(name)
|
||||
if await asyncio.to_thread(db.has_active_job, device_path):
|
||||
raise HTTPException(
|
||||
status_code=409, detail="Auf diesem Laufwerk läuft gerade ein Job"
|
||||
@@ -1498,9 +1521,7 @@ async def scan_tracks_starten(name: str):
|
||||
Läuft als Worker-Task (nur der hat makemkvcon + Laufwerk); das UI pollt
|
||||
GET /devices/{name}/tracks. Dauert je nach Disc 20–120 s.
|
||||
"""
|
||||
device_path = f"/dev/{name}"
|
||||
if device_path not in device_discovery.list_optical_devices():
|
||||
raise HTTPException(status_code=404, detail=f"Laufwerk {device_path} nicht gefunden")
|
||||
device_path = geraetepfad(name)
|
||||
if await asyncio.to_thread(db.has_active_job, device_path):
|
||||
raise HTTPException(status_code=409, detail="Auf diesem Laufwerk läuft gerade ein Job")
|
||||
|
||||
@@ -1512,7 +1533,7 @@ async def scan_tracks_starten(name: str):
|
||||
@app.get("/devices/{name}/tracks")
|
||||
async def scan_tracks_ergebnis(name: str):
|
||||
"""Ergebnis des Titel-Scans (Polling-Ziel des UI)."""
|
||||
device_path = f"/dev/{name}"
|
||||
device_path = geraetepfad(name)
|
||||
daten = await asyncio.to_thread(db.get_settings, f"tracks:{device_path}")
|
||||
if not daten:
|
||||
return {"status": "none"}
|
||||
|
||||
@@ -38,9 +38,10 @@ Verwechslungsgefahr gibt es dabei nicht: Roh-Verzeichnisse heißen exakt wie die
|
||||
Job-ID (vollständige UUID), fertige Ablagen heißen `Titel (Jahr) [kurz-id]`.
|
||||
"""
|
||||
|
||||
import posixpath
|
||||
import subprocess
|
||||
|
||||
from rippy import pfade as _pfade
|
||||
|
||||
# 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"
|
||||
@@ -246,7 +247,11 @@ def groesse(pfade: list, listdir, isfile, getsize) -> tuple:
|
||||
except OSError:
|
||||
continue
|
||||
for name in namen:
|
||||
voll = posixpath.join(pfad, name)
|
||||
# `pfade.verbinden` statt posixpath: Auf Windows ist `pfad`
|
||||
# ein echter Windows-Pfad, und `posixpath.join` baute daraus
|
||||
# `C:\Roh/datei.mkv` — gemischte Trenner, die im UI falsch
|
||||
# aussehen und jeden Vergleich brechen (Befund 29.08.2026).
|
||||
voll = _pfade.verbinden(pfad, name)
|
||||
try:
|
||||
if isfile(voll):
|
||||
bytes_gesamt += getsize(voll)
|
||||
|
||||
@@ -162,11 +162,15 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
||||
// auf einem Windows-PC eine Ortsangabe fuer einen Ort, den es nicht gibt.
|
||||
const betrieb = useBetrieb()
|
||||
|
||||
const [targets, setTargets] = useState<TargetConfig[]>([
|
||||
{ id: '1', name: 'Filme', path: '/app/media/movies', type: 'movies', isActive: true },
|
||||
{ id: '2', name: 'Serien', path: '/app/media/series', type: 'series', isActive: true },
|
||||
{ id: '3', name: 'Musik', path: '/app/media/music', type: 'music', isActive: true },
|
||||
])
|
||||
/*
|
||||
* Leer starten statt mit Container-Pfaden (Befund 29.08.2026).
|
||||
*
|
||||
* Hier standen `/app/media/movies` & Co. als Anfangswert. Auf einem
|
||||
* Windows-PC gibt es die nicht — und fuer den Augenblick zwischen Oeffnen
|
||||
* des Dialogs und der Antwort von /settings stand dort ein Pfad, den es
|
||||
* nirgends gibt. Ein leerer Wert ist ehrlicher: Er behauptet nichts.
|
||||
*/
|
||||
const [targets, setTargets] = useState<TargetConfig[]>([])
|
||||
const [browsePath, setBrowsePath] = useState<string>('')
|
||||
const [browseParent, setBrowseParent] = useState<string | null>(null)
|
||||
const [browseDirs, setBrowseDirs] = useState<BrowseDir[]>([])
|
||||
@@ -223,7 +227,7 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
||||
.split(',').map((t: string) => t.trim().toLowerCase()).filter(Boolean)
|
||||
setStandardAudio(codes(s.audioSprachen))
|
||||
setStandardUntertitel(codes(s.untertitelSprachen))
|
||||
const basis = s.outputDir || betrieb.ablage_vorgabe || '/app/media'
|
||||
const basis = s.outputDir || betrieb.ablage_vorgabe
|
||||
setTargets([
|
||||
{ id: '1', name: 'Filme', path: `${basis}/${s.movieDir || 'movies'}`, type: 'movies', isActive: true },
|
||||
{ id: '2', name: 'Serien', path: `${basis}/${s.seriesDir || 'series'}`, type: 'series', isActive: true },
|
||||
|
||||
@@ -77,8 +77,11 @@ const VORGABE: Betrieb = {
|
||||
werkzeuge_verwalten: false,
|
||||
frei_blaettern: false,
|
||||
},
|
||||
ablage_vorgabe: '/app/media',
|
||||
arbeits_vorgabe: '/app/temp',
|
||||
// Leer statt Container-Pfade: Bis /betrieb geantwortet hat, WISSEN wir den
|
||||
// Ort nicht. Ein leeres Feld sagt das; „/app/media" behauptet etwas, das
|
||||
// auf einem Windows-PC falsch ist (Befund 29.08.2026).
|
||||
ablage_vorgabe: '',
|
||||
arbeits_vorgabe: '',
|
||||
hilfe_befehl: 'docker compose -p rippy ps',
|
||||
geladen: false,
|
||||
}
|
||||
|
||||
+21
-3
@@ -867,7 +867,8 @@ def erster_vorhandener_ordner(pfad: str, isdir=None, dirname=None) -> str:
|
||||
|
||||
|
||||
def _erreichbarkeit_pruefen(raw_container: str, raw_lokal: str,
|
||||
final_container: str, final_lokal: str) -> str:
|
||||
final_container: str, final_lokal: str,
|
||||
fremd=None) -> str:
|
||||
"""Kann DIESER Worker Quelle und Ziel überhaupt sehen? Klartext oder "".
|
||||
|
||||
Befund 25.07.2026 am Job 95afdc89, live: Das gezielte Routing an den
|
||||
@@ -889,7 +890,20 @@ def _erreichbarkeit_pruefen(raw_container: str, raw_lokal: str,
|
||||
Quelle — sonst scheitert es erst beim Schreiben, nach Stunden Rechenzeit.
|
||||
"""
|
||||
mapping = os.getenv("RIPPY_PATH_MAP", "")
|
||||
fremder_worker = not os.path.isdir("/app")
|
||||
# ⚠️ Hier stand `not os.path.isdir("/app")` (Befund 29.08.2026) — dasselbe
|
||||
# Muster wie in `caps.py`. Im Image stimmt es; auf einem Windows-PC gibt
|
||||
# es `/app` nicht, und der eigenstaendige Rippy hielt sich fuer einen
|
||||
# FREMDEN Worker. Er haette sich dann selbst vorgeworfen, die
|
||||
# Container-Pfade nicht zu erreichen — auf einer Maschine ohne Container.
|
||||
#
|
||||
# `fremd` ist einspritzbar, damit BEIDE Faelle ueberall pruefbar sind:
|
||||
# Vorher haing der Test daran, ob es einen Ordner `/app` gibt, und war
|
||||
# damit an den laufenden Rechner gebunden.
|
||||
if fremd is None:
|
||||
from caps import _extern
|
||||
|
||||
fremd = _extern() == "ja"
|
||||
fremder_worker = fremd
|
||||
|
||||
for zweck, container, lokal in (
|
||||
("die Quelle (Rohdaten)", raw_container, raw_lokal),
|
||||
@@ -1067,7 +1081,11 @@ def komprimieren(job_id: str, raw_dir: str, final_dir: str):
|
||||
"status": "error",
|
||||
"error": (
|
||||
f"Kompression fehlgeschlagen bei {os.path.basename(quelle)}: "
|
||||
f"{hb.get('error')} — Roh-Datei bleibt in /app/temp erhalten"
|
||||
# Den ECHTEN Ordner nennen, nicht „/app/temp" (Befund
|
||||
# 29.08.2026). Auf Windows gibt es den nicht, und wer die
|
||||
# Roh-Datei retten will, sucht sonst am falschen Ort.
|
||||
f"{hb.get('error')} — Roh-Datei bleibt unter "
|
||||
f"{os.path.dirname(quelle)} erhalten"
|
||||
),
|
||||
}
|
||||
_job_abschliessen(job_id, ergebnis)
|
||||
|
||||
@@ -476,6 +476,12 @@ def preset_fuer(disc_type: str, einstellungen: dict) -> str:
|
||||
return (einstellungen.get("transcodePreset") or "").strip() or DEFAULT_HB_PRESET
|
||||
|
||||
|
||||
#: Endung -> HandBrake-Container. Am mitgelieferten HandBrake 1.11.2
|
||||
#: gegengeprueft (`--help`, Abschnitt `-f, --format`).
|
||||
FORMATE = {".mkv": "av_mkv", ".mp4": "av_mp4", ".m4v": "av_mp4",
|
||||
".mov": "av_mov", ".webm": "av_webm"}
|
||||
|
||||
|
||||
def build_handbrake_cmd(input_path: str, output_path: str,
|
||||
preset: str = DEFAULT_HB_PRESET,
|
||||
audio_sprachen=None, untertitel_sprachen=None) -> list:
|
||||
@@ -510,6 +516,25 @@ def build_handbrake_cmd(input_path: str, output_path: str,
|
||||
"--output", output_path,
|
||||
"--preset", preset,
|
||||
]
|
||||
# Den Container zur Endung erzwingen (Befund 29.08.2026).
|
||||
#
|
||||
# Commander: „Kompression fehlgeschlagen bei title_t00.mkv: HandBrake
|
||||
# endete mit Code 0"
|
||||
#
|
||||
# Code 0 heisst bei HandBrake ERFOLG — und trotzdem lag am erwarteten Ort
|
||||
# keine Datei. Der Grund: **HandBrake bestimmt den Container aus dem
|
||||
# Preset, nicht aus der Endung.** Steht ein MP4-Preset ein, schreibt es
|
||||
# `title_t00.mp4` neben das verlangte `title_t00.mkv`, meldet „Output
|
||||
# format changed" — und beendet sich mit 0. Rippy sah an seiner Stelle
|
||||
# nichts und nannte das „fehlgeschlagen".
|
||||
#
|
||||
# `-f/--format` ist der dokumentierte Schalter dafuer (an dem
|
||||
# mitgelieferten HandBrake 1.11.2 gegengeprueft: av_mp4, av_mov, av_mkv,
|
||||
# av_webm). Damit sind Endung und Container EINE Entscheidung statt zwei,
|
||||
# die auseinanderlaufen koennen.
|
||||
format_name = FORMATE.get(os.path.splitext(output_path)[1].lower())
|
||||
if format_name:
|
||||
befehl += ["--format", format_name]
|
||||
audio = [s for s in (audio_sprachen or []) if s]
|
||||
if audio:
|
||||
befehl += ["--audio-lang-list", ",".join(audio)]
|
||||
@@ -579,6 +604,23 @@ def sprachliste(wert) -> list:
|
||||
return sauber
|
||||
|
||||
|
||||
def _datei_daneben(erwartet: str) -> str:
|
||||
"""Dieselbe Datei mit anderer Endung im selben Ordner — oder "".
|
||||
|
||||
HandBrake waehlt den Container nach dem Preset. Passt er nicht zur
|
||||
verlangten Endung, liegt das Ergebnis unter demselben Namen mit anderer
|
||||
Endung daneben (Befund 29.08.2026). Gesucht wird nur in den Endungen, die
|
||||
HandBrake ueberhaupt schreiben kann.
|
||||
"""
|
||||
ordner = os.path.dirname(erwartet)
|
||||
stamm = os.path.splitext(os.path.basename(erwartet))[0]
|
||||
for endung in FORMATE:
|
||||
kandidat = os.path.join(ordner, stamm + endung)
|
||||
if kandidat != erwartet and os.path.isfile(kandidat):
|
||||
return kandidat
|
||||
return ""
|
||||
|
||||
|
||||
def run_handbrake(input_path: str, output_path: str, preset: str = DEFAULT_HB_PRESET,
|
||||
progress_cb=None, abbruch_cb=None,
|
||||
audio_sprachen=None, untertitel_sprachen=None) -> dict:
|
||||
@@ -659,6 +701,33 @@ def _handbrake_schleife(process, output_path: str, abbruch_cb=None, progress_cb=
|
||||
|
||||
if process.returncode == 0 and os.path.exists(output_path):
|
||||
return {"status": "success", "output_path": output_path}
|
||||
|
||||
# Code 0, aber die Datei fehlt: HandBrake hat sie woanders hingeschrieben.
|
||||
#
|
||||
# Commander 29.08.2026: „Kompression fehlgeschlagen bei title_t00.mkv:
|
||||
# HandBrake endete mit Code 0". Code 0 heisst ERFOLG — HandBrake meldet
|
||||
# Fehler notorisch nicht ueber den Rueckgabewert. Der haeufigste Fall ist
|
||||
# der Container: Er kommt aus dem PRESET, nicht aus der Endung. Ein
|
||||
# MP4-Preset schreibt `title_t00.mp4` neben das verlangte `.mkv`.
|
||||
#
|
||||
# Verhindert wird das jetzt mit `--format` (siehe build_handbrake_cmd).
|
||||
# Dieser Zweig bleibt trotzdem: Er FINDET die Datei, statt einen
|
||||
# erfolgreichen Lauf wegzuwerfen — und sagt, was passiert ist.
|
||||
if process.returncode == 0:
|
||||
daneben = _datei_daneben(output_path)
|
||||
if daneben:
|
||||
return {"status": "success", "output_path": daneben,
|
||||
"hinweis": "HandBrake hat %s geschrieben statt %s "
|
||||
"(der Container kommt aus dem Preset)."
|
||||
% (os.path.basename(daneben),
|
||||
os.path.basename(output_path))}
|
||||
return {
|
||||
"status": "error",
|
||||
"error": ("HandBrake meldet Erfolg, aber es ist keine Datei "
|
||||
"entstanden. Meist ist der Zielordner nicht "
|
||||
"beschreibbar: %s" % os.path.dirname(output_path)),
|
||||
"return_code": 0,
|
||||
}
|
||||
if falsches_preset:
|
||||
return {
|
||||
"status": "error",
|
||||
|
||||
@@ -101,7 +101,14 @@ def makemkvcon_pfad() -> str:
|
||||
for pfad in fest:
|
||||
if os.path.isfile(pfad):
|
||||
return pfad
|
||||
return shutil.which("makemkvcon64") or shutil.which("makemkvcon") or ""
|
||||
# ⚠️ `shutil.which` sucht NUR im PATH (Befund 29.08.2026). Unter Windows
|
||||
# liegt MakeMKV in „Programme" und steht dort nie — ausgerechnet in dem
|
||||
# Modul, das es NUR unter Windows gibt. Die Schluessel-Automatik lief
|
||||
# damit nie an. Der Werkzeug-Katalog kennt die echten Orte.
|
||||
from rippy.tools import katalog
|
||||
|
||||
return (katalog.finden("makemkv")
|
||||
or shutil.which("makemkvcon64") or shutil.which("makemkvcon") or "")
|
||||
|
||||
|
||||
def parse_laufwerke(ausgabe: str) -> list:
|
||||
|
||||
@@ -84,6 +84,11 @@ def _pruefen(monkeypatch, vorhandene, mapping, im_container=False):
|
||||
return tasks._erreichbarkeit_pruefen(
|
||||
"/app/media/rippy/job1", "\\\\NAS\\rippy\\job1",
|
||||
"/app/media/rippy/movies/Akira (1988)", "\\\\NAS\\rippy\\movies\\Akira (1988)",
|
||||
# „Fremd" heisst: Rippy laeuft woanders als dieser Worker. Bis zum
|
||||
# 29.08.2026 wurde das an der Existenz von `/app` festgemacht — damit
|
||||
# hing dieser Test am laufenden Rechner, und unter Windows hielt sich
|
||||
# der eigenstaendige Rippy fuer einen fremden Worker.
|
||||
fremd=not im_container,
|
||||
)
|
||||
|
||||
|
||||
@@ -124,6 +129,10 @@ def test_nicht_uebersetzter_pfad_beschuldigt_sehr_wohl_die_karte(monkeypatch):
|
||||
meldung = tasks._erreichbarkeit_pruefen(
|
||||
"/app/media/rippy/job1", "/app/media/rippy/job1",
|
||||
"/app/media/movies/X", "/app/media/movies/X",
|
||||
# Ausdruecklich ein FREMDER Worker — nur den betrifft die Pfad-Karte.
|
||||
# Vorher ergab sich das aus „es gibt kein /app", und genau daran hing
|
||||
# der Test am laufenden Rechner (Befund 29.08.2026).
|
||||
fremd=True,
|
||||
)
|
||||
assert "deckt diesen Pfad aber nicht ab" in meldung
|
||||
assert "von keinem Eintrag übersetzt" in meldung
|
||||
|
||||
Reference in New Issue
Block a user