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
|
# Logs / temporäre Files
|
||||||
*.log
|
*.log
|
||||||
*.tmp
|
*.tmp
|
||||||
|
temp/
|
||||||
|
|
||||||
# Editor / IDE
|
# Editor / IDE
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue