feat(windows): Audio-CDs rippen — die letzte Luecke ist zu
Ampel / ampel (push) Successful in 1m24s

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 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-08-29 16:26:23 +02:00
co-authored by Claude Opus 5
parent 1b84ec2a45
commit 986fcf19b1
9 changed files with 813 additions and 5 deletions
+56 -4
View File
@@ -1,6 +1,56 @@
# SAVEPOINT — Rippy
## Aktueller Stand: v4.0-rc7vier Befunde, drei mit derselben Wurzel (29.08.2026)
## Aktueller Stand: v4.0-rc8Windows 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
+41
View File
@@ -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",
+25
View File
@@ -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
+58
View File
@@ -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("<qII", von * audio_cd.OFFSET_EINHEIT,
anzahl, audio_cd.CDDA)
daten = api.steuern(handle, w.IOCTL_CDROM_RAW_READ, eingabe,
anzahl * audio_cd.SEKTOR_BYTES)
schreiben(daten)
gelesen += len(daten)
if fortschritt and teile:
fortschritt((i + 1) / len(teile))
finally:
api.schliessen(handle)
return gelesen
+278
View File
@@ -0,0 +1,278 @@
r"""Audio-CDs unter Windows lesen — ohne cdparanoia.
## Warum es diese Datei gibt (Commander, 29.08.2026)
Nach dem Rundgang durch die Docker-Reste blieb genau eine Lücke offen, und
sie stand seit Wochen so im SAVEPOINT:
> **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: MSFLBA, 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("<I", 36 + daten_bytes) + b"WAVE"
+ b"fmt " + struct.pack("<IHHIIHH", 16, 1, kanaele, rate,
byte_rate, block, bits)
+ b"data" + struct.pack("<I", daten_bytes))
def dateiname(nr: int, titel: str = "") -> 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}
+243
View File
@@ -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("<HIIHH", kopf[22:36])
assert (kanaele, rate, bits) == (2, 44100, 16)
def test_die_groessen_im_kopf_stimmen():
"""Ein falscher Wert hier ergibt eine Datei, die jeder Spieler ablehnt."""
kopf = audio_cd.wav_kopf(2352 * 10)
assert struct.unpack("<I", kopf[4:8])[0] == 36 + 23520
assert struct.unpack("<I", kopf[40:44])[0] == 23520
# ── Dateinamen ──────────────────────────────────────────────────────────
def test_ohne_titel_heisst_es_track():
assert audio_cd.dateiname(3) == "Track 03"
def test_mit_titel_wird_nummeriert():
assert audio_cd.dateiname(7, "Back in Black") == "07 - Back in Black"
def test_verbotene_zeichen_fliegen_raus():
"""„AC/DC: Back in Black" darf keinen Ordner aufmachen."""
name = audio_cd.dateiname(1, 'AC/DC: Back "in" Black?')
for zeichen in '<>:"/\\|?*':
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"
+91
View File
@@ -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-<version>-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")
+7 -1
View File
@@ -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
+14
View File
@@ -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.",