From c79181275551e7ba0952cec05e869ed07e1c0c2d Mon Sep 17 00:00:00 2001 From: dschlueter Date: Fri, 20 Feb 2026 10:31:27 +0100 Subject: [PATCH 1/3] 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 From 6a7602387a87250090247615bebd8155fb281c2d Mon Sep 17 00:00:00 2001 From: dschlueter Date: Fri, 20 Feb 2026 10:57:04 +0100 Subject: [PATCH 2/3] docs: add parallel metadata/ripping workflow plan (Phase A/B/C) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Describes the restructured workflow where metadata gathering (TOC, CDDB, MB, Vision-LLM) happens before ripping starts, so the user can review and edit album.json before committing to the long rip — not after. Co-Authored-By: Claude Sonnet 4.6 --- docs/refactoring_plan.md | 131 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 131 insertions(+) diff --git a/docs/refactoring_plan.md b/docs/refactoring_plan.md index 47e5962..ec47a7f 100644 --- a/docs/refactoring_plan.md +++ b/docs/refactoring_plan.md @@ -204,3 +204,134 @@ Testvorgaben: - 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. From 5de6caba3a220fad5b0019385bb13475e4a25cd1 Mon Sep 17 00:00:00 2001 From: dschlueter Date: Fri, 20 Feb 2026 11:29:57 +0100 Subject: [PATCH 3/3] 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 --- docs/refactoring_plan.md | 186 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 186 insertions(+) diff --git a/docs/refactoring_plan.md b/docs/refactoring_plan.md index ec47a7f..ff29974 100644 --- a/docs/refactoring_plan.md +++ b/docs/refactoring_plan.md @@ -335,3 +335,189 @@ class DiscMetadata: 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 +``` +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): + +```python +for abcde_dir in cd_dir.glob("abcde.*"): + shutil.rmtree(abcde_dir) +``` + +Alternativ als expliziter Befehl für manuelles Aufräumen: +``` +musiksammlung cleanup +``` + +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 +``` + +### 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.