Merge pull request #1316 from MoojMidge/master

v7.3.0+beta.7
This commit is contained in:
MoojMidge 2025-10-19 15:05:05 +11:00 committed by GitHub
commit e86297d3b2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 415 additions and 263 deletions

View file

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<addon id="plugin.video.youtube" name="YouTube" version="7.3.0+beta.6" provider-name="anxdpanic, bromix, MoojMidge">
<addon id="plugin.video.youtube" name="YouTube" version="7.3.0+beta.7" provider-name="anxdpanic, bromix, MoojMidge">
<requires>
<import addon="xbmc.python" version="3.0.0"/>
<import addon="script.module.requests" version="2.27.1"/>

View file

@ -1,6 +1,27 @@
## v7.3.0+beta.7
### Fixed
- Only add playable items to playlist when adding related items
- Fix using invalid default end limit with Playlist.GetItems JSONRPC method
- Fix conversion of SRT subtitles to WebVTT #1256
- Workaround playback failure of progressive streams
- Fix re-sorting live search lists
- Disable use of custom thumbnail urls #1245
- Workaround addon service not starting prior to plugin invocation #1298
- Fix unofficial version using localised sort order #1309
- Fix parsing of logged_in query parameter
### Changed
- Ignore player request failures that may incorrectly indicate a need to sign-in #1312
- Include playlist_id listitem property for items from virtual playlists
### New
- Add refresh to context menu of playlists
- Allow watch urls from music.youtube.com to be directly handled by the addon
- Allow urls from www.youtubekids.com to be directly handled by the addon
## v7.3.0+beta.6
### Fixed
Fix typo in YouTubePlayerClient error hook
- Fix typo in YouTubePlayerClient error hook
## v7.3.0+beta.5
### Fixed

View file

@ -53,9 +53,18 @@ VALUE_TO_STR = {
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'
@ -216,10 +225,12 @@ __all__ = (
# Const values
'BOOL_FROM_STR',
'VALUE_TO_STR',
'YOUTUBE_HOSTNAMES',
# Flags
'ABORT_FLAG',
'BUSY_FLAG',
'SERVICE_RUNNING_FLAG',
'TRAKT_PAUSE_FLAG',
'WAIT_END_FLAG',

View file

@ -177,6 +177,7 @@ class AbstractContext(object):
'visitor',
))
_STRING_BOOL_PARAMS = frozenset((
'logged_in',
'reload_path',
))
_STRING_INT_PARAMS = frozenset((
@ -209,7 +210,7 @@ class AbstractContext(object):
self.parse_params(params)
self._uri = None
self._path = path
self._path = None
self._path_parts = []
self.set_path(path, force=True)
@ -536,7 +537,12 @@ class AbstractContext(object):
value = unquote(value)
try:
if param in self._BOOL_PARAMS:
parsed_value = BOOL_FROM_STR.get(str(value), False)
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)
@ -678,7 +684,7 @@ class AbstractContext(object):
def tear_down(self):
pass
def ipc_exec(self, target, timeout=None, payload=None):
def ipc_exec(self, target, timeout=None, payload=None, raise_exc=False):
raise NotImplementedError()
@staticmethod

View file

@ -28,12 +28,14 @@ from ...compatibility import (
from ...constants import (
ABORT_FLAG,
ADDON_ID,
BUSY_FLAG,
CHANNEL_ID,
CONTENT,
FOLDER_NAME,
PLAYLIST_ID,
PLAY_FORCE_AUDIO,
SERVICE_IPC,
SERVICE_RUNNING_FLAG,
SORT,
URI,
VIDEO_ID,
@ -962,6 +964,8 @@ class XbmcContext(AbstractContext):
attrs = (
'_ui',
'_playlist',
'_api_store',
'_access_manager',
)
for attr in attrs:
try:
@ -970,7 +974,15 @@ class XbmcContext(AbstractContext):
except AttributeError:
pass
def ipc_exec(self, target, timeout=None, payload=None):
def ipc_exec(self, target, timeout=None, payload=None, raise_exc=False):
if not XbmcContextUI.get_property(SERVICE_RUNNING_FLAG, as_bool=True):
msg = 'Service IPC - Monitor has not started'
XbmcContextUI.set_property(SERVICE_RUNNING_FLAG, BUSY_FLAG)
if raise_exc:
raise RuntimeError(msg)
self.log.warning_trace(msg)
return None
data = {'target': target, 'response_required': bool(timeout)}
if payload:
data.update(payload)

View file

@ -139,14 +139,26 @@ def media_play_using(context, video_id=VIDEO_ID_INFOLABEL):
)
def refresh_listing(context):
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,
(PATHS.ROUTE, context.get_path(),),
dict(context.get_params(),
refresh=context.refresh_requested(force=True, on=True)),
path,
dict(params,
refresh=context.refresh_requested(
force=True,
on=True,
params=params,
)),
),
)
@ -829,7 +841,7 @@ def search_sort_by(context, params, order):
),
context_menu_uri(
context,
(PATHS.ROUTE, PATHS.SEARCH, 'query',),
(PATHS.ROUTE, context.get_path(),),
params=dict(params,
order=order,
page=1,

View file

@ -36,7 +36,12 @@ class AccessManager(JSONStore):
}
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)
@ -204,7 +209,8 @@ class AccessManager(JSONStore):
Returns users
:return: users
"""
return self._data['access_manager'].get('users', {})
data = self._data if self._loaded else self.get_data()
return data['access_manager'].get('users', {})
def add_user(self, username='', user=None):
"""
@ -546,7 +552,8 @@ class AccessManager(JSONStore):
Returns developers
:return: dict, developers
"""
return self._data['access_manager'].get('developers', {})
data = self._data if self._loaded else self.get_data()
return data['access_manager'].get('developers', {})
def add_new_developer(self, addon_id):
"""

View file

@ -38,9 +38,17 @@ class JSONStore(object):
self.filepath = None
self._context = context
self._loaded = False
self._data = {}
self.load(stacklevel=3)
self.set_defaults()
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
@ -81,6 +89,7 @@ class JSONStore(object):
FILE_WRITE,
timeout=5,
payload={'filepath': filepath},
raise_exc=True,
)
if response is False:
raise IOError
@ -92,7 +101,7 @@ class JSONStore(object):
else:
with open(filepath, mode='w', encoding='utf-8') as file:
file.write(to_unicode(_data))
except (IOError, OSError):
except (RuntimeError, IOError, OSError):
self.log.exception(('Access error', 'File: %s'),
filepath,
stacklevel=stacklevel)
@ -119,6 +128,7 @@ class JSONStore(object):
FILE_READ,
timeout=5,
payload={'filepath': filepath},
raise_exc=True,
) is not False:
data = self._context.get_ui().get_property(
'-'.join((FILE_READ, filepath)),
@ -135,17 +145,23 @@ class JSONStore(object):
data,
object_pairs_hook=(self._process_data if process else None),
)
except (IOError, OSError):
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
@ -160,7 +176,9 @@ class JSONStore(object):
if fallback:
self.set_defaults(reset=True)
return self.get_data(process=process, fallback=False)
raise exc
if self._loaded:
raise exc
return data
def load_data(self, data, process=True, stacklevel=2):
try:

View file

