This commit is contained in:
2025-10-25 13:21:06 +02:00
parent eb57506d39
commit 033ffb21f5
8388 changed files with 484789 additions and 16 deletions

View File

@@ -0,0 +1,34 @@
# -*- 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
key_sets = {
'youtube-tv': {
'api_key': 'QUl6YVN5QzZmdlpTSkhBN1Z6NWo4akNpS1J0N3RVSU9xakUyTjNn',
'client_id': 'ODYxNTU2NzA4NDU0LWQ2ZGxtM2xoMDVpZGQ4bnBlazE4azZiZThiYTNvYzY4',
'client_secret': 'U2JvVmhvRzlzMHJOYWZpeENTR0dLWEFU',
},
'youtube-vr': {
'api_key': '',
'client_id': 'NjUyNDY5MzEyMTY5LTRsdnM5Ym5ocjlscG5zOXY0NTFqNW9pdmQ4MXZqdnUx',
'client_secret': 'M2ZUV3JCSkk1VW9qbTFUSzdfaUpDVzVa',
},
'provided': {
'0': {
'api_key': '',
'client_id': '',
'client_secret': '',
}
}
}
__all__ = ('kodion', 'youtube', 'key_sets',)

View File

@@ -0,0 +1,26 @@
# -*- 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 .abstract_provider import (
# Abstract provider for implementation by the user
AbstractProvider,
)
# import base exception of kodion directly into the kodion namespace
from .exceptions import KodionException
__all__ = (
'AbstractProvider',
'KodionException',
)
__version__ = '1.5.4'

View File

