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

@ -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()