diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml new file mode 100644 index 00000000..07d22891 --- /dev/null +++ b/.github/workflows/deploy-pages.yml @@ -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 diff --git a/addon.xml b/addon.xml index 8d72a2ad..bebaf520 100644 --- a/addon.xml +++ b/addon.xml @@ -1,9 +1,9 @@ - + - + video diff --git a/docs/index.html b/docs/index.html index 8dbc1374..86a55021 100644 --- a/docs/index.html +++ b/docs/index.html @@ -115,3 +115,4 @@

🎮 Twitch for Kodi

+ diff --git a/resources/language/resource.language.de_de/strings.po b/resources/language/resource.language.de_de/strings.po index 64b8b794..c4fe8edc 100644 --- a/resources/language/resource.language.de_de/strings.po +++ b/resources/language/resource.language.de_de/strings.po @@ -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." diff --git a/resources/language/resource.language.en_gb/strings.po b/resources/language/resource.language.en_gb/strings.po index 2c7c7725..b0cc0605 100644 --- a/resources/language/resource.language.en_gb/strings.po +++ b/resources/language/resource.language.en_gb/strings.po @@ -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 "" \ No newline at end of file diff --git a/resources/lib/twitch_addon/addon/api.py b/resources/lib/twitch_addon/addon/api.py index 7cc17445..c4a8fd6d 100644 --- a/resources/lib/twitch_addon/addon/api.py +++ b/resources/lib/twitch_addon/addon/api.py @@ -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 @@ -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 @@ -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: @@ -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 @@ -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) @@ -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( @@ -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 diff --git a/resources/lib/twitch_addon/addon/common/cache.py b/resources/lib/twitch_addon/addon/common/cache.py index 20cbfd05..bd4321db 100644 --- a/resources/lib/twitch_addon/addon/common/cache.py +++ b/resources/lib/twitch_addon/addon/common/cache.py @@ -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() diff --git a/resources/lib/twitch_addon/addon/constants.py b/resources/lib/twitch_addon/addon/constants.py index 1599534f..65d7e9bd 100644 --- a/resources/lib/twitch_addon/addon/constants.py +++ b/resources/lib/twitch_addon/addon/constants.py @@ -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 diff --git a/resources/lib/twitch_addon/addon/device_auth.py b/resources/lib/twitch_addon/addon/device_auth.py new file mode 100644 index 00000000..cb295ff6 --- /dev/null +++ b/resources/lib/twitch_addon/addon/device_auth.py @@ -0,0 +1,433 @@ +# -*- coding: utf-8 -*- +""" + Device Code Grant Flow for Twitch OAuth + + Copyright (C) 2026 Twitch-on-Kodi + + This file is part of Twitch-on-Kodi (plugin.video.twitch) + + SPDX-License-Identifier: GPL-3.0-only + See LICENSES/GPL-3.0-only for more information. +""" + +import json +import time +import requests +from urllib.parse import urlencode + +from .common import kodi, log_utils +from . import utils +from .constants import SCOPES + +# Twitch OAuth endpoints +DEVICE_AUTH_URL = 'https://id.twitch.tv/oauth2/device' +TOKEN_URL = 'https://id.twitch.tv/oauth2/token' +VALIDATE_URL = 'https://id.twitch.tv/oauth2/validate' + +# Device Code Grant type +DEVICE_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:device_code' +REFRESH_GRANT_TYPE = 'refresh_token' + + +class DeviceAuthError(Exception): + """Exception for Device Auth errors""" + pass + + +class DeviceAuth: + """ + Implements the Device Code Grant Flow for Twitch. + + This flow is designed for devices with limited input capabilities + like set-top boxes, game consoles, and media centers (Kodi). + + Requires the user's own registered Twitch application Client-ID. + """ + + def __init__(self, client_id): + """ + Initialize Device Auth with a client ID. + + Args: + client_id: Twitch application Client ID (required). + """ + if not client_id: + raise DeviceAuthError('Client ID is required for Device Authentication') + self.client_id = client_id + self.proxies = utils.get_proxy_dict() + + def _make_request(self, url, data, method='POST'): + """Make HTTP request with error handling""" + try: + headers = {'Content-Type': 'application/x-www-form-urlencoded'} + + if method == 'POST': + response = requests.post(url, data=urlencode(data), headers=headers, + proxies=self.proxies, timeout=30) + else: + response = requests.get(url, headers=headers, proxies=self.proxies, timeout=30) + + return response.json() + except requests.exceptions.RequestException as e: + log_utils.log('Device Auth request failed: %s' % str(e), log_utils.LOGERROR) + raise DeviceAuthError('Network error: %s' % str(e)) + except json.JSONDecodeError as e: + log_utils.log('Device Auth JSON decode failed: %s' % str(e), log_utils.LOGERROR) + raise DeviceAuthError('Invalid response from Twitch') + + def start_device_flow(self, scopes=None): + """ + Start the Device Code flow. + + Args: + scopes: List of OAuth scopes to request. Defaults to SCOPES from constants. + + Returns: + dict with device_code, user_code, verification_uri, expires_in, interval + """ + if scopes is None: + scopes = SCOPES + + scope_string = ' '.join(scopes) + + data = { + 'client_id': self.client_id, + 'scopes': scope_string + } + + log_utils.log('Starting Device Code flow with client_id: %s' % self.client_id[:8] + '...', + log_utils.LOGDEBUG) + + result = self._make_request(DEVICE_AUTH_URL, data) + + if 'error' in result: + error_msg = result.get('message', result.get('error', 'Unknown error')) + log_utils.log('Device Code flow failed: %s' % error_msg, log_utils.LOGERROR) + raise DeviceAuthError(error_msg) + + required_fields = ['device_code', 'user_code', 'verification_uri'] + for field in required_fields: + if field not in result: + raise DeviceAuthError('Missing required field: %s' % field) + + log_utils.log('Device Code flow started. User code: %s' % result['user_code'], + log_utils.LOGINFO) + + return result + + def poll_for_token(self, device_code, interval=5, timeout=1800, progress_callback=None): + """ + Poll for the access token after user authorizes. + + Args: + device_code: The device_code from start_device_flow() + interval: Polling interval in seconds (default: 5) + timeout: Maximum time to wait in seconds (default: 1800 = 30 min) + progress_callback: Optional callback(elapsed, total) for progress updates + + Returns: + dict with access_token, refresh_token, expires_in, scope, token_type + """ + data = { + 'client_id': self.client_id, + 'device_code': device_code, + 'grant_type': DEVICE_GRANT_TYPE + } + + start_time = time.time() + + while True: + elapsed = time.time() - start_time + + if elapsed >= timeout: + raise DeviceAuthError('Authorization timeout') + + if progress_callback: + progress_callback(int(elapsed), timeout) + + result = self._make_request(TOKEN_URL, data) + + if 'access_token' in result: + log_utils.log('Device Code flow: Token received successfully', log_utils.LOGINFO) + return result + + if 'status' in result: + status = result['status'] + message = result.get('message', '') + + if status == 400 and message == 'authorization_pending': + # User hasn't authorized yet, keep polling + time.sleep(interval) + continue + elif status == 400 and message == 'slow_down': + # We're polling too fast, increase interval + interval = min(interval + 5, 30) + log_utils.log('Device Code flow: Slowing down, new interval: %d' % interval, + log_utils.LOGDEBUG) + time.sleep(interval) + continue + elif status == 400 and message == 'expired_token': + raise DeviceAuthError('Device code expired. Please try again.') + elif status == 400 and message == 'access_denied': + raise DeviceAuthError('Authorization denied by user.') + else: + raise DeviceAuthError('Authorization failed: %s' % message) + + # Unknown response, wait and retry + time.sleep(interval) + + def refresh_token(self, refresh_token, client_secret=None): + """ + Refresh an access token using a refresh token. + + For Public clients (no client_secret), the refresh token is one-time use. + Store the new refresh_token from the response! + + Args: + refresh_token: The refresh token to use + client_secret: Optional client secret (for Confidential clients) + + Returns: + dict with access_token, refresh_token, expires_in, scope, token_type + """ + data = { + 'client_id': self.client_id, + 'grant_type': REFRESH_GRANT_TYPE, + 'refresh_token': refresh_token + } + + if client_secret: + data['client_secret'] = client_secret + + log_utils.log('Refreshing access token', log_utils.LOGDEBUG) + + result = self._make_request(TOKEN_URL, data) + + if 'error' in result or 'status' in result: + error_msg = result.get('message', result.get('error', 'Token refresh failed')) + log_utils.log('Token refresh failed: %s' % error_msg, log_utils.LOGERROR) + raise DeviceAuthError(error_msg) + + if 'access_token' not in result: + raise DeviceAuthError('Invalid response: no access_token') + + log_utils.log('Token refreshed successfully', log_utils.LOGINFO) + return result + + @staticmethod + def validate_token(token): + """ + Validate an access token. + + Args: + token: The access token to validate + + Returns: + dict with client_id, login, scopes, user_id, expires_in + or dict with status and message if invalid + """ + try: + headers = {'Authorization': 'OAuth %s' % token} + response = requests.get(VALIDATE_URL, headers=headers, timeout=30) + return response.json() + except Exception as e: + log_utils.log('Token validation failed: %s' % str(e), log_utils.LOGERROR) + return {'status': 401, 'message': 'Validation request failed'} + + +def show_device_auth_dialog(client_id): + """ + Show the Device Auth dialog to the user and handle the complete flow. + + Args: + client_id: Twitch application Client ID (required) + + Returns: + dict with tokens on success, None on failure/cancel + """ + i18n = utils.i18n + + auth = DeviceAuth(client_id) + + try: + # Start the flow + device_info = auth.start_device_flow() + + user_code = device_info['user_code'] + verification_uri = device_info['verification_uri'] + expires_in = device_info.get('expires_in', 1800) + interval = device_info.get('interval', 5) + device_code = device_info['device_code'] + + # Show dialog with user code + instructions = i18n('device_auth_instructions') % (verification_uri, user_code) + progress = kodi.ProgressDialog( + i18n('device_auth_title'), + line1=instructions + ) + + def progress_callback(elapsed, total): + percent = int((elapsed / total) * 100) + progress.update( + percent, + line1=instructions, + line2=i18n('device_auth_waiting') + ) + if progress.is_canceled(): + raise DeviceAuthError('Cancelled by user') + + try: + tokens = auth.poll_for_token( + device_code, + interval=interval, + timeout=expires_in, + progress_callback=progress_callback + ) + + if progress.pd: + progress.pd.close() + + # Save the tokens + save_device_tokens(tokens) + + kodi.notify( + kodi.get_name(), + i18n('device_auth_success'), + sound=False + ) + + return tokens + + except DeviceAuthError as e: + if progress.pd: + progress.pd.close() + if str(e) != 'Cancelled by user': + kodi.Dialog().ok(i18n('device_auth_title'), str(e)) + return None + + except DeviceAuthError as e: + kodi.Dialog().ok(i18n('device_auth_title'), str(e)) + return None + + +def save_device_tokens(tokens): + """ + Save tokens from Device Code flow to addon settings. + + Args: + tokens: dict with access_token, refresh_token, expires_in + """ + access_token = tokens.get('access_token', '') + refresh_token = tokens.get('refresh_token', '') + expires_in = tokens.get('expires_in', 0) + + # Calculate expiry timestamp + # expires_in=0 means the token never expires (e.g. Twitch web client ID tokens) + # Use -1 as sentinel for "never expires" + if expires_in and expires_in > 0: + expires_at = int(time.time()) + expires_in + else: + expires_at = -1 # Never expires + + # Save to settings - only save to oauth_token_helix (for Helix API) + # Do NOT save to twitch_hevc_token - Device Auth tokens are third-party tokens + # that don't work with the GQL/private API (causes 401). The GQL API works + # anonymously with just a Client-ID, or with first-party website tokens. + kodi.set_setting('oauth_token_helix', access_token) + kodi.set_setting('device_refresh_token', refresh_token) + kodi.set_setting('device_token_expires_at', str(expires_at)) + + log_utils.log('Device tokens saved. Expires at: %s (expires_in=%s)' % (expires_at, expires_in), log_utils.LOGINFO) + + +def get_device_tokens(): + """ + Get saved device tokens. + + Returns: + dict with access_token, refresh_token, expires_at or None + """ + access_token = kodi.get_setting('oauth_token_helix') + refresh_token = kodi.get_setting('device_refresh_token') + expires_at_str = kodi.get_setting('device_token_expires_at') + + if not access_token or not refresh_token: + return None + + try: + expires_at = int(expires_at_str) if expires_at_str else 0 + except ValueError: + expires_at = 0 + + return { + 'access_token': access_token, + 'refresh_token': refresh_token, + 'expires_at': expires_at + } + + +def is_token_expired(): + """ + Check if the current token is expired or about to expire. + + Returns: + True if token is expired or expires within 5 minutes + False if token is still valid or never expires (expires_at <= 0) + """ + tokens = get_device_tokens() + if not tokens: + return True + + expires_at = tokens.get('expires_at', 0) + # expires_at <= 0 means "never expires" (sentinel -1 or legacy 0) + # Tokens from Twitch web client ID have expires_in=0 + if expires_at <= 0: + return False + # Consider expired if less than 5 minutes remaining + return time.time() >= (expires_at - 300) + + +def auto_refresh_token(): + """ + Automatically refresh the token if it's expired. + + Returns: + True if token is valid (or was refreshed), False if refresh failed + """ + tokens = get_device_tokens() + if not tokens: + log_utils.log('No device tokens found for auto-refresh', log_utils.LOGDEBUG) + return False + + if not is_token_expired(): + return True + + refresh_token = tokens.get('refresh_token') + if not refresh_token: + log_utils.log('No refresh token available', log_utils.LOGWARNING) + return False + + try: + client_id = utils.get_client_id() + if not client_id: + log_utils.log('No client_id configured, cannot auto-refresh', log_utils.LOGWARNING) + return False + auth = DeviceAuth(client_id) + new_tokens = auth.refresh_token(refresh_token) + save_device_tokens(new_tokens) + log_utils.log('Token auto-refreshed successfully', log_utils.LOGINFO) + return True + except DeviceAuthError as e: + log_utils.log('Token auto-refresh failed: %s' % str(e), log_utils.LOGWARNING) + # Clear invalid tokens + kodi.set_setting('device_refresh_token', '') + kodi.set_setting('device_token_expires_at', '') + return False + + +def clear_device_tokens(): + """Clear all saved device tokens (does not touch twitch_hevc_token).""" + kodi.set_setting('oauth_token_helix', '') + kodi.set_setting('device_refresh_token', '') + kodi.set_setting('device_token_expires_at', '') + log_utils.log('Device tokens cleared', log_utils.LOGINFO) diff --git a/resources/lib/twitch_addon/addon/strings.py b/resources/lib/twitch_addon/addon/strings.py index 3d24bfe7..b51502b8 100644 --- a/resources/lib/twitch_addon/addon/strings.py +++ b/resources/lib/twitch_addon/addon/strings.py @@ -164,4 +164,17 @@ 'error_network_check': 30303, 'error_unexpected': 30304, 'error_unknown': 30305, + # Device Auth + 'device_auth_title': 30310, + 'device_auth_instructions': 30311, + 'device_auth_waiting': 30312, + 'device_auth_success': 30313, + 'device_auth_connect': 30314, + 'device_auth_disconnect': 30315, + 'device_auth_refresh_success': 30316, + 'device_auth_refresh_failed': 30317, + 'device_auth_disconnect_confirm': 30318, + 'device_auth_disconnected': 30319, + 'device_auth_recommended': 30320, + 'client_id_required': 30321, } diff --git a/resources/lib/twitch_addon/addon/utils.py b/resources/lib/twitch_addon/addon/utils.py index a33dd4f6..c8cbb4df 100644 --- a/resources/lib/twitch_addon/addon/utils.py +++ b/resources/lib/twitch_addon/addon/utils.py @@ -139,16 +139,18 @@ def get_redirect_uri(): def get_twitch_client_id(): - """Get the Twitch Client-ID from settings""" + """Get the user's Twitch App Client-ID from settings. + + This is the user's own registered Twitch application Client-ID, + required for Helix API access and Device Auth flow. + Returns empty string if not configured. + """ settings_id = kodi.get_setting('twitch_client_id') stripped_id = settings_id.strip() if settings_id != stripped_id: settings_id = stripped_id kodi.set_setting('twitch_client_id', settings_id) - if not settings_id: - # Default to Twitch's web Client-ID - return 'kimne78kx3ncx6brgo4mv6wki5h1ko' - return kodi.decode_utf8(settings_id) + return kodi.decode_utf8(settings_id) if settings_id else '' def get_hevc_token(): @@ -238,8 +240,12 @@ def get_private_oauth_token(): def get_private_client_id(): - """Get Client-ID for private/GQL API - uses the main twitch_client_id setting""" - return get_twitch_client_id() + """Get Client-ID for private/GQL API - always uses Twitch's web client ID. + + The GQL API (gql.twitch.tv) only accepts Twitch's own web client ID. + Third-party app client IDs get rejected with 'The Client-ID header is invalid'. + """ + return 'kimne78kx3ncx6brgo4mv6wki5h1ko' def get_low_latency(): diff --git a/resources/lib/twitch_addon/router.py b/resources/lib/twitch_addon/router.py index b623863b..88fa49f7 100644 --- a/resources/lib/twitch_addon/router.py +++ b/resources/lib/twitch_addon/router.py @@ -290,6 +290,20 @@ def _clear_watch_history(content_type=None, content_id=None, clear_all=False): clear_watch_history.route(content_type, content_id, clear_all) +@dispatcher.register(MODES.DEVICEAUTH) +@error_handler +def _device_auth(): + from .routes import device_auth + device_auth.route_connect(twitch_api) + + +@dispatcher.register(MODES.DEVICEAUTHDISCONNECT) +@error_handler +def _device_auth_disconnect(): + from .routes import device_auth + device_auth.route_disconnect(twitch_api) + + def run(argv): queries = kodi.parse_query(argv[2]) log_utils.log('Version: |%s| Application Version: %s' % (kodi.get_version(), kodi.get_kodi_version()), log_utils.LOGDEBUG) diff --git a/resources/lib/twitch_addon/routes/device_auth.py b/resources/lib/twitch_addon/routes/device_auth.py new file mode 100644 index 00000000..c058c26b --- /dev/null +++ b/resources/lib/twitch_addon/routes/device_auth.py @@ -0,0 +1,62 @@ +# -*- coding: utf-8 -*- +""" + Device Authentication Route + + Copyright (C) 2026 Twitch-on-Kodi + + This file is part of Twitch-on-Kodi (plugin.video.twitch) + + SPDX-License-Identifier: GPL-3.0-only + See LICENSES/GPL-3.0-only for more information. +""" + +from ..addon import utils +from ..addon.common import kodi +from ..addon.device_auth import show_device_auth_dialog, clear_device_tokens, get_device_tokens + +i18n = utils.i18n + + +def route_connect(api): + """ + Start the Device Code Authentication flow. + Shows a dialog with the user code and waits for authorization. + """ + # Check if user has entered their Client-ID + client_id = utils.get_client_id() + if not client_id: + kodi.Dialog().ok( + i18n('device_auth_title'), + i18n('client_id_required') + ) + kodi.show_settings() + return + + result = show_device_auth_dialog(client_id=client_id) + + if result: + # Refresh the settings dialog to show updated state + kodi.execute_builtin('Container.Refresh') + + +def route_disconnect(api): + """ + Disconnect the Twitch account by clearing saved tokens. + """ + tokens = get_device_tokens() + + if not tokens or not tokens.get('access_token'): + kodi.notify(kodi.get_name(), i18n('device_auth_disconnected'), sound=False) + return + + # Ask for confirmation + dialog = kodi.Dialog() + confirmed = dialog.yesno( + i18n('device_auth_title'), + i18n('device_auth_disconnect_confirm') + ) + + if confirmed: + clear_device_tokens() + kodi.notify(kodi.get_name(), i18n('device_auth_disconnected'), sound=False) + kodi.execute_builtin('Container.Refresh') diff --git a/resources/settings.xml b/resources/settings.xml index d7d7cdc2..60963fb9 100644 --- a/resources/settings.xml +++ b/resources/settings.xml @@ -3,13 +3,20 @@
- - + + 0 - kimne78kx3ncx6brgo4mv6wki5h1ko - + + RunPlugin(plugin://$ID/?mode=device_auth) + + + 0 + + RunPlugin(plugin://$ID/?mode=device_auth_disconnect) - + + + 0 @@ -28,6 +35,8 @@ + 0