@@ -0,0 +1,538 @@
# -*- 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()
wizard_steps.extend(ui.get_view_manager().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(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

View File

@@ -0,0 +1,347 @@
# -*- coding: utf-8 -*-
"""
Copyright (C) 2023-2025 plugin.video.youtube
SPDX-License-Identifier: GPL-2.0-only
See LICENSES/GPL-2.0-only for more information.
"""
__all__ = (
'BaseHTTPRequestHandler',
'StringIO',
'TCPServer',
'ThreadingMixIn',
'available_cpu_count',
'byte_string_type',
'datetime_infolabel',
'default_quote',
'default_quote_plus',
'entity_escape',
'generate_hash',
'parse_qs',
'parse_qsl',
'pickle',
'quote',
'quote_plus',
'range_type',
'string_type',
'to_str',
'unescape',
'unquote',
'unquote_plus',
'urlencode',
'urljoin',
'urlsplit',
'urlunsplit',
'xbmc',
'xbmcaddon',
'xbmcgui',
'xbmcplugin',
'xbmcvfs',
)
# Kodi v19+ and Python v3.x
try:
import _pickle as pickle
from hashlib import md5
from html import unescape
from http.server import BaseHTTPRequestHandler
from io import StringIO
from socketserver import TCPServer, ThreadingMixIn
from urllib.parse import (
parse_qs,
parse_qsl,
quote,
quote_plus,
unquote,
unquote_plus,
urlencode,
urljoin,
urlsplit,
urlunsplit,
)
import xbmc
import xbmcaddon
import xbmcgui
import xbmcplugin
import xbmcvfs
xbmc.LOGNOTICE = xbmc.LOGINFO
xbmc.LOGSEVERE = xbmc.LOGFATAL
range_type = (range, list)
byte_string_type = bytes
string_type = str
to_str = str
def entity_escape(text,
entities=str.maketrans({
'&': '&amp;',
'"': '&quot;',
'<': '&lt;',
'>': '&gt;',
'\'': '&#x27;',
})):
return text.translate(entities)
def generate_hash(*args, **kwargs):
return md5(''.join(
map(str, args or kwargs.get('iter'))
).encode('utf-8')).hexdigest()
SAFE_CHARS = frozenset(
b'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
b'abcdefghijklmnopqrstuvwxyz'
b'0123456789'
b'_.-~'
b'/' # safe character by default
)
reserved = {
chr(ordinal): '%%%x' % ordinal
for ordinal in range(0, 128)
if ordinal not in SAFE_CHARS
}
reserved_plus = reserved.copy()
reserved_plus.update((
('/', '%2f'),
(' ', '+'),
))
reserved = str.maketrans(reserved)
reserved_plus = str.maketrans(reserved_plus)
non_ascii = str.maketrans({
chr(ordinal): '%%%x' % ordinal
for ordinal in range(128, 256)
})
def default_quote(string,
safe='',
encoding=None,
errors=None,
_encoding='utf-8',
_errors='strict',
_reserved=reserved,
_non_ascii=non_ascii,
_encode=str.encode,
_is_ascii=str.isascii,
_replace=str.replace,
_old='\\x',
_new='%',
_slice=slice(2, -1),
_str=str,
_translate=str.translate):
_string = _translate(string, _reserved)
if _is_ascii(_string):
return _string
_string = _str(_encode(_string, _encoding, _errors))[_slice]
if _string == string:
if _is_ascii(_string):
return _string
return _translate(_string, _non_ascii)
if _is_ascii(_string):
return _replace(_string, _old, _new)
return _translate(_replace(_string, _old, _new), _non_ascii)
def default_quote_plus(string,
safe='',
encoding=None,
errors=None,
_encoding='utf-8',
_errors='strict',
_reserved=reserved_plus,
_non_ascii=non_ascii,
_encode=str.encode,
_is_ascii=str.isascii,
_replace=str.replace,
_old='\\x',
_new='%',
_slice=slice(2, -1),
_str=str,
_translate=str.translate):
if (not safe and encoding is None and errors is None
and isinstance(string, str)):
_string = _translate(string, _reserved)
if _is_ascii(_string):
return _string
_string = _str(_encode(_string, _encoding, _errors))[_slice]
if _string == string:
if _is_ascii(_string):
return _string
return _translate(_string, _non_ascii)
if _is_ascii(_string):
return _replace(_string, _old, _new)
return _translate(_replace(_string, _old, _new), _non_ascii)
return quote_plus(string, safe, encoding, errors)
urlencode.__defaults__ = (False, '', None, None, default_quote_plus)
# Compatibility shims for Kodi v18 and Python v2.7
except ImportError:
import cPickle as pickle
from hashlib import md5
from BaseHTTPServer import BaseHTTPRequestHandler
from SocketServer import TCPServer, ThreadingMixIn
from StringIO import StringIO as _StringIO
from urllib import (
quote as _quote,
quote_plus as _quote_plus,
unquote as _unquote,
unquote_plus as _unquote_plus,
urlencode as _urlencode,
)
from urlparse import (
parse_qs,
parse_qsl,
urljoin,
urlsplit,
urlunsplit,
)
from xml.sax.saxutils import unescape
from kodi_six import (
xbmc,
xbmcaddon,
xbmcgui,
xbmcplugin,
xbmcvfs,
)
def quote(data, *args, **kwargs):
return _quote(to_str(data), *args, **kwargs)
default_quote = quote
def quote_plus(data, *args, **kwargs):
return _quote_plus(to_str(data), *args, **kwargs)
default_quote_plus = quote_plus
def unquote(data):
return _unquote(to_str(data))
def unquote_plus(data):
return _unquote_plus(to_str(data))
def urlencode(data, *args, **kwargs):
if isinstance(data, dict):
data = data.items()
kwargs = {
key: value
for key, value in kwargs.viewitems()
if key in {'query', 'doseq'}
}
return _urlencode({
to_str(key): (
[to_str(part) for part in value]
if isinstance(value, (list, tuple)) else
to_str(value)
)
for key, value in data
}, *args, **kwargs)
class StringIO(_StringIO):
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.close()
class File(xbmcvfs.File):
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.close()
xbmcvfs.File = File
xbmcvfs.translatePath = xbmc.translatePath
range_type = (xrange, list)
byte_string_type = (bytes, str)
string_type = basestring
def to_str(value, _format='{0!s}'.format):
if not isinstance(value, basestring):
value = _format(value)
if isinstance(value, unicode):
value = value.encode('utf-8')
return value
def entity_escape(text,
entities={
'&': '&amp;',
'"': '&quot;',
'<': '&lt;',
'>': '&gt;',
'\'': '&#x27;',
}):
for key, value in entities.viewitems():
text = text.replace(key, value)
return text
def generate_hash(*args, **kwargs):
return md5(''.join(
map(to_str, args or kwargs.get('iter'))
)).hexdigest()
def _loads(string, _loads=pickle.loads):
return _loads(to_str(string))
pickle.loads = _loads
# Kodi v20+
if hasattr(xbmcgui.ListItem, 'setDateTime'):
def datetime_infolabel(datetime_obj, *_args, **_kwargs):
return datetime_obj.replace(microsecond=0, tzinfo=None).isoformat()
# Compatibility shims for Kodi v18 and v19
else:
def datetime_infolabel(datetime_obj, str_format='%Y-%m-%d %H:%M:%S'):
return datetime_obj.strftime(str_format)
try:
from os import sched_getaffinity as _sched_get_affinity
except ImportError:
_sched_get_affinity = None
try:
from multiprocessing import cpu_count as _cpu_count
except ImportError:
_cpu_count = None
def available_cpu_count():
if _sched_get_affinity:
# Equivalent to os.process_cpu_count()
return len(_sched_get_affinity(0)) or 1
if _cpu_count:
try:
return _cpu_count() or 1
except NotImplementedError:
return 1
return 1

View File

@@ -0,0 +1,368 @@
# -*- coding: utf-8 -*-
"""
Copyright (C) 2014-2016 bromix (plugin.video.youtube)
Copyright (C) 2016-2025 plugin.video.youtube
SPDX-License-Identifier: GPL-2.0-only
See LICENSES/GPL-2.0-only for more information.
"""
from __future__ import absolute_import, division, unicode_literals
from . import (
const_content_types as CONTENT,
const_paths as PATHS,
const_settings as SETTINGS,
const_sort_methods as SORT,
)
from .const_lang_region import (
DEFAULT_LANGUAGES,
DEFAULT_REGIONS,
TRANSLATION_LANGUAGES,
)
# Addon paths
ADDON_ID = 'plugin.video.youtube'
ADDON_PATH = 'special://home/addons/' + ADDON_ID
DATA_PATH = 'special://profile/addon_data/' + ADDON_ID
MEDIA_PATH = ADDON_PATH + '/resources/media'
RESOURCE_PATH = ADDON_PATH + '/resources'
TEMP_PATH = 'special://temp/' + ADDON_ID
# Const values
BOOL_FROM_STR = {
'0': False,
'1': True,
'false': False,
'False': False,
'true': True,
'True': True,
'None': None,
'null': None,
'': None,
}
VALUE_TO_STR = {
False: 'false',
True: 'true',
None: '',
-1: '',
0: 'false',
1: 'true',
}
YOUTUBE_HOSTNAMES = frozenset((
'youtube.com',
'www.youtube.com',
'm.youtube.com',
'www.youtubekids.com',
'music.youtube.com',
))
# Flags
ABORT_FLAG = 'abort_requested'
BUSY_FLAG = 'busy'
SERVICE_RUNNING_FLAG = 'service_monitor_running'
WAIT_END_FLAG = 'builtin_completed'
TRAKT_PAUSE_FLAG = 'script.trakt.paused'
# Container Info
CURRENT_CONTAINER_INFO = 'Container.%s'
PLUGIN_CONTAINER_INFO = 'Container(%s).%s'
CURRENT_ITEM = 'CurrentItem'
FOLDER_NAME = 'FolderName'
FOLDER_URI = 'FolderPath'
HAS_FILES = 'HasFiles'
HAS_FOLDERS = 'HasFolders'
HAS_PARENT = 'HasParent'
SCROLLING = 'Scrolling'
UPDATING = 'IsUpdating'
# ListItem Info
CONTAINER_LISTITEM_INFO = 'Container(%s).ListItem(0).%s'
LISTITEM_INFO = 'ListItem.%s'
ARTIST = 'Artist'
LABEL = 'Label'
PLAY_COUNT = 'PlayCount'
RESUMABLE = 'IsResumable'
TITLE = 'Title'
URI = 'FileNameAndPath'
# ListItem Properties
CONTAINER_LISTITEM_PROP = 'Container(%s).ListItem(0).Property(%s)'
LISTITEM_PROP = 'ListItem.Property(%s)'
BOOKMARK_ID = 'bookmark_id'
CHANNEL_ID = 'channel_id'
PLAY_COUNT_PROP = 'video_play_count'
PLAYLIST_ID = 'playlist_id'
PLAYLIST_ITEM_ID = 'playlist_item_id'
SUBSCRIPTION_ID = 'subscription_id'
VIDEO_ID = 'video_id'
# Events
CHECK_SETTINGS = 'check_settings'
CONTEXT_MENU = 'cxm_action'
FILE_READ = 'file_read'
FILE_WRITE = 'file_write'
KEYMAP = 'key_action'
PLAYBACK_INIT = 'playback_init'
PLAYBACK_FAILED = 'playback_failed'
PLAYBACK_STARTED = 'playback_started'
PLAYBACK_STOPPED = 'playback_stopped'
REFRESH_CONTAINER = 'refresh_container'
RELOAD_ACCESS_MANAGER = 'reload_access_manager'
SERVICE_IPC = 'service_ipc'
SYNC_LISTITEM = 'sync_listitem'
# Sleep/wakeup states
PLUGIN_WAKEUP = 'plugin_wakeup'
PLUGIN_SLEEPING = 'plugin_sleeping'
SERVER_WAKEUP = 'server_wakeup'
# Play options
PLAY_CANCELLED = 'play_cancelled'
PLAY_FORCE_AUDIO = 'audio_only'
PLAY_FORCED = 'play_forced'
PLAY_PROMPT_QUALITY = 'ask_for_quality'
PLAY_PROMPT_SUBTITLES = 'prompt_for_subtitles'
PLAY_STRM = 'strm'
PLAY_TIMESHIFT = 'timeshift'
PLAY_USING = 'play_using'
FORCE_PLAY_PARAMS = frozenset((
PLAY_FORCE_AUDIO,
PLAY_TIMESHIFT,
PLAY_PROMPT_QUALITY,
PLAY_PROMPT_SUBTITLES,
PLAY_USING,
))
# Stored data
PROPERTY = 'Window(home).Property(%s-%%s)' % ADDON_ID
PROPERTY_AS_LABEL = '$INFO[Window(home).Property(%s-%%s)]' % ADDON_ID
CONTAINER_ID = 'container_id'
CONTAINER_FOCUS = 'container_focus'
CONTAINER_POSITION = 'container_position'
DEVELOPER_CONFIGS = 'configs'
LICENSE_TOKEN = 'license_token'
LICENSE_URL = 'license_url'
MARK_AS_LABEL = 'mark_as_label'
PLAYER_DATA = 'player_json'
PLAYER_VIDEO_ID = 'player_video_id'
PLAYLIST_PATH = 'playlist_path'
PLAYLIST_POSITION = 'playlist_position'
REROUTE_PATH = 'reroute_path'
# Routing parameters
WINDOW_CACHE = 'window_cache'
WINDOW_FALLBACK = 'window_fallback'
WINDOW_REPLACE = 'window_replace'
WINDOW_RETURN = 'window_return'
# Plugin url query parameters
ACTION = 'action'
ADDON_ID_PARAM = 'addon_id'
CHANNEL_IDS = 'channel_ids'
CLIP = 'clip'
END = 'end'
FANART_TYPE = 'fanart_type'
HIDE_CHANNELS = 'hide_channels'
HIDE_FOLDERS = 'hide_folders'
HIDE_LIVE = 'hide_live'
HIDE_MEMBERS = 'hide_members'
HIDE_NEXT_PAGE = 'hide_next_page'
HIDE_PLAYLISTS = 'hide_playlists'
HIDE_PROGRESS = 'hide_progress'
HIDE_SEARCH = 'hide_search'
HIDE_SHORTS = 'hide_shorts'
HIDE_VIDEOS = 'hide_videos'
INCOGNITO = 'incognito'
ITEM_FILTER = 'item_filter'
ITEMS_PER_PAGE = 'items_per_page'
LIVE = 'live'
ORDER = 'order'
PAGE = 'page'
PLAYLIST_IDS = 'playlist_ids'
SCREENSAVER = 'screensaver'
SEEK = 'seek'
SORT_DIR = 'sort_dir'
SORT_METHOD = 'sort_method'
START = 'start'
VIDEO_IDS = 'video_ids'
INHERITED_PARAMS = frozenset((
ADDON_ID_PARAM,
FANART_TYPE,
HIDE_CHANNELS,
HIDE_FOLDERS,
HIDE_LIVE,
HIDE_MEMBERS,
HIDE_NEXT_PAGE,
HIDE_PLAYLISTS,
HIDE_PROGRESS,
HIDE_SEARCH,
HIDE_SHORTS,
HIDE_VIDEOS,
INCOGNITO,
ITEM_FILTER,
ITEMS_PER_PAGE,
PLAY_FORCE_AUDIO,
PLAY_TIMESHIFT,
PLAY_PROMPT_QUALITY,
PLAY_PROMPT_SUBTITLES,
PLAY_USING,
))
__all__ = (
# Addon paths
'ADDON_ID',
'ADDON_PATH',
'DATA_PATH',
'MEDIA_PATH',
'RESOURCE_PATH',
'TEMP_PATH',
# Const values
'BOOL_FROM_STR',
'VALUE_TO_STR',
'YOUTUBE_HOSTNAMES',
# Flags
'ABORT_FLAG',
'BUSY_FLAG',
'SERVICE_RUNNING_FLAG',
'TRAKT_PAUSE_FLAG',
'WAIT_END_FLAG',
# Container Info
'CURRENT_CONTAINER_INFO',
'PLUGIN_CONTAINER_INFO',
'CURRENT_ITEM',
'FOLDER_NAME',
'FOLDER_URI',
'HAS_FILES',
'HAS_FOLDERS',
'HAS_PARENT',
'SCROLLING',
'UPDATING',
# ListItem Info
'CONTAINER_LISTITEM_INFO',
'LISTITEM_INFO',
'ARTIST',
'LABEL',
'PLAY_COUNT',
'RESUMABLE',
'TITLE',
'URI',
# ListItem Properties
'CONTAINER_LISTITEM_PROP',
'LISTITEM_PROP',
'BOOKMARK_ID',
'CHANNEL_ID',
'PLAY_COUNT_PROP',
'PLAYLIST_ID',
'PLAYLIST_ITEM_ID',
'SUBSCRIPTION_ID',
'VIDEO_ID',
# Events
'CHECK_SETTINGS',
'CONTEXT_MENU',
'FILE_READ',
'FILE_WRITE',
'KEYMAP',
'PLAYBACK_INIT',
'PLAYBACK_FAILED',
'PLAYBACK_STARTED',
'PLAYBACK_STOPPED',
'REFRESH_CONTAINER',
'RELOAD_ACCESS_MANAGER',
'SERVICE_IPC',
'SYNC_LISTITEM',
# Sleep/wakeup states
'PLUGIN_SLEEPING',
'PLUGIN_WAKEUP',
'SERVER_WAKEUP',
# Play options
'PLAY_CANCELLED',
'PLAY_FORCE_AUDIO',
'PLAY_FORCED',
'PLAY_PROMPT_QUALITY',
'PLAY_PROMPT_SUBTITLES',
'PLAY_STRM',
'PLAY_TIMESHIFT',
'PLAY_USING',
'FORCE_PLAY_PARAMS',
# Stored data
'PROPERTY',
'PROPERTY_AS_LABEL',
'CONTAINER_ID',
'CONTAINER_FOCUS',
'CONTAINER_POSITION',
'DEVELOPER_CONFIGS',
'LICENSE_TOKEN',
'LICENSE_URL',
'MARK_AS_LABEL',
'PLAYER_DATA',
'PLAYER_VIDEO_ID',
'PLAYLIST_PATH',
'PLAYLIST_POSITION',
'REROUTE_PATH',
# Routing parameters
'WINDOW_CACHE',
'WINDOW_FALLBACK',
'WINDOW_REPLACE',
'WINDOW_RETURN',
# Plugin url query parameters
'ACTION',
'ADDON_ID_PARAM',
'CHANNEL_IDS',
'CLIP',
'END',
'FANART_TYPE',
'HIDE_CHANNELS',
'HIDE_FOLDERS',
'HIDE_LIVE',
'HIDE_MEMBERS',
'HIDE_NEXT_PAGE',
'HIDE_PLAYLISTS',
'HIDE_PROGRESS',
'HIDE_SEARCH',
'HIDE_SHORTS',
'HIDE_VIDEOS',
'INCOGNITO',
'ITEM_FILTER',
'ITEMS_PER_PAGE',
'LIVE',
'ORDER',
'PAGE',
'PLAYLIST_IDS',
'SCREENSAVER',
'SEEK',
'SORT_DIR',
'SORT_METHOD',
'START',
'VIDEO_IDS',
'INHERITED_PARAMS',
# Other constants
'CONTENT',
'PATHS',
'SETTINGS',
'SORT',
# Languages and Regions
'DEFAULT_LANGUAGES',
'DEFAULT_REGIONS',
'TRANSLATION_LANGUAGES',
)

View File

@@ -0,0 +1,32 @@
# -*- 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
VIDEO_CONTENT = 'episodes'
LIST_CONTENT = 'default'
COMMENTS = 'comments'
HISTORY = 'history'
PLAYLIST = 'playlist'
AUDIO_TYPE = 'music'
VIDEO_TYPE = 'video'
FILES = 'files'
SONGS = 'songs'
ARTISTS = 'artists'
ALBUMS = 'albums'
MOVIES = 'movies'
TV_SHOWS = 'tvshows'
EPISODES = 'episodes'
VIDEOS = 'videos'
MUSIC_VIDEOS = 'musicvideos'

View File

@@ -0,0 +1,772 @@
# -*- 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
DEFAULT_LANGUAGES = {'items': [
{'id': 'af', 'snippet': {'hl': 'af', 'name': 'Afrikaans'}},
{'id': 'sq', 'snippet': {'hl': 'sq', 'name': 'Albanian'}},
{'id': 'am', 'snippet': {'hl': 'am', 'name': 'Amharic'}},
{'id': 'ar', 'snippet': {'hl': 'ar', 'name': 'Arabic'}},
{'id': 'hy', 'snippet': {'hl': 'hy', 'name': 'Armenian'}},
{'id': 'az', 'snippet': {'hl': 'az', 'name': 'Azerbaijani'}},
{'id': 'eu', 'snippet': {'hl': 'eu', 'name': 'Basque'}},
{'id': 'bn', 'snippet': {'hl': 'bn', 'name': 'Bengali'}},
{'id': 'bg', 'snippet': {'hl': 'bg', 'name': 'Bulgarian'}},
{'id': 'ca', 'snippet': {'hl': 'ca', 'name': 'Catalan'}},
{'id': 'zh-CN', 'snippet': {'hl': 'zh-CN', 'name': 'Chinese'}},
{'id': 'zh-HK', 'snippet': {'hl': 'zh-HK', 'name': 'Chinese (Hong Kong)'}},
{'id': 'zh-TW', 'snippet': {'hl': 'zh-TW', 'name': 'Chinese (Taiwan)'}},
{'id': 'hr', 'snippet': {'hl': 'hr', 'name': 'Croatian'}},
{'id': 'cs', 'snippet': {'hl': 'cs', 'name': 'Czech'}},
{'id': 'da', 'snippet': {'hl': 'da', 'name': 'Danish'}},
{'id': 'nl', 'snippet': {'hl': 'nl', 'name': 'Dutch'}},
{'id': 'en', 'snippet': {'hl': 'en', 'name': 'English'}},
{'id': 'en-GB', 'snippet': {'hl': 'en-GB', 'name': 'English (United Kingdom)'}},
{'id': 'et', 'snippet': {'hl': 'et', 'name': 'Estonian'}},
{'id': 'fil', 'snippet': {'hl': 'fil', 'name': 'Filipino'}},
{'id': 'fi', 'snippet': {'hl': 'fi', 'name': 'Finnish'}},
{'id': 'fr', 'snippet': {'hl': 'fr', 'name': 'French'}},
{'id': 'fr-CA', 'snippet': {'hl': 'fr-CA', 'name': 'French (Canada)'}},
{'id': 'gl', 'snippet': {'hl': 'gl', 'name': 'Galician'}},
{'id': 'ka', 'snippet': {'hl': 'ka', 'name': 'Georgian'}},
{'id': 'de', 'snippet': {'hl': 'de', 'name': 'German'}},
{'id': 'el', 'snippet': {'hl': 'el', 'name': 'Greek'}},
{'id': 'gu', 'snippet': {'hl': 'gu', 'name': 'Gujarati'}},
{'id': 'iw', 'snippet': {'hl': 'iw', 'name': 'Hebrew'}},
{'id': 'hi', 'snippet': {'hl': 'hi', 'name': 'Hindi'}},
{'id': 'hu', 'snippet': {'hl': 'hu', 'name': 'Hungarian'}},
{'id': 'is', 'snippet': {'hl': 'is', 'name': 'Icelandic'}},
{'id': 'id', 'snippet': {'hl': 'id', 'name': 'Indonesian'}},
{'id': 'it', 'snippet': {'hl': 'it', 'name': 'Italian'}},
{'id': 'ja', 'snippet': {'hl': 'ja', 'name': 'Japanese'}},
{'id': 'kn', 'snippet': {'hl': 'kn', 'name': 'Kannada'}},
{'id': 'kk', 'snippet': {'hl': 'kk', 'name': 'Kazakh'}},
{'id': 'km', 'snippet': {'hl': 'km', 'name': 'Khmer'}},
{'id': 'ko', 'snippet': {'hl': 'ko', 'name': 'Korean'}},
{'id': 'ky', 'snippet': {'hl': 'ky', 'name': 'Kyrgyz'}},
{'id': 'lo', 'snippet': {'hl': 'lo', 'name': 'Lao'}},
{'id': 'lv', 'snippet': {'hl': 'lv', 'name': 'Latvian'}},
{'id': 'lt', 'snippet': {'hl': 'lt', 'name': 'Lithuanian'}},
{'id': 'mk', 'snippet': {'hl': 'mk', 'name': 'Macedonian'}},
{'id': 'ms', 'snippet': {'hl': 'ms', 'name': 'Malay'}},
{'id': 'ml', 'snippet': {'hl': 'ml', 'name': 'Malayalam'}},
{'id': 'mr', 'snippet': {'hl': 'mr', 'name': 'Marathi'}},
{'id': 'mn', 'snippet': {'hl': 'mn', 'name': 'Mongolian'}},
{'id': 'my', 'snippet': {'hl': 'my', 'name': 'Myanmar (Burmese)'}},
{'id': 'ne', 'snippet': {'hl': 'ne', 'name': 'Nepali'}},
{'id': 'no', 'snippet': {'hl': 'no', 'name': 'Norwegian'}},
{'id': 'fa', 'snippet': {'hl': 'fa', 'name': 'Persian'}},
{'id': 'pl', 'snippet': {'hl': 'pl', 'name': 'Polish'}},
{'id': 'pt', 'snippet': {'hl': 'pt', 'name': 'Portuguese (Brazil)'}},
{'id': 'pt-PT', 'snippet': {'hl': 'pt-PT', 'name': 'Portuguese (Portugal)'}},
{'id': 'pa', 'snippet': {'hl': 'pa', 'name': 'Punjabi'}},
{'id': 'ro', 'snippet': {'hl': 'ro', 'name': 'Romanian'}},
{'id': 'ru', 'snippet': {'hl': 'ru', 'name': 'Russian'}},
{'id': 'sr', 'snippet': {'hl': 'sr', 'name': 'Serbian'}},
{'id': 'si', 'snippet': {'hl': 'si', 'name': 'Sinhala'}},
{'id': 'sk', 'snippet': {'hl': 'sk', 'name': 'Slovak'}},
{'id': 'sl', 'snippet': {'hl': 'sl', 'name': 'Slovenian'}},
{'id': 'es-419', 'snippet': {'hl': 'es-419', 'name': 'Spanish (Latin America)'}},
{'id': 'es', 'snippet': {'hl': 'es', 'name': 'Spanish (Spain)'}},
{'id': 'sw', 'snippet': {'hl': 'sw', 'name': 'Swahili'}},
{'id': 'sv', 'snippet': {'hl': 'sv', 'name': 'Swedish'}},
{'id': 'ta', 'snippet': {'hl': 'ta', 'name': 'Tamil'}},
{'id': 'te', 'snippet': {'hl': 'te', 'name': 'Telugu'}},
{'id': 'th', 'snippet': {'hl': 'th', 'name': 'Thai'}},
{'id': 'tr', 'snippet': {'hl': 'tr', 'name': 'Turkish'}},
{'id': 'uk', 'snippet': {'hl': 'uk', 'name': 'Ukrainian'}},
{'id': 'ur', 'snippet': {'hl': 'ur', 'name': 'Urdu'}},
{'id': 'uz', 'snippet': {'hl': 'uz', 'name': 'Uzbek'}},
{'id': 'vi', 'snippet': {'hl': 'vi', 'name': 'Vietnamese'}},
{'id': 'zu', 'snippet': {'hl': 'zu', 'name': 'Zulu'}},
]}
DEFAULT_REGIONS = {'items': [
{'id': 'DZ', 'snippet': {'gl': 'DZ', 'name': 'Algeria'}},
{'id': 'AR', 'snippet': {'gl': 'AR', 'name': 'Argentina'}},
{'id': 'AU', 'snippet': {'gl': 'AU', 'name': 'Australia'}},
{'id': 'AT', 'snippet': {'gl': 'AT', 'name': 'Austria'}},
{'id': 'AZ', 'snippet': {'gl': 'AZ', 'name': 'Azerbaijan'}},
{'id': 'BH', 'snippet': {'gl': 'BH', 'name': 'Bahrain'}},
{'id': 'BY', 'snippet': {'gl': 'BY', 'name': 'Belarus'}},
{'id': 'BE', 'snippet': {'gl': 'BE', 'name': 'Belgium'}},
{'id': 'BA', 'snippet': {'gl': 'BA', 'name': 'Bosnia and Herzegovina'}},
{'id': 'BR', 'snippet': {'gl': 'BR', 'name': 'Brazil'}},
{'id': 'BG', 'snippet': {'gl': 'BG', 'name': 'Bulgaria'}},
{'id': 'CA', 'snippet': {'gl': 'CA', 'name': 'Canada'}},
{'id': 'CL', 'snippet': {'gl': 'CL', 'name': 'Chile'}},
{'id': 'CO', 'snippet': {'gl': 'CO', 'name': 'Colombia'}},
{'id': 'HR', 'snippet': {'gl': 'HR', 'name': 'Croatia'}},
{'id': 'CZ', 'snippet': {'gl': 'CZ', 'name': 'Czech Republic'}},
{'id': 'DK', 'snippet': {'gl': 'DK', 'name': 'Denmark'}},
{'id': 'EG', 'snippet': {'gl': 'EG', 'name': 'Egypt'}},
{'id': 'EE', 'snippet': {'gl': 'EE', 'name': 'Estonia'}},
{'id': 'FI', 'snippet': {'gl': 'FI', 'name': 'Finland'}},
{'id': 'FR', 'snippet': {'gl': 'FR', 'name': 'France'}},
{'id': 'GE', 'snippet': {'gl': 'GE', 'name': 'Georgia'}},
{'id': 'DE', 'snippet': {'gl': 'DE', 'name': 'Germany'}},
{'id': 'GH', 'snippet': {'gl': 'GH', 'name': 'Ghana'}},
{'id': 'GR', 'snippet': {'gl': 'GR', 'name': 'Greece'}},
{'id': 'HK', 'snippet': {'gl': 'HK', 'name': 'Hong Kong'}},
{'id': 'HU', 'snippet': {'gl': 'HU', 'name': 'Hungary'}},
{'id': 'IS', 'snippet': {'gl': 'IS', 'name': 'Iceland'}},
{'id': 'IN', 'snippet': {'gl': 'IN', 'name': 'India'}},
{'id': 'ID', 'snippet': {'gl': 'ID', 'name': 'Indonesia'}},
{'id': 'IQ', 'snippet': {'gl': 'IQ', 'name': 'Iraq'}},
{'id': 'IE', 'snippet': {'gl': 'IE', 'name': 'Ireland'}},
{'id': 'IL', 'snippet': {'gl': 'IL', 'name': 'Israel'}},
{'id': 'IT', 'snippet': {'gl': 'IT', 'name': 'Italy'}},
{'id': 'JM', 'snippet': {'gl': 'JM', 'name': 'Jamaica'}},
{'id': 'JP', 'snippet': {'gl': 'JP', 'name': 'Japan'}},
{'id': 'JO', 'snippet': {'gl': 'JO', 'name': 'Jordan'}},
{'id': 'KZ', 'snippet': {'gl': 'KZ', 'name': 'Kazakhstan'}},
{'id': 'KE', 'snippet': {'gl': 'KE', 'name': 'Kenya'}},
{'id': 'KW', 'snippet': {'gl': 'KW', 'name': 'Kuwait'}},
{'id': 'LV', 'snippet': {'gl': 'LV', 'name': 'Latvia'}},
{'id': 'LB', 'snippet': {'gl': 'LB', 'name': 'Lebanon'}},
{'id': 'LY', 'snippet': {'gl': 'LY', 'name': 'Libya'}},
{'id': 'LT', 'snippet': {'gl': 'LT', 'name': 'Lithuania'}},
{'id': 'LU', 'snippet': {'gl': 'LU', 'name': 'Luxembourg'}},
{'id': 'MK', 'snippet': {'gl': 'MK', 'name': 'Macedonia'}},
{'id': 'MY', 'snippet': {'gl': 'MY', 'name': 'Malaysia'}},
{'id': 'MX', 'snippet': {'gl': 'MX', 'name': 'Mexico'}},
{'id': 'ME', 'snippet': {'gl': 'ME', 'name': 'Montenegro'}},
{'id': 'MA', 'snippet': {'gl': 'MA', 'name': 'Morocco'}},
{'id': 'NP', 'snippet': {'gl': 'NP', 'name': 'Nepal'}},
{'id': 'NL', 'snippet': {'gl': 'NL', 'name': 'Netherlands'}},
{'id': 'NZ', 'snippet': {'gl': 'NZ', 'name': 'New Zealand'}},
{'id': 'NG', 'snippet': {'gl': 'NG', 'name': 'Nigeria'}},
{'id': 'NO', 'snippet': {'gl': 'NO', 'name': 'Norway'}},
{'id': 'OM', 'snippet': {'gl': 'OM', 'name': 'Oman'}},
{'id': 'PK', 'snippet': {'gl': 'PK', 'name': 'Pakistan'}},
{'id': 'PE', 'snippet': {'gl': 'PE', 'name': 'Peru'}},
{'id': 'PH', 'snippet': {'gl': 'PH', 'name': 'Philippines'}},
{'id': 'PL', 'snippet': {'gl': 'PL', 'name': 'Poland'}},
{'id': 'PT', 'snippet': {'gl': 'PT', 'name': 'Portugal'}},
{'id': 'PR', 'snippet': {'gl': 'PR', 'name': 'Puerto Rico'}},
{'id': 'QA', 'snippet': {'gl': 'QA', 'name': 'Qatar'}},
{'id': 'RO', 'snippet': {'gl': 'RO', 'name': 'Romania'}},
{'id': 'RU', 'snippet': {'gl': 'RU', 'name': 'Russia'}},
{'id': 'SA', 'snippet': {'gl': 'SA', 'name': 'Saudi Arabia'}},
{'id': 'SN', 'snippet': {'gl': 'SN', 'name': 'Senegal'}},
{'id': 'RS', 'snippet': {'gl': 'RS', 'name': 'Serbia'}},
{'id': 'SG', 'snippet': {'gl': 'SG', 'name': 'Singapore'}},
{'id': 'SK', 'snippet': {'gl': 'SK', 'name': 'Slovakia'}},
{'id': 'SI', 'snippet': {'gl': 'SI', 'name': 'Slovenia'}},
{'id': 'ZA', 'snippet': {'gl': 'ZA', 'name': 'South Africa'}},
{'id': 'KR', 'snippet': {'gl': 'KR', 'name': 'South Korea'}},
{'id': 'ES', 'snippet': {'gl': 'ES', 'name': 'Spain'}},
{'id': 'LK', 'snippet': {'gl': 'LK', 'name': 'Sri Lanka'}},
{'id': 'SE', 'snippet': {'gl': 'SE', 'name': 'Sweden'}},
{'id': 'CH', 'snippet': {'gl': 'CH', 'name': 'Switzerland'}},
{'id': 'TW', 'snippet': {'gl': 'TW', 'name': 'Taiwan'}},
{'id': 'TZ', 'snippet': {'gl': 'TZ', 'name': 'Tanzania'}},
{'id': 'TH', 'snippet': {'gl': 'TH', 'name': 'Thailand'}},
{'id': 'TN', 'snippet': {'gl': 'TN', 'name': 'Tunisia'}},
{'id': 'TR', 'snippet': {'gl': 'TR', 'name': 'Turkey'}},
{'id': 'UG', 'snippet': {'gl': 'UG', 'name': 'Uganda'}},
{'id': 'UA', 'snippet': {'gl': 'UA', 'name': 'Ukraine'}},
{'id': 'AE', 'snippet': {'gl': 'AE', 'name': 'United Arab Emirates'}},
{'id': 'GB', 'snippet': {'gl': 'GB', 'name': 'United Kingdom'}},
{'id': 'US', 'snippet': {'gl': 'US', 'name': 'United States'}},
{'id': 'VN', 'snippet': {'gl': 'VN', 'name': 'Vietnam'}},
{'id': 'YE', 'snippet': {'gl': 'YE', 'name': 'Yemen'}},
{'id': 'ZW', 'snippet': {'gl': 'ZW', 'name': 'Zimbabwe'}},
]}
TRANSLATION_LANGUAGES = [
{'languageCode': 'aa', 'languageName': {'simpleText': 'Afar'}},
{'languageCode': 'ab', 'languageName': {'simpleText': 'Abkhazian'}},
{'languageCode': 'ace', 'languageName': {'simpleText': 'Acehnese'}},
{'languageCode': 'ach', 'languageName': {'simpleText': 'Acoli'}},
{'languageCode': 'ada', 'languageName': {'simpleText': 'Adangme'}},
{'languageCode': 'ady', 'languageName': {'simpleText': 'Adyghe'}},
{'languageCode': 'ae', 'languageName': {'simpleText': 'Avestan'}},
{'languageCode': 'aeb', 'languageName': {'simpleText': 'Tunisian Arabic'}},
{'languageCode': 'af', 'languageName': {'simpleText': 'Afrikaans'}},
{'languageCode': 'afh', 'languageName': {'simpleText': 'Afrihili'}},
{'languageCode': 'agq', 'languageName': {'simpleText': 'Aghem'}},
{'languageCode': 'ain', 'languageName': {'simpleText': 'Ainu'}},
{'languageCode': 'ak', 'languageName': {'simpleText': 'Akan'}},
{'languageCode': 'akk', 'languageName': {'simpleText': 'Akkadian'}},
{'languageCode': 'akz', 'languageName': {'simpleText': 'Alabama'}},
{'languageCode': 'ale', 'languageName': {'simpleText': 'Aleut'}},
{'languageCode': 'aln', 'languageName': {'simpleText': 'Gheg Albanian'}},
{'languageCode': 'alt', 'languageName': {'simpleText': 'Southern Altai'}},
{'languageCode': 'am', 'languageName': {'simpleText': 'Amharic'}},
{'languageCode': 'an', 'languageName': {'simpleText': 'Aragonese'}},
{'languageCode': 'ang', 'languageName': {'simpleText': 'Old English'}},
{'languageCode': 'anp', 'languageName': {'simpleText': 'Angika'}},
{'languageCode': 'ar', 'languageName': {'simpleText': 'Arabic'}},
{'languageCode': 'ar_001', 'languageName': {'simpleText': 'Arabic (world)'}},
{'languageCode': 'arc', 'languageName': {'simpleText': 'Aramaic'}},
{'languageCode': 'arn', 'languageName': {'simpleText': 'Mapuche'}},
{'languageCode': 'aro', 'languageName': {'simpleText': 'Araona'}},
{'languageCode': 'arp', 'languageName': {'simpleText': 'Arapaho'}},
{'languageCode': 'arq', 'languageName': {'simpleText': 'Algerian Arabic'}},
{'languageCode': 'ars', 'languageName': {'simpleText': 'Najdi Arabic'}},
{'languageCode': 'arw', 'languageName': {'simpleText': 'Arawak'}},
{'languageCode': 'ary', 'languageName': {'simpleText': 'Moroccan Arabic'}},
{'languageCode': 'arz', 'languageName': {'simpleText': 'Egyptian Arabic'}},
{'languageCode': 'as', 'languageName': {'simpleText': 'Assamese'}},
{'languageCode': 'asa', 'languageName': {'simpleText': 'Asu'}},
{'languageCode': 'ase', 'languageName': {'simpleText': 'American Sign Language'}},
{'languageCode': 'ast', 'languageName': {'simpleText': 'Asturian'}},
{'languageCode': 'av', 'languageName': {'simpleText': 'Avaric'}},
{'languageCode': 'avk', 'languageName': {'simpleText': 'Kotava'}},
{'languageCode': 'awa', 'languageName': {'simpleText': 'Awadhi'}},
{'languageCode': 'ay', 'languageName': {'simpleText': 'Aymara'}},
{'languageCode': 'az', 'languageName': {'simpleText': 'Azerbaijani'}},
{'languageCode': 'az_Cyrl', 'languageName': {'simpleText': 'Azerbaijani (Cyrillic)'}},
{'languageCode': 'az_Latn', 'languageName': {'simpleText': 'Azerbaijani (Latin)'}},
{'languageCode': 'ba', 'languageName': {'simpleText': 'Bashkir'}},
{'languageCode': 'bal', 'languageName': {'simpleText': 'Baluchi'}},
{'languageCode': 'ban', 'languageName': {'simpleText': 'Balinese'}},
{'languageCode': 'bar', 'languageName': {'simpleText': 'Bavarian'}},
{'languageCode': 'bas', 'languageName': {'simpleText': 'Basaa'}},
{'languageCode': 'bax', 'languageName': {'simpleText': 'Bamun'}},
{'languageCode': 'bbc', 'languageName': {'simpleText': 'Batak Toba'}},
{'languageCode': 'bbj', 'languageName': {'simpleText': 'Ghomala'}},
{'languageCode': 'be', 'languageName': {'simpleText': 'Belarusian'}},
{'languageCode': 'bej', 'languageName': {'simpleText': 'Beja'}},
{'languageCode': 'bem', 'languageName': {'simpleText': 'Bemba'}},
{'languageCode': 'bew', 'languageName': {'simpleText': 'Betawi'}},
{'languageCode': 'bez', 'languageName': {'simpleText': 'Bena'}},
{'languageCode': 'bfd', 'languageName': {'simpleText': 'Bafut'}},
{'languageCode': 'bfq', 'languageName': {'simpleText': 'Badaga'}},
{'languageCode': 'bg', 'languageName': {'simpleText': 'Bulgarian'}},
{'languageCode': 'bgc', 'languageName': {'simpleText': 'Haryanvi'}},
{'languageCode': 'bgn', 'languageName': {'simpleText': 'Western Balochi'}},
{'languageCode': 'bho', 'languageName': {'simpleText': 'Bhojpuri'}},
{'languageCode': 'bi', 'languageName': {'simpleText': 'Bislama'}},
{'languageCode': 'bik', 'languageName': {'simpleText': 'Bikol'}},
{'languageCode': 'bin', 'languageName': {'simpleText': 'Bini'}},
{'languageCode': 'bjn', 'languageName': {'simpleText': 'Banjar'}},
{'languageCode': 'bkm', 'languageName': {'simpleText': 'Kom'}},
{'languageCode': 'bla', 'languageName': {'simpleText': 'Siksik\u00e1'}},
{'languageCode': 'blo', 'languageName': {'simpleText': 'Anii'}},
{'languageCode': 'bm', 'languageName': {'simpleText': 'Bambara'}},
{'languageCode': 'bn', 'languageName': {'simpleText': 'Bangla'}},
{'languageCode': 'bo', 'languageName': {'simpleText': 'Tibetan'}},
{'languageCode': 'bpy', 'languageName': {'simpleText': 'Bishnupriya'}},
{'languageCode': 'bqi', 'languageName': {'simpleText': 'Bakhtiari'}},
{'languageCode': 'br', 'languageName': {'simpleText': 'Breton'}},
{'languageCode': 'bra', 'languageName': {'simpleText': 'Braj'}},
{'languageCode': 'brh', 'languageName': {'simpleText': 'Brahui'}},
{'languageCode': 'brx', 'languageName': {'simpleText': 'Bodo'}},
{'languageCode': 'bs', 'languageName': {'simpleText': 'Bosnian'}},
{'languageCode': 'bs_Cyrl', 'languageName': {'simpleText': 'Bosnian (Cyrillic)'}},
{'languageCode': 'bs_Latn', 'languageName': {'simpleText': 'Bosnian (Latin)'}},
{'languageCode': 'bss', 'languageName': {'simpleText': 'Akoose'}},
{'languageCode': 'bua', 'languageName': {'simpleText': 'Buriat'}},
{'languageCode': 'bug', 'languageName': {'simpleText': 'Buginese'}},
{'languageCode': 'bum', 'languageName': {'simpleText': 'Bulu'}},
{'languageCode': 'byn', 'languageName': {'simpleText': 'Blin'}},
{'languageCode': 'byv', 'languageName': {'simpleText': 'Medumba'}},
{'languageCode': 'ca', 'languageName': {'simpleText': 'Catalan'}},
{'languageCode': 'cad', 'languageName': {'simpleText': 'Caddo'}},
{'languageCode': 'car', 'languageName': {'simpleText': 'Carib'}},
{'languageCode': 'cay', 'languageName': {'simpleText': 'Cayuga'}},
{'languageCode': 'cch', 'languageName': {'simpleText': 'Atsam'}},
{'languageCode': 'ccp', 'languageName': {'simpleText': 'Chakma'}},
{'languageCode': 'ce', 'languageName': {'simpleText': 'Chechen'}},
{'languageCode': 'ceb', 'languageName': {'simpleText': 'Cebuano'}},
{'languageCode': 'cgg', 'languageName': {'simpleText': 'Chiga'}},
{'languageCode': 'ch', 'languageName': {'simpleText': 'Chamorro'}},
{'languageCode': 'chb', 'languageName': {'simpleText': 'Chibcha'}},
{'languageCode': 'chg', 'languageName': {'simpleText': 'Chagatai'}},
{'languageCode': 'chk', 'languageName': {'simpleText': 'Chuukese'}},
{'languageCode': 'chm', 'languageName': {'simpleText': 'Mari'}},
{'languageCode': 'chn', 'languageName': {'simpleText': 'Chinook Jargon'}},
{'languageCode': 'cho', 'languageName': {'simpleText': 'Choctaw'}},
{'languageCode': 'chp', 'languageName': {'simpleText': 'Chipewyan'}},
{'languageCode': 'chr', 'languageName': {'simpleText': 'Cherokee'}},
{'languageCode': 'chy', 'languageName': {'simpleText': 'Cheyenne'}},
{'languageCode': 'ckb', 'languageName': {'simpleText': 'Central Kurdish'}},
{'languageCode': 'co', 'languageName': {'simpleText': 'Corsican'}},
{'languageCode': 'cop', 'languageName': {'simpleText': 'Coptic'}},
{'languageCode': 'cps', 'languageName': {'simpleText': 'Capiznon'}},
{'languageCode': 'cr', 'languageName': {'simpleText': 'Cree'}},
{'languageCode': 'crh', 'languageName': {'simpleText': 'Crimean Tatar'}},
{'languageCode': 'cs', 'languageName': {'simpleText': 'Czech'}},
{'languageCode': 'csb', 'languageName': {'simpleText': 'Kashubian'}},
{'languageCode': 'csw', 'languageName': {'simpleText': 'Swampy Cree'}},
{'languageCode': 'cu', 'languageName': {'simpleText': 'Church Slavic'}},
{'languageCode': 'cv', 'languageName': {'simpleText': 'Chuvash'}},
{'languageCode': 'cy', 'languageName': {'simpleText': 'Welsh'}},
{'languageCode': 'da', 'languageName': {'simpleText': 'Danish'}},
{'languageCode': 'dak', 'languageName': {'simpleText': 'Dakota'}},
{'languageCode': 'dar', 'languageName': {'simpleText': 'Dargwa'}},
{'languageCode': 'dav', 'languageName': {'simpleText': 'Taita'}},
{'languageCode': 'de', 'languageName': {'simpleText': 'German'}},
{'languageCode': 'de_AT', 'languageName': {'simpleText': 'German (Austria)'}},
{'languageCode': 'de_CH', 'languageName': {'simpleText': 'German (Switzerland)'}},
{'languageCode': 'del', 'languageName': {'simpleText': 'Delaware'}},
{'languageCode': 'den', 'languageName': {'simpleText': 'Slave'}},
{'languageCode': 'dgr', 'languageName': {'simpleText': 'Dogrib'}},
{'languageCode': 'din', 'languageName': {'simpleText': 'Dinka'}},
{'languageCode': 'dje', 'languageName': {'simpleText': 'Zarma'}},
{'languageCode': 'doi', 'languageName': {'simpleText': 'Dogri'}},
{'languageCode': 'dsb', 'languageName': {'simpleText': 'Lower Sorbian'}},
{'languageCode': 'dua', 'languageName': {'simpleText': 'Duala'}},
{'languageCode': 'dum', 'languageName': {'simpleText': 'Middle Dutch'}},
{'languageCode': 'dv', 'languageName': {'simpleText': 'Divehi'}},
{'languageCode': 'dyo', 'languageName': {'simpleText': 'Jola-Fonyi'}},
{'languageCode': 'dyu', 'languageName': {'simpleText': 'Dyula'}},
{'languageCode': 'dz', 'languageName': {'simpleText': 'Dzongkha'}},
{'languageCode': 'dzg', 'languageName': {'simpleText': 'Dazaga'}},
{'languageCode': 'ebu', 'languageName': {'simpleText': 'Embu'}},
{'languageCode': 'ee', 'languageName': {'simpleText': 'Ewe'}},
{'languageCode': 'efi', 'languageName': {'simpleText': 'Efik'}},
{'languageCode': 'egy', 'languageName': {'simpleText': 'Ancient Egyptian'}},
{'languageCode': 'eka', 'languageName': {'simpleText': 'Ekajuk'}},
{'languageCode': 'el', 'languageName': {'simpleText': 'Greek'}},
{'languageCode': 'elx', 'languageName': {'simpleText': 'Elamite'}},
{'languageCode': 'en', 'languageName': {'simpleText': 'English'}},
{'languageCode': 'en_AU', 'languageName': {'simpleText': 'English (Australia)'}},
{'languageCode': 'en_CA', 'languageName': {'simpleText': 'English (Canada)'}},
{'languageCode': 'en_GB', 'languageName': {'simpleText': 'English (United Kingdom)'}},
{'languageCode': 'en_US', 'languageName': {'simpleText': 'English (United States)'}},
{'languageCode': 'enm', 'languageName': {'simpleText': 'Middle English'}},
{'languageCode': 'eo', 'languageName': {'simpleText': 'Esperanto'}},
{'languageCode': 'es', 'languageName': {'simpleText': 'Spanish'}},
{'languageCode': 'es_419', 'languageName': {'simpleText': 'Spanish (Latin America)'}},
{'languageCode': 'es_ES', 'languageName': {'simpleText': 'Spanish (Spain)'}},
{'languageCode': 'es_MX', 'languageName': {'simpleText': 'Spanish (Mexico)'}},
{'languageCode': 'et', 'languageName': {'simpleText': 'Estonian'}},
{'languageCode': 'eu', 'languageName': {'simpleText': 'Basque'}},
{'languageCode': 'ewo', 'languageName': {'simpleText': 'Ewondo'}},
{'languageCode': 'fa', 'languageName': {'simpleText': 'Persian'}},
{'languageCode': 'fa_AF', 'languageName': {'simpleText': 'Persian (Afghanistan)'}},
{'languageCode': 'fan', 'languageName': {'simpleText': 'Fang'}},
{'languageCode': 'fat', 'languageName': {'simpleText': 'Fanti'}},
{'languageCode': 'ff', 'languageName': {'simpleText': 'Fula'}},
{'languageCode': 'ff_Adlm', 'languageName': {'simpleText': 'Fula (Adlam)'}},
{'languageCode': 'ff_Latn', 'languageName': {'simpleText': 'Fula (Latin)'}},
{'languageCode': 'fi', 'languageName': {'simpleText': 'Finnish'}},
{'languageCode': 'fil', 'languageName': {'simpleText': 'Filipino'}},
{'languageCode': 'fj', 'languageName': {'simpleText': 'Fijian'}},
{'languageCode': 'fo', 'languageName': {'simpleText': 'Faroese'}},
{'languageCode': 'fon', 'languageName': {'simpleText': 'Fon'}},
{'languageCode': 'fr', 'languageName': {'simpleText': 'French'}},
{'languageCode': 'fr_CA', 'languageName': {'simpleText': 'French (Canada)'}},
{'languageCode': 'fr_CH', 'languageName': {'simpleText': 'French (Switzerland)'}},
{'languageCode': 'frm', 'languageName': {'simpleText': 'Middle French'}},
{'languageCode': 'fro', 'languageName': {'simpleText': 'Old French'}},
{'languageCode': 'frr', 'languageName': {'simpleText': 'Northern Frisian'}},
{'languageCode': 'frs', 'languageName': {'simpleText': 'Eastern Frisian'}},
{'languageCode': 'fur', 'languageName': {'simpleText': 'Friulian'}},
{'languageCode': 'fy', 'languageName': {'simpleText': 'Western Frisian'}},
{'languageCode': 'ga', 'languageName': {'simpleText': 'Irish'}},
{'languageCode': 'gaa', 'languageName': {'simpleText': 'Ga'}},
{'languageCode': 'gay', 'languageName': {'simpleText': 'Gayo'}},
{'languageCode': 'gba', 'languageName': {'simpleText': 'Gbaya'}},
{'languageCode': 'gd', 'languageName': {'simpleText': 'Scottish Gaelic'}},
{'languageCode': 'gez', 'languageName': {'simpleText': 'Geez'}},
{'languageCode': 'gil', 'languageName': {'simpleText': 'Gilbertese'}},
{'languageCode': 'gl', 'languageName': {'simpleText': 'Galician'}},
{'languageCode': 'gmh', 'languageName': {'simpleText': 'Middle High German'}},
{'languageCode': 'gn', 'languageName': {'simpleText': 'Guarani'}},
{'languageCode': 'goh', 'languageName': {'simpleText': 'Old High German'}},
{'languageCode': 'gon', 'languageName': {'simpleText': 'Gondi'}},
{'languageCode': 'gor', 'languageName': {'simpleText': 'Gorontalo'}},
{'languageCode': 'got', 'languageName': {'simpleText': 'Gothic'}},
{'languageCode': 'grb', 'languageName': {'simpleText': 'Grebo'}},
{'languageCode': 'grc', 'languageName': {'simpleText': 'Ancient Greek'}},
{'languageCode': 'gsw', 'languageName': {'simpleText': 'Swiss German'}},
{'languageCode': 'gu', 'languageName': {'simpleText': 'Gujarati'}},
{'languageCode': 'guz', 'languageName': {'simpleText': 'Gusii'}},
{'languageCode': 'gv', 'languageName': {'simpleText': 'Manx'}},
{'languageCode': 'gwi', 'languageName': {'simpleText': 'Gwich\u02bcin'}},
{'languageCode': 'ha', 'languageName': {'simpleText': 'Hausa'}},
{'languageCode': 'hai', 'languageName': {'simpleText': 'Haida'}},
{'languageCode': 'haw', 'languageName': {'simpleText': 'Hawaiian'}},
{'languageCode': 'he', 'languageName': {'simpleText': 'Hebrew'}},
{'languageCode': 'hi', 'languageName': {'simpleText': 'Hindi'}},
{'languageCode': 'hi_Latn', 'languageName': {'simpleText': 'Hindi (Latin)'}},
{'languageCode': 'hil', 'languageName': {'simpleText': 'Hiligaynon'}},
{'languageCode': 'hit', 'languageName': {'simpleText': 'Hittite'}},
{'languageCode': 'hmn', 'languageName': {'simpleText': 'Hmong'}},
{'languageCode': 'ho', 'languageName': {'simpleText': 'Hiri Motu'}},
{'languageCode': 'hr', 'languageName': {'simpleText': 'Croatian'}},
{'languageCode': 'hsb', 'languageName': {'simpleText': 'Upper Sorbian'}},
{'languageCode': 'ht', 'languageName': {'simpleText': 'Haitian Creole'}},
{'languageCode': 'hu', 'languageName': {'simpleText': 'Hungarian'}},
{'languageCode': 'hup', 'languageName': {'simpleText': 'Hupa'}},
{'languageCode': 'hy', 'languageName': {'simpleText': 'Armenian'}},
{'languageCode': 'hz', 'languageName': {'simpleText': 'Herero'}},
{'languageCode': 'ia', 'languageName': {'simpleText': 'Interlingua'}},
{'languageCode': 'iba', 'languageName': {'simpleText': 'Iban'}},
{'languageCode': 'ibb', 'languageName': {'simpleText': 'Ibibio'}},
{'languageCode': 'id', 'languageName': {'simpleText': 'Indonesian'}},
{'languageCode': 'ie', 'languageName': {'simpleText': 'Interlingue'}},
{'languageCode': 'ig', 'languageName': {'simpleText': 'Igbo'}},
{'languageCode': 'ii', 'languageName': {'simpleText': 'Sichuan Yi'}},
{'languageCode': 'ik', 'languageName': {'simpleText': 'Inupiaq'}},
{'languageCode': 'ilo', 'languageName': {'simpleText': 'Iloko'}},
{'languageCode': 'in', 'languageName': {'simpleText': 'Indonesian'}},
{'languageCode': 'inh', 'languageName': {'simpleText': 'Ingush'}},
{'languageCode': 'io', 'languageName': {'simpleText': 'Ido'}},
{'languageCode': 'is', 'languageName': {'simpleText': 'Icelandic'}},
{'languageCode': 'it', 'languageName': {'simpleText': 'Italian'}},
{'languageCode': 'iu', 'languageName': {'simpleText': 'Inuktitut'}},
{'languageCode': 'iw', 'languageName': {'simpleText': 'Hebrew'}},
{'languageCode': 'ja', 'languageName': {'simpleText': 'Japanese'}},
{'languageCode': 'jbo', 'languageName': {'simpleText': 'Lojban'}},
{'languageCode': 'jgo', 'languageName': {'simpleText': 'Ngomba'}},
{'languageCode': 'jmc', 'languageName': {'simpleText': 'Machame'}},
{'languageCode': 'jpr', 'languageName': {'simpleText': 'Judeo-Persian'}},
{'languageCode': 'jrb', 'languageName': {'simpleText': 'Judeo-Arabic'}},
{'languageCode': 'jv', 'languageName': {'simpleText': 'Javanese'}},
{'languageCode': 'ka', 'languageName': {'simpleText': 'Georgian'}},
{'languageCode': 'kaa', 'languageName': {'simpleText': 'Kara-Kalpak'}},
{'languageCode': 'kab', 'languageName': {'simpleText': 'Kabyle'}},
{'languageCode': 'kac', 'languageName': {'simpleText': 'Kachin'}},
{'languageCode': 'kaj', 'languageName': {'simpleText': 'Jju'}},
{'languageCode': 'kam', 'languageName': {'simpleText': 'Kamba'}},
{'languageCode': 'kaw', 'languageName': {'simpleText': 'Kawi'}},
{'languageCode': 'kbd', 'languageName': {'simpleText': 'Kabardian'}},
{'languageCode': 'kbl', 'languageName': {'simpleText': 'Kanembu'}},
{'languageCode': 'kcg', 'languageName': {'simpleText': 'Tyap'}},
{'languageCode': 'kde', 'languageName': {'simpleText': 'Makonde'}},
{'languageCode': 'kea', 'languageName': {'simpleText': 'Kabuverdianu'}},
{'languageCode': 'kfo', 'languageName': {'simpleText': 'Koro'}},
{'languageCode': 'kg', 'languageName': {'simpleText': 'Kongo'}},
{'languageCode': 'kgp', 'languageName': {'simpleText': 'Kaingang'}},
{'languageCode': 'kha', 'languageName': {'simpleText': 'Khasi'}},
{'languageCode': 'kho', 'languageName': {'simpleText': 'Khotanese'}},
{'languageCode': 'khq', 'languageName': {'simpleText': 'Koyra Chiini'}},
{'languageCode': 'ki', 'languageName': {'simpleText': 'Kikuyu'}},
{'languageCode': 'kj', 'languageName': {'simpleText': 'Kuanyama'}},
{'languageCode': 'kk', 'languageName': {'simpleText': 'Kazakh'}},
{'languageCode': 'kkj', 'languageName': {'simpleText': 'Kako'}},
{'languageCode': 'kl', 'languageName': {'simpleText': 'Kalaallisut'}},
{'languageCode': 'kln', 'languageName': {'simpleText': 'Kalenjin'}},
{'languageCode': 'km', 'languageName': {'simpleText': 'Khmer'}},
{'languageCode': 'kmb', 'languageName': {'simpleText': 'Kimbundu'}},
{'languageCode': 'kn', 'languageName': {'simpleText': 'Kannada'}},
{'languageCode': 'ko', 'languageName': {'simpleText': 'Korean'}},
{'languageCode': 'kok', 'languageName': {'simpleText': 'Konkani'}},
{'languageCode': 'kos', 'languageName': {'simpleText': 'Kosraean'}},
{'languageCode': 'kpe', 'languageName': {'simpleText': 'Kpelle'}},
{'languageCode': 'kr', 'languageName': {'simpleText': 'Kanuri'}},
{'languageCode': 'krc', 'languageName': {'simpleText': 'Karachay-Balkar'}},
{'languageCode': 'krl', 'languageName': {'simpleText': 'Karelian'}},
{'languageCode': 'kru', 'languageName': {'simpleText': 'Kurukh'}},
{'languageCode': 'ks', 'languageName': {'simpleText': 'Kashmiri'}},
{'languageCode': 'ks_Arab', 'languageName': {'simpleText': 'Kashmiri (Arabic)'}},
{'languageCode': 'ks_Deva', 'languageName': {'simpleText': 'Kashmiri (Devanagari)'}},
{'languageCode': 'ksb', 'languageName': {'simpleText': 'Shambala'}},
{'languageCode': 'ksf', 'languageName': {'simpleText': 'Bafia'}},
{'languageCode': 'ksh', 'languageName': {'simpleText': 'Colognian'}},
{'languageCode': 'ku', 'languageName': {'simpleText': 'Kurdish'}},
{'languageCode': 'kum', 'languageName': {'simpleText': 'Kumyk'}},
{'languageCode': 'kut', 'languageName': {'simpleText': 'Kutenai'}},
{'languageCode': 'kv', 'languageName': {'simpleText': 'Komi'}},
{'languageCode': 'kw', 'languageName': {'simpleText': 'Cornish'}},
{'languageCode': 'kxv', 'languageName': {'simpleText': 'Kuvi'}},
{'languageCode': 'kxv_Deva', 'languageName': {'simpleText': 'Kuvi (Devanagari)'}},
{'languageCode': 'kxv_Latn', 'languageName': {'simpleText': 'Kuvi (Latin)'}},
{'languageCode': 'kxv_Orya', 'languageName': {'simpleText': 'Kuvi (Odia)'}},
{'languageCode': 'kxv_Telu', 'languageName': {'simpleText': 'Kuvi (Telugu)'}},
{'languageCode': 'ky', 'languageName': {'simpleText': 'Kyrgyz'}},
{'languageCode': 'la', 'languageName': {'simpleText': 'Latin'}},
{'languageCode': 'lad', 'languageName': {'simpleText': 'Ladino'}},
{'languageCode': 'lag', 'languageName': {'simpleText': 'Langi'}},
{'languageCode': 'lah', 'languageName': {'simpleText': 'Western Panjabi'}},
{'languageCode': 'lam', 'languageName': {'simpleText': 'Lamba'}},
{'languageCode': 'lb', 'languageName': {'simpleText': 'Luxembourgish'}},
{'languageCode': 'lez', 'languageName': {'simpleText': 'Lezghian'}},
{'languageCode': 'lg', 'languageName': {'simpleText': 'Ganda'}},
{'languageCode': 'li', 'languageName': {'simpleText': 'Limburgish'}},
{'languageCode': 'lij', 'languageName': {'simpleText': 'Ligurian'}},
{'languageCode': 'lkt', 'languageName': {'simpleText': 'Lakota'}},
{'languageCode': 'lmo', 'languageName': {'simpleText': 'Lombard'}},
{'languageCode': 'ln', 'languageName': {'simpleText': 'Lingala'}},
{'languageCode': 'lo', 'languageName': {'simpleText': 'Lao'}},
{'languageCode': 'lol', 'languageName': {'simpleText': 'Mongo'}},
{'languageCode': 'loz', 'languageName': {'simpleText': 'Lozi'}},
{'languageCode': 'lrc', 'languageName': {'simpleText': 'Northern Luri'}},
{'languageCode': 'lt', 'languageName': {'simpleText': 'Lithuanian'}},
{'languageCode': 'lu', 'languageName': {'simpleText': 'Luba-Katanga'}},
{'languageCode': 'lua', 'languageName': {'simpleText': 'Luba-Lulua'}},
{'languageCode': 'lui', 'languageName': {'simpleText': 'Luiseno'}},
{'languageCode': 'lun', 'languageName': {'simpleText': 'Lunda'}},
{'languageCode': 'luo', 'languageName': {'simpleText': 'Luo'}},
{'languageCode': 'lus', 'languageName': {'simpleText': 'Mizo'}},
{'languageCode': 'luy', 'languageName': {'simpleText': 'Luyia'}},
{'languageCode': 'lv', 'languageName': {'simpleText': 'Latvian'}},
{'languageCode': 'mad', 'languageName': {'simpleText': 'Madurese'}},
{'languageCode': 'maf', 'languageName': {'simpleText': 'Mafa'}},
{'languageCode': 'mag', 'languageName': {'simpleText': 'Magahi'}},
{'languageCode': 'mai', 'languageName': {'simpleText': 'Maithili'}},
{'languageCode': 'mak', 'languageName': {'simpleText': 'Makasar'}},
{'languageCode': 'man', 'languageName': {'simpleText': 'Mandingo'}},
{'languageCode': 'mas', 'languageName': {'simpleText': 'Masai'}},
{'languageCode': 'mde', 'languageName': {'simpleText': 'Maba'}},
{'languageCode': 'mdf', 'languageName': {'simpleText': 'Moksha'}},
{'languageCode': 'mdr', 'languageName': {'simpleText': 'Mandar'}},
{'languageCode': 'men', 'languageName': {'simpleText': 'Mende'}},
{'languageCode': 'mer', 'languageName': {'simpleText': 'Meru'}},
{'languageCode': 'mfe', 'languageName': {'simpleText': 'Morisyen'}},
{'languageCode': 'mg', 'languageName': {'simpleText': 'Malagasy'}},
{'languageCode': 'mga', 'languageName': {'simpleText': 'Middle Irish'}},
{'languageCode': 'mgh', 'languageName': {'simpleText': 'Makhuwa-Meetto'}},
{'languageCode': 'mgo', 'languageName': {'simpleText': 'Meta\u02bc'}},
{'languageCode': 'mh', 'languageName': {'simpleText': 'Marshallese'}},
{'languageCode': 'mi', 'languageName': {'simpleText': 'M\u0101ori'}},
{'languageCode': 'mic', 'languageName': {'simpleText': 'Mi\'kmaw'}},
{'languageCode': 'min', 'languageName': {'simpleText': 'Minangkabau'}},
{'languageCode': 'mk', 'languageName': {'simpleText': 'Macedonian'}},
{'languageCode': 'ml', 'languageName': {'simpleText': 'Malayalam'}},
{'languageCode': 'mn', 'languageName': {'simpleText': 'Mongolian'}},
{'languageCode': 'mnc', 'languageName': {'simpleText': 'Manchu'}},
{'languageCode': 'mni', 'languageName': {'simpleText': 'Manipuri'}},
{'languageCode': 'mni_Beng', 'languageName': {'simpleText': 'Manipuri (Bangla)'}},
{'languageCode': 'mo', 'languageName': {'simpleText': 'Romanian'}},
{'languageCode': 'moh', 'languageName': {'simpleText': 'Mohawk'}},
{'languageCode': 'mos', 'languageName': {'simpleText': 'Mossi'}},
{'languageCode': 'mr', 'languageName': {'simpleText': 'Marathi'}},
{'languageCode': 'ms', 'languageName': {'simpleText': 'Malay'}},
{'languageCode': 'mt', 'languageName': {'simpleText': 'Maltese'}},
{'languageCode': 'mua', 'languageName': {'simpleText': 'Mundang'}},
{'languageCode': 'mul', 'languageName': {'simpleText': 'Multiple languages'}},
{'languageCode': 'mus', 'languageName': {'simpleText': 'Muscogee'}},
{'languageCode': 'mwl', 'languageName': {'simpleText': 'Mirandese'}},
{'languageCode': 'mwr', 'languageName': {'simpleText': 'Marwari'}},
{'languageCode': 'my', 'languageName': {'simpleText': 'Burmese'}},
{'languageCode': 'mye', 'languageName': {'simpleText': 'Myene'}},
{'languageCode': 'myv', 'languageName': {'simpleText': 'Erzya'}},
{'languageCode': 'mzn', 'languageName': {'simpleText': 'Mazanderani'}},
{'languageCode': 'na', 'languageName': {'simpleText': 'Nauru'}},
{'languageCode': 'nap', 'languageName': {'simpleText': 'Neapolitan'}},
{'languageCode': 'naq', 'languageName': {'simpleText': 'Nama'}},
{'languageCode': 'nb', 'languageName': {'simpleText': 'Norwegian Bokm\u00e5l'}},
{'languageCode': 'nd', 'languageName': {'simpleText': 'North Ndebele'}},
{'languageCode': 'nds', 'languageName': {'simpleText': 'Low German'}},
{'languageCode': 'nds_NL', 'languageName': {'simpleText': 'Low German (Netherlands)'}},
{'languageCode': 'ne', 'languageName': {'simpleText': 'Nepali'}},
{'languageCode': 'new', 'languageName': {'simpleText': 'Newari'}},
{'languageCode': 'ng', 'languageName': {'simpleText': 'Ndonga'}},
{'languageCode': 'nia', 'languageName': {'simpleText': 'Nias'}},
{'languageCode': 'niu', 'languageName': {'simpleText': 'Niuean'}},
{'languageCode': 'nl', 'languageName': {'simpleText': 'Dutch'}},
{'languageCode': 'nl_BE', 'languageName': {'simpleText': 'Dutch (Belgium)'}},
{'languageCode': 'nmg', 'languageName': {'simpleText': 'Kwasio'}},
{'languageCode': 'nn', 'languageName': {'simpleText': 'Norwegian Nynorsk'}},
{'languageCode': 'nnh', 'languageName': {'simpleText': 'Ngiemboon'}},
{'languageCode': 'no', 'languageName': {'simpleText': 'Norwegian'}},
{'languageCode': 'nog', 'languageName': {'simpleText': 'Nogai'}},
{'languageCode': 'non', 'languageName': {'simpleText': 'Old Norse'}},
{'languageCode': 'nqo', 'languageName': {'simpleText': 'N\u2019Ko'}},
{'languageCode': 'nr', 'languageName': {'simpleText': 'South Ndebele'}},
{'languageCode': 'nso', 'languageName': {'simpleText': 'Northern Sotho'}},
{'languageCode': 'nus', 'languageName': {'simpleText': 'Nuer'}},
{'languageCode': 'nv', 'languageName': {'simpleText': 'Navajo'}},
{'languageCode': 'nwc', 'languageName': {'simpleText': 'Classical Newari'}},
{'languageCode': 'ny', 'languageName': {'simpleText': 'Nyanja'}},
{'languageCode': 'nym', 'languageName': {'simpleText': 'Nyamwezi'}},
{'languageCode': 'nyn', 'languageName': {'simpleText': 'Nyankole'}},
{'languageCode': 'nyo', 'languageName': {'simpleText': 'Nyoro'}},
{'languageCode': 'nzi', 'languageName': {'simpleText': 'Nzima'}},
{'languageCode': 'oc', 'languageName': {'simpleText': 'Occitan'}},
{'languageCode': 'oj', 'languageName': {'simpleText': 'Ojibwa'}},
{'languageCode': 'om', 'languageName': {'simpleText': 'Oromo'}},
{'languageCode': 'or', 'languageName': {'simpleText': 'Odia'}},
{'languageCode': 'os', 'languageName': {'simpleText': 'Ossetic'}},
{'languageCode': 'osa', 'languageName': {'simpleText': 'Osage'}},
{'languageCode': 'ota', 'languageName': {'simpleText': 'Ottoman Turkish'}},
{'languageCode': 'pa', 'languageName': {'simpleText': 'Punjabi'}},
{'languageCode': 'pa_Arab', 'languageName': {'simpleText': 'Punjabi (Arabic)'}},
{'languageCode': 'pa_Guru', 'languageName': {'simpleText': 'Punjabi (Gurmukhi)'}},
{'languageCode': 'pag', 'languageName': {'simpleText': 'Pangasinan'}},
{'languageCode': 'pal', 'languageName': {'simpleText': 'Pahlavi'}},
{'languageCode': 'pam', 'languageName': {'simpleText': 'Pampanga'}},
{'languageCode': 'pap', 'languageName': {'simpleText': 'Papiamento'}},
{'languageCode': 'pau', 'languageName': {'simpleText': 'Palauan'}},
{'languageCode': 'pcm', 'languageName': {'simpleText': 'Nigerian Pidgin'}},
{'languageCode': 'peo', 'languageName': {'simpleText': 'Old Persian'}},
{'languageCode': 'phn', 'languageName': {'simpleText': 'Phoenician'}},
{'languageCode': 'pi', 'languageName': {'simpleText': 'Pali'}},
{'languageCode': 'pl', 'languageName': {'simpleText': 'Polish'}},
{'languageCode': 'pon', 'languageName': {'simpleText': 'Pohnpeian'}},
{'languageCode': 'prg', 'languageName': {'simpleText': 'Prussian'}},
{'languageCode': 'pro', 'languageName': {'simpleText': 'Old Proven\u00e7al'}},
{'languageCode': 'ps', 'languageName': {'simpleText': 'Pashto'}},
{'languageCode': 'pt', 'languageName': {'simpleText': 'Portuguese'}},
{'languageCode': 'pt_BR', 'languageName': {'simpleText': 'Portuguese (Brazil)'}},
{'languageCode': 'pt_PT', 'languageName': {'simpleText': 'Portuguese (Portugal)'}},
{'languageCode': 'qu', 'languageName': {'simpleText': 'Quechua'}},
{'languageCode': 'raj', 'languageName': {'simpleText': 'Rajasthani'}},
{'languageCode': 'rap', 'languageName': {'simpleText': 'Rapanui'}},
{'languageCode': 'rar', 'languageName': {'simpleText': 'Rarotongan'}},
{'languageCode': 'rm', 'languageName': {'simpleText': 'Romansh'}},
{'languageCode': 'rn', 'languageName': {'simpleText': 'Rundi'}},
{'languageCode': 'ro', 'languageName': {'simpleText': 'Romanian'}},
{'languageCode': 'ro_MD', 'languageName': {'simpleText': 'Romanian (Moldova)'}},
{'languageCode': 'rof', 'languageName': {'simpleText': 'Rombo'}},
{'languageCode': 'rom', 'languageName': {'simpleText': 'Romany'}},
{'languageCode': 'ru', 'languageName': {'simpleText': 'Russian'}},
{'languageCode': 'rup', 'languageName': {'simpleText': 'Aromanian'}},
{'languageCode': 'rw', 'languageName': {'simpleText': 'Kinyarwanda'}},
{'languageCode': 'rwk', 'languageName': {'simpleText': 'Rwa'}},
{'languageCode': 'sa', 'languageName': {'simpleText': 'Sanskrit'}},
{'languageCode': 'sad', 'languageName': {'simpleText': 'Sandawe'}},
{'languageCode': 'sah', 'languageName': {'simpleText': 'Yakut'}},
{'languageCode': 'sam', 'languageName': {'simpleText': 'Samaritan Aramaic'}},
{'languageCode': 'saq', 'languageName': {'simpleText': 'Samburu'}},
{'languageCode': 'sas', 'languageName': {'simpleText': 'Sasak'}},
{'languageCode': 'sat', 'languageName': {'simpleText': 'Santali'}},
{'languageCode': 'sat_Olck', 'languageName': {'simpleText': 'Santali (Ol Chiki)'}},
{'languageCode': 'sba', 'languageName': {'simpleText': 'Ngambay'}},
{'languageCode': 'sbp', 'languageName': {'simpleText': 'Sangu'}},
{'languageCode': 'sc', 'languageName': {'simpleText': 'Sardinian'}},
{'languageCode': 'scn', 'languageName': {'simpleText': 'Sicilian'}},
{'languageCode': 'sco', 'languageName': {'simpleText': 'Scots'}},
{'languageCode': 'sd', 'languageName': {'simpleText': 'Sindhi'}},
{'languageCode': 'sd_Arab', 'languageName': {'simpleText': 'Sindhi (Arabic)'}},
{'languageCode': 'sd_Deva', 'languageName': {'simpleText': 'Sindhi (Devanagari)'}},
{'languageCode': 'se', 'languageName': {'simpleText': 'Northern Sami'}},
{'languageCode': 'see', 'languageName': {'simpleText': 'Seneca'}},
{'languageCode': 'seh', 'languageName': {'simpleText': 'Sena'}},
{'languageCode': 'sel', 'languageName': {'simpleText': 'Selkup'}},
{'languageCode': 'ses', 'languageName': {'simpleText': 'Koyraboro Senni'}},
{'languageCode': 'sg', 'languageName': {'simpleText': 'Sango'}},
{'languageCode': 'sga', 'languageName': {'simpleText': 'Old Irish'}},
{'languageCode': 'sh', 'languageName': {'simpleText': 'Serbo-Croatian'}},
{'languageCode': 'shi', 'languageName': {'simpleText': 'Tachelhit'}},
{'languageCode': 'shi_Latn', 'languageName': {'simpleText': 'Tachelhit (Latin)'}},
{'languageCode': 'shi_Tfng', 'languageName': {'simpleText': 'Tachelhit (Tifinagh)'}},
{'languageCode': 'shn', 'languageName': {'simpleText': 'Shan'}},
{'languageCode': 'shu', 'languageName': {'simpleText': 'Chadian Arabic'}},
{'languageCode': 'si', 'languageName': {'simpleText': 'Sinhala'}},
{'languageCode': 'sid', 'languageName': {'simpleText': 'Sidamo'}},
{'languageCode': 'sk', 'languageName': {'simpleText': 'Slovak'}},
{'languageCode': 'sl', 'languageName': {'simpleText': 'Slovenian'}},
{'languageCode': 'sm', 'languageName': {'simpleText': 'Samoan'}},
{'languageCode': 'sma', 'languageName': {'simpleText': 'Southern Sami'}},
{'languageCode': 'smj', 'languageName': {'simpleText': 'Lule Sami'}},
{'languageCode': 'smn', 'languageName': {'simpleText': 'Inari Sami'}},
{'languageCode': 'sms', 'languageName': {'simpleText': 'Skolt Sami'}},
{'languageCode': 'sn', 'languageName': {'simpleText': 'Shona'}},
{'languageCode': 'snk', 'languageName': {'simpleText': 'Soninke'}},
{'languageCode': 'so', 'languageName': {'simpleText': 'Somali'}},
{'languageCode': 'sog', 'languageName': {'simpleText': 'Sogdien'}},
{'languageCode': 'sq', 'languageName': {'simpleText': 'Albanian'}},
{'languageCode': 'sr', 'languageName': {'simpleText': 'Serbian'}},
{'languageCode': 'sr_Cyrl', 'languageName': {'simpleText': 'Serbian (Cyrillic)'}},
{'languageCode': 'sr_Latn', 'languageName': {'simpleText': 'Serbian (Latin)'}},
{'languageCode': 'srn', 'languageName': {'simpleText': 'Sranan Tongo'}},
{'languageCode': 'srr', 'languageName': {'simpleText': 'Serer'}},
{'languageCode': 'ss', 'languageName': {'simpleText': 'Swati'}},
{'languageCode': 'ssy', 'languageName': {'simpleText': 'Saho'}},
{'languageCode': 'st', 'languageName': {'simpleText': 'Southern Sotho'}},
{'languageCode': 'su', 'languageName': {'simpleText': 'Sundanese'}},
{'languageCode': 'su_Latn', 'languageName': {'simpleText': 'Sundanese (Latin)'}},
{'languageCode': 'suk', 'languageName': {'simpleText': 'Sukuma'}},
{'languageCode': 'sus', 'languageName': {'simpleText': 'Susu'}},
{'languageCode': 'sux', 'languageName': {'simpleText': 'Sumerian'}},
{'languageCode': 'sv', 'languageName': {'simpleText': 'Swedish'}},
{'languageCode': 'sw', 'languageName': {'simpleText': 'Swahili'}},
{'languageCode': 'sw_CD', 'languageName': {'simpleText': 'Swahili (Congo - Kinshasa)'}},
{'languageCode': 'swb', 'languageName': {'simpleText': 'Comorian'}},
{'languageCode': 'syc', 'languageName': {'simpleText': 'Classical Syriac'}},
{'languageCode': 'syr', 'languageName': {'simpleText': 'Syriac'}},
{'languageCode': 'szl', 'languageName': {'simpleText': 'Silesian'}},
{'languageCode': 'ta', 'languageName': {'simpleText': 'Tamil'}},
{'languageCode': 'te', 'languageName': {'simpleText': 'Telugu'}},
{'languageCode': 'tem', 'languageName': {'simpleText': 'Timne'}},
{'languageCode': 'teo', 'languageName': {'simpleText': 'Teso'}},
{'languageCode': 'ter', 'languageName': {'simpleText': 'Tereno'}},
{'languageCode': 'tet', 'languageName': {'simpleText': 'Tetum'}},
{'languageCode': 'tg', 'languageName': {'simpleText': 'Tajik'}},
{'languageCode': 'th', 'languageName': {'simpleText': 'Thai'}},
{'languageCode': 'ti', 'languageName': {'simpleText': 'Tigrinya'}},
{'languageCode': 'tig', 'languageName': {'simpleText': 'Tigre'}},
{'languageCode': 'tiv', 'languageName': {'simpleText': 'Tiv'}},
{'languageCode': 'tk', 'languageName': {'simpleText': 'Turkmen'}},
{'languageCode': 'tkl', 'languageName': {'simpleText': 'Tokelau'}},
{'languageCode': 'tl', 'languageName': {'simpleText': 'Tagalog'}},
{'languageCode': 'tlh', 'languageName': {'simpleText': 'Klingon'}},
{'languageCode': 'tli', 'languageName': {'simpleText': 'Tlingit'}},
{'languageCode': 'tmh', 'languageName': {'simpleText': 'Tamashek'}},
{'languageCode': 'tn', 'languageName': {'simpleText': 'Tswana'}},
{'languageCode': 'to', 'languageName': {'simpleText': 'Tongan'}},
{'languageCode': 'tog', 'languageName': {'simpleText': 'Nyasa Tonga'}},
{'languageCode': 'tok', 'languageName': {'simpleText': 'Toki Pona'}},
{'languageCode': 'tpi', 'languageName': {'simpleText': 'Tok Pisin'}},
{'languageCode': 'tr', 'languageName': {'simpleText': 'Turkish'}},
{'languageCode': 'trv', 'languageName': {'simpleText': 'Taroko'}},
{'languageCode': 'ts', 'languageName': {'simpleText': 'Tsonga'}},
{'languageCode': 'tsi', 'languageName': {'simpleText': 'Tsimshian'}},
{'languageCode': 'tt', 'languageName': {'simpleText': 'Tatar'}},
{'languageCode': 'tum', 'languageName': {'simpleText': 'Tumbuka'}},
{'languageCode': 'tvl', 'languageName': {'simpleText': 'Tuvalu'}},
{'languageCode': 'tw', 'languageName': {'simpleText': 'Twi'}},
{'languageCode': 'twq', 'languageName': {'simpleText': 'Tasawaq'}},
{'languageCode': 'ty', 'languageName': {'simpleText': 'Tahitian'}},
{'languageCode': 'tyv', 'languageName': {'simpleText': 'Tuvinian'}},
{'languageCode': 'tzm', 'languageName': {'simpleText': 'Central Atlas Tamazight'}},
{'languageCode': 'udm', 'languageName': {'simpleText': 'Udmurt'}},
{'languageCode': 'ug', 'languageName': {'simpleText': 'Uyghur'}},
{'languageCode': 'uga', 'languageName': {'simpleText': 'Ugaritic'}},
{'languageCode': 'uk', 'languageName': {'simpleText': 'Ukrainian'}},
{'languageCode': 'umb', 'languageName': {'simpleText': 'Umbundu'}},
{'languageCode': 'ur', 'languageName': {'simpleText': 'Urdu'}},
{'languageCode': 'uz', 'languageName': {'simpleText': 'Uzbek'}},
{'languageCode': 'uz_Arab', 'languageName': {'simpleText': 'Uzbek (Arabic)'}},
{'languageCode': 'uz_Cyrl', 'languageName': {'simpleText': 'Uzbek (Cyrillic)'}},
{'languageCode': 'uz_Latn', 'languageName': {'simpleText': 'Uzbek (Latin)'}},
{'languageCode': 'vai', 'languageName': {'simpleText': 'Vai'}},
{'languageCode': 'vai_Latn', 'languageName': {'simpleText': 'Vai (Latin)'}},
{'languageCode': 'vai_Vaii', 'languageName': {'simpleText': 'Vai (Vai)'}},
{'languageCode': 've', 'languageName': {'simpleText': 'Venda'}},
{'languageCode': 'vec', 'languageName': {'simpleText': 'Venetian'}},
{'languageCode': 'vi', 'languageName': {'simpleText': 'Vietnamese'}},
{'languageCode': 'vmw', 'languageName': {'simpleText': 'Makhuwa'}},
{'languageCode': 'vo', 'languageName': {'simpleText': 'Volap\u00fck'}},
{'languageCode': 'vot', 'languageName': {'simpleText': 'Votic'}},
{'languageCode': 'vun', 'languageName': {'simpleText': 'Vunjo'}},
{'languageCode': 'wa', 'languageName': {'simpleText': 'Walloon'}},
{'languageCode': 'wae', 'languageName': {'simpleText': 'Walser'}},
{'languageCode': 'wal', 'languageName': {'simpleText': 'Wolaytta'}},
{'languageCode': 'war', 'languageName': {'simpleText': 'Waray'}},
{'languageCode': 'was', 'languageName': {'simpleText': 'Washo'}},
{'languageCode': 'wo', 'languageName': {'simpleText': 'Wolof'}},
{'languageCode': 'xal', 'languageName': {'simpleText': 'Kalmyk'}},
{'languageCode': 'xh', 'languageName': {'simpleText': 'Xhosa'}},
{'languageCode': 'xnr', 'languageName': {'simpleText': 'Kangri'}},
{'languageCode': 'xog', 'languageName': {'simpleText': 'Soga'}},
{'languageCode': 'yao', 'languageName': {'simpleText': 'Yao'}},
{'languageCode': 'yap', 'languageName': {'simpleText': 'Yapese'}},
{'languageCode': 'yav', 'languageName': {'simpleText': 'Yangben'}},
{'languageCode': 'ybb', 'languageName': {'simpleText': 'Yemba'}},
{'languageCode': 'yi', 'languageName': {'simpleText': 'Yiddish'}},
{'languageCode': 'yo', 'languageName': {'simpleText': 'Yoruba'}},
{'languageCode': 'yrl', 'languageName': {'simpleText': 'Nheengatu'}},
{'languageCode': 'yue', 'languageName': {'simpleText': 'Cantonese'}},
{'languageCode': 'yue_Hans', 'languageName': {'simpleText': 'Cantonese (Simplified)'}},
{'languageCode': 'yue_Hant', 'languageName': {'simpleText': 'Cantonese (Traditional)'}},
{'languageCode': 'za', 'languageName': {'simpleText': 'Zhuang'}},
{'languageCode': 'zap', 'languageName': {'simpleText': 'Zapotec'}},
{'languageCode': 'zbl', 'languageName': {'simpleText': 'Blissymbols'}},
{'languageCode': 'zen', 'languageName': {'simpleText': 'Zenaga'}},
{'languageCode': 'zgh', 'languageName': {'simpleText': 'Standard Moroccan Tamazight'}},
{'languageCode': 'zh', 'languageName': {'simpleText': 'Chinese'}},
{'languageCode': 'zh_Hans', 'languageName': {'simpleText': 'Chinese (Simplified)'}},
{'languageCode': 'zh_Hant', 'languageName': {'simpleText': 'Chinese (Traditional)'}},
{'languageCode': 'zh_TW', 'languageName': {'simpleText': 'Chinese (Taiwan)'}},
{'languageCode': 'zu', 'languageName': {'simpleText': 'Zulu'}},
{'languageCode': 'zun', 'languageName': {'simpleText': 'Zuni'}},
{'languageCode': 'zxx', 'languageName': {'simpleText': 'No linguistic content'}},
{'languageCode': 'zza', 'languageName': {'simpleText': 'Zaza'}},
]

View File

@@ -0,0 +1,60 @@
# -*- 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
INTERNAL = '/kodion'
BOOKMARKS = INTERNAL + '/bookmarks'
COMMAND = INTERNAL + '/command'
EXTERNAL_SEARCH = '/search'
GOTO_PAGE = INTERNAL + '/goto_page'
ROUTE = INTERNAL + '/route'
SEARCH = INTERNAL + '/search'
WATCH_LATER = INTERNAL + '/watch_later'
HISTORY = INTERNAL + '/playback_history'
CHANNEL = '/channel'
MY_CHANNEL = CHANNEL + '/mine'
LIKED_VIDEOS = CHANNEL + '/mine/playlist/LL'
MY_PLAYLIST = CHANNEL + '/mine/playlist'
MY_PLAYLISTS = CHANNEL + '/mine/playlists'
HOME = '/home'
MAINTENANCE = '/maintenance'
PLAY = '/play'
PLAYLIST = '/playlist'
SUBSCRIPTIONS = '/subscriptions'
VIDEO = '/video'
SPECIAL = '/special'
DESCRIPTION_LINKS = SPECIAL + '/description_links'
DISLIKED_VIDEOS = SPECIAL + '/disliked_videos'
LIVE_VIDEOS = SPECIAL + '/live'
LIVE_VIDEOS_COMPLETED = SPECIAL + '/completed_live'
LIVE_VIDEOS_UPCOMING = SPECIAL + '/upcoming_live'
MY_SUBSCRIPTIONS = SPECIAL + '/my_subscriptions'
MY_SUBSCRIPTIONS_FILTERED = SPECIAL + '/my_subscriptions_filtered'
RECOMMENDATIONS = SPECIAL + '/recommendations'
RELATED_VIDEOS = SPECIAL + '/related_videos'
SAVED_PLAYLISTS = SPECIAL + '/saved_playlists'
TRENDING = SPECIAL + '/popular_right_now'
VIDEO_COMMENTS = SPECIAL + '/parent_comments'
VIDEO_COMMENTS_THREAD = SPECIAL + '/child_comments'
VIRTUAL_PLAYLIST = SPECIAL + '/playlist'
HTTP_SERVER = '/youtube'
API = HTTP_SERVER + '/api'
API_SUBMIT = HTTP_SERVER + '/api/submit'
DRM = HTTP_SERVER + '/widevine'
IP = HTTP_SERVER + '/client_ip'
MPD = HTTP_SERVER + '/manifest/dash'
PING = HTTP_SERVER + '/ping'
REDIRECT = HTTP_SERVER + '/redirect'
STREAM_PROXY = HTTP_SERVER + '/stream'

View File

@@ -0,0 +1,139 @@
# -*- 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
SETUP_WIZARD = 'kodion.setup_wizard' # (bool)
SETUP_WIZARD_RUNS = 'kodion.setup_wizard.forced_runs' # (int)
SETTINGS_END = '|end_settings_marker|' # (bool)
MPD_VIDEOS = 'kodion.mpd.videos' # (bool)
MPD_STREAM_SELECT = 'kodion.mpd.stream.select' # (int)
MPD_QUALITY_SELECTION = 'kodion.mpd.quality.selection' # (int)
MPD_STREAM_FEATURES = 'kodion.mpd.stream.features' # (list[str])
VIDEO_STREAM_SELECT = 'kodion.video.stream.select' # (int)
VIDEO_QUALITY_ASK = 'kodion.video.quality.ask' # (bool)
VIDEO_QUALITY = 'kodion.video.quality' # (int)
AUDIO_ONLY = 'kodion.audio_only' # (bool)
SUBTITLE_SELECTION = 'kodion.subtitle.languages.num' # (int)
SUBTITLE_DOWNLOAD = 'kodion.subtitle.download' # (bool)
ITEMS_PER_PAGE = 'kodion.content.max_per_page' # (int)
HIDE_VIDEOS = 'youtube.view.hide_videos' # (list[str])
SHORTS_DURATION = 'youtube.view.shorts.duration' # (int)
FILTER_LIST = 'youtube.view.filter.list' # (str)
SUBSCRIPTIONS_FILTER_ENABLED = 'youtube.folder.my_subscriptions_filtered.show' # (bool)
SUBSCRIPTIONS_FILTER_BLACKLIST = 'youtube.filter.my_subscriptions_filtered.blacklist' # (bool)
SUBSCRIPTIONS_FILTER_LIST = 'youtube.filter.my_subscriptions_filtered.list' # (str)
SAFE_SEARCH = 'kodion.safe.search' # (int)
AGE_GATE = 'kodion.age.gate' # (bool)
API_CONFIG_PAGE = 'youtube.api.config.page' # (bool)
API_KEY = 'youtube.api.key' # (str)
API_ID = 'youtube.api.id' # (str)
API_SECRET = 'youtube.api.secret' # (str)
ALLOW_DEV_KEYS = 'youtube.allow.dev.keys' # (bool)
SHOW_SIGN_IN = 'youtube.folder.sign.in.show' # (bool)
SHOW_MY_SUBSCRIPTIONS = 'youtube.folder.my_subscriptions.show' # (bool)
SHOW_MY_SUBSCRIPTIONS_FILTERED = 'youtube.folder.my_subscriptions_filtered.show' # (bool)
SHOW_RECOMMENDATIONS = 'youtube.folder.recommendations.show' # (bool)
SHOW_RELATED = 'youtube.folder.related.show' # (bool)
SHOW_TRENDING = 'youtube.folder.popular_right_now.show' # (bool)
SHOW_SEARCH = 'youtube.folder.search.show' # (bool)
SHOW_QUICK_SEARCH = 'youtube.folder.quick_search.show' # (bool)
SHOW_INCOGNITO_SEARCH = 'youtube.folder.quick_search_incognito.show' # (bool)
SHOW_MY_LOCATION = 'youtube.folder.my_location.show' # (bool)
SHOW_MY_CHANNEL = 'youtube.folder.my_channel.show' # (bool)
SHOW_WATCH_LATER = 'youtube.folder.watch_later.show' # (bool)
SHOW_LIKED = 'youtube.folder.liked_videos.show' # (bool)
SHOW_DISLIKED = 'youtube.folder.disliked_videos.show' # (bool)
SHOW_HISTORY = 'youtube.folder.history.show' # (bool)
SHOW_PLAYLISTS = 'youtube.folder.playlists.show' # (bool)
SHOW_SAVED_PLAYLISTS = 'youtube.folder.saved.playlists.show' # (bool)
SHOW_SUBSCRIPTIONS = 'youtube.folder.subscriptions.show' # (bool)
SHOW_BOOKMARKS = 'youtube.folder.bookmarks.show' # (bool)
SHOW_BROWSE_CHANNELS = 'youtube.folder.browse_channels.show' # (bool)
SHOW_COMPlETED_LIVE = 'youtube.folder.completed.live.show' # (bool)
SHOW_UPCOMING_LIVE = 'youtube.folder.upcoming.live.show' # (bool)
SHOW_LIVE = 'youtube.folder.live.show' # (bool)
SHOW_SWITCH_USER = 'youtube.folder.switch.user.show' # (bool)
SHOW_SIGN_OUT = 'youtube.folder.sign.out.show' # (bool)
SHOW_SETUP_WIZARD = 'youtube.folder.settings.show' # (bool)
SHOW_SETTINGS = 'youtube.folder.settings.advanced.show' # (bool)
WATCH_LATER_PLAYLIST = 'youtube.folder.watch_later.playlist' # (str)
HISTORY_PLAYLIST = 'youtube.folder.history.playlist' # (str)
SUPPORT_ALTERNATIVE_PLAYER = 'kodion.support.alternative_player' # (bool)
DEFAULT_PLAYER_WEB_URLS = 'kodion.default_player.web_urls' # (bool)
ALTERNATIVE_PLAYER_WEB_URLS = 'kodion.alternative_player.web_urls' # (bool)
ALTERNATIVE_PLAYER_MPD = 'kodion.alternative_player.mpd' # (bool)
USE_ISA = 'kodion.video.quality.isa' # (bool)
LIVE_STREAMS = 'kodion.live_stream.selection' # (int)
USE_LOCAL_HISTORY = 'kodion.history.local' # (bool)
USE_REMOTE_HISTORY = 'kodion.history.remote' # (bool)
SEARCH_SIZE = 'kodion.search.size' # (int)
CACHE_SIZE = 'kodion.cache.size' # (int)
CHANNEL_NAME_ALIASES = 'youtube.view.channel_name.aliases' # (list[str])
DETAILED_DESCRIPTION = 'youtube.view.description.details' # (bool)
DETAILED_LABELS = 'youtube.view.label.details' # (bool)
LABEL_COLOR = 'youtube.view.label.color' # (str)
THUMB_SIZE = 'kodion.thumbnail.size' # (int)
THUMB_SIZE_BEST = 2
FANART_SELECTION = 'kodion.fanart.selection' # (int)
FANART_CHANNEL = 2
FANART_THUMBNAIL = 3
LANGUAGE = 'youtube.language' # (str)
REGION = 'youtube.region' # (str)
LOCATION = 'youtube.location' # (str)
LOCATION_RADIUS = 'youtube.location.radius' # (int)
PLAY_SUGGESTED = 'youtube.suggested_videos' # (bool)
PLAY_COUNT_MIN_PERCENT = 'kodion.play_count.percent' # (int)
RATE_VIDEOS = 'youtube.post.play.rate' # (bool)
RATE_PLAYLISTS = 'youtube.post.play.rate.playlists' # (bool)
PLAY_REFRESH = 'youtube.post.play.refresh' # (bool)
WATCH_LATER_REMOVE = 'youtube.playlist.watchlater.autoremove' # (bool)
VERIFY_SSL = 'requests.ssl.verify' # (bool)
CONNECT_TIMEOUT = 'requests.timeout.connect' # (int)
READ_TIMEOUT = 'requests.timeout.read' # (int)
REQUESTS_CACHE_SIZE = 'requests.cache.size' # (int)
PROXY_SOURCE = 'requests.proxy.source' # (int)
PROXY_ENABLED = 'requests.proxy.enabled' # (bool)
PROXY_TYPE = 'requests.proxy.type' # (int)
PROXY_SERVER = 'requests.proxy.server' # (str)
PROXY_PORT = 'requests.proxy.port' # (int)
PROXY_USERNAME = 'requests.proxy.username' # (str)
PROXY_PASSWORD = 'requests.proxy.password' # (str)
HTTPD_PORT = 'kodion.http.port' # (int)
HTTPD_LISTEN = 'kodion.http.listen' # (str)
HTTPD_WHITELIST = 'kodion.http.ip.whitelist' # (str)
HTTPD_IDLE_SLEEP = 'youtube.http.idle_sleep' # (bool)
HTTPD_STREAM_REDIRECT = 'youtube.http.stream_redirect' # (bool)
LOG_LEVEL = 'kodion.debug.log.level' # (int)
EXEC_LIMIT = 'kodion.debug.exec.limit' # (int)

View File

@@ -0,0 +1,210 @@
# -*- 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
import sys
from . import const_content_types as CONTENT
from ..compatibility import (
xbmc,
xbmcplugin,
)
# Sort methods exposed via xbmcplugin vary by Kodi version.
# Rather than try to access them directly they are hardcoded here and checked
# against what is defined in xbmcplugin.
# IDs of sort methods exposed via xbmcplugin are defined in SortMethod
# https://github.com/xbmc/xbmc/blob/master/xbmc/SortFileItem.h
# IDs of sort methods used by the Kodi GUI and builtins are defined in SortBy
# https://github.com/xbmc/xbmc/blob/master/xbmc/utils/SortUtils.h
# The IDs don't match...
# As a workaround they are mapped here using the localised label used in the GUI
# to allow the sort methods to be set by the addon and then changed dynamically
# using infolabels and builtins
methods = [
# Sort method name, Label ID, SortBy ID
('UNSORTED', 571, 0),
('NONE', 16018, 0),
('LABEL', 551, 1),
('LABEL_IGNORE_THE', 551, None),
('DATE', 552, 2),
('SIZE', 553, 3),
('FILE', 561, 4),
('FULLPATH', 573, 5),
('DRIVE_TYPE', 564, 6),
('TITLE', 556, 7),
('TITLE_IGNORE_THE', 556, None),
('TRACKNUM', 554, 8),
('DURATION', 180, 9),
('ARTIST', 557, 10),
('ARTIST_IGNORE_THE', 557, None),
('ALBUM', 558, 12),
('ALBUM_IGNORE_THE', 558, None),
('GENRE', 515, 14),
('COUNTRY', 574, 15),
('VIDEO_YEAR', 562, 16),
('VIDEO_RATING', 563, 17),
('VIDEO_USER_RATING', 38018, 18),
('PROGRAM_COUNT', 567, 21),
('PLAYLIST_ORDER', 559, 22),
('EPISODE', 20359, 23),
('DATEADDED', 570, 40),
('VIDEO_TITLE', 556, 7),
('VIDEO_SORT_TITLE', 171, 29),
('VIDEO_SORT_TITLE_IGNORE_THE', 171, None),
('VIDEO_RUNTIME', 180, 9),
('PRODUCTIONCODE', 20368, 30),
('SONG_RATING', 563, 17),
('SONG_USER_RATING', 38018, 18),
('MPAA_RATING', 20074, 31),
('STUDIO', 572, 39),
('STUDIO_IGNORE_THE', 572, None),
('LABEL_IGNORE_FOLDERS', 551, None),
('LASTPLAYED', 568, 41),
('PLAYCOUNT', 567, 42),
('LISTENERS', 20455, 43),
('BITRATE', 623, 44),
('CHANNEL', 19029, 46),
('DATE_TAKEN', 577, 48),
('VIDEO_ORIGINAL_TITLE', 20376, 57),
('VIDEO_ORIGINAL_TITLE_IGNORE_THE', 20376, None),
]
SORT_ID_MAPPING = {}
SORT = sys.modules[__name__]
name = label_id = sort_by = sort_method = None
for name, label_id, sort_by in methods:
sort_method = getattr(xbmcplugin, 'SORT_METHOD_' + name, 0)
setattr(SORT, name, sort_method)
if sort_by is not None:
SORT_ID_MAPPING.update((
(name, sort_by),
(xbmc.getLocalizedString(label_id), sort_by),
(sort_method, sort_by if sort_method else 0),
))
SORT_ID_MAPPING.update((
(CONTENT.VIDEO_CONTENT.join(('__', '__')), SORT.UNSORTED),
(CONTENT.LIST_CONTENT.join(('__', '__')), SORT.LABEL),
(CONTENT.COMMENTS.join(('__', '__')), SORT.CHANNEL),
(CONTENT.HISTORY.join(('__', '__')), SORT.LASTPLAYED),
))
SORT_DIR = {
xbmc.getLocalizedString(584): 'ascending',
xbmc.getLocalizedString(585): 'descending',
}
# Label mask token details:
# https://github.com/xbmc/xbmc/blob/master/xbmc/utils/LabelFormatter.cpp#L33-L105
# SORT.TRACKNUM is always included after SORT.UNSORTED to allow for
# manual/automatic setting of default sort method
LIST_CONTENT_DETAILED = (
(SORT.UNSORTED, '%T \u2022 %P', '%D | %J'),
(SORT.TRACKNUM, '[%N. ]%T \u2022 %P', '%D | %J'),
(SORT.LABEL, '%T \u2022 %P', '%D | %J'),
)
LIST_CONTENT_SIMPLE = (
(SORT.UNSORTED, '%T', '%D'),
(SORT.TRACKNUM, '[%N. ]%T', '%D'),
(SORT.LABEL, '%T', '%D'),
)
PLAYLIST_CONTENT_DETAILED = (
(SORT.TRACKNUM, '[%N. ]%T \u2022 %P', '%D | %J'),
(SORT.LABEL, '[%N. ]%T \u2022 %P', '%D | %J'),
(SORT.CHANNEL, '[%N. ][%A - ]%T \u2022 %P', '%D | %J'),
(SORT.ARTIST, '[%N. ]%T \u2022 %P | %D | %J', '%A'),
(SORT.PROGRAM_COUNT, '[%N. ]%T \u2022 %P | %D | %J', '%C'),
(SORT.VIDEO_RATING, '[%N. ]%T \u2022 %P | %D | %J', '%R'),
(SORT.DATE, '[%N. ]%T \u2022 %P | %D', '%J'),
(SORT.DATEADDED, '[%N. ]%T \u2022 %P | %D', '%a'),
(SORT.VIDEO_RUNTIME, '[%N. ]%T \u2022 %P | %J', '%D'),
)
PLAYLIST_CONTENT_SIMPLE = (
(SORT.TRACKNUM, '[%N. ]%T', '%D'),
(SORT.LABEL, '[%N. ]%T', '%D'),
(SORT.CHANNEL, '[%N. ][%A - ]%T', '%D'),
(SORT.ARTIST, '[%N. ]%T', '%A'),
(SORT.PROGRAM_COUNT, '[%N. ]%T', '%C'),
(SORT.VIDEO_RATING, '[%N. ]%T', '%R'),
(SORT.DATE, '[%N. ]%T', '%J'),
(SORT.DATEADDED, '[%N. ]%T', '%a'),
(SORT.VIDEO_RUNTIME, '[%N. ]%T', '%D'),
)
VIDEO_CONTENT_DETAILED = (
(SORT.UNSORTED, '%T \u2022 %P', '%D | %J'),
(SORT.TRACKNUM, '[%N. ]%T \u2022 %P', '%D | %J'),
(SORT.LABEL, '%T \u2022 %P', '%D | %J'),
(SORT.CHANNEL, '[%A - ]%T \u2022 %P', '%D | %J'),
(SORT.ARTIST, '%T \u2022 %P | %D | %J', '%A'),
(SORT.PROGRAM_COUNT, '%T \u2022 %P | %D | %J', '%C'),
(SORT.VIDEO_RATING, '%T \u2022 %P | %D | %J', '%R'),
(SORT.DATE, '%T \u2022 %P | %D', '%J'),
(SORT.DATEADDED, '%T \u2022 %P | %D', '%a'),
(SORT.VIDEO_RUNTIME, '%T \u2022 %P | %J', '%D'),
)
VIDEO_CONTENT_SIMPLE = (
(SORT.UNSORTED, '%T', '%D'),
(SORT.TRACKNUM, '[%N. ]%T', '%D'),
(SORT.LABEL, '%T', '%D'),
(SORT.CHANNEL, '[%A - ]%T', '%D'),
(SORT.ARTIST, '%T', '%A'),
(SORT.PROGRAM_COUNT, '%T', '%C'),
(SORT.VIDEO_RATING, '%T', '%R'),
(SORT.DATE, '%T', '%J'),
(SORT.DATEADDED, '%T', '%a'),
(SORT.VIDEO_RUNTIME, '%T', '%D'),
)
HISTORY_CONTENT_DETAILED = (
(SORT.LASTPLAYED, '%T \u2022 %P | %J', '%D | %p'),
(SORT.PLAYCOUNT, '%T \u2022 %P | %J', '%D | %V'),
)
HISTORY_CONTENT_DETAILED += VIDEO_CONTENT_DETAILED
HISTORY_CONTENT_SIMPLE = (
(SORT.LASTPLAYED, '%T', '%D | %p'),
(SORT.PLAYCOUNT, '%T', '%D | %V'),
)
HISTORY_CONTENT_SIMPLE += VIDEO_CONTENT_SIMPLE
COMMENTS_CONTENT_DETAILED = (
(SORT.CHANNEL, '[%A - ]%P \u2022 %T', '%J'),
(SORT.TRACKNUM, '[%N. ][%A - ]%P \u2022 %T', '%J'),
(SORT.ARTIST, '[%J - ]%P \u2022 %T', '%A'),
(SORT.PROGRAM_COUNT, '[%A - ]%P | %J \u2022 %T', '%C'),
(SORT.DATE, '[%A - ]%P \u2022 %T', '%J'),
)
COMMENTS_CONTENT_SIMPLE = (
(SORT.CHANNEL, '[%A - ]%T', '%J'),
(SORT.TRACKNUM, '[%N. ][%A - ]%T', '%J'),
(SORT.ARTIST, '[%A - ]%T', '%A'),
(SORT.PROGRAM_COUNT, '[%A - ]%T', '%C'),
(SORT.DATE, '[%A - ]%T', '%J'),
)
del (
sys,
CONTENT,
xbmc,
xbmcplugin,
methods,
SORT,
name,
label_id,
sort_by,
sort_method,
)

View File

@@ -0,0 +1,15 @@
# -*- coding: utf-8 -*-
"""
Copyright (C) 2023-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 .xbmc.xbmc_context import XbmcContext
__all__ = ('XbmcContext',)

View File

@@ -0,0 +1,704 @@
# -*- 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
import os
from .. import logging
from ..compatibility import (
default_quote,
parse_qsl,
string_type,
to_str,
unquote,
urlencode,
urlsplit,
)
from ..constants import (
ACTION,
ADDON_ID_PARAM,
BOOL_FROM_STR,
CHANNEL_ID,
CHANNEL_IDS,
CLIP,
CONTEXT_MENU,
END,
FANART_TYPE,
HIDE_CHANNELS,
HIDE_FOLDERS,
HIDE_LIVE,
HIDE_MEMBERS,
HIDE_NEXT_PAGE,
HIDE_PLAYLISTS,
HIDE_PROGRESS,
HIDE_SEARCH,
HIDE_SHORTS,
HIDE_VIDEOS,
INCOGNITO,
ITEMS_PER_PAGE,
ITEM_FILTER,
KEYMAP,
LIVE,
ORDER,
PAGE,
PATHS,
PLAYLIST_ID,
PLAYLIST_IDS,
PLAYLIST_ITEM_ID,
PLAY_FORCE_AUDIO,
PLAY_PROMPT_QUALITY,
PLAY_PROMPT_SUBTITLES,
PLAY_STRM,
PLAY_TIMESHIFT,
PLAY_USING,
SCREENSAVER,
SEEK,
SORT_DIR,
SORT_METHOD,
START,
SUBSCRIPTION_ID,
VIDEO_ID,
VIDEO_IDS,
WINDOW_CACHE,
WINDOW_FALLBACK,
WINDOW_REPLACE,
WINDOW_RETURN,
)
from ..sql_store import (
BookmarksList,
DataCache,
FeedHistory,
FunctionCache,
PlaybackHistory,
RequestCache,
SearchHistory,
WatchLaterList,
)
from ..utils.system_version import current_system_version
class AbstractContext(object):
log = logging.getLogger(__name__)
_initialized = False
_addon = None
_settings = None
_BOOL_PARAMS = frozenset((
CONTEXT_MENU,
KEYMAP,
PLAY_FORCE_AUDIO,
PLAY_PROMPT_SUBTITLES,
PLAY_PROMPT_QUALITY,
PLAY_STRM,
PLAY_TIMESHIFT,
PLAY_USING,
'confirmed',
CLIP,
'enable',
HIDE_CHANNELS,
HIDE_FOLDERS,
HIDE_LIVE,
HIDE_MEMBERS,
HIDE_NEXT_PAGE,
HIDE_PLAYLISTS,
HIDE_PROGRESS,
HIDE_SEARCH,
HIDE_SHORTS,
HIDE_VIDEOS,
INCOGNITO,
'location',
'logged_in',
'resume',
SCREENSAVER,
WINDOW_CACHE,
WINDOW_FALLBACK,
WINDOW_REPLACE,
WINDOW_RETURN,
))
_INT_PARAMS = frozenset((
FANART_TYPE,
'filtered',
ITEMS_PER_PAGE,
LIVE,
'next_page_token',
PAGE,
'refresh',
))
_INT_BOOL_PARAMS = frozenset((
'refresh',
))
_FLOAT_PARAMS = frozenset((
END,
'recent_days',
SEEK,
START,
))
_LIST_PARAMS = frozenset((
CHANNEL_IDS,
'exclude',
ITEM_FILTER,
PLAYLIST_IDS,
VIDEO_IDS,
))
_STRING_PARAMS = frozenset((
'api_key',
ACTION,
ADDON_ID_PARAM,
'category_label',
CHANNEL_ID,
'client_id',
'client_secret',
'click_tracking',
'event_type',
'item',
'item_id',
'item_name',
ORDER,
'page_token',
'parent_id',
'playlist', # deprecated
PLAYLIST_ITEM_ID,
PLAYLIST_ID,
'q',
'rating',
'reload_path',
SORT_DIR,
SORT_METHOD,
'search_type',
SUBSCRIPTION_ID,
'uri',
'videoid', # deprecated
VIDEO_ID,
'visitor',
))
_STRING_BOOL_PARAMS = frozenset((
'logged_in',
'reload_path',
))
_STRING_INT_PARAMS = frozenset((
))
_NON_EMPTY_STRING_PARAMS = set()
def __init__(self, path='/', params=None, plugin_id=''):
self._access_manager = None
self._uuid = None
self._api_store = None
self._bookmarks_list = None
self._data_cache = None
self._feed_history = None
self._function_cache = None
self._playback_history = None
self._requests_cache = None
self._search_history = None
self._watch_later_list = None
self._plugin_handle = -1
self._plugin_id = plugin_id
self._plugin_name = None
self._plugin_icon = None
self._version = 'UNKNOWN'
self._param_string = ''
self._params = params or {}
if params:
self.parse_params(params)
self._uri = None
self._path = None
self._path_parts = []
self.set_path(path, force=True)
@staticmethod
def format_date_short(date_obj, str_format=None):
raise NotImplementedError()
@staticmethod
def format_time(time_obj, str_format=None):
raise NotImplementedError()
@staticmethod
def get_language():
raise NotImplementedError()
def get_language_name(self, lang_id=None):
raise NotImplementedError()
def get_player_language(self):
raise NotImplementedError()
def get_subtitle_language(self):
raise NotImplementedError()
def get_region(self):
raise NotImplementedError()
def get_playback_history(self):
uuid = self.get_uuid()
playback_history = self._playback_history
if not playback_history or playback_history.uuid != uuid:
filepath = (self.get_data_path(), uuid, 'history.sqlite')
playback_history = PlaybackHistory(filepath)
self._playback_history = playback_history
return playback_history
def get_feed_history(self):
uuid = self.get_uuid()
feed_history = self._feed_history
if not feed_history or feed_history.uuid != uuid:
filepath = (self.get_data_path(), uuid, 'feeds.sqlite')
feed_history = FeedHistory(filepath)
self._feed_history = feed_history
return feed_history
def get_data_cache(self):
uuid = self.get_uuid()
data_cache = self._data_cache
if not data_cache or data_cache.uuid != uuid:
filepath = (self.get_data_path(), uuid, 'data_cache.sqlite')
data_cache = DataCache(
filepath,
max_file_size_mb=self.get_settings().cache_size() / 2,
)
self._data_cache = data_cache
return data_cache
def get_function_cache(self):
uuid = self.get_uuid()
function_cache = self._function_cache
if not function_cache or function_cache.uuid != uuid:
filepath = (self.get_data_path(), uuid, 'cache.sqlite')
function_cache = FunctionCache(
filepath,
max_file_size_mb=self.get_settings().cache_size() / 2,
)
self._function_cache = function_cache
return function_cache
def get_requests_cache(self):
uuid = self.get_uuid()
requests_cache = self._requests_cache
if not requests_cache or requests_cache.uuid != uuid:
filepath = (self.get_data_path(), uuid, 'requests_cache.sqlite')
requests_cache = RequestCache(
filepath,
max_file_size_mb=self.get_settings().requests_cache_size(),
)
self._requests_cache = requests_cache
return requests_cache
def get_search_history(self):
uuid = self.get_uuid()
search_history = self._search_history
if not search_history or search_history.uuid != uuid:
filepath = (self.get_data_path(), uuid, 'search.sqlite')
search_history = SearchHistory(
filepath,
max_item_count=self.get_settings().get_search_history_size(),
)
self._search_history = search_history
return search_history
def get_bookmarks_list(self):
uuid = self.get_uuid()
bookmarks_list = self._bookmarks_list
if not bookmarks_list or bookmarks_list.uuid != uuid:
filepath = (self.get_data_path(), uuid, 'bookmarks.sqlite')
bookmarks_list = BookmarksList(filepath)
self._bookmarks_list = bookmarks_list
return bookmarks_list
def get_watch_later_list(self):
uuid = self.get_uuid()
watch_later_list = self._watch_later_list
if not watch_later_list or watch_later_list.uuid != uuid:
filepath = (self.get_data_path(), uuid, 'watch_later.sqlite')
watch_later_list = WatchLaterList(filepath)
self._watch_later_list = watch_later_list
return watch_later_list
def get_uuid(self):
uuid = self._uuid
if not uuid:
uuid = self.get_access_manager().get_current_user_id()
self._uuid = uuid
return uuid
def get_access_manager(self):
access_manager = self._access_manager
if access_manager:
return access_manager
return self.reload_access_manager()
def reload_access_manager(self):
raise NotImplementedError()
def get_api_store(self):
api_store = self._api_store
if api_store:
return api_store
return self.reload_api_store()
def reload_api_store(self):
raise NotImplementedError()
def get_playlist_player(self, playlist_type=None):
raise NotImplementedError()
def get_ui(self):
raise NotImplementedError()
@staticmethod
def get_system_version():
return current_system_version
def create_uri(self,
path=None,
params=None,
parse_params=False,
run=False,
play=None,
window=None,
command=False):
if isinstance(path, (list, tuple)):
uri = self.create_path(*path, is_uri=True)
elif path:
uri = path
else:
uri = '/'
if not uri.startswith('plugin://'):
uri = self._plugin_id.join(('plugin://', uri))
if params:
if isinstance(params, string_type):
if parse_params:
params = dict(parse_qsl(params, keep_blank_values=True))
else:
parse_params = True
if parse_params:
if isinstance(params, dict):
params = params.items()
params = urlencode([
(
('%' + param,
','.join([default_quote(item) for item in value]))
if len(value) > 1 else
(param, value[0])
)
if value and isinstance(value, (list, tuple)) else
(param, value)
for param, value in params
])
uri = '?'.join((uri, params))
command = 'command://' if command else ''
if run:
return ''.join((command, 'RunPlugin(', uri, ')'))
if play is not None:
return ''.join((
command,
'PlayMedia(',
uri,
',playlist_type_hint=', str(play), ')',
))
if window:
if not isinstance(window, dict):
window = {}
if window.setdefault('refresh', False):
method = 'Container.Refresh('
if not window.setdefault('replace', False):
uri = ''
history_replace = False
window_return = False
elif window.setdefault('update', False):
method = 'Container.Update('
history_replace = window.setdefault('replace', False)
window_return = False
else:
history_replace = False
window_name = window.setdefault('name', 'Videos')
if window.setdefault('replace', False):
method = 'ReplaceWindow(%s,' % window_name
window_return = window.setdefault('return', False)
else:
method = 'ActivateWindow(%s,' % window_name
window_return = window.setdefault('return', True)
return ''.join((
command,
method,
uri,
',return' if window_return else '',
',replace' if history_replace else '',
')'
))
return uri
def get_parent_uri(self, **kwargs):
return self.create_uri(self._path_parts[:-1], **kwargs)
@staticmethod
def create_path(*args, **kwargs):
include_parts = kwargs.get('parts')
parser = kwargs.get('parser')
parts = [
parser(part[6:-1])
if parser and part.startswith('$INFO[') else
part
for part in [
to_str(arg).strip('/').replace('\\', '/').replace('//', '/')
for arg in args
]
if part
]
if parts:
path = '/'.join(parts).join(('/', '/'))
if path.startswith(PATHS.ROUTE):
parts = parts[2:]
elif path.startswith(PATHS.COMMAND):
parts = []
elif path.startswith(PATHS.GOTO_PAGE):
parts = parts[2:]
if parts:
try:
int(parts[0])
except (TypeError, ValueError):
pass
else:
parts = parts[1:]
else:
return ('/', parts) if include_parts else '/'
if kwargs.get('is_uri'):
path = default_quote(path)
return (path, parts) if include_parts else path
def get_path(self):
return self._path
def set_path(self, *path, **kwargs):
if kwargs.get('force'):
parts = kwargs.get('parts')
path = unquote(path[0])
if parts is None:
path = path.split('/')
path, parts = self.create_path(
*path,
parts=True,
parser=kwargs.get('parser')
)
else:
path, parts = self.create_path(*path, parts=True)
self._path = path
self._path_parts = parts
if kwargs.get('update_uri', True):
self.update_uri()
def get_original_params(self):
return self._param_string
def get_params(self):
return self._params
def get_param(self, name, default=None):
return self._params.get(name, default)
def pop_param(self, name, default=None):
return self._params.pop(name, default)
def parse_uri(self, uri, parse_params=True, update=False):
uri = urlsplit(uri)
path = uri.path
if parse_params:
params = self.parse_params(
dict(parse_qsl(uri.query, keep_blank_values=True)),
update=False,
)
if update:
self._params = params
self.set_path(path)
else:
params = uri.query
return path, params
def parse_params(self, params, update=True, parser=None):
to_delete = []
output = self._params if update else {}
for param, value in params.items():
if param.startswith('%'):
param = param[1:]
value = unquote(value)
try:
if param in self._BOOL_PARAMS:
parsed_value = BOOL_FROM_STR.get(
str(value),
bool(value)
if param in self._STRING_BOOL_PARAMS else
False
)
elif param in self._INT_PARAMS:
parsed_value = int(
(BOOL_FROM_STR.get(str(value), value) or 0)
if param in self._INT_BOOL_PARAMS else
value
)
elif param in self._FLOAT_PARAMS:
parsed_value = float(value)
elif param in self._LIST_PARAMS:
parsed_value = (
list(value)
if isinstance(value, (list, tuple)) else
[unquote(val) for val in value.split(',') if val]
)
elif param in self._STRING_PARAMS:
if parser and value.startswith('$INFO['):
parsed_value = parser(value[6:-1])
else:
parsed_value = value
if param in self._STRING_BOOL_PARAMS:
parsed_value = BOOL_FROM_STR.get(
parsed_value, parsed_value
)
elif param in self._STRING_INT_PARAMS:
try:
parsed_value = int(parsed_value)
except (TypeError, ValueError):
pass
# process and translate deprecated parameters
elif param == 'action':
if parsed_value in {'play_all', 'play_video'}:
to_delete.append(param)
self.set_path(PATHS.PLAY, update_uri=False)
continue
elif param == 'videoid':
to_delete.append(param)
param = VIDEO_ID
elif params == 'playlist':
to_delete.append(param)
param = PLAYLIST_ID
elif param in self._NON_EMPTY_STRING_PARAMS:
parsed_value = BOOL_FROM_STR.get(value, value)
if not parsed_value:
raise ValueError
else:
self.log.debug('Unknown parameter {param!r}: {value!r}',
param=param,
value=value)
to_delete.append(param)
continue
except (TypeError, ValueError):
self.log.exception('Invalid value for {param!r}: {value!r}',
param=param,
value=value)
to_delete.append(param)
continue
output[param] = parsed_value
for param in to_delete:
del params[param]
return output
def set_params(self, **kwargs):
self.parse_params(kwargs)
def get_data_path(self):
"""
Returns the path for read/write access of files
:return:
"""
raise NotImplementedError()
def get_addon_path(self):
raise NotImplementedError()
def get_icon(self):
return self._plugin_icon
def get_fanart(self):
return self.create_resource_path('media/fanart.jpg')
def create_resource_path(self, *args):
path_comps = []
for arg in args:
path_comps.extend(arg.split('/'))
path = os.path.join(self.get_addon_path(), 'resources', *path_comps)
return path
def get_uri(self):
return self._uri
def update_uri(self):
self._uri = self.create_uri(self._path, self._params)
def get_name(self):
return self._plugin_name
def get_version(self):
return self._version
def get_id(self):
return self._plugin_id
def get_handle(self):
return self._plugin_handle
def get_settings(self, refresh=False):
raise NotImplementedError()
def localize(self, text_id, args=None, default_text=None):
raise NotImplementedError()
def apply_content(self,
content_type=None,
sub_type=None,
category_label=None):
raise NotImplementedError()
def add_sort_method(self, *sort_methods):
raise NotImplementedError()
def clone(self, new_path=None, new_params=None):
raise NotImplementedError()
def execute(self,
command,
wait=False,
wait_for=None,
wait_for_set=True,
block_ui=None):
raise NotImplementedError()
@staticmethod
def sleep(timeout=None):
raise NotImplementedError()
def tear_down(self):
pass
def ipc_exec(self, target, timeout=None, payload=None, raise_exc=False):
raise NotImplementedError()
@staticmethod
def is_plugin_folder(folder_name=None):
raise NotImplementedError()
def refresh_requested(self, force=False, on=False, off=False, params=None):
raise NotImplementedError
def parse_item_ids(self,
uri=None,
from_listitem=True):
raise NotImplementedError()

View File

@@ -0,0 +1,358 @@
# -*- 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
import sys
import threading
import time
from atexit import register as atexit_register
from cProfile import Profile
from functools import wraps
from inspect import getargvalues
from os.path import normpath
from pstats import Stats
from traceback import extract_stack, format_list
from weakref import ref
from . import logging
from .compatibility import StringIO
def debug_here(host='localhost'):
import os
import sys
for comp in sys.path:
if comp.find('addons') != -1:
pydevd_path = os.path.normpath(os.path.join(
comp,
os.pardir,
'script.module.pydevd',
'lib',
))
sys.path.append(pydevd_path)
break
# noinspection PyUnresolvedReferences,PyPackageRequirements
import pydevd
pydevd.settrace(host, stdoutToServer=True, stderrToServer=True)
class ProfilerProxy(ref):
def __call__(self, *args, **kwargs):
return super(ProfilerProxy, self).__call__().__call__(
*args, **kwargs
)
def __enter__(self, *args, **kwargs):
return super(ProfilerProxy, self).__call__().__enter__(
*args, **kwargs
)
def __exit__(self, *args, **kwargs):
return super(ProfilerProxy, self).__call__().__exit__(
*args, **kwargs
)
def disable(self, *args, **kwargs):
return super(ProfilerProxy, self).__call__().disable(
*args, **kwargs
)
def enable(self, *args, **kwargs):
return super(ProfilerProxy, self).__call__().enable(
*args, **kwargs
)
def get_stats(self, *args, **kwargs):
return super(ProfilerProxy, self).__call__().get_stats(
*args, **kwargs
)
def print_stats(self, *args, **kwargs):
return super(ProfilerProxy, self).__call__().print_stats(
*args, **kwargs
)
def tear_down(self, *args, **kwargs):
return super(ProfilerProxy, self).__call__().tear_down(
*args, **kwargs
)
class Profiler(object):
"""Class used to profile a block of code"""
__slots__ = (
'__weakref__',
'_enabled',
'_num_lines',
'_print_callees',
'_profiler',
'_reuse',
'_sort_by',
'_timer',
)
log = logging.getLogger(__name__)
_instances = set()
def __new__(cls, *args, **kwargs):
self = super(Profiler, cls).__new__(cls)
cls._instances.add(self)
if not kwargs.get('enabled') or kwargs.get('lazy'):
self.__init__(*args, **kwargs)
return ProfilerProxy(self)
return self
def __init__(self,
enabled=True,
lazy=True,
num_lines=20,
print_callees=False,
reuse=False,
sort_by=('cumulative', 'time'),
timer=None):
self._enabled = enabled
self._num_lines = num_lines
self._print_callees = print_callees
self._profiler = None
self._reuse = reuse
self._sort_by = sort_by
self._timer = timer
if enabled and not lazy:
self._create_profiler()
atexit_register(self.tear_down)
def __enter__(self):
if not self._enabled:
return
if not self._profiler:
self._create_profiler()
def __exit__(self, exc_type=None, exc_val=None, exc_tb=None):
if not self._enabled:
return
self.print_stats()
if not self._reuse:
self.tear_down()
def __call__(self, func=None, name=__name__, reuse=False):
"""Decorator used to profile function calls"""
if not func:
self._reuse = reuse
return self
@wraps(func)
def wrapper(*args, **kwargs):
"""
Wrapper to:
1) create a new Profiler instance;
2) run the function being profiled;
3) print out profiler result to the log; and
4) return result of function call
"""
with self:
result = func(*args, **kwargs)
return result
if not self._enabled:
self.tear_down()
return func
return wrapper
def _create_profiler(self):
if self._timer:
self._profiler = Profile(timer=self._timer)
else:
self._profiler = Profile()
self._profiler.enable()
try:
elapsed_timer = time.perf_counter
process_timer = time.process_time
except AttributeError:
elapsed_timer = time.clock
process_timer = time.clock
def disable(self):
if self._profiler:
self._profiler.disable()
def enable(self, flush=False):
self._enabled = True
if flush or not self._profiler:
self._create_profiler()
else:
self._profiler.enable()
def get_stats(self,
flush=True,
num_lines=20,
print_callees=False,
reuse=False,
sort_by=('cumulative', 'time')):
if not (self._enabled and self._profiler):
return None
self.disable()
output_stream = StringIO()
try:
stats = Stats(
self._profiler,
stream=output_stream
)
stats.strip_dirs().sort_stats(*sort_by)
if print_callees:
stats.print_callees(num_lines)
else:
stats.print_stats(num_lines)
output = output_stream.getvalue()
# Occurs when no stats were able to be generated from profiler
except TypeError:
output = 'Profiler: unable to generate stats'
finally:
output_stream.close()
if reuse:
# If stats are accumulating then enable existing/new profiler
self.enable(flush)
return output
def print_stats(self):
self.log.info('Profiling stats: %s',
self.get_stats(
num_lines=self._num_lines,
print_callees=self._print_callees,
reuse=self._reuse,
sort_by=self._sort_by,
),
stacklevel=3)
def tear_down(self):
self.__class__._instances.discard(self)
class ExecTimeout(object):
log = logging.getLogger('__name__')
src_file = None
def __init__(self,
seconds,
log_only=False,
trace_opcodes=False,
trace_threads=False,
log_locals=False,
callback=None,
skip_paths=('\\python\\lib\\',
'\\logging.py',
'\\addons\\script.')):
self._interval = seconds
self._log_only = log_only
self._last_event = (None, None, None)
self._timed_out = False
self._trace_opcodes = trace_opcodes
self._trace_threads = trace_threads
self._log_locals = log_locals
self._callback = callback if callable(callback) else None
self._skip_paths = skip_paths
def __call__(self, function):
@wraps(function)
def wrapper(*args, **kwargs):
timer = threading.Timer(self._interval, self.set_timed_out)
timer.daemon = True
if self._trace_threads:
threading.settrace(self.timeout_trace)
sys.settrace(self.timeout_trace)
timer.start()
try:
return function(*args, **kwargs)
finally:
timer.cancel()
if self._trace_threads:
threading.settrace(None)
sys.settrace(None)
if self._callback:
self._callback()
self._last_event = (None, None, None)
return wrapper
def timeout_trace(self, frame, event, arg):
if self._trace_opcodes and hasattr(frame, 'f_trace_opcodes'):
frame.f_trace_opcodes = True
if self._timed_out:
if not self._log_only:
raise RuntimeError('Python execution timed out')
else:
filename = normpath(frame.f_code.co_filename).lower()
skip_event = (
filename == self.src_file
or (self._skip_paths
and any(skip_path in filename
for skip_path in self._skip_paths))
)
if not skip_event:
self._last_event = (event, frame, arg)
return self.timeout_trace
def set_timed_out(self):
msg, kwargs = self._get_msg(to_log=True)
self.log.error(msg, **kwargs)
self._timed_out = True
def _get_msg(self, to_log=False):
event, frame, arg = self._last_event
out = (
'Python execution timed out',
'Event: {event!r}',
'Frame: {frame!r}',
'Arg: {arg!r}',
'Locals: {locals!r}',
'',
'Stack (most recent call last):',
'{stack_trace}',
)
log_locals = self._log_locals
if log_locals:
_locals = getargvalues(frame).locals
if log_locals is not True:
_locals = dict(tuple(_locals.items())[slice(*log_locals)])
else:
_locals = None
kwargs = {
'event': event,
'frame': frame,
'arg': arg,
'locals': _locals,
'stack_trace': ''.join(format_list(extract_stack(frame))),
}
if to_log:
return out, kwargs
return '\n'.join(out).format(**kwargs)
ExecTimeout.src_file = normpath(
ExecTimeout.__init__.__code__.co_filename
).lower()

View File

@@ -0,0 +1,25 @@
# -*- 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 .compatibility import to_str
class KodionException(Exception):
def __init__(self, message='', **kwargs):
super(KodionException, self).__init__(message)
attrs = self.__dict__
for attr, value in kwargs.items():
if attr not in attrs:
setattr(self, attr, value)
def get_message(self):
return to_str(self)

View File

@@ -0,0 +1,56 @@
# -*- coding: utf-8 -*-
"""
Copyright (C) 2014-2016 bromix (plugin.video.youtube)
Copyright (C) 2016-2025 plugin.video.youtube
SPDX-License-Identifier: GPL-2.0-only
See LICENSES/GPL-2.0-only for more information.
"""
from __future__ import absolute_import, division, unicode_literals
from . import menu_items
from .base_item import BaseItem
from .bookmark_item import BookmarkItem
from .command_item import CommandItem
from .directory_item import DirectoryItem
from .image_item import ImageItem
from .media_item import AudioItem, MediaItem, VideoItem
from .next_page_item import NextPageItem
from .search_items import NewSearchItem, SearchHistoryItem, SearchItem
from .uri_item import UriItem
from .utils import from_json
from .watch_later_item import WatchLaterItem
from .xbmc.xbmc_items import (
directory_listitem,
image_listitem,
media_listitem,
playback_item,
uri_listitem,
)
__all__ = (
'AudioItem',
'BaseItem',
'BookmarkItem',
'CommandItem',
'DirectoryItem',
'ImageItem',
'MediaItem',
'NewSearchItem',
'NextPageItem',
'SearchHistoryItem',
'SearchItem',
'UriItem',
'VideoItem',
'WatchLaterItem',
'from_json',
'menu_items',
'directory_listitem',
'image_listitem',
'media_listitem',
'playback_item',
'uri_listitem',
)

View File

@@ -0,0 +1,385 @@
# -*- 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
import json
from datetime import date, datetime
from .menu_items import separator
from ..compatibility import (
datetime_infolabel,
string_type,
to_str,
unescape,
)
from ..constants import MEDIA_PATH
from ..utils.methods import generate_hash
class BaseItem(object):
_version = 3
_playable = False
def __init__(self, name, uri, image=None, fanart=None, **kwargs):
super(BaseItem, self).__init__()
self._name = None
self.set_name(name)
self._uri = uri
self._available = True
self._callback = None
self._filter_reason = None
self._special_sort = None
self._image = ''
if image:
self.set_image(image)
self._fanart = ''
if fanart:
self.set_fanart(fanart)
self._bookmark_id = None
self._bookmark_timestamp = None
self._context_menu = None
self._added_utc = None
self._count = None
self._date = None
self._dateadded = None
self._short_details = None
self._production_code = None
self._track_number = None
self._cast = None
self._artists = None
self._studios = None
def __str_parts__(self, as_dict=False):
kwargs = {
'type': self.__class__.__name__,
'name': self._name,
'uri': self._uri,
'available': self._available,
'added': self._added_utc,
'filtered': self._filter_reason,
}
if as_dict:
return kwargs
out = (
'{type}(',
'name={name!r}, ',
'uri={uri!r}, ',
'available={available!r}, ',
'added=\'{added!s}\', ',
'filtered={filtered!r})',
)
return out, kwargs
def __str__(self):
out, kwargs = self.__str_parts__()
return ''.join(out).format(**kwargs)
def __repr_data__(self):
return {'type': self.__class__.__name__, 'data': self.__dict__}
def __repr__(self):
return json.dumps(
self.__repr_data__(),
ensure_ascii=False,
cls=_Encoder
)
@staticmethod
def generate_id(*args, **kwargs):
prefix = kwargs.get('prefix')
if prefix:
return '%s.%s' % (prefix, generate_hash(*args))
return generate_hash(*args)
def get_id(self):
"""
Returns a unique id of the item.
:return: unique id of the item.
"""
return self.generate_id(self._name, self._uri)
def set_name(self, name):
try:
name = unescape(name)
except Exception:
pass
self._name = name
return name
def get_name(self):
"""
Returns the name of the item.
:return: name of the item.
"""
return self._name
def set_uri(self, uri):
self._uri = uri if uri and isinstance(uri, string_type) else ''
def get_uri(self):
"""
Returns the path of the item.
:return: path of the item.
"""
return self._uri
@property
def available(self):
return self._available
@available.setter
def available(self, value):
self._available = value
@property
def callback(self):
return self._callback
@callback.setter
def callback(self, value):
self._callback = value.__get__(self) if callable(value) else None
def set_image(self, image):
if not image:
return
if '{media}/' in image:
self._image = image.format(media=MEDIA_PATH)
else:
self._image = image
def get_image(self):
return self._image
def set_fanart(self, fanart):
if not fanart:
return
if '{media}/' in fanart:
self._fanart = fanart.format(media=MEDIA_PATH)
else:
self._fanart = fanart
def get_fanart(self, default=True):
if self._fanart or not default:
return self._fanart
return '/'.join((
MEDIA_PATH,
'fanart.jpg',
))
def add_context_menu(self,
context_menu,
position='end',
replace=False,
end_separator=separator()):
context_menu = [item for item in context_menu if item]
if context_menu and end_separator and context_menu[-1] != end_separator:
context_menu.append(end_separator)
if replace or not self._context_menu:
self._context_menu = context_menu
elif position == 'end':
self._context_menu.extend(context_menu)
else:
self._context_menu[position:position] = context_menu
def get_context_menu(self):
return self._context_menu
def set_date(self, year, month, day, hour=0, minute=0, second=0):
self._date = datetime(year, month, day, hour, minute, second)
def set_date_from_datetime(self, date_time):
self._date = date_time
def get_date(self, as_text=False, short=False, as_info_label=False):
if self._date:
if as_info_label:
return datetime_infolabel(self._date, '%d.%m.%Y')
if short:
return self._date.date().strftime('%x')
if as_text:
return self._date.strftime('%x %X')
return self._date
def set_dateadded(self, year, month, day, hour=0, minute=0, second=0):
self._dateadded = datetime(year,
month,
day,
hour,
minute,
second)
def set_dateadded_from_datetime(self, date_time):
self._dateadded = date_time
def get_dateadded(self, as_text=False, as_info_label=False):
if self._dateadded:
if as_info_label:
return datetime_infolabel(self._dateadded)
if as_text:
return self._dateadded.strftime('%x %X')
return self._dateadded
def set_added_utc(self, date_time):
self._added_utc = date_time
def get_added_utc(self):
return self._added_utc
def get_short_details(self):
return self._short_details
def set_short_details(self, details):
self._short_details = details or ''
def get_count(self):
return self._count
def set_count(self, count):
self._count = int(count or 0)
@property
def bookmark_id(self):
return self._bookmark_id
@bookmark_id.setter
def bookmark_id(self, value):
self._bookmark_id = value
def set_bookmark_timestamp(self, timestamp):
self._bookmark_timestamp = timestamp
def get_bookmark_timestamp(self):
return self._bookmark_timestamp
@property
def playable(self):
return self._playable
@playable.setter
def playable(self, value):
self._playable = value
def add_artist(self, artist):
if artist:
if self._artists is None:
self._artists = []
self._artists.append(to_str(artist))
def get_artists(self):
return self._artists
def get_artists_string(self):
if self._artists:
return ', '.join(self._artists)
return None
def set_artists(self, artists):
self._artists = list(artists)
def set_cast(self, members):
self._cast = list(members)
def add_cast(self, name, role=None, order=None, thumbnail=None):
if name:
if self._cast is None:
self._cast = []
self._cast.append({
'name': to_str(name),
'role': to_str(role) if role else '',
'order': int(order) if order else len(self._cast) + 1,
'thumbnail': to_str(thumbnail) if thumbnail else '',
})
def get_cast(self):
return self._cast
def add_studio(self, studio):
if studio:
if self._studios is None:
self._studios = []
self._studios.append(to_str(studio))
def get_studios(self):
return self._studios
def set_studios(self, studios):
self._studios = list(studios)
def set_production_code(self, value):
self._production_code = value or ''
def get_production_code(self):
return self._production_code
def set_track_number(self, track_number):
self._track_number = int(track_number)
def get_track_number(self):
return self._track_number
def set_filter_reason(self, reason):
self._filter_reason = reason
def get_filter_reason(self):
return self._filter_reason
def set_special_sort(self, position):
self._special_sort = position
def get_special_sort(self):
return self._special_sort
class _Encoder(json.JSONEncoder):
def encode(self, obj, nested=False):
if isinstance(obj, (date, datetime)):
class_name = obj.__class__.__name__
if 'fromisoformat' in dir(obj):
obj = {
'__class__': class_name,
'__isoformat__': obj.isoformat(),
}
else:
if class_name == 'datetime':
if obj.tzinfo:
format_string = '%Y-%m-%dT%H:%M:%S%z'
else:
format_string = '%Y-%m-%dT%H:%M:%S'
else:
format_string = '%Y-%m-%d'
obj = {
'__class__': class_name,
'__format_string__': format_string,
'__value__': obj.strftime(format_string)
}
if isinstance(obj, string_type):
output = to_str(obj)
elif isinstance(obj, dict):
output = {to_str(key): self.encode(value, nested=True)
for key, value in obj.items()}
elif isinstance(obj, (list, tuple)):
output = [self.encode(item, nested=True) for item in obj]
else:
output = obj
if nested:
return output
return super(_Encoder, self).encode(output)
def default(self, obj):
pass

View File

@@ -0,0 +1,55 @@
# -*- 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 .directory_item import DirectoryItem
from .media_item import VideoItem
class BookmarkItem(VideoItem, DirectoryItem):
def __init__(self,
name,
uri,
image='{media}/bookmarks.png',
fanart=None,
plot=None,
action=False,
playable=None,
special_sort=None,
date_time=None,
category_label=None,
bookmark_id=None,
video_id=None,
channel_id=None,
playlist_id=None,
playlist_item_id=None,
subscription_id=None,
**_kwargs):
super(BookmarkItem, self).__init__(
name=name,
uri=uri,
image=image,
fanart=fanart,
plot=plot,
action=action,
special_sort=special_sort,
date_time=date_time,
category_label=category_label,
bookmark_id=bookmark_id,
video_id=video_id,
channel_id=channel_id,
playlist_id=playlist_id,
playlist_item_id=playlist_item_id,
subscription_id=subscription_id,
)
self._bookmark_id = bookmark_id
if playable is not None:
self._playable = playable

View File

@@ -0,0 +1,42 @@
# -*- coding: utf-8 -*-
"""
Copyright (C) 2014-2016 bromix (plugin.video.youtube)
Copyright (C) 2016-2025 plugin.video.youtube
SPDX-License-Identifier: GPL-2.0-only
See LICENSES/GPL-2.0-only for more information.
"""
from __future__ import absolute_import, division, unicode_literals
from . import menu_items
from .directory_item import DirectoryItem
from ..constants import PATHS
class CommandItem(DirectoryItem):
def __init__(self,
name,
command,
context,
image=None,
fanart=None,
plot=None,
**_kwargs):
super(CommandItem, self).__init__(
name,
context.create_uri((PATHS.COMMAND, command)),
image=image,
fanart=fanart,
plot=plot,
action=True,
category_label='__inherit__',
)
context_menu = [
menu_items.refresh_listing(context),
menu_items.goto_home(context),
menu_items.goto_quick_search(context),
]
self.add_context_menu(context_menu)

View File

@@ -0,0 +1,129 @@
# -*- 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 .base_item import BaseItem
from ..compatibility import parse_qsl, unescape, urlencode, urlsplit
class DirectoryItem(BaseItem):
def __init__(self,
name,
uri,
image='DefaultFolder.png',
fanart=None,
plot=None,
action=False,
special_sort=None,
date_time=None,
category_label=None,
bookmark_id=None,
channel_id=None,
playlist_id=None,
subscription_id=None,
**kwargs):
super(DirectoryItem, self).__init__(
name=name,
uri=uri,
image=image,
fanart=fanart,
**kwargs
)
name = self.get_name()
self._category_label = None
self.set_category_label(category_label or name)
self._plot = plot or name
self._is_action = action
self._bookmark_id = bookmark_id
self._channel_id = channel_id
self._playlist_id = playlist_id
self._subscription_id = subscription_id
self._next_page = False
if special_sort is not None:
self.set_special_sort(special_sort)
if date_time is not None:
self.set_date_from_datetime(date_time=date_time)
def set_name(self, name, category_label=None):
name = super(DirectoryItem, self).set_name(name)
if hasattr(self, '_category_label'):
self.set_category_label(category_label or name)
self.set_plot(name)
return name
def set_category_label(self, label):
if label == '__inherit__':
self._category_label = None
return
current_label = self._category_label
if current_label or label and current_label != label:
uri = urlsplit(self.get_uri())
params = dict(parse_qsl(uri.query))
if label:
params['category_label'] = label
else:
del params['category_label']
self.set_uri(uri._replace(query=urlencode(params)).geturl())
self._category_label = label
def get_category_label(self):
return self._category_label
def set_plot(self, plot):
try:
plot = unescape(plot)
except Exception:
pass
self._plot = plot
def get_plot(self):
return self._plot
def is_action(self):
return self._is_action
def set_action(self, value):
if isinstance(value, bool):
self._is_action = value
@property
def subscription_id(self):
return self._subscription_id
@subscription_id.setter
def subscription_id(self, value):
self._subscription_id = value
@property
def channel_id(self):
return self._channel_id
@channel_id.setter
def channel_id(self, value):
self._channel_id = value
@property
def playlist_id(self):
return self._playlist_id
@playlist_id.setter
def playlist_id(self, value):
self._playlist_id = value
@property
def next_page(self):
return self._next_page
@next_page.setter
def next_page(self, value):
self._next_page = value

View File

@@ -0,0 +1,18 @@
# -*- 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 .base_item import BaseItem
class ImageItem(BaseItem):
def __init__(self, name, uri, image='DefaultPicture.png', fanart=None):
super(ImageItem, self).__init__(name, uri, image, fanart)

View File

@@ -0,0 +1,483 @@
# -*- 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 datetime import date
from re import compile as re_compile
from . import BaseItem
from ..compatibility import datetime_infolabel, to_str, unescape, urlencode
from ..constants import CHANNEL_ID, CONTENT, PLAYLIST_ID, VIDEO_ID
from ..utils.convert_format import duration_to_seconds, seconds_to_duration
class MediaItem(BaseItem):
_ALLOWABLE_MEDIATYPES = frozenset()
_DEFAULT_MEDIATYPE = ''
_playable = True
def __init__(self,
name,
uri,
image='DefaultFile.png',
fanart=None,
plot=None,
video_id=None,
channel_id=None,
playlist_id=None,
playlist_item_id=None,
subscription_id=None,
**kwargs):
super(MediaItem, self).__init__(
name=name,
uri=uri,
image=image,
fanart=fanart,
**kwargs
)
self._aired = None
self._premiered = None
self._scheduled_start_utc = None
self._year = None
self._season = None
self._episode = None
self._genres = None
self._duration = -1
self._play_count = None
self._last_played = None
self._start_percent = None
self._start_time = None
self._mediatype = None
self._plot = plot
self._rating = None
self._headers = None
self._license_key = None
self._uses_isa = None
self.subtitles = None
self._completed = False
self._live = False
self._short = False
self._upcoming = False
self._vod = False
self._video_id = video_id
self._channel_id = channel_id
self._subscription_id = subscription_id
self._playlist_id = playlist_id
self._playlist_item_id = playlist_item_id
def __str_parts__(self, as_dict=False):
kwargs = {
'type': self.__class__.__name__,
'name': self._name,
'uri': self._uri,
VIDEO_ID: self._video_id,
CHANNEL_ID: self._channel_id,
PLAYLIST_ID: self._playlist_id,
# PLAYLIST_ITEM_ID: self._playlist_item_id,
# SUBSCRIPTION_ID: self._subscription_id,
'available': self._available,
'vod': self._vod,
'live': self._live,
'completed': self._completed,
'upcoming': self._upcoming,
'short': self._short,
'duration': self._duration,
'play_count': self._play_count,
'added': self._added_utc,
'track_number': self._track_number,
'filtered': self._filter_reason,
}
if as_dict:
return kwargs
out = (
'{type}(',
'name={name!r}, ',
'uri={uri!r}, ',
'video_id={video_id!r}, ',
'channel_id={channel_id!r}, ',
'playlist_id={playlist_id!r}, ',
# 'playlist_item_id={playlist_item_id!r}, ',
# 'subscription_id={subscription_id!r}, ',
'available={available!r}, ',
'vod={vod!r}, ',
'live={live!r}, ',
'completed={completed!r}, ',
'upcoming={upcoming!r}, ',
'short={short!r}, ',
'duration={duration!r}, ',
'play_count={play_count!r}, ',
'added=\'{added!s}\', ',
'track_number={track_number!r}, ',
'filtered={filtered!r})',
)
return out, kwargs
def set_aired(self, year, month, day):
self._aired = date(year, month, day)
def set_aired_from_datetime(self, date_time):
self._aired = date_time.date()
def get_aired(self, as_text=True, as_info_label=False):
if self._aired:
if as_info_label:
return self._aired.isoformat()
if as_text:
return self._aired.strftime('%x')
return self._aired
def set_premiered(self, year, month, day):
self._premiered = date(year, month, day)
def set_premiered_from_datetime(self, date_time):
self._premiered = date_time.date()
def get_premiered(self, as_text=True, as_info_label=False):
if self._premiered:
if as_info_label:
return self._premiered.isoformat()
if as_text:
return self._premiered.strftime('%x')
return self._premiered
def set_scheduled_start_utc(self, date_time):
self._scheduled_start_utc = date_time
def get_scheduled_start_utc(self):
return self._scheduled_start_utc
def set_year(self, year):
self._year = int(year)
def set_year_from_datetime(self, date_time):
self.set_year(date_time.year)
def get_year(self):
return self._year
def add_genre(self, genre):
if genre:
if self._genres is None:
self._genres = []
self._genres.append(to_str(genre))
def get_genres(self):
return self._genres
def set_genres(self, genres):
self._genres = list(genres)
def set_duration(self, hours=0, minutes=0, seconds=0, duration=''):
if duration:
_seconds = duration_to_seconds(duration)
else:
_seconds = seconds + minutes * 60 + hours * 3600
self._duration = _seconds or 0
def set_duration_from_minutes(self, minutes):
self._duration = int(minutes) * 60
def set_duration_from_seconds(self, seconds):
self._duration = int(seconds or 0)
def get_duration(self, as_text=False):
if as_text:
return seconds_to_duration(self._duration)
return self._duration
def set_play_count(self, play_count):
self._play_count = int(play_count or 0)
def get_play_count(self):
return self._play_count
def set_last_played(self, last_played):
self._last_played = last_played
def get_last_played(self, as_info_label=False):
if self._last_played:
if as_info_label:
return datetime_infolabel(self._last_played)
return self._last_played
def set_start_percent(self, start_percent):
self._start_percent = start_percent or 0
def get_start_percent(self):
return self._start_percent
def set_start_time(self, start_time):
self._start_time = start_time or 0.0
def get_start_time(self):
return self._start_time
def set_mediatype(self, mediatype):
if mediatype in self._ALLOWABLE_MEDIATYPES:
self._mediatype = mediatype
else:
self._mediatype = self._DEFAULT_MEDIATYPE
def get_mediatype(self):
return self._mediatype or self._DEFAULT_MEDIATYPE
def set_plot(self, plot):
try:
plot = unescape(plot)
except Exception:
pass
self._plot = plot
def get_plot(self):
return self._plot
def set_rating(self, rating):
rating = float(rating)
if rating > 10:
rating = 10.0
elif rating < 0:
rating = 0.0
self._rating = rating
def get_rating(self):
return self._rating
def set_headers(self, value):
self._headers = value
def get_headers(self, as_string=False):
if as_string:
return urlencode(self._headers) if self._headers else ''
return self._headers
def set_license_key(self, url):
self._license_key = url
def get_license_key(self):
return self._license_key
def set_isa(self, value=True):
self._uses_isa = value
def use_isa(self):
return self._uses_isa
def use_hls(self):
uri = self.get_uri()
if 'manifest/hls' in uri or uri.endswith('.m3u8'):
return True
return False
def use_mpd(self):
uri = self.get_uri()
if 'manifest/dash' in uri or uri.endswith('.mpd'):
return True
return False
def set_subtitles(self, value):
if value and isinstance(value, (list, tuple)):
self.subtitles = value
@property
def completed(self):
return self._completed
@completed.setter
def completed(self, value):
self._completed = value
@property
def live(self):
return self._live
@live.setter
def live(self, value):
self._live = value
@property
def short(self):
return self._short
@short.setter
def short(self, value):
self._short = value
@property
def upcoming(self):
return self._upcoming
@upcoming.setter
def upcoming(self, value):
self._upcoming = value
@property
def vod(self):
return self._vod
@vod.setter
def vod(self, value):
self._vod = value
@property
def video_id(self):
return self._video_id
@video_id.setter
def video_id(self, value):
self._video_id = value
@property
def channel_id(self):
return self._channel_id
@channel_id.setter
def channel_id(self, value):
self._channel_id = value
@property
def subscription_id(self):
return self._subscription_id
@subscription_id.setter
def subscription_id(self, value):
self._subscription_id = value
@property
def playlist_id(self):
return self._playlist_id
@playlist_id.setter
def playlist_id(self, value):
self._playlist_id = value
@property
def playlist_item_id(self):
return self._playlist_item_id
@playlist_item_id.setter
def playlist_item_id(self, value):
self._playlist_item_id = value
def set_episode(self, episode):
self._episode = int(episode)
def get_episode(self):
return self._episode
def set_season(self, season):
self._season = int(season)
def get_season(self):
return self._season
class AudioItem(MediaItem):
_ALLOWABLE_MEDIATYPES = {CONTENT.AUDIO_TYPE, 'song', 'album', 'artist'}
_DEFAULT_MEDIATYPE = CONTENT.AUDIO_TYPE
def __init__(self,
name,
uri,
image='DefaultAudio.png',
fanart=None,
plot=None,
video_id=None,
channel_id=None,
playlist_id=None,
playlist_item_id=None,
subscription_id=None):
super(AudioItem, self).__init__(
name=name,
uri=uri,
image=image,
fanart=fanart,
plot=plot,
video_id=video_id,
channel_id=channel_id,
playlist_id=playlist_id,
playlist_item_id=playlist_item_id,
subscription_id=subscription_id,
)
self._album = None
def set_album_name(self, album_name):
self._album = album_name or ''
def get_album_name(self):
return self._album
class VideoItem(MediaItem):
_ALLOWABLE_MEDIATYPES = {CONTENT.VIDEO_TYPE,
'movie',
'tvshow', 'season', 'episode',
'musicvideo'}
_DEFAULT_MEDIATYPE = CONTENT.VIDEO_TYPE
_RE_IMDB = re_compile(
r'(http(s)?://)?www.imdb.(com|de)/title/(?P<imdbid>[t0-9]+)(/)?'
)
def __init__(self,
name,
uri,
image='DefaultVideo.png',
fanart=None,
plot=None,
video_id=None,
channel_id=None,
playlist_id=None,
playlist_item_id=None,
subscription_id=None,
**kwargs):
super(VideoItem, self).__init__(
name=name,
uri=uri,
image=image,
fanart=fanart,
plot=plot,
video_id=video_id,
channel_id=channel_id,
playlist_id=playlist_id,
playlist_item_id=playlist_item_id,
subscription_id=subscription_id,
**kwargs
)
self._directors = None
self._imdb_id = None
def add_directors(self, director):
if director:
if self._directors is None:
self._directors = []
self._directors.append(to_str(director))
def get_directors(self):
return self._directors
def set_directors(self, directors):
self._directors = list(directors)
def set_imdb_id(self, url_or_id):
re_match = self._RE_IMDB.match(url_or_id)
if re_match:
self._imdb_id = re_match.group('imdbid')
else:
self._imdb_id = url_or_id
def get_imdb_id(self):
return self._imdb_id

View File

@@ -0,0 +1,903 @@
# -*- 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 ..constants import (
ARTIST,
BOOKMARK_ID,
CHANNEL_ID,
CONTEXT_MENU,
INCOGNITO,
MARK_AS_LABEL,
ORDER,
PATHS,
PLAYLIST_ITEM_ID,
PLAYLIST_ID,
PLAY_FORCE_AUDIO,
PLAY_PROMPT_QUALITY,
PLAY_PROMPT_SUBTITLES,
PLAY_TIMESHIFT,
PLAY_USING,
PROPERTY_AS_LABEL,
SUBSCRIPTION_ID,
TITLE,
URI,
VIDEO_ID,
WINDOW_RETURN,
)
ARTIST_INFOLABEL = PROPERTY_AS_LABEL % ARTIST
BOOKMARK_ID_INFOLABEL = PROPERTY_AS_LABEL % BOOKMARK_ID
CHANNEL_ID_INFOLABEL = PROPERTY_AS_LABEL % CHANNEL_ID
PLAYLIST_ID_INFOLABEL = PROPERTY_AS_LABEL % PLAYLIST_ID
PLAYLIST_ITEM_ID_INFOLABEL = PROPERTY_AS_LABEL % PLAYLIST_ITEM_ID
SUBSCRIPTION_ID_INFOLABEL = PROPERTY_AS_LABEL % SUBSCRIPTION_ID
TITLE_INFOLABEL = PROPERTY_AS_LABEL % TITLE
URI_INFOLABEL = PROPERTY_AS_LABEL % URI
VIDEO_ID_INFOLABEL = PROPERTY_AS_LABEL % VIDEO_ID
def context_menu_uri(context, path, params=None):
if params is None:
params = {CONTEXT_MENU: True}
else:
params[CONTEXT_MENU] = True
return context.create_uri(path, params, run=True)
def video_more_for(context,
video_id=VIDEO_ID_INFOLABEL,
video_name=TITLE_INFOLABEL,
logged_in=False,
refresh=False):
params = {
VIDEO_ID: video_id,
'item_name': video_name,
'logged_in': logged_in,
}
_refresh = context.refresh_requested(force=True, on=refresh)
if _refresh:
params['refresh'] = _refresh
return (
context.localize('video.more'),
context_menu_uri(
context,
('video', 'more',),
params,
),
)
def video_related(context,
video_id=VIDEO_ID_INFOLABEL,
video_name=TITLE_INFOLABEL):
return (
context.localize('video.related'),
context_menu_uri(
context,
(PATHS.ROUTE, PATHS.RELATED_VIDEOS,),
{
VIDEO_ID: video_id,
'item_name': video_name,
},
),
)
def video_comments(context,
video_id=VIDEO_ID_INFOLABEL,
video_name=TITLE_INFOLABEL):
return (
context.localize('video.comments'),
context_menu_uri(
context,
(PATHS.ROUTE, PATHS.VIDEO_COMMENTS),
{
VIDEO_ID: video_id,
'item_name': video_name,
},
)
)
def video_description_links(context,
video_id=VIDEO_ID_INFOLABEL,
video_name=TITLE_INFOLABEL):
return (
context.localize('video.description_links'),
context_menu_uri(
context,
(PATHS.ROUTE, PATHS.DESCRIPTION_LINKS),
{
VIDEO_ID: video_id,
'item_name': video_name,
},
)
)
def media_play_using(context, video_id=VIDEO_ID_INFOLABEL):
return (
context.localize('video.play.using'),
context_menu_uri(
context,
(PATHS.PLAY,),
{
VIDEO_ID: video_id,
PLAY_USING: True,
},
),
)
def refresh_listing(context, path=None, params=None):
if path is None:
path = (PATHS.ROUTE, context.get_path(),)
elif isinstance(path, tuple):
path = (PATHS.ROUTE,) + path
else:
path = (PATHS.ROUTE, path,)
if params is None:
params = context.get_params()
return (
context.localize('refresh'),
context_menu_uri(
context,
path,
dict(params,
refresh=context.refresh_requested(
force=True,
on=True,
params=params,
)),
),
)
def folder_play(context, path, order='normal'):
return (
context.localize('playlist.play.shuffle')
if order == 'shuffle' else
context.localize('playlist.play.all'),
context_menu_uri(
context,
(path, 'play',),
{
'order': order,
},
),
)
def media_play(context):
return (
context.localize('video.play'),
'Action(Play)'
)
def media_queue(context):
return (
context.localize('video.queue'),
'Action(Queue)'
)
def playlist_play(context, playlist_id=PLAYLIST_ID_INFOLABEL):
return (
context.localize('playlist.play.all'),
context_menu_uri(
context,
(PATHS.PLAY,),
{
PLAYLIST_ID: playlist_id,
'order': 'ask',
},
),
)
def playlist_play_from(context,
playlist_id=PLAYLIST_ID_INFOLABEL,
video_id=VIDEO_ID_INFOLABEL):
return (
context.localize('playlist.play.from_here'),
context_menu_uri(
context,
(PATHS.PLAY,),
{
PLAYLIST_ID: playlist_id,
VIDEO_ID: video_id,
},
),
)
def playlist_play_recently_added(context, playlist_id=PLAYLIST_ID_INFOLABEL):
return (
context.localize('playlist.play.recently_added'),
context_menu_uri(
context,
(PATHS.PLAY,),
{
PLAYLIST_ID: playlist_id,
'recent_days': 1,
},
),
)
def playlist_view(context, playlist_id=PLAYLIST_ID_INFOLABEL):
return (
context.localize('playlist.view.all'),
context_menu_uri(
context,
(PATHS.ROUTE, PATHS.PLAY,),
{
PLAYLIST_ID: playlist_id,
'order': 'normal',
'action': 'list',
},
),
)
def playlist_shuffle(context, playlist_id=PLAYLIST_ID_INFOLABEL):
return (
context.localize('playlist.play.shuffle'),
context_menu_uri(
context,
(PATHS.PLAY,),
{
PLAYLIST_ID: playlist_id,
'order': 'shuffle',
'action': 'play',
},
),
)
def playlist_add_to(context,
playlist_id,
name_id='playlist',
video_id=VIDEO_ID_INFOLABEL):
return (
context.localize(('add.to.x', name_id)),
context_menu_uri(
context,
(PATHS.PLAYLIST, 'add', 'video',),
{
PLAYLIST_ID: playlist_id,
VIDEO_ID: video_id,
},
),
)
def playlist_add_to_selected(context, video_id=VIDEO_ID_INFOLABEL):
return (
context.localize('video.add_to_playlist'),
context_menu_uri(
context,
(PATHS.PLAYLIST, 'select', 'playlist',),
{
VIDEO_ID: video_id,
},
),
)
def playlist_remove_from(context,
playlist_id=PLAYLIST_ID_INFOLABEL,
playlist_item_id=PLAYLIST_ITEM_ID_INFOLABEL,
video_id=VIDEO_ID_INFOLABEL,
video_name=TITLE_INFOLABEL):
return (
context.localize('remove'),
context_menu_uri(
context,
(PATHS.PLAYLIST, 'remove', 'video',),
dict(
context.get_params(),
**{
PLAYLIST_ID: playlist_id,
PLAYLIST_ITEM_ID: playlist_item_id,
VIDEO_ID: video_id,
'item_name': video_name,
'reload_path': context.get_path(),
}
),
),
)
def playlist_rename(context,
playlist_id=PLAYLIST_ID_INFOLABEL,
playlist_name=TITLE_INFOLABEL):
return (
context.localize('rename'),
context_menu_uri(
context,
(PATHS.PLAYLIST, 'rename', 'playlist',),
{
PLAYLIST_ID: playlist_id,
'item_name': playlist_name
},
),
)
def playlist_delete(context,
playlist_id=PLAYLIST_ID_INFOLABEL,
playlist_name=TITLE_INFOLABEL):
return (
context.localize('delete'),
context_menu_uri(
context,
(PATHS.PLAYLIST, 'remove', 'playlist',),
{
PLAYLIST_ID: playlist_id,
'item_name': playlist_name
},
),
)
def playlist_save_to_library(context, playlist_id=PLAYLIST_ID_INFOLABEL):
return (
context.localize('save'),
context_menu_uri(
context,
(PATHS.PLAYLIST, 'like', 'playlist',),
{
PLAYLIST_ID: playlist_id,
},
),
)
def playlist_remove_from_library(context,
playlist_id=PLAYLIST_ID_INFOLABEL,
playlist_name=TITLE_INFOLABEL):
return (
context.localize('remove'),
context_menu_uri(
context,
(PATHS.PLAYLIST, 'unlike', 'playlist',),
{
PLAYLIST_ID: playlist_id,
'item_name': playlist_name,
'reload_path': context.get_path(),
},
),
)
def watch_later_list_unassign(context,
playlist_id=PLAYLIST_ID_INFOLABEL,
playlist_name=TITLE_INFOLABEL):
return (
context.localize('watch_later.list.unassign'),
context_menu_uri(
context,
(PATHS.PLAYLIST, 'unassign', 'watch_later',),
{
PLAYLIST_ID: playlist_id,
'item_name': playlist_name
},
),
)
def watch_later_list_assign(context,
playlist_id=PLAYLIST_ID_INFOLABEL,
playlist_name=TITLE_INFOLABEL):
return (
context.localize('watch_later.list.assign'),
context_menu_uri(
context,
(PATHS.PLAYLIST, 'assign', 'watch_later',),
{
PLAYLIST_ID: playlist_id,
'item_name': playlist_name
},
),
)
def history_list_unassign(context,
playlist_id=PLAYLIST_ID_INFOLABEL,
playlist_name=TITLE_INFOLABEL):
return (
context.localize('history.list.unassign'),
context_menu_uri(
context,
(PATHS.PLAYLIST, 'unassign', 'history',),
{
PLAYLIST_ID: playlist_id,
'item_name': playlist_name
},
),
)
def history_list_assign(context,
playlist_id=PLAYLIST_ID_INFOLABEL,
playlist_name=TITLE_INFOLABEL):
return (
context.localize('history.list.assign'),
context_menu_uri(
context,
(PATHS.PLAYLIST, 'assign', 'history',),
{
PLAYLIST_ID: playlist_id,
'item_name': playlist_name
},
),
)
def my_subscriptions_filter_remove(context, channel_name=ARTIST_INFOLABEL):
return (
context.localize(('remove.from.x', 'my_subscriptions.filtered')),
context_menu_uri(
context,
('my_subscriptions', 'filter', 'remove'),
{
'item_name': channel_name,
},
),
)
def my_subscriptions_filter_add(context, channel_name=ARTIST_INFOLABEL):
return (
context.localize(('add.to.x', 'my_subscriptions.filtered')),
context_menu_uri(
context,
('my_subscriptions', 'filter', 'add',),
{
'item_name': channel_name,
},
),
)
def video_rate(context, video_id=VIDEO_ID_INFOLABEL, refresh=False):
params = {
VIDEO_ID: video_id,
}
_refresh = context.refresh_requested(force=True, on=refresh)
if _refresh:
params['refresh'] = _refresh
return (
context.localize('video.rate'),
context_menu_uri(
context,
('video', 'rate',),
params,
),
)
def watch_later_local_add(context, item):
return (
context.localize('watch_later.add'),
context_menu_uri(
context,
(PATHS.WATCH_LATER, 'add',),
{
VIDEO_ID: item.video_id,
'item': repr(item),
},
),
)
def watch_later_local_remove(context,
video_id=VIDEO_ID_INFOLABEL,
video_name=TITLE_INFOLABEL):
return (
context.localize('watch_later.remove'),
context_menu_uri(
context,
(PATHS.WATCH_LATER, 'remove',),
{
VIDEO_ID: video_id,
'item_name': video_name,
},
),
)
def watch_later_local_clear(context):
return (
context.localize('watch_later.clear'),
context_menu_uri(
context,
(PATHS.WATCH_LATER, 'clear',),
),
)
def channel_go_to(context,
channel_id=CHANNEL_ID_INFOLABEL,
channel_name=ARTIST_INFOLABEL):
return (
context.localize('go_to.x', context.get_ui().bold(channel_name)),
context_menu_uri(
context,
(PATHS.ROUTE, PATHS.CHANNEL, channel_id,),
{
'category_label': channel_name,
}
),
)
def channel_subscribe_to(context,
channel_id=CHANNEL_ID_INFOLABEL,
channel_name=ARTIST_INFOLABEL):
return (
context.localize('subscribe_to.x', context.get_ui().bold(channel_name))
if channel_name else
context.localize('subscribe'),
context_menu_uri(
context,
('subscriptions', 'add',),
{
SUBSCRIPTION_ID: channel_id,
},
),
)
def channel_unsubscribe_from(context, channel_id=None, subscription_id=None):
return (
context.localize('unsubscribe'),
context_menu_uri(
context,
('subscriptions', 'remove',),
{
SUBSCRIPTION_ID: subscription_id,
},
) if subscription_id else
context_menu_uri(
context,
('subscriptions', 'remove',),
{
CHANNEL_ID: channel_id,
},
),
)
def media_play_with_subtitles(context,
video_id=VIDEO_ID_INFOLABEL):
return (
context.localize('video.play.with_subtitles'),
context_menu_uri(
context,
(PATHS.PLAY,),
{
VIDEO_ID: video_id,
PLAY_PROMPT_SUBTITLES: True,
},
),
)
def media_play_audio_only(context,
video_id=VIDEO_ID_INFOLABEL):
return (
context.localize('video.play.audio_only'),
context_menu_uri(
context,
(PATHS.PLAY,),
{
VIDEO_ID: video_id,
PLAY_FORCE_AUDIO: True,
},
),
)
def media_play_ask_for_quality(context,
video_id=VIDEO_ID_INFOLABEL):
return (
context.localize('video.play.ask_for_quality'),
context_menu_uri(
context,
(PATHS.PLAY,),
{
VIDEO_ID: video_id,
PLAY_PROMPT_QUALITY: True,
},
),
)
def media_play_timeshift(context,
video_id=VIDEO_ID_INFOLABEL):
return (
context.localize('video.play.timeshift'),
context_menu_uri(
context,
(PATHS.PLAY,),
{
VIDEO_ID: video_id,
PLAY_TIMESHIFT: True,
},
),
)
def history_local_remove(context,
video_id=VIDEO_ID_INFOLABEL,
video_name=TITLE_INFOLABEL):
return (
context.localize('history.remove'),
context_menu_uri(
context,
(PATHS.HISTORY, 'remove',),
{
VIDEO_ID: video_id,
'item_name': video_name,
},
),
)
def history_local_clear(context):
return (
context.localize('history.clear'),
context_menu_uri(
context,
(PATHS.HISTORY, 'clear',),
),
)
def history_local_mark_as(context, video_id=VIDEO_ID_INFOLABEL):
return (
PROPERTY_AS_LABEL % MARK_AS_LABEL,
context_menu_uri(
context,
(PATHS.HISTORY, 'mark_as',),
{
VIDEO_ID: video_id,
},
),
)
def history_local_mark_watched(context, video_id=VIDEO_ID_INFOLABEL):
return (
context.localize('history.mark.watched'),
context_menu_uri(
context,
(PATHS.HISTORY, 'mark_watched',),
{
VIDEO_ID: video_id,
},
),
)
def history_local_mark_unwatched(context, video_id=VIDEO_ID_INFOLABEL):
return (
context.localize('history.mark.unwatched'),
context_menu_uri(
context,
(PATHS.HISTORY, 'mark_unwatched',),
{
VIDEO_ID: video_id,
},
),
)
def history_local_reset_resume(context, video_id=VIDEO_ID_INFOLABEL):
return (
context.localize('history.reset.resume_point'),
context_menu_uri(
context,
(PATHS.HISTORY, 'reset_resume',),
{
VIDEO_ID: video_id,
},
),
)
def bookmark_add(context, item):
return (
context.localize('bookmark'),
context_menu_uri(
context,
(PATHS.BOOKMARKS, 'add',),
{
'item_id': item.get_id(),
'item': repr(item),
},
),
)
def bookmark_add_channel(context,
channel_id=CHANNEL_ID_INFOLABEL,
channel_name=ARTIST_INFOLABEL):
return (
context.localize('bookmark.x',
context.get_ui().bold(channel_name)
if channel_name else
context.localize('channel')),
context_menu_uri(
context,
(PATHS.BOOKMARKS, 'add',),
{
'item_id': channel_id,
'item': None,
},
),
)
def bookmark_edit(context,
item_id=BOOKMARK_ID_INFOLABEL,
item_name=TITLE_INFOLABEL,
item_uri=URI_INFOLABEL):
return (
context.localize(('edit.x', 'bookmark')),
context_menu_uri(
context,
(PATHS.BOOKMARKS, 'edit',),
{
'item_id': item_id,
'item_name': item_name,
'uri': item_uri,
},
),
)
def bookmark_remove(context,
item_id=BOOKMARK_ID_INFOLABEL,
item_name=TITLE_INFOLABEL):
return (
context.localize('bookmark.remove'),
context_menu_uri(
context,
(PATHS.BOOKMARKS, 'remove',),
{
'item_id': item_id,
'item_name': item_name,
},
),
)
def bookmarks_clear(context):
return (
context.localize('bookmarks.clear'),
context_menu_uri(
context,
(PATHS.BOOKMARKS, 'clear',),
),
)
def search_remove(context, query):
return (
context.localize('search.remove'),
context_menu_uri(
context,
(PATHS.SEARCH, 'remove',),
{
'q': query,
},
),
)
def search_rename(context, query):
return (
context.localize('search.rename'),
context_menu_uri(
context,
(PATHS.SEARCH, 'rename',),
{
'q': query,
},
),
)
def search_clear(context):
return (
context.localize('search.clear'),
context_menu_uri(
context,
(PATHS.SEARCH, 'clear',),
),
)
def search_sort_by(context, params, order):
selected = params.get(ORDER, 'relevance') == order
order_label = context.localize('search.sort.' + order)
return (
context.localize('search.sort').format(
context.get_ui().bold(order_label) if selected else order_label
),
context_menu_uri(
context,
(PATHS.ROUTE, context.get_path(),),
params=dict(params,
order=order,
page=1,
page_token='',
pageToken='',
window_replace=True,
window_return=False),
),
)
def separator():
return (
'--------',
'noop'
)
def goto_home(context):
return (
context.localize('home'),
context_menu_uri(
context,
(PATHS.ROUTE, PATHS.HOME,),
{
WINDOW_RETURN: False,
},
),
)
def goto_quick_search(context, params=None, incognito=None):
if params is None:
params = {}
if incognito is None:
incognito = params.get(INCOGNITO)
else:
params[INCOGNITO] = incognito
return (
context.localize('search.quick.incognito'
if incognito else
'search.quick'),
context_menu_uri(
context,
(PATHS.ROUTE, PATHS.SEARCH, 'input',),
params,
),
)
def goto_page(context, params=None):
return (
context.localize('page.choose'),
context_menu_uri(
context,
(PATHS.GOTO_PAGE, context.get_path(),),
params or context.get_params(),
),
)

View File

@@ -0,0 +1,112 @@
# -*- coding: utf-8 -*-
"""
Copyright (C) 2014-2016 bromix (plugin.video.youtube)
Copyright (C) 2016-2025 plugin.video.youtube
SPDX-License-Identifier: GPL-2.0-only
See LICENSES/GPL-2.0-only for more information.
"""
from __future__ import absolute_import, division, unicode_literals
from . import menu_items
from .directory_item import DirectoryItem
from ..constants import ITEMS_PER_PAGE, PAGE, PATHS
class NextPageItem(DirectoryItem):
NEXT_PAGE_PARAM_EXCLUSIONS = (
'refresh',
)
JUMP_PAGE_PARAM_EXCLUSIONS = (
'click_tracking',
'exclude',
'filtered',
'page',
'refresh',
'visitor',
)
def __init__(self, context, params, image=None, fanart=None):
path = context.get_path()
page = params.get(PAGE) or 2
is_first_page_link = page < 2
items_per_page = params.get(ITEMS_PER_PAGE) or 50
can_jump = ('next_page_token' not in params
and not path.startswith(('/channel',
PATHS.RECOMMENDATIONS,
PATHS.RELATED_VIDEOS,
PATHS.VIRTUAL_PLAYLIST)))
if can_jump and not is_first_page_link and 'page_token' not in params:
params['page_token'] = self.create_page_token(page, items_per_page)
can_search = not path.startswith(PATHS.SEARCH)
for param in (
self.JUMP_PAGE_PARAM_EXCLUSIONS
if is_first_page_link else
self.NEXT_PAGE_PARAM_EXCLUSIONS
):
if param in params:
del params[param]
name = context.localize('page.next', page)
filtered = params.get('filtered')
if filtered:
name = ''.join((
name,
' (',
str(filtered),
' ',
context.localize('filtered'),
')',
))
super(NextPageItem, self).__init__(
name,
context.create_uri(path, params),
image=image,
fanart=fanart,
category_label='__inherit__',
)
self.next_page = page
self.items_per_page = items_per_page
context_menu = [
menu_items.refresh_listing(context),
menu_items.goto_page(context, params) if can_jump else None,
menu_items.goto_home(context),
menu_items.goto_quick_search(context) if can_search else None,
]
self.add_context_menu(context_menu)
@classmethod
def create_page_token(cls, page, items_per_page=50):
low = 'AEIMQUYcgkosw048'
high = 'ABCDEFGHIJKLMNOP'
len_low = len(low)
len_high = len(high)
position = (page - 1) * items_per_page
overflow_token = 'Q'
if position >= 128:
overflow_token_iteration = position // 128
overflow_token = '%sE' % high[overflow_token_iteration]
low_iteration = position % len_low
# at this position the iteration starts with 'I' again (after 'P')
if position >= 256:
multiplier = (position // 128) - 1
position -= 128 * multiplier
high_iteration = (position // len_low) % len_high
return 'C{high_token}{low_token}{overflow_token}AA'.format(
high_token=high[high_iteration],
low_token=low[low_iteration],
overflow_token=overflow_token
)

View File

@@ -0,0 +1,135 @@
# -*- coding: utf-8 -*-
"""
Copyright (C) 2014-2016 bromix (plugin.video.youtube)
Copyright (C) 2016-2025 plugin.video.youtube
SPDX-License-Identifier: GPL-2.0-only
See LICENSES/GPL-2.0-only for more information.
"""
from __future__ import absolute_import, division, unicode_literals
from . import menu_items
from .directory_item import DirectoryItem
from ..constants import CHANNEL_ID, INCOGNITO, PATHS
class SearchItem(DirectoryItem):
def __init__(self,
context,
name=None,
image=None,
fanart=None,
location=False):
if not name:
name = context.localize('search')
if image is None:
image = '{media}/search.png'
params = {}
if location:
params['location'] = location
super(SearchItem, self).__init__(name,
context.create_uri(
(PATHS.SEARCH, 'list',),
params=params,
),
image=image,
fanart=fanart)
context_menu = [
menu_items.search_clear(context),
menu_items.separator(),
menu_items.goto_quick_search(context, params),
menu_items.goto_quick_search(context, params, incognito=True)
]
self.add_context_menu(context_menu)
class SearchHistoryItem(DirectoryItem):
def __init__(self, context, query, image=None, fanart=None, location=False):
if image is None:
image = '{media}/search.png'
if isinstance(query, dict):
params = query
query = params['q']
else:
params = {'q': query}
if location:
params['location'] = location
super(SearchHistoryItem, self).__init__(query,
context.create_uri(
(PATHS.SEARCH, 'query',),
params=params,
),
image=image,
fanart=fanart)
context_menu = [
menu_items.search_remove(context, query),
menu_items.search_rename(context, query),
menu_items.search_clear(context),
menu_items.separator(),
menu_items.search_sort_by(context, params, 'relevance'),
menu_items.search_sort_by(context, params, 'date'),
menu_items.search_sort_by(context, params, 'viewCount'),
menu_items.search_sort_by(context, params, 'rating'),
menu_items.search_sort_by(context, params, 'title'),
]
self.add_context_menu(context_menu)
class NewSearchItem(DirectoryItem):
def __init__(self,
context,
name=None,
title=None,
image=None,
fanart=None,
incognito=False,
channel_id='',
addon_id='',
location=False,
**_kwargs):
if not name:
name = context.get_ui().bold(
title or context.localize('search.new')
)
if image is None:
image = '{media}/new_search.png'
params = {}
if addon_id:
params['addon_id'] = addon_id
if incognito:
params[INCOGNITO] = incognito
if channel_id:
params[CHANNEL_ID] = channel_id
if location:
params['location'] = location
super(NewSearchItem, self).__init__(name,
context.create_uri(
(PATHS.SEARCH, 'input',),
params=params,
),
image=image,
fanart=fanart)
if context.is_plugin_path(context.get_uri(), ((PATHS.SEARCH, 'list'),)):
context_menu = [
menu_items.search_clear(context),
menu_items.separator(),
menu_items.goto_quick_search(context, params, not incognito)
]
else:
context_menu = [
menu_items.goto_quick_search(context, params, not incognito)
]
self.add_context_menu(context_menu)

View File

@@ -0,0 +1,20 @@
# -*- 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 .base_item import BaseItem
class UriItem(BaseItem):
def __init__(self, uri, playable=None):
super(UriItem, self).__init__(name=uri, uri=uri)
if playable is not None:
self._playable = playable

View File

@@ -0,0 +1,89 @@
# -*- 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
import json
from datetime import date, datetime
from .bookmark_item import BookmarkItem
from .directory_item import DirectoryItem
from .image_item import ImageItem
from .media_item import AudioItem, VideoItem
from .. import logging
from ..compatibility import string_type, to_str
from ..utils.datetime import strptime
_ITEM_TYPES = {
'AudioItem': AudioItem,
'BookmarkItem': BookmarkItem,
'DirectoryItem': DirectoryItem,
'ImageItem': ImageItem,
'VideoItem': VideoItem,
}
def _decoder(obj):
date_in_isoformat = obj.get('__isoformat__')
if date_in_isoformat:
if obj['__class__'] == 'date':
return date.fromisoformat(date_in_isoformat)
return datetime.fromisoformat(date_in_isoformat)
format_string = obj.get('__format_string__')
if format_string:
value = obj['__value__']
value = strptime(value, format_string)
if obj['__class__'] == 'date':
return value.date()
return value
return obj
def from_json(json_data, *args):
"""
Creates an instance of the given json dump or dict.
:param json_data:
:return:
"""
if args and args[0] and len(args[0]) == 4:
bookmark_id = args[0][0]
bookmark_timestamp = args[0][1]
else:
bookmark_id = None
bookmark_timestamp = None
if isinstance(json_data, string_type):
if json_data == to_str(None):
# Channel bookmark that will be updated. Store timestamp for update
return bookmark_timestamp
json_data = json.loads(json_data, object_hook=_decoder)
item_type = json_data.get('type')
if not item_type or item_type not in _ITEM_TYPES:
logging.warning_trace(('Unsupported item type', 'Data: {data!r}'),
data=json_data)
return None
item_data = json_data.get('data')
if not item_data:
return None
item = _ITEM_TYPES[item_type](name='', uri='')
item.__dict__.update(item_data)
if bookmark_id:
item.bookmark_id = bookmark_id
if bookmark_timestamp:
item.set_bookmark_timestamp(bookmark_timestamp)
return item

View File

@@ -0,0 +1,30 @@
# -*- 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 .directory_item import DirectoryItem
from ..constants import PATHS
class WatchLaterItem(DirectoryItem):
def __init__(self, context, name=None, image=None, fanart=None):
if not name:
name = context.localize('watch_later')
if image is None:
image = '{media}/watch_later.png'
super(WatchLaterItem, self).__init__(name,
context.create_uri(
(PATHS.WATCH_LATER, 'list',),
),
image=image,
fanart=fanart)

View File

@@ -0,0 +1,767 @@
# -*- 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 json import dumps
from .. import (
AudioItem,
DirectoryItem,
ImageItem,
MediaItem,
VideoItem,
)
from ... import logging
from ...compatibility import to_str, xbmc, xbmcgui
from ...constants import (
ACTION,
BOOKMARK_ID,
CHANNEL_ID,
PATHS,
PLAYLIST_ITEM_ID,
PLAYLIST_ID,
PLAY_COUNT_PROP,
PLAY_STRM,
PLAY_TIMESHIFT,
PLAY_USING,
SUBSCRIPTION_ID,
VALUE_TO_STR,
VIDEO_ID,
)
from ...utils.datetime import datetime_to_since, utc_to_local
from ...utils.redact import redact_ip_in_uri
from ...utils.system_version import current_system_version
def set_info(list_item, item, properties, set_play_count=True, resume=True):
if not current_system_version.compatible(20):
info_labels = {}
info_type = None
if isinstance(item, MediaItem):
if isinstance(item, VideoItem):
info_type = 'video'
value = item.get_episode()
if value is not None:
info_labels['episode'] = value
value = item.get_season()
if value is not None:
info_labels['season'] = value
elif isinstance(item, AudioItem):
info_type = 'music'
value = item.get_album_name()
if value is not None:
info_labels['album'] = value
else:
return
value = item.get_aired(as_info_label=True)
if value is not None:
info_labels['aired'] = value
value = item.get_premiered(as_info_label=True)
if value is not None:
info_labels['premiered'] = value
value = item.get_plot()
if value is not None:
info_labels['plot'] = value
value = item.get_last_played(as_info_label=True)
if value is not None:
info_labels['lastplayed'] = value
value = item.get_mediatype()
if value is not None:
info_labels['mediatype'] = value
value = item.get_play_count()
if value is not None:
if set_play_count:
info_labels['playcount'] = value
properties[PLAY_COUNT_PROP] = value
value = item.get_rating()
if value is not None:
info_labels['rating'] = value
value = item.get_name()
if value is not None:
info_labels['title'] = value
value = item.get_track_number()
if value is not None:
info_labels['tracknumber'] = value
value = item.get_year()
if value is not None:
info_labels['year'] = value
resume_time = resume and item.get_start_time()
if resume_time is not None:
properties['ResumeTime'] = str(resume_time)
duration = item.get_duration()
if duration > 0:
properties['TotalTime'] = str(duration)
if info_type == 'video':
list_item.addStreamInfo(info_type, {'duration': duration})
info_labels['duration'] = duration
elif isinstance(item, DirectoryItem):
info_type = 'video'
value = item.get_name()
if value is not None:
info_labels['title'] = value
value = item.get_plot()
if value is not None:
info_labels['plot'] = value
value = item.get_track_number()
if value is not None:
info_labels['tracknumber'] = value
elif isinstance(item, ImageItem):
info_type = 'picture'
value = item.get_name()
if value is not None:
info_labels['title'] = value
else:
return
value = item.get_production_code()
if value is not None:
info_labels['code'] = value
value = item.get_dateadded(as_info_label=True)
if value is not None:
info_labels['dateadded'] = value
value = item.get_studios()
if value is not None:
info_labels['studio'] = value
value = item.get_cast()
if value is not None:
info_labels['castandrole'] = [(member['name'], member['role'])
for member in value]
value = item.get_artists()
if value is not None:
info_labels['artist'] = value
value = item.get_count()
if value is not None:
info_labels['count'] = value
value = item.get_date(as_info_label=True)
if value is not None:
info_labels['date'] = value
if properties:
list_item.setProperties(properties)
if info_labels and info_type:
list_item.setInfo(info_type, info_labels)
return
if isinstance(item, MediaItem):
if isinstance(item, VideoItem):
info_tag = list_item.getVideoInfoTag()
info_type = 'video'
# episode: int
value = item.get_episode()
if value is not None:
info_tag.setEpisode(value)
# season: int
value = item.get_season()
if value is not None:
info_tag.setSeason(value)
value = item.get_premiered(as_info_label=True)
if value is not None:
info_tag.setPremiered(value)
value = item.get_aired(as_info_label=True)
if value is not None:
info_tag.setFirstAired(value)
# plot: str
value = item.get_plot()
if value is not None:
info_tag.setPlot(value)
# tracknumber: int
# eg. 12
value = item.get_track_number()
if value is not None:
info_tag.setTrackNumber(value)
# director: list[str]
# eg. "Steven Spielberg"
# Currently unused
# value = item.get_directors()
# if value is not None:
# info_tag.setDirectors(value)
# imdbnumber: str
# eg. "tt3458353"
# Currently unused
# value = item.get_imdb_id()
# if value is not None:
# info_tag.setIMDBNumber(value)
elif isinstance(item, AudioItem):
info_tag = list_item.getMusicInfoTag()
info_type = 'music'
# album: str
# eg. "Buckle Up"
value = item.get_album_name()
if value is not None:
info_tag.setAlbum(value)
value = item.get_premiered(as_info_label=True)
if value is not None:
info_tag.setReleaseDate(value)
# comment: str
value = item.get_plot()
if value is not None:
info_tag.setComment(value)
# artist: str
# eg. "Artist 1, Artist 2"
# Used as alias for channel name
value = item.get_artists_string()
if value is not None:
info_tag.setArtist(value)
# track: int
# eg. 12
value = item.get_track_number()
if value is not None:
info_tag.setTrack(value)
else:
return
value = item.get_last_played(as_info_label=True)
if value is not None:
info_tag.setLastPlayed(value)
# mediatype: str
value = item.get_mediatype()
if value is not None:
info_tag.setMediaType(value)
# playcount: int
value = item.get_play_count()
if value is not None:
if set_play_count:
if info_type == 'video':
info_tag.setPlaycount(value)
elif info_type == 'music':
info_tag.setPlayCount(value)
properties[PLAY_COUNT_PROP] = value
# rating: float
value = item.get_rating()
if value is not None:
info_tag.setRating(value)
# title: str
# eg. "Blow Your Head Off"
value = item.get_name()
if value is not None:
info_tag.setTitle(value)
# year: int
# eg. 1994
value = item.get_year()
if value is not None:
info_tag.setYear(value)
# genre: list[str]
# eg. ["Hardcore"]
# Currently unused
# value = item.get_genres()
# if value is not None:
# info_tag.setGenres(value)
resume_time = resume and item.get_start_time()
duration = item.get_duration()
if info_type == 'video':
if resume_time is not None:
if duration > 0:
info_tag.setResumePoint(resume_time, float(duration))
else:
info_tag.setResumePoint(resume_time)
if duration > 0:
info_tag.addVideoStream(xbmc.VideoStreamDetail(
duration=duration,
))
elif info_type == 'music':
# These properties are deprecated but there is no other way to set
# these details for a ListItem with a MusicInfoTag
if resume_time is not None:
properties['ResumeTime'] = str(resume_time)
if duration > 0:
properties['TotalTime'] = str(duration)
# duration: int
# As seconds
if duration > 0:
info_tag.setDuration(duration)
elif isinstance(item, DirectoryItem):
info_tag = list_item.getVideoInfoTag()
info_type = 'video'
value = item.get_name()
if value is not None:
info_tag.setTitle(value)
value = item.get_plot()
if value is not None:
info_tag.setPlot(value)
# tracknumber: int
# eg. 12
value = item.get_track_number()
if value is not None:
info_tag.setTrackNumber(value)
elif isinstance(item, ImageItem):
info_tag = list_item.getPictureInfoTag()
info_type = 'picture'
value = item.get_name()
if value is not None:
info_tag.setTitle(value)
else:
return
if info_type == 'video':
# code: str
# eg. "466K | 3.9K | 312"
# Production code, currently used to store misc video data for label
# formatting
value = item.get_production_code()
if value is not None:
info_tag.setProductionCode(value)
value = item.get_dateadded(as_info_label=True)
if value is not None:
info_tag.setDateAdded(value)
# studio: list[str]
# Used as alias for channel name if enabled
value = item.get_studios()
if value is not None:
info_tag.setStudios(value)
# cast: list[xbmc.Actor]
# From list[{member: str, role: str, order: int, thumbnail: str}]
# Used as alias for channel name if enabled
value = item.get_cast()
if value is not None:
info_tag.setCast([xbmc.Actor(**member) for member in value])
# artist: list[str]
# eg. ["Angerfist"]
# Used as alias for channel name
value = item.get_artists()
if value is not None:
info_tag.setArtists(value)
# count: int
# eg. 12
# Can be used to store an id for later, or for sorting purposes
# Used for Youtube video view count
value = item.get_count()
if value is not None:
list_item.setInfo(info_type, {'count': value})
value = item.get_date(as_info_label=True)
if value is not None:
list_item.setDateTime(value)
if properties:
list_item.setProperties(properties)
def playback_item(context, media_item, show_fanart=None, **_kwargs):
uri = media_item.get_uri()
logging.debug('Converting %s for playback: %r',
media_item.__class__.__name__,
redact_ip_in_uri(uri))
params = context.get_params()
settings = context.get_settings()
ui = context.get_ui()
is_external = ui.get_property(PLAY_USING)
is_strm = params.get(PLAY_STRM)
mime_type = None
if is_strm:
kwargs = {
'path': uri,
'offscreen': True,
}
props = {}
else:
kwargs = {
'label': media_item.get_name(),
'label2': media_item.get_short_details(),
'path': uri,
'offscreen': True,
}
props = {
'isPlayable': VALUE_TO_STR[media_item.playable],
'playlist_type_hint': (
xbmc.PLAYLIST_MUSIC
if isinstance(media_item, AudioItem) else
xbmc.PLAYLIST_VIDEO
),
}
if media_item.use_isa() and context.use_inputstream_adaptive():
capabilities = context.inputstream_adaptive_capabilities()
use_mpd = media_item.use_mpd()
if use_mpd:
manifest_type = 'mpd'
mime_type = 'application/dash+xml'
else:
manifest_type = 'hls'
mime_type = 'application/x-mpegURL'
stream_select = settings.stream_select()
if not use_mpd and 'list' in stream_select:
props['inputstream.adaptive.stream_selection_type'] = 'manual-osd'
elif 'auto' in stream_select:
props['inputstream.adaptive.stream_selection_type'] = 'adaptive'
props['inputstream.adaptive.chooser_resolution_max'] = 'auto'
if current_system_version.compatible(19):
props['inputstream'] = 'inputstream.adaptive'
else:
props['inputstreamaddon'] = 'inputstream.adaptive'
if not current_system_version.compatible(21):
props['inputstream.adaptive.manifest_type'] = manifest_type
if media_item.live:
if 'manifest_config_prop' in capabilities:
props['inputstream.adaptive.manifest_config'] = dumps({
'timeshift_bufferlimit': 4 * 60 * 60,
})
if ui.pop_property(PLAY_TIMESHIFT) and 'timeshift' in capabilities:
props['inputstream.adaptive.play_timeshift_buffer'] = True
if not settings.verify_ssl() and 'config_prop' in capabilities:
props['inputstream.adaptive.config'] = dumps({
'ssl_verify_peer': False,
})
headers = media_item.get_headers(as_string=True)
if headers:
props['inputstream.adaptive.manifest_headers'] = headers
props['inputstream.adaptive.stream_headers'] = headers
license_key = media_item.get_license_key()
if license_key:
props['inputstream.adaptive.license_type'] = 'com.widevine.alpha'
props['inputstream.adaptive.license_key'] = license_key
else:
if 'mime=' in uri:
mime_type = uri.split('mime=', 1)[1].split('&', 1)[0]
mime_type = mime_type.replace('%2F', '/')
headers = media_item.get_headers(as_string=True)
if (headers and uri.startswith('http')
and not (is_external
or settings.default_player_web_urls())):
uri = '|'.join((uri, headers))
kwargs['path'] = uri
media_item.set_uri(uri)
list_item = xbmcgui.ListItem(**kwargs)
if mime_type or is_external:
list_item.setContentLookup(False)
list_item.setMimeType(mime_type or '*/*')
if is_strm:
list_item.setProperties(props)
return list_item
if show_fanart is None:
show_fanart = settings.fanart_selection()
image = media_item.get_image()
art = {'icon': image}
if image:
art['thumb'] = image
if show_fanart:
art['fanart'] = media_item.get_fanart()
list_item.setArt(art)
if media_item.subtitles:
list_item.setSubtitles(media_item.subtitles)
resume = params.get('resume')
set_info(list_item, media_item, props, resume=resume)
return list_item
def directory_listitem(context, directory_item, show_fanart=None, **_kwargs):
uri = directory_item.get_uri()
is_action = directory_item.is_action()
if not is_action:
path, params = context.parse_uri(uri)
if path.rstrip('/') == PATHS.PLAY and params.get(ACTION) != 'list':
is_action = True
if is_action:
logging.debug('Converting DirectoryItem action: %r', uri)
else:
logging.debug('Converting DirectoryItem: %r', uri)
kwargs = {
'label': directory_item.get_name(),
'label2': directory_item.get_short_details(),
'path': uri,
'offscreen': True,
}
props = {
'ForceResolvePlugin': 'true',
}
if directory_item.next_page:
props['specialSort'] = 'bottom'
else:
special_sort = directory_item.get_special_sort()
if special_sort is None:
special_sort = 'top'
elif special_sort is False:
special_sort = None
prop_value = directory_item.subscription_id
if prop_value:
special_sort = None
props[SUBSCRIPTION_ID] = prop_value
prop_value = directory_item.channel_id
if prop_value:
special_sort = None
props[CHANNEL_ID] = prop_value
prop_value = directory_item.playlist_id
if prop_value:
special_sort = None
props[PLAYLIST_ID] = prop_value
prop_value = directory_item.bookmark_id
if prop_value:
special_sort = None
props[BOOKMARK_ID] = prop_value
if special_sort:
props['specialSort'] = special_sort
list_item = xbmcgui.ListItem(**kwargs)
if show_fanart is None:
show_fanart = context.get_settings().fanart_selection()
image = directory_item.get_image()
art = {'icon': image}
if image:
art['thumb'] = image
art['poster'] = image
if show_fanart:
art['fanart'] = directory_item.get_fanart()
list_item.setArt(art)
set_info(list_item, directory_item, props)
context_menu = directory_item.get_context_menu()
if context_menu is not None:
list_item.addContextMenuItems(context_menu)
return uri, list_item, not is_action
def image_listitem(context, image_item, show_fanart=None, **_kwargs):
uri = image_item.get_uri()
logging.debug('Converting ImageItem: %r', uri)
kwargs = {
'label': image_item.get_name(),
'path': uri,
'offscreen': True,
}
props = {
'isPlayable': VALUE_TO_STR[image_item.playable],
'ForceResolvePlugin': 'true',
}
list_item = xbmcgui.ListItem(**kwargs)
if show_fanart is None:
show_fanart = context.get_settings().fanart_selection()
image = image_item.get_image()
art = {'icon': image}
if image:
art['thumb'] = image
if show_fanart:
art['fanart'] = image_item.get_fanart()
list_item.setArt(art)
set_info(list_item, image_item, props)
context_menu = image_item.get_context_menu()
if context_menu is not None:
list_item.addContextMenuItems(context_menu)
return uri, list_item, False
def uri_listitem(_context, uri_item, **_kwargs):
uri = uri_item.get_uri()
logging.debug('Converting UriItem: %r', uri)
kwargs = {
'label': uri_item.get_name(),
'path': uri,
'offscreen': True,
}
props = {
'isPlayable': VALUE_TO_STR[uri_item.playable],
'ForceResolvePlugin': 'true',
}
list_item = xbmcgui.ListItem(**kwargs)
list_item.setProperties(props)
return list_item
def media_listitem(context,
media_item,
show_fanart=None,
to_sync=None,
**_kwargs):
uri = media_item.get_uri()
logging.debug('Converting %s: %r', media_item.__class__.__name__, uri)
kwargs = {
'label': media_item.get_name(),
'label2': media_item.get_short_details(),
'path': uri,
'offscreen': True,
}
props = {
'isPlayable': VALUE_TO_STR[media_item.playable],
'ForceResolvePlugin': 'true',
'playlist_type_hint': (
xbmc.PLAYLIST_MUSIC
if isinstance(media_item, AudioItem) else
xbmc.PLAYLIST_VIDEO
),
}
published_at = media_item.get_added_utc()
scheduled_start = media_item.get_scheduled_start_utc()
datetime = scheduled_start or published_at
local_datetime = None
if datetime:
local_datetime = utc_to_local(datetime)
props['PublishedLocal'] = to_str(local_datetime)
if media_item.live:
props['PublishedSince'] = context.localize('live')
elif local_datetime:
props['PublishedSince'] = to_str(datetime_to_since(
context, local_datetime
))
set_play_count = True
resume = True
prop_value = media_item.video_id
if prop_value:
props[VIDEO_ID] = prop_value
if to_sync and prop_value in to_sync:
set_play_count = False
# make channel_id property available for keymapping
prop_value = media_item.channel_id
if prop_value:
props[CHANNEL_ID] = prop_value
# make subscription_id property available for keymapping
prop_value = media_item.subscription_id
if prop_value:
props[SUBSCRIPTION_ID] = prop_value
# make playlist_id property available for keymapping
prop_value = media_item.playlist_id
if prop_value:
props[PLAYLIST_ID] = prop_value
# make playlist_item_id property available for keymapping
prop_value = media_item.playlist_item_id
if prop_value:
props[PLAYLIST_ITEM_ID] = prop_value
# make bookmark_id property available for keymapping
prop_value = media_item.bookmark_id
if prop_value:
props[BOOKMARK_ID] = prop_value
list_item = xbmcgui.ListItem(**kwargs)
if show_fanart is None:
show_fanart = context.get_settings().fanart_selection()
image = media_item.get_image()
art = {'icon': image}
if image:
art['thumb'] = image
if show_fanart:
art['fanart'] = media_item.get_fanart()
list_item.setArt(art)
if media_item.subtitles:
list_item.setSubtitles(media_item.subtitles)
set_info(list_item,
media_item,
props,
set_play_count=set_play_count,
resume=resume)
context_menu = media_item.get_context_menu()
if context_menu:
list_item.addContextMenuItems(context_menu)
return uri, list_item, False

View File

@@ -0,0 +1,16 @@
# -*- coding: utf-8 -*-
"""
Copyright (C) 2018-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 .access_manager import AccessManager
from .api_keys import APIKeyStore
__all__ = ('AccessManager', 'APIKeyStore',)

View File

@@ -0,0 +1,590 @@
# -*- 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.
"""
import time
import uuid
from .json_store import JSONStore
from ..compatibility import string_type
from ..constants import ADDON_ID
from ..utils.methods import generate_hash
class AccessManager(JSONStore):
DEFAULT_NEW_USER = {
'access_token': '',
'refresh_token': '',
'token_expires': -1,
'last_key_hash': '',
'name': 'Default',
'id': None,
'watch_later': 'WL',
'watch_history': 'HL'
}
DEFAULT_NEW_DEVELOPER = {
'access_token': '',
'refresh_token': '',
'token_expires': -1,
'last_key_hash': ''
}
def __init__(self, context):
self._user = None
self._last_origin = None
super(AccessManager, self).__init__('access_manager.json', context)
def init(self):
super(AccessManager, self).init()
access_manager_data = self._data['access_manager']
self._user = access_manager_data.get('current_user', 0)
self._last_origin = access_manager_data.get('last_origin', ADDON_ID)
def set_defaults(self, reset=False):
data = {} if reset else self.get_data()
access_manager = data.get('access_manager')
if not access_manager or not isinstance(access_manager, dict):
users = {
0: self.DEFAULT_NEW_USER.copy(),
}
access_manager = {
'users': users,
'current_user': 0,
'last_origin': ADDON_ID,
'developers': {},
}
else:
users = access_manager.get('users')
if not users or not isinstance(users, dict):
users = {
0: self.DEFAULT_NEW_USER.copy(),
}
elif any(not isinstance(user_id, int) for user_id in users):
new_users = {}
old_users = {}
for user_id, user in users.items():
if isinstance(user_id, int):
new_users[user_id] = user
else:
try:
user_id = int(user_id)
if user_id in users:
raise ValueError
new_users[user_id] = user
except (TypeError, ValueError):
old_users[user_id] = user
if new_users:
users = new_users
if old_users:
new_user_id = max(users) + 1 if users else 0
for user in old_users.values():
users[new_user_id] = user
new_user_id += 1
access_manager['users'] = users
current_id = access_manager.get('current_user')
if (not current_id
or current_id == 'default'
or current_id not in users):
current_id = min(users)
else:
if not isinstance(current_id, int):
try:
current_id = int(current_id)
if current_id not in users:
raise ValueError
except (TypeError, ValueError):
current_id = min(users)
access_manager['current_user'] = current_id
current_user = users[current_id]
current_user.setdefault('watch_later', 'WL')
current_user.setdefault('watch_history', 'HL')
if 'default' in access_manager:
default_user = access_manager['default']
if (isinstance(default_user, dict)
and (default_user.get('access_token')
or default_user.get('refresh_token'))
and not current_user.get('access_token')
and not current_user.get('refresh_token')):
default_user.setdefault('name', 'Default')
users[current_id] = default_user
del access_manager['default']
if 'access_token' in access_manager:
del access_manager['access_token']
if 'refresh_token' in access_manager:
del access_manager['refresh_token']
if 'token_expires' in access_manager:
del access_manager['token_expires']
last_origin = access_manager.get('last_origin')
if not last_origin or not isinstance(last_origin, string_type):
access_manager['last_origin'] = ADDON_ID
developers = access_manager.get('developers')
if not developers or not isinstance(developers, dict):
access_manager['developers'] = {}
data['access_manager'] = access_manager
# ensure all users have uuid
uuids = set()
for user in users.values():
user_uuid = user.get('id')
if user_uuid:
if user_uuid in uuids:
user['old_id'] = user_uuid
user_uuid = None
else:
uuids.add(user_uuid)
continue
while not user_uuid or user_uuid in uuids:
user_uuid = uuid.uuid4().hex
uuids.add(user_uuid)
user['id'] = user_uuid
# end uuid check
return self.save(data)
@staticmethod
def _process_data(data):
output = {}
for key, value in data:
if key in output:
continue
if key == 'current_user':
try:
value = int(value)
except (TypeError, ValueError):
value = 0
else:
try:
key = int(key)
except (TypeError, ValueError):
pass
output[key] = value
return output
def get_current_user_details(self, addon_id=None):
"""
:return: current user
"""
if addon_id and addon_id != ADDON_ID:
return self.get_developers().get(addon_id, {})
return self.get_users()[self._user]
def get_current_user_id(self):
"""
:return: uuid of the current user
"""
return self.get_users()[self._user]['id']
def get_new_user(self, username=''):
"""
:param username: string, users name
:return: a new user dict
"""
uuids = [
user.get('id')
for user in self.get_users().values()
]
new_uuid = None
while not new_uuid or new_uuid in uuids:
new_uuid = uuid.uuid4().hex
return dict(self.DEFAULT_NEW_USER,
name=username,
id=new_uuid)
def get_users(self):
"""
Returns users
:return: users
"""
data = self._data if self._loaded else self.get_data()
return data['access_manager'].get('users', {})
def add_user(self, username='', user=None):
"""
Add single new user to users collection
:param username: str, chosen name of new user
:param user: int, optional index for new user
:return: tuple, (index, details) of newly added user
"""
users = self.get_users()
new_user_details = self.get_new_user(username)
new_user = max(users) + 1 if users and user is None else user or 0
data = {
'access_manager': {
'users': {
new_user: new_user_details,
},
},
}
self.save(data, update=True)
return new_user, new_user_details
def remove_user(self, user):
"""
Remove user from collection of current users
:param user: int, user index
:return:
"""
users = self.get_users()
if user in users:
data = {
'access_manager': {
'users': {
user: KeyError,
},
},
}
self.save(data, update=True)
def set_users(self, users):
"""
Updates all users
:param users: dict, users
:return:
"""
data = self.get_data()
data['access_manager']['users'] = users
self.save(data)
def set_user(self, user, switch_to=False):
"""
Updates the user
:param user: string, username
:param switch_to: boolean, change current user
:return:
"""
try:
user = int(user)
except (TypeError, ValueError):
pass
self._user = user
if switch_to:
data = {
'access_manager': {
'current_user': user,
},
}
self.save(data, update=True)
def get_current_user(self):
"""
Returns the current user
:return: user
"""
return self._user
def get_username(self, user=None):
"""
Returns the username of the current or nominated user
:return: username
"""
if user is None:
user = self._user
users = self.get_users()
if user in users:
return users[user].get('name')
return ''
def set_username(self, user, username):
"""
Sets the username of the nominated user
:return: True if username was set, false otherwise
"""
users = self.get_users()
if user in users:
data = {
'access_manager': {
'users': {
user: {
'name': username,
},
},
},
}
self.save(data, update=True)
return True
return False
def get_watch_later_id(self):
"""
Returns the current users watch later playlist id
:return: the current users watch later playlist id
"""
current_id = self.get_current_user_details().get('watch_later', '')
current_id = current_id.strip()
current_id_lower = current_id.lower()
settings = self._context.get_settings()
settings_id = settings.get_watch_later_playlist()
settings_id_lower = settings_id.lower()
if settings_id_lower == 'local':
current_id = self.set_watch_later_id(None)
elif settings_id and settings_id_lower != current_id_lower:
current_id = self.set_watch_later_id(settings_id)
elif current_id_lower == 'local':
current_id = ''
if settings_id:
settings.set_watch_later_playlist('')
return current_id
def set_watch_later_id(self, playlist_id=None):
"""
Sets the current users watch later playlist id
:param playlist_id: string, watch later playlist id
:return:
"""
if not playlist_id:
playlist_id = ''
self._context.get_settings().set_watch_later_playlist('')
playlists = {
'watch_later': playlist_id,
}
current_id = self.get_current_user_details().get('watch_later')
if current_id:
playlists['watch_later_old'] = current_id
data = {
'access_manager': {
'users': {
self._user: playlists,
},
},
}
self.save(data, update=True)
return playlist_id
def get_watch_history_id(self):
"""
Returns the current users watch history playlist id
:return: the current users watch history playlist id
"""
current_id = self.get_current_user_details().get('watch_history', '')
current_id = current_id.strip()
current_id_lower = current_id.lower()
settings = self._context.get_settings()
settings_id = settings.get_history_playlist()
settings_id_lower = settings_id.lower()
if settings_id_lower == 'local':
current_id = self.set_watch_history_id(None)
elif settings_id and settings_id_lower != current_id_lower:
current_id = self.set_watch_history_id(settings_id)
elif current_id_lower == 'local':
current_id = ''
if settings_id:
settings.set_history_playlist('')
return current_id
def set_watch_history_id(self, playlist_id=None):
"""
Sets the current users watch history playlist id
:param playlist_id: string, watch history playlist id
:return:
"""
if not playlist_id:
playlist_id = ''
self._context.get_settings().set_history_playlist('')
playlists = {
'watch_history': playlist_id,
}
current_id = self.get_current_user_details().get('watch_history')
if current_id:
playlists['watch_history_old'] = current_id
data = {
'access_manager': {
'users': {
self._user: playlists,
},
},
}
self.save(data, update=True)
return playlist_id
def set_last_origin(self, origin):
"""
Updates the origin
:param origin: string, origin
:return:
"""
self._last_origin = origin
data = {
'access_manager': {
'last_origin': origin,
},
}
self.save(data, update=True)
def get_last_origin(self):
"""
Returns the last origin
:return:
"""
return self._last_origin
def get_refresh_tokens(self, addon_id=None):
"""
Returns a tuple containing a list of refresh tokens and the number of
valid refresh tokens
:return:
"""
details = self.get_current_user_details(addon_id)
refresh_tokens = details.get('refresh_token', '').split('|')
num_refresh_tokens = len([1 for token in refresh_tokens if token])
return refresh_tokens, num_refresh_tokens
def get_access_tokens(self, addon_id=None):
"""
Returns a tuple containing a list of access tokens, the number of valid
access tokens, and the token expiry timestamp.
:return:
"""
details = self.get_current_user_details(addon_id)
access_tokens = details.get('access_token').split('|')
expiry_timestamp = int(details.get('token_expires', -1))
if expiry_timestamp > int(time.time()):
num_access_tokens = len([1 for token in access_tokens if token])
else:
access_tokens = [None, None, None, None]
num_access_tokens = 0
return access_tokens, num_access_tokens, expiry_timestamp
def update_access_token(self,
addon_id,
access_token=None,
expiry=None,
refresh_token=None):
"""
Updates the old access token with the new one.
:param addon_id:
:param access_token:
:param expiry:
:param refresh_token:
:return:
"""
details = {
'access_token': (
'|'.join([token or '' for token in access_token])
if isinstance(access_token, (list, tuple)) else
access_token
if access_token else
''
)
}
if expiry is not None:
if isinstance(expiry, (list, tuple)):
expiry = [val for val in expiry if val]
expiry = min(map(int, expiry)) if expiry else -1
else:
expiry = int(expiry)
details['token_expires'] = time.time() + expiry
if refresh_token is not None:
details['refresh_token'] = (
'|'.join([token or '' for token in refresh_token])
if isinstance(refresh_token, (list, tuple)) else
refresh_token
)
data = {
'access_manager': {
'developers': {
addon_id: details,
},
} if addon_id and addon_id != ADDON_ID else {
'users': {
self._user: details,
},
},
}
self.save(data, update=True)
def get_last_key_hash(self, addon_id=None):
details = self.get_current_user_details(addon_id)
return details.get('last_key_hash', '')
def set_last_key_hash(self, key_hash, addon_id=None):
data = {
'access_manager': {
'developers': {
addon_id: {
'last_key_hash': key_hash,
},
},
} if addon_id and addon_id != ADDON_ID else {
'users': {
self._user: {
'last_key_hash': key_hash,
},
},
},
}
self.save(data, update=True)
def get_developers(self):
"""
Returns developers
:return: dict, developers
"""
data = self._data if self._loaded else self.get_data()
return data['access_manager'].get('developers', {})
def add_new_developer(self, addon_id):
"""
Updates the developer users
:param addon_id: str
:return:
"""
data = self.get_data()
developers = data['access_manager'].get('developers', {})
if addon_id not in developers:
developers[addon_id] = self.DEFAULT_NEW_DEVELOPER.copy()
data['access_manager']['developers'] = developers
return self.save(data)
return False
def keys_changed(self,
addon_id,
api_key,
client_id,
client_secret,
update_hash=True):
last_hash = self.get_last_key_hash(addon_id)
current_hash = generate_hash(api_key, client_id, client_secret)
keys_changed = False
if not last_hash and current_hash:
if update_hash:
self.set_last_key_hash(current_hash, addon_id)
elif (not current_hash and last_hash
or last_hash != current_hash):
if update_hash:
self.set_last_key_hash(current_hash, addon_id)
keys_changed = True
return keys_changed

View File

@@ -0,0 +1,379 @@
# -*- coding: utf-8 -*-
"""
Copyright (C) 2018-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 base64 import b64decode, b64encode
from .json_store import JSONStore
from .. import logging
from ..constants import DEVELOPER_CONFIGS
from ... import key_sets
class APIKeyStore(JSONStore):
log = logging.getLogger(__name__)
DOMAIN_SUFFIX = '.apps.googleusercontent.com'
def __init__(self, context):
super(APIKeyStore, self).__init__('api_keys.json', context)
def set_defaults(self, reset=False):
data = {} if reset else self.get_data()
if 'keys' not in data:
data = {
'keys': {
'user': {
'api_key': '',
'client_id': '',
'client_secret': '',
},
'developer': {},
},
}
else:
keys = data['keys'] or {}
if 'user' not in keys:
keys['user'] = keys.pop('personal', {
'api_key': '',
'client_id': '',
'client_secret': '',
})
if 'developer' not in keys:
keys['developer'] = {}
data['keys'] = keys
self.save(data)
@staticmethod
def get_current_switch():
return 'user'
def has_user_api_keys(self):
api_data = self.get_data()
try:
return (api_data['keys']['user']['api_key']
and api_data['keys']['user']['client_id']
and api_data['keys']['user']['client_secret'])
except KeyError:
return False
def get_api_keys(self, switch):
api_data = self.get_data()
if switch == 'developer':
return api_data['keys'][switch]
decode = True
if switch == 'youtube-tv':
system = 'YouTube TV'
key_set_details = key_sets[switch]
elif switch == 'youtube-vr':
system = 'YouTube VR'
key_set_details = key_sets[switch]
elif switch.startswith('user'):
decode = False
system = 'All'
key_set_details = api_data['keys']['user']
else:
system = 'All'
if switch not in key_sets['provided']:
switch = 0
key_set_details = key_sets['provided'][switch]
key_set = {
'system': system,
'id': '',
'key': '',
'secret': ''
}
for key, value in key_set_details.items():
if decode:
value = b64decode(value).decode('utf-8')
key = key.partition('_')[-1]
if key and key in key_set:
key_set[key] = value
if (key_set['id']
and not key_set['id'].endswith(self.DOMAIN_SUFFIX)):
key_set['id'] += self.DOMAIN_SUFFIX
return key_set
def get_key_set(self, switch):
key_set = self.get_api_keys(switch)
if switch.startswith('user'):
client_id = key_set['id'].replace(self.DOMAIN_SUFFIX, '')
if switch == 'user_old':
client_id += self.DOMAIN_SUFFIX
key_set['id'] = client_id
return key_set
def strip_details(self, api_key, client_id, client_secret):
stripped_key = ''.join(api_key.split())
stripped_id = ''.join(client_id.replace(self.DOMAIN_SUFFIX, '').split())
stripped_secret = ''.join(client_secret.split())
if api_key != stripped_key:
if stripped_key not in api_key:
self.log.debug('Personal API key'
' - skipped (mangled by stripping)')
return_key = api_key
else:
self.log.debug('Personal API key'
' - whitespace removed')
return_key = stripped_key
else:
return_key = api_key
if client_id != stripped_id:
if stripped_id not in client_id:
self.log.debug('Personal API client ID'
' - skipped (mangled by stripping)')
return_id = client_id
elif self.DOMAIN_SUFFIX in client_id:
self.log.debug('Personal API client ID'
' - whitespace and domain removed')
return_id = stripped_id
else:
self.log.debug('Personal API client ID'
' - whitespace removed')
return_id = stripped_id
else:
return_id = client_id
if client_secret != stripped_secret:
if stripped_secret not in client_secret:
self.log.debug('Personal API client secret'
' - skipped (mangled by stripping)')
return_secret = client_secret
else:
self.log.debug('Personal API client secret'
' - whitespace removed')
return_secret = stripped_secret
else:
return_secret = client_secret
return return_key, return_id, return_secret
def get_configs(self):
return {
'tv': self.get_api_keys('youtube-tv'),
'user': self.get_api_keys(self.get_current_switch()),
'vr': self.get_api_keys('youtube-vr'),
'dev': self.get_api_keys('developer'),
}
def get_developer_config(self, developer_id):
context = self._context
developer_configs = self.get_api_keys('developer')
if developer_id and developer_configs:
config = developer_configs.get(developer_id)
else:
config = context.get_ui().pop_property(DEVELOPER_CONFIGS)
if config:
self.log.warning('Storing developer keys in window property'
' has been deprecated. Please use the'
' youtube_registration module instead')
config = self.load_data(config)
if not config:
return {}
if not context.get_settings().allow_dev_keys():
self.log.debug('Developer config ignored')
return {}
origin = config.get('origin', developer_id)
key_details = config.get(origin)
required_details = {'key', 'id', 'secret'}
if (not origin
or not key_details
or not required_details.issubset(key_details)):
self.log.error_trace(('Invalid developer config: {config!r}',
'Expected: {{',
' "origin": ADDON_ID,',
' ADDON_ID: {{',
' "system": SYSTEM_NAME,',
' "key": API_KEY,',
' "id": CLIENT_ID,',
' "secret": CLIENT_SECRET',
' }},',
'}}'),
config=config)
return {}
key_system = key_details.get('system')
if key_system == 'JSONStore':
for key in required_details:
key_details[key] = b64decode(key_details[key]).decode('utf-8')
self.log.debug(('Using developer config',
'Origin: {origin!r}',
'System: {system!r}'),
origin=origin,
system=key_system)
return {
'origin': origin,
origin: {
'system': key_system,
'key': key_details['key'],
'id': key_details['id'],
'secret': key_details['secret'],
}
}
def update_developer_config(self,
developer_id,
api_key,
client_id,
client_secret):
data = self.get_data()
existing_config = data['keys']['developer'].get(developer_id, {})
new_config = {
'origin': developer_id,
developer_id: {
'system': 'JSONStore',
'key': b64encode(
bytes(api_key, 'utf-8')
).decode('ascii'),
'id': b64encode(
bytes(client_id, 'utf-8')
).decode('ascii'),
'secret': b64encode(
bytes(client_secret, 'utf-8')
).decode('ascii'),
}
}
if existing_config and existing_config == new_config:
return False
data['keys']['developer'][developer_id] = new_config
return self.save(data)
def sync(self):
api_data = self.get_data()
settings = self._context.get_settings()
update_saved_values = False
update_settings_values = False
saved_details = (
api_data['keys']['user'].get('api_key', ''),
api_data['keys']['user'].get('client_id', ''),
api_data['keys']['user'].get('client_secret', ''),
)
if all(saved_details):
update_settings_values = True
# users are now pasting keys into api_keys.json
# try stripping whitespace and domain suffix from API details
# and save the results if they differ
stripped_details = self.strip_details(*saved_details)
if all(stripped_details) and saved_details != stripped_details:
saved_details = stripped_details
api_data['keys']['user'] = {
'api_key': saved_details[0],
'client_id': saved_details[1],
'client_secret': saved_details[2],
}
update_saved_values = True
setting_details = (
settings.api_key(),
settings.api_id(),
settings.api_secret(),
)
if all(setting_details):
update_settings_values = False
stripped_details = self.strip_details(*setting_details)
if all(stripped_details) and setting_details != stripped_details:
setting_details = (
settings.api_key(stripped_details[0]),
settings.api_id(stripped_details[1]),
settings.api_secret(stripped_details[2]),
)
if saved_details != setting_details:
api_data['keys']['user'] = {
'api_key': setting_details[0],
'client_id': setting_details[1],
'client_secret': setting_details[2],
}
update_saved_values = True
if update_settings_values:
settings.api_key(saved_details[0])
settings.api_id(saved_details[1])
settings.api_secret(saved_details[2])
if update_saved_values:
self.save(api_data)
return True
return False
def update(self):
context = self._context
localize = context.localize
settings = context.get_settings()
ui = context.get_ui()
params = context.get_params()
api_key = params.get('api_key')
client_id = params.get('client_id')
client_secret = params.get('client_secret')
enable = params.get('enable')
updated_list = []
log_list = []
if api_key:
settings.api_key(api_key)
updated_list.append(localize('api.key'))
log_list.append('api_key')
if client_id:
settings.api_id(client_id)
updated_list.append(localize('api.id'))
log_list.append('client_id')
if client_secret:
settings.api_secret(client_secret)
updated_list.append(localize('api.secret'))
log_list.append('client_secret')
if updated_list:
ui.show_notification(localize('updated.x', ', '.join(updated_list)))
self.log.debug('Updated API details: %s', log_list)
client_id = settings.api_id()
client_secret = settings.api_secret()
api_key = settings.api_key
missing_list = []
log_list = []
if enable and client_id and client_secret and api_key:
ui.show_notification(localize('api.personal.enabled'))
self.log.debug('Personal API keys enabled')
elif enable:
if not api_key:
missing_list.append(localize('api.key'))
log_list.append('api_key')
if not client_id:
missing_list.append(localize('api.id'))
log_list.append('client_id')
if not client_secret:
missing_list.append(localize('api.secret'))
log_list.append('client_secret')
ui.show_notification(localize('api.personal.failed',
', '.join(missing_list)))
self.log.error_trace(('Failed to enable personal API keys',
'Missing: %s'),
log_list)

View File

@@ -0,0 +1,193 @@
# -*- coding: utf-8 -*-
"""
Copyright (C) 2018-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
import json
import os
from io import open
from .. import logging
from ..constants import DATA_PATH, FILE_READ, FILE_WRITE
from ..utils.convert_format import to_unicode
from ..utils.file_system import make_dirs
from ..utils.methods import merge_dicts
class JSONStore(object):
log = logging.getLogger(__name__)
BASE_PATH = make_dirs(DATA_PATH)
_process_data = None
def __init__(self, filename, context):
if self.BASE_PATH:
self.filepath = os.path.join(self.BASE_PATH, filename)
else:
self.log.error_trace(('Addon data directory not available',
'Path: %s'),
DATA_PATH,
stacklevel=2)
self.filepath = None
self._context = context
self._loaded = False
self._data = {}
self.init()
def init(self):
if self.load(stacklevel=4):
self._loaded = True
self.set_defaults()
else:
self.set_defaults(reset=True)
return self._loaded
def set_defaults(self, reset=False):
raise NotImplementedError
def save(self, data, update=False, process=True, ipc=True, stacklevel=2):
filepath = self.filepath
if not filepath:
return False
if update:
data = merge_dicts(self._data, data)
if data == self._data:
self.log.debug(('Data unchanged', 'File: %s'),
filepath,
stacklevel=stacklevel)
return None
self.log.debug(('Saving', 'File: %s'),
filepath,
stacklevel=stacklevel)
try:
if not data:
raise ValueError
_data = json.dumps(
data, ensure_ascii=False, indent=4, sort_keys=True
)
self._data = json.loads(
_data,
object_pairs_hook=(self._process_data if process else None),
)
if ipc:
self._context.get_ui().set_property(
'-'.join((FILE_WRITE, filepath)),
to_unicode(_data),
log_value='<redacted>',
)
response = self._context.ipc_exec(
FILE_WRITE,
timeout=5,
payload={'filepath': filepath},
raise_exc=True,
)
if response is False:
raise IOError
if response is None:
self.log.debug(('Data unchanged', 'File: %s'),
filepath,
stacklevel=stacklevel)
return None
else:
with open(filepath, mode='w', encoding='utf-8') as file:
file.write(to_unicode(_data))
except (RuntimeError, IOError, OSError):
self.log.exception(('Access error', 'File: %s'),
filepath,
stacklevel=stacklevel)
return False
except (TypeError, ValueError):
self.log.exception(('Invalid data', 'Data: {data!r}'),
data=data,
stacklevel=stacklevel)
self.set_defaults(reset=True)
return False
return True
def load(self, process=True, ipc=True, stacklevel=2):
filepath = self.filepath
if not filepath:
return False
self.log.debug(('Loading', 'File: %s'),
filepath,
stacklevel=stacklevel)
try:
if ipc:
if self._context.ipc_exec(
FILE_READ,
timeout=5,
payload={'filepath': filepath},
raise_exc=True,
) is not False:
data = self._context.get_ui().get_property(
'-'.join((FILE_READ, filepath)),
log_value='<redacted>',
)
else:
raise IOError
else:
with open(filepath, mode='r', encoding='utf-8') as file:
data = file.read()
if not data:
raise ValueError
self._data = json.loads(
data,
object_pairs_hook=(self._process_data if process else None),
)
except (RuntimeError, IOError, OSError):
self.log.exception(('Access error', 'File: %s'),
filepath,
stacklevel=stacklevel)
return False
except (TypeError, ValueError):
self.log.exception(('Invalid data', 'Data: {data!r}'),
data=data,
stacklevel=stacklevel)
return False
return True
def get_data(self, process=True, fallback=True, stacklevel=2):
if not self._loaded:
self.init()
data = self._data
try:
if not data:
raise ValueError
return json.loads(
json.dumps(data, ensure_ascii=False),
object_pairs_hook=(self._process_data if process else None),
)
except (TypeError, ValueError) as exc:
self.log.exception(('Invalid data', 'Data: {data!r}'),
data=data,
stacklevel=stacklevel)
if fallback:
self.set_defaults(reset=True)
return self.get_data(process=process, fallback=False)
if self._loaded:
raise exc
return data
def load_data(self, data, process=True, stacklevel=2):
try:
return json.loads(
data,
object_pairs_hook=(self._process_data if process else None),
)
except (TypeError, ValueError):
self.log.exception(('Invalid data', 'Data: {data!r}'),
data=data,
stacklevel=stacklevel)
return {}

View File

@@ -0,0 +1,619 @@
# -*- 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
import logging
import sys
from os.path import normpath
from pprint import PrettyPrinter
from string import Formatter
from sys import exc_info as sys_exc_info
from traceback import extract_stack, format_list
from .compatibility import StringIO, string_type, to_str, xbmc
from .constants import ADDON_ID
from .utils.convert_format import to_unicode
from .utils.system_version import current_system_version
# noinspection PyUnresolvedReferences
__all__ = (
'check_frame',
'critical',
'debug',
'debugging',
'error',
'exception',
'info',
'log',
'warning',
'CRITICAL',
'DEBUG',
'ERROR',
'INFO',
'WARNING',
)
class RecordFormatter(logging.Formatter):
def formatMessage(self, record):
record.__dict__['__sep__'] = '\n' if '\n' in record.message else ' - '
try:
return self._style.format(record)
except AttributeError:
try:
return self._fmt % record.__dict__
except UnicodeDecodeError as e:
record.__dict__ = {
key: to_unicode(value)
for key, value in record.__dict__.items()
}
try:
return self._fmt % record.__dict__
except UnicodeDecodeError:
raise e
def formatStack(self, stack_info):
return stack_info
def format(self, record):
record.message = to_unicode(record.getMessage())
if self.usesTime():
record.asctime = self.formatTime(record, self.datefmt)
s = self.formatMessage(record)
if record.exc_info:
if not record.exc_text:
record.exc_text = self.formatException(record.exc_info)
if record.exc_text:
if record.stack_info:
if s[-1:] != '\n':
s += '\n\n'
s += self.formatStack(record.stack_info)
if s[-1:] != '\n':
s += '\n\n'
s += record.exc_text
elif record.stack_info:
if s[-1:] != '\n':
s += '\n\n'
s += self.formatStack(record.stack_info)
return s
class StreamWrapper(object):
OPEN = frozenset(('(', '[', '{'))
CLOSE = frozenset((')', ']', '}'))
def __init__(self, stream, indent_per_level, level, indent):
self.stream = stream
self.indent_per_level = indent_per_level
self.level = level
self.indent = indent
self.previous_indent = 0
self.previous_out = ''
def update_level(self, level, indent):
self.level = level
self.indent = indent
def write(self, out):
write = self.stream.write
indent = self.indent
out = to_unicode(out)
if '\n' in out:
write(to_str(out))
elif out in self.OPEN:
write(to_str(out))
write('\n' + (1 + indent) * ' ')
elif out in self.CLOSE:
if self.previous_out not in self.CLOSE:
if indent == self.previous_indent:
indent = (self.level - 1) * self.indent_per_level
write('\n' + indent * ' ')
write(to_str(out))
else:
write(to_str(out))
self.previous_indent = indent
self.previous_out = out
class VariableWidthPrettyPrinter(PrettyPrinter, object):
def _format(self, object, stream, indent, allowance, context, level):
if not isinstance(object, string_type):
indent = level * self._indent_per_level
if level:
stream.update_level(level, indent)
else:
stream = StreamWrapper(
stream,
self._indent_per_level,
level,
indent,
)
super(VariableWidthPrettyPrinter, self)._format(
object=object,
stream=stream,
indent=indent,
allowance=allowance,
context=context,
level=level,
)
class PrettyPrintFormatter(Formatter):
_pretty_printer = VariableWidthPrettyPrinter(indent=4, width=160)
def convert_field(self, value, conversion):
if conversion == 'r':
return self._pretty_printer.pformat(value)
if conversion in {'d', 'e', 't', 'w'}:
_sort_dicts = sort_dicts = getattr(self._pretty_printer,
'_sort_dicts',
None)
width = self._pretty_printer._width
# __dict__
if conversion == 'd':
if sort_dicts:
_sort_dicts = False
try:
value = getattr(value, '__repr_data__')()
except AttributeError:
if not isinstance(value, dict):
value = {
attr: getattr(value, attr, None)
for attr in dir(value)
}
# eval iterators
elif conversion == 'e':
if (getattr(value, '__iter__', None)
and not getattr(value, '__len__', None)):
value = tuple(value)
if sort_dicts:
_sort_dicts = False
# text representation
elif conversion == 't':
try:
value = getattr(value, '__str_parts__')(as_dict=True)
if sort_dicts:
_sort_dicts = False
except AttributeError:
pass
# wide output
elif conversion == 'w':
self._pretty_printer._width = 2 * width
if _sort_dicts != sort_dicts:
self._pretty_printer._sort_dicts = _sort_dicts
out = self._pretty_printer.pformat(value)
if sort_dicts:
self._pretty_printer._sort_dicts = sort_dicts
self._pretty_printer._width = width
return out
return super(PrettyPrintFormatter, self).convert_field(
value,
conversion,
)
if not current_system_version.compatible(19):
def parse(self, *args, **kwargs):
output = super(PrettyPrintFormatter, self).parse(*args, **kwargs)
return (
(to_str(literal_text), field_name, format_spec, conversion)
for literal_text, field_name, format_spec, conversion in output
)
def format_field(self, *args, **kwargs):
return to_str(
super(PrettyPrintFormatter, self).format_field(*args, **kwargs)
)
class MessageFormatter(object):
_formatter = PrettyPrintFormatter()
__slots__ = (
'args',
'kwargs',
'msg',
)
def __init__(self, msg, *args, **kwargs):
self.msg = msg
self.args = args
self.kwargs = kwargs
def __str__(self):
return self._formatter.vformat(self.msg, self.args, self.kwargs)
class Handler(logging.Handler):
LEVELS = {
logging.NOTSET: xbmc.LOGNONE,
logging.DEBUG: xbmc.LOGDEBUG,
# logging.INFO: xbmc.LOGINFO,
logging.INFO: xbmc.LOGNOTICE,
logging.WARN: xbmc.LOGWARNING,
logging.WARNING: xbmc.LOGWARNING,
logging.ERROR: xbmc.LOGERROR,
logging.CRITICAL: xbmc.LOGFATAL,
}
STANDARD_FORMATTER = RecordFormatter(
fmt='[%(addon_id)s] %(module)s:%(lineno)d(%(funcName)s)'
'%(__sep__)s%(message)s',
)
DEBUG_FORMATTER = RecordFormatter(
fmt='[%(addon_id)s] %(module)s, line %(lineno)d, in %(funcName)s'
'\n%(message)s',
)
_stack_info = False
def __init__(self, level):
super(Handler, self).__init__(level=level)
self.setFormatter(self.STANDARD_FORMATTER)
def emit(self, record):
record.addon_id = ADDON_ID
xbmc.log(
msg=self.format(record),
level=self.LEVELS.get(record.levelno, xbmc.LOGDEBUG),
)
def format(self, record):
if self.stack_info:
fmt = self.DEBUG_FORMATTER
else:
fmt = self.STANDARD_FORMATTER
return fmt.format(record)
@property
def stack_info(self):
return self._stack_info
@stack_info.setter
def stack_info(self, value):
type(self)._stack_info = value
class LogRecord(logging.LogRecord):
def __init__(self, name, level, pathname, lineno, msg, args, exc_info,
func=None, **kwargs):
stack_info = kwargs.pop('sinfo', None)
super(LogRecord, self).__init__(name,
level,
pathname,
lineno,
msg,
args,
exc_info,
func=func,
**kwargs)
self.stack_info = stack_info
if not current_system_version.compatible(19):
def getMessage(self):
msg = self.msg
if isinstance(msg, MessageFormatter):
msg = msg.__str__()
else:
msg = to_str(msg)
if self.args:
msg = msg % self.args
return msg
class KodiLogger(logging.Logger):
_verbose_logging = False
_stack_info = False
def __init__(self, name, level=logging.DEBUG):
super(KodiLogger, self).__init__(name=name, level=level)
self.propagate = False
self.addHandler(Handler(level=logging.DEBUG))
def _log(self,
level,
msg,
args,
exc_info=None,
extra=None,
stack_info=False,
stacklevel=1,
**kwargs):
if isinstance(msg, (list, tuple)):
msg = '\n'.join(map(to_str, msg))
if kwargs:
msg = MessageFormatter(msg, *args, **kwargs)
args = ()
elif args and args[0] == '*(' and args[-1] == ')':
msg = MessageFormatter(msg, *args[1:-1], **kwargs)
args = ()
stack_info = stack_info and (exc_info or self.stack_info)
sinfo = None
if _srcfiles:
try:
fn, lno, func, sinfo = self.findCaller(stack_info, stacklevel)
except ValueError:
fn, lno, func = '(unknown file)', 0, '(unknown function)'
else:
fn, lno, func = '(unknown file)', 0, '(unknown function)'
if exc_info:
if isinstance(exc_info, BaseException):
exc_info = (type(exc_info), exc_info, exc_info.__traceback__)
elif not isinstance(exc_info, tuple):
exc_info = sys_exc_info()
record = self.makeRecord(self.name, level, fn, lno, msg, args,
exc_info, func, extra, sinfo)
self.handle(record)
def findCaller(self, stack_info=False, stacklevel=1):
target_frame = logging.currentframe()
if target_frame is None:
return '(unknown file)', 0, '(unknown function)', None
last_frame = None
while stacklevel > 0:
next_frame = target_frame.f_back
if next_frame is None:
break
target_frame = next_frame
stacklevel, is_internal = check_frame(target_frame, stacklevel)
if is_internal:
continue
if last_frame is None:
last_frame = target_frame
stacklevel -= 1
if stack_info:
with StringIO() as output:
output.write('Stack (most recent call last):\n')
for item in format_list(extract_stack(last_frame)):
output.write(item)
stack_info = output.getvalue()
if stack_info[-1] == '\n':
stack_info = stack_info[:-1]
else:
stack_info = None
target_frame_code = target_frame.f_code
return (target_frame_code.co_filename,
target_frame.f_lineno,
target_frame_code.co_name,
stack_info)
def makeRecord(self, name, level, fn, lno, msg, args, exc_info,
func=None, extra=None, sinfo=None):
rv = LogRecord(name,
level,
fn,
lno,
msg,
args,
exc_info,
func=func,
sinfo=sinfo)
if extra is not None:
for key in extra:
if (key in ["message", "asctime"]) or (key in rv.__dict__):
raise KeyError("Attempt to overwrite %r in LogRecord" % key)
rv.__dict__[key] = extra[key]
return rv
def exception(self, msg, *args, **kwargs):
if self.isEnabledFor(ERROR):
self._log(
ERROR,
msg,
args,
exc_info=kwargs.pop('exc_info', True),
stack_info=kwargs.pop('stack_info', True),
stacklevel=kwargs.pop('stacklevel', 1),
**kwargs
)
def error_trace(self, msg, *args, **kwargs):
if self.isEnabledFor(ERROR):
self._log(
ERROR,
msg,
args,
stack_info=kwargs.pop('stack_info', True),
stacklevel=kwargs.pop('stacklevel', 1),
**kwargs
)
def warning_trace(self, msg, *args, **kwargs):
if self.isEnabledFor(WARNING):
self._log(
WARNING,
msg,
args,
stack_info=kwargs.pop('stack_info', True),
stacklevel=kwargs.pop('stacklevel', 1),
**kwargs
)
def debug_trace(self, msg, *args, **kwargs):
if self.isEnabledFor(DEBUG):
self._log(
DEBUG,
msg,
args,
stack_info=kwargs.pop('stack_info', True),
stacklevel=kwargs.pop('stacklevel', 1),
**kwargs
)
@property
def debugging(self):
return self.isEnabledFor(logging.DEBUG)
@debugging.setter
def debugging(self, value):
if value:
Handler.LEVELS[logging.DEBUG] = xbmc.LOGNOTICE
self.setLevel(logging.DEBUG)
root.setLevel(logging.DEBUG)
else:
Handler.LEVELS[logging.DEBUG] = xbmc.LOGDEBUG
self.setLevel(logging.INFO)
root.setLevel(logging.INFO)
@property
def stack_info(self):
return self._stack_info
@stack_info.setter
def stack_info(self, value):
if value:
type(self)._stack_info = True
Handler.stack_info = True
else:
type(self)._stack_info = False
Handler.stack_info = False
@property
def verbose_logging(self):
return self._verbose_logging
@verbose_logging.setter
def verbose_logging(self, value):
cls = type(self)
if value:
cls._verbose_logging = True
logging.root = root
logging.Logger.root = root
logging.Logger.manager = manager
logging.Logger.manager.setLoggerClass(KodiLogger)
logging.setLoggerClass(KodiLogger)
else:
if cls._verbose_logging:
logging.root = logging.RootLogger(logging.WARNING)
logging.Logger.root = logging.root
logging.Logger.manager = logging.Manager(logging.root)
logging.Logger.manager.setLoggerClass(logging.Logger)
logging.setLoggerClass(logging.Logger)
cls._verbose_logging = False
class RootLogger(KodiLogger):
def __init__(self, level):
super(RootLogger, self).__init__('root', level)
def __reduce__(self):
return getLogger, ()
root = RootLogger(logging.INFO)
KodiLogger.root = root
manager = logging.Manager(root)
KodiLogger.manager = manager
KodiLogger.manager.setLoggerClass(KodiLogger)
critical = root.critical
error = root.error
warning = root.warning
info = root.info
debug = root.debug
log = root.log
CRITICAL = logging.CRITICAL
ERROR = logging.ERROR
WARNING = logging.WARNING
INFO = logging.INFO
DEBUG = logging.DEBUG
def exception(msg, *args, **kwargs):
root.error(msg,
*args,
exc_info=kwargs.pop('exc_info', True),
stack_info=kwargs.pop('stack_info', True),
stacklevel=kwargs.pop('stacklevel', 1),
**kwargs)
def error_trace(msg, *args, **kwargs):
root.error(msg,
*args,
stack_info=kwargs.pop('stack_info', True),
stacklevel=kwargs.pop('stacklevel', 1),
**kwargs)
def warning_trace(msg, *args, **kwargs):
root.warning(msg,
*args,
stack_info=kwargs.pop('stack_info', True),
stacklevel=kwargs.pop('stacklevel', 1),
**kwargs)
def debug_trace(msg, *args, **kwargs):
root.debug(msg,
*args,
stack_info=kwargs.pop('stack_info', True),
stacklevel=kwargs.pop('stacklevel', 1),
**kwargs)
def getLogger(name=None):
if not name or isinstance(name, string_type) and name == root.name:
return root
return KodiLogger.manager.getLogger(name)
_srcfiles = {
normpath(getLogger.__code__.co_filename).lower(),
normpath(logging.getLogger.__code__.co_filename).lower(),
}
def check_frame(frame, stacklevel=None, skip_paths=None):
filename = normpath(frame.f_code.co_filename).lower()
is_internal = (
filename in _srcfiles
or ('importlib' in filename and '_bootstrap' in filename)
or (skip_paths
and any(skip_path in filename for skip_path in skip_paths))
)
if stacklevel is None:
return is_internal
if (ADDON_ID in filename and filename.endswith((
'function_cache.py',
'abstract_settings.py',
'xbmc_items.py',
))):
stacklevel += 1
return stacklevel, is_internal
__original_module__ = sys.modules[__name__]
class ModuleProperties(__original_module__.__class__, object):
__name__ = __original_module__.__name__
__file__ = __original_module__.__file__
__getattribute__ = __original_module__.__getattribute__
def __getattr__(self, item):
if item == 'debugging':
return root.isEnabledFor(logging.DEBUG)
raise AttributeError(
'module \'{}\' has no attribute \'{}\''.format(__name__, item)
)
sys.modules[__name__] = ModuleProperties(__name__, __doc__)

View File

@@ -0,0 +1,20 @@
# -*- 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 .player_monitor import PlayerMonitor
from .service_monitor import ServiceMonitor
__all__ = (
'PlayerMonitor',
'ServiceMonitor',
)

View File

@@ -0,0 +1,465 @@
# -*- coding: utf-8 -*-
"""
Copyright (C) 2018-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
import json
import threading
from .. import logging
from ..compatibility import xbmc
from ..constants import (
BUSY_FLAG,
CHANNEL_ID,
PATHS,
PLAYBACK_STARTED,
PLAYBACK_STOPPED,
PLAYER_DATA,
PLAY_USING,
REFRESH_CONTAINER,
TRAKT_PAUSE_FLAG,
VIDEO_ID,
)
from ..utils.redact import redact_params
class PlayerMonitorThread(threading.Thread):
def __init__(self, player, provider, context, monitor, player_data):
self.player_data = player_data
video_id = player_data.get(VIDEO_ID)
self.video_id = video_id
self.channel_id = player_data.get(CHANNEL_ID)
self.video_status = player_data.get('video_status')
self._stopped = threading.Event()
self._ended = threading.Event()
self._player = player
self._provider = provider
self._context = context
self._monitor = monitor
self.current_time = 0.0
self.total_time = 0.0
self.progress = 0
name = '{class_name}[{video_id}]'.format(
class_name=self.__class__.__name__,
video_id=video_id,
)
self.log = logging.getLogger(name)
super(PlayerMonitorThread, self).__init__(name=name)
self.daemon = True
self.start()
def abort_now(self):
return (not self._player.isPlaying()
or self._context.abort_requested()
or self.stopped())
def run(self):
video_id = self.video_id
playing_file = self.player_data.get('playing_file')
play_count = self.player_data.get('play_count', 0)
use_remote_history = self.player_data.get('use_remote_history', False)
use_local_history = self.player_data.get('use_local_history', False)
playback_stats = self.player_data.get('playback_stats', {})
refresh_only = self.player_data.get('refresh_only', False)
clip = self.player_data.get('clip', False)
context = self._context
log = self.log
monitor = self._monitor
player = self._player
provider = self._provider
log.debug('Starting')
timeout_period = 5
waited = 0
wait_interval = 0.5
while not player.isPlaying():
if context.abort_requested():
break
if waited >= timeout_period:
self.end()
return
log.debug('Waiting for playback to start')
monitor.waitForAbort(wait_interval)
waited += wait_interval
else:
context.send_notification(PLAYBACK_STARTED, {
VIDEO_ID: video_id,
CHANNEL_ID: self.channel_id,
'status': self.video_status,
})
client = provider.get_client(context)
logged_in = client.logged_in
report_url = use_remote_history and playback_stats.get('playback_url')
state = 'playing'
if report_url:
client.update_watch_history(
video_id,
report_url,
)
access_manager = context.get_access_manager()
settings = context.get_settings()
playlist_player = context.get_playlist_player()
video_id_param = 'video_id=%s' % video_id
report_url = use_remote_history and playback_stats.get('watchtime_url')
segment_start = 0.0
report_time = -1.0
wait_interval = 1
report_period = waited = 10
while not self.abort_now():
try:
current_file = player.getPlayingFile()
played_time = player.getTime()
total_time = player.getTotalTime()
if not player.seeking:
player.current_time = played_time
player.total_time = total_time
except RuntimeError:
self.stop()
break
if (not current_file.startswith(playing_file) and not (
context.is_plugin_path(current_file, PATHS.PLAY)
and video_id_param in current_file
)) or total_time <= 0:
self.stop()
break
_seek_time = player.start_time or player.seek_time
if waited and _seek_time and played_time < _seek_time:
waited = 0
player.seekTime(_seek_time)
continue
if player.end_time and played_time >= player.end_time:
if waited and clip and player.start_time:
waited = 0
player.seekTime(player.start_time)
continue
if playlist_player.size() > 1:
playlist_player.play_playlist_item('next')
else:
player.stop()
if waited >= report_period:
waited = 0
last_state = state
if played_time == report_time:
state = 'paused'
else:
state = 'playing'
report_time = played_time
if logged_in and report_url:
if state == 'playing':
segment_end = played_time
else:
segment_end = segment_start
if segment_start > segment_end:
segment_end = segment_start + report_period
if segment_end > total_time:
segment_end = total_time
# only report state='paused' once
if state == 'playing' or last_state == 'playing':
client = provider.get_client(context)
logged_in = client.logged_in
if logged_in:
client.update_watch_history(
video_id,
report_url,
status=(
played_time,
segment_start,
segment_end,
state,
),
)
segment_start = segment_end
monitor.waitForAbort(wait_interval)
waited += wait_interval
self.current_time = player.current_time
self.total_time = player.total_time
if self.total_time > 0:
self.progress = int(100 * self.current_time / self.total_time)
if logged_in:
client = provider.get_client(context)
logged_in = client.logged_in
if self.video_status.get('live'):
play_count += 1
segment_end = self.current_time
play_data = {
'play_count': play_count,
'total_time': 0,
'played_time': 0,
'played_percent': 0,
}
else:
if self.progress >= settings.get_play_count_min_percent():
play_count += 1
self.current_time = 0
segment_end = self.total_time
else:
segment_end = self.current_time
refresh_only = True
play_data = {
'play_count': play_count,
'total_time': self.total_time,
'played_time': self.current_time,
'played_percent': self.progress,
}
self.player_data['play_data'] = play_data
if logged_in and report_url:
client.update_watch_history(
video_id,
report_url,
status=(
segment_end,
segment_end,
segment_end,
'stopped',
),
)
if use_local_history:
context.get_playback_history().set_item(video_id, play_data)
context.send_notification(PLAYBACK_STOPPED, self.player_data)
log.debug('Playback stopped:'
' {played_time:.3f} secs of {total_time:.3f}'
' @ {played_percent}%,'
' played {play_count} time(s)',
**play_data)
if refresh_only:
pass
elif settings.get_bool(settings.WATCH_LATER_REMOVE, True):
watch_later_id = logged_in and access_manager.get_watch_later_id()
if not watch_later_id:
context.get_watch_later_list().del_item(video_id)
elif watch_later_id.lower() == 'wl':
provider.on_playlist_x(
provider,
context,
command='remove',
category='video',
playlist_id=watch_later_id,
video_id=video_id,
video_name='',
confirmed=True,
)
else:
playlist_item_id = client.get_playlist_item_id_of_video_id(
playlist_id=watch_later_id,
video_id=video_id,
do_auth=True,
)
if playlist_item_id:
provider.on_playlist_x(
provider,
context,
command='remove',
category='video',
playlist_id=watch_later_id,
video_id=playlist_item_id,
video_name='',
confirmed=True,
)
if logged_in and not refresh_only:
history_id = access_manager.get_watch_history_id()
if history_id and history_id.lower() != 'hl':
client.add_video_to_playlist(history_id, video_id)
# rate video
if (settings.get_bool(settings.RATE_VIDEOS) and
(settings.get_bool(settings.RATE_PLAYLISTS)
or xbmc.PlayList(xbmc.PLAYLIST_VIDEO).size() < 2)):
json_data = client.get_video_rating(video_id)
if json_data:
items = json_data.get('items', [{'rating': 'none'}])
rating = items[0].get('rating', 'none')
if rating == 'none':
provider.on_video_x(
provider,
context,
command='rate',
video_id=video_id,
current_rating=rating,
)
if settings.get_bool(settings.PLAY_REFRESH):
context.send_notification(REFRESH_CONTAINER)
self.end()
def stop(self):
self.log.debug('Stop event set')
self._stopped.set()
def stopped(self):
return self._stopped.is_set()
def end(self):
self.log.debug('End event set')
self._ended.set()
def ended(self):
return self._ended.is_set()
class PlayerMonitor(xbmc.Player):
log = logging.getLogger(__name__)
def __init__(self, provider, context, monitor):
super(PlayerMonitor, self).__init__()
self._provider = provider
self._context = context
self._monitor = monitor
self._ui = self._context.get_ui()
self.threads = []
self.seeking = False
self.seek_time = None
self.start_time = None
self.end_time = None
self.current_time = None
self.total_time = None
def stop_threads(self):
for thread in self.threads:
if thread.ended():
continue
if not thread.stopped():
self.log.debug('Stopping: %s', thread.name)
thread.stop()
for thread in self.threads:
if thread.stopped() and not thread.ended():
try:
thread.join(5)
except RuntimeError:
pass
def cleanup_threads(self, only_ended=True):
active_threads = []
active_thread_names = []
for thread in self.threads:
if only_ended and not thread.ended():
active_threads.append(thread)
active_thread_names.append(thread.name)
continue
if thread.ended():
self.log.debug('Clean up: %s', thread.name)
else:
self.log.debug('Stopping: %s', thread.name)
if not thread.stopped():
thread.stop()
try:
thread.join(5)
except RuntimeError:
pass
self.log.debug('Active threads: %s', active_thread_names)
self.threads = active_threads
def onPlayBackStarted(self):
if not self._ui.busy_dialog_active():
self._ui.clear_property(BUSY_FLAG)
if self._ui.get_property(PLAY_USING):
self._context.execute('Action(SwitchPlayer)')
self._context.execute('Action(Stop)')
return
def onAVStarted(self):
ui = self._ui
if ui.get_property(PLAY_USING):
return
if not ui.busy_dialog_active():
ui.clear_property(BUSY_FLAG)
player_data = ui.pop_property(PLAYER_DATA,
process=json.loads,
log_process=redact_params)
if not player_data:
return
self.cleanup_threads()
try:
self.seek_time = float(player_data.get('seek_time'))
self.start_time = float(player_data.get('start_time'))
self.end_time = float(player_data.get('end_time'))
self.current_time = max(0.0, self.getTime())
self.total_time = max(0.0, self.getTotalTime())
except (ValueError, TypeError, RuntimeError):
self.seek_time = None
self.start_time = None
self.end_time = None
self.current_time = 0.0
self.total_time = 0.0
self.threads.append(PlayerMonitorThread(self,
self._provider,
self._context,
self._monitor,
player_data))
def onPlayBackEnded(self):
ui = self._ui
if not ui.busy_dialog_active():
ui.clear_property(BUSY_FLAG)
ui.pop_property(PLAY_USING)
ui.clear_property(TRAKT_PAUSE_FLAG, raw=True)
self.stop_threads()
self.cleanup_threads()
def onPlayBackStopped(self):
self.onPlayBackEnded()
def onPlayBackError(self):
self.onPlayBackEnded()
def onPlayBackSeek(self, time, seekOffset):
time_s = time / 1000
self.seeking = True
self.current_time = time_s
self.seek_time = None
if ((self.end_time and time_s > self.end_time + 1)
or (self.start_time and time_s < self.start_time - 1)):
self.start_time = None
self.end_time = None
def onAVChange(self):
self.seeking = False

View File

@@ -0,0 +1,500 @@
# -*- coding: utf-8 -*-
"""
Copyright (C) 2018-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
import json
from io import open
from threading import Event, Lock, Thread
from .. import logging
from ..compatibility import urlsplit, xbmc, xbmcgui
from ..constants import (
ACTION,
ADDON_ID,
CHECK_SETTINGS,
CONTAINER_FOCUS,
CONTAINER_ID,
CONTAINER_POSITION,
CURRENT_ITEM,
FILE_READ,
FILE_WRITE,
HAS_PARENT,
MARK_AS_LABEL,
PATHS,
PLAYBACK_STOPPED,
PLAYER_VIDEO_ID,
PLAY_CANCELLED,
PLAY_COUNT,
PLAY_FORCED,
PLUGIN_WAKEUP,
REFRESH_CONTAINER,
RELOAD_ACCESS_MANAGER,
RESUMABLE,
SERVER_WAKEUP,
SERVICE_IPC,
SYNC_LISTITEM,
VIDEO_ID,
)
from ..network import get_connect_address, get_http_server, httpd_status
from ..utils.methods import jsonrpc
class ServiceMonitor(xbmc.Monitor):
log = logging.getLogger(__name__)
_settings_changes = 0
_settings_collect = False
get_idle_time = xbmc.getGlobalIdleTime
def __init__(self, context):
self._context = context
self._httpd_address = None
self._httpd_port = None
self._whitelist = None
self._old_httpd_address = None
self._old_httpd_port = None
self._use_httpd = None
self._httpd_error = False
self.httpd = None
self.httpd_thread = None
self.httpd_sleep_allowed = True
self.system_idle = False
self.system_sleep = False
self.refresh = False
self.interrupt = False
self.file_access = {}
self.onSettingsChanged(force=True)
super(ServiceMonitor, self).__init__()
@staticmethod
def send_notification(method,
data=True,
sender='.'.join((ADDON_ID, 'service'))):
jsonrpc(method='JSONRPC.NotifyAll',
params={'sender': sender,
'message': method,
'data': data})
def set_property(self,
property_id,
value='true',
stacklevel=2,
process=None,
log_value=None,
log_process=None,
raw=False):
if log_value is None:
log_value = value
if log_process:
log_value = log_process(log_value)
self.log.debug_trace('Set property {property_id!r}: {value!r}',
property_id=property_id,
value=log_value,
stacklevel=stacklevel)
_property_id = property_id if raw else '-'.join((ADDON_ID, property_id))
if process:
value = process(value)
xbmcgui.Window(10000).setProperty(_property_id, value)
return value
def refresh_container(self, force=False):
if force:
self.refresh = False
refreshed = self._context.get_ui().refresh_container(force=force)
if refreshed is None:
self.refresh = True
def onNotification(self, sender, method, data):
if sender == 'xbmc':
if method == 'System.OnSleep':
self.system_idle = True
self.system_sleep = True
elif method in {
'GUI.OnScreensaverActivated',
'GUI.OnDPMSActivated',
}:
self.system_idle = True
elif method in {
'GUI.OnScreensaverDeactivated',
'GUI.OnDPMSDeactivated',
'System.OnWake',
}:
self.system_idle = False
self.system_sleep = False
self.interrupt = True
elif method == 'Player.OnPlay':
player = xbmc.Player()
try:
playing_file = urlsplit(player.getPlayingFile())
if playing_file.path in {PATHS.MPD,
PATHS.PLAY,
PATHS.REDIRECT}:
if not self.httpd:
self.start_httpd()
if self.httpd_sleep_allowed:
self.httpd_sleep_allowed = None
except RuntimeError:
pass
elif method == 'Playlist.OnAdd':
context = self._context
data = json.loads(data)
position = data.get('position', 0)
playlist_player = context.get_playlist_player()
item_uri = playlist_player.get_item_path(position)
if context.is_plugin_path(item_uri):
path, params = context.parse_uri(item_uri)
if path.rstrip('/') != PATHS.PLAY:
self.log.warning(('Playlist.OnAdd item is not playable',
'Path: {path}',
'Params: {params}'),
path=path,
params=params)
self.set_property(PLAY_FORCED)
elif params.get(ACTION) == 'list':
playlist_player.stop()
playlist_player.clear()
self.log.warning(('Playlist.OnAdd item is a listing',
'Path: {path}',
'Params: {params}'),
path=path,
params=params)
self.set_property(PLAY_CANCELLED)
return
if sender != ADDON_ID:
return
group, separator, event = method.partition('.')
if event == SERVICE_IPC:
if not isinstance(data, dict):
data = json.loads(data)
if not data:
return
target = data.get('target')
if target == PLUGIN_WAKEUP:
self.system_idle = False
self.system_sleep = False
self.interrupt = True
response = True
elif target == SERVER_WAKEUP:
if not self.httpd and self.httpd_required():
response = self.start_httpd()
else:
response = bool(self.httpd)
if self.httpd_sleep_allowed:
self.httpd_sleep_allowed = None
elif target == CHECK_SETTINGS:
state = data.get('state')
if state == 'defer':
self._settings_collect = True
elif state == 'process':
self.onSettingsChanged(force=True)
elif state == 'ignore':
self._settings_collect = -1
response = True
elif target in {FILE_READ, FILE_WRITE}:
response = None
filepath = data.get('filepath')
if filepath:
if filepath not in self.file_access:
read_access = Event()
read_access.set()
write_access = Lock()
self.file_access[filepath] = (read_access, write_access)
else:
read_access, write_access = self.file_access[filepath]
if target == FILE_READ:
try:
with open(filepath, mode='r',
encoding='utf-8') as file:
read_access.wait()
self.set_property(
'-'.join((FILE_READ, filepath)),
file.read(),
log_value='<redacted>',
)
response = True
except (IOError, OSError):
response = False
else:
with write_access:
content = self._context.get_ui().pop_property(
'-'.join((FILE_WRITE, filepath)),
log_value='<redacted>',
)
response = None
if content:
read_access.clear()
try:
with open(filepath, mode='w',
encoding='utf-8') as file:
file.write(content)
response = True
except (IOError, OSError):
response = False
finally:
read_access.set()
else:
return
if data.get('response_required'):
data['response'] = response
self.send_notification(SERVICE_IPC, data)
elif event == REFRESH_CONTAINER:
self.refresh_container()
elif event == CONTAINER_FOCUS:
if data:
data = json.loads(data)
if data:
self._context.get_ui().focus_container(
container_id=data.get(CONTAINER_ID),
position=data.get(CONTAINER_POSITION),
)
elif event == RELOAD_ACCESS_MANAGER:
self._context.reload_access_manager()
self.refresh_container()
elif event == PLAYBACK_STOPPED:
if data:
data = json.loads(data)
if not data:
return
if data.get('play_data', {}).get('play_count'):
self.set_property(PLAYER_VIDEO_ID, data.get(VIDEO_ID))
elif event == SYNC_LISTITEM:
video_ids = json.loads(data) if data else None
if not video_ids:
return
context = self._context
ui = context.get_ui()
focused_video_id = ui.get_listitem_property(VIDEO_ID)
if not focused_video_id:
return
playback_history = context.get_playback_history()
for video_id in video_ids:
if not video_id or video_id != focused_video_id:
continue
play_count = ui.get_listitem_info(PLAY_COUNT)
resumable = ui.get_listitem_bool(RESUMABLE)
self.set_property(MARK_AS_LABEL,
context.localize('history.mark.unwatched')
if play_count else
context.localize('history.mark.watched'))
item_history = playback_history.get_item(video_id)
if item_history:
item_history = dict(
item_history,
play_count=int(play_count) if play_count else 0,
)
if not resumable:
item_history['played_time'] = 0
item_history['played_percent'] = 0
playback_history.update_item(video_id, item_history)
else:
playback_history.set_item(video_id, {
'play_count': int(play_count) if play_count else 0,
})
def onSettingsChanged(self, force=False):
context = self._context
if force:
self._settings_collect = False
self._settings_changes = 0
else:
self._settings_changes += 1
if self._settings_collect:
if self._settings_collect == -1:
self._settings_collect = False
return
total = self._settings_changes
self.waitForAbort(1)
if total != self._settings_changes:
return
self.log.debug('onSettingsChanged: %d change(s)', total)
self._settings_changes = 0
settings = context.get_settings(refresh=True)
log_level = settings.log_level()
if log_level:
self.log.debugging = True
if log_level & 2:
self.log.stack_info = True
self.log.verbose_logging = True
else:
self.log.stack_info = False
self.log.verbose_logging = False
else:
self.log.debugging = False
self.log.stack_info = False
self.log.verbose_logging = False
self.set_property(CHECK_SETTINGS)
self.refresh_container()
httpd_started = bool(self.httpd)
httpd_restart = False
address, port = get_connect_address(context)
if port != self._httpd_port:
self._old_httpd_port = self._httpd_port
self._httpd_port = port
httpd_restart = httpd_started
if address != self._httpd_address:
self._old_httpd_address = self._httpd_address
self._httpd_address = address
httpd_restart = httpd_started
whitelist = settings.httpd_whitelist()
if whitelist != self._whitelist:
self._whitelist = whitelist
httpd_restart = httpd_started
sleep_allowed = settings.httpd_sleep_allowed()
if sleep_allowed is False:
self.httpd_sleep_allowed = False
if self.httpd_required(settings):
if httpd_restart:
self.restart_httpd()
else:
self.start_httpd()
elif httpd_started:
self.shutdown_httpd(terminate=True)
def httpd_address_sync(self):
self._old_httpd_address = self._httpd_address
self._old_httpd_port = self._httpd_port
def start_httpd(self):
if self.httpd:
self._httpd_error = False
return True
context = self._context
self.log.debug('HTTPServer: Starting {ip}:{port}',
ip=self._httpd_address,
port=self._httpd_port)
self.httpd_address_sync()
self.httpd = get_http_server(address=self._httpd_address,
port=self._httpd_port,
context=context)
if not self.httpd:
self._httpd_error = True
return False
self.httpd_thread = Thread(target=self.httpd.serve_forever)
self.httpd_thread.daemon = True
self.httpd_thread.start()
address = self.httpd.socket.getsockname()
self.log.debug('HTTPServer: Listening on {address[0]}:{address[1]}',
address=address)
self._httpd_error = False
return True
def shutdown_httpd(self, on_idle=False, terminate=False, player=None):
if (not self.httpd
or (not (terminate or self.system_sleep)
and (on_idle or self.system_idle)
and self.httpd_required(on_idle=True, player=player))):
return
self.log.debug('HTTPServer: Shutting down {ip}:{port}',
ip=self._old_httpd_address,
port=self._old_httpd_port)
self.httpd_address_sync()
shutdown_thread = Thread(target=self.httpd.shutdown)
shutdown_thread.daemon = True
shutdown_thread.start()
for thread in (self.httpd_thread, shutdown_thread):
if not thread.is_alive():
continue
try:
thread.join(2)
except RuntimeError:
pass
self.httpd.server_close()
self.httpd_thread = None
self.httpd = None
def restart_httpd(self):
self.log.debug('HTTPServer: Restarting'
' {old_ip}:{old_port} > {ip}:{port}',
old_ip=self._old_httpd_address,
old_port=self._old_httpd_port,
ip=self._httpd_address,
port=self._httpd_port)
self.shutdown_httpd(terminate=True)
self.start_httpd()
def ping_httpd(self):
return self.httpd and httpd_status(self._context)
def httpd_required(self, settings=None, on_idle=False, player=None):
if settings:
required = (settings.use_mpd_videos()
or settings.api_config_page()
or settings.support_alternative_player())
self._use_httpd = required
elif self._httpd_error:
required = False
elif on_idle:
settings = self._context.get_settings()
playing = player.isPlaying() if player else False
external = player.isExternalPlayer() if playing else False
required = ((playing and settings.use_mpd_videos())
or settings.api_config_page()
or (external and settings.support_alternative_player()))
else:
required = self._use_httpd
return required

View File

@@ -0,0 +1,32 @@
# -*- coding: utf-8 -*-
"""
Copyright (C) 2023-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 .http_server import (
get_client_ip_address,
get_connect_address,
get_http_server,
get_listen_addresses,
httpd_status,
)
from .ip_api import Locator
from .requests import BaseRequestsClass, InvalidJSONError
__all__ = (
'get_client_ip_address',
'get_connect_address',
'get_http_server',
'get_listen_addresses',
'httpd_status',
'BaseRequestsClass',
'InvalidJSONError',
'Locator',
)

