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