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>
211 lines
8.1 KiB
Python
211 lines
8.1 KiB
Python
"""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, download_caa_covers, find_cover, prepare_cover
|
|
|
|
|
|
def _make_image(path: Path, size: tuple[int, int] = (100, 100), mode: str = "RGB") -> Path:
|
|
img = Image.new(mode, size, color=(200, 100, 50))
|
|
fmt = "PNG" if path.suffix.lower() == ".png" else "JPEG"
|
|
if mode == "RGBA" and fmt == "JPEG":
|
|
img = img.convert("RGB")
|
|
img.save(str(path), fmt)
|
|
return path
|
|
|
|
|
|
class TestFindCover:
|
|
"""Tests für find_cover."""
|
|
|
|
def test_finds_frontcover_jpg(self, tmp_path: Path) -> None:
|
|
(tmp_path / "frontcover.jpg").touch()
|
|
assert find_cover(tmp_path, "front") == tmp_path / "frontcover.jpg"
|
|
|
|
def test_finds_frontcover_png(self, tmp_path: Path) -> None:
|
|
(tmp_path / "frontcover.png").touch()
|
|
assert find_cover(tmp_path, "front") == tmp_path / "frontcover.png"
|
|
|
|
def test_jpg_preferred_over_png(self, tmp_path: Path) -> None:
|
|
(tmp_path / "frontcover.jpg").touch()
|
|
(tmp_path / "frontcover.png").touch()
|
|
# .jpg wird zuerst geprüft
|
|
assert find_cover(tmp_path, "front") == tmp_path / "frontcover.jpg"
|
|
|
|
def test_finds_backcover(self, tmp_path: Path) -> None:
|
|
(tmp_path / "backcover.jpg").touch()
|
|
assert find_cover(tmp_path, "back") == tmp_path / "backcover.jpg"
|
|
|
|
def test_returns_none_if_missing(self, tmp_path: Path) -> None:
|
|
assert find_cover(tmp_path, "front") is None
|
|
assert find_cover(tmp_path, "back") is None
|
|
|
|
def test_follows_symlink(self, tmp_path: Path) -> None:
|
|
real = tmp_path / "original.jpg"
|
|
real.touch()
|
|
link = tmp_path / "frontcover.jpg"
|
|
link.symlink_to(real)
|
|
assert find_cover(tmp_path, "front") == link
|
|
|
|
def test_ignores_wrong_names(self, tmp_path: Path) -> None:
|
|
(tmp_path / "cover.jpg").touch()
|
|
(tmp_path / "back.jpg").touch()
|
|
assert find_cover(tmp_path, "front") is None
|
|
|
|
|
|
class TestPrepareCover:
|
|
def test_creates_jpeg_output(self, tmp_path: Path) -> None:
|
|
src = _make_image(tmp_path / "src.jpg")
|
|
dst = tmp_path / "out.jpg"
|
|
prepare_cover(src, dst)
|
|
assert dst.exists()
|
|
assert Image.open(dst).format == "JPEG"
|
|
|
|
def test_scales_down_large_image(self, tmp_path: Path) -> None:
|
|
src = _make_image(tmp_path / "src.jpg", size=(2000, 2000))
|
|
dst = tmp_path / "out.jpg"
|
|
prepare_cover(src, dst, max_size=1200)
|
|
assert max(Image.open(dst).size) <= 1200
|
|
|
|
def test_does_not_upscale_small_image(self, tmp_path: Path) -> None:
|
|
src = _make_image(tmp_path / "src.jpg", size=(300, 300))
|
|
dst = tmp_path / "out.jpg"
|
|
prepare_cover(src, dst, max_size=1200)
|
|
assert max(Image.open(dst).size) <= 300
|
|
|
|
def test_converts_rgba_to_rgb(self, tmp_path: Path) -> None:
|
|
src = tmp_path / "src.png"
|
|
Image.new("RGBA", (100, 100), (255, 0, 0, 128)).save(str(src), "PNG")
|
|
dst = tmp_path / "out.jpg"
|
|
prepare_cover(src, dst)
|
|
assert Image.open(dst).mode == "RGB"
|
|
|
|
def test_creates_parent_directory(self, tmp_path: Path) -> None:
|
|
src = _make_image(tmp_path / "src.jpg")
|
|
dst = tmp_path / "subdir" / "nested" / "out.jpg"
|
|
prepare_cover(src, dst)
|
|
assert dst.exists()
|
|
|
|
|
|
class TestCopyCovers:
|
|
def test_copies_front_cover(self, tmp_path: Path) -> None:
|
|
src = _make_image(tmp_path / "src.jpg")
|
|
copy_covers(src, None, tmp_path)
|
|
assert (tmp_path / "frontcover.jpg").exists()
|
|
|
|
def test_copies_back_cover(self, tmp_path: Path) -> None:
|
|
src = _make_image(tmp_path / "src.jpg")
|
|
copy_covers(None, src, tmp_path)
|
|
assert (tmp_path / "backcover.jpg").exists()
|
|
|
|
def test_copies_both_covers(self, tmp_path: Path) -> None:
|
|
front = _make_image(tmp_path / "front.jpg")
|
|
back = _make_image(tmp_path / "back.jpg")
|
|
copy_covers(front, back, tmp_path)
|
|
assert (tmp_path / "frontcover.jpg").exists()
|
|
assert (tmp_path / "backcover.jpg").exists()
|
|
|
|
def test_skips_nonexistent_front(self, tmp_path: Path) -> None:
|
|
copy_covers(tmp_path / "nope.jpg", None, tmp_path)
|
|
assert not (tmp_path / "frontcover.jpg").exists()
|
|
|
|
def test_skips_nonexistent_back(self, tmp_path: Path) -> None:
|
|
copy_covers(None, tmp_path / "nope.jpg", tmp_path)
|
|
assert not (tmp_path / "backcover.jpg").exists()
|
|
|
|
def test_existing_frontcover_not_overwritten_when_no_source(self, tmp_path: Path) -> None:
|
|
existing = _make_image(tmp_path / "frontcover.jpg")
|
|
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()
|