From baf4f4f440976009f1dbc68f061003eb8a994d97 Mon Sep 17 00:00:00 2001 From: MoojMidge <56883549+MoojMidge@users.noreply.github.com> Date: Sat, 21 Mar 2026 10:47:08 +0900 Subject: [PATCH 01/18] Update XbmcContext.is_plugin_folder() to match partial paths by default --- .../youtube_plugin/kodion/context/xbmc/xbmc_context.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/resources/lib/youtube_plugin/kodion/context/xbmc/xbmc_context.py b/resources/lib/youtube_plugin/kodion/context/xbmc/xbmc_context.py index 7e13d584..e1437060 100644 --- a/resources/lib/youtube_plugin/kodion/context/xbmc/xbmc_context.py +++ b/resources/lib/youtube_plugin/kodion/context/xbmc/xbmc_context.py @@ -1091,18 +1091,17 @@ class XbmcContext(AbstractContext): ) return value - def is_plugin_folder(self, folder_path='', name=False): + def is_plugin_folder(self, folder_path='', name=False, partial=True): if name: return XbmcContextUI.get_container_info( FOLDER_NAME, - container_id=None, ) == self._plugin_name return self.is_plugin_path( - XbmcContextUI.get_container_info( + uri=XbmcContextUI.get_container_info( FOLDER_URI, - container_id=None, ), - folder_path, + uri_path=folder_path, + partial=partial, ) def refresh_requested(self, force=False, on=False, off=False, params=None): From b0345b142f6372a76b8384388c5a6814918f91e4 Mon Sep 17 00:00:00 2001 From: MoojMidge <56883549+MoojMidge@users.noreply.github.com> Date: Mon, 30 Mar 2026 13:47:01 +0900 Subject: [PATCH 02/18] Invalidate expired access tokens only if already stored --- resources/lib/youtube_plugin/youtube/provider.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/resources/lib/youtube_plugin/youtube/provider.py b/resources/lib/youtube_plugin/youtube/provider.py index a8114ade..3fbf9abe 100644 --- a/resources/lib/youtube_plugin/youtube/provider.py +++ b/resources/lib/youtube_plugin/youtube/provider.py @@ -259,7 +259,8 @@ class Provider(AbstractProvider): ) = access_manager.get_refresh_tokens(dev_id) if not num_access_tokens and not num_refresh_tokens: - access_manager.update_access_token(dev_id, access_token='') + if any(access_tokens): + access_manager.update_access_token(dev_id, access_token='') return client if num_access_tokens == num_refresh_tokens and client.logged_in: return client From 0dc2d76a20ca393be2e2e59803eedb4b1a0608ce Mon Sep 17 00:00:00 2001 From: MoojMidge <56883549+MoojMidge@users.noreply.github.com> Date: Mon, 30 Mar 2026 11:46:33 +0900 Subject: [PATCH 03/18] Use consistent language for signing in/out --- resources/language/resource.language.en_gb/strings.po | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/resources/language/resource.language.en_gb/strings.po b/resources/language/resource.language.en_gb/strings.po index 5c7a6ed2..f1b71433 100644 --- a/resources/language/resource.language.en_gb/strings.po +++ b/resources/language/resource.language.en_gb/strings.po @@ -490,11 +490,11 @@ msgid "No videos found." msgstr "" msgctxt "#30546" -msgid "Please complete all login prompts" +msgid "Please sign in and complete all access authorisation prompts" msgstr "" msgctxt "#30547" -msgid "You may be prompted to login and enable access to multiple applications so that this addon can function properly." +msgid "You may be prompted to sign in and enable access to multiple applications so that this addon can function properly." msgstr "" msgctxt "#30548" @@ -1166,7 +1166,7 @@ msgid "Prefer automatically translated dubbed audio over original audio" msgstr "" msgctxt "#30715" -msgid "Use YouTube internal list for Watch History?[CR][CR]Requires signing-in via the addon and activating history tracking on YouTube." +msgid "Use YouTube internal list for Watch History?[CR][CR]Requires signing in via the addon and activating history tracking on YouTube." msgstr "" msgctxt "#30716" @@ -1178,7 +1178,7 @@ msgid "Disliked video" msgstr "" msgctxt "#30718" -msgid "Use YouTube internal list for Watch Later?[CR][CR]Requires signing-in via the addon." +msgid "Use YouTube internal list for Watch Later?[CR][CR]Requires signing in via the addon." msgstr "" msgctxt "#30719" From 34f763953a854fc8d0c20c58bbf2e3bc0ec260f4 Mon Sep 17 00:00:00 2001 From: MoojMidge <56883549+MoojMidge@users.noreply.github.com> Date: Sat, 28 Mar 2026 23:06:06 +0900 Subject: [PATCH 04/18] Detect and notify when API requests cannot be completed - Provide specific user notifications for the various different causes - Force cache use when requests can't be made --- .../youtube/client/data_client.py | 55 +++++++++---- .../youtube/helper/resource_manager.py | 82 +++++++++++-------- 2 files changed, 88 insertions(+), 49 deletions(-) diff --git a/resources/lib/youtube_plugin/youtube/client/data_client.py b/resources/lib/youtube_plugin/youtube/client/data_client.py index 01e0e742..5fe96d59 100644 --- a/resources/lib/youtube_plugin/youtube/client/data_client.py +++ b/resources/lib/youtube_plugin/youtube/client/data_client.py @@ -2976,6 +2976,13 @@ class YouTubeDataClient(YouTubeLoginClient): v3_response['_item_filter'] = item_filter return v3_response + @classmethod + def v3_api_available(cls): + user_config = cls._configs.get('user') + if user_config: + return bool(user_config.get('key')) + return False + def _auth_required(self, params): if params: if params.get('mine') or params.get('forMine'): @@ -3105,15 +3112,30 @@ class YouTubeDataClient(YouTubeLoginClient): do_auth=None, cache=None, **kwargs): + context = self._context + + if client == 'v3' and not self.v3_api_available(): + abort = True + abort_msg = 'Request skipped: API key not provided' + abort_prompt = 'key.requirement' + else: + abort = False + abort_msg = None + abort_prompt = None + if not client_data: client_data = {} - client_data.setdefault('method', method) + if path: client_data['_endpoint'] = path.strip('/') + if url: client_data['url'] = url + if headers: client_data['headers'] = headers + + client_data.setdefault('method', method) if method in {'POST', 'PUT'}: if post_data: client_data['json'] = post_data @@ -3124,16 +3146,18 @@ class YouTubeDataClient(YouTubeLoginClient): if do_auth is None and method == 'DELETE': do_auth = True clear_data = True + if params: client_data['params'] = params if do_auth is None: do_auth = self._auth_required(params) if do_auth: - abort = not self.logged_in + if not self.logged_in: + abort = True + abort_msg = 'Request skipped: Authorisation required' + abort_prompt = 'sign.multi.title' client_data.setdefault('_auth_required', do_auth) - else: - abort = False client_data['_access_tokens'] = access_tokens = {} client_data['_api_keys'] = api_keys = {} @@ -3161,17 +3185,18 @@ class YouTubeDataClient(YouTubeLoginClient): params = client.get('params') if params and 'key' in params: key = params['key'] - if key: - abort = False - elif not client['_has_auth']: + if not key and not client['_has_auth']: abort = True + abort_msg = 'Request skipped: API key not provided' + abort_prompt = 'key.requirement' else: client_data.setdefault('_name', client) client = client_data params = client.get('params') abort = True + abort_msg = 'Request skipped: Invalid or disabled client' + abort_prompt = None - context = self._context self.log.debug(('{request_name} API request', 'method: {method!r}', 'path: {path!u}', @@ -3185,18 +3210,18 @@ class YouTubeDataClient(YouTubeLoginClient): data=client.get('json'), headers=client.get('headers'), stacklevel=2) + if abort: - if kwargs.get('notify', True): - context.get_ui().on_ok( - context.get_name(), - context.localize('key.requirement'), - ) - self.log.warning('Aborted', stacklevel=2) + if abort_prompt and kwargs.get('notify', True): + context.get_ui().on_ok(context.get_name(), abort_prompt) + self.log.warning(abort_msg, stacklevel=2) return {} + if cache is None and 'no_content' in kwargs: cache = False - elif cache is not False and self._context.refresh_requested(): + elif cache is not False and context.refresh_requested(): cache = 'refresh' + return self.request(response_hook=self._request_response_hook, event_hook_kwargs=kwargs, error_hook=self._request_error_hook, diff --git a/resources/lib/youtube_plugin/youtube/helper/resource_manager.py b/resources/lib/youtube_plugin/youtube/helper/resource_manager.py index 9bb034c5..10267365 100644 --- a/resources/lib/youtube_plugin/youtube/helper/resource_manager.py +++ b/resources/lib/youtube_plugin/youtube/helper/resource_manager.py @@ -61,15 +61,18 @@ class ResourceManager(object): def get_channels(self, ids, suppress_errors=False, defer_cache=False): context = self._context client = self._client - data_cache = context.get_data_cache() + function_cache = context.get_function_cache() refresh = context.refresh_requested() - forced_cache = not function_cache.run( - client.internet_available, - function_cache.ONE_MINUTE * 5, - _refresh=refresh, - ) + if not client.v3_api_available(): + forced_cache = True + else: + forced_cache = not function_cache.run( + client.internet_available, + function_cache.ONE_MINUTE * 5, + _refresh=refresh, + ) refresh = not forced_cache and refresh updated = [] @@ -97,6 +100,7 @@ class ResourceManager(object): if refresh or not ids: result = {} else: + data_cache = context.get_data_cache() result = data_cache.get_items( ids, None if forced_cache else data_cache.ONE_DAY, @@ -168,14 +172,17 @@ class ResourceManager(object): defer_cache=False): context = self._context client = self._client - function_cache = context.get_function_cache() refresh = context.refresh_requested() - forced_cache = not function_cache.run( - client.internet_available, - function_cache.ONE_MINUTE * 5, - _refresh=refresh, - ) + if not client.v3_api_available(): + forced_cache = True + else: + function_cache = context.get_function_cache() + forced_cache = not function_cache.run( + client.internet_available, + function_cache.ONE_MINUTE * 5, + _refresh=refresh, + ) refresh = not forced_cache and refresh if not refresh and channel_data: @@ -289,14 +296,17 @@ class ResourceManager(object): context = self._context client = self._client - function_cache = context.get_function_cache() refresh = context.refresh_requested() - forced_cache = not function_cache.run( - client.internet_available, - function_cache.ONE_MINUTE * 5, - _refresh=refresh, - ) + if not client.v3_api_available(): + forced_cache = True + else: + function_cache = context.get_function_cache() + forced_cache = not function_cache.run( + client.internet_available, + function_cache.ONE_MINUTE * 5, + _refresh=refresh, + ) refresh = not forced_cache and refresh if refresh or not ids: @@ -379,18 +389,19 @@ class ResourceManager(object): context = self._context client = self._client - function_cache = context.get_function_cache() refresh = context.refresh_requested() - forced_cache = ( - not function_cache.run( - client.internet_available, - function_cache.ONE_MINUTE * 5, - _refresh=refresh, - ) - or (context.get_param(CHANNEL_ID) == 'mine' - and not client.logged_in) - ) + if not client.v3_api_available(): + forced_cache = True + elif not client.logged_in and context.get_param(CHANNEL_ID) == 'mine': + forced_cache = True + else: + function_cache = context.get_function_cache() + forced_cache = not function_cache.run( + client.internet_available, + function_cache.ONE_MINUTE * 5, + _refresh=refresh, + ) refresh = not forced_cache and refresh if batch_id: @@ -564,14 +575,17 @@ class ResourceManager(object): context = self._context client = self._client - function_cache = context.get_function_cache() refresh = context.refresh_requested() - forced_cache = not function_cache.run( - client.internet_available, - function_cache.ONE_MINUTE * 5, - _refresh=refresh, - ) + if not client.v3_api_available(): + forced_cache = True + else: + function_cache = context.get_function_cache() + forced_cache = not function_cache.run( + client.internet_available, + function_cache.ONE_MINUTE * 5, + _refresh=refresh, + ) refresh = not forced_cache and refresh if refresh or not ids: From 9cca37c97324a87cee3d675e79d73b1d47d1dba0 Mon Sep 17 00:00:00 2001 From: MoojMidge <56883549+MoojMidge@users.noreply.github.com> Date: Mon, 30 Mar 2026 11:54:03 +0900 Subject: [PATCH 05/18] Add fallback method to load playlists when v3 API request cannot be made --- .../youtube/helper/resource_manager.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/resources/lib/youtube_plugin/youtube/helper/resource_manager.py b/resources/lib/youtube_plugin/youtube/helper/resource_manager.py index 10267365..ee28c7e4 100644 --- a/resources/lib/youtube_plugin/youtube/helper/resource_manager.py +++ b/resources/lib/youtube_plugin/youtube/helper/resource_manager.py @@ -391,9 +391,8 @@ class ResourceManager(object): client = self._client refresh = context.refresh_requested() - if not client.v3_api_available(): - forced_cache = True - elif not client.logged_in and context.get_param(CHANNEL_ID) == 'mine': + v3_api_available = client.v3_api_available() + if not client.logged_in and context.get_param(CHANNEL_ID) == 'mine': forced_cache = True else: function_cache = context.get_function_cache() @@ -478,7 +477,18 @@ class ResourceManager(object): batch_id = (playlist_id, page_token) if batch_id in result: break - batch = client.get_playlist_items(*batch_id, **kwargs) + batch = ( + client.get_playlist_items(*batch_id, **kwargs) + if v3_api_available else + client.get_browse_items( + browse_id='VL' + playlist_id, + playlist_id=playlist_id, + page_token=page_token, + response_type='playlistItems', + client='tv', + json_path=client.JSON_PATHS['tv_playlist'], + ) + ) if not batch: break new_batch_ids.append(batch_id) From b051092f9ab54b9ae04c14d0c052d8f50aad8006 Mon Sep 17 00:00:00 2001 From: MoojMidge <56883549+MoojMidge@users.noreply.github.com> Date: Wed, 25 Mar 2026 12:02:01 +0900 Subject: [PATCH 06/18] Improve handling of API key settings changes - Allow settings to sync back to api_keys.json when actually changed --- .../kodion/constants/__init__.py | 2 + .../kodion/json_store/api_keys.py | 106 ++++++++++-------- .../kodion/monitors/service_monitor.py | 18 ++- .../kodion/network/http_server.py | 2 + .../kodion/plugin/xbmc/xbmc_plugin.py | 4 + 5 files changed, 87 insertions(+), 45 deletions(-) diff --git a/resources/lib/youtube_plugin/kodion/constants/__init__.py b/resources/lib/youtube_plugin/kodion/constants/__init__.py index e3cc93cd..7acb8698 100644 --- a/resources/lib/youtube_plugin/kodion/constants/__init__.py +++ b/resources/lib/youtube_plugin/kodion/constants/__init__.py @@ -115,6 +115,7 @@ PLAYBACK_STOPPED = 'playback_stopped' REFRESH_CONTAINER = 'refresh_container' RELOAD_ACCESS_MANAGER = 'reload_access_manager' SERVICE_IPC = 'service_ipc' +SYNC_API_KEYS = 'sync_api_keys' SYNC_LISTITEM = 'sync_listitem' # Sleep/wakeup states @@ -282,6 +283,7 @@ __all__ = ( 'REFRESH_CONTAINER', 'RELOAD_ACCESS_MANAGER', 'SERVICE_IPC', + 'SYNC_API_KEYS', 'SYNC_LISTITEM', # Sleep/wakeup states diff --git a/resources/lib/youtube_plugin/kodion/json_store/api_keys.py b/resources/lib/youtube_plugin/kodion/json_store/api_keys.py index 2e99fc38..ac816db3 100644 --- a/resources/lib/youtube_plugin/kodion/json_store/api_keys.py +++ b/resources/lib/youtube_plugin/kodion/json_store/api_keys.py @@ -262,65 +262,83 @@ class APIKeyStore(JSONStore): data['keys']['developer'][developer_id] = new_config return self.save(data) - def sync(self): + def sync(self, update_store=False, update_settings=False): api_data = self.get_data() settings = self._context.get_settings() - update_saved_values = False - update_settings_values = False - - saved_details = ( + forced = update_store + stored_values = ( api_data['keys']['user'].get('api_key', ''), api_data['keys']['user'].get('client_id', ''), api_data['keys']['user'].get('client_secret', ''), ) - if all(saved_details): - update_settings_values = True - # users are now pasting keys into api_keys.json - # try stripping whitespace and domain suffix from API details - # and save the results if they differ - stripped_details = self.strip_details(*saved_details) - if all(stripped_details) and saved_details != stripped_details: - saved_details = stripped_details - api_data['keys']['user'] = { - 'api_key': saved_details[0], - 'client_id': saved_details[1], - 'client_secret': saved_details[2], - } - update_saved_values = True + _stored_values = self.strip_details(*stored_values) + all_stored = all(stored_values) - setting_details = ( + settings_values = ( settings.api_key(), settings.api_id(), settings.api_secret(), ) - if all(setting_details): - update_settings_values = False - stripped_details = self.strip_details(*setting_details) - if all(stripped_details) and setting_details != stripped_details: - setting_details = ( - settings.api_key(stripped_details[0]), - settings.api_id(stripped_details[1]), - settings.api_secret(stripped_details[2]), - ) + _settings_values = self.strip_details(*settings_values) + all_settings = all(settings_values) - if saved_details != setting_details: - api_data['keys']['user'] = { - 'api_key': setting_details[0], - 'client_id': setting_details[1], - 'client_secret': setting_details[2], - } - update_saved_values = True + if all_stored: + sync_to_settings = ( + not update_store + and _settings_values != _stored_values + ) + else: + sync_to_settings = update_settings - if update_settings_values: - settings.api_key(saved_details[0]) - settings.api_id(saved_details[1]) - settings.api_secret(saved_details[2]) + if all_settings: + sync_to_store = ( + not update_settings + and _stored_values != _settings_values + ) + else: + sync_to_store = update_store - if update_saved_values: - self.save(api_data) - return True - return False + update_settings = all_settings and settings_values != _settings_values + update_store = all_stored and stored_values != _stored_values + + if sync_to_settings: + settings.api_key(_stored_values[0]) + settings.api_id(_stored_values[1]) + settings.api_secret(_stored_values[2]) + + elif update_settings: + settings.api_key(_settings_values[0]) + settings.api_id(_settings_values[1]) + settings.api_secret(_settings_values[2]) + + elif sync_to_store: + api_data = { + 'keys': { + 'user': { + 'api_key': _settings_values[0], + 'client_id': _settings_values[1], + 'client_secret': _settings_values[2], + }, + }, + } + self.save(api_data, update=True, ipc=not forced) + + elif update_store: + api_data = { + 'keys': { + 'user': { + 'api_key': _stored_values[0], + 'client_id': _stored_values[1], + 'client_secret': _stored_values[2], + }, + }, + } + self.save(api_data, update=True, ipc=not forced) + + else: + return False + return True def update(self): context = self._context diff --git a/resources/lib/youtube_plugin/kodion/monitors/service_monitor.py b/resources/lib/youtube_plugin/kodion/monitors/service_monitor.py index b309ce53..e74556ec 100644 --- a/resources/lib/youtube_plugin/kodion/monitors/service_monitor.py +++ b/resources/lib/youtube_plugin/kodion/monitors/service_monitor.py @@ -37,6 +37,7 @@ from ..constants import ( RESUMABLE, SERVER_WAKEUP, SERVICE_IPC, + SYNC_API_KEYS, SYNC_LISTITEM, VIDEO_ID, ) @@ -54,6 +55,7 @@ class ServiceMonitor(xbmc.Monitor): def __init__(self, context): self._context = context + self._api_values = ('', '', '') self._httpd_address = None self._httpd_port = None self._whitelist = None @@ -260,6 +262,9 @@ class ServiceMonitor(xbmc.Monitor): self._context.reload_access_manager() self.refresh_container() + elif event == SYNC_API_KEYS: + self.onSettingsChanged(force=True) + elif event == PLAYBACK_STOPPED: if data: data = json.loads(data) @@ -315,6 +320,7 @@ class ServiceMonitor(xbmc.Monitor): def onSettingsChanged(self, force=False): context = self._context + ui = context.get_ui() if force: self._settings_collect = False @@ -352,7 +358,17 @@ class ServiceMonitor(xbmc.Monitor): self.log.stack_info = False self.log.verbose_logging = False - context.get_ui().set_property(CHECK_SETTINGS) + api_values = ( + settings.api_key(), + settings.api_id(), + settings.api_secret(), + ) + if api_values != self._api_values: + context.get_api_store().sync(update_store=True) + self._api_values = api_values + ui.set_property(SYNC_API_KEYS) + + ui.set_property(CHECK_SETTINGS) self.refresh_container() httpd_started = bool(self.httpd) diff --git a/resources/lib/youtube_plugin/kodion/network/http_server.py b/resources/lib/youtube_plugin/kodion/network/http_server.py index 09ce1058..73bf9bd0 100644 --- a/resources/lib/youtube_plugin/kodion/network/http_server.py +++ b/resources/lib/youtube_plugin/kodion/network/http_server.py @@ -38,6 +38,7 @@ from ..constants import ( LICENSE_TOKEN, LICENSE_URL, PATHS, + SYNC_API_KEYS, TEMP_PATH, ) from ..utils.convert_format import fix_subtitle_stream @@ -359,6 +360,7 @@ class RequestHandler(BaseHTTPRequestHandler, object): enabled = localize('api.personal.disabled') if updated: + context.send_notification(SYNC_API_KEYS) # Successfully updated updated = localize('api.config.updated', ', '.join(updated)) else: diff --git a/resources/lib/youtube_plugin/kodion/plugin/xbmc/xbmc_plugin.py b/resources/lib/youtube_plugin/kodion/plugin/xbmc/xbmc_plugin.py index 48fc23ac..78eac75e 100644 --- a/resources/lib/youtube_plugin/kodion/plugin/xbmc/xbmc_plugin.py +++ b/resources/lib/youtube_plugin/kodion/plugin/xbmc/xbmc_plugin.py @@ -33,6 +33,7 @@ from ...constants import ( REFRESH_CONTAINER, RELOAD_ACCESS_MANAGER, REROUTE_PATH, + SYNC_API_KEYS, SYNC_LISTITEM, TRAKT_PAUSE_FLAG, VIDEO_ID, @@ -195,6 +196,9 @@ class XbmcPlugin(AbstractPlugin): if ui.get_property(PLUGIN_SLEEPING): context.ipc_exec(PLUGIN_WAKEUP) + if ui.pop_property(SYNC_API_KEYS): + context.get_api_store().sync(update_store=True) + if ui.pop_property(RELOAD_ACCESS_MANAGER): context.reload_access_manager() From b4d625130287d7146c31f3f10a820188dad61f44 Mon Sep 17 00:00:00 2001 From: MoojMidge <56883549+MoojMidge@users.noreply.github.com> Date: Wed, 25 Mar 2026 12:03:46 +0900 Subject: [PATCH 07/18] Allow trailing slashes in url of html pages served by internal http server --- resources/lib/youtube_plugin/kodion/network/http_server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/lib/youtube_plugin/kodion/network/http_server.py b/resources/lib/youtube_plugin/kodion/network/http_server.py index 73bf9bd0..512f7439 100644 --- a/resources/lib/youtube_plugin/kodion/network/http_server.py +++ b/resources/lib/youtube_plugin/kodion/network/http_server.py @@ -232,7 +232,7 @@ class RequestHandler(BaseHTTPRequestHandler, object): parts, params, log_uri, log_params, log_path = parse_and_redact_uri(uri) path = { 'uri': uri, - 'path': parts.path, + 'path': parts.path.rstrip('/'), 'query': parts.query, 'params': params, 'log_uri': log_uri, From e749719efd36e5fe7ad79cd8a384b4f864d602b6 Mon Sep 17 00:00:00 2001 From: MoojMidge <56883549+MoojMidge@users.noreply.github.com> Date: Wed, 25 Mar 2026 12:22:52 +0900 Subject: [PATCH 08/18] Update API config page html - Move note to bookmark page from /youtube/api/submit to /youtube/api - Use POST method for form submission to prevent OAuth2 details potentially being logged or recorded in browser history - Disable form input autocomplete on /youtube/api - Improve display when input labels wrap --- .../kodion/network/http_server.py | 202 +++++++++--------- 1 file changed, 106 insertions(+), 96 deletions(-) diff --git a/resources/lib/youtube_plugin/kodion/network/http_server.py b/resources/lib/youtube_plugin/kodion/network/http_server.py index 512f7439..ee91755b 100644 --- a/resources/lib/youtube_plugin/kodion/network/http_server.py +++ b/resources/lib/youtube_plugin/kodion/network/http_server.py @@ -28,6 +28,7 @@ from ..compatibility import ( BaseHTTPRequestHandler, TCPServer, ThreadingMixIn, + parse_qs, urlencode, urlsplit, urlunsplit, @@ -266,10 +267,7 @@ class RequestHandler(BaseHTTPRequestHandler, object): return context = self._context - localize = context.localize - settings = context.get_settings() - api_config_enabled = settings.api_config_page() empty = [None] @@ -307,7 +305,7 @@ class RequestHandler(BaseHTTPRequestHandler, object): .format(uri=path['log_uri'], file_path=file_path)) self.send_error(404, response) - elif api_config_enabled and path['path'] == PATHS.API: + elif path['path'] == PATHS.API and settings.api_config_page(): html = self.api_config_page() html = html.encode('utf-8') @@ -319,65 +317,6 @@ class RequestHandler(BaseHTTPRequestHandler, object): for chunk in self._get_chunks(html): self.wfile.write(chunk) - elif api_config_enabled and path['path'].startswith(PATHS.API_SUBMIT): - xbmc.executebuiltin('Dialog.Close(addonsettings,true)') - - query = path['query'] - params = path['params'] - updated = [] - - api_key = params.get('api_key', empty)[0] - api_id = params.get('api_id', empty)[0] - api_secret = params.get('api_secret', empty)[0] - # Bookmark this page - if api_key and api_id and api_secret: - footer = localize('api.config.bookmark') - else: - footer = '' - - if re.search(r'api_key=(?:&|$)', query): - api_key = '' - if re.search(r'api_id=(?:&|$)', query): - api_id = '' - if re.search(r'api_secret=(?:&|$)', query): - api_secret = '' - - if api_key is not None and api_key != settings.api_key(): - settings.api_key(new_key=api_key) - updated.append(localize('api.key')) - - if api_id is not None and api_id != settings.api_id(): - settings.api_id(new_id=api_id) - updated.append(localize('api.id')) - - if api_secret is not None and api_secret != settings.api_secret(): - settings.api_secret(new_secret=api_secret) - updated.append(localize('api.secret')) - - if api_key and api_id and api_secret: - enabled = localize('api.personal.enabled') - else: - enabled = localize('api.personal.disabled') - - if updated: - context.send_notification(SYNC_API_KEYS) - # Successfully updated - updated = localize('api.config.updated', ', '.join(updated)) - else: - # No changes, not updated - updated = localize('api.config.not_updated') - - html = self.api_submit_page(updated, enabled, footer) - html = html.encode('utf-8') - - self.send_response(200) - self.send_header('Content-Type', 'text/html; charset=utf-8') - self.send_header('Content-Length', str(len(html))) - self.end_headers() - - for chunk in self._get_chunks(html): - self.wfile.write(chunk) - elif path['path'] == PATHS.PING: self.send_error(204) @@ -694,7 +633,70 @@ class RequestHandler(BaseHTTPRequestHandler, object): self.send_error(403) return - if path['path'].startswith(PATHS.DRM): + context = self._context + settings = context.get_settings() + localize = context.localize + + empty = [None] + + if path['path'] == PATHS.API_SUBMIT and settings.api_config_page(): + xbmc.executebuiltin('Dialog.Close(addonsettings,true)') + + length = int(self.headers['Content-Length']) + post_data = self.rfile.read(length) + + query = post_data.decode('utf-8', 'ignore') + params = parse_qs(query, keep_blank_values=True) + updated = [] + + api_key = params.get('api_key', empty)[0] + api_id = params.get('api_id', empty)[0] + api_secret = params.get('api_secret', empty)[0] + + if re.search(r'api_key=(?:&|$)', query): + api_key = '' + if re.search(r'api_id=(?:&|$)', query): + api_id = '' + if re.search(r'api_secret=(?:&|$)', query): + api_secret = '' + + if api_key is not None and api_key != settings.api_key(): + settings.api_key(new_key=api_key) + updated.append(localize('api.key')) + + if api_id is not None and api_id != settings.api_id(): + settings.api_id(new_id=api_id) + updated.append(localize('api.id')) + + if api_secret is not None and api_secret != settings.api_secret(): + settings.api_secret(new_secret=api_secret) + updated.append(localize('api.secret')) + + if api_key and api_id and api_secret: + enabled = localize('api.personal.enabled') + else: + enabled = localize('api.personal.disabled') + + if updated: + context.send_notification(SYNC_API_KEYS) + # Successfully updated + updated = localize('api.config.updated', ', '.join(updated)) + else: + # No changes, not updated + updated = localize('api.config.not_updated') + + html = self.api_submit_page(updated, enabled) + html = html.encode('utf-8') + + self.send_response(200) + self.send_header('Content-Type', 'text/html; charset=utf-8') + self.send_header('Content-Length', str(len(html))) + self.end_headers() + + for chunk in self._get_chunks(html): + self.wfile.write(chunk) + + elif path['path'].startswith(PATHS.DRM): ui = self._context.get_ui() lic_url = ui.get_property(LICENSE_URL) @@ -816,12 +818,14 @@ class RequestHandler(BaseHTTPRequestHandler, object): api_key_value=api_key, api_secret_value=api_secret, submit=localize('api.config.save'), + action_url=PATHS.API_SUBMIT, header=localize('api.config'), + footer=localize('api.config.bookmark'), ) return html @classmethod - def api_submit_page(cls, updated_keys, enabled, footer): + def api_submit_page(cls, updated_keys, enabled): localize = cls._context.localize html = Pages.api_submit.get('html') css = Pages.api_submit.get('css') @@ -830,7 +834,6 @@ class RequestHandler(BaseHTTPRequestHandler, object): title=localize('api.config'), updated=updated_keys, enabled=enabled, - footer=footer, header=localize('api.config'), ) return html @@ -844,31 +847,34 @@ class Pages(object): - {{title}} - + {title} +
-
{{header}}
-
+
{header}
+ - +
+

+ {footer} +

- '''.format(action_url=PATHS.API_SUBMIT)), + '''), 'css': ''.join('\t\t\t'.expandtabs(2) + line for line in dedent(''' body { background: #141718; @@ -880,7 +886,6 @@ class Pages(object): } .config_form { width: 575px; - height: 145px; font-size: 16px; background: #1a2123; padding: 30px 30px 15px 30px; @@ -908,7 +913,7 @@ class Pages(object): color: #fff; } .config_form label { - display:block; + display: inline-block; margin-bottom: 10px; } .config_form label > span { @@ -929,18 +934,38 @@ class Pages(object): } .config_form input[type=submit], .config_form input[type=button] { + display: block; width: 150px; background: #141718; border: 1px solid #147a96; padding: 8px 0px 8px 10px; border-radius: 5px; color: #fff; - margin-top: 10px + margin: 10px auto; } .config_form input[type=submit]:hover, .config_form input[type=button]:hover { background: #0f84a5; } + .text_center { + margin: 2em auto auto; + width: 600px; + padding: 10px; + text-align: center; + } + p { + font-family: Arial, Helvetica, sans-serif; + font-size: 16px; + color: #fff; + float: left; + width: 575px; + margin: 0.5em auto; + } + small { + font-family: Arial, Helvetica, sans-serif; + font-size: 12px; + color: #fff; + } ''').splitlines(True)) + '\t\t'.expandtabs(2) } @@ -960,9 +985,6 @@ class Pages(object):

