Rename cover files: frontcover.jpg → front.jpg, backcover.jpg → back.jpg

Shorter, cleaner filenames consistent with Jellyfin conventions.
Updated all references in source, tests, and documentation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-02-20 09:56:12 +01:00
commit 1ca88b0d6d
9 changed files with 145 additions and 68 deletions

View file

@ -73,8 +73,8 @@ EAN/Barcode eingeben ODER CD-Hülle fotografieren
├─ MusicBrainz-Treffer → Auto-Rip ├─ MusicBrainz-Treffer → Auto-Rip
│ Zeige: Artist Album (Year), N Discs, M Tracks │ Zeige: Artist Album (Year), N Discs, M Tracks
│ CAA-Cover herunterladen (frontcover.jpg + backcover.jpg) │ CAA-Cover herunterladen (front.jpg + back.jpg)
│ Vision-LLM im Hintergrund starten (analysiert backcover.jpg) ←─┐ │ Vision-LLM im Hintergrund starten (analysiert back.jpg) ←─┐
│ Für jede Disc: "CD 1/3 einlegen, Enter" → Rip → Rename │ parallel │ Für jede Disc: "CD 1/3 einlegen, Enter" → Rip → Rename │ parallel
│ Vision-LLM-Ergebnis abwarten (max. 120 s, oft schon fertig) ───┘ │ Vision-LLM-Ergebnis abwarten (max. 120 s, oft schon fertig) ───┘
│ Priorität: Vision-LLM > MusicBrainz │ Priorität: Vision-LLM > MusicBrainz
@ -154,8 +154,8 @@ EAN/Barcode (Enter = überspringen): 028943753227
MusicBrainz-Suche nach Barcode 028943753227 ... MusicBrainz-Suche nach Barcode 028943753227 ...
✓ Herbert von Karajan Beethoven: 9 Symphonies (1963, 5 Disc(s), 50 Tracks) ✓ Herbert von Karajan Beethoven: 9 Symphonies (1963, 5 Disc(s), 50 Tracks)
Cover-Download: frontcover.jpg, backcover.jpg Cover-Download: front.jpg, back.jpg
[Vision-LLM analysiert backcover.jpg im Hintergrund...] [Vision-LLM analysiert back.jpg im Hintergrund...]
CD 1/5 einlegen und Enter drücken (9 Tracks) ... CD 1/5 einlegen und Enter drücken (9 Tracks) ...
@ -263,7 +263,7 @@ Im Fallback-Modus (kein MusicBrainz-Treffer) kann derselbe laufende Scanner-Serv
Nach dem Hochladen fährt das Programm automatisch fort und startet die Vision-LLM-Analyse im Hintergrund. Sobald der Ripping-Prozess abgeschlossen ist, wird das Ergebnis übernommen. Nach dem Hochladen fährt das Programm automatisch fort und startet die Vision-LLM-Analyse im Hintergrund. Sobald der Ripping-Prozess abgeschlossen ist, wird das Ergebnis übernommen.
Bei MusicBrainz-Treffern wird `backcover.jpg` aus dem Cover Art Archive heruntergeladen — kein Foto nötig. Bei MusicBrainz-Treffern wird `back.jpg` aus dem Cover Art Archive heruntergeladen — kein Foto nötig.
### Ergebnis-Verzeichnis ### Ergebnis-Verzeichnis
@ -275,8 +275,8 @@ Bei MusicBrainz-Treffern wird `backcover.jpg` aus dem Cover Art Archive herunter
01_-_Allegro_con_brio_-_Karajan.flac 01_-_Allegro_con_brio_-_Karajan.flac
02_-_Andante_con_moto_-_Karajan.flac 02_-_Andante_con_moto_-_Karajan.flac
... ...
frontcover.jpg ← aus CAA oder automatisch hinzugefügt front.jpg ← aus CAA oder automatisch hinzugefügt
backcover.jpg ← aus CAA oder Smartphone-Foto back.jpg ← aus CAA oder Smartphone-Foto
album.json ← automatisch gespeichert album.json ← automatisch gespeichert
``` ```
→ Am Ende zeigt das Programm den fertigen `apply`-Befehl an (copy-paste-fähig). → Am Ende zeigt das Programm den fertigen `apply`-Befehl an (copy-paste-fähig).
@ -469,10 +469,10 @@ Im Album-Verzeichnis werden folgende Dateinamen erwartet:
| Datei | Zweck | | Datei | Zweck |
|-------|-------| |-------|-------|
| `frontcover.jpg` oder `frontcover.png` | Front-Cover | | `front.jpg` oder `front.png` | Front-Cover |
| `backcover.jpg` oder `backcover.png` | Rückseiten-Cover | | `back.jpg` oder `back.png` | Rückseiten-Cover |
Symbolische Links auf diese Namen sind erlaubt. `apply` kopiert die mit `--front`/`--back` angegebenen Bilder automatisch als `frontcover.jpg` bzw. `backcover.jpg` ins Album-Verzeichnis und bettet das Frontcover anschließend in alle Audio-Dateien ein (skaliert auf max. 500 px). Symbolische Links auf diese Namen sind erlaubt. `apply` kopiert die mit `--front`/`--back` angegebenen Bilder automatisch als `front.jpg` bzw. `back.jpg` ins Album-Verzeichnis und bettet das Frontcover anschließend in alle Audio-Dateien ein (skaliert auf max. 500 px).
Ist bereits ein `frontcover.*` vorhanden (z.B. bei erneutem `apply`), wird es ohne `--front`-Option verwendet. Ist bereits ein `frontcover.*` vorhanden (z.B. bei erneutem `apply`), wird es ohne `--front`-Option verwendet.
@ -505,8 +505,8 @@ Ausgabe:
``` ```
Verzeichnis: ~/rip/Beethoven_Sinfonien Verzeichnis: ~/rip/Beethoven_Sinfonien
frontcover: frontcover.jpg frontcover: front.jpg
backcover: backcover.jpg backcover: back.jpg
CD1/ CD1/
[♪] 01_-_Allegro_con_brio_-_Karajan.flac [♪] 01_-_Allegro_con_brio_-_Karajan.flac

