diff --git a/docs/refactoring_plan.md b/docs/refactoring_plan.md index ff29974..19f958d 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-20 — Entwurf zur Diskussion* +*Stand: 2026-02-19 — Entwurf zur Diskussion* ## Aktueller Zustand @@ -75,449 +75,3 @@ 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. - ---- - -## 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.