diff --git a/core/musicbrainz_search.py b/core/musicbrainz_search.py index 3e32300b..adf24a2d 100644 --- a/core/musicbrainz_search.py +++ b/core/musicbrainz_search.py @@ -7,7 +7,6 @@ Album art is fetched from Cover Art Archive (free, linked by release MBID). """ import threading -import requests from dataclasses import dataclass from typing import Any, Dict, List, Optional @@ -60,29 +59,24 @@ class Album: external_urls: Optional[Dict[str, str]] = None -def _get_cover_art_url(release_mbid: str) -> Optional[str]: - """Fetch album art URL from Cover Art Archive. Returns None if not available.""" - 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 +def _cover_art_url(mbid: str, scope: str = 'release') -> Optional[str]: + """Build a Cover Art Archive URL without hitting the network. + 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 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]: - """Fetch album art from release group (covers all editions).""" - try: - url = f"{COVER_ART_ARCHIVE_URL}/release-group/{release_group_mbid}/front-250" - resp = requests.head(url, timeout=3, allow_redirects=True) - if resp.status_code == 200: - return resp.url - return None - except Exception: + `scope` is 'release' (most specific) or 'release-group' (covers all + editions — better hit rate). + """ + if not mbid: 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]: @@ -121,7 +115,6 @@ class MusicBrainzSearchClient: # which is what MusicBrainz wants. Version stays generic ("2") — # the exact UI minor version would add noise to every request. 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 # backend fires artists/albums/tracks searches in parallel against # one client instance, and albums+tracks both need the same artist @@ -133,15 +126,17 @@ class MusicBrainzSearchClient: self._artist_mbid_lock = threading.Lock() def _cached_art(self, release_mbid: str, release_group_mbid: str = '') -> Optional[str]: - """Get cover art with caching. Tries release first, then release group.""" - if release_mbid in self._art_cache: - return self._art_cache[release_mbid] - - url = _get_cover_art_url(release_mbid) - if not url and release_group_mbid: - url = _get_release_group_art(release_group_mbid) - self._art_cache[release_mbid] = url - return url + """Build a Cover Art Archive URL for a release / release-group MBID. + + Prefers release-group scope when provided — better hit rate because + it covers all editions of the same album. No network call; the + frontend's fallback handles 404s. + """ + 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 # Lucene score 0-100 on every match; exact name/alias hits score 100,