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:
parent
6aba30c0e5
commit
b30aaa617d
5 changed files with 552 additions and 7 deletions
209
tests/test_musicbrainz.py
Normal file
209
tests/test_musicbrainz.py
Normal 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")
|
||||
Loading…
Add table
Add a link
Reference in a new issue