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:
co-authored by
Claude Opus 5
parent
1b84ec2a45
commit
986fcf19b1
+56
-4
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: 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("<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}
|
||||
@@ -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"
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.",
|
||||
|
||||
Reference in New Issue
Block a user