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
|
||||
|
||||
@@ -229,6 +229,24 @@ def read_sys_attr(device_name: str, attr: str) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def kennung(device_path: str) -> str:
|
||||
"""Der kurze Name eines Laufwerks — `/dev/sr0` wird zu `sr0`.
|
||||
|
||||
Gegenstueck zu `pfad_zu_kennung`. Begruendung im Windows-Treiber: Hin-
|
||||
und Rueckweg muessen zusammenpassen, sonst antworten die Endpunkte mit
|
||||
404 (Befund 29.08.2026).
|
||||
"""
|
||||
return os.path.basename(device_path)
|
||||
|
||||
|
||||
def pfad_zu_kennung(name: str, geraete=None) -> str:
|
||||
"""Kennung -> Geraetepfad, oder "" wenn es dieses Laufwerk nicht gibt."""
|
||||
for pfad in (geraete if geraete is not None else list_optical_devices()):
|
||||
if pfad == name or kennung(pfad) == name:
|
||||
return pfad
|
||||
return ""
|
||||
|
||||
|
||||
def device_info(device_path: str) -> dict:
|
||||
"""Baut den Geräte-Eintrag fürs UI: Name aus /sys, Disc-Status per ioctl.
|
||||
|
||||
@@ -247,7 +265,7 @@ def device_info(device_path: str) -> dict:
|
||||
drive_status,
|
||||
)
|
||||
|
||||
name = os.path.basename(device_path)
|
||||
name = kennung(device_path)
|
||||
vendor = read_sys_attr(name, "vendor")
|
||||
model = read_sys_attr(name, "model")
|
||||
|
||||
|
||||
@@ -436,6 +436,34 @@ def geraeteangaben(geraet: str, api=None) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def kennung(geraet: str) -> str:
|
||||
r"""Der kurze Name eines Laufwerks — `\.\G:` wird zu `G`.
|
||||
|
||||
## Warum das eine benannte Funktion ist (Befund 29.08.2026)
|
||||
|
||||
In `main.py` stand an DREI Stellen der Rueckweg als feste Zeile:
|
||||
|
||||
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. Ergebnis: **Auswerfen und „Disc scannen" antworten
|
||||
unter Windows immer mit 404**, ohne dass irgendwo stuende, warum.
|
||||
|
||||
Hin und zurueck muessen zusammenpassen. Deshalb gehoert beides zum
|
||||
Treiber: Er vergibt die Kennung, und er loest sie wieder auf.
|
||||
"""
|
||||
return geraet.rstrip(":").rsplit("\\", 1)[-1].rstrip(":")
|
||||
|
||||
|
||||
def pfad_zu_kennung(name: str, geraete=None) -> str:
|
||||
"""Kennung -> Geraetepfad, oder "" wenn es dieses Laufwerk nicht gibt."""
|
||||
for pfad in (geraete if geraete is not None else list_optical_devices()):
|
||||
if pfad == name or kennung(pfad) == name:
|
||||
return pfad
|
||||
return ""
|
||||
|
||||
|
||||
def device_info(geraet: str, api=None) -> dict:
|
||||
"""Der Geräte-Eintrag fürs UI — gleiche Felder wie beim Linux-Treiber.
|
||||
|
||||
@@ -459,7 +487,7 @@ def device_info(geraet: str, api=None) -> dict:
|
||||
except OSError:
|
||||
status = "unknown"
|
||||
|
||||
buchstabe = geraet.rstrip(":").rsplit("\\", 1)[-1].rstrip(":")
|
||||
buchstabe = kennung(geraet)
|
||||
angaben = _angaben_gemerkt(geraet, api)
|
||||
modell = " ".join(t for t in (angaben.get("hersteller"),
|
||||
angaben.get("modell")) if t)
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
"""Der Wächter gegen Container-Annahmen im Windows-Betrieb.
|
||||
|
||||
## Warum es diesen Test gibt (Commander, 29.08.2026)
|
||||
|
||||
> „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."
|
||||
|
||||
Der Rundgang dazu hat an einem Tag gefunden:
|
||||
|
||||
/app/media als Pfadgrenze -> Auswahl blieb leer, Browser tot
|
||||
/app/temp/raw als Arbeitsordner -> 436 MB landeten in F:\\app\\temp
|
||||
/root/.MakeMKV fuer den Beta-Key -> Key wurde nie gelesen
|
||||
/dev/{name} in drei Endpunkten -> Auswerfen und Disc-Scan gaben 404
|
||||
os.path.isdir("/app") zweimal -> Rippy hielt sich fuer fremd
|
||||
shutil.which fuer makemkvcon -> Schluessel-Automatik lief nie an
|
||||
posixpath auf echten Pfaden -> C:\\Roh/datei.mkv
|
||||
|
||||
Jeder einzelne Fall sah gleich aus: kein Absturz, keine Meldung, nur etwas,
|
||||
das still nicht tat, was es sollte. Genau deshalb steht hier ein MECHANISCHER
|
||||
Waechter und keine Merkregel.
|
||||
|
||||
## Die Regel
|
||||
|
||||
In allem, was der Windows-Betrieb laedt, darf ein Container- oder Linux-Pfad
|
||||
nur stehen, wenn er in einem Zweig steht, der auf Windows nicht laeuft. Diese
|
||||
Zweige stehen unten namentlich in `ERLAUBT` — mit Begruendung. Wer eine neue
|
||||
Fundstelle hinzufuegt, muss sie dort begruenden; das ist der eigentliche
|
||||
Zweck.
|
||||
"""
|
||||
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
WURZEL = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
#: Was der Windows-Betrieb wirklich laedt.
|
||||
GEPRUEFT = ("src/rippy", "docker/api", "docker/worker")
|
||||
|
||||
#: Module, die es unter Windows GAR NICHT gibt — dort sind Linux-Pfade richtig.
|
||||
NUR_LINUX = (
|
||||
"src/rippy/drives/linux.py", # der Linux-Treiber
|
||||
"docker/api/mounts.py", # Freigaben einhaengen macht der Wirt
|
||||
)
|
||||
|
||||
#: Begruendete Ausnahmen: Datei -> warum der Pfad dort stehen DARF.
|
||||
ERLAUBT = {
|
||||
"src/rippy/betrieb.py":
|
||||
"Container-Zweige von ablage_vorgabe/arbeits_vorgabe/platz_orte — "
|
||||
"sie laufen nur mit container=True",
|
||||
"src/rippy/config.py":
|
||||
"Linux-Zweig von standard_datenbankpfad (Windows: %LOCALAPPDATA%)",
|
||||
"src/rippy/daemon.py":
|
||||
"Linux-Zweig von standard_konfigpfad (Windows: %PROGRAMDATA%)",
|
||||
"src/rippy/rip/makemkv_daten.py":
|
||||
"Linux-Zweig von daten_verzeichnis (Windows: %USERPROFILE%\\.MakeMKV)",
|
||||
"docker/api/config.py":
|
||||
"Pydantic-Vorgaben ohne Leser — kein Aufrufer im Quellcode",
|
||||
"docker/api/main.py":
|
||||
"MEDIA_ROOT als benannter Rueckfall; die Wurzel liefert medien_wurzel()",
|
||||
"docker/api/rohdaten.py":
|
||||
"RAW_STANDARD/MEDIA_ROOT als Rueckfall; die Wurzeln liefert wurzeln()",
|
||||
"docker/api/prescan/prescan.py":
|
||||
"Linux-Zweig von disc_wurzel (Windows haengt die Disc selbst ein) "
|
||||
"und die Audio-CD-Werkzeuge, die es unter Windows nicht gibt",
|
||||
"docker/worker/ablauf.py":
|
||||
"RAW_DIR/MEDIA_ROOT als Rueckfall; die Wurzeln liefert _betriebs_wurzeln()",
|
||||
"docker/worker/ripping.py":
|
||||
"RIP_OUTPUT_DIR als Rueckfall und die Audio-CD-Werkzeuge (Linux-only)",
|
||||
}
|
||||
|
||||
PFAD_MUSTER = re.compile(r'["\'](/(?:app|root|dev|mnt|srv|etc|var|usr)(?:/[^"\']*)?)["\']')
|
||||
|
||||
|
||||
def _dateien():
|
||||
for teil in GEPRUEFT:
|
||||
for ordner, unter, namen in os.walk(os.path.join(WURZEL, teil)):
|
||||
unter[:] = [u for u in unter if u != "__pycache__"]
|
||||
for name in namen:
|
||||
if not name.endswith(".py") or name.startswith("test_"):
|
||||
continue
|
||||
voll = os.path.join(ordner, name)
|
||||
rel = os.path.relpath(voll, WURZEL).replace("\\", "/")
|
||||
if rel in NUR_LINUX:
|
||||
continue
|
||||
yield rel, voll
|
||||
|
||||
|
||||
def _code_zeilen(voll: str):
|
||||
"""Zeilen OHNE Kommentare und Docstrings — nur was wirklich laeuft.
|
||||
|
||||
Ueber den TOKENIZER, nicht ueber „faengt mit Anfuehrungszeichen an": Ein
|
||||
Docstring kann `r\"\"\"` oder `f\"\"\"` heissen, und der erste Anlauf dieses
|
||||
Tests ist prompt an seinem eigenen `r\"\"\"`-Docstring haengengeblieben.
|
||||
Python weiss selbst am besten, was Code ist und was Text.
|
||||
"""
|
||||
import tokenize
|
||||
|
||||
with tokenize.open(voll) as f:
|
||||
try:
|
||||
marken = list(tokenize.generate_tokens(f.readline))
|
||||
except (tokenize.TokenError, IndentationError, SyntaxError):
|
||||
return
|
||||
|
||||
# Zeilen, in denen echter Code steht (alles ausser Kommentar, Text,
|
||||
# Einrueckung und Zeilenende).
|
||||
egal = {tokenize.COMMENT, tokenize.STRING, tokenize.NL, tokenize.NEWLINE,
|
||||
tokenize.INDENT, tokenize.DEDENT, tokenize.ENDMARKER}
|
||||
zeilen = {}
|
||||
for marke in marken:
|
||||
if marke.type in egal:
|
||||
continue
|
||||
zeilen[marke.start[0]] = marke.line.rstrip("\n")
|
||||
# Zeichenketten IM Code (kein Docstring) muessen mit — dort stehen die
|
||||
# Pfade. Erkennbar daran, dass in derselben Zeile auch Code steht.
|
||||
for marke in marken:
|
||||
if marke.type == tokenize.STRING and marke.start[0] in zeilen:
|
||||
zeilen[marke.start[0]] = marke.line.rstrip("\n")
|
||||
for nr in sorted(zeilen):
|
||||
yield nr, zeilen[nr]
|
||||
|
||||
|
||||
def test_kein_unbegruendeter_container_pfad():
|
||||
"""DER Waechter. Neue Fundstelle -> hier begruenden oder beheben."""
|
||||
offen = []
|
||||
for rel, voll in _dateien():
|
||||
for nr, zeile in _code_zeilen(voll):
|
||||
treffer = PFAD_MUSTER.search(zeile)
|
||||
if treffer and rel not in ERLAUBT:
|
||||
offen.append("%s:%d %s" % (rel, nr, treffer.group(1)))
|
||||
assert not offen, (
|
||||
"Container-Pfad im Windows-Betrieb ohne Begruendung:\n "
|
||||
+ "\n ".join(offen)
|
||||
+ "\n\nEntweder fuer Windows umschreiben (betrieb.medien_wurzel, "
|
||||
"rippy.pfade) oder in ERLAUBT begruenden.")
|
||||
|
||||
|
||||
def test_die_ausnahmen_sind_noch_noetig():
|
||||
"""Eine Ausnahme, die niemand mehr braucht, ist eine Unwahrheit im Test."""
|
||||
ungenutzt = []
|
||||
for rel in ERLAUBT:
|
||||
voll = os.path.join(WURZEL, rel)
|
||||
if not os.path.isfile(voll):
|
||||
ungenutzt.append(rel + " (Datei gibt es nicht mehr)")
|
||||
continue
|
||||
if not any(PFAD_MUSTER.search(z) for _, z in _code_zeilen(voll)):
|
||||
ungenutzt.append(rel + " (kein Container-Pfad mehr drin)")
|
||||
assert not ungenutzt, "Ausnahme ueberfluessig:\n " + "\n ".join(ungenutzt)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("modul,name", [
|
||||
("src/rippy/drives/windows.py", "kennung"),
|
||||
("src/rippy/drives/windows.py", "pfad_zu_kennung"),
|
||||
("src/rippy/drives/linux.py", "kennung"),
|
||||
("src/rippy/drives/linux.py", "pfad_zu_kennung"),
|
||||
])
|
||||
def test_beide_treiber_koennen_die_kennung_in_beide_richtungen(modul, name):
|
||||
"""Hin und zurueck muessen zusammenpassen.
|
||||
|
||||
`main.py` baute den Geraetepfad aus der Kennung selbst zusammen
|
||||
(`/dev/{name}`) — unter Windows heisst das Laufwerk aber `\\\\.\\G:`.
|
||||
Auswerfen und „Disc scannen" antworteten deshalb IMMER mit 404.
|
||||
"""
|
||||
text = io.open(os.path.join(WURZEL, modul), encoding="utf-8",
|
||||
errors="replace").read()
|
||||
assert "def %s(" % name in text
|
||||
|
||||
|
||||
def test_niemand_baut_den_geraetepfad_mehr_selbst():
|
||||
"""Der konkrete Fehler: `f"/dev/{name}"` in drei Endpunkten."""
|
||||
voll = os.path.join(WURZEL, "docker/api/main.py")
|
||||
gebaut = [nr for nr, z in _code_zeilen(voll) if 'f"/dev/{' in z]
|
||||
assert not gebaut, (
|
||||
"main.py:%s baut den Geraetepfad selbst — device_discovery."
|
||||
"pfad_zu_kennung() benutzen" % gebaut)
|
||||
Reference in New Issue
Block a user