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 # Logs / temporäre Files
*.log *.log
*.tmp *.tmp
temp/
# Editor / IDE # Editor / IDE
.vscode/ .vscode/

View file

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

View file

@ -718,51 +718,50 @@ def interactive_rip(config: RipperConfig) -> None:
disc_num = disc.disc_number disc_num = disc.disc_number
disc_dir = album_root / f"CD{disc_num}" disc_dir = album_root / f"CD{disc_num}"
_, disc_photo = _input_or_scan( while True:
f"\n CD {disc_num}/{total_discs} einlegen und Enter drücken " _, disc_photo = _input_or_scan(
f"({len(disc.tracks)} Tracks) ...", f"\n CD {disc_num}/{total_discs} einlegen und Enter drücken "
scanner, f"({len(disc.tracks)} Tracks) ...",
) scanner,
if disc_photo and vision_queue is None:
uploaded_photo = disc_photo
print(
" Backcover-Foto empfangen → Vision-LLM-Analyse gestartet...",
flush=True,
)
vision_queue = _start_vision_thread(
disc_photo, config.vision_model, config.vision_url
) )
if disc_photo and vision_queue is None:
uploaded_photo = disc_photo
print(
" Backcover-Foto empfangen → Vision-LLM-Analyse gestartet...",
flush=True,
)
vision_queue = _start_vision_thread(
disc_photo, config.vision_model, config.vision_url
)
print(f" Ripping to: {disc_dir}") print(f" Ripping to: {disc_dir}")
print(" " + "-" * 50) print(" " + "-" * 50)
try: try:
_, _cddb_album, tracks = rip_disc( _, _cddb_album, tracks = rip_disc(
device=config.device, device=config.device,
output_dir=disc_dir, output_dir=disc_dir,
audio_format=config.audio_format, audio_format=config.audio_format,
quality=config.quality, quality=config.quality,
parallel_jobs=config.parallel_jobs, parallel_jobs=config.parallel_jobs,
use_pipes=config.use_pipes, use_pipes=config.use_pipes,
use_cddb=config.use_cddb, use_cddb=config.use_cddb,
rename=False, rename=False,
) )
# Rename mit CDDB-Tracks (lokale Dateinamen), falls vorhanden # Rename mit CDDB-Tracks (lokale Dateinamen), falls vorhanden
if tracks: if tracks:
print(" Umbenennen (CDDB-Daten) ...", flush=True) print(" Umbenennen (CDDB-Daten) ...", flush=True)
_rename_files(disc_dir, tracks, config.audio_format) _rename_files(disc_dir, tracks, config.audio_format)
else: else:
print(" ✓ Fertig (keine CDDB-Daten für Rename)") print(" ✓ Fertig (keine CDDB-Daten für Rename)")
break # Disc erfolgreich — nächste Disc
except RuntimeError as e: except RuntimeError as e:
print(f"\n ✗ Error: {e}") print(f"\n ✗ Error: {e}")
raw_retry = input(" Nochmal versuchen? (j/n): ") raw_retry = input(" Nochmal versuchen? (j/n): ")
if _clean_input(raw_retry).lower() in ("j", "ja", "y", "yes"): if _clean_input(raw_retry).lower() not in ("j", "ja", "y", "yes"):
# Gleiche Disc nochmal — aber wir können im for-loop raise # Abbruch: Ausnahme nach oben weitergeben
# nicht einfach zurückspringen, daher Hinweis
print(" Bitte Album neu starten.")
break
# Vision-LLM-Ergebnis abholen (läuft parallel zum Ripping) # Vision-LLM-Ergebnis abholen (läuft parallel zum Ripping)
final_album = mb_album final_album = mb_album

View file

@ -211,6 +211,82 @@ class TestScanCommand:
result = runner.invoke(app, ["scan"]) result = runner.invoke(app, ["scan"])
assert result.exit_code == 1 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) # _rename_album_dir_inplace (via apply --in-place)