Musiksammlung/docs/refactoring_plan.md
dschlueter 5de6caba3a docs: add re-apply, cleanup, and gen_json plans
- Re-apply: idempotent apply using track number prefix as stable anchor;
  album.json never touched; optional --rename-dir flag for dir renames
- Cleanup: auto-remove abcde.* temp dirs after ripping + manual command
- gen_json: reverse-engineer album.json from file tree using fixed naming
  convention; audio tags take priority over filenames for all fields

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 11:29:57 +01:00

523 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Refactoring-Plan: Workflow-Phasen in ripper.py
*Stand: 2026-02-20 — 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.
---
## Phase 5 im Detail: Intelligente Metadaten-Zusammenführung (merger.py)
### Ziel
Statt "Winner-takes-all" (bisher: Vision > MB > CDDB) eine **feldweise
Zusammenführung** aller verfügbaren Quellen. Jedes Feld wird aus der besten
verfügbaren Quelle befüllt. Zusätzlich werden bisher verworfene technische
Daten (Disc-ID, Tracklängen) persistiert.
### Neues Modul: `src/musiksammlung/merger.py`
Einzige öffentliche Funktion:
```python
def merge_album(
mb_album: Album | None, # Phase 1: MusicBrainz-Treffer
cddb_results: list[CddbResult], # Phase 4: ein CddbResult pro Disc
vision_album: Album | None, # Phase 3: Vision-LLM-Ergebnis
disc_ids: list[str], # Phase 4: cd-discid-Output pro Disc
user_album_name: str | None, # Phase 1/4: manuell eingetippter Name
) -> Album:
...
```
### Datenmodell-Erweiterungen (models.py)
```python
class Track(BaseModel):
track_number: int
title: str
artist: str | None = None
duration_ms: int | None = None # NEU: aus MB (ms) oder TOC berechnet
class Disc(BaseModel):
disc_number: int
name: str | None = None
tracks: list[Track]
disc_id: str | None = None # NEU: 8-Hex CDDB Disc-ID aus cd-discid
```
`Album.genre` ist bereits vorhanden, wird aber bisher nie befüllt — das
wird mit dieser Änderung behoben.
### Feldweise Priorisierung
| Feld | Priorität | Begründung |
|------|-----------|------------|
| `album.artist` | MB > Vision > CDDB | MB normalisiert (z.B. "Bach, J.S.") |
| `album.album` | MB > Vision > CDDB | MB autoritativ |
| `album.year` | MB > CDDB > Vision | MB hat exaktes Release-Datum |
| `album.genre` | CDDB (einzige Quelle) | MB und Vision liefern nie Genre |
| `disc.name` | Vision (einzige Quelle) | MB und CDDB kennen kein disc.name |
| `disc.disc_id` | cd-discid (einzige Quelle) | Physischer Fingerprint der CD |
| `track.title` | Vision > MB > CDDB | Vision bereinigt (ohne Zeitangaben) |
| `track.artist` | MB > CDDB > Vision | MB normalisiert Track-Artists |
| `track.duration_ms` | MB > TOC-berechnet | MB in ms; TOC aus Sektor-Offsets |
### Tracklängen: zwei Quellen
**Quelle A — MusicBrainz** (`track.length` in Millisekunden):
- Zuverlässigste Quelle, von MB-Redakteuren gepflegt
- Wird in `_parse_release()` (musicbrainz.py) bereits ignoriert → dort ergänzen
**Quelle B — TOC aus cd-discid** (Sektor-Offsets):
- cd-discid liefert: `discid ntrks offset_1 offset_2 ... total_sectors`
- Laufzeit Track n = `(offset_{n+1} - offset_n) / 75` Sekunden
- Für letzten Track: `(total_sectors - offset_n) / 75`
- Genau, weil direkt vom physischen Medium gemessen
- Erfordert: `disc_ids`-Liste als strukturierte Offsets parsen (heute nur als
Rohstring gespeichert)
### Track-Matching zwischen Quellen
Wenn Quellen unterschiedlich viele Tracks liefern (z.B. Vision-LLM liest
12 Tracks, MB hat 13 wegen Hidden Track):
```
Merge-Strategie: track_number als Primary Key
for track_number in union(alle Quellen):
title = erste verfügbare Quelle nach Priorität
artist = erste verfügbare Quelle nach Priorität
duration_ms = MB[track_number] oder TOC[track_number] oder None
```
Tracks die nur in einer Quelle existieren, werden übernommen (kein
stilles Verwerfen).
### Cover-Strategie (Phase 2 — Änderung gegenüber heute)
```
front.jpg / back.jpg Priorität:
1. CAA (Cover Art Archive via MBID) ← bevorzugt, standardisiert
2. Handy-Foto ← nur wenn CAA nicht verfügbar
Handy-Fotos back.jpg:
- Primärer Zweck: Text-Extraktion durch Vision-LLM (ephemer)
- Als back.jpg speichern: nur wenn kein CAA-Cover vorhanden
- Qualitätsproblem: Handy-Foto ≠ standardisiertes Cover-Artwork
Handy-Fotos front.jpg:
- Nie nötig, wenn CAA verfügbar
- Scanner-Upload für front.jpg entfällt bei MB-Treffern komplett
```
### Zu erfassende Daten (Änderungen in anderen Modulen)
| Modul | Änderung | Zweck |
|-------|----------|-------|
| `musicbrainz.py` | `track.get("length")``Track.duration_ms` | Laufzeiten aus MB |
| `cddb.py` | `disc_id` und Offsets aus discid_line parsen | TOC-Laufzeiten berechnen |
| `ripper.py` | `discid_line` pro Disc speichern (heute verworfen) | Weitergabe an merger |
| `cddb.py` | EXTT-Felder parsen (oft leer, aber opportunistisch) | Laufzeiten als Fallback |
### Testbarkeit
`merger.py` hat keine Abhängigkeit zu Hardware, Netzwerk oder Subprocess.
Input: Python-Objekte. Output: `Album`. Vollständig unit-testbar mit
Fixture-Daten — kein Mocking erforderlich.
Testvorgaben:
- MB-Album + CDDB-Ergebnis + Vision-Album → korrekte Feldauswahl
- Genre immer aus CDDB übernommen
- Tracklängen aus MB, falls vorhanden
- Track-Matching bei unterschiedlicher Track-Anzahl
- disc_id korrekt pro Disc zugeordnet
- Fehlende Quellen (None) robust behandelt
---
## Parallelisierung: Informationsbeschaffung vor dem Rippen
### Kernidee
Die CD muss für zwei Operationen physisch im Laufwerk liegen, aber diese
brauchen sich nicht zu überlappen:
```
Operation A: TOC lesen (cd-discid) ~2 Sekunden
Operation B: Audio rippen (cdparanoia) 2060 Minuten pro Disc
```
Heute sind beide in einem monolithischen Durchlauf verschmolzen. Das
Umordnen kostet nichts — die CD darf ruhig zweimal im Laufwerk liegen.
### Neuer Ablauf (Drei Phasen A / B / C)
```
┌─────────────────────────────────────────────────────────┐
│ Phase A: Informationsbeschaffung (~30s 5 min) │
│ │
│ 1. CD einlegen (einmalig oder pro Disc bei Multi-Disc) │
│ 2. cd-discid → TOC: disc_id, Offsets, Laufzeiten │
│ 3. CDDB-Lookup → artist, album, year, genre, tracks │
│ 4. EAN-Scan → MusicBrainz → Album + MBID │
│ 5. CAA-Download → front.jpg, back.jpg │
│ 6. back.jpg → Vision-LLM (läuft parallel, ~13 min) │
│ 7. merge_album() → album.json (preliminary) │
│ 8. "album.json bereit — bitte prüfen." │
│ 9. User editiert album.json (optional, zweites │
│ Terminal oder nach Prompt) │
│ 10. "Rippen starten? [Enter]" │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ Phase B: Audio-Ripping (~2060 min pro Disc) │
│ │
│ abcde läuft im Vordergrund (Terminal zeigt Progress) │
│ Während des Rippens im zweiten Terminal: │
│ - album.json editieren (jederzeit möglich) │
│ - Vision-LLM-Ergebnis (falls noch ausstehend) │
│ wird nach Abschluss nachgemergt │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ Phase C: Apply (~30s) │
│ │
│ Liest finales album.json (evtl. editiert während B) │
│ organize → tag → embed → playlist │
└─────────────────────────────────────────────────────────┘
```
### Was der Hauptgewinn ist
```
Heute: Metadaten ── Rippen (60 min) ── album.json sehen ── editieren
Neu: Metadaten ── album.json sehen ── [editieren] ── Rippen (60 min)
└── Vision-LLM läuft parallel zum Rippen
```
Der User sieht die Metadaten **vor** dem langen Warten. Editing erfolgt
mit vollständiger Information, nicht blind im Nachhinein.
### Warum kein Hintergrund-Ripping
Hintergrund-Ripping (subprocess mit PID-Datei) ist technisch machbar,
erhöht aber die Komplexität (PID-Management, Fehlerbehandlung, Status-
Tracking) ohne entscheidenden Mehrwert: Der User kann während des
Vordergrund-Rippings jederzeit ein zweites Terminal öffnen und album.json
dort editieren. `$EDITOR album.json` reicht.
### Abcde: keine Änderung nötig
abcde hat Aktions-Flags (`-a cddb,read,encode,tag`), die eine Trennung
erlauben würden. Da wir CDDB und TOC aber bereits **vor** abcde über
eigene Module abfragen (`lookup_by_discid`, `cd-discid`), kann abcde
unverändert für Phase B genutzt werden — nur mit dem Wissen, dass seine
CDDB-Ausgabe bereits in album.json konsolidiert ist.
### Multi-Disc-Verhalten
```
Disc 1: Phase A (~2 min) → "Rippen starten?" → Phase B Disc 1 (~45 min)
Disc 2: Phase A (~2 min) → "Rippen starten?" → Phase B Disc 2 (~45 min)
...
```
Jede Disc wird sequenziell behandelt (ein Laufwerk). Phase A aller Discs
könnte theoretisch vorab gebündelt werden (alle Discs kurz einlegen, TOC
lesen), ist aber als optionale Optimierung einzustufen.
### Änderungsbedarf in ripper.py
Die heutige `interactive_rip`-Funktion wird aufgeteilt in:
```python
def gather_metadata(disc_num, config, scanner) -> DiscMetadata:
"""Phase A: TOC, CDDB, MB, Vision-LLM — kein Rippen."""
...
def rip_disc(disc_num, config) -> Path:
"""Phase B: abcde — kein Netzwerk, kein LLM."""
...
def interactive_rip(config):
"""Orchestriert: für jede Disc gather_metadata → confirm → rip_disc."""
...
```
`DiscMetadata` ist ein internes Dataclass/NamedTuple das alle Rohdaten
einer Disc bis zum Merge trägt:
```python
@dataclass
class DiscMetadata:
disc_number: int
discid_line: str # Rohstring aus cd-discid
cddb_result: CddbResult | None
mb_album: Album | None # nur bei erster Disc sinnvoll
mb_mbid: str | None
vision_album: Album | None # Ergebnis des Background-Threads
uploaded_photo: Path | None
```
Nach dem letzten `rip_disc`-Aufruf ruft `interactive_rip` einmalig
`merge_album()` auf und schreibt album.json.
---
## Wiederholbares Apply (Re-Apply in-place)
### Ziel
`apply` soll beliebig oft wiederholbar sein — auch nachträglich im
Jellyfin-Zielverzeichnis. Der User editiert album.json und ruft apply
erneut auf; Dateinamen, Tags und Playlist werden aktualisiert.
### Invariante: album.json bleibt immer erhalten
`apply` berührt album.json nie. Sie ist die einzige persistente
Wahrheitsquelle für alle Metadaten. Selbst wenn Tags oder Dateinamen
abweichen, definiert album.json den Sollzustand.
### Stabilität durch Tracknummer-Präfix
Der stabile Anker beim Re-Apply ist die führende Tracknummer im
Dateinamen:
```
01_-_Alter_Titel.flac → nach Edit → 01_-_Neuer_Titel.flac
```
`discover_audio_files()` identifiziert Tracks bereits nach dem
numerischen Präfix (unabhängig vom Titelanteil). Re-Apply ist damit
robust gegenüber beliebigen Titeländerungen.
### Was apply bei jedem Aufruf tut (idempotent)
1. Audiodateien per Tracknummer-Präfix identifizieren
2. Umbenennen gemäß aktuellem album.json (in-place)
3. Alle Audio-Tags neu schreiben (überschreiben)
4. Cover neu einbetten (überschreiben)
5. Playlist neu generieren (überschreiben, gleicher Dateiname)
6. album.json unangetastet lassen
### Verzeichnisumbenennung bei Albumname-Änderung
Wenn sich `album` oder `year` in album.json ändern, ändert sich der
korrekte Verzeichnisname. Im Jellyfin-Ordner ist das heikel (Jellyfin
erkennt das Album ggf. als neu).
Vorschlag: Verzeichnisumbenennung nur mit explizitem Flag:
```
musiksammlung apply --in-place --rename-dir <album_dir>
```
Ohne Flag: Dateien und Tags werden aktualisiert, Verzeichnisname bleibt.
---
## Cleanup nach dem Ripping
abcde hinterlässt Arbeitsverzeichnisse unterhalb der CDn-Ordner:
```
Album/
CD1/
abcde.XXXXX/ ← Temp-Verzeichnis von abcde
track01.wav ← evtl. noch vorhandene WAV-Zwischendateien
status
mbid.1
```
### Wann aufräumen
**Automatisch am Ende von Phase B** (nach erfolgreichem Ripping):
```python
for abcde_dir in cd_dir.glob("abcde.*"):
shutil.rmtree(abcde_dir)
```
Alternativ als expliziter Befehl für manuelles Aufräumen:
```
musiksammlung cleanup <album_dir>
```
Beide Varianten sollten implementiert werden: automatisch als Default,
manuell als Fallback wenn Phase B mit Fehler abgebrochen wurde.
---
## Neuer CLI-Befehl: gen_json
### Zweck
Rekonstruiert album.json aus dem vorhandenen Dateibaum, wenn die Datei
versehentlich gelöscht wurde. Kann auch für Alben genutzt werden, die
mit anderen Tools gerippt wurden.
```
musiksammlung gen_json <album_dir>
```
### Namenskonvention (verbindlich festgelegt)
**Album-Verzeichnis:**
```
Sanitized_Artist_-_Sanitized_Album_Title_(YYYY)/
Sanitized_Album_Title_(YYYY)/ ← wenn Artist "Various Artists"
```
Beispiele:
```
Pink_Floyd_-_The_Wall_(1979)/
Bach_-_Brandenburg_Concertos_(1967)/
Various_Artists_-_Now_Thats_What_I_Call_Music_(2001)/
```
**CDn-Unterverzeichnis** (bei Multi-Disc):
```
CD1/ CD2/ ...
```
Bei Single-Disc: Audiodateien liegen direkt im Album-Verzeichnis oder
in `CD1/` — beides wird akzeptiert.
**Track-Dateiname:**
```
NN_-_Sanitized_Track_Title.flac
NN_-_Sanitized_Track_Title_-_Sanitized_Track_Artist.flac
```
`NN` ist zweistellig mit führender Null (`01`, `02`, ..., `99`).
`_-_` (Unterstrich-Bindestrich-Unterstrich) ist der strukturelle
Trenner — kein einfacher `-` innerhalb eines Feldes ist `_-_`.
### gen_json Algorithmus
```
1. Verzeichnisname parsen:
"Pink_Floyd_-_The_Wall_(1979)" →
artist = "Pink Floyd" (vor erstem _-_)
album = "The Wall" (zwischen _-_ und _(YYYY))
year = 1979 (aus _(YYYY))
2. CDn-Unterverzeichnisse → disc_number (Zahl aus "CD1", "CD2" etc.)
Keine CDn-Dirs → Single-Disc, disc_number = 1
3. Pro Disc: Audiodateien nach Tracknummer-Präfix sortieren:
"01_-_In_the_Flesh.flac" → track_number=1, title="In the Flesh"
"02_-_The_Thin_Ice_-_Roger_Waters.flac"
→ track_number=2, title="The Thin Ice",
artist="Roger Waters"
4. Audio-Tags lesen (mutagen) — höhere Priorität als Dateiname:
- title, artist: aus Tags übernehmen wenn vorhanden
(Tags enthalten Original-Strings ohne Sanitizing)
- duration_ms: aus Audiodatei (exakt, besser als MB oder TOC)
- genre: nur aus Tags verfügbar, nirgends im Dateinamen kodiert
5. album.json schreiben (fehlende Felder als null)
```
### Daten-Rangfolge in gen_json
| Feld | Quelle 1 (bevorzugt) | Quelle 2 (Fallback) |
|------|---------------------|---------------------|
| title | Audio-Tag | Dateiname (desanitized) |
| artist | Audio-Tag | Dateiname / Verzeichnis |
| album | Audio-Tag | Verzeichnisname |
| year | Audio-Tag | Verzeichnisname _(YYYY) |
| genre | Audio-Tag | — (bleibt null) |
| duration_ms | Audiodatei (mutagen) | — |
| disc_number | CDn-Verzeichnis | — |
| track_number | Dateiname-Präfix | Audio-Tag |
### Was gen_json nicht rekonstruieren kann
- `disc.disc_id` (physischer TOC-Fingerprint — nicht in Dateien)
- `disc.name` (z.B. "Live in Berlin" — nicht im Dateinamen kodiert)
- `genre` wenn Audio-Tags fehlen
### Klassik: Komponist vs. Interpret
Beide Felder werden in den Audio-Tags gespeichert (COMPOSER-Tag bei
FLAC/MP3). `gen_json` liest COMPOSER aus den Tags und kann Komponist
von Interpret trennen — ohne spezielle Namenskonvention im Dateinamen.
Der Dateinamen-Titel enthält bei Klassik typischerweise Werk + Satz:
```
01_-_Symphony_No_5_Op_67_I_Allegro_con_brio.flac
```
Komponist steht im Verzeichnisnamen (`Bach_-_...`) und im COMPOSER-Tag.