Compare commits

..

No commits in common. "3e073250ca1ec908f897c0ab5cea6a9a33198159" and "036678cd0787d83148c8f64d917271b91bc7ff3f" have entirely different histories.

19 changed files with 0 additions and 1724 deletions

41
.gitignore vendored
View file

@ -1,41 +0,0 @@
# Bytecode / Cache
__pycache__/
*.py[cod]
*$py.class
# Distribution / Packaging
build/
dist/
.eggs/
*.egg-info/
*.egg
idea/
# Virtuelle Umgebungen
.venv/
venv/
env/
ENV/
.env
# Tests / Coverage
.coverage
.tox/
pytest_cache/
htmlcov/
# Logs / temporäre Files
*.log
*.tmp
# Editor / IDE
.vscode/
.idea/
*.swp
*.swo
# MyPy / static analysis
.mypy_cache/
.pytype/
.pyre/

View file

@ -1,38 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
**Musiksammlung** is a Python CLI tool that automates digitizing physical CD collections for use with Jellyfin. It orchestrates: CD ripping (via `abcde`), OCR of cover/back images (via Tesseract), LLM-based tracklist extraction, file renaming/tagging, and M3U playlist generation.
## Build & Development Commands
```bash
pip install -e ".[dev]" # Install in editable mode with dev deps
pytest tests/ -v # Run all tests
pytest tests/test_models.py -v # Run a single test module
ruff check src/ tests/ # Lint
musiksammlung --help # CLI entry point
```
## Architecture
The pipeline flows: **OCR → LLM → Organize → Tag → Playlist**
- `models.py` — Pydantic models (`Album`, `Disc`, `Track`) shared across all modules; the LLM JSON output validates directly into `Album`
- `cli.py` — Typer CLI with three commands: `scan` (OCR+LLM→JSON), `apply` (JSON→files), `process` (full pipeline)
- `ocr.py` — Tesseract wrapper with Pillow-based image preprocessing
- `llm_parser.py` — Sends OCR text to LLM (Ollama or OpenAI-compatible), enforces JSON output, retries on parse failure
- `organizer.py` — Builds source→target file mapping, handles single-disc and multi-disc layouts
- `tagger.py` — Sets audio tags via mutagen (format-agnostic), optional cover embedding for FLAC/MP3
- `playlist.py` — Generates M3U playlists with relative paths
- `ripper.py` — Drives `abcde` via subprocess for CD ripping
- `cover.py` — Resizes/converts cover images to JPEG for Jellyfin
## Conventions
- Python 3.11+, German variable names and comments are acceptable
- Pydantic for data models, Typer for CLI, mutagen for audio tagging
- External tools required at runtime: `tesseract`, `abcde`
- The two-step workflow (`scan` → review JSON → `apply`) is the recommended default over the one-shot `process` command

View file

@ -1,656 +0,0 @@
<img src="https://r2cdn.perplexity.ai/pplx-full-logo-primary-dark%402x.png" style="height:64px;margin-right:32px"/>
```
# Ein neues Programmier-Projekt: Ich möchte meine Musik-CD-Sammlung digitalisieren und sie für Jellyfin aufbereiten. Mit einem entsprechendem Programm kann ich die Audio-Tracks auslesen und in einem Verzeichnis speichern. Die Dateien heißen dann "Track_01.<format>", Track_02.<format>, usw. , wobe <format> für das Dateiformat und die dazu gehörende Endung steht ("wav", "mp3", "ogg", "flac", "opus", ...) steht. Diese Dateinamen sagen wenig aus. Deshalb möchte ich statt "Track" die Titel der Songs als Namen verwenden. Die zweistellige Nummer (mit eventuell führender 0) soll ganz vorn stehen. Aber ich will die Titel nicht von Hand eingeben. Diese Aufgabe sollen KI-Tools übernehmen. Ich will ein Foto vom Inhaltsverzeichnis oder der Cover-Rückseite machen und es mit OCR oder KI so auswerten lassen, dass am Ende ein Text mit CD-Namen und allen Titeln in der korrekten Reihenfolge herauskommt. Wenn das Objekt aus mehreren CDs besteht, soll das auch in der Verzeichnisstruktur abgebildet werden. Das Foto von der Titelseite soll als digitales Cover benutzt werden. Auch die Rückseite wird als Foto gespeichert. Bei diesem Workflow soll möglichst viel automatisch erfolgen. Input des Projektes ist ein Verzeichnis (oder mit mehreren Unterverzeichnissen für die einzelnen CDs) mit jeweils n Soundtracks, den Fotos vom Cover, der Rückseite und dem möglicherweise separat fotografierten Inhaltsverzeichnis. Output soll dieselbe Verzeichnisstruktur mit Namen für die CD , die Unterverzeichnisse ("CD1","CD2", ...) und für die Titel (mit entsprechenden Endungen) sein. Außerdem soll es noch einen Jellyfin-kompatible Playlist für die CDs, bzw. Alben geben. Denke gründlich nach und mach mir dann Vorschläge, wie ich das realisieren könnte.
```
Du kannst das ziemlich elegant automatisieren, wenn du drei Bausteine kombinierst: OCR/LLM für die Trackliste, ein kleines Rename-/Tagging-Skript und eine Playlist-Generierung.[^1_1][^1_2][^1_3]
## Zielstruktur und Jellyfin-Besonderheiten
Für Jellyfin ist bei Musik vor allem die **eingebettete** Metadatenstruktur wichtig, Dateinamen sind zweitrangig, solange sie sauber sind (keine Sonderzeichen wie `:` `?` `*` etc.).[^1_4][^1_1]
Empfehlenswerte Struktur:
- `Artist/Album (Year)/CD1/01 Titel.ext`
- `Artist/Album (Year)/CD2/01 Titel.ext` bei Mehrfach-CDs
- Cover-Datei im Album-Ordner, z.B. `cover.jpg` (Front) und `back.jpg` (Rückseite); Jellyfin kann Cover aus Tags und aus Bilddateien im Ordner ziehen.[^1_1]
## Pipeline grob skizziert
End-to-End-Workflow, wie du ihn skripten kannst:
1. Du ripst die CD(s) in ein temporäres Verzeichnis, z.B. `rip/album_raw/CD1/Track_01.flac` usw.
2. Du machst Fotos:
- Frontcover
- Rückseite (mit Trackliste)
- ggf. Innenseite/Buchlet, wenn die Rückseite unlesbar ist.
3. Skript-Pipeline:
- OCR auf die Tracklisten-Bilder → Rohtext.
- LLM-Prompting: Rohtext + Foto-Kontext → strukturiertes JSON mit `album_title`, `artist`, `disc_number`, `track_number`, `track_title`, `year` usw.
- Rename \& Tagging:
- Zuordnung `Track_01.*``01 Tracktitel.ext` nach JSON.
- Ordner umbenennen: `Album (Year)`, `CD1`, `CD2` usw.
- Cover-Bilder in `cover.jpg` / `back.jpg` umbenennen und in Album-Root legen.
- Audio-Tags setzen (Artist, Album, Track, TrackNr, DiscNr, Year, AlbumArtist).
- Playlist(s) erzeugen: Für jedes Album / jede CD eine `Albumname.m3u` im Album-Ordner mit relativen Pfaden.[^1_5]
## OCR + KI-Extraktion
Unter Linux bieten sich für OCR primär Tesseract und Calamari/Ocropus an.[^1_2][^1_3]
- OCR-Schritt:
- `tesseract back.jpg ocr_out --psm 6 -l deu+eng``ocr_out.txt`.[^1_2]
- LLM-Schritt:
- Prompt ungefähr: „Hier ist der OCR-Text der Rückseite einer Audio-CD. Extrahiere Artist, Albumtitel, Veröffentlichungsjahr (falls vorhanden) und für jede CD (CD1, CD2…) die Tracks in Reihenfolge. Gib reines JSON im Format … aus.“
- Dein Script ruft z.B. eine lokale LLM-API (oder OpenAI/Perplexity) an und bekommt parsebares JSON zurück.
Beispiel-Ziel-JSON (vereinfacht):
```json
{
"artist": "Example Artist",
"album": "Example Album",
"year": 1999,
"discs": [
{
"disc_number": 1,
"tracks": [
{ "track_number": 1, "title": "First Song" },
{ "track_number": 2, "title": "Second Song" }
]
}
]
}
```
## Benennung, Tagging und Mehrfach-CDs
### Dateinamen
Regeln, die du im Script umsetzt:
- Zuordnung nach Tracknummer:
- Sortiere die vorhandenen Dateien `Track_01.ext` numerisch.
- Mappe `Track_01.ext` → JSON-Eintrag `track_number == 1`.
- Neuer Name: `"{track_number:02d} {title}.{ext}"`.
- Für Mehrfach-CD:
- Input-Struktur z.B. `album_raw/CD1/*.flac`, `album_raw/CD2/*.flac`.
- Pro Unterordner eigenes `disc_number`-JSON verwenden und Ausgabe in `Album (Year)/CD1/…`, `Album (Year)/CD2/…`.
### Tags schreiben
Für Jellyfin relevant sind saubere Audio-Tags; es liest bei Musik primär diese und nicht Dateinamen.[^1_4][^1_1]
- Tools:
- `ffmpeg` / `metaflac` / `eyeD3` / `id3v2` / Python mit `mutagen`.
- Pro Datei:
- `artist` / `albumartist` (z.B. Artist oder „Various Artists“)
- `album`
- `title`
- `track` (Nummer, optional `/total`)
- `disc` (bei Mehrfach-CD)
- `date` oder `year`
- Cover als Embedded Art:
- `ffmpeg -i "01 First Song.flac" -i cover.jpg -map 0 -map 1 -c copy -metadata:s:v title="Cover" -metadata:s:v comment="Front cover" out.flac` (oder `metaflac --import-picture-from=cover.jpg`).[^1_6]
- Zusätzlich `cover.jpg` im Ordner lassen, falls du nicht überall einbetten willst.[^1_1]
## Playlists für Jellyfin
Jellyfin kann M3U-Playlists importieren; du kannst sie direkt im Album-Ordner ablegen.[^1_5]
- Format: simple `m3u`:
```text
#EXTM3U
#EXTINF:0,First Song
CD1/01 First Song.flac
#EXTINF:0,Second Song
CD1/02 Second Song.flac
```
- Für Mehrfach-CD:
- Entweder eine Playlist pro CD (`Album (CD1).m3u`, `Album (CD2).m3u`)
- Oder eine gemeinsame `Album.m3u` mit Tracks aus CD1, dann CD2 in Reihenfolge.
- Wichtig: Relative Pfade zum Playlist-Standort benutzen, dann bleiben sie portabel, auch wenn du den gesamten Musikordner verschiebst.[^1_5]
## Konkrete Projekt-Architektur
Aufbau als Python-CLI-Tool bietet sich an, mit Subcommands:
- `rip-organizer ocr album_raw/`
Läuft Tesseract über definierte Bilddateien (z.B. `cover_back.jpg`, `inlay*.jpg`) und speichert `ocr.txt`.
- `rip-organizer parse ocr.txt > album.json`
Ruft LLM-API, bekommt strukturiertes JSON.
- `rip-organizer apply album_raw/ album.json output_root/`
- Legt `Artist/Album (Year)/CDx/` an
- Verschiebt/benennt Tracks
- Schreibt Tags
- Kopiert `cover_front.jpg``cover.jpg`, `cover_back.jpg``back.jpg`
- Baut `.m3u`-Playlists.
Für deinen Use-Case könntest du auch alles in einem Durchlauf machen:
```bash
rip-organizer process \
--input album_raw/ \
--cover-front front.jpg \
--cover-back back.jpg \
--output /srv/media/Musik/
```
### Ergänzung: MusicBrainz-Picard als Fallback
Falls OCR/LLM mal scheitert oder du keine Fotos machen willst, kannst du viele CDs auch einfach über MusicBrainz identifizieren lassen; Picard nutzt AcoustID-Fingerprints und kann Cover laden.[^1_7][^1_6]
- Idee: Dein Tool versucht zuerst, anhand der gerippten Dateien eine MusicBrainz-Match zu finden (über Picard-CLI oder eigene API-Nutzung).
- Falls eindeutig → nimm diese Metadaten.
- Falls nicht → fallback auf dein OCR/LLM-Verfahren.
So minimierst du manuelle Nacharbeit und bekommst häufig sogar bessere/standardisierte Tags und Cover.[^1_7][^1_6]
***
Wenn du möchtest, kann ich dir im nächsten Schritt ein konkretes Python-Skelett mit `mutagen` (Tagging), Tesseract-Aufruf und einer einfachen JSON-Struktur skizzieren, an dem du weiterbauen kannst.
<span style="display:none">[^1_10][^1_11][^1_12][^1_13][^1_14][^1_15][^1_8][^1_9]</span>
<div align="center">⁂</div>
[^1_1]: https://jellyfin.org/docs/general/server/media/music/
[^1_2]: https://linuxhaxor.net/code/ocr-apps-linux.html
[^1_3]: https://www.baeldung.com/linux/ocr-tools
[^1_4]: https://www.youtube.com/watch?v=mZKf5s86EXQ
[^1_5]: https://soundiiz.com/tutorial/import-m3u-to-jellyfin
[^1_6]: https://community.metabrainz.org/t/recommended-workflow-of-ripping-a-cd-and-then-encoding-and-tagging-the-files/557824
[^1_7]: https://www.youtube.com/watch?v=Otyqv0Gy7qA
[^1_8]: https://jellyfin.org/docs/general/server/media/movies/
[^1_9]: https://www.reddit.com/r/jellyfin/comments/z5yfan/my_tutorial_on_manually_organising_music_video/
[^1_10]: https://jellyfin.org/docs/general/server/media/shows/
[^1_11]: https://www.filebot.net/forums/viewtopic.php?t=14860
[^1_12]: https://github.com/shinseitom/JellyfinPlaylistToM3U
[^1_13]: https://github.com/dynobo/normcap
[^1_14]: https://www.reddit.com/r/jellyfin/comments/12j0xde/help_with_movie_naming_format/
[^1_15]: https://musconv.com/jellyfin-to-m3u/
---
# Vergiss MusicBrainz. Die CDs sind so alt, da findet man nichts in MusicBrains. Wir müssen uns auf OCR und LLMs verlassen.
Dann würde ich den ganzen Workflow konsequent um OCR + LLM herum designen und MusicBrainz komplett rauslassen.[^2_1][^2_2]
## 1. Gesamtarchitektur
Du baust ein Python-CLI-Tool, das auf einem Ordner arbeitet:
- Input pro Album:
- `raw/` (oder `CD1/`, `CD2/` …) mit `Track_01.flac`, `Track_02.flac`, …
- `cover_front.jpg`
- `cover_back.jpg` (mit Trackliste) und ggf. `booklet_*.jpg`
- Output:
- `/Artist/Album (Year)/CD1/01 Titel.ext`
- `/Artist/Album (Year)/CD2/01 Titel.ext`
- `cover.jpg`, `back.jpg` im Album-Root
- `Album.m3u` bzw. `Album (CD1).m3u` etc.
- Alle Tags sauber gesetzt (Artist, Album, TrackNr, DiscNr, Year), damit Jellyfin alles über Tags bekommt.[^2_2][^2_1]
CLIBeispiel:
```bash
cd-organizer process \
--input /pfad/zum/rip \
--output /srv/media/Musik \
--front cover_front.jpg \
--back cover_back.jpg
```
## 2. OCR-Schicht
Du verwendest Tesseract direkt von der CLI bzw. via `subprocess`.[^2_3][^2_4]
- Für jede relevante Bilddatei:
```bash
tesseract cover_back.jpg ocr_back -l deu+eng --dpi 300 --psm 6
```
- Resultate zusammenführen:
- `ocr_back.txt`, `ocr_booklet1.txt`, … → alles in einen String.
- Optional Pre-Cleaning (z.B. doppelte Leerzeichen, Zeilen mit nur Trackzeiten/Bitraten filtern).
Für bessere Erkennung:
- Auflösung: möglichst 300dpi oder höher.
- Kontrast/Schwarz-Weiß per `convert` (ImageMagick) verbessern, falls nötig.
## 3. LLM-Parsing in strukturierte Daten
Kern: Du erzeugst aus dem OCR-Text ein strikt parsebares JSON.
### Prompt-Strategie
System/Instruktionsprompt (vereinfacht):
- „Du bist ein Parser für CD-Rückseiten und Tracklisten.“
- „Analysiere den OCR-Text, ignoriere Werbung/Kleingedrucktes.“
- „Gib ausschließlich valid JSON zurück, kein anderes Format.“
- „Struktur: { "artist": ..., "album": ..., "year": ..., "discs": [...]}“
User-Content:
- Voller OCR-Text
- Hinweis auf Mehrfach-CDs:
- „Wenn es Hinweise wie CD1, CD2, Disc 1, Disc 2, Seite A/B gibt, ordne Tracks entsprechend `disc_number` zu.“
- Anweisung zum Umgang mit Unklarheiten:
- „Wenn du dir bei einem Feld unsicher bist, lass es weg oder setze `null`, errate nichts.“
Ziel-JSON:
```json
{
"artist": "Example Artist",
"album": "Example Album",
"year": 1987,
"discs": [
{
"disc_number": 1,
"name": "CD1",
"tracks": [
{ "track_number": 1, "title": "Erster Song" },
{ "track_number": 2, "title": "Zweiter Song" }
]
},
{
"disc_number": 2,
"name": "CD2",
"tracks": [
{ "track_number": 1, "title": "Dritter Song" }
]
}
]
}
```
Das kannst du lokal (z.B. llamafile, vLLM, ollama) oder gegen einen externen API-Endpunkt machen entscheidend ist nur, dass du 100% JSON erzwingst (ggf. nachträglich mit `json.loads()` + Fehlerbehandlung und Retry).
## 4. Mapping auf die Audiodateien
Ausgangspunkt: Du hast pro Disc einen Ordner mit Dateien `Track_01.ext``Track_nn.ext`.
Schritte:
1. Alle Dateien der Disc sortieren:
- Numerisch nach der Zahl in `Track_XX` (RegEx `Track_(\d+)`).
2. Sicherstellen, dass Anzahl Dateien = Anzahl JSON-Tracks:
- Wenn nicht: Warnung ausgeben, entweder manuell fixen oder einfache Heuristik (z.B. letzte Bonus-Tracks ignorieren).
3. Map:
- ites File ↔ Track mit `track_number == i` im JSON.
4. Neuer Name:
- `"{track_number:02d} {title}.{ext}"`
→ z.B. `01 In the Beginning.flac`.
## 5. Ordner- und Dateistruktur bauen
Unter Berücksichtigung der Jellyfin-Empfehlungen: „ein Album pro Ordner, Dateinamen egal, Tags entscheidend“.[^2_1][^2_2]
- Zielpfad:
```text
/root/Artist/Album (Year)/CD1/...
/root/Artist/Album (Year)/CD2/...
```
- `Artist` aus JSON, unsichere/fehlende Felder kannst du auf „Unknown Artist“ / „Unknown Album“ mappen.
- Year optional in Klammern anfügen.
Das Skript:
- Legt Artist-Ordner an, wenn nicht vorhanden.
- Legt Album-Ordner an.
- Für jede Disc:
- Unterordner `CD{disc_number}`.
- Verschiebt/benennt Dateien hinein.
- `cover_front.jpg``cover.jpg`, `cover_back.jpg``back.jpg` ins Album-Root.
## 6. Tags mit `mutagen` setzen
Mit `mutagen` kannst du viele Formate einheitlich bearbeiten.[^2_5][^2_6]
Pseudo-Python:
```python
from mutagen import File
def tag_file(path, artist, album, albumartist, title, track_no, total_tracks, disc_no, year):
audio = File(path, easy=True)
audio["artist"] = artist
audio["album"] = album
audio["albumartist"] = albumartist or artist
audio["title"] = title
audio["tracknumber"] = f"{track_no}/{total_tracks}"
if disc_no is not None:
audio["discnumber"] = str(disc_no)
if year:
audio["date"] = str(year)
audio.save()
```
Format-Spezifika (kurz):
- FLAC/OGG: Vorbis Comments (Mutagen setzt das automatisch).[^2_5]
- MP3: ID3v2 (`EasyID3`), ggf. explizite Klassen verwenden, falls du Cover einbetten willst.
- WAV: Tags sind tricky; wenn möglich lieber FLAC/OGG/MP3 verwenden.
Optional: Cover einbetten (nur nötig, wenn du zusätzlich zu `cover.jpg` im Ordner embedded Artwork willst):
- Für MP3 mit ID3 APIC; FLAC mit PICTURE-Block (`mutagen.flac.FLAC.add_picture()`).
## 7. Playlists generieren (M3U)
Jellyfin versteht normale M3U; relative Pfade sind am portabelsten.[^2_7][^2_8]
- Pro Album:
- Entweder eine Playlist pro CD (z.B. `Album (CD1).m3u`) oder eine Gesamtplaylist `Album.m3u`, die alle Discs in Reihenfolge referenziert.
- Inhalt (Beispiel Gesamtalbum):
```text
#EXTM3U
#EXTINF:0,Erster Song
CD1/01 Erster Song.flac
#EXTINF:0,Zweiter Song
CD1/02 Zweiter Song.flac
#EXTINF:0,Dritter Song
CD2/01 Dritter Song.flac
```
Die Pfade sind relativ zum Album-Ordner, in dem die Playlist liegt.[^2_7]
Du kannst Trackdauer aus den Dateien auslesen, ist aber für Jellyfin nicht zwingend nötig.
## 8. Umgang mit Mehrfach-CDs im LLM
Damit das bei alten/kruden Layouts robust wird, würde ich im Prompt explizit:
- nach Mustern wie „CD 1“, „CD 2“, „CDI“, „CDII“, „Disc One“, „Disc Two“, „Seite 1/2“ suchen lassen,
- anweisen, dass bei fehlenden expliziten Disc-Hinweisen alles als `disc_number = 1` zu behandeln ist,
- track-spezifische Zusätze („live“, „bonus track“, „remastered“) in den Titel integrieren.
Beispiel-Ausschnitt im Prompt:
> „Wenn der OCR-Text keine Disc-Angabe hat, gehe von einer einzigen CD mit `disc_number = 1` aus. Wenn du Formulierungen wie „CD 1“, „CD 2“, „Disc One“, „Disc Two“ findest, ordne die direkt darunter stehenden Tracks dieser Disc zu.“
## 9. Fehlertoleranz und manuelle Korrekturen
Damit du nicht in Sackgassen läufst:
- Validierungsschritt:
- JSON vs. Dateiliste: Anzahl Tracks, Disc-Anzahl.
- Bei Diskrepanzen optional eine minimale TUI fragen: „Disc 1: 9 Tracks im JSON, 10 Dateien gefunden letzte Datei ignorieren? (y/n)“.
- Log-Datei pro Album (`process.log`) mit:
- OCR-Dateiname
- verwendetes JSON
- Mapping File ↔ Tracktitel
- Fehler/Warnungen
So kannst du im Zweifel einzelne Alben manuell nachziehen, ohne den Rest zu verlieren.
***
Wenn du möchtest, kann ich dir im nächsten Schritt ein konkretes Python-Skelett für `cd-organizer process` mit grober Modulstruktur (`ocr.py`, `llm_client.py`, `model.py` für das JSON, `tagging.py`, `playlist.py`, `cli.py`) skizzieren so, dass du es direkt in ein Repo kippen und ausbauen kannst.
<span style="display:none">[^2_10][^2_11][^2_12][^2_13][^2_14][^2_15][^2_16][^2_9]</span>
<div align="center">⁂</div>
[^2_1]: https://jellyfin.org/docs/general/server/media/music/
[^2_2]: https://diymediaserver.com/post/how-proper-organization-helps-jellyfin-automatically-fetch-metadata-and-display-content-correctly/
[^2_3]: https://github.com/tesseract-ocr/tesseract/wiki/Command-Line-Usage/7fc3dc8c3160f5b79a7ed7378f34ee5786729860
[^2_4]: https://www.howtogeek.com/682389/how-to-do-ocr-from-the-linux-command-line-using-tesseract/
[^2_5]: https://products.documentprocessing.com/metadata/python/mutagen/
[^2_6]: https://stackoverflow.com/questions/50747484/removing-single-tag-from-audio-files-mp3-and-flac-python-mutagen
[^2_7]: https://github.com/MusicPlayerDaemon/MPD/issues/200
[^2_8]: https://soundiiz.com/tutorial/import-m3u-to-jellyfin
[^2_9]: https://www.reddit.com/r/jellyfin/comments/ikp2wc/help_me_to_organize_my_music_collection_for/
[^2_10]: https://www.reddit.com/r/jellyfin/comments/l8vhzv/using_playlistbased_music_folder_structure_and/
[^2_11]: https://www.reddit.com/r/jellyfin/comments/mtfg1p/jellyfin_music_metadatafolder_structure/
[^2_12]: https://github.com/mystoragebox/Jellyfin-Music-Video-Tutorial
[^2_13]: https://github.com/jellyfin/jellyfin/issues/2570
[^2_14]: https://github.com/ImKyleDouglas/jellyfin_music
[^2_15]: https://www.reddit.com/r/jellyfin/comments/omdzei/support_for_id3v24_tags_multiple_artistsgenres/
[^2_16]: https://forum.minimserver.com/showthread.php?pid=23291
---
# Können wir das Auto-Ripping nicht auch selbst mit einem Progamm erledigen? Das gäbe uns viel größere Felexibilität in Bezug auf unterschiedliche Konstellationen (Nur 1 CD, Album mit 2 CDS bis Album mit einigen CDs) . Dann erzeugt uns das Programm gleich die Verzeichnisstruktur.
Ja, das Ripping kannst du sehr gut in deinen eigenen Workflow integrieren ich würde aber nicht „alles selbst“ implementieren, sondern ein bestehendes CLI-Rip-Tool wie abcde oder cdparanoia/icedax aus Python heraus steuern und den Rest (Struktur + Tags) in deinem Programm machen.[^3_1][^3_2][^3_3]
## 1. Grundidee: Dein Programm steuert den ganzen Zyklus
Ablauf, wenn eine CD im Laufwerk liegt:
1. Dein Programm erkennt (`/dev/cdrom` o.ä.), dass eine CD eingelegt ist.
2. Es fragt dich minimal interaktiv:
- „Ist das ein Single-Album, 2-CD-Album, Box mit n CDs?“
- oder du gibst eine Box-ID an und legst nacheinander die CDs ein.
3. Für jede eingelegte CD:
- Ripping via externem Tool (abcde/icedax/cdparanoia) in ein temporäres Verzeichnis.
- Parallel/anschließend: Fotos verarbeiten (OCR+LLM) für das ganze Album/Box-Set.
4. Wenn alle CDs gerippt sind, baut dein Programm aus dem OCR/LLM-JSON die Ziel-Verzeichnisstruktur (Artist/Album/CD1…, CD2…) und benennt/vertaggt alles passend für Jellyfin.[^3_4][^3_5]
So hast du volle Kontrolle über Mehrfach-CD-Konstellationen, ohne ein eigenes CD-DA-Lesemodul schreiben zu müssen.
## 2. Ripping-Ebene: abcde als „Engine“ unter deiner Kontrolle
Für Linux ist `abcde` sehr robust, flexibel und skriptfreundlich.[^3_6][^3_2][^3_3][^3_1]
- Du nutzt es nur als „raw ripper“, Metadaten kommen aus deinem LLM, nicht aus CDDB:
- `abcde -n -o flac -x -N -p -d /dev/cdrom`
- `-n` = kein CDDB-Lookup (du willst deine eigenen Tags)[^3_3]
- `-o flac` (oder mp3/ogg/opus, was du willst)[^3_3]
- `-x` = CD auswerfen nach dem Ripping (Qual-of-Life)[^3_3]
- `-N` = non-interaktiv[^3_3]
- `-p` = führende Nullen bei Tracknummern[^3_3]
- In der `.abcde.conf` kannst du z.B. einstellen, dass:
- in ein von dir bestimmtes Temp-Verzeichnis gerippt wird,
- Dateinamen generisch bleiben (`Track_01.flac` etc.), weil du sie später selbst umbenennst.[^3_1][^3_3]
Dein Programm ruft `abcde` via `subprocess.run()` auf, wartet, bis der Prozess fertig ist, und weiß dann:
„CD X des aktuellen Albums wurde erfolgreich in `tmp/album_X/` gerippt“.
Alternativ: direkter Einsatz von `cdparanoia`/`icedax` + `flac`/`lame`, aber abcde kapselt das alles schon sehr gut.[^3_2][^3_7][^3_3]
## 3. Multi-CD-Logik in deinem Programm
Du definierst den Album-Kontext **nicht** über das Rip-Tool, sondern in deiner Anwendung:
- Du startest einen „Album-Job“:
- `album_id` (intern), optional manuell gesetzter Name.
- erwartete Anzahl CDs (kann auch „unbekannt“ sein, dann fragst du, wenn die Box fertig ist).
- Für jede CD:
- Dein Programm zeigt z.B. „Bitte CD 1 (von ?/N) einlegen und Enter drücken“.
- Startet `abcde` mit Zielordner `tmp/album_id/CD1`.
- Wenn fertig → `Disc 1` als „gerippt“ markieren.
- Nach CD1, CD2, …:
- Parallel dazu läuft dein OCR+LLM-Prozess auf den Cover/Back-Fotos und erzeugt das JSON mit `discs` und `track_number`/`title`.
- Dein Programm matcht dann `CD1``disc_number: 1`, `CD2``disc_number: 2` usw.
Damit kannst du alles abbilden: Single-CD, 2-CD-Album, 10-CD-Box, ohne dass das Rip-Tool davon etwas wissen muss.[^3_4]
## 4. Automatische Verzeichnisstruktur ab Ripping-Phase
Weil du beim Start eines Album-Jobs schon weißt, wohin es final gehen soll, kannst du die Struktur direkt ab dem ersten Rip anlegen:
- Ziel (Jellyfin-root):
`/srv/media/Musik/Artist/Album (Year)/CD1`, `/CD2`
- Dein Programm entscheidet:
- Ab wann es die finalen Ordner anlegt (z.B. nach erfolgreichem OCR/LLM-Parsing).
- Ob es zuerst in `tmp/` rippt und dann alles per Move/rename in den finalen Baum schiebt, oder ob du schon während des Rippens in die finalen `CDx`-Ordner schreibst und später nur noch umbenennst + Tags setzt.
Pragmatisch:
- Beim Start eines Album-Jobs:
- `tmp/album_id/CD1`, `tmp/album_id/CD2`
- Nach LLM-Parsing:
- `Artist`, `Album`, `Year` klar → finaler Pfad steht fest.
- Jetzt: `mkdir -p /srv/media/Musik/Artist/Album (Year)/CD1` usw.
- Dateien umbenennen und verschieben: `Track_01.flac``01 Songtitle.flac`.
## 5. Vorteil: Einheitliche Logik für alte/komplexe CDs
Mit eigenem Orchestrierungs-Programm und CLI-Ripper hast du:
- Gleichen Workflow für:
- Single-CD ohne Booklet (nur Rückseite)
- 2-CD-Album
- 8-CD-Komplettbox
- Kein Abhängigkeitschaos mit Online-Datenbanken (du wolltest ja MusicBrainz bewusst vermeiden).
- Möglichkeit, die Multi-CD-Logik **rein im JSON** abzubilden:
- `disc_number`
- `disc_name` (z.B. „CD 3 Live in Berlin“)
- Playlist-Generierung direkt nach dem Ripping und Tagging:
- Für jede CD eine eigene M3U
- optional Gesamt-Album-Playlist (alle CDs in Reihe).[^3_8][^3_4]
***
Wenn du willst, können wir als nächsten Schritt die Architektur deines Programms genauer entwerfen (Module/Klassen, CLI-Interface, State-File pro Album), z.B.:
- `ripper.py` (abcde-Steuerung),
- `ocr.py`,
- `llm_parser.py`,
- `organizer.py` (Struktur + Umbenennung),
- `tagger.py`,
- `playlist.py`,
- `cli.py` mit Subcommands wie `start-album`, `rip-disc`, `finalize-album`.
<span style="display:none">[^3_10][^3_11][^3_12][^3_13][^3_14][^3_15][^3_16][^3_9]</span>
<div align="center">⁂</div>
[^3_1]: https://wiki.ubuntuusers.de/CDs_rippen/
[^3_2]: https://linuxconfig.org/how-to-rip-cds-from-the-linux-command-line
[^3_3]: https://notes.enovision.net/linux/rip-cds-with-abcde
[^3_4]: https://www.reddit.com/r/jellyfin/comments/d5lpan/multidisc_music_albums/
[^3_5]: https://jellyfin.org/docs/general/server/media/music/
[^3_6]: https://wiki.ubuntuusers.de/abcde/
[^3_7]: https://www.reddit.com/r/audiophile/comments/p3p16s/ripping_cd_linux_terminal/
[^3_8]: https://github.com/MusicPlayerDaemon/MPD/issues/200
[^3_9]: https://www.reddit.com/r/linuxquestions/comments/11lh4zv/how_to_rip_audio_cd_entire_disc_to_a_single_track/
[^3_10]: https://www.tomshardware.com/software/linux/how-to-rip-your-audio-cds-to-mp3-flac-from-the-linux-terminal-with-abcde
[^3_11]: http://www.screamingstonedesign.com/blog/a-cd-ripper-in-python
[^3_12]: https://stackoverflow.com/questions/30472518/python-ripping-cd-files-to-wav
[^3_13]: https://www.reddit.com/r/jellyfin/comments/11orkog/jellyfin_music_album_with_multiple_cds/
[^3_14]: https://www.reddit.com/r/linux/comments/6yoheo/any_music_fans_who_rip_cds_from_the_linux_command/
[^3_15]: https://hydrogenaudio.org/index.php/topic,105061.0.html
[^3_16]: https://github.com/jack-cli-cd-ripper/jack
---
# Bevor wir anfangen: Gibt es so ein Programm bereits ?
Nach allem, was ich kenne: Es gibt viele Programme, die einzelne Teilaspekte abdecken, aber nichts, was genau deinen gewünschten Gesamtworkflow „Rippen + OCR der Cover + LLMParsing + automatische Jellyfin-Verzeichnisstruktur + Playlists“ in einem Guss erledigt.
Typische existierende Lösungen sehen eher so aus:
- Klassische CDRipper (EAC, dBpoweramp, abcde, fre:ac, Audiograbber, MediaMonkey, etc.) können:
- CDs zuverlässig rippen.
- Metadaten aus Online-Datenbanken holen.
- Dateien nach bestimmten Mustern benennen und Ordner anlegen.
- Meist einfache Cover-Bilder einbinden oder laden.
- Media-Manager (MediaMonkey, JRiver, MusicBee, beets, …) können:
- Bibliothek organisieren, umbennen, taggen.
- Cover laden (online), Playlists erstellen.
- Einige Tools können OCR oder mit Online-Diensten Metadaten finden, aber:
- Sie arbeiten fast immer mit großen Musikdatenbanken (MusicBrainz, Discogs, Freedb, etc.).
- Sie nutzen **nicht** gezielt Fotos der CD-Rückseite als primäre Quelle.
- LLM-Integration für das saubere Strukturieren der OCR-Daten ist bisher sehr exotisch.
Was du haben willst, ist eine Mischung aus:
1. Headless CD-Ripper-Engine (abcde/whipper o.Ä.) als Backend.
2. Eigene OCR-Pipeline (Tesseract).
3. LLM-Schicht, die aus dem OCR-Text ein robustes JSON-Modell für Album/Discs/Tracks baut.
4. Organizer, der:
- die Dateien passend benennt,
- eine Jellyfin-konforme Album- und Mehrfach-CD-Struktur erzeugt,
- Tags setzt,
- Playlists anlegt.
Genau diese Kombination in einem integrierten Tool ist nach meinem Wissensstand noch nicht als fertiges, allgemein verfügbares Programm umgesetzt worden das ist genau der interessante „Sweet Spot“ für dein Projekt.
<span style="display:none">[^4_1][^4_10][^4_2][^4_3][^4_4][^4_5][^4_6][^4_7][^4_8][^4_9]</span>
<div align="center">⁂</div>
[^4_1]: https://sourceforge.net/software/compare/Express-Rip-vs-Musicnizer/
[^4_2]: https://apps.microsoft.com/detail/9nsnq0cpd06g?hl=en-GB
[^4_3]: https://www.mediamonkey.com
[^4_4]: https://www.reddit.com/r/audiophile/comments/1gbhpyi/looking_for_simple_reliable_cd_ripping_software/
[^4_5]: https://www.freac.org
[^4_6]: https://www.reddit.com/r/musichoarder/comments/1cyq0h2/advice_on_starting_to_organize_and_digitize/
[^4_7]: https://www.audiograbber.de
[^4_8]: https://www.reddit.com/r/Cd_collectors/comments/1dbd50c/best_app_for_ripping_organization/
[^4_9]: https://www.reddit.com/r/audio/comments/yfyk4w/program_for_ripping_cds_with_correct_metadata/
[^4_10]: https://www.heise.de/download/products/windows/audio/cd-ripper?f80NGcM=1vtZ937

