"""LrclibHttpClient โ€” fetches lyrics from LRCLIB (plan ยง6.7). LRCLIB is a free, keyless lyrics database. ``/api/get`` does an exact match on artist+track+album+duration; if that misses we fall back to ``/api/search`` and take the best-scoring hit. Graceful degradation: any network/parse error โ†’ ``fetch`` returns ``None`` (the service then caches a ``not_found``), never raising. No API key is needed, so this provider is always "available". """ import httpx from app.core.logging import get_logger from app.domain.entities.lyrics import LyricsResult log = get_logger(__name__) _BASE_URL = "https://lrclib.net" _TIMEOUT_SECONDS = 10.0 _SOURCE = "lrclib" class LrclibHttpClient: """Implements :class:`app.domain.ports.LyricsProvider`.""" def __init__(self, *, user_agent: str, base_url: str = _BASE_URL) -> None: self._user_agent = user_agent self._base_url = base_url.rstrip("/") async def fetch( self, *, artist: str, title: str, album: str | None, duration_seconds: int | None, ) -> LyricsResult | None: try: async with httpx.AsyncClient( timeout=_TIMEOUT_SECONDS, headers={"User-Agent": self._user_agent}, base_url=self._base_url, ) as client: hit = await self._get(client, artist, title, album, duration_seconds) if hit is None: hit = await self._search(client, artist, title) except (httpx.HTTPError, ValueError) as exc: log.warning("lrclib.fetch_failed", error=str(exc)) return None return hit async def _get( self, client: httpx.AsyncClient, artist: str, title: str, album: str | None, duration_seconds: int | None, ) -> LyricsResult | None: """Exact match via ``/api/get`` (404 when nothing matches exactly).""" params = {"artist_name": artist, "track_name": title} if album: params["album_name"] = album if duration_seconds is not None: params["duration"] = str(duration_seconds) resp = await client.get("/api/get", params=params) if resp.status_code == httpx.codes.NOT_FOUND: return None resp.raise_for_status() return _to_result(resp.json()) async def _search( self, client: httpx.AsyncClient, artist: str, title: str ) -> LyricsResult | None: """Fuzzy fallback via ``/api/search`` โ€” take the first usable hit.""" resp = await client.get( "/api/search", params={"artist_name": artist, "track_name": title} ) resp.raise_for_status() results = resp.json() if not isinstance(results, list): return None for item in results: result = _to_result(item) if result is not None: return result return None def _to_result(payload: object) -> LyricsResult | None: """Map an LRCLIB record to a ``LyricsResult``. Instrumental tracks and empty records yield ``None`` (nothing worth caching as "found").""" if not isinstance(payload, dict): return None if payload.get("instrumental"): return None synced = payload.get("syncedLyrics") or None plain = payload.get("plainLyrics") or None if synced is None and plain is None: return None return LyricsResult(synced=synced, plain=plain, source=_SOURCE)