diff --git a/addon.xml b/addon.xml index e01b16daf..841116c7a 100644 --- a/addon.xml +++ b/addon.xml @@ -1,5 +1,5 @@ - + diff --git a/changelog.txt b/changelog.txt index 24f440a75..57a132a71 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,3 +1,30 @@ +## v7.0.8+beta.1 +### Fixed +- Update selection and sorting of streams to fix missing live streams +- Fix using cached settings for XbmcContext.use_inputstream_adaptive +- Wakeup http server on playback start if required #746 +- Improve handling of context menu play items +- Use consistent return values for function cache to avoid caching errors #782 +- Attempt to fix PlaylistPlayer race condition error #704 +- Fix not reloading access manager when performing user actions #780 +- Fix page jump when page_token is not used +- Ensure container is always refreshed when required and available +- Fix incorrect comparison after cd45122 + +### Changed +- Update icons +- Only enable recommendations when logged in +- Improve sync with internal Kodi watched state #709 +- Align default value for watched percentage with Kodi default #746 +- Limit need to check window property for sleep and wakeup +- Only show Next Page listitem linking to first page when opening listings from the GUI #787 +- Use shortened YouTube url with external players for better compatibility with different types of videos + +### New +- Cache and update My Subscriptions content per channel feed #785 #786 +- Use separate database for My Subscription feed history #785 +- Enable action parameter in play playlist route + ## v7.0.7 ### Fixed - Fixed not being able to re-refresh a directory listing that has already been refreshed @@ -22,7 +49,6 @@ - Improve resource usage - Fix adding video to playlist #764 - Fix plugin settings raising exception in Kodi 19-20 #769 -- Fix location icon background colour ### Changed - Removed Settings > Advanced > Views > Show channel fanart diff --git a/resources/language/resource.language.en_gb/strings.po b/resources/language/resource.language.en_gb/strings.po index d4bce3261..d48d862f0 100644 --- a/resources/language/resource.language.en_gb/strings.po +++ b/resources/language/resource.language.en_gb/strings.po @@ -1552,3 +1552,15 @@ msgstr "" msgctxt "#30811" msgid "Filter Live folders" msgstr "" + +msgctxt "#30812" +msgid "Clear subscription feed history" +msgstr "" + +msgctxt "#30813" +msgid "Delete subscription feed history database" +msgstr "" + +msgctxt "#30814" +msgid "feed history" +msgstr "" diff --git a/resources/lib/youtube_plugin/kodion/abstract_provider.py b/resources/lib/youtube_plugin/kodion/abstract_provider.py index c5b2fae40..a9c38bfbc 100644 --- a/resources/lib/youtube_plugin/kodion/abstract_provider.py +++ b/resources/lib/youtube_plugin/kodion/abstract_provider.py @@ -25,7 +25,8 @@ class AbstractProvider(object): RESULT_CACHE_TO_DISC = 'cache_to_disc' # (bool) - RESULT_UPDATE_LISTING = 'update_listing' + RESULT_FORCE_RESOLVE = 'force_resolve' # (bool) + RESULT_UPDATE_LISTING = 'update_listing' # (bool) def __init__(self): # map for regular expression (path) to method (names) @@ -139,14 +140,14 @@ def navigate(self, context): if not re_match: continue + options = { + self.RESULT_CACHE_TO_DISC: True, + self.RESULT_UPDATE_LISTING: False, + } result = method(context, re_match) if isinstance(result, tuple): - result, options = result - else: - options = { - self.RESULT_CACHE_TO_DISC: True, - self.RESULT_UPDATE_LISTING: False, - } + result, new_options = result + options.update(new_options) refresh = context.get_param('refresh') if refresh is not None: @@ -197,9 +198,12 @@ def _internal_goto_page(self, context, re_match): path = re_match.group('path') params = context.get_params() - page_token = NextPageItem.create_page_token( - page, params.get('items_per_page', 50) - ) + if 'page_token' in params: + page_token = NextPageItem.create_page_token( + page, params.get('items_per_page', 50) + ) + else: + page_token = '' params = dict(params, page=page, page_token=page_token) return self.reroute(context, path=path, params=params) @@ -220,8 +224,8 @@ def reroute(self, context, re_match=None, path=None, params=None): result, options = function_cache.run( self.navigate, seconds=None, - _cacheparams=function_cache.PARAMS_NONE, _refresh=True, + _scope=function_cache.SCOPE_NONE, context=context.clone(path, params), ) finally: @@ -277,13 +281,14 @@ def _internal_search(self, context, re_match): if command == 'input': data_cache = context.get_data_cache() - 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 if (not params.get('refresh') - and folder_path.startswith('plugin://%s' % context.get_id()) - and re.match('.+/(?:query|input)/.*', folder_path)): + and context.is_plugin_path( + context.get_infolabel('Container.FolderPath'), + ('query', 'input') + )): cached = data_cache.get_item('search_query', data_cache.ONE_DAY) if cached: query = to_unicode(cached) diff --git a/resources/lib/youtube_plugin/kodion/constants/__init__.py b/resources/lib/youtube_plugin/kodion/constants/__init__.py index 1d30c094f..0c7c6c682 100644 --- a/resources/lib/youtube_plugin/kodion/constants/__init__.py +++ b/resources/lib/youtube_plugin/kodion/constants/__init__.py @@ -34,14 +34,29 @@ ABORT_FLAG = 'abort_requested' BUSY_FLAG = 'busy' +CHANNEL_ID = 'channel_id' CHECK_SETTINGS = 'check_settings' +DEVELOPER_CONFIGS = 'configs' +CONTENT_TYPE = 'content_type' +LICENSE_TOKEN = 'license_token' +LICENSE_URL = 'license_url' PLAY_COUNT = 'video_play_count' +PLAY_FORCE_AUDIO = 'audio_only' +PLAY_PROMPT_QUALITY = 'ask_for_quality' +PLAY_PROMPT_SUBTITLES = 'prompt_for_subtitles' +PLAYBACK_INIT = 'playback_init' +PLAYBACK_STARTED = 'playback_started' +PLAYBACK_STOPPED = 'playback_stopped' PLAYER_DATA = 'player_json' +PLAYLIST_ID = 'playlist_id' +PLAYLISTITEM_ID = 'playlistitem_id' PLAYLIST_PATH = 'playlist_path' PLAYLIST_POSITION = 'playlist_position' REFRESH_CONTAINER = 'refresh_container' +RELOAD_ACCESS_MANAGER = 'reload_access_manager' REROUTE = 'reroute' SLEEPING = 'sleeping' +SUBSCRIPTION_ID = 'subscription_id' SWITCH_PLAYER_FLAG = 'switch_player' VIDEO_ID = 'video_id' WAIT_FLAG = 'builtin_running' @@ -52,17 +67,32 @@ 'ADDON_ID', 'ADDON_PATH', 'BUSY_FLAG', + 'CHANNEL_ID', 'CHECK_SETTINGS', + 'CONTENT_TYPE', 'DATA_PATH', + 'DEVELOPER_CONFIGS', + 'LICENSE_TOKEN', + 'LICENSE_URL', 'MEDIA_PATH', 'PLAY_COUNT', + 'PLAY_FORCE_AUDIO', + 'PLAY_PROMPT_QUALITY', + 'PLAY_PROMPT_SUBTITLES', + 'PLAYBACK_INIT', + 'PLAYBACK_STARTED', + 'PLAYBACK_STOPPED', 'PLAYER_DATA', + 'PLAYLIST_ID', + 'PLAYLISTITEM_ID', 'PLAYLIST_PATH', 'PLAYLIST_POSITION', 'REFRESH_CONTAINER', + 'RELOAD_ACCESS_MANAGER', 'RESOURCE_PATH', 'REROUTE', 'SLEEPING', + 'SUBSCRIPTION_ID', 'SWITCH_PLAYER_FLAG', 'TEMP_PATH', 'VALUE_FROM_STR', diff --git a/resources/lib/youtube_plugin/kodion/context/abstract_context.py b/resources/lib/youtube_plugin/kodion/context/abstract_context.py index 5c2aefaee..f43bda5e5 100644 --- a/resources/lib/youtube_plugin/kodion/context/abstract_context.py +++ b/resources/lib/youtube_plugin/kodion/context/abstract_context.py @@ -19,6 +19,7 @@ from ..sql_store import ( BookmarksList, DataCache, + FeedHistory, FunctionCache, PlaybackHistory, SearchHistory, @@ -107,13 +108,15 @@ class AbstractContext(object): } def __init__(self, path='/', params=None, plugin_id=''): - self._function_cache = None + self._access_manager = None + + self._bookmarks_list = None self._data_cache = None - self._search_history = None + self._feed_history = None + self._function_cache = None self._playback_history = None - self._bookmarks_list = None + self._search_history = None self._watch_later_list = None - self._access_manager = None self._plugin_handle = -1 self._plugin_id = plugin_id @@ -155,6 +158,14 @@ def get_playback_history(self): self._playback_history = PlaybackHistory(filepath) return self._playback_history + def get_feed_history(self): + if not self._feed_history: + uuid = self.get_access_manager().get_current_user_id() + filename = 'feeds.sqlite' + filepath = os.path.join(self.get_data_path(), uuid, filename) + self._feed_history = FeedHistory(filepath) + return self._feed_history + def get_data_cache(self): if not self._data_cache: settings = self.get_settings() @@ -208,6 +219,9 @@ def get_access_manager(self): self._access_manager = AccessManager(self) return self._access_manager + def reload_access_manager(self): + self._access_manager = AccessManager(self) + def get_video_playlist(self): raise NotImplementedError() @@ -306,7 +320,7 @@ def parse_params(self, params=None): ) # process and translate deprecated parameters elif param == 'action': - if parsed_value in ('play_all', 'play_video'): + if parsed_value in {'play_all', 'play_video'}: to_delete.append(param) self.set_path('play') continue @@ -375,7 +389,7 @@ def get_id(self): def get_handle(self): return self._plugin_handle - def get_settings(self, flush=False): + def get_settings(self, refresh=False): raise NotImplementedError() def localize(self, text_id, default_text=None): @@ -425,7 +439,7 @@ def get_infolabel(name): raise NotImplementedError() @staticmethod - def get_listitem_detail(detail_name): + def get_listitem_property(detail_name): raise NotImplementedError() @staticmethod 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 f9cdcf741..1b6201983 100644 --- a/resources/lib/youtube_plugin/kodion/context/xbmc/xbmc_context.py +++ b/resources/lib/youtube_plugin/kodion/context/xbmc/xbmc_context.py @@ -11,6 +11,7 @@ from __future__ import absolute_import, division, unicode_literals import atexit +import json import sys from weakref import proxy @@ -23,7 +24,14 @@ xbmcaddon, xbmcplugin, ) -from ...constants import ABORT_FLAG, ADDON_ID, WAKEUP, content, sort +from ...constants import ( + ABORT_FLAG, + ADDON_ID, + CONTENT_TYPE, + WAKEUP, + content, + sort, +) from ...player import XbmcPlayer, XbmcPlaylist from ...settings import XbmcPluginSettings from ...ui import XbmcContextUI @@ -119,6 +127,7 @@ class XbmcContext(AbstractContext): 'live.upcoming': 30646, 'maintenance.bookmarks': 30800, 'maintenance.data_cache': 30687, + 'maintenance.feed_history': 30814, 'maintenance.function_cache': 30557, 'maintenance.playback_history': 30673, 'maintenance.search_history': 30558, @@ -353,7 +362,23 @@ def get_region(self): pass # implement from abstract def is_plugin_path(self, uri, uri_path='', partial=False): - uri_path = ('plugin://%s/%s' % (self.get_id(), uri_path)).rstrip('/') + plugin = self.get_id() + + if isinstance(uri_path, (list, tuple)): + if partial: + paths = ['plugin://{0}/{1}'.format(plugin, path).rstrip('/') + for path in uri_path] + else: + paths = [] + for path in uri_path: + path = 'plugin://{0}/{1}'.format(plugin, path).rstrip('/') + paths.extend(( + path + '/', + path + '?' + )) + return uri.startswith(tuple(paths)) + + uri_path = 'plugin://{0}/{1}'.format(plugin, uri_path).rstrip('/') if not partial: uri_path = ( uri_path + '/', @@ -481,7 +506,22 @@ def localize(self, text_id, default_text=None): return result def set_content(self, content_type, sub_type=None, category_label=None): - self.log_debug('Setting content-type: |{type}| for |{path}|'.format( + ui = self.get_ui() + ui.set_property(CONTENT_TYPE, json.dumps( + (content_type, sub_type, category_label), + ensure_ascii=False, + )) + + def apply_content(self): + ui = self.get_ui() + content_type = ui.get_property(CONTENT_TYPE) + if content_type: + ui.clear_property(CONTENT_TYPE) + content_type, sub_type, category_label = json.loads(content_type) + else: + return + + self.log_debug('Applying content-type: |{type}| for |{path}|'.format( type=(sub_type or content_type), path=self.get_path() )) xbmcplugin.setContent(self._plugin_handle, content_type) @@ -597,26 +637,31 @@ def set_addon_enabled(self, addon_id, enabled=True): error.get('message', 'unknown'))) return False - def send_notification(self, method, data): + def send_notification(self, method, data=True): self.log_debug('send_notification: |%s| -> |%s|' % (method, data)) jsonrpc(method='JSONRPC.NotifyAll', params={'sender': ADDON_ID, 'message': method, 'data': data}) - 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') - ): - success = self.set_addon_enabled('inputstream.adaptive') - else: - success = False - else: - success = False - return success + def use_inputstream_adaptive(self, prompt=False): + if not self.get_settings().use_isa(): + return None + + while 1: + try: + addon = xbmcaddon.Addon('inputstream.adaptive') + return addon.getAddonInfo('version') + except RuntimeError: + if (prompt + and self.get_ui().on_yes_no_input( + self.get_name(), + self.localize('isa.enable.confirm'), + ) + and self.set_addon_enabled('inputstream.adaptive')): + prompt = False + continue + return None # Values of capability map can be any of the following: # - required version number, as string param to loose_version() to compare @@ -647,13 +692,8 @@ def inputstream_adaptive_capabilities(self, capability=None): # If capability param is provided, returns True if the installed version # of ISA supports the nominated capability, False otherwise - try: - addon = xbmcaddon.Addon('inputstream.adaptive') - inputstream_version = addon.getAddonInfo('version') - except RuntimeError: - inputstream_version = '' - - if not self.use_inputstream_adaptive() or not inputstream_version: + inputstream_version = self.use_inputstream_adaptive() + if not inputstream_version: return frozenset() if capability is None else None isa_loose_version = loose_version(inputstream_version) @@ -688,7 +728,7 @@ def get_infolabel(name): return xbmc.getInfoLabel(name) @staticmethod - def get_listitem_detail(detail_name): + def get_listitem_property(detail_name): return xbmc.getInfoLabel('Container.ListItem(0).Property({0})' .format(detail_name)) @@ -726,5 +766,4 @@ def tear_down(self): pass def wakeup(self): - self.get_ui().set_property(WAKEUP) - self.send_notification(WAKEUP, True) + self.send_notification(WAKEUP) diff --git a/resources/lib/youtube_plugin/kodion/items/menu_items.py b/resources/lib/youtube_plugin/kodion/items/menu_items.py index 3a0dd7473..14020981f 100644 --- a/resources/lib/youtube_plugin/kodion/items/menu_items.py +++ b/resources/lib/youtube_plugin/kodion/items/menu_items.py @@ -564,11 +564,11 @@ def goto_quick_search(context): ) -def goto_page(context): +def goto_page(context, params=None): return ( context.localize('page.choose'), 'RunPlugin({0})'.format(context.create_uri( (paths.GOTO_PAGE, context.get_path(),), - context.get_params(), + params or context.get_params(), )) ) 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 f685bcd81..e4dbc2055 100644 --- a/resources/lib/youtube_plugin/kodion/items/next_page_item.py +++ b/resources/lib/youtube_plugin/kodion/items/next_page_item.py @@ -43,7 +43,7 @@ def __init__(self, context, params, image=None, fanart=None): context_menu = [ menu_items.refresh(context), - menu_items.goto_page(context) if can_jump else None, + menu_items.goto_page(context, params) if can_jump else None, menu_items.goto_home(context), menu_items.goto_quick_search(context), menu_items.separator(), diff --git a/resources/lib/youtube_plugin/kodion/items/utils.py b/resources/lib/youtube_plugin/kodion/items/utils.py index f2e7d5b95..0d54ba76b 100644 --- a/resources/lib/youtube_plugin/kodion/items/utils.py +++ b/resources/lib/youtube_plugin/kodion/items/utils.py @@ -17,7 +17,7 @@ from .directory_item import DirectoryItem from .image_item import ImageItem from .video_item import VideoItem -from ..compatibility import string_type +from ..compatibility import string_type, to_str from ..utils.datetime_parser import strptime @@ -54,7 +54,7 @@ def from_json(json_data, *args): :return: """ if isinstance(json_data, string_type): - if json_data == b'None': + if json_data == to_str(None): # Channel bookmark that will be updated. Store timestamp for update if args and args[0] and len(args[0]) == 4: return args[0][1] diff --git a/resources/lib/youtube_plugin/kodion/items/video_item.py b/resources/lib/youtube_plugin/kodion/items/video_item.py index f6d7e8590..133b08226 100644 --- a/resources/lib/youtube_plugin/kodion/items/video_item.py +++ b/resources/lib/youtube_plugin/kodion/items/video_item.py @@ -318,14 +318,15 @@ def use_mpd_video(self): return False def set_mediatype(self, mediatype): - self._mediatype = mediatype + if (mediatype in {'video', + 'movie', + 'tvshow', 'season', 'episode', + 'musicvideo'}): + self._mediatype = mediatype + else: + self._mediatype = 'video' def get_mediatype(self): - if (self._mediatype not in {'video', - 'movie', - 'tvshow', 'season', 'episode', - 'musicvideo'}): - self._mediatype = 'video' return self._mediatype def set_subtitles(self, value): diff --git a/resources/lib/youtube_plugin/kodion/items/xbmc/xbmc_items.py b/resources/lib/youtube_plugin/kodion/items/xbmc/xbmc_items.py index daa04372c..3e6c2dbb6 100644 --- a/resources/lib/youtube_plugin/kodion/items/xbmc/xbmc_items.py +++ b/resources/lib/youtube_plugin/kodion/items/xbmc/xbmc_items.py @@ -14,7 +14,15 @@ from .. import AudioItem, DirectoryItem, ImageItem, VideoItem from ...compatibility import to_str, xbmc, xbmcgui -from ...constants import PLAY_COUNT, SWITCH_PLAYER_FLAG +from ...constants import ( + CHANNEL_ID, + PLAY_COUNT, + PLAYLIST_ID, + PLAYLISTITEM_ID, + SUBSCRIPTION_ID, + SWITCH_PLAYER_FLAG, + VIDEO_ID, +) from ...utils import current_system_version, datetime_parser @@ -393,8 +401,7 @@ def video_playback_item(context, video_item, show_fanart=None, **_kwargs): 'isPlayable': str(video_item.playable).lower(), } - if (video_item.use_isa_video() - and context.addon_enabled('inputstream.adaptive')): + if video_item.use_isa_video() and context.use_inputstream_adaptive(): if video_item.use_mpd_video(): manifest_type = 'mpd' mime_type = 'application/dash+xml' @@ -519,14 +526,12 @@ def directory_listitem(context, directory_item, show_fanart=None, **_kwargs): 'ForceResolvePlugin': 'true', } - list_item = xbmcgui.ListItem(**kwargs) - if directory_item.next_page: props['specialSort'] = 'bottom' else: prop_value = directory_item.get_subscription_id() if prop_value: - props['channel_subscription_id'] = prop_value + props[SUBSCRIPTION_ID] = prop_value elif directory_item.get_channel_id(): pass elif directory_item.get_playlist_id(): @@ -534,6 +539,8 @@ def directory_listitem(context, directory_item, show_fanart=None, **_kwargs): else: props['specialSort'] = 'top' + list_item = xbmcgui.ListItem(**kwargs) + if show_fanart is None: show_fanart = context.get_settings().fanart_selection() image = directory_item.get_image() or 'DefaultFolder.png' @@ -635,8 +642,6 @@ def video_listitem(context, 'ForceResolvePlugin': 'true', } - list_item = xbmcgui.ListItem(**kwargs) - published_at = video_item.get_added_utc() scheduled_start = video_item.get_scheduled_start_utc() datetime = scheduled_start or published_at @@ -658,27 +663,29 @@ def video_listitem(context, if focused and focused == prop_value: set_play_count = False resume = False - props['video_id'] = prop_value + props[VIDEO_ID] = prop_value # make channel_id property available for keymapping prop_value = video_item.get_channel_id() if prop_value: - props['channel_id'] = prop_value + props[CHANNEL_ID] = prop_value # make subscription_id property available for keymapping prop_value = video_item.get_subscription_id() if prop_value: - props['subscription_id'] = prop_value + props[SUBSCRIPTION_ID] = prop_value # make playlist_id property available for keymapping prop_value = video_item.get_playlist_id() if prop_value: - props['playlist_id'] = prop_value + props[PLAYLIST_ID] = prop_value # make playlist_item_id property available for keymapping prop_value = video_item.get_playlist_item_id() if prop_value: - props['playlist_item_id'] = prop_value + props[PLAYLISTITEM_ID] = prop_value + + list_item = xbmcgui.ListItem(**kwargs) if show_fanart is None: show_fanart = context.get_settings().fanart_selection() @@ -698,6 +705,16 @@ def video_listitem(context, set_play_count=set_play_count, resume=resume) + if not set_play_count: + video_id = video_item.video_id + playback_history = context.get_playback_history() + playback_history.update(video_id, dict( + playback_history.get_item(video_id) or {}, + play_count=int(not video_item.get_play_count()), + played_time=0.0, + played_percent=0, + )) + context_menu = video_item.get_context_menu() if context_menu: list_item.addContextMenuItems(context_menu) diff --git a/resources/lib/youtube_plugin/kodion/monitors/player_monitor.py b/resources/lib/youtube_plugin/kodion/monitors/player_monitor.py index 37e18f6fe..adec22392 100644 --- a/resources/lib/youtube_plugin/kodion/monitors/player_monitor.py +++ b/resources/lib/youtube_plugin/kodion/monitors/player_monitor.py @@ -16,6 +16,8 @@ from ..compatibility import xbmc from ..constants import ( BUSY_FLAG, + PLAYBACK_STARTED, + PLAYBACK_STOPPED, PLAYER_DATA, REFRESH_CONTAINER, SWITCH_PLAYER_FLAG, @@ -79,7 +81,7 @@ def run(self): self._monitor.waitForAbort(wait_interval) waited += wait_interval else: - self._context.send_notification('PlaybackStarted', { + self._context.send_notification(PLAYBACK_STARTED, { 'video_id': self.video_id, 'channel_id': self.channel_id, 'status': self.video_status, @@ -218,7 +220,7 @@ def run(self): self._context.get_playback_history().update(self.video_id, play_data) - self._context.send_notification('PlaybackStopped', self.playback_data) + self._context.send_notification(PLAYBACK_STOPPED, self.playback_data) self._context.log_debug('Playback stopped [{video_id}]:' ' {played_time:.3f} secs of {total_time:.3f}' ' @ {played_percent}%,' @@ -264,7 +266,7 @@ def run(self): rating_match) if settings.get_bool('youtube.post.play.refresh', False): - self._context.send_notification(REFRESH_CONTAINER, True) + self._context.send_notification(REFRESH_CONTAINER) self.end() diff --git a/resources/lib/youtube_plugin/kodion/monitors/service_monitor.py b/resources/lib/youtube_plugin/kodion/monitors/service_monitor.py index 31aab8421..0117eace3 100644 --- a/resources/lib/youtube_plugin/kodion/monitors/service_monitor.py +++ b/resources/lib/youtube_plugin/kodion/monitors/service_monitor.py @@ -13,7 +13,14 @@ import threading from ..compatibility import xbmc, xbmcgui -from ..constants import ADDON_ID, CHECK_SETTINGS, REFRESH_CONTAINER, WAKEUP +from ..constants import ( + ADDON_ID, + CHECK_SETTINGS, + PLAYBACK_INIT, + REFRESH_CONTAINER, + RELOAD_ACCESS_MANAGER, + WAKEUP, +) from ..logger import log_debug from ..network import get_connect_address, get_http_server, httpd_status @@ -36,18 +43,42 @@ def __init__(self, context): self.httpd = None self.httpd_thread = None + self.refresh = False + self.interrupt = False + if self._use_httpd: self.start_httpd() super(ServiceMonitor, self).__init__() @staticmethod - def _refresh_allowed(): - return (not xbmc.getCondVisibility('Container.IsUpdating') - and not xbmc.getCondVisibility('System.HasActiveModalDialog') - and xbmc.getInfoLabel('Container.FolderPath').startswith( - 'plugin://{0}/'.format(ADDON_ID) - )) + def is_plugin_container(url='plugin://{0}/'.format(ADDON_ID), + check_all=False, + _bool=xbmc.getCondVisibility, + _label=xbmc.getInfoLabel): + if check_all: + return (not _bool('Container.IsUpdating') + and not _bool('System.HasActiveModalDialog') + and _label('Container.FolderPath').startswith(url)) + is_plugin = _label('Container.FolderPath').startswith(url) + return { + 'is_plugin': is_plugin, + 'is_loaded': is_plugin and not _bool('Container.IsUpdating'), + 'is_active': is_plugin and not _bool('System.HasActiveModalDialog'), + } + + @staticmethod + def set_property(property_id, value='true'): + property_id = '-'.join((ADDON_ID, property_id)) + xbmcgui.Window(10000).setProperty(property_id, value) + return value + + def refresh_container(self, force=False): + self.set_property(REFRESH_CONTAINER) + if force or self.is_plugin_container(check_all=True): + xbmc.executebuiltin('Container.Refresh') + else: + self.refresh = True def onNotification(self, sender, method, data): if sender != ADDON_ID: @@ -70,9 +101,15 @@ def onNotification(self, sender, method, data): elif event == WAKEUP: if not self.httpd and self.httpd_required(): self.start_httpd() + self.interrupt = True elif event == REFRESH_CONTAINER: - if self._refresh_allowed(): - xbmc.executebuiltin('Container.Refresh') + self.refresh_container() + elif event == RELOAD_ACCESS_MANAGER: + self._context.reload_access_manager() + self.refresh_container() + elif event == PLAYBACK_INIT: + if not self.httpd and self.httpd_required(): + self.start_httpd() else: log_debug('onNotification: |unhandled method| -> |{method}|' .format(method=method)) @@ -91,12 +128,8 @@ def onSettingsChanged(self): settings = self._context.get_settings(refresh=True) - xbmcgui.Window(10000).setProperty( - '-'.join((ADDON_ID, CHECK_SETTINGS)), 'true' - ) - - if self._refresh_allowed(): - xbmc.executebuiltin('Container.Refresh') + self.set_property(CHECK_SETTINGS) + self.refresh_container() use_httpd = (settings.use_isa() or settings.api_config_page() @@ -157,8 +190,10 @@ def start_httpd(self): log_debug('HTTPServer: Serving on |{ip}:{port}|' .format(ip=address[0], port=address[1])) - def shutdown_httpd(self): + def shutdown_httpd(self, sleep=False): if self.httpd: + if sleep and self._context.get_settings().api_config_page(): + return log_debug('HTTPServer: Shutting down |{ip}:{port}|' .format(ip=self._old_httpd_address, port=self._old_httpd_port)) diff --git a/resources/lib/youtube_plugin/kodion/network/http_server.py b/resources/lib/youtube_plugin/kodion/network/http_server.py index f54a49b8e..5763b36bc 100644 --- a/resources/lib/youtube_plugin/kodion/network/http_server.py +++ b/resources/lib/youtube_plugin/kodion/network/http_server.py @@ -26,7 +26,7 @@ xbmcgui, xbmcvfs, ) -from ..constants import ADDON_ID, TEMP_PATH, paths +from ..constants import ADDON_ID, LICENSE_TOKEN, LICENSE_URL, TEMP_PATH, paths from ..logger import log_debug, log_error from ..utils import validate_ip_address, wait @@ -237,12 +237,12 @@ def do_POST(self): elif self.path.startswith(paths.DRM): home = xbmcgui.Window(10000) - lic_url = home.getProperty('-'.join((ADDON_ID, 'license_url'))) + lic_url = home.getProperty('-'.join((ADDON_ID, LICENSE_URL))) if not lic_url: self.send_error(404) return - lic_token = home.getProperty('-'.join((ADDON_ID, 'license_token'))) + lic_token = home.getProperty('-'.join((ADDON_ID, LICENSE_TOKEN))) if not lic_token: self.send_error(403) return diff --git a/resources/lib/youtube_plugin/kodion/plugin/xbmc/xbmc_plugin.py b/resources/lib/youtube_plugin/kodion/plugin/xbmc/xbmc_plugin.py index 024692253..87d4c1e09 100644 --- a/resources/lib/youtube_plugin/kodion/plugin/xbmc/xbmc_plugin.py +++ b/resources/lib/youtube_plugin/kodion/plugin/xbmc/xbmc_plugin.py @@ -19,6 +19,8 @@ CHECK_SETTINGS, PLAYLIST_PATH, PLAYLIST_POSITION, + REFRESH_CONTAINER, + RELOAD_ACCESS_MANAGER, REROUTE, SLEEPING, VIDEO_ID, @@ -58,7 +60,7 @@ def __init__(self): super(XbmcPlugin, self).__init__() self.handle = None - def run(self, provider, context, refresh=False): + def run(self, provider, context, focused=None): self.handle = context.get_handle() ui = context.get_ui() @@ -129,7 +131,16 @@ def run(self, provider, context, refresh=False): if ui.get_property(SLEEPING): context.wakeup() - ui.clear_property(SLEEPING) + + if ui.get_property(REFRESH_CONTAINER): + focused = False + ui.clear_property(REFRESH_CONTAINER) + elif focused: + focused = ui.get_property(VIDEO_ID) + + if ui.get_property(RELOAD_ACCESS_MANAGER): + context.reload_access_manager() + ui.clear_property(RELOAD_ACCESS_MANAGER) if ui.get_property(CHECK_SETTINGS): provider.reset_client() @@ -148,8 +159,8 @@ def run(self, provider, context, refresh=False): result, options = function_cache.run( provider.navigate, seconds=None, - _cacheparams=function_cache.PARAMS_NONE, _oneshot=True, + _scope=function_cache.SCOPE_NONE, context=context.clone(route), ) ui.clear_property(REROUTE) @@ -163,11 +174,12 @@ def run(self, provider, context, refresh=False): )) ui.on_ok('Error in ContentProvider', exc.__str__()) - focused = ui.get_property(VIDEO_ID) if refresh else None + items = None item_count = 0 - if isinstance(result, (list, tuple)): + + if result and isinstance(result, (list, tuple)): show_fanart = settings.fanart_selection() - result = [ + items = [ self._LIST_ITEM_MAP[item.__class__.__name__]( context, item, @@ -177,13 +189,18 @@ def run(self, provider, context, refresh=False): for item in result if item.__class__.__name__ in self._LIST_ITEM_MAP ] - item_count = len(result) - elif result.__class__.__name__ in self._PLAY_ITEM_MAP: + item_count = len(items) + + if options.get(provider.RESULT_FORCE_RESOLVE): + result = result[0] + + if result and result.__class__.__name__ in self._PLAY_ITEM_MAP: result = self._set_resolved_url(context, result) if item_count: + context.apply_content() succeeded = xbmcplugin.addDirectoryItems( - self.handle, result, item_count + self.handle, items, item_count ) cache_to_disc = options.get(provider.RESULT_CACHE_TO_DISC, True) update_listing = options.get(provider.RESULT_UPDATE_LISTING, False) diff --git a/resources/lib/youtube_plugin/kodion/plugin_runner.py b/resources/lib/youtube_plugin/kodion/plugin_runner.py index 35f5aceca..ae617563f 100644 --- a/resources/lib/youtube_plugin/kodion/plugin_runner.py +++ b/resources/lib/youtube_plugin/kodion/plugin_runner.py @@ -60,7 +60,7 @@ def run(context=_context, path=context.get_path(), params=params)) - plugin.run(provider, context, new_uri == current_uri) + plugin.run(provider, context, focused=(current_uri == new_uri)) if profiler: profiler.print_stats() diff --git a/resources/lib/youtube_plugin/kodion/script_actions.py b/resources/lib/youtube_plugin/kodion/script_actions.py index 4b3763179..32385d522 100644 --- a/resources/lib/youtube_plugin/kodion/script_actions.py +++ b/resources/lib/youtube_plugin/kodion/script_actions.py @@ -13,7 +13,13 @@ import socket from .compatibility import parse_qsl, urlsplit, xbmc, xbmcaddon, xbmcvfs -from .constants import DATA_PATH, SWITCH_PLAYER_FLAG, TEMP_PATH, WAIT_FLAG +from .constants import ( + DATA_PATH, + RELOAD_ACCESS_MANAGER, + SWITCH_PLAYER_FLAG, + TEMP_PATH, + WAIT_FLAG, +) from .context import XbmcContext from .network import get_client_ip_address, httpd_status from .utils import rm_dir, validate_ip_address @@ -28,7 +34,7 @@ def _config_actions(context, action, *_args): xbmcaddon.Addon().openSettings() elif action == 'isa': - if context.use_inputstream_adaptive(): + if context.use_inputstream_adaptive(prompt=True): xbmcaddon.Addon('inputstream.adaptive').openSettings() else: settings.use_isa(False) @@ -132,6 +138,7 @@ def _maintenance_actions(context, action, params): targets = { 'bookmarks': context.get_bookmarks_list, 'data_cache': context.get_data_cache, + 'feed_history': context.get_feed_history, 'function_cache': context.get_function_cache, 'playback_history': context.get_playback_history, 'search_history': context.get_search_history, @@ -140,9 +147,7 @@ def _maintenance_actions(context, action, params): if target not in targets: return - if ui.on_clear_content( - localize('maintenance.{0}'.format(target)) - ): + if ui.on_clear_content(localize('maintenance.{0}'.format(target))): targets[target]().clear() ui.show_notification(localize('succeeded')) @@ -151,6 +156,7 @@ def _maintenance_actions(context, action, params): targets = { 'bookmarks': 'bookmarks.sqlite', 'data_cache': 'data_cache.sqlite', + 'feed_history': 'feeds.sqlite', 'function_cache': 'cache.sqlite', 'playback_history': 'history.sqlite', 'search_history': 'search.sqlite', @@ -205,6 +211,7 @@ def _user_actions(context, action, params): localize = context.localize access_manager = context.get_access_manager() ui = context.get_ui() + reload = False def select_user(reason, new_user=False): current_users = access_manager.get_users() @@ -239,8 +246,6 @@ def switch_to_user(user): localize('user.changed') % access_manager.get_username(user), localize('user.switch') ) - if context.get_param('refresh') != 0: - ui.refresh_container() if action == 'switch': result, user_index_map = select_user(localize('user.switch'), @@ -254,6 +259,7 @@ def switch_to_user(user): if user is not None and user != access_manager.get_current_user(): switch_to_user(user) + reload = True elif action == 'add': user, details = add_user() @@ -264,6 +270,7 @@ def switch_to_user(user): ) if result: switch_to_user(user) + reload = True elif action == 'remove': result, user_index_map = select_user(localize('user.remove')) @@ -274,13 +281,14 @@ def switch_to_user(user): username = access_manager.get_username(user) if ui.on_remove_content(username): access_manager.remove_user(user) + ui.show_notification(localize('removed') % username, + localize('remove')) 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')) + switch_to_user(0) + reload = True elif action == 'rename': result, user_index_map = select_user(localize('user.rename')) @@ -304,7 +312,11 @@ def switch_to_user(user): localize('renamed') % (old_username, new_username), localize('rename') ) + reload = True + if reload: + ui.set_property(RELOAD_ACCESS_MANAGER) + context.send_notification(RELOAD_ACCESS_MANAGER) return True diff --git a/resources/lib/youtube_plugin/kodion/service_runner.py b/resources/lib/youtube_plugin/kodion/service_runner.py index 662de4589..2d87baf2a 100644 --- a/resources/lib/youtube_plugin/kodion/service_runner.py +++ b/resources/lib/youtube_plugin/kodion/service_runner.py @@ -12,12 +12,9 @@ from .constants import ( ABORT_FLAG, - ADDON_ID, - PLAY_COUNT, SLEEPING, TEMP_PATH, VIDEO_ID, - WAKEUP, ) from .context import XbmcContext from .monitors import PlayerMonitor, ServiceMonitor @@ -35,13 +32,11 @@ def run(): provider = Provider() get_infobool = context.get_infobool - get_infolabel = context.get_infolabel - get_listitem_detail = context.get_listitem_detail get_listitem_info = context.get_listitem_info + get_listitem_property = context.get_listitem_property ui = context.get_ui() clear_property = ui.clear_property - get_property = ui.get_property set_property = ui.set_property clear_property(ABORT_FLAG) @@ -54,77 +49,95 @@ def run(): # wipe add-on temp folder on updates/restarts (subtitles, and mpd files) rm_dir(TEMP_PATH) + sleeping = False ping_period = waited = 60 + loop_num = sub_loop_num = 0 restart_attempts = 0 - plugin_url = 'plugin://{0}/'.format(ADDON_ID) video_id = None + container = monitor.is_plugin_container() while not monitor.abortRequested(): if not monitor.httpd: - if (monitor.httpd_required() - and not get_infobool('System.IdleTime(10)')): - monitor.start_httpd() - waited = 0 + waited = 0 elif get_infobool('System.IdleTime(10)'): - if get_property(WAKEUP): - clear_property(WAKEUP) - waited = 0 if waited >= 30: - monitor.shutdown_httpd() - set_property(SLEEPING) - elif waited >= ping_period: - waited = 0 - if monitor.ping_httpd(): - restart_attempts = 0 - elif restart_attempts < 5: - monitor.restart_httpd() - restart_attempts += 1 - else: - monitor.shutdown_httpd() - - if get_infolabel('Container.FolderPath').startswith(plugin_url): - new_video_id = get_listitem_detail('video_id') - if not new_video_id: - video_id = None - if get_listitem_info('Label'): + waited = 0 + monitor.shutdown_httpd(sleep=True) + if not sleeping: + sleeping = set_property(SLEEPING) + else: + if sleeping: + sleeping = clear_property(SLEEPING) + if waited >= ping_period: + waited = 0 + if monitor.ping_httpd(): + restart_attempts = 0 + elif restart_attempts < 5: + monitor.restart_httpd() + restart_attempts += 1 + else: + monitor.shutdown_httpd() + + while not monitor.abortRequested(): + if container['is_plugin']: + wait_interval = 0.1 + if loop_num < 1: + loop_num = 1 + if sub_loop_num < 1: + sub_loop_num = 10 + + if monitor.refresh and all(container.values()): + monitor.refresh_container(force=True) + monitor.refresh = False + break + monitor.interrupt = False + + new_video_id = get_listitem_property(VIDEO_ID) + if new_video_id: + if video_id != new_video_id: + video_id = new_video_id + set_property(VIDEO_ID, video_id) + elif video_id and get_listitem_info('Label'): + video_id = None clear_property(VIDEO_ID) - clear_property(PLAY_COUNT) - elif video_id != new_video_id: - video_id = new_video_id - set_property(VIDEO_ID, video_id) - plugin_play_count = get_listitem_detail(PLAY_COUNT) - set_property(PLAY_COUNT, plugin_play_count) else: - kodi_play_count = get_listitem_info('PlayCount') - kodi_play_count = int(kodi_play_count or 0) - plugin_play_count = get_property(PLAY_COUNT) - plugin_play_count = int(plugin_play_count or 0) - if kodi_play_count != plugin_play_count: - playback_history = context.get_playback_history() - play_data = playback_history.get_item(video_id) - if not play_data: - play_data = {'play_count': kodi_play_count} - playback_history.update(video_id, play_data) - elif play_data.get('play_count') != kodi_play_count: - play_data['play_count'] = kodi_play_count - play_data['played_time'] = 0.0 - play_data['played_percent'] = 0 - playback_history.update(video_id, play_data) - set_property(PLAY_COUNT, str(kodi_play_count)) - wait_interval = 0.1 + wait_interval = 1 + if loop_num < 1: + loop_num = 2 + if sub_loop_num < 1: + sub_loop_num = 5 + + if not sleeping: + sleeping = set_property(SLEEPING) + + if sub_loop_num > 1: + sub_loop_num -= 1 + if monitor.interrupt: + container = monitor.is_plugin_container() + monitor.interrupt = False + else: + container = monitor.is_plugin_container() + sub_loop_num = 0 + loop_num -= 1 + if not wait_interval or container['is_plugin']: + wait_interval = 0.1 + + if wait_interval: + monitor.waitForAbort(wait_interval) + waited += wait_interval + + if loop_num <= 0: + break else: - wait_interval = 10 - - if monitor.waitForAbort(wait_interval): break - waited += wait_interval set_property(ABORT_FLAG) # clean up any/all playback monitoring threads player.cleanup_threads(only_ended=False) + # shutdown http server if monitor.httpd: - monitor.shutdown_httpd() # shutdown http server + monitor.shutdown_httpd() provider.tear_down() context.tear_down() diff --git a/resources/lib/youtube_plugin/kodion/settings/abstract_settings.py b/resources/lib/youtube_plugin/kodion/settings/abstract_settings.py index 881d56d5e..b4cce62ae 100644 --- a/resources/lib/youtube_plugin/kodion/settings/abstract_settings.py +++ b/resources/lib/youtube_plugin/kodion/settings/abstract_settings.py @@ -70,13 +70,9 @@ def items_per_page(self, value=None): 4: 1080, } - def get_video_quality(self, quality_map_override=None): - if quality_map_override is not None: - video_quality_map = quality_map_override - else: - video_quality_map = self._VIDEO_QUALITY_MAP + def get_video_quality(self): value = self.get_int(settings.VIDEO_QUALITY, 3) - return video_quality_map[value] + return self._VIDEO_QUALITY_MAP[value] def ask_for_video_quality(self): return (self.get_bool(settings.VIDEO_QUALITY_ASK, False) @@ -339,18 +335,18 @@ def use_remote_history(self): return self.get_bool(settings.USE_REMOTE_HISTORY, False) # Selections based on max width and min height at common (utra-)wide aspect ratios - _QUALITY_SELECTIONS = { # Setting | Resolution - 7: {'width': 7680, 'height': 3148, 'label': '4320p{0} (8K){1}'}, # 7 | 4320p 8K - 6: {'width': 3840, 'height': 1080, 'label': '2160p{0} (4K){1}'}, # 6 | 2160p 4K - 5: {'width': 2560, 'height': 984, 'label': '1440p{0} (QHD){1}'}, # 5 | 1440p 2.5K / QHD - 4.1: {'width': 2048, 'height': 858, 'label': '1152p{0} (2K){1}'}, # N/A | 1152p 2K / QWXGA - 4: {'width': 1920, 'height': 787, 'label': '1080p{0} (FHD){1}'}, # 4 | 1080p FHD - 3: {'width': 1280, 'height': 525, 'label': '720p{0} (HD){1}'}, # 3 | 720p HD - 2: {'width': 854, 'height': 350, 'label': '480p{0}{1}'}, # 2 | 480p - 1: {'width': 640, 'height': 263, 'label': '360p{0}{1}'}, # 1 | 360p - 0: {'width': 426, 'height': 175, 'label': '240p{0}{1}'}, # 0 | 240p - -1: {'width': 256, 'height': 105, 'label': '144p{0}{1}'}, # N/A | 144p - -2: {'width': 0, 'height': 0, 'label': '{2}p{0}{1}'}, # N/A | Custom + _QUALITY_SELECTIONS = { # Setting | Resolution + 7: {'width': 7680, 'min_height': 3148, 'nom_height': 4320, 'label': '{0}p{1} (8K){2}'}, # 7 | 4320p 8K + 6: {'width': 3840, 'min_height': 1080, 'nom_height': 2160, 'label': '{0}p{1} (4K){2}'}, # 6 | 2160p 4K + 5: {'width': 2560, 'min_height': 984, 'nom_height': 1440, 'label': '{0}p{1} (QHD){2}'}, # 5 | 1440p 2.5K / QHD + 4.1: {'width': 2048, 'min_height': 858, 'nom_height': 1152, 'label': '{0}p{1} (2K){2}'}, # N/A | 1152p 2K / QWXGA + 4: {'width': 1920, 'min_height': 787, 'nom_height': 1080, 'label': '{0}p{1} (FHD){2}'}, # 4 | 1080p FHD + 3: {'width': 1280, 'min_height': 525, 'nom_height': 720, 'label': '{0}p{1} (HD){2}'}, # 3 | 720p HD + 2: {'width': 854, 'min_height': 350, 'nom_height': 480, 'label': '{0}p{1}{2}'}, # 2 | 480p + 1: {'width': 640, 'min_height': 263, 'nom_height': 360, 'label': '{0}p{1}{2}'}, # 1 | 360p + 0: {'width': 426, 'min_height': 175, 'nom_height': 240, 'label': '{0}p{1}{2}'}, # 0 | 240p + -1: {'width': 256, 'min_height': 105, 'nom_height': 144, 'label': '{0}p{1}{2}'}, # N/A | 144p + -2: {'width': 0, 'min_height': 0, 'nom_height': 0, 'label': '{0}p{1}{2}'}, # N/A | Custom } def mpd_video_qualities(self, value=None): 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 b133fc800..af586fe1d 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 @@ -237,7 +237,7 @@ def get_string(self, setting, default='', echo=None): echo = 'xx.xxxx,xx.xxxx' elif setting == 'youtube.api.id': echo = '...'.join((value[:3], value[-5:])) - elif setting in ('youtube.api.key', 'youtube.api.secret'): + elif setting in {'youtube.api.key', 'youtube.api.secret'}: echo = '...'.join((value[:3], value[-3:])) else: echo = value @@ -265,7 +265,7 @@ def set_string(self, setting, value, echo=None): echo = 'xx.xxxx,xx.xxxx' elif setting == 'youtube.api.id': echo = '...'.join((value[:3], value[-5:])) - elif setting in ('youtube.api.key', 'youtube.api.secret'): + elif setting in {'youtube.api.key', 'youtube.api.secret'}: echo = '...'.join((value[:3], value[-3:])) else: echo = value diff --git a/resources/lib/youtube_plugin/kodion/sql_store/__init__.py b/resources/lib/youtube_plugin/kodion/sql_store/__init__.py index c47919849..d62662379 100644 --- a/resources/lib/youtube_plugin/kodion/sql_store/__init__.py +++ b/resources/lib/youtube_plugin/kodion/sql_store/__init__.py @@ -9,6 +9,7 @@ from .bookmarks_list import BookmarksList from .data_cache import DataCache +from .feed_history import FeedHistory from .function_cache import FunctionCache from .playback_history import PlaybackHistory from .search_history import SearchHistory @@ -18,6 +19,7 @@ __all__ = ( 'BookmarksList', 'DataCache', + 'FeedHistory', 'FunctionCache', 'PlaybackHistory', 'SearchHistory', diff --git a/resources/lib/youtube_plugin/kodion/sql_store/data_cache.py b/resources/lib/youtube_plugin/kodion/sql_store/data_cache.py index 791af6f25..c800ea630 100644 --- a/resources/lib/youtube_plugin/kodion/sql_store/data_cache.py +++ b/resources/lib/youtube_plugin/kodion/sql_store/data_cache.py @@ -24,12 +24,15 @@ def __init__(self, filepath, max_file_size_mb=5): super(DataCache, self).__init__(filepath, max_file_size_kb=max_file_size_kb) - def get_items(self, content_ids, seconds): - result = self._get_by_ids(content_ids, seconds=seconds, as_dict=True) + def get_items(self, content_ids, seconds, as_dict=True, values_only=True): + result = self._get_by_ids(content_ids, + seconds=seconds, + as_dict=as_dict, + values_only=values_only) return result - def get_item(self, content_id, seconds): - result = self._get(content_id, seconds=seconds) + def get_item(self, content_id, seconds=None, as_dict=False): + result = self._get(content_id, seconds=seconds, as_dict=as_dict) return result def set_item(self, content_id, item): diff --git a/resources/lib/youtube_plugin/kodion/sql_store/feed_history.py b/resources/lib/youtube_plugin/kodion/sql_store/feed_history.py new file mode 100644 index 000000000..c759c09e2 --- /dev/null +++ b/resources/lib/youtube_plugin/kodion/sql_store/feed_history.py @@ -0,0 +1,42 @@ +# -*- 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 + +from .storage import Storage + + +class FeedHistory(Storage): + _table_name = 'storage_v2' + _table_created = False + _table_updated = False + _sql = {} + + def __init__(self, filepath): + super(FeedHistory, self).__init__(filepath) + + def get_items(self, content_ids, seconds=None): + result = self._get_by_ids(content_ids, + seconds=seconds, + as_dict=True, + values_only=False) + return result + + def get_item(self, content_id, seconds=None): + result = self._get(content_id, seconds=seconds, as_dict=True) + return result + + def set_items(self, items): + self._set_many(items) + + def _optimize_item_count(self, limit=-1, defer=False): + return False + + def _optimize_file_size(self, limit=-1, defer=False): + return False diff --git a/resources/lib/youtube_plugin/kodion/sql_store/function_cache.py b/resources/lib/youtube_plugin/kodion/sql_store/function_cache.py index 06297fe42..5578b8791 100644 --- a/resources/lib/youtube_plugin/kodion/sql_store/function_cache.py +++ b/resources/lib/youtube_plugin/kodion/sql_store/function_cache.py @@ -24,9 +24,9 @@ class FunctionCache(Storage): _sql = {} _BUILTIN = str.__module__ - PARAMS_NONE = 0 - PARAMS_BUILTINS = 1 - PARAMS_ALL = 2 + SCOPE_NONE = 0 + SCOPE_BUILTINS = 1 + SCOPE_ALL = 2 def __init__(self, filepath, max_file_size_mb=5): max_file_size_kb = max_file_size_mb * 1024 @@ -49,7 +49,7 @@ def disable(self): self._enabled = False @classmethod - def _create_id_from_func(cls, partial_func, hash_params=PARAMS_ALL): + def _create_id_from_func(cls, partial_func, scope=SCOPE_ALL): """ Creates an id from the given function :param partial_func: @@ -60,7 +60,7 @@ def _create_id_from_func(cls, partial_func, hash_params=PARAMS_ALL): partial_func.func.__module__, partial_func.func.__name__, ) - if hash_params == cls.PARAMS_BUILTINS: + if scope == cls.SCOPE_BUILTINS: signature = chain( signature, (( @@ -74,7 +74,7 @@ def _create_id_from_func(cls, partial_func, hash_params=PARAMS_ALL): (key, type(arg)) ) for key, arg in partial_func.keywords.items()), ) - elif hash_params == cls.PARAMS_ALL: + elif scope == cls.SCOPE_ALL: signature = chain( signature, partial_func.args, @@ -101,27 +101,34 @@ def run(self, func, seconds, *args, **kwargs): :param int|None seconds: max allowable age of cached result :param tuple args: positional arguments passed to the function :param dict kwargs: keyword arguments passed to the function - :keyword _cacheparams: (int) cache result for function and parameters. - 0: function only, - 1: include value of builtin type parameters - 2: include value of all parameters, default 2 + :keyword _scope: (int) cache result if matching: + 0: function only, + 1: function + value of builtin type parameters + 2: function + value of all parameters, default 2 + :keyword _ignore_value: (Any) don't cache func return value if equal to + _ignored_value, default None :keyword _oneshot: (bool) remove previously cached result, default False :keyword _refresh: (bool) updates cache with new result, default False + :keyword _retry_value: (Any) re-evaluate func if cached value is equal + _retry_value, default None :return: """ - cache_params = kwargs.pop('_cacheparams', self.PARAMS_ALL) + scope = kwargs.pop('_scope', self.SCOPE_ALL) + ignore_value = kwargs.pop('_ignore_value', None) oneshot = kwargs.pop('_oneshot', False) refresh = kwargs.pop('_refresh', False) + retry_value = kwargs.pop('_retry_value', None) partial_func = partial(func, *args, **kwargs) # if caching is disabled call the function if not self._enabled: return partial_func() - cache_id = self._create_id_from_func(partial_func, cache_params) - data = None if refresh else self._get(cache_id, seconds=seconds) - if data is None: + cache_id = self._create_id_from_func(partial_func, scope) + data = retry_value if refresh else self._get(cache_id, seconds=seconds) + if data == retry_value: data = partial_func() + if data != ignore_value: self._set(cache_id, data) elif oneshot: self._remove(cache_id) diff --git a/resources/lib/youtube_plugin/kodion/sql_store/search_history.py b/resources/lib/youtube_plugin/kodion/sql_store/search_history.py index 5afc4a740..d8c790951 100644 --- a/resources/lib/youtube_plugin/kodion/sql_store/search_history.py +++ b/resources/lib/youtube_plugin/kodion/sql_store/search_history.py @@ -29,8 +29,7 @@ def __init__(self, filepath, max_item_count=10, migrate=False): def get_items(self, process=None): result = self._get_by_ids(oldest_first=False, limit=self._max_item_count, - process=process, - values_only=True) + process=process) return result @staticmethod diff --git a/resources/lib/youtube_plugin/kodion/sql_store/storage.py b/resources/lib/youtube_plugin/kodion/sql_store/storage.py index eec0588ea..8fa190b26 100644 --- a/resources/lib/youtube_plugin/kodion/sql_store/storage.py +++ b/resources/lib/youtube_plugin/kodion/sql_store/storage.py @@ -14,6 +14,7 @@ import pickle import sqlite3 import time +from threading import Lock from traceback import format_stack from ..logger import log_error @@ -145,6 +146,7 @@ def __init__(self, self._filepath = filepath self._db = None self._cursor = None + self._lock = Lock() self._max_item_count = -1 if migrate else max_item_count self._max_file_size_kb = -1 if migrate else max_file_size_kb @@ -171,16 +173,15 @@ def set_max_item_count(self, max_item_count): def set_max_file_size_kb(self, max_file_size_kb): self._max_file_size_kb = max_file_size_kb - def __del__(self): - self._close() - def __enter__(self): + self._lock.acquire() if not self._db or not self._cursor: self._open() return self._db, self._cursor def __exit__(self, exc_type=None, exc_val=None, exc_tb=None): self._close() + self._lock.release() def _open(self): if not os.path.exists(self._filepath): @@ -396,7 +397,7 @@ def _encode(key, obj, timestamp=None): size = int(memoryview(blob).itemsize) * len(blob) return str(key), timestamp, blob, size - def _get(self, item_id, process=None, seconds=None): + def _get(self, item_id, process=None, seconds=None, as_dict=False): with self as (db, cursor), db: result = self._execute(cursor, self._sql['get'], [str(item_id)]) item = result.fetchone() if result else None @@ -404,12 +405,18 @@ def _get(self, item_id, process=None, seconds=None): return None cut_off = since_epoch() - seconds if seconds else 0 if not cut_off or item[1] >= cut_off: + if as_dict: + return { + 'item_id': item_id, + 'age': since_epoch() - item[1], + 'value': self._decode(item[2], process, item), + } return self._decode(item[2], process, item) return None def _get_by_ids(self, item_ids=None, oldest_first=True, limit=-1, seconds=None, process=None, - as_dict=False, values_only=False): + as_dict=False, values_only=True): if not item_ids: if oldest_first: query = self._sql['get_many'] @@ -421,14 +428,24 @@ 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() - seconds if seconds else 0 + epoch = since_epoch() + cut_off = epoch - seconds if seconds else 0 with self as (db, cursor), db: result = self._execute(cursor, query, item_ids) if as_dict: - result = { - item[0]: self._decode(item[2], process, item) - for item in result if not cut_off or item[1] >= cut_off - } + if values_only: + result = { + item[0]: self._decode(item[2], process, item) + for item in result if not cut_off or item[1] >= cut_off + } + else: + result = { + item[0]: { + 'age': epoch - item[1], + 'value': self._decode(item[2], process, item), + } + for item in result if not cut_off or item[1] >= cut_off + } elif values_only: result = [ self._decode(item[2], process, item) 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 967fc7d8a..2597d5377 100644 --- a/resources/lib/youtube_plugin/kodion/ui/abstract_context_ui.py +++ b/resources/lib/youtube_plugin/kodion/ui/abstract_context_ui.py @@ -42,7 +42,6 @@ def on_clear_content(self, name): def on_select(self, title, items=None, preselect=-1, use_details=False): raise NotImplementedError() - def show_notification(self, message, header='', image_uri='', time_ms=5000, audible=True): raise NotImplementedError() 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 59c1a80bd..9164ea059 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 @@ -132,12 +132,13 @@ def show_notification(self, audible) def refresh_container(self): - self._context.send_notification(REFRESH_CONTAINER, True) + self._context.send_notification(REFRESH_CONTAINER) @staticmethod def set_property(property_id, value='true'): property_id = '-'.join((ADDON_ID, property_id)) xbmcgui.Window(10000).setProperty(property_id, value) + return value @staticmethod def get_property(property_id): @@ -148,6 +149,7 @@ def get_property(property_id): def clear_property(property_id): property_id = '-'.join((ADDON_ID, property_id)) xbmcgui.Window(10000).clearProperty(property_id) + return None @staticmethod def bold(value, cr_before=0, cr_after=0): diff --git a/resources/lib/youtube_plugin/kodion/utils/__init__.py b/resources/lib/youtube_plugin/kodion/utils/__init__.py index 4e9d5a2cf..8df55c144 100644 --- a/resources/lib/youtube_plugin/kodion/utils/__init__.py +++ b/resources/lib/youtube_plugin/kodion/utils/__init__.py @@ -13,7 +13,6 @@ from . import datetime_parser from .methods import ( duration_to_seconds, - find_best_fit, find_video_id, friendly_number, get_kodi_setting_bool, @@ -37,7 +36,6 @@ 'current_system_version', 'datetime_parser', 'duration_to_seconds', - 'find_best_fit', 'find_video_id', 'friendly_number', 'get_kodi_setting_bool', diff --git a/resources/lib/youtube_plugin/kodion/utils/datetime_parser.py b/resources/lib/youtube_plugin/kodion/utils/datetime_parser.py index 402af6d2c..b09097f73 100644 --- a/resources/lib/youtube_plugin/kodion/utils/datetime_parser.py +++ b/resources/lib/youtube_plugin/kodion/utils/datetime_parser.py @@ -280,7 +280,7 @@ def strptime(datetime_str, fmt=None): return datetime.strptime(datetime_str, fmt) except TypeError: if '_strptime' not in modules or strptime.reloading.locked(): - if strptime.reloaded.acquire(blocking=False): + if strptime.reloaded.acquire(False): _strptime = import_module('_strptime') modules['_strptime'] = _strptime log_error('Python strptime bug workaround - ' diff --git a/resources/lib/youtube_plugin/kodion/utils/methods.py b/resources/lib/youtube_plugin/kodion/utils/methods.py index 556ade575..240fa3543 100644 --- a/resources/lib/youtube_plugin/kodion/utils/methods.py +++ b/resources/lib/youtube_plugin/kodion/utils/methods.py @@ -24,7 +24,6 @@ __all__ = ( 'duration_to_seconds', - 'find_best_fit', 'find_video_id', 'friendly_number', 'get_kodi_setting_bool', @@ -57,122 +56,79 @@ def to_unicode(text): return text -def find_best_fit(data, compare_method=None): - if isinstance(data, dict): - data = data.values() - - try: - return next(item for item in data if item.get('container') == 'mpd') - except StopIteration: - pass - - if not compare_method: - return None - - result = None - last_fit = -1 - for item in data: - fit = abs(compare_method(item)) - if last_fit == -1 or fit < last_fit: - last_fit = fit - result = item - - return result - - def select_stream(context, stream_data_list, - quality_map_override=None, ask_for_quality=None, audio_only=None, use_adaptive_formats=True): - # sort - best stream first - def _sort_stream_data(_stream_data): - return _stream_data.get('sort', (0, 0)) - settings = context.get_settings() - use_adaptive = use_adaptive_formats and context.use_inputstream_adaptive() if ask_for_quality is None: ask_for_quality = context.get_settings().ask_for_video_quality() - video_quality = settings.get_video_quality(quality_map_override) if audio_only is None: audio_only = settings.audio_only() - adaptive_live = settings.use_isa_live_streams() and context.inputstream_adaptive_capabilities('live') - if not ask_for_quality: - stream_data_list = [item for item in stream_data_list - if (item['container'] not in {'mpd', 'hls'} or - item.get('hls/video') or - item.get('dash/video'))] + isa_capabilities = context.inputstream_adaptive_capabilities() + use_adaptive = (use_adaptive_formats + and settings.use_isa() + and bool(isa_capabilities)) + live_type = ('live' in isa_capabilities + and settings.live_stream_type()) or 'hls' - if not ask_for_quality and audio_only: # check for live stream, audio only not supported + if audio_only: context.log_debug('Select stream: Audio only') - for item in stream_data_list: - if item.get('Live'): - context.log_debug('Select stream: Live stream, audio only not available') - audio_only = False - break - - if not ask_for_quality and audio_only: - audio_stream_data_list = [item for item in stream_data_list - if (item.get('dash/audio') and - not item.get('dash/video') and - not item.get('hls/video'))] - - if audio_stream_data_list: - use_adaptive = False - stream_data_list = audio_stream_data_list - else: - context.log_debug('Select stream: Audio only, no audio only streams found') - - if not adaptive_live: - stream_data_list = [item for item in stream_data_list - if (item['container'] != 'mpd' or - not item.get('Live'))] - elif not use_adaptive: - stream_data_list = [item for item in stream_data_list - if item['container'] != 'mpd'] - - def _find_best_fit_video(_stream_data): - if audio_only: - return video_quality - _stream_data.get('sort', (0, 0))[0] - return video_quality - _stream_data.get('video', {}).get('height', 0) - - sorted_stream_data_list = sorted(stream_data_list, key=_sort_stream_data) - - context.log_debug('selectable streams: %d' % len(sorted_stream_data_list)) - log_streams = [] - for sorted_stream_data in sorted_stream_data_list: - log_data = copy.deepcopy(sorted_stream_data) - if 'license_info' in log_data: - log_data['license_info']['url'] = '[not shown]' if log_data['license_info'].get('url') else None - log_data['license_info']['token'] = '[not shown]' if log_data['license_info'].get('token') else None - else: - log_data['url'] = re.sub(r'ip=\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}', 'ip=xxx.xxx.xxx.xxx', log_data['url']) - log_streams.append(log_data) - context.log_debug('selectable streams: \n%s' % '\n'.join(str(stream) for stream in log_streams)) - - selected_stream_data = None - if ask_for_quality and len(sorted_stream_data_list) > 1: - items = [ - (sorted_stream_data['title'], sorted_stream_data) - for sorted_stream_data in sorted_stream_data_list + stream_list = [item for item in stream_data_list + if 'video' not in item] + else: + stream_list = [ + item for item in stream_data_list + if (not item.get('adaptive') + or (not item.get('live') and use_adaptive) + or (live_type.startswith('isa_') and item.get('hls/video')) + or (live_type == 'isa_mpd' and item.get('dash/video'))) ] - result = context.get_ui().on_select(context.localize('select_video_quality'), items) - if result != -1: - selected_stream_data = result - else: - selected_stream_data = find_best_fit(sorted_stream_data_list, _find_best_fit_video) + if not stream_list: + context.log_debug('Select stream: no streams found') + return None + + def _stream_sort(_stream): + return _stream.get('sort', [0, 0, 0]) + + stream_list.sort(key=_stream_sort, reverse=True) + num_streams = len(stream_list) + ask_for_quality = ask_for_quality and num_streams > 1 + context.log_debug('Available streams: {0}'.format(num_streams)) + + for idx, stream in enumerate(stream_list): + log_data = copy.deepcopy(stream) - if selected_stream_data is not None: - log_data = copy.deepcopy(selected_stream_data) if 'license_info' in log_data: - log_data['license_info']['url'] = '[not shown]' if log_data['license_info'].get('url') else None - log_data['license_info']['token'] = '[not shown]' if log_data['license_info'].get('token') else None - context.log_debug('selected stream: %s' % log_data) + for detail in ('url', 'token'): + original_value = log_data['license_info'].get(detail) + if original_value: + log_data['license_info'][detail] = '' + + original_value = log_data.get('url') + if original_value: + log_data['url'] = re.sub(r'ip=\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}', + 'ip=xxx.xxx.xxx.xxx', + original_value) + + context.log_debug('Stream {0}:\n{1}'.format(idx, log_data)) + + if ask_for_quality: + selected_stream = context.get_ui().on_select( + context.localize('select_video_quality'), + [stream['title'] for stream in stream_list], + ) + if selected_stream == -1: + context.log_debug('Select stream: no stream selected') + return None + else: + selected_stream = 0 - return selected_stream_data + context.log_debug('Selected stream: Stream {0}'.format(selected_stream)) + return stream_list[selected_stream] def strip_html_from_text(text): @@ -239,7 +195,8 @@ def rm_dir(path): def find_video_id(plugin_path): - match = re.search(r'.*video_id=(?P[a-zA-Z0-9_\-]{11}).*', plugin_path) + match = re.search(r'.*video_id=(?P[a-zA-Z0-9_\-]{11}).*', + plugin_path) if match: return match.group('video_id') return '' @@ -288,7 +245,7 @@ def merge_dicts(item1, item2, templates=None, _=Ellipsis): if not isinstance(item1, dict) or not isinstance(item2, dict): return ( item1 if item2 is _ else - _ if KeyError in (item1, item2) else + _ if (item1 is KeyError or item2 is KeyError) else item2 ) new = {} @@ -328,7 +285,7 @@ def validate_ip_address(ip_address): if len(octets) != 4: raise ValueError except ValueError: - return (0, 0, 0, 0) + return 0, 0, 0, 0 return tuple(octets) diff --git a/resources/lib/youtube_plugin/youtube/client/youtube.py b/resources/lib/youtube_plugin/youtube/client/youtube.py index 7eed295c9..b5d887fda 100644 --- a/resources/lib/youtube_plugin/youtube/client/youtube.py +++ b/resources/lib/youtube_plugin/youtube/client/youtube.py @@ -13,6 +13,7 @@ import threading import xml.etree.ElementTree as ET from copy import deepcopy +from functools import partial from itertools import chain, islice from random import randint @@ -209,43 +210,43 @@ def get_video_streams(self, context, video_id): if 'audio' in video_stream and 'video' in video_stream: if (video_stream['audio']['bitrate'] > 0 - and video_stream['video']['encoding'] - and video_stream['audio']['encoding']): + and video_stream['video']['codec'] + and video_stream['audio']['codec']): title = '%s (%s; %s / %s@%d)' % ( context.get_ui().bold(video_stream['title']), video_stream['container'], - video_stream['video']['encoding'], - video_stream['audio']['encoding'], + video_stream['video']['codec'], + video_stream['audio']['codec'], video_stream['audio']['bitrate'] ) - elif (video_stream['video']['encoding'] - and video_stream['audio']['encoding']): + elif (video_stream['video']['codec'] + and video_stream['audio']['codec']): title = '%s (%s; %s / %s)' % ( context.get_ui().bold(video_stream['title']), video_stream['container'], - video_stream['video']['encoding'], - video_stream['audio']['encoding'] + video_stream['video']['codec'], + video_stream['audio']['codec'] ) elif 'audio' in video_stream and 'video' not in video_stream: - if (video_stream['audio']['encoding'] + if (video_stream['audio']['codec'] and video_stream['audio']['bitrate'] > 0): title = '%s (%s; %s@%d)' % ( context.get_ui().bold(video_stream['title']), video_stream['container'], - video_stream['audio']['encoding'], + video_stream['audio']['codec'], video_stream['audio']['bitrate'] ) elif 'audio' in video_stream or 'video' in video_stream: - encoding = video_stream.get('audio', {}).get('encoding') - if not encoding: - encoding = video_stream.get('video', {}).get('encoding') - if encoding: + codec = video_stream.get('audio', {}).get('codec') + if not codec: + codec = video_stream.get('video', {}).get('codec') + if codec: title = '%s (%s; %s)' % ( context.get_ui().bold(video_stream['title']), video_stream['container'], - encoding + codec ) video_stream['title'] = title @@ -486,11 +487,6 @@ def get_recommended_for_home(self, visitor='', page_token='', click_tracking=''): - payload = { - 'kind': 'youtube#activityListResponse', - 'items': [] - } - post_data = {'browseId': 'FEwhat_to_watch'} if page_token: post_data['continuation'] = page_token @@ -511,7 +507,7 @@ def get_recommended_for_home(self, path='browse', post_data=post_data) if not result: - return payload + return None recommended_videos = self.json_traverse( result, @@ -565,7 +561,7 @@ def get_recommended_for_home(self, ) ) if not recommended_videos: - return payload + return None v3_response = { 'kind': 'youtube#activityListResponse', @@ -611,6 +607,8 @@ def get_recommended_for_home(self, if visitor: v3_response['visitorData'] = visitor + if not v3_response['items']: + v3_response = None return v3_response def get_related_for_home(self, page_token='', refresh=False): @@ -1104,7 +1102,7 @@ def get_related_videos(self, post_data=post_data, no_login=True) if not result: - return {} + return None related_videos = self.json_traverse(result, path=( ( @@ -1270,14 +1268,17 @@ def get_related_videos(self, max_results=remaining, **kwargs ) - if 'nextPageToken' in continuation: + if continuation and 'nextPageToken' in continuation: page_token = continuation['nextPageToken'] else: page_token = '' if 'items' in continuation: items.extend(continuation['items']) - v3_response['items'] = items + if items: + v3_response['items'] = items + else: + v3_response = None return v3_response def get_parent_comments(self, @@ -1394,7 +1395,7 @@ def search(self, 'relevanceLanguage': self._language, 'maxResults': str(self._max_results)} - if event_type and event_type in ['live', 'upcoming', 'completed']: + if event_type and event_type in {'live', 'upcoming', 'completed'}: params['eventType'] = event_type if search_type: params['type'] = search_type @@ -1442,308 +1443,364 @@ def get_my_subscriptions(self, 'items': [], } - cache = self._context.get_data_cache() + cache = self._context.get_feed_history() settings = self._context.get_settings() - filter_list = [] - black_list = False if do_filter: - black_list = settings.get_bool( - 'youtube.filter.my_subscriptions_filtered.blacklist', False - ) - filter_list = settings.get_string( - 'youtube.filter.my_subscriptions_filtered.list', '' - ).replace(', ', ',').split(',') - filter_list = {filter_item.lower() for filter_item in filter_list} - - # if new uploads is cached - cache_items_key = 'my-subscriptions-items-v2' - if refresh: - cached = None - else: - cached = cache.get_item(cache_items_key, cache.ONE_HOUR) or [] - if cached: - items = cached - # no cache, get uploads data from web - else: - channel_ids = [] - params = { - 'part': 'snippet', - 'maxResults': '50', - 'order': 'alphabetical', - 'mine': 'true' + subscription_filters = { + 'blacklist': settings.get_bool( + 'youtube.filter.my_subscriptions_filtered.blacklist', False + ), + 'set': { + item.lower() + for item in settings.get_string( + 'youtube.filter.my_subscriptions_filtered.list', '' + ).replace(', ', ',').split(',') + }, } + else: + subscription_filters = None - def _get_channels(params=params): - if not params or 'complete' in params: - return None, None - json_data = self.api_request(method='GET', - path='subscriptions', - params=params, - **kwargs) - if not json_data: - return None, None - - page_token = json_data.get('nextPageToken') - if page_token: - params['pageToken'] = page_token - else: - params['complete'] = True - - return 'list_list', [{ - 'channel_id': item['snippet']['resourceId']['channelId'] - } for item in json_data.get('items', [])] - - bookmarks = self._context.get_bookmarks_list().get_items() - if bookmarks: - channel_ids.extend([ - {'channel_id': item_id} - for item_id, item in bookmarks.items() - if (isinstance(item, float) - or getattr(item, 'get_channel_id', bool)()) - ]) - - feeds = [] - headers = { - 'Host': 'www.youtube.com', - 'Connection': 'keep-alive', - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' - ' AppleWebKit/537.36 (KHTML, like Gecko)' - ' Chrome/87.0.4280.66 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.7,de;q=0.3' - } + page = page_token or 1 + totals = { + 'num': 0, + 'start': -self._max_results, + 'end': page * self._max_results, + 'video_ids': set(), + } + totals['start'] += totals['end'] + + def _sort_by_date_time(item, limits): + video_id = item['id'] + if video_id in limits['video_ids']: + return -1 + limits['num'] += 1 + limits['video_ids'].add(video_id) + return item['_timestamp'] - def _get_feed(channel_id, headers=headers): - return 'value_list', { - 'channel_id': channel_id, - 'feed': self.request( - 'https://www.youtube.com/feeds/videos.xml?channel_id=' - + channel_id, - headers=headers, - ), + channel_ids = [] + params = { + 'part': 'snippet', + 'maxResults': '50', + 'order': 'alphabetical', + 'mine': 'true' + } + + def _get_channels(_params=params): + if not _params or 'complete' in _params: + return None, None + json_data = self.api_request(method='GET', + path='subscriptions', + params=_params, + **kwargs) + if not json_data: + return None, None + + subs_page_token = json_data.get('nextPageToken') + if subs_page_token: + _params['pageToken'] = subs_page_token + else: + _params['complete'] = True + + return 'list_list', [{ + 'channel_id': item['snippet']['resourceId']['channelId'] + } for item in json_data.get('items', [])] + + bookmarks = self._context.get_bookmarks_list().get_items() + if bookmarks: + channel_ids.extend([ + {'channel_id': item_id} + for item_id, item in bookmarks.items() + if (isinstance(item, float) + or getattr(item, 'get_channel_id', bool)()) + ]) + + feeds = {} + headers = { + 'Host': 'www.youtube.com', + 'Connection': 'keep-alive', + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' + ' AppleWebKit/537.36 (KHTML, like Gecko)' + ' Chrome/87.0.4280.66 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.7,de;q=0.3' + } + + def _get_feed_cache(channel_id, _cache=cache, _refresh=refresh): + cached = _cache.get_item(channel_id) + if cached: + feed_details = cached['value'] + _refresh = _refresh or cached['age'] > _cache.ONE_HOUR + else: + feed_details = { + 'channel_name': None, + 'cached_items': None, } + _refresh = True - items = [] - namespaces = { - 'atom': 'http://www.w3.org/2005/Atom', - 'yt': 'http://www.youtube.com/xml/schemas/2015', - 'media': 'http://search.yahoo.com/mrss/', - } + if _refresh: + feed_details['refresh'] = True - def _parse_feed(channel_id, - feed, - encode=not current_system_version.compatible(19, 0), - namespaces=namespaces, - as_list=False): - feed.encoding = 'utf-8' - feed = to_unicode(feed.content).replace('\n', '') - - root = ET.fromstring(to_str(feed) if encode else feed) - channel_name = (root.findtext('atom:title', '', namespaces) - .lower().replace(',', '')) - feed_items = [{ - 'kind': 'youtube#video', - 'id': item.findtext('yt:videoId', '', namespaces), - 'snippet': { - 'title': item.findtext('atom:title', '', namespaces), - 'channelId': channel_id, - }, - '_channel': channel_name, - '_timestamp': datetime_parser.since_epoch( - datetime_parser.strptime( - item.findtext('atom:published', '', namespaces) - ) - ), - '_partial': True, - } for item in root.findall('atom:entry', namespaces)] - if as_list: - return feed_items - return 'list_list', feed_items - - def _threaded_fetch(kwargs, - output, - worker, - threads, - pool_id, - dynamic, - input_wait, - **_kwargs): - while not threads['balance'].is_set(): - if kwargs is True: - _kwargs = {} - elif kwargs: - _kwargs = kwargs.pop() - elif input_wait: - input_wait.acquire(True) - input_wait.release() - if kwargs: - continue - break - else: - break - - try: - output_type, _output = worker(**_kwargs) - except Exception as exc: - self._context.log_error('threaded_fetch error: |{exc}|' - .format(exc=exc)) - continue + return 'dict_dict_dict', (channel_id, feed_details) + + def _get_feed(channel_id, _headers=headers): + return 'dict_dict_dict', (channel_id, { + 'content': self.request( + 'https://www.youtube.com/feeds/videos.xml?channel_id=' + + channel_id, + headers=_headers, + ), + 'refresh': True, + }) - if not output_type: - break - if output_type == 'value_dict': - output[_output[0]] = _output[1] - elif output_type == 'dict_dict': - output.update(_output) - elif output_type == 'value_list': - output.append(_output) - elif output_type == 'list_list': - output.extend(_output) + namespaces = { + 'atom': 'http://www.w3.org/2005/Atom', + 'yt': 'http://www.youtube.com/xml/schemas/2015', + 'media': 'http://search.yahoo.com/mrss/', + } + + def _parse_feeds(feeds, + encode=not current_system_version.compatible(19, 0), + filters=subscription_filters, + _ns=namespaces, + _cache=cache): + all_items = {} + new_cache = {} + for channel_id, feed in feeds.items(): + channel_name = feed.get('channel_name') + cached_items = feed.get('cached_items') + refresh_feed = feed.get('refresh') + content = feed.get('content') + + if refresh_feed and content: + content.encoding = 'utf-8' + content = to_unicode(content.content).replace('\n', '') + + root = ET.fromstring(to_str(content) if encode else content) + channel_name = (root.findtext('atom:title', '', _ns) + .lower().replace(',', '')) + feed_items = [{ + 'kind': 'youtube#video', + 'id': item.findtext('yt:videoId', '', _ns), + 'snippet': { + 'channelId': channel_id, + }, + '_timestamp': datetime_parser.since_epoch( + datetime_parser.strptime( + item.findtext('atom:published', '', _ns) + ) + ), + '_partial': True, + } for item in root.findall('atom:entry', _ns)] else: - threads['balance'].clear() - - thread = threading.current_thread() - threads['available'].release() - if dynamic: - threads['pool_counts'][pool_id] -= 1 - threads['pool_counts']['all'] -= 1 - threads['current'].discard(thread) - - try: - num_cores = cpu_count() or 1 - except NotImplementedError: - num_cores = 1 - max_threads = min(32, 2 * (num_cores + 4)) - threads = { - 'max': max_threads, - 'available': threading.Semaphore(max_threads), - 'current': set(), - 'pool_counts': { - 'all': 0, - }, - 'balance': threading.Event(), - } - payloads = [ - { - 'pool_id': 1, - 'kwargs': True, - 'output': channel_ids, - 'worker': _get_channels, - 'threads': threads, - 'limit': 1, - 'dynamic': False, - 'input_wait': None, - }, - ] if logged_in else [] - payloads.extend(( - { - 'pool_id': 2, - 'kwargs': channel_ids, - 'output': feeds, - 'worker': _get_feed, - 'threads': threads, - 'limit': None, - 'dynamic': True, - 'input_wait': threading.Lock(), - }, - { - 'pool_id': 3, - 'kwargs': feeds, - 'output': items, - 'worker': _parse_feed, - 'threads': threads, - 'limit': 1, - 'dynamic': True, - 'input_wait': threading.Lock(), - }, - )) - while 1: - for payload in payloads: - pool_id = payload['pool_id'] - if pool_id in threads['pool_counts']: - current_num = threads['pool_counts'][pool_id] - else: - current_num = threads['pool_counts'][pool_id] = 0 - - input_wait = payload['input_wait'] - if payload['kwargs']: - if input_wait and input_wait.locked(): - input_wait.release() - else: + feed_items = [] + + if feed_items: + if cached_items: + feed_items.extend(cached_items) + feed_limits = { + 'num': 0, + 'video_ids': set(), + } + feed_items.sort(reverse=True, + key=partial(_sort_by_date_time, + limits=feed_limits)) + feed_items = feed_items[:min(1000, feed_limits['num'])] + new_cache[channel_id] = { + 'channel_name': channel_name, + 'cached_items': feed_items, + } + elif cached_items: + feed_items = cached_items + else: + continue + if filters: + filtered = channel_name and channel_name in filters['set'] + if filters['blacklist']: + if not filtered: + all_items[channel_id] = feed_items + elif filtered: + all_items[channel_id] = feed_items + else: + all_items[channel_id] = feed_items + + if new_cache: + _cache.set_items(new_cache) + return list(chain.from_iterable(all_items.values())) + + def _threaded_fetch(kwargs, + output, + worker, + threads, + pool_id, + dynamic, + input_wait, + **_kwargs): + while not threads['balance'].is_set(): + if kwargs is True: + _kwargs = {} + elif kwargs: + _kwargs = kwargs.pop() + elif input_wait: + input_wait.acquire(True) + input_wait.release() + if kwargs: continue + break + else: + break - available = threads['max'] - threads['pool_counts']['all'] - limit = payload['limit'] - if limit: - if current_num >= limit: - continue - if not available: - threads['balance'].set() - elif not available: - continue + try: + output_type, _output = worker(**_kwargs) + except Exception as exc: + self._context.log_error('threaded_fetch error: |{exc}|' + .format(exc=exc)) + continue - thread = threading.Thread( - target=_threaded_fetch, - kwargs=payload, - ) - thread.daemon = True - threads['current'].add(thread) - threads['pool_counts'][pool_id] += 1 - threads['pool_counts']['all'] -= 1 - threads['available'].acquire(blocking=True) - thread.start() - - if not threads['current']: + if not output_type: break + if output_type == 'value_dict': + output[_output[0]] = _output[1] + elif output_type == 'dict_dict': + output.update(_output) + elif output_type == 'value_list': + output.append(_output) + elif output_type == 'list_list': + output.extend(_output) + elif output_type == 'value_list_dict': + if _output[0] not in output: + output[_output[0]] = [] + output[_output[0]].append(_output[1]) + elif output_type == 'list_list_dict': + if _output[0] not in output: + output[_output[0]] = [] + output[_output[0]].extend(_output[1]) + elif output_type == 'dict_dict_dict': + if _output[0] not in output: + output[_output[0]] = {} + output[_output[0]].update(_output[1]) + else: + threads['balance'].clear() + + thread = threading.current_thread() + threads['available'].release() + if dynamic: + threads['pool_counts'][pool_id] -= 1 + threads['pool_counts']['all'] -= 1 + threads['current'].discard(thread) + + try: + num_cores = cpu_count() or 1 + except NotImplementedError: + num_cores = 1 + max_threads = min(32, 2 * (num_cores + 4)) + threads = { + 'max': max_threads, + 'available': threading.Semaphore(max_threads), + 'current': set(), + 'pool_counts': { + 'all': 0, + }, + 'balance': threading.Event(), + } + payloads = [ + { + 'pool_id': 1, + 'kwargs': True, + 'output': channel_ids, + 'worker': _get_channels, + 'threads': threads, + 'limit': 1, + 'dynamic': False, + 'input_wait': None, + }, + ] if logged_in else [] + payloads.extend(( + { + 'pool_id': 2, + 'kwargs': channel_ids, + 'output': feeds, + 'worker': _get_feed_cache, + 'threads': threads, + 'limit': 1, + 'dynamic': False, + 'input_wait': threading.Lock(), + }, + { + 'pool_id': 3, + 'kwargs': channel_ids, + 'output': feeds, + 'worker': _get_feed, + 'threads': threads, + 'limit': None, + 'dynamic': True, + 'input_wait': threading.Lock(), + }, + )) + while 1: + for payload in payloads: + pool_id = payload['pool_id'] + if pool_id in threads['pool_counts']: + current_num = threads['pool_counts'][pool_id] + else: + current_num = threads['pool_counts'][pool_id] = 0 - for thread in threads['current']: - if thread and thread.is_alive(): - thread.join(30) + input_wait = payload['input_wait'] + if payload['kwargs']: + if input_wait and input_wait.locked(): + input_wait.release() + else: + continue + available = threads['max'] - threads['pool_counts']['all'] + limit = payload['limit'] + if limit: + if current_num >= limit: + continue + if not available: + threads['balance'].set() + elif not available: + continue - # Update cache - cache.set_item(cache_items_key, items) + thread = threading.Thread( + target=_threaded_fetch, + kwargs=payload, + ) + thread.daemon = True + threads['current'].add(thread) + threads['pool_counts'][pool_id] += 1 + threads['pool_counts']['all'] += 1 + threads['available'].acquire(True) + thread.start() - # filter, sorting by publish date and trim - page = page_token or 1 + if not threads['current']: + break - limits = { - 'num': 0, - 'start': -self._max_results, - 'end': page * self._max_results, - 'video_ids': set(), - } - limits['start'] += limits['end'] - - def _sort_by_date_time(item, limits=limits): - if do_filter: - filtered = item['_channel'] in filter_list - if black_list: - if filtered: - return -1 - elif not filtered: - return -1 - video_id = item['id'] - if video_id in limits['video_ids']: - return -1 - limits['num'] += 1 - limits['video_ids'].add(video_id) - return item['_timestamp'] + for thread in threads['current']: + if thread and thread.is_alive(): + thread.join(30) - items.sort(reverse=True, key=_sort_by_date_time) + items = _parse_feeds(feeds) + + # filter, sorting by publish date and trim + if items: + items.sort(reverse=True, + key=partial(_sort_by_date_time, + limits=totals)) + else: + return None - if limits['num'] > limits['end']: + if totals['num'] > totals['end']: v3_response['nextPageToken'] = page + 1 - if limits['num'] > limits['start']: - items = items[limits['start']:min(limits['num'], limits['end'])] + if totals['num'] > totals['start']: + items = items[totals['start']:min(totals['num'], totals['end'])] else: - items = [] + return None v3_response['items'] = items return v3_response @@ -1947,7 +2004,7 @@ def _error_hook(self, **kwargs): elif reason == 'keyInvalid' and message == 'Bad Request': notification = self._context.localize('api.key.incorrect') timeout = 7000 - elif reason in ('quotaExceeded', 'dailyLimitExceeded'): + elif reason in {'quotaExceeded', 'dailyLimitExceeded'}: notification = message timeout = 7000 else: diff --git a/resources/lib/youtube_plugin/youtube/helper/resource_manager.py b/resources/lib/youtube_plugin/youtube/helper/resource_manager.py index 4bd74b2eb..d775cfa04 100644 --- a/resources/lib/youtube_plugin/youtube/helper/resource_manager.py +++ b/resources/lib/youtube_plugin/youtube/helper/resource_manager.py @@ -264,9 +264,8 @@ def get_related_playlists(self, channel_id, defer_cache=False): break if item is None: - return {} - - return item.get('contentDetails', {}).get('relatedPlaylists', {}) + return None + return item.get('contentDetails', {}).get('relatedPlaylists') def get_videos(self, ids, diff --git a/resources/lib/youtube_plugin/youtube/helper/subtitles.py b/resources/lib/youtube_plugin/youtube/helper/subtitles.py index a669705af..025f6ffbd 100644 --- a/resources/lib/youtube_plugin/youtube/helper/subtitles.py +++ b/resources/lib/youtube_plugin/youtube/helper/subtitles.py @@ -18,7 +18,7 @@ urlsplit, xbmcvfs, ) -from ...kodion.constants import TEMP_PATH +from ...kodion.constants import PLAY_PROMPT_SUBTITLES, TEMP_PATH from ...kodion.network import BaseRequestsClass from ...kodion.utils import make_dirs @@ -82,9 +82,9 @@ def __init__(self, context, video_id): self.preferred_lang = ('en',) ui = context.get_ui() - self.prompt_override = (ui.get_property('prompt_for_subtitles') + self.prompt_override = (ui.get_property(PLAY_PROMPT_SUBTITLES) == video_id) - ui.clear_property('prompt_for_subtitles') + ui.clear_property(PLAY_PROMPT_SUBTITLES) def load(self, captions, headers=None): if headers: diff --git a/resources/lib/youtube_plugin/youtube/helper/url_resolver.py b/resources/lib/youtube_plugin/youtube/helper/url_resolver.py index 056c20ab4..7c7b0ece5 100644 --- a/resources/lib/youtube_plugin/youtube/helper/url_resolver.py +++ b/resources/lib/youtube_plugin/youtube/helper/url_resolver.py @@ -66,11 +66,11 @@ def __init__(self, *args, **kwargs): super(YouTubeResolver, self).__init__(*args, **kwargs) def supports_url(self, url, url_components): - if url_components.hostname not in ( - 'www.youtube.com', - 'youtube.com', - 'm.youtube.com', - ): + if url_components.hostname not in { + 'www.youtube.com', + 'youtube.com', + 'm.youtube.com', + }: return False path = url_components.path.lower() @@ -193,11 +193,11 @@ def __init__(self, *args, **kwargs): super(CommonResolver, self).__init__(*args, **kwargs) def supports_url(self, url, url_components): - if url_components.hostname in ( - 'www.youtube.com', - 'youtube.com', - 'm.youtube.com', - ): + if url_components.hostname in { + 'www.youtube.com', + 'youtube.com', + 'm.youtube.com', + }: return False return 'HEAD' diff --git a/resources/lib/youtube_plugin/youtube/helper/utils.py b/resources/lib/youtube_plugin/youtube/helper/utils.py index daaa468f1..529001e6c 100644 --- a/resources/lib/youtube_plugin/youtube/helper/utils.py +++ b/resources/lib/youtube_plugin/youtube/helper/utils.py @@ -14,7 +14,7 @@ import time from math import log10 -from ...kodion.constants import content, paths +from ...kodion.constants import LICENSE_TOKEN, LICENSE_URL, content, paths from ...kodion.items import DirectoryItem, menu_items from ...kodion.utils import ( datetime_parser, @@ -705,7 +705,7 @@ def update_video_infos(provider, context, video_id_dict, # 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')): + 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) @@ -855,8 +855,8 @@ def update_play_info(provider, context, video_id, video_item, video_stream, drm='com.widevine.alpha').check_inputstream() video_item.set_license_key(license_proxy) - ui.set_property('license_url', license_url) - ui.set_property('license_token', license_token) + ui.set_property(LICENSE_URL, license_url) + ui.set_property(LICENSE_TOKEN, license_token) def update_fanarts(provider, context, channel_items_dict, data=None): diff --git a/resources/lib/youtube_plugin/youtube/helper/v3.py b/resources/lib/youtube_plugin/youtube/helper/v3.py index e8f08e96d..afff8e5c9 100644 --- a/resources/lib/youtube_plugin/youtube/helper/v3.py +++ b/resources/lib/youtube_plugin/youtube/helper/v3.py @@ -422,7 +422,9 @@ def response_to_items(provider, new_params = dict(params, page=next_page) yt_next_page_token = json_data.get('nextPageToken') - if yt_next_page_token: + if yt_next_page_token == next_page: + new_params['page_token'] = '' + elif yt_next_page_token: new_params['page_token'] = yt_next_page_token elif 'page_token' in new_params: del new_params['page_token'] @@ -432,9 +434,14 @@ def response_to_items(provider, if current_page * yt_results_per_page < yt_total_results: new_params['items_per_page'] = yt_results_per_page - else: + elif context.is_plugin_path( + context.get_infolabel('Container.FolderPath'), + partial=True, + ): next_page = 1 new_params['page'] = 1 + else: + return result else: return result diff --git a/resources/lib/youtube_plugin/youtube/helper/video_info.py b/resources/lib/youtube_plugin/youtube/helper/video_info.py index 15610bbf1..4879359cc 100644 --- a/resources/lib/youtube_plugin/youtube/helper/video_info.py +++ b/resources/lib/youtube_plugin/youtube/helper/video_info.py @@ -44,549 +44,503 @@ class VideoInfo(YouTubeRequestClient): # === Non-DASH === '5': {'container': 'flv', 'title': '240p', - 'sort': [-240, 0], - 'video': {'height': 240, 'encoding': 'h.263'}, - 'audio': {'bitrate': 64, 'encoding': 'mp3'}}, + 'sort': [240, 0], + 'video': {'height': 240, 'codec': 'h.263'}, + 'audio': {'bitrate': 64, 'codec': 'mp3'}}, '6': {'container': 'flv', # Discontinued 'discontinued': True, - 'video': {'height': 270, 'encoding': 'h.263'}, - 'audio': {'bitrate': 64, 'encoding': 'mp3'}}, + 'video': {'height': 270, 'codec': 'h.263'}, + 'audio': {'bitrate': 64, 'codec': 'mp3'}}, '13': {'container': '3gp', # Discontinued 'discontinued': True, - 'video': {'encoding': 'mpeg-4'}, - 'audio': {'encoding': 'aac'}}, + 'video': {'codec': 'h.264'}, + 'audio': {'codec': 'aac'}}, '17': {'container': '3gp', 'title': '144p', - 'sort': [-144, 20], - 'video': {'height': 144, 'encoding': 'mpeg-4'}, - 'audio': {'bitrate': 24, 'encoding': 'aac'}}, + 'video': {'height': 144, 'codec': 'h.264'}, + 'audio': {'bitrate': 24, 'codec': 'aac'}}, '18': {'container': 'mp4', 'title': '360p', - 'sort': [-360, 0], - 'video': {'height': 360, 'encoding': 'h.264'}, - 'audio': {'bitrate': 96, 'encoding': 'aac'}}, + 'video': {'height': 360, 'codec': 'h.264'}, + 'audio': {'bitrate': 96, 'codec': 'aac'}}, '22': {'container': 'mp4', 'title': '720p', - 'sort': [-720, 0], - 'video': {'height': 720, 'encoding': 'h.264'}, - 'audio': {'bitrate': 192, 'encoding': 'aac'}}, + 'video': {'height': 720, 'codec': 'h.264'}, + 'audio': {'bitrate': 192, 'codec': 'aac'}}, '34': {'container': 'flv', # Discontinued 'discontinued': True, - 'video': {'height': 360, 'encoding': 'h.264'}, - 'audio': {'bitrate': 128, 'encoding': 'aac'}}, + 'video': {'height': 360, 'codec': 'h.264'}, + 'audio': {'bitrate': 128, 'codec': 'aac'}}, '35': {'container': 'flv', # Discontinued 'discontinued': True, - 'video': {'height': 480, 'encoding': 'h.264'}, - 'audio': {'bitrate': 128, 'encoding': 'aac'}}, + 'video': {'height': 480, 'codec': 'h.264'}, + 'audio': {'bitrate': 128, 'codec': 'aac'}}, '36': {'container': '3gp', 'title': '240p', - 'sort': [-240, 20], - 'video': {'height': 240, 'encoding': 'mpeg-4'}, - 'audio': {'bitrate': 32, 'encoding': 'aac'}}, + 'video': {'height': 240, 'codec': 'h.264'}, + 'audio': {'bitrate': 32, 'codec': 'aac'}}, '37': {'container': 'mp4', 'title': '1080p', - 'sort': [-1080, 0], - 'video': {'height': 1080, 'encoding': 'h.264'}, - 'audio': {'bitrate': 192, 'encoding': 'aac'}}, + 'video': {'height': 1080, 'codec': 'h.264'}, + 'audio': {'bitrate': 192, 'codec': 'aac'}}, '38': {'container': 'mp4', 'title': '3072p', - 'sort': [-3072, 0], - 'video': {'height': 3072, 'encoding': 'h.264'}, - 'audio': {'bitrate': 192, 'encoding': 'aac'}}, + 'video': {'height': 3072, 'codec': 'h.264'}, + 'audio': {'bitrate': 192, 'codec': 'aac'}}, '43': {'container': 'webm', 'title': '360p', - 'sort': [-360, 1], - 'video': {'height': 360, 'encoding': 'vp8'}, - 'audio': {'bitrate': 128, 'encoding': 'vorbis'}}, + 'video': {'height': 360, 'codec': 'vp8'}, + 'audio': {'bitrate': 128, 'codec': 'vorbis'}}, '44': {'container': 'webm', # Discontinued 'discontinued': True, - 'video': {'height': 480, 'encoding': 'vp8'}, - 'audio': {'bitrate': 128, 'encoding': 'vorbis'}}, + 'video': {'height': 480, 'codec': 'vp8'}, + 'audio': {'bitrate': 128, 'codec': 'vorbis'}}, '45': {'container': 'webm', # Discontinued 'discontinued': True, - 'video': {'height': 720, 'encoding': 'vp8'}, - 'audio': {'bitrate': 192, 'encoding': 'vorbis'}}, + 'video': {'height': 720, 'codec': 'vp8'}, + 'audio': {'bitrate': 192, 'codec': 'vorbis'}}, '46': {'container': 'webm', # Discontinued 'discontinued': True, - 'video': {'height': 1080, 'encoding': 'vp8'}, - 'audio': {'bitrate': 192, 'encoding': 'vorbis'}}, + 'video': {'height': 1080, 'codec': 'vp8'}, + 'audio': {'bitrate': 192, 'codec': 'vorbis'}}, '59': {'container': 'mp4', 'title': '480p', - 'sort': [-480, 0], - 'video': {'height': 480, 'encoding': 'h.264'}, - 'audio': {'bitrate': 96, 'encoding': 'aac'}}, + 'video': {'height': 480, 'codec': 'h.264'}, + 'audio': {'bitrate': 96, 'codec': 'aac'}}, '78': {'container': 'mp4', 'title': '360p', - 'sort': [-360, 0], - 'video': {'height': 360, 'encoding': 'h.264'}, - 'audio': {'bitrate': 96, 'encoding': 'aac'}}, + 'video': {'height': 360, 'codec': 'h.264'}, + 'audio': {'bitrate': 96, 'codec': 'aac'}}, # === 3D === '82': {'container': 'mp4', '3D': True, - 'title': '3D@360p', - 'sort': [-360, 0], - 'video': {'height': 360, 'encoding': 'h.264'}, - 'audio': {'bitrate': 96, 'encoding': 'aac'}}, + 'title': '3D 360p', + 'video': {'height': 360, 'codec': 'h.264'}, + 'audio': {'bitrate': 96, 'codec': 'aac'}}, '83': {'container': 'mp4', '3D': True, - 'title': '3D@240p', - 'sort': [-240, 0], - 'video': {'height': 240, 'encoding': 'h.264'}, - 'audio': {'bitrate': 96, 'encoding': 'aac'}}, + 'title': '3D 240p', + 'video': {'height': 240, 'codec': 'h.264'}, + 'audio': {'bitrate': 96, 'codec': 'aac'}}, '84': {'container': 'mp4', '3D': True, - 'title': '3D@720p', - 'sort': [-720, 0], - 'video': {'height': 720, 'encoding': 'h.264'}, - 'audio': {'bitrate': 192, 'encoding': 'aac'}}, + 'title': '3D 720p', + 'video': {'height': 720, 'codec': 'h.264'}, + 'audio': {'bitrate': 192, 'codec': 'aac'}}, '85': {'container': 'mp4', '3D': True, - 'title': '3D@1080p', - 'sort': [-1080, 0], - 'video': {'height': 1080, 'encoding': 'h.264'}, - 'audio': {'bitrate': 192, 'encoding': 'aac'}}, + 'title': '3D 1080p', + 'video': {'height': 1080, 'codec': 'h.264'}, + 'audio': {'bitrate': 192, 'codec': 'aac'}}, '100': {'container': 'webm', '3D': True, - 'title': '3D@360p', - 'sort': [-360, 1], - 'video': {'height': 360, 'encoding': 'vp8'}, - 'audio': {'bitrate': 128, 'encoding': 'vorbis'}}, + 'title': '3D 360p', + 'video': {'height': 360, 'codec': 'vp8'}, + 'audio': {'bitrate': 128, 'codec': 'vorbis'}}, '101': {'container': 'webm', # Discontinued 'discontinued': True, '3D': True, - 'title': '3D@360p', - 'sort': [-360, 1], - 'video': {'height': 360, 'encoding': 'vp8'}, - 'audio': {'bitrate': 192, 'encoding': 'vorbis'}}, + 'title': '3D 360p', + 'video': {'height': 360, 'codec': 'vp8'}, + 'audio': {'bitrate': 192, 'codec': 'vorbis'}}, '102': {'container': 'webm', # Discontinued 'discontinued': True, '3D': True, - 'video': {'height': 720, 'encoding': 'vp8'}, - 'audio': {'bitrate': 192, 'encoding': 'vorbis'}}, + 'video': {'height': 720, 'codec': 'vp8'}, + 'audio': {'bitrate': 192, 'codec': 'vorbis'}}, # === Live Streams === '91': {'container': 'ts', - 'Live': True, - 'title': 'Live@144p', - 'sort': [-144, 0], - 'video': {'height': 144, 'encoding': 'h.264'}, - 'audio': {'bitrate': 48, 'encoding': 'aac'}}, + 'title': '144p', + 'video': {'height': 144, 'codec': 'h.264'}, + 'audio': {'bitrate': 48, 'codec': 'aac'}}, '92': {'container': 'ts', - 'Live': True, - 'title': 'Live@240p', - 'sort': [-240, 0], - 'video': {'height': 240, 'encoding': 'h.264'}, - 'audio': {'bitrate': 48, 'encoding': 'aac'}}, + 'title': '240p', + 'video': {'height': 240, 'codec': 'h.264'}, + 'audio': {'bitrate': 48, 'codec': 'aac'}}, '93': {'container': 'ts', - 'Live': True, - 'title': 'Live@360p', - 'sort': [-360, 0], - 'video': {'height': 360, 'encoding': 'h.264'}, - 'audio': {'bitrate': 128, 'encoding': 'aac'}}, + 'title': '360p', + 'video': {'height': 360, 'codec': 'h.264'}, + 'audio': {'bitrate': 128, 'codec': 'aac'}}, '94': {'container': 'ts', - 'Live': True, - 'title': 'Live@480p', - 'sort': [-480, 0], - 'video': {'height': 480, 'encoding': 'h.264'}, - 'audio': {'bitrate': 128, 'encoding': 'aac'}}, + 'title': '480p', + 'video': {'height': 480, 'codec': 'h.264'}, + 'audio': {'bitrate': 128, 'codec': 'aac'}}, '95': {'container': 'ts', - 'Live': True, - 'title': 'Live@720p', - 'sort': [-720, 0], - 'video': {'height': 720, 'encoding': 'h.264'}, - 'audio': {'bitrate': 256, 'encoding': 'aac'}}, + 'title': '720p', + 'video': {'height': 720, 'codec': 'h.264'}, + 'audio': {'bitrate': 256, 'codec': 'aac'}}, '96': {'container': 'ts', - 'Live': True, - 'title': 'Live@1080p', - 'sort': [-1080, 0], - 'video': {'height': 1080, 'encoding': 'h.264'}, - 'audio': {'bitrate': 256, 'encoding': 'aac'}}, + 'title': '1080p', + 'video': {'height': 1080, 'codec': 'h.264'}, + 'audio': {'bitrate': 256, 'codec': 'aac'}}, '120': {'container': 'flv', # Discontinued 'discontinued': True, - 'Live': True, - 'title': 'Live@720p', - 'sort': [-720, 10], - 'video': {'height': 720, 'encoding': 'h.264'}, - 'audio': {'bitrate': 128, 'encoding': 'aac'}}, + 'live': True, + 'title': 'Live 720p', + 'video': {'height': 720, 'codec': 'h.264'}, + 'audio': {'bitrate': 128, 'codec': 'aac'}}, '127': {'container': 'ts', - 'Live': True, - 'audio': {'bitrate': 96, 'encoding': 'aac'}}, + 'live': True, + 'audio': {'bitrate': 96, 'codec': 'aac'}}, '128': {'container': 'ts', - 'Live': True, - 'audio': {'bitrate': 96, 'encoding': 'aac'}}, + 'live': True, + 'audio': {'bitrate': 96, 'codec': 'aac'}}, '132': {'container': 'ts', - 'Live': True, - 'title': 'Live@240p', - 'sort': [-240, 0], - 'video': {'height': 240, 'encoding': 'h.264'}, - 'audio': {'bitrate': 48, 'encoding': 'aac'}}, + 'title': '240p', + 'video': {'height': 240, 'codec': 'h.264'}, + 'audio': {'bitrate': 48, 'codec': 'aac'}}, '151': {'container': 'ts', - 'Live': True, + 'live': True, 'unsupported': True, - 'title': 'Live@72p', - 'sort': [-72, 0], - 'video': {'height': 72, 'encoding': 'h.264'}, - 'audio': {'bitrate': 24, 'encoding': 'aac'}}, + 'title': 'Live 72p', + 'video': {'height': 72, 'codec': 'h.264'}, + 'audio': {'bitrate': 24, 'codec': 'aac'}}, '300': {'container': 'ts', - 'Live': True, - 'title': 'Live@720p', - 'sort': [-720, 0], - 'video': {'height': 720, 'encoding': 'h.264'}, - 'audio': {'bitrate': 128, 'encoding': 'aac'}}, + 'title': '720p', + 'video': {'height': 720, 'codec': 'h.264'}, + 'audio': {'bitrate': 128, 'codec': 'aac'}}, '301': {'container': 'ts', - 'Live': True, - 'title': 'Live@1080p', - 'sort': [-1080, 0], - 'video': {'height': 1080, 'encoding': 'h.264'}, - 'audio': {'bitrate': 128, 'encoding': 'aac'}}, + 'title': '1080p', + 'video': {'height': 1080, 'codec': 'h.264'}, + 'audio': {'bitrate': 128, 'codec': 'aac'}}, # === DASH (video only) '133': {'container': 'mp4', 'dash/video': True, - 'video': {'height': 240, 'encoding': 'h.264'}}, + 'video': {'height': 240, 'codec': 'h.264'}}, '134': {'container': 'mp4', 'dash/video': True, - 'video': {'height': 360, 'encoding': 'h.264'}}, + 'video': {'height': 360, 'codec': 'h.264'}}, '135': {'container': 'mp4', 'dash/video': True, - 'video': {'height': 480, 'encoding': 'h.264'}}, + 'video': {'height': 480, 'codec': 'h.264'}}, '136': {'container': 'mp4', 'dash/video': True, - 'video': {'height': 720, 'encoding': 'h.264'}}, + 'video': {'height': 720, 'codec': 'h.264'}}, '137': {'container': 'mp4', 'dash/video': True, - 'video': {'height': 1080, 'encoding': 'h.264'}}, + 'video': {'height': 1080, 'codec': 'h.264'}}, '138': {'container': 'mp4', # Discontinued 'discontinued': True, 'dash/video': True, - 'video': {'height': 2160, 'encoding': 'h.264'}}, + 'video': {'height': 2160, 'codec': 'h.264'}}, '160': {'container': 'mp4', 'dash/video': True, - 'video': {'height': 144, 'encoding': 'h.264'}}, + 'video': {'height': 144, 'codec': 'h.264'}}, '167': {'container': 'webm', 'dash/video': True, - 'video': {'height': 360, 'encoding': 'vp8'}}, + 'video': {'height': 360, 'codec': 'vp8'}}, '168': {'container': 'webm', 'dash/video': True, - 'video': {'height': 480, 'encoding': 'vp8'}}, + 'video': {'height': 480, 'codec': 'vp8'}}, '169': {'container': 'webm', 'dash/video': True, - 'video': {'height': 720, 'encoding': 'vp8'}}, + 'video': {'height': 720, 'codec': 'vp8'}}, '170': {'container': 'webm', 'dash/video': True, - 'video': {'height': 1080, 'encoding': 'vp8'}}, + 'video': {'height': 1080, 'codec': 'vp8'}}, '218': {'container': 'webm', 'dash/video': True, - 'video': {'height': 480, 'encoding': 'vp8'}}, + 'video': {'height': 480, 'codec': 'vp8'}}, '219': {'container': 'webm', 'dash/video': True, - 'video': {'height': 480, 'encoding': 'vp8'}}, + 'video': {'height': 480, 'codec': 'vp8'}}, '242': {'container': 'webm', 'dash/video': True, - 'video': {'height': 240, 'encoding': 'vp9'}}, + 'video': {'height': 240, 'codec': 'vp9'}}, '243': {'container': 'webm', 'dash/video': True, - 'video': {'height': 360, 'encoding': 'vp9'}}, + 'video': {'height': 360, 'codec': 'vp9'}}, '244': {'container': 'webm', 'dash/video': True, - 'video': {'height': 480, 'encoding': 'vp9'}}, + 'video': {'height': 480, 'codec': 'vp9'}}, '247': {'container': 'webm', 'dash/video': True, - 'video': {'height': 720, 'encoding': 'vp9'}}, + 'video': {'height': 720, 'codec': 'vp9'}}, '248': {'container': 'webm', 'dash/video': True, - 'video': {'height': 1080, 'encoding': 'vp9'}}, + 'video': {'height': 1080, 'codec': 'vp9'}}, '264': {'container': 'mp4', 'dash/video': True, - 'video': {'height': 1440, 'encoding': 'h.264'}}, + 'video': {'height': 1440, 'codec': 'h.264'}}, '266': {'container': 'mp4', 'dash/video': True, - 'video': {'height': 2160, 'encoding': 'h.264'}}, + 'video': {'height': 2160, 'codec': 'h.264'}}, '271': {'container': 'webm', 'dash/video': True, - 'video': {'height': 1440, 'encoding': 'vp9'}}, + 'video': {'height': 1440, 'codec': 'vp9'}}, '272': {'container': 'webm', # was VP9 2160p30 'dash/video': True, 'fps': 60, - 'video': {'height': 4320, 'encoding': 'vp9'}}, + 'video': {'height': 4320, 'codec': 'vp9'}}, '278': {'container': 'webm', 'dash/video': True, - 'video': {'height': 144, 'encoding': 'vp9'}}, + 'video': {'height': 144, 'codec': 'vp9'}}, '298': {'container': 'mp4', 'dash/video': True, 'fps': 60, - 'video': {'height': 720, 'encoding': 'h.264'}}, + 'video': {'height': 720, 'codec': 'h.264'}}, '299': {'container': 'mp4', 'dash/video': True, 'fps': 60, - 'video': {'height': 1080, 'encoding': 'h.264'}}, + 'video': {'height': 1080, 'codec': 'h.264'}}, '302': {'container': 'webm', 'dash/video': True, 'fps': 60, - 'video': {'height': 720, 'encoding': 'vp9'}}, + 'video': {'height': 720, 'codec': 'vp9'}}, '303': {'container': 'webm', 'dash/video': True, 'fps': 60, - 'video': {'height': 1080, 'encoding': 'vp9'}}, + 'video': {'height': 1080, 'codec': 'vp9'}}, '308': {'container': 'webm', 'dash/video': True, 'fps': 60, - 'video': {'height': 1440, 'encoding': 'vp9'}}, + 'video': {'height': 1440, 'codec': 'vp9'}}, '313': {'container': 'webm', 'dash/video': True, - 'video': {'height': 2160, 'encoding': 'vp9'}}, + 'video': {'height': 2160, 'codec': 'vp9'}}, '315': {'container': 'webm', 'dash/video': True, 'fps': 60, - 'video': {'height': 2160, 'encoding': 'vp9'}}, + 'video': {'height': 2160, 'codec': 'vp9'}}, '330': {'container': 'webm', 'dash/video': True, 'fps': 60, 'hdr': True, - 'video': {'height': 144, 'encoding': 'vp9.2'}}, + 'video': {'height': 144, 'codec': 'vp9.2'}}, '331': {'container': 'webm', 'dash/video': True, 'fps': 60, 'hdr': True, - 'video': {'height': 240, 'encoding': 'vp9.2'}}, + 'video': {'height': 240, 'codec': 'vp9.2'}}, '332': {'container': 'webm', 'dash/video': True, 'fps': 60, 'hdr': True, - 'video': {'height': 360, 'encoding': 'vp9.2'}}, + 'video': {'height': 360, 'codec': 'vp9.2'}}, '333': {'container': 'webm', 'dash/video': True, 'fps': 60, 'hdr': True, - 'video': {'height': 480, 'encoding': 'vp9.2'}}, + 'video': {'height': 480, 'codec': 'vp9.2'}}, '334': {'container': 'webm', 'dash/video': True, 'fps': 60, 'hdr': True, - 'video': {'height': 720, 'encoding': 'vp9.2'}}, + 'video': {'height': 720, 'codec': 'vp9.2'}}, '335': {'container': 'webm', 'dash/video': True, 'fps': 60, 'hdr': True, - 'video': {'height': 1080, 'encoding': 'vp9.2'}}, + 'video': {'height': 1080, 'codec': 'vp9.2'}}, '336': {'container': 'webm', 'dash/video': True, 'fps': 60, 'hdr': True, - 'video': {'height': 1440, 'encoding': 'vp9.2'}}, + 'video': {'height': 1440, 'codec': 'vp9.2'}}, '337': {'container': 'webm', 'dash/video': True, 'fps': 60, 'hdr': True, - 'video': {'height': 2160, 'encoding': 'vp9.2'}}, + 'video': {'height': 2160, 'codec': 'vp9.2'}}, '394': {'container': 'mp4', 'dash/video': True, 'fps': 30, - 'video': {'height': 144, 'encoding': 'av1'}}, + 'video': {'height': 144, 'codec': 'av1'}}, '395': {'container': 'mp4', 'dash/video': True, 'fps': 30, - 'video': {'height': 240, 'encoding': 'av1'}}, + 'video': {'height': 240, 'codec': 'av1'}}, '396': {'container': 'mp4', 'dash/video': True, 'fps': 30, - 'video': {'height': 360, 'encoding': 'av1'}}, + 'video': {'height': 360, 'codec': 'av1'}}, '397': {'container': 'mp4', 'dash/video': True, 'fps': 30, - 'video': {'height': 480, 'encoding': 'av1'}}, + 'video': {'height': 480, 'codec': 'av1'}}, '398': {'container': 'mp4', 'dash/video': True, 'fps': 30, - 'video': {'height': 720, 'encoding': 'av1'}}, + 'video': {'height': 720, 'codec': 'av1'}}, '399': {'container': 'mp4', 'dash/video': True, 'fps': 30, - 'video': {'height': 1080, 'encoding': 'av1'}}, + 'video': {'height': 1080, 'codec': 'av1'}}, '400': {'container': 'mp4', 'dash/video': True, 'fps': 30, - 'video': {'height': 1440, 'encoding': 'av1'}}, + 'video': {'height': 1440, 'codec': 'av1'}}, '401': {'container': 'mp4', 'dash/video': True, 'fps': 30, - 'video': {'height': 2160, 'encoding': 'av1'}}, + 'video': {'height': 2160, 'codec': 'av1'}}, '402': {'container': 'mp4', 'dash/video': True, 'fps': 30, - 'video': {'height': 4320, 'encoding': 'av1'}}, + 'video': {'height': 4320, 'codec': 'av1'}}, '571': {'container': 'mp4', 'dash/video': True, 'fps': 30, - 'video': {'height': 4320, 'encoding': 'av1'}}, + 'video': {'height': 4320, 'codec': 'av1'}}, '694': {'container': 'mp4', 'dash/video': True, 'fps': 60, 'hdr': True, - 'video': {'height': 144, 'encoding': 'av1'}}, + 'video': {'height': 144, 'codec': 'av1'}}, '695': {'container': 'mp4', 'dash/video': True, 'fps': 60, 'hdr': True, - 'video': {'height': 240, 'encoding': 'av1'}}, + 'video': {'height': 240, 'codec': 'av1'}}, '696': {'container': 'mp4', 'dash/video': True, 'fps': 60, 'hdr': True, - 'video': {'height': 360, 'encoding': 'av1'}}, + 'video': {'height': 360, 'codec': 'av1'}}, '697': {'container': 'mp4', 'dash/video': True, 'fps': 60, 'hdr': True, - 'video': {'height': 480, 'encoding': 'av1'}}, + 'video': {'height': 480, 'codec': 'av1'}}, '698': {'container': 'mp4', 'dash/video': True, 'fps': 60, 'hdr': True, - 'video': {'height': 720, 'encoding': 'av1'}}, + 'video': {'height': 720, 'codec': 'av1'}}, '699': {'container': 'mp4', 'dash/video': True, 'fps': 60, 'hdr': True, - 'video': {'height': 1080, 'encoding': 'av1'}}, + 'video': {'height': 1080, 'codec': 'av1'}}, '700': {'container': 'mp4', 'dash/video': True, 'fps': 60, 'hdr': True, - 'video': {'height': 1440, 'encoding': 'av1'}}, + 'video': {'height': 1440, 'codec': 'av1'}}, '701': {'container': 'mp4', 'dash/video': True, 'fps': 60, 'hdr': True, - 'video': {'height': 2160, 'encoding': 'av1'}}, + 'video': {'height': 2160, 'codec': 'av1'}}, '702': {'container': 'mp4', 'dash/video': True, 'fps': 60, 'hdr': True, - 'video': {'height': 4320, 'encoding': 'av1'}}, + 'video': {'height': 4320, 'codec': 'av1'}}, # === Dash (audio only) '139': {'container': 'mp4', - 'sort': [0, -48 * 0.9], 'title': 'he-aac@48', 'dash/audio': True, - 'audio': {'bitrate': 48, 'encoding': 'aac'}}, + 'audio': {'bitrate': 48, 'codec': 'aac'}}, '140': {'container': 'mp4', - 'sort': [0, -128 * 0.9], 'title': 'aac-lc@128', 'dash/audio': True, - 'audio': {'bitrate': 128, 'encoding': 'aac'}}, + 'audio': {'bitrate': 128, 'codec': 'aac'}}, '141': {'container': 'mp4', - 'sort': [0, -256 * 0.9], 'title': 'aac-lc@256', 'dash/audio': True, - 'audio': {'bitrate': 256, 'encoding': 'aac'}}, + 'audio': {'bitrate': 256, 'codec': 'aac'}}, '256': {'container': 'mp4', - 'sort': [0, -192 * 0.9], 'title': 'he-aac@192', 'dash/audio': True, - 'audio': {'bitrate': 192, 'encoding': 'aac'}}, + 'audio': {'bitrate': 192, 'codec': 'aac'}}, '258': {'container': 'mp4', - 'sort': [0, -384 * 0.9], 'title': 'aac-lc@384', 'dash/audio': True, - 'audio': {'bitrate': 384, 'encoding': 'aac'}}, + 'audio': {'bitrate': 384, 'codec': 'aac'}}, '325': {'container': 'mp4', - 'sort': [0, -384 * 1.3], 'title': 'dtse@384', 'dash/audio': True, - 'audio': {'bitrate': 384, 'encoding': 'dtse'}}, + 'audio': {'bitrate': 384, 'codec': 'dtse'}}, '327': {'container': 'mp4', - 'sort': [0, -256 * 0.9], 'title': 'aac-lc@256', 'dash/audio': True, - 'audio': {'bitrate': 256, 'encoding': 'aac'}}, + 'audio': {'bitrate': 256, 'codec': 'aac'}}, '328': {'container': 'mp4', - 'sort': [0, -384 * 1.2], 'title': 'ec-3@384', 'dash/audio': True, - 'audio': {'bitrate': 384, 'encoding': 'ec-3'}}, + 'audio': {'bitrate': 384, 'codec': 'ec-3'}}, '171': {'container': 'webm', - 'sort': [0, -128 * 0.75], 'title': 'vorbis@128', 'dash/audio': True, - 'audio': {'bitrate': 128, 'encoding': 'vorbis'}}, + 'audio': {'bitrate': 128, 'codec': 'vorbis'}}, '172': {'container': 'webm', - 'sort': [0, -192 * 0.75], 'title': 'vorbis@192', 'dash/audio': True, - 'audio': {'bitrate': 192, 'encoding': 'vorbis'}}, + 'audio': {'bitrate': 192, 'codec': 'vorbis'}}, '249': {'container': 'webm', - 'sort': [0, -50], 'title': 'opus@50', 'dash/audio': True, - 'audio': {'bitrate': 50, 'encoding': 'opus'}}, + 'audio': {'bitrate': 50, 'codec': 'opus'}}, '250': {'container': 'webm', - 'sort': [0, -70], 'title': 'opus@70', 'dash/audio': True, - 'audio': {'bitrate': 70, 'encoding': 'opus'}}, + 'audio': {'bitrate': 70, 'codec': 'opus'}}, '251': {'container': 'webm', - 'sort': [0, -160], 'title': 'opus@160', 'dash/audio': True, - 'audio': {'bitrate': 160, 'encoding': 'opus'}}, + 'audio': {'bitrate': 160, 'codec': 'opus'}}, '338': {'container': 'webm', - 'sort': [0, -480], 'title': 'opus@480', 'dash/audio': True, - 'audio': {'bitrate': 480, 'encoding': 'opus'}}, + 'audio': {'bitrate': 480, 'codec': 'opus'}}, '380': {'container': 'mp4', - 'sort': [0, -384 * 1.1], 'title': 'ac-3@384', 'dash/audio': True, - 'audio': {'bitrate': 384, 'encoding': 'ac-3'}}, + 'audio': {'bitrate': 384, 'codec': 'ac-3'}}, # === HLS '9994': {'container': 'hls', - 'sort': [-1080, -1], 'title': 'HLS', 'hls/audio': True, 'hls/video': True, - 'audio': {'bitrate': 0, 'encoding': 'aac'}, - 'video': {'height': 0, 'encoding': 'h.264'}}, + 'sort': 9994, + 'audio': {'bitrate': 0, 'codec': 'aac'}, + 'video': {'height': 0, 'codec': 'h.264'}}, # === Live HLS '9995': {'container': 'hls', - 'Live': True, - 'sort': [-1080, -1], + 'live': True, 'title': 'Live HLS', 'hls/audio': True, 'hls/video': True, - 'audio': {'bitrate': 0, 'encoding': 'aac'}, - 'video': {'height': 0, 'encoding': 'h.264'}}, + 'sort': 9995, + 'audio': {'bitrate': 0, 'codec': 'aac'}, + 'video': {'height': 0, 'codec': 'h.264'}}, # === Live HLS adaptive '9996': {'container': 'hls', - 'Live': True, - 'sort': [-1080, -1], + 'live': True, 'title': 'Adaptive Live HLS', 'hls/audio': True, 'hls/video': True, - 'audio': {'bitrate': 0, 'encoding': 'aac'}, - 'video': {'height': 0, 'encoding': 'h.264'}}, + 'adaptive': True, + 'sort': 9996, + 'audio': {'bitrate': 0, 'codec': 'aac'}, + 'video': {'height': 0, 'codec': 'h.264'}}, # === DASH adaptive audio only '9997': {'container': 'mpd', - 'sort': [1, 0], 'title': 'DASH Audio', 'dash/audio': True, - 'audio': {'bitrate': 0, 'encoding': ''}}, + 'adaptive': True, + 'sort': 9997, + 'audio': {'bitrate': 0, 'codec': ''}}, # === Live DASH adaptive '9998': {'container': 'mpd', - 'Live': True, - 'sort': [-1080, -1], + 'live': True, 'title': 'Live DASH', 'dash/audio': True, 'dash/video': True, - 'audio': {'bitrate': 0, 'encoding': ''}, - 'video': {'height': 0, 'encoding': ''}}, + 'adaptive': True, + 'sort': 9998, + 'audio': {'bitrate': 0, 'codec': ''}, + 'video': {'height': 0, 'codec': ''}}, # === DASH adaptive '9999': {'container': 'mpd', - 'sort': [-1080, -1], 'title': 'DASH', 'dash/audio': True, 'dash/video': True, - 'audio': {'bitrate': 0, 'encoding': ''}, - 'video': {'height': 0, 'encoding': ''}} + 'adaptive': True, + 'sort': 9999, + 'audio': {'bitrate': 0, 'codec': ''}, + 'video': {'height': 0, 'codec': ''}} } INTEGER_FPS_SCALE = { @@ -614,13 +568,17 @@ class VideoInfo(YouTubeRequestClient): 'vp9': 0.75, 'vp8': 0.55, 'avc1': 0.5, + 'h.264': 0.5, + 'h.263': 0.4, # audio - order based on preference + 'mp3': 0.5, 'vorbis': 0.75, 'mp4a': 0.9, 'opus': 1, 'ac-3': 1.1, 'ec-3': 1.2, 'dts': 1.3, + 'dtse': 1.3, } def __init__(self, context, access_token='', **kwargs): @@ -745,6 +703,55 @@ def _generate_cpn(): '0123456789-_') return ''.join(random.choice(cpn_alphabet) for _ in range(16)) + def _get_stream_format(self, itag, info=None, max_height=None, **kwargs): + yt_format = self.FORMAT.get(itag) + if not yt_format: + return None + + yt_format = yt_format.copy() + manual_sort = yt_format.get('sort', 0) + + if info: + video_info = info.get('video') or {} + yt_format['title'] = video_info.get('label', '') + yt_format['video']['codec'] = video_info.get('codec', '') + yt_format['video']['height'] = video_info.get('height', 0) + + audio_info = info.get('audio') or {} + yt_format['audio']['codec'] = audio_info.get('codec', '') + yt_format['audio']['bitrate'] = audio_info.get('bitrate', 0) // 1000 + + video_info = yt_format.get('video') + if video_info: + video_height = video_info.get('height', 0) + if max_height and video_height > max_height: + return None + video_sort = ( + video_height + * self.QUALITY_FACTOR.get(video_info.get('codec'), 1) + ) + else: + video_sort = -1 + + audio_info = yt_format.get('audio') + if audio_info: + audio_sort = ( + audio_info.get('bitrate', 0) + * self.QUALITY_FACTOR.get(audio_info.get('codec'), 1) + ) + else: + audio_sort = 0 + + yt_format['sort'] = [ + manual_sort, + video_sort, + audio_sort, + ] + if kwargs: + kwargs.update(yt_format) + return kwargs + return yt_format + def load_stream_infos(self, video_id): self.video_id = video_id return self._get_video_info() @@ -887,7 +894,7 @@ def _normalize_url(url): url = urljoin('https://www.youtube.com', url) return url - def _load_hls_manifest(self, url, live_type=None, meta_info=None, + def _load_hls_manifest(self, url, is_live=False, meta_info=None, headers=None, playback_stats=None): if not url: return [] @@ -927,23 +934,35 @@ def _load_hls_manifest(self, url, live_type=None, meta_info=None, if playback_stats is None: playback_stats = {} - yt_format = None - if not live_type: - yt_format = self.FORMAT['9994'] - elif live_type == 'hls': - yt_format = self.FORMAT['9995'] - elif live_type == 'isa_hls': - yt_format = self.FORMAT['9996'] - - if yt_format: - stream = {'url': url, - 'meta': meta_info, - 'headers': curl_headers, - 'playback_stats': playback_stats} - stream.update(yt_format) - stream_list = [stream] + if is_live: + stream_list = [ + self._get_stream_format( + itag=yt_format, + title='', + url=url, + meta=meta_info, + headers=curl_headers, + playback_stats=playback_stats, + ) for yt_format in ('9995', '9996') + ] + else: + stream_list = [ + self._get_stream_format( + itag='9994', + title='', + url=url, + meta=meta_info, + headers=curl_headers, + playback_stats=playback_stats, + ) + ] + + settings = self._context.get_settings() + if settings.use_mpd_videos(): + qualities = settings.mpd_video_qualities() + selected_height = qualities[0]['nom_height'] else: - stream_list = [] + selected_height = settings.get_video_quality() # The playlist might include a #EXT-X-MEDIA entry, but it's usually for # a small default stream with itag 133 (240p) and can be ignored. @@ -953,25 +972,31 @@ def _load_hls_manifest(self, url, live_type=None, meta_info=None, r'(?Phttp\S+/itag/(?P\d+)\S+)' ) for match in re_playlist_data.finditer(result): - playlist_url = match.group('url') itag = match.group('itag') - - yt_format = self.FORMAT.get(itag) - if not yt_format: - self._context.log_debug('Unknown itag: {itag}\n{stream}' - .format(itag=itag, stream=match[0])) + yt_format = self._get_stream_format( + itag=itag, + max_height=selected_height, + title='', + url=match.group('url'), + meta=meta_info, + headers=curl_headers, + playback_stats=playback_stats, + ) + if yt_format: + if is_live: + yt_format['live'] = True + yt_format['title'] = 'Live ' + yt_format['title'] + stream_list.append(yt_format) continue + self._context.log_debug('Unknown itag: {itag}\n{stream}'.format( + itag=itag, stream=match[0] + )) - stream = {'url': playlist_url, - 'meta': meta_info, - 'headers': curl_headers, - 'playback_stats': playback_stats} - stream.update(yt_format) - stream_list.append(stream) return stream_list def _create_stream_list(self, streams, + is_live=False, meta_info=None, headers=None, playback_stats=None): @@ -993,6 +1018,13 @@ def _create_stream_list(self, if playback_stats is None: playback_stats = {} + settings = self._context.get_settings() + if settings.use_mpd_videos(): + qualities = settings.mpd_video_qualities() + selected_height = qualities[0]['nom_height'] + else: + selected_height = settings.get_video_quality() + stream_list = [] for stream_map in streams: url = stream_map.get('url') @@ -1009,7 +1041,15 @@ def _create_stream_list(self, itag = str(stream_map['itag']) stream_map['itag'] = itag - yt_format = self.FORMAT.get(itag) + yt_format = self._get_stream_format( + itag=itag, + max_height=selected_height, + title='', + url=url, + meta=meta_info, + headers=curl_headers, + playback_stats=playback_stats, + ) if not yt_format: self._context.log_debug('Unknown itag: {itag}\n{stream}' .format(itag=itag, stream=stream_map)) @@ -1019,25 +1059,23 @@ def _create_stream_list(self, and not yt_format.get('dash/audio'))): continue - stream = {'url': url, - 'meta': meta_info, - 'headers': curl_headers, - 'playback_stats': playback_stats} - stream.update(yt_format) + if is_live: + yt_format['live'] = True + yt_format['title'] = 'Live ' + yt_format['title'] if 'audioTrack' in stream_map: audio_track = stream_map['audioTrack'] display_name = audio_track['displayName'] - stream['title'] = '{0} {1}'.format( - stream['title'], display_name - ) - stream['sort'] = stream['sort'] + [ - not audio_track['id'].startswith(self._language_base), - 'original' not in display_name, + yt_format['title'] = '{0} {1}'.format( + yt_format['title'], display_name + ).strip() + yt_format['sort'].extend(( + audio_track['id'].startswith(self._language_base), + 'original' in display_name, display_name - ] + )) - stream_list.append(stream) + stream_list.append(yt_format) return stream_list def _process_signature_cipher(self, stream_map): @@ -1309,11 +1347,10 @@ def _get_video_info(self): streaming_data = result.get('streamingData', {}) is_live = video_details.get('isLiveContent', False) if is_live: - live_type = _settings.live_stream_type() + is_live = video_details.get('isLive', False) live_dvr = video_details.get('isLiveDvrEnabled', False) - thumb_suffix = '_live' + thumb_suffix = '_live' if is_live else '' else: - live_type = None live_dvr = False thumb_suffix = '' @@ -1414,9 +1451,7 @@ def _get_video_info(self): self._player_js = self._get_player_js() self._cipher = Cipher(self._context, javascript=self._player_js) - manifest_url = None - - if live_type == 'isa_mpd' and 'dashManifestUrl' in streaming_data: + if 'dashManifestUrl' in streaming_data: manifest_url = streaming_data['dashManifestUrl'] if '?' in manifest_url: manifest_url += '&mpd_version=5' @@ -1425,27 +1460,24 @@ def _get_video_info(self): else: manifest_url += '/mpd_version/5' - video_stream = { - 'url': manifest_url, - 'meta': meta_info, - 'headers': curl_headers, - 'license_info': license_info, - 'playback_stats': playback_stats - } - details = self.FORMAT.get('9998') - video_stream.update(details) - stream_list.append(video_stream) - elif 'hlsManifestUrl' in streaming_data: + stream_list.append(self._get_stream_format( + itag='9998', + title='', + url=manifest_url, + meta=meta_info, + headers=curl_headers, + license_info=license_info, + playback_stats=playback_stats, + )) + if 'hlsManifestUrl' in streaming_data: stream_list.extend(self._load_hls_manifest( streaming_data['hlsManifestUrl'], - live_type, meta_info, client['headers'], playback_stats + is_live, meta_info, client['headers'], playback_stats )) - else: - live_type = None subtitles = Subtitles(self._context, video_id) query_subtitles = client.get('_query_subtitles') - if (not live_type or live_dvr) and ( + if (not is_live or live_dvr) and ( query_subtitles is True or (query_subtitles and subtitles.sub_selection == subtitles.LANG_ALL)): @@ -1490,7 +1522,7 @@ def _get_video_info(self): subs_data = None # extract adaptive streams and create MPEG-DASH manifest - if not live_type and not manifest_url and adaptive_fmts: + if adaptive_fmts: video_data, audio_data = self._process_stream_data( adaptive_fmts, default_lang['default'] @@ -1501,57 +1533,44 @@ def _get_video_info(self): video_data, audio_data, subs_data, license_info.get('url') ) - video_stream = { - 'url': manifest_url, - 'meta': meta_info, - 'headers': curl_headers, - 'license_info': license_info, - 'playback_stats': playback_stats - } if main_stream: - details = self.FORMAT.get('9999').copy() - - video_info = main_stream['video'] - details['title'] = [video_info['label']] - details['video']['encoding'] = video_info['codec'] - details['video']['height'] = video_info['height'] - - audio_info = main_stream['audio'] - if audio_info: - details['audio']['encoding'] = audio_info['codec'] - details['audio']['bitrate'] = audio_info['bitrate'] // 1000 - if audio_info['langCode'] not in {'', 'und'}: - details['title'].extend((' ', audio_info['langName'])) - if default_lang['default'] != 'und': - details['title'].extend(( - ' [', - default_lang['default'], - ']' - )) - elif default_lang['is_asr']: - details['title'].append(' [ASR]') - if main_stream['multi_lang']: - details['title'].extend(( - ' [', - self._context.localize('stream.multi_language'), - ']' - )) - if main_stream['multi_audio']: - details['title'].extend(( - ' [', - self._context.localize('stream.multi_audio'), - ']' - )) - - details['title'] = ''.join(details['title']) - - video_stream.update(details) - stream_list.append(video_stream) + yt_format = self._get_stream_format( + itag='9999', + info=main_stream, + title='', + url=manifest_url, + meta=meta_info, + headers=curl_headers, + license_info=license_info, + playback_stats=playback_stats, + ) + + title = [yt_format['title']] + + audio_info = main_stream.get('audio') or {} + if audio_info.get('langCode', '') not in {'', 'und'}: + title.extend((' ', audio_info.get('langName', ''))) + + if default_lang['default'] != 'und': + title.extend((' [', default_lang['default'], ']')) + elif default_lang['is_asr']: + title.append(' [ASR]') + + for _prop in ('multi_lang', 'multi_audio'): + if not main_stream.get(_prop): + continue + _prop = 'stream.' + _prop + title.extend((' [', self._context.localize(_prop), ']')) + + if len(title) > 1: + yt_format['title'] = ''.join(yt_format['title']) + + stream_list.append(yt_format) # extract non-adaptive streams if all_fmts: stream_list.extend(self._create_stream_list( - all_fmts, meta_info, client['headers'], playback_stats + all_fmts, is_live, meta_info, client['headers'], playback_stats )) if not stream_list: @@ -1706,9 +1725,9 @@ def _process_stream_data(self, stream_data, default_lang_code='und'): for quality in qualities: if compare_width > quality['width']: if bounded_quality: - if compare_height >= bounded_quality['height']: + if compare_height >= bounded_quality['min_height']: quality = bounded_quality - elif compare_height < quality['height']: + elif compare_height < quality['min_height']: quality = qualities[-1] if fps > 30 and disable_hfr_max: bounded_quality = None @@ -1731,9 +1750,11 @@ def _process_stream_data(self, stream_data, default_lang_code='und'): hdr='_hdr' if hdr else '' ) channels = language = role = role_type = sample_rate = None - label = quality['label'].format(fps if fps > 30 else '', - ' HDR' if hdr else '', - compare_height) + label = quality['label'].format( + quality['nom_height'] or compare_height, + fps if fps > 30 else '', + ' HDR' if hdr else '', + ) quality_group = '{0}_{1}_{2}'.format(container, codec, label) if mime_group not in data: @@ -1944,7 +1965,7 @@ def _filter_group(previous_group, previous_stream, item): label = '{0} {1}'.format( stream['langName'], stream['label'] - ) + ).strip() if stream == main_stream[media_type]: default = True role = 'main' diff --git a/resources/lib/youtube_plugin/youtube/helper/yt_play.py b/resources/lib/youtube_plugin/youtube/helper/yt_play.py index bb85e75f9..48a5a5e09 100644 --- a/resources/lib/youtube_plugin/youtube/helper/yt_play.py +++ b/resources/lib/youtube_plugin/youtube/helper/yt_play.py @@ -17,7 +17,14 @@ from ..helper import utils, v3 from ..youtube_exceptions import YouTubeException from ...kodion.compatibility import urlencode, urlunsplit -from ...kodion.constants import PLAYER_DATA, SWITCH_PLAYER_FLAG, paths +from ...kodion.constants import ( + PLAY_FORCE_AUDIO, + PLAY_PROMPT_QUALITY, + PLAYBACK_INIT, + PLAYER_DATA, + SWITCH_PLAYER_FLAG, + paths, +) from ...kodion.items import VideoItem from ...kodion.network import get_connect_address from ...kodion.utils import select_stream @@ -42,19 +49,19 @@ def play_video(provider, context): if ((is_external and settings.alternative_player_web_urls()) or settings.default_player_web_urls()): video_stream = { - 'url': 'https://www.youtube.com/watch?v={0}'.format(video_id), + 'url': 'https://youtu.be/{0}'.format(video_id), } else: ask_for_quality = None - if not screensaver and ui.get_property('ask_for_quality') == video_id: + if not screensaver and ui.get_property(PLAY_PROMPT_QUALITY) == video_id: ask_for_quality = True - ui.clear_property('ask_for_quality') + ui.clear_property(PLAY_PROMPT_QUALITY) audio_only = None - if ui.get_property('audio_only') == video_id: + if ui.get_property(PLAY_FORCE_AUDIO) == video_id: ask_for_quality = False audio_only = True - ui.clear_property('audio_only') + ui.clear_property(PLAY_FORCE_AUDIO) try: video_streams = client.get_video_streams(context, video_id) @@ -110,7 +117,7 @@ def play_video(provider, context): video_stream['url'] = url video_item = VideoItem(video_details.get('title', ''), video_stream['url']) - use_history = not (screensaver or incognito or video_stream.get('Live')) + use_history = not (screensaver or incognito or video_stream.get('live')) use_remote_history = use_history and settings.use_remote_history() use_play_data = use_history and settings.use_local_history() @@ -148,7 +155,7 @@ def play_video(provider, context): } ui.set_property(PLAYER_DATA, json.dumps(playback_data, ensure_ascii=False)) - context.send_notification('PlaybackInit', playback_data) + context.send_notification(PLAYBACK_INIT, playback_data) return video_item @@ -159,6 +166,7 @@ def play_playlist(provider, context): player = context.get_video_player() player.stop() + action = params.get('action') playlist_ids = params.get('playlist_ids') if not playlist_ids: playlist_ids = [params.get('playlist_id')] @@ -223,36 +231,35 @@ def play_playlist(provider, context): # The implementation of XBMC/KODI is quite weak :( random.shuffle(videos) + if action == 'list': + return videos + # clear the playlist playlist = context.get_video_playlist() playlist.clear() - - # select unshuffle - if order == 'shuffle': - playlist.unshuffle() + playlist.unshuffle() # check if we have a video as starting point for the playlist - video_id = params.get('video_id', '') + video_id = params.get('video_id') + playlist_position = None if video_id else 0 # add videos to playlist - playlist_position = 0 for idx, video in enumerate(videos): playlist.add(video) - if (video_id and not playlist_position - and video_id in video.get_uri()): + if playlist_position is None and video.video_id == video_id: playlist_position = idx - # we use the shuffle implementation of the playlist - """ - if order == 'shuffle': - playlist.shuffle() - """ + options = { + provider.RESULT_CACHE_TO_DISC: False, + provider.RESULT_FORCE_RESOLVE: True, + provider.RESULT_UPDATE_LISTING: False, + } - if not params.get('play'): - return videos - if context.get_handle() == -1: + if action == 'queue': + return videos, options + if context.get_handle() == -1 or action == 'play': player.play(playlist_index=playlist_position) return False - return videos[playlist_position] + return videos[playlist_position], options def play_channel_live(provider, context): diff --git a/resources/lib/youtube_plugin/youtube/helper/yt_playlist.py b/resources/lib/youtube_plugin/youtube/helper/yt_playlist.py index 26cdd2a5f..8c14c4472 100644 --- a/resources/lib/youtube_plugin/youtube/helper/yt_playlist.py +++ b/resources/lib/youtube_plugin/youtube/helper/yt_playlist.py @@ -12,6 +12,7 @@ from .utils import get_thumbnail from ...kodion import KodionException +from ...kodion.constants import PLAYLIST_ID, PLAYLISTITEM_ID from ...kodion.utils import find_video_id @@ -65,8 +66,8 @@ def _process_add_video(provider, context, keymap_action=False): def _process_remove_video(provider, context): - listitem_playlist_id = context.get_listitem_detail('playlist_id') - listitem_playlist_item_id = context.get_listitem_detail('playlist_item_id') + listitem_playlist_id = context.get_listitem_property(PLAYLIST_ID) + listitem_playlist_item_id = context.get_listitem_property(PLAYLISTITEM_ID) listitem_title = context.get_listitem_info('Title') keymap_action = False @@ -98,7 +99,7 @@ def _process_remove_video(provider, context): else: raise KodionException('Playlist/Remove: missing video_name') - if playlist_id.strip().lower() not in ('wl', 'hl'): + if playlist_id.strip().lower() not in {'wl', 'hl'}: if context.get_ui().on_remove_content(video_name): success = provider.get_client(context).remove_video_from_playlist( playlist_id=playlist_id, @@ -181,14 +182,15 @@ def _process_select_playlist(provider, context): thumb_size = context.get_settings().get_thumbnail_size() default_thumb = context.create_resource_path('media', 'playlist.png') - while True: + while 1: current_page += 1 json_data = function_cache.run(client.get_playlists_of_channel, function_cache.ONE_MINUTE // 3, _refresh=params.get('refresh'), channel_id='mine', page_token=page_token) - + if not json_data: + break playlists = json_data.get('items', []) page_token = json_data.get('nextPageToken', '') @@ -252,7 +254,6 @@ def _process_select_playlist(provider, context): new_params = dict(context.get_params(), playlist_id=result) new_context = context.clone(new_params=new_params) _process_add_video(provider, new_context, keymap_action) - break break diff --git a/resources/lib/youtube_plugin/youtube/helper/yt_specials.py b/resources/lib/youtube_plugin/youtube/helper/yt_specials.py index a6cb0e5bf..4e7301f97 100644 --- a/resources/lib/youtube_plugin/youtube/helper/yt_specials.py +++ b/resources/lib/youtube_plugin/youtube/helper/yt_specials.py @@ -120,7 +120,8 @@ def _process_browse_channels(provider, context, client): else: function_cache = context.get_function_cache() json_data = function_cache.run(client.get_guide_categories, - function_cache.ONE_MONTH) + function_cache.ONE_MONTH, + _refresh=context.get_param('refresh')) if not json_data: return False @@ -301,11 +302,8 @@ def _process_new_uploaded_videos_tv(provider, context, client, filtered=False): params = context.get_params() refresh = params.get('refresh') - json_data = function_cache.run( - client.get_my_subscriptions, - function_cache.ONE_MINUTE * 30, - _refresh=refresh, - page_token=params.get('page_token', ''), + json_data = client.get_my_subscriptions( + page_token=params.get('page', 1), logged_in=provider.is_logged_in(), do_filter=filtered, refresh=refresh, diff --git a/resources/lib/youtube_plugin/youtube/helper/yt_subscriptions.py b/resources/lib/youtube_plugin/youtube/helper/yt_subscriptions.py index dccfc0ebb..717515c5e 100644 --- a/resources/lib/youtube_plugin/youtube/helper/yt_subscriptions.py +++ b/resources/lib/youtube_plugin/youtube/helper/yt_subscriptions.py @@ -12,6 +12,7 @@ from ..helper import v3 from ...kodion import KodionException +from ...kodion.constants import CHANNEL_ID, SUBSCRIPTION_ID from ...kodion.items import UriItem @@ -25,7 +26,7 @@ def _process_list(provider, context, client): def _process_add(_provider, context, client): - listitem_subscription_id = context.get_listitem_detail('subscription_id') + listitem_subscription_id = context.get_listitem_property(SUBSCRIPTION_ID) subscription_id = context.get_param('subscription_id', '') if (not subscription_id and listitem_subscription_id @@ -48,8 +49,8 @@ def _process_add(_provider, context, client): def _process_remove(_provider, context, client): - listitem_subscription_id = context.get_listitem_detail('channel_subscription_id') - listitem_channel_id = context.get_listitem_detail('channel_id') + listitem_subscription_id = context.get_listitem_property(SUBSCRIPTION_ID) + listitem_channel_id = context.get_listitem_property(CHANNEL_ID) subscription_id = context.get_param('subscription_id', '') if not subscription_id and listitem_subscription_id: diff --git a/resources/lib/youtube_plugin/youtube/provider.py b/resources/lib/youtube_plugin/youtube/provider.py index 3943dcd56..75e2c1bfb 100644 --- a/resources/lib/youtube_plugin/youtube/provider.py +++ b/resources/lib/youtube_plugin/youtube/provider.py @@ -34,6 +34,11 @@ from ..kodion import AbstractProvider, RegisterProviderPath from ..kodion.constants import ( ADDON_ID, + CHANNEL_ID, + DEVELOPER_CONFIGS, + PLAY_FORCE_AUDIO, + PLAY_PROMPT_QUALITY, + PLAY_PROMPT_SUBTITLES, content, paths, ) @@ -77,8 +82,8 @@ def is_logged_in(self): @staticmethod def get_dev_config(context, addon_id, dev_configs): - _dev_config = context.get_ui().get_property('configs') - context.get_ui().clear_property('configs') + _dev_config = context.get_ui().get_property(DEVELOPER_CONFIGS) + context.get_ui().clear_property(DEVELOPER_CONFIGS) dev_config = {} if _dev_config: @@ -413,12 +418,11 @@ def _on_channel_live(self, context, re_match): playlists = function_cache.run(resource_manager.get_related_playlists, function_cache.ONE_DAY, channel_id=channel_id) - upload_playlist = playlists.get('uploads', '') - if upload_playlist: + if playlists and 'uploads' in playlists: json_data = function_cache.run(client.get_playlist_items, function_cache.ONE_MINUTE * 5, _refresh=params.get('refresh'), - playlist_id=upload_playlist, + playlist_id=playlists['uploads'], page_token=page_token) if not json_data: return result @@ -440,7 +444,7 @@ 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_listitem_detail('channel_id') + listitem_channel_id = context.get_listitem_property(CHANNEL_ID) client = self.get_client(context) localize = context.localize @@ -558,12 +562,11 @@ def _on_channel(self, context, re_match): playlists = function_cache.run(resource_manager.get_related_playlists, function_cache.ONE_DAY, channel_id=channel_id) - upload_playlist = playlists.get('uploads', '') - if upload_playlist: + if playlists and 'uploads' in playlists: json_data = function_cache.run(client.get_playlist_items, function_cache.ONE_MINUTE * 5, _refresh=params.get('refresh'), - playlist_id=upload_playlist, + playlist_id=playlists['uploads'], page_token=page_token) if not json_data: return result @@ -651,7 +654,7 @@ def _on_my_location(self, context, re_match): def on_play(self, context, re_match): ui = context.get_ui() - redirect = False + force_play = False params = context.get_params() if ({'channel_id', 'live', 'playlist_id', 'playlist_ids', 'video_id'} @@ -670,48 +673,30 @@ def on_play(self, context, re_match): video_id = params.get('video_id') playlist_id = params.get('playlist_id') - if ui.get_property('prompt_for_subtitles') != video_id: - ui.clear_property('prompt_for_subtitles') + if ui.get_property(PLAY_PROMPT_SUBTITLES) != video_id: + ui.clear_property(PLAY_PROMPT_SUBTITLES) - if ui.get_property('audio_only') != video_id: - ui.clear_property('audio_only') + if ui.get_property(PLAY_FORCE_AUDIO) != video_id: + ui.clear_property(PLAY_FORCE_AUDIO) - if ui.get_property('ask_for_quality') != video_id: - ui.clear_property('ask_for_quality') + if ui.get_property(PLAY_PROMPT_QUALITY) != video_id: + ui.clear_property(PLAY_PROMPT_QUALITY) if video_id and not playlist_id: - if params.pop('prompt_for_subtitles', None): - # redirect to builtin after setting home window property, - # so playback url matches playable listitems - ui.set_property('prompt_for_subtitles', video_id) - context.log_debug('Redirecting playback with subtitles') - redirect = True + if params.pop(PLAY_PROMPT_SUBTITLES, None): + ui.set_property(PLAY_PROMPT_SUBTITLES, video_id) + force_play = True if params.pop('audio_only', None): - # redirect to builtin after setting home window property, - # so playback url matches playable listitems - ui.set_property('audio_only', video_id) - context.log_debug('Redirecting audio only playback') - redirect = True + ui.set_property(PLAY_FORCE_AUDIO, video_id) + force_play = True if params.pop('ask_for_quality', None): - # redirect to builtin after setting home window property, - # so playback url matches playable listitems - ui.set_property('ask_for_quality', video_id) - context.log_debug('Redirecting ask quality playback') - redirect = True - - builtin = None - if context.get_handle() == -1: - builtin = 'PlayMedia({0})' - context.log_debug('Redirecting playback, handle is -1') - elif redirect: - builtin = 'RunPlugin({0})' - - if builtin: - context.execute(builtin.format( - context.create_uri(('play',), params) - )) + ui.set_property(PLAY_PROMPT_QUALITY, video_id) + force_play = True + + if force_play: + context.execute('Action(Play)') return False return yt_play.play_video(self, context) @@ -736,7 +721,6 @@ def _on_playlist_x(self, context, re_match): @RegisterProviderPath('^/subscriptions/(?P[^/]+)/?$') def _on_subscriptions(self, context, re_match): method = re_match.group('method') - resource_manager = self.get_resource_manager(context) subscriptions = yt_subscriptions.process(method, self, context) if method == 'list': @@ -1165,7 +1149,7 @@ def on_root(self, context, re_match): local_history = settings.use_local_history() # Home / Recommendations - if settings.get_bool('youtube.folder.recommendations.show', True): + if logged_in and settings.get_bool('youtube.folder.recommendations.show', True): recommendations_item = DirectoryItem( localize('recommendations'), create_uri(('special', 'recommendations')), @@ -1230,7 +1214,7 @@ def on_root(self, context, re_match): my_channel_item = DirectoryItem( localize('my_channel'), create_uri(('channel', 'mine')), - image='{media}/channel.png', + image='{media}/user.png', ) result.append(my_channel_item) @@ -1385,7 +1369,7 @@ def on_root(self, context, re_match): switch_user_item = DirectoryItem( localize('user.switch'), create_uri(('users', 'switch')), - image='{media}/channel.png', + image='{media}/user.png', action=True, ) result.append(switch_user_item) diff --git a/resources/media/browse_channels.png b/resources/media/browse_channels.png index ea0653f50..6b0715175 100644 Binary files a/resources/media/browse_channels.png and b/resources/media/browse_channels.png differ diff --git a/resources/media/new_uploads.png b/resources/media/new_uploads.png index b016cdfc5..23243b367 100644 Binary files a/resources/media/new_uploads.png and b/resources/media/new_uploads.png differ diff --git a/resources/media/related_videos.png b/resources/media/related_videos.png index fdcee48ac..b016cdfc5 100644 Binary files a/resources/media/related_videos.png and b/resources/media/related_videos.png differ diff --git a/resources/media/channel.png b/resources/media/user.png similarity index 100% rename from resources/media/channel.png rename to resources/media/user.png diff --git a/resources/settings.xml b/resources/settings.xml index 5fb17f92a..8863e2215 100644 --- a/resources/settings.xml +++ b/resources/settings.xml @@ -216,7 +216,7 @@ 0 - + @@ -892,7 +892,7 @@ 0 - 85 + 90 1 1 @@ -1045,6 +1045,14 @@ RunScript($ID,maintenance/clear?target=bookmarks) + + 0 + + true + + RunScript($ID,maintenance/clear?target=feed_history) + + @@ -1095,7 +1103,16 @@ RunScript($ID,maintenance/delete?target=bookmarks) + + 0 + + true + + RunScript($ID,maintenance/delete?target=feed_history) + + + 0