Add 4 new cover/tracklist sources: MB back cover, iTunes, Last.fm, Discogs tracklist
cover_handler.py: - _download_image(): shared helper replaces duplicated download logic - download_back_cover(): fetches back cover from MusicBrainz CAA (/back endpoint), saves as back.jpg; skips if already present - _itunes_cover_url() / download_itunes_cover(): iTunes Search API (no auth), requests 600x600 artwork; fallback after Discogs - _lastfm_cover_url() / download_lastfm_cover(): Last.fm album.getinfo (LASTFM_API_KEY env var); last cover fallback, skips placeholder images - resolve_cover(): extended with iTunes → Last.fm fallback chain metadata_resolver.py: - _discogs_get_tracklist(): fetches full Discogs release via REST API, parses tracklist[] including heading-based disc detection - _lastfm_tracklist(): fetches Last.fm album.getinfo tracks (LASTFM_API_KEY) - resolve(): uses Discogs tracklist → Last.fm tracklist as fallback when MusicBrainz returns no tracks; LASTFM_API_KEY added to env var block music_enricher.py: - process_album(): calls download_back_cover() after execute_album() when MBID known New cover priority: local → MusicBrainz front → Discogs → iTunes → Last.fm New tracklist priority: local → YouTube → MusicBrainz → Discogs → Last.fm → OCR Test suite: 29 → 33 tests (all pass) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
071f4c5e1d
commit
80472653b4
4 changed files with 273 additions and 33 deletions
130
cover_handler.py
130
cover_handler.py
|
|
@ -1,5 +1,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
|
|
@ -108,21 +110,14 @@ def _mb_cover_url(release_mbid: str) -> Optional[str]:
|
|||
return None
|
||||
|
||||
|
||||
def download_cover(release_mbid: Optional[str], dest_dir: Path) -> Optional[Path]:
|
||||
if not release_mbid or not HAS_REQUESTS:
|
||||
return None
|
||||
url = _mb_cover_url(release_mbid)
|
||||
if not url:
|
||||
return None
|
||||
dest = dest_dir / "folder.jpg"
|
||||
def _download_image(url: str, dest: Path, label: str = "") -> Optional[Path]:
|
||||
"""Hilfsfunktion: URL herunterladen, PNG→JPEG konvertieren, als dest speichern."""
|
||||
try:
|
||||
r = requests.get(url, timeout=15)
|
||||
r = requests.get(url, timeout=15, headers={"User-Agent": "MusicMetadataEnricher/1.0"})
|
||||
if r.status_code != 200:
|
||||
return None
|
||||
ct = r.headers.get("content-type", "")
|
||||
if "png" in ct and HAS_PIL:
|
||||
# PNG → JPEG konvertieren
|
||||
import io
|
||||
if ("png" in ct or url.lower().endswith(".png")) and HAS_PIL:
|
||||
with Image.open(io.BytesIO(r.content)) as img:
|
||||
buf = io.BytesIO()
|
||||
img.convert("RGB").save(buf, format="JPEG", quality=92)
|
||||
|
|
@ -133,11 +128,38 @@ def download_cover(release_mbid: Optional[str], dest_dir: Path) -> Optional[Path
|
|||
return dest
|
||||
dest.unlink(missing_ok=True)
|
||||
except Exception as e:
|
||||
print(f" ⚠️ Cover-Download-Fehler: {e}", file=sys.stderr)
|
||||
if label:
|
||||
print(f" ⚠️ {label}: {e}", file=sys.stderr)
|
||||
dest.unlink(missing_ok=True)
|
||||
return None
|
||||
|
||||
|
||||
def download_cover(release_mbid: Optional[str], dest_dir: Path) -> Optional[Path]:
|
||||
if not release_mbid or not HAS_REQUESTS:
|
||||
return None
|
||||
url = _mb_cover_url(release_mbid)
|
||||
if not url:
|
||||
return None
|
||||
return _download_image(url, dest_dir / "folder.jpg", "Cover-Download-Fehler")
|
||||
|
||||
|
||||
def download_back_cover(release_mbid: Optional[str], dest_dir: Path) -> Optional[Path]:
|
||||
"""Lädt das Back-Cover von MusicBrainz Cover Art Archive als back.jpg."""
|
||||
if not release_mbid or not HAS_REQUESTS:
|
||||
return None
|
||||
dest = dest_dir / "back.jpg"
|
||||
if dest.exists():
|
||||
return dest # bereits vorhanden
|
||||
url = f"https://coverartarchive.org/release/{release_mbid}/back"
|
||||
try:
|
||||
r = requests.head(url, timeout=5, allow_redirects=True)
|
||||
if r.status_code != 200:
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
return _download_image(url, dest, "Back-Cover-Fehler")
|
||||
|
||||
|
||||
def _load_cover_data(cover_path: Path) -> tuple[bytes, str]:
|
||||
"""
|
||||
Liest Cover-Bilddaten und gibt (bytes, mime_type) zurück.
|
||||
|
|
@ -253,29 +275,71 @@ def download_discogs_cover(artist: Optional[str], album: Optional[str], dest_dir
|
|||
url = _discogs_cover_url(artist, album)
|
||||
if not url:
|
||||
return None
|
||||
dest = dest_dir / "folder.jpg"
|
||||
return _download_image(url, dest_dir / "folder.jpg", "Discogs-Cover-Fehler")
|
||||
|
||||
|
||||
def _itunes_cover_url(artist: Optional[str], album: Optional[str]) -> Optional[str]:
|
||||
"""Sucht auf iTunes nach artist+album, gibt 600x600-Artwork-URL zurück."""
|
||||
if not HAS_REQUESTS or not (artist or album):
|
||||
return None
|
||||
term = f"{artist or ''} {album or ''}".strip()
|
||||
try:
|
||||
r = requests.get(url, timeout=15, headers={"User-Agent": "MusicMetadataEnricher/1.0"})
|
||||
r = requests.get(
|
||||
"https://itunes.apple.com/search",
|
||||
params={"term": term, "media": "music", "entity": "album", "limit": 5},
|
||||
timeout=8,
|
||||
)
|
||||
if r.status_code != 200:
|
||||
return None
|
||||
ct = r.headers.get("content-type", "")
|
||||
if ("png" in ct or url.lower().endswith(".png")) and HAS_PIL:
|
||||
import io
|
||||
with Image.open(io.BytesIO(r.content)) as img:
|
||||
buf = io.BytesIO()
|
||||
img.convert("RGB").save(buf, format="JPEG", quality=92)
|
||||
dest.write_bytes(buf.getvalue())
|
||||
else:
|
||||
dest.write_bytes(r.content)
|
||||
if _image_ok(dest):
|
||||
return dest
|
||||
dest.unlink(missing_ok=True)
|
||||
for result in r.json().get("results", []):
|
||||
url = result.get("artworkUrl100", "")
|
||||
if url:
|
||||
# Auf 600x600 hochskalieren
|
||||
return url.replace("100x100bb", "600x600bb").replace("100x100", "600x600")
|
||||
except Exception as e:
|
||||
print(f" ⚠️ Discogs-Cover-Fehler: {e}", file=sys.stderr)
|
||||
dest.unlink(missing_ok=True)
|
||||
print(f" ⚠️ iTunes-Suche: {e}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
|
||||
def download_itunes_cover(artist: Optional[str], album: Optional[str], dest_dir: Path) -> Optional[Path]:
|
||||
url = _itunes_cover_url(artist, album)
|
||||
if not url:
|
||||
return None
|
||||
return _download_image(url, dest_dir / "folder.jpg", "iTunes-Cover-Fehler")
|
||||
|
||||
|
||||
def _lastfm_cover_url(artist: Optional[str], album: Optional[str]) -> Optional[str]:
|
||||
"""Last.fm album.getinfo → größtes verfügbares Artwork-URL."""
|
||||
api_key = os.getenv("LASTFM_API_KEY", "")
|
||||
if not HAS_REQUESTS or not api_key or not artist or not album:
|
||||
return None
|
||||
try:
|
||||
r = requests.get(
|
||||
"https://ws.audioscrobbler.com/2.0/",
|
||||
params={"method": "album.getinfo", "api_key": api_key,
|
||||
"artist": artist, "album": album, "format": "json"},
|
||||
timeout=8,
|
||||
)
|
||||
if r.status_code != 200:
|
||||
return None
|
||||
images = r.json().get("album", {}).get("image", [])
|
||||
# Images sind aufsteigend nach Größe sortiert: small, medium, large, extralarge, mega
|
||||
for img in reversed(images):
|
||||
url = img.get("#text", "")
|
||||
if url and "2a96cbd8b46e442fc41c2b86b821562f" not in url: # Last.fm Platzhalter-Hash
|
||||
return url
|
||||
except Exception as e:
|
||||
print(f" ⚠️ Last.fm-Cover: {e}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
|
||||
def download_lastfm_cover(artist: Optional[str], album: Optional[str], dest_dir: Path) -> Optional[Path]:
|
||||
url = _lastfm_cover_url(artist, album)
|
||||
if not url:
|
||||
return None
|
||||
return _download_image(url, dest_dir / "folder.jpg", "Last.fm-Cover-Fehler")
|
||||
|
||||
|
||||
def resolve_cover(
|
||||
image_files: List[Path],
|
||||
release_mbid: Optional[str],
|
||||
|
|
@ -299,4 +363,14 @@ def resolve_cover(
|
|||
if downloaded:
|
||||
return downloaded, "discogs"
|
||||
|
||||
if artist or album:
|
||||
downloaded = download_itunes_cover(artist, album, album_dir)
|
||||
if downloaded:
|
||||
return downloaded, "itunes"
|
||||
|
||||
if artist or album:
|
||||
downloaded = download_lastfm_cover(artist, album, album_dir)
|
||||
if downloaded:
|
||||
return downloaded, "lastfm"
|
||||
|
||||
return None, None
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue