Skip to content

Commit

Permalink
Merge pull request #6 from s4w3d0ff/dev
Browse files Browse the repository at this point in the history
fix token refreshing
  • Loading branch information
s4w3d0ff authored Feb 3, 2025
2 parents d308af6 + 2482ffa commit 3a260a4
Show file tree
Hide file tree
Showing 3 changed files with 63 additions and 40 deletions.
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
9 changes: 6 additions & 3 deletions spotifio/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 """
Expand All @@ -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")
Expand Down
92 changes: 56 additions & 36 deletions spotifio/oauth.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import aiohttp
import asyncio
import webbrowser
import random
import os
import base64
import logging
import time
Expand All @@ -24,14 +24,6 @@
</html>
"""


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()
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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 = {
Expand All @@ -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

Expand All @@ -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

0 comments on commit 3a260a4

Please sign in to comment.