77
docs/refactoring_plan.md Normal file
View file

@ -0,0 +1,77 @@
# Refactoring-Plan: Workflow-Phasen in ripper.py
*Stand: 2026-02-19 — Entwurf zur Diskussion*
## Aktueller Zustand
`interactive_rip` ist eine ~400-Zeilen-Funktion mit zwei verschränkten Pfaden
(MB-Hit vs. Fallback), die alles in einem monolithischen Block erledigt.
Die `apply`-Seite ist bereits sauber getrennt (organizer → tagger → playlist).
## Vorschlag: 7 logische Phasen
### Phase 1: Album-Identifikation (EAN / Barcode)
- EAN-Eingabe per Tastatur oder Foto-Scan
- Vision-LLM: Barcode aus Foto extrahieren
- MusicBrainz-Lookup per EAN → Album-Struktur + MBID
- Ergebnis: `ean`, `mb_album`, `mb_mbid` (oder alles None)
### Phase 2: Cover-Beschaffung
- CAA-Download (front.jpg, back.jpg) per MBID
- Foto-Upload per Scanner-Server (Backcover, ggf. Frontcover)
- `prepare_cover()` für Jellyfin-Format
- Ergebnis: Cover-Dateien im album_root
### Phase 3: Backcover-Analyse (parallel zu Phase 4)
- Vision-LLM auf Backcover-Foto → vollständige Album-Metadaten
- Läuft als Background-Thread während des Rippens
- Ergebnis: `vision_album: Album | None`
### Phase 4: Audio-Ripping (pro Disc)
- Disc einlegen → abcde starten (cdparanoia + Encoder)
- CDDB-Lookup als Nebenprodukt von abcde
- CDDB-Bestätigung durch User (Fallback-Pfad)
- Audio-Dateien in album_root/CDn/
- Ergebnis: Audio-Dateien + `cddb_tracks` pro Disc
### Phase 5: Metadaten-Zusammenführung (Priorität: Vision > MB > CDDB)
- Vision-LLM-Ergebnis einsammeln (Timeout 120s)
- Beste Quelle auswählen nach Priorität
- Album-Name, Artist, Year, Genre, Tracklist konsolidieren
- Ergebnis: `final_album: Album`
### Phase 6: album.json erzeugen + Apply-Hint
- `final_album` serialisieren → album.json
- Kopierbaren `apply`-Befehl ausgeben
- **Hier endet `rip`** — User prüft/editiert JSON manuell
### Phase 7: Apply (bereits separater CLI-Befehl)
- Datei-Mapping erstellen (organizer)
- Umbenennen/Verschieben (sanitize_filename)
- Audio-Tagging (mutagen)
- Cover-Embedding
- M3U-Playlist generieren
## Strukturelle Beobachtungen
### Was heute vermischt ist
- Phase 16 stecken alle in `interactive_rip()` mit if/else-Verzweigung
für MB-Hit vs. Fallback
- Die Disc-Schleife (Phase 4) enthält Logik aus Phase 2 (Cover-Upload
während Disc-Insert) und Phase 3 (Vision-Thread starten)
- Phase 5 ist dupliziert: einmal im MB-Pfad (Zeile ~791), einmal im
Fallback-Pfad (Zeile ~956)
### Was eine Refaktorierung bringen würde
- Die zwei Pfade (MB-Hit / Fallback) konvergieren nach Phase 4 — ab Phase 5
ist die Logik identisch, aber heute kopiert
- Cover-Beschaffung ist über den ganzen Code verstreut (CAA-Download,
Scanner-Upload während Disc-Insert, Backcover-Save am Ende)
- Die Vision-LLM-Steuerung (Thread starten, Ergebnis einsammeln) könnte ein
eigenes Objekt/Kontext sein
### Offene Frage
Soll die Disc-Schleife (Phase 4) eine einheitliche Schnittstelle haben, die
beide Pfade bedient? Im MB-Pfad ist die Disc-Anzahl bekannt, im Fallback-Pfad
offen (while-Schleife mit "Nächste CD?"). Das ist der größte strukturelle
Unterschied.

