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,14 @@
# -*- 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
__all__ = ('youtube_plugin',)

View File

@@ -0,0 +1,16 @@
# -*- 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 youtube_plugin.kodion import plugin_runner
plugin_runner.run()

View File

@@ -0,0 +1,17 @@
# -*- coding: utf-8 -*-
"""
Copyright (C) 2024-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 youtube_plugin.kodion import script_actions
script_actions.run(sys.argv)

View File

@@ -0,0 +1,16 @@
# -*- 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 youtube_plugin.kodion import service_runner
service_runner.run()

View File

@@ -0,0 +1,149 @@
# -*- 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 youtube_plugin.kodion import logging
from youtube_plugin.kodion.constants import ADDON_ID
from youtube_plugin.kodion.context import XbmcContext
from youtube_plugin.youtube.helper import yt_login
from youtube_plugin.youtube.provider import Provider
from youtube_plugin.youtube.youtube_exceptions import LoginException
__all__ = (
'LoginException',
'reset_access_tokens',
'sign_in',
'sign_out',
)
def _auth(addon_id, mode=yt_login.SIGN_IN):
"""
:param addon_id: id of the add-on being signed in
:param mode: SIGN_IN or SIGN_OUT
:return: addon provider, context and client
"""
if not addon_id or addon_id == ADDON_ID:
logging.error_trace('Invalid addon_id: %r', addon_id)
return False
provider = Provider()
context = XbmcContext(params={'addon_id': addon_id})
access_manager = context.get_access_manager()
if access_manager.add_new_developer(addon_id):
logging.debug('Creating developer user: %r', addon_id)
client = provider.get_client(context=context)
if mode == yt_login.SIGN_IN:
if client.logged_in:
yt_login.process(yt_login.SIGN_OUT,
provider,
context,
client=client,
refresh=False)
client = None
elif mode != yt_login.SIGN_OUT:
raise Exception('Unknown mode: %r' % mode)
yt_login.process(mode, provider, context, client=client, refresh=False)
logged_in = provider.get_client(context=context).logged_in
if mode == yt_login.SIGN_IN:
return logged_in
return not logged_in
def sign_in(addon_id):
"""
To use the signed in context, see youtube_registration.py and youtube_requests.py
Usage:
addon.xml
---
<import addon="plugin.video.youtube" version="6.1.0"/>
---
.py
---
import youtube_registration
import youtube_authentication
youtube_registration.register_api_keys(addon_id='plugin.video.example',
api_key='A1zaSyA0b5sTjgxzTzYLmVtradlFVBfSHNOJKS0',
client_id='825419953561-ert5tccq1r0upsuqdf5nm3le39czk23a.apps.googleusercontent.com',
client_secret='Y5cE1IKzJQe1NZ0OsOoEqpu3')
try:
signed_in = youtube_authentication.sign_in(addon_id='plugin.video.example') # refreshes access tokens if already signed in
except youtube_authentication.LoginException as exc:
error_message = exc.get_message()
# handle error
signed_in = False
if signed_in:
pass # see youtube_registration.py and youtube_requests.py to use the signed in context
---
:param addon_id: id of the add-on being signed in
:return: boolean, True when signed in
"""
return _auth(addon_id, mode=yt_login.SIGN_IN)
def sign_out(addon_id):
"""
Usage:
addon.xml
---
<import addon="plugin.video.youtube" version="6.1.0"/>
---
.py
---
import youtube_registration
import youtube_authentication
youtube_registration.register_api_keys(addon_id='plugin.video.example',
api_key='A1zaSyA0b5sTjgxzTzYLmVtradlFVBfSHNOJKS0',
client_id='825419953561-ert5tccq1r0upsuqdf5nm3le39czk23a.apps.googleusercontent.com',
client_secret='Y5cE1IKzJQe1NZ0OsOoEqpu3')
signed_out = youtube_authentication.sign_out(addon_id='plugin.video.example')
if signed_out:
pass
---
:param addon_id: id of the add-on being signed out
:return: boolean, True when signed out
"""
return _auth(addon_id, mode=yt_login.SIGN_OUT)
def reset_access_tokens(addon_id):
"""
:param addon_id: id of the add-on having it's access tokens reset
:return:
"""
if not addon_id or addon_id == ADDON_ID:
logging.error_trace('Invalid addon_id: %r', addon_id)
return
context = XbmcContext(params={'addon_id': addon_id})
context.get_access_manager().update_access_token(
addon_id, access_token='', expiry=-1, refresh_token=''
)

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()

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