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:
parent
55c71823d1
commit
a32b0229f5
4 changed files with 147 additions and 49 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -31,6 +31,7 @@ htmlcov/
|
|||
# Logs / temporäre Files
|
||||
*.log
|
||||
*.tmp
|
||||
temp/
|
||||
|
||||
# Editor / IDE
|
||||
.vscode/
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue