Add phone-based EAN scanning, scanner server for cover upload, Vision-LLM integration
New features: - EAN/Barcode can now be entered by typing or by photographing the CD sleeve; Vision-LLM (extract_barcode_from_image) reads the barcode from the photo - Scanner server (port 8765) starts at the beginning of every album loop, serving both EAN barcode scanning and back cover upload via QR code - Vision-LLM analyses back cover in background thread while ripping; priority: Vision-LLM > MusicBrainz > CDDB - _find_abcde_mbid reads MBID from abcde temp dirs for CAA cover download even when the CD barcode is not linked in MusicBrainz - Concrete copy-paste apply commands shown after each album in 'Next steps' - _sanitize_name: whitelist approach (removes brackets and punctuation) - qrcode added as dependency for terminal QR code display Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
6c12510f76
commit
32c84b9edb
15 changed files with 1027 additions and 92 deletions
|
|
@ -26,6 +26,7 @@
|
||||||
| `flac` / `lame` / `opusenc` / `ffmpeg` | Encoder je nach Format | je nach Format |
|
| `flac` / `lame` / `opusenc` / `ffmpeg` | Encoder je nach Format | je nach Format |
|
||||||
| `tesseract` | OCR für Coverbilder | nein (optional) |
|
| `tesseract` | OCR für Coverbilder | nein (optional) |
|
||||||
| Ollama / OpenAI-API | LLM für Tracklisten-Extraktion | nein (optional) |
|
| Ollama / OpenAI-API | LLM für Tracklisten-Extraktion | nein (optional) |
|
||||||
|
| Ollama / OpenAI-API | Vision-LLM für Backcover-Analyse (parallel zum Ripping) | nein (optional) |
|
||||||
|
|
||||||
Installation der externen Tools (Debian/Ubuntu):
|
Installation der externen Tools (Debian/Ubuntu):
|
||||||
|
|
||||||
|
|
@ -33,6 +34,12 @@ Installation der externen Tools (Debian/Ubuntu):
|
||||||
sudo apt install abcde cdparanoia flac lame opus-tools ffmpeg tesseract-ocr
|
sudo apt install abcde cdparanoia flac lame opus-tools ffmpeg tesseract-ocr
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Firewall (falls aktiv): Port 8765 für Smartphone-Upload freigeben:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo ufw allow 8765/tcp
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. Installation
|
## 2. Installation
|
||||||
|
|
@ -57,27 +64,41 @@ musiksammlung --help
|
||||||
musiksammlung rip
|
musiksammlung rip
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
EAN/Barcode eingeben (Enter = überspringen)
|
Scanner-Server starten (Port 8765) + QR-Code im Terminal
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
EAN/Barcode eingeben ODER CD-Hülle fotografieren
|
||||||
|
│
|
||||||
|
├─ Foto → KI liest Barcode → Bestätigung/Korrektur
|
||||||
│
|
│
|
||||||
├─ MusicBrainz-Treffer → Auto-Rip
|
├─ MusicBrainz-Treffer → Auto-Rip
|
||||||
│ Zeige: Artist – Album (Year), N Discs, M Tracks
|
│ Zeige: Artist – Album (Year), N Discs, M Tracks
|
||||||
│ Für jede Disc: "CD 1/3 einlegen, Enter" → Rip → Rename
|
│ CAA-Cover herunterladen (frontcover.jpg + backcover.jpg)
|
||||||
│ album.json automatisch aus MusicBrainz-Daten
|
│ Vision-LLM im Hintergrund starten (analysiert backcover.jpg) ←─┐
|
||||||
|
│ Für jede Disc: "CD 1/3 einlegen, Enter" → Rip → Rename │ parallel
|
||||||
|
│ Vision-LLM-Ergebnis abwarten (max. 120 s, oft schon fertig) ───┘
|
||||||
|
│ Priorität: Vision-LLM > MusicBrainz
|
||||||
|
│ album.json gespeichert
|
||||||
│ → direkt weiter mit musiksammlung apply
|
│ → direkt weiter mit musiksammlung apply
|
||||||
│
|
│
|
||||||
└─ Kein Treffer / EAN leer → Fallback
|
└─ Kein Treffer / EAN leer → Fallback
|
||||||
Albumname eingeben → CD-Nummer → Rip → CDDB-Confirm
|
QR-Code im Terminal + Scanner-Server starten (Port 8765)
|
||||||
album.json aus CDDB-Daten (falls bestätigt)
|
Albumname eingeben ODER Backcover-Foto hochladen (Auto-Continue)
|
||||||
|
CD-Nummer → Rip → CDDB-Confirm
|
||||||
|
Backcover-Foto → Vision-LLM im Hintergrund ←─┐
|
||||||
|
abcde sucht MBID → CAA-Cover herunterladen │ parallel
|
||||||
|
Weitere Discs rippen ───┘
|
||||||
|
Vision-LLM-Ergebnis abwarten
|
||||||
|
Priorität: Vision-LLM > CDDB
|
||||||
|
album.json gespeichert (ggf. Verzeichnis umbenannt)
|
||||||
│
|
│
|
||||||
├─ album.json vorhanden?
|
└─ ohne Vision-LLM und ohne CDDB-Treffer:
|
||||||
│ ja → direkt weiter mit musiksammlung apply
|
album.json manuell erzeugen:
|
||||||
│
|
A: musiksammlung scan --from-text trackliste.txt
|
||||||
└─ nein → album.json manuell erzeugen:
|
B: musiksammlung scan back.jpg
|
||||||
A: musiksammlung scan --from-text trackliste.txt
|
C: musiksammlung scan back.jpg --vision
|
||||||
B: musiksammlung scan back.jpg
|
D: musiksammlung scan --barcode 0602557360561
|
||||||
C: musiksammlung scan back.jpg --vision
|
→ album.json prüfen/korrigieren
|
||||||
D: musiksammlung scan --barcode 0602557360561
|
|
||||||
→ album.json prüfen/korrigieren
|
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
musiksammlung apply ← Umbenennen, Tags, Cover, Playlist
|
musiksammlung apply ← Umbenennen, Tags, Cover, Playlist
|
||||||
|
|
@ -112,6 +133,9 @@ Das Programm fragt zuerst nach dem EAN/Barcode. Bei einem MusicBrainz-Treffer st
|
||||||
| `-o /pfad` | Ausgabe-Verzeichnis | Standard: `./temp` |
|
| `-o /pfad` | Ausgabe-Verzeichnis | Standard: `./temp` |
|
||||||
| `-d /dev/sr0` | CD-Laufwerk | falls nicht `/dev/cdrom` |
|
| `-d /dev/sr0` | CD-Laufwerk | falls nicht `/dev/cdrom` |
|
||||||
| `--no-cddb` | CDDB-Lookup deaktivieren | bei Offline-Betrieb |
|
| `--no-cddb` | CDDB-Lookup deaktivieren | bei Offline-Betrieb |
|
||||||
|
| `--vision-model` | Vision-LLM-Modell für Backcover-Analyse | Standard: `qwen3-vl:235b-cloud` |
|
||||||
|
| `--vision-url` | Ollama/OpenAI-Basis-URL für Vision-LLM | Standard: `http://localhost:11434` |
|
||||||
|
| `--scanner-port` | Port des Smartphone-Upload-Servers | Standard: `8765` |
|
||||||
|
|
||||||
### Beispiel: Schnelles FLAC-Ripping mit allen Kernen
|
### Beispiel: Schnelles FLAC-Ripping mit allen Kernen
|
||||||
|
|
||||||
|
|
@ -129,6 +153,9 @@ EAN/Barcode (Enter = überspringen): 028943753227
|
||||||
MusicBrainz-Suche nach Barcode 028943753227 ...
|
MusicBrainz-Suche nach Barcode 028943753227 ...
|
||||||
✓ Herbert von Karajan – Beethoven: 9 Symphonies (1963, 5 Disc(s), 50 Tracks)
|
✓ Herbert von Karajan – Beethoven: 9 Symphonies (1963, 5 Disc(s), 50 Tracks)
|
||||||
|
|
||||||
|
Cover-Download: frontcover.jpg, backcover.jpg
|
||||||
|
[Vision-LLM analysiert backcover.jpg im Hintergrund...]
|
||||||
|
|
||||||
CD 1/5 einlegen und Enter drücken (9 Tracks) ...
|
CD 1/5 einlegen und Enter drücken (9 Tracks) ...
|
||||||
|
|
||||||
Ripping to: temp/Beethoven__9_Symphonies/CD1
|
Ripping to: temp/Beethoven__9_Symphonies/CD1
|
||||||
|
|
@ -143,21 +170,34 @@ EAN/Barcode (Enter = überspringen): 028943753227
|
||||||
|
|
||||||
album.json gespeichert: temp/Beethoven__9_Symphonies/album.json
|
album.json gespeichert: temp/Beethoven__9_Symphonies/album.json
|
||||||
|
|
||||||
|
Next steps:
|
||||||
|
Album 1: Beethoven__9_Symphonies (1963)
|
||||||
|
1. Prüfen/bearbeiten: temp/Beethoven__9_Symphonies/album.json
|
||||||
|
2. musiksammlung apply temp/Beethoven__9_Symphonies/CD1 \
|
||||||
|
temp/Beethoven__9_Symphonies/album.json <jellyfin-verzeichnis>
|
||||||
|
|
||||||
Next album? (y/n): n
|
Next album? (y/n): n
|
||||||
```
|
```
|
||||||
|
|
||||||
**Ohne EAN (Fallback):**
|
**Ohne EAN (Fallback mit Backcover-Foto):**
|
||||||
|
|
||||||
```
|
```
|
||||||
--- Album 1 ---
|
--- Album 1 ---
|
||||||
EAN/Barcode (Enter = überspringen):
|
EAN/Barcode (Enter = überspringen):
|
||||||
Album name (Enter = CDDB name / default 'Album1'): Beethoven Sinfonien
|
|
||||||
|
|
||||||
Album: Beethoven Sinfonien
|
Scanner-Server gestartet: http://192.168.1.42:8765
|
||||||
|
[QR-Code im Terminal]
|
||||||
|
Smartphone öffnen → Backcover fotografieren → automatisch weiter
|
||||||
|
|
||||||
|
Album name (Enter = CDDB name / default 'Album1'): [Warten auf Foto oder Texteingabe]
|
||||||
|
|
||||||
|
→ Foto empfangen! Vision-LLM analysiert im Hintergrund...
|
||||||
|
[Programm fährt automatisch fort]
|
||||||
|
|
||||||
CD Drive: /dev/cdrom
|
CD Drive: /dev/cdrom
|
||||||
CD number [1]: 1
|
CD number [1]: 1
|
||||||
|
|
||||||
Ripping to: temp/Beethoven_Sinfonien/CD1
|
Ripping to: temp/Album1/CD1
|
||||||
--------------------------------------------------
|
--------------------------------------------------
|
||||||
Track 1/4 Allegro con brio
|
Track 1/4 Allegro con brio
|
||||||
[████████████████░░░░░░░░░░░░░░] 54.3% 18.2 MB
|
[████████████████░░░░░░░░░░░░░░] 54.3% 18.2 MB
|
||||||
|
|
@ -169,14 +209,64 @@ Album name (Enter = CDDB name / default 'Album1'): Beethoven Sinfonien
|
||||||
Treffer korrekt? (j/n) [j]: j
|
Treffer korrekt? (j/n) [j]: j
|
||||||
Umbenennen ...
|
Umbenennen ...
|
||||||
|
|
||||||
|
Vision-LLM: Karajan / Beethoven Sinfonien (1963, 4 Tracks)
|
||||||
|
Verzeichnis umbenannt: temp/Album1 → temp/Beethoven_Sinfonien
|
||||||
|
|
||||||
Next CD for this album? (y/n): n
|
Next CD for this album? (y/n): n
|
||||||
|
|
||||||
|
album.json gespeichert: temp/Beethoven_Sinfonien/album.json
|
||||||
|
|
||||||
|
Next steps:
|
||||||
|
Album 1: Beethoven_Sinfonien
|
||||||
|
1. Prüfen/bearbeiten: temp/Beethoven_Sinfonien/album.json
|
||||||
|
2. musiksammlung apply temp/Beethoven_Sinfonien/CD1 \
|
||||||
|
temp/Beethoven_Sinfonien/album.json <jellyfin-verzeichnis>
|
||||||
|
|
||||||
Next album? (y/n): n
|
Next album? (y/n): n
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Barcode / EAN per Smartphone scannen
|
||||||
|
|
||||||
|
Das Programm startet zu Beginn jedes Albums automatisch einen lokalen HTTP-Server und zeigt einen QR-Code im Terminal an. Das Smartphone kann damit:
|
||||||
|
|
||||||
|
1. **EAN scannen** — CD-Hülle fotografieren → KI liest den Barcode automatisch aus
|
||||||
|
2. **Backcover hochladen** — Rückseite fotografieren → Vision-LLM analysiert parallel
|
||||||
|
|
||||||
|
Der Server läuft für das gesamte Album (EAN + Backcover). Beim nächsten Album startet er neu.
|
||||||
|
|
||||||
|
Ablauf EAN-Scan:
|
||||||
|
```
|
||||||
|
Scanner bereit: http://192.168.1.42:8765
|
||||||
|
[QR-Code]
|
||||||
|
EAN/Barcode (Enter = überspringen): [Foto hochladen oder tippen]
|
||||||
|
Barcode wird per KI erkannt...
|
||||||
|
Erkannter Barcode: 0028943753227
|
||||||
|
Korrekt? (Enter = ja, neuer Wert = tippen):
|
||||||
|
```
|
||||||
|
|
||||||
|
### Backcover-Foto per Smartphone hochladen
|
||||||
|
|
||||||
|
Im Fallback-Modus (kein MusicBrainz-Treffer) kann derselbe laufende Scanner-Server auch für das Backcover genutzt werden:
|
||||||
|
|
||||||
|
```
|
||||||
|
Scanner-Server gestartet: http://192.168.1.42:8765
|
||||||
|
[█████████████]
|
||||||
|
[█ ██ █ ] ← QR-Code im Terminal
|
||||||
|
[█████████████]
|
||||||
|
→ Smartphone: QR-Code scannen → Backcover fotografieren
|
||||||
|
```
|
||||||
|
|
||||||
|
**Voraussetzungen:**
|
||||||
|
- Smartphone im gleichen WLAN wie der Rip-Rechner
|
||||||
|
- Firewall-Port 8765 offen: `sudo ufw allow 8765/tcp`
|
||||||
|
|
||||||
|
Nach dem Hochladen fährt das Programm automatisch fort und startet die Vision-LLM-Analyse im Hintergrund. Sobald der Ripping-Prozess abgeschlossen ist, wird das Ergebnis übernommen.
|
||||||
|
|
||||||
|
Bei MusicBrainz-Treffern wird `backcover.jpg` aus dem Cover Art Archive heruntergeladen — kein Foto nötig.
|
||||||
|
|
||||||
### Ergebnis-Verzeichnis
|
### Ergebnis-Verzeichnis
|
||||||
|
|
||||||
**Wenn CDDB Daten liefert:**
|
**Wenn CDDB/Vision-LLM Daten liefert:**
|
||||||
```
|
```
|
||||||
~/rip/
|
~/rip/
|
||||||
Beethoven_Sinfonien/
|
Beethoven_Sinfonien/
|
||||||
|
|
@ -184,11 +274,13 @@ Next album? (y/n): n
|
||||||
01_-_Allegro_con_brio_-_Karajan.flac
|
01_-_Allegro_con_brio_-_Karajan.flac
|
||||||
02_-_Andante_con_moto_-_Karajan.flac
|
02_-_Andante_con_moto_-_Karajan.flac
|
||||||
...
|
...
|
||||||
|
frontcover.jpg ← aus CAA oder automatisch hinzugefügt
|
||||||
|
backcover.jpg ← aus CAA oder Smartphone-Foto
|
||||||
album.json ← automatisch gespeichert
|
album.json ← automatisch gespeichert
|
||||||
```
|
```
|
||||||
→ Direkt weiter mit `musiksammlung apply ~/rip/Beethoven_Sinfonien album.json`
|
→ Am Ende zeigt das Programm den fertigen `apply`-Befehl an (copy-paste-fähig).
|
||||||
|
|
||||||
**Wenn kein CDDB-Treffer:**
|
**Wenn kein CDDB-Treffer und kein Foto:**
|
||||||
```
|
```
|
||||||
~/rip/
|
~/rip/
|
||||||
Beethoven_Sinfonien/
|
Beethoven_Sinfonien/
|
||||||
|
|
@ -377,7 +469,9 @@ Ist bereits ein `frontcover.*` vorhanden (z.B. bei erneutem `apply`), wird es oh
|
||||||
|
|
||||||
Beispiel: `01_-_Allegro_con_brio_-_Karajan.flac`
|
Beispiel: `01_-_Allegro_con_brio_-_Karajan.flac`
|
||||||
|
|
||||||
- Leerzeichen und Satzzeichen → `_`
|
- Leerzeichen → `_`
|
||||||
|
- Klammern `()[]{}` und Satzzeichen `,;:!?.'"` → werden entfernt
|
||||||
|
- Nur Buchstaben, Ziffern, Unterstriche und Bindestriche bleiben erhalten
|
||||||
- Mehrere `_` hintereinander → ein `_`
|
- Mehrere `_` hintereinander → ein `_`
|
||||||
- Umlaute (ä, ö, ü, ß) bleiben erhalten
|
- Umlaute (ä, ö, ü, ß) bleiben erhalten
|
||||||
- Künstler pro Track: falls im `album.json` ein Track-`artist` gesetzt ist, wird dieser verwendet; sonst der Album-Künstler
|
- Künstler pro Track: falls im `album.json` ein Track-`artist` gesetzt ist, wird dieser verwendet; sonst der Album-Künstler
|
||||||
|
|
@ -424,11 +518,21 @@ musiksammlung process temp/Album/CD1 ~/Musik --back back.jpg
|
||||||
|
|
||||||
## 9. Tipps und Hinweise
|
## 9. Tipps und Hinweise
|
||||||
|
|
||||||
**EAN/Barcode verfügbar? → Schnellster Weg**
|
**EAN/Barcode eingeben → Schnellster Weg**
|
||||||
- EAN-13 oder UPC-12 von der CD-Hülle ablesen (ggf. Barcode-Scanner-App nutzen)
|
- Beim `rip`-Befehl erscheint direkt ein QR-Code im Terminal
|
||||||
- Beim `rip`-Befehl wird die EAN als erstes abgefragt — bei MusicBrainz-Treffer startet der Auto-Rip sofort (keine weiteren Fragen)
|
- **Option A (tippen):** EAN-13 oder UPC-12 von der CD-Hülle ablesen und eingeben
|
||||||
- Alternativ: `musiksammlung scan --barcode 0602557360561 -o album.json`
|
- **Option B (fotografieren):** CD-Hülle mit dem Smartphone fotografieren → KI liest Barcode automatisch → Bestätigung/Korrektur möglich
|
||||||
- Kein Bild, kein OCR, kein lokales LLM notwendig
|
- Bei MusicBrainz-Treffer startet der Auto-Rip sofort (keine weiteren Fragen)
|
||||||
|
- Cover (Front + Rückseite) werden automatisch aus dem Cover Art Archive heruntergeladen
|
||||||
|
- Alternativ ohne `rip`: `musiksammlung scan --barcode 0602557360561 -o album.json`
|
||||||
|
|
||||||
|
**Backcover-Foto per Smartphone hochladen**
|
||||||
|
- Derselbe QR-Code / Scanner-Server wird für EAN und Backcover genutzt
|
||||||
|
- Smartphone muss im gleichen WLAN sein
|
||||||
|
- Firewall-Port 8765 (TCP) muss offen sein: `sudo ufw allow 8765/tcp`
|
||||||
|
- Nach dem Hochladen fährt das Programm automatisch fort
|
||||||
|
- Vision-LLM analysiert das Foto parallel zum Ripping — kein Warten nötig
|
||||||
|
- Anderes Modell oder URL: `--vision-model qwen3-vl:7b --vision-url http://localhost:11434`
|
||||||
|
|
||||||
**CDDB-Lookup schlägt fehl?**
|
**CDDB-Lookup schlägt fehl?**
|
||||||
- Internetverbindung prüfen
|
- Internetverbindung prüfen
|
||||||
|
|
@ -443,6 +547,7 @@ musiksammlung process temp/Album/CD1 ~/Musik --back back.jpg
|
||||||
**Mehrspaltige Trackliste auf dem Backcover?**
|
**Mehrspaltige Trackliste auf dem Backcover?**
|
||||||
- OCR erkennt mehrspaltige Layouts oft unvollständig
|
- OCR erkennt mehrspaltige Layouts oft unvollständig
|
||||||
- Vision-LLM verwenden: `--vision --vision-model qwen3-vl:235b-cloud`
|
- Vision-LLM verwenden: `--vision --vision-model qwen3-vl:235b-cloud`
|
||||||
|
- Beim `rip`-Befehl: Backcover per Smartphone hochladen → Vision-LLM läuft automatisch
|
||||||
|
|
||||||
**Mehrere CDs eines Albums (Multi-Disc)?**
|
**Mehrere CDs eines Albums (Multi-Disc)?**
|
||||||
- Bei EAN-Treffer: MusicBrainz kennt die Disc-Anzahl, der Auto-Rip fordert automatisch jede CD an
|
- Bei EAN-Treffer: MusicBrainz kennt die Disc-Anzahl, der Auto-Rip fordert automatisch jede CD an
|
||||||
|
|
@ -450,6 +555,16 @@ musiksammlung process temp/Album/CD1 ~/Musik --back back.jpg
|
||||||
- Jede CD erhält ein eigenes Unterverzeichnis `CD1`, `CD2`, ...
|
- Jede CD erhält ein eigenes Unterverzeichnis `CD1`, `CD2`, ...
|
||||||
- `apply` einmal mit dem Album-Verzeichnis aufrufen (nicht pro CD)
|
- `apply` einmal mit dem Album-Verzeichnis aufrufen (nicht pro CD)
|
||||||
|
|
||||||
|
**Copy-paste-fähige `apply`-Befehle**
|
||||||
|
- Am Ende des Rippens zeigt das Programm für jedes Album konkrete Kommandos an:
|
||||||
|
```
|
||||||
|
Next steps:
|
||||||
|
Album 1: Beethoven_Sinfonien (1963)
|
||||||
|
1. Prüfen/bearbeiten: ~/rip/Beethoven_Sinfonien/album.json
|
||||||
|
2. musiksammlung apply ~/rip/Beethoven_Sinfonien/CD1 \
|
||||||
|
~/rip/Beethoven_Sinfonien/album.json <jellyfin-verzeichnis>
|
||||||
|
```
|
||||||
|
|
||||||
**Unterstützte Audio-Formate:**
|
**Unterstützte Audio-Formate:**
|
||||||
|
|
||||||
| Format | Qualität high | Verwendung |
|
| Format | Qualität high | Verwendung |
|
||||||
|
|
|
||||||
12
README.md
12
README.md
|
|
@ -9,10 +9,12 @@ CLI-Tool zum Digitalisieren von CD-Sammlungen für Jellyfin.
|
||||||
**Musiksammlung** automatisiert die Digitalisierung physischer CDs:
|
**Musiksammlung** automatisiert die Digitalisierung physischer CDs:
|
||||||
|
|
||||||
1. CDs rippen (via `abcde`, CDDB-Lookup)
|
1. CDs rippen (via `abcde`, CDDB-Lookup)
|
||||||
2. Coverbilder per OCR oder Vision-LLM analysieren
|
2. Backcover automatisch via Smartphone-Upload erfassen
|
||||||
3. Tracklisten per LLM extrahieren und als JSON speichern
|
3. Backcover per Vision-LLM parallel zum Ripping analysieren (Cloud-Modell, kein lokales VRAM nötig)
|
||||||
4. Audiodateien umbenennen, taggen und in Jellyfin-Struktur ablegen
|
4. Coverbilder per OCR oder Vision-LLM analysieren
|
||||||
5. M3U-Playlisten erzeugen
|
5. Tracklisten per LLM extrahieren und als JSON speichern
|
||||||
|
6. Audiodateien umbenennen, taggen und in Jellyfin-Struktur ablegen
|
||||||
|
7. M3U-Playlisten erzeugen
|
||||||
|
|
||||||
## Voraussetzungen
|
## Voraussetzungen
|
||||||
|
|
||||||
|
|
@ -20,6 +22,7 @@ CLI-Tool zum Digitalisieren von CD-Sammlungen für Jellyfin.
|
||||||
- `abcde` (CD-Ripping)
|
- `abcde` (CD-Ripping)
|
||||||
- `tesseract` (OCR, optional)
|
- `tesseract` (OCR, optional)
|
||||||
- Ollama oder OpenAI-kompatibles LLM (optional)
|
- Ollama oder OpenAI-kompatibles LLM (optional)
|
||||||
|
- Firewall: Port 8765 (TCP) für Smartphone-Upload offen (`sudo ufw allow 8765/tcp`)
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
|
|
@ -31,6 +34,7 @@ pip install -e ".[dev]"
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# CDs rippen (interaktiv, EAN-First: MusicBrainz → Auto-Rip bei Treffer)
|
# CDs rippen (interaktiv, EAN-First: MusicBrainz → Auto-Rip bei Treffer)
|
||||||
|
# QR-Code im Terminal: Backcover mit Smartphone fotografieren → Vision-LLM analysiert parallel
|
||||||
musiksammlung rip -j 0 -P
|
musiksammlung rip -j 0 -P
|
||||||
|
|
||||||
# Variante A: EAN/Barcode → MusicBrainz → album.json (schnellste Methode)
|
# Variante A: EAN/Barcode → MusicBrainz → album.json (schnellste Methode)
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ dependencies = [
|
||||||
"mutagen>=1.47",
|
"mutagen>=1.47",
|
||||||
"Pillow>=10.0",
|
"Pillow>=10.0",
|
||||||
"httpx>=0.27",
|
"httpx>=0.27",
|
||||||
|
"qrcode>=7.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
|
|
||||||
|
|
@ -191,7 +191,10 @@ def _read_gnudb(category: str, discid: str) -> CddbResult | None:
|
||||||
title=title,
|
title=title,
|
||||||
))
|
))
|
||||||
|
|
||||||
logger.info("GnuDB: %d Tracks für '%s' geladen (year=%s, genre=%s)", len(tracks), dtitle, year, dgenre)
|
logger.info(
|
||||||
|
"GnuDB: %d Tracks für '%s' geladen (year=%s, genre=%s)",
|
||||||
|
len(tracks), dtitle, year, dgenre,
|
||||||
|
)
|
||||||
return CddbResult(
|
return CddbResult(
|
||||||
tracks=tracks,
|
tracks=tracks,
|
||||||
artist=album_artist,
|
artist=album_artist,
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,8 @@ def _scan_to_album(
|
||||||
if barcode:
|
if barcode:
|
||||||
typer.echo(f"MusicBrainz-Suche nach Barcode {barcode}...")
|
typer.echo(f"MusicBrainz-Suche nach Barcode {barcode}...")
|
||||||
try:
|
try:
|
||||||
return lookup_by_barcode(barcode)
|
album, _mbid = lookup_by_barcode(barcode)
|
||||||
|
return album
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
typer.echo(f"Fehler: {e}", err=True)
|
typer.echo(f"Fehler: {e}", err=True)
|
||||||
typer.echo(
|
typer.echo(
|
||||||
|
|
@ -338,6 +339,18 @@ def rip(
|
||||||
no_cddb: bool = typer.Option(
|
no_cddb: bool = typer.Option(
|
||||||
False, "--no-cddb", help="Disable CDDB lookup"
|
False, "--no-cddb", help="Disable CDDB lookup"
|
||||||
),
|
),
|
||||||
|
vision_model: str = typer.Option(
|
||||||
|
"qwen3-vl:235b-cloud", "--vision-model",
|
||||||
|
help="Vision-LLM für Backcover-Analyse (Ollama-Modell)"
|
||||||
|
),
|
||||||
|
vision_url: str = typer.Option(
|
||||||
|
"http://localhost:11434", "--vision-url",
|
||||||
|
help="Ollama API URL für Vision-LLM"
|
||||||
|
),
|
||||||
|
scanner_port: int = typer.Option(
|
||||||
|
8765, "--scanner-port",
|
||||||
|
help="Port für den Foto-Upload-Server (Fallback-Pfad)"
|
||||||
|
),
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Interactive CD ripping with abcde.
|
"""Interactive CD ripping with abcde.
|
||||||
|
|
||||||
|
|
@ -377,6 +390,9 @@ def rip(
|
||||||
parallel_jobs=parallel,
|
parallel_jobs=parallel,
|
||||||
use_pipes=pipes,
|
use_pipes=pipes,
|
||||||
use_cddb=not no_cddb,
|
use_cddb=not no_cddb,
|
||||||
|
vision_model=vision_model,
|
||||||
|
vision_url=vision_url,
|
||||||
|
scanner_port=scanner_port,
|
||||||
)
|
)
|
||||||
interactive_rip(config)
|
interactive_rip(config)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,10 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -79,3 +81,48 @@ def copy_covers(
|
||||||
prepare_cover(back_image, album_dir / "backcover.jpg")
|
prepare_cover(back_image, album_dir / "backcover.jpg")
|
||||||
else:
|
else:
|
||||||
logger.debug("Kein Back-Cover angegeben")
|
logger.debug("Kein Back-Cover angegeben")
|
||||||
|
|
||||||
|
|
||||||
|
_CAA_BASE = "https://coverartarchive.org/release"
|
||||||
|
|
||||||
|
|
||||||
|
def download_caa_covers(mbid: str, album_dir: Path) -> None:
|
||||||
|
"""Lädt Front- und Back-Cover vom Cover Art Archive herunter.
|
||||||
|
|
||||||
|
Nutzt die CAA-API: GET /release/{mbid}/front bzw. /back.
|
||||||
|
Bereits vorhandene Cover werden nicht überschrieben.
|
||||||
|
Fehler (404, Netzwerk) werden geloggt, brechen aber nicht ab.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
mbid: MusicBrainz Release-MBID
|
||||||
|
album_dir: Zielverzeichnis für frontcover.jpg / backcover.jpg
|
||||||
|
"""
|
||||||
|
for kind, filename in [("front", "frontcover.jpg"), ("back", "backcover.jpg")]:
|
||||||
|
target = album_dir / filename
|
||||||
|
if target.exists():
|
||||||
|
logger.info("CAA: %s existiert bereits, überspringe.", filename)
|
||||||
|
continue
|
||||||
|
|
||||||
|
url = f"{_CAA_BASE}/{mbid}/{kind}"
|
||||||
|
try:
|
||||||
|
response = httpx.get(url, follow_redirects=True, timeout=30.0)
|
||||||
|
if response.status_code == 404:
|
||||||
|
logger.info("CAA: Kein %s-Cover für MBID %s verfügbar.", kind, mbid)
|
||||||
|
continue
|
||||||
|
response.raise_for_status()
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
logger.warning("CAA: Fehler beim Download von %s-Cover: %s", kind, e)
|
||||||
|
continue
|
||||||
|
|
||||||
|
album_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=".img", delete=False) as tmp:
|
||||||
|
tmp.write(response.content)
|
||||||
|
tmp_path = Path(tmp.name)
|
||||||
|
|
||||||
|
try:
|
||||||
|
prepare_cover(tmp_path, target, max_size=1200)
|
||||||
|
logger.info("CAA: %s heruntergeladen → %s", kind, target)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("CAA: Fehler beim Verarbeiten von %s-Cover: %s", kind, e)
|
||||||
|
finally:
|
||||||
|
tmp_path.unlink(missing_ok=True)
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ def _get(path: str, params: dict) -> dict:
|
||||||
return response.json()
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
def lookup_by_barcode(ean: str) -> Album:
|
def lookup_by_barcode(ean: str) -> tuple[Album, str]:
|
||||||
"""Schlägt ein Album anhand des EAN-Barcodes in MusicBrainz nach.
|
"""Schlägt ein Album anhand des EAN-Barcodes in MusicBrainz nach.
|
||||||
|
|
||||||
Führt zwei API-Requests durch:
|
Führt zwei API-Requests durch:
|
||||||
|
|
@ -42,7 +42,7 @@ def lookup_by_barcode(ean: str) -> Album:
|
||||||
ean: EAN-13- oder UPC-12-Barcode
|
ean: EAN-13- oder UPC-12-Barcode
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Album mit vollständiger Trackliste
|
Tuple aus (Album mit vollständiger Trackliste, MusicBrainz Release-MBID)
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ValueError: Kein Eintrag für diesen Barcode gefunden
|
ValueError: Kein Eintrag für diesen Barcode gefunden
|
||||||
|
|
@ -60,7 +60,7 @@ def lookup_by_barcode(ean: str) -> Album:
|
||||||
time.sleep(_RATE_SLEEP)
|
time.sleep(_RATE_SLEEP)
|
||||||
|
|
||||||
detail = _get(f"/release/{mbid}", {"inc": "recordings", "fmt": "json"})
|
detail = _get(f"/release/{mbid}", {"inc": "recordings", "fmt": "json"})
|
||||||
return _parse_release(detail)
|
return _parse_release(detail), mbid
|
||||||
|
|
||||||
|
|
||||||
def _parse_release(data: dict) -> Album:
|
def _parse_release(data: dict) -> Album:
|
||||||
|
|
|
||||||
|
|
@ -134,7 +134,10 @@ def build_mapping(
|
||||||
artist_raw = track.artist or album.artist
|
artist_raw = track.artist or album.artist
|
||||||
if artist_raw:
|
if artist_raw:
|
||||||
safe_artist = sanitize_filename(artist_raw)
|
safe_artist = sanitize_filename(artist_raw)
|
||||||
new_name = f"{track.track_number:02d}_-_{safe_title}_-_{safe_artist}{audio_file.suffix}"
|
new_name = (
|
||||||
|
f"{track.track_number:02d}_-_{safe_title}"
|
||||||
|
f"_-_{safe_artist}{audio_file.suffix}"
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
new_name = f"{track.track_number:02d}_-_{safe_title}{audio_file.suffix}"
|
new_name = f"{track.track_number:02d}_-_{safe_title}{audio_file.suffix}"
|
||||||
mapping[audio_file] = target_dir / new_name
|
mapping[audio_file] = target_dir / new_name
|
||||||
|
|
|
||||||
|
|
@ -3,19 +3,25 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import queue as _queue_module
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from musiksammlung.cddb import CddbResult, get_discid, lookup_by_discid
|
from musiksammlung.cddb import get_discid, lookup_by_discid
|
||||||
from musiksammlung.config import AudioFormat
|
from musiksammlung.config import AudioFormat
|
||||||
|
from musiksammlung.cover import download_caa_covers, prepare_cover
|
||||||
from musiksammlung.models import Album as AlbumModel
|
from musiksammlung.models import Album as AlbumModel
|
||||||
from musiksammlung.models import Disc as DiscModel
|
from musiksammlung.models import Disc as DiscModel
|
||||||
from musiksammlung.models import Track as TrackModel
|
from musiksammlung.models import Track as TrackModel
|
||||||
from musiksammlung.models import TrackInfo
|
from musiksammlung.models import TrackInfo
|
||||||
from musiksammlung.musicbrainz import lookup_by_barcode
|
from musiksammlung.musicbrainz import lookup_by_barcode
|
||||||
|
from musiksammlung.scanner_server import ScannerServer, print_qr
|
||||||
|
from musiksammlung.vision_llm import extract_barcode_from_image, parse_image
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -33,6 +39,9 @@ class RipperConfig(BaseModel):
|
||||||
parallel_jobs: int = 1 # Number of parallel encoder processes
|
parallel_jobs: int = 1 # Number of parallel encoder processes
|
||||||
use_pipes: bool = False # True = faster, no WAV files
|
use_pipes: bool = False # True = faster, no WAV files
|
||||||
use_cddb: bool = True # Use CDDB lookup
|
use_cddb: bool = True # Use CDDB lookup
|
||||||
|
vision_model: str = "qwen3-vl:235b-cloud"
|
||||||
|
vision_url: str = "http://localhost:11434"
|
||||||
|
scanner_port: int = 8765
|
||||||
|
|
||||||
|
|
||||||
def _clean_input(raw: str) -> str:
|
def _clean_input(raw: str) -> str:
|
||||||
|
|
@ -53,18 +62,144 @@ def _clean_input(raw: str) -> str:
|
||||||
return cleaned
|
return cleaned
|
||||||
|
|
||||||
|
|
||||||
def _sanitize_name(name: str) -> str:
|
def _print_apply_hint(album_root: Path, json_path: Path, num_discs: int) -> None:
|
||||||
"""Remove problematic characters and replace spaces.
|
"""Gibt einen kopierbaren apply-Befehl aus."""
|
||||||
|
input_dir = album_root / "CD1" if num_discs == 1 else album_root
|
||||||
|
parts = ["musiksammlung apply", str(input_dir), str(json_path)]
|
||||||
|
if not (album_root / "frontcover.jpg").exists():
|
||||||
|
parts.append("--front <cover.jpg>")
|
||||||
|
print(f" → {' '.join(parts)}")
|
||||||
|
|
||||||
|
|
||||||
|
def _find_abcde_mbid(disc_dir: Path) -> str | None:
|
||||||
|
"""Liest die MusicBrainz-ID aus dem abcde-Temp-Verzeichnis.
|
||||||
|
|
||||||
|
abcde legt die ausgewählte Treffer-Nummer in der status-Datei als
|
||||||
|
'cddb-choice=N' ab; die zugehörige MBID steht in 'mbid.N'.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
name: Original name
|
disc_dir: Verzeichnis, in das abcde geripped hat (enthält abcde.XXXX/)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Cleaned name (spaces -> underscores)
|
MusicBrainz Release-MBID oder None
|
||||||
|
"""
|
||||||
|
for abcde_dir in sorted(disc_dir.glob("abcde.*")):
|
||||||
|
status_file = abcde_dir / "status"
|
||||||
|
if not status_file.exists():
|
||||||
|
continue
|
||||||
|
|
||||||
|
choice = 1
|
||||||
|
for line in status_file.read_text(encoding="utf-8", errors="replace").splitlines():
|
||||||
|
if line.startswith("cddb-choice="):
|
||||||
|
try:
|
||||||
|
choice = int(line.split("=", 1)[1])
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
break
|
||||||
|
|
||||||
|
mbid_file = abcde_dir / f"mbid.{choice}"
|
||||||
|
if mbid_file.exists():
|
||||||
|
mbid = mbid_file.read_text(encoding="utf-8").strip()
|
||||||
|
if mbid:
|
||||||
|
logger.info("abcde MBID gefunden: %s (Wahl %d)", mbid, choice)
|
||||||
|
return mbid
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _start_vision_thread(
|
||||||
|
image_path: Path,
|
||||||
|
model: str,
|
||||||
|
base_url: str,
|
||||||
|
) -> _queue_module.Queue:
|
||||||
|
"""Startet Vision-LLM-Analyse im Hintergrund-Thread.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Queue, in die AlbumModel (bei Erfolg) oder None (bei Fehler) gelegt wird.
|
||||||
|
"""
|
||||||
|
result_q: _queue_module.Queue = _queue_module.Queue()
|
||||||
|
|
||||||
|
def _run() -> None:
|
||||||
|
try:
|
||||||
|
album = parse_image([image_path], model=model, base_url=base_url)
|
||||||
|
result_q.put(album)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Vision-LLM-Analyse fehlgeschlagen: %s", exc)
|
||||||
|
result_q.put(None)
|
||||||
|
|
||||||
|
threading.Thread(target=_run, daemon=True).start()
|
||||||
|
return result_q
|
||||||
|
|
||||||
|
|
||||||
|
def _get_vision_result(
|
||||||
|
result_q: _queue_module.Queue,
|
||||||
|
timeout: float = 120.0,
|
||||||
|
) -> AlbumModel | None:
|
||||||
|
"""Holt Vision-LLM-Ergebnis aus Queue mit Timeout."""
|
||||||
|
try:
|
||||||
|
return result_q.get(timeout=timeout)
|
||||||
|
except _queue_module.Empty:
|
||||||
|
logger.warning("Vision-LLM: Timeout nach %.0f s", timeout)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _input_or_scan(
|
||||||
|
prompt: str,
|
||||||
|
scanner: ScannerServer | None,
|
||||||
|
) -> tuple[str, Path | None]:
|
||||||
|
"""Kombiniertes input() + Scanner-Queue: wartet gleichzeitig auf Tastatur und Foto.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(eingegebener Text, None) — wenn der User Enter drückt
|
||||||
|
("", photo_path) — wenn ein Foto hochgeladen wird
|
||||||
|
"""
|
||||||
|
if scanner is None:
|
||||||
|
return _clean_input(input(prompt)), None
|
||||||
|
|
||||||
|
print(prompt, end="", flush=True)
|
||||||
|
|
||||||
|
stdin_q: _queue_module.Queue = _queue_module.Queue()
|
||||||
|
|
||||||
|
def _read_stdin() -> None:
|
||||||
|
try:
|
||||||
|
val = sys.stdin.readline().rstrip("\n")
|
||||||
|
except EOFError:
|
||||||
|
val = ""
|
||||||
|
stdin_q.put(val)
|
||||||
|
|
||||||
|
threading.Thread(target=_read_stdin, daemon=True).start()
|
||||||
|
|
||||||
|
while True:
|
||||||
|
photo = scanner.get_photo(timeout=0)
|
||||||
|
if photo is not None:
|
||||||
|
print("\n [Foto empfangen — weiter automatisch]", flush=True)
|
||||||
|
return "", photo
|
||||||
|
|
||||||
|
try:
|
||||||
|
val = stdin_q.get(timeout=0.1)
|
||||||
|
return _clean_input(val), None
|
||||||
|
except _queue_module.Empty:
|
||||||
|
continue
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_name(name: str) -> str:
|
||||||
|
"""Bereinigt einen Namen für die Verwendung als Verzeichnis- oder Dateiname.
|
||||||
|
|
||||||
|
- Leerzeichen → Unterstrich
|
||||||
|
- Alle Sonder- und Satzzeichen (Klammern, Komma, Punkt, …) werden entfernt
|
||||||
|
- Nur Buchstaben (inkl. Umlaute), Ziffern, Unterstrich und Bindestrich bleiben
|
||||||
|
- Mehrfache Unterstriche werden zusammengefasst
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Original-Name
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Bereinigter Name
|
||||||
"""
|
"""
|
||||||
name = name.replace(" ", "_")
|
name = name.replace(" ", "_")
|
||||||
name = re.sub(r'[<>:"\'/\\|?*]', "", name)
|
name = re.sub(r"[^\w\-]", "", name) # nur \w (Buchstaben/Ziffern/_) und - behalten
|
||||||
name = name.strip("_")
|
name = re.sub(r"_+", "_", name) # mehrfache Unterstriche zusammenfassen
|
||||||
|
name = name.strip("_-")
|
||||||
return name
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -517,18 +652,45 @@ def interactive_rip(config: RipperConfig) -> None:
|
||||||
print("\nNote: Do not use arrow keys while typing — press Enter to confirm.\n")
|
print("\nNote: Do not use arrow keys while typing — press Enter to confirm.\n")
|
||||||
|
|
||||||
album_counter = 1
|
album_counter = 1
|
||||||
|
processed_albums: list[tuple[Path, Path, int]] = [] # (album_root, json_path, num_discs)
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
print(f"\n--- Album {album_counter} ---")
|
print(f"\n--- Album {album_counter} ---")
|
||||||
|
|
||||||
# ── EAN zuerst abfragen ──
|
# ── Scanner-Server starten (EAN-Foto + späteres Backcover) ──
|
||||||
raw_ean = input("EAN/Barcode (Enter = überspringen): ")
|
scanner = ScannerServer(port=config.scanner_port)
|
||||||
ean = _clean_input(raw_ean)
|
scanner.start()
|
||||||
|
scanner_url = scanner.url()
|
||||||
|
print(f"\n Scanner bereit: {scanner_url}")
|
||||||
|
print_qr(scanner_url)
|
||||||
|
|
||||||
|
# ── EAN: Texteingabe oder Barcode-Foto ──
|
||||||
|
raw_ean, ean_photo = _input_or_scan(
|
||||||
|
"EAN/Barcode (Enter = überspringen): ", scanner
|
||||||
|
)
|
||||||
|
ean = _clean_input(raw_ean) if raw_ean else ""
|
||||||
|
|
||||||
|
if not ean and ean_photo:
|
||||||
|
print(" Barcode wird per KI erkannt...", flush=True)
|
||||||
|
detected_ean = extract_barcode_from_image(
|
||||||
|
ean_photo, config.vision_model, config.vision_url
|
||||||
|
)
|
||||||
|
if detected_ean:
|
||||||
|
print(f" Erkannter Barcode: {detected_ean}")
|
||||||
|
confirm = _clean_input(
|
||||||
|
input(" Korrekt? (Enter = ja, neuer Wert = tippen): ")
|
||||||
|
)
|
||||||
|
ean = confirm if confirm else detected_ean
|
||||||
|
else:
|
||||||
|
print(" Kein Barcode erkannt.")
|
||||||
|
ean = _clean_input(input(" EAN manuell eingeben (Enter = überspringen): "))
|
||||||
|
|
||||||
mb_album: AlbumModel | None = None
|
mb_album: AlbumModel | None = None
|
||||||
|
mb_mbid: str | None = None
|
||||||
if ean:
|
if ean:
|
||||||
try:
|
try:
|
||||||
print(f" MusicBrainz-Suche nach Barcode {ean} ...", flush=True)
|
print(f" MusicBrainz-Suche nach Barcode {ean} ...", flush=True)
|
||||||
mb_album = lookup_by_barcode(ean)
|
mb_album, mb_mbid = lookup_by_barcode(ean)
|
||||||
total_tracks = sum(len(d.tracks) for d in mb_album.discs)
|
total_tracks = sum(len(d.tracks) for d in mb_album.discs)
|
||||||
print(
|
print(
|
||||||
f" ✓ {mb_album.artist} – {mb_album.album}"
|
f" ✓ {mb_album.artist} – {mb_album.album}"
|
||||||
|
|
@ -545,13 +707,28 @@ def interactive_rip(config: RipperConfig) -> None:
|
||||||
album_name = mb_album.album or f"Album{album_counter}"
|
album_name = mb_album.album or f"Album{album_counter}"
|
||||||
total_discs = len(mb_album.discs)
|
total_discs = len(mb_album.discs)
|
||||||
|
|
||||||
|
# Album-Root und Cover VOR dem Ripping anlegen,
|
||||||
|
# damit backcover.jpg für Vision-LLM verfügbar ist.
|
||||||
|
album_root = config.output_dir / _sanitize_name(album_name)
|
||||||
|
album_root.mkdir(parents=True, exist_ok=True)
|
||||||
|
if mb_mbid:
|
||||||
|
download_caa_covers(mb_mbid, album_root)
|
||||||
|
|
||||||
|
# Vision-LLM im Hintergrund starten, falls Back-Cover vorhanden
|
||||||
|
vision_queue = None
|
||||||
|
backcover = album_root / "backcover.jpg"
|
||||||
|
if backcover.exists():
|
||||||
|
print(
|
||||||
|
" Backcover verfügbar → Vision-LLM-Analyse im Hintergrund...",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
vision_queue = _start_vision_thread(
|
||||||
|
backcover, config.vision_model, config.vision_url
|
||||||
|
)
|
||||||
|
|
||||||
for disc in mb_album.discs:
|
for disc in mb_album.discs:
|
||||||
disc_num = disc.disc_number
|
disc_num = disc.disc_number
|
||||||
disc_dir = (
|
disc_dir = album_root / f"CD{disc_num}"
|
||||||
config.output_dir
|
|
||||||
/ _sanitize_name(album_name)
|
|
||||||
/ f"CD{disc_num}"
|
|
||||||
)
|
|
||||||
|
|
||||||
input(
|
input(
|
||||||
f"\n CD {disc_num}/{total_discs} einlegen und Enter drücken "
|
f"\n CD {disc_num}/{total_discs} einlegen und Enter drücken "
|
||||||
|
|
@ -589,30 +766,76 @@ def interactive_rip(config: RipperConfig) -> None:
|
||||||
print(" Bitte Album neu starten.")
|
print(" Bitte Album neu starten.")
|
||||||
break
|
break
|
||||||
|
|
||||||
# album.json aus MusicBrainz-Daten schreiben
|
# Vision-LLM-Ergebnis abholen (läuft parallel zum Ripping)
|
||||||
album_root = (
|
final_album = mb_album
|
||||||
config.output_dir / _sanitize_name(album_name)
|
if vision_queue is not None:
|
||||||
)
|
print(" Warte auf Vision-LLM-Ergebnis ...", flush=True)
|
||||||
album_root.mkdir(parents=True, exist_ok=True)
|
vision_result = _get_vision_result(vision_queue, timeout=120.0)
|
||||||
|
if vision_result:
|
||||||
|
n_tracks = sum(len(d.tracks) for d in vision_result.discs)
|
||||||
|
print(
|
||||||
|
f" ✓ Vision-LLM: {n_tracks} Tracks aus Backcover extrahiert"
|
||||||
|
f" (überschreibt MusicBrainz-Trackliste)",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
final_album = vision_result
|
||||||
|
else:
|
||||||
|
print(
|
||||||
|
" Vision-LLM: kein Ergebnis — MusicBrainz-Daten werden verwendet.",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
|
||||||
json_path = album_root / "album.json"
|
json_path = album_root / "album.json"
|
||||||
json_path.write_text(
|
json_path.write_text(
|
||||||
mb_album.model_dump_json(indent=2), encoding="utf-8"
|
final_album.model_dump_json(indent=2), encoding="utf-8"
|
||||||
)
|
)
|
||||||
print(f"\n album.json gespeichert: {json_path}")
|
print(f"\n album.json gespeichert: {json_path}")
|
||||||
print(" → Weiter mit: musiksammlung apply <album-verzeichnis> album.json")
|
_print_apply_hint(album_root, json_path, len(final_album.discs))
|
||||||
|
processed_albums.append((album_root, json_path, len(final_album.discs)))
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# ── Fallback: kein MusicBrainz-Treffer ──
|
# ── Fallback: kein MusicBrainz-Treffer ──
|
||||||
raw = input("Album name (Enter = CDDB name / default 'Album{N}'): ")
|
# Scanner läuft bereits (seit EAN-Prompt), kann auch für Backcover genutzt werden
|
||||||
album_name = _clean_input(raw)
|
print("\n Kein MusicBrainz-Treffer.")
|
||||||
if not album_name:
|
print(" Optional: Backcover-Foto hochladen für automatische Metadaten-Extraktion.")
|
||||||
album_name = f"Album{album_counter}"
|
print(f" URL: {scanner_url}")
|
||||||
|
print_qr(scanner_url)
|
||||||
|
print(" → Foto hochladen und Enter drücken — oder Album-Namen eingeben:")
|
||||||
|
|
||||||
|
raw, photo_from_prompt = _input_or_scan(
|
||||||
|
"Album name (Enter = CDDB/Vision-LLM): ",
|
||||||
|
scanner,
|
||||||
|
)
|
||||||
|
album_name = raw if raw else f"Album{album_counter}"
|
||||||
|
|
||||||
disc_counter = 1
|
disc_counter = 1
|
||||||
all_discs: list[DiscModel] = []
|
all_discs: list[DiscModel] = []
|
||||||
cddb_album: str | None = None
|
cddb_album: str | None = None
|
||||||
|
|
||||||
|
# Vision-LLM sofort starten wenn Foto bereits beim Prompt hochgeladen wurde
|
||||||
|
vision_queue = None
|
||||||
|
uploaded_photo: Path | None = None
|
||||||
|
if photo_from_prompt:
|
||||||
|
uploaded_photo = photo_from_prompt
|
||||||
|
print(" Backcover-Foto empfangen → Vision-LLM-Analyse gestartet...", flush=True)
|
||||||
|
vision_queue = _start_vision_thread(
|
||||||
|
photo_from_prompt, config.vision_model, config.vision_url
|
||||||
|
)
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
|
# Foto prüfen (non-blocking) — Vision-LLM starten falls noch nicht geschehen
|
||||||
|
if vision_queue is None:
|
||||||
|
photo = scanner.get_photo(timeout=0)
|
||||||
|
if photo:
|
||||||
|
uploaded_photo = photo
|
||||||
|
print(
|
||||||
|
" Backcover-Foto empfangen → Vision-LLM-Analyse gestartet...",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
vision_queue = _start_vision_thread(
|
||||||
|
photo, config.vision_model, config.vision_url
|
||||||
|
)
|
||||||
|
|
||||||
print(f"\n Album: {album_name}")
|
print(f"\n Album: {album_name}")
|
||||||
print(f" CD Drive: {config.device}")
|
print(f" CD Drive: {config.device}")
|
||||||
|
|
||||||
|
|
@ -697,17 +920,76 @@ def interactive_rip(config: RipperConfig) -> None:
|
||||||
# album_root = tatsächliches Elternverzeichnis der CD-Ordner
|
# album_root = tatsächliches Elternverzeichnis der CD-Ordner
|
||||||
album_root = disc_dir.parent
|
album_root = disc_dir.parent
|
||||||
|
|
||||||
if all_discs:
|
# Vision-LLM-Ergebnis: Priorität über CDDB-Daten
|
||||||
|
final_album: AlbumModel | None = None
|
||||||
|
if vision_queue is not None:
|
||||||
|
print(" Warte auf Vision-LLM-Ergebnis ...", flush=True)
|
||||||
|
vision_result = _get_vision_result(vision_queue, timeout=120.0)
|
||||||
|
if vision_result:
|
||||||
|
n_tracks = sum(len(d.tracks) for d in vision_result.discs)
|
||||||
|
print(
|
||||||
|
f" ✓ Vision-LLM: {n_tracks} Tracks aus Backcover extrahiert"
|
||||||
|
f" (Priorität über CDDB)",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
final_album = vision_result
|
||||||
|
else:
|
||||||
|
print(" Vision-LLM: kein Ergebnis — verwende CDDB-Daten.", flush=True)
|
||||||
|
|
||||||
|
if final_album is None and all_discs:
|
||||||
artist = all_discs[0].tracks[0].artist or ""
|
artist = all_discs[0].tracks[0].artist or ""
|
||||||
album_model = AlbumModel(artist=artist, album=album_name, discs=all_discs)
|
final_album = AlbumModel(artist=artist, album=album_name, discs=all_discs)
|
||||||
|
|
||||||
|
# MBID aus abcde-Temp-Verzeichnis ermitteln → CAA-Cover laden
|
||||||
|
# (funktioniert auch wenn der Barcode nicht in MusicBrainz verknüpft war)
|
||||||
|
abcde_mbid: str | None = None
|
||||||
|
for cd_dir in sorted(album_root.glob("CD*")):
|
||||||
|
abcde_mbid = _find_abcde_mbid(cd_dir)
|
||||||
|
if abcde_mbid:
|
||||||
|
break
|
||||||
|
if abcde_mbid:
|
||||||
|
print(f" MusicBrainz-ID aus CD-Daten: {abcde_mbid}", flush=True)
|
||||||
|
print(" Lade Cover vom Cover Art Archive...", flush=True)
|
||||||
|
download_caa_covers(abcde_mbid, album_root)
|
||||||
|
|
||||||
|
if final_album is not None:
|
||||||
|
# Verzeichnis umbenennen, wenn Vision-LLM/CDDB einen anderen
|
||||||
|
# Namen lieferte als der Platzhalter (z. B. "Album1")
|
||||||
|
proper_name = _sanitize_name(final_album.album or album_name)
|
||||||
|
if proper_name and proper_name != album_root.name:
|
||||||
|
new_root = album_root.parent / proper_name
|
||||||
|
if new_root.exists():
|
||||||
|
print(
|
||||||
|
f" Hinweis: '{proper_name}' existiert bereits"
|
||||||
|
" — Verzeichnis nicht umbenannt.",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
album_root.rename(new_root)
|
||||||
|
print(
|
||||||
|
f" Verzeichnis umbenannt: {album_root.name} → {proper_name}",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
album_root = new_root
|
||||||
|
|
||||||
album_root.mkdir(parents=True, exist_ok=True)
|
album_root.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Hochgeladenes Backcover ins Album-Verzeichnis kopieren
|
||||||
|
# (überschreibt ggf. das CAA-Backcover — das Handy-Foto hat Vorrang)
|
||||||
|
if uploaded_photo and uploaded_photo.exists():
|
||||||
|
dest = album_root / "backcover.jpg"
|
||||||
|
prepare_cover(uploaded_photo, dest)
|
||||||
|
print(f" backcover.jpg gespeichert: {dest}", flush=True)
|
||||||
|
|
||||||
json_path = album_root / "album.json"
|
json_path = album_root / "album.json"
|
||||||
json_path.write_text(
|
json_path.write_text(
|
||||||
album_model.model_dump_json(indent=2), encoding="utf-8"
|
final_album.model_dump_json(indent=2), encoding="utf-8"
|
||||||
)
|
)
|
||||||
print(f"\n album.json gespeichert: {json_path}")
|
print(f"\n album.json gespeichert: {json_path}")
|
||||||
print(" → Weiter mit: musiksammlung apply <album-verzeichnis> album.json")
|
_print_apply_hint(album_root, json_path, len(final_album.discs))
|
||||||
|
processed_albums.append((album_root, json_path, len(final_album.discs)))
|
||||||
|
|
||||||
|
scanner.stop()
|
||||||
raw_album = input("\nNext album? (y/n): ")
|
raw_album = input("\nNext album? (y/n): ")
|
||||||
if _clean_input(raw_album).lower() != "y":
|
if _clean_input(raw_album).lower() != "y":
|
||||||
break
|
break
|
||||||
|
|
@ -717,11 +999,22 @@ def interactive_rip(config: RipperConfig) -> None:
|
||||||
print("\n" + "=" * 60)
|
print("\n" + "=" * 60)
|
||||||
print("Ripping completed!")
|
print("Ripping completed!")
|
||||||
print(f"Files are in: {config.output_dir.absolute()}")
|
print(f"Files are in: {config.output_dir.absolute()}")
|
||||||
print("\nNext steps:")
|
|
||||||
print(" 1. Check filenames and tags")
|
if processed_albums:
|
||||||
if config.use_cddb:
|
print("\nNext steps:")
|
||||||
print(" 2. Adjust tags/covers with 'musiksammlung apply'")
|
for i, (album_root, json_path, num_discs) in enumerate(processed_albums, 1):
|
||||||
else:
|
input_dir = album_root / "CD1" if num_discs == 1 else album_root
|
||||||
print(" 2. Run 'musiksammlung scan' to extract metadata")
|
front_flag = (
|
||||||
print(" 3. Run 'musiksammlung apply' to organize & tag")
|
"" if (album_root / "frontcover.jpg").exists()
|
||||||
|
else " --front <cover.jpg>"
|
||||||
|
)
|
||||||
|
print(f"\n Album {i}: {album_root.name}")
|
||||||
|
print(f" 1. Prüfen/bearbeiten: {json_path}")
|
||||||
|
print(
|
||||||
|
f" 2. musiksammlung apply"
|
||||||
|
f" {input_dir}"
|
||||||
|
f" {json_path}"
|
||||||
|
f"{front_flag}"
|
||||||
|
f" <jellyfin-verzeichnis>"
|
||||||
|
)
|
||||||
print("=" * 60 + "\n")
|
print("=" * 60 + "\n")
|
||||||
|
|
|
||||||
274
src/musiksammlung/scanner_server.py
Normal file
274
src/musiksammlung/scanner_server.py
Normal file
|
|
@ -0,0 +1,274 @@
|
||||||
|
"""Mini-HTTP-Server für Handy-basiertes Backcover-Upload.
|
||||||
|
|
||||||
|
Startet einen lokalen HTTP-Server (kein HTTPS nötig, da nur file-input,
|
||||||
|
kein getUserMedia), der eine mobile Upload-Seite ausliefert und
|
||||||
|
hochgeladene Fotos in einer Queue bereitstellt.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import queue
|
||||||
|
import socket
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_UPLOAD_HTML = """\
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>CD-Backcover</title>
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
|
||||||
|
background: #1a1a2e; color: #eee;
|
||||||
|
min-height: 100vh; display: flex; flex-direction: column;
|
||||||
|
align-items: center; justify-content: flex-start;
|
||||||
|
padding: 1.5em 1em;
|
||||||
|
}
|
||||||
|
h2 { margin-bottom: 0.3em; font-size: 1.4em; }
|
||||||
|
p { color: #aaa; margin-bottom: 1.5em; font-size: 0.95em; }
|
||||||
|
label.btn {
|
||||||
|
display: block; width: 100%; max-width: 420px;
|
||||||
|
padding: 0.9em; background: #1a7bd4; color: #fff;
|
||||||
|
text-align: center; border-radius: 10px;
|
||||||
|
font-size: 1.1em; cursor: pointer; margin-bottom: 0.8em;
|
||||||
|
}
|
||||||
|
input[type=file] { display: none; }
|
||||||
|
#preview {
|
||||||
|
width: 100%; max-width: 420px; border-radius: 10px;
|
||||||
|
display: none; margin-bottom: 0.8em;
|
||||||
|
}
|
||||||
|
button#upload-btn {
|
||||||
|
display: none; width: 100%; max-width: 420px;
|
||||||
|
padding: 0.9em; background: #27ae60; color: #fff;
|
||||||
|
border: none; border-radius: 10px; font-size: 1.1em;
|
||||||
|
cursor: pointer; margin-bottom: 0.8em;
|
||||||
|
}
|
||||||
|
button#upload-btn:disabled { background: #555; cursor: default; }
|
||||||
|
#status {
|
||||||
|
width: 100%; max-width: 420px;
|
||||||
|
padding: 0.8em 1em; border-radius: 8px;
|
||||||
|
text-align: center; display: none; font-size: 1em;
|
||||||
|
}
|
||||||
|
.ok { background: #1e4d2b; color: #7dffaa; }
|
||||||
|
.err { background: #4d1e1e; color: #ff9999; }
|
||||||
|
.wait{ background: #1e2d4d; color: #99ccff; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h2>CD-Backcover hochladen</h2>
|
||||||
|
<p>Fotografiere die Rückseite der CD-Hülle und lade das Bild hoch.</p>
|
||||||
|
|
||||||
|
<label class="btn" for="photo">Foto aufnehmen / auswählen</label>
|
||||||
|
<input type="file" id="photo" accept="image/*">
|
||||||
|
<img id="preview" alt="Vorschau">
|
||||||
|
<button id="upload-btn">Hochladen</button>
|
||||||
|
<div id="status"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const input = document.getElementById('photo');
|
||||||
|
const preview = document.getElementById('preview');
|
||||||
|
const btn = document.getElementById('upload-btn');
|
||||||
|
const status = document.getElementById('status');
|
||||||
|
|
||||||
|
input.onchange = () => {
|
||||||
|
const file = input.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
preview.src = URL.createObjectURL(file);
|
||||||
|
preview.style.display = 'block';
|
||||||
|
btn.style.display = 'block';
|
||||||
|
status.style.display = 'none';
|
||||||
|
};
|
||||||
|
|
||||||
|
btn.onclick = async () => {
|
||||||
|
const file = input.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
btn.disabled = true;
|
||||||
|
status.className = 'wait';
|
||||||
|
status.style.display = 'block';
|
||||||
|
status.textContent = 'Wird hochgeladen\u2026';
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = async () => {
|
||||||
|
try {
|
||||||
|
const r = await fetch('/upload', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ image: reader.result })
|
||||||
|
});
|
||||||
|
const data = await r.json();
|
||||||
|
if (data.status === 'ok') {
|
||||||
|
status.className = 'ok';
|
||||||
|
status.textContent = '\u2713 Erfolgreich hochgeladen! KI analysiert das Cover\u2026';
|
||||||
|
} else {
|
||||||
|
throw new Error(data.message || 'Unbekannter Fehler');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
status.className = 'err';
|
||||||
|
status.textContent = 'Fehler: ' + e.message;
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _get_local_ip() -> str:
|
||||||
|
"""Ermittelt die lokale LAN-IP-Adresse."""
|
||||||
|
try:
|
||||||
|
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
|
||||||
|
s.connect(("8.8.8.8", 80))
|
||||||
|
return s.getsockname()[0]
|
||||||
|
except OSError:
|
||||||
|
return "127.0.0.1"
|
||||||
|
|
||||||
|
|
||||||
|
def print_qr(url: str) -> None:
|
||||||
|
"""Gibt einen QR-Code für die URL im Terminal aus.
|
||||||
|
|
||||||
|
Zeigt alternativ nur die URL, wenn qrcode nicht installiert ist.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import qrcode # type: ignore[import-untyped]
|
||||||
|
qr = qrcode.QRCode(border=1)
|
||||||
|
qr.add_data(url)
|
||||||
|
qr.make(fit=True)
|
||||||
|
qr.print_ascii(invert=True)
|
||||||
|
except ImportError:
|
||||||
|
pass # qrcode optional — URL wird sowieso separat ausgegeben
|
||||||
|
|
||||||
|
|
||||||
|
class ScannerServer:
|
||||||
|
"""Lokaler HTTP-Server für Backcover-Foto-Upload vom Handy.
|
||||||
|
|
||||||
|
Beispiel:
|
||||||
|
server = ScannerServer(port=8765)
|
||||||
|
server.start()
|
||||||
|
print(server.url())
|
||||||
|
photo_path = server.get_photo(timeout=300)
|
||||||
|
server.stop()
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, port: int = 8765, upload_dir: Path | None = None) -> None:
|
||||||
|
self._port = port
|
||||||
|
self._queue: queue.Queue[Path] = queue.Queue()
|
||||||
|
self._upload_dir = upload_dir or Path(tempfile.mkdtemp(prefix="ms_scan_"))
|
||||||
|
self._server: HTTPServer | None = None
|
||||||
|
self._thread: threading.Thread | None = None
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Public API
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
"""Startet den HTTP-Server im Hintergrund-Thread."""
|
||||||
|
server_instance = self
|
||||||
|
|
||||||
|
class _Handler(BaseHTTPRequestHandler):
|
||||||
|
def log_message(self, fmt: str, *args: object) -> None: # suppress output
|
||||||
|
logger.debug("Scanner HTTP: " + fmt, *args)
|
||||||
|
|
||||||
|
def do_GET(self) -> None: # noqa: N802
|
||||||
|
if self.path in ("/", "/index.html"):
|
||||||
|
body = _UPLOAD_HTML.encode("utf-8")
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
else:
|
||||||
|
self.send_response(404)
|
||||||
|
self.end_headers()
|
||||||
|
|
||||||
|
def do_POST(self) -> None: # noqa: N802
|
||||||
|
if self.path != "/upload":
|
||||||
|
self.send_response(404)
|
||||||
|
self.end_headers()
|
||||||
|
return
|
||||||
|
|
||||||
|
length = int(self.headers.get("Content-Length", 0))
|
||||||
|
body = self.rfile.read(length)
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = json.loads(body)
|
||||||
|
img_data: str = data["image"] # "data:<mime>;base64,<bytes>"
|
||||||
|
|
||||||
|
# Mime-Typ und Erweiterung bestimmen
|
||||||
|
mime = "image/jpeg"
|
||||||
|
if img_data.startswith("data:"):
|
||||||
|
mime = img_data.split(";")[0][5:]
|
||||||
|
ext = {
|
||||||
|
"image/jpeg": ".jpg",
|
||||||
|
"image/png": ".png",
|
||||||
|
"image/webp": ".webp",
|
||||||
|
}.get(mime, ".jpg")
|
||||||
|
|
||||||
|
_, b64 = img_data.split(",", 1)
|
||||||
|
img_bytes = base64.b64decode(b64)
|
||||||
|
|
||||||
|
path = server_instance._upload_dir / f"backcover{ext}"
|
||||||
|
path.write_bytes(img_bytes)
|
||||||
|
logger.info("Backcover hochgeladen: %s (%d bytes)", path, len(img_bytes))
|
||||||
|
server_instance._queue.put(path)
|
||||||
|
|
||||||
|
resp = json.dumps({"status": "ok"}).encode()
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "application/json")
|
||||||
|
self.send_header("Content-Length", str(len(resp)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(resp)
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Upload-Fehler: %s", exc)
|
||||||
|
resp = json.dumps({"status": "error", "message": str(exc)}).encode()
|
||||||
|
self.send_response(400)
|
||||||
|
self.send_header("Content-Type", "application/json")
|
||||||
|
self.send_header("Content-Length", str(len(resp)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(resp)
|
||||||
|
|
||||||
|
self._server = HTTPServer(("0.0.0.0", self._port), _Handler)
|
||||||
|
self._thread = threading.Thread(target=self._server.serve_forever, daemon=True)
|
||||||
|
self._thread.start()
|
||||||
|
logger.info("Scanner-Server gestartet: %s", self.url())
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
"""Beendet den HTTP-Server."""
|
||||||
|
if self._server:
|
||||||
|
self._server.shutdown()
|
||||||
|
self._server = None
|
||||||
|
logger.info("Scanner-Server gestoppt.")
|
||||||
|
|
||||||
|
def get_photo(self, timeout: float | None = None) -> Path | None:
|
||||||
|
"""Gibt den Pfad zum hochgeladenen Foto zurück (blockierend mit Timeout).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
timeout: Wartezeit in Sekunden. None = unbegrenzt. 0 = sofort.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Pfad zur Bilddatei oder None wenn Timeout abgelaufen.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if timeout == 0:
|
||||||
|
return self._queue.get_nowait()
|
||||||
|
return self._queue.get(timeout=timeout)
|
||||||
|
except queue.Empty:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def url(self) -> str:
|
||||||
|
"""Gibt die URL des Servers im LAN zurück."""
|
||||||
|
return f"http://{_get_local_ip()}:{self._port}"
|
||||||
|
|
@ -46,6 +46,45 @@ VISION_PROMPT += """
|
||||||
Jetzt lies das Bild ab und gib das vollständige JSON aus. /no_think"""
|
Jetzt lies das Bild ab und gib das vollständige JSON aus. /no_think"""
|
||||||
|
|
||||||
|
|
||||||
|
EAN_PROMPT = """\
|
||||||
|
Schau dir das Bild an. Es zeigt eine CD-Hülle oder Produktverpackung.
|
||||||
|
Suche den EAN-13 oder UPC-A Barcode — die Ziffernreihe unter dem Strichcode-Symbol.
|
||||||
|
Gib NUR die Ziffern aus, ohne Leerzeichen, ohne Erklärung, kein weiterer Text.
|
||||||
|
Wenn kein Barcode erkennbar ist, gib einen leeren String zurück. /no_think"""
|
||||||
|
|
||||||
|
|
||||||
|
def extract_barcode_from_image(
|
||||||
|
image_path: Path,
|
||||||
|
model: str = "qwen3-vl:235b-cloud",
|
||||||
|
base_url: str = "http://localhost:11434",
|
||||||
|
) -> str | None:
|
||||||
|
"""Extrahiert EAN/Barcode-Nummer aus einem Foto via Vision-LLM.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Nur Ziffern (z.B. '4006408262121') oder None wenn nicht erkannt.
|
||||||
|
"""
|
||||||
|
images_b64 = [_encode_image(image_path)]
|
||||||
|
messages = [{"role": "user", "content": EAN_PROMPT, "images": images_b64}]
|
||||||
|
try:
|
||||||
|
response = httpx.post(
|
||||||
|
f"{base_url}/api/chat",
|
||||||
|
json={"model": model, "messages": messages, "stream": False},
|
||||||
|
timeout=60.0,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
raw = response.json()["message"]["content"]
|
||||||
|
raw = re.sub(r"<think>.*?</think>", "", raw, flags=re.DOTALL).strip()
|
||||||
|
digits = re.sub(r"\D", "", raw)
|
||||||
|
if digits:
|
||||||
|
logger.info("Barcode aus Bild extrahiert: %s", digits)
|
||||||
|
return digits
|
||||||
|
logger.warning("Kein Barcode im Bild erkannt")
|
||||||
|
return None
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Barcode-Extraktion fehlgeschlagen: %s", exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _encode_image(image_path: Path) -> str:
|
def _encode_image(image_path: Path) -> str:
|
||||||
"""Liest ein Bild und gibt es als Base64-String zurück."""
|
"""Liest ein Bild und gibt es als Base64-String zurück."""
|
||||||
return base64.b64encode(image_path.read_bytes()).decode("utf-8")
|
return base64.b64encode(image_path.read_bytes()).decode("utf-8")
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,6 @@
|
||||||
|
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
from musiksammlung.cddb import CddbResult, _read_gnudb, lookup_by_discid
|
from musiksammlung.cddb import CddbResult, _read_gnudb, lookup_by_discid
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,13 @@
|
||||||
"""Tests für Cover-Funktionen."""
|
"""Tests für Cover-Funktionen."""
|
||||||
|
|
||||||
|
from io import BytesIO
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import httpx
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
from musiksammlung.cover import copy_covers, find_cover, prepare_cover
|
from musiksammlung.cover import copy_covers, download_caa_covers, find_cover, prepare_cover
|
||||||
|
|
||||||
|
|
||||||
def _make_image(path: Path, size: tuple[int, int] = (100, 100), mode: str = "RGB") -> Path:
|
def _make_image(path: Path, size: tuple[int, int] = (100, 100), mode: str = "RGB") -> Path:
|
||||||
|
|
@ -119,3 +122,90 @@ class TestCopyCovers:
|
||||||
original_mtime = existing.stat().st_mtime
|
original_mtime = existing.stat().st_mtime
|
||||||
copy_covers(None, None, tmp_path)
|
copy_covers(None, None, tmp_path)
|
||||||
assert (tmp_path / "frontcover.jpg").stat().st_mtime == original_mtime
|
assert (tmp_path / "frontcover.jpg").stat().st_mtime == original_mtime
|
||||||
|
|
||||||
|
|
||||||
|
def _fake_image_bytes() -> bytes:
|
||||||
|
"""Erzeugt ein gültiges JPEG-Bild als bytes."""
|
||||||
|
buf = BytesIO()
|
||||||
|
Image.new("RGB", (200, 200), (100, 150, 200)).save(buf, "JPEG")
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def _mock_caa_response(status_code: int = 200, content: bytes = b"") -> MagicMock:
|
||||||
|
"""Erstellt ein Mock-httpx.Response für CAA-Requests."""
|
||||||
|
resp = MagicMock(spec=httpx.Response)
|
||||||
|
resp.status_code = status_code
|
||||||
|
resp.content = content
|
||||||
|
resp.raise_for_status.return_value = None
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
class TestDownloadCaaCovers:
|
||||||
|
"""Tests für download_caa_covers."""
|
||||||
|
|
||||||
|
def test_downloads_front_and_back(self, tmp_path: Path) -> None:
|
||||||
|
"""Beide Cover werden heruntergeladen und als JPEG gespeichert."""
|
||||||
|
img_bytes = _fake_image_bytes()
|
||||||
|
resp = _mock_caa_response(200, img_bytes)
|
||||||
|
|
||||||
|
with patch("musiksammlung.cover.httpx.get", return_value=resp):
|
||||||
|
download_caa_covers("test-mbid", tmp_path)
|
||||||
|
|
||||||
|
assert (tmp_path / "frontcover.jpg").exists()
|
||||||
|
assert (tmp_path / "backcover.jpg").exists()
|
||||||
|
# Ergebnis ist ein gültiges JPEG
|
||||||
|
assert Image.open(tmp_path / "frontcover.jpg").format == "JPEG"
|
||||||
|
|
||||||
|
def test_404_skips_cover(self, tmp_path: Path) -> None:
|
||||||
|
"""404 → kein Cover, kein Fehler."""
|
||||||
|
resp_404 = _mock_caa_response(404)
|
||||||
|
|
||||||
|
with patch("musiksammlung.cover.httpx.get", return_value=resp_404):
|
||||||
|
download_caa_covers("no-cover-mbid", tmp_path)
|
||||||
|
|
||||||
|
assert not (tmp_path / "frontcover.jpg").exists()
|
||||||
|
assert not (tmp_path / "backcover.jpg").exists()
|
||||||
|
|
||||||
|
def test_http_error_continues(self, tmp_path: Path) -> None:
|
||||||
|
"""Netzwerkfehler → Warnung, kein Abbruch."""
|
||||||
|
with patch(
|
||||||
|
"musiksammlung.cover.httpx.get",
|
||||||
|
side_effect=httpx.HTTPError("timeout"),
|
||||||
|
):
|
||||||
|
download_caa_covers("error-mbid", tmp_path)
|
||||||
|
|
||||||
|
assert not (tmp_path / "frontcover.jpg").exists()
|
||||||
|
assert not (tmp_path / "backcover.jpg").exists()
|
||||||
|
|
||||||
|
def test_skips_existing_cover(self, tmp_path: Path) -> None:
|
||||||
|
"""Bereits vorhandene Cover werden nicht überschrieben."""
|
||||||
|
existing = _make_image(tmp_path / "frontcover.jpg")
|
||||||
|
original_size = existing.stat().st_size
|
||||||
|
|
||||||
|
img_bytes = _fake_image_bytes()
|
||||||
|
resp = _mock_caa_response(200, img_bytes)
|
||||||
|
|
||||||
|
with patch("musiksammlung.cover.httpx.get", return_value=resp) as mock_get:
|
||||||
|
download_caa_covers("test-mbid", tmp_path)
|
||||||
|
|
||||||
|
# frontcover.jpg bleibt unverändert
|
||||||
|
assert (tmp_path / "frontcover.jpg").stat().st_size == original_size
|
||||||
|
# backcover.jpg wird heruntergeladen (war nicht vorhanden)
|
||||||
|
assert (tmp_path / "backcover.jpg").exists()
|
||||||
|
# Nur ein HTTP-Request (für back), nicht zwei
|
||||||
|
assert mock_get.call_count == 1
|
||||||
|
|
||||||
|
def test_front_only_on_back_404(self, tmp_path: Path) -> None:
|
||||||
|
"""Front 200, Back 404 → nur frontcover.jpg erstellt."""
|
||||||
|
img_bytes = _fake_image_bytes()
|
||||||
|
resp_ok = _mock_caa_response(200, img_bytes)
|
||||||
|
resp_404 = _mock_caa_response(404)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"musiksammlung.cover.httpx.get",
|
||||||
|
side_effect=[resp_ok, resp_404],
|
||||||
|
):
|
||||||
|
download_caa_covers("mixed-mbid", tmp_path)
|
||||||
|
|
||||||
|
assert (tmp_path / "frontcover.jpg").exists()
|
||||||
|
assert not (tmp_path / "backcover.jpg").exists()
|
||||||
|
|
|
||||||
|
|
@ -160,11 +160,12 @@ class TestLookupByBarcode:
|
||||||
patch("musiksammlung.musicbrainz.httpx.get", side_effect=responses),
|
patch("musiksammlung.musicbrainz.httpx.get", side_effect=responses),
|
||||||
patch("musiksammlung.musicbrainz.time.sleep"),
|
patch("musiksammlung.musicbrainz.time.sleep"),
|
||||||
):
|
):
|
||||||
album = lookup_by_barcode("0602557360561")
|
album, mbid = lookup_by_barcode("0602557360561")
|
||||||
|
|
||||||
assert album.artist == "The Beatles"
|
assert album.artist == "The Beatles"
|
||||||
assert album.album == "Abbey Road"
|
assert album.album == "Abbey Road"
|
||||||
assert album.year == 1969
|
assert album.year == 1969
|
||||||
|
assert mbid == "abc-123"
|
||||||
|
|
||||||
def test_raises_when_no_releases(self) -> None:
|
def test_raises_when_no_releases(self) -> None:
|
||||||
empty = _mock_response({"releases": []})
|
empty = _mock_response({"releases": []})
|
||||||
|
|
@ -173,7 +174,7 @@ class TestLookupByBarcode:
|
||||||
patch("musiksammlung.musicbrainz.time.sleep"),
|
patch("musiksammlung.musicbrainz.time.sleep"),
|
||||||
pytest.raises(ValueError, match="Kein MusicBrainz-Eintrag"),
|
pytest.raises(ValueError, match="Kein MusicBrainz-Eintrag"),
|
||||||
):
|
):
|
||||||
lookup_by_barcode("0000000000000")
|
lookup_by_barcode("0000000000000") # raises before returning tuple
|
||||||
|
|
||||||
def test_uses_first_release(self) -> None:
|
def test_uses_first_release(self) -> None:
|
||||||
barcode_data = {"releases": [{"id": "first-id"}, {"id": "second-id"}]}
|
barcode_data = {"releases": [{"id": "first-id"}, {"id": "second-id"}]}
|
||||||
|
|
@ -182,11 +183,12 @@ class TestLookupByBarcode:
|
||||||
patch("musiksammlung.musicbrainz.httpx.get", side_effect=responses) as mock_get,
|
patch("musiksammlung.musicbrainz.httpx.get", side_effect=responses) as mock_get,
|
||||||
patch("musiksammlung.musicbrainz.time.sleep"),
|
patch("musiksammlung.musicbrainz.time.sleep"),
|
||||||
):
|
):
|
||||||
lookup_by_barcode("1234567890123")
|
_album, mbid = lookup_by_barcode("1234567890123")
|
||||||
|
|
||||||
# Zweiter Request muss die MBID des ersten Treffers verwenden
|
# Zweiter Request muss die MBID des ersten Treffers verwenden
|
||||||
second_call_url = mock_get.call_args_list[1][0][0]
|
second_call_url = mock_get.call_args_list[1][0][0]
|
||||||
assert "first-id" in second_call_url
|
assert "first-id" in second_call_url
|
||||||
|
assert mbid == "first-id"
|
||||||
|
|
||||||
def test_rate_limit_sleep_is_called(self) -> None:
|
def test_rate_limit_sleep_is_called(self) -> None:
|
||||||
responses = [_mock_response(_BARCODE_RESPONSE), _mock_response(_RELEASE_RESPONSE)]
|
responses = [_mock_response(_BARCODE_RESPONSE), _mock_response(_RELEASE_RESPONSE)]
|
||||||
|
|
@ -194,7 +196,7 @@ class TestLookupByBarcode:
|
||||||
patch("musiksammlung.musicbrainz.httpx.get", side_effect=responses),
|
patch("musiksammlung.musicbrainz.httpx.get", side_effect=responses),
|
||||||
patch("musiksammlung.musicbrainz.time.sleep") as mock_sleep,
|
patch("musiksammlung.musicbrainz.time.sleep") as mock_sleep,
|
||||||
):
|
):
|
||||||
lookup_by_barcode("0602557360561")
|
_album, _mbid = lookup_by_barcode("0602557360561")
|
||||||
|
|
||||||
mock_sleep.assert_called_once()
|
mock_sleep.assert_called_once()
|
||||||
assert mock_sleep.call_args[0][0] >= 1.0
|
assert mock_sleep.call_args[0][0] >= 1.0
|
||||||
|
|
@ -206,4 +208,4 @@ class TestLookupByBarcode:
|
||||||
patch("musiksammlung.musicbrainz.httpx.get", side_effect=httpx.HTTPError("timeout")),
|
patch("musiksammlung.musicbrainz.httpx.get", side_effect=httpx.HTTPError("timeout")),
|
||||||
pytest.raises(httpx.HTTPError),
|
pytest.raises(httpx.HTTPError),
|
||||||
):
|
):
|
||||||
lookup_by_barcode("0000000000000")
|
lookup_by_barcode("0000000000000") # raises before returning tuple
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,13 @@ class TestSanitizeName:
|
||||||
assert _sanitize_name("Test|Track?Name*") == "TestTrackName"
|
assert _sanitize_name("Test|Track?Name*") == "TestTrackName"
|
||||||
assert _sanitize_name("It's_a_Test") == "Its_a_Test"
|
assert _sanitize_name("It's_a_Test") == "Its_a_Test"
|
||||||
|
|
||||||
|
def test_remove_brackets_and_punctuation(self) -> None:
|
||||||
|
assert _sanitize_name("Best of (1990)") == "Best_of_1990"
|
||||||
|
assert _sanitize_name("Hello, World!") == "Hello_World"
|
||||||
|
assert _sanitize_name("Vol. 2") == "Vol_2"
|
||||||
|
assert _sanitize_name("Salt & Pepper [Remix]") == "Salt_Pepper_Remix"
|
||||||
|
assert _sanitize_name("The Best of... (Deluxe Edition)") == "The_Best_of_Deluxe_Edition"
|
||||||
|
|
||||||
def test_keep_umlauts(self) -> None:
|
def test_keep_umlauts(self) -> None:
|
||||||
assert _sanitize_name("Grüße aus Österreich") == "Grüße_aus_Österreich"
|
assert _sanitize_name("Grüße aus Österreich") == "Grüße_aus_Österreich"
|
||||||
assert _sanitize_name("Schöne Sänger") == "Schöne_Sänger"
|
assert _sanitize_name("Schöne Sänger") == "Schöne_Sänger"
|
||||||
|
|
@ -349,10 +356,13 @@ class TestInteractiveRipEanFirst:
|
||||||
"n", # next album?
|
"n", # next album?
|
||||||
]
|
]
|
||||||
config = RipperConfig(output_dir=tmp_path)
|
config = RipperConfig(output_dir=tmp_path)
|
||||||
|
sp = self._scanner_patches()
|
||||||
with (
|
with (
|
||||||
|
sp[0], sp[1],
|
||||||
patch("musiksammlung.ripper.rip_disc", return_value=(tmp_path, None, _CDDB_TRACKS)),
|
patch("musiksammlung.ripper.rip_disc", return_value=(tmp_path, None, _CDDB_TRACKS)),
|
||||||
patch("builtins.input", side_effect=iter(inputs)),
|
patch("builtins.input", side_effect=iter(inputs)),
|
||||||
patch("musiksammlung.ripper.lookup_by_barcode", return_value=_MB_ALBUM),
|
patch("musiksammlung.ripper.lookup_by_barcode", return_value=(_MB_ALBUM, "fake-mbid")),
|
||||||
|
patch("musiksammlung.ripper.download_caa_covers") as mock_caa,
|
||||||
):
|
):
|
||||||
interactive_rip(config)
|
interactive_rip(config)
|
||||||
|
|
||||||
|
|
@ -363,6 +373,7 @@ class TestInteractiveRipEanFirst:
|
||||||
assert data["artist"] == "The Beatles"
|
assert data["artist"] == "The Beatles"
|
||||||
assert data["album"] == "Abbey Road"
|
assert data["album"] == "Abbey Road"
|
||||||
assert data["year"] == 1969
|
assert data["year"] == 1969
|
||||||
|
mock_caa.assert_called_once_with("fake-mbid", tmp_path / "Abbey_Road")
|
||||||
|
|
||||||
def test_mb_hit_auto_rip_multi_disc(self, tmp_path: Path) -> None:
|
def test_mb_hit_auto_rip_multi_disc(self, tmp_path: Path) -> None:
|
||||||
"""MB-Treffer mit 2 Discs → 2x Disc-Insert-Prompt, album.json aus MB."""
|
"""MB-Treffer mit 2 Discs → 2x Disc-Insert-Prompt, album.json aus MB."""
|
||||||
|
|
@ -373,10 +384,14 @@ class TestInteractiveRipEanFirst:
|
||||||
"n", # next album?
|
"n", # next album?
|
||||||
]
|
]
|
||||||
config = RipperConfig(output_dir=tmp_path)
|
config = RipperConfig(output_dir=tmp_path)
|
||||||
|
sp = self._scanner_patches()
|
||||||
with (
|
with (
|
||||||
|
sp[0], sp[1],
|
||||||
patch("musiksammlung.ripper.rip_disc", return_value=(tmp_path, None, _CDDB_TRACKS)),
|
patch("musiksammlung.ripper.rip_disc", return_value=(tmp_path, None, _CDDB_TRACKS)),
|
||||||
patch("builtins.input", side_effect=iter(inputs)),
|
patch("builtins.input", side_effect=iter(inputs)),
|
||||||
patch("musiksammlung.ripper.lookup_by_barcode", return_value=_MB_ALBUM_2DISC),
|
patch("musiksammlung.ripper.lookup_by_barcode",
|
||||||
|
return_value=(_MB_ALBUM_2DISC, "fake-mbid-2")),
|
||||||
|
patch("musiksammlung.ripper.download_caa_covers"),
|
||||||
):
|
):
|
||||||
interactive_rip(config)
|
interactive_rip(config)
|
||||||
|
|
||||||
|
|
@ -397,10 +412,14 @@ class TestInteractiveRipEanFirst:
|
||||||
"n", # next album?
|
"n", # next album?
|
||||||
]
|
]
|
||||||
config = RipperConfig(output_dir=tmp_path)
|
config = RipperConfig(output_dir=tmp_path)
|
||||||
|
sp = self._scanner_patches()
|
||||||
with (
|
with (
|
||||||
|
sp[0], sp[1],
|
||||||
patch("musiksammlung.ripper.rip_disc", return_value=(tmp_path, None, _CDDB_TRACKS)),
|
patch("musiksammlung.ripper.rip_disc", return_value=(tmp_path, None, _CDDB_TRACKS)),
|
||||||
patch("builtins.input", side_effect=iter(inputs)),
|
patch("builtins.input", side_effect=iter(inputs)),
|
||||||
patch("musiksammlung.ripper.lookup_by_barcode", return_value=_MB_ALBUM) as mock_lookup,
|
patch("musiksammlung.ripper.lookup_by_barcode",
|
||||||
|
return_value=(_MB_ALBUM, "fake-mbid")) as mock_lookup,
|
||||||
|
patch("musiksammlung.ripper.download_caa_covers"),
|
||||||
):
|
):
|
||||||
interactive_rip(config)
|
interactive_rip(config)
|
||||||
|
|
||||||
|
|
@ -419,10 +438,13 @@ class TestInteractiveRipEanFirst:
|
||||||
(disc_dir / "track01.flac").touch()
|
(disc_dir / "track01.flac").touch()
|
||||||
(disc_dir / "track02.flac").touch()
|
(disc_dir / "track02.flac").touch()
|
||||||
|
|
||||||
|
sp = self._scanner_patches()
|
||||||
with (
|
with (
|
||||||
|
sp[0], sp[1],
|
||||||
patch("musiksammlung.ripper.rip_disc", return_value=(disc_dir, None, _CDDB_TRACKS)),
|
patch("musiksammlung.ripper.rip_disc", return_value=(disc_dir, None, _CDDB_TRACKS)),
|
||||||
patch("builtins.input", side_effect=iter(inputs)),
|
patch("builtins.input", side_effect=iter(inputs)),
|
||||||
patch("musiksammlung.ripper.lookup_by_barcode", return_value=_MB_ALBUM),
|
patch("musiksammlung.ripper.lookup_by_barcode", return_value=(_MB_ALBUM, "fake-mbid")),
|
||||||
|
patch("musiksammlung.ripper.download_caa_covers"),
|
||||||
):
|
):
|
||||||
interactive_rip(config)
|
interactive_rip(config)
|
||||||
|
|
||||||
|
|
@ -430,7 +452,31 @@ class TestInteractiveRipEanFirst:
|
||||||
# Dateien existieren schon vorher, rename findet in _rename_files statt
|
# Dateien existieren schon vorher, rename findet in _rename_files statt
|
||||||
assert (tmp_path / "Abbey_Road" / "album.json").exists()
|
assert (tmp_path / "Abbey_Road" / "album.json").exists()
|
||||||
|
|
||||||
# ── Fallback (kein MB-Treffer / EAN leer) ──
|
# ── Gemeinsame Scanner-Patches (alle interactive_rip-Tests) ──
|
||||||
|
#
|
||||||
|
# Ab EAN-Prompt startet interactive_rip immer einen ScannerServer und
|
||||||
|
# nutzt _input_or_scan. Beide werden in allen Tests gemockt.
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _scanner_patches():
|
||||||
|
"""Patches für ScannerServer und _input_or_scan (alle interactive_rip-Tests)."""
|
||||||
|
mock_scanner = MagicMock()
|
||||||
|
mock_scanner.url.return_value = "http://127.0.0.1:8765"
|
||||||
|
mock_scanner.get_photo.return_value = None
|
||||||
|
return [
|
||||||
|
patch("musiksammlung.ripper.ScannerServer", return_value=mock_scanner),
|
||||||
|
patch(
|
||||||
|
"musiksammlung.ripper._input_or_scan",
|
||||||
|
side_effect=lambda prompt, scanner: (input(prompt), None),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _fallback_patches(inputs: list[str]):
|
||||||
|
"""Gemeinsame Patches für Fallback-Tests."""
|
||||||
|
patches = TestInteractiveRipEanFirst._scanner_patches()
|
||||||
|
patches.append(patch("builtins.input", side_effect=iter(inputs)))
|
||||||
|
return patches
|
||||||
|
|
||||||
def test_ean_empty_falls_back_to_album_prompt(self, tmp_path: Path) -> None:
|
def test_ean_empty_falls_back_to_album_prompt(self, tmp_path: Path) -> None:
|
||||||
"""Leere EAN → Fallback: Albumname wird abgefragt."""
|
"""Leere EAN → Fallback: Albumname wird abgefragt."""
|
||||||
|
|
@ -443,9 +489,10 @@ class TestInteractiveRipEanFirst:
|
||||||
"n", # next album?
|
"n", # next album?
|
||||||
]
|
]
|
||||||
config = RipperConfig(output_dir=tmp_path)
|
config = RipperConfig(output_dir=tmp_path)
|
||||||
|
patches = self._fallback_patches(inputs)
|
||||||
with (
|
with (
|
||||||
|
patches[0], patches[1], patches[2],
|
||||||
patch("musiksammlung.ripper.rip_disc", return_value=(tmp_path, None, _CDDB_TRACKS)),
|
patch("musiksammlung.ripper.rip_disc", return_value=(tmp_path, None, _CDDB_TRACKS)),
|
||||||
patch("builtins.input", side_effect=iter(inputs)),
|
|
||||||
patch("musiksammlung.ripper.lookup_by_barcode") as mock_lookup,
|
patch("musiksammlung.ripper.lookup_by_barcode") as mock_lookup,
|
||||||
):
|
):
|
||||||
interactive_rip(config)
|
interactive_rip(config)
|
||||||
|
|
@ -465,15 +512,16 @@ class TestInteractiveRipEanFirst:
|
||||||
"n", # next album?
|
"n", # next album?
|
||||||
]
|
]
|
||||||
config = RipperConfig(output_dir=tmp_path)
|
config = RipperConfig(output_dir=tmp_path)
|
||||||
|
patches = self._fallback_patches(inputs)
|
||||||
with (
|
with (
|
||||||
|
patches[0], patches[1], patches[2],
|
||||||
patch("musiksammlung.ripper.rip_disc", return_value=(tmp_path, None, _CDDB_TRACKS)),
|
patch("musiksammlung.ripper.rip_disc", return_value=(tmp_path, None, _CDDB_TRACKS)),
|
||||||
patch("builtins.input", side_effect=iter(inputs)),
|
|
||||||
patch(
|
patch(
|
||||||
"musiksammlung.ripper.lookup_by_barcode",
|
"musiksammlung.ripper.lookup_by_barcode",
|
||||||
side_effect=ValueError("Kein MusicBrainz-Eintrag"),
|
side_effect=ValueError("Kein MusicBrainz-Eintrag"),
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
interactive_rip(config) # darf nicht werfen
|
interactive_rip(config)
|
||||||
|
|
||||||
json_path = tmp_path / "Abbey_Road" / "album.json"
|
json_path = tmp_path / "Abbey_Road" / "album.json"
|
||||||
assert json_path.exists()
|
assert json_path.exists()
|
||||||
|
|
@ -489,9 +537,10 @@ class TestInteractiveRipEanFirst:
|
||||||
"n", # next album?
|
"n", # next album?
|
||||||
]
|
]
|
||||||
config = RipperConfig(output_dir=tmp_path)
|
config = RipperConfig(output_dir=tmp_path)
|
||||||
|
patches = self._fallback_patches(inputs)
|
||||||
with (
|
with (
|
||||||
|
patches[0], patches[1], patches[2],
|
||||||
patch("musiksammlung.ripper.rip_disc", return_value=(tmp_path, None, _CDDB_TRACKS)),
|
patch("musiksammlung.ripper.rip_disc", return_value=(tmp_path, None, _CDDB_TRACKS)),
|
||||||
patch("builtins.input", side_effect=iter(inputs)),
|
|
||||||
patch("musiksammlung.ripper.lookup_by_barcode"),
|
patch("musiksammlung.ripper.lookup_by_barcode"),
|
||||||
):
|
):
|
||||||
interactive_rip(config)
|
interactive_rip(config)
|
||||||
|
|
@ -514,9 +563,10 @@ class TestInteractiveRipEanFirst:
|
||||||
"n", # next album?
|
"n", # next album?
|
||||||
]
|
]
|
||||||
config = RipperConfig(output_dir=tmp_path)
|
config = RipperConfig(output_dir=tmp_path)
|
||||||
|
patches = self._fallback_patches(inputs)
|
||||||
with (
|
with (
|
||||||
|
patches[0], patches[1], patches[2],
|
||||||
patch("musiksammlung.ripper.rip_disc", return_value=(tmp_path, None, _CDDB_TRACKS)),
|
patch("musiksammlung.ripper.rip_disc", return_value=(tmp_path, None, _CDDB_TRACKS)),
|
||||||
patch("builtins.input", side_effect=iter(inputs)),
|
|
||||||
patch("musiksammlung.ripper.lookup_by_barcode"),
|
patch("musiksammlung.ripper.lookup_by_barcode"),
|
||||||
):
|
):
|
||||||
interactive_rip(config)
|
interactive_rip(config)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue