Musiksammlung/docs/refactoring_plan.md

337 lines
13 KiB
Markdown
Raw Normal View History

# 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.