From 986fcf19b17c5e3f40ba75b755e627dabc60fb8f Mon Sep 17 00:00:00 2001 From: Hitonabi Date: Sat, 29 Aug 2026 16:26:23 +0200 Subject: [PATCH] =?UTF-8?q?feat(windows):=20Audio-CDs=20rippen=20=E2=80=94?= =?UTF-8?q?=20die=20letzte=20Luecke=20ist=20zu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Commander: „mach es" Gemeint war die Lücke, die seit Wochen so im SAVEPOINT stand: **Audio-CDs laufen unter Windows nicht.** `cdparanoia` und `abcde` sind Linux-Werkzeuge. ## Nicht abcde nachbauen — den Windows-Weg gehen Eine Audio-CD hat kein Dateisystem. Die `Track01.cda`, die Windows zeigt, sind 44 Byte grosse Platzhalter; die Musik liegt roh in 2352-Byte-Sektoren. Windows bietet dafuer zwei Steuercodes an: IOCTL_CDROM_READ_TOC Inhaltsverzeichnis (MSF je Spur, Audio/Daten) IOCTL_CDROM_RAW_READ die Sektoren selbst Gegengeprueft, dass die berechneten Codes den dokumentierten entsprechen: 0x24000 und 0x2403e. Kodiert wird mit dem FLAC-Encoder vom offiziellen Xiph-Spiegel (1.5.0), den Rippy beim Einrichten holt — wie MakeMKV. KEIN Pflichtwerkzeug: Ohne ihn laeuft alles ausser Audio-CDs, und ein Fehlschlag darf das Einrichten nicht truebe machen. ## Zwei Fallen, beide gemessen **Die Leseadresse zaehlt in 2048er-Einheiten**, obwohl ein Audio-Sektor 2352 Bytes hat. Das ist dokumentiert und sieht falsch aus; mit 2352 liest man an der falschen Stelle. **`flac.exe` braucht `libFLAC.dll` daneben.** Mit nur der exe endete jeder Aufruf mit 0xC0000135 — „DLL nicht gefunden" — und zwar ohne eine einzige Zeile Ausgabe. Jetzt wird der ganze Win64-Ordner ausgepackt. ## Was geprueft ist Ein Laufwerk und eine Audio-CD lassen sich in der Ampel nicht herstellen. Deshalb steht alles Rechenbare in reinen Funktionen — MSF↔LBA, das Zerlegen der TOC-Bytes, WAV-Kopf, Blockaufteilung, Dateinamen — und der Ablauf bekommt Laufwerk und Encoder eingespritzt. 28 Tests dafuer. Zusaetzlich mit dem ECHTEN Encoder gemessen: zwei Spuren erzeugten Tons gerippt, und **FLAC bestaetigt seine eigenen Dateien** (`flac -t`, Code 0). Am echten Laufwerk gegengeprueft, dass das Inhaltsverzeichnis gelesen wird — die eingelegte Blu-ray meldet sich korrekt als DATEN-Track und wird nicht als Musik behandelt. Was noch fehlt: MusicBrainz-Tags. Die Dateien heissen `Track 01.flac`. Der Weg dafuer steht (`tags_je_spur`), die Disc-Kennung fuer die Abfrage nicht. 915 Tests gruen, ruff sauber. Co-Authored-By: Claude Opus 5 --- SAVEPOINT.md | 60 ++++++- docker/worker/ripping.py | 41 +++++ src/rippy/drives/win_ioctl.py | 25 +++ src/rippy/drives/windows.py | 58 +++++++ src/rippy/rip/audio_cd.py | 278 +++++++++++++++++++++++++++++++++ src/rippy/rip/test_audio_cd.py | 243 ++++++++++++++++++++++++++++ src/rippy/tools/beschaffen.py | 91 +++++++++++ src/rippy/tools/einrichten.py | 8 +- src/rippy/tools/katalog.py | 14 ++ 9 files changed, 813 insertions(+), 5 deletions(-) create mode 100644 src/rippy/rip/audio_cd.py create mode 100644 src/rippy/rip/test_audio_cd.py diff --git a/SAVEPOINT.md b/SAVEPOINT.md index d4270a1..58eefcc 100644 --- a/SAVEPOINT.md +++ b/SAVEPOINT.md @@ -1,6 +1,56 @@ # SAVEPOINT — Rippy -## Aktueller Stand: v4.0-rc7 — vier Befunde, drei mit derselben Wurzel (29.08.2026) +## Aktueller Stand: v4.0-rc8 — Windows vollständig (29.08.2026) + +> **Der Rundgang durch die Docker-Reste ist durch, und die Audio-CD-Lücke ist +> zu.** 915 Tests grün. + +### Die letzte Lücke: Audio-CDs + +`cdparanoia` und `abcde` gibt es für Windows nicht — ein Nachbau ihrer +Shell-Logik wäre ein zweites Projekt gewesen. Gebaut ist stattdessen der Weg, +den Windows selbst anbietet: + +* **Lesen** macht Rippy selbst über zwei Win32-Steuercodes. Eine Audio-CD hat + kein Dateisystem; die `Track01.cda`, die Windows zeigt, sind 44 Byte große + Platzhalter. Die Musik liegt roh in 2352-Byte-Sektoren. +* **Kodieren** macht der FLAC-Encoder, den Rippy beim Einrichten holt + (offizieller Xiph-Spiegel, Fassung 1.5.0). Kein Pflichtwerkzeug: Ohne ihn + läuft alles außer Audio-CDs. + +Gemessen: FLAC 1.5.0 geladen, zwei Spuren gerippt, **und FLAC selbst bestätigt +seine Dateien** (`flac -t`, Code 0). Am echten Laufwerk gegengeprüft, dass das +Inhaltsverzeichnis gelesen wird — die eingelegte Blu-ray meldet sich korrekt +als *Daten*-Track und wird nicht als Musik behandelt. + +Zwei Fallen stecken in der Sache, beide dokumentiert und beide im Code +begründet: Die Leseadresse zählt in **2048er**-Einheiten, obwohl ein +Audio-Sektor 2352 Bytes hat. Und `flac.exe` braucht `libFLAC.dll` daneben — +mit nur der exe endet jeder Aufruf mit „DLL nicht gefunden", ohne eine Zeile +Ausgabe. + +### Was der Rundgang sonst noch fand + +**`/dev/{name}` in drei Endpunkten.** Das UI ruft sie mit der Kennung `G` auf, +gebaut wurde `/dev/G` — **Auswerfen und „Disc scannen" antworteten unter +Windows immer mit 404.** Beide Treiber lösen die Kennung jetzt selbst auf. + +**`os.path.isdir("/app")` zum zweiten Mal**, jetzt in `ablauf.py`: Rippy hielt +sich für einen *fremden* Worker. + +**`shutil.which` in `schluessel.py`** — ausgerechnet im Modul, das es nur unter +Windows gibt. Die Disc-Schlüssel-Automatik für 4K-UHD lief nie an. + +Dazu ein Wächter-Test: Er prüft ab sofort **mechanisch**, dass im Windows-Weg +kein Container-Pfad ohne Begründung steht. + +### HandBrakes „Code 0" + +Code 0 heißt **Erfolg**. Rippy meldete trotzdem „fehlgeschlagen", weil die +Datei nicht am erwarteten Ort lag: HandBrake bestimmt den Container aus dem +**Preset**, nicht aus der Endung. Jetzt wird er erzwungen. + +## Letzter Stand davor: v4.0-rc7 — vier Befunde, drei mit derselben Wurzel (29.08.2026) > Setup auf dem Desktop, `7b0c41d`, **852 Tests grün**. @@ -410,11 +460,13 @@ und `shutil.which("makemkvcon")` — das findet unter Windows nie etwas. Ergebnis an der echten Disc: `Neon Genesis Evangelion` (1995), Confidence 0,8, Fingerabdruck `BD_EVG_D2|48149364736`. -### ⚠️ BEKANNTE LÜCKE: Audio-CDs laufen unter Windows NICHT +### ~~BEKANNTE LÜCKE: Audio-CDs laufen unter Windows NICHT~~ — geschlossen (29.08.2026) `cdparanoia` (Titelliste) und `abcde` (Rippen) sind Linux-Werkzeuge und -werden nicht mitgeliefert. Die CD wird erkannt, aber nicht gerippt. Steht -bewusst hier statt versteckt im Code. +werden nicht mitgeliefert. Die CD wurde erkannt, aber nicht gerippt. + +**Seit dem 29.08.2026 geht es** — nicht mit einem Nachbau von abcde, sondern +über den Weg, den Windows selbst anbietet. Siehe den aktuellen Stand oben. ### Die Lehre dieser Sitzung, viermal bezahlt diff --git a/docker/worker/ripping.py b/docker/worker/ripping.py index 1aefa8f..2327804 100644 --- a/docker/worker/ripping.py +++ b/docker/worker/ripping.py @@ -928,10 +928,51 @@ def rip_video(device_path: str, disc_id: str, disc_type: str = "dvd", progress_c log_cb=log_cb) +def _rip_cd_windows(device_path: str, disc_id: str, progress_cb=None, + output_dir: str = None, auswerfen: bool = True) -> dict: + """Audio-CD unter Windows: selbst lesen, mit FLAC kodieren. + + Die Rechnung steht in `rippy.rip.audio_cd` (ohne Laufwerk pruefbar), das + Lesen im Windows-Treiber (der einzige Ort mit Win32-Aufrufen). + """ + from rippy.drives import windows as win + from rippy.rip import audio_cd + + class Leser: + toc = staticmethod(win.audio_toc) + + @staticmethod + def spur(geraet, start, sektoren, schreiben, fortschritt=None): + return win.audio_spur_lesen(geraet, start, sektoren, schreiben, + fortschritt) + + ziel = output_dir or os.path.join(RIP_OUTPUT_DIR, "cd", disc_id) + ergebnis = audio_cd.rippen(device_path, ziel, Leser(), + werkzeuge.finden("flac"), + fortschritt=progress_cb) + if auswerfen and ergebnis.get("status") == "success": + wirf_disc_aus(device_path) + return ergebnis + + def rip_cd(device_path: str, disc_id: str, progress_cb=None, output_dir: str = None, auswerfen: bool = True) -> dict: """Rippt eine CD mit abcde (FLAC). `auswerfen` = Einstellung „Automatischer Auswurf" (abcde macht das selbst per -x).""" + # ── Windows geht einen eigenen Weg (Befund 29.08.2026) ───────────── + # + # `abcde` und `cdparanoia` sind Linux-Werkzeuge; es gibt sie fuer Windows + # nicht, und ein Nachbau ihrer Shell-Logik waere ein zweites Projekt. + # Windows bietet stattdessen zwei Steuercodes an, mit denen sich eine + # Audio-CD direkt lesen laesst — `rippy.rip.audio_cd`, kodiert wird mit + # dem FLAC-Encoder aus dem Werkzeug-Katalog. + # + # Das war die letzte offene Luecke des Windows-Betriebs; sie stand seit + # Wochen so im SAVEPOINT. + if os.name == "nt": + return _rip_cd_windows(device_path, disc_id, progress_cb, output_dir, + auswerfen) + if not check_abcde_installed(): return { "status": "error", diff --git a/src/rippy/drives/win_ioctl.py b/src/rippy/drives/win_ioctl.py index ffdf246..4a4dfa2 100644 --- a/src/rippy/drives/win_ioctl.py +++ b/src/rippy/drives/win_ioctl.py @@ -149,3 +149,28 @@ def geraetepfad(buchstabe: str) -> str: if not kern: raise ValueError("Kein Laufwerksbuchstabe angegeben") return "\\\\.\\" + kern[0].upper() + ":" + + +# ── Audio-CD lesen (29.08.2026) ───────────────────────────────────────── +# +# Beide aus ntddcdrm.h. Eine Audio-CD hat kein Dateisystem: Windows zeigt +# `Track01.cda`, das sind aber 44-Byte-Platzhalter. Die Musik liegt roh in +# 2352-Byte-Sektoren und muss sektorweise gelesen werden. + +#: Inhaltsverzeichnis der CD (CDROM_TOC: je Track Startadresse als MSF). +IOCTL_CDROM_READ_TOC = ctl_code( + FILE_DEVICE_CD_ROM, 0x0000, METHOD_BUFFERED, FILE_READ_ACCESS) + +#: Rohe Sektoren lesen. Eingabe ist RAW_READ_INFO: +#: +#: LARGE_INTEGER DiskOffset; Sektor * 2048 (NICHT * 2352 — dokumentiert) +#: ULONG SectorCount; +#: TRACK_MODE_TYPE TrackMode; CDDA = 2 +#: +#: Der Ausgabepuffer muss SectorCount * 2352 gross sein. +#: learn.microsoft.com/windows-hardware/drivers/ddi/ntddcdrm/ns-ntddcdrm-_raw_read_info +IOCTL_CDROM_RAW_READ = ctl_code( + FILE_DEVICE_CD_ROM, 0x000F, METHOD_OUT_DIRECT, FILE_READ_ACCESS) + +#: Groesse von CDROM_TOC: 4 Byte Kopf + 100 Eintraege a 8 Byte. +CDROM_TOC_BYTES = 4 + 100 * 8 diff --git a/src/rippy/drives/windows.py b/src/rippy/drives/windows.py index 66e7dbc..cb8656e 100644 --- a/src/rippy/drives/windows.py +++ b/src/rippy/drives/windows.py @@ -513,3 +513,61 @@ def _angaben_gemerkt(geraet: str, api=None) -> dict: if geraet not in _ANGABEN_SPEICHER: _ANGABEN_SPEICHER[geraet] = geraeteangaben(geraet, api) or {} return _ANGABEN_SPEICHER[geraet] + +# ── Audio-CD (29.08.2026) ─────────────────────────────────────────────── +# +# Die LETZTE offene Luecke des Windows-Betriebs: `cdparanoia` und `abcde` +# sind Linux-Werkzeuge, es gab sie hier nie. Windows bietet stattdessen zwei +# Steuercodes an — die Rechnung dazu steht in `rippy.rip.audio_cd`, damit sie +# ohne Laufwerk pruefbar ist. + + +def audio_toc(geraet: str, api=None) -> list: + """Das Inhaltsverzeichnis einer Audio-CD — `[]`, wenn keine drin ist. + + Gibt die Spuren aus `audio_cd.toc_zerlegen()` zurueck. + """ + from rippy.rip import audio_cd + + api = _api(api) + handle = api.oeffnen(geraet) + try: + roh = api.steuern(handle, w.IOCTL_CDROM_READ_TOC, + ausgabe_bytes=w.CDROM_TOC_BYTES) + finally: + api.schliessen(handle) + return audio_cd.toc_zerlegen(roh) + + +def audio_spur_lesen(geraet: str, start: int, sektoren: int, schreiben, + fortschritt=None, api=None) -> int: + """Eine Audio-Spur sektorweise lesen. Gibt die gelesenen Bytes zurueck. + + `schreiben(bytes)` bekommt jeden Block — so wandert eine 47-MB-Spur nie + komplett in den Speicher. `fortschritt(anteil)` bekommt 0..1. + + ⚠️ `DiskOffset` zaehlt in 2048er-Einheiten, obwohl ein Audio-Sektor 2352 + Bytes hat. Das ist dokumentiert und sieht falsch aus; wer hier 2352 + einsetzt, liest an der falschen Stelle. + """ + import struct + + from rippy.rip import audio_cd + + api = _api(api) + handle = api.oeffnen(geraet) + gelesen = 0 + try: + teile = audio_cd.bloecke(start, sektoren) + for i, (von, anzahl) in enumerate(teile): + eingabe = struct.pack(" **BEKANNTE LÜCKE: Audio-CDs laufen unter Windows NICHT.** `cdparanoia` +> (Titelliste) und `abcde` (Rippen) sind Linux-Werkzeuge. + +Auf die Ansage „mach es" wird sie hier geschlossen. Übernommen wird nichts +von abcde — es gibt keine Windows-Fassung, und ein Nachbau seiner Shell-Logik +wäre ein zweites Projekt. Gebaut wird der Weg, den Windows selbst anbietet. + +## Warum eine Audio-CD nicht einfach kopiert werden kann + +Windows zeigt auf einer Audio-CD `Track01.cda` & Co. Das sind **keine +Audiodateien**, sondern 44 Byte große Platzhalter mit der Track-Nummer darin. +Die Musik liegt roh auf der Disc, in 2352-Byte-Sektoren ohne Dateisystem — +44100 Hz, 16 Bit, Stereo, little endian. Wer sie will, muss sie sektorweise +lesen. + +## Die zwei Win32-Aufrufe + +`IOCTL_CDROM_READ_TOC` liefert das Inhaltsverzeichnis: je Track die +Startadresse als MSF (Minute/Sekunde/Frame) und ob er Audio ist. + +`IOCTL_CDROM_RAW_READ` liest die Sektoren. Zwei Fallen, beide dokumentiert +(learn.microsoft.com, `RAW_READ_INFO`): + +* `DiskOffset` zählt in **2048er**-Einheiten, obwohl ein Audio-Sektor 2352 + Bytes hat. `sektor * 2048` ist also richtig und sieht falsch aus. +* Der Ausgabepuffer muss `SectorCount * 2352` groß sein. + +## Was hier PUR ist — und warum das der Punkt ist + +Ein Laufwerk lässt sich in der Ampel nicht herstellen. Deshalb steht alles, +was sich rechnen lässt, in reinen Funktionen: MSF↔LBA, das Zerlegen der +TOC-Bytes, der WAV-Kopf, die Aufteilung in Lese-Blöcke. Geprüft wird damit +das, woran solche Arbeit erfahrungsgemäß scheitert — die Rechnung, nicht der +Treiber. +""" + +import struct + +#: Ein Audio-Sektor auf einer CD: 588 Sample-Paare à 4 Byte. +SEKTOR_BYTES = 2352 + +#: `RAW_READ_INFO.DiskOffset` zählt in diesen Einheiten — NICHT in 2352. +#: (learn.microsoft.com/windows-hardware/drivers/ddi/ntddcdrm/ns-ntddcdrm-_raw_read_info) +OFFSET_EINHEIT = 2048 + +#: 75 Frames je Sekunde, und die ersten 2 Sekunden sind Vorlauf. +FRAMES_JE_SEKUNDE = 75 +VORLAUF_FRAMES = 150 + +#: Wie viele Sektoren auf einmal gelesen werden. 27 * 2352 = 63504 Bytes — +#: knapp unter der 64-KB-Grenze, die viele Treiber je Anfrage setzen. +BLOCK_SEKTOREN = 27 + +#: Audioformat einer CD: 44100 Hz, 16 Bit, Stereo. +RATE = 44100 +BITS = 16 +KANAELE = 2 + +#: TRACK_MODE_TYPE.CDDA aus ntddcdrm.h +CDDA = 2 + + +def msf_zu_lba(minute: int, sekunde: int, frame: int) -> int: + """MSF-Adresse → logische Sektornummer (pure Funktion). + + Die Rechnung ist Standard (ECMA-130): Jede Sekunde hat 75 Frames, und die + Adressierung beginnt bei 00:02:00 — die ersten zwei Sekunden sind + Vorlauf. Wer die 150 vergisst, liest jeden Track zwei Sekunden zu früh. + """ + return (minute * 60 + sekunde) * FRAMES_JE_SEKUNDE + frame - VORLAUF_FRAMES + + +def dauer_text(sektoren: int) -> str: + """Sektoren → „M:SS" (pure Funktion).""" + gesamt = max(0, sektoren) // FRAMES_JE_SEKUNDE + return "%d:%02d" % (gesamt // 60, gesamt % 60) + + +def toc_zerlegen(rohdaten: bytes) -> list: + """Die Bytes von IOCTL_CDROM_READ_TOC → Track-Liste (pure Funktion). + + Aufbau von `CDROM_TOC` (ntddcdrm.h): + + 0..1 Length (big endian, ohne diese zwei Bytes) + 2 FirstTrack + 3 LastTrack + 4.. TrackData[], je 8 Byte: + 0 Reserved + 1 Adr:4 / Control:4 — Control-Bit 2 gesetzt = DATEN-Track + 2 TrackNumber — 0xAA ist der Lead-Out + 3 Reserved + 4..7 Address — 0, M, S, F + + Rückgabe: `[{"nr", "start", "sektoren", "audio", "dauer"}, …]`. Die Länge + eines Tracks ergibt sich aus dem Start des NÄCHSTEN — deshalb muss der + Lead-Out mitgelesen werden, sonst fehlt beim letzten Track die Länge. + """ + if not rohdaten or len(rohdaten) < 12: + return [] + eintraege = [] + stelle = 4 + while stelle + 8 <= len(rohdaten): + _res, adr_control, nummer, _res2, _null, m, s, f = struct.unpack( + ">BBBBBBBB", rohdaten[stelle:stelle + 8]) + eintraege.append({ + "nr": nummer, + "lba": msf_zu_lba(m, s, f), + # Control-Bit 2 (0x04) heisst „Daten-Track". Eine Misch-CD hat + # beides; wir rippen nur die Audio-Spuren. + "audio": not (adr_control & 0x04), + }) + stelle += 8 + + spuren = [] + for i, eintrag in enumerate(eintraege): + if eintrag["nr"] == 0xAA: # Lead-Out: nur als Endmarke + continue + naechster = eintraege[i + 1]["lba"] if i + 1 < len(eintraege) else None + if naechster is None: + continue # ohne Endmarke keine Laenge + laenge = naechster - eintrag["lba"] + if laenge <= 0: + continue + spuren.append({ + "nr": eintrag["nr"], + "start": eintrag["lba"], + "sektoren": laenge, + "audio": eintrag["audio"], + "dauer": dauer_text(laenge), + }) + return spuren + + +def bloecke(start: int, sektoren: int, je_block: int = BLOCK_SEKTOREN) -> list: + """Einen Track in Lese-Blöcke zerlegen (pure Funktion). + + `[(start_sektor, anzahl), …]`. Am Stück zu lesen scheitert: Viele Treiber + nehmen je Anfrage höchstens 64 KB, und ein Track hat schnell 20 000 + Sektoren (47 MB). + """ + if sektoren <= 0 or je_block <= 0: + return [] + teile = [] + gelesen = 0 + while gelesen < sektoren: + anzahl = min(je_block, sektoren - gelesen) + teile.append((start + gelesen, anzahl)) + gelesen += anzahl + return teile + + +def wav_kopf(daten_bytes: int, rate: int = RATE, kanaele: int = KANAELE, + bits: int = BITS) -> bytes: + """Der 44-Byte-WAV-Kopf für rohe CD-Audiodaten (pure Funktion). + + CD-Audio ist bereits PCM in genau dem Format, das WAV erwartet — es fehlt + nur der Kopf. Deshalb wird nichts umgerechnet: Die Sektoren wandern + unverändert hinter diese 44 Byte. + """ + byte_rate = rate * kanaele * bits // 8 + block = kanaele * bits // 8 + return (b"RIFF" + struct.pack(" str: + """`01 - Titel.flac` — oder `Track 01.flac`, wenn kein Titel bekannt ist. + + Ohne Zeichen, die Windows in Dateinamen verbietet. Ein Titel wie + „AC/DC: Back in Black" darf keinen Ordner aufmachen. + """ + sauber = "".join(z for z in (titel or "") if z not in '<>:"/\\|?*').strip() + sauber = " ".join(sauber.split()) + return ("%02d - %s" % (nr, sauber)) if sauber else ("Track %02d" % nr) + + +def flac_befehl(programm: str, wav: str, ziel: str, tags: dict = None) -> list: + """Der FLAC-Aufruf (pure Funktion, testbar). + + `-8` ist die beste Kompression; sie kostet bei Audio-CD-Material Sekunden + und spart gegenüber `-5` spürbar Platz. `--totally-silent`, weil Rippy den + Fortschritt selbst meldet. + """ + befehl = [programm, "-8", "--totally-silent", "-o", ziel] + for name, wert in (tags or {}).items(): + if wert: + befehl += ["--tag", "%s=%s" % (name, wert)] + befehl.append(wav) + return befehl + + +def rippen(geraet: str, ziel_ordner: str, leser, encoder: str, + fortschritt=None, laufen=None, tags_je_spur=None) -> dict: + """Eine Audio-CD nach FLAC rippen. Der Windows-Weg. + + ## Warum das hier steht und nicht in `ripping.py` + + `ripping.py` ist der Worker-Code mit abcde und makemkvcon — beides + Linux-Werkzeuge oder Windows-Programme mit eigener Logik. Dieser Weg + gehört zum gemeinsamen Paket, weil er nur zwei Dinge braucht: einen + `leser` (die Win32-Seite) und einen Encoder. + + Alles Fremde ist eingespritzt: + + * `leser.toc(geraet)` -> Spurliste + * `leser.spur(geraet, start, n, schreiben)` -> Sektoren + * `laufen(befehl)` -> ruft FLAC auf + + Damit ist der ganze Ablauf ohne Laufwerk und ohne Encoder prüfbar — und + genau das war bei jedem Fehler dieser Sitzung der Unterschied zwischen + „gefunden" und „übersehen". + """ + import os + import subprocess + import tempfile + + def melde(anteil, text=""): + if fortschritt: + fortschritt(anteil, text) + + spuren = [s for s in leser.toc(geraet) if s["audio"]] + if not spuren: + return {"status": "error", + "error": "Keine Audio-Spuren gefunden. Ist das wirklich eine " + "Audio-CD? (Daten-Discs kommen hier nicht an.)"} + if not encoder: + return {"status": "error", + "error": "Der FLAC-Encoder fehlt. Er wird beim Einrichten " + "geholt — Einstellungen → System zeigt den Stand."} + + os.makedirs(ziel_ordner, exist_ok=True) + laufen = laufen or (lambda b: subprocess.run( + b, capture_output=True, timeout=600, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))) + dateien = [] + + for i, spur in enumerate(spuren): + tags = (tags_je_spur or {}).get(spur["nr"]) or {} + name = dateiname(spur["nr"], tags.get("TITLE", "")) + ziel = os.path.join(ziel_ordner, name + ".flac") + melde(int(i * 100 / len(spuren)), + "Spur %d von %d (%s)" % (i + 1, len(spuren), spur["dauer"])) + + # Erst nach WAV, dann kodieren. Direkt in FLAC zu leiten waere + # eleganter — aber ein abgebrochener Lesevorgang hinterliesse dann + # eine halbe FLAC-Datei, die wie eine ganze aussieht. + with tempfile.TemporaryDirectory(prefix="rippy-cd-") as arbeit: + wav = os.path.join(arbeit, "spur.wav") + bytes_gesamt = spur["sektoren"] * SEKTOR_BYTES + with open(wav, "wb") as f: + f.write(wav_kopf(bytes_gesamt)) + anteil_basis = i / len(spuren) + + def teil(anteil, _b=anteil_basis, _n=len(spuren)): + melde(int((_b + anteil / _n) * 100)) + + leser.spur(geraet, spur["start"], spur["sektoren"], f.write, teil) + + ergebnis = laufen(flac_befehl(encoder, wav, ziel, tags)) + if getattr(ergebnis, "returncode", 0) != 0: + fehler = getattr(ergebnis, "stderr", b"") or b"" + return {"status": "error", + "error": "FLAC brach bei Spur %d ab: %s" + % (spur["nr"], + fehler.decode("utf-8", "replace")[:200])} + dateien.append(ziel) + + melde(100, "Fertig") + return {"status": "success", "output_dir": ziel_ordner, "files": dateien} diff --git a/src/rippy/rip/test_audio_cd.py b/src/rippy/rip/test_audio_cd.py new file mode 100644 index 0000000..a1ca4fd --- /dev/null +++ b/src/rippy/rip/test_audio_cd.py @@ -0,0 +1,243 @@ +"""Audio-CD unter Windows — die Rechnung, ohne Laufwerk geprüft. + +## Warum es das gibt (Commander, 29.08.2026) + +Nach dem Rundgang durch die Docker-Reste blieb eine Lücke offen, die seit +Wochen so im SAVEPOINT stand: **Audio-CDs laufen unter Windows nicht** — +`cdparanoia` und `abcde` sind Linux-Werkzeuge. Auf „mach es" wird sie +geschlossen. + +Ein Laufwerk lässt sich in der Ampel nicht herstellen, ein Encoder auch +nicht. Beides ist deshalb eingespritzt, und geprüft wird das, woran solche +Arbeit wirklich scheitert: die Adressrechnung, das Zerlegen der TOC-Bytes, +der WAV-Kopf, die Blockaufteilung. +""" + +import struct + +import pytest + +from rippy.rip import audio_cd + + +# ── Adressrechnung ────────────────────────────────────────────────────── +@pytest.mark.parametrize("m,s,f,erwartet", [ + (0, 2, 0, 0), # der Anfang der ersten Spur + (0, 3, 0, 75), # eine Sekunde spaeter + (1, 2, 0, 4500), # eine Minute + # (3*60+25)*75 + 40 - 150 = 15265 — nachgerechnet, nicht geschaetzt. + (3, 25, 40, 15265), +]) +def test_msf_wird_zur_richtigen_sektornummer(m, s, f, erwartet): + """Die 150 Frames Vorlauf sind der Klassiker: Wer sie vergisst, liest + jede Spur zwei Sekunden zu frueh.""" + assert audio_cd.msf_zu_lba(m, s, f) == erwartet + + +def test_der_vorlauf_ist_wirklich_abgezogen(): + assert audio_cd.msf_zu_lba(0, 0, 0) == -audio_cd.VORLAUF_FRAMES + + +@pytest.mark.parametrize("sektoren,text", [ + (0, "0:00"), + (75, "0:01"), + (75 * 61, "1:01"), + (75 * 214, "3:34"), +]) +def test_dauer_wird_lesbar(sektoren, text): + assert audio_cd.dauer_text(sektoren) == text + + +# ── Das Inhaltsverzeichnis ────────────────────────────────────────────── +def _eintrag(nr, m, s, f, daten=False): + return struct.pack(">BBBBBBBB", 0, 0x14 if daten else 0x10, nr, 0, 0, m, s, f) + + +def _toc(*eintraege): + rumpf = b"".join(eintraege) + return struct.pack(">HBB", len(rumpf) + 2, 1, len(eintraege) - 1) + rumpf + + +def test_eine_normale_audio_cd_wird_zerlegt(): + roh = _toc(_eintrag(1, 0, 2, 0), + _eintrag(2, 3, 27, 0), + _eintrag(0xAA, 7, 12, 0)) # Lead-Out + spuren = audio_cd.toc_zerlegen(roh) + + assert [s["nr"] for s in spuren] == [1, 2] + assert spuren[0]["start"] == 0 + assert spuren[0]["sektoren"] == audio_cd.msf_zu_lba(3, 27, 0) + assert all(s["audio"] for s in spuren) + + +def test_die_letzte_spur_bekommt_ihre_laenge_aus_dem_lead_out(): + """Ohne den Lead-Out fehlt beim letzten Track die Laenge — er waere dann + entweder 0 lang oder wuerde ueber das Disc-Ende hinaus gelesen.""" + roh = _toc(_eintrag(1, 0, 2, 0), _eintrag(0xAA, 5, 2, 0)) + spuren = audio_cd.toc_zerlegen(roh) + assert len(spuren) == 1 + assert spuren[0]["sektoren"] == 5 * 60 * 75 + + +def test_daten_spuren_werden_als_solche_erkannt(): + """Eine Misch-CD hat beides. Nur die Audio-Spuren werden gerippt — und + genau daran haengt, dass eine Blu-ray hier nicht als Musik durchgeht.""" + roh = _toc(_eintrag(1, 0, 2, 0, daten=True), _eintrag(0xAA, 9, 9, 0)) + assert audio_cd.toc_zerlegen(roh)[0]["audio"] is False + + +def test_leere_oder_kaputte_daten_ergeben_keine_spuren(): + """Ohne Disc kommt Unsinn zurueck — das darf nichts umwerfen.""" + for roh in (b"", b"\x00" * 4, None): + assert audio_cd.toc_zerlegen(roh) == [] + + +# ── Lesen in Bloecken ─────────────────────────────────────────────────── +def test_ein_track_wird_in_bloecke_zerlegt(): + """Am Stueck zu lesen scheitert: Viele Treiber nehmen je Anfrage + hoechstens 64 KB.""" + teile = audio_cd.bloecke(100, 60, je_block=27) + assert teile == [(100, 27), (127, 27), (154, 6)] + assert sum(n for _, n in teile) == 60 + + +def test_der_block_bleibt_unter_64_kb(): + """27 * 2352 = 63504 — knapp darunter. Wer hier hoeher geht, bekommt vom + Treiber einen Fehler statt Daten.""" + assert audio_cd.BLOCK_SEKTOREN * audio_cd.SEKTOR_BYTES < 65536 + + +def test_keine_bloecke_ohne_laenge(): + assert audio_cd.bloecke(0, 0) == [] + assert audio_cd.bloecke(0, -5) == [] + + +# ── Der WAV-Kopf ──────────────────────────────────────────────────────── +def test_wav_kopf_ist_44_byte_und_beschreibt_cd_audio(): + kopf = audio_cd.wav_kopf(1000) + assert len(kopf) == 44 + assert kopf[:4] == b"RIFF" and kopf[8:12] == b"WAVE" + kanaele, rate, _byte_rate, _block, bits = struct.unpack(":"/\\|?*': + assert zeichen not in name + + +# ── Der FLAC-Aufruf ───────────────────────────────────────────────────── +def test_flac_bekommt_ziel_und_quelle(): + befehl = audio_cd.flac_befehl("flac.exe", "a.wav", "b.flac") + assert befehl[0] == "flac.exe" + assert befehl[-1] == "a.wav" + assert "-o" in befehl and "b.flac" in befehl + + +def test_tags_wandern_als_schalter_mit(): + befehl = audio_cd.flac_befehl("flac.exe", "a.wav", "b.flac", + {"TITLE": "Lied", "ARTIST": ""}) + assert "TITLE=Lied" in befehl + assert not any(t.startswith("ARTIST=") for t in befehl), "leere Tags weglassen" + + +# ── Der ganze Ablauf, ohne Laufwerk und ohne Encoder ──────────────────── +class FakeLeser: + """Ein Laufwerk mit zwei Audio-Spuren und einer Daten-Spur.""" + + def __init__(self): + self.gelesen = [] + + def toc(self, geraet): + return [ + {"nr": 1, "start": 0, "sektoren": 54, "audio": True, "dauer": "0:00"}, + {"nr": 2, "start": 54, "sektoren": 27, "audio": True, "dauer": "0:00"}, + {"nr": 3, "start": 81, "sektoren": 99, "audio": False, "dauer": "0:01"}, + ] + + def spur(self, geraet, start, sektoren, schreiben, fortschritt=None): + self.gelesen.append((start, sektoren)) + schreiben(b"\0" * (sektoren * audio_cd.SEKTOR_BYTES)) + if fortschritt: + fortschritt(1.0) + + +class FertigMelder: + returncode = 0 + stderr = b"" + + +def test_ein_ganzer_rip_laeuft_durch(tmp_path): + aufrufe = [] + + def laufen(befehl): + aufrufe.append(befehl) + # FLAC schreibt normalerweise die Zieldatei — hier nachgestellt. + with open(befehl[befehl.index("-o") + 1], "wb") as f: + f.write(b"fLaC") + return FertigMelder() + + ergebnis = audio_cd.rippen(r"\\.\G:", str(tmp_path), FakeLeser(), + "flac.exe", laufen=laufen) + + assert ergebnis["status"] == "success" + assert len(ergebnis["files"]) == 2, "die Daten-Spur gehoert nicht dazu" + assert all(p.endswith(".flac") for p in ergebnis["files"]) + assert len(aufrufe) == 2 + + +def test_eine_daten_disc_wird_nicht_gerippt(tmp_path): + """Genau das ist beim Messen passiert: Im Laufwerk lag eine Blu-ray, und + die meldet sich als EIN Daten-Track ueber 1,1 Millionen Sektoren.""" + class NurDaten(FakeLeser): + def toc(self, geraet): + return [{"nr": 1, "start": 0, "sektoren": 1154141, + "audio": False, "dauer": "256:28"}] + + ergebnis = audio_cd.rippen(r"\\.\G:", str(tmp_path), NurDaten(), "flac.exe", + laufen=lambda b: FertigMelder()) + assert ergebnis["status"] == "error" + assert "Audio-CD" in ergebnis["error"] + + +def test_ohne_encoder_wird_nichts_behauptet(tmp_path): + ergebnis = audio_cd.rippen(r"\\.\G:", str(tmp_path), FakeLeser(), "") + assert ergebnis["status"] == "error" + assert "FLAC-Encoder" in ergebnis["error"] + + +def test_ein_encoder_fehlschlag_wird_gemeldet(tmp_path): + class Kaputt: + returncode = 1 + stderr = b"ERROR: no space left" + + ergebnis = audio_cd.rippen(r"\\.\G:", str(tmp_path), FakeLeser(), "flac.exe", + laufen=lambda b: Kaputt()) + assert ergebnis["status"] == "error" + assert "no space left" in ergebnis["error"] + + +def test_die_spuren_werden_vollstaendig_gelesen(tmp_path): + leser = FakeLeser() + audio_cd.rippen(r"\\.\G:", str(tmp_path), leser, "flac.exe", + laufen=lambda b: FertigMelder()) + assert leser.gelesen == [(0, 54), (54, 27)], \ + "jede Audio-Spur genau einmal, von ihrem Start bis zu ihrer Laenge" diff --git a/src/rippy/tools/beschaffen.py b/src/rippy/tools/beschaffen.py index e4771ac..8f05ddd 100644 --- a/src/rippy/tools/beschaffen.py +++ b/src/rippy/tools/beschaffen.py @@ -48,6 +48,7 @@ mehr geht. import json import os +import re import shutil import subprocess import tempfile @@ -60,6 +61,16 @@ from rippy.tools import katalog HANDBRAKE_API = "https://api.github.com/repos/HandBrake/HandBrake/releases/latest" +# FLAC-Encoder fuer Audio-CDs (Befund 29.08.2026). Der offizielle Xiph-Spiegel +# listet die Windows-Pakete als `flac--win.zip`; am 29.08.2026 +# gemessen: HTTP 200, hoechste Fassung 1.5.0. +# +# Warum ein eigenes Werkzeug und nicht HandBrake: HandBrake schreibt FLAC nur +# IN einen Container (MKV/MKA), nie als `.flac`-Datei. Eine Musiksammlung will +# aber `.flac`. +FLAC_SPIEGEL = "https://ftp.osuosl.org/pub/xiph/releases/flac/" +FLAC_DATEI = re.compile(r"flac-(\d+\.\d+\.\d+)-win\.zip") + # Wie lange auf eine Antwort gewartet wird. Großzügig für den Download, # knapp für die Versionsabfrage — die soll das UI nicht aufhalten. ABFRAGE_TIMEOUT = 20 @@ -160,6 +171,86 @@ def waehle_handbrake_datei(assets: list, maschine: str = None) -> dict: return {} +def neueste_flac_fassung(seite: str) -> str: + """Hoechste Windows-Fassung aus dem Verzeichnis-HTML (pure Funktion). + + Der Xiph-Spiegel listet die Pakete als `flac-1.5.0-win.zip`. Sortiert + wird NUMERISCH — alphabetisch waere 1.4.10 kleiner als 1.4.2. + """ + fassungen = set(FLAC_DATEI.findall(seite or "")) + if not fassungen: + return "" + return max(fassungen, key=lambda v: [int(t) for t in v.split(".")]) + + +def _flac_ordner(eintraege) -> str: + """Der Ordner im Archiv, der flac.exe enthaelt (pure Funktion). + + Bevorzugt `Win64`. Zurueck kommt das Praefix samt Schraegstrich, damit + der Aufrufer alle Dateien daraus greifen kann — die DLLs eingeschlossen. + """ + kandidaten = [n for n in eintraege if n.lower().endswith("/flac.exe")] + bevorzugt = [n for n in kandidaten if "win64" in n.lower()] or kandidaten + return bevorzugt[0].rsplit("/", 1)[0] + "/" if bevorzugt else "" + + +def flac_holen(ziel_ordner: str = None, fortschritt=None, seite_laden=None) -> str: + """Laedt den FLAC-Encoder und legt ihn in Rippys Werkzeug-Ordner. + + Fuer Audio-CDs (Befund 29.08.2026): Windows hat weder `cdparanoia` noch + `abcde`. Das LESEN macht Rippy selbst ueber Win32 (`rippy.rip.audio_cd`), + zum Kodieren fehlte ein Encoder. + """ + ziel_ordner = ziel_ordner or os.path.join(katalog.werkzeug_ordner(), "flac") + + def melden(text, anteil=None): + if fortschritt: + fortschritt(text, anteil) + + melden("FLAC-Encoder wird gesucht …", 0.0) + seite = (seite_laden or (lambda: _laden(FLAC_SPIEGEL).decode("utf-8", "replace")))() + version = neueste_flac_fassung(seite) + if not version: + raise BeschaffungsFehler( + "Im Verzeichnis %s steht kein Windows-Paket (flac-*-win.zip). " + "Hat sich der Spiegel geaendert?" % FLAC_SPIEGEL) + + url = "%sflac-%s-win.zip" % (FLAC_SPIEGEL, version) + with tempfile.TemporaryDirectory(prefix="rippy-flac-") as arbeit: + archiv = os.path.join(arbeit, "flac.zip") + melden("FLAC %s wird geladen …" % version, 0.1) + _datei_laden(url, archiv, 0, melden) + + melden("Wird entpackt …", 0.9) + with zipfile.ZipFile(archiv) as z: + eintraege = z.namelist() + # ⚠️ Den GANZEN Ordner auspacken, nicht nur die exe (Befund + # 29.08.2026): `flac.exe` braucht `libFLAC.dll` daneben. Mit nur + # der exe endete jeder Aufruf mit 0xC0000135 — „DLL nicht + # gefunden" — und zwar ohne eine einzige Zeile Ausgabe. + # + # Das Paket bringt 32- und 64-Bit mit. `Win64/` ist die richtige + # Wahl; ohne sie liefe auf einem 64-Bit-System die 32-Bit-Fassung. + ordner = _flac_ordner(eintraege) + if not ordner: + raise BeschaffungsFehler( + "Im FLAC-Archiv steckt keine flac.exe. Format geaendert?") + os.makedirs(ziel_ordner, exist_ok=True) + for name in eintraege: + if not name.startswith(ordner) or name.endswith("/"): + continue + z.extract(name, arbeit) + quelle = os.path.join(arbeit, name) + ziel_datei = os.path.join(ziel_ordner, os.path.basename(name)) + vorlaeufig = ziel_datei + ".neu" + shutil.copy2(quelle, vorlaeufig) + os.replace(vorlaeufig, ziel_datei) + ziel = os.path.join(ziel_ordner, "flac.exe") + + melden("FLAC %s ist einsatzbereit." % version, 1.0) + return ziel + + def handbrake_holen(ziel_ordner: str = None, fortschritt=None) -> str: """Lädt HandBrakeCLI und legt es in Rippys Werkzeug-Ordner. Gibt den Pfad zurück.""" ziel_ordner = ziel_ordner or os.path.join(katalog.werkzeug_ordner(), "handbrake") diff --git a/src/rippy/tools/einrichten.py b/src/rippy/tools/einrichten.py index cea7195..f3caf13 100644 --- a/src/rippy/tools/einrichten.py +++ b/src/rippy/tools/einrichten.py @@ -78,7 +78,7 @@ def veraltete(bestand: dict, neueste: dict) -> list: def sicherstellen(fortschritt=None, eingestellt: dict = None, - handbrake_holen=None, makemkv_holen=None, + handbrake_holen=None, makemkv_holen=None, flac_holen=None, bestand_lesen=None, aktualisieren: bool = False, neueste_lesen=None) -> dict: """Holt, was fehlt. Gibt zurück, was DANACH wirklich da ist. @@ -100,6 +100,7 @@ def sicherstellen(fortschritt=None, eingestellt: dict = None, bestand_lesen = bestand_lesen or (lambda: katalog.bestand(eingestellt)) handbrake_holen = handbrake_holen or beschaffen.handbrake_holen makemkv_holen = makemkv_holen or beschaffen.makemkv_holen + flac_holen = flac_holen or beschaffen.flac_holen vorher = bestand_lesen() geholt, fehler = [], {} @@ -144,6 +145,11 @@ def sicherstellen(fortschritt=None, eingestellt: dict = None, # installieren, ist ein Ubergriff — und wenn es hakt, sieht # er wenigstens, woran. makemkv_holen(fortschritt=teil_melden, still=False) + elif name == "flac": + # Der Encoder fuer Audio-CDs. Kein Pflichtwerkzeug: Ohne ihn + # laeuft alles ausser Audio-CDs, und ein Fehlschlag hier darf + # das Einrichten nicht truebe machen. + flac_holen(fortschritt=teil_melden) else: continue except Exception as e: # noqa: BLE001 diff --git a/src/rippy/tools/katalog.py b/src/rippy/tools/katalog.py index 9338644..45089fa 100644 --- a/src/rippy/tools/katalog.py +++ b/src/rippy/tools/katalog.py @@ -61,6 +61,20 @@ WERKZEUGE = { "version_flag": None, # kennt keinen Versions-Schalter "pflicht": True, }, + "flac": { + "titel": "FLAC", + "zweck": "Wandelt die von der Audio-CD gelesenen Rohdaten in FLAC.", + "programme": ("flac.exe", "flac"), + "orte": ( + r"%ProgramFiles%\FLAC", + r"%ProgramFiles(x86)%\FLAC", + ), + "anzeige": "FLAC", + "version_flag": "--version", + # KEINE Pflicht: Ohne FLAC laeuft alles ausser Audio-CDs. Wer nie eine + # CD rippt, soll deswegen keine Fehlermeldung im Setup sehen. + "pflicht": False, + }, "handbrake": { "titel": "HandBrakeCLI", "zweck": "Komprimiert den verlustfreien Rip auf Arbeitsgröße.",