kodi.plugin.video.youtube/resources/lib/youtube_plugin/kodion/abstract_provider.py
MoojMidge 36f1cc6048 Misc optimisations
- Avoid using dir()
- Remove custom url quote methods that are no longer faster than urllib.parse methods in newer Python versions
- Reduce polling intervals when checking if Kodi is busy
- Use custom requests.Session class to avoid creation of unused default https adapter and ssl context
2025-11-16 10:50:30 +11:00

537 lines
17 KiB
Python

# -*- coding: utf-8 -*-
"""
Copyright (C) 2014-2016 bromix (plugin.video.youtube)
Copyright (C) 2016-2025 plugin.video.youtube
SPDX-License-Identifier: GPL-2.0-only
See LICENSES/GPL-2.0-only for more information.
"""
from __future__ import absolute_import, division, unicode_literals
from re import (
UNICODE,
compile as re_compile,
)
from . import logging
from .constants import (
CHECK_SETTINGS,
CONTENT,
FOLDER_URI,
ITEMS_PER_PAGE,
PATHS,
REROUTE_PATH,
WINDOW_CACHE,
WINDOW_FALLBACK,
WINDOW_REPLACE,
WINDOW_RETURN,
)
from .debug import ExecTimeout
from .exceptions import KodionException
from .items import (
DirectoryItem,
NewSearchItem,
NextPageItem,
SearchHistoryItem,
UriItem,
)
from .utils.convert_format import to_unicode
class AbstractProvider(object):
log = logging.getLogger(__name__)
CACHE_TO_DISC = 'provider_cache_to_disc' # type: bool
FALLBACK = 'provider_fallback' # type: bool | str
FORCE_PLAY = 'provider_force_play' # type: bool
FORCE_REFRESH = 'provider_force_refresh' # type: bool
FORCE_RESOLVE = 'provider_force_resolve' # type: bool
FORCE_RETURN = 'provider_force_return' # type: bool
POST_RUN = 'provider_post_run' # type: bool
UPDATE_LISTING = 'provider_update_listing' # type: bool
CONTENT_TYPE = 'provider_content_type' # type: tuple[str, str, str]
# map for regular expression (path) to method (names)
_dict_path = {}
def __init__(self):
# register some default paths
self.register_path(r''.join((
'^',
'(?:', PATHS.HOME, ')?/?$'
)), self.on_root)
self.register_path(r''.join((
'^',
PATHS.ROUTE,
'(?P<path>/[^?]+?)(?:/*[?].+|/*)$'
)), self.on_reroute)
self.register_path(r''.join((
'^',
PATHS.GOTO_PAGE,
'(?P<page>/[0-9]+)?'
'(?P<path>/[^?]+?)(?:/*[?].+|/*)$'
)), self.on_goto_page)
self.register_path(r''.join((
'^',
PATHS.COMMAND,
'/(?P<command>[^?]+?)(?:/*[?].+|/*)$'
)), self.on_command)
self.register_path(r''.join((
'^',
PATHS.WATCH_LATER,
'/(?P<command>add|clear|list|play|remove)?/?$'
)), self.on_watch_later)
self.register_path(r''.join((
'^',
PATHS.BOOKMARKS,
'/(?P<command>add|add_custom|clear|edit|list|play|remove)?/?$'
)), self.on_bookmarks)
self.register_path(r''.join((
'^',
'(', PATHS.SEARCH, '|', PATHS.EXTERNAL_SEARCH, ')',
'/(?P<command>input|input_prompt|query|list|links|remove|clear|rename)?/?$'
)), self.on_search)
self.register_path(r''.join((
'^',
PATHS.HISTORY,
'/(?P<command>clear|list|mark_as|mark_unwatched|mark_watched|play|remove|reset_resume)?/?$'
)), self.on_playback_history)
self.register_path(r'(?P<path>.*\/)extrafanart\/([\?#].+)?$',
self.on_extra_fanart)
@classmethod
def register_path(cls, re_path, command=None):
"""
Registers a new method for the given regular expression
:param re_path: regular expression of the path
:param command: command or function to be registered
:return:
"""
def wrapper(command):
if callable(command):
func = command
else:
func = getattr(command, '__func__', None)
if not callable(func):
return None
cls._dict_path[re_compile(re_path, UNICODE)] = func
return command
if command:
return wrapper(command)
return wrapper
def run_wizard(self, context, last_run=None):
localize = context.localize
# ui local variable used for ui.get_view_manager() in unofficial version
ui = context.get_ui()
settings_state = {'state': 'defer'}
context.ipc_exec(CHECK_SETTINGS, timeout=5, payload=settings_state)
if last_run and last_run > 1:
self.pre_run_wizard_step(provider=self, context=context)
wizard_steps = self.get_wizard_steps()
step = 0
steps = len(wizard_steps)
try:
if wizard_steps and ui.on_yes_no_input(
' - '.join((localize('youtube'), localize('setup_wizard'))),
localize(('setup_wizard.prompt.x',
'setup_wizard.prompt.settings')),
):
for wizard_step in wizard_steps:
if callable(wizard_step):
step = wizard_step(provider=self,
context=context,
step=step,
steps=steps)
else:
step += 1
finally:
settings = context.get_settings(refresh=True)
settings.setup_wizard_enabled(False)
settings_state['state'] = 'process'
context.ipc_exec(CHECK_SETTINGS, timeout=5, payload=settings_state)
@staticmethod
def get_wizard_steps():
# can be overridden by the derived class
return []
@staticmethod
def pre_run_wizard_step(provider, context):
# can be overridden by the derived class
pass
def navigate(self, context):
path = context.get_path()
for re_path, handler in self._dict_path.items():
re_match = re_path.search(path)
if not re_match:
continue
exec_limit = context.get_settings().exec_limit()
if exec_limit:
handler = ExecTimeout(
seconds=exec_limit,
# log_only=True,
# trace_opcodes=True,
# trace_threads=True,
log_locals=(-15, None),
callback=None,
)(handler)
options = {
self.CACHE_TO_DISC: True,
self.UPDATE_LISTING: False,
}
result = handler(provider=self, context=context, re_match=re_match)
if isinstance(result, tuple):
result, new_options = result
if new_options:
options.update(new_options)
if context.refresh_requested():
options[self.CACHE_TO_DISC] = True
options[self.UPDATE_LISTING] = True
return result, options
raise KodionException('Mapping for path "%s" not found' % path)
def on_extra_fanart_run(self, context, re_match):
"""
The implementation of the provider can override this behavior.
:param context:
:param re_match:
:return:
"""
return
@staticmethod
def on_extra_fanart(provider, context, re_match):
path = re_match.group('path')
new_context = context.clone(new_path=path)
return provider.on_extra_fanart_run(new_context, re_match)
@staticmethod
def on_playback_history(provider, context, re_match):
raise NotImplementedError()
@staticmethod
def on_root(provider, context, re_match):
raise NotImplementedError()
@staticmethod
def on_goto_page(provider, context, re_match):
ui = context.get_ui()
page = re_match.group('page')
if page:
page = int(page.lstrip('/'))
else:
result, page = ui.on_numeric_input(
title=context.localize('page.choose'),
default=1,
)
if not result:
return False
path = re_match.group('path')
params = context.get_params()
if 'page_token' in params:
page_token = NextPageItem.create_page_token(
page, params.get(ITEMS_PER_PAGE, 50)
)
else:
page_token = ''
for param in NextPageItem.JUMP_PAGE_PARAM_EXCLUSIONS:
if param in params:
del params[param]
params = dict(params, page=page, page_token=page_token)
if (not ui.busy_dialog_active()
and ui.get_container_info(FOLDER_URI)):
return provider.reroute(context=context, path=path, params=params)
return provider.navigate(context.clone(path, params))
@staticmethod
def on_reroute(provider, context, re_match):
return provider.reroute(
context=context,
path=re_match.group('path'),
params=context.get_params(),
)
def reroute(self, context, path=None, params=None, uri=None):
ui = context.get_ui()
current_path, current_params = context.parse_uri(
ui.get_container_info(FOLDER_URI, container_id=None)
)
if uri is None:
if path is None:
path = current_path
if params is None:
params = current_params
else:
uri = context.parse_uri(uri)
if params:
uri[1].update(params)
path, params = uri
if not path:
self.log.error_trace('No route path')
return False
elif path.startswith(PATHS.ROUTE):
path = path[len(PATHS.ROUTE):]
window_cache = params.pop(WINDOW_CACHE, True)
window_fallback = params.pop(WINDOW_FALLBACK, False)
window_replace = params.pop(WINDOW_REPLACE, False)
window_return = params.pop(WINDOW_RETURN, True)
if window_fallback:
if ui.get_container_info(FOLDER_URI):
self.log.debug('Rerouting - Fallback route not required')
return False, {self.FALLBACK: False}
refresh = context.refresh_requested(params=params)
if (refresh or (
params == current_params
and path.rstrip('/') == current_path.rstrip('/')
)):
if refresh and refresh < 0:
del params['refresh']
else:
params['refresh'] = context.refresh_requested(
force=True,
on=True,
params=params,
)
else:
params['refresh'] = 0
result = None
uri = context.create_uri(path, params)
if window_cache:
function_cache = context.get_function_cache()
with ui.on_busy():
result, options = function_cache.run(
self.navigate,
_refresh=True,
_scope=function_cache.SCOPE_NONE,
context=context.clone(path, params),
)
if not result:
self.log.debug(('No results', 'URI: %s'), uri)
return False
self.log.debug(('Success',
'URI: {uri}',
'Cache: {window_cache!r}',
'Fallback: {window_fallback!r}',
'Replace: {window_replace!r}',
'Return: {window_return!r}'),
uri=uri,
window_cache=window_cache,
window_fallback=window_fallback,
window_replace=window_replace,
window_return=window_return)
reroute_path = ui.get_property(REROUTE_PATH)
if reroute_path:
return True
if window_cache:
ui.set_property(REROUTE_PATH, path)
action = ''.join((
'ReplaceWindow' if window_replace else 'ActivateWindow',
'(Videos,',
uri,
',return)' if window_return else ')',
))
timeout = 30
while ui.busy_dialog_active():
timeout -= 1
if timeout < 0:
self.log.warning('Multiple busy dialogs active'
' - Rerouting workaround')
return UriItem('command://{0}'.format(action))
context.sleep(0.1)
else:
context.execute(
action,
# wait=True,
# wait_for=(REROUTE_PATH if window_cache else None),
# wait_for_set=False,
# block_ui=True,
)
return True
@staticmethod
def on_bookmarks(provider, context, re_match):
raise NotImplementedError()
@staticmethod
def on_watch_later(provider, context, re_match):
raise NotImplementedError()
def on_search_run(self, context, query):
raise NotImplementedError()
@staticmethod
def on_search(provider, context, re_match):
params = context.get_params()
localize = context.localize
ui = context.get_ui()
command = re_match.group('command')
search_history = context.get_search_history()
if not command or command == 'query':
query = to_unicode(params.get('q', ''))
if query:
result, options = provider.on_search_run(context, query=query)
if not options:
options = {provider.CACHE_TO_DISC: False}
if result:
fallback = options.setdefault(
provider.FALLBACK, context.get_uri()
)
ui.set_property(provider.FALLBACK, fallback)
return result, options
command = 'list'
context.set_path(PATHS.SEARCH, command)
if command == 'remove':
query = to_unicode(params.get('q', ''))
if not ui.on_yes_no_input(
localize('content.remove'),
localize('content.remove.check.x', query),
):
return False, None
search_history.del_item(query)
ui.show_notification(localize('removed.name.x', query),
time_ms=2500,
audible=False)
return True, {provider.FORCE_REFRESH: True}
if command == 'rename':
query = to_unicode(params.get('q', ''))
result, new_query = ui.on_keyboard_input(
localize('search.rename'), query
)
if not result:
return False, None
search_history.del_item(query)
search_history.add_item(new_query)
return True, {provider.FORCE_REFRESH: True}
if command == 'clear':
if not ui.on_yes_no_input(
localize('search.clear'),
localize(('content.clear.check.x', 'search.history'))
):
return False, None
search_history.clear()
ui.show_notification(localize('completed'),
time_ms=2500,
audible=False)
return True, {provider.FORCE_REFRESH: True}
if command == 'links':
return provider.on_specials_x(
provider,
context,
category='description_links',
)
if command.startswith('input'):
result, query = ui.on_keyboard_input(
localize('search.title')
)
if result and query:
result = []
options = {
provider.FALLBACK: context.create_uri(
(PATHS.SEARCH, 'query'),
dict(params, q=query, category_label=query),
window={
'replace': False,
'return': True,
},
),
provider.FORCE_RETURN: True,
provider.POST_RUN: True,
provider.CACHE_TO_DISC: True,
provider.UPDATE_LISTING: False,
}
else:
result = False
options = {
provider.FALLBACK: True,
}
return result, options
location = context.get_param('location', False)
result = []
options = {
provider.CACHE_TO_DISC: False,
provider.CONTENT_TYPE: {
'content_type': CONTENT.LIST_CONTENT,
'sub_type': None,
'category_label': localize('search'),
},
}
# 'New Search...'
new_search_item = NewSearchItem(
context, location=location
)
result.append(new_search_item)
for search in search_history.get_items():
# little fallback for old history entries
if isinstance(search, DirectoryItem):
search = search.get_name()
# we create a new instance of the SearchItem
search_history_item = SearchHistoryItem(
context, search, location=location
)
result.append(search_history_item)
return result, options
@staticmethod
def on_command(re_match, **_kwargs):
command = re_match.group('command')
return UriItem(''.join(('command://', command)))
def handle_exception(self, context, exception_to_handle):
return True
def tear_down(self):
pass