{updated}

{enabled}

-

- {footer} -

@@ -977,15 +999,8 @@ class Pages(object): width: 600px; padding: 10px; } - .text_center { - margin: 2em auto auto; - width: 600px; - padding: 10px; - text-align: center; - } .content { width: 575px; - height: 145px; background: #1a2123; padding: 30px 30px 15px 30px; border: 5px solid #1a2123; @@ -1010,11 +1025,6 @@ class Pages(object): width: 575px; margin: 0.5em auto; } - small { - font-family: Arial, Helvetica, sans-serif; - font-size: 12px; - color: #fff; - } ''').splitlines(True)) + '\t\t'.expandtabs(2) } From a224b242798977cf0ae6587a2e0a65cc046d872b Mon Sep 17 00:00:00 2001 From: MoojMidge <56883549+MoojMidge@users.noreply.github.com> Date: Sun, 29 Mar 2026 16:07:50 +0900 Subject: [PATCH 09/18] Ensure parameters to Kodi builtin functions are parsed as string literals - Wrap values in double quotes if they may include dynamic/variable/user inputs --- .../kodion/abstract_provider.py | 34 +++--- .../kodion/context/abstract_context.py | 110 ++++++++++-------- .../kodion/ui/xbmc/xbmc_context_ui.py | 2 +- .../lib/youtube_plugin/youtube/provider.py | 2 +- 4 files changed, 79 insertions(+), 69 deletions(-) diff --git a/resources/lib/youtube_plugin/kodion/abstract_provider.py b/resources/lib/youtube_plugin/kodion/abstract_provider.py index 9eb33adb..d026ef4b 100644 --- a/resources/lib/youtube_plugin/kodion/abstract_provider.py +++ b/resources/lib/youtube_plugin/kodion/abstract_provider.py @@ -366,30 +366,32 @@ class AbstractProvider(object): if window_cache: ui.set_property(REROUTE_PATH, path) - action = ''.join(( - 'ReplaceWindow' if window_replace else 'ActivateWindow', - '(Videos,', - uri, - ',return)' if window_return else ')', - )) - timeout = 30 while ui.busy_dialog_active(): timeout -= 1 if timeout < 0: self.log.warning('Multiple busy dialogs active' ' - Rerouting workaround') - return UriItem('command://{0}'.format(action)) + defer = True + break context.sleep(0.1) else: - context.execute( - action, - # wait=True, - # wait_for=(REROUTE_PATH if window_cache else None), - # wait_for_set=False, - # block_ui=True, - ) - return True + defer = False + + action = context.create_uri( + uri, + window={ + 'name': 'Videos', + 'replace': window_replace, + 'return': window_return, + }, + command=defer, + ) + + if defer: + return UriItem(action) + context.execute(action) + return True @staticmethod def on_bookmarks(provider, context, re_match): diff --git a/resources/lib/youtube_plugin/kodion/context/abstract_context.py b/resources/lib/youtube_plugin/kodion/context/abstract_context.py index fff9652e..39a758c7 100644 --- a/resources/lib/youtube_plugin/kodion/context/abstract_context.py +++ b/resources/lib/youtube_plugin/kodion/context/abstract_context.py @@ -373,6 +373,11 @@ class AbstractContext(object): play=None, window=None, command=False, + _window=(('name', ''), + ('refresh', ''), + ('replace', ''), + ('return', ''), + ('update', '')), **kwargs): if isinstance(path, (list, tuple)): uri = self.create_path(*path, is_uri=True) @@ -408,66 +413,69 @@ class AbstractContext(object): command = 'command://' if command else '' - if window: - if not isinstance(window, dict): - window = {} - if window.setdefault('refresh', False): - method = 'Container.Refresh(' - if not window.setdefault('replace', False): + if run == 'addon': + method = 'RunAddon' + elif run == 'script': + method = 'RunScript' + elif run: + method = 'RunPlugin' + elif play is not None: + method = 'PlayMedia' + kwargs['playlist_type_hint'] = play + elif isinstance(window, dict): + window = dict(_window, **window) + if window['refresh']: + method = 'Container.Refresh' + if not window['replace']: uri = '' - history_replace = False - window_return = False - elif window.setdefault('update', False): - method = 'Container.Update(' - history_replace = window.setdefault('replace', False) - window_return = False + window['name'] = '' + window['return'] = '' + window['replace'] = '' + elif window['update']: + method = 'Container.Update' + window['name'] = '' + window['return'] = '' + window['replace'] = ',replace' if window['replace'] else '' else: - history_replace = False - window_name = window.setdefault('name', 'Videos') - if window.setdefault('replace', False): - method = 'ReplaceWindow(%s,' % window_name - window_return = window.setdefault('return', False) + window['name'] = '"%s",' % (window['name'] or 'Videos') + if window['replace']: + method = 'ReplaceWindow' + window['return'] = '' else: - method = 'ActivateWindow(%s,' % window_name - window_return = window.setdefault('return', True) - return ''.join(( - command, - method, - uri, - ',return' if window_return else '', - ',replace' if history_replace else '', - ')' - )) + method = 'ActivateWindow' + window['return'] = ',return' if window['return'] else '' + window['replace'] = '' + + return ('{command}{method}(' + '{window[name]}' + '{uri}' + '{window[return]}' + '{window[replace]}' + ')').format( + command=command, + method=method, + uri=('"%s"' % uri) if uri else '', + window=window, + ) + else: + return uri kwargs = ',' + ','.join([ - '%s=%s' % (kwarg, value) + '"%s=%s"' % (kwarg, value) if value is not None else - kwarg + "%s" % kwarg for kwarg, value in kwargs.items() ]) if kwargs else '' - if run: - return ''.join(( - command, - 'RunAddon(' - if run == 'addon' else - 'RunScript(' - if run == 'script' else - 'RunPlugin(', - uri, - kwargs, - ')' - )) - if play is not None: - return ''.join(( - command, - 'PlayMedia(', - uri, - kwargs, - ',playlist_type_hint=', str(play), - ')', - )) - return uri + return ('{command}{method}(' + '"{uri}"' + '{kwargs}' + ')').format( + command=command, + method=method, + uri=uri, + kwargs=kwargs, + ) def get_parent_uri(self, **kwargs): return self.create_uri(self._path_parts[:-1], **kwargs) diff --git a/resources/lib/youtube_plugin/kodion/ui/xbmc/xbmc_context_ui.py b/resources/lib/youtube_plugin/kodion/ui/xbmc/xbmc_context_ui.py index d9bb89ff..b309a754 100644 --- a/resources/lib/youtube_plugin/kodion/ui/xbmc/xbmc_context_ui.py +++ b/resources/lib/youtube_plugin/kodion/ui/xbmc/xbmc_context_ui.py @@ -261,7 +261,7 @@ class XbmcContextUI(AbstractContextUI): except (TypeError, ValueError): return - xbmc.executebuiltin('SetFocus({0},{1},absolute)'.format( + xbmc.executebuiltin('SetFocus("{0}","{1}",absolute)'.format( container_id, position + offset, )) diff --git a/resources/lib/youtube_plugin/youtube/provider.py b/resources/lib/youtube_plugin/youtube/provider.py index 3fbf9abe..107e3d36 100644 --- a/resources/lib/youtube_plugin/youtube/provider.py +++ b/resources/lib/youtube_plugin/youtube/provider.py @@ -711,7 +711,7 @@ class Provider(AbstractProvider): and identifier.lower() == 'property' and li_channel_id and li_channel_id.lower().startswith(('mine', 'uc'))): - context.execute('ActivateWindow(Videos, {channel}, return)'.format( + context.execute('ActivateWindow(Videos,"{channel}",return)'.format( channel=create_uri( (PATHS.CHANNEL, li_channel_id,), ) From f8f28580a4a4fb5145b79e8f6c49445816359823 Mon Sep 17 00:00:00 2001 From: MoojMidge <56883549+MoojMidge@users.noreply.github.com> Date: Tue, 10 Mar 2026 00:13:00 +0900 Subject: [PATCH 10/18] Fallback to playing channel uploads if no live stream is available --- resources/lib/youtube_plugin/youtube/helper/yt_play.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/resources/lib/youtube_plugin/youtube/helper/yt_play.py b/resources/lib/youtube_plugin/youtube/helper/yt_play.py index c801a9ee..78fd0f8c 100644 --- a/resources/lib/youtube_plugin/youtube/helper/yt_play.py +++ b/resources/lib/youtube_plugin/youtube/helper/yt_play.py @@ -201,6 +201,10 @@ def _play_playlist(provider, context): playlist_id = params.get(PLAYLIST_ID) if playlist_id: playlist_ids = [playlist_id] + else: + channel_id = params.get(CHANNEL_ID) + if channel_id and channel_id.startswith('UC'): + playlist_ids = [channel_id.replace('UC', 'UU', 1)] video_ids = params.get(VIDEO_IDS) if not playlist_ids and not video_ids: @@ -284,6 +288,9 @@ def _play_channel_live(provider, context): if not json_data: return False + if not json_data.get('items'): + return _play_playlist(provider, context) + channel_streams = v3.response_to_items(provider, context, json_data, From bfb04c06fdfd813a3901b301edc1bdca29f5c9aa Mon Sep 17 00:00:00 2001 From: MoojMidge <56883549+MoojMidge@users.noreply.github.com> Date: Tue, 7 Apr 2026 11:05:49 +0900 Subject: [PATCH 11/18] Open confirmation dialog, with a link for further information, when sign in process fails --- resources/language/resource.language.en_gb/strings.po | 2 +- .../youtube_plugin/kodion/context/xbmc/xbmc_context.py | 1 + .../lib/youtube_plugin/youtube/helper/yt_login.py | 10 ++++++---- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/resources/language/resource.language.en_gb/strings.po b/resources/language/resource.language.en_gb/strings.po index f1b71433..a1f8f884 100644 --- a/resources/language/resource.language.en_gb/strings.po +++ b/resources/language/resource.language.en_gb/strings.po @@ -498,7 +498,7 @@ msgid "You may be prompted to sign in and enable access to multiple applications msgstr "" msgctxt "#30548" -msgid "" +msgid "Authorisation process failed. Check the log for more information about this message.[CR][CR]Refer to the wiki for detailed setup instructions: [B]https://ytaddon.page.link/keys[/B]" msgstr "" msgctxt "#30549" diff --git a/resources/lib/youtube_plugin/kodion/context/xbmc/xbmc_context.py b/resources/lib/youtube_plugin/kodion/context/xbmc/xbmc_context.py index e1437060..3fba9c44 100644 --- a/resources/lib/youtube_plugin/kodion/context/xbmc/xbmc_context.py +++ b/resources/lib/youtube_plugin/kodion/context/xbmc/xbmc_context.py @@ -318,6 +318,7 @@ class XbmcContext(AbstractContext): 'sign.out': 30112, 'sign.multi.text': 30547, 'sign.multi.title': 30546, + 'sign.multi.failed': 30548, 'start': 335, 'stats.commentCount': 30732, # 'stats.favoriteCount': 1036, diff --git a/resources/lib/youtube_plugin/youtube/helper/yt_login.py b/resources/lib/youtube_plugin/youtube/helper/yt_login.py index e2139a1f..7dc8121f 100644 --- a/resources/lib/youtube_plugin/youtube/helper/yt_login.py +++ b/resources/lib/youtube_plugin/youtube/helper/yt_login.py @@ -18,9 +18,10 @@ SIGN_IN = 'in' SIGN_OUT = 'out' -def _do_logout(provider, context, client=None, **kwargs): - ui = context.get_ui() - if not context.get_param('confirmed') and not ui.on_yes_no_input( +def _do_logout(provider, context, client=None, confirmed=None, **kwargs): + if confirmed is None: + confirmed = context.pop_param('confirmed', False) + if not confirmed and not context.get_ui().on_yes_no_input( context.localize('sign.out'), context.localize('are_you_sure') ): @@ -158,7 +159,8 @@ def _do_login(provider, context, client=None, **kwargs): context.sleep(interval) except LoginException: - _do_logout(provider, context, client=client) + ui.on_ok(context.get_name(), localize('sign.multi.failed')) + _do_logout(provider, context, client=client, confirmed=True) break finally: new_access_tokens[token_type] = new_token[0] From b69621765d5a0697fc3fba305bef64b91737f2ed Mon Sep 17 00:00:00 2001 From: MoojMidge <56883549+MoojMidge@users.noreply.github.com> Date: Tue, 7 Apr 2026 12:56:09 +0900 Subject: [PATCH 12/18] Allow script actions to restart http server as part of settings changes --- .../lib/youtube_plugin/kodion/monitors/service_monitor.py | 6 ++++-- resources/lib/youtube_plugin/kodion/script_actions.py | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/resources/lib/youtube_plugin/kodion/monitors/service_monitor.py b/resources/lib/youtube_plugin/kodion/monitors/service_monitor.py index e74556ec..6cb6265c 100644 --- a/resources/lib/youtube_plugin/kodion/monitors/service_monitor.py +++ b/resources/lib/youtube_plugin/kodion/monitors/service_monitor.py @@ -180,6 +180,8 @@ class ServiceMonitor(xbmc.Monitor): elif target == SERVER_WAKEUP: if self.httpd_required(): response = self.start_httpd() + elif data.get('force'): + response = self.restart_httpd() else: response = bool(self.httpd) if self.httpd_sleep_allowed: @@ -468,7 +470,7 @@ class ServiceMonitor(xbmc.Monitor): ip=self._httpd_address, port=self._httpd_port) self.shutdown_httpd(terminate=True) - self.start_httpd() + return self.start_httpd() def ping_httpd(self): return self.httpd and httpd_status(self._context) @@ -481,7 +483,7 @@ class ServiceMonitor(xbmc.Monitor): self._use_httpd = required elif self._httpd_error: - required = False + required = None elif on_idle: settings = self._context.get_settings() diff --git a/resources/lib/youtube_plugin/kodion/script_actions.py b/resources/lib/youtube_plugin/kodion/script_actions.py index a8fe6c68..6b3f943c 100644 --- a/resources/lib/youtube_plugin/kodion/script_actions.py +++ b/resources/lib/youtube_plugin/kodion/script_actions.py @@ -114,7 +114,7 @@ def _config_actions(context, action, *_args): settings.httpd_listen(addresses[selected_address]) elif action == 'show_client_ip': - context.ipc_exec(SERVER_WAKEUP, timeout=5) + context.ipc_exec(SERVER_WAKEUP, timeout=5, payload={'force': True}) if httpd_status(context): client_ip = get_client_ip_address(context) if client_ip: From 9d1ae49c9ca9e0959c974d72961ee9ab3093c7a7 Mon Sep 17 00:00:00 2001 From: MoojMidge <56883549+MoojMidge@users.noreply.github.com> Date: Tue, 7 Apr 2026 13:06:05 +0900 Subject: [PATCH 13/18] Allow http_server.httpd_status() to return a valid server url if server can be pinged - Added new path and query parameters to construct new server url - Avoids unnecessary calls http_server.get_connect_address() to obtain netloc if httpd status is already being checked --- .../kodion/network/http_server.py | 27 ++++++++++++------- .../youtube_plugin/kodion/script_actions.py | 6 +++-- 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/resources/lib/youtube_plugin/kodion/network/http_server.py b/resources/lib/youtube_plugin/kodion/network/http_server.py index ee91755b..b3d60165 100644 --- a/resources/lib/youtube_plugin/kodion/network/http_server.py +++ b/resources/lib/youtube_plugin/kodion/network/http_server.py @@ -1049,7 +1049,7 @@ def get_http_server(address, port, context): return None -def httpd_status(context, address=None): +def httpd_status(context, address=None, path=None, query=None): netloc = get_connect_address(context, as_netloc=True, address=address) url = urlunsplit(( 'http', @@ -1066,6 +1066,14 @@ def httpd_status(context, address=None): with response: result = response.status_code if result == 204: + if path: + return urlunsplit(( + 'http', + netloc, + path, + query or '', + '', + )) return True logging.debug(('Ping', @@ -1076,14 +1084,15 @@ def httpd_status(context, address=None): return False -def get_client_ip_address(context): - url = urlunsplit(( - 'http', - get_connect_address(context, as_netloc=True), - PATHS.IP, - '', - '', - )) +def get_client_ip_address(context, url=None): + if url is None: + url = urlunsplit(( + 'http', + get_connect_address(context, as_netloc=True), + PATHS.IP, + '', + '', + )) if not RequestHandler.requests: RequestHandler.requests = BaseRequestsClass(context=context) response = RequestHandler.requests.request(url, cache=False) diff --git a/resources/lib/youtube_plugin/kodion/script_actions.py b/resources/lib/youtube_plugin/kodion/script_actions.py index 6b3f943c..6fbe1a10 100644 --- a/resources/lib/youtube_plugin/kodion/script_actions.py +++ b/resources/lib/youtube_plugin/kodion/script_actions.py @@ -17,6 +17,7 @@ from .constants import ( DATA_PATH, DEFAULT_LANGUAGES, DEFAULT_REGIONS, + PATHS, RELOAD_ACCESS_MANAGER, SERVER_WAKEUP, TEMP_PATH, @@ -115,8 +116,9 @@ def _config_actions(context, action, *_args): elif action == 'show_client_ip': context.ipc_exec(SERVER_WAKEUP, timeout=5, payload={'force': True}) - if httpd_status(context): - client_ip = get_client_ip_address(context) + url = httpd_status(context, path=PATHS.IP) + if url: + client_ip = get_client_ip_address(context, url) if client_ip: ui.on_ok(context.get_name(), context.localize('client.ip.is.x', client_ip)) From b54a30dfbb76a964c4ffd082df444188ad8429ad Mon Sep 17 00:00:00 2001 From: MoojMidge <56883549+MoojMidge@users.noreply.github.com> Date: Tue, 7 Apr 2026 20:57:41 +0900 Subject: [PATCH 14/18] Add button in API settings to view address of API config page --- .../language/resource.language.en_gb/strings.po | 2 +- .../lib/youtube_plugin/kodion/script_actions.py | 9 +++++++++ resources/settings.xml | 15 ++++++++++++++- 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/resources/language/resource.language.en_gb/strings.po b/resources/language/resource.language.en_gb/strings.po index a1f8f884..8c6a41cd 100644 --- a/resources/language/resource.language.en_gb/strings.po +++ b/resources/language/resource.language.en_gb/strings.po @@ -826,7 +826,7 @@ msgid "IP whitelist (comma delimited)" msgstr "" msgctxt "#30630" -msgid "" +msgid "Check API configuration page address" msgstr "" msgctxt "#30631" diff --git a/resources/lib/youtube_plugin/kodion/script_actions.py b/resources/lib/youtube_plugin/kodion/script_actions.py index 6fbe1a10..9f37861a 100644 --- a/resources/lib/youtube_plugin/kodion/script_actions.py +++ b/resources/lib/youtube_plugin/kodion/script_actions.py @@ -127,6 +127,15 @@ def _config_actions(context, action, *_args): else: ui.show_notification(context.localize('httpd.not.running')) + elif action == 'show_api_config_page_address': + context.ipc_exec(SERVER_WAKEUP, timeout=5, payload={'force': True}) + url = httpd_status(context, path=PATHS.API) + if url: + ui.on_ok(context.localize('api.config'), + context.localize('go.to.x', ui.bold(url))) + else: + ui.show_notification(context.localize('httpd.not.running')) + elif action == 'geo_location': locator = Locator(context) locator.locate_requester() diff --git a/resources/settings.xml b/resources/settings.xml index 651f8c14..8c0cb215 100644 --- a/resources/settings.xml +++ b/resources/settings.xml @@ -330,12 +330,25 @@ - + 0 false + + 0 + + true + + + + true + + + RunScript($ID,config/show_api_config_page_address) + + From 994c66798f492ae9477cd52f34d0a5487dc7bdad Mon Sep 17 00:00:00 2001 From: MoojMidge <56883549+MoojMidge@users.noreply.github.com> Date: Tue, 7 Apr 2026 10:02:06 +0900 Subject: [PATCH 15/18] Re-initialise existing client instance in preference to creating new instance when context changes - Make kodiai happy --- .../lib/youtube_plugin/youtube/provider.py | 24 ++++++++++--------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/resources/lib/youtube_plugin/youtube/provider.py b/resources/lib/youtube_plugin/youtube/provider.py index 107e3d36..f65cc605 100644 --- a/resources/lib/youtube_plugin/youtube/provider.py +++ b/resources/lib/youtube_plugin/youtube/provider.py @@ -145,7 +145,6 @@ class Provider(AbstractProvider): settings = context.get_settings() user = access_manager.get_current_user() - api_last_origin = access_manager.get_last_origin() client = self._client if not client or not client.initialised: @@ -222,16 +221,8 @@ class Provider(AbstractProvider): _, ) = access_manager.get_access_tokens(dev_id) - if client and not client.context_changed(context): - if api_last_origin != origin: - access_manager.set_last_origin(origin) - self.log.info(('API key origin changed - Resetting client', - 'Previous: {old!r}', - 'Current: {new!r}'), - old=api_last_origin, - new=origin) - client.initialised = False - else: + api_last_origin = access_manager.get_last_origin() + if not client: client = YouTubePlayerClient( context=context, language=settings.get_language(), @@ -242,6 +233,17 @@ class Provider(AbstractProvider): self._client = client if api_last_origin != origin: access_manager.set_last_origin(origin) + elif api_last_origin != origin: + access_manager.set_last_origin(origin) + self.log.info(('Resetting client - API key origin changed', + 'Previous: {old!r}', + 'Current: {new!r}'), + old=api_last_origin, + new=origin) + client.initialised = False + elif client.context_changed(context): + self.log.debug('Resetting client - Current context changed') + client.initialised = False if not client.initialised: self.reset_client( From cd9252f2c937dcd424f109494c16c30de69f6fef Mon Sep 17 00:00:00 2001 From: MoojMidge <56883549+MoojMidge@users.noreply.github.com> Date: Fri, 10 Apr 2026 19:13:44 +0900 Subject: [PATCH 16/18] Updates to resolve inconsistent Python2 string encoding/decoding - What a mess --- .../kodion/abstract_provider.py | 7 +- .../kodion/compatibility/__init__.py | 155 +++++++++++++----- .../kodion/context/xbmc/xbmc_context.py | 7 +- .../kodion/json_store/json_store.py | 2 +- .../lib/youtube_plugin/kodion/logging.py | 4 +- .../kodion/ui/xbmc/xbmc_context_ui.py | 16 +- .../kodion/utils/convert_format.py | 19 +-- .../lib/youtube_plugin/kodion/utils/redact.py | 4 +- .../youtube/helper/yt_setup_wizard.py | 3 +- .../lib/youtube_plugin/youtube/provider.py | 6 +- 10 files changed, 144 insertions(+), 79 deletions(-) diff --git a/resources/lib/youtube_plugin/kodion/abstract_provider.py b/resources/lib/youtube_plugin/kodion/abstract_provider.py index d026ef4b..a801bbbd 100644 --- a/resources/lib/youtube_plugin/kodion/abstract_provider.py +++ b/resources/lib/youtube_plugin/kodion/abstract_provider.py @@ -38,7 +38,6 @@ from .items import ( SearchHistoryItem, UriItem, ) -from .utils.convert_format import to_unicode class AbstractProvider(object): @@ -414,7 +413,7 @@ class AbstractProvider(object): search_history = context.get_search_history() if not command or command == 'query': - query = to_unicode(params.get('q', '')) + query = params.get('q', '') if query: result, options = provider.on_search_run(context, query=query) if not options: @@ -430,7 +429,7 @@ class AbstractProvider(object): context.set_path(PATHS.SEARCH, command) if command == 'remove': - query = to_unicode(params.get('q', '')) + query = params.get('q', '') if not ui.on_yes_no_input( localize('content.remove'), localize('content.remove.check.x', query), @@ -444,7 +443,7 @@ class AbstractProvider(object): return True, {provider.FORCE_REFRESH: True} if command == 'rename': - query = to_unicode(params.get('q', '')) + query = params.get('q', '') result, new_query = ui.on_keyboard_input( localize('search.rename'), query ) diff --git a/resources/lib/youtube_plugin/kodion/compatibility/__init__.py b/resources/lib/youtube_plugin/kodion/compatibility/__init__.py index 2ad0a112..d8e02fc4 100644 --- a/resources/lib/youtube_plugin/kodion/compatibility/__init__.py +++ b/resources/lib/youtube_plugin/kodion/compatibility/__init__.py @@ -6,6 +6,8 @@ SPDX-License-Identifier: GPL-2.0-only See LICENSES/GPL-2.0-only for more information. """ +from __future__ import absolute_import, division, unicode_literals + __all__ = ( 'BaseHTTPRequestHandler', @@ -13,7 +15,6 @@ __all__ = ( 'TCPServer', 'ThreadingMixIn', 'available_cpu_count', - 'byte_string_type', 'datetime_infolabel', 'entity_escape', 'generate_hash', @@ -25,6 +26,7 @@ __all__ = ( 'range_type', 'string_type', 'to_str', + 'to_unicode', 'unescape', 'unquote', 'unquote_plus', @@ -72,11 +74,16 @@ try: range_type = (range, list) - byte_string_type = bytes string_type = str to_str = str + def to_unicode(text): + if isinstance(text, bytes): + text = text.decode('utf-8', errors='ignore') + return text + + def entity_escape(text, entities=str.maketrans({ '&': '&', @@ -133,8 +140,8 @@ except ImportError: urlencode as _urlencode, ) from urlparse import ( - parse_qs, - parse_qsl, + parse_qs as _parse_qs, + parse_qsl as _parse_qsl, urljoin, urlsplit, urlunsplit, @@ -150,38 +157,103 @@ except ImportError: ) - def quote(data, *args, **kwargs): - return _quote(to_str(data), *args, **kwargs) - - - def quote_plus(data, *args, **kwargs): - return _quote_plus(to_str(data), *args, **kwargs) - - - def unquote(data): - return _unquote(to_str(data)) - - - def unquote_plus(data): - return _unquote_plus(to_str(data)) - - - def urlencode(data, *args, **kwargs): - if isinstance(data, dict): - data = data.items() - kwargs = { - key: value - for key, value in kwargs.viewitems() - if key in {'query', 'doseq'} - } - return _urlencode({ - to_str(key): ( - [to_str(part) for part in value] + def parse_qs(*args, **kwargs): + num_args = len(args) + query_dict = _parse_qs( + to_str(args[0] if args else kwargs.get('qs')), + keep_blank_values=( + args[1] + if num_args >= 2 else + kwargs.get('keep_blank_values', 0) + ), + strict_parsing=( + args[2] + if num_args >= 3 else + kwargs.get('strict_parsing', 0) + ), + # max_num_fields=( + # args[3] + # if num_args >= 4 else + # kwargs.get('max_num_fields', 0) + # ), + ) + return { + to_unicode(key): ( + [to_unicode(part) for part in value] if isinstance(value, (list, tuple)) else - to_str(value) + to_unicode(value) ) - for key, value in data - }, *args, **kwargs) + for key, value in query_dict.viewitems() + } + + + def parse_qsl(*args, **kwargs): + num_args = len(args) + query_list = _parse_qsl( + to_str(args[0] if args else kwargs.get('qs')), + keep_blank_values=( + args[1] + if num_args >= 2 else + kwargs.get('keep_blank_values', 0) + ), + strict_parsing=( + args[2] + if num_args >= 3 else + kwargs.get('strict_parsing', 0) + ), + # max_num_fields=( + # args[3] + # if num_args >= 4 else + # kwargs.get('max_num_fields', 0) + # ), + ) + return [ + (to_unicode(key), to_unicode(value)) + for key, value in query_list + ] + + + def quote(*args, **kwargs): + return _quote( + to_str(args[0] if args else kwargs.get('string')), + safe=args[1] if len(args) >= 2 else kwargs.get('safe', '/'), + ) + + + def quote_plus(*args, **kwargs): + return _quote_plus( + to_str(args[0] if args else kwargs.get('string')), + safe=args[1] if len(args) >= 2 else kwargs.get('safe', '/'), + ) + + + def unquote(*args, **kwargs): + return _unquote( + to_str(args[0] if args else kwargs.get('string')) + ) + + + def unquote_plus(*args, **kwargs): + return _unquote_plus( + to_str(args[0] if args else kwargs.get('string')) + ) + + + def urlencode(*args, **kwargs): + query = args[0] if args else kwargs.get('query') + if isinstance(query, dict): + query = query.viewitems() + return _urlencode( + query={ + to_str(key): ( + [to_str(part) for part in value] + if isinstance(value, (list, tuple)) else + to_str(value) + ) + for key, value in query + }, + doseq=args[1] if len(args) >= 2 else kwargs.get('doseq', 0), + ) class StringIO(_StringIO): @@ -205,18 +277,23 @@ except ImportError: range_type = (xrange, list) - byte_string_type = (bytes, str) string_type = basestring - def to_str(value, _format='{0!s}'.format): + def to_str(value, _format=b'{0!s}'.format): if not isinstance(value, basestring): value = _format(value) - if isinstance(value, unicode): - value = value.encode('utf-8') + elif isinstance(value, unicode): + value = value.encode('utf-8', errors='ignore') return value + def to_unicode(text): + if isinstance(text, (bytes, str)): + text = text.decode('utf-8', errors='ignore') + return text + + def entity_escape(text, entities={ '&': '&', @@ -231,7 +308,7 @@ except ImportError: def generate_hash(*args, **kwargs): - return md5(''.join( + return md5(b''.join( map(to_str, args or kwargs.get('iter')) )).hexdigest() diff --git a/resources/lib/youtube_plugin/kodion/context/xbmc/xbmc_context.py b/resources/lib/youtube_plugin/kodion/context/xbmc/xbmc_context.py index 3fba9c44..dc695335 100644 --- a/resources/lib/youtube_plugin/kodion/context/xbmc/xbmc_context.py +++ b/resources/lib/youtube_plugin/kodion/context/xbmc/xbmc_context.py @@ -47,7 +47,6 @@ from ...json_store import APIKeyStore, AccessManager from ...player import XbmcPlaylistPlayer from ...settings import XbmcPluginSettings from ...ui import XbmcContextUI -from ...utils.convert_format import to_unicode from ...utils.file_system import make_dirs from ...utils.methods import ( get_kodi_setting_bool, @@ -470,7 +469,7 @@ class XbmcContext(AbstractContext): def init(self): num_args = len(sys.argv) if num_args: - uri = to_unicode(sys.argv[0]) + uri = sys.argv[0] if uri.startswith('plugin://'): self._plugin_handle = int(sys.argv[1]) else: @@ -494,7 +493,7 @@ class XbmcContext(AbstractContext): # after that try to get the params if num_args > 2: - _params = to_unicode(sys.argv[2][1:]) + _params = sys.argv[2][1:] if _params: self._param_string = _params params.update(dict(parse_qsl(_params, keep_blank_values=True))) @@ -727,7 +726,7 @@ class XbmcContext(AbstractContext): else: self.log.warning(msg, text_id=text_id) return default_text - result = to_unicode(result) + result = result if _args: if localize_args: diff --git a/resources/lib/youtube_plugin/kodion/json_store/json_store.py b/resources/lib/youtube_plugin/kodion/json_store/json_store.py index 194cb8e6..d0271184 100644 --- a/resources/lib/youtube_plugin/kodion/json_store/json_store.py +++ b/resources/lib/youtube_plugin/kodion/json_store/json_store.py @@ -15,8 +15,8 @@ import errno from io import open from .. import logging +from ..compatibility import to_unicode from ..constants import DATA_PATH, FILE_READ, FILE_WRITE -from ..utils.convert_format import to_unicode from ..utils.file_system import make_dirs from ..utils.methods import merge_dicts diff --git a/resources/lib/youtube_plugin/kodion/logging.py b/resources/lib/youtube_plugin/kodion/logging.py index 457b3be4..a90cfb10 100644 --- a/resources/lib/youtube_plugin/kodion/logging.py +++ b/resources/lib/youtube_plugin/kodion/logging.py @@ -18,9 +18,9 @@ from string import Formatter from sys import exc_info as sys_exc_info from traceback import extract_stack, format_list -from .compatibility import StringIO, string_type, to_str, xbmc +from .compatibility import StringIO, string_type, to_str, to_unicode, xbmc from .constants import ADDON_ID -from .utils.convert_format import to_unicode, urls_in_text +from .utils.convert_format import urls_in_text from .utils.redact import ( parse_and_redact_uri, redact_auth_header, diff --git a/resources/lib/youtube_plugin/kodion/ui/xbmc/xbmc_context_ui.py b/resources/lib/youtube_plugin/kodion/ui/xbmc/xbmc_context_ui.py index b309a754..264bcf89 100644 --- a/resources/lib/youtube_plugin/kodion/ui/xbmc/xbmc_context_ui.py +++ b/resources/lib/youtube_plugin/kodion/ui/xbmc/xbmc_context_ui.py @@ -39,7 +39,6 @@ from ...constants import ( UPDATING, URI, ) -from ...utils.convert_format import to_unicode class XbmcContextUI(AbstractContextUI): @@ -82,13 +81,9 @@ class XbmcContextUI(AbstractContextUI): def on_keyboard_input(title, default='', hidden=False): # Starting with Gotham (13.X > ...) dialog = xbmcgui.Dialog() - result = dialog.input(title, - to_unicode(default), - type=xbmcgui.INPUT_ALPHANUM) + result = dialog.input(title, default, type=xbmcgui.INPUT_ALPHANUM) if result: - text = to_unicode(result) - return True, text - + return True, result return False, '' @staticmethod @@ -97,7 +92,6 @@ class XbmcContextUI(AbstractContextUI): result = dialog.input(title, str(default), type=xbmcgui.INPUT_NUMERIC) if result: return True, int(result) - return False, None @staticmethod @@ -113,19 +107,19 @@ class XbmcContextUI(AbstractContextUI): def on_remove_content(self, name): return self.on_yes_no_input( self._context.localize('content.remove'), - self._context.localize('content.remove.check.x', to_unicode(name)), + self._context.localize('content.remove.check.x', name), ) def on_delete_content(self, name): return self.on_yes_no_input( self._context.localize('content.delete'), - self._context.localize('content.delete.check.x', to_unicode(name)), + self._context.localize('content.delete.check.x', name), ) def on_clear_content(self, name): return self.on_yes_no_input( self._context.localize('content.clear'), - self._context.localize('content.clear.check.x', to_unicode(name)), + self._context.localize('content.clear.check.x', name), ) @staticmethod diff --git a/resources/lib/youtube_plugin/kodion/utils/convert_format.py b/resources/lib/youtube_plugin/kodion/utils/convert_format.py index cbc41624..515cf5eb 100644 --- a/resources/lib/youtube_plugin/kodion/utils/convert_format.py +++ b/resources/lib/youtube_plugin/kodion/utils/convert_format.py @@ -14,7 +14,15 @@ from datetime import timedelta from math import floor, log from re import DOTALL, compile as re_compile -from ..compatibility import byte_string_type + +__all__ = ( + 'channel_filter_split', + 'custom_filter_split', + 'fix_subtitle_stream', + 'friendly_number', + 'strip_html_from_text', + 'urls_in_text', +) __RE_URL = re_compile(r'(https?://\S+)') @@ -26,15 +34,6 @@ def urls_in_text(text, process=None, count=0): return __RE_URL.findall(text) -def to_unicode(text): - if isinstance(text, byte_string_type): - try: - return text.decode('utf-8', 'ignore') - except UnicodeError: - pass - return text - - def strip_html_from_text(text, tag_re=re_compile('<[^<]+?>')): """ Removes html tags diff --git a/resources/lib/youtube_plugin/kodion/utils/redact.py b/resources/lib/youtube_plugin/kodion/utils/redact.py index ecd087ff..c5fbcaac 100644 --- a/resources/lib/youtube_plugin/kodion/utils/redact.py +++ b/resources/lib/youtube_plugin/kodion/utils/redact.py @@ -181,7 +181,9 @@ def parse_and_redact_uri(uri, redact_only=False): params = parse_qs(parts.query, keep_blank_values=True) headers = params.get('__headers', [None])[0] if headers: - params['__headers'] = [urlsafe_b64decode(headers).decode('utf-8')] + params['__headers'] = [ + urlsafe_b64decode(headers.encode('utf-8')).decode('utf-8') + ] log_params = redact_params(params) log_query = urlencode(log_params, doseq=True) else: diff --git a/resources/lib/youtube_plugin/youtube/helper/yt_setup_wizard.py b/resources/lib/youtube_plugin/youtube/helper/yt_setup_wizard.py index 2544cdf4..2d3e4c3c 100644 --- a/resources/lib/youtube_plugin/youtube/helper/yt_setup_wizard.py +++ b/resources/lib/youtube_plugin/youtube/helper/yt_setup_wizard.py @@ -12,11 +12,10 @@ from __future__ import absolute_import, division, unicode_literals import os -from ...kodion.compatibility import urlencode, xbmcvfs +from ...kodion.compatibility import to_unicode, urlencode, xbmcvfs from ...kodion.constants import ADDON_ID, DATA_PATH, WAIT_END_FLAG from ...kodion.network import get_listen_addresses, httpd_status from ...kodion.sql_store import PlaybackHistory, SearchHistory -from ...kodion.utils.convert_format import to_unicode from ...kodion.utils.datetime import since_epoch, strptime diff --git a/resources/lib/youtube_plugin/youtube/provider.py b/resources/lib/youtube_plugin/youtube/provider.py index f65cc605..5edbbb63 100644 --- a/resources/lib/youtube_plugin/youtube/provider.py +++ b/resources/lib/youtube_plugin/youtube/provider.py @@ -64,7 +64,6 @@ from ..kodion.items import ( from ..kodion.utils.convert_format import ( channel_filter_split, strip_html_from_text, - to_unicode, ) from ..kodion.utils.datetime import now, since_epoch @@ -992,7 +991,7 @@ class Provider(AbstractProvider): def on_search_run(self, context, query=None): params = context.get_params() if query is None: - query = to_unicode(params.get('q', '')) + query = params.get('q', '') # Search by url to access unlisted videos if query.startswith(('https://', 'http://')): @@ -1336,7 +1335,6 @@ class Provider(AbstractProvider): if command == 'remove': video_name = params.get('item_name') or video_id - video_name = to_unicode(video_name) if not ui.on_yes_no_input( localize('content.remove'), localize('content.remove.check.x', video_name), @@ -2082,7 +2080,6 @@ class Provider(AbstractProvider): if command == 'remove': bookmark_name = params.get('item_name') or localize('bookmark') - bookmark_name = to_unicode(bookmark_name) if not ui.on_yes_no_input( localize('content.remove'), localize('content.remove.check.x', bookmark_name), @@ -2182,7 +2179,6 @@ class Provider(AbstractProvider): if command == 'remove': video_name = params.get('item_name') or localize('untitled') - video_name = to_unicode(video_name) if not ui.on_yes_no_input( localize('content.remove'), localize('content.remove.check.x', video_name), From 3edccdaf6b0cf1eac1acbf89e1580526467b0ecc Mon Sep 17 00:00:00 2001 From: MoojMidge <56883549+MoojMidge@users.noreply.github.com> Date: Tue, 7 Apr 2026 23:06:33 +0900 Subject: [PATCH 17/18] Version bump v7.4.3+beta.1 --- addon.xml | 2 +- changelog.txt | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/addon.xml b/addon.xml index 76e5ff1d..6e21ffb9 100644 --- a/addon.xml +++ b/addon.xml @@ -1,5 +1,5 @@ - + diff --git a/changelog.txt b/changelog.txt index 6355f723..f5679c73 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,3 +1,21 @@ +## v7.4.3+beta.1 +### Fixed +- Updates to resolve inconsistent Python2 string encoding/decoding +- Ensure parameters to Kodi builtin functions are parsed as string literals +- Allow trailing slashes in url of html pages served by internal http server + +### Changed +- Open confirmation dialog, with a link for further information, when sign in process fails +- Update API config page html +- Improve handling of API key settings changes +- Detect and notify when API requests cannot be completed +- Use consistent language for signing in/out + +### New +- Add button in API settings to view address of API config page +- Fallback to playing channel uploads if no live stream is available +- Add fallback method to load playlists when v3 API request cannot be made + ## v7.4.2 ### Fixed - Ensure updated context is used in client instance when rerouting to existing provider handler methods #1418 From 9adcc656e7c753b0d159ca9dc49154194cdd98a4 Mon Sep 17 00:00:00 2001 From: MoojMidge <56883549+MoojMidge@users.noreply.github.com> Date: Wed, 3 Sep 2025 12:58:31 +0900 Subject: [PATCH 18/18] Add ViewManager - Updated to be more self-contained and work better with unsupported skins - TODO: Add support for setting default sort order and sort direction Fix content type not being set to episodes - Fix #586, #589 Update for restructure of xbmc_plugin - Only set view mode if directory items successfully added Fix preselect on view_manager view lists Update to match new setup wizard Update for reorganised/renamed constants fca610c Update for updated XbmcContext.apply_content 8a8247a Update for new localize and logging methods Update to fix setting view mode not working if container is still updating Update to handle sort method and order and workaround #1243 Update to handle localised sort order #1309 --- .../kodion/abstract_provider.py | 1 + .../kodion/constants/__init__.py | 4 + .../kodion/constants/const_content_types.py | 4 +- .../kodion/constants/const_sort_methods.py | 21 ++ .../kodion/context/abstract_context.py | 4 + .../kodion/context/xbmc/xbmc_context.py | 1 + .../kodion/plugin/xbmc/xbmc_plugin.py | 26 ++ .../youtube_plugin/kodion/plugin_runner.py | 18 +- .../youtube_plugin/kodion/script_actions.py | 2 +- .../youtube_plugin/kodion/service_runner.py | 2 +- .../kodion/ui/abstract_context_ui.py | 3 + .../kodion/ui/xbmc/view_manager.py | 342 ++++++++++++++++++ .../kodion/ui/xbmc/xbmc_context_ui.py | 8 + resources/settings.xml | 29 ++ 14 files changed, 460 insertions(+), 5 deletions(-) create mode 100644 resources/lib/youtube_plugin/kodion/ui/xbmc/view_manager.py diff --git a/resources/lib/youtube_plugin/kodion/abstract_provider.py b/resources/lib/youtube_plugin/kodion/abstract_provider.py index a801bbbd..ea6fbe30 100644 --- a/resources/lib/youtube_plugin/kodion/abstract_provider.py +++ b/resources/lib/youtube_plugin/kodion/abstract_provider.py @@ -144,6 +144,7 @@ class AbstractProvider(object): if last_run and last_run > 1: self.pre_run_wizard_step(provider=self, context=context) wizard_steps = self.get_wizard_steps() + wizard_steps.extend(ui.get_view_manager().get_wizard_steps()) step = 0 steps = len(wizard_steps) diff --git a/resources/lib/youtube_plugin/kodion/constants/__init__.py b/resources/lib/youtube_plugin/kodion/constants/__init__.py index 7acb8698..84f02c24 100644 --- a/resources/lib/youtube_plugin/kodion/constants/__init__.py +++ b/resources/lib/youtube_plugin/kodion/constants/__init__.py @@ -188,6 +188,8 @@ PAGE = 'page' PLAYLIST_IDS = 'playlist_ids' SCREENSAVER = 'screensaver' SEEK = 'seek' +SORT_DIR = 'sort_dir' +SORT_METHOD = 'sort_method' START = 'start' VIDEO_IDS = 'video_ids' @@ -350,6 +352,8 @@ __all__ = ( 'PLAYLIST_IDS', 'SCREENSAVER', 'SEEK', + 'SORT_DIR', + 'SORT_METHOD', 'START', 'VIDEO_IDS', diff --git a/resources/lib/youtube_plugin/kodion/constants/const_content_types.py b/resources/lib/youtube_plugin/kodion/constants/const_content_types.py index d0d9fc33..0e5d5902 100644 --- a/resources/lib/youtube_plugin/kodion/constants/const_content_types.py +++ b/resources/lib/youtube_plugin/kodion/constants/const_content_types.py @@ -11,8 +11,8 @@ from __future__ import absolute_import, division, unicode_literals -VIDEO_CONTENT = 'videos' -LIST_CONTENT = 'files' +VIDEO_CONTENT = 'episodes' +LIST_CONTENT = 'default' COMMENTS = 'comments' HISTORY = 'history' diff --git a/resources/lib/youtube_plugin/kodion/constants/const_sort_methods.py b/resources/lib/youtube_plugin/kodion/constants/const_sort_methods.py index 11b2a1ca..d197ee32 100644 --- a/resources/lib/youtube_plugin/kodion/constants/const_sort_methods.py +++ b/resources/lib/youtube_plugin/kodion/constants/const_sort_methods.py @@ -14,6 +14,7 @@ import sys from . import const_content_types as CONTENT from ..compatibility import ( + xbmc, xbmcplugin, ) @@ -77,12 +78,31 @@ methods = [ ('VIDEO_ORIGINAL_TITLE', 20376, 57), ('VIDEO_ORIGINAL_TITLE_IGNORE_THE', 20376, None), ] +SORT_ID_MAPPING = {} SORT = sys.modules[__name__] name = label_id = sort_by = sort_method = None for name, label_id, sort_by in methods: sort_method = getattr(xbmcplugin, 'SORT_METHOD_' + name, 0) setattr(SORT, name, sort_method) + if sort_by is not None: + SORT_ID_MAPPING.update(( + (name, sort_by), + (xbmc.getLocalizedString(label_id), sort_by), + (sort_method, sort_by if sort_method else 0), + )) + +SORT_ID_MAPPING.update(( + (CONTENT.VIDEO_CONTENT.join(('__', '__')), SORT.UNSORTED), + (CONTENT.LIST_CONTENT.join(('__', '__')), SORT.LABEL), + (CONTENT.COMMENTS.join(('__', '__')), SORT.CHANNEL), + (CONTENT.HISTORY.join(('__', '__')), SORT.LASTPLAYED), +)) + +SORT_DIR = { + xbmc.getLocalizedString(584): 'ascending', + xbmc.getLocalizedString(585): 'descending', +} # Label mask token details: # https://github.com/xbmc/xbmc/blob/master/xbmc/utils/LabelFormatter.cpp#L33-L105 @@ -179,6 +199,7 @@ COMMENTS_CONTENT_SIMPLE = ( del ( sys, CONTENT, + xbmc, xbmcplugin, methods, SORT, diff --git a/resources/lib/youtube_plugin/kodion/context/abstract_context.py b/resources/lib/youtube_plugin/kodion/context/abstract_context.py index 39a758c7..6ae9acb8 100644 --- a/resources/lib/youtube_plugin/kodion/context/abstract_context.py +++ b/resources/lib/youtube_plugin/kodion/context/abstract_context.py @@ -61,6 +61,8 @@ from ..constants import ( PLAY_USING, SCREENSAVER, SEEK, + SORT_DIR, + SORT_METHOD, START, SUBSCRIPTION_ID, VIDEO_ID, @@ -169,6 +171,8 @@ class AbstractContext(object): 'q', 'rating', 'reload_path', + SORT_DIR, + SORT_METHOD, 'search_type', SUBSCRIPTION_ID, 'uri', diff --git a/resources/lib/youtube_plugin/kodion/context/xbmc/xbmc_context.py b/resources/lib/youtube_plugin/kodion/context/xbmc/xbmc_context.py index dc695335..683988dc 100644 --- a/resources/lib/youtube_plugin/kodion/context/xbmc/xbmc_context.py +++ b/resources/lib/youtube_plugin/kodion/context/xbmc/xbmc_context.py @@ -757,6 +757,7 @@ class XbmcContext(AbstractContext): path=self.get_path()) if content_type != 'default': xbmcplugin.setContent(self._plugin_handle, content_type) + ui.get_view_manager().set_view_mode(content_type) if category_label is None: category_label = self.get_param('category_label') diff --git a/resources/lib/youtube_plugin/kodion/plugin/xbmc/xbmc_plugin.py b/resources/lib/youtube_plugin/kodion/plugin/xbmc/xbmc_plugin.py index 78eac75e..b9ed2239 100644 --- a/resources/lib/youtube_plugin/kodion/plugin/xbmc/xbmc_plugin.py +++ b/resources/lib/youtube_plugin/kodion/plugin/xbmc/xbmc_plugin.py @@ -33,6 +33,8 @@ from ...constants import ( REFRESH_CONTAINER, RELOAD_ACCESS_MANAGER, REROUTE_PATH, + SORT_DIR, + SORT_METHOD, SYNC_API_KEYS, SYNC_LISTITEM, TRAKT_PAUSE_FLAG, @@ -429,7 +431,31 @@ class XbmcPlugin(AbstractPlugin): container = ui.get_property(CONTAINER_ID) position = ui.get_property(CONTAINER_POSITION) + # set alternative view mode + view_manager = ui.get_view_manager() + if view_manager.is_override_view_enabled(): + post_run_actions.append(( + view_manager.apply_view_mode, + { + 'context': context, + }, + )) + if is_same_path: + sort_method = kwargs.get(SORT_METHOD) + sort_dir = kwargs.get(SORT_DIR) + if sort_method and sort_dir: + post_run_actions.append(( + view_manager.apply_sort_method, + { + 'context': context, + SORT_METHOD: sort_method, + SORT_DIR: sort_dir, + CONTAINER_POSITION: position if forced else None, + }, + )) + position = None + if (container and position and (forced or position == 'current') and (not played_video_id or route)): diff --git a/resources/lib/youtube_plugin/kodion/plugin_runner.py b/resources/lib/youtube_plugin/kodion/plugin_runner.py index 8899f4ba..7d779ed7 100644 --- a/resources/lib/youtube_plugin/kodion/plugin_runner.py +++ b/resources/lib/youtube_plugin/kodion/plugin_runner.py @@ -19,6 +19,8 @@ from .constants import ( FOLDER_URI, FORCE_PLAY_PARAMS, PATHS, + SORT_DIR, + SORT_METHOD, TRAKT_PAUSE_FLAG, ) from .context import XbmcContext @@ -107,11 +109,25 @@ def run(context=_context, refresh = context.refresh_requested(force=True, off=True, params=params) new_params['refresh'] = refresh if refresh else 0 + sort_method = ( + params.get(SORT_METHOD) + or ui.get_infolabel('Container.SortMethod') + ) + if sort_method: + new_kwargs[SORT_METHOD] = sort_method + + sort_dir = ( + params.get(SORT_DIR) + or ui.get_infolabel('Container.SortOrder') + ) + if sort_dir: + new_kwargs[SORT_DIR] = sort_dir + if new_params: context.set_params(**new_params) system_version = context.get_system_version() - log.info(('Running v{version}', + log.info(('Running v{version} (unofficial)', 'Kodi: v{kodi}', 'Python: v{python}', 'Handle: {handle}', diff --git a/resources/lib/youtube_plugin/kodion/script_actions.py b/resources/lib/youtube_plugin/kodion/script_actions.py index 9f37861a..a2b3330a 100644 --- a/resources/lib/youtube_plugin/kodion/script_actions.py +++ b/resources/lib/youtube_plugin/kodion/script_actions.py @@ -507,7 +507,7 @@ def run(argv): log.verbose_logging = False system_version = context.get_system_version() - log.info(('Running v{version}', + log.info(('Running v{version} (unofficial)', 'Kodi: v{kodi}', 'Python: v{python}', 'Category: {category!r}', diff --git a/resources/lib/youtube_plugin/kodion/service_runner.py b/resources/lib/youtube_plugin/kodion/service_runner.py index c4871e4b..01268f08 100644 --- a/resources/lib/youtube_plugin/kodion/service_runner.py +++ b/resources/lib/youtube_plugin/kodion/service_runner.py @@ -51,7 +51,7 @@ def run(): monitor=monitor) system_version = context.get_system_version() - logging.info(('Starting v{version}', + logging.info(('Starting v{version} (unofficial)', 'Kodi: v{kodi}', 'Python: v{python}'), version=context.get_version(), diff --git a/resources/lib/youtube_plugin/kodion/ui/abstract_context_ui.py b/resources/lib/youtube_plugin/kodion/ui/abstract_context_ui.py index 4e3bbb6f..419de464 100644 --- a/resources/lib/youtube_plugin/kodion/ui/abstract_context_ui.py +++ b/resources/lib/youtube_plugin/kodion/ui/abstract_context_ui.py @@ -22,6 +22,9 @@ class AbstractContextUI(object): message_template=None): raise NotImplementedError() + def get_view_manager(self): + raise NotImplementedError() + @staticmethod def on_keyboard_input(title, default='', hidden=False): raise NotImplementedError() diff --git a/resources/lib/youtube_plugin/kodion/ui/xbmc/view_manager.py b/resources/lib/youtube_plugin/kodion/ui/xbmc/view_manager.py new file mode 100644 index 00000000..b17ccba3 --- /dev/null +++ b/resources/lib/youtube_plugin/kodion/ui/xbmc/view_manager.py @@ -0,0 +1,342 @@ +# -*- coding: utf-8 -*- +""" + + Copyright (C) 2014-2016 bromix (plugin.video.youtube) + Copyright (C) 2016-2025 plugin.video.youtube + + SPDX-License-Identifier: GPL-2.0-only + See LICENSES/GPL-2.0-only for more information. +""" + +from __future__ import absolute_import, division, unicode_literals + +from ... import logging +from ...compatibility import xbmc +from ...constants import ( + CONTAINER_POSITION, + CONTENT, + SORT, + SORT_DIR, + SORT_METHOD, +) + + +class ViewManager(object): + log = logging.getLogger(__name__) + + SETTINGS = { + 'override': 'kodion.view.override', # (bool) + 'view_default': 'kodion.view.default', # (int) + 'view_type': 'kodion.view.{0}', # (int) + } + + SUPPORTED_TYPES_MAP = { + CONTENT.LIST_CONTENT: 'default', + CONTENT.VIDEO_CONTENT: 'episodes', + } + + STRING_MAP = { + 'prompt': 30777, + 'unsupported_skin': 10109, + 'supported_skin': 14240, + 'albums': 30035, + 'artists': 30034, + 'default': 30027, + 'episodes': 30028, + 'movies': 30029, + 'songs': 30033, + 'tvshows': 30032, + } + + SKIN_DATA = { + 'skin.confluence': { + 'default': ( + {'name': 'List', 'id': 50}, + {'name': 'Big List', 'id': 51}, + {'name': 'Thumbnail', 'id': 500} + ), + 'movies': ( + {'name': 'List', 'id': 50}, + {'name': 'Big List', 'id': 51}, + {'name': 'Thumbnail', 'id': 500}, + {'name': 'Media info', 'id': 504}, + {'name': 'Media info 2', 'id': 503} + ), + 'episodes': ( + {'name': 'List', 'id': 50}, + {'name': 'Big List', 'id': 51}, + {'name': 'Thumbnail', 'id': 500}, + {'name': 'Media info', 'id': 504}, + {'name': 'Media info 2', 'id': 503} + ), + 'tvshows': ( + {'name': 'List', 'id': 50}, + {'name': 'Big List', 'id': 51}, + {'name': 'Thumbnail', 'id': 500}, + {'name': 'Poster', 'id': 500}, + {'name': 'Wide', 'id': 505}, + {'name': 'Media info', 'id': 504}, + {'name': 'Media info 2', 'id': 503}, + {'name': 'Fanart', 'id': 508} + ), + 'musicvideos': ( + {'name': 'List', 'id': 50}, + {'name': 'Big List', 'id': 51}, + {'name': 'Thumbnail', 'id': 500}, + {'name': 'Media info', 'id': 504}, + {'name': 'Media info 2', 'id': 503} + ), + 'songs': ( + {'name': 'List', 'id': 50}, + {'name': 'Big List', 'id': 51}, + {'name': 'Thumbnail', 'id': 500}, + {'name': 'Media info', 'id': 506} + ), + 'albums': ( + {'name': 'List', 'id': 50}, + {'name': 'Big List', 'id': 51}, + {'name': 'Thumbnail', 'id': 500}, + {'name': 'Media info', 'id': 506} + ), + 'artists': ( + {'name': 'List', 'id': 50}, + {'name': 'Big List', 'id': 51}, + {'name': 'Thumbnail', 'id': 500}, + {'name': 'Media info', 'id': 506} + ) + }, + 'skin.aeon.nox.5': { + 'default': ( + {'name': 'List', 'id': 50}, + {'name': 'Episodes', 'id': 502}, + {'name': 'LowList', 'id': 501}, + {'name': 'BannerWall', 'id': 58}, + {'name': 'Shift', 'id': 57}, + {'name': 'Posters', 'id': 56}, + {'name': 'ShowCase', 'id': 53}, + {'name': 'Landscape', 'id': 52}, + {'name': 'InfoWall', 'id': 51} + ) + }, + 'skin.xperience1080+': { + 'default': ( + {'name': 'List', 'id': 50}, + {'name': 'Thumbnail', 'id': 500}, + ), + 'episodes': ( + {'name': 'List', 'id': 50}, + {'name': 'Info list', 'id': 52}, + {'name': 'Fanart', 'id': 502}, + {'name': 'Landscape', 'id': 54}, + {'name': 'Poster', 'id': 55}, + {'name': 'Thumbnail', 'id': 500}, + {'name': 'Banner', 'id': 60} + ), + }, + 'skin.xperience1080': { + 'default': ( + {'name': 'List', 'id': 50}, + {'name': 'Thumbnail', 'id': 500}, + ), + 'episodes': ( + {'name': 'List', 'id': 50}, + {'name': 'Info list', 'id': 52}, + {'name': 'Fanart', 'id': 502}, + {'name': 'Landscape', 'id': 54}, + {'name': 'Poster', 'id': 55}, + {'name': 'Thumbnail', 'id': 500}, + {'name': 'Banner', 'id': 60} + ), + }, + 'skin.estuary': { + 'default': ( + {'name': 'IconWall', 'id': 52}, + {'name': 'WideList', 'id': 55}, + ), + 'videos': ( + {'name': 'Shift', 'id': 53}, + {'name': 'InfoWall', 'id': 54}, + {'name': 'WideList', 'id': 55}, + {'name': 'Wall', 'id': 500}, + ), + 'episodes': ( + {'name': 'InfoWall', 'id': 54}, + {'name': 'Wall', 'id': 500}, + {'name': 'WideList', 'id': 55}, + ) + } + } + + def __init__(self, context): + self._context = context + self._view_mode = None + + def is_override_view_enabled(self): + return self._context.get_settings().get_bool(self.SETTINGS['override']) + + def get_wizard_steps(self): + return (self.run,) + + def run(self, context, step, steps, **_kwargs): + localize = context.localize + + skin_id = xbmc.getSkinDir() + if skin_id in self.SKIN_DATA: + status = localize(self.STRING_MAP['supported_skin']) + else: + status = localize(self.STRING_MAP['unsupported_skin']) + prompt_text = localize(self.STRING_MAP['prompt'], (skin_id, status)) + + step += 1 + if context.get_ui().on_yes_no_input( + '{youtube} - {setup_wizard} ({step}/{steps})'.format( + youtube=localize('youtube'), + setup_wizard=localize('setup_wizard'), + step=step, + steps=steps, + ), + localize('setup_wizard.prompt.x', prompt_text) + ): + for view_type in self.SUPPORTED_TYPES_MAP: + self.update_view_mode(skin_id, view_type) + return step + + def get_view_mode(self): + if self._view_mode is None: + self.set_view_mode() + return self._view_mode + + def set_view_mode(self, view_type='default'): + settings = self._context.get_settings() + default = settings.get_int(self.SETTINGS['view_default'], 50) + if view_type == 'default': + view_mode = default + else: + view_type = self.SUPPORTED_TYPES_MAP.get(view_type, 'default') + view_mode = settings.get_int( + self.SETTINGS['view_type'].format(view_type), default + ) + self._view_mode = view_mode + + def update_view_mode(self, skin_id, view_type='default'): + view_id = -1 + settings = self._context.get_settings() + ui = self._context.get_ui() + + content_type = self.SUPPORTED_TYPES_MAP[view_type] + + if content_type not in self.STRING_MAP: + self.log.warning('Unsupported content type: %r', content_type) + return False + title = self._context.localize(self.STRING_MAP[content_type]) + + view_setting = self.SETTINGS['view_type'].format(content_type) + current_value = settings.get_int(view_setting) + if current_value == -1: + self.log.warning('No setting for content type: %r', content_type) + return False + + skin_data = self.SKIN_DATA.get(skin_id, {}) + view_type_data = skin_data.get(view_type) or skin_data.get(content_type) + if view_type_data: + items = [] + preselect = -1 + for view_data in view_type_data: + view_id = view_data['id'] + items.append((view_data['name'], view_id)) + if view_id == current_value: + preselect = len(items) - 1 + view_id = ui.on_select(title, items, preselect=preselect) + else: + self.log.warning('Unsupported view: %r', view_type) + + if view_id == -1: + result, view_id = ui.on_numeric_input(title, current_value) + if not result: + return False + + if view_id > -1: + settings.set_int(view_setting, view_id) + settings.set_bool(self.SETTINGS['override'], True) + return True + + return False + + def apply_view_mode(self, context): + view_mode = self.get_view_mode() + if view_mode is None: + return + + self.log.debug('Applying view mode: %r', view_mode) + context.execute('Container.SetViewMode(%s)' % view_mode) + + @classmethod + def apply_sort_method(cls, context, **kwargs): + execute = context.execute + get_infobool = xbmc.getCondVisibility + + sort_method = ( + kwargs.get(SORT_METHOD) + or CONTENT.VIDEO_CONTENT.join(('__', '__')) + ) + sort_id = SORT.SORT_ID_MAPPING.get(sort_method) + if sort_id is None: + cls.log.warning('Unknown sort method: %r', sort_method) + return + + sort_dir = kwargs.get(SORT_DIR) + _sort_dir = SORT.SORT_DIR.get(sort_dir) + if _sort_dir is None: + cls.log.warning('Invalid sort direction: %r', sort_dir) + return + + position = kwargs.get(CONTAINER_POSITION) + if position is not None: + context.get_ui().focus_container(position=position) + + # Workaround for Container.SetSortMethod failing for some sort methods + num_attempts = 0 + while num_attempts < 4: + # Workaround for Container.SetSortMethod(0) being a noop + # https://github.com/xbmc/xbmc/blob/7e1a55cb861342cd9062745161d88aca08dcead1/xbmc/windows/GUIMediaWindow.cpp#L502 + if sort_id == 0: + # Sort by track number to reset sort order to default order + if not num_attempts % 2: + _sort_method = 'TRACKNUM' + _sort_id = SORT.SORT_ID_MAPPING.get(_sort_method) + sort_action = 'Container.SetSortMethod(%s)' % _sort_id + # Then switch to previous sort method which is default/unsorted + # as per the order set in XbmcContext.apply_content + else: + _sort_method = 'UNSORTED' + _sort_id = SORT.SORT_ID_MAPPING.get(_sort_method) + sort_action = 'Container.PreviousSortMethod' + else: + _sort_method = sort_method + _sort_id = sort_id + sort_action = 'Container.SetSortMethod(%s)' % _sort_id + + cls.log.debug('Applying sort method: {method!r} ({id})', + method=_sort_method, + id=_sort_id) + execute(sort_action) + context.sleep(0.1) + + if not get_infobool('Container.SortDirection(%s)' % _sort_dir): + cls.log.debug('Applying sort direction: %r', sort_dir) + # This builtin should be Container.SortDirection but has been + # broken since Kodi v16 + # https://github.com/xbmc/xbmc/commit/ac870b64b16dfd0fc2bd0496c14529cf6d563f41 + execute('Container.SetSortDirection') + context.sleep(0.1) + + num_attempts += 1 + + if get_infobool('Container.SortMethod(%s)' % sort_id): + break + else: + cls.log.warning('Unable to apply sorting:' + ' {sort_method!r} ({sort_id}) {sort_dir!r}', + sort_method=sort_method, + sort_id=sort_id, + sort_dir=sort_dir) diff --git a/resources/lib/youtube_plugin/kodion/ui/xbmc/xbmc_context_ui.py b/resources/lib/youtube_plugin/kodion/ui/xbmc/xbmc_context_ui.py index 264bcf89..820c7d2a 100644 --- a/resources/lib/youtube_plugin/kodion/ui/xbmc/xbmc_context_ui.py +++ b/resources/lib/youtube_plugin/kodion/ui/xbmc/xbmc_context_ui.py @@ -12,6 +12,7 @@ from __future__ import absolute_import, division, unicode_literals from weakref import proxy +from .view_manager import ViewManager from ..abstract_context_ui import AbstractContextUI from ... import logging from ...compatibility import string_type, xbmc, xbmcgui @@ -47,6 +48,7 @@ class XbmcContextUI(AbstractContextUI): def __init__(self, context): super(XbmcContextUI, self).__init__() self._context = context + self._view_manager = None def create_progress_dialog(self, heading, @@ -77,6 +79,12 @@ class XbmcContextUI(AbstractContextUI): ), ) + def get_view_manager(self): + if self._view_manager is None: + self._view_manager = ViewManager(self._context) + + return self._view_manager + @staticmethod def on_keyboard_input(title, default='', hidden=False): # Starting with Gotham (13.X > ...) diff --git a/resources/settings.xml b/resources/settings.xml index 8c0cb215..8092e193 100644 --- a/resources/settings.xml +++ b/resources/settings.xml @@ -927,6 +927,35 @@ + + 0 + false + + + + 0 + 55 + + + true + + + + 30027 + + + + 0 + 55 + + + true + + + + 30028 + +