@ -433,7 +433,7 @@ class KodiLogger(logging.Logger):
**kwargs
)
def waring_trace(self, msg, *args, **kwargs):
def warning_trace(self, msg, *args, **kwargs):
if self.isEnabledFor(WARNING):
self._log(
WARNING,

View file

@ -18,8 +18,6 @@ from ..compatibility import urlsplit, xbmc, xbmcgui
from ..constants import (
ACTION,
ADDON_ID,
BOOL_FROM_STR,
BUSY_FLAG,
CHECK_SETTINGS,
CONTAINER_FOCUS,
CONTAINER_ID,
@ -112,81 +110,11 @@ class ServiceMonitor(xbmc.Monitor):
xbmcgui.Window(10000).setProperty(_property_id, value)
return value
def get_property(self,
property_id,
stacklevel=2,
process=None,
log_value=None,
log_process=None,
raw=False,
as_bool=False,
default=False):
_property_id = property_id if raw else '-'.join((ADDON_ID, property_id))
value = xbmcgui.Window(10000).getProperty(_property_id)
if log_value is None:
log_value = value
if log_process:
log_value = log_process(log_value)
self.log.debug_trace('Get property {property_id!r}: {value!r}',
property_id=property_id,
value=log_value,
stacklevel=stacklevel)
if process:
value = process(value)
return BOOL_FROM_STR.get(value, default) if as_bool else value
def pop_property(self,
property_id,
stacklevel=2,
process=None,
log_value=None,
log_process=None,
raw=False,
as_bool=False,
default=False):
_property_id = property_id if raw else '-'.join((ADDON_ID, property_id))
window = xbmcgui.Window(10000)
value = window.getProperty(_property_id)
if value:
window.clearProperty(_property_id)
if process:
value = process(value)
if log_value is None:
log_value = value
if log_value and log_process:
log_value = log_process(log_value)
self.log.debug_trace('Pop property {property_id!r}: {value!r}',
property_id=property_id,
value=log_value,
stacklevel=stacklevel)
return BOOL_FROM_STR.get(value, default) if as_bool else value
def clear_property(self, property_id, stacklevel=2, raw=False):
self.log.debug_trace('Clear property {property_id!r}',
property_id=property_id,
stacklevel=stacklevel)
_property_id = property_id if raw else '-'.join((ADDON_ID, property_id))
xbmcgui.Window(10000).clearProperty(_property_id)
return None
def refresh_container(self, deferred=False):
if deferred:
def refresh_container(self, force=False):
if force:
self.refresh = False
if self.get_property(REFRESH_CONTAINER) == BUSY_FLAG:
self.set_property(REFRESH_CONTAINER)
xbmc.executebuiltin('Container.Refresh')
return
container = self._context.get_ui().get_container()
if not container['is_plugin'] or not container['is_loaded']:
self.log.debug('No plugin container loaded - cancelling refresh')
return
if container['is_active']:
self.set_property(REFRESH_CONTAINER)
xbmc.executebuiltin('Container.Refresh')
else:
self.set_property(REFRESH_CONTAINER, BUSY_FLAG)
self.log.debug('Plugin container not active - deferring refresh')
refreshed = self._context.get_ui().refresh_container(force=force)
if refreshed is None:
self.refresh = True
def onNotification(self, sender, method, data):
@ -317,7 +245,7 @@ class ServiceMonitor(xbmc.Monitor):
response = False
else:
with write_access:
content = self.pop_property(
content = self._context.get_ui().pop_property(
'-'.join((FILE_WRITE, filepath)),
log_value='<redacted>',
)
@ -347,52 +275,11 @@ class ServiceMonitor(xbmc.Monitor):
elif event == CONTAINER_FOCUS:
if data:
data = json.loads(data)
if not data:
return
context = self._context
ui = context.get_ui()
container = ui.get_container()
if not all(container.values()):
return
container_id = data.get(CONTAINER_ID)
if container_id is None:
container_id = container['id']
elif not container_id:
return
if not isinstance(container_id, int):
try:
container_id = int(container_id)
except (TypeError, ValueError):
return
position = data.get(CONTAINER_POSITION)
if position is None:
return
if ui.get_container_bool(HAS_PARENT, container_id):
offset = 0
else:
offset = -1
if not isinstance(position, int):
if position == 'next':
position = ui.get_container_info(CURRENT_ITEM, container_id)
offset += 1
elif position == 'previous':
position = ui.get_container_info(CURRENT_ITEM, container_id)
offset -= 1
try:
position = int(position)
except (TypeError, ValueError):
return
context.execute('SetFocus({0},{1},absolute)'.format(
container_id,
position + offset,
))
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()

View file

@ -174,14 +174,14 @@ class XbmcPlaylistPlayer(AbstractPlaylistPlayer):
def get_items(self, properties=None, start=0, end=-1, dumps=False):
if properties is None:
properties = ('title', 'file')
limits = {'start': start}
if end != -1:
limits['end'] = end
response = jsonrpc(method='Playlist.GetItems',
params={
'properties': properties,
'playlistid': self._playlist.getPlayListId(),
'limits': {
'start': start,
'end': end,
},
'limits': limits,
})
try:

View file

@ -372,7 +372,12 @@ class XbmcPlugin(AbstractPlugin):
listitem=item,
)
elif options.get(provider.FORCE_REFRESH):
_post_run_action = ui.refresh_container
_post_run_action = (
context.send_notification,
{
'method': REFRESH_CONTAINER,
},
)
else:
if context.is_plugin_path(
ui.get_container_info(FOLDER_URI, container_id=None)
@ -409,10 +414,13 @@ class XbmcPlugin(AbstractPlugin):
if any(sync_items):
context.send_notification(SYNC_LISTITEM, sync_items)
if forced and is_same_path and (not played_video_id or route):
container = ui.get_property(CONTAINER_ID)
position = ui.get_property(CONTAINER_POSITION)
if container and position:
container = ui.get_property(CONTAINER_ID)
position = ui.get_property(CONTAINER_POSITION)
if is_same_path:
if (container and position
and (forced or position == 'current')
and (not played_video_id or route)):
post_run_actions.append((
context.send_notification,
{

View file

@ -67,11 +67,8 @@ def run(context=_context,
log.verbose_logging = False
profiler.disable()
old_path, old_params = context.parse_uri(
ui.get_container_info(FOLDER_URI, container_id=None),
parse_params=False,
)
old_path = old_path.rstrip('/')
old_path = context.get_path().rstrip('/')
old_uri = ui.get_container_info(FOLDER_URI, container_id=None)
context.init()
current_path = context.get_path().rstrip('/')
current_params = context.get_original_params()
@ -79,13 +76,28 @@ def run(context=_context,
new_params = {}
new_kwargs = {}
params = context.get_params()
params = context.get_params()
refresh = context.refresh_requested(params=params)
is_same_path = refresh != 0 and current_path == old_path
forced = (current_handle != -1
and (old_path == PATHS.PLAY
or (is_same_path and current_params == old_params)))
was_playing = old_path == PATHS.PLAY
is_same_path = current_path == old_path
if was_playing or is_same_path or refresh:
old_path, old_params = context.parse_uri(
old_uri,
parse_params=False,
)
old_path = old_path.rstrip('/')
is_same_path = current_path == old_path
if was_playing and current_handle != -1:
forced = True
elif is_same_path and current_params == old_params:
forced = True
else:
forced = False
else:
forced = False
if forced:
refresh = context.refresh_requested(force=True, off=True, params=params)
new_params['refresh'] = refresh if refresh else 0

View file

@ -145,7 +145,10 @@ def _config_actions(context, action, *_args):
base_kodi_language = kodi_language.partition('-')[0]
json_data = client.get_supported_languages(kodi_language)
items = json_data.get('items') or DEFAULT_LANGUAGES['items']
if json_data:
items = json_data.get('items') or DEFAULT_LANGUAGES['items']
else:
items = DEFAULT_LANGUAGES['items']
selected_language = [None]
@ -184,7 +187,10 @@ def _config_actions(context, action, *_args):
return
json_data = client.get_supported_regions(language=language_id)
items = json_data.get('items') or DEFAULT_REGIONS['items']
if json_data:
items = json_data.get('items') or DEFAULT_REGIONS['items']
else:
items = DEFAULT_REGIONS['items']
selected_region = [None]

View file

@ -15,6 +15,7 @@ from .constants import (
ABORT_FLAG,
ARTIST,
BOOKMARK_ID,
BUSY_FLAG,
CHANNEL_ID,
CONTAINER_ID,
CONTAINER_POSITION,
@ -24,6 +25,7 @@ from .constants import (
PLAYLIST_ITEM_ID,
PLAY_COUNT,
PLUGIN_SLEEPING,
SERVICE_RUNNING_FLAG,
SUBSCRIPTION_ID,
TEMP_PATH,
TITLE,
@ -67,6 +69,9 @@ def run():
localize = context.localize
clear_property(ABORT_FLAG)
if ui.get_property(SERVICE_RUNNING_FLAG) == BUSY_FLAG:
monitor.refresh_container()
set_property(SERVICE_RUNNING_FLAG)
# wipe add-on temp folder on updates/restarts (subtitles, and mpd files)
rm_dir(TEMP_PATH)
@ -173,7 +178,7 @@ def run():
monitor.interrupt = True
if monitor.refresh and all(container.values()):
monitor.refresh_container(deferred=True)
monitor.refresh_container(force=True)
break
if (monitor.interrupt
@ -240,6 +245,7 @@ def run():
break
set_property(ABORT_FLAG)
clear_property(SERVICE_RUNNING_FLAG)
# clean up any/all playback monitoring threads
player.cleanup_threads(only_ended=False)

View file

@ -59,8 +59,7 @@ class AbstractContextUI(object):
def on_busy():
raise NotImplementedError()
@staticmethod
def refresh_container():
def refresh_container(self, force=False, stacklevel=None):
"""
Needs to be implemented by a mock for testing or the real deal.
This will refresh the current container or list.
@ -68,6 +67,9 @@ class AbstractContextUI(object):
"""
raise NotImplementedError()
def focus_container(self, container_id=None, position=None):
raise NotImplementedError()
@staticmethod
def get_infobool(name):
raise NotImplementedError()

View file

@ -18,12 +18,14 @@ from ...compatibility import string_type, xbmc, xbmcgui
from ...constants import (
ADDON_ID,
BOOL_FROM_STR,
BUSY_FLAG,
CONTAINER_FOCUS,
CONTAINER_ID,
CONTAINER_LISTITEM_INFO,
CONTAINER_LISTITEM_PROP,
CONTAINER_POSITION,
CURRENT_CONTAINER_INFO,
CURRENT_ITEM,
HAS_FILES,
HAS_FOLDERS,
HAS_PARENT,
@ -192,8 +194,76 @@ class XbmcContextUI(AbstractContextUI):
def on_busy():
return XbmcBusyDialog()
def refresh_container(self):
self._context.send_notification(REFRESH_CONTAINER)
def refresh_container(self, force=False, stacklevel=None):
if force:
if self.get_property(REFRESH_CONTAINER) == BUSY_FLAG:
self.set_property(REFRESH_CONTAINER)
xbmc.executebuiltin('Container.Refresh')
return True
stacklevel = 2 if stacklevel is None else stacklevel + 1
container = self.get_container()
if not container['is_plugin'] or not container['is_loaded']:
self.log.debug('No plugin container loaded - cancelling refresh',
stacklevel=stacklevel)
return False
if container['is_active']:
self.set_property(REFRESH_CONTAINER)
xbmc.executebuiltin('Container.Refresh')
return True
self.set_property(REFRESH_CONTAINER, BUSY_FLAG)
self.log.debug('Plugin container not active - deferring refresh',
stacklevel=stacklevel)
return None
def focus_container(self, container_id=None, position=None):
if position is None:
return
container = self.get_container()
if not all(container.values()):
return
if container_id is None:
container_id = container['id']
elif not container_id:
return
if not isinstance(container_id, int):
try:
container_id = int(container_id)
except (TypeError, ValueError):
return
if self.get_container_bool(HAS_PARENT, container_id):
offset = 0
else:
offset = -1
if not isinstance(position, int):
if position == 'next':
position = self.get_container_info(CURRENT_ITEM, container_id)
offset += 1
elif position == 'previous':
position = self.get_container_info(CURRENT_ITEM, container_id)
offset -= 1
elif position == 'current':
position = (
self.get_property(CONTAINER_POSITION)
or self.get_container_info(CURRENT_ITEM, container_id)
)
try:
position = int(position)
except (TypeError, ValueError):
return
xbmc.executebuiltin('SetFocus({0},{1},absolute)'.format(
container_id,
position + offset,
))
@staticmethod
def get_infobool(name, _bool=xbmc.getCondVisibility):

View file

@ -12,7 +12,7 @@ from __future__ import absolute_import, division, unicode_literals
from datetime import timedelta
from math import floor, log
from re import MULTILINE, compile as re_compile
from re import DOTALL, compile as re_compile
from ..compatibility import byte_string_type
@ -103,10 +103,10 @@ def timedelta_to_timestamp(delta, offset=None, multiplier=1.0):
def _srt_to_vtt(content,
srt_re=re_compile(
br'\d+[\r\n]'
br'(?P<start>\d+:\d+:\d+,\d+) --> '
br'(?P<end>\d+:\d+:\d+,\d+)[\r\n]'
br'(?P<text>.+)(?=[\r\n]{2,})',
flags=MULTILINE,
br'(?P<start>[\d:,]+) --> '
br'(?P<end>[\d:,]+)[\r\n]'
br'(?P<text>.+?)[\r\n][\r\n]',
flags=DOTALL,
)):
subtitle_iter = srt_re.finditer(content)
try:
@ -136,6 +136,7 @@ def _srt_to_vtt(content,
except StopIteration:
if subtitle == next_subtitle:
break
subtitle = None
next_subtitle = None
if next_subtitle and end > next_start:

View file

@ -156,6 +156,12 @@ class YouTubeDataClient(YouTubeLoginClient):
'browseEndpoint',
'browseId',
),
'playlist_id': (
'tileRenderer',
'onSelectCommand',
'watchEndpoint',
'playlistId',
),
'continuation': (
'contents',
'tvBrowseRenderer',
@ -1072,12 +1078,16 @@ class YouTubeDataClient(YouTubeLoginClient):
if playlist_id_upper == 'HL':
browse_id = 'FEhistory'
json_path = self.JSON_PATHS['tv_grid']
response_type = 'videos'
else:
browse_id = 'VL' + playlist_id_upper
json_path = self.JSON_PATHS['tv_playlist']
response_type = 'playlistItems'
return self.get_browse_items(
browse_id=browse_id,
playlist_id=playlist_id,
response_type=response_type,
client='tv',
do_auth=True,
page_token=page_token,
@ -1315,6 +1325,7 @@ class YouTubeDataClient(YouTubeLoginClient):
def get_browse_items(self,
browse_id=None,
channel_id=None,
playlist_id=None,
skip_ids=None,
params=None,
route=None,
@ -1340,7 +1351,12 @@ class YouTubeDataClient(YouTubeLoginClient):
'youtube#playlistListResponse',
'youtube#playlist',
'contentId',
)
),
'playlistItems': (
'youtube#playlistItemListResponse',
'youtube#playlistItem',
'contentId',
),
},
data=None,
client=None,
@ -1448,6 +1464,13 @@ class YouTubeDataClient(YouTubeLoginClient):
)
if skip_ids and _channel_id in skip_ids:
continue
if playlist_id:
_playlist_id = playlist_id
else:
_playlist_id = self.json_traverse(
item,
json_path.get('playlist_id'),
)
items.append({
'kind': item_kind,
'id': item_id,
@ -1470,6 +1493,7 @@ class YouTubeDataClient(YouTubeLoginClient):
),
),
'channelId': _channel_id,
'playlistId': _playlist_id,
}
})
if not items:

View file

@ -18,20 +18,9 @@ from ...kodion import logging
class YouTubeLoginClient(YouTubeRequestClient):
log = logging.getLogger(__name__)
ANDROID_CLIENT_AUTH_URL = 'https://android.clients.google.com/auth'
DOMAIN_SUFFIX = '.apps.googleusercontent.com'
DEVICE_CODE_URL = 'https://accounts.google.com/o/oauth2/device/code'
REVOKE_URL = 'https://accounts.google.com/o/oauth2/revoke'
SERVICE_URLS = 'oauth2:' + 'https://www.googleapis.com/auth/'.join((
'youtube '
'youtube.force-ssl '
'plus.me '
'emeraldsea.mobileapps.doritos.cookie '
'plus.stream.read '
'plus.stream.write '
'plus.pages.manage '
'identity.plus.page.impersonation',
))
TOKEN_URL = 'https://www.googleapis.com/oauth2/v4/token'
TOKEN_TYPES = {
0: 'tv',

View file

@ -790,6 +790,31 @@ class YouTubePlayerClient(YouTubeDataClient):
'-1': ('original', 'main', -6),
}
FAILURE_REASONS = {
'abort': frozenset((
'country',
'not available',
)),
'auth': frozenset((
'not a bot',
'please sign in',
)),
'reauth': frozenset((
'confirm your age',
'inappropriate',
'member',
)),
'retry': frozenset((
'try again later',
'unavailable',
'unknown',
)),
'skip': frozenset((
'error code: 6',
'latest version',
)),
}
def __init__(self,
context,
clients=None,
@ -826,18 +851,18 @@ class YouTubePlayerClient(YouTubeDataClient):
self._auth_client = {}
self._client_groups = (
('custom', clients if clients else ()),
('auth_enabled_initial_request', (
('auth_enabled|initial_request|no_playable_streams', (
'tv_embed',
'tv_unplugged',
'tv',
)),
('auth_disabled_kids_vp9_avc1', (
('auth_disabled|kids|av1|vp9|vp9.2|avc1|stereo_sound|multi_audio', (
'ios_testsuite_params',
)),
('auth_disabled_kids_av1_avc1', (
('auth_disabled|kids|av1|vp9.2|avc1|surround_sound|multi_audio', (
'android_testsuite_params',
)),
('auth_enabled_no_kids', (
('auth_enabled|no_kids|av1|vp9.2|avc1|surround_sound', (
'android_vr',
)),
('mpd', (
@ -1285,7 +1310,11 @@ class YouTubePlayerClient(YouTubeDataClient):
else:
new_url = url
new_url = self._process_url_params(new_url, mpd=False)
new_url = self._process_url_params(new_url,
mpd=False,
headers=headers,
referrer=None,
visitor_data=None)
if not new_url:
continue
@ -1622,26 +1651,7 @@ class YouTubePlayerClient(YouTubeDataClient):
responses = {}
stream_list = {}
abort_reasons = {
'country',
'not available',
}
reauth_reasons = {
'confirm your age',
'inappropriate',
'please sign in',
'not a bot',
'member',
}
skip_reasons = {
'latest version',
'error code: 6',
}
retry_reasons = {
'try again later',
'unavailable',
'unknown',
}
fail = self.FAILURE_REASONS
abort = False
logged_in = self.logged_in
@ -1670,17 +1680,17 @@ class YouTubePlayerClient(YouTubeDataClient):
for name, clients in self._client_groups:
if not clients:
continue
if name == 'auth_enabled_initial_request':
if name == 'mpd' and not use_mpd:
continue
if name == 'ask' and use_mpd and not ask_for_quality:
continue
if name.startswith('auth_enabled|initial_request'):
if visitor_data and not logged_in:
continue
allow_skip = False
client_data['_auth_requested'] = True
else:
allow_skip = True
if name == 'mpd' and not use_mpd:
continue
if name == 'ask' and use_mpd and not ask_for_quality:
continue
exclude_retry = set()
restart = None
@ -1792,8 +1802,17 @@ class YouTubePlayerClient(YouTubeDataClient):
video_id=video_id,
client=_client_name,
has_auth=_has_auth)
compare_reason = _reason.lower()
if any(why in compare_reason for why in reauth_reasons):
fail_reason = _reason.lower()
if any(why in fail_reason for why in fail['auth']):
if _has_auth:
restart = False
elif restart is None and logged_in:
client_data['_auth_requested'] = True
restart = True
else:
continue
break
elif any(why in fail_reason for why in fail['reauth']):
if _client.get('_auth_required') == 'ignore_fail':
continue
elif client_data.get('_auth_required'):
@ -1803,13 +1822,13 @@ class YouTubePlayerClient(YouTubeDataClient):
client_data['_auth_required'] = True
restart = True
break
if any(why in compare_reason for why in abort_reasons):
elif any(why in fail_reason for why in fail['abort']):
abort = True
break
if any(why in compare_reason for why in skip_reasons):
elif any(why in fail_reason for why in fail['skip']):
if allow_skip:
break
if any(why in compare_reason for why in retry_reasons):
elif any(why in fail_reason for why in fail['retry']):
continue
else:
self.log.warning('Unknown playabilityStatus: {status!r}',

View file

@ -14,6 +14,7 @@ from re import compile as re_compile
from ...kodion import logging
from ...kodion.compatibility import parse_qsl, unescape, urlencode, urlsplit
from ...kodion.constants import YOUTUBE_HOSTNAMES
from ...kodion.network import BaseRequestsClass
@ -63,16 +64,16 @@ class YouTubeResolver(AbstractResolver):
r'|(?P<is_clip>"clipConfig":\{)'
r'|("startTimeMs":"(?P<start_time>\d+)")'
r'|("endTimeMs":"(?P<end_time>\d+)")')
_RE_MUSIC_VIDEO_ID = re_compile(r'"INITIAL_ENDPOINT":.+?videoId\\":\\"'
r'(?P<video_id>[^\\"]+)'
r'\\"')
def __init__(self, *args, **kwargs):
super(YouTubeResolver, self).__init__(*args, **kwargs)
def supports_url(self, url, url_components):
if url_components.hostname not in {
'www.youtube.com',
'youtube.com',
'm.youtube.com',
}:
hostname = url_components.hostname
if hostname not in YOUTUBE_HOSTNAMES:
return False
path = url_components.path.lower()
@ -91,10 +92,14 @@ class YouTubeResolver(AbstractResolver):
'/redirect',
'/shorts',
'/supported_browsers',
'/watch',
)):
return 'HEAD'
if path.startswith('/watch'):
if hostname.startswith('music.'):
return 'GET'
return 'HEAD'
# user channel in the form of youtube.com/username
path = path.strip('/').split('/', 1)
return 'GET' if len(path) == 1 and path[0] else False
@ -192,7 +197,17 @@ class YouTubeResolver(AbstractResolver):
query=urlencode(new_params)
).geturl()
# we try to extract the channel id from the html content
# try to extract the real videoId from the html content
elif method == 'GET' and url_components.hostname.startswith('music.'):
match = self._RE_MUSIC_VIDEO_ID.search(response_text)
if match:
params = dict(parse_qsl(url_components.query))
params['v'] = match.group('video_id')
return url_components._replace(
query=urlencode(params)
).geturl()
# try to extract the channel id from the html content
# With the channel id we can construct a URL we already work with
# https://www.youtube.com/channel/<CHANNEL_ID>
elif method == 'GET':
@ -217,11 +232,7 @@ class CommonResolver(AbstractResolver):
super(CommonResolver, self).__init__(*args, **kwargs)
def supports_url(self, url, url_components):
if url_components.hostname in {
'www.youtube.com',
'youtube.com',
'm.youtube.com',
}:
if url_components.hostname in YOUTUBE_HOSTNAMES:
return False
return 'HEAD'

View file

@ -32,6 +32,7 @@ from ...kodion.constants import (
START,
VIDEO_ID,
VIDEO_IDS,
YOUTUBE_HOSTNAMES,
)
from ...kodion.items import DirectoryItem, UriItem, VideoItem
from ...kodion.utils.convert_format import duration_to_seconds
@ -41,11 +42,6 @@ class UrlToItemConverter(object):
log = logging.getLogger(__name__)
RE_PATH_ID = re_compile(r'/[^/]*?[/@](?P<id>[^/?#]+)', IGNORECASE)
VALID_HOSTNAMES = {
'youtube.com',
'www.youtube.com',
'm.youtube.com',
}
def __init__(self, flatten=True):
self._flatten = flatten
@ -67,7 +63,7 @@ class UrlToItemConverter(object):
def add_url(self, url):
parsed_url = urlsplit(url)
if (not parsed_url.hostname
or parsed_url.hostname.lower() not in self.VALID_HOSTNAMES):
or parsed_url.hostname.lower() not in YOUTUBE_HOSTNAMES):
self.log.debug('Unknown hostname "{hostname}" in url "{url}"',
hostname=parsed_url.hostname,
url=url)

View file

@ -36,7 +36,13 @@ from ...kodion.constants import (
PATHS,
PLAYLIST_ID,
)
from ...kodion.items import AudioItem, CommandItem, DirectoryItem, menu_items
from ...kodion.items import (
AudioItem,
CommandItem,
DirectoryItem,
MediaItem,
menu_items,
)
from ...kodion.utils.convert_format import friendly_number, strip_html_from_text
from ...kodion.utils.datetime import (
get_scheduled_start,
@ -401,7 +407,8 @@ def update_playlist_items(provider, context, playlist_id_dict,
show_details = settings.show_detailed_description()
item_count_color = settings.get_label_color('itemCount')
fanart_type = context.get_param(FANART_TYPE)
params = context.get_params()
fanart_type = params.get(FANART_TYPE)
if fanart_type is None:
fanart_type = settings.fanart_selection()
thumb_size = settings.get_thumbnail_size()
@ -443,6 +450,7 @@ def update_playlist_items(provider, context, playlist_id_dict,
cxm_play_recently_added = menu_items.playlist_play_recently_added(context)
cxm_view_playlist = menu_items.playlist_view(context)
cxm_play_shuffled_playlist = menu_items.playlist_shuffle(context)
cxm_refresh_listing = menu_items.refresh_listing(context, path, params)
cxm_remove_saved_playlist = menu_items.playlist_remove_from_library(context)
cxm_save_playlist = (
menu_items.playlist_save_to_library(context)
@ -585,6 +593,7 @@ def update_playlist_items(provider, context, playlist_id_dict,
cxm_play_recently_added,
cxm_view_playlist,
cxm_play_shuffled_playlist,
cxm_refresh_listing,
cxm_separator,
cxm_save_playlist,
menu_items.bookmark_add(
@ -1238,6 +1247,7 @@ if PREFER_WEBP_THUMBS:
THUMB_URL = 'https://i.ytimg.com/vi_webp/{0}/{1}{2}.webp'
else:
THUMB_URL = 'https://i.ytimg.com/vi/{0}/{1}{2}.jpg'
RE_CUSTOM_THUMB = re_compile(r'_custom_[0-9]')
THUMB_TYPES = {
'default': {
'name': 'default',
@ -1326,10 +1336,17 @@ def get_thumbnail(thumb_size, thumbnails, default_thumb=None):
url = (thumbnail[1] if is_dict else thumbnail).get('url')
if not url:
return default_thumb
if PREFER_WEBP_THUMBS and '/vi_webp/' not in url and '?' not in url:
url = url.replace('/vi/', '/vi_webp/', 1).replace('.jpg', '.webp', 1)
if url.startswith('//'):
url = 'https:' + url
if '?' in url:
url = urlsplit(url)
url = url._replace(
netloc='i.ytimg.com',
path=RE_CUSTOM_THUMB.sub('', url.path),
query=None,
).geturl()
elif PREFER_WEBP_THUMBS and '/vi_webp/' not in url:
url = url.replace('/vi/', '/vi_webp/', 1).replace('.jpg', '.webp', 1)
return url
@ -1360,6 +1377,7 @@ def add_related_video_to_playlist(provider, context, client, v3, video_id):
next_item = next((
item for item in result_items
if (item
and isinstance(item, MediaItem)
and not any((
item.get_uri() == playlist_item.get('file')
or item.get_name() == playlist_item.get('title')

View file

@ -364,8 +364,12 @@ def _process_list_response(provider,
item.available = yt_item.get('_available', False)
elif kind_type == 'playlistitem':
playlist_item_id = item_id
video_id = snippet['resourceId']['videoId']
video_id = snippet.get('resourceId', {}).get('videoId')
if video_id:
playlist_item_id = item_id
else:
video_id = item_id
playlist_item_id = None
channel_id = (snippet.get('videoOwnerChannelId')
or snippet.get('channelId'))
playlist_id = snippet.get('playlistId')

View file

@ -74,7 +74,7 @@ def _play_stream(provider, context):
ask_for_quality = settings.ask_for_video_quality()
if ui.pop_property(PLAY_PROMPT_QUALITY) and not screensaver:
ask_for_quality = True
elif ui.pop_property(PLAY_FORCE_AUDIO):
if ui.pop_property(PLAY_FORCE_AUDIO):
audio_only = True
else:
audio_only = settings.audio_only()
@ -97,6 +97,7 @@ def _play_stream(provider, context):
if not streams:
ui.show_notification(context.localize('error.no_streams_found'))
logging.debug('No streams found')
return False
stream = _select_stream(
@ -106,7 +107,6 @@ def _play_stream(provider, context):
audio_only=audio_only,
use_mpd=use_mpd,
)
if stream is None:
return False
@ -345,7 +345,6 @@ def _select_stream(context,
stream_list.sort(key=_stream_sort, reverse=True)
num_streams = len(stream_list)
ask_for_quality = ask_for_quality and num_streams >= 1
if logging.debugging:
def _default_NA():
@ -362,7 +361,7 @@ def _select_stream(context,
idx=idx,
stream=defaultdict(_default_NA, stream))
if ask_for_quality:
if ask_for_quality and num_streams > 1:
selected_stream = context.get_ui().on_select(
context.localize('select_video_quality'),
[stream['title'] for stream in stream_list],

View file

@ -242,11 +242,13 @@ def _process_disliked_videos(provider, context, client):
def _process_live_events(provider, context, client, event_type='live'):
# TODO: cache result
params = context.get_params()
json_data = client.get_live_events(
event_type=event_type,
order='date' if event_type == 'upcoming' else 'viewCount',
page_token=context.get_param('page_token', ''),
location=context.get_param('location', False),
order=params.get('order',
'date' if event_type == 'upcoming' else 'viewCount'),
page_token=params.get('page_token', ''),
location=params.get('location', False),
after={'days': 3} if event_type == 'completed' else None,
)
if not json_data:

View file

@ -1488,13 +1488,14 @@ class Provider(AbstractProvider):
# watch later
if settings_bool(settings.SHOW_WATCH_LATER, True):
if watch_later_id:
path = (
(PATHS.VIRTUAL_PLAYLIST, watch_later_id)
if watch_later_id.lower() == 'wl' else
(PATHS.MY_PLAYLIST, watch_later_id)
)
watch_later_item = DirectoryItem(
localize('watch_later'),
create_uri(
(PATHS.VIRTUAL_PLAYLIST, watch_later_id)
if watch_later_id.lower() == 'wl' else
(PATHS.MY_PLAYLIST, watch_later_id)
),
create_uri(path),
image='{media}/watch_later.png',
)
context_menu = [
@ -1510,6 +1511,9 @@ class Provider(AbstractProvider):
menu_items.playlist_shuffle(
context, watch_later_id
),
menu_items.refresh_listing(
context, path, {}
),
]
watch_later_item.add_context_menu(context_menu)
result.append(watch_later_item)
@ -1541,9 +1545,10 @@ class Provider(AbstractProvider):
playlists = resource_manager.get_related_playlists('mine')
if playlists and 'likes' in playlists:
liked_list_id = playlists['likes'] or 'LL'
path = (PATHS.VIRTUAL_PLAYLIST, liked_list_id)
liked_videos_item = DirectoryItem(
localize('video.liked'),
create_uri((PATHS.VIRTUAL_PLAYLIST, liked_list_id)),
create_uri(path),
image='{media}/likes.png',
)
context_menu = [
@ -1559,6 +1564,9 @@ class Provider(AbstractProvider):
menu_items.playlist_shuffle(
context, liked_list_id
),
menu_items.refresh_listing(
context, path, {}
),
]
liked_videos_item.add_context_menu(context_menu)
result.append(liked_videos_item)
@ -1575,13 +1583,14 @@ class Provider(AbstractProvider):
# history
if settings_bool(settings.SHOW_HISTORY, True):
if history_id:
path = (
(PATHS.VIRTUAL_PLAYLIST, history_id)
if history_id.lower() == 'hl' else
(PATHS.MY_PLAYLIST, history_id)
)
watch_history_item = DirectoryItem(
localize('history'),
create_uri(
(PATHS.VIRTUAL_PLAYLIST, history_id)
if history_id.lower() == 'hl' else
(PATHS.MY_PLAYLIST, history_id)
),
create_uri(path),
image='{media}/history.png',
)
context_menu = [
@ -1597,6 +1606,9 @@ class Provider(AbstractProvider):
menu_items.playlist_shuffle(
context, history_id
),
menu_items.refresh_listing(
context, path, {}
),
]
watch_history_item.add_context_menu(context_menu)
result.append(watch_history_item)
@ -2210,7 +2222,6 @@ class Provider(AbstractProvider):
attrs = (
'_resource_manager',
'_client',
'_api_check',
)
for attr in attrs:
try: