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>
This commit is contained in:
Dieter Schlüter 2026-02-19 14:05:59 +01:00
commit 32c84b9edb
15 changed files with 1027 additions and 92 deletions

View file

@ -2,8 +2,6 @@
from unittest.mock import MagicMock, patch
import httpx
from musiksammlung.cddb import CddbResult, _read_gnudb, lookup_by_discid

View file

@ -1,10 +1,13 @@
"""Tests für Cover-Funktionen."""
from io import BytesIO
from pathlib import Path
from unittest.mock import MagicMock, patch
import httpx
from PIL import Image
from musiksammlung.cover import copy_covers, find_cover, prepare_cover
from musiksammlung.cover import copy_covers, download_caa_covers, find_cover, prepare_cover
def _make_image(path: Path, size: tuple[int, int] = (100, 100), mode: str = "RGB") -> Path:
@ -119,3 +122,90 @@ class TestCopyCovers:
original_mtime = existing.stat().st_mtime
copy_covers(None, None, tmp_path)
assert (tmp_path / "frontcover.jpg").stat().st_mtime == original_mtime
def _fake_image_bytes() -> bytes:
"""Erzeugt ein gültiges JPEG-Bild als bytes."""
buf = BytesIO()
Image.new("RGB", (200, 200), (100, 150, 200)).save(buf, "JPEG")
return buf.getvalue()
def _mock_caa_response(status_code: int = 200, content: bytes = b"") -> MagicMock:
"""Erstellt ein Mock-httpx.Response für CAA-Requests."""
resp = MagicMock(spec=httpx.Response)
resp.status_code = status_code
resp.content = content
resp.raise_for_status.return_value = None
return resp
class TestDownloadCaaCovers:
"""Tests für download_caa_covers."""
def test_downloads_front_and_back(self, tmp_path: Path) -> None:
"""Beide Cover werden heruntergeladen und als JPEG gespeichert."""
img_bytes = _fake_image_bytes()
resp = _mock_caa_response(200, img_bytes)
with patch("musiksammlung.cover.httpx.get", return_value=resp):
download_caa_covers("test-mbid", tmp_path)
assert (tmp_path / "frontcover.jpg").exists()
assert (tmp_path / "backcover.jpg").exists()
# Ergebnis ist ein gültiges JPEG
assert Image.open(tmp_path / "frontcover.jpg").format == "JPEG"
def test_404_skips_cover(self, tmp_path: Path) -> None:
"""404 → kein Cover, kein Fehler."""
resp_404 = _mock_caa_response(404)
with patch("musiksammlung.cover.httpx.get", return_value=resp_404):
download_caa_covers("no-cover-mbid", tmp_path)
assert not (tmp_path / "frontcover.jpg").exists()
assert not (tmp_path / "backcover.jpg").exists()
def test_http_error_continues(self, tmp_path: Path) -> None:
"""Netzwerkfehler → Warnung, kein Abbruch."""
with patch(
"musiksammlung.cover.httpx.get",
side_effect=httpx.HTTPError("timeout"),
):
download_caa_covers("error-mbid", tmp_path)
assert not (tmp_path / "frontcover.jpg").exists()
assert not (tmp_path / "backcover.jpg").exists()
def test_skips_existing_cover(self, tmp_path: Path) -> None:
"""Bereits vorhandene Cover werden nicht überschrieben."""
existing = _make_image(tmp_path / "frontcover.jpg")
original_size = existing.stat().st_size
img_bytes = _fake_image_bytes()
resp = _mock_caa_response(200, img_bytes)
with patch("musiksammlung.cover.httpx.get", return_value=resp) as mock_get:
download_caa_covers("test-mbid", tmp_path)
# frontcover.jpg bleibt unverändert
assert (tmp_path / "frontcover.jpg").stat().st_size == original_size
# backcover.jpg wird heruntergeladen (war nicht vorhanden)
assert (tmp_path / "backcover.jpg").exists()
# Nur ein HTTP-Request (für back), nicht zwei
assert mock_get.call_count == 1
def test_front_only_on_back_404(self, tmp_path: Path) -> None:
"""Front 200, Back 404 → nur frontcover.jpg erstellt."""
img_bytes = _fake_image_bytes()
resp_ok = _mock_caa_response(200, img_bytes)
resp_404 = _mock_caa_response(404)
with patch(
"musiksammlung.cover.httpx.get",
side_effect=[resp_ok, resp_404],
):
download_caa_covers("mixed-mbid", tmp_path)
assert (tmp_path / "frontcover.jpg").exists()
assert not (tmp_path / "backcover.jpg").exists()

View file

@ -160,11 +160,12 @@ class TestLookupByBarcode:
patch("musiksammlung.musicbrainz.httpx.get", side_effect=responses),
patch("musiksammlung.musicbrainz.time.sleep"),
):
album = lookup_by_barcode("0602557360561")
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": []})
@ -173,7 +174,7 @@ class TestLookupByBarcode:
patch("musiksammlung.musicbrainz.time.sleep"),
pytest.raises(ValueError, match="Kein MusicBrainz-Eintrag"),
):
lookup_by_barcode("0000000000000")
lookup_by_barcode("0000000000000") # raises before returning tuple
def test_uses_first_release(self) -> None:
barcode_data = {"releases": [{"id": "first-id"}, {"id": "second-id"}]}
@ -182,11 +183,12 @@ class TestLookupByBarcode:
patch("musiksammlung.musicbrainz.httpx.get", side_effect=responses) as mock_get,
patch("musiksammlung.musicbrainz.time.sleep"),
):
lookup_by_barcode("1234567890123")
_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)]
@ -194,7 +196,7 @@ class TestLookupByBarcode:
patch("musiksammlung.musicbrainz.httpx.get", side_effect=responses),
patch("musiksammlung.musicbrainz.time.sleep") as mock_sleep,
):
lookup_by_barcode("0602557360561")
_album, _mbid = lookup_by_barcode("0602557360561")
mock_sleep.assert_called_once()
assert mock_sleep.call_args[0][0] >= 1.0
@ -206,4 +208,4 @@ class TestLookupByBarcode:
patch("musiksammlung.musicbrainz.httpx.get", side_effect=httpx.HTTPError("timeout")),
pytest.raises(httpx.HTTPError),
):
lookup_by_barcode("0000000000000")
lookup_by_barcode("0000000000000") # raises before returning tuple

View file

@ -30,6 +30,13 @@ class TestSanitizeName:
assert _sanitize_name("Test|Track?Name*") == "TestTrackName"
assert _sanitize_name("It's_a_Test") == "Its_a_Test"
def test_remove_brackets_and_punctuation(self) -> None:
assert _sanitize_name("Best of (1990)") == "Best_of_1990"
assert _sanitize_name("Hello, World!") == "Hello_World"
assert _sanitize_name("Vol. 2") == "Vol_2"
assert _sanitize_name("Salt & Pepper [Remix]") == "Salt_Pepper_Remix"
assert _sanitize_name("The Best of... (Deluxe Edition)") == "The_Best_of_Deluxe_Edition"
def test_keep_umlauts(self) -> None:
assert _sanitize_name("Grüße aus Österreich") == "Grüße_aus_Österreich"
assert _sanitize_name("Schöne Sänger") == "Schöne_Sänger"
@ -349,10 +356,13 @@ class TestInteractiveRipEanFirst:
"n", # next album?
]
config = RipperConfig(output_dir=tmp_path)
sp = self._scanner_patches()
with (
sp[0], sp[1],
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", return_value=(_MB_ALBUM, "fake-mbid")),
patch("musiksammlung.ripper.download_caa_covers") as mock_caa,
):
interactive_rip(config)
@ -363,6 +373,7 @@ class TestInteractiveRipEanFirst:
assert data["artist"] == "The Beatles"
assert data["album"] == "Abbey Road"
assert data["year"] == 1969
mock_caa.assert_called_once_with("fake-mbid", tmp_path / "Abbey_Road")
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."""
@ -373,10 +384,14 @@ class TestInteractiveRipEanFirst:
"n", # next album?
]
config = RipperConfig(output_dir=tmp_path)
sp = self._scanner_patches()
with (
sp[0], sp[1],
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),
patch("musiksammlung.ripper.lookup_by_barcode",
return_value=(_MB_ALBUM_2DISC, "fake-mbid-2")),
patch("musiksammlung.ripper.download_caa_covers"),
):
interactive_rip(config)
@ -397,10 +412,14 @@ class TestInteractiveRipEanFirst:
"n", # next album?
]
config = RipperConfig(output_dir=tmp_path)
sp = self._scanner_patches()
with (
sp[0], sp[1],
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,
patch("musiksammlung.ripper.lookup_by_barcode",
return_value=(_MB_ALBUM, "fake-mbid")) as mock_lookup,
patch("musiksammlung.ripper.download_caa_covers"),
):
interactive_rip(config)
@ -419,10 +438,13 @@ class TestInteractiveRipEanFirst:
(disc_dir / "track01.flac").touch()
(disc_dir / "track02.flac").touch()
sp = self._scanner_patches()
with (
sp[0], sp[1],
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),
patch("musiksammlung.ripper.lookup_by_barcode", return_value=(_MB_ALBUM, "fake-mbid")),
patch("musiksammlung.ripper.download_caa_covers"),
):
interactive_rip(config)
@ -430,7 +452,31 @@ class TestInteractiveRipEanFirst:
# Dateien existieren schon vorher, rename findet in _rename_files statt
assert (tmp_path / "Abbey_Road" / "album.json").exists()
# ── Fallback (kein MB-Treffer / EAN leer) ──
# ── Gemeinsame Scanner-Patches (alle interactive_rip-Tests) ──
#
# Ab EAN-Prompt startet interactive_rip immer einen ScannerServer und
# nutzt _input_or_scan. Beide werden in allen Tests gemockt.
@staticmethod
def _scanner_patches():
"""Patches für ScannerServer und _input_or_scan (alle interactive_rip-Tests)."""
mock_scanner = MagicMock()
mock_scanner.url.return_value = "http://127.0.0.1:8765"
mock_scanner.get_photo.return_value = None
return [
patch("musiksammlung.ripper.ScannerServer", return_value=mock_scanner),
patch(
"musiksammlung.ripper._input_or_scan",
side_effect=lambda prompt, scanner: (input(prompt), None),
),
]
@staticmethod
def _fallback_patches(inputs: list[str]):
"""Gemeinsame Patches für Fallback-Tests."""
patches = TestInteractiveRipEanFirst._scanner_patches()
patches.append(patch("builtins.input", side_effect=iter(inputs)))
return patches
def test_ean_empty_falls_back_to_album_prompt(self, tmp_path: Path) -> None:
"""Leere EAN → Fallback: Albumname wird abgefragt."""
@ -443,9 +489,10 @@ class TestInteractiveRipEanFirst:
"n", # next album?
]
config = RipperConfig(output_dir=tmp_path)
patches = self._fallback_patches(inputs)
with (
patches[0], patches[1], patches[2],
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)
@ -465,15 +512,16 @@ class TestInteractiveRipEanFirst:
"n", # next album?
]
config = RipperConfig(output_dir=tmp_path)
patches = self._fallback_patches(inputs)
with (
patches[0], patches[1], patches[2],
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
interactive_rip(config)
json_path = tmp_path / "Abbey_Road" / "album.json"
assert json_path.exists()
@ -489,9 +537,10 @@ class TestInteractiveRipEanFirst:
"n", # next album?
]
config = RipperConfig(output_dir=tmp_path)
patches = self._fallback_patches(inputs)
with (
patches[0], patches[1], patches[2],
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"),
):
interactive_rip(config)
@ -514,9 +563,10 @@ class TestInteractiveRipEanFirst:
"n", # next album?
]
config = RipperConfig(output_dir=tmp_path)
patches = self._fallback_patches(inputs)
with (
patches[0], patches[1], patches[2],
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"),
):
interactive_rip(config)