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:
Dieter Schlüter 2026-02-19 14:05:59 +01:00
commit 32c84b9edb
15 changed files with 1027 additions and 92 deletions

View file

@ -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 |

View file

@ -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)

View file

@ -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]

View file

@ -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,

View file

@ -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)

View file

@ -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)

View file

@ -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:

View file

@ -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

View file

@ -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")

View 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}"

View file

@ -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")

View file

@ -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

View file

@ -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()

View file

@ -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

View file

@ -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)