"""CoverArtArchiveClient — fetches front cover art from the Cover Art Archive. The network fallback when a file carries no embedded artwork: given a MusicBrainz **release-group** id (supplied by the AcoustID lookup), request the front image from ``coverartarchive.org``. The CAA redirects to the Internet Archive, so redirects are followed. ``thumbnail`` 500px keeps payloads small. Graceful degradation (CLAUDE.md): no release-group id → never called; any network/HTTP error (incl. 404 "no cover") → returns ``None``, never raises. A small inter-call delay respects the shared MusicBrainz/CAA infrastructure. """ import asyncio import time import httpx from app.core.logging import get_logger from app.domain.entities.cover import CoverArt log = get_logger(__name__) _DEFAULT_BASE_URL = "https://coverartarchive.org" _TIMEOUT_SECONDS = 15.0 _MIN_INTERVAL_SECONDS = 1.0 # CAA piggybacks on MusicBrainz infra; stay polite _MAX_BYTES = 10 * 1024 * 1024 # ignore absurdly large images class CoverArtArchiveClient: """Implements :class:`app.domain.ports.CoverArtProvider`.""" _throttle_lock = asyncio.Lock() _last_call_monotonic = 0.0 def __init__( self, *, user_agent: str, enabled: bool = True, base_url: str = _DEFAULT_BASE_URL, ) -> None: self._user_agent = user_agent self._enabled = enabled self._base_url = base_url.rstrip("/") def is_available(self) -> bool: return self._enabled async def fetch_release_group(self, release_group_mbid: str) -> CoverArt | None: if not self._enabled or not release_group_mbid: return None url = f"{self._base_url}/release-group/{release_group_mbid}/front-500" try: await self._throttle() async with httpx.AsyncClient( timeout=_TIMEOUT_SECONDS, follow_redirects=True, headers={"User-Agent": self._user_agent}, ) as client: resp = await client.get(url) if resp.status_code == 404: return None # no cover for this release group — normal, not an error resp.raise_for_status() except httpx.HTTPError: log.warning("coverart_fetch_failed", release_group=release_group_mbid) return None data = resp.content if not data or len(data) > _MAX_BYTES: return None content_type = resp.headers.get("content-type", "image/jpeg").split(";")[0].strip() if not content_type.startswith("image/"): return None return CoverArt(data=data, content_type=content_type) @classmethod async def _throttle(cls) -> None: async with cls._throttle_lock: elapsed = time.monotonic() - cls._last_call_monotonic wait = _MIN_INTERVAL_SECONDS - elapsed if wait > 0: await asyncio.sleep(wait) cls._last_call_monotonic = time.monotonic()