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:
parent
fd8de16bdd
commit
1ca88b0d6d
9 changed files with 145 additions and 68 deletions
|
|
@ -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
77
docs/refactoring_plan.md
Normal 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 1–6 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.
|
||||||
|
|
@ -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():
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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}")
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)])
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue