Files
rippy/docker/api/rohdaten.py
HitonabiandClaude Opus 5 d3e86d2641 fix(windows): Der Schalter, den es nicht gibt, und vierzehn weitere Funde
Commander: „Kompression fehlgeschlagen bei Spartacus … _t01.mkv: HandBrake
endete mit Code 0" — 16,5 GB fertiger Rohschnitt, und die Kompression war in
derselben Sekunde vorbei, in der sie begann.

Nachgestellt mit genau der Befehlszeile, die Rippy baute:

    unknown option (--audio-codec)
    HandBrake has exited.        $? = 0

Den Schalter `--audio-codec` gibt es bei HandBrakeCLI nicht; er heisst
`-E` / `--aencoder`. Ein unbekannter Schalter ist fuer HandBrake kein Fehler,
der Rueckgabewert ist 0. Der Test dazu forderte den falschen Namen sogar ein.

Daraus wurde ein Rundgang durch den Windows-Pfad. Alles unten ist gemessen,
nichts vermutet (Regel D).

## Die Kompression

1. `--aencoder` statt `--audio-codec`. Am mitgelieferten HandBrakeCLI 1.11.2
   gemessen, mit einem 5-Sekunden-Encode auf der echten Roh-Datei bestaetigt.

2. HandBrakes letzte Zeilen werden aufgehoben (12 gepuffert, 4 in der
   Meldung) und `unknown option (...)` wird als eigener Fall erkannt, VOR
   allen anderen. Vorher wurde jede Zeile weggeworfen, die kein Fortschritt
   war — bei Rueckgabewert 0 blieb damit keine Auskunft uebrig. Die geratene
   Zeile „Meist ist der Zielordner nicht beschreibbar" ist raus; sie war
   falsch und hat die Suche in die falsche Richtung geschickt.

3. Ein `ue`-Umlaut im Pfad toetete die Kompression. HandBrake schreibt zwei
   Kodierungen in denselben Strom (derselbe Pfad einmal UTF-8, einmal CP850).
   In CP850 ist das Byte 0x81, und das ist in cp1252 — was `text=True` auf
   deutschem Windows waehlt — undefiniert:

       UnicodeDecodeError: charmap codec can't decode byte 0x81

   Neu: `rip/handbrake_aufruf.py` mit `HB_LESEN`, benutzt von ripping.py und
   caps.py. Bewusst nicht binaer wie bei makemkvcon: HandBrake trennt
   Fortschrittszeilen mit CR, im Binaermodus waere der Balken weg.

## Die Rohdaten

