Add MusicBrainz barcode lookup (scan --barcode and interactive rip)

- New module musicbrainz.py: lookup_by_barcode() via EAN-13/UPC-12,
  two-step API (barcode search → release detail with recordings),
  respects 1 req/s rate limit with User-Agent header
- cli.py: scan command gets --barcode option as highest-priority mode
  (no images needed); _scan_to_album() dispatches to MusicBrainz first
- ripper.py: interactive_rip() prompts for optional EAN after album name;
  MusicBrainz data (incl. year) takes priority over CDDB for album.json;
  album_root.mkdir() added so JSON can be written even when MB changes dir
- tests: test_musicbrainz.py (16 tests), test_ripper.py +6 barcode tests

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-02-18 06:13:10 +01:00
commit b30aaa617d
5 changed files with 552 additions and 7 deletions

209
tests/test_musicbrainz.py Normal file
View file

@ -0,0 +1,209 @@
"""Tests für den MusicBrainz-Barcode-Lookup."""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
from musiksammlung.musicbrainz import _parse_release, lookup_by_barcode
# ---------------------------------------------------------------------------
# Hilfsfunktionen
# ---------------------------------------------------------------------------
def _mock_response(data: dict) -> MagicMock:
"""Erstellt ein Mock-httpx.Response-Objekt."""
r = MagicMock()
r.json.return_value = data
r.raise_for_status.return_value = None
return r
_BARCODE_RESPONSE = {
"releases": [{"id": "abc-123"}],
}
_RELEASE_RESPONSE = {
"title": "Abbey Road",
"date": "1969-09-26",
"artist-credit": [{"artist": {"name": "The Beatles"}}],
"media": [
{
"position": 1,
"tracks": [
{
"position": 1,
"title": "Come Together",
"artist-credit": [{"artist": {"name": "The Beatles"}}],
},
{
"position": 2,
"title": "Something",
"artist-credit": [{"artist": {"name": "The Beatles"}}],
},
],
}
],
}
# ---------------------------------------------------------------------------
# _parse_release
# ---------------------------------------------------------------------------
class TestParseRelease:
def test_basic_fields(self) -> None:
album = _parse_release(_RELEASE_RESPONSE)
assert album.artist == "The Beatles"
assert album.album == "Abbey Road"
assert album.year == 1969
def test_tracks(self) -> None:
album = _parse_release(_RELEASE_RESPONSE)
assert len(album.discs) == 1
disc = album.discs[0]
assert disc.disc_number == 1
assert len(disc.tracks) == 2
assert disc.tracks[0].title == "Come Together"
assert disc.tracks[1].title == "Something"
def test_track_artist_same_as_album_artist_is_none(self) -> None:
album = _parse_release(_RELEASE_RESPONSE)
# Track-Künstler = Album-Künstler → track.artist muss None sein
assert album.discs[0].tracks[0].artist is None
def test_track_artist_different_is_set(self) -> None:
data = {
"title": "Compilation",
"date": "2000",
"artist-credit": [{"artist": {"name": "Various Artists"}}],
"media": [
{
"position": 1,
"tracks": [
{
"position": 1,
"title": "Song A",
"artist-credit": [{"artist": {"name": "Artist A"}}],
}
],
}
],
}
album = _parse_release(data)
assert album.discs[0].tracks[0].artist == "Artist A"
def test_year_from_full_date(self) -> None:
data = {**_RELEASE_RESPONSE, "date": "1969-09-26"}
album = _parse_release(data)
assert album.year == 1969
def test_year_from_year_only(self) -> None:
data = {**_RELEASE_RESPONSE, "date": "1969"}
album = _parse_release(data)
assert album.year == 1969
def test_year_none_when_missing(self) -> None:
data = {**_RELEASE_RESPONSE, "date": ""}
album = _parse_release(data)
assert album.year is None
def test_year_none_when_invalid(self) -> None:
data = {**_RELEASE_RESPONSE, "date": "unbekannt"}
album = _parse_release(data)
assert album.year is None
def test_multi_disc(self) -> None:
data = {
"title": "The Wall",
"date": "1979",
"artist-credit": [{"artist": {"name": "Pink Floyd"}}],
"media": [
{
"position": 1,
"tracks": [{"position": 1, "title": "In the Flesh?", "artist-credit": []}],
},
{
"position": 2,
"tracks": [{"position": 1, "title": "Hey You", "artist-credit": []}],
},
],
}
album = _parse_release(data)
assert len(album.discs) == 2
assert album.discs[0].disc_number == 1
assert album.discs[1].disc_number == 2
def test_raises_when_no_media(self) -> None:
data = {**_RELEASE_RESPONSE, "media": []}
with pytest.raises(ValueError, match="keine Medien"):
_parse_release(data)
def test_no_artist_credit_gives_empty_string(self) -> None:
data = {**_RELEASE_RESPONSE, "artist-credit": []}
album = _parse_release(data)
assert album.artist == ""
# ---------------------------------------------------------------------------
# lookup_by_barcode
# ---------------------------------------------------------------------------
class TestLookupByBarcode:
def test_successful_lookup(self) -> None:
responses = [_mock_response(_BARCODE_RESPONSE), _mock_response(_RELEASE_RESPONSE)]
with (
patch("musiksammlung.musicbrainz.httpx.get", side_effect=responses),
patch("musiksammlung.musicbrainz.time.sleep"),
):
album = lookup_by_barcode("0602557360561")
assert album.artist == "The Beatles"
assert album.album == "Abbey Road"
assert album.year == 1969
def test_raises_when_no_releases(self) -> None:
empty = _mock_response({"releases": []})
with (
patch("musiksammlung.musicbrainz.httpx.get", return_value=empty),
patch("musiksammlung.musicbrainz.time.sleep"),
pytest.raises(ValueError, match="Kein MusicBrainz-Eintrag"),
):
lookup_by_barcode("0000000000000")
def test_uses_first_release(self) -> None:
barcode_data = {"releases": [{"id": "first-id"}, {"id": "second-id"}]}
responses = [_mock_response(barcode_data), _mock_response(_RELEASE_RESPONSE)]
with (
patch("musiksammlung.musicbrainz.httpx.get", side_effect=responses) as mock_get,
patch("musiksammlung.musicbrainz.time.sleep"),
):
lookup_by_barcode("1234567890123")
# Zweiter Request muss die MBID des ersten Treffers verwenden
second_call_url = mock_get.call_args_list[1][0][0]
assert "first-id" in second_call_url
def test_rate_limit_sleep_is_called(self) -> None:
responses = [_mock_response(_BARCODE_RESPONSE), _mock_response(_RELEASE_RESPONSE)]
with (
patch("musiksammlung.musicbrainz.httpx.get", side_effect=responses),
patch("musiksammlung.musicbrainz.time.sleep") as mock_sleep,
):
lookup_by_barcode("0602557360561")
mock_sleep.assert_called_once()
assert mock_sleep.call_args[0][0] >= 1.0
def test_http_error_propagates(self) -> None:
import httpx
with (
patch("musiksammlung.musicbrainz.httpx.get", side_effect=httpx.HTTPError("timeout")),
pytest.raises(httpx.HTTPError),
):
lookup_by_barcode("0000000000000")