View File

@@ -0,0 +1,57 @@
# -*- coding: utf-8 -*-
"""
Copyright (C) 2018-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 .requests import BaseRequestsClass
from .. import logging
class Locator(BaseRequestsClass):
log = logging.getLogger(__name__)
def __init__(self, context):
self._base_url = 'http://ip-api.com'
self._response = {}
super(Locator, self).__init__(context=context)
def response(self):
return self._response
def locate_requester(self):
request_url = '/'.join((self._base_url, 'json'))
response = self.request(request_url)
if response is None:
self._response = {}
return
with response:
self._response = response.json()
def success(self):
response = self.response()
successful = response.get('status', 'fail') == 'success'
if successful:
self.log.debug('Request successful')
else:
self.log.error(('Request failed', 'Message: %s'),
response.get('message', 'Unknown'))
return successful
def coordinates(self):
lat = None
lon = None
if self.success():
lat = self._response.get('lat')
lon = self._response.get('lon')
if lat is None or lon is None:
self.log.error('No coordinates returned')
return None
self.log.debug('Coordinates found')
return {'lat': lat, 'lon': lon}

View File

@@ -0,0 +1,416 @@
# -*- coding: utf-8 -*-
"""
Copyright (C) 2023-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
import atexit
import socket
from requests import Request, Session
from requests.adapters import HTTPAdapter, Retry
from requests.exceptions import InvalidJSONError, RequestException, URLRequired
from requests.utils import DEFAULT_CA_BUNDLE_PATH, extract_zipped_paths
from urllib3.util.ssl_ import create_urllib3_context
from .. import logging
from ..utils.datetime import imf_fixdate
from ..utils.methods import generate_hash
__all__ = (
'BaseRequestsClass',
'InvalidJSONError'
)
class SSLHTTPAdapter(HTTPAdapter):
_SOCKET_OPTIONS = (
(socket.SOL_SOCKET, getattr(socket, 'SO_KEEPALIVE', None), 1),
(socket.IPPROTO_TCP, getattr(socket, 'TCP_NODELAY', None), 1),
(socket.IPPROTO_TCP, getattr(socket, 'TCP_KEEPIDLE', None), 300),
# TCP_KEEPALIVE equivalent to TCP_KEEPIDLE on iOS/macOS
(socket.IPPROTO_TCP, getattr(socket, 'TCP_KEEPALIVE', None), 300),
# TCP_KEEPINTVL may not be implemented at app level on iOS/macOS
(socket.IPPROTO_TCP, getattr(socket, 'TCP_KEEPINTVL', None), 60),
# TCP_KEEPCNT may not be implemented at app level on iOS/macOS
(socket.IPPROTO_TCP, getattr(socket, 'TCP_KEEPCNT', None), 5),
# TCP_USER_TIMEOUT = TCP_KEEPIDLE + TCP_KEEPINTVL * TCP_KEEPCNT
(socket.IPPROTO_TCP, getattr(socket, 'TCP_USER_TIMEOUT', None), 600),
)
_ssl_context = create_urllib3_context()
_ssl_context.load_verify_locations(
capath=extract_zipped_paths(DEFAULT_CA_BUNDLE_PATH)
)
def init_poolmanager(self, *args, **kwargs):
kwargs['ssl_context'] = self._ssl_context
kwargs['socket_options'] = [
socket_option for socket_option in self._SOCKET_OPTIONS
if socket_option[1] is not None
]
return super(SSLHTTPAdapter, self).init_poolmanager(*args, **kwargs)
def cert_verify(self, conn, url, verify, cert):
self._ssl_context.check_hostname = bool(verify)
return super(SSLHTTPAdapter, self).cert_verify(conn, url, verify, cert)
class BaseRequestsClass(object):
log = logging.getLogger(__name__)
_session = Session()
_session.trust_env = False
_session.mount('https://', SSLHTTPAdapter(
pool_maxsize=10,
pool_block=True,
max_retries=Retry(
total=3,
backoff_factor=0.1,
status_forcelist={500, 502, 503, 504},
allowed_methods=None,
)
))
atexit.register(_session.close)
_context = None
_verify = True
_timeout = (9.5, 27)
_proxy = None
_default_exc = (RequestException,)
METHODS_TO_CACHE = {'GET', 'HEAD'}
def __init__(self,
context=None,
verify_ssl=None,
timeout=None,
proxy_settings=None,
exc_type=None,
**_kwargs):
super(BaseRequestsClass, self).__init__()
BaseRequestsClass.init(
context=context,
verify_ssl=verify_ssl,
timeout=timeout,
proxy_settings=proxy_settings,
)
self._default_exc = (
(RequestException,) + exc_type
if isinstance(exc_type, tuple) else
(RequestException, exc_type)
if exc_type else
(RequestException,)
)
@classmethod
def init(cls,
context=None,
verify_ssl=None,
timeout=None,
proxy_settings=None,
**_kwargs):
cls._context = (cls._context
if context is None else
context)
if cls._context:
settings = cls._context.get_settings()
cls._verify = (settings.verify_ssl()
if verify_ssl is None else
verify_ssl)
cls._timeout = (settings.requests_timeout()
if timeout is None else
timeout)
cls._proxy = (settings.proxy_settings()
if proxy_settings is None else
proxy_settings)
def reinit(self, **kwargs):
self.__init__(**kwargs)
def __enter__(self):
return self
def __exit__(self, exc_type=None, exc_val=None, exc_tb=None):
self._session.close()
@staticmethod
def _raise_exception(new_exception, *args, **kwargs):
if not new_exception:
return
if issubclass(new_exception, RequestException):
new_exception = new_exception(*args)
attrs = new_exception.__dict__
for attr, value in kwargs.items():
if attr not in attrs:
setattr(new_exception, attr, value)
raise new_exception
else:
raise new_exception(*args, **kwargs)
def _response_hook_json(self, **kwargs):
response = kwargs['response']
if response is None:
return None, None
with response:
try:
json_data = response.json()
if 'error' in json_data:
kwargs.setdefault('pass_data', True)
kwargs.setdefault('json_data', json_data)
json_data.setdefault('code', response.status_code)
self._raise_exception(
kwargs.get('exception', RequestException),
'"error" in response JSON data',
**kwargs
)
except ValueError as exc:
if kwargs.get('raise_exc') is None:
kwargs['raise_exc'] = True
self._raise_exception(
InvalidJSONError,
exc,
**kwargs
)
response.raise_for_status()
return json_data.get('etag'), json_data
def _response_hook_text(self, **kwargs):
response = kwargs['response']
if response is None:
return None, None
with response:
response.raise_for_status()
result = response and response.text
if not result:
self._raise_exception(
kwargs.get('exception', RequestException),
'Empty response text',
**kwargs
)
return None, result
def request(self, url=None, method='GET',
params=None, data=None, headers=None, cookies=None, files=None,
auth=None, timeout=None, allow_redirects=None, proxies=None,
hooks=None, stream=None, verify=None, cert=None, json=None,
prepared_request=None,
# Custom event hook implementation
# See _response_hook and _error_hook in login_client.py
# for example usage
response_hook=None,
error_hook=None,
event_hook_kwargs=None,
error_title=None,
error_info=None,
raise_exc=None,
cache=None,
**kwargs):
if timeout is None:
timeout = self._timeout
if verify is None:
verify = self._verify
if proxies is None:
proxies = self._proxy
if allow_redirects is None:
allow_redirects = True
stacklevel = kwargs.pop('stacklevel', 2)
response = None
request_id = None
cached_response = None
etag = None
timestamp = None
if url:
prepared_request = self._session.prepare_request(Request(
method=method,
url=url,
headers=headers,
files=files,
data=data,
json=json,
params=params,
auth=auth,
cookies=cookies,
hooks=hooks,
))
if cache is not False:
if prepared_request:
method = prepared_request.method
if cache is True or method in self.METHODS_TO_CACHE:
headers = prepared_request.headers
request_id = generate_hash(
method,
prepared_request.url,
headers,
prepared_request.body,
)
if request_id:
if cache == 'refresh':
cache = self._context.get_requests_cache()
cached_request = None
else:
cache = self._context.get_requests_cache()
cached_request = cache.get(request_id)
else:
cache = False
cached_request = None
if cached_request:
etag, cached_response = cached_request['value']
if cached_response is not None:
if etag:
# Etag is meant to be enclosed in double quotes, but the
# Google servers don't seem to support this
headers['If-None-Match'] = '"{0}", {0}'.format(etag)
timestamp = imf_fixdate(cached_request['timestamp'])
headers['If-Modified-Since'] = timestamp
self.log.debug(('Cached response',
'Request ID: {request_id}',
'Etag: {etag}',
'Modified: {timestamp}'),
request_id=request_id,
etag=etag,
timestamp=timestamp,
stacklevel=stacklevel)
if event_hook_kwargs is None:
event_hook_kwargs = {}
try:
if prepared_request:
response = self._session.send(
request=prepared_request,
stream=stream,
verify=verify,
proxies=proxies,
cert=cert,
timeout=timeout,
allow_redirects=allow_redirects,
)
else:
raise URLRequired()
status_code = getattr(response, 'status_code', None)
if not status_code:
raise self._default_exc[0](response=response)
if cached_response is None or status_code != 304:
timestamp = response.headers.get('Date')
if response_hook:
event_hook_kwargs['exception'] = self._default_exc[-1]
event_hook_kwargs['raise_exc'] = raise_exc
event_hook_kwargs['response'] = response
etag, response = response_hook(**event_hook_kwargs)
else:
etag = None
response.raise_for_status()
# Only clear cached response if there was no error response
cached_response = None
except self._default_exc as exc:
exc_response = exc.response or response
if exc_response:
response_text = exc_response.text
response_status = exc_response.status_code
response_reason = exc_response.reason
else:
response_text = None
response_status = 'Error'
response_reason = 'No response'
log_msg = [
'{title}',
'URL: {method} {url}',
'Status: {response_status} - {response_reason}',
'Response: {response_text}',
]
kwargs.update(event_hook_kwargs)
kwargs['exc'] = exc
kwargs['response'] = exc_response
if error_hook:
error_response = error_hook(**kwargs)
_title, _info, _detail, _response, _exc = error_response
if _title is not None:
error_title = _title
if _info:
if isinstance(_info, (list, tuple)):
log_msg.extend(_info)
else:
log_msg.append(_info)
if _detail is not None:
kwargs.update(_detail)
if _response is not None:
response = _response
if response and not response_text:
response_text = repr(_response)
if _exc is not None:
raise_exc = _exc
if error_info:
if isinstance(error_info, (list, tuple)):
log_msg.extend(error_info)
else:
log_msg.append(error_info)
self.log.exception(log_msg,
title=(error_title or 'Failed'),
method=method,
url=url,
response_status=response_status,
response_reason=response_reason,
response_text=response_text,
stacklevel=stacklevel,
**kwargs)
if raise_exc:
if not isinstance(raise_exc, BaseException):
if not callable(raise_exc):
raise_exc = self._default_exc[-1]
raise_exc = raise_exc(error_title)
if isinstance(raise_exc, BaseException):
raise_exc.__cause__ = exc
raise raise_exc
raise exc
if cache:
if cached_response is not None:
self.log.debug(('Using cached response',
'Request ID: {request_id}',
'Etag: {etag}',
'Modified: {timestamp}'),
request_id=request_id,
etag=etag,
timestamp=timestamp,
stacklevel=stacklevel)
cache.set(request_id)
response = cached_response
else:
self.log.debug(('Saving response to cache',
'Request ID: {request_id}',
'Etag: {etag}',
'Modified: {timestamp}'),
request_id=request_id,
etag=etag,
timestamp=timestamp,
stacklevel=stacklevel)
cache.set(request_id, response, etag)
return response

View File

@@ -0,0 +1,15 @@
# -*- coding: utf-8 -*-
"""
Copyright (C) 2023-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 .xbmc.xbmc_playlist_player import XbmcPlaylistPlayer
__all__ = ('XbmcPlaylistPlayer',)

View File

@@ -0,0 +1,26 @@
# -*- 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.
"""
class AbstractPlaylistPlayer(object):
def __init__(self):
pass
def clear(self):
raise NotImplementedError()
def add(self, base_item):
raise NotImplementedError()
def shuffle(self):
raise NotImplementedError()
def unshuffle(self):
raise NotImplementedError()

View File

@@ -0,0 +1,308 @@
# -*- 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
import json
from ..abstract_playlist_player import AbstractPlaylistPlayer
from ... import logging
from ...compatibility import xbmc
from ...items import VideoItem, media_listitem
from ...utils.methods import jsonrpc, wait
from ...utils.system_version import current_system_version
class XbmcPlaylistPlayer(AbstractPlaylistPlayer):
log = logging.getLogger(__name__)
_CACHE = {
'player_id': None,
'playlist_id': None
}
# xbmc.PlayList only supports music and video playlist IDs
PLAYLIST_MAP = {
# -1: 'none',
0: 'music',
1: 'video',
# 2: 'picture',
# 'none': -1,
'audio': xbmc.PLAYLIST_MUSIC, # 0
'video': xbmc.PLAYLIST_VIDEO, # 1
# 'picture': 2,
}
def __init__(self, context, playlist_type=None, retry=None):
super(XbmcPlaylistPlayer, self).__init__()
self._context = context
player = xbmc.Player()
if retry is None:
retry = 3 if player.isPlaying() else 0
if playlist_type is None:
playlist_id = self.get_playlist_id(retry=retry)
else:
playlist_id = self.PLAYLIST_MAP.get(playlist_type)
if playlist_id is None:
playlist_id = self.PLAYLIST_MAP['video']
self.set_playlist_id(playlist_id)
self._playlist = xbmc.PlayList(playlist_id)
self._player = player
def clear(self):
self._playlist.clear()
def add(self, base_item):
uri, item, _ = media_listitem(self._context, base_item)
if item:
self._playlist.add(uri, listitem=item)
def shuffle(self):
self._playlist.shuffle()
def unshuffle(self):
self._playlist.unshuffle()
def size(self):
return self._playlist.size()
def stop(self):
return self._player.stop()
def pause(self):
return self._player.pause()
def play_item(self, *args, **kwargs):
return self._player.play(*args, **kwargs)
def is_playing(self):
return self._player.isPlaying()
@classmethod
def get_player_id(cls, retry=0):
"""Function to get active player player_id"""
# We don't need to get player_id every time, cache and reuse instead
player_id = cls._CACHE['player_id']
if player_id is not None:
return player_id
# Sometimes Kodi gets confused and uses a music playlist for video
# content, so get the first active player instead, default to video
# player. Wait 2s per retry in case of delay in getting response.
attempts_left = 1 + retry
while attempts_left > 0:
result = jsonrpc(method='Player.GetActivePlayers').get('result')
if result:
break
attempts_left -= 1
if attempts_left > 0:
wait(2)
else:
# No active player
cls.set_player_id(None)
return None
for player in result:
if player.get('type', 'video') in cls.PLAYLIST_MAP:
try:
player_id = int(player['playerid'])
except (KeyError, TypeError, ValueError):
continue
break
else:
# No active player
player_id = None
cls.set_player_id(player_id)
return player_id
@classmethod
def set_player_id(cls, player_id):
"""Function to set player_id for requested player type"""
cls._CACHE['player_id'] = player_id
@classmethod
def set_playlist_id(cls, playlist_id):
"""Function to set playlist_id for requested playlist type"""
cls._CACHE['playlist_id'] = playlist_id
@classmethod
def get_playlist_id(cls, retry=0):
"""Function to get playlist_id of active player"""
# We don't need to get playlist_id every time, cache and reuse instead
playlist_id = cls._CACHE['playlist_id']
if playlist_id is not None:
return playlist_id
result = jsonrpc(method='Player.GetProperties',
params={'playerid': cls.get_player_id(retry=retry),
'properties': ['playlistid']})
try:
playlist_id = int(result['result']['playlistid'])
except (KeyError, TypeError, ValueError):
playlist_id = cls.PLAYLIST_MAP['video']
cls.set_playlist_id(playlist_id)
return playlist_id
if current_system_version.compatible(19):
@staticmethod
def get_item_path(position, _label=xbmc.getInfoLabel):
return _label('Player.position(%d).FilenameAndPath' % position)
else:
def get_item_path(self, position):
item = self.get_items(start=position, end=position + 1)
return item[0]['file'] if item else ''
def get_items(self, properties=None, start=0, end=-1, dumps=False):
if properties is None:
properties = ('title', 'file')
limits = {'start': start}
if end != -1:
limits['end'] = end
response = jsonrpc(method='Playlist.GetItems',
params={
'properties': properties,
'playlistid': self._playlist.getPlayListId(),
'limits': limits,
})
try:
result = response['result']['items']
return json.dumps(result, ensure_ascii=False) if dumps else result
except (KeyError, TypeError, ValueError):
error = response.get('error', {})
self.log.exception(('Error',
'Code: {code}',
'Message: {message}'),
code=error.get('code', 'Unknown'),
message=error.get('message', 'Unknown'))
return '' if dumps else []
def add_items(self, items, loads=False):
if loads:
items = json.loads(items)
# Playlist.GetItems allows retrieving full playlist item details, but
# Playlist.Add only allows for file/path/id etc.
# Have to add items individually rather than using JSON-RPC
for item in items:
self.add(VideoItem(item.get('title', ''), item['file']))
# jsonrpc(method='Playlist.Add',
# params={
# 'playlistid': self._playlist.getPlaylistId(),
# 'item': items,
# },
# no_response=True)
return len(items)
def play_playlist_item(self, position, resume=False, defer=False):
"""
Function to play item in playlist from a specified position, where the
first item in the playlist is position 1
"""
playlist_id = self._playlist.getPlayListId()
if position == 'next':
position, _ = self.get_position(offset=1)
if not position:
self.log.warning('Unable to play from playlist position: %s',
position)
return None
self.log.debug('Playing from playlist: %d, position: %d',
playlist_id,
position)
if not resume:
command = 'Playlist.PlayOffset({type},{position})'.format(
type=self.PLAYLIST_MAP.get(playlist_id) or 'video',
position=position - 1,
)
if defer:
return ''.join(('command://', command))
return self._context.execute(command)
# JSON Player.Open can be too slow but is needed if resuming is enabled
return jsonrpc(
method='Player.Open',
params={'item': {'playlistid': playlist_id,
# Convert 1 indexed to 0 indexed position
'position': position - 1}},
options={'resume': True},
no_response=True,
)
def play(self, playlist_index=-1, defer=False):
"""
We call the player in this way, because 'Player.play(...)' will call the
addon again while the instance is running. This is somehow shitty,
because we couldn't release any resources and in our case we couldn't
release the cache. So this is the solution to prevent a locked database
(sqlite) and Kodi crashing.
"""
"""
playlist = None
if self._player_type == 'video':
playlist = xbmc.PlayList(xbmc.PLAYLIST_VIDEO)
elif self._player_type == 'music':
playlist = xbmc.PlayList(xbmc.PLAYLIST_MUSIC)
if playlist_index >= 0:
xbmc.Player().play(item=playlist, startpos=playlist_index)
else:
xbmc.Player().play(item=playlist)
"""
playlist_type = self.PLAYLIST_MAP.get(self._playlist.getPlayListId())
command = 'Playlist.PlayOffset({type},{position})'.format(
type=playlist_type or 'video',
position=playlist_index,
)
if defer:
return ''.join(('command://', command))
return self._context.execute(command)
def get_position(self, offset=0):
"""
Function to get current playlist position and number of remaining
playlist items, where the first item in the playlist is position 1
"""
position = self._playlist.getposition()
# PlayList().getposition() starts from zero unless playlist not active
if position < 0:
return None, None
playlist_size = self._playlist.size()
# Use 1 based index value for playlist position
position += (offset + 1)
# A playlist with only one element has no next item
if playlist_size >= 1 and position <= playlist_size:
self.log.debug('playlist_id: %d, position - %d/%d',
self.get_playlist_id(),
position,
playlist_size)
return position, (playlist_size - position)
return None, None

View File

@@ -0,0 +1,15 @@
# -*- coding: utf-8 -*-
"""
Copyright (C) 2023-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 .xbmc.xbmc_plugin import XbmcPlugin
__all__ = ('XbmcPlugin',)

View File

@@ -0,0 +1,19 @@
# -*- 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
class AbstractPlugin(object):
def __init__(self):
pass
def run(self, provider, context):
raise NotImplementedError()

Some files were not shown because too many files have changed in this diff Show More