EAN-first workflow in interactive_rip + GnuDB DYEAR/DGENRE parsing

EAN is now asked before the album name. On MusicBrainz hit, the ripper
enters an auto-rip flow (no album name prompt, no CDDB confirm, disc
count from MB data). On miss/empty EAN, the previous fallback flow
(album name → CDDB confirm) is preserved.

GnuDB responses now parse DYEAR and DGENRE fields into a new CddbResult
NamedTuple. Album model gains an optional genre field.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-02-19 00:21:42 +01:00
commit 8b449493cd
5 changed files with 510 additions and 196 deletions

170
tests/test_cddb.py Normal file
View file

@ -0,0 +1,170 @@
"""Tests für cddb.py — CddbResult, DYEAR/DGENRE-Parsing."""
from unittest.mock import MagicMock, patch
import httpx
from musiksammlung.cddb import CddbResult, _read_gnudb, lookup_by_discid
def _make_xmcd_response(
dtitle: str = "Artist / Album Title",
dyear: str = "",
dgenre: str = "",
ttitles: dict[int, str] | None = None,
) -> str:
"""Baut eine GnuDB-xmcd-Antwort zusammen."""
lines = ["210 OK"]
lines.append(f"DTITLE={dtitle}")
if dyear:
lines.append(f"DYEAR={dyear}")
if dgenre:
lines.append(f"DGENRE={dgenre}")
if ttitles is None:
ttitles = {0: "Track One", 1: "Track Two"}
for idx, title in sorted(ttitles.items()):
lines.append(f"TTITLE{idx}={title}")
lines.append(".")
return "\n".join(lines)
class TestReadGnudbCddbResult:
"""Tests für _read_gnudb mit CddbResult-Rückgabe."""
def _call(self, xmcd_text: str) -> CddbResult | None:
"""Ruft _read_gnudb mit gemockter HTTP-Antwort auf."""
mock_response = MagicMock()
mock_response.text = xmcd_text
mock_response.raise_for_status = MagicMock()
with patch("musiksammlung.cddb.httpx.get", return_value=mock_response):
with patch("musiksammlung.cddb.time.sleep"):
return _read_gnudb("rock", "ab0c1d0e")
def test_basic_result_fields(self) -> None:
"""CddbResult enthält Artist, Album, Tracks."""
text = _make_xmcd_response(dtitle="Beatles / Abbey Road")
result = self._call(text)
assert result is not None
assert isinstance(result, CddbResult)
assert result.artist == "Beatles"
assert result.album == "Abbey Road"
assert len(result.tracks) == 2
assert result.tracks[0].track_number == 1
assert result.tracks[0].title == "Track One"
def test_dyear_parsed(self) -> None:
"""DYEAR wird als int geparst."""
text = _make_xmcd_response(dyear="1969")
result = self._call(text)
assert result is not None
assert result.year == 1969
def test_dyear_empty(self) -> None:
"""Kein DYEAR → year=None."""
text = _make_xmcd_response(dyear="")
result = self._call(text)
assert result is not None
assert result.year is None
def test_dyear_invalid(self) -> None:
"""Ungültiges DYEAR → year=None."""
text = _make_xmcd_response(dyear="unknown")
result = self._call(text)
assert result is not None
assert result.year is None
def test_dgenre_parsed(self) -> None:
"""DGENRE wird übernommen."""
text = _make_xmcd_response(dgenre="Classical")
result = self._call(text)
assert result is not None
assert result.genre == "Classical"
def test_dgenre_empty(self) -> None:
"""Kein DGENRE → genre=''."""
text = _make_xmcd_response()
result = self._call(text)
assert result is not None
assert result.genre == ""
def test_dyear_and_dgenre_together(self) -> None:
"""Beide Felder gleichzeitig."""
text = _make_xmcd_response(
dtitle="Karajan / Beethoven Sinfonien",
dyear="1985",
dgenre="Classical",
)
result = self._call(text)
assert result is not None
assert result.artist == "Karajan"
assert result.album == "Beethoven Sinfonien"
assert result.year == 1985
assert result.genre == "Classical"
def test_no_ttitles_returns_none(self) -> None:
"""Keine TTITLEs → None."""
text = _make_xmcd_response(ttitles={})
result = self._call(text)
assert result is None
def test_dtitle_without_slash(self) -> None:
"""DTITLE ohne ' / ' → artist leer, album = gesamter DTITLE."""
text = _make_xmcd_response(dtitle="Just An Album Name")
result = self._call(text)
assert result is not None
assert result.artist == ""
assert result.album == "Just An Album Name"
def test_bad_status_code_returns_none(self) -> None:
"""Unerwarteter Statuscode → None."""
mock_response = MagicMock()
mock_response.text = "401 permission denied\n"
mock_response.raise_for_status = MagicMock()
with patch("musiksammlung.cddb.httpx.get", return_value=mock_response):
with patch("musiksammlung.cddb.time.sleep"):
result = _read_gnudb("rock", "ab0c1d0e")
assert result is None
class TestLookupByDiscidCddbResult:
"""Tests dass lookup_by_discid CddbResult zurückgibt."""
def test_returns_cddb_result(self) -> None:
"""Erfolgreicher Lookup liefert CddbResult."""
expected = CddbResult(
tracks=[],
artist="Test",
album="Album",
year=2000,
genre="Pop",
)
with (
patch("musiksammlung.cddb._query_gnudb", return_value=("rock", "ab0c1d0e")),
patch("musiksammlung.cddb._read_gnudb", return_value=expected),
):
result = lookup_by_discid("ab0c1d0e 2 150 1000 200")
assert result is expected
def test_returns_none_on_no_match(self) -> None:
"""Kein Treffer → None."""
with (
patch("musiksammlung.cddb._query_gnudb", return_value=None),
patch("musiksammlung.cddb.time.sleep"),
patch("musiksammlung.cddb.random.uniform", return_value=0.01),
):
result = lookup_by_discid("ab0c1d0e 2 150 1000 200", retries=0)
assert result is None