4. `rohdaten.kandidaten` machte aus dem Arbeitsordner `F:\` ein `F:` und
   verband damit weiter. Das ist unter Windows der aktuelle Ordner auf
   Laufwerk F, nicht dessen Wurzel — 16,5 GB waren unsichtbar, und der
   Wiederholen-Dialog bot nur „Neu rippen" an. Die Falle steht woertlich im
   Kopf von `pfade.verbinden`.

5. Gesucht wurde unter der heutigen Einstellung statt unter der Wahl DIESES
   Rips (`meta["work_dir"]`). Genau dafuer wurde rohdaten.py am 26.07.
   gebaut; repariert wurde damals die Kandidatenliste, nicht der Aufrufer.
   Neu: `_arbeitsverzeichnis_des_jobs`, benutzt an vier Stellen.

6. Zwei Speicher fuer dieselben Ordner: Die Oberflaeche schreibt
   `outputDir`/`workDir` in die Datenbank, `betrieb` liest `storage.*` aus
   der Konfigurationsdatei, und die schreibt niemand. Gemessen: eingestellt
   `E:\Rippy`, angezeigt `C:\Users\...\Videos\Rippy`. Neu:
   `betrieb.mit_einstellungen`.

## Das Laufwerk

7. `device_info` fing den OSError ab und lieferte „unknown" ohne den Grund.
   Nach einem Rip mit Lesefehlern beantwortete das Laufwerk keine
   Medien-Abfragen mehr (Win32-Fehler 1), die Geraete-Auskunft aber schon —
   im UI stand eine volle Laufwerkskarte, kein Rip startbar, und im
   Protokoll das laengst veraltete „Disc erkannt". Neu: `ZUGRIFFS_GRUENDE`,
   ein Feld `grund` im Laufwerks-Eintrag und eine Protokollzeile je Wechsel.
   Eine fehlgeschlagene Disc-Erkennung wird ebenfalls protokolliert.

8. Der Linux-Treiber nannte ein unzugaengliches Laufwerk „empty", waehrend
   Windows richtig „unknown" sagt. Angeglichen, samt Feld-Paritaet.

9. `CreateFileW`, `DeviceIoControl` und `CloseHandle` hatten weder `restype`
   noch `argtypes` — 32-Bit-`c_int` fuer einen 64-Bit-HANDLE, in beide
   Richtungen. Mit `restype` aendert sich der Fehlerwert von -1 auf
   0xFFFFFFFFFFFFFFFF; die Pruefung deckt jetzt beides ab. Am echten
   Laufwerk gegengeprueft, Fehlerpfad eingeschlossen.

10. Der Vor-Scan lief bei JEDER eingelegten Disc ein `makemkvcon info` mit
    120 s Zeitgrenze — 20 bis 120 Sekunden „Disc wird gelesen". Frueher war
    das schnell, weil der Zweig unter Windows nie lief (`shutil.which`,
    repariert am 28.08.). Das Ergebnis landete allein in `toc["tracks"]`,
    das niemand liest: Der Rip-Dialog holt seine Liste ueber
    `/devices/{id}/scan-tracks`, wenn sie gebraucht wird. Entfernt.

## Notbremsen

11. `_frei_bytes` suchte den naechsten vorhandenen Ordner selbst.
    `os.path.dirname("Q:\\")` gibt sich selbst zurueck — ein
    Arbeitsverzeichnis auf einer abgezogenen Platte haette den Job vor dem
    Rip stumm haengen lassen. Benutzt jetzt
    `pfade.naechster_vorhandener`, das den Abbruch seit V2-1 hat.

12. `naechster_vorhandener` haelt Laufwerks- und UNC-Wurzeln jetzt absolut.

13. `aufraeum_skript` baut sein `rmdir /s /q` aus `InstallLocation` in der
    Registry. Waere das eine Laufwerks-Wurzel, loeschte die Deinstallation
    das Laufwerk. Nicht beobachtet, aber nicht wiedergutzumachen — der
    Loeschbefehl bleibt in dem Fall weg.

## Lesefehler

MakeMKV sicherte 1 von 2 Titeln, endete mit 0, und Rippy schrieb „Rip
fertig". Jetzt gibt es eine Warnung, auch wenn der Rip als Erfolg endet, und
die MSG-Nummer steht im Protokoll: MakeMKVs Texte sind uebersetzt, die
Nummern nicht.

## Aus der Gegenprobe am laufenden Rippy

Die erste Fassung dieses Standes war installiert, als der Commander meldete:
„nun oeffnen sich diverse fenster im hintergrund, gehen ganz kurz auf und dann
wieder zu. Das laufwerk hoert auch einfach auf zu lesen." Beides Altlasten,
die erst durch die neue Protokollzeile sichtbar wurden.

14. Prozesserzeugung mitgeschnitten:

        14:54:40  timeout.exe          timeout 4 ls -d C:\Users\...\d7ee6c06-...
        14:54:40  WindowsTerminal.exe

    `rohdaten.pruefen` fragt mit `timeout N ls -d`, ob es ein Verzeichnis
    gibt. Unter Linux ist das richtig (os.path.isdir kann an einem toten
    CIFS-Mount im Kernel haengen, ein Kindprozess laesst sich abbrechen).
    Unter Windows ist es dreifach falsch: timeout.exe gibt es dort, kennt
    aber weder `ls` noch `-d`; sie braucht eine Konsole, und die reisst
    Windows auf; und ihr Rueckgabewert ist nie 0, die Antwort lautete also
    „weg" fuer JEDES Verzeichnis. Rohdaten waren unter Windows
    grundsaetzlich unsichtbar. Neu: `nativ_nachsehen()`. Die zwei
    gleichartigen Aufrufe in mounts.py bekommen dieselbe Absicherung.

15. Der Waechter fragte das Laufwerk alle drei Sekunden ab — auch mitten im
    Rip, also drei CreateFileW plus IOCTLs auf ein Geraet, das makemkvcon
    gerade liest:

        12:49:52  bluray-Rip gestartet
        12:50:09  [watcher] Laufwerk G: beantwortet keine Medien-Abfragen
        12:50:12  MSG 2003 SCSI-Fehler ILLEGAL REQUEST:INVALID FIELD IN CDB
        12:50:12  makemkvcon endete mit Code 11

    `_auto_prescan` haelt sich seit dem 29.08.2026 an die Regel „waehrend
    eines Rips wird nicht gescannt"; die Laufwerksabfrage tat es nicht.
    Jetzt gilt in der Zeit der letzte bekannte Stand.

## Zwei Tests, die gelogen haben

* `assert "--audio-codec" in cmd` schrieb den Fehler fest.
* `lambda: {}` als Doppelgaenger fuer `get_settings(key, bei_fehler_leer)`
  brach, sobald ein Aufrufer einen Parameter benutzte — und zeigte dann auf
  den Code statt auf sich selbst.

977 Tests gruen, ruff sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 15:11:00 +02:00

341 lines
15 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Wo liegen die Roh-MKVs eines Jobs? — Suchen statt annehmen.
## Der Fund, der dieses Modul nötig gemacht hat (26.07.2026, live gemessen)
Job `95afdc89` stand auf `failed`, und im Ablageziel lagen **79,6 GB** intakter
Rohschnitt (`/app/media/rippy/95afdc89-…/title_t00.mkv`). Der SAVEPOINT v3.16
schrieb dazu: „→ Neu komprimieren' genügt, kein Neu-Rip". Die Gegenprobe an der
laufenden Instanz sagt: **`can_retry` war `false`** — den Knopf gab es gar nicht.
Ursache: `_kann_neu_komprimieren` suchte an genau zwei Orten — im
Container-Standard `/app/temp/raw/<id>` und unter dem AKTUELLEN Wert der
Einstellung `workDir`. Der Rip war aber mit einer Wahl *für diesen einen Rip*
auf die NAS gelegt worden (das gibt es seit v3.15 im Rip-Dialog), und die
Einstellung selbst stand auf leer. Damit zeigte nichts mehr auf die Datei:
workDir (Einstellung) = "" → geprüft wurde nur /app/temp/raw
tatsächlicher Ort = /app/media/rippy/<id>
Ergebnis → 75 GB unsichtbar, Neu-Rip scheinbar unvermeidlich
Das ist derselbe Fehler, der dieses Projekt schon mehrfach gekostet hat: aus
einem Zustandswert (der heutigen Einstellung) auf einen Mechanismus (wohin
damals gerippt wurde) geschlossen, statt nachzusehen.
## Warum gesucht und nicht gespeichert wird
Den Ort in die Job-Zeile zu schreiben wäre sauberer — aber die jobs-Tabelle
bräuchte eine neue Spalte, und `create_all` legt nur fehlende TABELLEN an, keine
fehlenden Spalten. Eine Migration für einen Suchraum von einer Handvoll
Verzeichnissen ist das falsche Werkzeug, und Bestandsjobs (genau der Fall hier)
hätten den Wert ohnehin nicht.
Der Suchraum ist nämlich klein und geschlossen: `_arbeitsverzeichnis()` im Worker
lässt ausschließlich den Container-Standard oder einen Pfad UNTER `/app/media`
zu. Es genügt also, `/app/temp/raw/<id>` und `<jedes Speicherziel>/<id>`
anzusehen — die oberste Ebene von `/app/media`, ohne Rekursion.
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 os
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"
MEDIA_ROOT = "/app/media"
def _ui_einstellungen() -> dict:
"""Was in der Oberflaeche eingestellt ist — leer, wenn die Datenbank
gerade nicht antwortet.
Bewusst gekapselt und abgesichert: `wurzeln()` wird auch aus der
Jobliste heraus aufgerufen, die alle vier Sekunden laeuft. Sie darf an
einer klemmenden Datenbank nicht scheitern — dann gilt eben die
Vorgabe, wie bisher.
"""
try:
from rippy import store as db
return db.get_settings(bei_fehler_leer=True) or {}
except Exception: # noqa: BLE001
return {}
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 = {}
# Dieselbe Bruecke wie in /system/info: Die Oberflaeche legt
# ihre Orte in der Datenbank ab, nicht in der Datei. Ohne sie
# suchte die Rohdaten-Suche unter der Vorgabe statt unter dem,
# was eingestellt ist (Befund 30.08.2026).
werte = betrieb.mit_einstellungen(werte, _ui_einstellungen())
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,
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
(Ablageziele inkl. eingehängter Freigaben) — die Liste kommt vom Aufrufer,
damit diese Funktion ohne Dateisystem testbar bleibt.
Reihenfolge: Container-Standard, dann die eingestellte Wahl, dann alle
Ablageziele. Doppelte fliegen raus, die Reihenfolge bleibt stabil.
posixpath, nicht os.path: Das sind Container-Pfade. os.path.join baut unter
Windows Backslashes daraus, und dann greift keine Prüfung mehr — dieselbe
Falle wie bei `_zielbasis()` (v3.14) und `_mountpoint()` (26.07.2026).
"""
if not job_id:
return []
roh, medien, frei = orte_wurzeln or (RAW_STANDARD, MEDIA_ROOT, False)
from rippy import pfade
orte = [pfade.verbinden(roh, job_id)]
# Zum VERGLEICHEN ohne Schluss-Trenner, zum VERBINDEN mit.
#
# ⚠️ Befund 30.08.2026, am Rechner des Commanders nachgerechnet:
# Hier stand `wahl = (work_dir or "").strip().rstrip("/\\")`, und mit
# dieser einen abgestreiften Zeichenkette wurde dann auch VERBUNDEN.
# Sein Arbeitsordner ist `F:\` — ein Laufwerks-Stammverzeichnis:
#
# "F:\\".rstrip("/\\") -> "F:"
# verbinden("F:", job_id) -> "F:1aa41fef-…" isdir: False
# verbinden("F:\\", job_id) -> "F:\1aa41fef-…" isdir: True
#
# `F:` ohne Trenner heisst unter Windows „der aktuelle Ordner auf
# Laufwerk F", nicht die Wurzel — die Falle steht woertlich im Kopf von
# `pfade.verbinden`, und diese Zeile ist hineingetreten. Folge: 16,5 GB
# Rohschnitt unsichtbar, und der Wiederholen-Dialog bot nur „Neu
# rippen" an — Stunden am beschaedigten Datentraeger fuer nichts.
wahl = (work_dir or "").strip()
vergleich = wahl.rstrip("/\\")
grenze = (medien or "").rstrip("/\\")
# Nativ zaehlt jede Wahl — dort liegt der Arbeitsordner oft auf einem
# ganz anderen Laufwerk und damit unter gar keiner Wurzel.
if vergleich and (frei or vergleich == grenze
or vergleich.startswith(grenze + "/")):
orte.append(pfade.verbinden(wahl, job_id))
for name in media_unterordner or []:
if name:
orte.append(pfade.verbinden(pfade.verbinden(medien, name), job_id))
gesehen, eindeutig = set(), []
for ort in orte:
if ort not in gesehen:
gesehen.add(ort)
eindeutig.append(ort)
return eindeutig
def nativ_nachsehen() -> bool:
"""Darf direkt nachgesehen werden, statt einen Prozess dafuer zu starten?
Eigene Funktion, damit beide Zweige ueberall pruefbar sind — dieselbe
Regel wie bei `betrieb.im_container`. Die Begruendung steht in
`pruefen`.
"""
return os.name == "nt"
def pruefen(pfad: str, laufen=None) -> str:
"""Gibt es dieses Verzeichnis? „da" | „weg" | „unklar" — mit HARTER Zeitgrenze.
Drei Antworten statt zwei, weil „ich konnte nicht nachsehen" etwas anderes
ist als „es ist nicht da". Gemessen am 26.07.2026: Nach einem
Container-Neustart stallt der ERSTE Zugriff auf die CIFS-Freigabe mehrere
Sekunden (die SMB-Sitzung wird neu aufgebaut), danach antwortet sie in
0,01 s — zehn von zehn Versuchen. Ohne die Unterscheidung verschwindet in
diesem Fenster der Knopf „Neu komprimieren", und der Nutzer schließt daraus,
seine 74 GB seien weg. Genau diese Sorte Fehlschluss hat das Projekt schon
zweimal bezahlt.
Begründung der Technik siehe verzeichnis_da.
"""
if not pfad:
return "weg"
if laufen is None and nativ_nachsehen():
# ## Warum Windows hier NICHT den Umweg ueber einen Prozess geht
#
# ⚠️ Befund 30.08.2026, an der laufenden Instanz beobachtet:
#
# 14:54:40 timeout.exe timeout 4 ls -d C:\\...\\d7ee6c06-...
# 14:54:40 WindowsTerminal.exe
#
# Der Commander: „nun oeffnen sich diverse fenster im hintergrund,
# gehen ganz kurz auf und dann wieder zu."
#
# `timeout` und `ls` sind Linux-Befehle. Unter Windows GIBT es eine
# `timeout.exe` — sie wartet nur Sekunden ab und kennt weder `ls`
# noch `-d`. Sie braucht aber eine Konsole, und die reisst Windows
# dann auf. Dreifach falsch also: ein Fenster bei jedem Durchlauf,
# ein Prozess fuer nichts, und ein Rueckgabewert ungleich 0 — also
# die Antwort „weg" fuer JEDES Verzeichnis. Rohdaten waren damit
# unter Windows grundsaetzlich unsichtbar.
#
# Der Grund fuer den Umweg gilt hier nicht: Der Kernel-Hang im
# Zustand D (siehe `verzeichnis_da`) ist eine Linux-Eigenheit. Ein
# totes Netzlaufwerk laesst `os.path.isdir` unter Windows mit einem
# Fehler zurueckkommen, nicht unabbrechbar haengen.
try:
return "da" if os.path.isdir(pfad) else "weg"
except OSError:
return "unklar"
starten = laufen or subprocess.run
try:
ergebnis = starten(
["timeout", str(PRUEF_TIMEOUT_SEKUNDEN), "ls", "-d", pfad],
capture_output=True,
timeout=PRUEF_TIMEOUT_SEKUNDEN + 2,
)
except (OSError, subprocess.TimeoutExpired):
return "unklar"
if ergebnis.returncode == 0:
return "da"
# 124 ist der Rückgabewert von `timeout`, wenn es das Kind abgeschossen hat
# (dokumentiert in coreutils). Das heißt: nicht angesehen, nicht „weg".
return "unklar" if ergebnis.returncode == 124 else "weg"
def verzeichnis_da(pfad: str, laufen=None) -> bool:
"""Gibt es dieses Verzeichnis? — mit HARTER Zeitgrenze.
## Warum nicht os.path.isdir
Weil es an einem Netz-Mount unbegrenzt hängen kann, und zwar im Kernel
(Prozess-Zustand D, „uninterruptible sleep"). Genau das ist am 26.07.2026
passiert: Die Hintergrund-Schleife startete ihren ersten Durchlauf, während
Rippy die CIFS-Freigabe nach einem Container-Neustart neu einhängte. Ihr
`os.path.isdir` blieb stecken, `asyncio.to_thread` kam nie zurück, die
Schleife erreichte ihr `sleep` nie — und war damit für immer tot. Sichtbar
war nur, dass `can_retry` dauerhaft `false` blieb; zwei Threads standen im
Zustand D.
Ein Timeout um den Aufruf hätte nichts geholfen: Ein im Kernel hängender
Thread lässt sich aus Python nicht abbrechen, jeder Versuch hätte einen
weiteren Thread verbrannt, bis der Pool leer ist.
Ein Kind-PROZESS lässt sich abbrechen. Deshalb `timeout N ls -d <pfad>` —
dasselbe Werkzeug, das `mounts.ist_erreichbar` seit dem 24.07.2026 für
genau dieses Problem benutzt (dort für den toten NAS-Mount). Läuft es in
die Zeitgrenze, gilt das Verzeichnis als „nicht da": Ein Ort, den man nicht
innerhalb von Sekunden ansehen kann, ist für einen Rip ohnehin unbrauchbar.
`laufen` ist einspritzbar, damit das ohne echte Prozesse testbar bleibt.
Für den Fall „konnte nicht nachsehen" gibt es `pruefen()` mit drei
Antworten. Hier gilt nur „da" als ja — wer eine Ja/Nein-Antwort braucht,
soll im Zweifel Nein bekommen.
"""
return pruefen(pfad, laufen) == "da"
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
ohne Dateisystem prüfbar. Für `isdir` gehört `verzeichnis_da` eingesetzt und
NICHT os.path.isdir: Die Kandidaten liegen unter /app/media, und dort kann
ein Netz-Mount unbegrenzt hängen (Begründung bei verzeichnis_da).
`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(orte_wurzeln[1]))
except OSError:
unterordner = []
gefunden = []
for ort in kandidaten(job_id, work_dir, unterordner, orte_wurzeln):
try:
if isdir(ort):
gefunden.append(ort)
except OSError:
# Toter Mount → als „nicht da" werten. Ein Fehlschlag hier darf die
# Job-Liste nicht mitnehmen (Befund 24.07. bei /storage-targets).
continue
return gefunden
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
Ort hat nicht geantwortet — ein leeres `pfade` ist dann kein Beweis für
„nichts da". Der Aufrufer soll in diesem Fall seine letzte bekannte Antwort
behalten, statt Abwesenheit zu behaupten (siehe pruefen()).
"""
pruefe = pruefer or pruefen
orte_wurzeln = orte_wurzeln or wurzeln()
try:
unterordner = sorted(listdir(orte_wurzeln[1]))
except OSError:
unterordner = []
gefunden, unklar = [], False
for ort in kandidaten(job_id, work_dir, unterordner, orte_wurzeln):
antwort = pruefe(ort)
if antwort == "da":
gefunden.append(ort)
elif antwort == "unklar":
unklar = True
return {"pfade": gefunden, "unklar": unklar}
def groesse(pfade: list, listdir, isfile, getsize) -> tuple:
"""(Bytes, Dateizahl) der Roh-Dateien — flach, nicht rekursiv.
Flach genügt: MakeMKV legt die Titel als `title_tNN.mkv` direkt in das
Job-Verzeichnis, Unterordner entstehen dort nicht.
"""
bytes_gesamt, dateien = 0, 0
for pfad in pfade or []:
try:
namen = listdir(pfad)
except OSError:
continue
for name in namen:
# `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)
dateien += 1
except OSError:
continue
return bytes_gesamt, dateien