diff --git a/.gitignore b/.gitignore index 17df95b..70a4042 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,7 @@ htmlcov/ # Logs / temporäre Files *.log *.tmp +temp/ # Editor / IDE .vscode/ diff --git a/src/musiksammlung/cli.py b/src/musiksammlung/cli.py index b7fb97c..b9fbaff 100644 --- a/src/musiksammlung/cli.py +++ b/src/musiksammlung/cli.py @@ -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: - --barcode EAN/UPC → MusicBrainz-Lookup → JSON - --from-text Textdatei (z.B. von Perplexity) → LLM → JSON - --vision Bild → Vision-LLM → JSON - (Standard) Bild → Tesseract-OCR → Text-LLM → JSON + 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: diff --git a/src/musiksammlung/ripper.py b/src/musiksammlung/ripper.py index 286cbe9..541edee 100644 --- a/src/musiksammlung/ripper.py +++ b/src/musiksammlung/ripper.py @@ -718,51 +718,50 @@ def interactive_rip(config: RipperConfig) -> None: disc_num = disc.disc_number disc_dir = album_root / f"CD{disc_num}" - _, disc_photo = _input_or_scan( - f"\n CD {disc_num}/{total_discs} einlegen und Enter drücken " - 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 + while True: + _, disc_photo = _input_or_scan( + f"\n CD {disc_num}/{total_discs} einlegen und Enter drücken " + 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 + ) - print(f" Ripping to: {disc_dir}") - print(" " + "-" * 50) + print(f" Ripping to: {disc_dir}") + print(" " + "-" * 50) - try: - _, _cddb_album, tracks = rip_disc( - device=config.device, - output_dir=disc_dir, - audio_format=config.audio_format, - quality=config.quality, - parallel_jobs=config.parallel_jobs, - use_pipes=config.use_pipes, - use_cddb=config.use_cddb, - rename=False, - ) + try: + _, _cddb_album, tracks = rip_disc( + device=config.device, + output_dir=disc_dir, + audio_format=config.audio_format, + quality=config.quality, + parallel_jobs=config.parallel_jobs, + use_pipes=config.use_pipes, + use_cddb=config.use_cddb, + rename=False, + ) - # Rename mit CDDB-Tracks (lokale Dateinamen), falls vorhanden - if tracks: - print(" Umbenennen (CDDB-Daten) ...", flush=True) - _rename_files(disc_dir, tracks, config.audio_format) - else: - print(" ✓ Fertig (keine CDDB-Daten für Rename)") + # Rename mit CDDB-Tracks (lokale Dateinamen), falls vorhanden + if tracks: + print(" Umbenennen (CDDB-Daten) ...", flush=True) + _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 + except RuntimeError as e: + print(f"\n ✗ Error: {e}") + raw_retry = input(" Nochmal versuchen? (j/n): ") + 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 diff --git a/tests/test_cli.py b/tests/test_cli.py index 8ff7f65..cd96ae0 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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)