MusicBrainz: Construct Cover Art URLs instead of HEAD-probing them
Cover Art Archive URLs are deterministic from the MBID: a GET either 307-redirects to the image or returns 404. The previous adapter fired `requests.head(timeout=3)` per search result to probe for the image first. 10 results × 3s worst-case = up to 30s of blocking HEAD calls before a search returned. The probe was defensive overhead — the frontend already handles 404 via `<img onerror>` fallback. Building the URL deterministically and letting the browser load it lazily collapses the tail latency to the real MB API calls (artist-search + browse = ~3s at the 1-rps rate limit). Also prefer release-group scope over per-release scope when both are available — release-group covers every edition of an album, so the hit rate is noticeably higher than pinning to a specific regional release. Removes now-unused `self._art_cache` and the `requests` import.
This commit is contained in:
parent
d7e232e01c
commit
73df2951e5
1 changed files with 25 additions and 30 deletions
|
|
@ -7,7 +7,6 @@ Album art is fetched from Cover Art Archive (free, linked by release MBID).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import threading
|
import threading
|
||||||
import requests
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
|
@ -60,29 +59,24 @@ class Album:
|
||||||
external_urls: Optional[Dict[str, str]] = None
|
external_urls: Optional[Dict[str, str]] = None
|
||||||
|
|
||||||
|
|
||||||
def _get_cover_art_url(release_mbid: str) -> Optional[str]:
|
def _cover_art_url(mbid: str, scope: str = 'release') -> Optional[str]:
|
||||||
"""Fetch album art URL from Cover Art Archive. Returns None if not available."""
|
"""Build a Cover Art Archive URL without hitting the network.
|
||||||
try:
|
|
||||||
# CAA redirects to the actual image URL — just get the front image URL
|
|
||||||
url = f"{COVER_ART_ARCHIVE_URL}/release/{release_mbid}/front-250"
|
|
||||||
resp = requests.head(url, timeout=3, allow_redirects=True)
|
|
||||||
if resp.status_code == 200:
|
|
||||||
return resp.url # The redirect target is the actual image
|
|
||||||
return None
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
CAA URLs are deterministic from the MBID: the endpoint either 307-redirects
|
||||||
|
to the image or returns 404. Previously we fired `requests.head(timeout=3)`
|
||||||
|
per result during search — 10 results × 3s worst-case = up to 30s of
|
||||||
|
blocking HEAD calls before a search returned. The frontend's <img> tag
|
||||||
|
handles the 404 case via onerror fallback, so the HEAD round-trip was
|
||||||
|
pure overhead.
|
||||||
|
|
||||||
def _get_release_group_art(release_group_mbid: str) -> Optional[str]:
|
`scope` is 'release' (most specific) or 'release-group' (covers all
|
||||||
"""Fetch album art from release group (covers all editions)."""
|
editions — better hit rate).
|
||||||
try:
|
"""
|
||||||
url = f"{COVER_ART_ARCHIVE_URL}/release-group/{release_group_mbid}/front-250"
|
if not mbid:
|
||||||
resp = requests.head(url, timeout=3, allow_redirects=True)
|
|
||||||
if resp.status_code == 200:
|
|
||||||
return resp.url
|
|
||||||
return None
|
|
||||||
except Exception:
|
|
||||||
return None
|
return None
|
||||||
|
if scope not in ('release', 'release-group'):
|
||||||
|
scope = 'release'
|
||||||
|
return f"{COVER_ART_ARCHIVE_URL}/{scope}/{mbid}/front-250"
|
||||||
|
|
||||||
|
|
||||||
def _extract_artist_credit(artist_credit) -> List[str]:
|
def _extract_artist_credit(artist_credit) -> List[str]:
|
||||||
|
|
@ -121,7 +115,6 @@ class MusicBrainzSearchClient:
|
||||||
# which is what MusicBrainz wants. Version stays generic ("2") —
|
# which is what MusicBrainz wants. Version stays generic ("2") —
|
||||||
# the exact UI minor version would add noise to every request.
|
# the exact UI minor version would add noise to every request.
|
||||||
self._client = MusicBrainzClient("SoulSync", "2")
|
self._client = MusicBrainzClient("SoulSync", "2")
|
||||||
self._art_cache: Dict[str, Optional[str]] = {} # mbid -> url
|
|
||||||
# Per-instance cache for "top artist MBID for this query". The
|
# Per-instance cache for "top artist MBID for this query". The
|
||||||
# backend fires artists/albums/tracks searches in parallel against
|
# backend fires artists/albums/tracks searches in parallel against
|
||||||
# one client instance, and albums+tracks both need the same artist
|
# one client instance, and albums+tracks both need the same artist
|
||||||
|
|
@ -133,15 +126,17 @@ class MusicBrainzSearchClient:
|
||||||
self._artist_mbid_lock = threading.Lock()
|
self._artist_mbid_lock = threading.Lock()
|
||||||
|
|
||||||
def _cached_art(self, release_mbid: str, release_group_mbid: str = '') -> Optional[str]:
|
def _cached_art(self, release_mbid: str, release_group_mbid: str = '') -> Optional[str]:
|
||||||
"""Get cover art with caching. Tries release first, then release group."""
|
"""Build a Cover Art Archive URL for a release / release-group MBID.
|
||||||
if release_mbid in self._art_cache:
|
|
||||||
return self._art_cache[release_mbid]
|
|
||||||
|
|
||||||
url = _get_cover_art_url(release_mbid)
|
Prefers release-group scope when provided — better hit rate because
|
||||||
if not url and release_group_mbid:
|
it covers all editions of the same album. No network call; the
|
||||||
url = _get_release_group_art(release_group_mbid)
|
frontend's <img onerror> fallback handles 404s.
|
||||||
self._art_cache[release_mbid] = url
|
"""
|
||||||
return url
|
preferred = release_group_mbid or release_mbid
|
||||||
|
if not preferred:
|
||||||
|
return None
|
||||||
|
scope = 'release-group' if release_group_mbid else 'release'
|
||||||
|
return _cover_art_url(preferred, scope=scope)
|
||||||
|
|
||||||
# Score threshold for user-facing search results. MusicBrainz returns a
|
# Score threshold for user-facing search results. MusicBrainz returns a
|
||||||
# Lucene score 0-100 on every match; exact name/alias hits score 100,
|
# Lucene score 0-100 on every match; exact name/alias hits score 100,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue