diff --git a/resources/requirements.txt b/resources/requirements.txt index 4eb7894..8786b5b 100644 --- a/resources/requirements.txt +++ b/resources/requirements.txt @@ -1,7 +1,7 @@ setuptools requests>=2.21.0 click -bs4 +bs4>=4.8.1 six tqdm websocket-client diff --git a/resources/spotipy/cache_handler.py b/resources/spotipy/cache_handler.py index 3ba3987..ed0e878 100644 --- a/resources/spotipy/cache_handler.py +++ b/resources/spotipy/cache_handler.py @@ -1,8 +1,17 @@ -__all__ = ['CacheHandler', 'CacheFileHandler'] +__all__ = [ + 'CacheHandler', + 'CacheFileHandler', + 'DjangoSessionCacheHandler', + 'MemoryCacheHandler', + 'RedisCacheHandler'] import errno import json import logging +import os +from spotipy.util import CLIENT_CREDS_ENV_VARS + +from redis import RedisError logger = logging.getLogger(__name__) @@ -53,6 +62,7 @@ def __init__(self, self.cache_path = cache_path else: cache_path = ".cache" + username = (username or os.getenv(CLIENT_CREDS_ENV_VARS["client_username"])) if username: cache_path += "-" + str(username) self.cache_path = cache_path @@ -82,3 +92,89 @@ def save_token_to_cache(self, token_info): except IOError: logger.warning('Couldn\'t write token to cache at: %s', self.cache_path) + + +class MemoryCacheHandler(CacheHandler): + """ + A cache handler that simply stores the token info in memory as an + instance attribute of this class. The token info will be lost when this + instance is freed. + """ + + def __init__(self, token_info=None): + """ + Parameters: + * token_info: The token info to store in memory. Can be None. + """ + self.token_info = token_info + + def get_cached_token(self): + return self.token_info + + def save_token_to_cache(self, token_info): + self.token_info = token_info + + +class DjangoSessionCacheHandler(CacheHandler): + """ + A cache handler that stores the token info in the session framework + provided by Django. + + Read more at https://docs.djangoproject.com/en/3.2/topics/http/sessions/ + """ + + def __init__(self, request): + """ + Parameters: + * request: HttpRequest object provided by Django for every + incoming request + """ + self.request = request + + def get_cached_token(self): + token_info = None + try: + token_info = self.request.session['token_info'] + except KeyError: + logger.debug("Token not found in the session") + + return token_info + + def save_token_to_cache(self, token_info): + try: + self.request.session['token_info'] = token_info + except Exception as e: + logger.warning("Error saving token to cache: " + str(e)) + + +class RedisCacheHandler(CacheHandler): + """ + A cache handler that stores the token info in the Redis. + """ + + def __init__(self, redis, key=None): + """ + Parameters: + * redis: Redis object provided by redis-py library + (https://github.com/redis/redis-py) + * key: May be supplied, will otherwise be generated + (takes precedence over `token_info`) + """ + self.redis = redis + self.key = key if key else 'token_info' + + def get_cached_token(self): + token_info = None + try: + if self.redis.exists(self.key): + token_info = json.loads(self.redis.get(self.key)) + except RedisError as e: + logger.warning('Error getting token from cache: ' + str(e)) + + return token_info + + def save_token_to_cache(self, token_info): + try: + self.redis.set(self.key, json.dumps(token_info)) + except RedisError as e: + logger.warning('Error saving token to cache: ' + str(e)) diff --git a/resources/spotipy/client.py b/resources/spotipy/client.py index 8c1fc8a..4ad4b2d 100644 --- a/resources/spotipy/client.py +++ b/resources/spotipy/client.py @@ -247,16 +247,22 @@ def _internal_call(self, method, url, payload, params): except requests.exceptions.HTTPError as http_error: response = http_error.response try: - msg = response.json()["error"]["message"] - except (ValueError, KeyError): - msg = "error" - try: - reason = response.json()["error"]["reason"] - except (ValueError, KeyError): + json_response = response.json() + error = json_response.get("error", {}) + msg = error.get("message") + reason = error.get("reason") + except ValueError: + # if the response cannnot be decoded into JSON (which raises a ValueError), + # then try to decode it into text + + # if we receive an empty string (which is falsy), then replace it with `None` + msg = response.text or None reason = None - logger.error('HTTP Error for %s to %s returned %s due to %s', - method, url, response.status_code, msg) + logger.error( + 'HTTP Error for %s to %s with Params: %s returned %s due to %s', + method, url, args.get("params"), response.status_code, msg + ) raise SpotifyException( response.status_code, @@ -627,7 +633,7 @@ def playlist_tracks( """ Get full details of the tracks of a playlist. Parameters: - - playlist_id - the id of the playlist + - playlist_id - the playlist ID, URI or URL - fields - which fields to return - limit - the maximum number of tracks to return - offset - the index of the first track to return @@ -655,7 +661,7 @@ def playlist_items( """ Get full details of the tracks and episodes of a playlist. Parameters: - - playlist_id - the id of the playlist + - playlist_id - the playlist ID, URI or URL - fields - which fields to return - limit - the maximum number of tracks to return - offset - the index of the first track to return @@ -677,7 +683,7 @@ def playlist_cover_image(self, playlist_id): """ Get cover of a playlist. Parameters: - - playlist_id - the id of the playlist + - playlist_id - the playlist ID, URI or URL """ plid = self._get_id("playlist", playlist_id) return self._get("playlists/%s/images" % (plid)) @@ -1176,7 +1182,7 @@ def current_user_saved_albums(self, limit=20, offset=0, market=None): "Your Music" library Parameters: - - limit - the number of albums to return + - limit - the number of albums to return (MAX_LIMIT=50) - offset - the index of the first album to return - market - an ISO 3166-1 alpha-2 country code. @@ -1907,7 +1913,7 @@ def _get_id(self, type, id): if type != fields[-2]: logger.warning('Expected id of type %s but found type %s %s', type, fields[-2], id) - return fields[-1] + return fields[-1].split("?")[0] fields = id.split("/") if len(fields) >= 3: itype = fields[-2] diff --git a/resources/spotipy/oauth2.py b/resources/spotipy/oauth2.py index c711ce0..a7ce334 100644 --- a/resources/spotipy/oauth2.py +++ b/resources/spotipy/oauth2.py @@ -129,6 +129,28 @@ def _is_scope_subset(needle_scope, haystack_scope): ) return needle_scope <= haystack_scope + def _handle_oauth_error(self, http_error): + response = http_error.response + try: + error_payload = response.json() + error = error_payload.get('error') + error_description = error_payload.get('error_description') + except ValueError: + # if the response cannnot be decoded into JSON (which raises a ValueError), + # then try do decode it into text + + # if we receive an empty string (which is falsy), then replace it with `None` + error = response.txt or None + error_description = None + + raise SpotifyOauthError( + 'error: {0}, error_description: {1}'.format( + error, error_description + ), + error=error, + error_description=error_description + ) + def __del__(self): """Make sure the connection (pool) gets closed""" if isinstance(self._session, requests.Session): @@ -231,23 +253,20 @@ def _request_access_token(self): self.OAUTH_TOKEN_URL, headers, payload ) - response = self._session.post( - self.OAUTH_TOKEN_URL, - data=payload, - headers=headers, - verify=True, - proxies=self.proxies, - timeout=self.requests_timeout, - ) - if response.status_code != 200: - error_payload = response.json() - raise SpotifyOauthError( - 'error: {0}, error_description: {1}'.format( - error_payload['error'], error_payload['error_description']), - error=error_payload['error'], - error_description=error_payload['error_description']) - token_info = response.json() - return token_info + try: + response = self._session.post( + self.OAUTH_TOKEN_URL, + data=payload, + headers=headers, + verify=True, + proxies=self.proxies, + timeout=self.requests_timeout, + ) + response.raise_for_status() + token_info = response.json() + return token_info + except requests.exceptions.HTTPError as http_error: + self._handle_oauth_error(http_error) def _add_custom_values_to_token_info(self, token_info): """ @@ -315,7 +334,6 @@ def __init__( self.redirect_uri = redirect_uri self.state = state self.scope = self._normalize_scope(scope) - username = (username or os.getenv(CLIENT_CREDS_ENV_VARS["client_username"])) if username or cache_path: warnings.warn("Specifying cache_path or username as arguments to SpotifyOAuth " + "will be deprecated. Instead, please create a CacheFileHandler " + @@ -338,7 +356,7 @@ def __init__( + " != " + str(CacheHandler) self.cache_handler = cache_handler else: - + username = (username or os.getenv(CLIENT_CREDS_ENV_VARS["client_username"])) self.cache_handler = CacheFileHandler( username=username, cache_path=cache_path @@ -440,13 +458,12 @@ def _get_auth_response_local_server(self, redirect_port): self._open_auth_url() server.handle_request() - if self.state is not None and server.state != self.state: + if server.error is not None: + raise server.error + elif self.state is not None and server.state != self.state: raise SpotifyStateError(self.state, server.state) - - if server.auth_code is not None: + elif server.auth_code is not None: return server.auth_code - elif server.error is not None: - raise server.error else: raise SpotifyOauthError("Server listening on localhost has not been accessed") @@ -530,25 +547,22 @@ def get_access_token(self, code=None, as_dict=True, check_cache=True): self.OAUTH_TOKEN_URL, headers, payload ) - response = self._session.post( - self.OAUTH_TOKEN_URL, - data=payload, - headers=headers, - verify=True, - proxies=self.proxies, - timeout=self.requests_timeout, - ) - if response.status_code != 200: - error_payload = response.json() - raise SpotifyOauthError( - 'error: {0}, error_description: {1}'.format( - error_payload['error'], error_payload['error_description']), - error=error_payload['error'], - error_description=error_payload['error_description']) - token_info = response.json() - token_info = self._add_custom_values_to_token_info(token_info) - self.cache_handler.save_token_to_cache(token_info) - return token_info if as_dict else token_info["access_token"] + try: + response = self._session.post( + self.OAUTH_TOKEN_URL, + data=payload, + headers=headers, + verify=True, + proxies=self.proxies, + timeout=self.requests_timeout, + ) + response.raise_for_status() + token_info = response.json() + token_info = self._add_custom_values_to_token_info(token_info) + self.cache_handler.save_token_to_cache(token_info) + return token_info if as_dict else token_info["access_token"] + except requests.exceptions.HTTPError as http_error: + self._handle_oauth_error(http_error) def refresh_access_token(self, refresh_token): payload = { @@ -563,28 +577,23 @@ def refresh_access_token(self, refresh_token): self.OAUTH_TOKEN_URL, headers, payload ) - response = self._session.post( - self.OAUTH_TOKEN_URL, - data=payload, - headers=headers, - proxies=self.proxies, - timeout=self.requests_timeout, - ) - - if response.status_code != 200: - error_payload = response.json() - raise SpotifyOauthError( - 'error: {0}, error_description: {1}'.format( - error_payload['error'], error_payload['error_description']), - error=error_payload['error'], - error_description=error_payload['error_description']) - - token_info = response.json() - token_info = self._add_custom_values_to_token_info(token_info) - if "refresh_token" not in token_info: - token_info["refresh_token"] = refresh_token - self.cache_handler.save_token_to_cache(token_info) - return token_info + try: + response = self._session.post( + self.OAUTH_TOKEN_URL, + data=payload, + headers=headers, + proxies=self.proxies, + timeout=self.requests_timeout, + ) + response.raise_for_status() + token_info = response.json() + token_info = self._add_custom_values_to_token_info(token_info) + if "refresh_token" not in token_info: + token_info["refresh_token"] = refresh_token + self.cache_handler.save_token_to_cache(token_info) + return token_info + except requests.exceptions.HTTPError as http_error: + self._handle_oauth_error(http_error) def _add_custom_values_to_token_info(self, token_info): """ @@ -673,7 +682,6 @@ def __init__(self, self.redirect_uri = redirect_uri self.state = state self.scope = self._normalize_scope(scope) - username = (username or os.getenv(CLIENT_CREDS_ENV_VARS["client_username"])) if username or cache_path: warnings.warn("Specifying cache_path or username as arguments to SpotifyPKCE " + "will be deprecated. Instead, please create a CacheFileHandler " + @@ -694,6 +702,7 @@ def __init__(self, "type(cache_handler): " + str(type(cache_handler)) + " != " + str(CacheHandler) self.cache_handler = cache_handler else: + username = (username or os.getenv(CLIENT_CREDS_ENV_VARS["client_username"])) self.cache_handler = CacheFileHandler( username=username, cache_path=cache_path @@ -902,26 +911,22 @@ def get_access_token(self, code=None, check_cache=True): self.OAUTH_TOKEN_URL, headers, payload ) - response = self._session.post( - self.OAUTH_TOKEN_URL, - data=payload, - headers=headers, - verify=True, - proxies=self.proxies, - timeout=self.requests_timeout, - ) - if response.status_code != 200: - error_payload = response.json() - raise SpotifyOauthError('error: {0}, error_descr: {1}'.format(error_payload['error'], - error_payload[ - 'error_description' - ]), - error=error_payload['error'], - error_description=error_payload['error_description']) - token_info = response.json() - token_info = self._add_custom_values_to_token_info(token_info) - self.cache_handler.save_token_to_cache(token_info) - return token_info["access_token"] + try: + response = self._session.post( + self.OAUTH_TOKEN_URL, + data=payload, + headers=headers, + verify=True, + proxies=self.proxies, + timeout=self.requests_timeout, + ) + response.raise_for_status() + token_info = response.json() + token_info = self._add_custom_values_to_token_info(token_info) + self.cache_handler.save_token_to_cache(token_info) + return token_info["access_token"] + except requests.exceptions.HTTPError as http_error: + self._handle_oauth_error(http_error) def refresh_access_token(self, refresh_token): payload = { @@ -937,28 +942,23 @@ def refresh_access_token(self, refresh_token): self.OAUTH_TOKEN_URL, headers, payload ) - response = self._session.post( - self.OAUTH_TOKEN_URL, - data=payload, - headers=headers, - proxies=self.proxies, - timeout=self.requests_timeout, - ) - - if response.status_code != 200: - error_payload = response.json() - raise SpotifyOauthError( - 'error: {0}, error_description: {1}'.format( - error_payload['error'], error_payload['error_description']), - error=error_payload['error'], - error_description=error_payload['error_description']) - - token_info = response.json() - token_info = self._add_custom_values_to_token_info(token_info) - if "refresh_token" not in token_info: - token_info["refresh_token"] = refresh_token - self.cache_handler.save_token_to_cache(token_info) - return token_info + try: + response = self._session.post( + self.OAUTH_TOKEN_URL, + data=payload, + headers=headers, + proxies=self.proxies, + timeout=self.requests_timeout, + ) + response.raise_for_status() + token_info = response.json() + token_info = self._add_custom_values_to_token_info(token_info) + if "refresh_token" not in token_info: + token_info["refresh_token"] = refresh_token + self.cache_handler.save_token_to_cache(token_info) + return token_info + except requests.exceptions.HTTPError as http_error: + self._handle_oauth_error(http_error) def parse_response_code(self, url): """ Parse the response code in the given response url @@ -1071,7 +1071,6 @@ def __init__(self, self.client_id = client_id self.redirect_uri = redirect_uri self.state = state - username = (username or os.getenv(CLIENT_CREDS_ENV_VARS["client_username"])) if username or cache_path: warnings.warn("Specifying cache_path or username as arguments to " + "SpotifyImplicitGrant will be deprecated. Instead, please create " + @@ -1093,6 +1092,7 @@ def __init__(self, "type(cache_handler): " + str(type(cache_handler)) + " != " + str(CacheHandler) self.cache_handler = cache_handler else: + username = (username or os.getenv(CLIENT_CREDS_ENV_VARS["client_username"])) self.cache_handler = CacheFileHandler( username=username, cache_path=cache_path diff --git a/resources/spotipy/spotify_token.py b/resources/spotipy/spotify_token.py index 64b4b01..5d2297f 100644 --- a/resources/spotipy/spotify_token.py +++ b/resources/spotipy/spotify_token.py @@ -1,5 +1,7 @@ """Utility module that helps get a webplayer access token""" +import os import requests +from bs4 import BeautifulSoup import json USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_2) \