From c7d9a3f0dc6903a94583ff630e82a5b8f5982866 Mon Sep 17 00:00:00 2001 From: dschlueter Date: Sun, 15 Feb 2026 00:36:18 +0100 Subject: [PATCH 1/4] initial commit --- .gitignore | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4c37464 --- /dev/null +++ b/.gitignore @@ -0,0 +1,32 @@ +# Logs / temporär +*.log +*.tmp + +# Build-Artefakte +build/ +dist/ + +# Python-Beispiele +__pycache__/ +*.py[cod] + +# Node / Frontend +node_modules/ + +# Umgebungen / Secrets +.env +.venv/ +venv/ + +# IDE / Editor +.vscode/ +.idea/ + +# OS-Müll +.DS_Store +Thumbs.db + +# Gehört nicht dazu +idea/ + + From a55bd8eabb4ece7b00643c330726262ca37b6a2b Mon Sep 17 00:00:00 2001 From: dschlueter Date: Sun, 15 Feb 2026 00:39:10 +0100 Subject: [PATCH 2/4] initial commit --- .gitignore | 59 +-- idea/Digitale_Musik_CD_Sammlung.md | 656 +++++++++++++++++++++++++++++ 2 files changed, 689 insertions(+), 26 deletions(-) create mode 100644 idea/Digitale_Musik_CD_Sammlung.md diff --git a/.gitignore b/.gitignore index 4c37464..e70059c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,32 +1,39 @@ -# Logs / temporär +# Bytecode / Cache +__pycache__/ +*.py[cod] +*$py.class + +# Distribution / Packaging +build/ +dist/ +.eggs/ +*.egg-info/ +*.egg + +# Virtuelle Umgebungen +.venv/ +venv/ +env/ +ENV/ +.env + +# Tests / Coverage +.coverage +.tox/ +pytest_cache/ +htmlcov/ + +# Logs / temporäre Files *.log *.tmp -# Build-Artefakte -build/ -dist/ - -# Python-Beispiele -__pycache__/ -*.py[cod] - -# Node / Frontend -node_modules/ - -# Umgebungen / Secrets -.env -.venv/ -venv/ - -# IDE / Editor +# Editor / IDE .vscode/ .idea/ +*.swp +*.swo -# OS-Müll -.DS_Store -Thumbs.db - -# Gehört nicht dazu -idea/ - - +# MyPy / static analysis +.mypy_cache/ +.pytype/ +.pyre/ diff --git a/idea/Digitale_Musik_CD_Sammlung.md b/idea/Digitale_Musik_CD_Sammlung.md new file mode 100644 index 0000000..c85a997 --- /dev/null +++ b/idea/Digitale_Musik_CD_Sammlung.md @@ -0,0 +1,656 @@ + +``` +# 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.", Track_02., usw. , wobe 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. +[^1_10][^1_11][^1_12][^1_13][^1_14][^1_15][^1_8][^1_9] + +
+ +[^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] + +CLI‑Beispiel: + +```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 300 dpi 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: + - i‑tes 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. +[^2_10][^2_11][^2_12][^2_13][^2_14][^2_15][^2_16][^2_9] + +
+ +[^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`. +[^3_10][^3_11][^3_12][^3_13][^3_14][^3_15][^3_16][^3_9] + +
+ +[^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 + LLM‑Parsing + automatische Jellyfin-Verzeichnisstruktur + Playlists“ in einem Guss erledigt. + +Typische existierende Lösungen sehen eher so aus: + +- Klassische CD‑Ripper (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. +[^4_1][^4_10][^4_2][^4_3][^4_4][^4_5][^4_6][^4_7][^4_8][^4_9] + +
+ +[^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 + From 225f6b3dbfd7ccd90a873c74fe063e361855b3db Mon Sep 17 00:00:00 2001 From: dschlueter Date: Sun, 15 Feb 2026 00:40:16 +0100 Subject: [PATCH 3/4] initial commit --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index e70059c..b423d6a 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,8 @@ dist/ *.egg-info/ *.egg +idea/ + # Virtuelle Umgebungen .venv/ venv/ From 3e073250ca1ec908f897c0ab5cea6a9a33198159 Mon Sep 17 00:00:00 2001 From: dschlueter Date: Sun, 15 Feb 2026 00:47:54 +0100 Subject: [PATCH 4/4] Add project skeleton: CLI pipeline for CD digitization Modular Python package with Typer CLI (scan/apply/process commands), Pydantic data models, OCR via Tesseract, LLM-based tracklist parsing, mutagen audio tagging, M3U playlist generation, and cover processing. Includes 8 passing tests and ruff lint config. Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 38 +++++++ pyproject.toml | 36 +++++++ src/musiksammlung/__init__.py | 3 + src/musiksammlung/cli.py | 175 ++++++++++++++++++++++++++++++++ src/musiksammlung/config.py | 28 +++++ src/musiksammlung/cover.py | 51 ++++++++++ src/musiksammlung/llm_parser.py | 126 +++++++++++++++++++++++ src/musiksammlung/models.py | 38 +++++++ src/musiksammlung/ocr.py | 70 +++++++++++++ src/musiksammlung/organizer.py | 92 +++++++++++++++++ src/musiksammlung/playlist.py | 57 +++++++++++ src/musiksammlung/ripper.py | 57 +++++++++++ src/musiksammlung/tagger.py | 99 ++++++++++++++++++ tests/__init__.py | 0 tests/test_models.py | 42 ++++++++ tests/test_organizer.py | 78 ++++++++++++++ tests/test_playlist.py | 37 +++++++ 17 files changed, 1027 insertions(+) create mode 100644 CLAUDE.md create mode 100644 pyproject.toml create mode 100644 src/musiksammlung/__init__.py create mode 100644 src/musiksammlung/cli.py create mode 100644 src/musiksammlung/config.py create mode 100644 src/musiksammlung/cover.py create mode 100644 src/musiksammlung/llm_parser.py create mode 100644 src/musiksammlung/models.py create mode 100644 src/musiksammlung/ocr.py create mode 100644 src/musiksammlung/organizer.py create mode 100644 src/musiksammlung/playlist.py create mode 100644 src/musiksammlung/ripper.py create mode 100644 src/musiksammlung/tagger.py create mode 100644 tests/__init__.py create mode 100644 tests/test_models.py create mode 100644 tests/test_organizer.py create mode 100644 tests/test_playlist.py diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..7935796 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,38 @@ +# 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 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..ffcae8f --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,36 @@ +[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"] diff --git a/src/musiksammlung/__init__.py b/src/musiksammlung/__init__.py new file mode 100644 index 0000000..22aa402 --- /dev/null +++ b/src/musiksammlung/__init__.py @@ -0,0 +1,3 @@ +"""Musiksammlung – CLI-Tool zum Digitalisieren von CD-Sammlungen für Jellyfin.""" + +__version__ = "0.1.0" diff --git a/src/musiksammlung/cli.py b/src/musiksammlung/cli.py new file mode 100644 index 0000000..f7404cb --- /dev/null +++ b/src/musiksammlung/cli.py @@ -0,0 +1,175 @@ +"""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() diff --git a/src/musiksammlung/config.py b/src/musiksammlung/config.py new file mode 100644 index 0000000..483c22d --- /dev/null +++ b/src/musiksammlung/config.py @@ -0,0 +1,28 @@ +"""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 diff --git a/src/musiksammlung/cover.py b/src/musiksammlung/cover.py new file mode 100644 index 0000000..6910442 --- /dev/null +++ b/src/musiksammlung/cover.py @@ -0,0 +1,51 @@ +"""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") diff --git a/src/musiksammlung/llm_parser.py b/src/musiksammlung/llm_parser.py new file mode 100644 index 0000000..295c7c7 --- /dev/null +++ b/src/musiksammlung/llm_parser.py @@ -0,0 +1,126 @@ +"""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 diff --git a/src/musiksammlung/models.py b/src/musiksammlung/models.py new file mode 100644 index 0000000..f459f2e --- /dev/null +++ b/src/musiksammlung/models.py @@ -0,0 +1,38 @@ +"""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 diff --git a/src/musiksammlung/ocr.py b/src/musiksammlung/ocr.py new file mode 100644 index 0000000..8cbab2a --- /dev/null +++ b/src/musiksammlung/ocr.py @@ -0,0 +1,70 @@ +"""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) diff --git a/src/musiksammlung/organizer.py b/src/musiksammlung/organizer.py new file mode 100644 index 0000000..22a6a3e --- /dev/null +++ b/src/musiksammlung/organizer.py @@ -0,0 +1,92 @@ +"""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) diff --git a/src/musiksammlung/playlist.py b/src/musiksammlung/playlist.py new file mode 100644 index 0000000..9f3bc70 --- /dev/null +++ b/src/musiksammlung/playlist.py @@ -0,0 +1,57 @@ +"""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 diff --git a/src/musiksammlung/ripper.py b/src/musiksammlung/ripper.py new file mode 100644 index 0000000..a83db15 --- /dev/null +++ b/src/musiksammlung/ripper.py @@ -0,0 +1,57 @@ +"""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 diff --git a/src/musiksammlung/tagger.py b/src/musiksammlung/tagger.py new file mode 100644 index 0000000..ac03139 --- /dev/null +++ b/src/musiksammlung/tagger.py @@ -0,0 +1,99 @@ +"""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) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..c3c2634 --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,42 @@ +"""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" diff --git a/tests/test_organizer.py b/tests/test_organizer.py new file mode 100644 index 0000000..c5b077a --- /dev/null +++ b/tests/test_organizer.py @@ -0,0 +1,78 @@ +"""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]) diff --git a/tests/test_playlist.py b/tests/test_playlist.py new file mode 100644 index 0000000..4fe2d83 --- /dev/null +++ b/tests/test_playlist.py @@ -0,0 +1,37 @@ +"""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