Skip to content
Merged
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
42 changes: 42 additions & 0 deletions .github/workflows/deploy-pages.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Workflow für GitHub Pages Deployment
name: Deploy GitHub Pages

on:
push:
branches: [master]
paths:
- 'docs/**'
workflow_dispatch:

# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
permissions:
contents: read
pages: write
id-token: write

# Allow only one concurrent deployment
concurrency:
group: "pages"
cancel-in-progress: false

jobs:
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Pages
uses: actions/configure-pages@v5

- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: 'docs'

- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
4 changes: 2 additions & 2 deletions addon.xml
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<addon id="plugin.video.twitch" version="3.1.0" name="Twitch" provider-name="Seraph91P">
<addon id="plugin.video.twitch" version="3.1.1" name="Twitch" provider-name="Seraph91P">
<requires>
<import addon="xbmc.python" version="3.0.1"/>
<import addon="script.module.requests" version="2.9.1"/>
<import addon="script.module.python.twitch" version="3.0.2"/>
<import addon="script.module.python.twitch" version="3.0.3"/>
</requires>
<extension point="xbmc.python.pluginsource" library="resources/lib/addon_runner.py">
<provides>video</provides>
Expand Down
1 change: 1 addition & 0 deletions docs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -115,3 +115,4 @@ <h1>🎮 Twitch for Kodi</h1>
</div>
</body>
</html>

4 changes: 4 additions & 0 deletions resources/language/resource.language.de_de/strings.po
Original file line number Diff line number Diff line change
Expand Up @@ -1129,6 +1129,10 @@ msgctxt "#30305"
msgid "An unknown error occurred."
msgstr "Ein unbekannter Fehler ist aufgetreten."

msgctxt "#30321"
msgid "Please enter your Twitch Application Client ID in the settings first. You can create one at dev.twitch.tv/console"
msgstr "Bitte gib zuerst deine Twitch Application Client ID in den Einstellungen ein. Du kannst eine unter dev.twitch.tv/console erstellen."

#~ msgctxt "#30132"
#~ msgid "OAuth token is required for access to authorized user functions."
#~ msgstr "Für den Zugriff auf geschützte Benutzerfunktionen wird ein OAuth-Token benötigt."
Expand Down
47 changes: 47 additions & 0 deletions resources/language/resource.language.en_gb/strings.po
Original file line number Diff line number Diff line change
Expand Up @@ -1133,3 +1133,50 @@ msgstr ""
msgctxt "#30305"
msgid "An unknown error occurred."
msgstr ""
msgctxt "#30310"
msgid "Device Authentication"
msgstr ""

msgctxt "#30311"
msgid "Go to: %s[CR][CR]Enter code: [B]%s[/B]"
msgstr ""

msgctxt "#30312"
msgid "Waiting for authorization..."
msgstr ""

msgctxt "#30313"
msgid "Authentication successful!"
msgstr ""

msgctxt "#30314"
msgid "Connect with Twitch"
msgstr ""

msgctxt "#30315"
msgid "Disconnect from Twitch"
msgstr ""

msgctxt "#30316"
msgid "Token refreshed automatically"
msgstr ""

msgctxt "#30317"
msgid "Token refresh failed - please re-authenticate"
msgstr ""

msgctxt "#30318"
msgid "Are you sure you want to disconnect your Twitch account?"
msgstr ""

msgctxt "#30319"
msgid "Account disconnected"
msgstr ""

msgctxt "#30320"
msgid "Use Device Authentication (recommended)"
msgstr ""

msgctxt "#30321"
msgid "Please enter your Twitch Application Client ID in the settings first. You can create one at dev.twitch.tv/console"
msgstr ""
89 changes: 80 additions & 9 deletions resources/lib/twitch_addon/addon/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

from . import cache, utils
from .common import kodi, log_utils
from .common.cache import invalidate_cache_for_function
from .constants import Keys, SCOPES
from .error_handling import api_error_handler
from .twitch_exceptions import PlaybackFailed, TwitchException
Expand Down Expand Up @@ -48,6 +49,18 @@ def __init__(self):
else:
log_utils.log('No proxy configuration found', log_utils.LOGINFO)

# Try to auto-refresh token if expired (Device Code Flow)
self._try_auto_refresh()

# Re-read token after potential refresh
self.access_token = utils.get_oauth_token(token_only=True, required=False)

log_utils.log('Init: client_id=%s, has_token=%s, token_len=%s' % (
self.client_id[:8] + '...' if self.client_id else 'None',
bool(self.access_token),
len(self.access_token) if self.access_token else 0
), log_utils.LOGINFO)

self.queries.CLIENT_ID = self.client_id
self.queries.CLIENT_SECRET = self.client_secret
self.queries.OAUTH_TOKEN = self.access_token
Expand All @@ -64,10 +77,51 @@ def __init__(self):

if self.access_token:
if not self.valid_token(self.client_id, self.access_token, self.required_scopes):
log_utils.log('Init: Token validation FAILED, clearing token', log_utils.LOGWARNING)
self.queries.OAUTH_TOKEN = ''
self.access_token = ''
else:
log_utils.log('Init: Token validation passed', log_utils.LOGINFO)

@cache.cache_method(cache_limit=1)
def _try_auto_refresh(self):
"""Try to auto-refresh token if using Device Code Flow and token is expired."""
try:
from .device_auth import auto_refresh_token, is_token_expired, get_device_tokens

# Migration cleanup: old code saved Device Auth tokens to twitch_hevc_token,
# but third-party tokens don't work with the GQL API (401 error).
# If twitch_hevc_token matches oauth_token_helix, it was set by old Device Auth
# code and should be cleared so GQL uses anonymous access instead.
helix_token = kodi.get_setting('oauth_token_helix')
hevc_token = kodi.get_setting('twitch_hevc_token')
if helix_token and hevc_token and helix_token.strip() == hevc_token.strip():
log_utils.log('Clearing twitch_hevc_token (matches Device Auth token)', log_utils.LOGINFO)
kodi.set_setting('twitch_hevc_token', '')

tokens = get_device_tokens()
log_utils.log('Auto-refresh: tokens=%s, has_refresh=%s' % (
'present' if tokens else 'None',
bool(tokens.get('refresh_token')) if tokens else 'N/A'
), log_utils.LOGINFO)

if tokens and tokens.get('refresh_token'):
expired = is_token_expired()
log_utils.log('Auto-refresh: is_expired=%s, expires_at=%s' % (
expired, tokens.get('expires_at')
), log_utils.LOGINFO)

if expired:
log_utils.log('Token expired, attempting auto-refresh', log_utils.LOGINFO)
if auto_refresh_token():
log_utils.log('Token auto-refreshed successfully', log_utils.LOGINFO)
else:
log_utils.log('Token auto-refresh failed', log_utils.LOGWARNING)
else:
log_utils.log('Token still valid, no refresh needed', log_utils.LOGINFO)
except Exception as e:
log_utils.log('Auto-refresh check failed: %s' % str(e), log_utils.LOGWARNING)

@cache.cache_method(cache_limit=0.25) # 15 minutes cache for token validation
def valid_token(self, client_id, token, scopes): # client_id, token used for unique caching
token_check = self.root()
while True:
Expand All @@ -78,9 +132,18 @@ def valid_token(self, client_id, token, scopes): # client_id, token used for un
log_utils.log('valid_token: token_client_id=%s, self.client_id=%s' %
(token_check.get('client_id'), self.client_id), log_utils.LOGDEBUG)

# Update client_id to match the token's client_id (user provides their own)
if token_check['client_id'] != self.client_id:
log_utils.log('Updating client_id to match token: %s' % token_check['client_id'], log_utils.LOGDEBUG)
# Check if token's client_id matches the configured client_id
if self.client_id and token_check['client_id'] != self.client_id:
log_utils.log('Token client_id mismatch: token=%s, configured=%s. Clearing stale token.' % (
token_check['client_id'], self.client_id), log_utils.LOGWARNING)
# Token was obtained with a different client_id — it won't work with Helix
kodi.set_setting('oauth_token_helix', '')
kodi.set_setting('device_refresh_token', '')
kodi.set_setting('device_token_expires_at', '')
return False
elif not self.client_id:
# No client_id configured — adopt the token's client_id
log_utils.log('No client_id configured, adopting from token: %s' % token_check['client_id'], log_utils.LOGDEBUG)
self.client_id = token_check['client_id']
self.queries.CLIENT_ID = self.client_id

Expand All @@ -93,7 +156,7 @@ def valid_token(self, client_id, token, scopes): # client_id, token used for un

return True

@cache.cache_method(cache_limit=1)
@cache.cache_method(cache_limit=0.25) # 15 minutes cache for token validation
def valid_private_token(self, client_id, token): # client_id used for unique caching only
token_check = self.validate(token)

Expand Down Expand Up @@ -365,10 +428,14 @@ def error_check(results, private=False):
payload = payload['response']

if ('error' in payload) and (payload['status'] == 401):
# Clear old cache entries when we get a 401 error (token expired)
log_utils.log('401 Unauthorized - invalidating stale cache entries', log_utils.LOGWARNING)
invalidate_cache_for_function('valid')

if not private:
_ = kodi.Dialog().ok(
i18n('oauth_heading'),
i18n('oauth_message') % (i18n('settings'), i18n('login'), i18n('get_oauth_token'))
i18n('oauth_message') % (i18n('settings'), i18n('login'), i18n('device_auth_connect'))
)
else:
_ = kodi.Dialog().ok(
Expand Down Expand Up @@ -396,14 +463,18 @@ def return_boolean(results):
@staticmethod
def get_private_credential_headers():
headers = {}
private_oauth_token = utils.get_private_oauth_token()
private_client_id = utils.get_private_client_id()

# Use the configured Client-ID for GQL requests
if private_client_id:
headers['Client-ID'] = private_client_id

if private_oauth_token:
headers['Authorization'] = 'OAuth {token}'.format(token=private_oauth_token)
# Only use manually configured HEVC/website tokens for the GQL API.
# Device Auth tokens are third-party OAuth tokens and get rejected
# by the GQL API with 401. The GQL API works fine anonymously
# (Client-ID only) or with first-party website tokens.
hevc_token = utils.get_hevc_token()
if hevc_token:
headers['Authorization'] = 'OAuth {token}'.format(token=hevc_token)

return headers
35 changes: 35 additions & 0 deletions resources/lib/twitch_addon/addon/common/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,41 @@ def reset_cache():
return False


def invalidate_cache_for_function(func_name_pattern):
"""
Invalidate cache entries matching a function name pattern.
Since cache filenames are MD5 hashes, we need to delete all cache files
and let them be regenerated. This is a targeted approach that only
removes cache files older than a certain time to minimize impact.

For token validation, we simply remove all cache files that are
older than 5 minutes to force re-validation without affecting
recently cached data.
"""
try:
if not os.path.exists(cache_path):
return True

now = time.time()
max_age = now - (5 * 60) # 5 minutes

count = 0
for filename in os.listdir(cache_path):
filepath = os.path.join(cache_path, filename)
if os.path.isfile(filepath):
mtime = os.path.getmtime(filepath)
# Remove files older than 5 minutes
if mtime < max_age:
os.remove(filepath)
count += 1

log_utils.log('Invalidated %d cache entries older than 5 minutes' % count, log_utils.LOGDEBUG)
return True
except Exception as e:
log_utils.log('Failed to invalidate cache: %s' % (e), log_utils.LOGWARNING)
return False


def _get_func(name, args=None, kwargs=None, cache_limit=1):
if not cache_enabled or cache_limit <= 0: return False, None
now = time.time()
Expand Down
4 changes: 3 additions & 1 deletion resources/lib/twitch_addon/addon/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,9 @@ def __enum(**enums):
REMOVESEARCHHISTORY='remove_search_history',
MAINTAIN='maintain',
WATCHHISTORY='watch_history',
CLEARWATCHHISTORY='clear_watch_history'
CLEARWATCHHISTORY='clear_watch_history',
DEVICEAUTH='device_auth',
DEVICEAUTHDISCONNECT='device_auth_disconnect'
)

LINE_LENGTH = 60
Expand Down
Loading
Loading