from __future__ import annotations from typing import Any, Iterable, Mapping import requests from more_itertools import ichunked from src import config from src.data_info import TokenInfo, UserInfo from ..errors import BaseApiError, SpotifyApiError from .base import BaseDSPApiClient __all__ = ["SpotifyApiClient"] class SpotifyApiClient(BaseDSPApiClient): default_error_cls = SpotifyApiError base_url = "https://api.spotify.com/v1" base_auth_url: str = "https://accounts.spotify.com" batch_size: int = 100 available_entity_types: tuple[str, ...] = ("track", "playlist") def __init__( self, *, client_id: str = config.SPOTIFY_CLIENT_ID, client_secret: str = config.SPOTIFY_CLIENT_SECRET, image_base_url: str = config.IMAGE_BASE_URL, **kwargs, ): super().__init__(**kwargs) self._client_id = client_id self._client_secret = client_secret self._image_base_url = image_base_url def _get_retry_after(self, exception: Exception) -> int: if ( isinstance(exception, BaseApiError) and self._is_retry_error(exception) and exception.response and exception.response.headers.get("Retry-After") ): return int(exception.response.headers["Retry-After"]) def _giveup(self, exception: Exception) -> bool: result = super()._giveup(exception) if not result: ts = self._get_retry_after(exception) return ts is not None and ts > config.MAX_RETRY_TIMEOUT return result def _calc_wait_time(self, count: int, exception: Exception): """Calculate wait time.""" ts = self._get_retry_after(exception) if ts is not None: self._logger.info( f"[Spotify] Too-Many-Requests Retry-After={ts}. Max retry-timeout {config.MAX_RETRY_TIMEOUT}" ) if ts <= config.MAX_RETRY_TIMEOUT: return ts return super()._calc_wait_time(count, exception) def _get_token_from_code(self, code: str, redirect_uri: str, **extra: Any) -> TokenInfo: request = requests.Request( method="post", url=f"{self.base_auth_url}/api/token", data={ "grant_type": "authorization_code", "code": code, "redirect_uri": redirect_uri, "client_id": self._client_id, "client_secret": self._client_secret, }, ) result = self.send_request_with_retry(request, authorize=False).json() return TokenInfo( refresh_token=result["refresh_token"], access_token=result["access_token"], expires_in=result["expires_in"] ) def _refresh_access_token(self) -> tuple[str, int]: request = requests.Request( method="post", url=f"{self.base_auth_url}/api/token", data={ "grant_type": "refresh_token", "refresh_token": self._refresh_token, "client_id": self._client_id, "client_secret": self._client_secret, }, ) result = self.send_request_with_retry(request, authorize=False).json() return result["access_token"], result["expires_in"] @classmethod def _get_uri(cls, entity_type: str, id_: str) -> str: if entity_type not in cls.available_entity_types: raise SpotifyApiError(f"Unknown entity type: {entity_type}") return f"spotify:{entity_type}:{id_}" def insert_tracks(self, playlist_id: str, track_ids: Iterable[str], position: int): self._logger.debug(f"Inserting tracks to playlist with id '{playlist_id}'") for i, batch in enumerate(ichunked(track_ids, self.batch_size)): request = requests.Request( method="post", url=f"{self.base_url}/playlists/{playlist_id}/tracks", json={ "uris": [self._get_uri("track", id_) for id_ in batch], "position": position + (i * self.batch_size), }, ) self.send_request_with_retry(request) self._logger.debug(f"Inserted tracks to playlist with id '{playlist_id}'") def delete_tracks(self, playlist_id: str, track_ids: Iterable[str]): self._logger.debug(f"Deleting tracks from playlist with id '{playlist_id}'") for batch in ichunked(track_ids, self.batch_size): request = requests.Request( method="delete", url=f"{self.base_url}/playlists/{playlist_id}/tracks", json={"tracks": [{"uri": self._get_uri("track", id_)} for id_ in batch]}, ) self.send_request_with_retry(request) self._logger.debug(f"Deleted tracks from playlist with id '{playlist_id}'") def update_playlist_info(self, playlist_id: str, info: Mapping[str, str | None]): self._logger.debug(f"Updating info about playlist with id '{playlist_id}'") # TODO: it's a dirty hack, rework this piece new_info = dict(**info) if "title" in new_info: new_info["name"] = new_info.pop("title") request = requests.Request(method="put", url=f"{self.base_url}/playlists/{playlist_id}", json=new_info) self.send_request_with_retry(request) self._logger.debug(f"Updated info about playlist with id '{playlist_id}'") def get_user_info(self, **extra) -> UserInfo: self._logger.debug("Getting user info") request = requests.Request(method="get", url=f"{self.base_url}/me") result = self.send_request_with_retry(request).json() self._logger.debug("Got user info") return UserInfo( user_identifier=result["id"], display_name=result["display_name"], image_url=result["images"][0]["url"] if result["images"] else None, )