From 05a02fce8dedf504447c94a53a63cf5a29ad5372 Mon Sep 17 00:00:00 2001 From: einanderson <289327658+einanderson@users.noreply.github.com> Date: Fri, 5 Jun 2026 16:09:40 +0200 Subject: [PATCH 1/8] Add OAuth Device Code Grant login with automatic token refresh The implicit-grant link flow never returns a refresh token, so the OAuth token has to be regenerated manually every few weeks/months. This adds Twitch's Device Code Grant flow ("Login (device code)" under Settings -> Login): the user authorizes once with a short code at twitch.tv/activate, and the add-on then refreshes the access token silently via the stored refresh token (on demand in api.Twitch and proactively from the background service). The existing manual link flow is kept as a fallback. No client secret is required (public client, default Client-ID). New strings are English source strings, translatable via Weblate. Refs #701, #698 --- .../resource.language.en_gb/strings.po | 28 +++++++ resources/lib/twitch_addon/addon/api.py | 3 + resources/lib/twitch_addon/addon/constants.py | 1 + .../lib/twitch_addon/addon/device_oauth.py | 72 +++++++++++++++++ resources/lib/twitch_addon/addon/strings.py | 7 ++ resources/lib/twitch_addon/addon/utils.py | 49 +++++++++++- resources/lib/twitch_addon/router.py | 7 ++ .../lib/twitch_addon/routes/device_login.py | 79 +++++++++++++++++++ resources/lib/twitch_addon/service.py | 14 +++- resources/settings.xml | 3 + 10 files changed, 261 insertions(+), 2 deletions(-) create mode 100644 resources/lib/twitch_addon/addon/device_oauth.py create mode 100644 resources/lib/twitch_addon/routes/device_login.py diff --git a/resources/language/resource.language.en_gb/strings.po b/resources/language/resource.language.en_gb/strings.po index b55dfaa9..392e5c9d 100644 --- a/resources/language/resource.language.en_gb/strings.po +++ b/resources/language/resource.language.en_gb/strings.po @@ -1005,3 +1005,31 @@ msgstr "" msgctxt "#30273" msgid "OAuth Token is expired or invalid" msgstr "" + +msgctxt "#30300" +msgid "Login (device code)" +msgstr "" + +msgctxt "#30301" +msgid "Go to [B]%s[/B] and enter this code:[CR][CR][B]%s[/B]" +msgstr "" + +msgctxt "#30302" +msgid "Waiting for authorization..." +msgstr "" + +msgctxt "#30303" +msgid "Login successful. The access token will now be refreshed automatically." +msgstr "" + +msgctxt "#30304" +msgid "Login failed or timed out." +msgstr "" + +msgctxt "#30305" +msgid "Login cancelled." +msgstr "" + +msgctxt "#30306" +msgid "Device login is not available for this Client-ID.[CR]Register your own application (Public type) at dev.twitch.tv/console/apps and set its Client-ID in the add-on settings, then try again." +msgstr "" diff --git a/resources/lib/twitch_addon/addon/api.py b/resources/lib/twitch_addon/addon/api.py index b1eee1d2..7cc777fe 100644 --- a/resources/lib/twitch_addon/addon/api.py +++ b/resources/lib/twitch_addon/addon/api.py @@ -38,6 +38,9 @@ class Twitch: required_scopes = SCOPES def __init__(self): + # 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) self.queries.CLIENT_ID = self.client_id self.queries.CLIENT_SECRET = self.client_secret self.queries.OAUTH_TOKEN = self.access_token diff --git a/resources/lib/twitch_addon/addon/constants.py b/resources/lib/twitch_addon/addon/constants.py index d7fbd5bd..aa9b2439 100644 --- a/resources/lib/twitch_addon/addon/constants.py +++ b/resources/lib/twitch_addon/addon/constants.py @@ -38,6 +38,7 @@ def __enum(**enums): INSTALLIRCCHAT='install_ircchat', PLAY='play', TOKENURL='get_token_url', + DEVICELOGIN='device_login', EDITFOLLOW='edit_user_follows', EDITBLOCK='edit_user_blocks', EDITBLACKLIST='edit_blacklist', diff --git a/resources/lib/twitch_addon/addon/device_oauth.py b/resources/lib/twitch_addon/addon/device_oauth.py new file mode 100644 index 00000000..41e45ab7 --- /dev/null +++ b/resources/lib/twitch_addon/addon/device_oauth.py @@ -0,0 +1,72 @@ +# -*- coding: utf-8 -*- +""" + + Twitch OAuth 2.0 Device Code Grant Flow + silent refresh. + + Replaces the manual implicit-grant link flow (which never returned a + refresh token, forcing periodic manual re-login). Pure logic only; the + interactive login UI lives in routes/device_login.py. + + SPDX-License-Identifier: GPL-3.0-only +""" + +import requests + +from .common import log_utils + +OAUTH_BASE = 'https://id.twitch.tv/oauth2' +DEVICE_GRANT = 'urn:ietf:params:oauth:grant-type:device_code' +TIMEOUT = 15 + + +def request_device_code(client_id, scopes): + """Start the device flow. scopes = space-separated string. + Returns (ok, data): on ok, data has device_code/user_code/verification_uri/interval/expires_in.""" + try: + r = requests.post(OAUTH_BASE + '/device', + data={'client_id': client_id, 'scopes': scopes}, timeout=TIMEOUT) + data = r.json() if r.content else {} + return (r.status_code == 200 and bool(data.get('device_code'))), data + except Exception as e: + log_utils.log('device_oauth.request_device_code error: %s' % e, log_utils.LOGERROR) + return False, {'message': str(e)} + + +def poll_device_token(client_id, scopes, device_code): + """Poll the token endpoint. Returns (status, data) where status is one of + 'ok' | 'pending' | 'slow_down' | 'expired' | 'denied' | 'error'.""" + try: + r = requests.post(OAUTH_BASE + '/token', + data={'client_id': client_id, 'scopes': scopes, + 'device_code': device_code, 'grant_type': DEVICE_GRANT}, + timeout=TIMEOUT) + data = r.json() if r.content else {} + if r.status_code == 200 and data.get('access_token'): + return 'ok', data + msg = str(data.get('message', '')).lower() + if 'pending' in msg: + return 'pending', data + if 'slow' in msg: + return 'slow_down', data + if 'expired' in msg: + return 'expired', data + if 'denied' in msg or r.status_code == 403: + return 'denied', data + return 'error', data + except Exception as e: + log_utils.log('device_oauth.poll_device_token error: %s' % e, log_utils.LOGERROR) + return 'error', {'message': str(e)} + + +def refresh_access_token(client_id, refresh_token): + """Refresh silently via grant_type=refresh_token (public client -> NO client_secret). + Returns (ok, data): on ok, data has a new access_token + (rotated) refresh_token + expires_in.""" + try: + r = requests.post(OAUTH_BASE + '/token', + data={'client_id': client_id, 'grant_type': 'refresh_token', + 'refresh_token': refresh_token}, timeout=TIMEOUT) + data = r.json() if r.content else {} + return (r.status_code == 200 and bool(data.get('access_token'))), data + except Exception as e: + log_utils.log('device_oauth.refresh_access_token error: %s' % e, log_utils.LOGERROR) + return False, {'message': str(e)} diff --git a/resources/lib/twitch_addon/addon/strings.py b/resources/lib/twitch_addon/addon/strings.py index d5fb4c29..d92d471d 100644 --- a/resources/lib/twitch_addon/addon/strings.py +++ b/resources/lib/twitch_addon/addon/strings.py @@ -118,6 +118,13 @@ 'revoke_confirmation': 30228, 'token_revoked': 30229, 'token_updated': 30230, + 'device_login': 30300, + 'device_login_instructions': 30301, + 'device_login_waiting': 30302, + 'device_login_success': 30303, + 'device_login_failed': 30304, + 'device_login_cancelled': 30305, + 'device_login_unsupported': 30306, 'started_streaming': 30231, 'new_search': 30235, 'clear_search_history_': 30237, diff --git a/resources/lib/twitch_addon/addon/utils.py b/resources/lib/twitch_addon/addon/utils.py index 1a7aeab1..128a7a87 100644 --- a/resources/lib/twitch_addon/addon/utils.py +++ b/resources/lib/twitch_addon/addon/utils.py @@ -16,7 +16,7 @@ from datetime import datetime from urllib.parse import quote_plus -from .common import kodi, json_store +from .common import kodi, json_store, log_utils from .strings import STRINGS from .constants import CLIENT_ID, REDIRECT_URI, LIVE_PREVIEW_TEMPLATE, Images, ADDON_DATA_DIR, COLORS, Keys from .search_history import StreamsSearchHistory, ChannelsSearchHistory, GamesSearchHistory, IdUrlSearchHistory @@ -179,6 +179,53 @@ def get_private_oauth_token(): return kodi.decode_utf8(settings_id) +# --- OAuth Device Code Flow: token storage + silent refresh ----------------------------- + +def get_refresh_token(): + return kodi.get_setting('oauth_refresh_token').strip() + + +def store_oauth_tokens(access_token, refresh_token, expires_in): + kodi.set_setting('oauth_token_helix', access_token) + kodi.set_setting('oauth_refresh_token', refresh_token or '') + try: + expiry = int(time.time()) + int(expires_in) - 120 # refresh ~2 min before expiry + except (TypeError, ValueError): + expiry = int(time.time()) + 3600 + kodi.set_setting('oauth_token_expiry', str(expiry)) + + +def clear_oauth_tokens(): + kodi.set_setting('oauth_token_helix', '') + kodi.set_setting('oauth_refresh_token', '') + kodi.set_setting('oauth_token_expiry', '0') + + +def ensure_valid_token(force=False): + """Silently refresh the Helix OAuth token via the stored refresh_token when it is + (near) expired. No-op for legacy implicit tokens (no refresh_token stored). + Returns the (possibly refreshed) access token, or '' if none available.""" + from . import device_oauth + refresh_token = get_refresh_token() + access_token = kodi.get_setting('oauth_token_helix').strip() + if not refresh_token: + return access_token # legacy implicit-grant token -> nothing to refresh + try: + expiry = float(kodi.get_setting('oauth_token_expiry') or '0') + except ValueError: + expiry = 0 + if access_token and not force and time.time() < expiry: + return access_token + ok, data = device_oauth.refresh_access_token(get_client_id(), refresh_token) + if ok and data.get('access_token'): + store_oauth_tokens(data['access_token'], data.get('refresh_token', refresh_token), + data.get('expires_in', 3600)) + log_utils.log('OAuth: access token refreshed via refresh_token', log_utils.LOGNOTICE) + return data['access_token'] + log_utils.log('OAuth: token refresh failed |%s|' % data, log_utils.LOGWARNING) + return access_token # keep current; valid_token() will prompt re-login if truly invalid + + def get_search_history_size(): return int(kodi.get_setting('search_history_size')) diff --git a/resources/lib/twitch_addon/router.py b/resources/lib/twitch_addon/router.py index a8f22b94..18bb3d67 100644 --- a/resources/lib/twitch_addon/router.py +++ b/resources/lib/twitch_addon/router.py @@ -255,6 +255,13 @@ def _get_token_url(): token_url.route(twitch_api) +@dispatcher.register(MODES.DEVICELOGIN) +@error_handler +def _device_login(): + from .routes import device_login + device_login.route() + + @dispatcher.register(MODES.REVOKETOKEN) @error_handler def _revoke_token(): diff --git a/resources/lib/twitch_addon/routes/device_login.py b/resources/lib/twitch_addon/routes/device_login.py new file mode 100644 index 00000000..337cec54 --- /dev/null +++ b/resources/lib/twitch_addon/routes/device_login.py @@ -0,0 +1,79 @@ +# -*- coding: utf-8 -*- +""" + + Copyright (C) 2024 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 xbmc +import xbmcgui + +from ..addon import utils, device_oauth +from ..addon.common import kodi, log_utils +from ..addon.constants import SCOPES +from ..addon.utils import i18n + + +def route(): + client_id = utils.get_client_id() + scopes = ' '.join(SCOPES) + + ok, data = device_oauth.request_device_code(client_id, scopes) + if not ok or not data.get('user_code'): + kodi.Dialog().ok(i18n('device_login'), i18n('device_login_unsupported')) + return + + user_code = data['user_code'] + device_code = data['device_code'] + interval = int(data.get('interval', 5)) or 5 + expires_in = int(data.get('expires_in', 1800)) or 1800 + + # Short, clean activation URL (Twitch's verification_uri carries the code as a long query string). + activate_url = 'https://www.twitch.tv/activate' + body = i18n('device_login_instructions') % (activate_url, user_code) + + monitor = xbmc.Monitor() + progress = xbmcgui.DialogProgress() + progress.create(i18n('device_login'), body) + + token = None + waited = 0 + while waited < expires_in: + # wait the poll interval, abortable (user cancel or Kodi shutdown) + for _ in range(interval): + if progress.iscanceled(): + progress.close() + kodi.notify(i18n('login'), i18n('device_login_cancelled'), sound=False) + return + if monitor.waitForAbort(1): # Kodi is shutting down + progress.close() + return + waited += 1 + status, tdata = device_oauth.poll_device_token(client_id, scopes, device_code) + if status == 'ok': + token = tdata + break + elif status == 'slow_down': + interval += 2 + elif status in ('expired', 'denied'): + break + # 'pending' / transient 'error' -> keep polling + try: + progress.update(int(min(99, (waited * 100) // expires_in)), + body + '[CR]' + i18n('device_login_waiting')) + except Exception: + pass + + progress.close() + if token and token.get('access_token'): + utils.store_oauth_tokens(token['access_token'], + token.get('refresh_token', ''), + token.get('expires_in', 14400)) + log_utils.log('OAuth: device login succeeded, refresh token stored', log_utils.LOGNOTICE) + kodi.Dialog().ok(i18n('device_login'), i18n('device_login_success')) + else: + kodi.notify(i18n('login'), i18n('device_login_failed'), sound=False) diff --git a/resources/lib/twitch_addon/service.py b/resources/lib/twitch_addon/service.py index f1c200f7..681f736d 100644 --- a/resources/lib/twitch_addon/service.py +++ b/resources/lib/twitch_addon/service.py @@ -19,7 +19,7 @@ from .addon.common import kodi, log_utils from .addon.constants import Keys -from .addon.utils import i18n, get_stamp_diff, get_vodcast_color +from .addon.utils import i18n, get_stamp_diff, get_vodcast_color, ensure_valid_token from .addon.player import TwitchPlayer from .addon import api, cache @@ -216,10 +216,22 @@ def run(): live_notifications_thread = LiveNotificationsThread() + token_check = 0 + try: + ensure_valid_token() # refresh on startup if needed + except Exception: + pass while not monitor.abortRequested(): if monitor.waitForAbort(1.0): break + token_check += 1 + if token_check >= 300: # proactively refresh the OAuth token every ~5 min + token_check = 0 + try: + ensure_valid_token() + except Exception: + pass live_notifications_thread.stop() live_notifications_thread.join() diff --git a/resources/settings.xml b/resources/settings.xml index a6f09d46..d5632dba 100644 --- a/resources/settings.xml +++ b/resources/settings.xml @@ -2,6 +2,9 @@ + + + From 25ef979a663bebce17f2198e9ed9785fc9cce6ac Mon Sep 17 00:00:00 2001 From: einanderson <289327658+einanderson@users.noreply.github.com> Date: Fri, 5 Jun 2026 16:31:32 +0200 Subject: [PATCH 2/8] Add a "Supported codecs" setting (HEVC / Enhanced Broadcasting) Adds a Supported codecs setting (Twitch default / H.265 (HEVC)) whose value is passed to usher via the new supported_codecs parameter, so users with hardware HEVC decode can receive Enhanced Broadcasting variants (1440p+). Default is "Twitch default" (no codec requested) -> behaviour unchanged. The value is only passed when the installed script.module.python.twitch accepts the parameter (feature-detected), so it degrades gracefully with an older library. Refs #699, #700 --- .../resource.language.en_gb/strings.po | 12 +++++++++ resources/lib/twitch_addon/addon/api.py | 26 ++++++++++++++----- resources/lib/twitch_addon/addon/utils.py | 11 ++++++++ resources/settings.xml | 2 ++ 4 files changed, 45 insertions(+), 6 deletions(-) diff --git a/resources/language/resource.language.en_gb/strings.po b/resources/language/resource.language.en_gb/strings.po index b55dfaa9..5d6414d9 100644 --- a/resources/language/resource.language.en_gb/strings.po +++ b/resources/language/resource.language.en_gb/strings.po @@ -1005,3 +1005,15 @@ msgstr "" msgctxt "#30273" msgid "OAuth Token is expired or invalid" msgstr "" + +msgctxt "#30307" +msgid "Supported codecs" +msgstr "" + +msgctxt "#30308" +msgid "Twitch default" +msgstr "" + +msgctxt "#30309" +msgid "H.265 (HEVC)" +msgstr "" diff --git a/resources/lib/twitch_addon/addon/api.py b/resources/lib/twitch_addon/addon/api.py index b1eee1d2..c16ea89f 100644 --- a/resources/lib/twitch_addon/addon/api.py +++ b/resources/lib/twitch_addon/addon/api.py @@ -24,6 +24,13 @@ from twitch.api import helix as twitch from twitch.api.parameters import Language, Boolean, VideoSort, PeriodHelix +try: + from inspect import signature as _signature + # Older library versions don't accept the supported_codecs keyword; degrade gracefully. + _USHER_SUPPORTS_CODECS = 'supported_codecs' in _signature(usher.live_request).parameters +except Exception: + _USHER_SUPPORTS_CODECS = False + i18n = utils.i18n @@ -329,10 +336,17 @@ def get_followed_streams(self, user_id, after='MA==', first=20): results = self.error_check(results) return results + @staticmethod + def _codec_kwargs(): + codecs = utils.get_supported_codecs() + if codecs and _USHER_SUPPORTS_CODECS: + return {'supported_codecs': codecs} + return {} + @api_error_handler @cache.cache_method(cache_limit=cache.limit) def get_vod(self, video_id): - results = self.usher.video(video_id, headers=self.get_private_credential_headers()) + results = self.usher.video(video_id, headers=self.get_private_credential_headers(), **self._codec_kwargs()) return self.error_check(results, private=True) @api_error_handler @@ -343,25 +357,25 @@ def get_clip(self, slug): @api_error_handler @cache.cache_method(cache_limit=cache.limit) def get_live(self, name): - results = self.usher.live(name, headers=self.get_private_credential_headers()) + results = self.usher.live(name, headers=self.get_private_credential_headers(), **self._codec_kwargs()) return self.error_check(results, private=True) @api_error_handler @cache.cache_method(cache_limit=cache.limit) def live_request(self, name): if not utils.inputstream_adpative_supports('EXT-X-DISCONTINUITY'): - results = self.usher.live_request(name, platform='ps4', headers=self.get_private_credential_headers()) + results = self.usher.live_request(name, platform='ps4', headers=self.get_private_credential_headers(), **self._codec_kwargs()) else: - results = self.usher.live_request(name, headers=self.get_private_credential_headers()) + results = self.usher.live_request(name, headers=self.get_private_credential_headers(), **self._codec_kwargs()) return self.error_check(results, private=True) @api_error_handler @cache.cache_method(cache_limit=cache.limit) def video_request(self, video_id): if not utils.inputstream_adpative_supports('EXT-X-DISCONTINUITY'): - results = self.usher.video_request(video_id, platform='ps4', headers=self.get_private_credential_headers()) + results = self.usher.video_request(video_id, platform='ps4', headers=self.get_private_credential_headers(), **self._codec_kwargs()) else: - results = self.usher.video_request(video_id, headers=self.get_private_credential_headers()) + results = self.usher.video_request(video_id, headers=self.get_private_credential_headers(), **self._codec_kwargs()) return self.error_check(results, private=True) @staticmethod diff --git a/resources/lib/twitch_addon/addon/utils.py b/resources/lib/twitch_addon/addon/utils.py index 1a7aeab1..33b440b8 100644 --- a/resources/lib/twitch_addon/addon/utils.py +++ b/resources/lib/twitch_addon/addon/utils.py @@ -104,6 +104,17 @@ def append_headers(headers): return '|%s' % '&'.join(['%s=%s' % (key, quote_plus(headers[key])) for key in headers]) +# supported_codecs setting -> usher 'supported_codecs' value. Index 0 = Twitch default (omit param). +SUPPORTED_CODECS = ('', 'h265,h264') + + +def get_supported_codecs(): + try: + return SUPPORTED_CODECS[int(kodi.get_setting('supported_codecs'))] + except (ValueError, IndexError): + return '' + + def get_redirect_uri(): settings_id = kodi.get_setting('oauth_redirecturi') stripped_id = settings_id.strip() diff --git a/resources/settings.xml b/resources/settings.xml index a6f09d46..ead86f17 100644 --- a/resources/settings.xml +++ b/resources/settings.xml @@ -35,6 +35,8 @@ + + From 87f22dff27758c4b6bd3386d38c108e0e69b434e Mon Sep 17 00:00:00 2001 From: einanderson <289327658+einanderson@users.noreply.github.com> Date: Fri, 5 Jun 2026 19:21:04 +0200 Subject: [PATCH 3/8] Handle confidential client ids in auto-refresh gracefully A silent refresh is impossible for a confidential client (Twitch replies 'missing client secret'). Detect it, stop retrying, and show a one-time hint to set a public Client-ID; the manual login keeps working. Public client ids are unaffected. Co-Authored-By: Claude Opus 4.8 --- .../resource.language.en_gb/strings.po | 4 ++++ resources/lib/twitch_addon/addon/strings.py | 1 + resources/lib/twitch_addon/addon/utils.py | 18 +++++++++++++++++- resources/settings.xml | 1 + 4 files changed, 23 insertions(+), 1 deletion(-) diff --git a/resources/language/resource.language.en_gb/strings.po b/resources/language/resource.language.en_gb/strings.po index 392e5c9d..a8c32bee 100644 --- a/resources/language/resource.language.en_gb/strings.po +++ b/resources/language/resource.language.en_gb/strings.po @@ -1033,3 +1033,7 @@ msgstr "" msgctxt "#30306" msgid "Device login is not available for this Client-ID.[CR]Register your own application (Public type) at dev.twitch.tv/console/apps and set its Client-ID in the add-on settings, then try again." msgstr "" + +msgctxt "#30310" +msgid "Automatic token refresh needs a public Client-ID.[CR]Register your own application (Public type) at dev.twitch.tv/console/apps and set its Client-ID in the add-on settings." +msgstr "" diff --git a/resources/lib/twitch_addon/addon/strings.py b/resources/lib/twitch_addon/addon/strings.py index d92d471d..f04078a8 100644 --- a/resources/lib/twitch_addon/addon/strings.py +++ b/resources/lib/twitch_addon/addon/strings.py @@ -125,6 +125,7 @@ 'device_login_failed': 30304, 'device_login_cancelled': 30305, 'device_login_unsupported': 30306, + 'oauth_refresh_needs_public_client': 30310, 'started_streaming': 30231, 'new_search': 30235, 'clear_search_history_': 30237, diff --git a/resources/lib/twitch_addon/addon/utils.py b/resources/lib/twitch_addon/addon/utils.py index 128a7a87..50492ff2 100644 --- a/resources/lib/twitch_addon/addon/utils.py +++ b/resources/lib/twitch_addon/addon/utils.py @@ -188,6 +188,7 @@ def get_refresh_token(): def store_oauth_tokens(access_token, refresh_token, expires_in): kodi.set_setting('oauth_token_helix', access_token) kodi.set_setting('oauth_refresh_token', refresh_token or '') + kodi.set_setting('oauth_refresh_unsupported', '') # fresh working tokens -> (re)enable auto-refresh try: expiry = int(time.time()) + int(expires_in) - 120 # refresh ~2 min before expiry except (TypeError, ValueError): @@ -210,18 +211,33 @@ def ensure_valid_token(force=False): access_token = kodi.get_setting('oauth_token_helix').strip() if not refresh_token: return access_token # legacy implicit-grant token -> nothing to refresh + client_id = get_client_id() + # A confidential app (one registered with a secret) cannot refresh without that secret; + # Twitch then replies 'missing client secret'. Once we have seen that for this client id we + # stop retrying, so we neither spam the log every few minutes nor block API calls -> the user + # 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 access_token try: expiry = float(kodi.get_setting('oauth_token_expiry') or '0') except ValueError: expiry = 0 if access_token and not force and time.time() < expiry: return access_token - ok, data = device_oauth.refresh_access_token(get_client_id(), refresh_token) + ok, data = device_oauth.refresh_access_token(client_id, refresh_token) if ok and data.get('access_token'): store_oauth_tokens(data['access_token'], data.get('refresh_token', refresh_token), data.get('expires_in', 3600)) log_utils.log('OAuth: access token refreshed via refresh_token', log_utils.LOGNOTICE) return data['access_token'] + if 'client secret' in str(data.get('message', '')).lower(): + # Confidential client id -> a silent refresh is impossible. Remember it, and tell the + # user once how to fix it (register a public app and set its Client-ID). + kodi.set_setting('oauth_refresh_unsupported', client_id) + log_utils.log('OAuth: refresh needs a public client id (this one is confidential); set your own ' + 'under Settings > Login. Falling back to manual login.', log_utils.LOGWARNING) + kodi.notify(msg=i18n('oauth_refresh_needs_public_client')) + return access_token log_utils.log('OAuth: token refresh failed |%s|' % data, log_utils.LOGWARNING) return access_token # keep current; valid_token() will prompt re-login if truly invalid diff --git a/resources/settings.xml b/resources/settings.xml index d5632dba..c494148f 100644 --- a/resources/settings.xml +++ b/resources/settings.xml @@ -5,6 +5,7 @@ + From ac7561520a9d21281105fbabd47289b3c284e1dc Mon Sep 17 00:00:00 2001 From: einanderson <289327658+einanderson@users.noreply.github.com> Date: Sat, 6 Jun 2026 00:20:35 +0200 Subject: [PATCH 4/8] Add a website (GQL) search backend with fuzzy/relevance ranking Adds an optional Search backend using Twitch's GQL searchFor (the same search twitch.tv uses) for proper fuzzy matching + relevance/live ranking, which the Helix search/channels endpoint lacks. The result is mapped into the existing Helix search shape so routes and converters are unchanged. A new "Search method" setting selects it (default) or the Helix search, and it falls back to Helix automatically if GQL errors/empties. Co-Authored-By: Claude Opus 4.8 --- .../resource.language.en_gb/strings.po | 12 ++ resources/lib/twitch_addon/addon/api.py | 14 ++- .../lib/twitch_addon/addon/gql_search.py | 107 ++++++++++++++++++ resources/lib/twitch_addon/addon/utils.py | 8 ++ resources/settings.xml | 2 + 5 files changed, 142 insertions(+), 1 deletion(-) create mode 100644 resources/lib/twitch_addon/addon/gql_search.py diff --git a/resources/language/resource.language.en_gb/strings.po b/resources/language/resource.language.en_gb/strings.po index b55dfaa9..707b4e2c 100644 --- a/resources/language/resource.language.en_gb/strings.po +++ b/resources/language/resource.language.en_gb/strings.po @@ -1005,3 +1005,15 @@ msgstr "" msgctxt "#30273" msgid "OAuth Token is expired or invalid" msgstr "" + +msgctxt "#30330" +msgid "Search method" +msgstr "" + +msgctxt "#30331" +msgid "Website (twitch.tv, fuzzy)" +msgstr "" + +msgctxt "#30332" +msgid "Helix API" +msgstr "" diff --git a/resources/lib/twitch_addon/addon/api.py b/resources/lib/twitch_addon/addon/api.py index b1eee1d2..85c106db 100644 --- a/resources/lib/twitch_addon/addon/api.py +++ b/resources/lib/twitch_addon/addon/api.py @@ -12,7 +12,7 @@ import json import sys -from . import cache, utils +from . import cache, utils, gql_search from .common import kodi, log_utils from .constants import Keys, SCOPES from .error_handling import api_error_handler @@ -230,6 +230,10 @@ def get_game_streams(self, game_id=None, language=Language.ALL, after='MA==', be @api_error_handler @cache.cache_method(cache_limit=cache.limit) def get_channel_search(self, search_query, after='MA==', first=20): + if utils.use_gql_search(): + gql = gql_search.search(search_query, 'channels') + if gql is not None: + return gql # GQL ok -> use it; else fall back to Helix below results = self.api.search.get_channels(search_query=search_query, after=after, first=first, live_only=Boolean.FALSE) return self.error_check(results) @@ -237,6 +241,10 @@ def get_channel_search(self, search_query, after='MA==', first=20): @api_error_handler @cache.cache_method(cache_limit=cache.limit) def get_stream_search(self, search_query, after='MA==', first=20): + if utils.use_gql_search(): + gql = gql_search.search(search_query, 'streams') + if gql is not None: + return gql results = self.api.search.get_channels(search_query=search_query, after=after, first=first, live_only=Boolean.TRUE) return self.error_check(results) @@ -244,6 +252,10 @@ def get_stream_search(self, search_query, after='MA==', first=20): @api_error_handler @cache.cache_method(cache_limit=cache.limit) def get_game_search(self, search_query, after='MA==', first=20): + if utils.use_gql_search(): + gql = gql_search.search(search_query, 'games') + if gql is not None: + return gql results = self.api.search.get_categories(search_query=search_query, after=after, first=first) return self.error_check(results) diff --git a/resources/lib/twitch_addon/addon/gql_search.py b/resources/lib/twitch_addon/addon/gql_search.py new file mode 100644 index 00000000..2aa2d94a --- /dev/null +++ b/resources/lib/twitch_addon/addon/gql_search.py @@ -0,0 +1,107 @@ +# -*- coding: utf-8 -*- +""" + Website-grade search via Twitch's GQL backend (gql.twitch.tv) — the same + Elasticsearch-backed search the Twitch website uses. Gives proper fuzzy + matching + relevance ranking (and live ordering) that the Helix + search/channels endpoint does not. Public/anonymous (web client id, no OAuth). + + Results are adapted to the same shape the Helix search returns + ({'data': [...]} with the addon's Keys) so the existing converters and + routes keep working unchanged. Returns None on any failure so the caller + can fall back to the Helix search. + + SPDX-License-Identifier: GPL-3.0-only + See LICENSES/GPL-3.0-only for more information. +""" +import requests + +from .constants import Keys +from .common import log_utils + +GQL_URL = 'https://gql.twitch.tv/gql' +WEB_CLIENT_ID = 'kimne78kx3ncx6brgo4mv6wki5h1ko' # Twitch public web client (same as the website) +TIMEOUT = 15 + +_CHANNEL_QUERY = ( + 'query Search($q: String!) {' + ' searchFor(userQuery: $q, platform: "web", target: {index: CHANNEL}) {' + ' channels { edges { item { ... on User {' + ' id login displayName' + ' broadcastSettings { language title }' + ' profileImageURL(width: 300)' + ' stream { id viewersCount previewImageURL game { id name displayName } }' + ' } } } }' + ' }' + '}' +) + +_GAME_QUERY = ( + 'query Search($q: String!) {' + ' searchFor(userQuery: $q, platform: "web", target: {index: GAME}) {' + ' games { edges { item { ... on Game { id name displayName boxArtURL(width: 285, height: 380) } } } }' + ' }' + '}' +) + + +def _post(query, search_query): + body = [{'operationName': 'Search', 'query': query, 'variables': {'q': search_query}}] + r = requests.post(GQL_URL, json=body, headers={'Client-ID': WEB_CLIENT_ID}, timeout=TIMEOUT) + data = r.json() + if isinstance(data, list): + data = data[0] if data else {} + if data.get('errors'): + log_utils.log('gql_search: GQL errors |%s|' % data['errors'], log_utils.LOGWARNING) + return None + return (data.get('data') or {}).get('searchFor') or {} + + +def _channel_item(item): + stream = item.get('stream') or {} + game = stream.get('game') or {} + profile = item.get('profileImageURL', '') + return { + Keys.ID: item.get('id'), + Keys.BROADCASTER_LOGIN: item.get('login'), + Keys.DISPLAY_NAME: item.get('displayName'), + Keys.BROADCASTER_LANGUAGE: (item.get('broadcastSettings') or {}).get('language', ''), + Keys.TITLE: (item.get('broadcastSettings') or {}).get('title', ''), + Keys.OFFLINE_IMAGE_URL: profile, + Keys.THUMBNAIL_URL: stream.get('previewImageURL') or profile, + Keys.VIEWER_COUNT: stream.get('viewersCount', 0) if item.get('stream') else 0, + Keys.GAME_NAME: game.get('name', ''), + Keys.GAME_ID: game.get('id', ''), + } + + +def search(search_query, kind): + """kind: 'streams' (live only) | 'channels' (all) | 'games'. + Returns {'data': [...]} (Helix-shaped) on success, or None on failure (-> caller falls back to Helix).""" + try: + if kind == 'games': + sf = _post(_GAME_QUERY, search_query) + if sf is None: + return None + edges = ((sf.get('games') or {}).get('edges')) or [] + items = [{Keys.ID: e['item'].get('id'), + Keys.NAME: e['item'].get('name') or e['item'].get('displayName'), + Keys.BOX_ART_URL: e['item'].get('boxArtURL', '')} + for e in edges if e.get('item')] + return {Keys.DATA: items} + + sf = _post(_CHANNEL_QUERY, search_query) + if sf is None: + return None + edges = ((sf.get('channels') or {}).get('edges')) or [] + items = [] + for e in edges: + item = e.get('item') + if not item: + continue + if kind == 'streams' and not item.get('stream'): + continue # streams branch == live channels only + items.append(_channel_item(item)) + return {Keys.DATA: items} + except Exception as e: + log_utils.log('gql_search.search error |%s|' % e, log_utils.LOGWARNING) + return None diff --git a/resources/lib/twitch_addon/addon/utils.py b/resources/lib/twitch_addon/addon/utils.py index 1a7aeab1..9122e7ce 100644 --- a/resources/lib/twitch_addon/addon/utils.py +++ b/resources/lib/twitch_addon/addon/utils.py @@ -183,6 +183,14 @@ def get_search_history_size(): return int(kodi.get_setting('search_history_size')) +def use_gql_search(): + # search_backend: 0 = Website (GQL, fuzzy/relevance ranking), 1 = Helix (search/channels) + try: + return int(kodi.get_setting('search_backend')) == 0 + except (ValueError, TypeError): + return True # default to the website (GQL) search + + def get_search_history(search_type): history = None history_size = get_search_history_size() diff --git a/resources/settings.xml b/resources/settings.xml index a6f09d46..67391be4 100644 --- a/resources/settings.xml +++ b/resources/settings.xml @@ -49,6 +49,8 @@ + + From 83d48192be7d7a01434fb9874ff8fe87b4b24e7c Mon Sep 17 00:00:00 2001 From: einanderson <289327658+einanderson@users.noreply.github.com> Date: Wed, 10 Jun 2026 22:15:40 +0200 Subject: [PATCH 5/8] oauth: store tokens in addon_data json with atomic writes + cross-process lock The rotating, single-use refresh token could be lost under Kodi's multi-process settings race: several add-on processes (service + plugin calls) refreshing at once and consuming the same single-use token, or one clobbering another's settings.xml write from its in-memory cache. This surfaced as "Invalid refresh token" and broke the silent auto-refresh after a while. Keep the tokens in addon_data/oauth_tokens.json instead: read fresh on every access, written atomically via os.replace, and guarded by a cross-process fcntl lock around the whole refresh with a re-check inside the lock so a rotated token is never consumed twice. Migrates once from the legacy settings (mirrored back for backward compatibility) and preserves the confidential-client handling. Co-Authored-By: Claude Opus 4.8 (1M context) --- resources/lib/twitch_addon/addon/utils.py | 178 ++++++++++++++++------ 1 file changed, 135 insertions(+), 43 deletions(-) diff --git a/resources/lib/twitch_addon/addon/utils.py b/resources/lib/twitch_addon/addon/utils.py index 50492ff2..0924db61 100644 --- a/resources/lib/twitch_addon/addon/utils.py +++ b/resources/lib/twitch_addon/addon/utils.py @@ -11,6 +11,13 @@ import re import time +import os +import json as _json + +try: + import fcntl as _fcntl +except ImportError: + _fcntl = None from base64 import b64decode from datetime import datetime @@ -144,16 +151,15 @@ def clear_client_id(): def get_oauth_token(token_only=True, required=False): - oauth_token = kodi.get_setting('oauth_token_helix') + oauth_token = _read_oauth_store().get('access', '') + if not oauth_token or not oauth_token.strip(): + oauth_token = kodi.get_setting('oauth_token_helix') # legacy fallback (manual entry) if not oauth_token or not oauth_token.strip(): if not required: return '' kodi.notify(kodi.get_name(), i18n('token_required'), sound=False) kodi.show_settings() - oauth_token = kodi.get_setting('oauth_token_helix') - stripped_token = oauth_token.strip() - if oauth_token != stripped_token: - oauth_token = stripped_token - kodi.set_setting('oauth_token_helix', oauth_token) + oauth_token = _read_oauth_store().get('access', '') or kodi.get_setting('oauth_token_helix') + oauth_token = oauth_token.strip() if oauth_token: if token_only: idx = oauth_token.find(':') @@ -180,66 +186,152 @@ def get_private_oauth_token(): # --- OAuth Device Code Flow: token storage + silent refresh ----------------------------- +# +# The rotating, single-use refresh token must survive two hazards the Kodi settings store +# cannot: (a) concurrent refreshes from several add-on processes (service + plugin calls) +# racing to consume the same single-use token, and (b) one process clobbering another's +# settings.xml write from its in-memory cache. Both are solved by keeping the tokens in a +# dedicated JSON file that is read fresh every time, written atomically (os.replace), and +# guarded by a cross-process file lock for the whole refresh. + +_OAUTH_STORE_FILE = os.path.join(ADDON_DATA_DIR, 'oauth_tokens.json') +_OAUTH_LOCK_FILE = os.path.join(ADDON_DATA_DIR, 'oauth_tokens.lock') + + +class _OAuthLock: + """Serialise token refresh across processes (flock on POSIX; no-op fallback elsewhere).""" + + def __init__(self): + self._fh = None + + def __enter__(self): + if _fcntl is not None: + try: + self._fh = open(_OAUTH_LOCK_FILE, 'a+') + _fcntl.flock(self._fh.fileno(), _fcntl.LOCK_EX) + except Exception as e: + log_utils.log('OAuth: lock acquire failed: %s' % e, log_utils.LOGWARNING) + self._fh = None + return self + + def __exit__(self, *exc): + if self._fh is not None: + try: + _fcntl.flock(self._fh.fileno(), _fcntl.LOCK_UN) + self._fh.close() + except Exception: + pass + self._fh = None + return False + + +def _read_oauth_store(): + """Token dict {access, refresh, expiry}, read fresh from disk (never cached). Migrates + once from the legacy Kodi settings so existing logins keep working. Never logs values.""" + data = {} + try: + if os.path.exists(_OAUTH_STORE_FILE): + with open(_OAUTH_STORE_FILE, 'r') as fh: + data = _json.load(fh) or {} + except Exception as e: + log_utils.log('OAuth: store read failed: %s' % e, log_utils.LOGWARNING) + data = {} + if not data.get('access') and not data.get('refresh'): + access = (kodi.get_setting('oauth_token_helix') or '').strip() + refresh = (kodi.get_setting('oauth_refresh_token') or '').strip() + try: + expiry = int(float(kodi.get_setting('oauth_token_expiry') or '0')) + except (TypeError, ValueError): + expiry = 0 + if access or refresh: + data = {'access': access, 'refresh': refresh, 'expiry': expiry} + _write_oauth_store(data) + return data + + +def _write_oauth_store(data): + """Atomically persist the token dict, then mirror it to the legacy settings for backward + compatibility (the mirror is never read back for refresh, so a clobber is harmless). Never + logs token values.""" + try: + tmp = _OAUTH_STORE_FILE + '.tmp' + with open(tmp, 'w') as fh: + _json.dump(data, fh) + os.replace(tmp, _OAUTH_STORE_FILE) + except Exception as e: + log_utils.log('OAuth: store write failed: %s' % e, log_utils.LOGERROR) + return False + try: + kodi.set_setting('oauth_token_helix', data.get('access', '')) + kodi.set_setting('oauth_refresh_token', data.get('refresh', '')) + kodi.set_setting('oauth_token_expiry', str(int(data.get('expiry', 0) or 0))) + except Exception: + pass + return True + def get_refresh_token(): - return kodi.get_setting('oauth_refresh_token').strip() + return (_read_oauth_store().get('refresh') or '').strip() def store_oauth_tokens(access_token, refresh_token, expires_in): - kodi.set_setting('oauth_token_helix', access_token) - kodi.set_setting('oauth_refresh_token', refresh_token or '') - kodi.set_setting('oauth_refresh_unsupported', '') # fresh working tokens -> (re)enable auto-refresh try: expiry = int(time.time()) + int(expires_in) - 120 # refresh ~2 min before expiry except (TypeError, ValueError): expiry = int(time.time()) + 3600 - kodi.set_setting('oauth_token_expiry', str(expiry)) + _write_oauth_store({'access': (access_token or '').strip(), + 'refresh': (refresh_token or '').strip(), + 'expiry': expiry}) + kodi.set_setting('oauth_refresh_unsupported', '') # fresh working tokens -> (re)enable auto-refresh def clear_oauth_tokens(): - kodi.set_setting('oauth_token_helix', '') - kodi.set_setting('oauth_refresh_token', '') - kodi.set_setting('oauth_token_expiry', '0') + _write_oauth_store({'access': '', 'refresh': '', 'expiry': 0}) def ensure_valid_token(force=False): """Silently refresh the Helix OAuth token via the stored refresh_token when it is - (near) expired. No-op for legacy implicit tokens (no refresh_token stored). - Returns the (possibly refreshed) access token, or '' if none available.""" + (near) expired. No-op for legacy implicit tokens (no refresh_token stored). Serialised + across processes so the single-use refresh token is never consumed twice. Returns the + (possibly refreshed) access token, or '' if none available.""" from . import device_oauth - refresh_token = get_refresh_token() - access_token = kodi.get_setting('oauth_token_helix').strip() - if not refresh_token: - return access_token # legacy implicit-grant token -> nothing to refresh client_id = get_client_id() # A confidential app (one registered with a secret) cannot refresh without that secret; # Twitch then replies 'missing client secret'. Once we have seen that for this client id we # stop retrying, so we neither spam the log every few minutes nor block API calls -> the user # 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 access_token - try: - expiry = float(kodi.get_setting('oauth_token_expiry') or '0') - except ValueError: - expiry = 0 - if access_token and not force and time.time() < expiry: - return access_token - ok, data = device_oauth.refresh_access_token(client_id, refresh_token) - if ok and data.get('access_token'): - store_oauth_tokens(data['access_token'], data.get('refresh_token', refresh_token), - data.get('expires_in', 3600)) - log_utils.log('OAuth: access token refreshed via refresh_token', log_utils.LOGNOTICE) - return data['access_token'] - if 'client secret' in str(data.get('message', '')).lower(): - # Confidential client id -> a silent refresh is impossible. Remember it, and tell the - # user once how to fix it (register a public app and set its Client-ID). - kodi.set_setting('oauth_refresh_unsupported', client_id) - log_utils.log('OAuth: refresh needs a public client id (this one is confidential); set your own ' - 'under Settings > Login. Falling back to manual login.', log_utils.LOGWARNING) - kodi.notify(msg=i18n('oauth_refresh_needs_public_client')) - return access_token - log_utils.log('OAuth: token refresh failed |%s|' % data, log_utils.LOGWARNING) - return access_token # keep current; valid_token() will prompt re-login if truly invalid + return (_read_oauth_store().get('access') or '').strip() + with _OAuthLock(): + store = _read_oauth_store() + refresh_token = (store.get('refresh') or '').strip() + access_token = (store.get('access') or '').strip() + if not refresh_token: + return access_token # legacy implicit-grant token -> nothing to refresh + try: + expiry = float(store.get('expiry') or 0) + except (TypeError, ValueError): + expiry = 0 + # Re-check INSIDE the lock: if another process refreshed while we waited, the store + # is now fresh -> reuse it instead of consuming the rotated token a second time. + if access_token and not force and time.time() < expiry: + return access_token + ok, data = device_oauth.refresh_access_token(client_id, refresh_token) + if ok and data.get('access_token'): + store_oauth_tokens(data['access_token'], data.get('refresh_token', refresh_token), + data.get('expires_in', 3600)) + log_utils.log('OAuth: access token refreshed via refresh_token', log_utils.LOGNOTICE) + return data['access_token'] + if 'client secret' in str(data.get('message', '')).lower(): + # Confidential client id -> a silent refresh is impossible. Remember it, and tell the + # user once how to fix it (register a public app and set its Client-ID). + kodi.set_setting('oauth_refresh_unsupported', client_id) + log_utils.log('OAuth: refresh needs a public client id (this one is confidential); set your own ' + 'under Settings > Login. Falling back to manual login.', log_utils.LOGWARNING) + kodi.notify(msg=i18n('oauth_refresh_needs_public_client')) + return access_token + log_utils.log('OAuth: token refresh failed |%s|' % data, log_utils.LOGWARNING) + return access_token # keep current; valid_token() will prompt re-login if truly invalid def get_search_history_size(): From 9d9ac8c0a30cae84aa92b669c1128f6ebcd66a52 Mon Sep 17 00:00:00 2001 From: einanderson <289327658+einanderson@users.noreply.github.com> Date: Sat, 11 Jul 2026 05:45:11 +0200 Subject: [PATCH 6/8] Port community-fork 3.0.3 features: Turbo login, search fix, tofu filter, ISA headers/1440p, cleanup - Turbo/ad-free device login: second device-code login with the private client id, stored in its own race-free token store (oauth_private_tokens.json, atomic write + cross-process lock) with silent refresh; get_private_oauth_token() prefers the store and falls back to the manual private_oauth_token setting. Server-side revoke for both (routes/revoke_private_token.py, device_oauth.revoke_token). No refresh attempt for the kimne78 web client (confidential, tokens never expire) to avoid a doomed POST + warning per API call. - Search: remember the query in a window property so the post-playback container reload re-renders the previous results instead of re-popping the keyboard or hanging at 'Working...' (fork issue #1); the search menus clear the property so a deliberate New Search still prompts. - Strip emoji/symbols Kodi's skin font cannot render (tofu boxes) from list labels, plot, tagline and info dialog text. - InputStream Adaptive: pass headers via manifest_headers/stream_headers properties instead of the legacy URL pipe; allow up to 1440p via chooser_resolution_max so Twitch 2K plays regardless of screen res; drop sub-720p variants from quality selection (Source/720p+/audio_only /Adaptive kept); default video_quality=Adaptive + IA enabled. - Manual token entry / revoke now go through the race-free token store. - Cleanup: remove IRC chat integration (script.ircchat), the obsolete browser-based 'Get OAuth token' flow (token_url, google_firebase) and dead routes (edit_blacklist, collections); raw-string regex fixes in common/kodi.py. Co-Authored-By: Claude Fable 5 --- .../resource.language.en_gb/strings.po | 12 ++ .../lib/twitch_addon/addon/common/kodi.py | 6 +- resources/lib/twitch_addon/addon/constants.py | 7 +- resources/lib/twitch_addon/addon/converter.py | 19 +- .../lib/twitch_addon/addon/device_oauth.py | 13 ++ .../lib/twitch_addon/addon/google_firebase.py | 48 ----- resources/lib/twitch_addon/addon/player.py | 27 +-- resources/lib/twitch_addon/addon/strings.py | 3 + resources/lib/twitch_addon/addon/utils.py | 166 ++++++++++++++++-- resources/lib/twitch_addon/router.py | 51 ++---- resources/lib/twitch_addon/routes/__init__.py | 8 +- .../lib/twitch_addon/routes/device_login.py | 33 ++-- .../routes/device_login_private.py | 31 ++++ .../lib/twitch_addon/routes/edit_qualities.py | 1 + .../twitch_addon/routes/install_ircchat.py | 18 -- .../lib/twitch_addon/routes/new_search.py | 52 ++++-- resources/lib/twitch_addon/routes/play.py | 16 +- .../routes/revoke_private_token.py | 38 ++++ .../lib/twitch_addon/routes/revoke_token.py | 2 +- resources/lib/twitch_addon/routes/search.py | 5 + .../lib/twitch_addon/routes/search_history.py | 4 + .../lib/twitch_addon/routes/token_url.py | 28 --- .../lib/twitch_addon/routes/update_token.py | 4 +- resources/settings.xml | 23 +-- 24 files changed, 382 insertions(+), 233 deletions(-) delete mode 100644 resources/lib/twitch_addon/addon/google_firebase.py create mode 100644 resources/lib/twitch_addon/routes/device_login_private.py delete mode 100644 resources/lib/twitch_addon/routes/install_ircchat.py create mode 100644 resources/lib/twitch_addon/routes/revoke_private_token.py delete mode 100644 resources/lib/twitch_addon/routes/token_url.py diff --git a/resources/language/resource.language.en_gb/strings.po b/resources/language/resource.language.en_gb/strings.po index 0ce36878..2d54d5c9 100644 --- a/resources/language/resource.language.en_gb/strings.po +++ b/resources/language/resource.language.en_gb/strings.po @@ -1050,6 +1050,18 @@ msgctxt "#30310" msgid "Automatic token refresh needs a public Client-ID.[CR]Register your own application (Public type) at dev.twitch.tv/console/apps and set its Client-ID in the add-on settings." msgstr "" +msgctxt "#30311" +msgid "Login: ad-free playback / Turbo (device code)" +msgstr "" + +msgctxt "#30312" +msgid "Go to [B]%s[/B] with your [B]Turbo/subscriber account[/B] and enter this code:[CR][CR][B]%s[/B]" +msgstr "" + +msgctxt "#30313" +msgid "Turbo / ad-free login successful. The token is renewed automatically. Ad-free playback only applies if the logged-in account has Turbo or is subscribed to the channel." +msgstr "" + msgctxt "#30330" msgid "Search method" msgstr "" diff --git a/resources/lib/twitch_addon/addon/common/kodi.py b/resources/lib/twitch_addon/addon/common/kodi.py index 4dd6572c..1f0a95a0 100644 --- a/resources/lib/twitch_addon/addon/common/kodi.py +++ b/resources/lib/twitch_addon/addon/common/kodi.py @@ -171,11 +171,11 @@ class KodiVersion(object): if ('result' in _json_query) and ('name' in _json_query['result']): application = decode_utf8(_json_query['result']['name']) version = decode_utf8(xbmc.getInfoLabel('System.BuildVersion')) - match = re.search('([0-9]+)\.([0-9]+)', version) + match = re.search(r'([0-9]+)\.([0-9]+)', version) if match: major, minor = match.groups() - match = re.search('-([a-zA-Z]+)([0-9]*)', version) + match = re.search(r'-([a-zA-Z]+)([0-9]*)', version) if match: tag, tag_version = match.groups() - match = re.search('\w+:(\w+-\w+)', version) + match = re.search(r'\w+:(\w+-\w+)', version) if match: revision = match.group(1) try: diff --git a/resources/lib/twitch_addon/addon/constants.py b/resources/lib/twitch_addon/addon/constants.py index aa9b2439..746463dc 100644 --- a/resources/lib/twitch_addon/addon/constants.py +++ b/resources/lib/twitch_addon/addon/constants.py @@ -35,17 +35,14 @@ def __enum(**enums): CHANNELVIDEOLIST='channel_video_list', GAMESTREAMS='game_streams', RESETCACHE='reset_cache', - INSTALLIRCCHAT='install_ircchat', + DEVICELOGINPRIVATE='device_login_private', + REVOKEPRIVATETOKEN='revoke_private_token', PLAY='play', - TOKENURL='get_token_url', DEVICELOGIN='device_login', EDITFOLLOW='edit_user_follows', EDITBLOCK='edit_user_blocks', - EDITBLACKLIST='edit_blacklist', EDITQUALITIES='edit_qualities', CLEARLIST='clear_list', - COLLECTIONS='collections', - COLLECTIONVIDEOLIST='collection_video_list', BROWSE='browse', STREAMLIST='stream_list', CLIPSLIST='clips_list', diff --git a/resources/lib/twitch_addon/addon/converter.py b/resources/lib/twitch_addon/addon/converter.py index d7183e94..3dc37558 100644 --- a/resources/lib/twitch_addon/addon/converter.py +++ b/resources/lib/twitch_addon/addon/converter.py @@ -14,7 +14,7 @@ from . import menu_items from .common import kodi from .constants import Keys, Images, MODES, ADAPTIVE_SOURCE_TEMPLATE -from .utils import the_art, TitleBuilder, i18n, get_oauth_token, get_vodcast_color, use_inputstream_adaptive, get_thumbnail_size, get_refresh_stamp, to_string, get_private_oauth_token, convert_duration +from .utils import the_art, TitleBuilder, i18n, get_oauth_token, get_vodcast_color, use_inputstream_adaptive, get_thumbnail_size, get_refresh_stamp, to_string, get_private_oauth_token, convert_duration, filter_qualities, strip_tofu class PlaylistConverter(object): @@ -403,6 +403,12 @@ def _format_key(key, headings, info): value = item_template.format(head=val_heading, info=val_info) return value + @staticmethod + def _clean_info(info): + # Strip emoji/symbols Kodi's skin font cannot render from the free-text fields + # (title/description) -- affects the text below the thumb (tagline) + info dialog (plot). + return {key: strip_tofu(value) for key, value in info.items()} + def get_plot_for_search(self, search, include_title=True): headings = {Keys.GAME: i18n('game'), Keys.BROADCASTER_LANGUAGE: i18n('language')} @@ -421,7 +427,7 @@ def get_plot_for_search(self, search, include_title=True): plot = plot_template.format(title=title, game=self._format_key(Keys.GAME, headings, info), broadcaster_language=self._format_key(Keys.BROADCASTER_LANGUAGE, headings, info)) - return {u'plot': plot, u'plotoutline': plot, u'tagline': _title.rstrip('\r\n')} + return self._clean_info({u'plot': plot, u'plotoutline': plot, u'tagline': _title.rstrip('\r\n')}) def get_plot_for_stream(self, stream, include_title=True): headings = {Keys.GAME: i18n('game'), @@ -446,7 +452,7 @@ def get_plot_for_stream(self, stream, include_title=True): broadcaster_language=self._format_key(Keys.BROADCASTER_LANGUAGE, headings, info), mature=self._format_key(Keys.MATURE, headings, info)) - return {u'plot': plot, u'plotoutline': plot, u'tagline': _title.rstrip('\r\n')} + return self._clean_info({u'plot': plot, u'plotoutline': plot, u'tagline': _title.rstrip('\r\n')}) def get_plot_for_channel(self, channel): headings = {Keys.VIEWS: i18n('views'), @@ -468,7 +474,7 @@ def get_plot_for_channel(self, channel): broadcaster_type=broadcaster_type + '\r\n', date=date) - return {u'plot': plot, u'plotoutline': plot, u'tagline': title.rstrip('\r\n')} + return self._clean_info({u'plot': plot, u'plotoutline': plot, u'tagline': title.rstrip('\r\n')}) def get_plot_for_clip(self, clip, include_title=True): headings = {Keys.VIEWS: i18n('views'), @@ -495,7 +501,7 @@ def get_plot_for_clip(self, clip, include_title=True): curator=self._format_key(Keys.CURATOR, headings, info), date=date) - return {u'plot': plot, u'plotoutline': plot, u'tagline': _title.rstrip('\r\n')} + return self._clean_info({u'plot': plot, u'plotoutline': plot, u'tagline': _title.rstrip('\r\n')}) def get_plot_for_video(self, video, include_title=True): headings = {Keys.VIEWS: i18n('views'), @@ -519,12 +525,13 @@ def get_plot_for_video(self, video, include_title=True): if video.get(Keys.DESCRIPTION) else title, date=date) - return {u'plot': plot, u'plotoutline': plot, u'tagline': _title.rstrip('\r\n')} + return self._clean_info({u'plot': plot, u'plotoutline': plot, u'tagline': _title.rstrip('\r\n')}) def get_video_for_quality(self, videos, ask=True, quality=None, clip=False): use_ia = use_inputstream_adaptive() if use_ia and not any(v['name'] == 'Adaptive' for v in videos) and not clip: videos.append(ADAPTIVE_SOURCE_TEMPLATE) + videos = filter_qualities(videos) # drop sub-720p variants (keep Source/720p+/audio_only/Adaptive) if ask is True: return self.select_video_for_quality(videos) else: diff --git a/resources/lib/twitch_addon/addon/device_oauth.py b/resources/lib/twitch_addon/addon/device_oauth.py index 41e45ab7..6d9e7b02 100644 --- a/resources/lib/twitch_addon/addon/device_oauth.py +++ b/resources/lib/twitch_addon/addon/device_oauth.py @@ -70,3 +70,16 @@ def refresh_access_token(client_id, refresh_token): except Exception as e: log_utils.log('device_oauth.refresh_access_token error: %s' % e, log_utils.LOGERROR) return False, {'message': str(e)} + + +def revoke_token(client_id, token): + """Best-effort server-side revoke of an access token (public client). Returns True on 200.""" + if not token: + return False + try: + r = requests.post(OAUTH_BASE + '/revoke', + data={'client_id': client_id, 'token': token}, timeout=TIMEOUT) + return r.status_code == 200 + except Exception as e: + log_utils.log('device_oauth.revoke_token error: %s' % e, log_utils.LOGWARNING) + return False diff --git a/resources/lib/twitch_addon/addon/google_firebase.py b/resources/lib/twitch_addon/addon/google_firebase.py deleted file mode 100644 index cee66cb0..00000000 --- a/resources/lib/twitch_addon/addon/google_firebase.py +++ /dev/null @@ -1,48 +0,0 @@ -# -*- coding: utf-8 -*- -""" - - Copyright (C) 2012-2019 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 requests -from base64 import b64decode -from urllib.parse import quote - -from .common import log_utils - -__key = 'QUl6YVN5RDBtVGtVUU1TQnZ2dzVobnN4LTRZeGktNXNKSmdRR0E4' - - -def dynamic_links_short_url(url): - key = b64decode(__key) - if isinstance(key, bytes): - key = key.decode('utf-8') - post_url = 'https://firebasedynamiclinks.googleapis.com/v1/shortLinks?key=%s' % key - data = { - 'longDynamicLink': 'https://twitchaddon.page.link/?link=%s' % quote(url), - 'suffix': { - 'option': 'SHORT' - } - } - headers = {'content-type': 'application/json'} - request = requests.post(post_url, data=json.dumps(data), headers=headers) - json_data = request.json() - - if 'shortLink' in json_data: - return json_data['shortLink'] - else: - if 'error' in json_data: - if 'errors' in json_data['error']: - errors = '' - for err in json_data['error']['errors']: - errors += '%s |%s| ' % (err['message'], err['reason']) - log_utils.log('Error: %s' % errors, log_utils.LOGERROR) - else: - log_utils.log('Error: %s |%s|' % (json_data['error']['code'], json_data['error']['message']), log_utils.LOGERROR) - return None diff --git a/resources/lib/twitch_addon/addon/player.py b/resources/lib/twitch_addon/addon/player.py index 6c45c049..f4075f10 100644 --- a/resources/lib/twitch_addon/addon/player.py +++ b/resources/lib/twitch_addon/addon/player.py @@ -42,21 +42,10 @@ def __init__(self, window, *args, **kwargs): self.reset() def reset(self): - self.close_chat() self.reset_player() self.reset_seek() self.reset_reconnect() - def close_chat(self): - win_dialog_id = kodi.get_current_window_dialog_id() - if utils.irc_enabled() and \ - self.window.getProperty(key=self.player_keys['twitch_playing']) == 'True' and \ - win_dialog_id != 9999: - xbmc.executebuiltin('Dialog.Close(%s,true)' % win_dialog_id) - win_dialog_id = kodi.get_current_window_dialog_id() - if win_dialog_id != 9999: - xbmc.executebuiltin('Dialog.Close(all,true)') - def reset_seek(self): for k in self.seek_keys.keys(): self.window.clearProperty(key=self.seek_keys[k]) @@ -97,7 +86,6 @@ def onPlayBackEnded(self): if reconnect: live_channel = self.window.getProperty(self.reconnect_keys['stream']) if live_channel: - self.close_chat() channel_id, name, display_name, quality = live_channel.split(',') retries = 0 max_retries = 5 @@ -152,8 +140,7 @@ def onPlayBackEnded(self): if request: if kodi.get_kodi_version().major >= 18: request['headers']['verifypeer'] = 'false' - item_dict['path'] = \ - request['url'] + utils.append_headers(request['headers']) + item_dict['path'] = request['url'] # headers via ISA props below playback_item = kodi.create_item(item_dict, add=False) if video['name'] == 'Adaptive': inputstream_property = 'inputstream' @@ -161,16 +148,20 @@ def onPlayBackEnded(self): inputstream_property += 'addon' playback_item.setProperty(inputstream_property, 'inputstream.adaptive') playback_item.setProperty('inputstream.adaptive.manifest_type', 'hls') + playback_item.setProperty('inputstream.adaptive.chooser_resolution_max', '1440p') + playback_item.setProperty('inputstream.adaptive.chooser_resolution_secure_max', '1440p') + if request: + isa_headers = utils.format_isa_headers( + {k: v for k, v in request['headers'].items() if k != 'verifypeer'}) + if isa_headers: + playback_item.setProperty('inputstream.adaptive.manifest_headers', isa_headers) + playback_item.setProperty('inputstream.adaptive.stream_headers', isa_headers) stream_name = display_name or name self.window.setProperty(self.reconnect_keys['stream'], '{0},{1},{2},{3}'.format(channel_id, name, stream_name, quality)) self.play(item_dict['path'], playback_item) - if utils.irc_enabled() and twitch.access_token: - username = twitch.get_username() - if username: - utils.exec_irc_script(username, name) break except: log_utils.log('Player: |Reconnection| Failed attempt |{0}|'.format(retries), diff --git a/resources/lib/twitch_addon/addon/strings.py b/resources/lib/twitch_addon/addon/strings.py index f04078a8..c93023e0 100644 --- a/resources/lib/twitch_addon/addon/strings.py +++ b/resources/lib/twitch_addon/addon/strings.py @@ -126,6 +126,9 @@ 'device_login_cancelled': 30305, 'device_login_unsupported': 30306, 'oauth_refresh_needs_public_client': 30310, + 'device_login_private': 30311, + 'device_login_private_instructions': 30312, + 'device_login_private_success': 30313, 'started_streaming': 30231, 'new_search': 30235, 'clear_search_history_': 30237, diff --git a/resources/lib/twitch_addon/addon/utils.py b/resources/lib/twitch_addon/addon/utils.py index ee719883..85431754 100644 --- a/resources/lib/twitch_addon/addon/utils.py +++ b/resources/lib/twitch_addon/addon/utils.py @@ -107,8 +107,13 @@ def inputstream_adpative_supports(feature): return False +def format_isa_headers(headers): + # key=value&... (url-encoded) for InputStream Adaptive stream_headers / manifest_headers properties. + return '&'.join(['%s=%s' % (key, quote_plus(headers[key])) for key in headers]) + + def append_headers(headers): - return '|%s' % '&'.join(['%s=%s' % (key, quote_plus(headers[key])) for key in headers]) + return '|%s' % format_isa_headers(headers) # supported_codecs setting -> usher 'supported_codecs' value. Index 0 = Twitch default (omit param). @@ -122,6 +127,18 @@ def get_supported_codecs(): return '' +# Drop sub-720p video variants from a usher quality list; keep Source/720p+/audio_only/Adaptive. +QUALITY_FLOOR_DROP = ('160p', '360p', '480p') + + +def filter_qualities(videos): + if not videos: + return videos + filtered = [v for v in videos + if not any(drop in v.get('name', '').lower() for drop in QUALITY_FLOOR_DROP)] + return filtered if filtered else videos # never return empty -> fall back to original list + + def get_redirect_uri(): settings_id = kodi.get_setting('oauth_redirecturi') stripped_id = settings_id.strip() @@ -186,6 +203,12 @@ def get_oauth_token(token_only=True, required=False): def get_private_oauth_token(): + # Prefer the device-login store (Turbo/ad-free, auto-refreshed); fall back to the manual + # `private_oauth_token` setting so an existing long-lived token keeps working. + if (_read_private_store().get('access') or '').strip(): + token = ensure_valid_private_token() + if token: + return kodi.decode_utf8(token) settings_id = kodi.get_setting('private_oauth_token') stripped_id = settings_id.strip() if settings_id != stripped_id: @@ -212,13 +235,14 @@ def get_private_oauth_token(): class _OAuthLock: """Serialise token refresh across processes (flock on POSIX; no-op fallback elsewhere).""" - def __init__(self): + def __init__(self, lock_file=_OAUTH_LOCK_FILE): self._fh = None + self._lock_file = lock_file def __enter__(self): if _fcntl is not None: try: - self._fh = open(_OAUTH_LOCK_FILE, 'a+') + self._fh = open(self._lock_file, 'a+') _fcntl.flock(self._fh.fileno(), _fcntl.LOCK_EX) except Exception as e: log_utils.log('OAuth: lock acquire failed: %s' % e, log_utils.LOGWARNING) @@ -341,10 +365,103 @@ def ensure_valid_token(force=False): 'under Settings > Login. Falling back to manual login.', log_utils.LOGWARNING) kodi.notify(msg=i18n('oauth_refresh_needs_public_client')) return access_token - log_utils.log('OAuth: token refresh failed |%s|' % data, log_utils.LOGWARNING) + log_utils.log('OAuth: token refresh failed |%s|' % (data.get('message') or data.get('error') or 'error'), + log_utils.LOGWARNING) # log only the reason, never token values return access_token # keep current; valid_token() will prompt re-login if truly invalid +# --- Private (Turbo/ad-free) OAuth token store -- device login + silent refresh ---------- +# +# Mirrors the main store above but for the "private" credentials used by usher/GQL +# (Turbo/subscriber ad-free playback). Separate file from the main (login/Helix) token and +# additive: get_private_oauth_token() prefers this store (auto-refreshed) and falls back to +# the manual `private_oauth_token` setting, so an existing long-lived token keeps working. + +_PRIVATE_STORE_FILE = os.path.join(ADDON_DATA_DIR, 'oauth_private_tokens.json') +_PRIVATE_LOCK_FILE = os.path.join(ADDON_DATA_DIR, 'oauth_private_tokens.lock') + +_KIMNE_CLIENT_ID = 'kimne78kx3ncx6brgo4mv6wki5h1ko' # Twitch public web client (Turbo-aware GQL) + + +def effective_private_client_id(): + return get_private_client_id() or _KIMNE_CLIENT_ID + + +def _read_private_store(): + try: + if os.path.exists(_PRIVATE_STORE_FILE): + with open(_PRIVATE_STORE_FILE, 'r') as fh: + return _json.load(fh) or {} + except Exception as e: + log_utils.log('OAuth(private): store read failed: %s' % e, log_utils.LOGWARNING) + return {} + + +def _write_private_store(data): + try: + tmp = _PRIVATE_STORE_FILE + '.tmp' + with open(tmp, 'w') as fh: + _json.dump(data, fh) + os.replace(tmp, _PRIVATE_STORE_FILE) + except Exception as e: + log_utils.log('OAuth(private): store write failed: %s' % e, log_utils.LOGERROR) + return False + return True + + +def store_private_tokens(access_token, refresh_token, expires_in): + try: + expiry = int(time.time()) + int(expires_in) - 120 # refresh ~2 min before expiry + except (TypeError, ValueError): + expiry = int(time.time()) + 3600 + _write_private_store({'access': (access_token or '').strip(), + 'refresh': (refresh_token or '').strip(), + 'expiry': expiry}) + + +def clear_private_tokens(): + _write_private_store({'access': '', 'refresh': '', 'expiry': 0}) + + +def ensure_valid_private_token(force=False): + """Refresh the private (Turbo) token via its stored refresh_token when (near) + expired. No-op if no refresh_token (e.g. legacy long-lived token). Serialised across + processes. Never logs token values.""" + from . import device_oauth + with _OAuthLock(_PRIVATE_LOCK_FILE): + store = _read_private_store() + refresh_token = (store.get('refresh') or '').strip() + access_token = (store.get('access') or '').strip() + if not refresh_token: + return access_token + # The kimne78 web client is a *confidential* client: its tokens cannot be refreshed + # without a client secret we don't have (-> "missing client secret"), but they also + # never expire (Twitch /validate reports expires_in=0). Our stored expiry is just the + # device-flow expires_in, which lapses while the token stays valid -> attempting a + # refresh here only spams a doomed HTTP POST + warning on every API call. So for + # kimne78 just use the stored access token. Only a user-supplied *public* private + # client (own client id) is actually refreshable. + if effective_private_client_id() == _KIMNE_CLIENT_ID: + return access_token + try: + expiry = float(store.get('expiry') or 0) + except (TypeError, ValueError): + expiry = 0 + if access_token and not force and time.time() < expiry: + return access_token + ok, data = device_oauth.refresh_access_token(effective_private_client_id(), refresh_token) + if ok and data.get('access_token'): + store_private_tokens(data['access_token'], data.get('refresh_token', refresh_token), + data.get('expires_in', 3600)) + log_utils.log('OAuth(private): token refreshed via refresh_token', log_utils.LOGNOTICE) + return data['access_token'] + # Token (near) expired and refresh failed -> signal invalid so get_private_oauth_token() + # falls back to the manual long-lived private_oauth_token instead of using a dead token. + log_utils.log('OAuth(private): token refresh failed |%s|' % (data.get('message') or data.get('error') or 'error'), + log_utils.LOGWARNING) # log only the reason, never token values + return '' + + def get_search_history_size(): return int(kodi.get_setting('search_history_size')) @@ -407,21 +524,6 @@ def link_to_next_page(queries): 'info': {'plot': i18n('next_page')}} -def irc_enabled(): - return (kodi.get_setting('irc_enable') == 'true') and kodi.has_addon('script.ircchat') - - -def exec_irc_script(username, channel): - if not irc_enabled(): - return - password = get_oauth_token(token_only=False, required=True) - if username and password: - host = 'irc.chat.twitch.tv' - builtin = 'RunScript(script.ircchat, run_irc=True&nickname=%s&username=%s&password=%s&host=%s&channel=#%s)' % \ - (username, username, password, host, channel) - kodi.execute_builtin(builtin) - - def notify_refresh(): if kodi.get_setting('notify_refresh') == 'false': return False @@ -678,6 +780,31 @@ def convert_duration(duration): return payload +# Characters Kodi's default skin font (Estuary/Noto Sans) cannot render show up as an empty +# box ("tofu") -- mostly emoji/symbols in stream titles. Filter them out before display. +_TOFU_RE = re.compile( + u'[' + u'\U0001F000-\U0001FAFF' # emoji & pictographs (emoticons, transport, symbols & pictographs, flags ...) + u'\U00002600-\U000027BF' # misc symbols + dingbats + u'\U00002B00-\U00002BFF' # misc symbols and arrows + u'\U000023E9-\U000023FA' # media/clock emoji from misc technical + u'\U0000FE00-\U0000FE0F' # variation selectors (emoji presentation) + u'\U0000200D' # zero width joiner (emoji sequences) + u'\U000020E3' # combining enclosing keycap + u']' +) + + +def strip_tofu(text): + # Remove non-renderable emoji/symbols (would show as tofu boxes). Only collapse + # horizontal double spaces -- line breaks are kept (matters for plots). + if not isinstance(text, str): + return text + text = _TOFU_RE.sub(u'', text) + text = re.sub(u'[ \\t]{2,}', u' ', text) + return text + + class TitleBuilder(object): class Templates(object): TITLE = u"{title}" @@ -744,6 +871,7 @@ def clean_title_value(value): pass value = value.replace(u'\r\n', u' ') value = value.replace(u'\n', u' ') + value = strip_tofu(value) # drop emoji/symbols the skin font cannot render value = value.strip() return value else: diff --git a/resources/lib/twitch_addon/router.py b/resources/lib/twitch_addon/router.py index 18bb3d67..e294c772 100644 --- a/resources/lib/twitch_addon/router.py +++ b/resources/lib/twitch_addon/router.py @@ -56,7 +56,7 @@ def _search_history(content): @error_handler def _new_search(content): from .routes import new_search - new_search.route(content) + new_search.route(twitch_api, content) @dispatcher.register(MODES.SEARCHRESULTS, args=['content', 'query'], kwargs=['after']) @@ -108,20 +108,6 @@ def _list_channel_video_categories(channel_id=None, channel_name=None, display_n channel_video_categories.route(channel_id, channel_name, display_name, game, game_name) -@dispatcher.register(MODES.COLLECTIONS, args=['channel_id'], kwargs=['cursor']) -@error_handler(route_type=1) -def _list_collections(channel_id, cursor='MA=='): - from .routes import collections - collections.route(twitch_api, channel_id, cursor) - - -@dispatcher.register(MODES.COLLECTIONVIDEOLIST, args=['collection_id']) -@error_handler(route_type=1) -def _list_collection_videos(collection_id): - from .routes import collection_videos - collection_videos.route(twitch_api, collection_id) - - @dispatcher.register(MODES.CLIPSLIST, kwargs=['after', 'channel_id', 'game_id']) @error_handler(route_type=1) def _list_clips(after='MA==', channel_id='', game_id=''): @@ -164,13 +150,6 @@ def _edit_user_follows(channel_id=None, channel_name=None, game_id=None, game_na edit_user_follows.route(twitch_api, channel_id, channel_name, game_id, game_name, follow) -@dispatcher.register(MODES.EDITBLACKLIST, kwargs=['list_type', 'target_id', 'name', 'remove', 'refresh']) -@error_handler -def _edit_blacklist(list_type='user', target_id=None, name=None, remove=False, refresh=False): - from .routes import edit_blacklist - edit_blacklist.route(list_type, target_id, name, remove, refresh) - - @dispatcher.register(MODES.EDITQUALITIES, args=['content_type'], kwargs=['video_id', 'target_id', 'name', 'remove', 'clip_id']) @error_handler def _edit_qualities(content_type, target_id=None, name=None, video_id=None, remove=False, clip_id=None): @@ -234,13 +213,6 @@ def _reset_cache(): reset_cache.route() -@dispatcher.register(MODES.INSTALLIRCCHAT) -@error_handler -def _install_ircchat(): - from .routes import install_ircchat - install_ircchat.route() - - @dispatcher.register(MODES.CONFIGUREIA) @error_handler def _configure_ia(): @@ -248,13 +220,6 @@ def _configure_ia(): configure_inputstream_adaptive.route() -@dispatcher.register(MODES.TOKENURL) -@error_handler -def _get_token_url(): - from .routes import token_url - token_url.route(twitch_api) - - @dispatcher.register(MODES.DEVICELOGIN) @error_handler def _device_login(): @@ -262,6 +227,20 @@ def _device_login(): device_login.route() +@dispatcher.register(MODES.DEVICELOGINPRIVATE) +@error_handler +def _device_login_private(): + from .routes import device_login_private + device_login_private.route() + + +@dispatcher.register(MODES.REVOKEPRIVATETOKEN) +@error_handler +def _revoke_private_token(): + from .routes import revoke_private_token + revoke_private_token.route() + + @dispatcher.register(MODES.REVOKETOKEN) @error_handler def _revoke_token(): diff --git a/resources/lib/twitch_addon/routes/__init__.py b/resources/lib/twitch_addon/routes/__init__.py index 5fe9704d..bcd27d08 100644 --- a/resources/lib/twitch_addon/routes/__init__.py +++ b/resources/lib/twitch_addon/routes/__init__.py @@ -10,10 +10,10 @@ """ __all__ = ['browse', 'channel_video_categories', 'channel_videos', 'clear_list', - 'clear_search_history', 'clips', 'configure_inputstream_adaptive', + 'clear_search_history', 'clips', 'configure_inputstream_adaptive', 'device_login', 'device_login_private', 'edit_languages', 'edit_qualities', 'edit_sorting', 'edit_user_follows', 'popular_streams', 'followed', 'following', - 'game_categories', 'game_streams', 'games', 'install_ircchat', 'main', + 'game_categories', 'game_streams', 'games', 'main', 'maintain', 'new_search', 'play', 'refresh', 'remove_search_history', - 'reset_cache', 'revoke_token', 'search', 'search_history', 'search_results', - 'settings', 'streams', 'token_url', 'update_token'] + 'reset_cache', 'revoke_token', 'revoke_private_token', 'search', 'search_history', 'search_results', + 'settings', 'streams', 'update_token'] diff --git a/resources/lib/twitch_addon/routes/device_login.py b/resources/lib/twitch_addon/routes/device_login.py index 337cec54..536448b4 100644 --- a/resources/lib/twitch_addon/routes/device_login.py +++ b/resources/lib/twitch_addon/routes/device_login.py @@ -18,13 +18,11 @@ from ..addon.utils import i18n -def route(): - client_id = utils.get_client_id() - scopes = ' '.join(SCOPES) - +def do_device_login(heading, body_i18n, client_id, scopes, store_tokens, success_i18n, log_tag): + """Shared device-code login flow: show code, poll, store tokens via store_tokens().""" ok, data = device_oauth.request_device_code(client_id, scopes) if not ok or not data.get('user_code'): - kodi.Dialog().ok(i18n('device_login'), i18n('device_login_unsupported')) + kodi.Dialog().ok(heading, i18n('device_login_unsupported')) return user_code = data['user_code'] @@ -34,11 +32,11 @@ def route(): # Short, clean activation URL (Twitch's verification_uri carries the code as a long query string). activate_url = 'https://www.twitch.tv/activate' - body = i18n('device_login_instructions') % (activate_url, user_code) + body = i18n(body_i18n) % (activate_url, user_code) monitor = xbmc.Monitor() progress = xbmcgui.DialogProgress() - progress.create(i18n('device_login'), body) + progress.create(heading, body) token = None waited = 0 @@ -70,10 +68,21 @@ def route(): progress.close() if token and token.get('access_token'): - utils.store_oauth_tokens(token['access_token'], - token.get('refresh_token', ''), - token.get('expires_in', 14400)) - log_utils.log('OAuth: device login succeeded, refresh token stored', log_utils.LOGNOTICE) - kodi.Dialog().ok(i18n('device_login'), i18n('device_login_success')) + store_tokens(token['access_token'], + token.get('refresh_token', ''), + token.get('expires_in', 14400)) + log_utils.log('OAuth%s: device login succeeded, refresh token stored' % log_tag, + log_utils.LOGNOTICE) + kodi.Dialog().ok(heading, i18n(success_i18n)) else: kodi.notify(i18n('login'), i18n('device_login_failed'), sound=False) + + +def route(): + do_device_login(heading=i18n('device_login'), + body_i18n='device_login_instructions', + client_id=utils.get_client_id(), + scopes=' '.join(SCOPES), + store_tokens=utils.store_oauth_tokens, + success_i18n='device_login_success', + log_tag='') diff --git a/resources/lib/twitch_addon/routes/device_login_private.py b/resources/lib/twitch_addon/routes/device_login_private.py new file mode 100644 index 00000000..5d578cf6 --- /dev/null +++ b/resources/lib/twitch_addon/routes/device_login_private.py @@ -0,0 +1,31 @@ +# -*- coding: utf-8 -*- +""" + + Copyright (C) 2024 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. + + Device-code login for the PRIVATE (Turbo/ad-free) credentials: same flow as + device_login, but with the private client id and the private token store used by + usher/GQL for ad-free Turbo/subscriber playback. Additive to the manual + `private_oauth_token` setting, which keeps working as a fallback. +""" + +from ..addon import utils +from ..addon.utils import i18n + +from . import device_login + + +def route(): + # No scopes: the playback entitlement (Turbo/subscription) is account-bound. + device_login.do_device_login(heading=i18n('device_login_private'), + body_i18n='device_login_private_instructions', + client_id=utils.effective_private_client_id(), + scopes='', + store_tokens=utils.store_private_tokens, + success_i18n='device_login_private_success', + log_tag='(private)') diff --git a/resources/lib/twitch_addon/routes/edit_qualities.py b/resources/lib/twitch_addon/routes/edit_qualities.py index 03000dcf..863ff9f3 100644 --- a/resources/lib/twitch_addon/routes/edit_qualities.py +++ b/resources/lib/twitch_addon/routes/edit_qualities.py @@ -32,6 +32,7 @@ def route(api, content_type, target_id=None, name=None, video_id=None, remove=Fa use_ia = utils.use_inputstream_adaptive() if use_ia and not any(v['name'] == 'Adaptive' for v in videos) and (content_type != 'clip'): videos.append(ADAPTIVE_SOURCE_TEMPLATE) + videos = utils.filter_qualities(videos) # drop sub-720p (keep Source/720p+/audio_only/Adaptive) result = converter.select_video_for_quality(videos) if result: quality = result['name'] diff --git a/resources/lib/twitch_addon/routes/install_ircchat.py b/resources/lib/twitch_addon/routes/install_ircchat.py deleted file mode 100644 index 4686feed..00000000 --- a/resources/lib/twitch_addon/routes/install_ircchat.py +++ /dev/null @@ -1,18 +0,0 @@ -# -*- coding: utf-8 -*- -""" - - Copyright (C) 2012-2018 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.common import kodi - - -def route(): - if kodi.get_kodi_version().major > 16: - kodi.execute_builtin('InstallAddon(script.ircchat)') - else: - kodi.execute_builtin('RunPlugin(plugin://script.ircchat/)') diff --git a/resources/lib/twitch_addon/routes/new_search.py b/resources/lib/twitch_addon/routes/new_search.py index 5badb317..d24a50ec 100644 --- a/resources/lib/twitch_addon/routes/new_search.py +++ b/resources/lib/twitch_addon/routes/new_search.py @@ -9,18 +9,48 @@ See LICENSES/GPL-3.0-only for more information. """ from ..addon.common import kodi -from ..addon.constants import MODES from ..addon.utils import i18n -def route(content): - if MODES.SEARCHRESULTS not in kodi.get_info_label('Container.FolderPath'): - kodi.set_view('files', set_sort=False) - user_input = kodi.get_keyboard(i18n('search')) - if user_input: - kodi.end_of_directory() - kodi.update_container(kodi.get_plugin_url({'mode': MODES.SEARCHRESULTS, 'content': content, 'query': user_input, 'after': 'MA=='})) - else: - return - else: +def _query_key(content): + return kodi.get_id() + '-new_search_query-' + content + + +def clear_query(content): + # Called by the search menus (search.py / search_history.py) right before the user can + # click "New Search", so a deliberate New Search always prompts the keyboard again. + kodi.Window(10000).clearProperty(_query_key(content)) + + +def route(api, content): + # Render results inline. A Container.Update redirect to a dedicated search_results + # container would be cleaner, but it is reliably *swallowed* under + # reuselanguageinvoker=true (empty result list) -- verified on both SZ (2026-06-17) + # and WZ (2026-06-21, issue #1), with succeeded=True and succeeded=False alike. + # + # Inline render leaves the folder path at new_search, so Kodi reloads this route when + # returning from playback. Plain inline then re-popped the keyboard, and (issue #1) + # cancelling it hung at "Working..." (route returned without end_of_directory) until + # CScriptRunner killed the script after 12 min. + # + # Fix: remember the entered query in a window property. On the post-playback reload the + # property is set -> re-render the previous results instead of re-prompting. The search + # menus clear the property before "New Search" is reachable, so a deliberate new search + # still prompts. An empty/cancelled keyboard ends the directory cleanly (no hang). + win = kodi.Window(10000) + key = _query_key(content) + previous = win.getProperty(key) + + if previous: + from . import search_results + search_results.route(api, content, previous) + return + + user_input = kodi.get_keyboard(i18n('search')) + if not user_input: + kodi.end_of_directory(succeeded=False) return + + win.setProperty(key, user_input) + from . import search_results + search_results.route(api, content, user_input) diff --git a/resources/lib/twitch_addon/routes/play.py b/resources/lib/twitch_addon/routes/play.py index 13bee877..0ae427de 100644 --- a/resources/lib/twitch_addon/routes/play.py +++ b/resources/lib/twitch_addon/routes/play.py @@ -161,7 +161,7 @@ def _set_seek_time(value): if request: if kodi.get_kodi_version().major >= 18: request['headers']['verifypeer'] = 'false' - play_url = request['url'] + utils.append_headers(request['headers']) + play_url = request['url'] # headers passed via ISA stream/manifest_headers below if not play_url: play_url = result['url'] @@ -203,6 +203,15 @@ def _set_seek_time(value): inputstream_property += 'addon' playback_item.setProperty(inputstream_property, 'inputstream.adaptive') playback_item.setProperty('inputstream.adaptive.manifest_type', 'hls') + # allow up to Twitch-2K (1440p HEVC) regardless of screen res (ISA "2K"=2048x1080 < 1440p) + playback_item.setProperty('inputstream.adaptive.chooser_resolution_max', '1440p') + playback_item.setProperty('inputstream.adaptive.chooser_resolution_secure_max', '1440p') + if request: + isa_headers = utils.format_isa_headers( + {k: v for k, v in request['headers'].items() if k != 'verifypeer'}) + if isa_headers: + playback_item.setProperty('inputstream.adaptive.manifest_headers', isa_headers) + playback_item.setProperty('inputstream.adaptive.stream_headers', isa_headers) if (seek_time > 0) and video_id: _set_seek_time(seek_time) _set_playing() @@ -210,11 +219,6 @@ def _set_seek_time(value): kodi.Player().play(item_dict['path'], playback_item) else: kodi.set_resolved_url(playback_item) - if (not slug and not video_id) and (name is not None): - if utils.irc_enabled() and api.access_token: - username = api.get_username() - if username: - utils.exec_irc_script(username, name) return else: kodi.set_resolved_url(kodi.ListItem(), succeeded=False) diff --git a/resources/lib/twitch_addon/routes/revoke_private_token.py b/resources/lib/twitch_addon/routes/revoke_private_token.py new file mode 100644 index 00000000..b5c068ae --- /dev/null +++ b/resources/lib/twitch_addon/routes/revoke_private_token.py @@ -0,0 +1,38 @@ +# -*- coding: utf-8 -*- +""" + + Copyright (C) 2024 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. + + Logout / revoke the PRIVATE (Turbo/ad-free) credentials used for ad-free playback. + Clears the device-login store (oauth_private_tokens.json) AND the manual + `private_oauth_token` setting, and best-effort revokes them server-side. Analogous + to revoke_token (which handles the main login/Helix token). +""" + +from ..addon import utils, device_oauth +from ..addon.common import kodi +from ..addon.utils import i18n + + +def route(): + store_token = (utils._read_private_store().get('access') or '').strip() + manual_token = (kodi.get_setting('private_oauth_token') or '').strip() + if not store_token and not manual_token: + kodi.notify(msg=i18n('token_required')) + return + if not kodi.Dialog().yesno(i18n('revoke_token'), i18n('revoke_confirmation')): + return + + client_id = utils.effective_private_client_id() + for token in {store_token, manual_token}: + if token: + device_oauth.revoke_token(client_id, token) # best-effort, server-side + + utils.clear_private_tokens() + kodi.set_setting('private_oauth_token', '') + kodi.notify(msg=i18n('token_revoked')) diff --git a/resources/lib/twitch_addon/routes/revoke_token.py b/resources/lib/twitch_addon/routes/revoke_token.py index 5db02b23..bda33cd6 100644 --- a/resources/lib/twitch_addon/routes/revoke_token.py +++ b/resources/lib/twitch_addon/routes/revoke_token.py @@ -28,6 +28,6 @@ def route(api): raise TwitchException(response) raise TwitchException(response['error']) else: - kodi.set_setting('oauth_token_helix', '') + utils.clear_oauth_tokens() kodi.notify(msg=i18n('token_revoked')) cache.reset_cache() diff --git a/resources/lib/twitch_addon/routes/search.py b/resources/lib/twitch_addon/routes/search.py index b9e90398..0bd6d78e 100644 --- a/resources/lib/twitch_addon/routes/search.py +++ b/resources/lib/twitch_addon/routes/search.py @@ -15,6 +15,11 @@ def route(): + # No-history fallback navigates straight to New Search from here -> clear any + # remembered query so a deliberate new search prompts the keyboard (see new_search.py). + from . import new_search + for _content in ('streams', 'channels', 'games', 'id_url'): + new_search.clear_query(_content) kodi.set_view('files', set_sort=False) history_size = utils.get_search_history_size() use_history = history_size > 0 diff --git a/resources/lib/twitch_addon/routes/search_history.py b/resources/lib/twitch_addon/routes/search_history.py index b5a16e8a..cfc06cb7 100644 --- a/resources/lib/twitch_addon/routes/search_history.py +++ b/resources/lib/twitch_addon/routes/search_history.py @@ -15,6 +15,10 @@ def route(content): + # Reaching "New Search" always passes through here -> clear any remembered query so a + # deliberate new search prompts the keyboard (see new_search.py for the mechanism). + from . import new_search + new_search.clear_query(content) kodi.set_view('files', set_sort=False) context_menu = list() context_menu.extend(menu_items.clear_search_history(content, do_refresh=True)) diff --git a/resources/lib/twitch_addon/routes/token_url.py b/resources/lib/twitch_addon/routes/token_url.py deleted file mode 100644 index a1dea362..00000000 --- a/resources/lib/twitch_addon/routes/token_url.py +++ /dev/null @@ -1,28 +0,0 @@ -# -*- coding: utf-8 -*- -""" - - Copyright (C) 2012-2019 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.google_firebase import dynamic_links_short_url -from ..addon.utils import i18n - - -def route(api): - redirect_uri = utils.get_redirect_uri() - request_url = api.client.prepare_request_uri(redirect_uri=redirect_uri, scope=api.required_scopes) - try: - short_url = dynamic_links_short_url(request_url) - except: - short_url = None - prompt_url = short_url if short_url else i18n('authorize_url_fail') - - _ = kodi.Dialog().ok(i18n('authorize_heading'), i18n('authorize_message') + '[CR]%s' % prompt_url) - kodi.show_settings() diff --git a/resources/lib/twitch_addon/routes/update_token.py b/resources/lib/twitch_addon/routes/update_token.py index 144f0fe6..7c7b2b74 100644 --- a/resources/lib/twitch_addon/routes/update_token.py +++ b/resources/lib/twitch_addon/routes/update_token.py @@ -8,10 +8,12 @@ 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.utils import i18n def route(oauth_token): - kodi.set_setting('oauth_token_helix', oauth_token) + # Manually entered token has no refresh token; route it through the race-free store. + utils.store_oauth_tokens(oauth_token, '', 0) kodi.notify(msg=i18n('token_updated')) diff --git a/resources/settings.xml b/resources/settings.xml index d484ece4..caf3fdc5 100644 --- a/resources/settings.xml +++ b/resources/settings.xml @@ -7,8 +7,6 @@ - - @@ -32,12 +30,12 @@ - + - + @@ -73,15 +71,13 @@ - + - + - - - - + + @@ -93,13 +89,6 @@ - - - - - - From 7ac70b6966f4eaea7db7145106d6bd50e796cbe4 Mon Sep 17 00:00:00 2001 From: einanderson <289327658+einanderson@users.noreply.github.com> Date: Sat, 11 Jul 2026 05:46:25 +0200 Subject: [PATCH 7/8] Release 3.0.3: version bump, news and changelog Co-Authored-By: Claude Fable 5 --- addon.xml | 12 +++++++++--- changelog.txt | 21 +++++++++++++++++++++ 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/addon.xml b/addon.xml index 9174d96c..4d8363a7 100644 --- a/addon.xml +++ b/addon.xml @@ -1,5 +1,5 @@ - + @@ -15,8 +15,14 @@ resources/media/fanart.jpg -[fix] Following Channels -[lang] updated translations from Weblate +[add] OAuth device-code login with automatic background token refresh +[add] second device-code login for ad-free playback (Turbo/subscriber) +[add] website (GQL) search backend with fuzzy/relevance ranking (+ Helix fallback) +[add] optional "Supported codecs" setting (H.265/HEVC for Enhanced Broadcasting 1440p) +[fix] search hanging at "Working..." after stopping playback started from search results +[fix] hide emoji/symbols the skin font cannot render (tofu boxes) in titles and plots +[upd] InputStream Adaptive: headers via properties, allow up to 1440p, drop sub-720p variants +[rem] IRC chat integration and the obsolete browser-based token flow all https://github.com/anxdpanic/plugin.video.twitch diff --git a/changelog.txt b/changelog.txt index 96aba9b3..bcd31bd1 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,3 +1,24 @@ +3.0.3 +[add] OAuth device-code login with automatic background token refresh (Settings -> "Login (device code)") + - tokens kept in addon_data/oauth_tokens.json with atomic writes and a cross-process lock, + so the rotating single-use refresh token survives Kodi's multi-process settings race + - confidential client ids (no silent refresh possible) are detected once and fall back + to manual login with a one-time hint +[add] second device-code login for ad-free playback (Turbo/subscriber) with its own + auto-refreshed token store; manual private token keeps working as fallback +[add] website (GQL) search backend with fuzzy/relevance ranking, selectable via + "Search method" with automatic Helix fallback +[add] optional "Supported codecs" setting: request H.265 (HEVC) so Enhanced Broadcasting + 1440p variants are offered (requires script.module.python.twitch >= 3.0.3; silently + ignored with older library versions) +[fix] search hanging at "Working..." / keyboard re-prompt after stopping playback that was + started from search results +[fix] hide emoji/symbols the skin font cannot render (tofu boxes) in titles, plots and info +[upd] InputStream Adaptive: pass headers via stream/manifest_headers properties instead of + the URL pipe; allow up to 1440p (chooser_resolution_max); drop sub-720p variants from + quality selection; default to Adaptive quality with InputStream Adaptive enabled +[rem] IRC chat integration (script.ircchat) and the obsolete browser-based token flow + 2.6.0 [update] Update Twitch API usage from v5 to Helix - This change will require you to generate a new oauth token from the add-on settings From a41bc093167d80696c64d8b09b6de0703cab30f5 Mon Sep 17 00:00:00 2001 From: einanderson <289327658+einanderson@users.noreply.github.com> Date: Sat, 11 Jul 2026 06:01:48 +0200 Subject: [PATCH 8/8] Review fixes: guard empty bandwidth match, point token hints at device login, dedup import - get_video_for_quality: with sub-720p variants filtered out, the bandwidth-limited selection can end up with no candidate; guard the max() so it falls through to the quality dialog instead of raising ValueError. - The 'generate a new token' dialogs still pointed at the removed 'Get OAuth token' settings entry; point them at 'Login (device code)'. - new_search: import search_results once. Co-Authored-By: Claude Fable 5 --- resources/lib/twitch_addon/addon/api.py | 6 +++--- resources/lib/twitch_addon/addon/converter.py | 15 +++++++++------ resources/lib/twitch_addon/routes/main.py | 2 +- resources/lib/twitch_addon/routes/new_search.py | 4 ++-- 4 files changed, 15 insertions(+), 12 deletions(-) diff --git a/resources/lib/twitch_addon/addon/api.py b/resources/lib/twitch_addon/addon/api.py index 980a6590..2cf4f370 100644 --- a/resources/lib/twitch_addon/addon/api.py +++ b/resources/lib/twitch_addon/addon/api.py @@ -82,7 +82,7 @@ def valid_token(self, client_id, token, scopes): # client_id, token used for un '[CR]'.join([ i18n('missing_scopes') % missing_scopes, i18n('get_new_oauth_token') % - (i18n('settings'), i18n('login'), i18n('get_oauth_token')) + (i18n('settings'), i18n('login'), i18n('device_login')) ]) ) log_utils.log('Error: Current OAuth token is missing required scopes |%s|' % missing_scopes, @@ -110,7 +110,7 @@ def valid_token(self, client_id, token, scopes): # client_id, token used for un '[CR]'.join([ i18n('client_id_mismatch'), i18n('get_new_oauth_token') % - (i18n('settings'), i18n('login'), i18n('get_oauth_token')) + (i18n('settings'), i18n('login'), i18n('device_login')) ]) ) return False @@ -406,7 +406,7 @@ def error_check(results, private=False): 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_login')) ) else: _ = kodi.Dialog().ok( diff --git a/resources/lib/twitch_addon/addon/converter.py b/resources/lib/twitch_addon/addon/converter.py index 3dc37558..7ba04018 100644 --- a/resources/lib/twitch_addon/addon/converter.py +++ b/resources/lib/twitch_addon/addon/converter.py @@ -581,12 +581,15 @@ def get_video_for_quality(self, videos, ask=True, quality=None, clip=False): bwidth = int(video['bandwidth']) if bwidth <= bandwidth_value: bandwidths.append(bwidth) - best_match = max(bandwidths) - try: - index = next(idx for idx, video in enumerate(videos) if int(video['bandwidth']) == best_match) - return videos[index] - except: - pass + # may be empty: with sub-720p variants filtered out, every remaining + # variant can exceed the configured limit -> fall through to the dialog + if bandwidths: + best_match = max(bandwidths) + try: + index = next(idx for idx, video in enumerate(videos) if int(video['bandwidth']) == best_match) + return videos[index] + except: + pass return self.select_video_for_quality(videos) @staticmethod diff --git a/resources/lib/twitch_addon/routes/main.py b/resources/lib/twitch_addon/routes/main.py index 47b9b01d..6293b0bc 100644 --- a/resources/lib/twitch_addon/routes/main.py +++ b/resources/lib/twitch_addon/routes/main.py @@ -23,7 +23,7 @@ def route(api): if not has_token: _ = 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_login')) ) if has_token: diff --git a/resources/lib/twitch_addon/routes/new_search.py b/resources/lib/twitch_addon/routes/new_search.py index d24a50ec..4f8899b2 100644 --- a/resources/lib/twitch_addon/routes/new_search.py +++ b/resources/lib/twitch_addon/routes/new_search.py @@ -37,12 +37,13 @@ def route(api, content): # property is set -> re-render the previous results instead of re-prompting. The search # menus clear the property before "New Search" is reachable, so a deliberate new search # still prompts. An empty/cancelled keyboard ends the directory cleanly (no hang). + from . import search_results + win = kodi.Window(10000) key = _query_key(content) previous = win.getProperty(key) if previous: - from . import search_results search_results.route(api, content, previous) return @@ -52,5 +53,4 @@ def route(api, content): return win.setProperty(key, user_input) - from . import search_results search_results.route(api, content, user_input)