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

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