diff --git a/SAVEPOINT.md b/SAVEPOINT.md index 58eefcc..939f1c3 100644 --- a/SAVEPOINT.md +++ b/SAVEPOINT.md @@ -23,6 +23,35 @@ 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. +### Und die Titel dazu + +Die Dateien heißen nicht mehr `Track 01.flac`. MusicBrainz erkennt eine CD an +der **Lage ihrer Spuren** — daraus wird ein Fingerabdruck gerechnet, und der +führt zu Album, Interpret, Jahr und jedem Titel. + +Belegt ohne Audio-CD, weil MusicBrainz zu jeder bekannten Kennung die +Spurlage herausgibt, aus der sie gerechnet wurde: + +``` +Spurlage von „Back in Black" (abgefragt) → 3KVSIWn_ewv9z0cCizmOJzDWtsQ- +MusicBrainz sagt dazu → 3KVSIWn_ewv9z0cCizmOJzDWtsQ- +``` + +Die ganze Kette am Stück gemessen — echte Spurlage, echte Abfrage, echter +Encoder: + +``` +Ordner ACDC - Back in Black +Dateien 01 - Hells Bells.flac, 02 - Shoot to Thrill.flac, … +Tags TITLE / ARTIST / ALBUM / ALBUMARTIST / DATE / TRACKNUMBER + (mit metaflac aus der fertigen Datei zurückgelesen) +``` + +Zwei Fallen dabei: Die Kennung rechnet **mit** den 150 Frames Vorlauf, das +Lesen **ohne** — wer das verwechselt, bekommt eine Kennung, die niemand kennt, +und zwar ohne Fehlermeldung. Und `AC/DC - Back in Black` hätte als Ordner +einen Unterordner aufgemacht; die Band heißt nun mal so. + 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 — diff --git a/docker/worker/ripping.py b/docker/worker/ripping.py index 2327804..9385ce8 100644 --- a/docker/worker/ripping.py +++ b/docker/worker/ripping.py @@ -946,10 +946,25 @@ def _rip_cd_windows(device_path: str, disc_id: str, progress_cb=None, return win.audio_spur_lesen(geraet, start, sektoren, schreiben, fortschritt) - ziel = output_dir or os.path.join(RIP_OUTPUT_DIR, "cd", disc_id) + # Erst die Disc bei MusicBrainz nachschlagen — daraus kommen sowohl der + # Ordnername als auch die Titel. Ohne Treffer heissen die Dateien + # `Track 01.flac`; das ist keine Ausrede, sondern die Wahrheit ueber eine + # Disc, die niemand kennt. + from rippy.rip import musicbrainz_cd + + try: + spuren = win.audio_toc(device_path) + antwort = musicbrainz_cd.abfragen( + musicbrainz_cd.kennung_aus_spuren(spuren)) + except Exception: # noqa: BLE001 + antwort = None + tags = musicbrainz_cd.tags_je_spur(antwort) + ordner = musicbrainz_cd.album_ordner(antwort) + + ziel = output_dir or os.path.join(RIP_OUTPUT_DIR, "cd", ordner or disc_id) ergebnis = audio_cd.rippen(device_path, ziel, Leser(), werkzeuge.finden("flac"), - fortschritt=progress_cb) + fortschritt=progress_cb, tags_je_spur=tags) if auswerfen and ergebnis.get("status") == "success": wirf_disc_aus(device_path) return ergebnis diff --git a/src/rippy/rip/audio_cd.py b/src/rippy/rip/audio_cd.py index 6b10571..185d4a0 100644 --- a/src/rippy/rip/audio_cd.py +++ b/src/rippy/rip/audio_cd.py @@ -172,14 +172,28 @@ def wav_kopf(daten_bytes: int, rate: int = RATE, kanaele: int = KANAELE, + b"data" + struct.pack(" str: - """`01 - Titel.flac` — oder `Track 01.flac`, wenn kein Titel bekannt ist. +#: Zeichen, die Windows in Datei- und Ordnernamen verbietet. +VERBOTEN = '<>:"/\\|?*' - Ohne Zeichen, die Windows in Dateinamen verbietet. Ein Titel wie - „AC/DC: Back in Black" darf keinen Ordner aufmachen. + +def sauberer_name(text: str) -> str: + """Ein Datei- oder Ordnername ohne verbotene Zeichen (pure Funktion). + + ⚠️ Das ist keine Kosmetik. Beim Beweis der MusicBrainz-Abfrage kam als + Ordnername `AC/DC - Back in Black` heraus (29.08.2026) — der Schrägstrich + hätte unter Windows einen Unterordner `DC - Back in Black` in einem Ordner + `AC` aufgemacht. Die Band heißt nun mal so. + + Ersetzt wird durch nichts; ein Bindestrich wäre geraten. Punkte und + Leerzeichen am Ende fallen weg — die verweigert der Explorer. """ - sauber = "".join(z for z in (titel or "") if z not in '<>:"/\\|?*').strip() - sauber = " ".join(sauber.split()) + sauber = "".join(z for z in (text or "") if z not in VERBOTEN) + return " ".join(sauber.split()).rstrip(". ") + + +def dateiname(nr: int, titel: str = "") -> str: + """`01 - Titel.flac` — oder `Track 01.flac`, wenn kein Titel bekannt ist.""" + sauber = sauberer_name(titel) return ("%02d - %s" % (nr, sauber)) if sauber else ("Track %02d" % nr) diff --git a/src/rippy/rip/musicbrainz_cd.py b/src/rippy/rip/musicbrainz_cd.py new file mode 100644 index 0000000..f445fc1 --- /dev/null +++ b/src/rippy/rip/musicbrainz_cd.py @@ -0,0 +1,178 @@ +"""Die Disc-Kennung einer Audio-CD und die Tags dazu. + +## Warum es das gibt (Commander, 29.08.2026) + +Audio-CDs laufen unter Windows seit heute — die Dateien hiessen aber +`Track 01.flac`. Eine Musiksammlung ohne Titel ist eine Sammlung von Nummern. + +## Wie MusicBrainz eine CD wiedererkennt + +Nicht am Namen (den kennt die CD nicht) und nicht an einer Kennung auf der +Disc (die gibt es nicht), sondern an der **Lage ihrer Spuren**. Zwei Pressungen +desselben Albums haben verschiedene Abstaende, zwei Exemplare derselben +Pressung dieselben. Daraus wird ein Fingerabdruck gerechnet: + + Text = erste Spur (2 Hex, gross) + + letzte Spur (2 Hex) + + Lead-Out-Versatz (8 Hex) + + 99 Spur-Versaetze (je 8 Hex, fehlende als Nullen) + Kennung = base64(sha1(Text)) mit + / = ersetzt durch . _ - + +Quelle: musicbrainz.org/doc/Disc_ID_Calculation (AGENTS Regel D — die +Rechnung steht dort, nicht in meinem Kopf). + +## Die Falle: der Vorlauf + +Die Versaetze in dieser Rechnung zaehlen **mit** den 150 Frames Vorlauf — es +sind MSF-Adressen, keine LBA-Nummern. `rippy.rip.audio_cd` rechnet den +Vorlauf beim Lesen bewusst heraus (dort ist er falsch). Wer hier dieselben +Zahlen einsetzt, bekommt eine Kennung, die MusicBrainz nicht kennt — und zwar +ohne Fehlermeldung, denn die Antwort ist dann einfach „unbekannte Disc". +""" + +import base64 +import hashlib +import json +import urllib.parse +import urllib.request + +from rippy.rip.audio_cd import VORLAUF_FRAMES + +#: Der Webdienst. `fmt=json` spart uns einen XML-Leser. +DIENST = "https://musicbrainz.org/ws/2/discid/" + +#: MusicBrainz verlangt eine sprechende Kennung mit Kontakt (Richtlinie auf +#: musicbrainz.org/doc/MusicBrainz_API/Rate_Limiting). Ohne sie wird gedrosselt +#: oder geblockt. +KOPF = {"User-Agent": "Rippy/2.0 (https://github.com/Hitonabi/rippy)"} + +#: Wie viele Spur-Versaetze im Text stehen — immer 99, auch bei 12 Spuren. +PLAETZE = 99 + + +def disc_id(spur_versaetze, lead_out: int, erste: int = 1, + letzte: int = None) -> str: + """Die MusicBrainz-Kennung einer CD (pure Funktion). + + `spur_versaetze` sind die Startadressen der Spuren **einschliesslich** + Vorlauf, `lead_out` das Ende der letzten Spur — ebenfalls mit Vorlauf. + """ + if not spur_versaetze: + return "" + letzte = letzte if letzte is not None else erste + len(spur_versaetze) - 1 + teile = ["%02X" % erste, "%02X" % letzte, "%08X" % lead_out] + for platz in range(PLAETZE): + wert = spur_versaetze[platz] if platz < len(spur_versaetze) else 0 + teile.append("%08X" % wert) + roh = hashlib.sha1("".join(teile).encode("ascii")).digest() + # Base64 mit MusicBrainz' eigenem Alphabet: + / = werden zu . _ - + return base64.b64encode(roh).decode("ascii").translate( + str.maketrans("+/=", "._-")) + + +def kennung_aus_spuren(spuren) -> str: + """Kennung direkt aus `audio_cd.toc_zerlegen()` (pure Funktion). + + Rechnet den Vorlauf wieder HINZU: `audio_cd` liefert LBA-Nummern (fuer + das Lesen richtig), MusicBrainz will MSF-Adressen. Genau hier gehen + solche Rechnungen sonst schief. + """ + if not spuren: + return "" + geordnet = sorted(spuren, key=lambda s: s["nr"]) + versaetze = [s["start"] + VORLAUF_FRAMES for s in geordnet] + letzte_spur = geordnet[-1] + lead_out = letzte_spur["start"] + letzte_spur["sektoren"] + VORLAUF_FRAMES + return disc_id(versaetze, lead_out, + erste=geordnet[0]["nr"], letzte=letzte_spur["nr"]) + + +def abfragen(kennung: str, laden=None, timeout: int = 15): + """Die Disc bei MusicBrainz nachschlagen. `None`, wenn unbekannt. + + `laden` ist einspritzbar — damit ist alles darunter ohne Netz pruefbar. + """ + if not kennung: + return None + url = (DIENST + urllib.parse.quote(kennung, safe="._-") + + "?fmt=json&inc=recordings+artist-credits") + if laden is None: + def laden(u): + anfrage = urllib.request.Request(u, headers=KOPF) + with urllib.request.urlopen(anfrage, timeout=timeout) as antwort: + return antwort.read() + try: + return json.loads(laden(url).decode("utf-8", "replace")) + except Exception: # noqa: BLE001 + # Unbekannte Disc, kein Netz, geaendertes Format — alles dasselbe + # Ergebnis: keine Tags. Ein Rip darf daran nicht scheitern. + return None + + +def _kuenstler(eintrag) -> str: + """„Artist Credit" zu einem Namen zusammensetzen (pure Funktion). + + MusicBrainz gibt Beteiligungen als Liste mit Bindewoertern zurueck — + „Simon" + " & " + „Garfunkel". Wer nur den ersten Namen nimmt, verliert + die Haelfte. + """ + teile = [] + for stueck in eintrag or []: + teile.append((stueck.get("name") or "") + + (stueck.get("joinphrase") or "")) + return "".join(teile).strip() + + +def tags_je_spur(antwort) -> dict: + """MusicBrainz-Antwort → `{spur_nr: {TAG: wert}}` (pure Funktion). + + Genommen wird die ERSTE Veroeffentlichung, die diese Disc enthaelt. Es + koennen mehrere sein (Wiederveroeffentlichungen mit identischer + Spurlage) — dann ist jede gleich richtig, und eine Auswahl waere geraten. + """ + if not antwort: + return {} + releases = antwort.get("releases") or [] + if not releases: + return {} + release = releases[0] + album = release.get("title") or "" + jahr = (release.get("date") or "")[:4] + album_kuenstler = _kuenstler(release.get("artist-credit")) + + tags = {} + for medium in release.get("media") or []: + for spur in medium.get("tracks") or []: + try: + nr = int(spur.get("position") or 0) + except (TypeError, ValueError): + continue + if nr <= 0: + continue + kuenstler = (_kuenstler(spur.get("artist-credit")) + or album_kuenstler) + tags[nr] = { + "TITLE": spur.get("title") or "", + "ARTIST": kuenstler, + "ALBUM": album, + "ALBUMARTIST": album_kuenstler, + "DATE": jahr, + "TRACKNUMBER": str(nr), + } + return tags + + +def album_ordner(antwort) -> str: + """`Künstler - Album` für den Zielordner — "" wenn nichts bekannt ist.""" + tags = tags_je_spur(antwort) + if not tags: + return "" + erste = tags[min(tags)] + kuenstler = erste.get("ALBUMARTIST") or erste.get("ARTIST") or "" + album = erste.get("ALBUM") or "" + from rippy.rip.audio_cd import sauberer_name + + roh = "%s - %s" % (kuenstler, album) if (kuenstler and album) else (album or kuenstler) + # ⚠️ „AC/DC - Back in Black" (beim Beweis herausgekommen) haette unter + # Windows einen Unterordner aufgemacht. Die Band heisst nun mal so. + return sauberer_name(roh) diff --git a/src/rippy/rip/test_musicbrainz_cd.py b/src/rippy/rip/test_musicbrainz_cd.py new file mode 100644 index 0000000..56f14c3 --- /dev/null +++ b/src/rippy/rip/test_musicbrainz_cd.py @@ -0,0 +1,179 @@ +"""Die Disc-Kennung und die Tags — an echten MusicBrainz-Daten geprüft. + +## Warum das ohne Audio-CD geht + +MusicBrainz gibt zu jeder bekannten Kennung die **Spurlage** heraus, aus der +sie gerechnet wurde. Wer die Lage zurückbekommt und daraus dieselbe Kennung +rechnet, hat die Rechnung belegt — ganz ohne Disc. + +Genau so wurde sie am 29.08.2026 abgenommen, gegen „Back in Black" von +AC/DC. Die Zahlen unten sind **nicht ausgedacht**, sie stammen aus dieser +Abfrage: + + https://musicbrainz.org/ws/2/release/?inc=discids&fmt=json + + Kennung 3KVSIWn_ewv9z0cCizmOJzDWtsQ- + Spuren 10 + Lead-Out 189281 + +Der Test läuft ohne Netz: Die Antwort ist hier festgehalten, nicht abgerufen. +""" + +import pytest + +from rippy.rip import audio_cd +from rippy.rip import musicbrainz_cd as mb + +#: Aus der echten MusicBrainz-Antwort (29.08.2026, AC/DC „Back in Black"), +#: abgerufen über `https://musicbrainz.org/ws/2/discid/?fmt=json`. +#: +#: ⚠️ Beim ersten Anlauf standen hier ausgedachte Zahlen — ich hatte beim +#: Beweis nur Spurzahl und Lead-Out ausgegeben und den Rest „passend" +#: ergänzt. Der Test wurde prompt rot, und zwar zu Recht. Diese Zahlen sind +#: abgefragt, nicht erinnert. +ACDC_VERSAETZE = [182, 23637, 47507, 63692, 79615, 98742, 117937, + 133712, 151660, 170112] +ACDC_LEAD_OUT = 189281 +ACDC_KENNUNG = "3KVSIWn_ewv9z0cCizmOJzDWtsQ-" + + +def test_die_kennung_stimmt_mit_musicbrainz_ueberein(): + """DER Beleg. Weicht auch nur ein Zeichen ab, findet MusicBrainz die Disc + nicht — und zwar ohne Fehlermeldung, denn die Antwort waere dann schlicht + „unbekannte Disc".""" + assert mb.disc_id(ACDC_VERSAETZE, ACDC_LEAD_OUT, + erste=1, letzte=10) == ACDC_KENNUNG + + +def test_der_vorlauf_wird_wieder_hinzugerechnet(): + """Die Falle: `audio_cd` liefert LBA-Nummern (fuer das LESEN richtig), + MusicBrainz will MSF-Adressen — also 150 mehr. Wer das verwechselt, + bekommt eine Kennung, die niemand kennt.""" + # Dieselbe Disc, aber so, wie `toc_zerlegen` sie liefert. + spuren = [] + for i, versatz in enumerate(ACDC_VERSAETZE): + naechster = (ACDC_VERSAETZE[i + 1] if i + 1 < len(ACDC_VERSAETZE) + else ACDC_LEAD_OUT) + spuren.append({"nr": i + 1, + "start": versatz - audio_cd.VORLAUF_FRAMES, + "sektoren": naechster - versatz, + "audio": True, "dauer": ""}) + assert mb.kennung_aus_spuren(spuren) == ACDC_KENNUNG + + +def test_ohne_spuren_gibt_es_keine_kennung(): + assert mb.disc_id([], 1000) == "" + assert mb.kennung_aus_spuren([]) == "" + + +def test_die_kennung_benutzt_das_musicbrainz_alphabet(): + """Nicht das normale Base64: + / = werden zu . _ - — sonst passt sie + nicht in eine URL und der Dienst antwortet mit 404.""" + kennung = mb.disc_id(ACDC_VERSAETZE, ACDC_LEAD_OUT, letzte=10) + assert not set("+/=") & set(kennung) + + +def test_es_stehen_immer_99_plaetze_im_text(): + """Auch bei 10 Spuren. Wer nur die vorhandenen einsetzt, rechnet einen + anderen Hash.""" + assert mb.PLAETZE == 99 + # Zwei Discs mit gleicher Spurzahl, aber anderer Lage -> andere Kennung. + a = mb.disc_id([150, 20000], 40000, letzte=2) + b = mb.disc_id([150, 20001], 40000, letzte=2) + assert a != b + + +# ── Die Tags ──────────────────────────────────────────────────────────── +ANTWORT = { + "releases": [{ + "title": "Back in Black", + "date": "1980-07-25", + "artist-credit": [{"name": "AC/DC"}], + "media": [{"tracks": [ + {"position": 1, "title": "Hells Bells"}, + {"position": 2, "title": "Shoot to Thrill"}, + ]}], + }] +} + + +def test_tags_kommen_je_spur(): + tags = mb.tags_je_spur(ANTWORT) + assert tags[1]["TITLE"] == "Hells Bells" + assert tags[2]["TRACKNUMBER"] == "2" + assert tags[1]["ALBUM"] == "Back in Black" + assert tags[1]["DATE"] == "1980", "nur das Jahr, nicht das ganze Datum" + + +def test_ohne_antwort_gibt_es_keine_tags(): + """Eine unbekannte Disc ist kein Fehler — die Dateien heissen dann + `Track 01.flac`, und das ist die Wahrheit.""" + assert mb.tags_je_spur(None) == {} + assert mb.tags_je_spur({}) == {} + assert mb.tags_je_spur({"releases": []}) == {} + + +def test_mehrere_beteiligte_werden_zusammengesetzt(): + """MusicBrainz gibt „Simon" + „ & " + „Garfunkel". Wer nur den ersten + Namen nimmt, verliert die Haelfte der Band.""" + antwort = {"releases": [{ + "title": "X", "artist-credit": [ + {"name": "Simon", "joinphrase": " & "}, {"name": "Garfunkel"}], + "media": [{"tracks": [{"position": 1, "title": "Y"}]}]}]} + assert mb.tags_je_spur(antwort)[1]["ARTIST"] == "Simon & Garfunkel" + + +def test_ein_abweichender_spur_kuenstler_gewinnt(): + """Sampler: Jede Spur hat ihren eigenen Interpreten, das Album einen + Sammelnamen.""" + antwort = {"releases": [{ + "title": "Sampler", "artist-credit": [{"name": "Various Artists"}], + "media": [{"tracks": [ + {"position": 1, "title": "A", "artist-credit": [{"name": "Nena"}]}]}]}]} + tags = mb.tags_je_spur(antwort)[1] + assert tags["ARTIST"] == "Nena" + assert tags["ALBUMARTIST"] == "Various Artists" + + +def test_der_ordnername_ist_windows_tauglich(): + """`AC/DC - Back in Black` haette einen Unterordner aufgemacht — genau + das kam beim Beweis am 29.08.2026 heraus.""" + name = mb.album_ordner(ANTWORT) + assert name == "ACDC - Back in Black" + for zeichen in audio_cd.VERBOTEN: + assert zeichen not in name + + +def test_ohne_treffer_kein_ordnername(): + assert mb.album_ordner(None) == "" + + +# ── Die Abfrage ───────────────────────────────────────────────────────── +def test_die_abfrage_baut_die_richtige_adresse(): + gesehen = {} + + def laden(url): + gesehen["url"] = url + return b'{"releases": []}' + + mb.abfragen(ACDC_KENNUNG, laden=laden) + assert ACDC_KENNUNG in gesehen["url"] + assert "fmt=json" in gesehen["url"] + assert "recordings" in gesehen["url"], "ohne Aufnahmen gibt es keine Titel" + + +def test_ein_netzfehler_ist_kein_grund_zu_scheitern(): + """Ein Rip darf nicht daran haengen, ob MusicBrainz gerade antwortet.""" + def kaputt(url): + raise OSError("kein Netz") + + assert mb.abfragen(ACDC_KENNUNG, laden=kaputt) is None + + +def test_ohne_kennung_wird_gar_nicht_gefragt(): + assert mb.abfragen("", laden=lambda u: 1 / 0) is None + + +@pytest.mark.parametrize("muell", [b"kein json", b"", b""]) +def test_eine_unlesbare_antwort_wirft_nicht(muell): + assert mb.abfragen("x", laden=lambda u: muell) is None