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
│ Zeige: Artist Album (Year), N Discs, M Tracks
│ CAA-Cover herunterladen (frontcover.jpg + backcover.jpg)
│ Vision-LLM im Hintergrund starten (analysiert backcover.jpg) ←─┐
│ CAA-Cover herunterladen (front.jpg + back.jpg)
│ Vision-LLM im Hintergrund starten (analysiert back.jpg) ←─┐
│ Für jede Disc: "CD 1/3 einlegen, Enter" → Rip → Rename │ parallel
│ Vision-LLM-Ergebnis abwarten (max. 120 s, oft schon fertig) ───┘
│ Priorität: Vision-LLM > MusicBrainz
@ -154,8 +154,8 @@ EAN/Barcode (Enter = überspringen): 028943753227
MusicBrainz-Suche nach Barcode 028943753227 ...
✓ Herbert von Karajan Beethoven: 9 Symphonies (1963, 5 Disc(s), 50 Tracks)
Cover-Download: frontcover.jpg, backcover.jpg
[Vision-LLM analysiert backcover.jpg im Hintergrund...]
Cover-Download: front.jpg, back.jpg
[Vision-LLM analysiert back.jpg im Hintergrund...]
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.
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
@ -275,8 +275,8 @@ Bei MusicBrainz-Treffern wird `backcover.jpg` aus dem Cover Art Archive herunter
01_-_Allegro_con_brio_-_Karajan.flac
02_-_Andante_con_moto_-_Karajan.flac
...
frontcover.jpg ← aus CAA oder automatisch hinzugefügt
backcover.jpg ← aus CAA oder Smartphone-Foto
front.jpg ← aus CAA oder automatisch hinzugefügt
back.jpg ← aus CAA oder Smartphone-Foto
album.json ← automatisch gespeichert
```
→ 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 |
|-------|-------|
| `frontcover.jpg` oder `frontcover.png` | Front-Cover |
| `backcover.jpg` oder `backcover.png` | Rückseiten-Cover |
| `front.jpg` oder `front.png` | Front-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.
@ -505,8 +505,8 @@ Ausgabe:
```
Verzeichnis: ~/rip/Beethoven_Sinfonien
frontcover: frontcover.jpg
backcover: backcover.jpg
frontcover: front.jpg
backcover: back.jpg
CD1/
[♪] 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.
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.
"""
if not directory.exists():

View file

@ -13,15 +13,15 @@ logger = logging.getLogger(__name__)
# Standard-Dateinamen für Cover im Album-Verzeichnis.
# Symbolische Links auf diese Namen sind erlaubt.
FRONT_COVER_STEMS = ["frontcover"]
BACK_COVER_STEMS = ["backcover"]
FRONT_COVER_STEMS = ["front"]
BACK_COVER_STEMS = ["back"]
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.
Prüft front.jpg, front.png (bzw. back.*) in dieser Reihenfolge.
Folgt symbolischen Links.
Args:
@ -65,20 +65,20 @@ def copy_covers(
back_image: Path | None,
album_dir: Path,
) -> 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.
Bereits vorhandene frontcover.*/backcover.*-Dateien werden nicht überschrieben,
Bereits vorhandene front.*/back.*-Dateien werden nicht überschrieben,
wenn kein Quellbild angegeben wurde.
"""
if front_image and front_image.exists():
prepare_cover(front_image, album_dir / "frontcover.jpg")
prepare_cover(front_image, album_dir / "front.jpg")
else:
if not find_cover(album_dir, "front"):
logger.debug("Kein Front-Cover in %s", album_dir)
if back_image and back_image.exists():
prepare_cover(back_image, album_dir / "backcover.jpg")
prepare_cover(back_image, album_dir / "back.jpg")
else:
logger.debug("Kein Back-Cover angegeben")
@ -95,9 +95,9 @@ def download_caa_covers(mbid: str, album_dir: Path) -> None:
Args:
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
if target.exists():
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."""
input_dir = album_root / "CD1" if num_discs == 1 else album_root
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>")
print(f"{' '.join(parts)}")
@ -713,7 +713,7 @@ def interactive_rip(config: RipperConfig) -> None:
total_discs = len(mb_album.discs)
# 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.mkdir(parents=True, exist_ok=True)
if mb_mbid:
@ -722,7 +722,7 @@ def interactive_rip(config: RipperConfig) -> None:
# Vision-LLM im Hintergrund starten, falls CAA-Backcover vorhanden
vision_queue = None
uploaded_photo: Path | None = None
backcover = album_root / "backcover.jpg"
backcover = album_root / "back.jpg"
if backcover.exists():
print(
" 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)
if uploaded_photo and uploaded_photo.exists():
dest = album_root / "backcover.jpg"
dest = album_root / "back.jpg"
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.write_text(
@ -1010,9 +1010,9 @@ def interactive_rip(config: RipperConfig) -> None:
# Hochgeladenes Backcover ins Album-Verzeichnis kopieren
# (überschreibt ggf. das CAA-Backcover — das Handy-Foto hat Vorrang)
if uploaded_photo and uploaded_photo.exists():
dest = album_root / "backcover.jpg"
dest = album_root / "back.jpg"
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.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):
input_dir = album_root / "CD1" if num_discs == 1 else album_root
front_flag = (
"" if (album_root / "frontcover.jpg").exists()
"" if (album_root / "front.jpg").exists()
else " --front <cover.jpg>"
)
print(f"\n Album {i}: {album_root.name}")

View file

@ -227,7 +227,7 @@ class ScannerServer:
_, b64 = img_data.split(",", 1)
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)
logger.info("Backcover hochgeladen: %s (%d bytes)", path, len(img_bytes))
server_instance._queue.put(path)

View file

@ -141,12 +141,12 @@ class TestApplyCommand:
class TestCheckCommand:
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)])
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:
result = runner.invoke(app, ["check", str(tmp_path)])

View file

@ -23,22 +23,22 @@ 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"
(tmp_path / "front.jpg").touch()
assert find_cover(tmp_path, "front") == tmp_path / "front.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"
(tmp_path / "front.png").touch()
assert find_cover(tmp_path, "front") == tmp_path / "front.png"
def test_jpg_preferred_over_png(self, tmp_path: Path) -> None:
(tmp_path / "frontcover.jpg").touch()
(tmp_path / "frontcover.png").touch()
(tmp_path / "front.jpg").touch()
(tmp_path / "front.png").touch()
# .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:
(tmp_path / "backcover.jpg").touch()
assert find_cover(tmp_path, "back") == tmp_path / "backcover.jpg"
(tmp_path / "back.jpg").touch()
assert find_cover(tmp_path, "back") == tmp_path / "back.jpg"
def test_returns_none_if_missing(self, tmp_path: Path) -> None:
assert find_cover(tmp_path, "front") is None
@ -47,7 +47,7 @@ class TestFindCover:
def test_follows_symlink(self, tmp_path: Path) -> None:
real = tmp_path / "original.jpg"
real.touch()
link = tmp_path / "frontcover.jpg"
link = tmp_path / "front.jpg"
link.symlink_to(real)
assert find_cover(tmp_path, "front") == link
@ -95,33 +95,33 @@ class TestCopyCovers:
def test_copies_front_cover(self, tmp_path: Path) -> None:
src = _make_image(tmp_path / "src.jpg")
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:
src = _make_image(tmp_path / "src.jpg")
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:
front = _make_image(tmp_path / "front.jpg")
back = _make_image(tmp_path / "back.jpg")
copy_covers(front, back, tmp_path)
assert (tmp_path / "frontcover.jpg").exists()
assert (tmp_path / "backcover.jpg").exists()
assert (tmp_path / "front.jpg").exists()
assert (tmp_path / "back.jpg").exists()
def test_skips_nonexistent_front(self, tmp_path: Path) -> None:
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:
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:
existing = _make_image(tmp_path / "frontcover.jpg")
existing = _make_image(tmp_path / "front.jpg")
original_mtime = existing.stat().st_mtime
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:
@ -151,10 +151,10 @@ class TestDownloadCaaCovers:
with patch("musiksammlung.cover.httpx.get", return_value=resp):
download_caa_covers("test-mbid", tmp_path)
assert (tmp_path / "frontcover.jpg").exists()
assert (tmp_path / "backcover.jpg").exists()
assert (tmp_path / "front.jpg").exists()
assert (tmp_path / "back.jpg").exists()
# 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:
"""404 → kein Cover, kein Fehler."""
@ -163,8 +163,8 @@ class TestDownloadCaaCovers:
with patch("musiksammlung.cover.httpx.get", return_value=resp_404):
download_caa_covers("no-cover-mbid", tmp_path)
assert not (tmp_path / "frontcover.jpg").exists()
assert not (tmp_path / "backcover.jpg").exists()
assert not (tmp_path / "front.jpg").exists()
assert not (tmp_path / "back.jpg").exists()
def test_http_error_continues(self, tmp_path: Path) -> None:
"""Netzwerkfehler → Warnung, kein Abbruch."""
@ -174,12 +174,12 @@ class TestDownloadCaaCovers:
):
download_caa_covers("error-mbid", tmp_path)
assert not (tmp_path / "frontcover.jpg").exists()
assert not (tmp_path / "backcover.jpg").exists()
assert not (tmp_path / "front.jpg").exists()
assert not (tmp_path / "back.jpg").exists()
def test_skips_existing_cover(self, tmp_path: Path) -> None:
"""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
img_bytes = _fake_image_bytes()
@ -188,15 +188,15 @@ class TestDownloadCaaCovers:
with patch("musiksammlung.cover.httpx.get", return_value=resp) as mock_get:
download_caa_covers("test-mbid", tmp_path)
# frontcover.jpg bleibt unverändert
assert (tmp_path / "frontcover.jpg").stat().st_size == original_size
# backcover.jpg wird heruntergeladen (war nicht vorhanden)
assert (tmp_path / "backcover.jpg").exists()
# front.jpg bleibt unverändert
assert (tmp_path / "front.jpg").stat().st_size == original_size
# back.jpg wird heruntergeladen (war nicht vorhanden)
assert (tmp_path / "back.jpg").exists()
# Nur ein HTTP-Request (für back), nicht zwei
assert mock_get.call_count == 1
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()
resp_ok = _mock_caa_response(200, img_bytes)
resp_404 = _mock_caa_response(404)
@ -207,5 +207,5 @@ class TestDownloadCaaCovers:
):
download_caa_covers("mixed-mbid", tmp_path)
assert (tmp_path / "frontcover.jpg").exists()
assert not (tmp_path / "backcover.jpg").exists()
assert (tmp_path / "front.jpg").exists()
assert not (tmp_path / "back.jpg").exists()

View file

@ -351,7 +351,7 @@ class TestEmbedAlbumCover:
album = _make_album(tracks=2)
_make_flac(tmp_path / "01_-_Track_1_-_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)
@ -374,7 +374,7 @@ class TestEmbedAlbumCover:
cd2.mkdir()
_make_flac(cd1 / "01_-_T1_-_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)