Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 33 additions & 3 deletions resources/lib/twitch_addon/addon/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
from .common import kodi, log_utils
from .constants import Keys, SCOPES
from .error_handling import api_error_handler
from .twitch_exceptions import PlaybackFailed, TwitchException
from .twitch_exceptions import PlaybackFailed, TwitchException, TokenRefreshed

from twitch import queries as twitch_queries
from twitch import oauth
Expand Down Expand Up @@ -45,6 +45,9 @@ class Twitch:
required_scopes = SCOPES

def __init__(self):
# Guards the one-shot silent 401 recovery in error_check, so a brand-new token that
# is somehow still rejected can't drive an endless refresh/retry loop.
self._oauth_recovery_attempted = False
# Silently refresh the Helix OAuth token if it is (near) expired (Device Code Flow).
utils.ensure_valid_token()
self.access_token = utils.get_oauth_token(token_only=True, required=False)
Expand All @@ -65,6 +68,12 @@ def __init__(self):
self.queries.OAUTH_TOKEN = ''
self.access_token = ''

def reload_access_token(self):
"""Re-read the (just-refreshed) access token from the store onto this instance and
the query layer, so an in-flight retry (see api_error_handler) uses the new token."""
self.access_token = utils.get_oauth_token(token_only=True, required=False)
self.queries.OAUTH_TOKEN = self.access_token

@cache.cache_method(cache_limit=1)
def valid_token(self, client_id, token, scopes): # client_id, token used for unique caching only
token_check = self.root()
Expand Down Expand Up @@ -393,8 +402,7 @@ def video_request(self, video_id):
results = self.usher.video_request(video_id, headers=self.get_private_credential_headers(), **self._codec_kwargs())
return self.error_check(results, private=True)

@staticmethod
def error_check(results, private=False):
def error_check(self, results, private=False):
if isinstance(results, list):
return results

Expand All @@ -404,6 +412,28 @@ def error_check(results, private=False):

if ('error' in payload) and (payload['status'] == 401):
if not private:
# A 401 while a refresh token is still stored is almost always recoverable:
# the short-lived access token lapsed (e.g. Kodi / the network only just came
# up after a reboot or an overnight gap) but the refresh token is still good.
# Refresh once and re-issue the request transparently instead of dragging the
# user through a needless re-login. The hard sign-in dialog is reached ONLY
# when no refresh token remains (a definitively dead session).
refresh_token = utils.get_refresh_token()
if refresh_token:
# Forced refresh is attempted at most once per instance (one-shot guard),
# so a token still rejected after a successful refresh can't loop.
if not self._oauth_recovery_attempted:
self._oauth_recovery_attempted = True
if utils.ensure_valid_token(force=True):
# Refreshed: hand back up to api_error_handler, which reloads the
# token and retries the request once (no toast, no re-login).
raise TokenRefreshed()
# Recovery already spent this instance (the retry itself 401'd), or the
# refresh returned nothing -- but a refresh token still survives, so this
# is a transient / in-flight refresh, not a dead session. Bail silently
# this once (next visit, or the proactive service refresh, recovers).
if utils.get_refresh_token():
sys.exit()
_ = kodi.Dialog().ok(
i18n('oauth_heading'),
i18n('oauth_message') % (i18n('settings'), i18n('login'), i18n('device_login'))
Expand Down
9 changes: 9 additions & 0 deletions resources/lib/twitch_addon/addon/common/kodi.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,15 @@ def has_addon(addon_id):
return xbmc.getCondVisibility('System.HasAddon(%s)' % addon_id) == 1


def connection_available():
"""Kodi's own view of whether the host currently has internet access. Used to skip a
doomed OAuth token refresh while offline (an outage, or the network simply not up yet
after a reboot) so we never hammer a dead connection -- and never mistake "no
connection" for "bad token". Kodi can lag a network change by a few seconds, which is
fine: the refresh is just deferred until this reports back true."""
return xbmc.getCondVisibility('System.InternetState') == 1


def addon_enabled(addon_id):
rpc_request = {"jsonrpc": "2.0",
"method": "Addons.GetAddonDetails",
Expand Down
15 changes: 10 additions & 5 deletions resources/lib/twitch_addon/addon/error_handling.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@

from . import utils
from .common import kodi, log_utils
from .twitch_exceptions import TwitchException, SubRequired, ResourceUnavailableException, NotFound, PlaybackFailed
from .twitch_exceptions import TwitchException, SubRequired, ResourceUnavailableException, NotFound, PlaybackFailed, TokenRefreshed

i18n = utils.i18n

Expand Down Expand Up @@ -69,9 +69,14 @@ def api_error_handler(func):
@wraps(func)
def wrapper(*args, **kwargs):
try:
result = func(*args, **kwargs)
return result
except:
raise
return func(*args, **kwargs)
except TokenRefreshed:
# error_check() silently refreshed an expired token after a 401. Reload it onto
# the Twitch instance (args[0]==self) and retry the request once, so the user
# gets their data on the first try instead of a re-login prompt. The one-shot
# guard in error_check ensures the retry can't raise TokenRefreshed again.
if args and hasattr(args[0], 'reload_access_token'):
args[0].reload_access_token()
return func(*args, **kwargs)

return wrapper
6 changes: 6 additions & 0 deletions resources/lib/twitch_addon/addon/twitch_exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,9 @@ class NotFound(Exception):

class PlaybackFailed(Exception):
pass


class TokenRefreshed(Exception):
"""Signal raised by error_check() when a 401 was recovered by a silent token
refresh, so api_error_handler can reload the token and retry the request once."""
pass
5 changes: 5 additions & 0 deletions resources/lib/twitch_addon/addon/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,11 @@ def ensure_valid_token(force=False):
# falls back to the manual login until they configure a public client id.
if not force and kodi.get_setting('oauth_refresh_unsupported') == client_id:
return (_read_oauth_store().get('access') or '').strip()
# While Kodi reports no internet, skip the (doomed) refresh entirely instead of hanging
# up to ~15s on a dead connection. The stored token is kept untouched and refreshed once
# we're back online. A forced recovery (post-401) bypasses this and attempts anyway.
if not force and not kodi.connection_available():
return (_read_oauth_store().get('access') or '').strip()
with _OAuthLock():
store = _read_oauth_store()
refresh_token = (store.get('refresh') or '').strip()
Expand Down