View file

@ -1,8 +1,10 @@
"""Tests für den CD-Ripper."""
from pathlib import Path
from unittest.mock import MagicMock, call, patch
from musiksammlung.config import AudioFormat
from musiksammlung.models import Album, Disc, Track
from musiksammlung.ripper import (
RipperConfig,
TrackInfo,
@ -11,6 +13,7 @@ from musiksammlung.ripper import (
_parse_cddb_lines,
_rename_files,
_sanitize_name,
interactive_rip,
)
@ -230,3 +233,183 @@ class TestRenameFiles:
tracks = [TrackInfo(1, "Art ist", "My Title")]
_rename_files(tmp_path, tracks, AudioFormat.FLAC)
assert (tmp_path / "01_-_My_Title_-_Art_ist.flac").exists()
# ---------------------------------------------------------------------------
# interactive_rip EAN/Barcode-Integration
# ---------------------------------------------------------------------------
_MB_ALBUM = Album(
artist="The Beatles",
album="Abbey Road",
year=1969,
discs=[
Disc(
disc_number=1,
tracks=[
Track(track_number=1, title="Come Together"),
Track(track_number=2, title="Something"),
],
)
],
)
_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 TestInteractiveRipBarcode:
"""Tests für EAN/Barcode-Abfrage in interactive_rip."""
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."""
inputs = [
"Abbey Road", # album name
"", # EAN: leer → überspringen
"1", # disc number
"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
"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
"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),
):
interactive_rip(config)
json_path = tmp_path / "Abbey_Road" / "album.json"
assert json_path.exists()
import json
data = json.loads(json_path.read_text())
assert data["artist"] == "The Beatles"
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."""
inputs = [
"Abbey Road", # album name
"0000000000000", # EAN (kein Treffer)
"1", # disc number
"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",
side_effect=ValueError("Kein MusicBrainz-Eintrag"),
),
):
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."""
inputs = [
"", # album name: leer → Default (Album1)
"0602557360561", # EAN
"1", # disc number
"n",
"n",
]
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),
):
interactive_rip(config)
# Verzeichnis und JSON nach MusicBrainz-Namen benannt
assert (tmp_path / "Abbey_Road" / "album.json").exists()
def test_manual_album_name_kept_when_not_default(self, tmp_path: Path) -> None:
"""Manuell eingegebener Albumname wird NICHT von MusicBrainz überschrieben."""
inputs = [
"Mein Album", # manuell eingegebener Name
"0602557360561", # EAN
"1",
"n",
"n",
]
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),
):
interactive_rip(config)
# JSON-Inhalt kommt von MusicBrainz (artist/year), aber das Verzeichnis-Layout
# richtet sich nach mb_album.album (da MB-Daten Priorität haben)
assert (tmp_path / "Abbey_Road" / "album.json").exists()