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

13 KiB
Raw Blame 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:

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)

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:

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:

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