View file

@ -292,7 +292,7 @@ class TestRenameFiles:
# ---------------------------------------------------------------------------
# interactive_rip EAN/Barcode-Integration
# interactive_rip EAN-First Workflow
# ---------------------------------------------------------------------------
_MB_ALBUM = Album(
@ -310,87 +310,43 @@ _MB_ALBUM = Album(
],
)
_MB_ALBUM_2DISC = Album(
artist="The Beatles",
album="White Album",
year=1968,
discs=[
Disc(disc_number=1, tracks=[
Track(track_number=1, title="Back in the U.S.S.R."),
]),
Disc(disc_number=2, tracks=[
Track(track_number=1, title="Birthday"),
]),
],
)
_CDDB_TRACKS = [
TrackInfo(1, "The Beatles", "Come Together"),
TrackInfo(2, "The Beatles", "Something"),
]
def _make_rip_disc_mock(tracks: list[TrackInfo] | None = None):
"""Erstellt ein Mock für rip_disc, das sofort zurückgibt."""
mock = MagicMock(return_value=(Path("/tmp/disc"), "Album1", tracks or []))
return mock
class TestInteractiveRipEanFirst:
"""Tests für EAN-First Workflow in interactive_rip.
Neuer Flow:
1. EAN abfragen
2. MB-Treffer Auto-Rip (kein Albumname, kein CDDB-Confirm)
3. Kein Treffer Fallback (Albumname, CDDB-Confirm wie bisher)
"""
class TestInteractiveRipBarcode:
"""Tests für EAN/Barcode-Abfrage in interactive_rip."""
# ── Auto-Rip (MusicBrainz-Treffer) ──
def _run(self, tmp_path: Path, inputs: list[str], mb_album=None, mb_error=None):
"""Führt interactive_rip mit gemocktem I/O aus."""
config = RipperConfig(output_dir=tmp_path)
input_iter = iter(inputs)
with (
patch("musiksammlung.ripper.rip_disc", return_value=(tmp_path, None, _CDDB_TRACKS)),
patch("builtins.input", side_effect=input_iter),
patch(
"musiksammlung.ripper.lookup_by_barcode",
side_effect=mb_error if mb_error else (lambda _: mb_album) if mb_album else None,
) as mock_lookup,
):
interactive_rip(config)
return mock_lookup
def test_ean_skipped_does_not_call_musicbrainz(self, tmp_path: Path) -> None:
"""Kein EAN → lookup_by_barcode wird nicht aufgerufen."""
def test_mb_hit_auto_rip_single_disc(self, tmp_path: Path) -> None:
"""MB-Treffer → Auto-Rip: eine Disc, kein Albumname-Prompt, album.json aus MB."""
inputs = [
"Abbey Road", # album name
"", # EAN: leer → überspringen
"1", # disc number
"j", # CDDB korrekt?
"n", # next CD?
"n", # next album?
]
config = RipperConfig(output_dir=tmp_path)
with (
patch("musiksammlung.ripper.rip_disc", return_value=(tmp_path, None, _CDDB_TRACKS)),
patch("builtins.input", side_effect=iter(inputs)),
patch("musiksammlung.ripper.lookup_by_barcode") as mock_lookup,
):
interactive_rip(config)
mock_lookup.assert_not_called()
def test_ean_triggers_musicbrainz_lookup(self, tmp_path: Path) -> None:
"""EAN eingegeben → lookup_by_barcode wird mit der EAN aufgerufen."""
inputs = [
"Abbey Road", # album name
"0602557360561", # EAN
"1", # disc number
"j", # CDDB korrekt?
"n", # next CD?
"n", # next album?
]
config = RipperConfig(output_dir=tmp_path)
with (
patch("musiksammlung.ripper.rip_disc", return_value=(tmp_path, None, _CDDB_TRACKS)),
patch("builtins.input", side_effect=iter(inputs)),
patch("musiksammlung.ripper.lookup_by_barcode", return_value=_MB_ALBUM) as mock_lookup,
):
interactive_rip(config)
mock_lookup.assert_called_once_with("0602557360561")
def test_musicbrainz_data_saved_to_json(self, tmp_path: Path) -> None:
"""MusicBrainz-Daten werden in album.json gespeichert."""
inputs = [
"", # album name: leer → Default
"0602557360561", # EAN
"1", # disc number
"j", # CDDB korrekt?
"n", # next CD?
"n", # next album?
"0602557360561", # EAN → MB-Treffer
"", # "CD 1/1 einlegen und Enter drücken"
"n", # next album?
]
config = RipperConfig(output_dir=tmp_path)
with (
@ -408,15 +364,105 @@ class TestInteractiveRipBarcode:
assert data["album"] == "Abbey Road"
assert data["year"] == 1969
def test_musicbrainz_failure_falls_back_to_cddb(self, tmp_path: Path) -> None:
"""MusicBrainz-Fehler → CDDB-Daten werden verwendet, kein Absturz."""
def test_mb_hit_auto_rip_multi_disc(self, tmp_path: Path) -> None:
"""MB-Treffer mit 2 Discs → 2x Disc-Insert-Prompt, album.json aus MB."""
inputs = [
"Abbey Road", # album name
"0000000000000", # EAN (kein Treffer)
"1", # disc number
"j", # CDDB korrekt?
"n", # next CD?
"n", # next album?
"1234567890123", # EAN → MB-Treffer (2 Discs)
"", # "CD 1/2 einlegen und Enter drücken"
"", # "CD 2/2 einlegen und Enter drücken"
"n", # next album?
]
config = RipperConfig(output_dir=tmp_path)
with (
patch("musiksammlung.ripper.rip_disc", return_value=(tmp_path, None, _CDDB_TRACKS)),
patch("builtins.input", side_effect=iter(inputs)),
patch("musiksammlung.ripper.lookup_by_barcode", return_value=_MB_ALBUM_2DISC),
):
interactive_rip(config)
json_path = tmp_path / "White_Album" / "album.json"
assert json_path.exists()
import json
data = json.loads(json_path.read_text())
assert data["artist"] == "The Beatles"
assert data["album"] == "White Album"
assert data["year"] == 1968
assert len(data["discs"]) == 2
def test_mb_hit_triggers_lookup(self, tmp_path: Path) -> None:
"""EAN eingegeben → lookup_by_barcode wird aufgerufen."""
inputs = [
"0602557360561", # EAN
"", # disc insert
"n", # next album?
]
config = RipperConfig(output_dir=tmp_path)
with (
patch("musiksammlung.ripper.rip_disc", return_value=(tmp_path, None, _CDDB_TRACKS)),
patch("builtins.input", side_effect=iter(inputs)),
patch("musiksammlung.ripper.lookup_by_barcode", return_value=_MB_ALBUM) as mock_lookup,
):
interactive_rip(config)
mock_lookup.assert_called_once_with("0602557360561")
def test_mb_hit_renames_with_cddb_tracks(self, tmp_path: Path) -> None:
"""Auto-Rip nutzt CDDB-Tracks zum Umbenennen der Dateien."""
inputs = [
"0602557360561",
"", # disc insert
"n",
]
config = RipperConfig(output_dir=tmp_path)
disc_dir = tmp_path / "Abbey_Road" / "CD1"
disc_dir.mkdir(parents=True)
(disc_dir / "track01.flac").touch()
(disc_dir / "track02.flac").touch()
with (
patch("musiksammlung.ripper.rip_disc", return_value=(disc_dir, None, _CDDB_TRACKS)),
patch("builtins.input", side_effect=iter(inputs)),
patch("musiksammlung.ripper.lookup_by_barcode", return_value=_MB_ALBUM),
):
interactive_rip(config)
# _rename_files wurde aufgerufen (tracks vorhanden)
# Dateien existieren schon vorher, rename findet in _rename_files statt
assert (tmp_path / "Abbey_Road" / "album.json").exists()
# ── Fallback (kein MB-Treffer / EAN leer) ──
def test_ean_empty_falls_back_to_album_prompt(self, tmp_path: Path) -> None:
"""Leere EAN → Fallback: Albumname wird abgefragt."""
inputs = [
"", # EAN: leer → überspringen
"Abbey Road", # album name (Fallback-Flow)
"1", # disc number
"j", # CDDB korrekt?
"n", # next CD?
"n", # next album?
]
config = RipperConfig(output_dir=tmp_path)
with (
patch("musiksammlung.ripper.rip_disc", return_value=(tmp_path, None, _CDDB_TRACKS)),
patch("builtins.input", side_effect=iter(inputs)),
patch("musiksammlung.ripper.lookup_by_barcode") as mock_lookup,
):
interactive_rip(config)
mock_lookup.assert_not_called()
json_path = tmp_path / "Abbey_Road" / "album.json"
assert json_path.exists()
def test_mb_miss_falls_back_to_album_prompt(self, tmp_path: Path) -> None:
"""MB-Fehler → Fallback: Albumname wird abgefragt, CDDB-Daten genutzt."""
inputs = [
"0000000000000", # EAN (kein Treffer)
"Abbey Road", # album name (Fallback-Flow)
"1", # disc number
"j", # CDDB korrekt?
"n", # next CD?
"n", # next album?
]
config = RipperConfig(output_dir=tmp_path)
with (
@ -429,53 +475,54 @@ class TestInteractiveRipBarcode:
):
interactive_rip(config) # darf nicht werfen
# CDDB-basierte album.json wurde erstellt
json_path = tmp_path / "Abbey_Road" / "album.json"
assert json_path.exists()
def test_album_name_taken_from_musicbrainz_when_default(self, tmp_path: Path) -> None:
"""Albumnamen wird von MusicBrainz übernommen wenn kein Name manuell eingegeben."""
def test_fallback_cddb_confirm_rejected(self, tmp_path: Path) -> None:
"""Fallback: CDDB-Daten abgelehnt → keine album.json."""
inputs = [
"", # album name: leer → Default (Album1)
"0602557360561", # EAN
"1", # disc number
"j", # CDDB korrekt?
"n",
"n",
"", # EAN: leer
"TestAlbum", # album name
"1", # disc number
"n", # CDDB korrekt? → nein
"n", # next CD?
"n", # next album?
]
config = RipperConfig(output_dir=tmp_path)
with (
patch("musiksammlung.ripper.rip_disc", return_value=(tmp_path, None, _CDDB_TRACKS)),
patch("builtins.input", side_effect=iter(inputs)),
patch("musiksammlung.ripper.lookup_by_barcode", return_value=_MB_ALBUM),
patch("musiksammlung.ripper.lookup_by_barcode"),
):
interactive_rip(config)
# Verzeichnis und JSON nach MusicBrainz-Namen benannt
assert (tmp_path / "Abbey_Road" / "album.json").exists()
# Keine album.json, da CDDB-Daten verworfen
json_path = tmp_path / "TestAlbum" / "album.json"
assert not json_path.exists()
def test_manual_album_name_kept_when_not_default(self, tmp_path: Path) -> None:
"""Manuell eingegebener Albumname → album.json liegt im manuellen Verzeichnis."""
def test_fallback_multiple_discs(self, tmp_path: Path) -> None:
"""Fallback: Mehrere CDs für ein Album."""
inputs = [
"Mein Album", # manuell eingegebener Name
"0602557360561", # EAN
"1", # disc number
"j", # CDDB korrekt?
"n",
"n",
"", # EAN: leer
"TestAlbum", # album name
"1", # disc 1
"j", # CDDB korrekt?
"y", # next CD? → ja
"2", # disc 2
"j", # CDDB korrekt?
"n", # next CD?
"n", # next album?
]
config = RipperConfig(output_dir=tmp_path)
with (
patch("musiksammlung.ripper.rip_disc", return_value=(tmp_path, None, _CDDB_TRACKS)),
patch("builtins.input", side_effect=iter(inputs)),
patch("musiksammlung.ripper.lookup_by_barcode", return_value=_MB_ALBUM),
patch("musiksammlung.ripper.lookup_by_barcode"),
):
interactive_rip(config)
# album.json liegt im Verzeichnis mit dem manuellen Namen (dort liegen die Dateien),
# Inhalt stammt aber aus MusicBrainz
json_path = tmp_path / "Mein_Album" / "album.json"
json_path = tmp_path / "TestAlbum" / "album.json"
assert json_path.exists()
import json
data = json.loads(json_path.read_text())
assert data["artist"] == "The Beatles"
assert len(data["discs"]) == 2