- 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>
365 lines
13 KiB
Python
365 lines
13 KiB
Python
"""Tests für die CLI-Commands (cli.py) via typer.testing.CliRunner."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import struct
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
from typer.testing import CliRunner
|
|
|
|
from musiksammlung.cli import app
|
|
from musiksammlung.models import Album, Disc, Track
|
|
|
|
runner = CliRunner()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Hilfsfunktionen (identisch zu test_tagger.py)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _make_flac(path: Path) -> Path:
|
|
data = b"fLaC"
|
|
data += b"\x80\x00\x00\x22"
|
|
data += struct.pack(">HH", 4096, 4096)
|
|
data += b"\x00\x00\x00\x00\x00\x00"
|
|
val = (44100 << 44) | (0 << 41) | (15 << 36) | 0
|
|
data += val.to_bytes(8, "big")
|
|
data += b"\x00" * 16
|
|
path.write_bytes(data)
|
|
return path
|
|
|
|
|
|
def _make_album_json(path: Path, *, n_tracks: int = 2, n_discs: int = 1) -> Album:
|
|
discs = [
|
|
Disc(
|
|
disc_number=d,
|
|
tracks=[
|
|
Track(track_number=t, title=f"Disc{d} Track{t}") for t in range(1, n_tracks + 1)
|
|
],
|
|
)
|
|
for d in range(1, n_discs + 1)
|
|
]
|
|
album = Album(artist="TestArtist", album="TestAlbum", year=2024, discs=discs)
|
|
path.write_text(album.model_dump_json(indent=2), encoding="utf-8")
|
|
return album
|
|
|
|
|
|
def _make_disc_files(album_dir: Path, album: Album) -> None:
|
|
"""Erstellt FLAC-Dateien im korrekten Namensschema."""
|
|
multi = len(album.discs) > 1
|
|
for disc in album.discs:
|
|
disc_dir = album_dir / f"CD{disc.disc_number}" if multi else album_dir
|
|
disc_dir.mkdir(parents=True, exist_ok=True)
|
|
for track in disc.tracks:
|
|
fname = f"{track.track_number:02d}_-_{track.title.replace(' ', '_')}_-_TestArtist.flac"
|
|
_make_flac(disc_dir / fname)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# apply
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestApplyCommand:
|
|
def test_apply_inplace_renames_and_tags(self, tmp_path: Path) -> None:
|
|
_make_album_json(tmp_path / "album.json")
|
|
# Audiodateien mit track*-Namen (wie nach CDDB-Fehler)
|
|
_make_flac(tmp_path / "track01.flac")
|
|
_make_flac(tmp_path / "track02.flac")
|
|
|
|
result = runner.invoke(app, [
|
|
"apply", str(tmp_path), str(tmp_path / "album.json"), "--in-place",
|
|
])
|
|
|
|
assert result.exit_code == 0, result.output
|
|
|
|
def test_apply_fails_on_disc_mismatch(self, tmp_path: Path) -> None:
|
|
_make_album_json(tmp_path / "album.json", n_tracks=3)
|
|
# Nur 2 Dateien, JSON hat 3 Tracks
|
|
_make_flac(tmp_path / "track01.flac")
|
|
_make_flac(tmp_path / "track02.flac")
|
|
|
|
result = runner.invoke(app, [
|
|
"apply", str(tmp_path), str(tmp_path / "album.json"), "--in-place",
|
|
])
|
|
|
|
assert result.exit_code == 1
|
|
assert "Diskrepanz" in result.output or "Diskrepanz" in (result.stderr or "")
|
|
|
|
def test_apply_dry_run_makes_no_changes(self, tmp_path: Path) -> None:
|
|
_make_album_json(tmp_path / "album.json")
|
|
f1 = _make_flac(tmp_path / "track01.flac")
|
|
f2 = _make_flac(tmp_path / "track02.flac")
|
|
|
|
result = runner.invoke(app, [
|
|
"apply", str(tmp_path), str(tmp_path / "album.json"),
|
|
"--in-place", "--dry-run",
|
|
])
|
|
|
|
assert result.exit_code == 0
|
|
assert "DRY-RUN" in result.output
|
|
# Originaldateien noch vorhanden
|
|
assert f1.exists()
|
|
assert f2.exists()
|
|
|
|
def test_apply_missing_json_raises(self, tmp_path: Path) -> None:
|
|
result = runner.invoke(app, [
|
|
"apply", str(tmp_path), str(tmp_path / "nope.json"), "--in-place",
|
|
])
|
|
assert result.exit_code != 0
|
|
|
|
def test_apply_creates_playlist(self, tmp_path: Path) -> None:
|
|
album = _make_album_json(tmp_path / "album.json")
|
|
_make_disc_files(tmp_path, album)
|
|
|
|
result = runner.invoke(app, [
|
|
"apply", str(tmp_path), str(tmp_path / "album.json"), "--in-place",
|
|
])
|
|
|
|
assert result.exit_code == 0, result.output
|
|
playlists = list(tmp_path.glob("*.m3u"))
|
|
assert len(playlists) == 1
|
|
|
|
def test_apply_multi_disc(self, tmp_path: Path) -> None:
|
|
album = _make_album_json(tmp_path / "album.json", n_tracks=2, n_discs=2)
|
|
_make_disc_files(tmp_path, album)
|
|
|
|
result = runner.invoke(app, [
|
|
"apply", str(tmp_path), str(tmp_path / "album.json"), "--in-place",
|
|
])
|
|
|
|
assert result.exit_code == 0, result.output
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# check
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestCheckCommand:
|
|
def test_check_shows_cover_status(self, tmp_path: Path) -> None:
|
|
(tmp_path / "frontcover.jpg").write_bytes(b"\xff\xd8\xff\xe0") # minimal JPEG magic
|
|
|
|
result = runner.invoke(app, ["check", str(tmp_path)])
|
|
|
|
assert result.exit_code == 0
|
|
assert "frontcover.jpg" in result.output
|
|
|
|
def test_check_shows_missing_cover(self, tmp_path: Path) -> None:
|
|
result = runner.invoke(app, ["check", str(tmp_path)])
|
|
|
|
assert result.exit_code == 0
|
|
assert "fehlt" in result.output
|
|
|
|
def test_check_lists_audio_files(self, tmp_path: Path) -> None:
|
|
_make_flac(tmp_path / "01_-_Track_-_Artist.flac")
|
|
|
|
result = runner.invoke(app, ["check", str(tmp_path)])
|
|
|
|
assert result.exit_code == 0
|
|
assert "01_-_Track_-_Artist.flac" in result.output
|
|
|
|
def test_check_nonexistent_directory(self, tmp_path: Path) -> None:
|
|
result = runner.invoke(app, ["check", str(tmp_path / "nope")])
|
|
assert result.exit_code == 1
|
|
|
|
def test_check_no_audio_files_message(self, tmp_path: Path) -> None:
|
|
result = runner.invoke(app, ["check", str(tmp_path)])
|
|
|
|
assert result.exit_code == 0
|
|
assert "Keine Audiodateien" in result.output
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# scan (mockt LLM-Aufruf)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestScanCommand:
|
|
def test_scan_from_text_creates_json(self, tmp_path: Path) -> None:
|
|
text_file = tmp_path / "tracklist.txt"
|
|
text_file.write_text("1. Allegro\n2. Andante", encoding="utf-8")
|
|
output = tmp_path / "album.json"
|
|
|
|
fake_album = Album(
|
|
artist="Karajan",
|
|
album="Beethoven",
|
|
year=1963,
|
|
discs=[Disc(disc_number=1, tracks=[Track(track_number=1, title="Allegro")])],
|
|
)
|
|
|
|
with patch("musiksammlung.cli.parse_tracklist", return_value=fake_album):
|
|
result = runner.invoke(app, [
|
|
"scan", "--from-text", str(text_file), "--output", str(output),
|
|
])
|
|
|
|
assert result.exit_code == 0, result.output
|
|
assert output.exists()
|
|
data = json.loads(output.read_text())
|
|
assert data["artist"] == "Karajan"
|
|
|
|
def test_scan_missing_text_file(self, tmp_path: Path) -> None:
|
|
result = runner.invoke(app, [
|
|
"scan", "--from-text", str(tmp_path / "nope.txt"),
|
|
])
|
|
assert result.exit_code == 1
|
|
|
|
def test_scan_no_args_fails(self) -> None:
|
|
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)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestRenameAlbumDir:
|
|
"""Tests für automatisches Umbenennen des Album-Verzeichnisses."""
|
|
|
|
def test_cd_subdir_renames_parent(self, tmp_path: Path) -> None:
|
|
"""input_dir = CD1 → Elternverzeichnis wird umbenannt."""
|
|
album_root = tmp_path / "Falscher_Name"
|
|
cd1 = album_root / "CD1"
|
|
cd1.mkdir(parents=True)
|
|
|
|
_make_album_json(album_root / "album.json", n_tracks=1)
|
|
_make_flac(cd1 / "track01.flac")
|
|
|
|
result = runner.invoke(app, [
|
|
"apply", str(cd1), str(album_root / "album.json"), "--in-place",
|
|
])
|
|
|
|
assert result.exit_code == 0, result.output
|
|
expected = tmp_path / "TestAlbum_2024"
|
|
assert expected.exists(), f"Verzeichnis nicht umbenannt; output: {result.output}"
|
|
assert not album_root.exists()
|
|
|
|
def test_album_root_as_input_prints_hint(self, tmp_path: Path) -> None:
|
|
"""input_dir = AlbumRoot → kein auto-rename, stattdessen Hinweis."""
|
|
album = _make_album_json(tmp_path / "album.json", n_tracks=2)
|
|
_make_disc_files(tmp_path, album)
|
|
|
|
result = runner.invoke(app, [
|
|
"apply", str(tmp_path), str(tmp_path / "album.json"), "--in-place",
|
|
])
|
|
|
|
assert result.exit_code == 0, result.output
|
|
# Verzeichnis nicht umbenannt
|
|
assert tmp_path.exists()
|
|
# Aber Hinweis ausgegeben
|
|
assert "Tipp" in result.output or "mv" in result.output
|
|
|
|
def test_already_correct_name_no_rename(self, tmp_path: Path) -> None:
|
|
"""CD1 als input_dir, Elternverzeichnis heißt bereits korrekt → keine Ausgabe."""
|
|
album_root = tmp_path / "TestAlbum_2024"
|
|
cd1 = album_root / "CD1"
|
|
cd1.mkdir(parents=True)
|
|
|
|
_make_album_json(album_root / "album.json", n_tracks=1)
|
|
_make_flac(cd1 / "track01.flac")
|
|
|
|
result = runner.invoke(app, [
|
|
"apply", str(cd1), str(album_root / "album.json"), "--in-place",
|
|
])
|
|
|
|
assert result.exit_code == 0, result.output
|
|
assert album_root.exists()
|
|
assert "umbenannt" not in result.output
|
|
|
|
def test_target_exists_prints_warning(self, tmp_path: Path) -> None:
|
|
"""Zielverzeichnis existiert bereits → Warnung, kein Umbenennen."""
|
|
album_root = tmp_path / "Falscher_Name"
|
|
cd1 = album_root / "CD1"
|
|
cd1.mkdir(parents=True)
|
|
# Ziel existiert bereits
|
|
(tmp_path / "TestAlbum_2024").mkdir()
|
|
|
|
_make_album_json(album_root / "album.json", n_tracks=1)
|
|
_make_flac(cd1 / "track01.flac")
|
|
|
|
result = runner.invoke(app, [
|
|
"apply", str(cd1), str(album_root / "album.json"), "--in-place",
|
|
])
|
|
|
|
assert result.exit_code == 0, result.output
|
|
assert album_root.exists() # nicht umbenannt
|