Musiksammlung/docs/refactoring_plan.md
dschlueter 5de6caba3a docs: add re-apply, cleanup, and gen_json plans
- Re-apply: idempotent apply using track number prefix as stable anchor;
  album.json never touched; optional --rename-dir flag for dir renames
- Cleanup: auto-remove abcde.* temp dirs after ripping + manual command
- gen_json: reverse-engineer album.json from file tree using fixed naming
  convention; audio tags take priority over filenames for all fields

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

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


Wiederholbares Apply (Re-Apply in-place)

Ziel

apply soll beliebig oft wiederholbar sein — auch nachträglich im Jellyfin-Zielverzeichnis. Der User editiert album.json und ruft apply erneut auf; Dateinamen, Tags und Playlist werden aktualisiert.

Invariante: album.json bleibt immer erhalten

apply berührt album.json nie. Sie ist die einzige persistente Wahrheitsquelle für alle Metadaten. Selbst wenn Tags oder Dateinamen abweichen, definiert album.json den Sollzustand.

Stabilität durch Tracknummer-Präfix

Der stabile Anker beim Re-Apply ist die führende Tracknummer im Dateinamen:

01_-_Alter_Titel.flac   →  nach Edit  →  01_-_Neuer_Titel.flac

discover_audio_files() identifiziert Tracks bereits nach dem numerischen Präfix (unabhängig vom Titelanteil). Re-Apply ist damit robust gegenüber beliebigen Titeländerungen.

Was apply bei jedem Aufruf tut (idempotent)

  1. Audiodateien per Tracknummer-Präfix identifizieren
  2. Umbenennen gemäß aktuellem album.json (in-place)
  3. Alle Audio-Tags neu schreiben (überschreiben)
  4. Cover neu einbetten (überschreiben)
  5. Playlist neu generieren (überschreiben, gleicher Dateiname)
  6. album.json unangetastet lassen

Verzeichnisumbenennung bei Albumname-Änderung

Wenn sich album oder year in album.json ändern, ändert sich der korrekte Verzeichnisname. Im Jellyfin-Ordner ist das heikel (Jellyfin erkennt das Album ggf. als neu).

Vorschlag: Verzeichnisumbenennung nur mit explizitem Flag:

musiksammlung apply --in-place --rename-dir <album_dir>

Ohne Flag: Dateien und Tags werden aktualisiert, Verzeichnisname bleibt.


Cleanup nach dem Ripping

abcde hinterlässt Arbeitsverzeichnisse unterhalb der CDn-Ordner:

Album/
  CD1/
    abcde.XXXXX/          ← Temp-Verzeichnis von abcde
      track01.wav         ← evtl. noch vorhandene WAV-Zwischendateien
      status
      mbid.1

Wann aufräumen

Automatisch am Ende von Phase B (nach erfolgreichem Ripping):

for abcde_dir in cd_dir.glob("abcde.*"):
    shutil.rmtree(abcde_dir)

Alternativ als expliziter Befehl für manuelles Aufräumen:

musiksammlung cleanup <album_dir>

Beide Varianten sollten implementiert werden: automatisch als Default, manuell als Fallback wenn Phase B mit Fehler abgebrochen wurde.


Neuer CLI-Befehl: gen_json

Zweck

Rekonstruiert album.json aus dem vorhandenen Dateibaum, wenn die Datei versehentlich gelöscht wurde. Kann auch für Alben genutzt werden, die mit anderen Tools gerippt wurden.

musiksammlung gen_json <album_dir>

Namenskonvention (verbindlich festgelegt)

Album-Verzeichnis:

Sanitized_Artist_-_Sanitized_Album_Title_(YYYY)/
Sanitized_Album_Title_(YYYY)/      ← wenn Artist "Various Artists"

Beispiele:

Pink_Floyd_-_The_Wall_(1979)/
Bach_-_Brandenburg_Concertos_(1967)/
Various_Artists_-_Now_Thats_What_I_Call_Music_(2001)/

CDn-Unterverzeichnis (bei Multi-Disc):

CD1/  CD2/  ...

Bei Single-Disc: Audiodateien liegen direkt im Album-Verzeichnis oder in CD1/ — beides wird akzeptiert.

Track-Dateiname:

NN_-_Sanitized_Track_Title.flac
NN_-_Sanitized_Track_Title_-_Sanitized_Track_Artist.flac

NN ist zweistellig mit führender Null (01, 02, ..., 99). _-_ (Unterstrich-Bindestrich-Unterstrich) ist der strukturelle Trenner — kein einfacher - innerhalb eines Feldes ist _-_.

gen_json Algorithmus

1. Verzeichnisname parsen:
   "Pink_Floyd_-_The_Wall_(1979)" →
       artist = "Pink Floyd"  (vor erstem _-_)
       album  = "The Wall"    (zwischen _-_ und _(YYYY))
       year   = 1979          (aus _(YYYY))

2. CDn-Unterverzeichnisse → disc_number (Zahl aus "CD1", "CD2" etc.)
   Keine CDn-Dirs → Single-Disc, disc_number = 1

3. Pro Disc: Audiodateien nach Tracknummer-Präfix sortieren:
   "01_-_In_the_Flesh.flac"         → track_number=1, title="In the Flesh"
   "02_-_The_Thin_Ice_-_Roger_Waters.flac"
                                    → track_number=2, title="The Thin Ice",
                                       artist="Roger Waters"

4. Audio-Tags lesen (mutagen) — höhere Priorität als Dateiname:
   - title, artist: aus Tags übernehmen wenn vorhanden
     (Tags enthalten Original-Strings ohne Sanitizing)
   - duration_ms: aus Audiodatei (exakt, besser als MB oder TOC)
   - genre: nur aus Tags verfügbar, nirgends im Dateinamen kodiert

5. album.json schreiben (fehlende Felder als null)

Daten-Rangfolge in gen_json

Feld Quelle 1 (bevorzugt) Quelle 2 (Fallback)
title Audio-Tag Dateiname (desanitized)
artist Audio-Tag Dateiname / Verzeichnis
album Audio-Tag Verzeichnisname
year Audio-Tag Verzeichnisname _(YYYY)
genre Audio-Tag — (bleibt null)
duration_ms Audiodatei (mutagen)
disc_number CDn-Verzeichnis
track_number Dateiname-Präfix Audio-Tag

Was gen_json nicht rekonstruieren kann

  • disc.disc_id (physischer TOC-Fingerprint — nicht in Dateien)
  • disc.name (z.B. "Live in Berlin" — nicht im Dateinamen kodiert)
  • genre wenn Audio-Tags fehlen

Klassik: Komponist vs. Interpret

Beide Felder werden in den Audio-Tags gespeichert (COMPOSER-Tag bei FLAC/MP3). gen_json liest COMPOSER aus den Tags und kann Komponist von Interpret trennen — ohne spezielle Namenskonvention im Dateinamen.

Der Dateinamen-Titel enthält bei Klassik typischerweise Werk + Satz:

01_-_Symphony_No_5_Op_67_I_Allegro_con_brio.flac

Komponist steht im Verzeichnisnamen (Bach_-_...) und im COMPOSER-Tag.