Tagger- und CLI-Tests; Bugfix embed_cover für MP3 ohne ID3-Header

- 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 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-02-18 04:37:07 +01:00
commit cfc2a2018e
3 changed files with 548 additions and 3 deletions

View file

@ -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)

212
tests/test_cli.py Normal file
View file

@ -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

330
tests/test_tagger.py Normal file
View file

@ -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)