"""AcoustIdHttpClient — identifies a recording from its Chromaprint fingerprint. One ``/v2/lookup`` call with ``meta=recordings+releasegroups`` returns the AcoustID id, the MusicBrainz recording id, and canonical title/artist/album — metadata that itself originates from MusicBrainz, so a separate MB call is not needed for Phase 1 (plan §6.2 steps 2-3 collapsed into one request). Graceful degradation: no API key → ``is_available()`` is False and the whole fingerprint path is skipped; any network/parse error → ``lookup`` returns ``None``. A small inter-call delay keeps us within AcoustID's rate limit. """ import asyncio import time import httpx from app.core.logging import get_logger from app.domain.entities.metadata import Fingerprint, RecordingMatch log = get_logger(__name__) _DEFAULT_URL = "https://api.acoustid.org/v2/lookup" _TIMEOUT_SECONDS = 10.0 _MIN_INTERVAL_SECONDS = 0.34 # AcoustID allows ~3 req/s; stay polite class AcoustIdHttpClient: """Implements :class:`app.domain.ports.AcoustIdClient`.""" _throttle_lock = asyncio.Lock() _last_call_monotonic = 0.0 def __init__( self, *, api_key: str | None, user_agent: str, api_url: str = _DEFAULT_URL, ) -> None: self._api_key = api_key self._user_agent = user_agent self._api_url = api_url def is_available(self) -> bool: return bool(self._api_key) async def lookup(self, fingerprint: Fingerprint) -> RecordingMatch | None: payload = await self._lookup_raw(fingerprint) if payload is None: return None return _parse_best_match(payload) async def lookup_all(self, fingerprint: Fingerprint) -> list[RecordingMatch]: payload = await self._lookup_raw(fingerprint) if payload is None: return [] return _parse_matches(payload) async def _lookup_raw(self, fingerprint: Fingerprint) -> object | None: if not self._api_key: return None try: await self._throttle() async with httpx.AsyncClient( timeout=_TIMEOUT_SECONDS, headers={"User-Agent": self._user_agent}, ) as client: resp = await client.get( self._api_url, params={ "client": self._api_key, "duration": str(fingerprint.duration_seconds), "fingerprint": fingerprint.fingerprint, "meta": "recordings releasegroups", "format": "json", }, ) resp.raise_for_status() return resp.json() # type: ignore[no-any-return] except httpx.HTTPError, ValueError: log.warning("acoustid_lookup_failed") return None @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() _MAX_MATCHES = 5 def _parse_best_match(payload: object) -> RecordingMatch | None: matches = _parse_matches(payload) return matches[0] if matches else None def _parse_matches(payload: object) -> list[RecordingMatch]: if not isinstance(payload, dict) or payload.get("status") != "ok": return [] results = payload.get("results") if not isinstance(results, list) or not results: return [] # Results are returned best-score-first, but sort defensively and cap the # number of candidates surfaced to the editor. candidates = [r for r in results if isinstance(r, dict)] candidates.sort(key=lambda r: r.get("score", 0.0), reverse=True) matches: list[RecordingMatch] = [] for result in candidates[:_MAX_MATCHES]: match = _parse_one(result) if match is not None: matches.append(match) return matches def _parse_one(result: dict[str, object]) -> RecordingMatch | None: acoustid = result.get("id") if not isinstance(acoustid, str): return None score = float(result.get("score", 0.0)) # type: ignore[arg-type] recording_mbid: str | None = None release_group_mbid: str | None = None title: str | None = None artist: str | None = None album: str | None = None recordings = result.get("recordings") if isinstance(recordings, list) and recordings and isinstance(recordings[0], dict): rec = recordings[0] recording_mbid = rec.get("id") if isinstance(rec.get("id"), str) else None title = rec.get("title") if isinstance(rec.get("title"), str) else None artists = rec.get("artists") if isinstance(artists, list) and artists and isinstance(artists[0], dict): name = artists[0].get("name") artist = name if isinstance(name, str) else None groups = rec.get("releasegroups") if isinstance(groups, list) and groups and isinstance(groups[0], dict): group = groups[0] gtitle = group.get("title") album = gtitle if isinstance(gtitle, str) else None gid = group.get("id") release_group_mbid = gid if isinstance(gid, str) else None return RecordingMatch( acoustid=acoustid, score=score, recording_mbid=recording_mbid, release_group_mbid=release_group_mbid, title=title, artist=artist, album=album, year=None, )