# 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 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: ```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) 20–60 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, ~1–3 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 (~20–60 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.