diff --git a/addon.xml b/addon.xml index e0c364981..59d30aa24 100644 --- a/addon.xml +++ b/addon.xml @@ -1,5 +1,5 @@ - + diff --git a/changelog.txt b/changelog.txt index 8acf6e596..eda86c0ec 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,10 +1,66 @@ +## v7.1.0 +### Fixed +- Fix logging/retry of sqlite3.OperationalError +- Fix trying to use ISA for progressive live streams +- Retain list position when refreshing listings +- Add workarounds for trying to play videos using RunPlugin rather PlayMedia +- Fix possible regression causing 6s delay on first play +- Fix regression in building client details causing wrong referer to be used +- Only reroute to new window if container was not filled by the plugin #896 +- Only reroute to new window if modal dialog is not open #896 +- Fix various timing and sync issues with script, service, plugin and Kodi settings +- Fix playback history related context menu items not being shown #904 +- Fix new resume details not being saved in plugin local playback history #904 +- Fix default thumbnails not being updated if available +- Fix login to personal project only #910 + +### Changed +- Update multiple busy dialog crash workaround #891 +- Use all playable codecs but deprioritise if not selected in stream features +- Change default live stream type to suit ISA and Youtube stream availability +- Use a CommandItem that opens the Info dialog for comments to avoid log spam +- Revert old workaround for Kodi treating a non-folder listitem as playable +- Improve parsing of plugin url query parameters to allow empty values +- Move IP location lookup to script and add to settings dialog +- Move language and region selection to script and add to settings dialog +- Allow default thumbnail selection fallbacks for all results +- Simplify handling of local history + - Incognito will no longer prevent existing local history from being shown + - Incognito will continue to prevent any update to local history +- Remove revoked API credentials #905 +- Improve handling of "forbidden - The caller does not have permission" errors #905 +- Allow retries on POST requests #913 + +### New +- Allow ask for quality from context menu to override audio only setting +- Add items_per_page query parameter to allow number of items in widgets to be customised #896 +- Add item_filter query parameter to override "Hide videos from listings" setting #896 + - Comma seperated string of item types to be filtered out of listing + - "?item_filter=shorts" will remove shorts from listing + - "?item_filter=shorts,live" will remove shorts and live streams from listing + - "?item_filter" will show all item types + - Allowable item types: + - shorts + - upcoming + - upcoming_live + - live + - premieres + - completed + - vod +- Add hide_next_page query parameter to hide plugin Next page item #896 +- Add new input_prompt command for search endpoint to bypass Kodi window caching + - plugin://plugin.video.youtube/kodion/search/input_prompt +- Add support for proxy settings #884 +- Preliminary support for /watch_videos YouTube urls +- Player client updates + ## v7.1.0+beta.5 ### Fixed - Fix default thumbnails not being updated if available ### Changed -- Remove revoked API credentials -- Improve handling of "forbidden - The caller does not have permission" errors +- Remove revoked API credentials #905 +- Improve handling of "forbidden - The caller does not have permission" errors #905 ### New - Preliminary support for /watch_videos YouTube urls diff --git a/resources/language/resource.language.en_gb/strings.po b/resources/language/resource.language.en_gb/strings.po index 34a194297..dccf900e2 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 further links found." msgstr "" msgctxt "#30546" -msgid "Please log in twice!" +msgid "Please complete all login prompts" msgstr "" msgctxt "#30547" -msgid "You must enable two applications so that YouTube is functioning properly." +msgid "You may be prompted to enable two applications so that YouTube is functioning properly." msgstr "" msgctxt "#30548" 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 cb540d190..d370f1968 100644 --- a/resources/lib/youtube_plugin/kodion/context/xbmc/xbmc_context.py +++ b/resources/lib/youtube_plugin/kodion/context/xbmc/xbmc_context.py @@ -206,8 +206,8 @@ class XbmcContext(AbstractContext): 'sign.go_to': 30502, 'sign.in': 30111, 'sign.out': 30112, - 'sign.twice.text': 30547, - 'sign.twice.title': 30546, + 'sign.multi.text': 30547, + 'sign.multi.title': 30546, 'stats.commentCount': 30732, # 'stats.favoriteCount': 1036, 'stats.likeCount': 30733, diff --git a/resources/lib/youtube_plugin/kodion/json_store/access_manager.py b/resources/lib/youtube_plugin/kodion/json_store/access_manager.py index d02693884..9114bd281 100644 --- a/resources/lib/youtube_plugin/kodion/json_store/access_manager.py +++ b/resources/lib/youtube_plugin/kodion/json_store/access_manager.py @@ -456,7 +456,7 @@ def update_access_token(self, if unix_timestamp is not None: details['token_expires'] = ( - min(map(int, unix_timestamp)) + min(map(int, [val for val in unix_timestamp if val])) if isinstance(unix_timestamp, (list, tuple)) else int(unix_timestamp) ) diff --git a/resources/lib/youtube_plugin/kodion/network/requests.py b/resources/lib/youtube_plugin/kodion/network/requests.py index 803adeae0..886bbffb4 100644 --- a/resources/lib/youtube_plugin/kodion/network/requests.py +++ b/resources/lib/youtube_plugin/kodion/network/requests.py @@ -51,6 +51,7 @@ class BaseRequestsClass(object): total=3, backoff_factor=0.1, status_forcelist={500, 502, 503, 504}, + allowed_methods=None, ) )) atexit.register(_session.close) 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 431354e86..15dcd3aba 100644 --- a/resources/lib/youtube_plugin/kodion/plugin/xbmc/xbmc_plugin.py +++ b/resources/lib/youtube_plugin/kodion/plugin/xbmc/xbmc_plugin.py @@ -20,6 +20,7 @@ CONTAINER_FOCUS, CONTAINER_ID, CONTAINER_POSITION, + CONTENT_TYPE, PLAYLIST_PATH, PLAYLIST_POSITION, PLUGIN_SLEEPING, @@ -258,6 +259,7 @@ def run(self, provider, context, focused=None): cache_to_disc = options.get(provider.RESULT_CACHE_TO_DISC, True) update_listing = options.get(provider.RESULT_UPDATE_LISTING, False) else: + ui.clear_property(CONTENT_TYPE) succeeded = bool(result) cache_to_disc = False update_listing = True diff --git a/resources/lib/youtube_plugin/youtube/client/__config__.py b/resources/lib/youtube_plugin/youtube/client/__config__.py index c7b5f87b4..c28a496ae 100644 --- a/resources/lib/youtube_plugin/youtube/client/__config__.py +++ b/resources/lib/youtube_plugin/youtube/client/__config__.py @@ -140,7 +140,8 @@ def get_api_keys(self, switch): key = key.partition('_')[-1] if key and key in key_set: key_set[key] = value - if not key_set['id'].endswith('.apps.googleusercontent.com'): + if (key_set['id'] + and not key_set['id'].endswith('.apps.googleusercontent.com')): key_set['id'] += '.apps.googleusercontent.com' return key_set diff --git a/resources/lib/youtube_plugin/youtube/client/login_client.py b/resources/lib/youtube_plugin/youtube/client/login_client.py index 0293c65b4..0c63f17b3 100644 --- a/resources/lib/youtube_plugin/youtube/client/login_client.py +++ b/resources/lib/youtube_plugin/youtube/client/login_client.py @@ -80,11 +80,11 @@ def _error_hook(**kwargs): return None, None, None, json_data, False, InvalidGrant(json_data) return None, None, None, json_data, False, LoginException(json_data) - def set_access_token(self, access_token=''): - self._access_token = access_token - - def set_access_token_tv(self, access_token_tv=''): - self._access_token_tv = access_token_tv + def set_access_token(self, personal=None, tv=None): + if personal is not None: + self._access_token = personal + if tv is not None: + self._access_token_tv = tv def revoke(self, refresh_token): # https://developers.google.com/youtube/v3/guides/auth/devices @@ -107,8 +107,10 @@ def revoke(self, refresh_token): raise_exc=True) def refresh_token_tv(self, refresh_token): - client_id = self._config_tv.get('id', '') - client_secret = self._config_tv.get('secret', '') + client_id = self._config_tv.get('id') + client_secret = self._config_tv.get('secret') + if not client_id or not client_secret: + return '', 0 return self.refresh_token(refresh_token, client_id=client_id, client_secret=client_secret) @@ -151,13 +153,15 @@ def refresh_token(self, refresh_token, client_id='', client_secret=''): if json_data: access_token = json_data['access_token'] - expires_in = time.time() + int(json_data.get('expires_in', 3600)) - return access_token, expires_in - return '', '' + expiry = time.time() + int(json_data.get('expires_in', 3600)) + return access_token, expiry + return '', 0 def request_access_token_tv(self, code, client_id='', client_secret=''): - client_id = client_id or self._config_tv.get('id', '') - client_secret = client_secret or self._config_tv.get('secret', '') + client_id = client_id or self._config_tv.get('id') + client_secret = client_secret or self._config_tv.get('secret') + if not client_id or not client_secret: + return '', '' return self.request_access_token(code, client_id=client_id, client_secret=client_secret) @@ -200,7 +204,9 @@ def request_access_token(self, code, client_id='', client_secret=''): return json_data def request_device_and_user_code_tv(self): - client_id = self._config_tv.get('id', '') + client_id = self._config_tv.get('id') + if not client_id: + return None return self.request_device_and_user_code(client_id=client_id) def request_device_and_user_code(self, client_id=''): @@ -282,17 +288,24 @@ def authenticate(self, username, password): def _get_config_type(self, client_id, client_secret=None): """used for logging""" if client_secret is None: - using_conf_tv = client_id == self._config_tv.get('id', '') - using_conf_main = client_id == self._config.get('id', '') + config_id = self._config_tv.get('id') + using_conf_tv = config_id and client_id == config_id + config_id = self._config.get('id') + using_conf_main = config_id and client_id == config_id else: + config_secret = self._config_tv.get('secret') + config_id = self._config_tv.get('id') using_conf_tv = ( - client_secret == self._config_tv.get('secret', '') - and client_id == self._config_tv.get('id', '') + config_secret and client_secret == config_secret + and config_id and client_id == config_id ) + config_secret = self._config.get('secret') + config_id = self._config.get('id') using_conf_main = ( - client_secret == self._config.get('secret', '') - and client_id == self._config.get('id', '') + config_secret and client_secret == config_secret + and config_id and client_id == config_id ) + if not using_conf_main and not using_conf_tv: return 'None' if using_conf_tv: diff --git a/resources/lib/youtube_plugin/youtube/client/request_client.py b/resources/lib/youtube_plugin/youtube/client/request_client.py index 0e308d0e5..8709aabd1 100644 --- a/resources/lib/youtube_plugin/youtube/client/request_client.py +++ b/resources/lib/youtube_plugin/youtube/client/request_client.py @@ -154,11 +154,11 @@ class YouTubeRequestClient(BaseRequestsClass): 'deviceModel': 'Quest 3', 'osName': 'Android', 'osVersion': '12L', - 'androidSdkVersion': '32' + 'androidSdkVersion': '32', } } }, - 'header': { + 'headers': { 'User-Agent': ('com.google.android.apps.youtube.vr.oculus/' '{json[context][client][clientVersion]}' ' (Linux; U; {json[context][client][osName]}' diff --git a/resources/lib/youtube_plugin/youtube/client/youtube.py b/resources/lib/youtube_plugin/youtube/client/youtube.py index 9b4c7ae17..d7c2d2a5e 100644 --- a/resources/lib/youtube_plugin/youtube/client/youtube.py +++ b/resources/lib/youtube_plugin/youtube/client/youtube.py @@ -53,6 +53,7 @@ class YouTube(LoginClient): 'headers': { 'Host': 'www.googleapis.com', }, + 'auth_required': True, }, 'tv': { 'url': 'https://www.youtube.com/youtubei/v1/{_endpoint}', @@ -194,7 +195,8 @@ def get_streams(self, audio_only=False, use_mpd=True): return StreamInfo(context, - access_token=self._access_token_tv, + access_token=(self._access_token + or self._access_token_tv), ask_for_quality=ask_for_quality, audio_only=audio_only, use_mpd=use_mpd).load_stream_info(video_id) @@ -2032,22 +2034,31 @@ def api_request(self, if params: client_data['params'] = params + abort = False + if no_login: + pass # a config can decide if a token is allowed - if (not no_login and self._access_token - and self._config.get('token-allowed', True)): + elif self._access_token and self._config.get('token-allowed', True): client_data['_access_token'] = self._access_token + # abort if authentication is required but not available for request + elif self.CLIENTS.get(version, {}).get('auth_required'): + abort = True client = self.build_client(version, client_data) params = client.get('params') - if 'key' in params and not params['key']: - params = params.copy() - key = self._config.get('key') or self._config_tv.get('key') - if key: - params['key'] = key + if 'key' in params: + if params['key']: + abort = False else: - del params['key'] - client['params'] = params + params = params.copy() + key = self._config.get('key') or self._config_tv.get('key') + if key: + abort = False + params['key'] = key + else: + del params['key'] + client['params'] = params if clear_data and 'json' in client: del client['json'] @@ -2070,21 +2081,26 @@ def api_request(self, else: log_headers = None - self._context.log_debug('API request:\n' - 'version: |{version}|\n' - 'method: |{method}|\n' - 'path: |{path}|\n' - 'params: |{params}|\n' - 'post_data: |{data}|\n' - 'headers: |{headers}|' - .format(version=version, - method=method, - path=path, - params=log_params, - data=client.get('json'), - headers=log_headers)) - response = self.request(response_hook=self._response_hook, - response_hook_kwargs=kwargs, - error_hook=self._error_hook, - **client) - return response + context = self._context + context.log_debug('API request:\n' + 'version: |{version}|\n' + 'method: |{method}|\n' + 'path: |{path}|\n' + 'params: |{params}|\n' + 'post_data: |{data}|\n' + 'headers: |{headers}|' + .format(version=version, + method=method, + path=path, + params=log_params, + data=client.get('json'), + headers=log_headers)) + if abort: + if kwargs.get('notify', True): + context.get_ui().on_ok(context.get_name(), context.localize('key.requirement')) + context.log_warning('API request: aborted') + return {} + return self.request(response_hook=self._response_hook, + response_hook_kwargs=kwargs, + error_hook=self._error_hook, + **client) diff --git a/resources/lib/youtube_plugin/youtube/helper/stream_info.py b/resources/lib/youtube_plugin/youtube/helper/stream_info.py index 145abdddc..d07bc20d5 100644 --- a/resources/lib/youtube_plugin/youtube/helper/stream_info.py +++ b/resources/lib/youtube_plugin/youtube/helper/stream_info.py @@ -1408,7 +1408,7 @@ def load_stream_info(self, video_id): 'auth': bool(_client.get('_access_token')), }, **_client - ) + ) or {} video_details = _result.get('videoDetails', {}) playability = _result.get('playabilityStatus', {}) diff --git a/resources/lib/youtube_plugin/youtube/helper/url_to_item_converter.py b/resources/lib/youtube_plugin/youtube/helper/url_to_item_converter.py index c74c509ba..11acef66a 100644 --- a/resources/lib/youtube_plugin/youtube/helper/url_to_item_converter.py +++ b/resources/lib/youtube_plugin/youtube/helper/url_to_item_converter.py @@ -83,24 +83,26 @@ def add_url(self, url, context): )) return + item = None + if 'video_ids' in new_params: for video_id in new_params['video_ids'].split(','): - video_item = VideoItem( + item = VideoItem( name='', uri=context.create_uri( (PATHS.PLAY,), dict(new_params, video_id=video_id), ) ) - self._video_id_dict[video_id] = video_item + self._video_id_dict[video_id] = item elif 'video_id' in new_params: video_id = new_params['video_id'] - video_item = VideoItem( + item = VideoItem( '', context.create_uri((PATHS.PLAY,), new_params) ) - self._video_id_dict[video_id] = video_item + self._video_id_dict[video_id] = item if 'playlist_id' in new_params: playlist_id = new_params['playlist_id'] @@ -109,10 +111,10 @@ def add_url(self, url, context): self._playlist_ids.append(playlist_id) return - playlist_item = DirectoryItem( + item = DirectoryItem( '', context.create_uri(('playlist', playlist_id,), new_params), ) - self._playlist_id_dict[playlist_id] = playlist_item + self._playlist_id_dict[playlist_id] = item if 'channel_id' in new_params: channel_id = new_params['channel_id'] @@ -122,14 +124,14 @@ def add_url(self, url, context): self._channel_ids.append(channel_id) return - channel_item = VideoItem( + item = VideoItem( '', context.create_uri((PATHS.PLAY,), new_params) ) if live else DirectoryItem( '', context.create_uri(('channel', channel_id,), new_params) ) - self._channel_id_dict[channel_id] = channel_item + self._channel_id_dict[channel_id] = item - else: + if not item: context.log_debug('No items found in url "{0}"'.format(url)) def add_urls(self, urls, context): diff --git a/resources/lib/youtube_plugin/youtube/helper/yt_login.py b/resources/lib/youtube_plugin/youtube/helper/yt_login.py index d951d869d..44c00beae 100644 --- a/resources/lib/youtube_plugin/youtube/helper/yt_login.py +++ b/resources/lib/youtube_plugin/youtube/helper/yt_login.py @@ -27,7 +27,8 @@ def _do_logout(): if refresh_tokens: for _refresh_token in set(refresh_tokens): try: - client.revoke(_refresh_token) + if _refresh_token: + client.revoke(_refresh_token) except LoginException: pass access_manager.update_access_token( @@ -44,6 +45,8 @@ def _do_login(login_type): json_data = _client.request_device_and_user_code_tv() else: json_data = _client.request_device_and_user_code() + if not json_data: + return '', 0, '' except LoginException: _do_logout() raise @@ -76,6 +79,8 @@ def _do_login(login_type): json_data = _client.request_access_token_tv(device_code) else: json_data = _client.request_access_token(device_code) + if not json_data: + break except LoginException: _do_logout() raise @@ -93,11 +98,11 @@ def _do_login(login_type): _access_token = json_data.get('access_token', '') _refresh_token = json_data.get('refresh_token', '') if not _access_token and not _refresh_token: - _expires_in = 0 + _expiry = 0 else: - _expires_in = (int(json_data.get('expires_in', 3600)) - + time.time()) - return _access_token, _expires_in, _refresh_token + _expiry = (int(json_data.get('expires_in', 3600)) + + time.time()) + return _access_token, _expiry, _refresh_token if json_data['error'] != 'authorization_pending': message = json_data['error'] @@ -118,15 +123,15 @@ def _do_login(login_type): ui.refresh_container() elif mode == 'in': - ui.on_ok(localize('sign.twice.title'), localize('sign.twice.text')) + ui.on_ok(localize('sign.multi.title'), localize('sign.multi.text')) - tokens = { - 'tv': None, - 'kodi': None, - } - for token in tokens: + token_types = ('tv', 'personal') + tokens = [None, None] + for idx, token in enumerate(token_types): new_token = _do_login(login_type=token) - access_token, expires_in, refresh_token = new_token + tokens[idx] = new_token + access_token, expiry, refresh_token = new_token + context.log_debug('YouTube Login:' ' Type |{0}|,' ' Access Token |{1}|,' @@ -135,15 +140,8 @@ def _do_login(login_type): .format(token, access_token != '', refresh_token != '', - expires_in)) - # abort login - if not access_token and not refresh_token: - provider.reset_client() - access_manager.update_access_token(addon_id, '') - ui.refresh_container() - return - tokens[token] = new_token + expiry)) provider.reset_client() - access_manager.update_access_token(addon_id, *zip(*tokens.values())) + access_manager.update_access_token(addon_id, *zip(*tokens)) ui.refresh_container() diff --git a/resources/lib/youtube_plugin/youtube/provider.py b/resources/lib/youtube_plugin/youtube/provider.py index d4846bf9e..31dff371a 100644 --- a/resources/lib/youtube_plugin/youtube/provider.py +++ b/resources/lib/youtube_plugin/youtube/provider.py @@ -277,34 +277,58 @@ def get_client(self, context): # create new access tokens elif len(access_tokens) != 2 and len(refresh_tokens) == 2: + access_tokens = ['', ''] + token_expiry = 0 + token_index = { + 0: { + 'type': 'tv', + 'refresh': client.refresh_token_tv, + }, + 1: { + 'type': 'personal', + 'refresh': client.refresh_token, + }, + } try: - kodi_token = client.refresh_token(refresh_tokens[1]) - tv_token = client.refresh_token_tv(refresh_tokens[0]) - access_tokens = (tv_token[0], kodi_token[0]) - expires_in = min(tv_token[1], kodi_token[1]) - access_manager.update_access_token( - dev_id, access_tokens, expires_in, - ) - except (InvalidGrant, LoginException) as exc: - self.handle_exception(context, exc) - # reset access_token - if isinstance(exc, InvalidGrant): + for idx, value in enumerate(refresh_tokens): + if not value: + continue + + token, expiry = token_index[idx]['refresh'](value) + if token and expiry > 0: + access_tokens[idx] = token + if not token_expiry or expiry < token_expiry: + token_expiry = expiry + + if any(access_tokens) and expiry: access_manager.update_access_token( - dev_id, access_token='', refresh_token='', + dev_id, access_tokens, token_expiry, ) else: - access_manager.update_access_token(dev_id) + raise InvalidGrant + + except (InvalidGrant, LoginException) as exc: + self.handle_exception(context, exc) + # reset access token + # reset refresh token if InvalidGrant otherwise leave as-is + # to retry later + access_manager.update_access_token( + dev_id, + refresh_token=('' if isinstance(exc, InvalidGrant) + else None), + ) # in debug log the login status - self._logged_in = len(access_tokens) == 2 + self._logged_in = any(access_tokens) if self._logged_in: context.log_debug('User is logged in') - client.set_access_token_tv(access_token_tv=access_tokens[0]) - client.set_access_token(access_token=access_tokens[1]) + client.set_access_token( + personal=access_tokens[1], + tv=access_tokens[0], + ) else: context.log_debug('User is not logged in') - client.set_access_token_tv(access_token_tv='') - client.set_access_token(access_token='') + client.set_access_token(personal='', tv='') self._client = client return self._client @@ -717,8 +741,13 @@ def on_users(re_match, **_kwargs): def on_sign(provider, context, re_match): sign_out_confirmed = context.get_param('confirmed') mode = re_match.group('mode') - if (mode == 'in') and context.get_access_manager().get_refresh_token(): - yt_login.process('out', provider, context, sign_out_refresh=False) + if mode == 'in': + refresh_tokens = context.get_access_manager().get_refresh_token() + if any(refresh_tokens): + yt_login.process('out', + provider, + context, + sign_out_refresh=False) if (not sign_out_confirmed and mode == 'out' and context.get_ui().on_yes_no_input( @@ -931,7 +960,8 @@ def on_maintenance_actions(provider, context, re_match): if refresh_tokens: for refresh_token in set(refresh_tokens): try: - client.revoke(refresh_token) + if refresh_token: + client.revoke(refresh_token) except LoginException: success = False provider.reset_client()