View file

@ -1,36 +0,0 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "musiksammlung"
version = "0.1.0"
description = "CLI-Tool zum Digitalisieren von CD-Sammlungen für Jellyfin"
requires-python = ">=3.11"
dependencies = [
"typer>=0.12",
"pydantic>=2.0",
"mutagen>=1.47",
"Pillow>=10.0",
"httpx>=0.27",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0",
"pytest-cov",
"ruff",
]
[project.scripts]
musiksammlung = "musiksammlung.cli:app"
[tool.ruff]
line-length = 100
target-version = "py311"
[tool.ruff.lint]
select = ["E", "F", "I", "W"]
[tool.pytest.ini_options]
testpaths = ["tests"]

View file

@ -1,3 +0,0 @@
"""Musiksammlung CLI-Tool zum Digitalisieren von CD-Sammlungen für Jellyfin."""
__version__ = "0.1.0"

View file

@ -1,175 +0,0 @@
"""CLI-Interface mit Typer."""
from __future__ import annotations
import json
import logging
from pathlib import Path
import typer
from musiksammlung.cover import copy_covers
from musiksammlung.llm_parser import parse_tracklist
from musiksammlung.models import Album
from musiksammlung.ocr import ocr_images
from musiksammlung.organizer import apply_mapping, build_mapping
from musiksammlung.playlist import generate_playlist
from musiksammlung.tagger import tag_album
app = typer.Typer(
name="musiksammlung",
help="CLI-Tool zum Digitalisieren von CD-Sammlungen für Jellyfin.",
)
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
@app.command()
def scan(
images: list[Path] = typer.Argument(..., help="Bilder der CD-Rückseite/Booklet"),
output: Path = typer.Option("album.json", "--output", "-o", help="Ausgabe-JSON-Datei"),
languages: str = typer.Option("deu+eng", "--lang", "-l", help="OCR-Sprachen"),
backend: str = typer.Option("ollama", "--backend", "-b", help="LLM-Backend"),
model: str = typer.Option("llama3", "--model", "-m", help="LLM-Modell"),
base_url: str = typer.Option("http://localhost:11434", "--url", help="LLM-API-URL"),
) -> None:
"""OCR + LLM → Album-JSON erzeugen (zur Prüfung vor dem Anwenden)."""
# Bilder prüfen
for img in images:
if not img.exists():
typer.echo(f"Fehler: Bild nicht gefunden: {img}", err=True)
raise typer.Exit(1)
typer.echo("Starte OCR...")
ocr_text = ocr_images(images, languages)
typer.echo(f"OCR-Text ({len(ocr_text)} Zeichen) erkannt.")
typer.echo("Starte LLM-Parsing...")
album = parse_tracklist(ocr_text, backend=backend, model=model, base_url=base_url)
output.write_text(album.model_dump_json(indent=2), encoding="utf-8")
typer.echo(f"Album-JSON gespeichert: {output}")
typer.echo(f" Artist: {album.artist}")
typer.echo(f" Album: {album.album}")
typer.echo(f" Year: {album.year}")
for disc in album.discs:
typer.echo(f" Disc {disc.disc_number}: {len(disc.tracks)} Tracks")
@app.command()
def apply(
input_dir: Path = typer.Argument(..., help="Verzeichnis mit gerippten Audiodateien"),
album_json: Path = typer.Argument(..., help="Album-JSON aus 'scan'"),
output_dir: Path = typer.Argument(..., help="Jellyfin-Musikverzeichnis"),
front: Path | None = typer.Option(None, "--front", help="Front-Cover-Bild"),
back: Path | None = typer.Option(None, "--back", help="Rückseiten-Cover-Bild"),
dry_run: bool = typer.Option(False, "--dry-run", help="Nur anzeigen, nichts ändern"),
) -> None:
"""Album-JSON + Audiodateien → Jellyfin-Struktur aufbauen."""
# JSON laden und validieren
raw = json.loads(album_json.read_text(encoding="utf-8"))
album = Album.model_validate(raw)
# Mapping berechnen und anzeigen
mapping = build_mapping(album, input_dir, output_dir)
typer.echo(f"Mapping: {len(mapping)} Dateien")
for src, dst in mapping.items():
typer.echo(f" {src.name}{dst.relative_to(output_dir)}")
if dry_run:
typer.echo("[DRY-RUN] Keine Änderungen vorgenommen.")
return
# Dateien verschieben
apply_mapping(mapping)
# Album-Verzeichnis bestimmen
first_target = next(iter(mapping.values()))
if len(album.discs) > 1:
album_dir = first_target.parent.parent # CD1/ → Album/
else:
album_dir = first_target.parent
# Tags setzen
typer.echo("Setze Audio-Tags...")
tag_album(album, album_dir)
# Cover kopieren
copy_covers(front, back, album_dir)
# Playlist erzeugen
generate_playlist(album, album_dir)
typer.echo(f"Fertig! Album liegt in: {album_dir}")
@app.command()
def process(
input_dir: Path = typer.Argument(..., help="Verzeichnis mit Audiodateien und Bildern"),
output_dir: Path = typer.Argument(..., help="Jellyfin-Musikverzeichnis"),
front: Path | None = typer.Option(None, "--front", help="Front-Cover-Bild"),
back: Path | None = typer.Option(None, "--back", help="Rückseiten-Bild (für OCR + Cover)"),
images: list[Path] | None = typer.Option(
None, "--image", "-i", help="Zusätzliche Bilder für OCR"
),
languages: str = typer.Option("deu+eng", "--lang", "-l"),
backend: str = typer.Option("ollama", "--backend", "-b"),
model: str = typer.Option("llama3", "--model", "-m"),
base_url: str = typer.Option("http://localhost:11434", "--url"),
dry_run: bool = typer.Option(False, "--dry-run"),
) -> None:
"""Komplett-Pipeline: OCR → LLM → Organize → Tag → Playlist."""
# OCR-Bilder zusammenstellen
ocr_sources: list[Path] = []
if back and back.exists():
ocr_sources.append(back)
if images:
ocr_sources.extend(images)
if not ocr_sources:
typer.echo("Fehler: Mindestens ein Bild für OCR nötig (--back oder --image)", err=True)
raise typer.Exit(1)
# 1. OCR
typer.echo("Schritt 1/5: OCR...")
ocr_text = ocr_images(ocr_sources, languages)
# 2. LLM-Parsing
typer.echo("Schritt 2/5: LLM-Parsing...")
album = parse_tracklist(ocr_text, backend=backend, model=model, base_url=base_url)
typer.echo(f"{album.artist} {album.album} ({album.year})")
# JSON zur Kontrolle speichern
json_path = input_dir / "album.json"
json_path.write_text(album.model_dump_json(indent=2), encoding="utf-8")
# 3. Dateien organisieren
typer.echo("Schritt 3/5: Dateien organisieren...")
mapping = build_mapping(album, input_dir, output_dir)
apply_mapping(mapping, dry_run=dry_run)
if dry_run:
typer.echo("[DRY-RUN] Abbruch nach Mapping-Anzeige.")
return
# Album-Verzeichnis bestimmen
first_target = next(iter(mapping.values()))
album_dir = first_target.parent.parent if len(album.discs) > 1 else first_target.parent
# 4. Tags + Cover
typer.echo("Schritt 4/5: Tags & Cover...")
tag_album(album, album_dir)
copy_covers(front, back, album_dir)
# 5. Playlist
typer.echo("Schritt 5/5: Playlist...")
generate_playlist(album, album_dir)
typer.echo(f"Fertig! Album: {album_dir}")
if __name__ == "__main__":
app()

View file

@ -1,28 +0,0 @@
"""Konfiguration und Defaults."""
from __future__ import annotations
from pathlib import Path
from pydantic import BaseModel
# Unterstützte Audio-Formate
AUDIO_EXTENSIONS = {".flac", ".mp3", ".ogg", ".opus", ".wav", ".m4a"}
# Standard-Bilddateien, die als Cover/Rückseite erkannt werden
DEFAULT_FRONT_PATTERNS = ["cover_front.*", "front.*", "cover.*"]
DEFAULT_BACK_PATTERNS = ["cover_back.*", "back.*", "inlay.*", "booklet.*"]
class AppConfig(BaseModel):
"""Globale Konfiguration für einen Durchlauf."""
input_dir: Path
output_dir: Path
audio_format: str = "flac"
cd_device: str = "/dev/cdrom"
ocr_languages: str = "deu+eng"
llm_backend: str = "ollama" # "ollama", "openai", "anthropic"
llm_model: str = "llama3"
llm_base_url: str = "http://localhost:11434"
dry_run: bool = False

View file

@ -1,51 +0,0 @@
"""Cover-Bilder verarbeiten und ins Album-Verzeichnis kopieren."""
from __future__ import annotations
import logging
from pathlib import Path
from PIL import Image
logger = logging.getLogger(__name__)
# Jellyfin erkennt diese Dateinamen automatisch
FRONT_COVER_NAME = "cover.jpg"
BACK_COVER_NAME = "back.jpg"
def prepare_cover(source: Path, target: Path, max_size: int = 1200) -> None:
"""Kopiert und optimiert ein Cover-Bild.
Konvertiert zu JPEG, begrenzt Größe auf max_size Pixel (längste Seite).
"""
img = Image.open(source)
# Auf max_size skalieren, Seitenverhältnis beibehalten
if max(img.size) > max_size:
img.thumbnail((max_size, max_size), Image.LANCZOS)
# In RGB konvertieren (JPEG unterstützt kein RGBA)
if img.mode in ("RGBA", "P"):
img = img.convert("RGB")
target.parent.mkdir(parents=True, exist_ok=True)
img.save(target, "JPEG", quality=90)
logger.info("Cover gespeichert: %s%s", source.name, target)
def copy_covers(
front_image: Path | None,
back_image: Path | None,
album_dir: Path,
) -> None:
"""Kopiert Front- und Rückseiten-Cover in das Album-Verzeichnis."""
if front_image and front_image.exists():
prepare_cover(front_image, album_dir / FRONT_COVER_NAME)
else:
logger.warning("Kein Front-Cover gefunden")
if back_image and back_image.exists():
prepare_cover(back_image, album_dir / BACK_COVER_NAME)
else:
logger.debug("Kein Back-Cover angegeben")

View file

@ -1,126 +0,0 @@
"""LLM-basiertes Parsing von OCR-Text zu strukturierten Album-Daten."""
from __future__ import annotations
import json
import logging
import httpx
from pydantic import ValidationError
from musiksammlung.models import Album
logger = logging.getLogger(__name__)
SYSTEM_PROMPT = """\
Du bist ein Parser für CD-Rückseiten und Tracklisten.
Analysiere den OCR-Text und extrahiere: Artist, Albumtitel, Jahr (falls vorhanden) \
und für jede CD die Tracks in korrekter Reihenfolge.
Ignoriere Werbung, Copyright-Hinweise und Kleingedrucktes.
Regeln:
- Wenn es Hinweise wie "CD 1", "CD 2", "Disc 1", "Disc 2" gibt, ordne die Tracks \
der entsprechenden disc_number zu.
- Ohne Disc-Angabe: alles als disc_number=1 behandeln.
- Zusätze wie "live", "bonus track", "remastered" gehören in den Tracktitel.
- Bei Unsicherheit: Feld weglassen oder null setzen, nichts erfinden.
Gib ausschließlich valides JSON zurück, kein anderer Text. Format:
{
"artist": "...",
"album": "...",
"year": 1987,
"discs": [
{
"disc_number": 1,
"name": null,
"tracks": [
{"track_number": 1, "title": "..."},
{"track_number": 2, "title": "..."}
]
}
]
}
"""
def _call_ollama(ocr_text: str, model: str, base_url: str) -> str:
"""Ruft Ollama-API auf und gibt die Antwort als String zurück."""
response = httpx.post(
f"{base_url}/api/generate",
json={
"model": model,
"system": SYSTEM_PROMPT,
"prompt": ocr_text,
"stream": False,
"format": "json",
},
timeout=120.0,
)
response.raise_for_status()
return response.json()["response"]
def _call_openai_compatible(
ocr_text: str, model: str, base_url: str, api_key: str | None = None
) -> str:
"""Ruft eine OpenAI-kompatible API auf (OpenAI, Anthropic via Proxy, etc.)."""
headers = {}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
response = httpx.post(
f"{base_url}/v1/chat/completions",
headers=headers,
json={
"model": model,
"messages": [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": ocr_text},
],
"response_format": {"type": "json_object"},
},
timeout=120.0,
)
response.raise_for_status()
return response.json()["choices"][0]["message"]["content"]
def parse_tracklist(
ocr_text: str,
backend: str = "ollama",
model: str = "llama3",
base_url: str = "http://localhost:11434",
api_key: str | None = None,
max_retries: int = 2,
) -> Album:
"""Parst OCR-Text via LLM zu einem Album-Modell.
Args:
ocr_text: Rohtext aus der OCR-Erkennung
backend: 'ollama' oder 'openai'
model: Modellname
base_url: API-Basis-URL
api_key: API-Key (nur für OpenAI-kompatible Backends)
max_retries: Anzahl Wiederholungsversuche bei ungültigem JSON
Returns:
Validiertes Album-Objekt
"""
for attempt in range(max_retries + 1):
try:
if backend == "ollama":
raw = _call_ollama(ocr_text, model, base_url)
else:
raw = _call_openai_compatible(ocr_text, model, base_url, api_key)
data = json.loads(raw)
album = Album.model_validate(data)
logger.info("LLM-Parsing erfolgreich: %s - %s", album.artist, album.album)
return album
except (json.JSONDecodeError, ValidationError) as e:
logger.warning("Versuch %d/%d fehlgeschlagen: %s", attempt + 1, max_retries + 1, e)
if attempt == max_retries:
msg = f"LLM lieferte nach {max_retries + 1} Versuchen kein valides JSON"
raise ValueError(msg) from e

View file

@ -1,38 +0,0 @@
"""Zentrale Datenmodelle für Album, Disc und Track."""
from __future__ import annotations
import re
from pydantic import BaseModel, field_validator
class Track(BaseModel):
track_number: int
title: str
class Disc(BaseModel):
disc_number: int
name: str | None = None # z.B. "Live in Berlin"
tracks: list[Track]
class Album(BaseModel):
artist: str
album: str
year: int | None = None
discs: list[Disc]
@field_validator("album", "artist")
@classmethod
def sanitize_name(cls, v: str) -> str:
"""Entfernt Zeichen, die in Dateinamen problematisch sind."""
return re.sub(r'[<>:"/\\|?*]', "_", v).strip()
@property
def folder_name(self) -> str:
"""Jellyfin-konformer Ordnername: 'Album (Year)' oder nur 'Album'."""
if self.year:
return f"{self.album} ({self.year})"
return self.album

View file

@ -1,70 +0,0 @@
"""OCR via Tesseract mit optionaler Bildvorverarbeitung."""
from __future__ import annotations
import logging
import subprocess
import tempfile
from pathlib import Path
from PIL import Image, ImageFilter, ImageOps
logger = logging.getLogger(__name__)
def preprocess_image(image_path: Path) -> Path:
"""Verbessert Kontrast und Schärfe für bessere OCR-Ergebnisse.
Returns:
Pfad zum vorverarbeiteten Bild (temporäre Datei).
"""
img = Image.open(image_path)
img = ImageOps.grayscale(img)
img = ImageOps.autocontrast(img, cutoff=2)
img = img.filter(ImageFilter.SHARPEN)
tmp = tempfile.NamedTemporaryFile(suffix=".png", delete=False)
img.save(tmp.name, dpi=(300, 300))
logger.debug("Vorverarbeitetes Bild: %s%s", image_path, tmp.name)
return Path(tmp.name)
def run_ocr(image_path: Path, languages: str = "deu+eng") -> str:
"""Führt Tesseract-OCR auf einem Bild aus.
Args:
image_path: Pfad zum Bild
languages: Tesseract-Sprachcodes, z.B. 'deu+eng'
Returns:
Erkannter Text als String.
"""
cmd = [
"tesseract",
str(image_path),
"stdout",
"-l", languages,
"--psm", "6", # einheitlicher Textblock
]
logger.info("OCR: %s", " ".join(cmd))
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
raise RuntimeError(f"Tesseract fehlgeschlagen: {result.stderr}")
return result.stdout.strip()
def ocr_images(image_paths: list[Path], languages: str = "deu+eng") -> str:
"""Führt OCR auf mehreren Bildern aus und fügt die Texte zusammen."""
texts = []
for path in image_paths:
preprocessed = preprocess_image(path)
try:
text = run_ocr(preprocessed, languages)
if text:
texts.append(text)
finally:
preprocessed.unlink(missing_ok=True)
return "\n\n".join(texts)

View file

@ -1,92 +0,0 @@
"""Verzeichnisstruktur anlegen und Audiodateien umbenennen."""
from __future__ import annotations
import logging
import re
import shutil
from pathlib import Path
from musiksammlung.config import AUDIO_EXTENSIONS
from musiksammlung.models import Album
logger = logging.getLogger(__name__)
def _sanitize_filename(name: str) -> str:
"""Entfernt problematische Zeichen aus Dateinamen."""
return re.sub(r'[<>:"/\\|?*]', "_", name).strip()
def discover_audio_files(directory: Path) -> list[Path]:
"""Findet und sortiert Audiodateien numerisch nach Track-Nummer."""
files = [f for f in directory.iterdir() if f.suffix.lower() in AUDIO_EXTENSIONS]
# Sortiere nach der Zahl im Dateinamen (z.B. Track_01 → 1)
def extract_number(p: Path) -> int:
match = re.search(r"(\d+)", p.stem)
return int(match.group(1)) if match else 0
return sorted(files, key=extract_number)
def build_mapping(
album: Album,
input_dir: Path,
output_root: Path,
) -> dict[Path, Path]:
"""Berechnet das Quell→Ziel-Mapping für alle Audiodateien.
Args:
album: Validiertes Album-Modell
input_dir: Verzeichnis mit den gerippten Dateien
output_root: Jellyfin-Musikverzeichnis
Returns:
Dict von Quellpfad Zielpfad
"""
artist_dir = _sanitize_filename(album.artist)
album_dir = output_root / artist_dir / _sanitize_filename(album.folder_name)
mapping: dict[Path, Path] = {}
multi_disc = len(album.discs) > 1
for disc in album.discs:
# Quellverzeichnis: bei Multi-CD z.B. input_dir/CD1, sonst input_dir direkt
if multi_disc:
source_dir = input_dir / f"CD{disc.disc_number}"
target_dir = album_dir / f"CD{disc.disc_number}"
else:
source_dir = input_dir
target_dir = album_dir
audio_files = discover_audio_files(source_dir)
if len(audio_files) != len(disc.tracks):
logger.warning(
"Disc %d: %d Dateien gefunden, aber %d Tracks im JSON",
disc.disc_number,
len(audio_files),
len(disc.tracks),
)
for audio_file, track in zip(audio_files, disc.tracks):
safe_title = _sanitize_filename(track.title)
new_name = f"{track.track_number:02d} {safe_title}{audio_file.suffix}"
mapping[audio_file] = target_dir / new_name
return mapping
def apply_mapping(mapping: dict[Path, Path], dry_run: bool = False) -> None:
"""Verschiebt/benennt Dateien gemäß dem Mapping um.
Args:
mapping: Quellpfad Zielpfad
dry_run: Wenn True, nur loggen ohne Dateien zu bewegen
"""
for source, target in mapping.items():
if dry_run:
logger.info("[DRY-RUN] %s%s", source, target)
continue
target.parent.mkdir(parents=True, exist_ok=True)
shutil.move(str(source), str(target))
logger.info("Verschoben: %s%s", source, target)

View file

@ -1,57 +0,0 @@
"""M3U-Playlist-Generierung für Jellyfin."""
from __future__ import annotations
import logging
from pathlib import Path
from musiksammlung.models import Album
from musiksammlung.organizer import _sanitize_filename
logger = logging.getLogger(__name__)
def generate_playlist(album: Album, album_dir: Path) -> Path:
"""Erzeugt eine M3U-Playlist für das gesamte Album.
Die Playlist liegt im Album-Root und referenziert alle Tracks
über relative Pfade (CD1/01 Titel.flac, CD2/01 Titel.flac, ...).
Returns:
Pfad zur erzeugten Playlist-Datei.
"""
playlist_name = _sanitize_filename(album.album) + ".m3u"
playlist_path = album_dir / playlist_name
multi_disc = len(album.discs) > 1
lines = ["#EXTM3U"]
for disc in album.discs:
if multi_disc:
disc_prefix = f"CD{disc.disc_number}/"
else:
disc_prefix = ""
for track in disc.tracks:
safe_title = _sanitize_filename(track.title)
# Audiodatei im Zielverzeichnis finden
pattern = f"{track.track_number:02d} {safe_title}.*"
if multi_disc:
search_dir = album_dir / f"CD{disc.disc_number}"
else:
search_dir = album_dir
matches = list(search_dir.glob(pattern))
if matches:
filename = matches[0].name
else:
# Fallback: generischer Name mit .flac
filename = f"{track.track_number:02d} {safe_title}.flac"
logger.warning("Datei nicht gefunden, Fallback: %s", filename)
lines.append(f"#EXTINF:0,{track.title}")
lines.append(f"{disc_prefix}{filename}")
playlist_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
logger.info("Playlist erstellt: %s", playlist_path)
return playlist_path

View file

@ -1,57 +0,0 @@
"""CD-Ripping via abcde."""
from __future__ import annotations
import logging
import subprocess
from pathlib import Path
logger = logging.getLogger(__name__)
def rip_disc(
device: str,
output_dir: Path,
audio_format: str = "flac",
eject: bool = True,
) -> Path:
"""Rippt eine CD mit abcde in output_dir.
Args:
device: CD-Laufwerk, z.B. '/dev/cdrom'
output_dir: Zielverzeichnis für die gerippten Dateien
audio_format: Ausgabeformat (flac, mp3, ogg, opus)
eject: CD nach dem Rippen auswerfen
Returns:
Pfad zum Verzeichnis mit den gerippten Dateien
"""
output_dir.mkdir(parents=True, exist_ok=True)
cmd = [
"abcde",
"-n", # kein CDDB-Lookup
"-N", # non-interaktiv
"-p", # führende Nullen bei Tracknummern
"-o", audio_format,
"-d", device,
"-D", # kein Debug
]
if eject:
cmd.append("-x")
logger.info("Starte Ripping: %s", " ".join(cmd))
result = subprocess.run(
cmd,
cwd=str(output_dir),
capture_output=True,
text=True,
)
if result.returncode != 0:
logger.error("abcde Fehler: %s", result.stderr)
raise RuntimeError(f"abcde fehlgeschlagen (exit {result.returncode}): {result.stderr}")
logger.info("Ripping abgeschlossen: %s", output_dir)
return output_dir

View file

@ -1,99 +0,0 @@
"""Audio-Tagging mit mutagen."""
from __future__ import annotations
import logging
from pathlib import Path
from mutagen import File as MutagenFile
from mutagen.flac import FLAC, Picture
from mutagen.id3 import APIC, ID3
from musiksammlung.models import Album, Disc, Track
logger = logging.getLogger(__name__)
def tag_file(
path: Path,
album: Album,
disc: Disc,
track: Track,
) -> None:
"""Setzt Audio-Tags auf einer Datei.
Verwendet mutagen im Easy-Modus für formatunabhängiges Tagging.
"""
audio = MutagenFile(str(path), easy=True)
if audio is None:
logger.warning("Kann Datei nicht öffnen: %s", path)
return
audio["artist"] = album.artist
audio["album"] = album.album
audio["albumartist"] = album.artist
audio["title"] = track.title
audio["tracknumber"] = f"{track.track_number}/{len(disc.tracks)}"
audio["discnumber"] = str(disc.disc_number)
if album.year:
audio["date"] = str(album.year)
audio.save()
logger.info("Tags gesetzt: %s", path.name)
def tag_album(album: Album, album_dir: Path) -> None:
"""Setzt Tags auf allen Audiodateien eines Albums."""
multi_disc = len(album.discs) > 1
for disc in album.discs:
if multi_disc:
disc_dir = album_dir / f"CD{disc.disc_number}"
else:
disc_dir = album_dir
for track in disc.tracks:
# Dateiname-Pattern: "01 Titel.ext"
pattern = f"{track.track_number:02d} *"
matches = list(disc_dir.glob(pattern))
if matches:
tag_file(matches[0], album, disc, track)
else:
logger.warning(
"Keine Datei für Track %d: %s", track.track_number, track.title
)
def embed_cover(audio_path: Path, cover_path: Path) -> None:
"""Bettet ein Cover-Bild in eine Audiodatei ein."""
cover_data = cover_path.read_bytes()
mime = "image/jpeg" if cover_path.suffix.lower() in (".jpg", ".jpeg") else "image/png"
suffix = audio_path.suffix.lower()
if suffix == ".flac":
audio = FLAC(str(audio_path))
pic = Picture()
pic.type = 3 # Front cover
pic.mime = mime
pic.data = cover_data
audio.add_picture(pic)
audio.save()
elif suffix == ".mp3":
audio = ID3(str(audio_path))
audio.add(APIC(
encoding=3,
mime=mime,
type=3,
desc="Front cover",
data=cover_data,
))
audio.save()
else:
logger.debug("Cover-Embedding für %s nicht unterstützt", suffix)
return
logger.info("Cover eingebettet: %s", audio_path.name)

View file

View file

@ -1,42 +0,0 @@
"""Tests für die Datenmodelle."""
from musiksammlung.models import Album
def test_album_folder_name_with_year():
album = Album(artist="Test", album="Mein Album", year=1987, discs=[])
assert album.folder_name == "Mein Album (1987)"
def test_album_folder_name_without_year():
album = Album(artist="Test", album="Mein Album", year=None, discs=[])
assert album.folder_name == "Mein Album"
def test_sanitize_name():
album = Album(artist='Art:ist', album='Al/bum?', year=None, discs=[])
assert ":" not in album.artist
assert "/" not in album.album
assert "?" not in album.album
def test_album_from_json():
data = {
"artist": "Die Toten Hosen",
"album": "Opium fürs Volk",
"year": 1996,
"discs": [
{
"disc_number": 1,
"tracks": [
{"track_number": 1, "title": "Bonnie & Clyde"},
{"track_number": 2, "title": "Zehn kleine Jägermeister"},
],
}
],
}
album = Album.model_validate(data)
assert album.artist == "Die Toten Hosen"
assert len(album.discs) == 1
assert len(album.discs[0].tracks) == 2
assert album.discs[0].tracks[1].title == "Zehn kleine Jägermeister"

View file

@ -1,78 +0,0 @@
"""Tests für den Organizer."""
from pathlib import Path
from musiksammlung.models import Album, Disc, Track
from musiksammlung.organizer import build_mapping, discover_audio_files
def test_discover_audio_files(tmp_path: Path):
"""Findet und sortiert Audiodateien korrekt."""
(tmp_path / "Track_03.flac").touch()
(tmp_path / "Track_01.flac").touch()
(tmp_path / "Track_02.flac").touch()
(tmp_path / "cover.jpg").touch() # soll ignoriert werden
files = discover_audio_files(tmp_path)
assert len(files) == 3
assert files[0].name == "Track_01.flac"
assert files[2].name == "Track_03.flac"
def test_build_mapping_single_disc(tmp_path: Path):
"""Mapping für ein Single-CD-Album."""
(tmp_path / "Track_01.flac").touch()
(tmp_path / "Track_02.flac").touch()
album = Album(
artist="TestArtist",
album="TestAlbum",
year=2000,
discs=[
Disc(
disc_number=1,
tracks=[
Track(track_number=1, title="Erster Song"),
Track(track_number=2, title="Zweiter Song"),
],
)
],
)
output = tmp_path / "output"
mapping = build_mapping(album, tmp_path, output)
assert len(mapping) == 2
targets = list(mapping.values())
assert targets[0].name == "01 Erster Song.flac"
assert targets[1].name == "02 Zweiter Song.flac"
# Single-Disc: kein CD1-Unterordner
assert "CD1" not in str(targets[0])
def test_build_mapping_multi_disc(tmp_path: Path):
"""Mapping für ein Multi-CD-Album."""
cd1 = tmp_path / "CD1"
cd2 = tmp_path / "CD2"
cd1.mkdir()
cd2.mkdir()
(cd1 / "Track_01.flac").touch()
(cd2 / "Track_01.flac").touch()
album = Album(
artist="Artist",
album="Box Set",
year=1999,
discs=[
Disc(disc_number=1, tracks=[Track(track_number=1, title="Song A")]),
Disc(disc_number=2, tracks=[Track(track_number=1, title="Song B")]),
],
)
output = tmp_path / "output"
mapping = build_mapping(album, tmp_path, output)
assert len(mapping) == 2
targets = list(mapping.values())
assert "CD1" in str(targets[0])
assert "CD2" in str(targets[1])

View file

@ -1,37 +0,0 @@
"""Tests für die Playlist-Generierung."""
from pathlib import Path
from musiksammlung.models import Album, Disc, Track
from musiksammlung.playlist import generate_playlist
def test_generate_playlist_single_disc(tmp_path: Path):
"""Erzeugt eine M3U-Playlist für ein Single-CD-Album."""
album = Album(
artist="Artist",
album="TestAlbum",
year=2000,
discs=[
Disc(
disc_number=1,
tracks=[
Track(track_number=1, title="Song Eins"),
Track(track_number=2, title="Song Zwei"),
],
)
],
)
# Dummy-Audiodateien anlegen
(tmp_path / "01 Song Eins.flac").touch()
(tmp_path / "02 Song Zwei.flac").touch()
playlist_path = generate_playlist(album, tmp_path)
assert playlist_path.exists()
content = playlist_path.read_text()
assert "#EXTM3U" in content
assert "01 Song Eins.flac" in content
assert "02 Song Zwei.flac" in content
# Kein CD-Prefix bei Single-Disc
assert "CD1/" not in content