diff --git a/src/musiksammlung/cli.py b/src/musiksammlung/cli.py index e3c63c3..46a0ed4 100644 --- a/src/musiksammlung/cli.py +++ b/src/musiksammlung/cli.py @@ -9,14 +9,14 @@ from pathlib import Path import typer from musiksammlung.config import AudioFormat -from musiksammlung.cover import copy_covers +from musiksammlung.cover import copy_covers, find_cover from musiksammlung.llm_parser import parse_tracklist from musiksammlung.models import Album from musiksammlung.ocr import ocr_images from musiksammlung.organizer import apply_mapping, build_mapping, check_disc_counts from musiksammlung.playlist import generate_playlist from musiksammlung.ripper import RipperConfig, interactive_rip -from musiksammlung.tagger import tag_album +from musiksammlung.tagger import embed_album_cover, tag_album from musiksammlung.vision_llm import parse_image app = typer.Typer( @@ -214,6 +214,10 @@ def apply( typer.echo("Setze Audio-Tags...") tag_album(album, album_dir) copy_covers(front, back, album_dir) + cover = find_cover(album_dir) + if cover: + typer.echo(f"Bette Cover ein ({cover.name})...") + embed_album_cover(album, album_dir, cover) generate_playlist(album, album_dir) typer.echo(f"Fertig! Album liegt in: {album_dir}") @@ -357,6 +361,10 @@ def process( typer.echo("Schritt 3/4: Tags & Cover...") tag_album(album, album_dir) copy_covers(front, back, album_dir) + cover = find_cover(album_dir) + if cover: + typer.echo(f"Bette Cover ein ({cover.name})...") + embed_album_cover(album, album_dir, cover) # 4. Playlist typer.echo("Schritt 4/4: Playlist...") diff --git a/src/musiksammlung/cover.py b/src/musiksammlung/cover.py index 6910442..9c47ee3 100644 --- a/src/musiksammlung/cover.py +++ b/src/musiksammlung/cover.py @@ -9,9 +9,33 @@ from PIL import Image logger = logging.getLogger(__name__) -# Jellyfin erkennt diese Dateinamen automatisch -FRONT_COVER_NAME = "cover.jpg" -BACK_COVER_NAME = "back.jpg" +# Standard-Dateinamen für Cover im Album-Verzeichnis. +# Symbolische Links auf diese Namen sind erlaubt. +FRONT_COVER_STEMS = ["frontcover"] +BACK_COVER_STEMS = ["backcover"] +COVER_EXTENSIONS = [".jpg", ".png"] + + +def find_cover(album_dir: Path, kind: str = "front") -> Path | None: + """Sucht das Standard-Coverbild im Album-Verzeichnis. + + Prüft frontcover.jpg, frontcover.png (bzw. backcover.*) in dieser Reihenfolge. + Folgt symbolischen Links. + + Args: + album_dir: Wurzelverzeichnis des Albums + kind: 'front' oder 'back' + + Returns: + Pfad zur Coverdatei oder None + """ + stems = FRONT_COVER_STEMS if kind == "front" else BACK_COVER_STEMS + for stem in stems: + for ext in COVER_EXTENSIONS: + p = album_dir / f"{stem}{ext}" + if p.exists(): + return p + return None def prepare_cover(source: Path, target: Path, max_size: int = 1200) -> None: @@ -39,13 +63,19 @@ def copy_covers( back_image: Path | None, album_dir: Path, ) -> None: - """Kopiert Front- und Rückseiten-Cover in das Album-Verzeichnis.""" + """Kopiert Front- und Rückseiten-Cover als frontcover.jpg / backcover.jpg + in das Album-Verzeichnis. + + Bereits vorhandene frontcover.*/backcover.*-Dateien werden nicht überschrieben, + wenn kein Quellbild angegeben wurde. + """ if front_image and front_image.exists(): - prepare_cover(front_image, album_dir / FRONT_COVER_NAME) + prepare_cover(front_image, album_dir / "frontcover.jpg") else: - logger.warning("Kein Front-Cover gefunden") + if not find_cover(album_dir, "front"): + logger.warning("Kein Front-Cover gefunden") if back_image and back_image.exists(): - prepare_cover(back_image, album_dir / BACK_COVER_NAME) + prepare_cover(back_image, album_dir / "backcover.jpg") else: logger.debug("Kein Back-Cover angegeben") diff --git a/src/musiksammlung/tagger.py b/src/musiksammlung/tagger.py index 996c465..4900381 100644 --- a/src/musiksammlung/tagger.py +++ b/src/musiksammlung/tagger.py @@ -65,6 +65,16 @@ def tag_album(album: Album, album_dir: Path) -> None: ) +def embed_album_cover(album: Album, album_dir: Path, cover_path: Path) -> None: + """Bettet das Cover in alle Audiodateien eines Albums ein.""" + multi_disc = len(album.discs) > 1 + for disc in album.discs: + disc_dir = album_dir / f"CD{disc.disc_number}" if multi_disc else album_dir + for track in disc.tracks: + for audio_file in disc_dir.glob(f"{track.track_number:02d}_-_*"): + embed_cover(audio_file, cover_path) + + def embed_cover(audio_path: Path, cover_path: Path) -> None: """Bettet ein Cover-Bild in eine Audiodatei ein.""" cover_data = cover_path.read_bytes() diff --git a/tests/test_cover.py b/tests/test_cover.py new file mode 100644 index 0000000..d94ffae --- /dev/null +++ b/tests/test_cover.py @@ -0,0 +1,45 @@ +"""Tests für Cover-Funktionen.""" + +from pathlib import Path + +import pytest + +from musiksammlung.cover import find_cover + + +class TestFindCover: + """Tests für find_cover.""" + + def test_finds_frontcover_jpg(self, tmp_path: Path) -> None: + (tmp_path / "frontcover.jpg").touch() + assert find_cover(tmp_path, "front") == tmp_path / "frontcover.jpg" + + def test_finds_frontcover_png(self, tmp_path: Path) -> None: + (tmp_path / "frontcover.png").touch() + assert find_cover(tmp_path, "front") == tmp_path / "frontcover.png" + + def test_jpg_preferred_over_png(self, tmp_path: Path) -> None: + (tmp_path / "frontcover.jpg").touch() + (tmp_path / "frontcover.png").touch() + # .jpg wird zuerst geprüft + assert find_cover(tmp_path, "front") == tmp_path / "frontcover.jpg" + + def test_finds_backcover(self, tmp_path: Path) -> None: + (tmp_path / "backcover.jpg").touch() + assert find_cover(tmp_path, "back") == tmp_path / "backcover.jpg" + + def test_returns_none_if_missing(self, tmp_path: Path) -> None: + assert find_cover(tmp_path, "front") is None + assert find_cover(tmp_path, "back") is None + + def test_follows_symlink(self, tmp_path: Path) -> None: + real = tmp_path / "original.jpg" + real.touch() + link = tmp_path / "frontcover.jpg" + link.symlink_to(real) + assert find_cover(tmp_path, "front") == link + + def test_ignores_wrong_names(self, tmp_path: Path) -> None: + (tmp_path / "cover.jpg").touch() + (tmp_path / "back.jpg").touch() + assert find_cover(tmp_path, "front") is None