feat(windows): MusicBrainz-Tags fuer Audio-CDs
Ampel / ampel (push) Successful in 1m25s

Commander: „go"

Die Dateien hiessen `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. Daraus wird ein
Fingerabdruck gerechnet — SHA-1 ueber die Versaetze, base64 mit eigenem
Alphabet (musicbrainz.org/doc/Disc_ID_Calculation).

## Belegt OHNE Audio-CD

MusicBrainz gibt zu jeder bekannten Kennung die Spurlage heraus, aus der sie
gerechnet wurde. Wer die Lage zurueckbekommt und daraus dieselbe Kennung
rechnet, hat die Rechnung belegt:

    Spurlage „Back in Black" (abgefragt)  ->  3KVSIWn_ewv9z0cCizmOJzDWtsQ-
    MusicBrainz sagt dazu                 ->  3KVSIWn_ewv9z0cCizmOJzDWtsQ-

Und die ganze Kette am Stueck, mit echtem 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 zurueckgelesen

## Zwei Fallen

**Der Vorlauf.** Die Kennung rechnet MIT den 150 Frames, das Lesen OHNE. Wer
das verwechselt, bekommt eine Kennung, die niemand kennt — und zwar ohne
Fehlermeldung, denn die Antwort ist dann schlicht „unbekannte Disc".

**`AC/DC`.** Als Ordnername haette der Schraegstrich unter Windows einen
Unterordner aufgemacht. Aufgefallen NUR, weil der Beweis den Namen ausgegeben
hat. Jetzt saeubert `sauberer_name` beides — Datei und Ordner.

## ⚠️ Mein Fehler dabei

Die Spurlage im Test hatte ich zuerst ERFUNDEN: Beim Beweis hatte ich nur
Spurzahl und Lead-Out ausgegeben und den Rest „passend" ergaenzt. Der Test
wurde prompt rot — zu Recht. Die Zahlen stehen jetzt so drin, wie MusicBrainz
sie herausgibt (erste Spur bei 182, nicht bei 150: diese Pressung hat einen
laengeren Vorlauf).

Ohne Treffer bleibt es bei `Track 01.flac`. Eine unbekannte Disc ist kein
Fehler, und ein Netzausfall darf keinen Rip umwerfen.

932 Tests gruen, ruff sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-08-29 16:33:46 +02:00
co-authored by Claude Opus 5
parent 986fcf19b1
commit f6f0a4ccb6
5 changed files with 423 additions and 8 deletions
+29
View File
@@ -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 Inhaltsverzeichnis gelesen wird — die eingelegte Blu-ray meldet sich korrekt
als *Daten*-Track und wird nicht als Musik behandelt. 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 Zwei Fallen stecken in der Sache, beide dokumentiert und beide im Code
begründet: Die Leseadresse zählt in **2048er**-Einheiten, obwohl ein begründet: Die Leseadresse zählt in **2048er**-Einheiten, obwohl ein
Audio-Sektor 2352 Bytes hat. Und `flac.exe` braucht `libFLAC.dll` daneben — Audio-Sektor 2352 Bytes hat. Und `flac.exe` braucht `libFLAC.dll` daneben —
+17 -2
View File
@@ -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, return win.audio_spur_lesen(geraet, start, sektoren, schreiben,
fortschritt) 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(), ergebnis = audio_cd.rippen(device_path, ziel, Leser(),
werkzeuge.finden("flac"), werkzeuge.finden("flac"),
fortschritt=progress_cb) fortschritt=progress_cb, tags_je_spur=tags)
if auswerfen and ergebnis.get("status") == "success": if auswerfen and ergebnis.get("status") == "success":
wirf_disc_aus(device_path) wirf_disc_aus(device_path)
return ergebnis return ergebnis
+20 -6
View File
@@ -172,14 +172,28 @@ def wav_kopf(daten_bytes: int, rate: int = RATE, kanaele: int = KANAELE,
+ b"data" + struct.pack("<I", daten_bytes)) + b"data" + struct.pack("<I", daten_bytes))
def dateiname(nr: int, titel: str = "") -> str: #: Zeichen, die Windows in Datei- und Ordnernamen verbietet.
"""`01 - Titel.flac` — oder `Track 01.flac`, wenn kein Titel bekannt ist. 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(z for z in (text or "") if z not in VERBOTEN)
sauber = " ".join(sauber.split()) 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) return ("%02d - %s" % (nr, sauber)) if sauber else ("Track %02d" % nr)
+178
View File
@@ -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)
+179
View File
@@ -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/<id>?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/<kennung>?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"<html>"])
def test_eine_unlesbare_antwort_wirft_nicht(muell):
assert mb.abfragen("x", laden=lambda u: muell) is None