View file

@ -509,7 +509,7 @@ def check(
"""Zeigt Audio-Tags und Cover-Status aller Dateien in einem Verzeichnis. """Zeigt Audio-Tags und Cover-Status aller Dateien in einem Verzeichnis.
Durchsucht das Verzeichnis rekursiv nach Audiodateien und gibt für jede Durchsucht das Verzeichnis rekursiv nach Audiodateien und gibt für jede
Datei die wichtigsten Tags aus. Zeigt außerdem ob frontcover.jpg/backcover.jpg Datei die wichtigsten Tags aus. Zeigt außerdem ob front.jpg/back.jpg
vorhanden sind und ob ein Cover eingebettet ist. vorhanden sind und ob ein Cover eingebettet ist.
""" """
if not directory.exists(): if not directory.exists():

View file

@ -13,15 +13,15 @@ logger = logging.getLogger(__name__)
# Standard-Dateinamen für Cover im Album-Verzeichnis. # Standard-Dateinamen für Cover im Album-Verzeichnis.
# Symbolische Links auf diese Namen sind erlaubt. # Symbolische Links auf diese Namen sind erlaubt.
FRONT_COVER_STEMS = ["frontcover"] FRONT_COVER_STEMS = ["front"]
BACK_COVER_STEMS = ["backcover"] BACK_COVER_STEMS = ["back"]
COVER_EXTENSIONS = [".jpg", ".png"] COVER_EXTENSIONS = [".jpg", ".png"]
def find_cover(album_dir: Path, kind: str = "front") -> Path | None: def find_cover(album_dir: Path, kind: str = "front") -> Path | None:
"""Sucht das Standard-Coverbild im Album-Verzeichnis. """Sucht das Standard-Coverbild im Album-Verzeichnis.
Prüft frontcover.jpg, frontcover.png (bzw. backcover.*) in dieser Reihenfolge. Prüft front.jpg, front.png (bzw. back.*) in dieser Reihenfolge.
Folgt symbolischen Links. Folgt symbolischen Links.
Args: Args:
@ -65,20 +65,20 @@ 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 als frontcover.jpg / backcover.jpg """Kopiert Front- und Rückseiten-Cover als front.jpg / back.jpg
in das Album-Verzeichnis. in das Album-Verzeichnis.
Bereits vorhandene frontcover.*/backcover.*-Dateien werden nicht überschrieben, Bereits vorhandene front.*/back.*-Dateien werden nicht überschrieben,
wenn kein Quellbild angegeben wurde. 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 / "frontcover.jpg") prepare_cover(front_image, album_dir / "front.jpg")
else: else:
if not find_cover(album_dir, "front"): if not find_cover(album_dir, "front"):
logger.debug("Kein Front-Cover in %s", album_dir) logger.debug("Kein Front-Cover in %s", album_dir)
if back_image and back_image.exists(): if back_image and back_image.exists():
prepare_cover(back_image, album_dir / "backcover.jpg") prepare_cover(back_image, album_dir / "back.jpg")
else: else:
logger.debug("Kein Back-Cover angegeben") logger.debug("Kein Back-Cover angegeben")
@ -95,9 +95,9 @@ def download_caa_covers(mbid: str, album_dir: Path) -> None:
Args: Args:
mbid: MusicBrainz Release-MBID mbid: MusicBrainz Release-MBID
album_dir: Zielverzeichnis für frontcover.jpg / backcover.jpg album_dir: Zielverzeichnis für front.jpg / back.jpg
""" """
for kind, filename in [("front", "frontcover.jpg"), ("back", "backcover.jpg")]: for kind, filename in [("front", "front.jpg"), ("back", "back.jpg")]:
target = album_dir / filename target = album_dir / filename
if target.exists(): if target.exists():
logger.info("CAA: %s existiert bereits, überspringe.", filename) logger.info("CAA: %s existiert bereits, überspringe.", filename)

View file

@ -73,7 +73,7 @@ def _print_apply_hint(album_root: Path, json_path: Path, num_discs: int) -> None
"""Gibt einen kopierbaren apply-Befehl aus.""" """Gibt einen kopierbaren apply-Befehl aus."""
input_dir = album_root / "CD1" if num_discs == 1 else album_root input_dir = album_root / "CD1" if num_discs == 1 else album_root
parts = ["musiksammlung apply", str(input_dir), str(json_path)] parts = ["musiksammlung apply", str(input_dir), str(json_path)]
if not (album_root / "frontcover.jpg").exists(): if not (album_root / "front.jpg").exists():
parts.append("--front <cover.jpg>") parts.append("--front <cover.jpg>")
print(f"{' '.join(parts)}") print(f"{' '.join(parts)}")
@ -713,7 +713,7 @@ def interactive_rip(config: RipperConfig) -> None:
total_discs = len(mb_album.discs) total_discs = len(mb_album.discs)
# Album-Root und Cover VOR dem Ripping anlegen, # Album-Root und Cover VOR dem Ripping anlegen,
# damit backcover.jpg für Vision-LLM verfügbar ist. # damit back.jpg für Vision-LLM verfügbar ist.
album_root = config.output_dir / sanitize_filename(album_name) album_root = config.output_dir / sanitize_filename(album_name)
album_root.mkdir(parents=True, exist_ok=True) album_root.mkdir(parents=True, exist_ok=True)
if mb_mbid: if mb_mbid:
@ -722,7 +722,7 @@ def interactive_rip(config: RipperConfig) -> None:
# Vision-LLM im Hintergrund starten, falls CAA-Backcover vorhanden # Vision-LLM im Hintergrund starten, falls CAA-Backcover vorhanden
vision_queue = None vision_queue = None
uploaded_photo: Path | None = None uploaded_photo: Path | None = None
backcover = album_root / "backcover.jpg" backcover = album_root / "back.jpg"
if backcover.exists(): if backcover.exists():
print( print(
" Backcover verfügbar → Vision-LLM-Analyse im Hintergrund...", " Backcover verfügbar → Vision-LLM-Analyse im Hintergrund...",
@ -809,9 +809,9 @@ def interactive_rip(config: RipperConfig) -> None:
# Hochgeladenes Backcover speichern (Handy-Foto hat Vorrang vor CAA) # Hochgeladenes Backcover speichern (Handy-Foto hat Vorrang vor CAA)
if uploaded_photo and uploaded_photo.exists(): if uploaded_photo and uploaded_photo.exists():
dest = album_root / "backcover.jpg" dest = album_root / "back.jpg"
prepare_cover(uploaded_photo, dest) prepare_cover(uploaded_photo, dest)
print(f" backcover.jpg gespeichert: {dest}", flush=True) print(f" back.jpg gespeichert: {dest}", flush=True)
json_path = album_root / "album.json" json_path = album_root / "album.json"
json_path.write_text( json_path.write_text(
@ -1010,9 +1010,9 @@ def interactive_rip(config: RipperConfig) -> None:
# Hochgeladenes Backcover ins Album-Verzeichnis kopieren # Hochgeladenes Backcover ins Album-Verzeichnis kopieren
# (überschreibt ggf. das CAA-Backcover — das Handy-Foto hat Vorrang) # (überschreibt ggf. das CAA-Backcover — das Handy-Foto hat Vorrang)
if uploaded_photo and uploaded_photo.exists(): if uploaded_photo and uploaded_photo.exists():
dest = album_root / "backcover.jpg" dest = album_root / "back.jpg"
prepare_cover(uploaded_photo, dest) prepare_cover(uploaded_photo, dest)
print(f" backcover.jpg gespeichert: {dest}", flush=True) print(f" back.jpg gespeichert: {dest}", flush=True)
json_path = album_root / "album.json" json_path = album_root / "album.json"
json_path.write_text( json_path.write_text(
@ -1038,7 +1038,7 @@ def interactive_rip(config: RipperConfig) -> None:
for i, (album_root, json_path, num_discs) in enumerate(processed_albums, 1): for i, (album_root, json_path, num_discs) in enumerate(processed_albums, 1):
input_dir = album_root / "CD1" if num_discs == 1 else album_root input_dir = album_root / "CD1" if num_discs == 1 else album_root
front_flag = ( front_flag = (
"" if (album_root / "frontcover.jpg").exists() "" if (album_root / "front.jpg").exists()
else " --front <cover.jpg>" else " --front <cover.jpg>"
) )
print(f"\n Album {i}: {album_root.name}") print(f"\n Album {i}: {album_root.name}")

View file

@ -227,7 +227,7 @@ class ScannerServer:
_, b64 = img_data.split(",", 1) _, b64 = img_data.split(",", 1)
img_bytes = base64.b64decode(b64) img_bytes = base64.b64decode(b64)
path = server_instance._upload_dir / f"backcover{ext}" path = server_instance._upload_dir / f"back{ext}"
path.write_bytes(img_bytes) path.write_bytes(img_bytes)
logger.info("Backcover hochgeladen: %s (%d bytes)", path, len(img_bytes)) logger.info("Backcover hochgeladen: %s (%d bytes)", path, len(img_bytes))
server_instance._queue.put(path) server_instance._queue.put(path)

View file

@ -141,12 +141,12 @@ class TestApplyCommand:
class TestCheckCommand: class TestCheckCommand:
def test_check_shows_cover_status(self, tmp_path: Path) -> None: 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 (tmp_path / "front.jpg").write_bytes(b"\xff\xd8\xff\xe0") # minimal JPEG magic
result = runner.invoke(app, ["check", str(tmp_path)]) result = runner.invoke(app, ["check", str(tmp_path)])
assert result.exit_code == 0 assert result.exit_code == 0
assert "frontcover.jpg" in result.output assert "front.jpg" in result.output
def test_check_shows_missing_cover(self, tmp_path: Path) -> None: def test_check_shows_missing_cover(self, tmp_path: Path) -> None:
result = runner.invoke(app, ["check", str(tmp_path)]) result = runner.invoke(app, ["check", str(tmp_path)])

View file

@ -23,22 +23,22 @@ class TestFindCover:
"""Tests für find_cover.""" """Tests für find_cover."""
def test_finds_frontcover_jpg(self, tmp_path: Path) -> None: def test_finds_frontcover_jpg(self, tmp_path: Path) -> None:
(tmp_path / "frontcover.jpg").touch() (tmp_path / "front.jpg").touch()
assert find_cover(tmp_path, "front") == tmp_path / "frontcover.jpg" assert find_cover(tmp_path, "front") == tmp_path / "front.jpg"
def test_finds_frontcover_png(self, tmp_path: Path) -> None: def test_finds_frontcover_png(self, tmp_path: Path) -> None:
(tmp_path / "frontcover.png").touch() (tmp_path / "front.png").touch()
assert find_cover(tmp_path, "front") == tmp_path / "frontcover.png" assert find_cover(tmp_path, "front") == tmp_path / "front.png"
def test_jpg_preferred_over_png(self, tmp_path: Path) -> None: def test_jpg_preferred_over_png(self, tmp_path: Path) -> None:
(tmp_path / "frontcover.jpg").touch() (tmp_path / "front.jpg").touch()
(tmp_path / "frontcover.png").touch() (tmp_path / "front.png").touch()
# .jpg wird zuerst geprüft # .jpg wird zuerst geprüft
assert find_cover(tmp_path, "front") == tmp_path / "frontcover.jpg" assert find_cover(tmp_path, "front") == tmp_path / "front.jpg"
def test_finds_backcover(self, tmp_path: Path) -> None: def test_finds_backcover(self, tmp_path: Path) -> None:
(tmp_path / "backcover.jpg").touch() (tmp_path / "back.jpg").touch()
assert find_cover(tmp_path, "back") == tmp_path / "backcover.jpg" assert find_cover(tmp_path, "back") == tmp_path / "back.jpg"
def test_returns_none_if_missing(self, tmp_path: Path) -> None: def test_returns_none_if_missing(self, tmp_path: Path) -> None:
assert find_cover(tmp_path, "front") is None assert find_cover(tmp_path, "front") is None
@ -47,7 +47,7 @@ class TestFindCover:
def test_follows_symlink(self, tmp_path: Path) -> None: def test_follows_symlink(self, tmp_path: Path) -> None:
real = tmp_path / "original.jpg" real = tmp_path / "original.jpg"
real.touch() real.touch()
link = tmp_path / "frontcover.jpg" link = tmp_path / "front.jpg"
link.symlink_to(real) link.symlink_to(real)
assert find_cover(tmp_path, "front") == link assert find_cover(tmp_path, "front") == link
@ -95,33 +95,33 @@ class TestCopyCovers:
def test_copies_front_cover(self, tmp_path: Path) -> None: def test_copies_front_cover(self, tmp_path: Path) -> None:
src = _make_image(tmp_path / "src.jpg") src = _make_image(tmp_path / "src.jpg")
copy_covers(src, None, tmp_path) copy_covers(src, None, tmp_path)
assert (tmp_path / "frontcover.jpg").exists() assert (tmp_path / "front.jpg").exists()
def test_copies_back_cover(self, tmp_path: Path) -> None: def test_copies_back_cover(self, tmp_path: Path) -> None:
src = _make_image(tmp_path / "src.jpg") src = _make_image(tmp_path / "src.jpg")
copy_covers(None, src, tmp_path) copy_covers(None, src, tmp_path)
assert (tmp_path / "backcover.jpg").exists() assert (tmp_path / "back.jpg").exists()
def test_copies_both_covers(self, tmp_path: Path) -> None: def test_copies_both_covers(self, tmp_path: Path) -> None:
front = _make_image(tmp_path / "front.jpg") front = _make_image(tmp_path / "front.jpg")
back = _make_image(tmp_path / "back.jpg") back = _make_image(tmp_path / "back.jpg")
copy_covers(front, back, tmp_path) copy_covers(front, back, tmp_path)
assert (tmp_path / "frontcover.jpg").exists() assert (tmp_path / "front.jpg").exists()
assert (tmp_path / "backcover.jpg").exists() assert (tmp_path / "back.jpg").exists()
def test_skips_nonexistent_front(self, tmp_path: Path) -> None: def test_skips_nonexistent_front(self, tmp_path: Path) -> None:
copy_covers(tmp_path / "nope.jpg", None, tmp_path) copy_covers(tmp_path / "nope.jpg", None, tmp_path)
assert not (tmp_path / "frontcover.jpg").exists() assert not (tmp_path / "front.jpg").exists()
def test_skips_nonexistent_back(self, tmp_path: Path) -> None: def test_skips_nonexistent_back(self, tmp_path: Path) -> None:
copy_covers(None, tmp_path / "nope.jpg", tmp_path) copy_covers(None, tmp_path / "nope.jpg", tmp_path)
assert not (tmp_path / "backcover.jpg").exists() assert not (tmp_path / "back.jpg").exists()
def test_existing_frontcover_not_overwritten_when_no_source(self, tmp_path: Path) -> None: def test_existing_frontcover_not_overwritten_when_no_source(self, tmp_path: Path) -> None:
existing = _make_image(tmp_path / "frontcover.jpg") existing = _make_image(tmp_path / "front.jpg")
original_mtime = existing.stat().st_mtime original_mtime = existing.stat().st_mtime
copy_covers(None, None, tmp_path) copy_covers(None, None, tmp_path)
assert (tmp_path / "frontcover.jpg").stat().st_mtime == original_mtime assert (tmp_path / "front.jpg").stat().st_mtime == original_mtime
def _fake_image_bytes() -> bytes: def _fake_image_bytes() -> bytes:
@ -151,10 +151,10 @@ class TestDownloadCaaCovers:
with patch("musiksammlung.cover.httpx.get", return_value=resp): with patch("musiksammlung.cover.httpx.get", return_value=resp):
download_caa_covers("test-mbid", tmp_path) download_caa_covers("test-mbid", tmp_path)
assert (tmp_path / "frontcover.jpg").exists() assert (tmp_path / "front.jpg").exists()
assert (tmp_path / "backcover.jpg").exists() assert (tmp_path / "back.jpg").exists()
# Ergebnis ist ein gültiges JPEG # Ergebnis ist ein gültiges JPEG
assert Image.open(tmp_path / "frontcover.jpg").format == "JPEG" assert Image.open(tmp_path / "front.jpg").format == "JPEG"
def test_404_skips_cover(self, tmp_path: Path) -> None: def test_404_skips_cover(self, tmp_path: Path) -> None:
"""404 → kein Cover, kein Fehler.""" """404 → kein Cover, kein Fehler."""
@ -163,8 +163,8 @@ class TestDownloadCaaCovers:
with patch("musiksammlung.cover.httpx.get", return_value=resp_404): with patch("musiksammlung.cover.httpx.get", return_value=resp_404):
download_caa_covers("no-cover-mbid", tmp_path) download_caa_covers("no-cover-mbid", tmp_path)
assert not (tmp_path / "frontcover.jpg").exists() assert not (tmp_path / "front.jpg").exists()
assert not (tmp_path / "backcover.jpg").exists() assert not (tmp_path / "back.jpg").exists()
def test_http_error_continues(self, tmp_path: Path) -> None: def test_http_error_continues(self, tmp_path: Path) -> None:
"""Netzwerkfehler → Warnung, kein Abbruch.""" """Netzwerkfehler → Warnung, kein Abbruch."""
@ -174,12 +174,12 @@ class TestDownloadCaaCovers:
): ):
download_caa_covers("error-mbid", tmp_path) download_caa_covers("error-mbid", tmp_path)
assert not (tmp_path / "frontcover.jpg").exists() assert not (tmp_path / "front.jpg").exists()
assert not (tmp_path / "backcover.jpg").exists() assert not (tmp_path / "back.jpg").exists()
def test_skips_existing_cover(self, tmp_path: Path) -> None: def test_skips_existing_cover(self, tmp_path: Path) -> None:
"""Bereits vorhandene Cover werden nicht überschrieben.""" """Bereits vorhandene Cover werden nicht überschrieben."""
existing = _make_image(tmp_path / "frontcover.jpg") existing = _make_image(tmp_path / "front.jpg")
original_size = existing.stat().st_size original_size = existing.stat().st_size
img_bytes = _fake_image_bytes() img_bytes = _fake_image_bytes()
@ -188,15 +188,15 @@ class TestDownloadCaaCovers:
with patch("musiksammlung.cover.httpx.get", return_value=resp) as mock_get: with patch("musiksammlung.cover.httpx.get", return_value=resp) as mock_get:
download_caa_covers("test-mbid", tmp_path) download_caa_covers("test-mbid", tmp_path)
# frontcover.jpg bleibt unverändert # front.jpg bleibt unverändert
assert (tmp_path / "frontcover.jpg").stat().st_size == original_size assert (tmp_path / "front.jpg").stat().st_size == original_size
# backcover.jpg wird heruntergeladen (war nicht vorhanden) # back.jpg wird heruntergeladen (war nicht vorhanden)
assert (tmp_path / "backcover.jpg").exists() assert (tmp_path / "back.jpg").exists()
# Nur ein HTTP-Request (für back), nicht zwei # Nur ein HTTP-Request (für back), nicht zwei
assert mock_get.call_count == 1 assert mock_get.call_count == 1
def test_front_only_on_back_404(self, tmp_path: Path) -> None: def test_front_only_on_back_404(self, tmp_path: Path) -> None:
"""Front 200, Back 404 → nur frontcover.jpg erstellt.""" """Front 200, Back 404 → nur front.jpg erstellt."""
img_bytes = _fake_image_bytes() img_bytes = _fake_image_bytes()
resp_ok = _mock_caa_response(200, img_bytes) resp_ok = _mock_caa_response(200, img_bytes)
resp_404 = _mock_caa_response(404) resp_404 = _mock_caa_response(404)
@ -207,5 +207,5 @@ class TestDownloadCaaCovers:
): ):
download_caa_covers("mixed-mbid", tmp_path) download_caa_covers("mixed-mbid", tmp_path)
assert (tmp_path / "frontcover.jpg").exists() assert (tmp_path / "front.jpg").exists()
assert not (tmp_path / "backcover.jpg").exists() assert not (tmp_path / "back.jpg").exists()

View file

@ -351,7 +351,7 @@ class TestEmbedAlbumCover:
album = _make_album(tracks=2) album = _make_album(tracks=2)
_make_flac(tmp_path / "01_-_Track_1_-_TestArtist.flac") _make_flac(tmp_path / "01_-_Track_1_-_TestArtist.flac")
_make_flac(tmp_path / "02_-_Track_2_-_TestArtist.flac") _make_flac(tmp_path / "02_-_Track_2_-_TestArtist.flac")
cover = _make_cover(tmp_path / "frontcover.jpg") cover = _make_cover(tmp_path / "front.jpg")
embed_album_cover(album, tmp_path, cover) embed_album_cover(album, tmp_path, cover)
@ -374,7 +374,7 @@ class TestEmbedAlbumCover:
cd2.mkdir() cd2.mkdir()
_make_flac(cd1 / "01_-_T1_-_A.flac") _make_flac(cd1 / "01_-_T1_-_A.flac")
_make_flac(cd2 / "01_-_T2_-_A.flac") _make_flac(cd2 / "01_-_T2_-_A.flac")
cover = _make_cover(tmp_path / "frontcover.jpg") cover = _make_cover(tmp_path / "front.jpg")
embed_album_cover(album, tmp_path, cover) embed_album_cover(album, tmp_path, cover)