Musiksammlung/docs/refactoring_plan.md
dschlueter 6a7602387a docs: add parallel metadata/ripping workflow plan (Phase A/B/C)
Describes the restructured workflow where metadata gathering (TOC, CDDB,
MB, Vision-LLM) happens before ripping starts, so the user can review and
edit album.json before committing to the long rip — not after.

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

337 lines
13 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.