diff --git a/.github/workflows/make-release.yml b/.github/workflows/make-release.yml index 76e1c82ef..8e8d45bab 100644 --- a/.github/workflows/make-release.yml +++ b/.github/workflows/make-release.yml @@ -20,7 +20,7 @@ jobs: id: release run: | version=${GITHUB_REF/refs\/tags\//} - if [[ $version == *-dev ]] ; + if [[ $version == *[-+]@(alpha|beta|dev)*([.0-9a-z]) ]] ; then echo "pre-release=true" >> $GITHUB_OUTPUT else @@ -64,11 +64,14 @@ jobs: - name: Create Zip (Nexus-Unofficial) id: zip-unofficial-nexus + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | git reset git checkout . + git fetch origin nexus-unofficial + git cherry-pick ...origin/nexus-unofficial -n git clean -fdx - git apply .patches/unofficial.patch mv .git .. rm -rf .??* rm *.md @@ -104,11 +107,14 @@ jobs: - name: Create Zip (Matrix-Unofficial) id: zip-unofficial-matrix + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | git reset git checkout . + git fetch origin nexus-unofficial + git cherry-pick ...origin/nexus-unofficial -n git clean -fdx - git apply .patches/unofficial.patch mv .git .. rm -rf .??* rm *.md diff --git a/.github/workflows/submit-release.yml b/.github/workflows/submit-release.yml index 5057f6dd3..320950dc0 100644 --- a/.github/workflows/submit-release.yml +++ b/.github/workflows/submit-release.yml @@ -74,7 +74,7 @@ jobs: version=$(xmlstarlet sel -t -v 'string(/addon/@version)' addon.xml) xmlstarlet ed -L -u '/addon/@version' -v "${version}+matrix.1" addon.xml xmlstarlet ed -L -u '/addon/requires/import[@addon="xbmc.python"]/@version' -v '3.0.0' addon.xml - xmlstarlet ed -L -u '/addon/requires/import[@addon="inputstream.adaptive"]/@version' -v '19.0.7' addon.xml + xmlstarlet ed -L -u '/addon/requires/import[@addon="inputstream.adaptive"]/@version' -v '19.0.0' addon.xml xmlstarlet ed -L -d '/addon/requires/import[@addon="script.module.infotagger"]' addon.xml git add . git commit -m "Kodi 19 Patch" diff --git a/.patches/unofficial.patch b/.patches/unofficial.patch deleted file mode 100644 index 32e255cce..000000000 --- a/.patches/unofficial.patch +++ /dev/null @@ -1,615 +0,0 @@ - addon.xml | 1 + - .../kodion/abstract_provider.py | 21 ++- - .../kodion/constants/const_settings.py | 4 + - .../kodion/impl/abstract_context_ui.py | 6 + - .../kodion/impl/abstract_settings.py | 3 + - .../kodion/impl/xbmc/xbmc_context.py | 1 + - .../kodion/impl/xbmc/xbmc_context_ui.py | 12 ++ - .../kodion/impl/xbmc/xbmc_runner.py | 8 + - .../youtube_plugin/kodion/utils/__init__.py | 3 +- - .../kodion/utils/view_manager.py | 166 ++++++++++++++++++ - .../youtube_plugin/youtube/helper/utils.py | 2 +- - .../youtube/helper/yt_specials.py | 18 +- - .../lib/youtube_plugin/youtube/provider.py | 22 +-- - resources/settings.xml | 33 +++- - 14 files changed, 276 insertions(+), 24 deletions(-) - create mode 100644 resources/lib/youtube_plugin/kodion/utils/view_manager.py - -diff --git a/addon.xml b/addon.xml -index 57d0b3c6..13eba70c 100644 ---- a/addon.xml -+++ b/addon.xml -@@ -107,3 +107,4 @@ - 此附加元件未由Google支持 - - -+ -diff --git a/resources/lib/youtube_plugin/kodion/abstract_provider.py b/resources/lib/youtube_plugin/kodion/abstract_provider.py -index ff8ffd44..1306ca2c 100644 ---- a/resources/lib/youtube_plugin/kodion/abstract_provider.py -+++ b/resources/lib/youtube_plugin/kodion/abstract_provider.py -@@ -72,10 +72,29 @@ class AbstractProvider(object): - self._dict_path[re_path] = method_name - - def _process_wizard(self, context): -+ def _setup_views(_context, _view): -+ view_manager = utils.ViewManager(_context) -+ if not view_manager.update_view_mode(_context.localize('setup_wizard.view.%s' % _view]), -+ _view): -+ return -+ -+ _context.get_settings().set_bool(constants.setting.VIEW_OVERRIDE, True) -+ - # start the setup wizard - wizard_steps = [] - if context.get_settings().is_setup_wizard_enabled(): - context.get_settings().set_bool(constants.setting.SETUP_WIZARD, False) -+ if utils.ViewManager(context).has_supported_views(): -+ views = self.get_wizard_supported_views() -+ for view in views: -+ if view in utils.ViewManager.SUPPORTED_VIEWS: -+ wizard_steps.append((_setup_views, [context, view])) -+ else: -+ context.log_warning('[Setup-Wizard] Unsupported view "%s"' % view) -+ else: -+ skin_id = context.get_ui().get_skin_id() -+ context.log("ViewManager: Unknown skin id '%s'" % skin_id) -+ - wizard_steps.extend(self.get_wizard_steps(context)) - - if wizard_steps and context.get_ui().on_yes_no_input(context.get_name(), -@@ -278,7 +297,7 @@ class AbstractProvider(object): - query = query.decode('utf-8') - return self.on_search(query, context, re_match) - else: -- context.set_content_type(constants.content_type.FILES) -+ context.set_content_type(constants.content_type.VIDEOS) - result = [] - - location = str(context.get_param('location', False)).lower() == 'true' -diff --git a/resources/lib/youtube_plugin/kodion/constants/const_settings.py b/resources/lib/youtube_plugin/kodion/constants/const_settings.py -index 6f64ca46..a751a1c1 100644 ---- a/resources/lib/youtube_plugin/kodion/constants/const_settings.py -+++ b/resources/lib/youtube_plugin/kodion/constants/const_settings.py -@@ -31,6 +31,10 @@ HIDE_SHORT_VIDEOS = 'youtube.hide_shorts' # (bool) - SUPPORT_ALTERNATIVE_PLAYER = 'kodion.support.alternative_player' # (bool) - ALTERNATIVE_PLAYER_WEB_URLS = 'kodion.alternative_player.web.urls' # (bool) - -+VIEW_OVERRIDE = 'kodion.view.override' # (bool) -+VIEW_DEFAULT = 'kodion.view.default' # (int) -+VIEW_X = 'kodion.view.%s' # (int) -+ - ALLOW_DEV_KEYS = 'youtube.allow.dev.keys' # (bool) - - VIDEO_QUALITY = 'kodion.video.quality' # (int) -diff --git a/resources/lib/youtube_plugin/kodion/impl/abstract_context_ui.py b/resources/lib/youtube_plugin/kodion/impl/abstract_context_ui.py -index 7ed3feff..46eabf26 100644 ---- a/resources/lib/youtube_plugin/kodion/impl/abstract_context_ui.py -+++ b/resources/lib/youtube_plugin/kodion/impl/abstract_context_ui.py -@@ -16,6 +16,12 @@ class AbstractContextUI(object): - def create_progress_dialog(self, heading, text=None, background=False): - raise NotImplementedError() - -+ def set_view_mode(self, view_mode): -+ raise NotImplementedError() -+ -+ def get_view_mode(self): -+ raise NotImplementedError() -+ - def get_skin_id(self): - raise NotImplementedError() - -diff --git a/resources/lib/youtube_plugin/kodion/impl/abstract_settings.py b/resources/lib/youtube_plugin/kodion/impl/abstract_settings.py -index 47ec7ed2..4a314d65 100644 ---- a/resources/lib/youtube_plugin/kodion/impl/abstract_settings.py -+++ b/resources/lib/youtube_plugin/kodion/impl/abstract_settings.py -@@ -90,6 +90,9 @@ class AbstractSettings(object): - def is_setup_wizard_enabled(self): - return self.get_bool(SETTINGS.SETUP_WIZARD, False) - -+ def is_override_view_enabled(self): -+ return self.get_bool(SETTINGS.VIEW_OVERRIDE, False) -+ - def is_support_alternative_player_enabled(self): - return self.get_bool(SETTINGS.SUPPORT_ALTERNATIVE_PLAYER, False) - -diff --git a/resources/lib/youtube_plugin/kodion/impl/xbmc/xbmc_context.py b/resources/lib/youtube_plugin/kodion/impl/xbmc/xbmc_context.py -index 03b9cee7..6704d974 100644 ---- a/resources/lib/youtube_plugin/kodion/impl/xbmc/xbmc_context.py -+++ b/resources/lib/youtube_plugin/kodion/impl/xbmc/xbmc_context.py -@@ -215,6 +215,7 @@ class XbmcContext(AbstractContext): - def set_content_type(self, content_type): - self.log_debug('Setting content-type: "%s" for "%s"' % (content_type, self.get_path())) - xbmcplugin.setContent(self._plugin_handle, content_type) -+ self.get_ui().set_view_mode(content_type) - - def add_sort_method(self, *sort_methods): - for sort_method in sort_methods: -diff --git a/resources/lib/youtube_plugin/kodion/impl/xbmc/xbmc_context_ui.py b/resources/lib/youtube_plugin/kodion/impl/xbmc/xbmc_context_ui.py -index 147d4e5e..b83f101b 100644 ---- a/resources/lib/youtube_plugin/kodion/impl/xbmc/xbmc_context_ui.py -+++ b/resources/lib/youtube_plugin/kodion/impl/xbmc/xbmc_context_ui.py -@@ -33,6 +33,18 @@ class XbmcContextUI(AbstractContextUI): - - return XbmcProgressDialog(heading, text) - -+ def set_view_mode(self, view_mode): -+ if isinstance(view_mode, str): -+ view_mode = self._context.get_settings().get_int(constants.setting.VIEW_X % view_mode, self._context.get_settings().get_int(constants.setting.VIEW_DEFAULT, 50)) -+ -+ self._view_mode = view_mode -+ -+ def get_view_mode(self): -+ if self._view_mode is not None: -+ return self._view_mode -+ -+ return self._context.get_settings().get_int(constants.setting.VIEW_DEFAULT, 50) -+ - def get_skin_id(self): - return xbmc.getSkinDir() - -diff --git a/resources/lib/youtube_plugin/kodion/impl/xbmc/xbmc_runner.py b/resources/lib/youtube_plugin/kodion/impl/xbmc/xbmc_runner.py -index 46fda8a7..1f35935a 100644 ---- a/resources/lib/youtube_plugin/kodion/impl/xbmc/xbmc_runner.py -+++ b/resources/lib/youtube_plugin/kodion/impl/xbmc/xbmc_runner.py -@@ -8,6 +8,7 @@ - See LICENSES/GPL-2.0-only for more information. - """ - -+import xbmc - import xbmcgui - import xbmcplugin - -@@ -66,6 +67,13 @@ class XbmcRunner(AbstractProviderRunner): - self.handle, succeeded=True, - updateListing=options.get(AbstractProvider.RESULT_UPDATE_LISTING, False), - cacheToDisc=options.get(AbstractProvider.RESULT_CACHE_TO_DISC, True)) -+ -+ # set alternative view mode -+ if context.get_settings().is_override_view_enabled(): -+ view_mode = context.get_ui().get_view_mode() -+ if view_mode is not None: -+ context.log_debug('Override view mode to "%d"' % view_mode) -+ xbmc.executebuiltin('Container.SetViewMode(%d)' % view_mode) - else: - # handle exception - pass -diff --git a/resources/lib/youtube_plugin/kodion/utils/__init__.py b/resources/lib/youtube_plugin/kodion/utils/__init__.py -index 717fb4dc..da0c45b1 100644 ---- a/resources/lib/youtube_plugin/kodion/utils/__init__.py -+++ b/resources/lib/youtube_plugin/kodion/utils/__init__.py -@@ -15,6 +15,7 @@ from .favorite_list import FavoriteList - from .watch_later_list import WatchLaterList - from .function_cache import FunctionCache - from .access_manager import AccessManager -+from .view_manager import ViewManager - from .http_server import get_http_server, is_httpd_live, get_client_ip_address - from .monitor import YouTubeMonitor - from .player import YouTubePlayer -@@ -24,7 +25,7 @@ from .system_version import SystemVersion - from . import ip_api - - --__all__ = ['SearchHistory', 'FavoriteList', 'WatchLaterList', 'FunctionCache', 'AccessManager', -+__all__ = ['SearchHistory', 'FavoriteList', 'WatchLaterList', 'FunctionCache', 'AccessManager', 'ViewManager', - 'strip_html_from_text', 'create_path', 'create_uri_path', 'find_best_fit', 'to_unicode', 'to_utf8', - 'datetime_parser', 'select_stream', 'get_http_server', 'is_httpd_live', 'YouTubeMonitor', - 'make_dirs', 'loose_version', 'ip_api', 'PlaybackHistory', 'DataCache', 'get_client_ip_address', -diff --git a/resources/lib/youtube_plugin/kodion/utils/view_manager.py b/resources/lib/youtube_plugin/kodion/utils/view_manager.py -new file mode 100644 -index 00000000..9a5fe5ae ---- /dev/null -+++ b/resources/lib/youtube_plugin/kodion/utils/view_manager.py -@@ -0,0 +1,166 @@ -+# -*- coding: utf-8 -*- -+""" -+ -+ Copyright (C) 2014-2016 bromix (plugin.video.youtube) -+ Copyright (C) 2016-2018 plugin.video.youtube -+ -+ SPDX-License-Identifier: GPL-2.0-only -+ See LICENSES/GPL-2.0-only for more information. -+""" -+ -+from .. import constants -+ -+ -+class ViewManager(object): -+ SUPPORTED_VIEWS = ['default', 'movies', 'tvshows', 'episodes', 'musicvideos', 'songs', 'albums', 'artists'] -+ 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': 'Shift', 'id': 53}, -+ {'name': 'InfoWall', 'id': 54}, -+ {'name': 'Wall', 'id': 500}, -+ {'name': 'WideList', 'id': 55}, -+ ], -+ 'episodes': [ -+ {'name': 'InfoWall', 'id': 54}, -+ {'name': 'Wall', 'id': 500}, -+ {'name': 'WideList', 'id': 55}, -+ ] -+ } -+ } -+ -+ def __init__(self, context): -+ self._context = context -+ -+ def has_supported_views(self): -+ """ -+ Returns True if the View of the current skin are supported -+ :return: True if the View of the current skin are supported -+ """ -+ return self._context.get_ui().get_skin_id() in self.SKIN_DATA -+ -+ def update_view_mode(self, title, view='default'): -+ view_id = -1 -+ settings = self._context.get_settings() -+ -+ skin_id = self._context.get_ui().get_skin_id() -+ skin_data = self.SKIN_DATA.get(skin_id, {}).get(view, []) -+ if skin_data: -+ items = [] -+ for view_data in skin_data: -+ items.append((view_data['name'], view_data['id'])) -+ view_id = self._context.get_ui().on_select(title, items) -+ else: -+ self._context.log_notice("ViewManager: Unknown skin id '%s'" % skin_id) -+ -+ if view_id == -1: -+ old_value = settings.get_string(constants.setting.VIEW_X % view, '') -+ if old_value: -+ result, view_id = self._context.get_ui().on_numeric_input(title, old_value) -+ if not result: -+ view_id = -1 -+ -+ if view_id > -1: -+ settings.set_int(constants.setting.VIEW_X % view, view_id) -+ return True -+ -+ return False -diff --git a/resources/lib/youtube_plugin/youtube/helper/utils.py b/resources/lib/youtube_plugin/youtube/helper/utils.py -index 734e0a12..784468b2 100644 ---- a/resources/lib/youtube_plugin/youtube/helper/utils.py -+++ b/resources/lib/youtube_plugin/youtube/helper/utils.py -@@ -254,7 +254,7 @@ def update_video_infos(provider, context, video_id_dict, playlist_item_id_dict=N - video_item = video_id_dict[video_id] - - # set mediatype -- video_item.set_mediatype('video') # using video -+ video_item.set_mediatype('episode') # using video - - if not yt_item: - continue -diff --git a/resources/lib/youtube_plugin/youtube/helper/yt_specials.py b/resources/lib/youtube_plugin/youtube/helper/yt_specials.py -index 89ad2b11..c5c8fec3 100644 ---- a/resources/lib/youtube_plugin/youtube/helper/yt_specials.py -+++ b/resources/lib/youtube_plugin/youtube/helper/yt_specials.py -@@ -15,7 +15,7 @@ from . import utils - - - def _process_related_videos(provider, context): -- provider.set_content_type(context, kodion.constants.content_type.VIDEOS) -+ provider.set_content_type(context, kodion.constants.content_type.EPISODES) - result = [] - - page_token = context.get_param('page_token', '') -@@ -60,7 +60,7 @@ def _process_child_comments(provider, context): - - - def _process_recommendations(provider, context): -- provider.set_content_type(context, kodion.constants.content_type.VIDEOS) -+ provider.set_content_type(context, kodion.constants.content_type.EPISODES) - result = [] - - page_token = context.get_param('page_token', '') -@@ -72,7 +72,7 @@ def _process_recommendations(provider, context): - - - def _process_popular_right_now(provider, context): -- provider.set_content_type(context, kodion.constants.content_type.VIDEOS) -+ provider.set_content_type(context, kodion.constants.content_type.EPISODES) - result = [] - - page_token = context.get_param('page_token', '') -@@ -85,7 +85,7 @@ def _process_popular_right_now(provider, context): - - - def _process_browse_channels(provider, context): -- provider.set_content_type(context, kodion.constants.content_type.FILES) -+ provider.set_content_type(context, kodion.constants.content_type.VIDEOS) - result = [] - - # page_token = context.get_param('page_token', '') -@@ -107,7 +107,7 @@ def _process_browse_channels(provider, context): - - - def _process_disliked_videos(provider, context): -- provider.set_content_type(context, kodion.constants.content_type.VIDEOS) -+ provider.set_content_type(context, kodion.constants.content_type.EPISODES) - result = [] - - page_token = context.get_param('page_token', '') -@@ -122,7 +122,7 @@ def _process_live_events(provider, context, event_type='live'): - def _sort(x): - return x.get_aired() - -- provider.set_content_type(context, kodion.constants.content_type.VIDEOS) -+ provider.set_content_type(context, kodion.constants.content_type.EPISODES) - result = [] - - # TODO: cache result -@@ -142,7 +142,7 @@ def _process_description_links(provider, context): - addon_id = context.get_param('addon_id', '') - - def _extract_urls(_video_id): -- provider.set_content_type(context, kodion.constants.content_type.VIDEOS) -+ provider.set_content_type(context, kodion.constants.content_type.EPISODES) - url_resolver = UrlResolver(context) - - result = [] -@@ -304,7 +304,7 @@ def _process_purchases_tv(provider, context): - - - def _process_new_uploaded_videos_tv(provider, context): -- provider.set_content_type(context, kodion.constants.content_type.VIDEOS) -+ provider.set_content_type(context, kodion.constants.content_type.EPISODES) - - result = [] - next_page_token = context.get_param('next_page_token', '') -@@ -316,7 +316,7 @@ def _process_new_uploaded_videos_tv(provider, context): - - - def _process_new_uploaded_videos_tv_filtered(provider, context): -- provider.set_content_type(context, kodion.constants.content_type.VIDEOS) -+ provider.set_content_type(context, kodion.constants.content_type.EPISODES) - - result = [] - next_page_token = context.get_param('next_page_token', '') -diff --git a/resources/lib/youtube_plugin/youtube/provider.py b/resources/lib/youtube_plugin/youtube/provider.py -index bdab6658..2503e380 100644 ---- a/resources/lib/youtube_plugin/youtube/provider.py -+++ b/resources/lib/youtube_plugin/youtube/provider.py -@@ -437,7 +437,7 @@ class Provider(kodion.AbstractProvider): - - @kodion.RegisterProviderPath('^/playlist/(?P[^/]+)/$') - def _on_playlist(self, context, re_match): -- self.set_content_type(context, kodion.constants.content_type.VIDEOS) -+ self.set_content_type(context, kodion.constants.content_type.EPISODES) - - result = [] - -@@ -461,7 +461,7 @@ class Provider(kodion.AbstractProvider): - - @kodion.RegisterProviderPath('^/channel/(?P[^/]+)/playlist/(?P[^/]+)/$') - def _on_channel_playlist(self, context, re_match): -- self.set_content_type(context, kodion.constants.content_type.VIDEOS) -+ self.set_content_type(context, kodion.constants.content_type.EPISODES) - client = self.get_client(context) - result = [] - -@@ -484,7 +484,7 @@ class Provider(kodion.AbstractProvider): - - @kodion.RegisterProviderPath('^/channel/(?P[^/]+)/playlists/$') - def _on_channel_playlists(self, context, re_match): -- self.set_content_type(context, kodion.constants.content_type.FILES) -+ self.set_content_type(context, kodion.constants.content_type.VIDEOS) - result = [] - - channel_id = re_match.group('channel_id') -@@ -525,7 +525,7 @@ class Provider(kodion.AbstractProvider): - - @kodion.RegisterProviderPath('^/channel/(?P[^/]+)/live/$') - def _on_channel_live(self, context, re_match): -- self.set_content_type(context, kodion.constants.content_type.VIDEOS) -+ self.set_content_type(context, kodion.constants.content_type.EPISODES) - result = [] - - channel_id = re_match.group('channel_id') -@@ -560,7 +560,7 @@ class Provider(kodion.AbstractProvider): - if method == 'channel' and not channel_id: - return False - -- self.set_content_type(context, kodion.constants.content_type.VIDEOS) -+ self.set_content_type(context, kodion.constants.content_type.EPISODES) - - resource_manager = self.get_resource_manager(context) - -@@ -648,7 +648,7 @@ class Provider(kodion.AbstractProvider): - # noinspection PyUnusedLocal - @kodion.RegisterProviderPath('^/location/mine/$') - def _on_my_location(self, context, re_match): -- self.set_content_type(context, kodion.constants.content_type.FILES) -+ self.set_content_type(context, kodion.constants.content_type.VIDEOS) - - settings = context.get_settings() - result = list() -@@ -793,7 +793,7 @@ class Provider(kodion.AbstractProvider): - subscriptions = yt_subscriptions.process(method, self, context) - - if method == 'list': -- self.set_content_type(context, kodion.constants.content_type.FILES) -+ self.set_content_type(context, kodion.constants.content_type.VIDEOS) - channel_ids = [] - for subscription in subscriptions: - channel_ids.append(subscription.get_channel_id()) -@@ -1025,9 +1025,9 @@ class Provider(kodion.AbstractProvider): - context.set_param('q', search_text) - - if search_type == 'video': -- self.set_content_type(context, kodion.constants.content_type.VIDEOS) -+ self.set_content_type(context, kodion.constants.content_type.EPISODES) - else: -- self.set_content_type(context, kodion.constants.content_type.FILES) -+ self.set_content_type(context, kodion.constants.content_type.VIDEOS) - - if page == 1 and search_type == 'video' and not event_type and not hide_folders: - if not channel_id and not location: -@@ -1360,7 +1360,7 @@ class Provider(kodion.AbstractProvider): - settings = context.get_settings() - _ = self.get_client(context) # required for self.is_logged_in() - -- self.set_content_type(context, kodion.constants.content_type.FILES) -+ self.set_content_type(context, kodion.constants.content_type.VIDEOS) - - result = [] - -@@ -1595,7 +1595,7 @@ class Provider(kodion.AbstractProvider): - @staticmethod - def set_content_type(context, content_type): - context.set_content_type(content_type) -- if content_type == kodion.constants.content_type.VIDEOS: -+ if content_type == kodion.constants.content_type.EPISODES: - context.add_sort_method(kodion.constants.sort_method.UNSORTED, - kodion.constants.sort_method.VIDEO_RUNTIME, - kodion.constants.sort_method.DATE_ADDED, -diff --git a/resources/settings.xml b/resources/settings.xml -index 70429df9..65206514 100644 ---- a/resources/settings.xml -+++ b/resources/settings.xml -@@ -663,6 +663,37 @@ - - - -+ -+ 0 -+ false -+ -+ -+ -+ 0 -+ 50 -+ -+ -+ true -+ -+ -+ -+ 30027 -+ -+ -+ -+ 0 -+ 50 -+ -+ -+ true -+ -+ -+ -+ 30028 -+ -+ -+ -+ - - 0 - 10 -@@ -681,7 +712,7 @@ - - - -- -+ - - 0 - en-US diff --git a/addon.xml b/addon.xml index b23e1c0ae..29f59368b 100644 --- a/addon.xml +++ b/addon.xml @@ -7,22 +7,48 @@ - + video - + + + executable + -[new] quality and stream feature selection -[new] enable HLS live streams -[new] stream and language labelling -[chg] separately enable local/remote history -[chg] restore unsupported compatibility for Kodi v19 (Matrix) -[fix] fix various playback issues |contrib: various| -[fix] Limit host connections getting subscription feeds |contrib: cas--| -[fix] fix handling of connection failures -[upd] Translations updated from Kodi Weblate +### New +- Add display of extra video information (premieres, views, comments, likes) #18, #464, #503 +- Add support for Clips #450 +- Add ability to combine playlists #480 +- Add support for timestamps in links #502 +- Add initial support for higher bitrate streams #505 +- Add ability to limit video FPS at max resolution #539 +- Add local Watch Later and History for use when not logged in or custom playlist not set +- Update main menu items: + - New Recommended videos (similar to YouTube home page, will use login if available) + - Old Recommended videos renamed to Related videos (requires local/remote history enabled) + - Popular right now renamed to Trending + +### Changed +- Local history made optional and enabled by default +- Existing user data will be lost due to changes in data format: + - Search, local history, and local watch later is stored per user + - Function and data cache will be wiped (will also become per user in future) +- Disable OPUS audio by default #537 + +### Fixed +- Fix sharing links #115, #250, #538 +- Fix date and sorting issues #411, #425, #434 +- Fix issue with switching between H264/AV1 streams #532 +- Fix prompt for subtitles #534 +- Fix issues with corrupt user data #536 +- Fix issues with live streams #530, #540 +- Fix issues with loading large playlists #545 +- Fix Recommendations, Related Videos, and Auto-play next #508 +- Fix queuing from current playlist #549 +- Fix issues with randomising playlists #485 +- Workaround for crashes #113, #540 resources/media/icon.png diff --git a/resources/language/resource.language.en_au/strings.po b/resources/language/resource.language.en_au/strings.po index c6e325355..ecf6f327a 100644 --- a/resources/language/resource.language.en_au/strings.po +++ b/resources/language/resource.language.en_au/strings.po @@ -31,10 +31,13 @@ msgstr "" # msgctxt "Addon Summary" # msgid "Plugin for YouTube" # msgstr "" + # msgctxt "Addon Description" # msgid "YouTube is a one of the biggest video-sharing websites of the world." # msgstr "" + # Kodion Settings + msgctxt "#30000" msgid "General" msgstr "" @@ -47,7 +50,12 @@ msgctxt "#30002" msgid "Password" msgstr "" -# empty strings from id 30003 to 30006 +msgctxt "#30003" +msgid "YouTube" +msgstr "" + +# empty strings from id 30004 to 30006 + msgctxt "#30007" msgid "Use InputStream Adaptive" msgstr "" @@ -178,6 +186,7 @@ msgstr "" # Kodion Common # empty strings from id 30039 to 30099 + msgctxt "#30100" msgid "Favourites" msgstr "" @@ -264,6 +273,7 @@ msgstr "" # YouTube # empty strings from id 30121 to 30199 + msgctxt "#30200" msgid "API" msgstr "" @@ -290,6 +300,7 @@ msgstr "" # YouTube # empty strings from id 30206 to 30499 + msgctxt "#30500" msgid "Channels" msgstr "" @@ -343,7 +354,7 @@ msgid "Browse Channels" msgstr "" msgctxt "#30513" -msgid "Popular right now" +msgid "Trending" msgstr "" msgctxt "#30514" diff --git a/resources/language/resource.language.en_gb/strings.po b/resources/language/resource.language.en_gb/strings.po index fb9fd6986..9c994c905 100644 --- a/resources/language/resource.language.en_gb/strings.po +++ b/resources/language/resource.language.en_gb/strings.po @@ -50,7 +50,11 @@ msgctxt "#30002" msgid "Password" msgstr "" -# empty strings from id 30003 to 30006 +msgctxt "#30003" +msgid "YouTube" +msgstr "" + +# empty strings from id 30004 to 30006 msgctxt "#30007" msgid "Use InputStream Adaptive" @@ -350,7 +354,7 @@ msgid "Browse Channels" msgstr "" msgctxt "#30513" -msgid "Popular right now" +msgid "Trending" msgstr "" msgctxt "#30514" diff --git a/resources/language/resource.language.en_nz/strings.po b/resources/language/resource.language.en_nz/strings.po index 7d15e52e8..872d3f7fe 100644 --- a/resources/language/resource.language.en_nz/strings.po +++ b/resources/language/resource.language.en_nz/strings.po @@ -34,7 +34,9 @@ msgstr "" # msgctxt "Addon Description" # msgid "YouTube is a one of the biggest video-sharing websites of the world." # msgstr "" + # Kodion Settings + msgctxt "#30000" msgid "General" msgstr "" @@ -47,7 +49,12 @@ msgctxt "#30002" msgid "Password" msgstr "" -# empty strings from id 30003 to 30006 +msgctxt "#30003" +msgid "YouTube" +msgstr "" + +# empty strings from id 30004 to 30006 + msgctxt "#30007" msgid "Use InputStream Adaptive" msgstr "" @@ -343,7 +350,7 @@ msgid "Browse Channels" msgstr "" msgctxt "#30513" -msgid "Popular right now" +msgid "Trending" msgstr "" msgctxt "#30514" diff --git a/resources/language/resource.language.en_us/strings.po b/resources/language/resource.language.en_us/strings.po index be70fa46c..e60a50d0a 100644 --- a/resources/language/resource.language.en_us/strings.po +++ b/resources/language/resource.language.en_us/strings.po @@ -32,10 +32,13 @@ msgstr "" # msgctxt "Addon Summary" # msgid "Plugin for YouTube" # msgstr "" + # msgctxt "Addon Description" # msgid "YouTube is a one of the biggest video-sharing websites of the world." # msgstr "" + # Kodion Settings + msgctxt "#30000" msgid "General" msgstr "General" @@ -48,7 +51,12 @@ msgctxt "#30002" msgid "Password" msgstr "Password" -# empty strings from id 30003 to 30006 +msgctxt "#30003" +msgid "YouTube" +msgstr "" + +# empty strings from id 30004 to 30006 + msgctxt "#30007" msgid "Use InputStream Adaptive" msgstr "" @@ -179,6 +187,7 @@ msgstr "" # Kodion Common # empty strings from id 30039 to 30099 + msgctxt "#30100" msgid "Favourites" msgstr "Favorites" @@ -265,6 +274,7 @@ msgstr "" # YouTube # empty strings from id 30121 to 30199 + msgctxt "#30200" msgid "API" msgstr "" @@ -291,6 +301,7 @@ msgstr "" # YouTube # empty strings from id 30206 to 30499 + msgctxt "#30500" msgid "Channels" msgstr "Channels" @@ -344,7 +355,7 @@ msgid "Browse Channels" msgstr "" msgctxt "#30513" -msgid "Popular right now" +msgid "Trending" msgstr "" msgctxt "#30514" diff --git a/resources/lib/default.py b/resources/lib/plugin.py similarity index 100% rename from resources/lib/default.py rename to resources/lib/plugin.py diff --git a/resources/lib/startup.py b/resources/lib/service.py similarity index 100% rename from resources/lib/startup.py rename to resources/lib/service.py diff --git a/resources/lib/youtube_plugin/__init__.py b/resources/lib/youtube_plugin/__init__.py index 84fc92d21..44b1ba27b 100644 --- a/resources/lib/youtube_plugin/__init__.py +++ b/resources/lib/youtube_plugin/__init__.py @@ -26,4 +26,4 @@ } } -__all__ = ['kodion', 'youtube', 'key_sets', 'refresh'] +__all__ = ['kodion', 'youtube', 'key_sets', 'script'] diff --git a/resources/lib/youtube_plugin/kodion/abstract_provider.py b/resources/lib/youtube_plugin/kodion/abstract_provider.py index 0fbbb337f..5f85ea08b 100644 --- a/resources/lib/youtube_plugin/kodion/abstract_provider.py +++ b/resources/lib/youtube_plugin/kodion/abstract_provider.py @@ -12,7 +12,7 @@ import re -from .constants import settings, paths, content +from .constants import paths, content from .compatibility import quote, unquote from .exceptions import KodionException from .items import ( @@ -85,28 +85,26 @@ def register_path(self, re_path, method_name): """ self._dict_path[re_path] = method_name - def _process_wizard(self, context): - # start the setup wizard - wizard_steps = [] - if context.get_settings().is_setup_wizard_enabled(): - context.get_settings().set_bool(settings.SETUP_WIZARD, False) - wizard_steps.extend(self.get_wizard_steps(context)) + def run_wizard(self, context): + settings = context.get_settings() + ui = context.get_ui() + + settings.set_bool(settings.SETUP_WIZARD, False) + + wizard_steps = self.get_wizard_steps(context) + wizard_steps.extend(ui.get_view_manager().get_wizard_steps()) - if wizard_steps and context.get_ui().on_yes_no_input(context.get_name(), - context.localize('setup_wizard.execute')): + if (wizard_steps and ui.on_yes_no_input( + context.get_name(), context.localize('setup_wizard.execute') + )): for wizard_step in wizard_steps: wizard_step[0](*wizard_step[1]) - def get_wizard_supported_views(self): - return ['default'] - def get_wizard_steps(self, context): # can be overridden by the derived class return [] def navigate(self, context): - self._process_wizard(context) - path = context.get_path() for key in self._dict_path: @@ -195,6 +193,7 @@ def _internal_watch_later(context, re_match): return False if command == 'list': + context.set_content(content.VIDEO_CONTENT, sub_type='watch_later') video_items = context.get_watch_later_list().get_items() for video_item in video_items: @@ -290,7 +289,7 @@ def _internal_search(self, context, re_match): if command == 'input': self.data_cache = context - folder_path = ui.get_info_label('Container.FolderPath') + folder_path = context.get_infolabel('Container.FolderPath') query = None # came from page 1 of search query by '..'/back # user doesn't want to input on this path @@ -327,7 +326,7 @@ def _internal_search(self, context, re_match): query = query.decode('utf-8') return self.on_search(query, context, re_match) - context.set_content_type(content.VIDEOS) + context.set_content(content.VIDEO_CONTENT) result = [] location = context.get_param('location', False) diff --git a/resources/lib/youtube_plugin/kodion/compatibility/__init__.py b/resources/lib/youtube_plugin/kodion/compatibility/__init__.py index b8cc23a3f..19cc2ebcd 100644 --- a/resources/lib/youtube_plugin/kodion/compatibility/__init__.py +++ b/resources/lib/youtube_plugin/kodion/compatibility/__init__.py @@ -26,6 +26,13 @@ import xbmcplugin import xbmcvfs + from infotagger.listitem import set_info_tag + + xbmc.LOGNOTICE = xbmc.LOGINFO + xbmc.LOGSEVERE = xbmc.LOGFATAL + + string_type = str + except ImportError: import BaseHTTPServer from contextlib import contextmanager as _contextmanager @@ -92,11 +99,39 @@ def _file_closer(*args, **kwargs): xbmcvfs.translatePath = xbmc.translatePath + def set_info_tag(listitem, infolabels, tag_type, *_args, **_kwargs): + listitem.setInfo(tag_type, infolabels) + return ListItemInfoTag(listitem, tag_type) + + + class ListItemInfoTag(object): + __slots__ = ('__li__',) + + def __init__(self, listitem, *_args, **_kwargs): + self.__li__ = listitem + + def add_stream_info(self, *args, **kwargs): + return self.__li__.addStreamInfo(*args, **kwargs) + + def set_resume_point(self, + infoproperties, + resume_key='ResumeTime', + total_key='TotalTime'): + if resume_key in infoproperties: + infoproperties[resume_key] = str(infoproperties[resume_key]) + if total_key in infoproperties: + infoproperties[total_key] = str(infoproperties[total_key]) + + + string_type = basestring + __all__ = ( 'BaseHTTPServer', 'parse_qs', 'parse_qsl', 'quote', + 'set_info_tag', + 'string_type', 'unescape', 'unquote', 'urlencode', 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 6436882bb..0003408a7 100644 --- a/resources/lib/youtube_plugin/kodion/constants/const_content_types.py +++ b/resources/lib/youtube_plugin/kodion/constants/const_content_types.py @@ -10,6 +10,9 @@ from __future__ import absolute_import, division, unicode_literals +VIDEO_CONTENT = 'videos' +LIST_CONTENT = 'files' +VIDEO_TYPE = 'video' FILES = 'files' SONGS = 'songs' diff --git a/resources/lib/youtube_plugin/kodion/constants/const_settings.py b/resources/lib/youtube_plugin/kodion/constants/const_settings.py index 9f232632b..ee74bdb40 100644 --- a/resources/lib/youtube_plugin/kodion/constants/const_settings.py +++ b/resources/lib/youtube_plugin/kodion/constants/const_settings.py @@ -58,11 +58,6 @@ API_KEY = 'youtube.api.key' # (string) API_ID = 'youtube.api.id' # (string) API_SECRET = 'youtube.api.secret' # (string) -API_LAST_HASH = 'youtube.api.last.hash' # (string) - -USER_ACCESS_TOKEN = 'kodion.access_token' # (string) -USER_REFRESH_TOKEN = 'kodion.refresh_token' # (string) -USER_TOKEN_EXPIRATION = 'kodion.access_token.expires' # (int) CLIENT_SELECTION = 'youtube.client.selection' # (int) diff --git a/resources/lib/youtube_plugin/kodion/context/abstract_context.py b/resources/lib/youtube_plugin/kodion/context/abstract_context.py index 4cee1fe2e..19c8d5d78 100644 --- a/resources/lib/youtube_plugin/kodion/context/abstract_context.py +++ b/resources/lib/youtube_plugin/kodion/context/abstract_context.py @@ -14,7 +14,6 @@ from .. import logger from ..compatibility import urlencode -from ..constants import settings from ..json_store import AccessManager from ..sql_store import ( DataCache, @@ -67,10 +66,12 @@ class AbstractContext(object): 'api_key', 'action', 'addon_id', + 'category_label', 'channel_id', 'channel_name', 'client_id', 'client_secret', + 'click_tracking', 'event_type', 'item', 'item_id', @@ -84,17 +85,16 @@ class AbstractContext(object): 'rating', 'search_type', 'subscription_id', + 'uri', 'videoid', # deprecated 'video_id', - 'uri', + 'visitor', } def __init__(self, path='/', params=None, plugin_name='', plugin_id=''): if not params: params = {} - self._system_version = None - self._cache_path = None self._debug_path = None @@ -112,7 +112,6 @@ def __init__(self, path='/', params=None, plugin_name='', plugin_id=''): self._path = create_path(path) self._params = params self._utils = None - self._view_mode = None # create valid uri self.parse_params() @@ -143,16 +142,17 @@ def get_cache_path(self): def get_playback_history(self): if not self._playback_history: uuid = self.get_access_manager().get_current_user_id() - filename = ''.join((uuid, '.sqlite')) - filepath = os.path.join(self.get_data_path(), 'playback', filename) + filename = 'history.sqlite' + filepath = os.path.join(self.get_data_path(), uuid, filename) self._playback_history = PlaybackHistory(filepath) return self._playback_history def get_data_cache(self): if not self._data_cache: - cache_size = self.get_settings().get_int(settings.CACHE_SIZE, -1) + settings = self.get_settings() + cache_size = settings.get_int(settings.CACHE_SIZE, -1) if cache_size <= 0: - cache_size = 5 + cache_size = 10 else: cache_size /= 2.0 filename = 'data_cache.sqlite' @@ -162,9 +162,10 @@ def get_data_cache(self): def get_function_cache(self): if not self._function_cache: - cache_size = self.get_settings().get_int(settings.CACHE_SIZE, -1) + settings = self.get_settings() + cache_size = settings.get_int(settings.CACHE_SIZE, -1) if cache_size <= 0: - cache_size = 5 + cache_size = 10 else: cache_size /= 2.0 filename = 'cache.sqlite' @@ -175,24 +176,28 @@ def get_function_cache(self): def get_search_history(self): if not self._search_history: - search_size = self.get_settings().get_int(settings.SEARCH_SIZE, 50) + settings = self.get_settings() + search_size = settings.get_int(settings.SEARCH_SIZE, 50) + uuid = self.get_access_manager().get_current_user_id() filename = 'search.sqlite' - filepath = os.path.join(self.get_cache_path(), filename) + filepath = os.path.join(self.get_data_path(), uuid, filename) self._search_history = SearchHistory(filepath, max_item_count=search_size) return self._search_history def get_favorite_list(self): if not self._favorite_list: + uuid = self.get_access_manager().get_current_user_id() filename = 'favorites.sqlite' - filepath = os.path.join(self.get_cache_path(), filename) + filepath = os.path.join(self.get_data_path(), uuid, filename) self._favorite_list = FavoriteList(filepath) return self._favorite_list def get_watch_later_list(self): if not self._watch_later_list: + uuid = self.get_access_manager().get_current_user_id() filename = 'watch_later.sqlite' - filepath = os.path.join(self.get_cache_path(), filename) + filepath = os.path.join(self.get_data_path(), uuid, filename) self._watch_later_list = WatchLaterList(filepath) return self._watch_later_list @@ -217,10 +222,7 @@ def get_ui(self): raise NotImplementedError() def get_system_version(self): - if not self._system_version: - self._system_version = current_system_version - - return self._system_version + return current_system_version def create_uri(self, path='/', params=None): if not params: @@ -330,10 +332,10 @@ def get_handle(self): def get_settings(self): raise NotImplementedError() - def localize(self, text_id, default_text=''): + def localize(self, text_id, default_text=None): raise NotImplementedError() - def set_content_type(self, content_type): + def set_content(self, content_type, sub_type=None, category_label=None): raise NotImplementedError() def add_sort_method(self, *sort_methods): @@ -360,8 +362,14 @@ def log_info(self, text): def clone(self, new_path=None, new_params=None): raise NotImplementedError() - def execute(self, command): + @staticmethod + def execute(command): raise NotImplementedError() - def sleep(self, milli_seconds): + @staticmethod + def sleep(milli_seconds): + raise NotImplementedError() + + @staticmethod + def get_infolabel(name): raise NotImplementedError() 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 9e6e30525..9e77ae4e1 100644 --- a/resources/lib/youtube_plugin/kodion/context/xbmc/xbmc_context.py +++ b/resources/lib/youtube_plugin/kodion/context/xbmc/xbmc_context.py @@ -26,7 +26,7 @@ xbmcplugin, xbmcvfs, ) -from ...constants import ADDON_ID +from ...constants import ADDON_ID, content, sort from ...player.xbmc.xbmc_player import XbmcPlayer from ...player.xbmc.xbmc_playlist import XbmcPlaylist from ...settings.xbmc.xbmc_plugin_settings import XbmcPluginSettings @@ -139,7 +139,6 @@ class XbmcContext(AbstractContext): 'playlist.select': 30521, 'playlists': 30501, 'please_wait': 30119, - 'popular_right_now': 30513, 'prompt': 30566, 'purchases': 30622, 'recommendations': 30551, @@ -165,8 +164,6 @@ class XbmcContext(AbstractContext): 'select.listen.ip': 30644, 'select_video_quality': 30010, 'settings': 30577, - 'setup.view_default': 30027, - 'setup.view_videos': 30028, 'setup_wizard.adjust': 30526, 'setup_wizard.adjust.language_and_region': 30527, 'setup_wizard.execute': 30030, @@ -199,6 +196,7 @@ class XbmcContext(AbstractContext): 'subtitles.no_auto_generated': 30602, 'subtitles.with_fallback': 30601, 'succeeded': 30575, + 'trending': 30513, 'unrated.video': 30718, 'unsubscribe': 30505, 'unsubscribed.from.channel': 30720, @@ -245,17 +243,15 @@ class XbmcContext(AbstractContext): 'watch_later.list.set.confirm': 30570, 'watch_later.remove': 30108, 'watch_later.retrieval_page': 30711, - # For unofficial setup wizard - 'setup_wizard.view.episodes': 30028, - 'setup_wizard.view.movies': 30029, - 'setup_wizard.view.tvshows': 30032, - 'setup_wizard.view.default': 30027, - 'setup_wizard.view.songs': 30033, - 'setup_wizard.view.artists': 30034, - 'setup_wizard.view.albums': 30035 + 'youtube': 30003, } - def __init__(self, path='/', params=None, plugin_name='', plugin_id='', override=True): + def __init__(self, + path='/', + params=None, + plugin_name='', + plugin_id='', + override=True): super(XbmcContext, self).__init__(path, params, plugin_name, plugin_id) if plugin_id: @@ -269,29 +265,37 @@ def __init__(self, path='/', params=None, plugin_name='', plugin_id='', override Also we extract the path and parameters - man, that would be so simple with the normal url-parsing routines. """ num_args = len(sys.argv) - - # first the path of the uri - if override: - self._uri = sys.argv[0] - parsed_url = urlsplit(self._uri) - self._path = unquote(parsed_url.path) - - # after that try to get the params - if num_args > 2: - params = sys.argv[2][1:] - if params: - self._uri = '?'.join((self._uri, params)) - self.parse_params(dict(parse_qsl(params))) - - if num_args > 3 and sys.argv[3].lower() == 'resume:true': - self._params['resume'] = True + if override and num_args: + uri = sys.argv[0] + is_plugin_invocation = uri.startswith('plugin://') + if is_plugin_invocation: + # first the path of the uri + self._uri = uri + parsed_url = urlsplit(uri) + self._path = unquote(parsed_url.path) + + # after that try to get the params + if num_args > 2: + params = sys.argv[2][1:] + if params: + self._uri = '?'.join((self._uri, params)) + self.parse_params(dict(parse_qsl(params))) + + # then Kodi resume status + if num_args > 3 and sys.argv[3].lower() == 'resume:true': + self._params['resume'] = True + elif num_args: + uri = sys.argv[0] + is_plugin_invocation = uri.startswith('plugin://') + else: + is_plugin_invocation = False self._ui = None self._video_playlist = None self._audio_playlist = None self._video_player = None self._audio_player = None - self._plugin_handle = int(sys.argv[1]) if num_args > 1 else -1 + self._plugin_handle = int(sys.argv[1]) if is_plugin_invocation else -1 self._plugin_id = plugin_id or ADDON_ID self._plugin_name = plugin_name or self._addon.getAddonInfo('name') self._version = self._addon.getAddonInfo('version') @@ -327,7 +331,7 @@ def get_language(self): The xbmc.getLanguage() method is fucked up!!! We always return 'en-US' for now """ - ''' + """ if self.get_system_version().get_release_name() == 'Frodo': return 'en-US' @@ -339,7 +343,7 @@ def get_language(self): except Exception as exc: self.log_error('Failed to get system language (%s)', exc.__str__()) return 'en-US' - ''' + """ return 'en-US' @@ -392,7 +396,10 @@ def get_addon_path(self): def get_settings(self): return self._settings - def localize(self, text_id, default_text=''): + def localize(self, text_id, default_text=None): + if default_text is None: + default_text = 'Undefined string ID: |{0}|'.format(text_id) + if not isinstance(text_id, int): try: text_id = self.LOCAL_MAP[text_id] @@ -406,17 +413,45 @@ def localize(self, text_id, default_text=''): """ We want to use all localization strings! - Addons should only use the range 30000 thru 30999 (see: http://kodi.wiki/view/Language_support) but we - do it anyway. I want some of the localized strings for the views of a skin. + Addons should only use the range 30000 thru 30999 + (see: http://kodi.wiki/view/Language_support) but we do it anyway. + I want some of the localized strings for the views of a skin. """ source = self._addon if 30000 <= text_id < 31000 else xbmc result = source.getLocalizedString(text_id) result = to_unicode(result) if result else default_text return result - def set_content_type(self, content_type): - self.log_debug('Setting content-type: "%s" for "%s"' % (content_type, self.get_path())) + def set_content(self, content_type, sub_type=None, category_label=None): + self.log_debug('Setting content-type: |{type}| for |{path}|'.format( + type=(sub_type or content_type), path=self.get_path() + )) xbmcplugin.setContent(self._plugin_handle, content_type) + if category_label is None: + category_label = self.get_param('category_label') + if category_label: + xbmcplugin.setPluginCategory(self._plugin_handle, category_label) + if sub_type == 'history': + self.add_sort_method( + (sort.LASTPLAYED, '%T \u2022 %P', '%D | %J'), + (sort.PLAYCOUNT, '%T \u2022 %P', '%D | %J'), + (sort.UNSORTED, '%T \u2022 %P', '%D | %J'), + (sort.LABEL_IGNORE_THE, '%T \u2022 %P', '%D | %J'), + ) + else: + self.add_sort_method( + (sort.UNSORTED, '%T \u2022 %P', '%D | %J'), + (sort.LABEL_IGNORE_THE, '%T \u2022 %P', '%D | %J'), + ) + if content_type == content.VIDEO_CONTENT: + self.add_sort_method( + (sort.PROGRAM_COUNT, '%T \u2022 %P | %D | %J', '%C'), + (sort.VIDEO_RATING, '%T \u2022 %P | %D | %J', '%R'), + (sort.DATE, '%T \u2022 %P | %D', '%J'), + (sort.DATEADDED, '%T \u2022 %P | %D', '%a'), + (sort.VIDEO_RUNTIME, '%T \u2022 %P | %J', '%D'), + (sort.TRACKNUM, '[%N. ]%T \u2022 %P', '%D | %J'), + ) def add_sort_method(self, *sort_methods): args = slice(None if current_system_version.compatible(19, 0) else 2) @@ -446,10 +481,12 @@ def clone(self, new_path=None, new_params=None): return new_context - def execute(self, command): + @staticmethod + def execute(command): xbmc.executebuiltin(command) - def sleep(self, milli_seconds): + @staticmethod + def sleep(milli_seconds): xbmc.sleep(milli_seconds) def addon_enabled(self, addon_id): @@ -466,7 +503,7 @@ def addon_enabled(self, addon_id): message = response['error']['message'] code = response['error']['code'] error = 'Requested |%s| received error |%s| and code: |%s|' % (rpc_request, message, code) - self.log_debug(error) + self.log_error(error) return False def set_addon_enabled(self, addon_id, enabled=True): @@ -483,7 +520,7 @@ def set_addon_enabled(self, addon_id, enabled=True): message = response['error']['message'] code = response['error']['code'] error = 'Requested |%s| received error |%s| and code: |%s|' % (rpc_request, message, code) - self.log_debug(error) + self.log_error(error) return False def send_notification(self, method, data): @@ -496,7 +533,9 @@ def use_inputstream_adaptive(self): if self._settings.use_isa(): if self.addon_enabled('inputstream.adaptive'): success = True - elif self.get_ui().on_yes_no_input(self.get_name(), self.localize('isa.enable.confirm')): + elif self.get_ui().on_yes_no_input( + self.get_name(), self.localize('isa.enable.confirm') + ): success = self.set_addon_enabled('inputstream.adaptive') else: success = False @@ -505,15 +544,16 @@ def use_inputstream_adaptive(self): return success # Values of capability map can be any of the following: - # - required version number as string for comparison with actual installed InputStream.Adaptive version - # - any Falsey value to exclude capability regardless of version + # - required version number, as string for comparison with actual installed + # InputStream.Adaptive version + # - any Falsy value to exclude capability regardless of version # - True to include capability regardless of version _ISA_CAPABILITIES = { 'live': '2.0.12', 'drm': '2.2.12', # audio codecs 'vorbis': '2.3.14', - 'opus': '19.0.0', # unknown when Opus audio support was first implemented + 'opus': '19.0.0', # unknown when Opus audio support was implemented 'mp4a': True, 'ac-3': '2.1.15', 'ec-3': '2.1.15', @@ -526,10 +566,13 @@ def use_inputstream_adaptive(self): } def inputstream_adaptive_capabilities(self, capability=None): - # return a list inputstream.adaptive capabilities, if capability set return version required + # Returns a list of inputstream.adaptive capabilities + # If capability param is provided, returns version of ISA where the + # capability is available try: - inputstream_version = xbmcaddon.Addon('inputstream.adaptive').getAddonInfo('version') + addon = xbmcaddon.Addon('inputstream.adaptive') + inputstream_version = addon.getAddonInfo('version') except RuntimeError: inputstream_version = '' @@ -539,20 +582,27 @@ def inputstream_adaptive_capabilities(self, capability=None): isa_loose_version = loose_version(inputstream_version) if capability is None: capabilities = frozenset( - capability for capability, version in self._ISA_CAPABILITIES.items() + capability + for (capability, version) in self._ISA_CAPABILITIES.items() if version is True or version and isa_loose_version >= loose_version(version) ) return capabilities version = self._ISA_CAPABILITIES.get(capability) - return version is True or version and isa_loose_version >= loose_version(version) + return (version is True + or version and isa_loose_version >= loose_version(version)) @staticmethod def inputstream_adaptive_auto_stream_selection(): try: - return xbmcaddon.Addon('inputstream.adaptive').getSetting('STREAMSELECTION') == '0' + addon = xbmcaddon.Addon('inputstream.adaptive') + return addon.getSetting('STREAMSELECTION') == '0' except RuntimeError: return False def abort_requested(self): return self.get_ui().get_property('abort_requested').lower() == 'true' + + @staticmethod + def get_infolabel(name): + return xbmc.getInfoLabel(name) diff --git a/resources/lib/youtube_plugin/kodion/items/base_item.py b/resources/lib/youtube_plugin/kodion/items/base_item.py index a919f8e68..ea0cdfa5d 100644 --- a/resources/lib/youtube_plugin/kodion/items/base_item.py +++ b/resources/lib/youtube_plugin/kodion/items/base_item.py @@ -14,7 +14,7 @@ from datetime import date, datetime from hashlib import md5 -from ..compatibility import unescape +from ..compatibility import string_type, unescape from ..constants import MEDIA_PATH @@ -106,7 +106,7 @@ def get_name(self): return self._name def set_uri(self, uri): - self._uri = uri if uri and isinstance(uri, str) else '' + self._uri = uri if uri and isinstance(uri, string_type) else '' def get_uri(self): """ diff --git a/resources/lib/youtube_plugin/kodion/items/directory_item.py b/resources/lib/youtube_plugin/kodion/items/directory_item.py index 8b26ae4e5..8f74a8793 100644 --- a/resources/lib/youtube_plugin/kodion/items/directory_item.py +++ b/resources/lib/youtube_plugin/kodion/items/directory_item.py @@ -11,10 +11,24 @@ from __future__ import absolute_import, division, unicode_literals from .base_item import BaseItem +from ..compatibility import urlencode class DirectoryItem(BaseItem): - def __init__(self, name, uri, image='', fanart='', action=False): + def __init__(self, + name, + uri, + image='', + fanart='', + action=False, + category_label=None): + if category_label is None: + category_label = name + if category_label: + uri = ('&' if '?' in uri else '?').join(( + uri, + urlencode({'category_label': category_label}), + )) super(DirectoryItem, self).__init__(name, uri, image, fanart) self._plot = self.get_name() self._is_action = action diff --git a/resources/lib/youtube_plugin/kodion/items/menu_items.py b/resources/lib/youtube_plugin/kodion/items/menu_items.py index ba51775bd..24a252919 100644 --- a/resources/lib/youtube_plugin/kodion/items/menu_items.py +++ b/resources/lib/youtube_plugin/kodion/items/menu_items.py @@ -17,7 +17,7 @@ def more_for_video(context, video_id, logged_in=False, refresh_container=False): return ( context.localize('video.more'), 'RunPlugin({0})'.format(context.create_uri( - ['video', 'more'], + ('video', 'more',), { 'video_id': video_id, 'logged_in': logged_in, @@ -27,11 +27,35 @@ def more_for_video(context, video_id, logged_in=False, refresh_container=False): ) +def related_videos(context, video_id): + return ( + context.localize('related_videos'), + 'ActivateWindow(Videos, {0}, return)'.format(context.create_uri( + ('special', 'related_videos',), + { + 'video_id': video_id, + } + )) + ) + + +def video_comments(context, video_id): + return ( + context.localize('video.comments'), + 'ActivateWindow(Videos, {0}, return)'.format(context.create_uri( + ('special', 'parent_comments',), + { + 'video_id': video_id, + } + )) + ) + + def content_from_description(context, video_id): return ( context.localize('video.description.links'), - 'RunPlugin({0})'.format(context.create_uri( - ['special', 'description_links'], + 'ActivateWindow(Videos, {0}, return)'.format(context.create_uri( + ('special', 'description_links',), { 'video_id': video_id, } @@ -46,6 +70,13 @@ def play_with(context): ) +def refresh(context): + return ( + context.localize('refresh'), + 'Container.Refresh' + ) + + def queue_video(context): return ( context.localize('video.queue'), @@ -58,7 +89,7 @@ def play_all_from_playlist(context, playlist_id, video_id=''): return ( context.localize('playlist.play.from_here'), 'RunPlugin({0})'.format(context.create_uri( - ['play'], + ('play',), { 'playlist_id': playlist_id, 'video_id': video_id, @@ -69,7 +100,7 @@ def play_all_from_playlist(context, playlist_id, video_id=''): return ( context.localize('playlist.play.all'), 'RunPlugin({0})'.format(context.create_uri( - ['play'], + ('play',), { 'playlist_id': playlist_id, 'play': True, @@ -82,7 +113,7 @@ def add_video_to_playlist(context, video_id): return ( context.localize('video.add_to_playlist'), 'RunPlugin({0})'.format(context.create_uri( - ['playlist', 'select', 'playlist'], + ('playlist', 'select', 'playlist',), { 'video_id': video_id, } @@ -94,7 +125,7 @@ def remove_video_from_playlist(context, playlist_id, video_id, video_name): return ( context.localize('remove'), 'RunPlugin({0})'.format(context.create_uri( - ['playlist', 'remove', 'video'], + ('playlist', 'remove', 'video',), { 'playlist_id': playlist_id, 'video_id': video_id, @@ -108,7 +139,7 @@ def rename_playlist(context, playlist_id, playlist_name): return ( context.localize('rename'), 'RunPlugin({0})'.format(context.create_uri( - ['playlist', 'rename', 'playlist'], + ('playlist', 'rename', 'playlist',), { 'playlist_id': playlist_id, 'playlist_name': playlist_name @@ -121,7 +152,7 @@ def delete_playlist(context, playlist_id, playlist_name): return ( context.localize('delete'), 'RunPlugin({0})'.format(context.create_uri( - ['playlist', 'remove', 'playlist'], + ('playlist', 'remove', 'playlist',), { 'playlist_id': playlist_id, 'playlist_name': playlist_name @@ -130,11 +161,11 @@ def delete_playlist(context, playlist_id, playlist_name): ) -def remove_as_watchlater(context, playlist_id, playlist_name): +def remove_as_watch_later(context, playlist_id, playlist_name): return ( context.localize('watch_later.list.remove'), 'RunPlugin({0})'.format(context.create_uri( - ['playlist', 'remove', 'watchlater'], + ('playlist', 'remove', 'watch_later',), { 'playlist_id': playlist_id, 'playlist_name': playlist_name @@ -143,11 +174,11 @@ def remove_as_watchlater(context, playlist_id, playlist_name): ) -def set_as_watchlater(context, playlist_id, playlist_name): +def set_as_watch_later(context, playlist_id, playlist_name): return ( context.localize('watch_later.list.set'), 'RunPlugin({0})'.format(context.create_uri( - ['playlist', 'set', 'watchlater'], + ('playlist', 'set', 'watch_later',), { 'playlist_id': playlist_id, 'playlist_name': playlist_name @@ -160,7 +191,7 @@ def remove_as_history(context, playlist_id, playlist_name): return ( context.localize('history.list.remove'), 'RunPlugin({0})'.format(context.create_uri( - ['playlist', 'remove', 'history'], + ('playlist', 'remove', 'history',), { 'playlist_id': playlist_id, 'playlist_name': playlist_name @@ -173,7 +204,7 @@ def set_as_history(context, playlist_id, playlist_name): return ( context.localize('history.list.set'), 'RunPlugin({0})'.format(context.create_uri( - ['playlist', 'set', 'history'], + ('playlist', 'set', 'history',), { 'playlist_id': playlist_id, 'playlist_name': playlist_name @@ -186,7 +217,7 @@ def remove_my_subscriptions_filter(context, channel_name): return ( context.localize('my_subscriptions.filter.remove'), 'RunPlugin({0})'.format(context.create_uri( - ['my_subscriptions', 'filter'], + ('my_subscriptions', 'filter',), { 'channel_name': channel_name, 'action': 'remove' @@ -199,7 +230,7 @@ def add_my_subscriptions_filter(context, channel_name): return ( context.localize('my_subscriptions.filter.add'), 'RunPlugin({0})'.format(context.create_uri( - ['my_subscriptions', 'filter'], + ('my_subscriptions', 'filter',), { 'channel_name': channel_name, 'action': 'add', @@ -212,7 +243,7 @@ def rate_video(context, video_id, refresh_container=False): return ( context.localize('video.rate'), 'RunPlugin({0})'.format(context.create_uri( - ['video', 'rate'], + ('video', 'rate',), { 'video_id': video_id, 'refresh_container': refresh_container, @@ -225,7 +256,7 @@ def watch_later_add(context, playlist_id, video_id): return ( context.localize('watch_later.add'), 'RunPlugin({0})'.format(context.create_uri( - ['playlist', 'add', 'video'], + ('playlist', 'add', 'video',), { 'playlist_id': playlist_id, 'video_id': video_id, @@ -238,7 +269,7 @@ def watch_later_local_add(context, item): return ( context.localize('watch_later.add'), 'RunPlugin({0})'.format(context.create_uri( - [paths.WATCH_LATER, 'add'], + (paths.WATCH_LATER, 'add',), { 'video_id': item.video_id, 'item': item.dumps(), @@ -251,7 +282,7 @@ def watch_later_local_remove(context, video_id): return ( context.localize('watch_later.remove'), 'RunPlugin({0})'.format(context.create_uri( - [paths.WATCH_LATER, 'remove'], + (paths.WATCH_LATER, 'remove',), { 'video_id': video_id, } @@ -263,7 +294,7 @@ def watch_later_local_clear(context): return ( context.localize('watch_later.clear'), 'RunPlugin({0})'.format(context.create_uri( - [paths.WATCH_LATER, 'clear'] + (paths.WATCH_LATER, 'clear',) )) ) @@ -271,37 +302,18 @@ def watch_later_local_clear(context): def go_to_channel(context, channel_id, channel_name): return ( context.localize('go_to_channel') % context.get_ui().bold(channel_name), - 'Container.Update({0})'.format(context.create_uri( - ['channel', channel_id] + 'ActivateWindow(Videos, {0}, return)'.format(context.create_uri( + ('channel', channel_id,) )) ) -def related_videos(context, video_id): - return ( - context.localize('related_videos'), - 'Container.Update({0})'.format(context.create_uri( - ['special', 'related_videos'], - { - 'video_id': video_id, - } - )) - ) - - -def refresh(context): - return ( - context.localize('refresh'), - 'Container.Refresh' - ) - - def subscribe_to_channel(context, channel_id, channel_name=''): if not channel_name: return ( context.localize('subscribe'), 'RunPlugin({0})'.format(context.create_uri( - ['subscriptions', 'add'], + ('subscriptions', 'add',), { 'subscription_id': channel_id, } @@ -310,7 +322,7 @@ def subscribe_to_channel(context, channel_id, channel_name=''): return ( context.localize('subscribe_to') % context.get_ui().bold(channel_name), 'RunPlugin({0})'.format(context.create_uri( - ['subscriptions', 'add'], + ('subscriptions', 'add',), { 'subscription_id': channel_id, } @@ -322,7 +334,7 @@ def unsubscribe_from_channel(context, channel_id): return ( context.localize('unsubscribe'), 'RunPlugin({0})'.format(context.create_uri( - ['subscriptions', 'remove'], + ('subscriptions', 'remove',), { 'subscription_id': channel_id, } @@ -334,7 +346,7 @@ def play_with_subtitles(context, video_id): return ( context.localize('video.play.with_subtitles'), 'RunPlugin({0})'.format(context.create_uri( - ['play'], + ('play',), { 'video_id': video_id, 'prompt_for_subtitles': True, @@ -347,7 +359,7 @@ def play_audio_only(context, video_id): return ( context.localize('video.play.audio_only'), 'RunPlugin({0})'.format(context.create_uri( - ['play'], + ('play',), { 'video_id': video_id, 'audio_only': True, @@ -360,7 +372,7 @@ def play_ask_for_quality(context, video_id): return ( context.localize('video.play.ask_for_quality'), 'RunPlugin({0})'.format(context.create_uri( - ['play'], + ('play',), { 'video_id': video_id, 'ask_for_quality': True, @@ -437,7 +449,7 @@ def favorites_add(context, item): return ( context.localize('favorites.add'), 'RunPlugin({0})'.format(context.create_uri( - [paths.FAVORITES, 'add'], + (paths.FAVORITES, 'add',), { 'video_id': item.video_id, 'item': item.dumps(), @@ -450,7 +462,7 @@ def favorites_remove(context, video_id): return ( context.localize('favorites.remove'), 'RunPlugin({0})'.format(context.create_uri( - [paths.FAVORITES, 'remove'], + (paths.FAVORITES, 'remove',), { 'vide_id': video_id, } @@ -462,7 +474,7 @@ def search_remove(context, query): return ( context.localize('search.remove'), 'RunPlugin({0})'.format(context.create_uri( - [paths.SEARCH, 'remove'], + (paths.SEARCH, 'remove',), { 'q': query, } @@ -474,7 +486,7 @@ def search_rename(context, query): return ( context.localize('search.rename'), 'RunPlugin({0})'.format(context.create_uri( - [paths.SEARCH, 'rename'], + (paths.SEARCH, 'rename',), { 'q': query, } @@ -486,6 +498,6 @@ def search_clear(context): return ( context.localize('search.clear'), 'RunPlugin({0})'.format(context.create_uri( - [paths.SEARCH, 'clear'] + (paths.SEARCH, 'clear',) )) ) diff --git a/resources/lib/youtube_plugin/kodion/items/next_page_item.py b/resources/lib/youtube_plugin/kodion/items/next_page_item.py index a73acb1c9..556dd8aa7 100644 --- a/resources/lib/youtube_plugin/kodion/items/next_page_item.py +++ b/resources/lib/youtube_plugin/kodion/items/next_page_item.py @@ -15,17 +15,17 @@ class NextPageItem(DirectoryItem): def __init__(self, context, current_page=1, image=None, fanart=None): - new_params = dict(context.get_params(), page=(current_page + 1)) - name = context.localize('next_page', 'Next Page') - if name.find('%d') != -1: - name %= current_page + 1 + next_page = current_page + 1 + new_params = dict(context.get_params(), page=next_page) + name = context.localize('next_page') % next_page super(NextPageItem, self).__init__(name, context.create_uri( context.get_path(), new_params ), - image=image) + image=image, + category_label=False) if fanart: self.set_fanart(fanart) diff --git a/resources/lib/youtube_plugin/kodion/items/utils.py b/resources/lib/youtube_plugin/kodion/items/utils.py index e85e7bd5c..d277802d3 100644 --- a/resources/lib/youtube_plugin/kodion/items/utils.py +++ b/resources/lib/youtube_plugin/kodion/items/utils.py @@ -17,6 +17,7 @@ from .directory_item import DirectoryItem from .image_item import ImageItem from .video_item import VideoItem +from ..compatibility import string_type from ..utils.datetime_parser import strptime @@ -52,7 +53,7 @@ def from_json(json_data, *_args): :param json_data: :return: """ - if isinstance(json_data, str): + if isinstance(json_data, string_type): json_data = json.loads(json_data, object_hook=_decoder) item_type = json_data.get('type') diff --git a/resources/lib/youtube_plugin/kodion/items/video_item.py b/resources/lib/youtube_plugin/kodion/items/video_item.py index 87b3483c1..258b6ab05 100644 --- a/resources/lib/youtube_plugin/kodion/items/video_item.py +++ b/resources/lib/youtube_plugin/kodion/items/video_item.py @@ -279,7 +279,7 @@ def get_license_key(self): return self.license_key def set_last_played(self, last_played): - self._last_played = last_played or '' + self._last_played = last_played def get_last_played(self): return self._last_played 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 544a830b6..fc723091d 100644 --- a/resources/lib/youtube_plugin/kodion/json_store/access_manager.py +++ b/resources/lib/youtube_plugin/kodion/json_store/access_manager.py @@ -26,7 +26,7 @@ class AccessManager(JSONStore): 'token_expires': -1, 'last_key_hash': '', 'name': 'Default', - 'watch_later': ' WL', + 'watch_later': 'WL', 'watch_history': 'HL' } @@ -85,7 +85,7 @@ def set_defaults(self, reset=False): current_user = data['access_manager']['current_user'] if 'watch_later' not in data['access_manager']['users'][current_user]: - data['access_manager']['users'][current_user]['watch_later'] = ' WL' + data['access_manager']['users'][current_user]['watch_later'] = 'WL' if 'watch_history' not in data['access_manager']['users'][current_user]: data['access_manager']['users'][current_user][ 'watch_history'] = 'HL' @@ -131,7 +131,7 @@ def get_current_user_details(self): """ :return: current user """ - return self.get_users()[self._user].copy() + return self.get_users()[self._user] def get_current_user_id(self): """ @@ -158,7 +158,7 @@ def get_new_user(self, username=''): 'last_key_hash': '', 'name': username, 'id': new_uuid, - 'watch_later': ' WL', + 'watch_later': 'WL', 'watch_history': 'HL' } @@ -179,11 +179,12 @@ def add_user(self, username='', user=None): users = self.get_users() new_user_details = self.get_new_user(username) new_user = max(users) + 1 if users and user is None else user or 0 - users[new_user] = new_user_details data = { 'access_manager': { - 'users': users - } + 'users': { + new_user: new_user_details, + }, + }, } self.save(data, update=True) return new_user, new_user_details @@ -196,13 +197,14 @@ def remove_user(self, user): """ users = self.get_users() if user in users: - del users[user] - data = { - 'access_manager': { - 'users': users + data = { + 'access_manager': { + 'users': { + user: KeyError, + }, + }, } - } - self.save(data, update=True) + self.save(data, update=True) def set_users(self, users): """ @@ -230,8 +232,8 @@ def set_user(self, user, switch_to=False): if switch_to: data = { 'access_manager': { - 'current_user': user - } + 'current_user': user, + }, } self.save(data, update=True) @@ -261,11 +263,14 @@ def set_username(self, user, username): """ users = self.get_users() if user in users: - users[user]['name'] = username data = { 'access_manager': { - 'users': users - } + 'users': { + user: { + 'name': username, + }, + }, + }, } self.save(data, update=True) return True @@ -276,39 +281,16 @@ def get_watch_later_id(self): Returns the current users watch later playlist id :return: the current users watch later playlist id """ - updated = False - watch_later_ids = ('wl', ' wl') - current_user = self.get_current_user_details() - current_playlist_id = current_user.get('watch_later', '') - settings_playlist_id = self._settings.get_watch_later_playlist() + current_id = current_user.get('watch_later', 'WL') + settings_id = self._settings.get_watch_later_playlist() - if settings_playlist_id.lower().startswith(watch_later_ids): - self._settings.set_watch_later_playlist('') - settings_playlist_id = '' + if settings_id and current_id != settings_id: + current_id = self.set_watch_later_id(settings_id) - if current_playlist_id.lower().startswith(watch_later_ids): - updated = True - current_user['watch_later'] = settings_playlist_id - self._settings.set_watch_later_playlist('') - settings_playlist_id = '' - - if settings_playlist_id and current_playlist_id != settings_playlist_id: - updated = True - current_user['watch_later'] = settings_playlist_id - self._settings.set_watch_later_playlist('') - - if updated: - data = { - 'access_manager': { - 'users': { - self._user: current_user - } - } - } - self.save(data, update=True) - - return current_user.get('watch_later', '') + if current_id and current_id.lower().strip() == 'wl': + return '' + return current_id def set_watch_later_id(self, playlist_id): """ @@ -316,20 +298,21 @@ def set_watch_later_id(self, playlist_id): :param playlist_id: string, watch later playlist id :return: """ - if playlist_id.lower() == 'wl' or playlist_id.lower() == ' wl': + if playlist_id.lower().strip() == 'wl': playlist_id = '' - current_user = self.get_current_user_details() - current_user['watch_later'] = playlist_id - self._settings.set_watch_later_playlist('') + self._settings.set_watch_later_playlist(playlist_id) data = { 'access_manager': { 'users': { - self._user: current_user - } - } + self._user: { + 'watch_later': playlist_id, + }, + }, + }, } self.save(data, update=True) + return playlist_id def get_watch_history_id(self): """ @@ -337,22 +320,15 @@ def get_watch_history_id(self): :return: the current users watch history playlist id """ current_user = self.get_current_user_details() - current_playlist_id = current_user.get('watch_history', 'HL') - settings_playlist_id = self._settings.get_history_playlist() + current_id = current_user.get('watch_history', 'HL') + settings_id = self._settings.get_history_playlist() - if settings_playlist_id and current_playlist_id != settings_playlist_id: - current_user['watch_history'] = settings_playlist_id - self._settings.set_history_playlist('') - data = { - 'access_manager': { - 'users': { - self._user: current_user - } - } - } - self.save(data, update=True) + if settings_id and current_id != settings_id: + current_id = self.set_watch_history_id(settings_id) - return current_user.get('watch_history', 'HL') + if current_id and current_id.lower().strip() == 'hl': + return '' + return current_id def set_watch_history_id(self, playlist_id): """ @@ -360,17 +336,21 @@ def set_watch_history_id(self, playlist_id): :param playlist_id: string, watch history playlist id :return: """ - current_user = self.get_current_user_details() - current_user['watch_history'] = playlist_id - self._settings.set_history_playlist('') + if playlist_id.lower().strip() == 'hl': + playlist_id = '' + + self._settings.set_history_playlist(playlist_id) data = { 'access_manager': { 'users': { - self._user: current_user - } - } + self._user: { + 'watch_history': playlist_id, + }, + }, + }, } self.save(data, update=True) + return playlist_id def set_last_origin(self, origin): """ @@ -381,8 +361,8 @@ def set_last_origin(self, origin): self._last_origin = origin data = { 'access_manager': { - 'last_origin': origin - } + 'last_origin': origin, + }, } self.save(data, update=True) @@ -443,8 +423,9 @@ def update_access_token(self, :param refresh_token: :return: """ - current_user = self.get_current_user_details() - current_user['access_token'] = access_token + current_user = { + 'access_token': access_token, + } if unix_timestamp is not None: current_user['token_expires'] = int(unix_timestamp) @@ -455,21 +436,21 @@ def update_access_token(self, data = { 'access_manager': { 'users': { - self._user: current_user - } - } + self._user: current_user, + }, + }, } self.save(data, update=True) def set_last_key_hash(self, key_hash): - current_user = self.get_current_user_details() - current_user['last_key_hash'] = key_hash data = { 'access_manager': { 'users': { - self._user: current_user - } - } + self._user: { + 'last_key_hash': key_hash, + }, + }, + }, } self.save(data, update=True) @@ -558,8 +539,9 @@ def update_dev_access_token(self, :param refresh_token: :return: """ - developer = self.get_developer(addon_id) - developer['access_token'] = access_token + developer = { + 'access_token': access_token + } if unix_timestamp is not None: developer['token_expires'] = int(unix_timestamp) @@ -570,9 +552,9 @@ def update_dev_access_token(self, data = { 'access_manager': { 'developers': { - addon_id: developer - } - } + addon_id: developer, + }, + }, } self.save(data, update=True) @@ -580,14 +562,14 @@ def get_dev_last_key_hash(self, addon_id): return self.get_developer(addon_id).get('last_key_hash', '') def set_dev_last_key_hash(self, addon_id, key_hash): - developer = self.get_developer(addon_id) - developer['last_key_hash'] = key_hash data = { 'access_manager': { 'developers': { - addon_id: developer - } - } + addon_id: { + 'last_key_hash': key_hash, + }, + }, + }, } self.save(data, update=True) diff --git a/resources/lib/youtube_plugin/kodion/logger.py b/resources/lib/youtube_plugin/kodion/logger.py index 03cde49ab..b3968f2d6 100644 --- a/resources/lib/youtube_plugin/kodion/logger.py +++ b/resources/lib/youtube_plugin/kodion/logger.py @@ -16,11 +16,11 @@ DEBUG = xbmc.LOGDEBUG INFO = xbmc.LOGINFO -NOTICE = INFO +NOTICE = xbmc.LOGNOTICE WARNING = xbmc.LOGWARNING ERROR = xbmc.LOGERROR FATAL = xbmc.LOGFATAL -SEVERE = FATAL +SEVERE = xbmc.LOGSEVERE NONE = xbmc.LOGNONE diff --git a/resources/lib/youtube_plugin/kodion/network/http_server.py b/resources/lib/youtube_plugin/kodion/network/http_server.py index 327789099..aa20ca176 100644 --- a/resources/lib/youtube_plugin/kodion/network/http_server.py +++ b/resources/lib/youtube_plugin/kodion/network/http_server.py @@ -27,7 +27,7 @@ xbmcvfs, ) from ..constants import ADDON_ID, TEMP_PATH, paths -from ..logger import log_debug +from ..logger import log_debug, log_error from ..settings import Settings @@ -245,6 +245,9 @@ def do_POST(self): headers=li_headers, data=post_data, stream=True) + if not response or not response.ok: + self.send_error(response and response.status_code or 500) + return response_length = int(response.headers.get('content-length')) content = response.raw.read(response_length) @@ -529,7 +532,7 @@ def get_http_server(address=None, port=None): server = BaseHTTPServer.HTTPServer((address, port), RequestHandler) return server except socket_error as exc: - log_debug('HTTPServer: Failed to start |{address}:{port}| |{response}|' + log_error('HTTPServer: Failed to start |{address}:{port}| |{response}|' .format(address=address, port=port, response=str(exc))) xbmcgui.Dialog().notification(_addon_name, str(exc), @@ -545,30 +548,27 @@ def is_httpd_live(address=None, port=None): url = 'http://{address}:{port}{path}'.format(address=address, port=port, path=paths.PING) - try: - response = _server_requests.request(url) - result = response.status_code == 204 - if not result: - log_debug('HTTPServer: Ping |{address}:{port}| |{response}|' - .format(address=address, - port=port, - response=response.status_code)) - return result - except: - log_debug('HTTPServer: Ping |{address}:{port}| |{response}|' - .format(address=address, port=port, response='failed')) - return False + response = _server_requests.request(url) + result = response and response.status_code + if result == 204: + return True + + log_debug('HTTPServer: Ping |{address}:{port}| |{response}|' + .format(address=address, + port=port, + response=result or 'failed')) + return False def get_client_ip_address(address=None, port=None): + ip_address = None address = _settings.httpd_listen(for_request=True, ip_address=address) port = _settings.httpd_port(port=port) url = 'http://{address}:{port}{path}'.format(address=address, port=port, path=paths.IP) response = _server_requests.request(url) - ip_address = None - if response.status_code == 200: + if response and response.status_code == 200: response_json = response.json() if response_json: ip_address = response_json.get('ip') diff --git a/resources/lib/youtube_plugin/kodion/network/ip_api.py b/resources/lib/youtube_plugin/kodion/network/ip_api.py index 93a2cd2be..4c6ea9062 100644 --- a/resources/lib/youtube_plugin/kodion/network/ip_api.py +++ b/resources/lib/youtube_plugin/kodion/network/ip_api.py @@ -27,7 +27,7 @@ def response(self): def locate_requester(self): request_url = '/'.join((self._base_url, 'json')) response = self.request(request_url) - self._response = response.json() + self._response = response and response.json() or {} def success(self): successful = self.response().get('status', 'fail') == 'success' diff --git a/resources/lib/youtube_plugin/kodion/network/requests.py b/resources/lib/youtube_plugin/kodion/network/requests.py index fe3912dd3..70abae383 100644 --- a/resources/lib/youtube_plugin/kodion/network/requests.py +++ b/resources/lib/youtube_plugin/kodion/network/requests.py @@ -54,7 +54,7 @@ def __init__(self, exc_type=None): elif exc_type: self._default_exc = (RequestException, exc_type) else: - self._default_exc = RequestException + self._default_exc = (RequestException,) def __del__(self): self._session.close() @@ -101,6 +101,9 @@ def request(self, url, method='GET', verify=verify, cert=cert, json=json) + if not getattr(response, 'status_code', None): + raise self._default_exc[0](response=response) + if response_hook: if response_hook_kwargs is None: response_hook_kwargs = {} @@ -110,7 +113,8 @@ def request(self, url, method='GET', response.raise_for_status() except self._default_exc as exc: - response_text = exc.response and exc.response.text + exc_response = exc.response or response + response_text = exc_response and exc_response.text stack_trace = format_stack() error_details = {'exc': exc} @@ -118,7 +122,7 @@ def request(self, url, method='GET', if error_hook_kwargs is None: error_hook_kwargs = {} error_hook_kwargs['exc'] = exc - error_hook_kwargs['response'] = response + error_hook_kwargs['response'] = exc_response error_response = error_hook(**error_hook_kwargs) _title, _info, _detail, _response, _trace, _exc = error_response if _title is not None: @@ -139,7 +143,12 @@ def request(self, url, method='GET', error_title = 'Request failed' if error_info is None: - error_info = str(exc) + try: + error_info = 'Status: {0.status_code} - {0.reason}'.format( + exc.response + ) + except AttributeError: + error_info = str(exc) elif '{' in error_info: try: error_info = error_info.format(**error_details) @@ -161,10 +170,13 @@ def request(self, url, method='GET', ] if part])) if raise_exc: + if not callable(raise_exc): + raise_exc = self._default_exc[-1] + raise_exc = raise_exc(error_title) + if isinstance(raise_exc, BaseException): - raise raise_exc(exc) - if callable(raise_exc): - raise raise_exc(error_title)(exc) - raise self._default_exc(error_title)(exc) + raise_exc.__cause__ = exc + raise raise_exc + raise exc return response diff --git a/resources/lib/youtube_plugin/kodion/player/xbmc/xbmc_playlist.py b/resources/lib/youtube_plugin/kodion/player/xbmc/xbmc_playlist.py index e6bdc14c2..e3b61af48 100644 --- a/resources/lib/youtube_plugin/kodion/player/xbmc/xbmc_playlist.py +++ b/resources/lib/youtube_plugin/kodion/player/xbmc/xbmc_playlist.py @@ -33,9 +33,9 @@ def clear(self): self._playlist.clear() def add(self, base_item): - item = xbmc_items.video_listitem(self._context, base_item) + uri, item, _ = xbmc_items.video_listitem(self._context, base_item) if item: - self._playlist.add(base_item.get_uri(), listitem=item) + self._playlist.add(uri, listitem=item) def shuffle(self): self._playlist.shuffle() @@ -72,7 +72,7 @@ def get_items(self, properties=None, dumps=False): error = 'Requested |%s| received error |%s| and code: |%s|' % (rpc_request, message, code) else: error = 'Requested |%s| received error |%s|' % (rpc_request, str(response)) - self._context.log_debug(error) + self._context.log_error(error) return '[]' if dumps else [] def add_items(self, items, loads=False): diff --git a/resources/lib/youtube_plugin/kodion/plugin/xbmc/xbmc_runner.py b/resources/lib/youtube_plugin/kodion/plugin/xbmc/xbmc_runner.py index 6300f1bd4..b94d781a7 100644 --- a/resources/lib/youtube_plugin/kodion/plugin/xbmc/xbmc_runner.py +++ b/resources/lib/youtube_plugin/kodion/plugin/xbmc/xbmc_runner.py @@ -17,17 +17,23 @@ from ...exceptions import KodionException from ...items import AudioItem, DirectoryItem, ImageItem, UriItem, VideoItem from ...player import Playlist -from ...ui.xbmc import info_labels, xbmc_items +from ...ui.xbmc.xbmc_items import ( + audio_listitem, + directory_listitem, + image_listitem, + playback_item, + video_listitem +) class XbmcRunner(AbstractProviderRunner): def __init__(self): super(XbmcRunner, self).__init__() self.handle = None - self.settings = None def run(self, provider, context): self.handle = context.get_handle() + settings = context.get_settings() ui = context.get_ui() if ui.get_property('busy').lower() == 'true': @@ -46,6 +52,9 @@ def run(self, provider, context): playlist.add_items(items, loads=True) return False + if settings.is_setup_wizard_enabled(): + provider.run_wizard(context) + try: results = provider.navigate(context) except KodionException as exc: @@ -57,32 +66,29 @@ def run(self, provider, context): xbmcplugin.endOfDirectory(self.handle, succeeded=False) return False - self.settings = context.get_settings() - result, options = results - if isinstance(result, bool): xbmcplugin.endOfDirectory(self.handle, succeeded=result) return result - if isinstance(result, (VideoItem, AudioItem, UriItem)): - return self._set_resolved_url(context, result) + show_fanart = settings.show_fanart() - show_fanart = self.settings.show_fanart() + if isinstance(result, (VideoItem, AudioItem, UriItem)): + return self._set_resolved_url(context, result, show_fanart) if isinstance(result, DirectoryItem): item_count = 1 - items = [self._add_directory(result, show_fanart)] + items = [directory_listitem(context, result, show_fanart)] elif isinstance(result, (list, tuple)): item_count = len(result) items = [ - self._add_directory(item, show_fanart) + directory_listitem(context, item, show_fanart) if isinstance(item, DirectoryItem) - else self._add_video(context, item) + else video_listitem(context, item, show_fanart) if isinstance(item, VideoItem) - else self._add_audio(context, item) + else audio_listitem(context, item, show_fanart) if isinstance(item, AudioItem) - else self._add_image(item, show_fanart) + else image_listitem(context, item, show_fanart) if isinstance(item, ImageItem) else None for item in result @@ -102,7 +108,7 @@ def run(self, provider, context): ) return succeeded - def _set_resolved_url(self, context, base_item): + def _set_resolved_url(self, context, base_item, show_fanart): uri = base_item.get_uri() if base_item.playable: @@ -112,94 +118,21 @@ def _set_resolved_url(self, context, base_item): playlist = Playlist('video', context) ui.set_property('playlist', playlist.get_items(dumps=True)) - item = xbmc_items.to_playback_item(context, base_item) + item = playback_item(context, base_item, show_fanart) xbmcplugin.setResolvedUrl(self.handle, succeeded=True, listitem=item) return True if context.is_plugin_path(uri): - context.log_debug('Redirecting to |{0}|'.format(uri)) + context.log_debug('Redirecting to: |{0}|'.format(uri)) context.execute('RunPlugin({0})'.format(uri)) + else: + context.log_debug('Running script: |{0}|'.format(uri)) + context.execute('RunScript({0})'.format(uri)) xbmcplugin.endOfDirectory(self.handle, succeeded=False, updateListing=False, cacheToDisc=False) return False - - @staticmethod - def _add_directory(directory_item, show_fanart=False): - art = {'icon': 'DefaultFolder.png', - 'thumb': directory_item.get_image()} - - item = xbmcgui.ListItem(label=directory_item.get_name(), offscreen=True) - item_info = info_labels.create_from_item(directory_item) - xbmc_items.set_info_tag(item, item_info, 'video') - - # only set fanart if enabled - if show_fanart: - fanart = directory_item.get_fanart() - if fanart: - art['fanart'] = fanart - - item.setArt(art) - - context_menu = directory_item.get_context_menu() - if context_menu is not None: - item.addContextMenuItems( - context_menu, replaceItems=directory_item.replace_context_menu() - ) - - item.setPath(directory_item.get_uri()) - - is_folder = not directory_item.is_action() - - if directory_item.next_page: - item.setProperty('specialSort', 'bottom') - - # make channel_subscription_id property available for keymapping - subscription_id = directory_item.get_channel_subscription_id() - if subscription_id: - item.setProperty('channel_subscription_id', subscription_id) - - return directory_item.get_uri(), item, is_folder - - @staticmethod - def _add_video(context, video_item): - item = xbmc_items.video_listitem(context, video_item) - item.setPath(video_item.get_uri()) - return video_item.get_uri(), item, False - - @staticmethod - def _add_image(image_item, show_fanart=False): - art = {'icon': 'DefaultPicture.png', - 'thumb': image_item.get_image()} - - item = xbmcgui.ListItem(label=image_item.get_name(), offscreen=True) - - # only set fanart is enabled - if show_fanart: - fanart = image_item.get_fanart() - if fanart: - art['fanart'] = fanart - - item.setArt(art) - - context_menu = image_item.get_context_menu() - if context_menu is not None: - item.addContextMenuItems( - context_menu, replaceItems=image_item.replace_context_menu() - ) - - item.setInfo(type='picture', - infoLabels=info_labels.create_from_item(image_item)) - - item.setPath(image_item.get_uri()) - return image_item.get_uri(), item, False - - @staticmethod - def _add_audio(context, audio_item): - item = xbmc_items.audio_listitem(context, audio_item) - item.setPath(audio_item.get_uri()) - return audio_item.get_uri(), item, False diff --git a/resources/lib/youtube_plugin/kodion/runner.py b/resources/lib/youtube_plugin/kodion/runner.py index 458fe9e97..9f62065e2 100644 --- a/resources/lib/youtube_plugin/kodion/runner.py +++ b/resources/lib/youtube_plugin/kodion/runner.py @@ -11,6 +11,7 @@ from __future__ import absolute_import, division, unicode_literals import copy +import platform import timeit from . import debug @@ -33,31 +34,28 @@ def run(provider, context=None): context = Context() context.log_debug('Starting Kodion framework by bromix...') - python_version = 'Unknown version of Python' - try: - import platform - - python_version = str(platform.python_version()) - python_version = 'Python %s' % python_version - except: - # do nothing - pass - - version = context.get_system_version() - name = context.get_name() + addon_version = context.get_version() + python_version = 'Python {0}'.format(platform.python_version()) + redacted = '' - context_params = copy.deepcopy(context.get_params()) - if 'api_key' in context_params: - context_params['api_key'] = redacted - if 'client_id' in context_params: - context_params['client_id'] = redacted - if 'client_secret' in context_params: - context_params['client_secret'] = redacted - - context.log_notice('Running: %s (%s) on %s with %s\n\tPath: %s\n\tParams: %s' % - (name, addon_version, version, python_version, - context.get_path(), context_params)) + params = copy.deepcopy(context.get_params()) + if 'api_key' in params: + params['api_key'] = redacted + if 'client_id' in params: + params['client_id'] = redacted + if 'client_secret' in params: + params['client_secret'] = redacted + + context.log_notice('Running: {plugin} ({version}) on {kodi} with {python}\n' + 'Path: {path}\n' + 'Params: {params}' + .format(plugin=context.get_name(), + version=addon_version, + kodi=context.get_system_version(), + python=python_version, + path=context.get_path(), + params=params)) __RUNNER__.run(provider, context) provider.tear_down(context) @@ -65,6 +63,10 @@ def run(provider, context=None): elapsed = timeit.default_timer() - start_time if __DEBUG_RUNTIME: - debug.runtime(context, addon_version, elapsed, single_file=__DEBUG_RUNTIME_SINGLE_FILE) + debug.runtime(context, + addon_version, + elapsed, + single_file=__DEBUG_RUNTIME_SINGLE_FILE) - context.log_debug('Shutdown of Kodion after |%s| seconds' % str(round(elapsed, 4))) + context.log_debug('Shutdown of Kodion after |{elapsed:.4}| seconds' + .format(elapsed=elapsed)) diff --git a/resources/lib/youtube_plugin/kodion/settings/abstract_settings.py b/resources/lib/youtube_plugin/kodion/settings/abstract_settings.py index 3dac23250..2582a9bb3 100644 --- a/resources/lib/youtube_plugin/kodion/settings/abstract_settings.py +++ b/resources/lib/youtube_plugin/kodion/settings/abstract_settings.py @@ -16,6 +16,11 @@ class AbstractSettings(object): + _vars = vars() + for name, value in settings.__dict__.items(): + _vars[name] = value + del _vars + VALUE_FROM_STR = { 'false': False, 'true': True, @@ -229,30 +234,6 @@ def api_secret(self, new_secret=None): return new_secret return self.get_string(settings.API_SECRET) - def api_last_hash(self, new_hash=None): - if new_hash is not None: - self.set_string(settings.API_LAST_HASH, new_hash) - return new_hash - return self.get_string(settings.API_LAST_HASH, '') - - def user_access_token(self, new_access_token=None): - if new_access_token is not None: - self.set_string(settings.USER_ACCESS_TOKEN, new_access_token) - return new_access_token - return self.get_string(settings.USER_ACCESS_TOKEN, '') - - def user_refresh_token(self, new_refresh_token=None): - if new_refresh_token is not None: - self.set_string(settings.USER_REFRESH_TOKEN, new_refresh_token) - return new_refresh_token - return self.get_string(settings.USER_REFRESH_TOKEN, '') - - def user_token_expiration(self, new_token_expiration=None): - if new_token_expiration is not None: - self.set_int(settings.USER_TOKEN_EXPIRATION, new_token_expiration) - return new_token_expiration - return self.get_int(settings.USER_TOKEN_EXPIRATION, -1) - def get_location(self): location = self.get_string(settings.LOCATION, '').replace(' ', '').strip() coords = location.split(',') @@ -275,7 +256,7 @@ def set_location(self, value): self.set_string(settings.LOCATION, value) def get_location_radius(self): - return ''.join((str(self.get_int(settings.LOCATION_RADIUS, 500)), 'km')) + return ''.join((self.get_int(settings.LOCATION_RADIUS, 500, str), 'km')) def get_play_count_min_percent(self): return self.get_int(settings.PLAY_COUNT_MIN_PERCENT, 0) diff --git a/resources/lib/youtube_plugin/kodion/settings/xbmc/xbmc_plugin_settings.py b/resources/lib/youtube_plugin/kodion/settings/xbmc/xbmc_plugin_settings.py index 682208539..710e6cef2 100644 --- a/resources/lib/youtube_plugin/kodion/settings/xbmc/xbmc_plugin_settings.py +++ b/resources/lib/youtube_plugin/kodion/settings/xbmc/xbmc_plugin_settings.py @@ -73,10 +73,14 @@ def get_bool(self, setting, default=None, echo=None): error = False try: value = bool(self._get_bool(self._type(), setting)) - except (AttributeError, TypeError) as exc: + except (TypeError, ValueError) as exc: error = exc - value = self.get_string(setting, echo=False) - value = AbstractSettings.VALUE_FROM_STR.get(value.lower(), default) + try: + value = self.get_string(setting, echo=False).lower() + value = AbstractSettings.VALUE_FROM_STR.get(value, default) + except TypeError as exc: + error = exc + value = default except RuntimeError as exc: error = exc value = default @@ -93,9 +97,11 @@ def get_bool(self, setting, default=None, echo=None): def set_bool(self, setting, value, echo=None): try: error = not self._set_bool(self._type(), setting, value) - if not error: + if error: + error = 'failed' + else: self._cache[setting] = value - except RuntimeError as exc: + except (RuntimeError, TypeError) as exc: error = exc if self._echo and echo is not False: @@ -115,10 +121,10 @@ def get_int(self, setting, default=-1, process=None, echo=None): value = int(self._get_int(self._type(), setting)) if process: value = process(value) - except (AttributeError, TypeError, ValueError) as exc: + except (TypeError, ValueError) as exc: error = exc - value = self.get_string(setting, echo=False) try: + value = self.get_string(setting, echo=False) value = int(value) except (TypeError, ValueError) as exc: error = exc @@ -139,9 +145,11 @@ def get_int(self, setting, default=-1, process=None, echo=None): def set_int(self, setting, value, echo=None): try: error = not self._set_int(self._type(), setting, value) - if not error: + if error: + error = 'failed' + else: self._cache[setting] = value - except RuntimeError as exc: + except (RuntimeError, TypeError) as exc: error = exc if self._echo and echo is not False: @@ -159,7 +167,7 @@ def get_string(self, setting, default='', echo=None): error = False try: value = self._get_str(self._type(), setting) or default - except RuntimeError as exc: + except (RuntimeError, TypeError) as exc: error = exc value = default @@ -175,9 +183,11 @@ def get_string(self, setting, default='', echo=None): def set_string(self, setting, value, echo=None): try: error = not self._set_str(self._type(), setting, value) - if not error: + if error: + error = 'failed' + else: self._cache[setting] = value - except RuntimeError as exc: + except (RuntimeError, TypeError) as exc: error = exc if self._echo and echo is not False: @@ -197,7 +207,7 @@ def get_string_list(self, setting, default=None, echo=None): value = self._get_str_list(self._type(), setting) if not value: value = [] if default is None else default - except RuntimeError as exc: + except (RuntimeError, TypeError) as exc: error = exc value = default @@ -213,9 +223,11 @@ def get_string_list(self, setting, default=None, echo=None): def set_string_list(self, setting, value, echo=None): try: error = not self._set_str_list(self._type(), setting, value) - if not error: + if error: + error = 'failed' + else: self._cache[setting] = value - except RuntimeError as exc: + except (RuntimeError, TypeError) as exc: error = exc if self._echo and echo is not False: diff --git a/resources/lib/youtube_plugin/kodion/sql_store/playback_history.py b/resources/lib/youtube_plugin/kodion/sql_store/playback_history.py index 880fac4da..9b49ac196 100644 --- a/resources/lib/youtube_plugin/kodion/sql_store/playback_history.py +++ b/resources/lib/youtube_plugin/kodion/sql_store/playback_history.py @@ -9,7 +9,7 @@ from __future__ import absolute_import, division, unicode_literals -from .storage import Storage +from .storage import Storage, fromtimestamp class PlaybackHistory(Storage): @@ -21,15 +21,17 @@ class PlaybackHistory(Storage): def __init__(self, filepath): super(PlaybackHistory, self).__init__(filepath) - def _add_last_played(self, value, item): - value['last_played'] = self._convert_timestamp(item[1]) + @staticmethod + def _add_last_played(value, item): + value['last_played'] = fromtimestamp(item[1]) return value - def get_items(self, keys=None): + def get_items(self, keys=None, limit=-1): result = self._get_by_ids(keys, oldest_first=False, process=self._add_last_played, - as_dict=True) + as_dict=True, + limit=limit) return result def get_item(self, key): diff --git a/resources/lib/youtube_plugin/kodion/sql_store/storage.py b/resources/lib/youtube_plugin/kodion/sql_store/storage.py index 3508b45b3..ce76b3bcb 100644 --- a/resources/lib/youtube_plugin/kodion/sql_store/storage.py +++ b/resources/lib/youtube_plugin/kodion/sql_store/storage.py @@ -14,11 +14,10 @@ import pickle import sqlite3 import time -from datetime import datetime from traceback import format_stack from ..logger import log_error -from ..utils.datetime_parser import since_epoch +from ..utils.datetime_parser import fromtimestamp, since_epoch from ..utils.methods import make_dirs @@ -317,7 +316,7 @@ def _set(self, item_id, item): self._execute(cursor, self._sql['set'], values=values) def _set_many(self, items, flatten=False): - now = since_epoch(datetime.now()) + now = since_epoch() num_items = len(items) if flatten: @@ -368,7 +367,7 @@ def _decode(obj, process=None, item=None): @staticmethod def _encode(key, obj, timestamp=None): - timestamp = timestamp or since_epoch(datetime.now()) + timestamp = timestamp or since_epoch() blob = sqlite3.Binary(pickle.dumps( obj, protocol=pickle.HIGHEST_PROTOCOL )) @@ -383,7 +382,7 @@ def _get(self, item_id, process=None, seconds=None): item = result.fetchone() if result else None if not item: return None - cut_off = since_epoch(datetime.now()) - seconds if seconds else 0 + cut_off = since_epoch() - seconds if seconds else 0 if not cut_off or item[1] >= cut_off: return self._decode(item[2], process, item) return None @@ -402,7 +401,7 @@ def _get_by_ids(self, item_ids=None, oldest_first=True, limit=-1, query = self._sql['get_by_key'].format('?,' * (num_ids - 1) + '?') item_ids = tuple(item_ids) - cut_off = since_epoch(datetime.now()) - seconds if seconds else 0 + cut_off = since_epoch() - seconds if seconds else 0 with self as (db, cursor), db: result = self._execute(cursor, query, item_ids) if as_dict: @@ -418,7 +417,7 @@ def _get_by_ids(self, item_ids=None, oldest_first=True, limit=-1, else: result = [ (item[0], - self._convert_timestamp(item[1]), + fromtimestamp(item[1]), self._decode(item[2], process, item)) for item in result if not cut_off or item[1] >= cut_off ] @@ -434,7 +433,3 @@ def _remove_many(self, item_ids): with self as (db, cursor), db: self._execute(cursor, query, tuple(item_ids)) self._execute(cursor, 'VACUUM') - - @classmethod - def _convert_timestamp(cls, val): - return datetime.fromtimestamp(val) 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 1f5c05769..73e8da332 100644 --- a/resources/lib/youtube_plugin/kodion/ui/abstract_context_ui.py +++ b/resources/lib/youtube_plugin/kodion/ui/abstract_context_ui.py @@ -8,6 +8,8 @@ See LICENSES/GPL-2.0-only for more information. """ +from __future__ import absolute_import, division, unicode_literals + class AbstractContextUI(object): def __init__(self): @@ -16,9 +18,6 @@ def __init__(self): def create_progress_dialog(self, heading, text=None, background=False): raise NotImplementedError() - def get_skin_id(self): - raise NotImplementedError() - def on_keyboard_input(self, title, default='', hidden=False): raise NotImplementedError() diff --git a/resources/lib/youtube_plugin/kodion/ui/abstract_progress_dialog.py b/resources/lib/youtube_plugin/kodion/ui/abstract_progress_dialog.py index dd6eb24f3..83ab3ac57 100644 --- a/resources/lib/youtube_plugin/kodion/ui/abstract_progress_dialog.py +++ b/resources/lib/youtube_plugin/kodion/ui/abstract_progress_dialog.py @@ -8,6 +8,10 @@ See LICENSES/GPL-2.0-only for more information. """ +from __future__ import absolute_import, division, unicode_literals + +from ..compatibility import string_type + class AbstractProgressDialog(object): def __init__(self, dialog, heading, text, total=100): @@ -49,7 +53,7 @@ def update(self, steps=1, text=None): else: position = int(100 * self._position / self._total) - if isinstance(text, str): + if isinstance(text, string_type): self._dialog.update(percent=position, message=text) else: self._dialog.update(percent=position) diff --git a/resources/lib/youtube_plugin/kodion/ui/xbmc/info_labels.py b/resources/lib/youtube_plugin/kodion/ui/xbmc/info_labels.py index 0a2715bd4..191a4379f 100644 --- a/resources/lib/youtube_plugin/kodion/ui/xbmc/info_labels.py +++ b/resources/lib/youtube_plugin/kodion/ui/xbmc/info_labels.py @@ -20,10 +20,11 @@ def _process_date_value(info_labels, name, param): def _process_datetime_value(info_labels, name, param): - if param: - info_labels[name] = (param.isoformat('T') - if current_system_version.compatible(19, 0) else - param.strftime('%d.%d.%Y')) + if not param: + return + info_labels[name] = (param.replace(microsecond=0, tzinfo=None).isoformat() + if current_system_version.compatible(19, 0) else + param.strftime('%d.%m.%Y')) def _process_int_value(info_labels, name, param): @@ -81,25 +82,12 @@ def _process_mediatype(info_labels, name, param): info_labels[name] = param -def _process_last_played(info_labels, name, param): - if param: - try: - info_labels[name] = param.strftime('%Y-%m-%d %H:%M:%S') - except AttributeError: - info_labels[name] = param - - def create_from_item(base_item): info_labels = {} # 'date' = '1982-03-09' (string) _process_datetime_value(info_labels, 'date', base_item.get_date()) - # 'count' = 12 (integer) - # Can be used to store an id for later, or for sorting purposes - # Used for video view count - _process_int_value(info_labels, 'count', base_item.get_count()) - # Directory if isinstance(base_item, DirectoryItem): _process_string_value(info_labels, 'plot', base_item.get_plot()) @@ -131,6 +119,11 @@ def create_from_item(base_item): # play count _process_int_value(info_labels, 'playcount', base_item.get_play_count()) + # 'count' = 12 (integer) + # Can be used to store an id for later, or for sorting purposes + # Used for Youtube video view count + _process_int_value(info_labels, 'count', base_item.get_count()) + # studio _process_studios(info_labels, 'studio', base_item.get_studio()) @@ -144,7 +137,7 @@ def create_from_item(base_item): # 'duration' = '3:18' (string) _process_video_duration(info_labels, base_item.get_duration()) - _process_last_played(info_labels, 'lastplayed', base_item.get_last_played()) + _process_datetime_value(info_labels, 'lastplayed', base_item.get_last_played()) # 'rating' = 4.5 (float) _process_video_rating(info_labels, base_item.get_rating()) 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 718805ff0..b6fc6a7e2 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 @@ -13,7 +13,7 @@ from .xbmc_progress_dialog import XbmcProgressDialog, XbmcProgressDialogBG from ..abstract_context_ui import AbstractContextUI from ...compatibility import xbmc, xbmcgui -from ...constants import ADDON_ID, ADDON_PATH +from ...constants import ADDON_ID from ...utils import to_unicode @@ -24,27 +24,15 @@ def __init__(self, xbmc_addon, context): self._xbmc_addon = xbmc_addon self._context = context - self._view_mode = None def create_progress_dialog(self, heading, text=None, background=False): - if background and self._context.get_system_version().get_version() > (12, 3): + if background: return XbmcProgressDialogBG(heading, text) return XbmcProgressDialog(heading, text) - def get_skin_id(self): - return xbmc.getSkinDir() def on_keyboard_input(self, title, default='', hidden=False): - # fallback for Frodo - if self._context.get_system_version().get_version() <= (12, 3): - keyboard = xbmc.Keyboard(default, title, hidden) - keyboard.doModal() - if keyboard.isConfirmed() and keyboard.getText(): - text = to_unicode(keyboard.getText()) - return True, text - return False, '' - # Starting with Gotham (13.X > ...) dialog = xbmcgui.Dialog() result = dialog.input(title, to_unicode(default), type=xbmcgui.INPUT_ALPHANUM) @@ -137,14 +125,11 @@ def open_settings(self): self._xbmc_addon.openSettings() def refresh_container(self): - xbmc.executebuiltin( - 'RunScript({path}/resources/lib/youtube_plugin/refresh.py)' - .format(path=ADDON_PATH) - ) - - @staticmethod - def get_info_label(value): - return xbmc.getInfoLabel(value) + # TODO: find out why the RunScript call is required + # xbmc.executebuiltin("Container.Refresh") + xbmc.executebuiltin('RunScript({addon_id},action/refresh)'.format( + addon_id=ADDON_ID + )) @staticmethod def set_property(property_id, value): @@ -220,10 +205,13 @@ def new_line(value=1, cr_before=0, cr_after=0): )) def set_focus_next_item(self): - cid = xbmcgui.Window(xbmcgui.getCurrentWindowId()).getFocusId() + list_id = xbmcgui.Window(xbmcgui.getCurrentWindowId()).getFocusId() try: - current_position = int(self.get_info_label('Container.Position')) + 1 - self._context.execute('SetFocus(%s,%s)' % (cid, str(current_position))) + position = self._context.get_infolabel('Container.Position') + next_position = int(position) + 1 + self._context.execute('SetFocus({list_id},{position})'.format( + list_id=list_id, position=next_position + )) except ValueError: pass diff --git a/resources/lib/youtube_plugin/kodion/ui/xbmc/xbmc_items.py b/resources/lib/youtube_plugin/kodion/ui/xbmc/xbmc_items.py index 5ede3e8c1..3cf8fdb7a 100644 --- a/resources/lib/youtube_plugin/kodion/ui/xbmc/xbmc_items.py +++ b/resources/lib/youtube_plugin/kodion/ui/xbmc/xbmc_items.py @@ -11,39 +11,12 @@ from __future__ import absolute_import, division, unicode_literals from . import info_labels -from ...compatibility import xbmcgui +from ...compatibility import set_info_tag, xbmcgui from ...items import AudioItem, UriItem, VideoItem from ...utils import current_system_version, datetime_parser -try: - from infotagger.listitem import set_info_tag -except ImportError: - def set_info_tag(listitem, infolabels, tag_type, *_args, **_kwargs): - listitem.setInfo(tag_type, infolabels) - return ListItemInfoTag(listitem, tag_type) - - - class ListItemInfoTag(object): - __slots__ = ('__li__',) - - def __init__(self, listitem, *_args, **_kwargs): - self.__li__ = listitem - - def add_stream_info(self, *args, **kwargs): - return self.__li__.addStreamInfo(*args, **kwargs) - - def set_resume_point(self, - infoproperties, - resume_key='ResumeTime', - total_key='TotalTime'): - if resume_key in infoproperties: - infoproperties[resume_key] = str(infoproperties[resume_key]) - if total_key in infoproperties: - infoproperties[total_key] = str(infoproperties[total_key]) - - -def video_playback_item(context, video_item): +def video_playback_item(context, video_item, show_fanart=None): uri = video_item.get_uri() context.log_debug('Converting VideoItem |%s|' % uri) @@ -68,7 +41,6 @@ def video_playback_item(context, video_item): } props = { 'isPlayable': str(video_item.playable).lower(), - 'ForceResolvePlugin': 'true', } if (alternative_player @@ -117,6 +89,7 @@ def video_playback_item(context, video_item): video_item.set_uri('|'.join((uri, headers))) list_item = xbmcgui.ListItem(**kwargs) + if mime_type: list_item.setContentLookup(False) list_item.setMimeType(mime_type) @@ -136,9 +109,14 @@ def video_playback_item(context, video_item): if prop_value: props['TotalTime'] = prop_value - fanart = settings.show_fanart() and video_item.get_fanart() or '' - thumb = video_item.get_image() or 'DefaultVideo.png' - list_item.setArt({'icon': thumb, 'thumb': thumb, 'fanart': fanart}) + if show_fanart is None: + show_fanart = settings.show_fanart() + image = video_item.get_image() or 'DefaultVideo.png' + list_item.setArt({ + 'icon': image, + 'fanart': show_fanart and video_item.get_fanart() or '', + 'thumb': image, + }) if video_item.subtitles: list_item.setSubtitles(video_item.subtitles) @@ -156,7 +134,7 @@ def video_playback_item(context, video_item): return list_item -def audio_listitem(context, audio_item): +def audio_listitem(context, audio_item, show_fanart=None): uri = audio_item.get_uri() context.log_debug('Converting AudioItem |%s|' % uri) @@ -173,11 +151,14 @@ def audio_listitem(context, audio_item): list_item = xbmcgui.ListItem(**kwargs) - fanart = (context.get_settings().show_fanart() - and audio_item.get_fanart() - or '') - thumb = audio_item.get_image() or 'DefaultAudio.png' - list_item.setArt({'icon': thumb, 'thumb': thumb, 'fanart': fanart}) + if show_fanart is None: + show_fanart = context.get_settings().show_fanart() + image = audio_item.get_image() or 'DefaultAudio.png' + list_item.setArt({ + 'icon': image, + 'fanart': show_fanart and audio_item.get_fanart() or '', + 'thumb': image, + }) item_info = info_labels.create_from_item(audio_item) set_info_tag(list_item, item_info, 'music') @@ -190,7 +171,101 @@ def audio_listitem(context, audio_item): context_menu, replaceItems=audio_item.replace_context_menu() ) - return list_item + return uri, list_item, False + + +def directory_listitem(context, directory_item, show_fanart=None): + uri = directory_item.get_uri() + context.log_debug('Converting DirectoryItem |%s|' % uri) + + kwargs = { + 'label': directory_item.get_name(), + 'path': uri, + 'offscreen': True, + } + props = { + 'specialSort': 'bottom' if directory_item.next_page else 'top', + 'ForceResolvePlugin': 'true', + } + + list_item = xbmcgui.ListItem(**kwargs) + + # make channel_subscription_id property available for keymapping + prop_value = directory_item.get_channel_subscription_id() + if prop_value: + props['channel_subscription_id'] = prop_value + + if show_fanart is None: + show_fanart = context.get_settings().show_fanart() + image = directory_item.get_image() or 'DefaultFolder.png' + list_item.setArt({ + 'icon': image, + 'fanart': show_fanart and directory_item.get_fanart() or '', + 'thumb': image, + }) + + item_info = info_labels.create_from_item(directory_item) + set_info_tag(list_item, item_info, 'video') + + """ + # ListItems that do not open a lower level list should have the isFolder + # parameter of the xbmcplugin.addDirectoryItem set to False, however this + # now appears to mark the ListItem as playable, even if the IsPlayable + # property is not set or set to "false". + # Set isFolder to True as a workaround, regardless of whether the ListItem + # is actually a folder. + is_folder = not directory_item.is_action() + """ + is_folder = True + + list_item.setProperties(props) + + context_menu = directory_item.get_context_menu() + if context_menu is not None: + list_item.addContextMenuItems( + context_menu, replaceItems=directory_item.replace_context_menu() + ) + + return uri, list_item, is_folder + + +def image_listitem(context, image_item, show_fanart=None): + uri = image_item.get_uri() + context.log_debug('Converting ImageItem |%s|' % uri) + + kwargs = { + 'label': image_item.get_name(), + 'path': uri, + 'offscreen': True, + } + props = { + 'isPlayable': str(image_item.playable).lower(), + 'ForceResolvePlugin': 'true', + } + + list_item = xbmcgui.ListItem(**kwargs) + + if show_fanart is None: + show_fanart = context.get_settings().show_fanart() + image = image_item.get_image() or 'DefaultPicture.png' + list_item.setArt({ + 'icon': image, + 'fanart': show_fanart and image_item.get_fanart() or '', + 'thumb': image, + }) + + item_info = info_labels.create_from_item(image_item) + set_info_tag(list_item, item_info, 'picture') + + list_item.setProperties(props) + + context_menu = image_item.get_context_menu() + if context_menu is not None: + list_item.addContextMenuItems( + context_menu, replaceItems=image_item.replace_context_menu() + ) + + return uri, list_item, False def uri_listitem(context, uri_item): @@ -212,7 +287,7 @@ def uri_listitem(context, uri_item): return list_item -def video_listitem(context, video_item): +def video_listitem(context, video_item, show_fanart=None): uri = video_item.get_uri() context.log_debug('Converting VideoItem |%s|' % uri) @@ -271,11 +346,14 @@ def video_listitem(context, video_item): if prop_value: props['playlist_item_id'] = prop_value - fanart = (context.get_settings().show_fanart() - and video_item.get_fanart() - or '') - thumb = video_item.get_image() or 'DefaultVideo.png' - list_item.setArt({'icon': thumb, 'thumb': thumb, 'fanart': fanart}) + if show_fanart is None: + show_fanart = context.get_settings().show_fanart() + image = video_item.get_image() + list_item.setArt({ + 'icon': image or 'DefaultVideo.png', + 'fanart': show_fanart and video_item.get_fanart() or '', + 'thumb': image, + }) if video_item.subtitles: list_item.setSubtitles(video_item.subtitles) @@ -296,17 +374,18 @@ def video_listitem(context, video_item): context_menu, replaceItems=video_item.replace_context_menu() ) - return list_item + return uri, list_item, False -def to_playback_item(context, base_item): +def playback_item(context, base_item, show_fanart=None): if isinstance(base_item, UriItem): return uri_listitem(context, base_item) if isinstance(base_item, AudioItem): - return audio_listitem(context, base_item) + _, item, _ = audio_listitem(context, base_item, show_fanart) + return item if isinstance(base_item, VideoItem): - return video_playback_item(context, base_item) + return video_playback_item(context, base_item, show_fanart) return None diff --git a/resources/lib/youtube_plugin/kodion/utils/datetime_parser.py b/resources/lib/youtube_plugin/kodion/utils/datetime_parser.py index 42791ccd7..df1487afe 100644 --- a/resources/lib/youtube_plugin/kodion/utils/datetime_parser.py +++ b/resources/lib/youtube_plugin/kodion/utils/datetime_parser.py @@ -16,96 +16,134 @@ from sys import modules from ..exceptions import KodionException - +from ..logger import log_error + +try: + from datetime import timezone +except ImportError: + timezone = None + + +__RE_MATCH_TIME_ONLY__ = re.compile( + r'^(?P[0-9]{2})(:?(?P[0-9]{2})(:?(?P[0-9]{2}))?)?$' +) +__RE_MATCH_DATE_ONLY__ = re.compile( + r'^(?P[0-9]{4})[-/.]?(?P[0-9]{2})[-/.]?(?P[0-9]{2})$' +) +__RE_MATCH_DATETIME__ = re.compile( + r'^(?P[0-9]{4})[-/.]?(?P[0-9]{2})[-/.]?(?P[0-9]{2})' + r'["T ](?P[0-9]{2}):?(?P[0-9]{2}):?(?P[0-9]{2})' +) +__RE_MATCH_PERIOD__ = re.compile( + r'P((?P\d+)Y)?((?P\d+)M)?((?P\d+)D)?' + r'(T((?P\d+)H)?((?P\d+)M)?((?P\d+)S)?)?' +) +__RE_MATCH_ABBREVIATED__ = re.compile( + r'\w+, (?P\d+) (?P\w+) (?P\d+)' + r' (?P\d+):(?P\d+):(?P\d+)' +) + +__INTERNAL_CONSTANTS__ = { + 'epoch_dt': ( + datetime.fromtimestamp(0, tz=timezone.utc) if timezone + else datetime.fromtimestamp(0) + ), + 'local_offset': None, + 'Jan': 1, + 'Feb': 2, + 'Mar': 3, + 'Apr': 4, + 'May': 5, + 'June': 6, + 'Jun': 6, + 'July': 7, + 'Jul': 7, + 'Aug': 8, + 'Sept': 9, + 'Sep': 9, + 'Oct': 10, + 'Nov': 11, + 'Dec': 12, +} now = datetime.now - -__RE_MATCH_TIME_ONLY__ = re.compile(r'^(?P[0-9]{2})(:?(?P[0-9]{2})(:?(?P[0-9]{2}))?)?$') -__RE_MATCH_DATE_ONLY__ = re.compile(r'^(?P[0-9]{4})[-/.]?(?P[0-9]{2})[-/.]?(?P[0-9]{2})$') -__RE_MATCH_DATETIME__ = re.compile(r'^(?P[0-9]{4})[-/.]?(?P[0-9]{2})[-/.]?(?P[0-9]{2})["T ](?P[0-9]{2}):?(?P[0-9]{2}):?(?P[0-9]{2})') -__RE_MATCH_PERIOD__ = re.compile(r'P((?P\d+)Y)?((?P\d+)M)?((?P\d+)D)?(T((?P\d+)H)?((?P\d+)M)?((?P\d+)S)?)?') -__RE_MATCH_ABBREVIATED__ = re.compile(r'(\w+), (?P\d+) (?P\w+) (?P\d+) (?P\d+):(?P\d+):(?P\d+)') - -__LOCAL_OFFSET__ = now() - datetime.utcnow() - -__EPOCH_DT__ = datetime.fromtimestamp(0) - - -def parse(datetime_string, as_utc=True): - offset = 0 if as_utc else None - - def _to_int(value): - if value is None: - return 0 - return int(value) - - # match time only '00:45:10' - time_only_match = __RE_MATCH_TIME_ONLY__.match(datetime_string) - if time_only_match: - return utc_to_local( - dt=datetime.combine( - date.today(), - dt_time(hour=_to_int(time_only_match.group('hour')), - minute=_to_int(time_only_match.group('minute')), - second=_to_int(time_only_match.group('second'))) - ), - offset=offset +fromtimestamp = datetime.fromtimestamp + + +def parse(datetime_string): + if not datetime_string: + return None + + # match time only "00:45:10" + match = __RE_MATCH_TIME_ONLY__.match(datetime_string) + if match: + match = { + group: int(value) + for group, value in match.groupdict().items() + if value + } + return datetime.combine( + date=date.today(), + time=dt_time(**match) ).time() # match date only '2014-11-08' - date_only_match = __RE_MATCH_DATE_ONLY__.match(datetime_string) - if date_only_match: - return utc_to_local( - dt=datetime(_to_int(date_only_match.group('year')), - _to_int(date_only_match.group('month')), - _to_int(date_only_match.group('day'))), - offset=offset - ) + match = __RE_MATCH_DATE_ONLY__.match(datetime_string) + if match: + match = { + group: int(value) + for group, value in match.groupdict().items() + if value + } + return datetime(**match) # full date time - date_time_match = __RE_MATCH_DATETIME__.match(datetime_string) - if date_time_match: - return utc_to_local( - dt=datetime(_to_int(date_time_match.group('year')), - _to_int(date_time_match.group('month')), - _to_int(date_time_match.group('day')), - _to_int(date_time_match.group('hour')), - _to_int(date_time_match.group('minute')), - _to_int(date_time_match.group('second'))), - offset=offset - ) + match = __RE_MATCH_DATETIME__.match(datetime_string) + if match: + match = { + group: int(value) + for group, value in match.groupdict().items() + if value + } + return datetime(**match) # period - at the moment we support only hours, minutes and seconds # e.g. videos and audio - period_match = __RE_MATCH_PERIOD__.match(datetime_string) - if period_match: - return timedelta(hours=_to_int(period_match.group('hours')), - minutes=_to_int(period_match.group('minutes')), - seconds=_to_int(period_match.group('seconds'))) + match = __RE_MATCH_PERIOD__.match(datetime_string) + if match: + match = { + group: int(value) + for group, value in match.groupdict().items() + if value + } + return timedelta(**match) # abbreviated match - abbreviated_match = __RE_MATCH_ABBREVIATED__.match(datetime_string) - if abbreviated_match: - month = {'Jan': 1, 'Feb': 2, 'Mar': 3, 'Apr': 4, 'May': 5, 'June': 6, - 'Jun': 6, 'July': 7, 'Jul': 7, 'Aug': 8, 'Sept': 9, 'Sep': 9, - 'Oct': 10, 'Nov': 11, 'Dec': 12} - return utc_to_local( - dt=datetime(year=_to_int(abbreviated_match.group('year')), - month=month[abbreviated_match.group('month')], - day=_to_int(abbreviated_match.group('day')), - hour=_to_int(abbreviated_match.group('hour')), - minute=_to_int(abbreviated_match.group('minute')), - second=_to_int(abbreviated_match.group('second'))), - offset=offset - ) + match = __RE_MATCH_ABBREVIATED__.match(datetime_string) + if match: + match = { + group: ( + __INTERNAL_CONSTANTS__.get(value, 0) if group == 'month' + else int(value) + ) + for group, value in match.groupdict().items() + if value + } + return datetime(**match) raise KodionException('Could not parse |{datetime}| as ISO 8601' .format(datetime=datetime_string)) def get_scheduled_start(context, datetime_object, local=True): - _now = now() if local else datetime.utcnow() - if datetime_object.date() == _now: + if timezone: + _now = now(tz=timezone.utc) + if local: + _now = _now.astimezone(None) + else: + _now = now() if local else datetime.utcnow() + + if datetime_object.date() == _now.date(): return '@ {start_time}'.format( start_time=context.format_time(datetime_object.time()) ) @@ -115,13 +153,27 @@ def get_scheduled_start(context, datetime_object, local=True): ) -def utc_to_local(dt, offset=None): - offset = __LOCAL_OFFSET__ if offset is None else timedelta(hours=offset) +def utc_to_local(dt): + if timezone: + return dt.astimezone(None) + + if __INTERNAL_CONSTANTS__['local_offset']: + offset = __INTERNAL_CONSTANTS__['local_offset'] + else: + offset = now() - datetime.utcnow() + __INTERNAL_CONSTANTS__['local_offset'] = offset + return dt + offset -def datetime_to_since(context, dt): - _now = now() +def datetime_to_since(context, dt, local=True): + if timezone: + _now = now(tz=timezone.utc) + if local: + _now = _now.astimezone(None) + else: + _now = now() if local else datetime.utcnow() + diff = _now - dt yesterday = _now - timedelta(days=1) yyesterday = _now - timedelta(days=2) @@ -176,19 +228,55 @@ def datetime_to_since(context, dt): return ' '.join((context.format_date_short(dt), context.format_time(dt))) -def strptime(datetime_str, fmt='%Y-%m-%dT%H:%M:%S'): - if '.' in datetime_str[-5:]: - fmt.replace('%S', '%S.%f') +def strptime(datetime_str, fmt=None): + if fmt is None: + fmt = '%Y-%m-%dT%H%M%S' + + if ' ' in datetime_str: + date_part, time_part = datetime_str.split(' ') + elif 'T' in datetime_str: + date_part, time_part = datetime_str.split('T') + + if ':' in time_part: + time_part = time_part.replace(':', '') + + if '+' in time_part: + time_part, offset, timezone_part = time_part.partition('+') + elif '-' in time_part: + time_part, offset, timezone_part = time_part.partition('+') else: - fmt.replace('%S.%f', '%S') + offset = timezone_part = '' + + if timezone and timezone_part and offset: + fmt = fmt.replace('%S', '%S%z') + else: + fmt = fmt.replace('%S%z', '%S') + + if '.' in time_part: + fmt = fmt.replace('%S', '%S.%f') + else: + fmt = fmt.replace('%S.%f', '%S') + + if timezone and timezone_part and offset: + time_part = offset.join((time_part, timezone_part)) + datetime_str = 'T'.join((date_part, time_part)) + + try: + return datetime.strptime(datetime_str, fmt) + except TypeError: + log_error('Python strptime bug workaround.\n' + 'Refer to https://github.com/python/cpython/issues/71587') - if not datetime.strptime: - if '_strptime' in modules: - del modules['_strptime'] - modules['_strptime'] = import_module('_strptime') + if '_strptime' not in modules: + modules['_strptime'] = import_module('_strptime') + _strptime = modules['_strptime'] - return datetime.strptime(datetime_str, fmt) + if timezone: + return _strptime._strptime_datetime(datetime, datetime_str, fmt) + return datetime(*(_strptime._strptime(datetime_str, fmt)[0][0:6])) -def since_epoch(dt_object): - return (dt_object - __EPOCH_DT__).total_seconds() +def since_epoch(dt_object=None): + if dt_object is None: + dt_object = now(tz=timezone.utc) if timezone else datetime.utcnow() + return (dt_object - __INTERNAL_CONSTANTS__['epoch_dt']).total_seconds() diff --git a/resources/lib/youtube_plugin/kodion/utils/methods.py b/resources/lib/youtube_plugin/kodion/utils/methods.py index cb469ee47..41c040515 100644 --- a/resources/lib/youtube_plugin/kodion/utils/methods.py +++ b/resources/lib/youtube_plugin/kodion/utils/methods.py @@ -18,7 +18,7 @@ from datetime import timedelta from math import floor, log -from ..compatibility import quote, xbmc, xbmcvfs +from ..compatibility import quote, string_type, xbmc, xbmcvfs from ..logger import log_error @@ -274,15 +274,16 @@ def find_video_id(plugin_path): return '' -def friendly_number(number, precision=3, scale=('', 'K', 'M', 'B')): +def friendly_number(input, precision=3, scale=('', 'K', 'M', 'B'), as_str=True): _input = float('{input:.{precision}g}'.format( - input=float(number), precision=precision + input=float(input), precision=precision )) _abs_input = abs(_input) magnitude = 0 if _abs_input < 1000 else int(log(floor(_abs_input), 1000)) - return '{output:f}'.format( + output = '{output:f}'.format( output=_input / 1000 ** magnitude - ).rstrip('0').rstrip('.') + scale[magnitude], _input + ).rstrip('0').rstrip('.') + scale[magnitude] + return output if as_str else (output, _input) _RE_PERIODS = re.compile(r'([\d.]+)(d|h|m|s|$)') @@ -314,7 +315,7 @@ def seconds_to_duration(seconds): def merge_dicts(item1, item2, templates=None, _=Ellipsis): if not isinstance(item1, dict) or not isinstance(item2, dict): - return item1 if item2 is _ else item2 + return item1 if item2 is _ else _ if item2 is KeyError else item2 new = {} keys = set(item1) keys.update(item2) @@ -323,7 +324,7 @@ def merge_dicts(item1, item2, templates=None, _=Ellipsis): if value is _: continue if (templates is not None - and isinstance(value, str) and '{' in value): + and isinstance(value, string_type) and '{' in value): templates['{0}.{1}'.format(id(new), key)] = (new, key, value) new[key] = value return new or _ diff --git a/resources/lib/youtube_plugin/kodion/utils/player_monitor.py b/resources/lib/youtube_plugin/kodion/utils/player_monitor.py index e3e4f44d2..d2e5fafa8 100644 --- a/resources/lib/youtube_plugin/kodion/utils/player_monitor.py +++ b/resources/lib/youtube_plugin/kodion/utils/player_monitor.py @@ -58,6 +58,7 @@ def abort_now(self): or self.stopped()) def run(self): + playing_file = self.playback_json.get('playing_file') play_count = self.playback_json.get('play_count', 0) use_remote_history = self.playback_json.get('use_remote_history', False) use_local_history = self.playback_json.get('use_local_history', False) @@ -130,8 +131,10 @@ def run(self): except RuntimeError: current_file = None - if (not current_file or video_id_param not in current_file - or not self._context.is_plugin_path(current_file, 'play/') + if (not current_file + or (current_file != playing_file and not ( + self._context.is_plugin_path(current_file, 'play/') + and video_id_param in current_file)) or self.stopped()): self.stop() break @@ -278,12 +281,11 @@ def run(self): }) self._context.log_debug('Playback stopped [{video_id}]:' ' {current:.3f} secs of {total:.3f}' - ' @ {percent}%'.format( - video_id=self.video_id, - current=self.current_time, - total=self.total_time, - percent=self.percent_complete, - )) + ' @ {percent}%' + .format(video_id=self.video_id, + current=self.current_time, + total=self.total_time, + percent=self.percent_complete)) state = 'stopped' # refresh client, tokens may need refreshing @@ -344,10 +346,9 @@ def run(self): self._context.get_watch_later_list().remove(self.video_id) if logged_in and not refresh_only: - history_playlist_id = access_manager.get_watch_history_id() - if history_playlist_id and history_playlist_id != 'HL': - _ = client.add_video_to_playlist(history_playlist_id, - self.video_id) + history_id = access_manager.get_watch_history_id() + if history_id: + _ = client.add_video_to_playlist(history_id, self.video_id) # rate video if settings.get_bool('youtube.post.play.rate', False): @@ -375,13 +376,7 @@ def run(self): playlist = xbmc.PlayList(xbmc.PLAYLIST_VIDEO) do_refresh = playlist.size() < 2 or playlist.getposition() == -1 - if (do_refresh and settings.get_bool('youtube.post.play.refresh', False) - and not xbmc.getInfoLabel('Container.FolderPath').startswith( - self._context.create_uri(('kodion', 'search', 'input')) - )): - # don't refresh search input it causes request for new input, - # (Container.Update in abstract_provider /kodion/search/input/ - # would resolve this but doesn't work with Remotes(Yatse)) + if do_refresh and settings.get_bool('youtube.post.play.refresh', False): self.ui.refresh_container() self.end() diff --git a/resources/lib/youtube_plugin/kodion/utils/system_version.py b/resources/lib/youtube_plugin/kodion/utils/system_version.py index b758dacb5..b38e3fa9e 100644 --- a/resources/lib/youtube_plugin/kodion/utils/system_version.py +++ b/resources/lib/youtube_plugin/kodion/utils/system_version.py @@ -12,7 +12,7 @@ import json -from ..compatibility import xbmc +from ..compatibility import string_type, xbmc class SystemVersion(object): @@ -23,12 +23,12 @@ def __init__(self, version=None, releasename=None, appname=None): ) self._releasename = ( - releasename if releasename and isinstance(releasename, str) + releasename if releasename and isinstance(releasename, string_type) else 'UNKNOWN' ) self._appname = ( - appname if appname and isinstance(appname, str) + appname if appname and isinstance(appname, string_type) else 'UNKNOWN' ) diff --git a/resources/lib/youtube_plugin/refresh.py b/resources/lib/youtube_plugin/refresh.py deleted file mode 100644 index 981231c3a..000000000 --- a/resources/lib/youtube_plugin/refresh.py +++ /dev/null @@ -1,16 +0,0 @@ -# -*- coding: utf-8 -*- -""" - - Copyright (C) 2018-2018 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 - -import xbmc - - -if __name__ == '__main__': - xbmc.executebuiltin("Container.Refresh") diff --git a/resources/lib/youtube_plugin/script.py b/resources/lib/youtube_plugin/script.py new file mode 100644 index 000000000..3ff79f8df --- /dev/null +++ b/resources/lib/youtube_plugin/script.py @@ -0,0 +1,292 @@ +# -*- coding: utf-8 -*- +""" + + Copyright (C) 2018-2018 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 + +import os +import socket +import sys + +from kodion.compatibility import parse_qsl, xbmc, xbmcaddon, xbmcvfs +from kodion.constants import DATA_PATH, TEMP_PATH +from kodion.context import Context +from kodion.network import get_client_ip_address, is_httpd_live +from kodion.utils import rm_dir + + +def _config_actions(action, *_args): + context = Context() + localize = context.localize + settings = context.get_settings() + ui = context.get_ui() + + if action == 'youtube': + xbmcaddon.Addon().openSettings() + xbmc.executebuiltin('Container.Refresh') + + elif action == 'isa': + if context.use_inputstream_adaptive(): + xbmcaddon.Addon(id='inputstream.adaptive').openSettings() + else: + settings.set_bool('kodion.video.quality.isa', False) + + elif action == 'inputstreamhelper': + try: + xbmcaddon.Addon('script.module.inputstreamhelper') + ui.show_notification(localize('inputstreamhelper.is_installed')) + except RuntimeError: + xbmc.executebuiltin('InstallAddon(script.module.inputstreamhelper)') + + elif action == 'subtitles': + language = settings.get_string('youtube.language', 'en-US') + sub_setting = settings.subtitle_languages() + + sub_opts = [ + localize('none'), + localize('prompt'), + (localize('subtitles.with_fallback') % ( + ('en', 'en-US/en-GB') if language.startswith('en') else + (language, 'en') + )), + language, + '%s (%s)' % (language, localize('subtitles.no_auto_generated')) + ] + sub_opts[sub_setting] = ui.bold(sub_opts[sub_setting]) + + result = ui.on_select(localize('subtitles.language'), sub_opts) + if result > -1: + settings.set_subtitle_languages(result) + + result = ui.on_yes_no_input( + localize('subtitles.download'), + localize('subtitles.download.pre') + ) + if result > -1: + settings.set_subtitle_download(result == 1) + + elif action == 'listen_ip': + local_ranges = ('10.', '172.16.', '192.168.') + addresses = [iface[4][0] + for iface in socket.getaddrinfo(socket.gethostname(), None) + if iface[4][0].startswith(local_ranges)] + addresses += ['127.0.0.1', '0.0.0.0'] + selected_address = ui.on_select(localize('select.listen.ip'), addresses) + if selected_address != -1: + settings.set_httpd_listen(addresses[selected_address]) + + elif action == 'show_client_ip': + port = settings.httpd_port() + + if is_httpd_live(port=port): + client_ip = get_client_ip_address(port=port) + if client_ip: + ui.on_ok(context.get_name(), + context.localize('client.ip') % client_ip) + else: + ui.show_notification(context.localize('client.ip.failed')) + else: + ui.show_notification(context.localize('httpd.not.running')) + + +def _maintenance_actions(action, target): + context = Context() + ui = context.get_ui() + localize = context.localize + + if action == 'clear': + if target == 'function_cache': + if ui.on_remove_content(localize('cache.function')): + context.get_function_cache().clear() + ui.show_notification(localize('succeeded')) + elif target == 'data_cache': + if ui.on_remove_content(localize('cache.data')): + context.get_data_cache().clear() + ui.show_notification(localize('succeeded')) + elif target == 'search_cache': + if ui.on_remove_content(localize('search.history')): + context.get_search_history().clear() + ui.show_notification(localize('succeeded')) + elif (target == 'playback_history' and ui.on_remove_content( + localize('playback.history') + )): + context.get_playback_history().clear() + ui.show_notification(localize('succeeded')) + + elif action == 'delete': + _maint_files = {'function_cache': 'cache.sqlite', + 'search_cache': 'search.sqlite', + 'data_cache': 'data_cache.sqlite', + 'playback_history': 'playback_history', + 'settings_xml': 'settings.xml', + 'api_keys': 'api_keys.json', + 'access_manager': 'access_manager.json', + 'temp_files': TEMP_PATH} + _file = _maint_files.get(target) + succeeded = False + + if not _file: + return + + data_path = xbmcvfs.translatePath(DATA_PATH) + if 'sqlite' in _file: + _file_w_path = os.path.join(data_path, 'kodion', _file) + elif target == 'temp_files': + _file_w_path = _file + elif target == 'playback_history': + _file = ''.join(( + context.get_access_manager().get_current_user_id(), + '.sqlite' + )) + _file_w_path = os.path.join(data_path, 'playback', _file) + else: + _file_w_path = os.path.join(data_path, _file) + + if not ui.on_delete_content(_file): + return + + if target == 'temp_files': + succeeded = rm_dir(_file_w_path) + + elif _file_w_path: + succeeded = xbmcvfs.delete(_file_w_path) + + if succeeded: + ui.show_notification(localize('succeeded')) + else: + ui.show_notification(localize('failed')) + + +def _user_actions(action, params): + context = Context() + if params: + context.parse_params(dict(parse_qsl(params))) + localize = context.localize + access_manager = context.get_access_manager() + ui = context.get_ui() + + def select_user(reason, new_user=False): + current_users = access_manager.get_users() + current_user = access_manager.get_current_user() + usernames = [] + for user, details in sorted(current_users.items()): + username = details.get('name') or localize('user.unnamed') + if user == current_user: + username = '> ' + ui.bold(username) + if details.get('access_token') or details.get('refresh_token'): + username = ui.color('limegreen', username) + usernames.append(username) + if new_user: + usernames.append(ui.italic(localize('user.new'))) + return ui.on_select(reason, usernames), sorted(current_users.keys()) + + def add_user(): + results = ui.on_keyboard_input(localize('user.enter_name')) + if results[0] is False: + return None, None + new_username = results[1].strip() + if not new_username: + new_username = localize('user.unnamed') + return access_manager.add_user(new_username) + + def switch_to_user(user): + access_manager.set_user(user, switch_to=True) + ui.show_notification( + localize('user.changed') % access_manager.get_username(user), + localize('user.switch') + ) + context.get_data_cache().clear() + context.get_function_cache().clear() + if context.get_param('refresh') is not False: + ui.refresh_container() + + if action == 'switch': + result, user_index_map = select_user(localize('user.switch'), + new_user=True) + if result == -1: + return False + if result == len(user_index_map): + user, _ = add_user() + else: + user = user_index_map[result] + + if user is not None and user != access_manager.get_current_user(): + switch_to_user(user) + + elif action == 'add': + user, details = add_user() + if user is not None: + result = ui.on_yes_no_input( + localize('user.switch'), + localize('user.switch.now') % details.get('name') + ) + if result: + switch_to_user(user) + + elif action == 'remove': + result, user_index_map = select_user(localize('user.remove')) + if result == -1: + return False + + user = user_index_map[result] + username = access_manager.get_username(user) + if ui.on_remove_content(username): + access_manager.remove_user(user) + if user == 0: + access_manager.add_user(username=localize('user.default'), + user=0) + if user == access_manager.get_current_user(): + access_manager.set_user(0, switch_to=True) + ui.show_notification(localize('removed') % username, + localize('remove')) + + elif action == 'rename': + result, user_index_map = select_user(localize('user.rename')) + if result == -1: + return False + + user = user_index_map[result] + old_username = access_manager.get_username(user) + results = ui.on_keyboard_input(localize('user.enter_name'), + default=old_username) + if results[0] is False: + return False + new_username = results[1].strip() + if not new_username: + new_username = localize('user.unnamed') + if old_username == new_username: + return False + + if access_manager.set_username(user, new_username): + ui.show_notification( + localize('renamed') % (old_username, new_username), + localize('rename') + ) + + return True + + +if __name__ == '__main__': + args = sys.argv[1:] + if args: + args = args[0].split('/') + num_args = len(args) + category = args[0] if num_args else None + action = args[1] if num_args > 1 else None + params = args[2] if num_args > 2 else None + + if not category: + xbmcaddon.Addon().openSettings() + elif action == 'refresh': + xbmc.executebuiltin('Container.Refresh') + elif category == 'config': + _config_actions(action, params) + elif category == 'maintenance': + _maintenance_actions(action, params) + elif category == 'users': + _user_actions(action, params) diff --git a/resources/lib/youtube_plugin/youtube/client/login_client.py b/resources/lib/youtube_plugin/youtube/client/login_client.py index ca4652c42..f23853dda 100644 --- a/resources/lib/youtube_plugin/youtube/client/login_client.py +++ b/resources/lib/youtube_plugin/youtube/client/login_client.py @@ -89,6 +89,7 @@ def _response_hook(**kwargs): try: json_data = response.json() if 'error' in json_data: + json_data.setdefault('code', response.status_code) raise LoginException('"error" in response JSON data', json_data=json_data, response=response) @@ -162,9 +163,10 @@ def refresh_token(self, refresh_token, client_id='', client_secret=''): config_type = self._get_config_type(client_id, client_secret) client = ''.join([ - '(config_type: |', config_type, '|', - ' client_id: |', client_id[:5], '...|', - ' client_secret: |', client_secret[:5], '...|)' + '(config_type: |', config_type, + '| client_id: |', client_id[:5], '...', client_id[-5:], + '| client_secret: |', client_secret[:5], '...', client_secret[-5:], + '|)' ]) log_debug('Refresh token for {0}'.format(client)) @@ -176,7 +178,7 @@ def refresh_token(self, refresh_token, client_id='', client_secret=''): error_hook=LoginClient._error_hook, error_title='Login Failed', error_info=('Refresh token failed' - ' {client}: {{exc}}' + ' {client}:\n{{exc}}' .format(client=client)), raise_exc=True) @@ -210,9 +212,10 @@ def request_access_token(self, code, client_id='', client_secret=''): config_type = self._get_config_type(client_id, client_secret) client = ''.join([ - '(config_type: |', config_type, '|', - ' client_id: |', client_id[:5], '...|', - ' client_secret: |', client_secret[:5], '...|)' + '(config_type: |', config_type, + '| client_id: |', client_id[:5], '...', client_id[-5:], + '| client_secret: |', client_secret[:5], '...', client_secret[-5:], + '|)' ]) log_debug('Requesting access token for {0}'.format(client)) @@ -224,7 +227,7 @@ def request_access_token(self, code, client_id='', client_secret=''): error_hook=LoginClient._error_hook, error_title='Login Failed: Unknown response', error_info=('Access token request failed' - ' {client}: {{exc}}' + ' {client}:\n{{exc}}' .format(client=client)), raise_exc=True) return json_data @@ -260,7 +263,7 @@ def request_device_and_user_code(self, client_id=''): error_hook=LoginClient._error_hook, error_title='Login Failed: Unknown response', error_info=('Device/user code request failed' - ' {client}: {{exc}}' + ' {client}:\n{{exc}}' .format(client=client)), raise_exc=True) return json_data diff --git a/resources/lib/youtube_plugin/youtube/client/request_client.py b/resources/lib/youtube_plugin/youtube/client/request_client.py index 3fd740386..442178cef 100644 --- a/resources/lib/youtube_plugin/youtube/client/request_client.py +++ b/resources/lib/youtube_plugin/youtube/client/request_client.py @@ -264,32 +264,41 @@ def __init__(self, exc_type=None): elif exc_type: exc_type = (YouTubeException, exc_type) else: - exc_type = YouTubeException + exc_type = (YouTubeException,) super(YouTubeRequestClient, self).__init__(exc_type=exc_type) - self._access_token = None - self.video_id = None - - @staticmethod - def json_traverse(json_data, path): + @classmethod + def json_traverse(cls, json_data, path): if not json_data or not path: return None result = json_data - for keys in path: - is_dict = isinstance(result, dict) - if not is_dict and not isinstance(result, (list, tuple)): + for idx, keys in enumerate(path): + if not isinstance(result, (dict, list, tuple)): return None + if isinstance(keys, slice): + return [ + cls.json_traverse(part, path[idx + 1:]) + for part in result[keys] + if part + ] + if not isinstance(keys, (list, tuple)): keys = [keys] + for key in keys: - if is_dict: - if key not in result: - continue - elif not isinstance(key, int) or len(result) <= key: + if isinstance(key, (list, tuple)): + new_result = cls.json_traverse(result, key) + if new_result: + result = new_result + break + continue + + try: + result = result[key] + except (KeyError, IndexError): continue - result = result[key] break else: return None @@ -298,18 +307,18 @@ def json_traverse(json_data, path): return None return result - def build_client(self, client_name, auth_header=False, data=None): + @classmethod + def build_client(cls, client_name, data=None): templates = {} - client = (self.CLIENTS.get(client_name) or self.CLIENTS['web']).copy() - client = merge_dicts(self.CLIENTS['_common'], client, templates) - + client = (cls.CLIENTS.get(client_name) + or YouTubeRequestClient.CLIENTS['web']).copy() if data: - client.update(data) - client['json']['videoId'] = self.video_id - if auth_header and self._access_token: - client['_access_token'] = self._access_token - client['params'] = None + client = merge_dicts(client, data) + client = merge_dicts(cls.CLIENTS['_common'], client, templates) + + if data and '_access_token' in data: + del client['params']['key'] elif 'Authorization' in client['headers']: del client['headers']['Authorization'] diff --git a/resources/lib/youtube_plugin/youtube/client/youtube.py b/resources/lib/youtube_plugin/youtube/client/youtube.py index f6f9317b5..ccb49226f 100644 --- a/resources/lib/youtube_plugin/youtube/client/youtube.py +++ b/resources/lib/youtube_plugin/youtube/client/youtube.py @@ -10,18 +10,66 @@ from __future__ import absolute_import, division, unicode_literals -import copy -import re import threading import xml.etree.ElementTree as ET +from copy import deepcopy +from itertools import chain, islice +from random import randint from .login_client import LoginClient from ..helper.video_info import VideoInfo from ..youtube_exceptions import InvalidJSON, YouTubeException +from ...kodion.compatibility import string_type from ...kodion.utils import datetime_parser, strip_html_from_text, to_unicode class YouTube(LoginClient): + CLIENTS = { + 1: { + 'url': 'https://www.youtube.com/youtubei/v1/{_endpoint}', + 'method': None, + 'json': { + 'context': { + 'client': { + 'clientName': 'WEB', + 'clientVersion': '2.20220801.00.00', + }, + }, + }, + 'headers': { + 'Host': 'www.youtube.com', + }, + 'params': { + 'key': 'AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8', + }, + }, + 3: { + 'url': 'https://www.googleapis.com/youtube/v3/{_endpoint}', + 'method': None, + 'headers': { + 'Host': 'www.googleapis.com', + }, + }, + '_common': { + '_access_token': None, + 'headers': { + 'Accept-Encoding': 'gzip, deflate', + 'Accept-Charset': 'ISO-8859-1,utf-8;q=0.7,*;q=0.7', + 'Accept': '*/*', + 'Accept-Language': 'en-US,en;q=0.5', + 'Authorization': 'Bearer {_access_token}', + 'DNT': '1', + 'User-Agent': ('Mozilla/5.0 (Linux; Android 10; SM-G981B)' + ' AppleWebKit/537.36 (KHTML, like Gecko)' + ' Chrome/80.0.3987.162 Mobile Safari/537.36'), + }, + 'params': { + 'key': None, + 'prettyPrint': 'false' + }, + }, + } + def __init__(self, context, **kwargs): self._context = context if not kwargs.get('config'): @@ -153,10 +201,10 @@ def get_video_streams(self, context, video_id): def remove_playlist(self, playlist_id, **kwargs): params = {'id': playlist_id, 'mine': 'true'} - return self.perform_v3_request(method='DELETE', - path='playlists', - params=params, - **kwargs) + return self.api_request(method='DELETE', + path='playlists', + params=params, + **kwargs) def get_supported_languages(self, language=None, **kwargs): _language = language @@ -165,10 +213,10 @@ def get_supported_languages(self, language=None, **kwargs): _language = _language.replace('-', '_') params = {'part': 'snippet', 'hl': _language} - return self.perform_v3_request(method='GET', - path='i18nLanguages', - params=params, - **kwargs) + return self.api_request(method='GET', + path='i18nLanguages', + params=params, + **kwargs) def get_supported_regions(self, language=None, **kwargs): _language = language @@ -177,10 +225,10 @@ def get_supported_regions(self, language=None, **kwargs): _language = _language.replace('-', '_') params = {'part': 'snippet', 'hl': _language} - return self.perform_v3_request(method='GET', - path='i18nRegions', - params=params, - **kwargs) + return self.api_request(method='GET', + path='i18nRegions', + params=params, + **kwargs) def rename_playlist(self, playlist_id, @@ -192,32 +240,32 @@ def rename_playlist(self, 'id': playlist_id, 'snippet': {'title': new_title}, 'status': {'privacyStatus': privacy_status}} - return self.perform_v3_request(method='PUT', - path='playlists', - params=params, - post_data=post_data, - **kwargs) + return self.api_request(method='PUT', + path='playlists', + params=params, + post_data=post_data, + **kwargs) def create_playlist(self, title, privacy_status='private', **kwargs): params = {'part': 'snippet,status'} post_data = {'kind': 'youtube#playlist', 'snippet': {'title': title}, 'status': {'privacyStatus': privacy_status}} - return self.perform_v3_request(method='POST', - path='playlists', - params=params, - post_data=post_data, - **kwargs) + return self.api_request(method='POST', + path='playlists', + params=params, + post_data=post_data, + **kwargs) def get_video_rating(self, video_id, **kwargs): - if not isinstance(video_id, str): + if not isinstance(video_id, string_type): video_id = ','.join(video_id) params = {'id': video_id} - return self.perform_v3_request(method='GET', - path='videos/getRating', - params=params, - **kwargs) + return self.api_request(method='GET', + path='videos/getRating', + params=params, + **kwargs) def rate_video(self, video_id, rating='like', **kwargs): """ @@ -228,10 +276,10 @@ def rate_video(self, video_id, rating='like', **kwargs): """ params = {'id': video_id, 'rating': rating} - return self.perform_v3_request(method='POST', - path='videos/rate', - params=params, - **kwargs) + return self.api_request(method='POST', + path='videos/rate', + params=params, + **kwargs) def add_video_to_playlist(self, playlist_id, video_id, **kwargs): params = {'part': 'snippet', @@ -240,11 +288,11 @@ def add_video_to_playlist(self, playlist_id, video_id, **kwargs): 'snippet': {'playlistId': playlist_id, 'resourceId': {'kind': 'youtube#video', 'videoId': video_id}}} - return self.perform_v3_request(method='POST', - path='playlistItems', - params=params, - post_data=post_data, - **kwargs) + return self.api_request(method='POST', + path='playlistItems', + params=params, + post_data=post_data, + **kwargs) # noinspection PyUnusedLocal def remove_video_from_playlist(self, @@ -252,28 +300,28 @@ def remove_video_from_playlist(self, playlist_item_id, **kwargs): params = {'id': playlist_item_id} - return self.perform_v3_request(method='DELETE', - path='playlistItems', - params=params, - **kwargs) + return self.api_request(method='DELETE', + path='playlistItems', + params=params, + **kwargs) def unsubscribe(self, subscription_id, **kwargs): params = {'id': subscription_id} - return self.perform_v3_request(method='DELETE', - path='subscriptions', - params=params, - **kwargs) + return self.api_request(method='DELETE', + path='subscriptions', + params=params, + **kwargs) def subscribe(self, channel_id, **kwargs): params = {'part': 'snippet'} post_data = {'kind': 'youtube#subscription', 'snippet': {'resourceId': {'kind': 'youtube#channel', 'channelId': channel_id}}} - return self.perform_v3_request(method='POST', - path='subscriptions', - params=params, - post_data=post_data, - **kwargs) + return self.api_request(method='POST', + path='subscriptions', + params=params, + post_data=post_data, + **kwargs) def get_subscription(self, channel_id, @@ -297,10 +345,10 @@ def get_subscription(self, if page_token: params['pageToken'] = page_token - return self.perform_v3_request(method='GET', - path='subscriptions', - params=params, - **kwargs) + return self.api_request(method='GET', + path='subscriptions', + params=params, + **kwargs) def get_guide_category(self, guide_category_id, page_token='', **kwargs): params = {'part': 'snippet,contentDetails,brandingSettings', @@ -310,10 +358,10 @@ def get_guide_category(self, guide_category_id, page_token='', **kwargs): 'hl': self._language} if page_token: params['pageToken'] = page_token - return self.perform_v3_request(method='GET', - path='channels', - params=params, - **kwargs) + return self.api_request(method='GET', + path='channels', + params=params, + **kwargs) def get_guide_categories(self, page_token='', **kwargs): params = {'part': 'snippet', @@ -323,12 +371,12 @@ def get_guide_categories(self, page_token='', **kwargs): if page_token: params['pageToken'] = page_token - return self.perform_v3_request(method='GET', - path='guideCategories', - params=params, - **kwargs) + return self.api_request(method='GET', + path='guideCategories', + params=params, + **kwargs) - def get_popular_videos(self, page_token='', **kwargs): + def get_trending_videos(self, page_token='', **kwargs): params = {'part': 'snippet,status', 'maxResults': str(self._max_results), 'regionCode': self._region, @@ -336,10 +384,10 @@ def get_popular_videos(self, page_token='', **kwargs): 'chart': 'mostPopular'} if page_token: params['pageToken'] = page_token - return self.perform_v3_request(method='GET', - path='videos', - params=params, - **kwargs) + return self.api_request(method='GET', + path='videos', + params=params, + **kwargs) def get_video_category(self, video_category_id, page_token='', **kwargs): params = {'part': 'snippet,contentDetails,status', @@ -350,10 +398,10 @@ def get_video_category(self, video_category_id, page_token='', **kwargs): 'hl': self._language} if page_token: params['pageToken'] = page_token - return self.perform_v3_request(method='GET', - path='videos', - params=params, - **kwargs) + return self.api_request(method='GET', + path='videos', + params=params, + **kwargs) def get_video_categories(self, page_token='', **kwargs): params = {'part': 'snippet', @@ -363,135 +411,381 @@ def get_video_categories(self, page_token='', **kwargs): if page_token: params['pageToken'] = page_token - return self.perform_v3_request(method='GET', - path='videoCategories', - params=params, - **kwargs) + return self.api_request(method='GET', + path='videoCategories', + params=params, + **kwargs) - def _get_recommendations_for_home(self): - # YouTube has deprecated this API, so use history and related items to form - # a recommended set. We cache aggressively because searches incur a high - # quota cost of 100 on the YouTube API. - # Note this is a first stab attempt and can be refined a lot more. + def get_recommended_for_home(self, + visitor='', + page_token='', + click_tracking=''): payload = { 'kind': 'youtube#activityListResponse', 'items': [] } - history_id = self._context.get_access_manager().get_watch_history_id() - if not history_id or history_id == 'HL': + post_data = {'browseId': 'FEwhat_to_watch'} + if page_token: + post_data['continuation'] = page_token + if click_tracking or visitor: + context = {} + if click_tracking: + context['clickTracking'] = { + 'clickTrackingParams': click_tracking, + } + if visitor: + context['client'] = { + 'visitorData': visitor, + } + post_data['context'] = context + + result = self.api_request(version=1, + method='POST', + path='browse', + post_data=post_data) + if not result: return payload - cache = self._context.get_data_cache() + recommended_videos = self.json_traverse( + result, + path=( + ( + ( + 'onResponseReceivedEndpoints', + 'onResponseReceivedActions', + ), + 0, + 'appendContinuationItemsAction', + 'continuationItems', + ) if page_token else ( + 'contents', + 'twoColumnBrowseResultsRenderer', + 'tabs', + 0, + 'tabRenderer', + 'content', + 'richGridRenderer', + 'contents', + ) + ) + ( + slice(None), + ( + ( + 'richItemRenderer', + 'content', + 'videoRenderer', + # 'videoId', + ), + ( + 'richSectionRenderer', + 'content', + 'richShelfRenderer', + 'contents', + slice(None), + 'richItemRenderer', + 'content', + ( + 'videoRenderer', + 'reelItemRenderer' + ), + # 'videoId', + ), + ( + 'continuationItemRenderer', + 'continuationEndpoint', + ), + ), + ) + ) + if not recommended_videos: + return payload - # Do we have a cached result? - cache_home_key = 'get-activities-home' - cached = cache.get_item(cache_home_key, cache.ONE_HOUR * 4) - cached = cached and cached.get('items') - if cached: - return cached + v3_response = { + 'kind': 'youtube#activityListResponse', + 'items': [ + { + 'kind': "youtube#video", + 'id': video['videoId'], + 'partial': True, + 'snippet': { + 'title': self.json_traverse(video, ( + ('title', 'runs', 0, 'text'), + ('headline', 'simpleText'), + )), + 'thumbnails': dict(zip( + ('default', 'high'), + video['thumbnail']['thumbnails'], + )), + 'channelId': self.json_traverse(video, ( + ('longBylineText', 'shortBylineText'), + 'runs', + 0, + 'navigationEndpoint', + 'browseEndpoint', + 'browseId', + )), + } + } + for videos in recommended_videos + for video in + (videos if isinstance(videos, list) else (videos,)) + if video and 'videoId' in video + ] + } - # Fetch existing list of items, if any - cache_items_key = 'get-activities-home-items' - cached = cache.get_item(cache_items_key, cache.ONE_WEEK * 2) - items = cached if cached else [] - - # Fetch history and recommended items. Use threads for faster execution. - def helper(video_id, responses): - self._context.log_debug( - 'Method get_activities: doing expensive API fetch for related' - 'items for video %s' % video_id - ) - di = self.get_related_videos(video_id, max_results=10) - if 'items' in di: - # Record for which video we fetched the items - for item in di['items']: - item['plugin_fetched_for'] = video_id - responses.extend(di['items']) + last_item = recommended_videos[-1] + if last_item and 'continuationCommand' in last_item: + if 'clickTrackingParams' in last_item: + v3_response['clickTracking'] = last_item['clickTrackingParams'] + token = last_item['continuationCommand'].get('token') + if token: + v3_response['nextPageToken'] = token + visitor = self.json_traverse(result, ( + 'responseContext', + 'visitorData', + )) or visitor + if visitor: + v3_response['visitorData'] = visitor + + return v3_response + + def get_related_for_home(self, page_token=''): + """ + YouTube has deprecated this API, so we use history and related items to + form a recommended set. + We cache aggressively because searches can be slow. + Note this is a naive implementation and can be refined a lot more. + """ - history = self.get_playlist_items(history_id, max_results=50) + payload = { + 'kind': 'youtube#activityListResponse', + 'items': [] + } - if not history.get('items'): - return payload + # Related videos are retrieved for the following num_items from history + num_items = 10 + local_history = self._context.get_settings().use_local_history() + history_id = self._context.get_access_manager().get_watch_history_id() + if not history_id: + if local_history: + history = self._context.get_playback_history() + video_ids = history.get_items(limit=num_items) + else: + return payload + else: + history = self.get_playlist_items(history_id, max_results=num_items) + if history and 'items' in history: + history_items = history['items'] or [] + video_ids = [] + else: + return payload - threads = [] - candidates = [] - already_fetched_for_video_ids = [item['plugin_fetched_for'] for item in items] - history_items = [item for item in history['items'] - if re.match(r'(?P[\w-]{11})', - item['snippet']['resourceId']['videoId'])] + for item in history_items: + try: + video_ids.append(item['snippet']['resourceId']['videoId']) + except KeyError: + continue - # TODO: - # It would be nice to make this 8 user configurable - for item in history_items[:8]: - video_id = item['snippet']['resourceId']['videoId'] - if video_id not in already_fetched_for_video_ids: - thread = threading.Thread(target=helper, args=(video_id, candidates)) - threads.append(thread) - thread.start() + # Fetch existing list of items, if any + cache = self._context.get_data_cache() + cache_items_key = 'get-activities-home-items' + cached = cache.get_item(cache_items_key, None) or [] + + # Increase value to recursively retrieve recommendations for the first + # recommended video, up to the set maximum recursion depth + max_depth = 2 + items_per_page = self._max_results + diversity_limits = items_per_page // (num_items * max_depth) + items = [[] for _ in range(max_depth * len(video_ids))] + counts = { + '_counter': 0, + '_pages': {}, + '_related': {}, + } - for thread in threads: - thread.join() + def index_items(items, index, + item_store=None, + original_ids=None, + group=None, + depth=1, + original_related=None, + original_channel=None): + if original_ids is not None: + original_ids = list(original_ids) + + running = 0 + threads = [] + + for idx, item in enumerate(items): + if original_related is not None: + related = item['related_video_id'] = original_related + else: + related = item['related_video_id'] + if original_channel is not None: + channel = item['related_channel_id'] = original_channel + else: + channel = item['related_channel_id'] + video_id = item['id'] + + index['_related'].setdefault(related, 0) + index['_related'][related] += 1 + + if video_id in index: + item_count = index[video_id] + item_count['related'].setdefault(related, 0) + item_count['related'][related] += 1 + item_count['channels'].setdefault(channel, 0) + item_count['channels'][channel] += 1 + continue + + index[video_id] = { + 'related': {related: 1}, + 'channels': {channel: 1} + } - # Prepend new candidates to items - seen = [item['id']['videoId'] for item in items] - for candidate in candidates: - vid = candidate['id']['videoId'] - if vid not in seen: - seen.append(vid) - candidate['plugin_created_date'] = datetime_parser.now().strftime('%Y-%m-%dT%H:%M:%SZ') - items.insert(0, candidate) + if item_store is None: + if original_ids and related not in original_ids: + items[idx] = None + continue + + if group is not None: + pass + elif original_ids and related in original_ids: + group = max_depth * original_ids.index(related) + else: + group = 0 + + num_stored = len(item_store[group]) + item['order'] = items_per_page * group + num_stored + item_store[group].append(item) + + if num_stored or depth <= 1: + continue + + running += 1 + thread = threading.Thread( + target=threaded_get_related, + args=(video_id, index_items, counts), + kwargs={'item_store': item_store, + 'group': (group + 1), + 'depth': (depth - 1), + 'original_related': related, + 'original_channel': channel}, + ) + thread.daemon = True + threads.append(thread) + thread.start() - # Truncate items to keep it manageable, and cache - items = items[:500] - cache.set_item(cache_items_key, items) + while running: + for thread in threads: + thread.join(5) + if not thread.is_alive(): + running -= 1 - # Build the result set - items.sort( - key=lambda a: datetime_parser.parse(a['plugin_created_date']), - reverse=True - ) - sorted_items = [] - counter = 0 - channel_counts = {} - while items: - counter += 1 - - # Hard stop on iteration. Good enough for our purposes. - if counter >= 1000: - break + index_items(cached, counts, original_ids=video_ids) - # Reset channel counts on a new page - if counter % 50 == 0: - channel_counts = {} + # Fetch related videos. Use threads for faster execution. + def threaded_get_related(video_id, func, *args, **kwargs): + related_videos = self.get_related_videos(video_id).get('items') + if related_videos: + func(related_videos[:items_per_page], *args, **kwargs) - # Ensure a single channel isn't hogging the page - item = items.pop() - channel_id = item.get('snippet', {}).get('channelId') - if not channel_id: + running = 0 + threads = [] + candidates = [] + for video_id in video_ids: + if video_id in counts['_related']: continue + running += 1 + thread = threading.Thread( + target=threaded_get_related, + args=(video_id, candidates.extend), + ) + thread.daemon = True + threads.append(thread) + thread.start() + + while running: + for thread in threads: + thread.join(5) + if not thread.is_alive(): + running -= 1 + + num_items = items_per_page * num_items * max_depth + index_items(candidates[:num_items], counts, + item_store=items, + original_ids=video_ids, + depth=max_depth) - channel_counts.setdefault(channel_id, 0) - if channel_counts[channel_id] <= 3: - # Use the item - channel_counts[channel_id] += 1 - item["page_number"] = counter // 50 - sorted_items.append(item) - else: - # Move the item to the end of the list - items.append(item) - - # Finally sort items per page by date for a better distribution - def _sort_by_date_time(item): - return (item['page_number'], - -datetime_parser.since_epoch(datetime_parser.parse( - item['snippet']['publishedAt'] - ))) + # Truncate items to keep it manageable, and cache + items = list(chain.from_iterable(items)) + counts['_counter'] = len(items) + remaining = num_items - counts['_counter'] + if remaining > 0: + items.extend(islice(filter(None, cached), remaining)) + elif remaining: + items = items[:num_items] + + # Finally sort items per page by rank and date for a better distribution + def rank_and_sort(item): + if 'order' not in item: + counts['_counter'] += 1 + item['order'] = counts['_counter'] + + page = 1 + item['order'] // (items_per_page * max_depth) + page_count = counts['_pages'].setdefault(page, {'_counter': 0}) + while page_count['_counter'] < items_per_page and page > 1: + page -= 1 + page_count = counts['_pages'].setdefault(page, {'_counter': 0}) + + related_video = item['related_video_id'] + related_channel = item['related_channel_id'] + channel_id = item.get('snippet', {}).get('channelId') + """ + # Video channel and related channel can be the same which can double + # up the channel count. Checking for this allows more similar videos + # in the recommendation, ignoring it allows for more variety. + # Currently prefer not to check for this to allow more variety. + if channel_id == related_channel: + channel_id = None + """ + while (page_count['_counter'] >= items_per_page + or (related_video in page_count + and page_count[related_video] >= diversity_limits) + or (related_channel and related_channel in page_count + and page_count[related_channel] >= diversity_limits) + or (channel_id and channel_id in page_count + and page_count[channel_id] >= diversity_limits) + ): + page += 1 + page_count = counts['_pages'].setdefault(page, {'_counter': 0}) + + page_count.setdefault(related_video, 0) + page_count[related_video] += 1 + if related_channel: + page_count.setdefault(related_channel, 0) + page_count[related_channel] += 1 + if channel_id: + page_count.setdefault(channel_id, 0) + page_count[channel_id] += 1 + page_count['_counter'] += 1 + item['page'] = page + + item_count = counts[item['id']] + item['rank'] = (2 * sum(item_count['channels'].values()) + + sum(item_count['related'].values())) + + return ( + -item['page'], + item['rank'], + -randint(0, item['order']) + ) - sorted_items.sort(key=_sort_by_date_time) + items.sort(key=rank_and_sort, reverse=True) # Finalize result - payload['items'] = sorted_items + payload['items'] = items """ # TODO: # Enable pagination @@ -500,10 +794,10 @@ def _sort_by_date_time(item): 'totalResults': len(sorted_items) } """ + # Update cache - cache.set_item(cache_home_key, payload) + cache.set_item(cache_items_key, items) - # If there are no sorted_items we fall back to default API behaviour return payload def get_activities(self, channel_id, page_token='', **kwargs): @@ -512,10 +806,6 @@ def get_activities(self, channel_id, page_token='', **kwargs): 'regionCode': self._region, 'hl': self._language} - if channel_id == 'home': - recommended = self._get_recommendations_for_home() - if 'items' in recommended and recommended.get('items'): - return recommended if channel_id == 'home': params['home'] = 'true' elif channel_id == 'mine': @@ -525,10 +815,10 @@ def get_activities(self, channel_id, page_token='', **kwargs): if page_token: params['pageToken'] = page_token - return self.perform_v3_request(method='GET', - path='activities', - params=params, - **kwargs) + return self.api_request(method='GET', + path='activities', + params=params, + **kwargs) def get_channel_sections(self, channel_id, **kwargs): params = {'part': 'snippet,contentDetails', @@ -538,10 +828,10 @@ def get_channel_sections(self, channel_id, **kwargs): params['mine'] = 'true' else: params['channelId'] = channel_id - return self.perform_v3_request(method='GET', - path='channelSections', - params=params, - **kwargs) + return self.api_request(method='GET', + path='channelSections', + params=params, + **kwargs) def get_playlists_of_channel(self, channel_id, page_token='', **kwargs): params = {'part': 'snippet', @@ -553,10 +843,10 @@ def get_playlists_of_channel(self, channel_id, page_token='', **kwargs): if page_token: params['pageToken'] = page_token - return self.perform_v3_request(method='GET', - path='playlists', - params=params, - **kwargs) + return self.api_request(method='GET', + path='playlists', + params=params, + **kwargs) def get_playlist_item_id_of_video_id(self, playlist_id, video_id, page_token=''): old_max_results = self._max_results @@ -591,10 +881,10 @@ def get_playlist_items(self, if page_token: params['pageToken'] = page_token - return self.perform_v3_request(method='GET', - path='playlistItems', - params=params, - **kwargs) + return self.api_request(method='GET', + path='playlistItems', + params=params, + **kwargs) def get_channel_by_username(self, username, **kwargs): """ @@ -608,10 +898,10 @@ def get_channel_by_username(self, username, **kwargs): else: params['forUsername'] = username - return self.perform_v3_request(method='GET', - path='channels', - params=params, - **kwargs) + return self.api_request(method='GET', + path='channels', + params=params, + **kwargs) def get_channels(self, channel_id, **kwargs): """ @@ -619,7 +909,7 @@ def get_channels(self, channel_id, **kwargs): :param channel_id: list or comma-separated list of the YouTube channel ID(s) :return: """ - if not isinstance(channel_id, str): + if not isinstance(channel_id, string_type): channel_id = ','.join(channel_id) params = {'part': 'snippet,contentDetails,brandingSettings'} @@ -627,10 +917,10 @@ def get_channels(self, channel_id, **kwargs): params['id'] = channel_id else: params['mine'] = 'true' - return self.perform_v3_request(method='GET', - path='channels', - params=params, - **kwargs) + return self.api_request(method='GET', + path='channels', + params=params, + **kwargs) def get_disliked_videos(self, page_token='', **kwargs): # prepare page token @@ -644,10 +934,10 @@ def get_disliked_videos(self, page_token='', **kwargs): if page_token: params['pageToken'] = page_token - return self.perform_v3_request(method='GET', - path='videos', - params=params, - **kwargs) + return self.api_request(method='GET', + path='videos', + params=params, + **kwargs) def get_videos(self, video_id, live_details=False, **kwargs): """ @@ -656,7 +946,7 @@ def get_videos(self, video_id, live_details=False, **kwargs): :param live_details: also retrieve liveStreamingDetails :return: """ - if not isinstance(video_id, str): + if not isinstance(video_id, string_type): video_id = ','.join(video_id) parts = ['snippet', 'contentDetails', 'status', 'statistics'] @@ -665,21 +955,21 @@ def get_videos(self, video_id, live_details=False, **kwargs): params = {'part': ','.join(parts), 'id': video_id} - return self.perform_v3_request(method='GET', - path='videos', - params=params, - **kwargs) + return self.api_request(method='GET', + path='videos', + params=params, + **kwargs) def get_playlists(self, playlist_id, **kwargs): - if not isinstance(playlist_id, str): + if not isinstance(playlist_id, string_type): playlist_id = ','.join(playlist_id) params = {'part': 'snippet,contentDetails', 'id': playlist_id} - return self.perform_v3_request(method='GET', - path='playlists', - params=params, - **kwargs) + return self.api_request(method='GET', + path='playlists', + params=params, + **kwargs) def get_live_events(self, event_type='live', @@ -718,36 +1008,123 @@ def get_live_events(self, if page_token: params['pageToken'] = page_token - return self.perform_v3_request(method='GET', - path='search', - params=params, - **kwargs) + return self.api_request(method='GET', + path='search', + params=params, + **kwargs) def get_related_videos(self, video_id, page_token='', max_results=0, **kwargs): - # prepare page token - if not page_token: - page_token = '' - + # TODO: Improve handling of InnerTube requests, including automatic + # continuation processing to retrieve max_results number of results + # See Youtube.get_saved_playlists for existing implementation max_results = self._max_results if max_results <= 0 else max_results - # prepare params - params = {'relatedToVideoId': video_id, - 'part': 'snippet', - 'type': 'video', - 'regionCode': self._region, - 'hl': self._language, - 'maxResults': str(max_results)} + post_data = {'videoId': video_id} if page_token: - params['pageToken'] = page_token + post_data['continuation'] = page_token + + result = self.api_request(version=1, + method='POST', + path='next', + post_data=post_data, + no_login=True) + if not result: + return [] + + related_videos = self.json_traverse( + result, + path=( + ( + 'onResponseReceivedEndpoints', + 0, + 'appendContinuationItemsAction', + 'continuationItems', + ) if page_token else ( + 'contents', + 'twoColumnWatchNextResults', + 'secondaryResults', + 'secondaryResults', + 'results', + ) + ) + ( + slice(None), + ( + ( + 'compactVideoRenderer', + # 'videoId', + ), + ( + 'continuationItemRenderer', + 'continuationEndpoint', + 'continuationCommand', + # 'token', + ), + ), + ) + ) + if not related_videos: + return [] + + channel_id = self.json_traverse( + result, + path=( + 'contents', + 'twoColumnWatchNextResults', + 'results', + 'results', + 'contents', + 1, + 'videoSecondaryInfoRenderer', + 'owner', + 'videoOwnerRenderer', + 'title', + 'runs', + 0, + 'navigationEndpoint', + 'browseEndpoint', + 'browseId' + ) + ) - return self.perform_v3_request(method='GET', - path='search', - params=params, - **kwargs) + v3_response = { + 'kind': 'youtube#videoListResponse', + 'items': [ + { + 'kind': "youtube#video", + 'id': video['videoId'], + 'related_video_id': video_id, + 'related_channel_id': channel_id, + 'partial': True, + 'snippet': { + 'title': video['title']['simpleText'], + 'thumbnails': dict(zip( + ('default', 'high'), + video['thumbnail']['thumbnails'], + )), + 'channelId': self.json_traverse(video, ( + ('longBylineText', 'shortBylineText'), + 'runs', + 0, + 'navigationEndpoint', + 'browseEndpoint', + 'browseId', + )), + } + } + for video in related_videos + if video and 'videoId' in video + ] + } + + last_item = related_videos[-1] + if last_item and 'token' in last_item: + v3_response['nextPageToken'] = last_item['token'] + + return v3_response def get_parent_comments(self, video_id, @@ -765,11 +1142,11 @@ def get_parent_comments(self, if page_token: params['pageToken'] = page_token - return self.perform_v3_request(method='GET', - path='commentThreads', - params=params, - no_login=True, - **kwargs) + return self.api_request(method='GET', + path='commentThreads', + params=params, + no_login=True, + **kwargs) def get_child_comments(self, parent_id, @@ -786,11 +1163,11 @@ def get_child_comments(self, if page_token: params['pageToken'] = page_token - return self.perform_v3_request(method='GET', - path='comments', - params=params, - no_login=True, - **kwargs) + return self.api_request(method='GET', + path='comments', + params=params, + no_login=True, + **kwargs) def get_channel_videos(self, channel_id, page_token='', **kwargs): """ @@ -812,10 +1189,10 @@ def get_channel_videos(self, channel_id, page_token='', **kwargs): if page_token: params['pageToken'] = page_token - return self.perform_v3_request(method='GET', - path='search', - params=params, - **kwargs) + return self.api_request(method='GET', + path='search', + params=params, + **kwargs) def search(self, q, @@ -848,7 +1225,7 @@ def search(self, # prepare search type if not search_type: search_type = '' - if not isinstance(search_type, str): + if not isinstance(search_type, string_type): search_type = ','.join(search_type) # prepare page token @@ -891,10 +1268,10 @@ def search(self, params['location'] = location params['locationRadius'] = settings.get_location_radius() - return self.perform_v3_request(method='GET', - path='search', - params=params, - **kwargs) + return self.api_request(method='GET', + path='search', + params=params, + **kwargs) def get_my_subscriptions(self, page_token=None, offset=0, **kwargs): """ @@ -921,7 +1298,7 @@ def _perform(_page_token, _offset, _result): # if new uploads is cached cache_items_key = 'my-subscriptions-items' - cached = cache.get_item(cache_items_key, cache.ONE_HOUR) + cached = cache.get_item(cache_items_key, cache.ONE_HOUR) or [] if cached: _result['items'] = cached @@ -945,10 +1322,10 @@ def _perform(_page_token, _offset, _result): if sub_page_token: params['pageToken'] = sub_page_token - json_data = self.perform_v3_request(method='GET', - path='subscriptions', - params=params, - **kwargs) + json_data = self.api_request(method='GET', + path='subscriptions', + params=params, + **kwargs) if not json_data: json_data = {} @@ -1021,7 +1398,7 @@ def fetch_xml(_url, _responses): # sorting by publish date def _sort_by_date_time(item): return datetime_parser.since_epoch( - datetime_parser.strptime(item['published'][0:19]) + datetime_parser.strptime(item['published']) ) _result['items'].sort(reverse=True, key=_sort_by_date_time) @@ -1087,7 +1464,10 @@ def _perform(_playlist_idx, _page_token, _offset, _result): else: _post_data['browseId'] = 'FEmy_youtube' - _json_data = self.perform_v1_tv_request(method='POST', path='browse', post_data=_post_data) + _json_data = self.api_request(version=1, + method='POST', + path='browse', + post_data=_post_data) _data = {} if 'continuationContents' in _json_data: _data = _json_data.get('continuationContents', {}).get('horizontalListContinuation', {}) @@ -1172,7 +1552,10 @@ def _perform(_playlist_idx, _page_token, _offset, _result): } playlist_index = None - json_data = self.perform_v1_tv_request(method='POST', path='browse', post_data=_en_post_data) + json_data = self.api_request(version=1, + method='POST', + path='browse', + post_data=_en_post_data) contents = json_data.get('contents', {}).get('sectionListRenderer', {}).get('contents', [{}]) for idx, shelf in enumerate(contents): @@ -1190,8 +1573,8 @@ def _perform(_playlist_idx, _page_token, _offset, _result): def _response_hook(self, **kwargs): response = kwargs['response'] - self._context.log_debug('[data] v3 response: |{0.status_code}|\n' - '\theaders: |{0.headers}|'.format(response)) + self._context.log_debug('API response: |{0.status_code}|\n' + 'headers: |{0.headers}|'.format(response)) try: json_data = response.json() if 'error' in json_data: @@ -1206,8 +1589,8 @@ def _response_hook(self, **kwargs): def _error_hook(self, **kwargs): exc = kwargs['exc'] json_data = getattr(exc, 'json_data', None) - data = getattr(exc, 'pass_data', False) and json_data - exception = getattr(exc, 'raise_exc', False) and YouTubeException + data = getattr(exc, 'pass_data', None) and json_data + exception = getattr(exc, 'raise_exc', None) and YouTubeException if not json_data or 'error' not in json_data: return None, None, None, data, None, exception @@ -1240,129 +1623,78 @@ def _error_hook(self, **kwargs): title, time_ms=timeout) - info = ('[data] v3 error: {reason}\n' - '\texc: |{exc}|\n' - '\tmessage: |{message}|') + info = ('API error: {reason}\n' + 'exc: |{exc}|\n' + 'message: |{message}|') details = {'reason': reason, 'message': message} return '', info, details, data, False, exception - def perform_v3_request(self, method='GET', headers=None, path=None, - post_data=None, params=None, no_login=False, - **kwargs): - # params - _params = {} - - # headers - _headers = {'Host': 'www.googleapis.com', - 'User-Agent': 'Mozilla/5.0 (Windows NT 6.1; WOW64)' - ' AppleWebKit/537.36 (KHTML, like Gecko)' - ' Chrome/39.0.2171.36 Safari/537.36', - 'Accept-Encoding': 'gzip, deflate'} + def api_request(self, + version=3, + method='GET', + path=None, + params=None, + post_data=None, + headers=None, + no_login=False, + **kwargs): + client_data = { + '_endpoint': path.strip('/'), + 'method': method, + } + if headers: + client_data['headers'] = headers + if post_data: + client_data['json'] = post_data + if params: + client_data['params'] = params # a config can decide if a token is allowed if (not no_login and self._access_token and self._config.get('token-allowed', True)): - _headers['Authorization'] = 'Bearer %s' % self._access_token - else: - _params['key'] = self._config_tv['key'] + client_data['_access_token'] = self._access_token - # url - _url = 'https://www.googleapis.com/youtube/v3/%s' % path.strip('/') + client = self.build_client(version, client_data) - if headers: - _headers.update(headers) + if 'key' in client['params'] and not client['params']['key']: + client['params']['key'] = self._config_tv['key'] + + params = client.get('params') if params: - _params.update(params) - log_params = copy.deepcopy(params) + log_params = deepcopy(params) if 'location' in log_params: log_params['location'] = 'xx.xxxx,xx.xxxx' + if 'key' in log_params: + key = list(log_params['key']) + key[5:-5] = '...' + log_params['key'] = ''.join(key) else: log_params = None - self._context.log_debug('[data] v3 request: |{method}|\n' - '\tpath: |{path}|\n' - '\tparams: |{params}|\n' - '\tpost_data: |{data}|\n' - '\theaders: |{headers}|' - .format(method=method, - path=path, - params=log_params, - data=post_data, - headers=_headers)) - - json_data = self.request(_url, - method=method, - headers=_headers, - json=post_data, - params=_params, - response_hook=self._response_hook, - response_hook_kwargs=kwargs, - error_hook=self._error_hook) - return json_data - - def perform_v1_tv_request(self, method='GET', headers=None, path=None, - post_data=None, params=None, no_login=False): - - # params - _params = {} - - # headers - _headers = { - 'User-Agent': ('Mozilla/5.0 (Linux; Android 7.0; SM-G892A Build/NRD90M;' - ' wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0' - ' Chrome/67.0.3396.87 Mobile Safari/537.36'), - 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8', - 'DNT': '1', - 'Accept-Encoding': 'gzip, deflate', - 'Accept-Language': 'en-US,en;q=0.5', - } - - if self._access_token and self._config.get('token-allowed', True) and not no_login: - _headers['Authorization'] = 'Bearer %s' % self._access_token - else: - _params = {'key': self._config_tv['key']} - - # url - _url = 'https://www.googleapis.com/youtubei/v1/%s' % path.strip('/') - + headers = client.get('headers') if headers: - _headers.update(headers) - if params: - _params.update(params) - log_params = copy.deepcopy(params) - if 'location' in log_params: - log_params['location'] = 'xx.xxxx,xx.xxxx' + log_headers = deepcopy(headers) + if 'Authorization' in log_headers: + log_headers['Authorization'] = 'logged in' else: - log_params = None - self._context.log_debug('[data] v1 request: |{method}|\n' - '\tpath: |{path}|\n' - '\tparams: |{params}|\n' - '\tpost_data: |{data}|\n' - '\theaders: |{headers}|' - .format(method=method, + 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=post_data, - headers=_headers)) - - result = self.request(_url, - method=method, - headers=_headers, - json=post_data, - params=_params) - if result is None: - return {} - - self._context.log_debug('[data] v1 response: |{0.status_code}|\n' - '\theaders: |{0.headers}|'.format(result)) - - result_type = result.headers.get('content-type') - if result_type and result_type.startswith('application/json'): - try: - return result.json() - except ValueError: - return { - 'status_code': result.status_code, - 'payload': result.text - } - return {} + data=client.get('json'), + headers=log_headers)) + + json_data = self.request(response_hook=self._response_hook, + response_hook_kwargs=kwargs, + error_hook=self._error_hook, + **client) + return json_data diff --git a/resources/lib/youtube_plugin/youtube/helper/resource_manager.py b/resources/lib/youtube_plugin/youtube/helper/resource_manager.py index ffcbb7cf9..01f52ee4b 100644 --- a/resources/lib/youtube_plugin/youtube/helper/resource_manager.py +++ b/resources/lib/youtube_plugin/youtube/helper/resource_manager.py @@ -52,12 +52,13 @@ def get_channels(self, ids, defer_cache=False): channel_id = items[0]['id'] updated.append(channel_id) except IndexError: - self._context.log_error('Channel not found:\n\t{data}' + self._context.log_error('Channel not found:\n{data}' .format(data=data)) ids = updated result = self._data_cache.get_items(ids, self._data_cache.ONE_MONTH) - to_update = [id_ for id_ in ids if id_ not in result] + to_update = [id_ for id_ in ids + if id_ not in result or result[id_].get('partial')] if result: self._context.log_debug('Found cached data for channels:\n|{ids}|' @@ -119,7 +120,8 @@ def get_fanarts(self, channel_ids, defer_cache=False): def get_playlists(self, ids, defer_cache=False): ids = tuple(ids) result = self._data_cache.get_items(ids, self._data_cache.ONE_MONTH) - to_update = [id_ for id_ in ids if id_ not in result] + to_update = [id_ for id_ in ids + if id_ not in result or result[id_].get('partial')] if result: self._context.log_debug('Found cached data for playlists:\n|{ids}|' @@ -249,7 +251,8 @@ def get_videos(self, defer_cache=False): ids = tuple(ids) result = self._data_cache.get_items(ids, self._data_cache.ONE_MONTH) - to_update = [id_ for id_ in ids if id_ not in result] + to_update = [id_ for id_ in ids + if id_ not in result or result[id_].get('partial')] if result: self._context.log_debug('Found cached data for videos:\n|{ids}|' @@ -270,11 +273,13 @@ def get_videos(self, if new_data: self._context.log_debug('Got data for videos:\n|{ids}|' .format(ids=to_update)) - new_data = dict(dict.fromkeys(to_update, {}), **{ - yt_item['id']: yt_item or {} + new_data = { + yt_item['id']: yt_item for batch in new_data for yt_item in batch.get('items', []) - }) + if yt_item + } + new_data = dict(dict.fromkeys(to_update, {}), **new_data) result.update(new_data) self.cache_data(new_data, defer=defer_cache) @@ -291,7 +296,8 @@ def get_videos(self, playback_history = self._context.get_playback_history() played_items = playback_history.get_items(ids) for video_id, play_data in played_items.items(): - result[video_id]['play_data'] = play_data + if video_id in result: + result[video_id]['play_data'] = play_data return result diff --git a/resources/lib/youtube_plugin/youtube/helper/subtitles.py b/resources/lib/youtube_plugin/youtube/helper/subtitles.py index 5a011008b..8effeada9 100644 --- a/resources/lib/youtube_plugin/youtube/helper/subtitles.py +++ b/resources/lib/youtube_plugin/youtube/helper/subtitles.py @@ -262,10 +262,11 @@ def _get(self, lang_code='en', language=None, no_asr=False, download=None): error_info=('Failed to retrieve subtitles for: {lang}: {{exc}}' .format(lang=lang_code)) ) - if not response.text: + response = response and response.text + if not response: return [] - output = bytearray(self._unescape(response.text), + output = bytearray(self._unescape(response), encoding='utf8', errors='ignore') try: diff --git a/resources/lib/youtube_plugin/youtube/helper/url_resolver.py b/resources/lib/youtube_plugin/youtube/helper/url_resolver.py index a6d94cfa8..73b4eeb4a 100644 --- a/resources/lib/youtube_plugin/youtube/helper/url_resolver.py +++ b/resources/lib/youtube_plugin/youtube/helper/url_resolver.py @@ -127,7 +127,7 @@ def resolve(self, url, url_components, method='HEAD'): method=method, headers=self._HEADERS, allow_redirects=True) - if response.status_code != 200: + if not response or not response.ok: return url if path.startswith('/clip'): @@ -197,7 +197,7 @@ def resolve(self, url, url_components, method='HEAD'): method=method, headers=self._HEADERS, allow_redirects=True) - if response.status_code != 200: + if not response or not response.ok: return url return response.url diff --git a/resources/lib/youtube_plugin/youtube/helper/utils.py b/resources/lib/youtube_plugin/youtube/helper/utils.py index 6d0d7e1f2..29c4ddba0 100644 --- a/resources/lib/youtube_plugin/youtube/helper/utils.py +++ b/resources/lib/youtube_plugin/youtube/helper/utils.py @@ -14,6 +14,7 @@ import time from math import log10 +from ...kodion.constants import content from ...kodion.items import DirectoryItem, menu_items from ...kodion.utils import ( create_path, @@ -22,12 +23,12 @@ strip_html_from_text, ) - try: from inputstreamhelper import Helper as ISHelper except ImportError: ISHelper = None + __COLOR_MAP = { 'commentCount': 'cyan', 'favoriteCount': 'gold', @@ -74,7 +75,7 @@ def make_comment_item(context, snippet, uri, total_replies=0): like_count = snippet['likeCount'] if like_count: - like_count, _ = friendly_number(like_count) + like_count = friendly_number(like_count) color = __COLOR_MAP['likeCount'] label_likes = ui.color(color, ui.bold(like_count)) plot_likes = ui.color(color, ui.bold(' '.join(( @@ -84,7 +85,7 @@ def make_comment_item(context, snippet, uri, total_replies=0): plot_props.append(plot_likes) if total_replies: - total_replies, _ = friendly_number(total_replies) + total_replies = friendly_number(total_replies) color = __COLOR_MAP['commentCount'] label_replies = ui.color(color, ui.bold(total_replies)) plot_replies = ui.color(color, ui.bold(' '.join(( @@ -127,12 +128,12 @@ def make_comment_item(context, snippet, uri, total_replies=0): comment_item = DirectoryItem(label, uri) comment_item.set_plot(plot) - datetime = datetime_parser.parse(published_at, as_utc=True) + datetime = datetime_parser.parse(published_at) comment_item.set_added_utc(datetime) local_datetime = datetime_parser.utc_to_local(datetime) comment_item.set_dateadded_from_datetime(local_datetime) if edited: - datetime = datetime_parser.parse(updated_at, as_utc=True) + datetime = datetime_parser.parse(updated_at) local_datetime = datetime_parser.utc_to_local(datetime) comment_item.set_date_from_datetime(local_datetime) @@ -311,11 +312,11 @@ def update_playlist_infos(provider, context, playlist_id_dict, context, playlist_id, title ), # remove as my custom watch later playlist - menu_items.remove_as_watchlater( + menu_items.remove_as_watch_later( context, playlist_id, title ) if playlist_id == custom_watch_later_id else # set as my custom watch later playlist - menu_items.set_as_watchlater( + menu_items.set_as_watch_later( context, playlist_id, title ), # remove as custom history playlist @@ -362,9 +363,9 @@ def update_video_infos(provider, context, video_id_dict, logged_in = provider.is_logged_in() if logged_in: - wl_playlist_id = context.get_access_manager().get_watch_later_id() + watch_later_id = context.get_access_manager().get_watch_later_id() else: - wl_playlist_id = None + watch_later_id = None settings = context.get_settings() hide_shorts = settings.hide_short_videos() @@ -380,7 +381,7 @@ def update_video_infos(provider, context, video_id_dict, video_item = video_id_dict[video_id] # set mediatype - video_item.set_mediatype('video') # using video + video_item.set_mediatype(content.VIDEO_TYPE) if not yt_item or 'snippet' not in yt_item: continue @@ -427,7 +428,7 @@ def update_video_infos(provider, context, video_id_dict, else: start_at = None if start_at: - datetime = datetime_parser.parse(start_at, as_utc=True) + datetime = datetime_parser.parse(start_at) video_item.set_scheduled_start_utc(datetime) local_datetime = datetime_parser.utc_to_local(datetime) video_item.set_year_from_datetime(local_datetime) @@ -452,7 +453,7 @@ def update_video_infos(provider, context, video_id_dict, if not label: continue - str_value, value = friendly_number(value) + str_value, value = friendly_number(value, as_str=False) if not value: continue @@ -488,7 +489,7 @@ def update_video_infos(provider, context, video_id_dict, video_item.set_short_details(label_stats) # Hack to force a custom label mask containing production code, # activated on sort order selection, to display details - # Refer Provider.set_content_type for usage + # Refer XbmcContext.set_content for usage video_item.set_code(label_stats) # update and set the title @@ -531,7 +532,7 @@ def update_video_infos(provider, context, video_id_dict, # date time published_at = snippet.get('publishedAt') if published_at: - datetime = datetime_parser.parse(published_at, as_utc=True) + datetime = datetime_parser.parse(published_at) video_item.set_added_utc(datetime) local_datetime = datetime_parser.utc_to_local(datetime) video_item.set_dateadded_from_datetime(local_datetime) @@ -591,26 +592,12 @@ def update_video_infos(provider, context, video_id_dict, if alternate_player: context_menu.append(menu_items.play_with(context)) - if logged_in: - # add 'Watch Later' only if we are not in my 'Watch Later' list - if wl_playlist_id and playlist_id and wl_playlist_id != playlist_id: + # add 'Watch Later' only if we are not in my 'Watch Later' list + if watch_later_id: + if not playlist_id or watch_later_id != playlist_id: context_menu.append( menu_items.watch_later_add( - context, wl_playlist_id, video_id - ) - ) - - # provide 'remove' for videos in my playlists - # we support all playlist except 'Watch History' - if (video_id in playlist_item_id_dict and playlist_id - and playlist_channel_id == 'mine' - and playlist_id.strip().lower() not in ('hl', 'wl')): - playlist_item_id = playlist_item_id_dict[video_id] - video_item.set_playlist_id(playlist_id) - video_item.set_playlist_item_id(playlist_item_id) - context_menu.append( - menu_items.remove_video_from_playlist( - context, playlist_id, video_id, video_item.get_name() + context, watch_later_id, video_id ) ) else: @@ -620,6 +607,21 @@ def update_video_infos(provider, context, video_id_dict, ) ) + # provide 'remove' for videos in my playlists + # we support all playlist except 'Watch History' + if (logged_in and video_id in playlist_item_id_dict and playlist_id + and playlist_channel_id == 'mine' + and playlist_id.strip().lower() not in ('hl', 'wl')): + playlist_item_id = playlist_item_id_dict[video_id] + video_item.set_playlist_id(playlist_id) + video_item.set_playlist_item_id(playlist_item_id) + context_menu.append( + menu_items.remove_video_from_playlist( + context, playlist_id, video_id, video_item.get_name() + ) + ) + + # got to [CHANNEL] only if we are not directly in the channel if (channel_id and channel_name and create_path('channel', channel_id) != path): diff --git a/resources/lib/youtube_plugin/youtube/helper/v3.py b/resources/lib/youtube_plugin/youtube/helper/v3.py index 0cc753aea..439ce29c9 100644 --- a/resources/lib/youtube_plugin/youtube/helper/v3.py +++ b/resources/lib/youtube_plugin/youtube/helper/v3.py @@ -39,8 +39,13 @@ def _process_list_response(provider, context, json_data): result = [] + item_params = {} incognito = context.get_param('incognito', False) + if incognito: + item_params['incognito'] = incognito addon_id = context.get_param('addon_id', '') + if addon_id: + item_params['addon_id'] = addon_id settings = context.get_settings() thumb_size = settings.use_thumbnail_size() @@ -52,223 +57,138 @@ def _process_list_response(provider, context, json_data): context.log_debug('v3 response: Item discarded, is_youtube=False') continue + item_id = yt_item.get('id') + snippet = yt_item.get('snippet', {}) + title = snippet.get('title', context.localize('untitled')) + image = get_thumbnail(thumb_size, snippet.get('thumbnails', {})) + + if kind == 'searchresult': + _, kind = _parse_kind(item_id) + if kind == 'video': + item_id = item_id['videoId'] + elif kind == 'playlist': + item_id = item_id['playlistId'] + elif kind == 'channel': + item_id = item_id['channelId'] + if kind == 'video': - video_id = yt_item['id'] - snippet = yt_item['snippet'] - title = snippet.get('title', context.localize('untitled')) - image = get_thumbnail(thumb_size, snippet.get('thumbnails', {})) - item_params = {'video_id': video_id} - if incognito: - item_params['incognito'] = incognito - if addon_id: - item_params['addon_id'] = addon_id - item_uri = context.create_uri(['play'], item_params) - video_item = VideoItem(title, item_uri, image=image) - video_item.video_id = video_id - if incognito: - video_item.set_play_count(0) - result.append(video_item) - video_id_dict[video_id] = video_item - elif kind == 'channel': - channel_id = yt_item['id'] - snippet = yt_item['snippet'] - title = snippet.get('title', context.localize('untitled')) - image = get_thumbnail(thumb_size, snippet.get('thumbnails', {})) - item_params = {} - if incognito: - item_params['incognito'] = incognito - if addon_id: - item_params['addon_id'] = addon_id - item_uri = context.create_uri(['channel', channel_id], item_params) - channel_item = DirectoryItem(title, item_uri, image=image) + item_uri = context.create_uri( + ('play',), + dict(item_params, video_id=item_id), + ) + item = VideoItem(title, item_uri, image=image) + video_id_dict[item_id] = item + elif kind == 'channel': + item_uri = context.create_uri( + ('channel', item_id), + item_params, + ) + item = DirectoryItem(title, item_uri, image=image) + channel_id_dict[item_id] = item # if logged in => provide subscribing to the channel if provider.is_logged_in(): - context_menu = [ + context_menu = ( menu_items.subscribe_to_channel( - context, channel_id + context, item_id ), - ] - channel_item.set_context_menu(context_menu) - result.append(channel_item) - channel_id_dict[channel_id] = channel_item + ) + item.set_context_menu(context_menu) + elif kind == 'guidecategory': - guide_id = yt_item['id'] - snippet = yt_item['snippet'] - title = snippet.get('title', context.localize('untitled')) - item_params = {'guide_id': guide_id} - if incognito: - item_params['incognito'] = incognito - if addon_id: - item_params['addon_id'] = addon_id - item_uri = context.create_uri(['special', 'browse_channels'], item_params) - guide_item = DirectoryItem(title, item_uri) - result.append(guide_item) - elif kind == 'subscription': - snippet = yt_item['snippet'] - title = snippet.get('title', context.localize('untitled')) - image = get_thumbnail(thumb_size, snippet.get('thumbnails', {})) - channel_id = snippet['resourceId']['channelId'] - item_params = {} - if incognito: - item_params['incognito'] = incognito - if addon_id: - item_params['addon_id'] = addon_id - item_uri = context.create_uri(['channel', channel_id], item_params) - channel_item = DirectoryItem(title, item_uri, image=image) - channel_item.set_channel_id(channel_id) - # map channel id with subscription id - we need it for the unsubscription - subscription_id_dict[channel_id] = yt_item['id'] - - result.append(channel_item) - channel_id_dict[channel_id] = channel_item - elif kind == 'playlist': - playlist_id = yt_item['id'] - snippet = yt_item['snippet'] - title = snippet.get('title', context.localize('untitled')) - image = get_thumbnail(thumb_size, snippet.get('thumbnails', {})) + item_uri = context.create_uri( + ('special', 'browse_channels'), + dict(item_params, guide_id=item_id), + ) + item = DirectoryItem(title, item_uri) - channel_id = snippet['channelId'] + elif kind == 'subscription': + subscription_id = item_id + item_id = snippet['resourceId']['channelId'] + # map channel id with subscription id - needed to unsubscribe + subscription_id_dict[item_id] = subscription_id + + item_uri = context.create_uri( + ('channel', item_id), + item_params + ) + item = DirectoryItem(title, item_uri, image=image) + channel_id_dict[item_id] = item + item.set_channel_id(item_id) - # if the path directs to a playlist of our own, we correct the channel id to 'mine' + elif kind == 'playlist': + # set channel id to 'mine' if the path is for a playlist of our own if context.get_path() == '/channel/mine/playlists/': channel_id = 'mine' - item_params = {} - if incognito: - item_params['incognito'] = incognito - if addon_id: - item_params['addon_id'] = addon_id - item_uri = context.create_uri(['channel', channel_id, 'playlist', playlist_id], item_params) - playlist_item = DirectoryItem(title, item_uri, image=image) - result.append(playlist_item) - playlist_id_dict[playlist_id] = playlist_item - elif kind == 'playlistitem': - snippet = yt_item['snippet'] - video_id = snippet['resourceId']['videoId'] - - # store the id of the playlistItem - for deleting this item we need this item - playlist_item_id_dict[video_id] = yt_item['id'] + else: + channel_id = snippet['channelId'] + item_uri = context.create_uri( + ('channel', channel_id, 'playlist', item_id), + item_params, + ) + item = DirectoryItem(title, item_uri, image=image) + playlist_id_dict[item_id] = item - title = snippet.get('title', context.localize('untitled')) - image = get_thumbnail(thumb_size, snippet.get('thumbnails', {})) - item_params = {'video_id': video_id} - if incognito: - item_params['incognito'] = incognito - if addon_id: - item_params['addon_id'] = addon_id - item_uri = context.create_uri(['play'], item_params) - video_item = VideoItem(title, item_uri, image=image) - video_item.video_id = video_id - if incognito: - video_item.set_play_count(0) - # Get Track-ID from Playlist - video_item.set_track_number(snippet['position'] + 1) - result.append(video_item) - video_id_dict[video_id] = video_item + elif kind == 'playlistitem': + playlistitem_id = item_id + item_id = snippet['resourceId']['videoId'] + # store the id of the playlistItem - needed for deleting item + playlist_item_id_dict[item_id] = playlistitem_id + + item_uri = context.create_uri( + ('play',), + dict(item_params, video_id=item_id), + ) + item = VideoItem(title, item_uri, image=image) + video_id_dict[item_id] = item elif kind == 'activity': - snippet = yt_item['snippet'] details = yt_item['contentDetails'] activity_type = snippet['type'] - - # recommendations if activity_type == 'recommendation': - video_id = details['recommendation']['resourceId']['videoId'] + item_id = details['recommendation']['resourceId']['videoId'] elif activity_type == 'upload': - video_id = details['upload']['videoId'] + item_id = details['upload']['videoId'] else: continue - title = snippet.get('title', context.localize('untitled')) - image = get_thumbnail(thumb_size, snippet.get('thumbnails', {})) - item_params = {'video_id': video_id} - if incognito: - item_params['incognito'] = incognito - if addon_id: - item_params['addon_id'] = addon_id - item_uri = context.create_uri(['play'], item_params) - video_item = VideoItem(title, item_uri, image=image) - video_item.video_id = video_id - if incognito: - video_item.set_play_count(0) - result.append(video_item) - video_id_dict[video_id] = video_item + item_uri = context.create_uri( + ('play',), + dict(item_params, video_id=item_id), + ) + item = VideoItem(title, item_uri, image=image) + video_id_dict[item_id] = item elif kind == 'commentthread': - thread_snippet = yt_item['snippet'] - total_replies = thread_snippet['totalReplyCount'] - snippet = thread_snippet['topLevelComment']['snippet'] - item_params = {'parent_id': yt_item['id']} + total_replies = snippet['totalReplyCount'] + snippet = snippet['topLevelComment']['snippet'] if total_replies: - item_uri = context.create_uri(['special', 'child_comments'], item_params) + item_uri = context.create_uri( + ('special', 'child_comments'), + {'parent_id': item_id} + ) else: item_uri = '' - result.append(make_comment_item(context, snippet, item_uri, total_replies)) + item = make_comment_item(context, snippet, item_uri, total_replies) elif kind == 'comment': - result.append(make_comment_item(context, yt_item['snippet'], uri='')) - - elif kind == 'searchresult': - _, kind = _parse_kind(yt_item.get('id', {})) + item = make_comment_item(context, snippet, uri='') - # video - if kind == 'video': - video_id = yt_item['id']['videoId'] - snippet = yt_item.get('snippet', {}) - title = snippet.get('title', context.localize('untitled')) - image = get_thumbnail(thumb_size, snippet.get('thumbnails', {})) - item_params = {'video_id': video_id} - if incognito: - item_params['incognito'] = incognito - if addon_id: - item_params['addon_id'] = addon_id - item_uri = context.create_uri(['play'], item_params) - video_item = VideoItem(title, item_uri, image=image) - video_item.video_id = video_id - if incognito: - video_item.set_play_count(0) - result.append(video_item) - video_id_dict[video_id] = video_item - # playlist - elif kind == 'playlist': - playlist_id = yt_item['id']['playlistId'] - snippet = yt_item['snippet'] - title = snippet.get('title', context.localize('untitled')) - image = get_thumbnail(thumb_size, snippet.get('thumbnails', {})) - - channel_id = snippet['channelId'] - # if the path directs to a playlist of our own, we correct the channel id to 'mine' - if context.get_path() == '/channel/mine/playlists/': - channel_id = 'mine' - # channel_name = snippet.get('channelTitle', '') - item_params = {} - if incognito: - item_params['incognito'] = incognito - if addon_id: - item_params['addon_id'] = addon_id - item_uri = context.create_uri(['channel', channel_id, 'playlist', playlist_id], item_params) - playlist_item = DirectoryItem(title, item_uri, image=image) - result.append(playlist_item) - playlist_id_dict[playlist_id] = playlist_item - elif kind == 'channel': - channel_id = yt_item['id']['channelId'] - snippet = yt_item['snippet'] - title = snippet.get('title', context.localize('untitled')) - image = get_thumbnail(thumb_size, snippet.get('thumbnails', {})) - item_params = {} - if incognito: - item_params['incognito'] = incognito - if addon_id: - item_params['addon_id'] = addon_id - item_uri = context.create_uri(['channel', channel_id], item_params) - channel_item = DirectoryItem(title, item_uri, image=image) - result.append(channel_item) - channel_id_dict[channel_id] = channel_item - else: - raise KodionException("Unknown kind '%s'" % kind) else: raise KodionException("Unknown kind '%s'" % kind) + if not item: + continue + if isinstance(item, VideoItem): + item.video_id = item_id + if incognito: + item.set_play_count(0) + # Set track number from playlist, or set to current list length to + # match "Default" (unsorted) sort order + position = snippet.get('position') or len(result) + item.set_track_number(position + 1) + result.append(item) + # this will also update the channel_id_dict with the correct channel_id # for each video. channel_items_dict = {} @@ -399,7 +319,7 @@ def _fetch(resource): thread = resource['thread'] if thread: - thread.join(1) + thread.join(5) if not thread.is_alive(): resource['thread'] = None resource['complete'] = True @@ -413,16 +333,29 @@ def _fetch(resource): return result -def response_to_items(provider, context, json_data, sort=None, reverse=False, process_next_page=True): +def response_to_items(provider, + context, + json_data, + sort=None, + reverse=False, + process_next_page=True): is_youtube, kind = _parse_kind(json_data) if not is_youtube: context.log_debug('v3 response: Response discarded, is_youtube=False') return [] - if kind in ['searchlistresponse', 'playlistitemlistresponse', 'playlistlistresponse', - 'subscriptionlistresponse', 'guidecategorylistresponse', 'channellistresponse', - 'videolistresponse', 'activitylistresponse', 'commentthreadlistresponse', - 'commentlistresponse']: + if kind in ( + 'activitylistresponse', + 'channellistresponse', + 'commentlistresponse', + 'commentthreadlistresponse', + 'guidecategorylistresponse', + 'playlistitemlistresponse', + 'playlistlistresponse', + 'searchlistresponse', + 'subscriptionlistresponse', + 'videolistresponse', + ): result = _process_list_response(provider, context, json_data) else: raise KodionException("Unknown kind '%s'" % kind) @@ -439,21 +372,32 @@ def response_to_items(provider, context, json_data, sort=None, reverse=False, pr # next page """ - This will try to prevent the issue 7163 (https://code.google.com/p/gdata-issues/issues/detail?id=7163). - Somehow the APIv3 is missing the token for the next page. We implemented our own calculation for the token - into the YouTube client...this should work for up to ~2000 entries. + This will try to prevent the issue 7163 + https://code.google.com/p/gdata-issues/issues/detail?id=7163 + Somehow the APIv3 is missing the token for the next page. + We implemented our own calculation for the token into the YouTube client + This should work for up to ~2000 entries. """ - yt_total_results = int(json_data.get('pageInfo', {}).get('totalResults', 0)) - yt_results_per_page = int(json_data.get('pageInfo', {}).get('resultsPerPage', 0)) + page_info = json_data.get('pageInfo', {}) + yt_total_results = int(page_info.get('totalResults', 0)) + yt_results_per_page = int(page_info.get('resultsPerPage', 0)) page = int(context.get_param('page', 1)) + yt_visitor_data = json_data.get('visitorData', '') yt_next_page_token = json_data.get('nextPageToken', '') + yt_click_tracking = json_data.get('clickTracking', '') if yt_next_page_token or (page * yt_results_per_page < yt_total_results): if not yt_next_page_token: client = provider.get_client(context) - yt_next_page_token = client.calculate_next_page_token(page + 1, yt_results_per_page) + yt_next_page_token = client.calculate_next_page_token( + page + 1, yt_results_per_page + ) new_params = dict(context.get_params(), page_token=yt_next_page_token) + if yt_click_tracking: + new_params['visitor'] = yt_visitor_data + if yt_click_tracking: + new_params['click_tracking'] = yt_click_tracking new_context = context.clone(new_params=new_params) current_page = new_context.get_param('page', 1) next_page_item = NextPageItem(new_context, current_page) diff --git a/resources/lib/youtube_plugin/youtube/helper/video_info.py b/resources/lib/youtube_plugin/youtube/helper/video_info.py index d2072f770..64088ff27 100644 --- a/resources/lib/youtube_plugin/youtube/helper/video_info.py +++ b/resources/lib/youtube_plugin/youtube/helper/video_info.py @@ -665,7 +665,6 @@ def load_stream_infos(self, video_id): return self._get_video_info() def _get_player_page(self, client='web', embed=False): - client = self.build_client(client) if embed: url = 'https://www.youtube.com/embed/{0}'.format(self.video_id) else: @@ -673,7 +672,7 @@ def _get_player_page(self, client='web', embed=False): cookies = {'CONSENT': 'YES+cb.20210615-14-p0.en+FX+294'} result = self.request( - url, cookies=cookies, headers=client['headers'], + url, cookies=cookies, headers=self.build_client(client)['headers'], error_msg=('Failed to get player html for video_id: {0}' .format(self.video_id)) ) @@ -746,18 +745,17 @@ def _get_player_js(self): if cached: return cached - client = self.build_client('web') result = self.request( - js_url, headers=client['headers'], + js_url, headers=self.build_client('web')['headers'], error_msg=('Failed to get player js for video_id: {0}' .format(self.video_id)) ) + result = result and result.text if not result: return '' - javascript = result.text - self._data_cache.set_item(js_cache_key, {'js': javascript}) - return javascript + self._data_cache.set_item(js_cache_key, {'js': result}) + return result @staticmethod def _make_curl_headers(headers, cookies=None): @@ -856,7 +854,11 @@ def _load_hls_manifest(self, url, live_type=None, meta_info=None, stream_list.append(stream) return stream_list - def _create_stream_list(self, streams, meta_info=None, headers=None, playback_stats=None): + def _create_stream_list(self, + streams, + meta_info=None, + headers=None, + playback_stats=None): if not headers and self._selected_client: headers = self._selected_client['headers'].copy() if 'Authorization' in headers: @@ -991,8 +993,16 @@ def _get_error_details(self, playability_status, details=None): if not details: details = ( 'errorScreen', - ('playerErrorMessageRenderer', 'confirmDialogRenderer'), - ('reason', 'title') + ( + ( + 'playerErrorMessageRenderer', + 'reason', + ), + ( + 'confirmDialogRenderer', + 'title', + ), + ) ) result = self.json_traverse(playability_status, details) @@ -1012,14 +1022,18 @@ def _get_error_details(self, playability_status, details=None): return None def _get_video_info(self): - auth_header = bool(self._access_token) video_info_url = 'https://www.youtube.com/youtubei/v1/player' _settings = self._context.get_settings() playability_status = status = reason = None + + client_data = {'json': {'videoId': self.video_id}} + if self._access_token: + client_data['_access_token'] = self._access_token + for _ in range(2): for client_name in self._prioritised_clients: - client = self.build_client(client_name, auth_header) + client = self.build_client(client_name, client_data) result = self.request( video_info_url, 'POST', @@ -1028,7 +1042,8 @@ def _get_video_info(self): ' using {1} client ({2})' .format(self.video_id, client_name, - 'logged in' if auth_header else 'logged out') + 'logged in' if '_access_token' in client_data + else 'logged out') ), raise_error=True, **client @@ -1074,8 +1089,8 @@ def _get_video_info(self): # Only attempt to remove Authorization header if clients iterable # was exhausted i.e. request attempted using all clients else: - if auth_header: - auth_header = False + if '_access_token' in client_data: + del client_data['_access_token'] continue # Otherwise skip retrying clients without Authorization header break @@ -1100,7 +1115,8 @@ def _get_video_info(self): self._context.log_debug( 'Retrieved video info for video_id: {0}, using {1} client ({2})' .format(self.video_id, client_name, - 'logged in' if auth_header else 'logged out') + 'logged in' if '_access_token' in client_data + else 'logged out') ) self._selected_client = client.copy() @@ -1126,10 +1142,9 @@ def _get_video_info(self): video_info_url, 'POST', error_msg=('Caption request failed to get player response for' 'video_id: {0}'.format(self.video_id)), - **self.build_client('smarttv_embedded', True) + **self.build_client('smarttv_embedded', client_data) ) - - response = result.json() + response = result and result.json() or {} captions = response.get('captions') if captions: captions['headers'] = result.request.headers diff --git a/resources/lib/youtube_plugin/youtube/helper/yt_login.py b/resources/lib/youtube_plugin/youtube/helper/yt_login.py index 4afb9992a..ac7b2bbf8 100644 --- a/resources/lib/youtube_plugin/youtube/helper/yt_login.py +++ b/resources/lib/youtube_plugin/youtube/helper/yt_login.py @@ -11,7 +11,6 @@ from __future__ import absolute_import, division, unicode_literals import copy -import json import time from ..youtube_exceptions import LoginException @@ -67,49 +66,53 @@ def _do_login(_for_tv=False): '[CR]%s %s' % (context.localize('sign.enter_code'), context.get_ui().bold(user_code))] text = ''.join(text) - dialog = context.get_ui().create_progress_dialog( - heading=context.localize('sign.in'), text=text, background=False) - - steps = ((10 * 60 * 1000) // interval) # 10 Minutes - dialog.set_total(steps) - for _ in range(steps): - dialog.update() - try: - if _for_tv: - json_data = _client.request_access_token_tv(device_code) - else: - json_data = _client.request_access_token(device_code) - except LoginException: - _do_logout() - raise - - log_data = copy.deepcopy(json_data) - if 'access_token' in log_data: - log_data['access_token'] = '' - if 'refresh_token' in log_data: - log_data['refresh_token'] = '' - context.log_debug('Requesting access token: |%s|' % json.dumps(log_data)) - - if 'error' not in json_data: - _access_token = json_data.get('access_token', '') - _expires_in = time.time() + int(json_data.get('expires_in', 3600)) - _refresh_token = json_data.get('refresh_token', '') - dialog.close() - if not _access_token and not _refresh_token: - _expires_in = 0 - return _access_token, _expires_in, _refresh_token - - if json_data['error'] != 'authorization_pending': - message = json_data['error'] - title = '%s: %s' % (context.get_name(), message) - context.get_ui().show_notification(message, title) - context.log_error('Error requesting access token: |%s|' % message) - - if dialog.is_aborted(): - break - - context.sleep(interval) - dialog.close() + + with context.get_ui().create_progress_dialog( + heading=context.localize('sign.in'), text=text, background=False + ) as dialog: + steps = ((10 * 60 * 1000) // interval) # 10 Minutes + dialog.set_total(steps) + for _ in range(steps): + dialog.update() + try: + if _for_tv: + json_data = _client.request_access_token_tv(device_code) + else: + json_data = _client.request_access_token(device_code) + except LoginException: + _do_logout() + raise + + log_data = copy.deepcopy(json_data) + if 'access_token' in log_data: + log_data['access_token'] = '' + if 'refresh_token' in log_data: + log_data['refresh_token'] = '' + context.log_debug('Requesting access token: |{data}|'.format( + data=log_data + )) + + if 'error' not in json_data: + _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 + else: + _expires_in = (int(json_data.get('expires_in', 3600)) + + time.time()) + return _access_token, _expires_in, _refresh_token + + if json_data['error'] != 'authorization_pending': + message = json_data['error'] + title = '%s: %s' % (context.get_name(), message) + context.get_ui().show_notification(message, title) + context.log_error('Error requesting access token: |error|' + .format(error=message)) + + if dialog.is_aborted(): + break + + context.sleep(interval) return '', 0, '' if mode == 'out': diff --git a/resources/lib/youtube_plugin/youtube/helper/yt_playlist.py b/resources/lib/youtube_plugin/youtube/helper/yt_playlist.py index 526a0af28..c3cf87cf0 100644 --- a/resources/lib/youtube_plugin/youtube/helper/yt_playlist.py +++ b/resources/lib/youtube_plugin/youtube/helper/yt_playlist.py @@ -15,55 +15,58 @@ def _process_add_video(provider, context, keymap_action=False): - listitem_path = context.get_ui().get_info_label('Container.ListItem(0).FileNameAndPath') + path = context.get_infolabel('Container.ListItem(0).FileNameAndPath') client = provider.get_client(context) + logged_in = provider.is_logged_in() + if not logged_in: + raise KodionException('Playlist/Add: not logged in') + watch_later_id = context.get_access_manager().get_watch_later_id() playlist_id = context.get_param('playlist_id', '') - if not playlist_id: - raise KodionException('Playlist/Add: missing playlist_id') - if playlist_id.lower() == 'watch_later': playlist_id = watch_later_id + if not playlist_id: + raise KodionException('Playlist/Add: missing playlist_id') + video_id = context.get_param('video_id', '') if not video_id: - if context.is_plugin_path(listitem_path, 'play/'): - video_id = find_video_id(listitem_path) + if context.is_plugin_path(path, 'play/'): + video_id = find_video_id(path) keymap_action = True if not video_id: raise KodionException('Playlist/Add: missing video_id') - if playlist_id != 'HL': - json_data = client.add_video_to_playlist(playlist_id=playlist_id, video_id=video_id) - if not json_data: - return False - - if playlist_id == watch_later_id: - notify_message = context.localize('watch_later.added_to') - else: - notify_message = context.localize('playlist.added_to') + json_data = client.add_video_to_playlist(playlist_id=playlist_id, + video_id=video_id) + if not json_data: + context.log_debug('Playlist/Add: failed for playlist |{playlist_id}|' + .format(playlist_id=playlist_id)) + return False - context.get_ui().show_notification( - message=notify_message, - time_ms=2500, - audible=False - ) + if playlist_id == watch_later_id: + notify_message = context.localize('watch_later.added_to') + else: + notify_message = context.localize('playlist.added_to') - if keymap_action: - context.get_ui().set_focus_next_item() + context.get_ui().show_notification( + message=notify_message, + time_ms=2500, + audible=False + ) - return True + if keymap_action: + context.get_ui().set_focus_next_item() - context.log_debug('Cannot add to playlist id |%s|' % playlist_id) - return False + return True def _process_remove_video(provider, context): - listitem_playlist_id = context.get_ui().get_info_label('Container.ListItem(0).Property(playlist_id)') - listitem_playlist_item_id = context.get_ui().get_info_label('Container.ListItem(0).Property(playlist_item_id)') - listitem_title = context.get_ui().get_info_label('Container.ListItem(0).Title') + listitem_playlist_id = context.get_infolabel('Container.ListItem(0).Property(playlist_id)') + listitem_playlist_item_id = context.get_infolabel('Container.ListItem(0).Property(playlist_item_id)') + listitem_title = context.get_infolabel('Container.ListItem(0).Title') keymap_action = False playlist_id = context.get_param('playlist_id', '') @@ -91,7 +94,7 @@ def _process_remove_video(provider, context): else: raise KodionException('Playlist/Remove: missing video_name') - if playlist_id != 'HL' and playlist_id.strip().lower() != 'wl': + if playlist_id.strip().lower() not in ('wl', 'hl'): if context.get_ui().on_remove_content(video_name): json_data = provider.get_client(context).remove_video_from_playlist(playlist_id=playlist_id, playlist_item_id=video_id) @@ -135,16 +138,18 @@ def _process_remove_playlist(provider, context): def _process_select_playlist(provider, context): + # Get listitem path asap, relies on listitems focus + path = context.get_infolabel('Container.ListItem(0).FileNameAndPath') + ui = context.get_ui() - listitem_path = ui.get_info_label('Container.ListItem(0).FileNameAndPath') # do this asap, relies on listitems focus keymap_action = False page_token = '' current_page = 0 video_id = context.get_param('video_id', '') if not video_id: - if context.is_plugin_path(listitem_path, 'play/'): - video_id = find_video_id(listitem_path) + if context.is_plugin_path(path, 'play/'): + video_id = find_video_id(path) if video_id: context.set_param('video_id', video_id) keymap_action = True @@ -181,11 +186,11 @@ def _process_select_playlist(provider, context): resource_manager = provider.get_resource_manager(context) my_playlists = resource_manager.get_related_playlists(channel_id='mine') if 'watchLater' in my_playlists: - watch_later_playlist_id = context.get_access_manager().get_watch_later_id() - if watch_later_playlist_id: + watch_later_id = context.get_access_manager().get_watch_later_id() + if watch_later_id: items.append(( ui.bold(context.localize('watch_later')), '', - watch_later_playlist_id, + watch_later_id, context.create_resource_path('media', 'watch_later.png') )) @@ -249,7 +254,7 @@ def _process_rename_playlist(provider, context): context.get_ui().refresh_container() -def _watchlater_playlist_id_change(context, method): +def _watch_later_playlist_id_change(context, method): playlist_id = context.get_param('playlist_id', '') if not playlist_id: raise KodionException('watchlater_list/%s: missing playlist_id' % method) @@ -264,7 +269,7 @@ def _watchlater_playlist_id_change(context, method): return elif method == 'remove': if context.get_ui().on_yes_no_input(context.get_name(), context.localize('watch_later.list.remove.confirm') % playlist_name): - context.get_access_manager().set_watch_later_id(' WL') + context.get_access_manager().set_watch_later_id('WL') else: return else: @@ -306,8 +311,8 @@ def process(method, category, provider, context): return _process_select_playlist(provider, context) if method == 'rename' and category == 'playlist': return _process_rename_playlist(provider, context) - if method in {'set', 'remove'} and category == 'watchlater': - return _watchlater_playlist_id_change(context, method) + if method in {'set', 'remove'} and category == 'watch_later': + return _watch_later_playlist_id_change(context, method) if method in {'set', 'remove'} and category == 'history': return _history_playlist_id_change(context, method) raise KodionException("Unknown category '%s' or method '%s'" % (category, method)) diff --git a/resources/lib/youtube_plugin/youtube/helper/yt_specials.py b/resources/lib/youtube_plugin/youtube/helper/yt_specials.py index ec3f19393..1f5cc53b3 100644 --- a/resources/lib/youtube_plugin/youtube/helper/yt_specials.py +++ b/resources/lib/youtube_plugin/youtube/helper/yt_specials.py @@ -25,24 +25,32 @@ def _process_related_videos(provider, context): - provider.set_content_type(context, content.VIDEOS) + context.set_content(content.VIDEO_CONTENT) + function_cache = context.get_function_cache() + video_id = context.get_param('video_id', '') - if not video_id: - return [] + if video_id: + json_data = function_cache.get( + provider.get_client(context).get_related_videos, + function_cache.ONE_HOUR, + video_id=video_id, + page_token=context.get_param('page_token', ''), + ) + else: + json_data = function_cache.get( + provider.get_client(context).get_related_for_home, + function_cache.ONE_HOUR, + page_token=context.get_param('page_token', ''), + ) - json_data = provider.get_client(context).get_related_videos( - video_id=video_id, page_token=context.get_param('page_token', '') - ) if not json_data: return False - return v3.response_to_items(provider, - context, - json_data, - process_next_page=False) + return v3.response_to_items(provider, context, json_data) def _process_parent_comments(provider, context): - provider.set_content_type(context, content.FILES) + context.set_content(content.LIST_CONTENT) + video_id = context.get_param('video_id', '') if not video_id: return [] @@ -50,13 +58,15 @@ def _process_parent_comments(provider, context): json_data = provider.get_client(context).get_parent_comments( video_id=video_id, page_token=context.get_param('page_token', '') ) + if not json_data: return False return v3.response_to_items(provider, context, json_data) def _process_child_comments(provider, context): - provider.set_content_type(context, content.FILES) + context.set_content(content.LIST_CONTENT) + parent_id = context.get_param('parent_id', '') if not parent_id: return [] @@ -64,54 +74,66 @@ def _process_child_comments(provider, context): json_data = provider.get_client(context).get_child_comments( parent_id=parent_id, page_token=context.get_param('page_token', '') ) + if not json_data: return False return v3.response_to_items(provider, context, json_data) def _process_recommendations(provider, context): - provider.set_content_type(context, content.VIDEOS) - json_data = provider.get_client(context).get_activities( - channel_id='home', page_token=context.get_param('page_token', '') + context.set_content(content.VIDEO_CONTENT) + params = context.get_params() + function_cache = context.get_function_cache() + + json_data = function_cache.get( + provider.get_client(context).get_recommended_for_home, + function_cache.ONE_HOUR, + visitor=params.get('visitor', ''), + page_token=params.get('page_token', ''), + click_tracking=params.get('click_tracking', ''), ) + if not json_data: return False return v3.response_to_items(provider, context, json_data) -def _process_popular_right_now(provider, context): - provider.set_content_type(context, content.VIDEOS) - json_data = provider.get_client(context).get_popular_videos( +def _process_trending(provider, context): + context.set_content(content.VIDEO_CONTENT) + + json_data = provider.get_client(context).get_trending_videos( page_token=context.get_param('page_token', '') ) + if not json_data: return False return v3.response_to_items(provider, context, json_data) def _process_browse_channels(provider, context): - provider.set_content_type(context, content.FILES) + context.set_content(content.LIST_CONTENT) client = provider.get_client(context) + guide_id = context.get_param('guide_id', '') if guide_id: json_data = client.get_guide_category(guide_id) - if not json_data: - return False - return v3.response_to_items(provider, context, json_data) + else: + function_cache = context.get_function_cache() + json_data = function_cache.get(client.get_guide_categories, + function_cache.ONE_MONTH) - function_cache = context.get_function_cache() - json_data = function_cache.get(client.get_guide_categories, - function_cache.ONE_MONTH) if not json_data: return False return v3.response_to_items(provider, context, json_data) def _process_disliked_videos(provider, context): - provider.set_content_type(context, content.VIDEOS) + context.set_content(content.VIDEO_CONTENT) + json_data = provider.get_client(context).get_disliked_videos( page_token=context.get_param('page_token', '') ) + if not json_data: return False return v3.response_to_items(provider, context, json_data) @@ -121,13 +143,15 @@ def _process_live_events(provider, context, event_type='live'): def _sort(x): return x.get_date() - provider.set_content_type(context, content.VIDEOS) + context.set_content(content.VIDEO_CONTENT) + # TODO: cache result json_data = provider.get_client(context).get_live_events( event_type=event_type, page_token=context.get_param('page_token', ''), location=context.get_param('location', False), ) + if not json_data: return False return v3.response_to_items(provider, context, json_data, sort=_sort) @@ -139,50 +163,45 @@ def _process_description_links(provider, context): addon_id = params.get('addon_id', '') def _extract_urls(video_id): - provider.set_content_type(context, content.VIDEOS) + context.set_content(content.VIDEO_CONTENT) url_resolver = UrlResolver(context) - progress_dialog = context.get_ui().create_progress_dialog( + with context.get_ui().create_progress_dialog( heading=context.localize('please_wait'), background=False - ) - - resource_manager = provider.get_resource_manager(context) - - video_data = resource_manager.get_videos((video_id, )) - yt_item = video_data[video_id] - if not yt_item or 'snippet' not in yt_item: - context.get_ui().on_ok( - title=context.localize('video.description.links'), - text=context.localize('video.description.links.not_found') - ) - return False - snippet = yt_item['snippet'] - description = strip_html_from_text(snippet['description']) - - function_cache = context.get_function_cache() - urls = function_cache.get(extract_urls, - function_cache.ONE_WEEK, - description) - - progress_dialog.set_total(len(urls)) - - res_urls = [] - for url in urls: - progress_dialog.update(steps=1, text=url) - resolved_url = url_resolver.resolve(url) - res_urls.append(resolved_url) - - if progress_dialog.is_aborted(): - context.log_debug('Resolving urls aborted') - break - - context.sleep(50) - - url_to_item_converter = UrlToItemConverter() - url_to_item_converter.add_urls(res_urls, context) - result = url_to_item_converter.get_items(provider, context) - - progress_dialog.close() + ) as progress_dialog: + resource_manager = provider.get_resource_manager(context) + + video_data = resource_manager.get_videos((video_id,)) + yt_item = video_data[video_id] + if not yt_item or 'snippet' not in yt_item: + context.get_ui().on_ok( + title=context.localize('video.description.links'), + text=context.localize('video.description.links.not_found') + ) + return False + snippet = yt_item['snippet'] + description = strip_html_from_text(snippet['description']) + + function_cache = context.get_function_cache() + urls = function_cache.get(extract_urls, + function_cache.ONE_WEEK, + description) + + progress_dialog.set_total(len(urls)) + + res_urls = [] + for url in urls: + progress_dialog.update(steps=1, text=url) + resolved_url = url_resolver.resolve(url) + res_urls.append(resolved_url) + + if progress_dialog.is_aborted(): + context.log_debug('Resolving urls aborted') + break + + url_to_item_converter = UrlToItemConverter() + url_to_item_converter.add_urls(res_urls, context) + result = url_to_item_converter.get_items(provider, context) if result: return result @@ -260,47 +279,46 @@ def _display_playlists(playlist_ids): def _process_saved_playlists_tv(provider, context): - provider.set_content_type(context, content.FILES) + context.set_content(content.LIST_CONTENT) + json_data = provider.get_client(context).get_saved_playlists( page_token=context.get_param('next_page_token', ''), offset=context.get_param('offset', 0) ) - return tv.saved_playlists_to_items(provider, context, json_data) + if not json_data: + return False + return tv.saved_playlists_to_items(provider, context, json_data) -def _process_new_uploaded_videos_tv(provider, context): - provider.set_content_type(context, content.VIDEOS) - json_data = provider.get_client(context).get_my_subscriptions( - page_token=context.get_param('next_page_token', ''), - offset=context.get_param('offset', 0) - ) - return tv.my_subscriptions_to_items(provider, context, json_data) +def _process_new_uploaded_videos_tv(provider, context, filtered=False): + context.set_content(content.VIDEO_CONTENT) -def _process_new_uploaded_videos_tv_filtered(provider, context): - provider.set_content_type(context, content.VIDEOS) json_data = provider.get_client(context).get_my_subscriptions( page_token=context.get_param('next_page_token', ''), offset=context.get_param('offset', 0) ) + + if not json_data: + return False return tv.my_subscriptions_to_items(provider, context, json_data, - do_filter=True) + do_filter=filtered) def process(category, provider, context): _ = provider.get_client(context) # required for provider.is_logged_in() if (not provider.is_logged_in() - and category in ['new_uploaded_videos_tv', + and category in ('new_uploaded_videos_tv', 'new_uploaded_videos_tv_filtered', - 'disliked_videos']): + 'disliked_videos')): return UriItem(context.create_uri(('sign', 'in'))) if category == 'related_videos': return _process_related_videos(provider, context) if category == 'popular_right_now': - return _process_popular_right_now(provider, context) + return _process_trending(provider, context) if category == 'recommendations': return _process_recommendations(provider, context) if category == 'browse_channels': @@ -308,7 +326,7 @@ def process(category, provider, context): if category == 'new_uploaded_videos_tv': return _process_new_uploaded_videos_tv(provider, context) if category == 'new_uploaded_videos_tv_filtered': - return _process_new_uploaded_videos_tv_filtered(provider, context) + return _process_new_uploaded_videos_tv(provider, context, filtered=True) if category == 'disliked_videos': return _process_disliked_videos(provider, context) if category == 'live': diff --git a/resources/lib/youtube_plugin/youtube/helper/yt_subscriptions.py b/resources/lib/youtube_plugin/youtube/helper/yt_subscriptions.py index 9a7a774c3..2d40944b4 100644 --- a/resources/lib/youtube_plugin/youtube/helper/yt_subscriptions.py +++ b/resources/lib/youtube_plugin/youtube/helper/yt_subscriptions.py @@ -29,7 +29,7 @@ def _process_list(provider, context): def _process_add(provider, context): - listitem_subscription_id = context.get_ui().get_info_label('Container.ListItem(0).Property(subscription_id)') + listitem_subscription_id = context.get_infolabel('Container.ListItem(0).Property(subscription_id)') subscription_id = context.get_param('subscription_id', '') if (not subscription_id and listitem_subscription_id @@ -53,7 +53,7 @@ def _process_add(provider, context): def _process_remove(provider, context): - listitem_subscription_id = context.get_ui().get_info_label('Container.ListItem(0).Property(channel_subscription_id)') + listitem_subscription_id = context.get_infolabel('Container.ListItem(0).Property(channel_subscription_id)') subscription_id = context.get_param('subscription_id', '') if not subscription_id and listitem_subscription_id: diff --git a/resources/lib/youtube_plugin/youtube/helper/yt_video.py b/resources/lib/youtube_plugin/youtube/helper/yt_video.py index b1e2511a7..c1e3bd527 100644 --- a/resources/lib/youtube_plugin/youtube/helper/yt_video.py +++ b/resources/lib/youtube_plugin/youtube/helper/yt_video.py @@ -11,11 +11,12 @@ from __future__ import absolute_import, division, unicode_literals from ...kodion import KodionException +from ...kodion.items import menu_items from ...kodion.utils import find_video_id def _process_rate_video(provider, context, re_match): - listitem_path = context.get_ui().get_info_label('Container.ListItem(0).FileNameAndPath') + listitem_path = context.get_infolabel('Container.ListItem(0).FileNameAndPath') ratings = ['like', 'dislike', 'none'] rating_param = context.get_param('rating', '') @@ -90,33 +91,25 @@ def _process_rate_video(provider, context, re_match): def _process_more_for_video(context): - video_id = context.get_param('video_id', '') + params = context.get_params() + + video_id = params.get('video_id') if not video_id: raise KodionException('video/more/: missing video_id') - items = [] - - logged_in = context.get_param('logged_in', '0') - if logged_in == '1': - # add video to a playlist - items.append((context.localize('video.add_to_playlist'), - 'RunPlugin(%s)' % context.create_uri(['playlist', 'select', 'playlist'], {'video_id': video_id}))) - - # default items - items.extend([(context.localize('related_videos'), - 'Container.Update(%s)' % context.create_uri(['special', 'related_videos'], {'video_id': video_id})), - (context.localize('video.comments'), - 'Container.Update(%s)' % context.create_uri(['special', 'parent_comments'], {'video_id': video_id})), - (context.localize('video.description.links'), - 'Container.Update(%s)' % context.create_uri(['special', 'description_links'], - {'video_id': video_id}))]) - - if logged_in == '1': - # rate a video - refresh_container = context.get_param('refresh_container', '0') - items.append((context.localize('video.rate'), - 'RunPlugin(%s)' % context.create_uri(['video', 'rate'], {'video_id': video_id, - 'refresh_container': refresh_container}))) + items = [ + menu_items.add_video_to_playlist(context, video_id), + menu_items.related_videos(context, video_id), + menu_items.video_comments(context, video_id), + menu_items.content_from_description(context, video_id), + menu_items.rate_video(context, + video_id, + params.get('refresh_container')), + ] if params.get('logged_in') else [ + menu_items.related_videos(context, video_id), + menu_items.video_comments(context, video_id), + menu_items.content_from_description(context, video_id), + ] result = context.get_ui().on_select(context.localize('video.more'), items) if result != -1: diff --git a/resources/lib/youtube_plugin/youtube/provider.py b/resources/lib/youtube_plugin/youtube/provider.py index c1c72e2c2..26517f7c9 100644 --- a/resources/lib/youtube_plugin/youtube/provider.py +++ b/resources/lib/youtube_plugin/youtube/provider.py @@ -11,9 +11,7 @@ from __future__ import absolute_import, division, unicode_literals import json -import os import re -import socket from base64 import b64decode from .client import YouTube @@ -33,18 +31,19 @@ ) from .youtube_exceptions import InvalidGrant, LoginException from ..kodion import AbstractProvider, RegisterProviderPath -from ..kodion.compatibility import xbmcaddon, xbmcvfs from ..kodion.constants import ( ADDON_ID, - DATA_PATH, - TEMP_PATH, content, paths, - sort, ) -from ..kodion.items import DirectoryItem, NewSearchItem, SearchItem, menu_items -from ..kodion.network import get_client_ip_address, is_httpd_live -from ..kodion.utils import find_video_id, rm_dir, strip_html_from_text +from ..kodion.items import ( + DirectoryItem, + NewSearchItem, + SearchItem, + UriItem, + menu_items, +) +from ..kodion.utils import find_video_id, strip_html_from_text class Provider(AbstractProvider): @@ -57,11 +56,8 @@ def __init__(self): self.yt_video = yt_video - def get_wizard_supported_views(self): - return ['default', 'episodes'] - def get_wizard_steps(self, context): - return [(yt_setup_wizard.process, [self, context])] + return [(yt_setup_wizard.process, (self, context))] def is_logged_in(self): return self._logged_in @@ -285,7 +281,7 @@ def on_uri2addon(self, context, re_match): @RegisterProviderPath('^(?:/channel/(?P[^/]+))?/playlist/(?P[^/]+)/$') def _on_playlist(self, context, re_match): - self.set_content_type(context, content.VIDEOS) + context.set_content(content.VIDEO_CONTENT) resource_manager = self.get_resource_manager(context) batch_id = (re_match.group('playlist_id'), @@ -305,7 +301,7 @@ def _on_playlist(self, context, re_match): @RegisterProviderPath('^/channel/(?P[^/]+)/playlists/$') def _on_channel_playlists(self, context, re_match): - self.set_content_type(context, content.FILES) + context.set_content(content.LIST_CONTENT) result = [] channel_id = re_match.group('channel_id') @@ -352,7 +348,7 @@ def _on_channel_playlists(self, context, re_match): @RegisterProviderPath('^/channel/(?P[^/]+)/live/$') def _on_channel_live(self, context, re_match): - self.set_content_type(context, content.VIDEOS) + context.set_content(content.VIDEO_CONTENT) result = [] channel_id = re_match.group('channel_id') @@ -380,25 +376,31 @@ def _on_channel_live(self, context, re_match): @RegisterProviderPath('^/(?P(channel|user))/(?P[^/]+)/$') def _on_channel(self, context, re_match): + listitem_channel_id = context.get_infolabel( + 'Container.ListItem(0).Property(channel_id)' + ) + client = self.get_client(context) localize = context.localize create_uri = context.create_uri function_cache = context.get_function_cache() ui = context.get_ui() - listitem_channel_id = ui.get_info_label('Container.ListItem(0).Property(channel_id)') - method = re_match.group('method') channel_id = re_match.group('channel_id') - if (method == 'channel' and channel_id and channel_id.lower() == 'property' - and listitem_channel_id and listitem_channel_id.lower().startswith(('mine', 'uc'))): - context.execute('Container.Update(%s)' % create_uri(('channel', listitem_channel_id))) # redirect if keymap, without redirect results in 'invalid handle -1' + if (method == 'channel' and channel_id + and channel_id.lower() == 'property' + and listitem_channel_id + and listitem_channel_id.lower().startswith(('mine', 'uc'))): + context.execute('ActivateWindow(Videos, {channel}, return)'.format( + channel=create_uri(('channel', listitem_channel_id)) + )) if method == 'channel' and not channel_id: return False - self.set_content_type(context, content.VIDEOS) + context.set_content(content.VIDEO_CONTENT) resource_manager = self.get_resource_manager(context) @@ -496,7 +498,7 @@ def _on_channel(self, context, re_match): # noinspection PyUnusedLocal @RegisterProviderPath('^/location/mine/$') def _on_my_location(self, context, re_match): - self.set_content_type(context, content.FILES) + context.set_content(content.LIST_CONTENT) create_uri = context.create_uri localize = context.localize @@ -571,7 +573,7 @@ def on_play(self, context, re_match): if ({'channel_id', 'live', 'playlist_id', 'playlist_ids', 'video_id'} .isdisjoint(params.keys())): - path = ui.get_info_label('Container.ListItem(0).FileNameAndPath') + path = context.get_infolabel('Container.ListItem(0).FileNameAndPath') if context.is_plugin_path(path, 'play/'): video_id = find_video_id(path) if video_id: @@ -655,14 +657,14 @@ def _on_subscriptions(self, context, re_match): subscriptions = yt_subscriptions.process(method, self, context) if method == 'list': - self.set_content_type(context, content.FILES) + context.set_content(content.LIST_CONTENT) channel_ids = [] for subscription in subscriptions: channel_ids.append(subscription.get_channel_id()) channel_ids = {subscription.get_channel_id(): subscription for subscription in subscriptions} channel_fanarts = resource_manager.get_fanarts(channel_ids) - for channel_id, fanart in channel_fanarts: + for channel_id, fanart in channel_fanarts.items(): channel_ids[channel_id].set_fanart(fanart) return subscriptions @@ -675,109 +677,9 @@ def _on_yt_specials(self, context, re_match): @RegisterProviderPath('^/users/(?P[^/]+)/$') def _on_users(self, context, re_match): action = re_match.group('action') - refresh = context.get_param('refresh') - - localize = context.localize - access_manager = context.get_access_manager() - ui = context.get_ui() - - def select_user(reason, new_user=False): - current_users = access_manager.get_users() - current_user = access_manager.get_current_user() - usernames = [] - for user, details in sorted(current_users.items()): - username = details.get('name') or localize('user.unnamed') - if user == current_user: - username = '> ' + ui.bold(username) - if details.get('access_token') or details.get('refresh_token'): - username = ui.color('limegreen', username) - usernames.append(username) - if new_user: - usernames.append(ui.italic(localize('user.new'))) - return ui.on_select(reason, usernames), sorted(current_users.keys()) - - def add_user(): - results = ui.on_keyboard_input(localize('user.enter_name')) - if results[0] is False: - return None, None - new_username = results[1].strip() - if not new_username: - new_username = localize('user.unnamed') - return access_manager.add_user(new_username) - - def switch_to_user(user): - access_manager.set_user(user, switch_to=True) - ui.show_notification( - localize('user.changed') % access_manager.get_username(user), - localize('user.switch') - ) - self.get_resource_manager(context).clear() - if refresh: - ui.refresh_container() - - if action == 'switch': - result, user_index_map = select_user(localize('user.switch'), - new_user=True) - if result == -1: - return True - if result == len(user_index_map): - user, _ = add_user() - else: - user = user_index_map[result] - - if user is not None and user != access_manager.get_current_user(): - switch_to_user(user) - - elif action == 'add': - user, details = add_user() - if user is not None: - result = ui.on_yes_no_input( - localize('user.switch'), - localize('user.switch.now') % details.get('name') - ) - if result: - switch_to_user(user) - - elif action == 'remove': - result, user_index_map = select_user(localize('user.remove')) - if result == -1: - return True - - user = user_index_map[result] - username = access_manager.get_username(user) - if ui.on_remove_content(username): - access_manager.remove_user(user) - if user == 0: - access_manager.add_user(username=localize('user.default'), - user=0) - if user == access_manager.get_current_user(): - access_manager.set_user(0, switch_to=True) - ui.show_notification(localize('removed') % username, - localize('remove')) - - elif action == 'rename': - result, user_index_map = select_user(localize('user.rename')) - if result == -1: - return True - - user = user_index_map[result] - old_username = access_manager.get_username(user) - results = ui.on_keyboard_input(localize('user.enter_name'), - default=old_username) - if results[0] is False: - return True - new_username = results[1].strip() - if not new_username: - new_username = localize('user.unnamed') - if old_username == new_username: - return True - - if access_manager.set_username(user, new_username): - ui.show_notification(localize('renamed') % (old_username, - new_username), - localize('rename')) - - return True + return UriItem('{addon},users/{action}'.format( + addon=ADDON_ID, action=action + )) @RegisterProviderPath('^/sign/(?P[^/]+)/$') def _on_sign(self, context, re_match): @@ -826,6 +728,7 @@ def on_search(self, search_text, context, re_match): return result context.set_param('q', search_text) + context.set_param('category_label', search_text) params = context.get_params() channel_id = params.get('channel_id') @@ -838,9 +741,9 @@ def on_search(self, search_text, context, re_match): safe_search = context.get_settings().safe_search() if search_type == 'video': - self.set_content_type(context, content.VIDEOS) + context.set_content(content.VIDEO_CONTENT) else: - self.set_content_type(context, content.FILES) + context.set_content(content.LIST_CONTENT) if page == 1 and search_type == 'video' and not event_type and not hide_folders: if not channel_id and not location: @@ -891,61 +794,12 @@ def on_search(self, search_text, context, re_match): result.extend(v3.response_to_items(self, context, json_data)) return result - @RegisterProviderPath('^/config/(?P[^/]+)/$') + @RegisterProviderPath('^/config/(?P[^/]+)/$') def configure_addon(self, context, re_match): - switch = re_match.group('switch') - localize = context.localize - settings = context.get_settings() - ui = context.get_ui() - - if switch == 'youtube': - context.addon().openSettings() - ui.refresh_container() - elif switch == 'isa': - if context.use_inputstream_adaptive(): - xbmcaddon.Addon(id='inputstream.adaptive').openSettings() - else: - settings.set_bool('kodion.video.quality.isa', False) - elif switch == 'subtitles': - yt_language = settings.get_string('youtube.language', 'en-US') - sub_setting = settings.subtitle_languages() - - if yt_language.startswith('en'): - sub_opts = [localize('none'), - localize('prompt'), - localize('subtitles.with_fallback') % ('en', 'en-US/en-GB'), - yt_language, - '%s (%s)' % (yt_language, localize('subtitles.no_auto_generated'))] - - else: - sub_opts = [localize('none'), - localize('prompt'), - localize('subtitles.with_fallback') % (yt_language, 'en'), - yt_language, - '%s (%s)' % (yt_language, localize('subtitles.no_auto_generated'))] - - sub_opts[sub_setting] = ui.bold(sub_opts[sub_setting]) - - result = ui.on_select(localize('subtitles.language'), sub_opts) - if result > -1: - settings.set_subtitle_languages(result) - - result = ui.on_yes_no_input( - localize('subtitles.download'), - localize('subtitles.download.pre') - ) - if result > -1: - settings.set_subtitle_download(result == 1) - elif switch == 'listen_ip': - local_ranges = ('10.', '172.16.', '192.168.') - addresses = [iface[4][0] - for iface in socket.getaddrinfo(socket.gethostname(), None) - if iface[4][0].startswith(local_ranges)] - addresses += ['127.0.0.1', '0.0.0.0'] - selected_address = ui.on_select(localize('select.listen.ip'), addresses) - if selected_address != -1: - settings.set_httpd_listen(addresses[selected_address]) - return False + action = re_match.group('action') + return UriItem('{addon},config/{action}'.format( + addon=ADDON_ID, action=action + )) # noinspection PyUnusedLocal @RegisterProviderPath('^/my_subscriptions/filter/$') @@ -989,101 +843,41 @@ def manage_my_subscription_filter(self, context, re_match): ui.show_notification(message=message) ui.refresh_container() - @RegisterProviderPath('^/maintain/(?P[^/]+)/(?P[^/]+)/$') + @RegisterProviderPath('^/maintenance/(?P[^/]+)/(?P[^/]+)/$') def maintenance_actions(self, context, re_match): - maint_type = re_match.group('maint_type') + target = re_match.group('target') action = re_match.group('action') + if action != 'reset': + return UriItem('{addon},maintenance/{action}/{target}'.format( + addon=ADDON_ID, action=action, target=target + )) + ui = context.get_ui() localize = context.localize - if action == 'clear': - if maint_type == 'function_cache': - if ui.on_remove_content(localize('cache.function')): - context.get_function_cache().clear() - ui.show_notification(localize('succeeded')) - elif maint_type == 'data_cache': - if ui.on_remove_content(localize('cache.data')): - context.get_data_cache().clear() - ui.show_notification(localize('succeeded')) - elif maint_type == 'search_cache': - if ui.on_remove_content(localize('search.history')): - context.get_search_history().clear() - ui.show_notification(localize('succeeded')) - elif maint_type == 'playback_history' and ui.on_remove_content(localize('playback.history')): - context.get_playback_history().clear() - ui.show_notification(localize('succeeded')) - elif action == 'reset': - if maint_type == 'access_manager' and ui.on_yes_no_input(context.get_name(), localize('reset.access_manager.confirm')): - try: - context.get_function_cache().clear() - access_manager = context.get_access_manager() - client = self.get_client(context) - if access_manager.has_refresh_token(): - refresh_tokens = access_manager.get_refresh_token().split('|') - for refresh_token in set(refresh_tokens): - try: - client.revoke(refresh_token) - except: - pass - self.reset_client() - access_manager.update_access_token(access_token='', refresh_token='') - ui.refresh_container() - ui.show_notification(localize('succeeded')) - except: - ui.show_notification(localize('failed')) - elif action == 'delete': - _maint_files = {'function_cache': 'cache.sqlite', - 'search_cache': 'search.sqlite', - 'data_cache': 'data_cache.sqlite', - 'playback_history': 'playback_history', - 'settings_xml': 'settings.xml', - 'api_keys': 'api_keys.json', - 'access_manager': 'access_manager.json', - 'temp_files': TEMP_PATH} - _file = _maint_files.get(maint_type) - succeeded = False - - if not _file: - return - - data_path = xbmcvfs.translatePath(DATA_PATH) - if 'sqlite' in _file: - _file_w_path = os.path.join(data_path, 'kodion', _file) - elif maint_type == 'temp_files': - _file_w_path = _file - elif maint_type == 'playback_history': - _file = ''.join(( - context.get_access_manager().get_current_user_id(), - '.sqlite' - )) - _file_w_path = os.path.join(data_path, 'playback', _file) - else: - _file_w_path = os.path.join(data_path, _file) - - if not ui.on_delete_content(_file): - return - - if maint_type == 'temp_files': - succeeded = rm_dir(_file_w_path) - - elif _file_w_path: - succeeded = xbmcvfs.delete(_file_w_path) - - if succeeded: + if (target == 'access_manager' and ui.on_yes_no_input( + context.get_name(), localize('reset.access_manager.confirm') + )): + try: + context.get_function_cache().clear() + access_manager = context.get_access_manager() + client = self.get_client(context) + if access_manager.has_refresh_token(): + refresh_tokens = access_manager.get_refresh_token() + for refresh_token in set(refresh_tokens.split('|')): + try: + client.revoke(refresh_token) + except: + pass + self.reset_client() + access_manager.update_access_token(access_token='', + refresh_token='') + ui.refresh_container() ui.show_notification(localize('succeeded')) - else: + except: ui.show_notification(localize('failed')) - elif action == 'install' and maint_type == 'inputstreamhelper': - if context.get_system_version().get_version()[0] >= 17: - try: - xbmcaddon.Addon('script.module.inputstreamhelper') - ui.show_notification(localize('inputstreamhelper.is_installed')) - except RuntimeError: - context.execute('InstallAddon(script.module.inputstreamhelper)') - else: - ui.show_notification(localize('requires.krypton')) # noinspection PyUnusedLocal @RegisterProviderPath('^/api/update/$') @@ -1139,20 +933,6 @@ def api_key_update(self, context, re_match): ui.show_notification(localize('api.personal.failed') % ', '.join(missing_list)) context.log_debug('Failed to enable personal API keys. Missing: %s' % ', '.join(log_list)) - # noinspection PyUnusedLocal - @RegisterProviderPath('^/show_client_ip/$') - def show_client_ip(self, context, re_match): - port = context.get_settings().httpd_port() - - if is_httpd_live(port=port): - client_ip = get_client_ip_address(port=port) - if client_ip: - context.get_ui().on_ok(context.get_name(), context.localize('client.ip') % client_ip) - else: - context.get_ui().show_notification(context.localize('client.ip.failed')) - else: - context.get_ui().show_notification(context.localize('httpd.not.running')) - # noinspection PyUnusedLocal def on_playback_history(self, context, re_match): params = context.get_params() @@ -1163,6 +943,7 @@ def on_playback_history(self, context, re_match): playback_history = context.get_playback_history() if action == 'list': + context.set_content(content.VIDEO_CONTENT, sub_type='history') play_data = playback_history.get_items() if not play_data: return True @@ -1251,7 +1032,7 @@ def on_root(self, context, re_match): _ = self.get_client(context) # required for self.is_logged_in() logged_in = self.is_logged_in() - self.set_content_type(context, content.FILES) + # context.set_content(content.LIST_CONTENT) result = [] @@ -1289,26 +1070,37 @@ def on_root(self, context, re_match): result.append(my_subscriptions_filtered_item) access_manager = context.get_access_manager() - - # Recommendations - if logged_in and settings.get_bool('youtube.folder.recommendations.show', True): - watch_history_playlist_id = access_manager.get_watch_history_id() - if watch_history_playlist_id != 'HL': - recommendations_item = DirectoryItem( - localize('recommendations'), - create_uri(('special', 'recommendations')), - image='{media}/popular.png', + watch_later_id = logged_in and access_manager.get_watch_later_id() + history_id = logged_in and access_manager.get_watch_history_id() + local_history = settings.use_local_history() + + # Home / Recommendations + if settings.get_bool('youtube.folder.recommendations.show', True): + recommendations_item = DirectoryItem( + localize('recommendations'), + create_uri(('special', 'recommendations')), + image='{media}/home.png', + ) + result.append(recommendations_item) + + # Related + if settings.get_bool('youtube.folder.related.show', True): + if history_id or local_history: + related_item = DirectoryItem( + localize('related_videos'), + create_uri(('special', 'related_videos')), + image='{media}/related_videos.png', ) - result.append(recommendations_item) + result.append(related_item) - # what to watch + # Trending if settings.get_bool('youtube.folder.popular_right_now.show', True): - what_to_watch_item = DirectoryItem( - localize('popular_right_now'), + trending_item = DirectoryItem( + localize('trending'), create_uri(('special', 'popular_right_now')), - image='{media}/popular.png', + image='{media}/trending.png', ) - result.append(what_to_watch_item) + result.append(trending_item) # search if settings.get_bool('youtube.folder.search.show', True): @@ -1354,16 +1146,15 @@ def on_root(self, context, re_match): # watch later if settings.get_bool('youtube.folder.watch_later.show', True): - playlist_id = logged_in and access_manager.get_watch_later_id() - if playlist_id: + if watch_later_id: watch_later_item = DirectoryItem( localize('watch_later'), - create_uri(('channel', 'mine', 'playlist', playlist_id)), + create_uri(('channel', 'mine', 'playlist', watch_later_id)), image='{media}/watch_later.png', ) context_menu = [ menu_items.play_all_from_playlist( - context, playlist_id + context, watch_later_id ) ] watch_later_item.set_context_menu(context_menu) @@ -1405,21 +1196,20 @@ def on_root(self, context, re_match): # history if settings.get_bool('youtube.folder.history.show', False): - playlist_id = logged_in and access_manager.get_watch_history_id() - if playlist_id and playlist_id != 'HL': + if history_id: watch_history_item = DirectoryItem( localize('history'), - create_uri(('channel', 'mine', 'playlist', playlist_id)), + create_uri(('channel', 'mine', 'playlist', history_id)), image='{media}/history.png', ) context_menu = [ menu_items.play_all_from_playlist( - context, playlist_id + context, history_id ) ] watch_history_item.set_context_menu(context_menu) result.append(watch_history_item) - elif settings.use_local_history(): + elif local_history: watch_history_item = DirectoryItem( localize('history'), create_uri([paths.HISTORY], params={'action': 'list'}), @@ -1521,24 +1311,6 @@ def on_root(self, context, re_match): return result - @staticmethod - def set_content_type(context, content_type): - context.set_content_type(content_type) - context.add_sort_method( - (sort.UNSORTED, '%T \u2022 %P', '%D | %J'), - (sort.LABEL_IGNORE_THE, '%T \u2022 %P', '%D | %J'), - ) - if content_type != content.VIDEOS: - return - context.add_sort_method( - (sort.PROGRAM_COUNT, '%T \u2022 %P | %D | %J', '%C'), - (sort.VIDEO_RATING, '%T \u2022 %P | %D | %J', '%R'), - (sort.DATE, '%T \u2022 %P | %D', '%J'), - (sort.DATEADDED, '%T \u2022 %P | %D', '%a'), - (sort.VIDEO_RUNTIME, '%T \u2022 %P | %J', '%D'), - (sort.TRACKNUM, '[%N. ]%T \u2022 %P', '%D | %J'), - ) - def handle_exception(self, context, exception_to_handle): if isinstance(exception_to_handle, (InvalidGrant, LoginException)): ok_dialog = False diff --git a/resources/media/what_to_watch.png b/resources/media/home.png similarity index 100% rename from resources/media/what_to_watch.png rename to resources/media/home.png diff --git a/resources/media/related_videos.png b/resources/media/related_videos.png new file mode 100644 index 000000000..fdcee48ac Binary files /dev/null and b/resources/media/related_videos.png differ diff --git a/resources/media/popular.png b/resources/media/trending.png similarity index 100% rename from resources/media/popular.png rename to resources/media/trending.png diff --git a/resources/settings.xml b/resources/settings.xml index bf0da9d9b..fbe786f5c 100644 --- a/resources/settings.xml +++ b/resources/settings.xml @@ -39,7 +39,6 @@ 0 - RunPlugin(plugin://plugin.video.youtube/config/subtitles/) true @@ -51,6 +50,7 @@ true + RunScript($ID,config/subtitles) true @@ -214,7 +214,6 @@ 0 - RunPlugin(plugin://plugin.video.youtube/config/isa/) true @@ -223,6 +222,7 @@ true + RunScript($ID,config/isa) true @@ -351,7 +351,6 @@ 0 - RunPlugin(plugin://plugin.video.youtube/maintain/inputstreamhelper/install/) true @@ -364,6 +363,7 @@ + RunScript($ID,config/inputstreamhelper) @@ -421,6 +421,11 @@ true + + 0 + true + + 0 true @@ -578,34 +583,34 @@ 0 - RunPlugin(plugin://plugin.video.youtube/users/add/?refresh=false) true + RunScript($ID,users/add) 0 - RunPlugin(plugin://plugin.video.youtube/users/remove/?refresh=false) true + RunScript($ID,users/remove) 0 - RunPlugin(plugin://plugin.video.youtube/users/rename/?refresh=false) true + RunScript($ID,users/rename) 0 - RunPlugin(plugin://plugin.video.youtube/users/switch/?refresh=false) true + RunScript($ID,users/switch/refresh=True) @@ -679,7 +684,7 @@ 0 - false + true @@ -691,7 +696,7 @@ 0 - 10 + 20 5 1 @@ -782,10 +787,10 @@ 0 - RunPlugin(plugin://plugin.video.youtube/config/listen_ip/) true + RunScript($ID,config/listen_ip) true @@ -823,10 +828,10 @@ 0 - RunPlugin(plugin://plugin.video.youtube/show_client_ip/) true + RunScript($ID,config/show_client_ip) @@ -881,78 +886,78 @@ 0 - RunPlugin(plugin://plugin.video.youtube/maintain/function_cache/clear/) true + RunScript($ID,maintenance/clear/function_cache) 0 - RunPlugin(plugin://plugin.video.youtube/maintain/data_cache/clear/) true + RunScript($ID,maintenance/clear/data_cache) 0 - RunPlugin(plugin://plugin.video.youtube/maintain/search_cache/clear/) true + RunScript($ID,maintenance/clear/search_cache) 0 - RunPlugin(plugin://plugin.video.youtube/maintain/playback_history/clear/) true + RunScript($ID,maintenance/clear/playback_history) 0 - RunPlugin(plugin://plugin.video.youtube/maintain/function_cache/delete/) true + RunScript($ID,maintenance/delete/function_cache) 0 - RunPlugin(plugin://plugin.video.youtube/maintain/data_cache/delete/) true + RunScript($ID,maintenance/delete/data_cache) 0 - RunPlugin(plugin://plugin.video.youtube/maintain/search_cache/delete/) true + RunScript($ID,maintenance/delete/search_cache) 0 - RunPlugin(plugin://plugin.video.youtube/maintain/playback_history/delete/) true + RunScript($ID,maintenance/delete/playback_history) 0 - RunPlugin(plugin://plugin.video.youtube/maintain/access_manager/reset/) true + RunPlugin(plugin://$ID/maintenance/reset/access_manager) true @@ -961,95 +966,38 @@ 0 - RunPlugin(plugin://plugin.video.youtube/maintain/settings_xml/delete/) true + RunScript($ID,maintenance/delete/settings_xml) true 0 - RunPlugin(plugin://plugin.video.youtube/maintain/api_keys/delete/) true + RunScript($ID,maintenance/delete/api_keys) 0 - RunPlugin(plugin://plugin.video.youtube/maintain/access_manager/delete/) true + RunScript($ID,maintenance/delete/access_manager) 0 - RunPlugin(plugin://plugin.video.youtube/maintain/temp_files/delete/) true + RunScript($ID,maintenance/delete/temp_files) - - 0 - - - true - - - - false - - - - - - - - 0 - - - true - - - - false - - - - - - - - 0 - 0 - - - false - - - - - - - - 0 - - - true - - - - false - - - - - -