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:
co-authored by
Claude Opus 5
parent
986fcf19b1
commit
f6f0a4ccb6
@@ -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 —
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user