"""Apple Music client.""" from datetime import datetime, timedelta from typing import Any import backoff import jwt import requests from pydantic import BaseModel, ConfigDict, Field from requests import HTTPError class AppleMusicClientCredentialsManager: """Credentials manager helper class.""" def __init__( self, client_id: str, team_id: str, private_key: bytes, token_expires_in: int = 3600, ): """Credentials manager initialization. Args: client_id (str): Apple 10-character key identifier. team_id (str): Apple 10-character Team ID. private_key (str): MusicKit private key. token_expires_in (int): Lifetime of token in seconds. """ self._client_id = client_id self._team_id = team_id self._private_key = private_key self._expires_in = token_expires_in self._token_info = None self._alg = "ES256" def get_access_token(self) -> str: """Get a valid access token. Generates a new token or returns exists if it's not expired. Returns: str: Access token. """ if self._token_info and not self._is_token_expired(): return self._token_info["access_token"] self._token_info = self._get_token_info() return self._token_info["access_token"] def _get_token_info(self) -> dict[str, Any]: """Get access token information.""" token_expiration_time = datetime.now() + timedelta(seconds=self._expires_in) token_info: dict[str, Any] = {"token_expiration_time": token_expiration_time} token_info["access_token"] = self._request_access_token(token_expiration_time) return token_info def _request_access_token(self, token_expiration_time: datetime) -> str: """Actual access token request.""" headers = {"alg": self._alg, "kid": self._client_id} payload = { "iss": self._team_id, "iat": int(datetime.now().timestamp()), "exp": int(token_expiration_time.timestamp()), } token = jwt.encode( payload, self._private_key, algorithm=self._alg, headers=headers ) return token def _is_token_expired(self) -> bool: """Check whether the token is expired.""" if not self._token_info: return True return self._token_info["token_expiration_time"] < datetime.now() class AppleArtist(BaseModel): model_config = ConfigDict(title="Apple Music artist model.") identifier: str = Field(description="Apple Music artist ID.") name: str = Field(description="Apple Music artist's name.") genres: list[str] = Field(description="Apple Music artist's genres.", default=[]) url: str = Field(description="Apple Music artist's URL.") class AppleMusicClient: """Apple Music client class.""" def __init__( self, client_credentials_manager, requests_timeout=5, delay_step=0.5, ): """Client class initialization. Args: client_credentials_manager (AppleMusicClientCredentialsManager): creds manager. requests_timeout (int): In what time request times out. delay_step (float): Additional delay between retries. """ self._client_credentials_manager = client_credentials_manager self._requests_timeout = requests_timeout self._delay_step = delay_step self._search_api_url = "https://api.music.apple.com/v1/catalog/us/search" self._search_artist_api_url = ( "https://api.music.apple.com/v1/catalog/us/artists/{}" ) def _auth_headers(self) -> dict[str, str]: return { "Authorization": "Bearer {}".format( self._client_credentials_manager.get_access_token() ) } @backoff.on_exception( backoff.expo, HTTPError, max_tries=3, giveup=lambda e: ( e.response.status_code != 429 and (e.response.status_code < 500 or e.response.status_code >= 600) ), ) def search( self, query: str, limit: int = 10, offset: int = 0, localization: str | None = "en", ) -> list[AppleArtist]: """Search artists in Apple Music. Args: query (str): Term for search. limit (int): Max number of matched artists. offset (int): Offset from the beginning of the result. localization (str): Localization for the search. Returns: dict: Apple Music search endpoint result. """ headers = self._auth_headers() headers["Content-Type"] = "application/json" params = { "term": query, "limit": limit, "offset": offset, "types": "artists", "l": localization, } response = requests.get( self._search_api_url, headers=headers, params=params, timeout=self._requests_timeout, ) response.raise_for_status() payload = response.json() if ( not isinstance(payload, dict) or "results" not in payload or "artists" not in payload["results"] ): return [] return [ AppleArtist( identifier=artist["id"], name=artist["attributes"]["name"], genres=artist["attributes"]["genreNames"], url=artist["attributes"]["url"], ) for artist in payload["results"]["artists"]["data"] ] @backoff.on_exception( backoff.expo, HTTPError, max_tries=3, giveup=lambda e: ( e.response.status_code != 429 and (e.response.status_code < 500 or e.response.status_code >= 600) ), ) def get_artist_by_id(self, artist_id: str) -> AppleArtist | None: """Search artist by ID in Apple Music. Args: artist_id (str): Artist ID. Returns: dict: Apple Music artist result. """ headers = self._auth_headers() headers["Content-Type"] = "application/json" response = requests.get( self._search_artist_api_url.format(artist_id), headers=headers, timeout=self._requests_timeout, ) response.raise_for_status() payload = response.json() artist_data = payload["data"][0] return AppleArtist( identifier=artist_data["id"], name=artist_data["attributes"]["name"], genres=artist_data["attributes"]["genreNames"], url=artist_data["attributes"]["url"], )