from collections.abc import Callable from typing import Any, TypedDict import httpx from fansifter_common.httpclient import HTTPClient, HTTPClientError from pydantic import SecretStr API_BASE = "https://api.deezer.com" TIMEOUT = 10.0 # Deezer returns errors in the body with HTTP 200. This error type means the # access token is invalid or expired; this code means a quota/rate-limit hit. _REVOKED_ERROR_TYPES = {"OAuthException"} _QUOTA_ERROR_CODE = 4 # --------------------------------------------------------------------------- # Response types # --------------------------------------------------------------------------- class DeezerTokenResponse(TypedDict): access_token: SecretStr # --------------------------------------------------------------------------- # Exceptions # --------------------------------------------------------------------------- class DeezerHTTPError(HTTPClientError): """HTTPClientError for Deezer. API errors come back in a 200 body, so this can carry an explicit status_code mapping the body error to standard HTTP semantics (401 revoked, 429 quota, 500 other) — the gateway turns that into a DSP error.""" def __init__( self, message: str = "HTTP client error.", *, status_code: int | None = None, request: httpx.Request | None = None, response: httpx.Response | None = None, ) -> None: super().__init__(message, request=request, response=response) self._status_code = status_code @property def status_code(self) -> int: return ( self._status_code if self._status_code is not None else super().status_code ) @property def detail(self) -> str | None: if self.response is None: return None try: body = self.response.json() except ValueError, AttributeError: return None error = body.get("error") if isinstance(body, dict) else None if isinstance(error, dict): return error.get("message") return None def __str__(self) -> str: return f"{self.message}: {self.detail}" if self.detail else self.message # --------------------------------------------------------------------------- # Client # --------------------------------------------------------------------------- class DeezerClient(HTTPClient): exception_class = DeezerHTTPError def __init__(self) -> None: super().__init__(client_options={"timeout": TIMEOUT}) self.start() def handle_response[T]( self, response: httpx.Response, type: type[T] | None = None ) -> httpx.Response | T: result = super().handle_response(response, type) # Deezer reports API errors in the body with HTTP 200 — surface them here. if isinstance(result, dict): self._raise_for_error(result) return result def refresh_token(self, access_token: SecretStr) -> DeezerTokenResponse: # No token exchange — Deezer tokens are long-lived; a revoked one surfaces # on the first resource call, not here. return {"access_token": access_token} @staticmethod def _raise_for_error(body: dict[Any, Any]) -> None: error = body.get("error") if not error: return if isinstance(error, dict): error_type = error.get("type", "") message = error.get("message") or error_type or "Deezer error" if error_type in _REVOKED_ERROR_TYPES: raise DeezerHTTPError(message, status_code=401) if error.get("code") == _QUOTA_ERROR_CODE: raise DeezerHTTPError(message, status_code=429) raise DeezerHTTPError(message, status_code=500) raise DeezerHTTPError(str(error), status_code=500) def _paginate( self, request_page: Callable[[int, int], dict[str, Any]], *, page_limit: int, max_items: int, ) -> list[dict[str, Any]]: items: list[dict[str, Any]] = [] index = 0 while len(items) < max_items: page = request_page(index, min(page_limit, max_items - len(items))) data = [item for item in page.get("data") or [] if item is not None] items.extend(data) if not page.get("next") or not data: break index += len(data) return items[:max_items] def get_current_user_profile(self, access_token: SecretStr) -> dict[str, Any]: return self.request( "GET", f"{API_BASE}/user/me", params={"access_token": access_token.get_secret_value()}, type=dict[str, Any], ) def get_current_user_top_artists( self, access_token: SecretStr, *, max_items: int = 50 ) -> list[dict[str, Any]]: return self._paginated("/user/me/charts/artists", access_token, 50, max_items) def get_current_user_top_tracks( self, access_token: SecretStr, *, max_items: int = 50 ) -> list[dict[str, Any]]: return self._paginated("/user/me/charts/tracks", access_token, 50, max_items) def get_current_user_history( self, access_token: SecretStr, *, max_items: int = 50 ) -> list[dict[str, Any]]: return self._paginated("/user/me/history", access_token, 50, max_items) def get_current_user_playlists( self, access_token: SecretStr, *, max_items: int = 100 ) -> list[dict[str, Any]]: return self._paginated("/user/me/playlists", access_token, 100, max_items) def get_current_user_albums( self, access_token: SecretStr, *, max_items: int = 100 ) -> list[dict[str, Any]]: return self._paginated("/user/me/albums", access_token, 100, max_items) def get_current_user_tracks( self, access_token: SecretStr, *, max_items: int = 100 ) -> list[dict[str, Any]]: return self._paginated("/user/me/tracks", access_token, 100, max_items) def get_current_user_artists( self, access_token: SecretStr, *, max_items: int = 100 ) -> list[dict[str, Any]]: return self._paginated("/user/me/artists", access_token, 100, max_items) def _paginated( self, path: str, access_token: SecretStr, page_limit: int, max_items: int, ) -> list[dict[str, Any]]: def request_page(index: int, limit: int) -> dict[str, Any]: return self.request( "GET", f"{API_BASE}{path}", params={ "access_token": access_token.get_secret_value(), "index": index, "limit": limit, }, type=dict[str, Any], ) return self._paginate(request_page, page_limit=page_limit, max_items=max_items)