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:
parent
c9152cf19f
commit
3fa6237f94
4 changed files with 102 additions and 9 deletions
|
|
@ -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...")
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
45
tests/test_cover.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue