From cfc2a2018ed04167a316a5837a3050add99fa916 Mon Sep 17 00:00:00 2001 From: dschlueter Date: Wed, 18 Feb 2026 04:37:07 +0100 Subject: [PATCH] =?UTF-8?q?Tagger-=20und=20CLI-Tests;=20Bugfix=20embed=5Fc?= =?UTF-8?q?over=20f=C3=BCr=20MP3=20ohne=20ID3-Header?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tests/test_tagger.py: 20 Tests für tag_file, tag_album, _scale_cover_for_embed, embed_cover (FLAC + MP3), embed_album_cover - tests/test_cli.py: 14 Tests für apply (in-place, disc-mismatch, dry-run, playlist, multi-disc), check und scan (via Mock) - tagger.py: embed_cover für MP3 fängt ID3NoHeaderError ab und erstellt einen neuen ID3-Tag wenn keiner vorhanden ist Co-Authored-By: Claude Opus 4.6 --- src/musiksammlung/tagger.py | 9 +- tests/test_cli.py | 212 +++++++++++++++++++++++ tests/test_tagger.py | 330 ++++++++++++++++++++++++++++++++++++ 3 files changed, 548 insertions(+), 3 deletions(-) create mode 100644 tests/test_cli.py create mode 100644 tests/test_tagger.py diff --git a/src/musiksammlung/tagger.py b/src/musiksammlung/tagger.py index c4f6744..9ff573e 100644 --- a/src/musiksammlung/tagger.py +++ b/src/musiksammlung/tagger.py @@ -8,7 +8,7 @@ from pathlib import Path from mutagen import File as MutagenFile from mutagen.flac import FLAC, Picture -from mutagen.id3 import APIC, ID3 +from mutagen.id3 import APIC, ID3, ID3NoHeaderError from PIL import Image from musiksammlung.models import Album, Disc, Track @@ -114,7 +114,10 @@ def embed_cover(audio_path: Path, cover_path: Path) -> None: audio.save() elif suffix == ".mp3": - audio = ID3(str(audio_path)) + try: + audio = ID3(str(audio_path)) + except ID3NoHeaderError: + audio = ID3() audio.add(APIC( encoding=3, mime=mime, @@ -122,7 +125,7 @@ def embed_cover(audio_path: Path, cover_path: Path) -> None: desc="Front cover", data=cover_data, )) - audio.save() + audio.save(str(audio_path)) else: logger.debug("Cover-Embedding für %s nicht unterstützt", suffix) diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..12b9453 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,212 @@ +"""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 diff --git a/tests/test_tagger.py b/tests/test_tagger.py new file mode 100644 index 0000000..989a0eb --- /dev/null +++ b/tests/test_tagger.py @@ -0,0 +1,330 @@ +"""Tests für Audio-Tagging und Cover-Embedding (tagger.py).""" + +from __future__ import annotations + +import io +import struct +from pathlib import Path + +from mutagen import File as MutagenFile +from mutagen.flac import FLAC +from mutagen.id3 import ID3 +from PIL import Image + +from musiksammlung.models import Album, Disc, Track +from musiksammlung.tagger import ( + EMBED_COVER_MAX_SIZE, + _scale_cover_for_embed, + embed_album_cover, + embed_cover, + tag_album, + tag_file, +) + +# --------------------------------------------------------------------------- +# Hilfsfunktionen +# --------------------------------------------------------------------------- + + +def _make_flac(path: Path) -> Path: + """Erstellt eine minimale gültige FLAC-Datei (nur STREAMINFO-Block, keine Audio-Daten).""" + data = b"fLaC" + # STREAMINFO: last=1 (0x80), type=0, length=34 (0x000022) + data += b"\x80\x00\x00\x22" + data += struct.pack(">HH", 4096, 4096) # min/max blocksize + data += b"\x00\x00\x00\x00\x00\x00" # min/max framesize + # 20-bit sample_rate=44100, 3-bit channels-1=0, 5-bit bps-1=15, 36-bit total_samples=0 + val = (44100 << 44) | (0 << 41) | (15 << 36) | 0 + data += val.to_bytes(8, "big") + data += b"\x00" * 16 # MD5 (Nullen = unbekannt) + path.write_bytes(data) + return path + + +def _make_mp3(path: Path) -> Path: + """Erstellt eine minimale gültige MP3-Datei (MPEG1 Layer3, 128kbps, 44100Hz, mono).""" + # Frame-Header: 0xFF 0xFB 0x90 0xC4 + # sync=0xFFF, MPEG1=11, Layer3=01, no-CRC=1 + # bitrate=1001 (128kbps), samplerate=00 (44100), no padding, private=0 + # mono=11, mode_ext=00, copyright=0, original=1, emphasis=00 + header = bytes([0xFF, 0xFB, 0x90, 0xC4]) + frame_size = 417 # 144 * 128000 / 44100 = 417 Bytes + frame = header + bytes(frame_size - 4) + path.write_bytes(frame * 4) # 4 aufeinanderfolgende Frames → mutagen findet Sync + return path + + +def _make_cover(path: Path, size: tuple[int, int] = (100, 100), mode: str = "RGB") -> Path: + """Erstellt ein einfarbiges Test-Cover-Bild.""" + img = Image.new(mode, size, color=(200, 100, 50)) + fmt = "JPEG" if path.suffix.lower() in (".jpg", ".jpeg") else "PNG" + if mode == "RGBA" and fmt == "JPEG": + img = img.convert("RGB") + img.save(str(path), fmt) + return path + + +def _make_album( + artist: str = "TestArtist", + tracks: int = 2, + year: int | None = 2024, +) -> Album: + return Album( + artist=artist, + album="TestAlbum", + year=year, + discs=[ + Disc( + disc_number=1, + tracks=[Track(track_number=i, title=f"Track {i}") for i in range(1, tracks + 1)], + ) + ], + ) + + +# --------------------------------------------------------------------------- +# tag_file +# --------------------------------------------------------------------------- + + +class TestTagFile: + def test_sets_basic_tags_on_flac(self, tmp_path: Path) -> None: + path = _make_flac(tmp_path / "t.flac") + album = _make_album() + disc = album.discs[0] + track = disc.tracks[0] + + tag_file(path, album, disc, track) + + audio = MutagenFile(str(path), easy=True) + assert audio["title"] == ["Track 1"] + assert audio["artist"] == ["TestArtist"] + assert audio["albumartist"] == ["TestArtist"] + assert audio["album"] == ["TestAlbum"] + assert audio["tracknumber"] == ["1/2"] + assert audio["discnumber"] == ["1"] + assert audio["date"] == ["2024"] + + def test_track_artist_overrides_album_artist(self, tmp_path: Path) -> None: + path = _make_flac(tmp_path / "t.flac") + album = _make_album("AlbumArtist") + disc = album.discs[0] + track = Track(track_number=1, title="Solo", artist="TrackArtist") + + tag_file(path, album, disc, track) + + audio = MutagenFile(str(path), easy=True) + assert audio["artist"] == ["TrackArtist"] + assert audio["albumartist"] == ["AlbumArtist"] + + def test_year_omitted_when_none(self, tmp_path: Path) -> None: + path = _make_flac(tmp_path / "t.flac") + album = _make_album(year=None) + disc = album.discs[0] + track = disc.tracks[0] + + tag_file(path, album, disc, track) + + audio = MutagenFile(str(path), easy=True) + assert audio.get("date") is None + + def test_tracknumber_includes_total(self, tmp_path: Path) -> None: + path = _make_flac(tmp_path / "t.flac") + album = _make_album(tracks=5) + disc = album.discs[0] + + tag_file(path, album, disc, disc.tracks[2]) # Track 3 + + audio = MutagenFile(str(path), easy=True) + assert audio["tracknumber"] == ["3/5"] + + def test_sets_tags_on_mp3(self, tmp_path: Path) -> None: + path = _make_mp3(tmp_path / "t.mp3") + album = _make_album() + disc = album.discs[0] + track = disc.tracks[0] + + tag_file(path, album, disc, track) + + audio = MutagenFile(str(path), easy=True) + assert audio["title"] == ["Track 1"] + assert audio["artist"] == ["TestArtist"] + + def test_ignores_unknown_file(self, tmp_path: Path) -> None: + path = tmp_path / "t.xyz" + path.write_bytes(b"not audio") + album = _make_album() + disc = album.discs[0] + track = disc.tracks[0] + # Darf nicht crashen + tag_file(path, album, disc, track) + + +# --------------------------------------------------------------------------- +# tag_album +# --------------------------------------------------------------------------- + + +class TestTagAlbum: + def test_tags_all_tracks_single_disc(self, tmp_path: Path) -> None: + _make_flac(tmp_path / "01_-_Track_1_-_TestArtist.flac") + _make_flac(tmp_path / "02_-_Track_2_-_TestArtist.flac") + album = _make_album(tracks=2) + + tag_album(album, tmp_path) + + audio = MutagenFile(str(tmp_path / "01_-_Track_1_-_TestArtist.flac"), easy=True) + assert audio["tracknumber"] == ["1/2"] + assert audio["title"] == ["Track 1"] + audio2 = MutagenFile(str(tmp_path / "02_-_Track_2_-_TestArtist.flac"), easy=True) + assert audio2["tracknumber"] == ["2/2"] + + def test_tags_multi_disc(self, tmp_path: Path) -> None: + album = Album( + artist="Artist", + album="Multi", + year=2000, + discs=[ + Disc(disc_number=1, tracks=[Track(track_number=1, title="A")]), + Disc(disc_number=2, tracks=[Track(track_number=1, title="B")]), + ], + ) + cd1 = tmp_path / "CD1" + cd2 = tmp_path / "CD2" + cd1.mkdir() + cd2.mkdir() + _make_flac(cd1 / "01_-_A_-_Artist.flac") + _make_flac(cd2 / "01_-_B_-_Artist.flac") + + tag_album(album, tmp_path) + + a1 = MutagenFile(str(cd1 / "01_-_A_-_Artist.flac"), easy=True) + assert a1["discnumber"] == ["1"] + a2 = MutagenFile(str(cd2 / "01_-_B_-_Artist.flac"), easy=True) + assert a2["discnumber"] == ["2"] + + def test_missing_file_does_not_crash(self, tmp_path: Path) -> None: + album = _make_album() + tag_album(album, tmp_path) # Keine Dateien → kein Fehler + + +# --------------------------------------------------------------------------- +# _scale_cover_for_embed +# --------------------------------------------------------------------------- + + +class TestScaleCoverForEmbed: + def test_returns_jpeg_bytes_and_mime(self, tmp_path: Path) -> None: + cover = _make_cover(tmp_path / "c.jpg", size=(100, 100)) + data, mime = _scale_cover_for_embed(cover, 500) + + assert mime == "image/jpeg" + assert len(data) > 0 + assert Image.open(io.BytesIO(data)).format == "JPEG" + + def test_scales_down_large_image(self, tmp_path: Path) -> None: + cover = _make_cover(tmp_path / "c.jpg", size=(1200, 1200)) + data, _ = _scale_cover_for_embed(cover, 500) + + assert max(Image.open(io.BytesIO(data)).size) <= 500 + + def test_does_not_upscale_small_image(self, tmp_path: Path) -> None: + cover = _make_cover(tmp_path / "c.jpg", size=(200, 200)) + data, _ = _scale_cover_for_embed(cover, 500) + + assert max(Image.open(io.BytesIO(data)).size) <= 200 + + def test_converts_rgba_to_rgb(self, tmp_path: Path) -> None: + cover = tmp_path / "c.png" + Image.new("RGBA", (100, 100), (255, 0, 0, 128)).save(str(cover), "PNG") + data, _ = _scale_cover_for_embed(cover, 500) + + assert Image.open(io.BytesIO(data)).mode == "RGB" + + def test_uses_embed_cover_max_size_constant(self, tmp_path: Path) -> None: + cover = _make_cover(tmp_path / "c.jpg", size=(2000, 2000)) + data, _ = _scale_cover_for_embed(cover, EMBED_COVER_MAX_SIZE) + + assert max(Image.open(io.BytesIO(data)).size) <= EMBED_COVER_MAX_SIZE + + +# --------------------------------------------------------------------------- +# embed_cover +# --------------------------------------------------------------------------- + + +class TestEmbedCover: + def test_embeds_cover_in_flac(self, tmp_path: Path) -> None: + path = _make_flac(tmp_path / "t.flac") + cover = _make_cover(tmp_path / "c.jpg") + + embed_cover(path, cover) + + assert bool(FLAC(str(path)).pictures) + + def test_flac_picture_type_is_front_cover(self, tmp_path: Path) -> None: + path = _make_flac(tmp_path / "t.flac") + cover = _make_cover(tmp_path / "c.jpg") + + embed_cover(path, cover) + + pic = FLAC(str(path)).pictures[0] + assert pic.type == 3 # Front cover + assert pic.mime == "image/jpeg" + + def test_embeds_cover_in_mp3(self, tmp_path: Path) -> None: + path = _make_mp3(tmp_path / "t.mp3") + cover = _make_cover(tmp_path / "c.jpg") + + embed_cover(path, cover) + + tags = ID3(str(path)) + assert any(k.startswith("APIC") for k in tags.keys()) + + def test_unsupported_format_does_not_crash(self, tmp_path: Path) -> None: + path = tmp_path / "t.ogg" + path.write_bytes(b"\x00" * 64) + cover = _make_cover(tmp_path / "c.jpg") + + embed_cover(path, cover) # Nur Debug-Log, kein Fehler + + +# --------------------------------------------------------------------------- +# embed_album_cover +# --------------------------------------------------------------------------- + + +class TestEmbedAlbumCover: + def test_embeds_cover_for_all_tracks(self, tmp_path: Path) -> None: + album = _make_album(tracks=2) + _make_flac(tmp_path / "01_-_Track_1_-_TestArtist.flac") + _make_flac(tmp_path / "02_-_Track_2_-_TestArtist.flac") + cover = _make_cover(tmp_path / "frontcover.jpg") + + embed_album_cover(album, tmp_path, cover) + + for fname in ["01_-_Track_1_-_TestArtist.flac", "02_-_Track_2_-_TestArtist.flac"]: + assert bool(FLAC(str(tmp_path / fname)).pictures), f"Cover fehlt in {fname}" + + def test_embeds_cover_multi_disc(self, tmp_path: Path) -> None: + album = Album( + artist="A", + album="B", + year=2000, + discs=[ + Disc(disc_number=1, tracks=[Track(track_number=1, title="T1")]), + Disc(disc_number=2, tracks=[Track(track_number=1, title="T2")]), + ], + ) + cd1 = tmp_path / "CD1" + cd2 = tmp_path / "CD2" + cd1.mkdir() + cd2.mkdir() + _make_flac(cd1 / "01_-_T1_-_A.flac") + _make_flac(cd2 / "01_-_T2_-_A.flac") + cover = _make_cover(tmp_path / "frontcover.jpg") + + embed_album_cover(album, tmp_path, cover) + + assert bool(FLAC(str(cd1 / "01_-_T1_-_A.flac")).pictures) + assert bool(FLAC(str(cd2 / "01_-_T2_-_A.flac")).pictures)