Musiksammlung/docs/refactoring_plan.md
dschlueter c791812755 docs: add Phase 5 merger.py plan with intelligent metadata merging
Concrete plan for Option B: new merger.py module with field-by-field
priority merging, duration_ms/disc_id model extensions, cover strategy,
and track-matching logic for sources with differing track counts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 10:31:27 +01:00

206 lines
7.7 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