diff --git a/setup.py b/setup.py index 62a874e..25118a9 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ setup( name="spotifio", - version="0.1.2", + version="0.1.3", author="s4w3d0ff", author_email="", description="An async Spotify Web API client library", diff --git a/spotifio/client.py b/spotifio/client.py index 6aef468..098b617 100644 --- a/spotifio/client.py +++ b/spotifio/client.py @@ -122,9 +122,9 @@ async def _check_scope(self, method_name): if missing_scopes: raise Exception(f"Missing required scopes for {method_name}: {', '.join(missing_scopes)}") - async def login(self): + async def login(self, token=None): """ Sets up the token """ - await self.token_handler._login() + await self.token_handler._login(token) async def _request(self, method, endpoint, params=None, data=None, headers=None): """ Base Request Method for Spotify API calls """ @@ -140,13 +140,16 @@ async def _request(self, method, endpoint, params=None, data=None, headers=None) default_headers.update(headers) # Create the full URL url = f"{base_url}/{endpoint.lstrip('/')}" - logger.debug(f"{method = } {url = } {default_headers = } {params = } {data = }") + logger.debug(f"_request({method=}, {url=}, {params=}, {data=})") try: async with aiohttp.ClientSession() as session: async with session.request(method=method, url=url, params=params, json=data, headers=default_headers) as resp: # Check if the response status is successful if resp.status == 204: # No content return None + if resp.status == 401: # bad token + await self.token_handler._refresh_token() + return await self._request(method, endpoint, params, data, headers) if resp.status == 429: # Rate limiting retry_after = int(resp.headers.get('Retry-After', 1)) logger.warning(f"Rate limited. Waiting {retry_after} seconds") diff --git a/spotifio/oauth.py b/spotifio/oauth.py index 808e704..869d6c0 100644 --- a/spotifio/oauth.py +++ b/spotifio/oauth.py @@ -1,7 +1,7 @@ import aiohttp import asyncio import webbrowser -import random +import os import base64 import logging import time @@ -24,14 +24,6 @@ """ - -def randString(length=12, chars=None): - if not chars: - import string - chars = string.ascii_letters + string.digits - ranstr = ''.join(random.choice(chars) for _ in range(length)) - return ranstr - class WebServer: def __init__(self, host, port): self.app = web.Application() @@ -68,7 +60,7 @@ def __init__(self, client_id, client_secret, redirect_uri=None, scope=[], storag self.redirect_uri = redirect_uri or "http://localhost:8888/callback" self.scope = scope self.storage = storage or JSONStorage() - self._state = randString(16) + self._state = os.urandom(14).hex() self._auth_code = None self._auth_future = None self._token = None @@ -80,7 +72,10 @@ def __init__(self, client_id, client_secret, redirect_uri=None, scope=[], storag parsed_uri = urlparse(self.redirect_uri) self.server = WebServer(parsed_uri.hostname, parsed_uri.port) self.server.add_route(f"/{parsed_uri.path.lstrip('/')}", self._callback_handler) - + # refresh token handler stuff + self._refresh_event = asyncio.Event() + self._refresh_task = None + self._running = False async def _callback_handler(self, request): if request.query.get('state') != self._state: @@ -93,7 +88,7 @@ async def _callback_handler(self, request): return web.Response(text=closeBrowser, content_type='text/html', charset='utf-8') async def _get_auth_code(self): - logger.warning(f"Getting Oauth code...") + logger.warning(f"Opening browser to get Oauth code...") await self.server.start() self._auth_future = asyncio.Future() params = { @@ -104,22 +99,32 @@ async def _get_auth_code(self): } if self.scope: params['scope'] = ' '.join(self.scope) - # open webbrowser with auth link - webbrowser.open(f"https://accounts.spotify.com/authorize?{urlencode(params)}") + auth_link = f"https://accounts.spotify.com/authorize?{urlencode(params)}" + try: + # open webbrowser with auth link + webbrowser.open(auth_link) + except: + # cant open webbrowser, show auth link for user to copy/paste + logger.error(f"Couldn't open default browser!: \n{auth_link}") # wait for auth code await self._auth_future # stop webserver await self.server.stop() + logger.warning(f"Got Oauth code!") async def _token_request(self, data): """ Base token request method, used for new or refreshing tokens """ + if self._token: + # temp store refresh token (spotify doesnt always send one) + r_token = self._token['refresh_token'] async with aiohttp.ClientSession() as session: async with session.post(self._token_url, headers=self._token_headers, data=data) as resp: if resp.status != 200: raise Exception(f"Token request failed: {await resp.text()}") self._token = await resp.json() - if "expires_in" in self._token: - self._token["expires_time"] = time.time()+int(self._token['expires_in']) + if "refresh_token" not in self._token: + self._token['refresh_token'] = r_token + self._token["expires_time"] = time.time()+int(self._token['expires_in']) await self.storage.save_token(self._token, name="spotify") return self._token @@ -133,38 +138,53 @@ async def _refresh_token(self): }) except Exception as e: logger.error(f"Refreshing token failed! {e}") - return await self.get_new_token() + return await self._get_new_token() - async def get_new_token(self): + async def _get_new_token(self): """ Get a new oauth token using the oauth code, get code if we dont have one yet """ + await self._get_auth_code() logger.warning(f"Getting new token...") - if not self._auth_code: - await self._get_auth_code() return await self._token_request({ "grant_type": "authorization_code", "code": self._auth_code, "redirect_uri": self.redirect_uri }) - async def _check_token(self): - """ Check token expire time, refresh if needed """ - time_left = self._token['expires_time'] - time.time() - logger.debug(f"Token expires in {time_left} seconds...") - if time_left < 0: - await self._refresh_token() - - async def _login(self): - """ Checks storage for saved token, gets new token if one isnt found. """ - logger.info(f"Attempting to load saved token...") - self._token = None - self._token = await self.storage.load_token(name="spotify") + async def _token_refresher(self): + """ Waits for the time to refresh the token and refreshes """ + self._running = True + self._refresh_event.set() + logger.debug(f"_token_refresher started...") + while self._running: + time_left = self._token['expires_time'] - time.time() + logger.debug(f"Token expires in {time_left} seconds...") + if time_left-60 <= 0: + # pause 'self.get_token' + self._refresh_event.clear() + # refresh token + await self._refresh_token() + # resume 'self.get_token' + self._refresh_event.set() + continue # skip sleep to get new time_left + await asyncio.sleep(time_left-60) + + async def _login(self, token=None): + """ Checks storage for saved token, gets new token if one isnt found. Starts the token refresher task.""" + self._token = token + self._refresh_task = None if not self._token: - logger.warning(f"No token found in storage!") - self._token = await self.get_new_token() + logger.debug(f"Attempting to load saved token...") + self._token = await self.storage.load_token(name="spotify") + if self._token: + logger.warning(f"Loaded saved token from storage!") + else: + self._token = await self._get_new_token() + self._refresh_task = asyncio.create_task(self._token_refresher()) async def get_token(self): """ Returns current token after checking if the token needs to be refreshed """ if not self._token: - await self.login() - await self._check_token() + await self._login() + # wait for refresh if needed + await self._refresh_event.wait() return self._token \ No newline at end of file