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

7.7 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