Musiksammlung/tests/test_musicbrainz.py
dschlueter 32c84b9edb Add phone-based EAN scanning, scanner server for cover upload, Vision-LLM integration
New features:
- EAN/Barcode can now be entered by typing or by photographing the CD sleeve;
  Vision-LLM (extract_barcode_from_image) reads the barcode from the photo
- Scanner server (port 8765) starts at the beginning of every album loop,
  serving both EAN barcode scanning and back cover upload via QR code
- Vision-LLM analyses back cover in background thread while ripping;
  priority: Vision-LLM > MusicBrainz > CDDB
- _find_abcde_mbid reads MBID from abcde temp dirs for CAA cover download
  even when the CD barcode is not linked in MusicBrainz
- Concrete copy-paste apply commands shown after each album in 'Next steps'
- _sanitize_name: whitelist approach (removes brackets and punctuation)
- qrcode added as dependency for terminal QR code display

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-19 14:05:59 +01:00

211 lines
7.3 KiB
Python

"""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, mbid = lookup_by_barcode("0602557360561")
assert album.artist == "The Beatles"
assert album.album == "Abbey Road"
assert album.year == 1969
assert mbid == "abc-123"
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") # raises before returning tuple
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"),
):
_album, mbid = 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
assert mbid == "first-id"
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,
):
_album, _mbid = 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") # raises before returning tuple