From c79181275551e7ba0952cec05e869ed07e1c0c2d Mon Sep 17 00:00:00 2001 From: dschlueter Date: Fri, 20 Feb 2026 10:31:27 +0100 Subject: [PATCH] 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 --- docs/refactoring_plan.md | 131 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 130 insertions(+), 1 deletion(-) diff --git a/docs/refactoring_plan.md b/docs/refactoring_plan.md index 19f958d..47e5962 100644 --- a/docs/refactoring_plan.md +++ b/docs/refactoring_plan.md @@ -1,6 +1,6 @@ # Refactoring-Plan: Workflow-Phasen in ripper.py -*Stand: 2026-02-19 — Entwurf zur Diskussion* +*Stand: 2026-02-20 — Entwurf zur Diskussion* ## Aktueller Zustand @@ -75,3 +75,132 @@ 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