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
|
import typer
|
||||||
|
|
||||||
from musiksammlung.config import AudioFormat
|
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.llm_parser import parse_tracklist
|
||||||
from musiksammlung.models import Album
|
from musiksammlung.models import Album
|
||||||
from musiksammlung.ocr import ocr_images
|
from musiksammlung.ocr import ocr_images
|
||||||
from musiksammlung.organizer import apply_mapping, build_mapping, check_disc_counts
|
from musiksammlung.organizer import apply_mapping, build_mapping, check_disc_counts
|
||||||
from musiksammlung.playlist import generate_playlist
|
from musiksammlung.playlist import generate_playlist
|
||||||
from musiksammlung.ripper import RipperConfig, interactive_rip
|
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
|
from musiksammlung.vision_llm import parse_image
|
||||||
|
|
||||||
app = typer.Typer(
|
app = typer.Typer(
|
||||||
|
|
@ -214,6 +214,10 @@ def apply(
|
||||||
typer.echo("Setze Audio-Tags...")
|
typer.echo("Setze Audio-Tags...")
|
||||||
tag_album(album, album_dir)
|
tag_album(album, album_dir)
|
||||||
copy_covers(front, back, 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)
|
generate_playlist(album, album_dir)
|
||||||
|
|
||||||
typer.echo(f"Fertig! Album liegt in: {album_dir}")
|
typer.echo(f"Fertig! Album liegt in: {album_dir}")
|
||||||
|
|
@ -357,6 +361,10 @@ def process(
|
||||||
typer.echo("Schritt 3/4: Tags & Cover...")
|
typer.echo("Schritt 3/4: Tags & Cover...")
|
||||||
tag_album(album, album_dir)
|
tag_album(album, album_dir)
|
||||||
copy_covers(front, back, 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
|
# 4. Playlist
|
||||||
typer.echo("Schritt 4/4: Playlist...")
|
typer.echo("Schritt 4/4: Playlist...")
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,33 @@ from PIL import Image
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Jellyfin erkennt diese Dateinamen automatisch
|
# Standard-Dateinamen für Cover im Album-Verzeichnis.
|
||||||
FRONT_COVER_NAME = "cover.jpg"
|
# Symbolische Links auf diese Namen sind erlaubt.
|
||||||
BACK_COVER_NAME = "back.jpg"
|
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:
|
def prepare_cover(source: Path, target: Path, max_size: int = 1200) -> None:
|
||||||
|
|
@ -39,13 +63,19 @@ def copy_covers(
|
||||||
back_image: Path | None,
|
back_image: Path | None,
|
||||||
album_dir: Path,
|
album_dir: Path,
|
||||||
) -> None:
|
) -> 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():
|
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:
|
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():
|
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:
|
else:
|
||||||
logger.debug("Kein Back-Cover angegeben")
|
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:
|
def embed_cover(audio_path: Path, cover_path: Path) -> None:
|
||||||
"""Bettet ein Cover-Bild in eine Audiodatei ein."""
|
"""Bettet ein Cover-Bild in eine Audiodatei ein."""
|
||||||
cover_data = cover_path.read_bytes()
|
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