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:
parent
6c12510f76
commit
32c84b9edb
15 changed files with 1027 additions and 92 deletions
|
|
@ -2,8 +2,6 @@
|
|||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import httpx
|
||||
|
||||
from musiksammlung.cddb import CddbResult, _read_gnudb, lookup_by_discid
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue