import time from collections.abc import Callable from typing import Any, NotRequired, TypedDict import jwt from fansifter_common.httpclient import HTTPClient, HTTPClientError from pydantic import SecretStr API_BASE = "https://api.music.apple.com/v1" TIMEOUT = 10.0 # Developer-token JWT lifetime and the margin before expiry to re-mint. _DEVELOPER_TOKEN_TTL_S = 12 * 3600 _DEVELOPER_TOKEN_MARGIN_S = 300 # --------------------------------------------------------------------------- # Response types # --------------------------------------------------------------------------- class AppleMusicPage(TypedDict): data: list[dict[str, Any] | None] next: NotRequired[str | None] meta: NotRequired[dict[str, Any] | None] # --------------------------------------------------------------------------- # Exceptions # --------------------------------------------------------------------------- class AppleMusicError(Exception): """Base exception for Apple Music client errors.""" class AppleMusicHTTPError(HTTPClientError): """HTTPClientError that appends Apple Music's JSON error message to the text.""" @property def detail(self) -> str | None: """Apple's error text from an Errors response, e.g. `{"errors": [{"status": "403", "title": "Forbidden", "detail": "..."}]}`.""" if self.response is None: return None try: body = self.response.json() except ValueError, AttributeError: return None errors = body.get("errors") if isinstance(body, dict) else None if isinstance(errors, list) and errors and isinstance(errors[0], dict): return errors[0].get("detail") or errors[0].get("title") return None def __str__(self) -> str: return f"{self.message}: {self.detail}" if self.detail else self.message # --------------------------------------------------------------------------- # Client # --------------------------------------------------------------------------- class AppleMusicClient(HTTPClient): exception_class = AppleMusicHTTPError def __init__(self, team_id: str, key_id: str, private_key: SecretStr) -> None: super().__init__(client_options={"timeout": TIMEOUT}) self._team_id = team_id self._key_id = key_id self._private_key = private_key self._developer_token_cache: str | None = None self._developer_token_exp = 0 self.start() def _developer_token(self) -> str: now = int(time.time()) if ( self._developer_token_cache is not None and now < self._developer_token_exp - _DEVELOPER_TOKEN_MARGIN_S ): return self._developer_token_cache exp = now + _DEVELOPER_TOKEN_TTL_S self._developer_token_cache = jwt.encode( {"iss": self._team_id, "iat": now, "exp": exp}, self._private_key.get_secret_value(), algorithm="ES256", headers={"kid": self._key_id}, ) self._developer_token_exp = exp return self._developer_token_cache def _auth_headers(self, user_token: SecretStr) -> dict[str, str]: # The single unwrap point — user_token stays a SecretStr everywhere else. return { "Authorization": f"Bearer {self._developer_token()}", "Music-User-Token": user_token.get_secret_value(), } def _paginate_offset( self, request_page: Callable[[int, int], AppleMusicPage], *, page_limit: int, max_items: int, ) -> list[dict[str, Any]]: items: list[dict[str, Any]] = [] offset = 0 while len(items) < max_items: page = request_page(offset, min(page_limit, max_items - len(items))) data = [item for item in page["data"] if item is not None] items.extend(data) if not page.get("next") or not data: break offset += len(data) return items[:max_items] def get_recently_played_tracks( self, user_token: SecretStr, *, max_items: int = 30 ) -> list[dict[str, Any]]: def request_page(offset: int, limit: int) -> AppleMusicPage: return self.request( "GET", f"{API_BASE}/me/recent/played/tracks", params={"limit": limit, "offset": offset}, headers=self._auth_headers(user_token), type=AppleMusicPage, ) return self._paginate_offset(request_page, page_limit=30, max_items=max_items) def get_library_playlists( self, user_token: SecretStr, *, max_items: int = 100 ) -> list[dict[str, Any]]: def request_page(offset: int, limit: int) -> AppleMusicPage: return self.request( "GET", f"{API_BASE}/me/library/playlists", params={"limit": limit, "offset": offset}, headers=self._auth_headers(user_token), type=AppleMusicPage, ) return self._paginate_offset(request_page, page_limit=100, max_items=max_items) def get_library_albums( self, user_token: SecretStr, *, max_items: int = 100 ) -> list[dict[str, Any]]: def request_page(offset: int, limit: int) -> AppleMusicPage: return self.request( "GET", f"{API_BASE}/me/library/albums", params={"limit": limit, "offset": offset}, headers=self._auth_headers(user_token), type=AppleMusicPage, ) return self._paginate_offset(request_page, page_limit=100, max_items=max_items) def get_library_songs( self, user_token: SecretStr, *, max_items: int = 100 ) -> list[dict[str, Any]]: def request_page(offset: int, limit: int) -> AppleMusicPage: return self.request( "GET", f"{API_BASE}/me/library/songs", params={"limit": limit, "offset": offset}, headers=self._auth_headers(user_token), type=AppleMusicPage, ) return self._paginate_offset(request_page, page_limit=100, max_items=max_items) def get_library_artists( self, user_token: SecretStr, *, max_items: int = 100 ) -> list[dict[str, Any]]: def request_page(offset: int, limit: int) -> AppleMusicPage: return self.request( "GET", f"{API_BASE}/me/library/artists", params={"limit": limit, "offset": offset}, headers=self._auth_headers(user_token), type=AppleMusicPage, ) return self._paginate_offset(request_page, page_limit=100, max_items=max_items)