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>
7.7 KiB
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_trackspro 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_albumserialisieren → 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 1–6 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) / 75Sekunden - 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