Add --from-photo to scan, retry in MB disc loop, temp/ to .gitignore

- scan: new --from-photo <img> option extracts EAN via Vision-LLM,
  then falls through to existing MusicBrainz barcode lookup
- ripper: MB disc loop now retries the same disc on rip failure instead
  of printing "Bitte Album neu starten"; user decline raises RuntimeError
- .gitignore: suppress temp/ directory
- tests: 4 new tests for scan --from-photo (225 total)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-02-19 14:43:37 +01:00
commit a32b0229f5
4 changed files with 147 additions and 49 deletions

1
.gitignore vendored
View file

@ -31,6 +31,7 @@ htmlcov/
# Logs / temporäre Files
*.log
*.tmp
temp/
# Editor / IDE
.vscode/

View file

@ -26,7 +26,7 @@ from musiksammlung.organizer import (
from musiksammlung.playlist import generate_playlist
from musiksammlung.ripper import RipperConfig, interactive_rip
from musiksammlung.tagger import embed_album_cover, tag_album
from musiksammlung.vision_llm import parse_image
from musiksammlung.vision_llm import extract_barcode_from_image, parse_image
app = typer.Typer(
name="musiksammlung",
@ -186,6 +186,10 @@ def scan(
barcode: str = typer.Option(
None, "--barcode", help="EAN-13- oder UPC-12-Barcode für MusicBrainz-Lookup"
),
from_photo: Path = typer.Option(
None, "--from-photo",
help="Foto mit EAN-Barcode → Vision-LLM extrahiert EAN → MusicBrainz-Lookup",
),
from_text: Path = typer.Option(
None, "--from-text", "-t",
help="Text/Markdown-Datei mit Trackliste (z.B. von Perplexity)",
@ -205,21 +209,39 @@ def scan(
) -> None:
"""Bilder, Text oder Barcode → Album-JSON erzeugen (zur Prüfung vor dem Anwenden).
Vier Modi:
Fünf Modi:
--barcode EAN/UPC MusicBrainz-Lookup JSON
--from-photo Foto mit Barcode Vision-LLM EAN MusicBrainz-Lookup JSON
--from-text Textdatei (z.B. von Perplexity) LLM JSON
--vision Bild Vision-LLM JSON
(Standard) Bild Tesseract-OCR Text-LLM JSON
"""
if barcode:
pass # kein Bild nötig
elif from_photo:
if not from_photo.exists():
typer.echo(f"Fehler: Foto nicht gefunden: {from_photo}", err=True)
raise typer.Exit(1)
typer.echo(f"EAN-Extraktion aus Foto via Vision-LLM ({vision_model})...")
extracted = extract_barcode_from_image(
from_photo, model=vision_model, base_url=base_url
)
if not extracted:
typer.echo(
"Fehler: Kein EAN-Barcode im Foto erkannt. "
"Bitte --barcode manuell angeben.",
err=True,
)
raise typer.Exit(1)
typer.echo(f"EAN erkannt: {extracted}")
barcode = extracted
elif from_text:
if not from_text.exists():
typer.echo(f"Fehler: Datei nicht gefunden: {from_text}", err=True)
raise typer.Exit(1)
elif not images:
typer.echo(
"Fehler: Bilder, --barcode oder --from-text angeben.", err=True
"Fehler: Bilder, --barcode, --from-photo oder --from-text angeben.", err=True
)
raise typer.Exit(1)
else:

View file

@ -718,6 +718,7 @@ def interactive_rip(config: RipperConfig) -> None:
disc_num = disc.disc_number
disc_dir = album_root / f"CD{disc_num}"
while True:
_, disc_photo = _input_or_scan(
f"\n CD {disc_num}/{total_discs} einlegen und Enter drücken "
f"({len(disc.tracks)} Tracks) ...",
@ -754,15 +755,13 @@ def interactive_rip(config: RipperConfig) -> None:
_rename_files(disc_dir, tracks, config.audio_format)
else:
print(" ✓ Fertig (keine CDDB-Daten für Rename)")
break # Disc erfolgreich — nächste Disc
except RuntimeError as e:
print(f"\n ✗ Error: {e}")
raw_retry = input(" Nochmal versuchen? (j/n): ")
if _clean_input(raw_retry).lower() in ("j", "ja", "y", "yes"):
# Gleiche Disc nochmal — aber wir können im for-loop
# nicht einfach zurückspringen, daher Hinweis
print(" Bitte Album neu starten.")
break
if _clean_input(raw_retry).lower() not in ("j", "ja", "y", "yes"):
raise # Abbruch: Ausnahme nach oben weitergeben
# Vision-LLM-Ergebnis abholen (läuft parallel zum Ripping)
final_album = mb_album

View file

@ -211,6 +211,82 @@ class TestScanCommand:
result = runner.invoke(app, ["scan"])
assert result.exit_code == 1
# --- --from-photo ---
def test_scan_from_photo_creates_json(self, tmp_path: Path) -> None:
"""Foto → EAN extrahiert → MusicBrainz-Lookup → JSON."""
img = tmp_path / "cover.jpg"
img.write_bytes(b"fake")
output = tmp_path / "album.json"
fake_album = Album(
artist="Beatles",
album="Abbey Road",
year=1969,
discs=[Disc(disc_number=1, tracks=[Track(track_number=1, title="Come Together")])],
)
mbid = "some-mbid"
with (
patch("musiksammlung.cli.extract_barcode_from_image", return_value="4006408262121"),
patch("musiksammlung.cli.lookup_by_barcode", return_value=(fake_album, mbid)),
):
result = runner.invoke(app, [
"scan", "--from-photo", str(img), "--output", str(output),
])
assert result.exit_code == 0, result.output
assert output.exists()
data = json.loads(output.read_text())
assert data["artist"] == "Beatles"
assert "4006408262121" in result.output
def test_scan_from_photo_file_not_found(self, tmp_path: Path) -> None:
"""Foto existiert nicht → Exit 1."""
result = runner.invoke(app, [
"scan", "--from-photo", str(tmp_path / "nope.jpg"),
])
assert result.exit_code == 1
def test_scan_from_photo_no_barcode_recognized(self, tmp_path: Path) -> None:
"""Vision-LLM erkennt keinen Barcode → Exit 1 mit Fehlermeldung."""
img = tmp_path / "cover.jpg"
img.write_bytes(b"fake")
with patch("musiksammlung.cli.extract_barcode_from_image", return_value=None):
result = runner.invoke(app, [
"scan", "--from-photo", str(img),
])
assert result.exit_code == 1
assert "Kein EAN" in result.output or "Kein EAN" in (result.stderr or "")
def test_scan_from_photo_passes_model_and_url(self, tmp_path: Path) -> None:
"""--vision-model und --url werden an extract_barcode_from_image weitergegeben."""
img = tmp_path / "cover.jpg"
img.write_bytes(b"fake")
fake_album = Album(
artist="A", album="B", year=2000,
discs=[Disc(disc_number=1, tracks=[Track(track_number=1, title="T")])],
)
with (
patch("musiksammlung.cli.extract_barcode_from_image", return_value="1234567890123")
as mock_extract,
patch("musiksammlung.cli.lookup_by_barcode", return_value=(fake_album, "mbid")),
):
runner.invoke(app, [
"scan", "--from-photo", str(img),
"--vision-model", "my-vlm",
"--url", "http://myhost:11434",
"--output", str(tmp_path / "out.json"),
])
mock_extract.assert_called_once_with(
img, model="my-vlm", base_url="http://myhost:11434"
)
# ---------------------------------------------------------------------------
# _rename_album_dir_inplace (via apply --in-place)