Cover-Embedding: frontcover.jpg/backcover.jpg als Standard-Konvention

cover: find_cover() sucht frontcover.jpg/.png und backcover.jpg/.png;
  copy_covers() speichert als frontcover.jpg / backcover.jpg
tagger: embed_album_cover() bettet Frontcover in alle Tracks ein
cli: apply und process rufen embed_album_cover() nach copy_covers() auf
tests: TestFindCover mit 7 Tests (jpg, png, Symlink, Priorität, Negativ)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-02-18 00:35:28 +01:00
commit 3fa6237f94
4 changed files with 102 additions and 9 deletions

View file

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

View file

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

View file

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

45
tests/test_cover.py Normal file
View file

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