diff --git a/addon.xml b/addon.xml index 1fb75467d..c76fee920 100644 --- a/addon.xml +++ b/addon.xml @@ -1,5 +1,5 @@ - + diff --git a/changelog.txt b/changelog.txt index 2595977a9..7f6f6f6d4 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,3 +1,26 @@ +## v7.1.1+beta.1 +### Fixed +- Fix http server not listening on any interface if listen IP is 0.0.0.0 #927 + +### New +- Explicitly enable TCP keep alive #913 +- Add localised title and description for videos, channels and playlists +- Update display of playlists to show the following details: + - item count + - date + - channel name + - description + - web url + - podcast status +- Update display of channels to show the following details: + - view count + - subscriber count + - video count + - date + - description + - channel name in description + - web url + ## v7.1.0.1 ### Fixed - Fix logging/retry of sqlite3.OperationalError diff --git a/resources/language/resource.language.en_gb/strings.po b/resources/language/resource.language.en_gb/strings.po index dccf900e2..b9b3ba928 100644 --- a/resources/language/resource.language.en_gb/strings.po +++ b/resources/language/resource.language.en_gb/strings.po @@ -1254,15 +1254,15 @@ msgid "Shorts (1 minute or less)" msgstr "" msgctxt "#30737" -msgid "" +msgid "Episodes" msgstr "" msgctxt "#30738" -msgid "" +msgid "Videos" msgstr "" msgctxt "#30739" -msgid "" +msgid "Subscribers" msgstr "" msgctxt "#30740" @@ -1482,11 +1482,11 @@ msgid "Views count display colour" msgstr "" msgctxt "#30794" -msgid "Likes count display colour" +msgid "Subscriber/Likes count display colour" msgstr "" msgctxt "#30795" -msgid "Comments count display colour" +msgid "Videos/Comments count display colour" msgstr "" msgctxt "#30796" @@ -1584,3 +1584,7 @@ msgstr "" msgctxt "#30819" msgid "Play from start" msgstr "" + +msgctxt "#30820" +msgid "Podcast" +msgstr "" diff --git a/resources/lib/youtube_authentication.py b/resources/lib/youtube_authentication.py index d32ca5b17..e2f04c942 100644 --- a/resources/lib/youtube_authentication.py +++ b/resources/lib/youtube_authentication.py @@ -167,5 +167,5 @@ def reset_access_tokens(addon_id): return context = XbmcContext(params={'addon_id': addon_id}) context.get_access_manager().update_access_token( - addon_id, access_token='', refresh_token='' + addon_id, access_token='', expiry=-1, refresh_token='' ) diff --git a/resources/lib/youtube_plugin/kodion/constants/__init__.py b/resources/lib/youtube_plugin/kodion/constants/__init__.py index ba16a54bb..b0431d4fe 100644 --- a/resources/lib/youtube_plugin/kodion/constants/__init__.py +++ b/resources/lib/youtube_plugin/kodion/constants/__init__.py @@ -59,7 +59,6 @@ PLUGIN_WAKEUP = 'plugin_wakeup' PLUGIN_SLEEPING = 'plugin_sleeping' SERVER_WAKEUP = 'server_wakeup' -SERVER_POST_START = 'server_post_start' WAKEUP = 'wakeup' # Play options @@ -118,7 +117,6 @@ # Sleep/wakeup states 'PLUGIN_SLEEPING', 'PLUGIN_WAKEUP', - 'SERVER_POST_START', 'SERVER_WAKEUP', 'WAKEUP', diff --git a/resources/lib/youtube_plugin/kodion/constants/const_paths.py b/resources/lib/youtube_plugin/kodion/constants/const_paths.py index e0003afb4..ff8d35917 100644 --- a/resources/lib/youtube_plugin/kodion/constants/const_paths.py +++ b/resources/lib/youtube_plugin/kodion/constants/const_paths.py @@ -32,6 +32,6 @@ API_SUBMIT = '/youtube/api/submit' DRM = '/youtube/widevine' IP = '/youtube/client_ip' -MPD = '/youtube/manifest/dash/' +MPD = '/youtube/manifest/dash' PING = '/youtube/ping' REDIRECT = '/youtube/redirect' diff --git a/resources/lib/youtube_plugin/kodion/context/xbmc/xbmc_context.py b/resources/lib/youtube_plugin/kodion/context/xbmc/xbmc_context.py index d370f1968..c1023005d 100644 --- a/resources/lib/youtube_plugin/kodion/context/xbmc/xbmc_context.py +++ b/resources/lib/youtube_plugin/kodion/context/xbmc/xbmc_context.py @@ -150,6 +150,7 @@ class XbmcContext(AbstractContext): 'playlist.play.reverse': 30533, 'playlist.play.select': 30535, 'playlist.play.shuffle': 30534, + 'playlist.podcast': 30820, 'playlist.progress.updating': 30536, 'playlist.removed_from': 30715, 'playlist.select': 30521, @@ -210,7 +211,10 @@ class XbmcContext(AbstractContext): 'sign.multi.title': 30546, 'stats.commentCount': 30732, # 'stats.favoriteCount': 1036, + 'stats.itemCount': 30737, 'stats.likeCount': 30733, + 'stats.subscriberCount': 30739, + 'stats.videoCount': 30738, 'stats.viewCount': 30767, 'stream.alternate': 30747, 'stream.automatic': 30583, @@ -550,7 +554,7 @@ def apply_content(self): ) def add_sort_method(self, *sort_methods): - args = slice(None if current_system_version.compatible(19, 0) else 2) + args = slice(None if current_system_version.compatible(19) else 2) for sort_method in sort_methods: xbmcplugin.addSortMethod(self._plugin_handle, *sort_method[args]) diff --git a/resources/lib/youtube_plugin/kodion/items/__init__.py b/resources/lib/youtube_plugin/kodion/items/__init__.py index 7e11964e8..48bb1ac62 100644 --- a/resources/lib/youtube_plugin/kodion/items/__init__.py +++ b/resources/lib/youtube_plugin/kodion/items/__init__.py @@ -15,7 +15,7 @@ from .command_item import CommandItem from .directory_item import DirectoryItem from .image_item import ImageItem -from .media_item import AudioItem, VideoItem +from .media_item import AudioItem, MediaItem, VideoItem from .new_search_item import NewSearchItem from .next_page_item import NextPageItem from .search_history_item import SearchHistoryItem @@ -38,6 +38,7 @@ 'CommandItem', 'DirectoryItem', 'ImageItem', + 'MediaItem', 'NewSearchItem', 'NextPageItem', 'SearchHistoryItem', diff --git a/resources/lib/youtube_plugin/kodion/items/base_item.py b/resources/lib/youtube_plugin/kodion/items/base_item.py index e286e3cce..84aa969d7 100644 --- a/resources/lib/youtube_plugin/kodion/items/base_item.py +++ b/resources/lib/youtube_plugin/kodion/items/base_item.py @@ -42,6 +42,11 @@ def __init__(self, name, uri, image=None, fanart=None): self._date = None self._dateadded = None self._short_details = None + self._production_code = None + + self._cast = None + self._artists = None + self._studios = None def __str__(self): return ('------------------------------\n' @@ -195,6 +200,58 @@ def get_bookmark_timestamp(self): def playable(self): return self._playable + 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 + class _Encoder(json.JSONEncoder): def encode(self, obj, nested=False): diff --git a/resources/lib/youtube_plugin/kodion/items/media_item.py b/resources/lib/youtube_plugin/kodion/items/media_item.py index 4118e59e1..1b59fbc9c 100644 --- a/resources/lib/youtube_plugin/kodion/items/media_item.py +++ b/resources/lib/youtube_plugin/kodion/items/media_item.py @@ -25,17 +25,19 @@ class MediaItem(BaseItem): _playable = True - def __init__(self, name, uri, image='DefaultFile.png', fanart=None): + def __init__(self, + name, + uri, + image='DefaultFile.png', + fanart=None, + plot=None): super(MediaItem, self).__init__(name, uri, image, fanart) self._aired = None self._premiered = None self._scheduled_start_utc = None self._year = None - self._artists = None - self._cast = None self._genres = None - self._studios = None self._duration = -1 self._play_count = None @@ -44,8 +46,7 @@ def __init__(self, name, uri, image='DefaultFile.png', fanart=None): self._start_time = None self._mediatype = None - self._plot = None - self._production_code = None + self._plot = plot self._rating = None self._title = self.get_name() self._track_number = None @@ -110,40 +111,6 @@ def set_year_from_datetime(self, date_time): def get_year(self): return self._year - 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_genre(self, genre): if genre: if self._genres is None: @@ -156,18 +123,6 @@ def get_genres(self): def set_genres(self, genres): self._genres = list(genres) - 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_duration(self, hours=0, minutes=0, seconds=0, duration=''): if duration: _seconds = duration_to_seconds(duration) @@ -232,12 +187,6 @@ def set_plot(self, plot): def get_plot(self): return self._plot - def set_production_code(self, value): - self._production_code = value or '' - - def get_production_code(self): - return self._production_code - def set_rating(self, rating): rating = float(rating) if rating > 10: @@ -384,8 +333,13 @@ 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): - super(AudioItem, self).__init__(name, uri, image, fanart) + def __init__(self, + name, + uri, + image='DefaultAudio.png', + fanart=None, + plot=None): + super(AudioItem, self).__init__(name, uri, image, fanart, plot) self._album = None def set_album_name(self, album_name): @@ -405,8 +359,13 @@ class VideoItem(MediaItem): r'(http(s)?://)?www.imdb.(com|de)/title/(?P[t0-9]+)(/)?' ) - def __init__(self, name, uri, image='DefaultVideo.png', fanart=None): - super(VideoItem, self).__init__(name, uri, image, fanart) + def __init__(self, + name, + uri, + image='DefaultVideo.png', + fanart=None, + plot=None): + super(VideoItem, self).__init__(name, uri, image, fanart, plot) self._directors = None self._episode = None self._imdb_id = None diff --git a/resources/lib/youtube_plugin/kodion/items/menu_items.py b/resources/lib/youtube_plugin/kodion/items/menu_items.py index 5ce366f99..0654b81d1 100644 --- a/resources/lib/youtube_plugin/kodion/items/menu_items.py +++ b/resources/lib/youtube_plugin/kodion/items/menu_items.py @@ -533,7 +533,7 @@ def bookmark_add_channel(context, channel_id, channel_name=''): return ( (context.localize('bookmark.channel') % ( context.get_ui().bold(channel_name) if channel_name else - context.localize(19029) + context.localize(19029) # "Channel" )), context.create_uri( (PATHS.BOOKMARKS, 'add',), @@ -614,7 +614,7 @@ def separator(): def goto_home(context): return ( - context.localize(10000), + context.localize(10000), # "Home" context.create_uri( (PATHS.ROUTE, PATHS.HOME,), { diff --git a/resources/lib/youtube_plugin/kodion/items/xbmc/xbmc_items.py b/resources/lib/youtube_plugin/kodion/items/xbmc/xbmc_items.py index 2c9c6aa26..3e57e76bb 100644 --- a/resources/lib/youtube_plugin/kodion/items/xbmc/xbmc_items.py +++ b/resources/lib/youtube_plugin/kodion/items/xbmc/xbmc_items.py @@ -12,7 +12,13 @@ from json import dumps -from .. import AudioItem, DirectoryItem, ImageItem, VideoItem +from .. import ( + AudioItem, + DirectoryItem, + ImageItem, + MediaItem, + VideoItem, +) from ...compatibility import to_str, xbmc, xbmcgui from ...constants import ( CHANNEL_ID, @@ -28,62 +34,87 @@ def set_info(list_item, item, properties, set_play_count=True, resume=True): - if not current_system_version.compatible(20, 0): - if isinstance(item, VideoItem): - info_labels = {} - info_type = 'video' + 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_cast() + value = item.get_premiered(as_info_label=True) if value is not None: - info_labels['castandrole'] = [(member['name'], member['role']) - for member in value] + info_labels['premiered'] = value - value = item.get_production_code() + value = item.get_plot() if value is not None: - info_labels['code'] = value + info_labels['plot'] = value - value = item.get_dateadded(as_info_label=True) + value = item.get_last_played(as_info_label=True) if value is not None: - info_labels['dateadded'] = value + info_labels['lastplayed'] = value - value = item.get_episode() + value = item.get_mediatype() if value is not None: - info_labels['episode'] = value + info_labels['mediatype'] = value - value = item.get_plot() + value = item.get_play_count() if value is not None: - info_labels['plot'] = value + if set_play_count: + info_labels['playcount'] = value + properties[PLAY_COUNT] = value - value = item.get_premiered(as_info_label=True) + value = item.get_rating() if value is not None: - info_labels['premiered'] = value + info_labels['rating'] = value - value = item.get_season() + value = item.get_title() if value is not None: - info_labels['season'] = value + info_labels['title'] = value - value = item.get_studios() + value = item.get_track_number() if value is not None: - info_labels['studio'] = value - - elif isinstance(item, AudioItem): - info_labels = {} - info_type = 'music' + info_labels['tracknumber'] = value - value = item.get_album_name() + value = item.get_year() if value is not None: - info_labels['album'] = value + info_labels['year'] = value - value = item.get_plot() - if value is not None: - info_labels['plot'] = value + resume_time = resume and item.get_start_time() + if resume_time: + properties['ResumeTime'] = str(resume_time) + duration = item.get_duration() + if duration: + properties['TotalTime'] = str(duration) + if info_type == 'video': + list_item.addStreamInfo(info_type, {'duration': duration}) + + if duration is not None: + info_labels['duration'] = value elif isinstance(item, DirectoryItem): - info_labels = {} info_type = 'video' value = item.get_name() @@ -94,204 +125,205 @@ def set_info(list_item, item, properties, set_play_count=True, resume=True): if value is not None: info_labels['plot'] = value - if info_labels: - list_item.setInfo(info_type, info_labels) - - if properties: - list_item.setProperties(properties) - return - elif isinstance(item, ImageItem): + info_type = 'picture' + value = item.get_title() if value is not None: - list_item.setInfo('picture', {'title': value}) - - if properties: - list_item.setProperties(properties) - return + info_labels['title'] = value else: return - 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 - - value = item.get_duration() - if value is not None: - info_labels['duration'] = value - - value = item.get_last_played(as_info_label=True) + value = item.get_production_code() if value is not None: - info_labels['lastplayed'] = value + info_labels['code'] = value - value = item.get_mediatype() + value = item.get_dateadded(as_info_label=True) if value is not None: - info_labels['mediatype'] = value + info_labels['dateadded'] = value - value = item.get_play_count() + value = item.get_studios() if value is not None: - if set_play_count: - info_labels['playcount'] = value - properties[PLAY_COUNT] = value + info_labels['studio'] = value - value = item.get_rating() + value = item.get_cast() if value is not None: - info_labels['rating'] = value + info_labels['castandrole'] = [(member['name'], member['role']) + for member in value] - value = item.get_title() + value = item.get_artists() if value is not None: - info_labels['title'] = value + info_labels['artist'] = value - value = item.get_track_number() + value = item.get_count() if value is not None: - info_labels['tracknumber'] = value + info_labels['count'] = value - value = item.get_year() + value = item.get_date(as_info_label=True) if value is not None: - info_labels['year'] = value - - resume_time = resume and item.get_start_time() - if resume_time: - properties['ResumeTime'] = str(resume_time) - duration = item.get_duration() - if duration: - properties['TotalTime'] = str(duration) - if info_type == 'video': - list_item.addStreamInfo(info_type, {'duration': duration}) + info_labels['date'] = value if properties: list_item.setProperties(properties) - if info_labels: + if info_labels and info_type: list_item.setInfo(info_type, info_labels) return - value = item.get_date(as_info_label=True) - if value is not None: - list_item.setDateTime(value) + if isinstance(item, MediaItem): + if isinstance(item, VideoItem): + info_tag = list_item.getVideoInfoTag() + info_type = 'video' - 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) - value = item.get_aired(as_info_label=True) - if value is not None: - info_tag.setFirstAired(value) + # season: int + value = item.get_season() + if value is not None: + info_tag.setSeason(value) - value = item.get_dateadded(as_info_label=True) - if value is not None: - info_tag.setDateAdded(value) + value = item.get_premiered(as_info_label=True) + if value is not None: + info_tag.setPremiered(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) - # artist: list[str] - # eg. ["Angerfist"] - # Used as alias for channel name - value = item.get_artists() - if value is not None: - info_tag.setArtists(value) + # plot: str + value = item.get_plot() + if value is not None: + info_tag.setPlot(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]) + # 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) - # director: list[str] - # eg. "Steven Spielberg" - # Currently unused - # value = item.get_directors() - # if value is not None: - # info_tag.setDirectors(value) + elif isinstance(item, AudioItem): + info_tag = list_item.getMusicInfoTag() + info_type = 'music' - # episode: int - value = item.get_episode() - if value is not None: - info_tag.setEpisode(value) + # album: str + # eg. "Buckle Up" + value = item.get_album_name() + if value is not None: + info_tag.setAlbum(value) - # imdbnumber: str - # eg. "tt3458353" - # Currently unused - # value = item.get_imdb_id() - # if value is not None: - # info_tag.setIMDBNumber(value) + value = item.get_premiered(as_info_label=True) + if value is not None: + info_tag.setReleaseDate(value) - # plot: str - value = item.get_plot() - if value is not None: - info_tag.setPlot(value) + # comment: str + value = item.get_plot() + if value is not None: + info_tag.setComment(value) - # 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) + # 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) - # season: int - value = item.get_season() - if value is not None: - info_tag.setSeason(value) + # track: int + # eg. 12 + value = item.get_track_number() + if value is not None: + info_tag.setTrack(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) + else: + return - # tracknumber: int - # eg. 12 - value = item.get_track_number() + value = item.get_last_played(as_info_label=True) if value is not None: - info_tag.setTrackNumber(value) - - elif isinstance(item, AudioItem): - info_tag = list_item.getMusicInfoTag() - info_type = 'music' + info_tag.setLastPlayed(value) - value = item.get_premiered(as_info_label=True) + # mediatype: str + value = item.get_mediatype() if value is not None: - info_tag.setReleaseDate(value) + info_tag.setMediaType(value) - # album: str - # eg. "Buckle Up" - value = item.get_album_name() + # playcount: int + value = item.get_play_count() if value is not None: - info_tag.setAlbum(value) + if set_play_count: + if info_type == 'video': + info_tag.setPlaycount(value) + elif info_type == 'music': + info_tag.setPlayCount(value) + properties[PLAY_COUNT] = value - # artist: str - # eg. "Artist 1, Artist 2" - # Used as alias for channel name - value = item.get_artists_string() + # rating: float + value = item.get_rating() if value is not None: - info_tag.setArtist(value) + info_tag.setRating(value) - # comment: str - value = item.get_plot() + # title: str + # eg. "Blow Your Head Off" + value = item.get_title() if value is not None: - info_tag.setComment(value) + info_tag.setTitle(value) - # track: int - # eg. 12 - value = item.get_track_number() + # year: int + # eg. 1994 + value = item.get_year() if value is not None: - info_tag.setTrack(value) + 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 and duration: + info_tag.setResumePoint(resume_time, float(duration)) + elif resume_time: + info_tag.setResumePoint(resume_time) + if duration: + 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: + properties['ResumeTime'] = str(resume_time) + if duration: + properties['TotalTime'] = str(duration) + + # duration: int + # As seconds + if duration is not None: + 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: @@ -301,64 +333,49 @@ def set_info(list_item, item, properties, set_play_count=True, resume=True): if value is not None: info_tag.setPlot(value) - if properties: - list_item.setProperties(properties) - return - elif isinstance(item, ImageItem): info_tag = list_item.getPictureInfoTag() + info_type = 'picture' value = item.get_title() if value is not None: info_tag.setTitle(value) - if properties: - list_item.setProperties(properties) - return - else: return - resume_time = resume and item.get_start_time() - duration = item.get_duration() if info_type == 'video': - if resume_time and duration: - info_tag.setResumePoint(resume_time, float(duration)) - elif resume_time: - info_tag.setResumePoint(resume_time) - if duration: - 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: - properties['ResumeTime'] = str(resume_time) - if duration: - properties['TotalTime'] = str(duration) - - # duration: int - # As seconds - if duration is not None: - info_tag.setDuration(duration) - - # mediatype: str - value = item.get_mediatype() - if value is not None: - info_tag.setMediaType(value) + # 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_last_played(as_info_label=True) - if value is not None: - info_tag.setLastPlayed(value) + value = item.get_dateadded(as_info_label=True) + if value is not None: + info_tag.setDateAdded(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] = 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 @@ -368,29 +385,9 @@ def set_info(list_item, item, properties, set_play_count=True, resume=True): if value is not None: list_item.setInfo(info_type, {'count': value}) - # genre: list[str] - # eg. ["Hardcore"] - # Currently unused - # value = item.get_genres() - # if value is not None: - # info_tag.setGenres(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_title() - if value is not None: - info_tag.setTitle(value) - - # year: int - # eg. 1994 - value = item.get_year() + value = item.get_date(as_info_label=True) if value is not None: - info_tag.setYear(value) + list_item.setDateTime(value) if properties: list_item.setProperties(properties) @@ -446,12 +443,12 @@ def playback_item(context, media_item, show_fanart=None, **_kwargs): props['inputstream.adaptive.stream_selection_type'] = 'adaptive' props['inputstream.adaptive.chooser_resolution_max'] = 'auto' - if current_system_version.compatible(19, 0): + if current_system_version.compatible(19): props['inputstream'] = 'inputstream.adaptive' else: props['inputstreamaddon'] = 'inputstream.adaptive' - if not current_system_version.compatible(21, 0): + if not current_system_version.compatible(21): props['inputstream.adaptive.manifest_type'] = manifest_type if media_item.live: @@ -523,6 +520,7 @@ def directory_listitem(context, directory_item, show_fanart=None, **_kwargs): kwargs = { 'label': directory_item.get_name(), + 'label2': directory_item.get_short_details(), 'path': uri, 'offscreen': True, } diff --git a/resources/lib/youtube_plugin/kodion/json_store/access_manager.py b/resources/lib/youtube_plugin/kodion/json_store/access_manager.py index 9114bd281..f71467b52 100644 --- a/resources/lib/youtube_plugin/kodion/json_store/access_manager.py +++ b/resources/lib/youtube_plugin/kodion/json_store/access_manager.py @@ -435,18 +435,18 @@ def is_access_token_expired(self, addon_id=None): def update_access_token(self, addon_id, access_token=None, - unix_timestamp=None, + expiry=None, refresh_token=None): """ Updates the old access token with the new one. :param access_token: - :param unix_timestamp: + :param expiry: :param refresh_token: :return: """ details = { 'access_token': ( - '|'.join(access_token) + '|'.join([token or '' for token in access_token]) if isinstance(access_token, (list, tuple)) else access_token if access_token else @@ -454,16 +454,16 @@ def update_access_token(self, ) } - if unix_timestamp is not None: + if expiry is not None: details['token_expires'] = ( - min(map(int, [val for val in unix_timestamp if val])) - if isinstance(unix_timestamp, (list, tuple)) else - int(unix_timestamp) + min(map(int, [val for val in expiry if val])) + if isinstance(expiry, (list, tuple)) else + int(expiry) ) if refresh_token is not None: details['refresh_token'] = ( - '|'.join(refresh_token) + '|'.join([token or '' for token in refresh_token]) if isinstance(refresh_token, (list, tuple)) else refresh_token ) diff --git a/resources/lib/youtube_plugin/kodion/monitors/service_monitor.py b/resources/lib/youtube_plugin/kodion/monitors/service_monitor.py index ecc2b6a84..fea592596 100644 --- a/resources/lib/youtube_plugin/kodion/monitors/service_monitor.py +++ b/resources/lib/youtube_plugin/kodion/monitors/service_monitor.py @@ -204,7 +204,7 @@ def start_httpd(self): self.httpd_thread.start() address = self.httpd.socket.getsockname() - log_debug('HTTPServer: Serving on |{ip}:{port}|' + log_debug('HTTPServer: Listening on |{ip}:{port}|' .format(ip=address[0], port=address[1])) def shutdown_httpd(self, sleep=False): diff --git a/resources/lib/youtube_plugin/kodion/network/http_server.py b/resources/lib/youtube_plugin/kodion/network/http_server.py index b2ecba8bb..276a423c3 100644 --- a/resources/lib/youtube_plugin/kodion/network/http_server.py +++ b/resources/lib/youtube_plugin/kodion/network/http_server.py @@ -20,15 +20,22 @@ from ..compatibility import ( BaseHTTPRequestHandler, TCPServer, - parse_qs, + parse_qsl, urlsplit, + urlunsplit, xbmc, xbmcgui, xbmcvfs, ) -from ..constants import ADDON_ID, LICENSE_TOKEN, LICENSE_URL, PATHS, TEMP_PATH +from ..constants import ( + ADDON_ID, + LICENSE_TOKEN, + LICENSE_URL, + PATHS, + TEMP_PATH, +) from ..logger import log_debug, log_error -from ..utils import validate_ip_address, redact_ip, wait +from ..utils import redact_ip, validate_ip_address, wait class HTTPServer(TCPServer): @@ -112,15 +119,22 @@ def do_GET(self): self.wfile.write(client_json.encode('utf-8')) elif stripped_path.startswith(PATHS.MPD): - filepath = os.path.join(self.BASE_PATH, self.path[len(PATHS.MPD):]) - file_chunk = True try: + file = dict(parse_qsl(urlsplit(self.path).query)).get('file') + if file: + filepath = os.path.join(self.BASE_PATH, file) + else: + filepath = None + raise IOError + with open(filepath, 'rb') as f: self.send_response(200) self.send_header('Content-Type', 'application/dash+xml') self.send_header('Content-Length', str(os.path.getsize(filepath))) self.end_headers() + + file_chunk = True while file_chunk: file_chunk = f.read(self.chunk_size) if file_chunk: @@ -146,12 +160,12 @@ def do_GET(self): xbmc.executebuiltin('Dialog.Close(addonsettings,true)') query = urlsplit(self.path).query - params = parse_qs(query) + params = dict(parse_qsl(query)) updated = [] - api_key = params.get('api_key', [None])[0] - api_id = params.get('api_id', [None])[0] - api_secret = params.get('api_secret', [None])[0] + api_key = params.get('api_key') + api_id = params.get('api_id') + api_secret = params.get('api_secret') # Bookmark this page if api_key and api_id and api_secret: footer = localize(30638) @@ -204,11 +218,11 @@ def do_GET(self): self.send_error(204) elif stripped_path.startswith(PATHS.REDIRECT): - url = parse_qs(urlsplit(self.path).query).get('url') + url = dict(parse_qsl(urlsplit(self.path).query)).get('url') if url: wait(1) self.send_response(301) - self.send_header('Location', url[0]) + self.send_header('Location', url) self.end_headers() else: self.send_error(501) @@ -577,13 +591,13 @@ def get_http_server(address, port, context): def httpd_status(context): - address, port = get_connect_address(context) - url = ''.join(( - 'http://', - address, - ':', - str(port), + netloc = get_connect_address(context, as_netloc=True) + url = urlunsplit(( + 'http', + netloc, PATHS.PING, + '', + '', )) if not RequestHandler.requests: RequestHandler.requests = BaseRequestsClass(context=context) @@ -592,22 +606,20 @@ def httpd_status(context): if result == 204: return True - log_debug('HTTPServer: Ping |{address}:{port}| - |{response}|' - .format(address=address, - port=port, + log_debug('HTTPServer: Ping |{netloc}| - |{response}|' + .format(netloc=netloc, response=result or 'failed')) return False def get_client_ip_address(context): ip_address = None - address, port = get_connect_address(context) - url = ''.join(( - 'http://', - address, - ':', - str(port), + url = urlunsplit(( + 'http', + get_connect_address(context, as_netloc=True), PATHS.IP, + '', + '', )) if not RequestHandler.requests: RequestHandler.requests = BaseRequestsClass(context=context) @@ -621,31 +633,29 @@ def get_client_ip_address(context): def get_connect_address(context, as_netloc=False): settings = context.get_settings() - address = settings.httpd_listen() - port = settings.httpd_port() - if address == '0.0.0.0': - address = '127.0.0.1' + listen_address = settings.httpd_listen() + listen_port = settings.httpd_port() sock = None try: sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - if hasattr(socket, "SO_REUSEADDR"): + if hasattr(socket, 'SO_REUSEADDR'): sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - if hasattr(socket, "SO_REUSEPORT"): + if hasattr(socket, 'SO_REUSEPORT'): sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1) except socket.error: - address = xbmc.getIPAddress() + listen_address = xbmc.getIPAddress() if sock: sock.settimeout(0) try: - sock.connect((address, 0)) - address = sock.getsockname()[0] + sock.connect((listen_address, 0)) + connect_address = sock.getsockname()[0] except socket.error: - address = xbmc.getIPAddress() + connect_address = xbmc.getIPAddress() finally: sock.close() if as_netloc: - return ':'.join((address, str(port))) - return address, port + return ':'.join((connect_address, str(listen_port))) + return listen_address, listen_port diff --git a/resources/lib/youtube_plugin/kodion/network/requests.py b/resources/lib/youtube_plugin/kodion/network/requests.py index 886bbffb4..deb19940a 100644 --- a/resources/lib/youtube_plugin/kodion/network/requests.py +++ b/resources/lib/youtube_plugin/kodion/network/requests.py @@ -10,6 +10,7 @@ from __future__ import absolute_import, division, unicode_literals import atexit +import socket from traceback import format_stack from requests import Session @@ -28,6 +29,20 @@ 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) @@ -35,6 +50,12 @@ class SSLHTTPAdapter(HTTPAdapter): 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): @@ -145,7 +166,7 @@ def request(self, url, method='GET', error_details.update(_detail) if _response is not None: response = _response - response_text = str(_response) + response_text = repr(_response) if _trace is not None: stack_trace = _trace if _exc is not None: @@ -168,7 +189,7 @@ def request(self, url, method='GET', error_info = str(exc) if response_text: - response_text = 'Request response:\n{0}'.format(response_text) + response_text = 'Response:\n\t|{0}|'.format(response_text) if stack_trace: stack_trace = ( diff --git a/resources/lib/youtube_plugin/kodion/plugin/xbmc/xbmc_plugin.py b/resources/lib/youtube_plugin/kodion/plugin/xbmc/xbmc_plugin.py index 971b2d814..7e54f32d3 100644 --- a/resources/lib/youtube_plugin/kodion/plugin/xbmc/xbmc_plugin.py +++ b/resources/lib/youtube_plugin/kodion/plugin/xbmc/xbmc_plugin.py @@ -28,6 +28,7 @@ REFRESH_CONTAINER, RELOAD_ACCESS_MANAGER, REROUTE_PATH, + SERVER_WAKEUP, VIDEO_ID, ) from ...exceptions import KodionException @@ -227,6 +228,7 @@ def run(self, provider, context, focused=None): playlist_player = context.get_playlist_player() playlist_player.play_item(item=uri, listitem=item) else: + context.wakeup(SERVER_WAKEUP, timeout=5) xbmcplugin.setResolvedUrl(self.handle, succeeded=result, listitem=item) diff --git a/resources/lib/youtube_plugin/kodion/plugin_runner.py b/resources/lib/youtube_plugin/kodion/plugin_runner.py index e5fa69fcf..8dcee5dd7 100644 --- a/resources/lib/youtube_plugin/kodion/plugin_runner.py +++ b/resources/lib/youtube_plugin/kodion/plugin_runner.py @@ -10,8 +10,6 @@ from __future__ import absolute_import, division, unicode_literals -from platform import python_version - from .context import XbmcContext from .plugin import XbmcPlugin from ..youtube import Provider @@ -24,6 +22,7 @@ _provider = Provider() _profiler = _context.get_infobool('System.GetBool(debug.showloginfo)') +_profiler = True if _profiler: from .debug import Profiler @@ -37,8 +36,6 @@ def run(context=_context, if profiler: profiler.enable(flush=True) - context.log_debug('Starting Kodion framework by bromix...') - current_uri = context.get_uri() context.init() new_uri = context.get_uri() @@ -48,14 +45,15 @@ def run(context=_context, if key in params: params[key] = '' - context.log_notice('Running: {plugin} ({version})' - ' on {kodi} with Python {python}\n' - 'Path: {path}\n' - 'Params: {params}' - .format(plugin=context.get_name(), - version=context.get_version(), - kodi=context.get_system_version(), - python=python_version(), + system_version = context.get_system_version() + context.log_notice('Plugin: Running |v{version}|\n' + 'Kodi: |v{kodi}|\n' + 'Python: |v{python}|\n' + 'Path: |{path}|\n' + 'Params: |{params}|' + .format(version=context.get_version(), + kodi=str(system_version), + python=system_version.get_python_version(), path=context.get_path(), params=params)) diff --git a/resources/lib/youtube_plugin/kodion/script_actions.py b/resources/lib/youtube_plugin/kodion/script_actions.py index cd4bffd34..403f79b92 100644 --- a/resources/lib/youtube_plugin/kodion/script_actions.py +++ b/resources/lib/youtube_plugin/kodion/script_actions.py @@ -21,7 +21,7 @@ ) from .context import XbmcContext from .network import Locator, get_client_ip_address, httpd_status -from .utils import current_system_version, rm_dir, validate_ip_address +from .utils import rm_dir, validate_ip_address from ..youtube import Provider @@ -426,7 +426,7 @@ def _maintenance_actions(context, action, params): if target == 'settings_xml' and ui.on_yes_no_input( context.get_name(), localize('refresh.settings.confirm') ): - if not current_system_version.compatible(20, 0): + if not context.get_system_version().compatible(20): ui.show_notification(localize('failed')) return @@ -646,6 +646,20 @@ def run(argv): if params: params = dict(parse_qsl(args.query)) + system_version = context.get_system_version() + context.log_notice('Script: Running |v{version}|\n' + 'Kodi: |v{kodi}|\n' + 'Python: |v{python}|\n' + 'Category: |{category}|\n' + 'Action: |{action}|\n' + 'Params: |{params}|' + .format(version=context.get_version(), + kodi=str(system_version), + python=system_version.get_python_version(), + category=category, + action=action, + params=params)) + if not category: xbmcaddon.Addon().openSettings() return diff --git a/resources/lib/youtube_plugin/kodion/service_runner.py b/resources/lib/youtube_plugin/kodion/service_runner.py index 89fc41fff..da8403d3d 100644 --- a/resources/lib/youtube_plugin/kodion/service_runner.py +++ b/resources/lib/youtube_plugin/kodion/service_runner.py @@ -13,7 +13,6 @@ from .constants import ( ABORT_FLAG, PLUGIN_SLEEPING, - SERVER_POST_START, TEMP_PATH, VIDEO_ID, ) @@ -28,16 +27,21 @@ def run(): context = XbmcContext() - context.log_debug('YouTube service initialization...') - provider = Provider() + system_version = context.get_system_version() + context.log_notice('Service: Starting |v{version}|\n' + 'Kodi: |v{kodi}|\n' + 'Python: |v{python}|' + .format(version=context.get_version(), + kodi=str(system_version), + python=system_version.get_python_version())) + get_listitem_info = context.get_listitem_info get_listitem_property = context.get_listitem_property ui = context.get_ui() clear_property = ui.clear_property - pop_property = ui.pop_property set_property = ui.set_property clear_property(ABORT_FLAG) @@ -89,13 +93,9 @@ def run(): if httpd_idle_time_ms >= httpd_idle_timeout_ms: httpd_idle_time_ms = 0 monitor.shutdown_httpd(sleep=True) - else: - if monitor.httpd_sleep_allowed is None: - if pop_property(SERVER_POST_START): - monitor.httpd_sleep_allowed = True - httpd_idle_time_ms = 0 - else: - pop_property(SERVER_POST_START) + elif monitor.httpd_sleep_allowed is None: + monitor.httpd_sleep_allowed = True + httpd_idle_time_ms = 0 else: if httpd_idle_time_ms >= httpd_ping_period_ms: httpd_idle_time_ms = 0 diff --git a/resources/lib/youtube_plugin/kodion/settings/abstract_settings.py b/resources/lib/youtube_plugin/kodion/settings/abstract_settings.py index f644a4769..81dbc3812 100644 --- a/resources/lib/youtube_plugin/kodion/settings/abstract_settings.py +++ b/resources/lib/youtube_plugin/kodion/settings/abstract_settings.py @@ -628,14 +628,22 @@ def get_history_playlist(self): def set_history_playlist(self, value): return self.set_string(SETTINGS.HISTORY_PLAYLIST, value) - if current_system_version.compatible(20, 0): + if current_system_version.compatible(20): + _COLOR_SETTING_MAP = { + 'itemCount': 'commentCount', + 'subscriberCount': 'likeCount', + 'videoCount': 'commentCount', + } + def get_label_color(self, label_part): + label_part = self._COLOR_SETTING_MAP.get(label_part) or label_part setting_name = '.'.join((SETTINGS.LABEL_COLOR, label_part)) return self.get_string(setting_name, 'white') else: _COLOR_MAP = { 'commentCount': 'cyan', 'favoriteCount': 'gold', + 'itemCount': 'cyan', 'likeCount': 'lime', 'viewCount': 'lightblue', } diff --git a/resources/lib/youtube_plugin/kodion/settings/xbmc/xbmc_plugin_settings.py b/resources/lib/youtube_plugin/kodion/settings/xbmc/xbmc_plugin_settings.py index ba22a9731..d8e82c413 100644 --- a/resources/lib/youtube_plugin/kodion/settings/xbmc/xbmc_plugin_settings.py +++ b/resources/lib/youtube_plugin/kodion/settings/xbmc/xbmc_plugin_settings.py @@ -24,7 +24,7 @@ class SettingsProxy(object): def __init__(self, instance): self.ref = instance - if current_system_version.compatible(21, 0): + if current_system_version.compatible(21): def get_bool(self, *args, **kwargs): return self.ref.getBool(*args, **kwargs) @@ -75,7 +75,7 @@ def set_str_list(self, setting, value): value = ','.join(value) return self.ref.setSetting(setting, value) - if not current_system_version.compatible(19, 0): + if not current_system_version.compatible(19): @property def ref(self): if self._ref: @@ -121,7 +121,7 @@ def flush(self, xbmc_addon=None, fill=False, flush_all=True): self._echo = get_kodi_setting_bool('debug.showloginfo') self._cache = {} - if current_system_version.compatible(21, 0): + if current_system_version.compatible(21): self._proxy = SettingsProxy(xbmc_addon.getSettings()) # set methods in new Settings class are documented as returning a # bool, True if value was set, False otherwise, similar to how the @@ -130,7 +130,7 @@ def flush(self, xbmc_addon=None, fill=False, flush_all=True): # Ignore return value until bug is fixed in Kodi self._check_set = False else: - if fill and not current_system_version.compatible(19, 0): + if fill and not current_system_version.compatible(19): self.__class__._instances.add(xbmc_addon) self._proxy = SettingsProxy(xbmc_addon) diff --git a/resources/lib/youtube_plugin/kodion/utils/system_version.py b/resources/lib/youtube_plugin/kodion/utils/system_version.py index b306abac1..7711fe580 100644 --- a/resources/lib/youtube_plugin/kodion/utils/system_version.py +++ b/resources/lib/youtube_plugin/kodion/utils/system_version.py @@ -10,70 +10,69 @@ from __future__ import absolute_import, division, unicode_literals +from platform import python_version + from .methods import jsonrpc from ..compatibility import string_type class SystemVersion(object): - def __init__(self, version=None, releasename=None, appname=None): - self._version = ( - version if version and isinstance(version, tuple) - else (0, 0, 0, 0) - ) + RELEASE_MAP = { + (22, 0): 'Piers', + (21, 0): 'Omega', + (20, 0): 'Nexus', + (19, 0): 'Matrix', + (18, 0): 'Leia', + (17, 0): 'Krypton', + (16, 0): 'Jarvis', + (15, 0): 'Isengard', + (14, 0): 'Helix', + (13, 0): 'Gotham', + (12, 0): 'Frodo', + } - self._releasename = ( - releasename if releasename and isinstance(releasename, string_type) - else 'UNKNOWN' - ) - - self._appname = ( - appname if appname and isinstance(appname, string_type) - else 'UNKNOWN' - ) + def __init__(self, version=None, releasename=None, appname=None): + if isinstance(version, tuple): + self._version = version + else: + version = None - try: - response = jsonrpc(method='Application.GetProperties', - params={'properties': ['version', 'name']}) - version_installed = response['result']['version'] - self._version = (version_installed.get('major', 1), - version_installed.get('minor', 0)) - self._appname = response['result']['name'] - except (KeyError, TypeError): - self._version = (1, 0) # Frodo - self._appname = 'Unknown Application' - - if self._version >= (22, 0): - self._releasename = 'Piers' - elif self._version >= (21, 0): - self._releasename = 'Omega' - elif self._version >= (20, 0): - self._releasename = 'Nexus' - elif self._version >= (19, 0): - self._releasename = 'Matrix' - elif self._version >= (18, 0): - self._releasename = 'Leia' - elif self._version >= (17, 0): - self._releasename = 'Krypton' - elif self._version >= (16, 0): - self._releasename = 'Jarvis' - elif self._version >= (15, 0): - self._releasename = 'Isengard' - elif self._version >= (14, 0): - self._releasename = 'Helix' - elif self._version >= (13, 0): - self._releasename = 'Gotham' - elif self._version >= (12, 0): - self._releasename = 'Frodo' + if appname and isinstance(appname, string_type): + self._appname = appname + else: + appname = None + + if version is None or appname is None: + try: + result = jsonrpc( + method='Application.GetProperties', + params={'properties': ['version', 'name']}, + )['result'] or {} + except (KeyError, TypeError): + result = {} + + if version is None: + version = result.get('version') or {} + self._version = (version.get('major', 1), + version.get('minor', 0)) + + if appname is None: + self._appname = result.get('name', 'Unknown application') + + if releasename and isinstance(releasename, string_type): + self._releasename = releasename else: - self._releasename = 'Unknown Release' + version = (self._version[0], self._version[1]) + self._releasename = self.RELEASE_MAP.get(version, 'Unknown release') + + self._python_version = python_version() def __str__(self): - obj_str = '{releasename} ({appname}-{version[0]}.{version[1]})'.format( + return '{version[0]}.{version[1]} ({appname} {releasename})'.format( releasename=self._releasename, appname=self._appname, version=self._version ) - return obj_str def get_release_name(self): return self._releasename @@ -84,6 +83,9 @@ def get_version(self): def get_app_name(self): return self._appname + def get_python_version(self): + return self._python_version + def compatible(self, *version): return self._version >= version diff --git a/resources/lib/youtube_plugin/youtube/client/login_client.py b/resources/lib/youtube_plugin/youtube/client/login_client.py index 0c63f17b3..ab14c4421 100644 --- a/resources/lib/youtube_plugin/youtube/client/login_client.py +++ b/resources/lib/youtube_plugin/youtube/client/login_client.py @@ -37,6 +37,12 @@ class LoginClient(YouTubeRequestClient): 'identity.plus.page.impersonation', )) TOKEN_URL = 'https://www.googleapis.com/oauth2/v4/token' + TOKEN_TYPES = { + 0: 'tv', + 'tv': 'tv', + 1: 'personal', + 'personal': 'personal', + } def __init__(self, configs=None, @@ -106,16 +112,19 @@ def revoke(self, refresh_token): error_info='Revoke failed: {exc}', raise_exc=True) - def refresh_token_tv(self, refresh_token): - client_id = self._config_tv.get('id') - client_secret = self._config_tv.get('secret') - if not client_id or not client_secret: - return '', 0 - return self.refresh_token(refresh_token, - client_id=client_id, - client_secret=client_secret) + def refresh_token(self, token_type, refresh_token=None): + login_type = self.TOKEN_TYPES.get(token_type) + if login_type == 'tv': + client_id = self._config_tv.get('id') + client_secret = self._config_tv.get('secret') + elif login_type == 'personal': + client_id = self._config.get('id') + client_secret = self._config.get('secret') + else: + return None + if not client_id or not client_secret or not refresh_token: + return None - def refresh_token(self, refresh_token, client_id='', client_secret=''): # https://developers.google.com/youtube/v3/guides/auth/devices headers = {'Host': 'www.googleapis.com', 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' @@ -123,21 +132,21 @@ def refresh_token(self, refresh_token, client_id='', client_secret=''): ' Chrome/61.0.3163.100 Safari/537.36', 'Content-Type': 'application/x-www-form-urlencoded'} - client_id = client_id or self._config.get('id', '') - client_secret = client_secret or self._config.get('secret', '') post_data = {'client_id': client_id, 'client_secret': client_secret, 'refresh_token': refresh_token, 'grant_type': 'refresh_token'} config_type = self._get_config_type(client_id, client_secret) - client = ''.join(( - '(config_type: |', config_type, - '| client_id: |', client_id[:3], '...', client_id[-5:], - '| client_secret: |', client_secret[:3], '...', client_secret[-3:], - '|)' - )) - log_debug('Refresh token for {0}'.format(client)) + client = (('config_type: |{config_type}|\n' + 'client_id: |{id_start}...{id_end}|\n' + 'client_secret: |{secret_start}...{secret_end}|') + .format(config_type=config_type, + id_start=client_id[:3], + id_end=client_id[-5:], + secret_start=client_secret[:3], + secret_end=client_secret[-3:])) + log_debug('Refresh token\n{0}'.format(client)) json_data = self.request(self.TOKEN_URL, method='POST', @@ -146,8 +155,9 @@ def refresh_token(self, refresh_token, client_id='', client_secret=''): response_hook=LoginClient._response_hook, error_hook=LoginClient._error_hook, error_title='Login Failed', - error_info=('Refresh token failed' - ' {client}:\n{{exc}}' + error_info=('Refresh token failed\n' + '{client}:\n' + '{{exc}}' .format(client=client)), raise_exc=True) @@ -157,16 +167,19 @@ def refresh_token(self, refresh_token, client_id='', client_secret=''): return access_token, expiry return '', 0 - def request_access_token_tv(self, code, client_id='', client_secret=''): - client_id = client_id or self._config_tv.get('id') - client_secret = client_secret or self._config_tv.get('secret') - if not client_id or not client_secret: - return '', '' - return self.request_access_token(code, - client_id=client_id, - client_secret=client_secret) + def request_access_token(self, token_type, code=None): + login_type = self.TOKEN_TYPES.get(token_type) + if login_type == 'tv': + client_id = self._config_tv.get('id') + client_secret = self._config_tv.get('secret') + elif login_type == 'personal': + client_id = self._config.get('id') + client_secret = self._config.get('secret') + else: + return None + if not client_id or not client_secret or not code: + return None - def request_access_token(self, code, client_id='', client_secret=''): # https://developers.google.com/youtube/v3/guides/auth/devices headers = {'Host': 'www.googleapis.com', 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' @@ -174,21 +187,21 @@ def request_access_token(self, code, client_id='', client_secret=''): ' Chrome/61.0.3163.100 Safari/537.36', 'Content-Type': 'application/x-www-form-urlencoded'} - client_id = client_id or self._config.get('id', '') - client_secret = client_secret or self._config.get('secret', '') post_data = {'client_id': client_id, 'client_secret': client_secret, 'code': code, 'grant_type': 'http://oauth.net/grant_type/device/1.0'} config_type = self._get_config_type(client_id, client_secret) - client = ''.join(( - '(config_type: |', config_type, - '| client_id: |', client_id[:3], '...', client_id[-5:], - '| client_secret: |', client_secret[:3], '...', client_secret[-3:], - '|)' - )) - log_debug('Requesting access token for {0}'.format(client)) + client = (('config_type: |{config_type}|\n' + 'client_id: |{id_start}...{id_end}|\n' + 'client_secret: |{secret_start}...{secret_end}|') + .format(config_type=config_type, + id_start=client_id[:3], + id_end=client_id[-5:], + secret_start=client_secret[:3], + secret_end=client_secret[-3:])) + log_debug('Requesting access token\n{0}'.format(client)) json_data = self.request(self.TOKEN_URL, method='POST', @@ -197,19 +210,24 @@ def request_access_token(self, code, client_id='', client_secret=''): response_hook=LoginClient._response_hook, error_hook=LoginClient._error_hook, error_title='Login Failed: Unknown response', - error_info=('Access token request failed' - ' {client}:\n{{exc}}' + error_info=('Access token request failed\n' + '{client}:\n' + '{{exc}}' .format(client=client)), raise_exc=True) return json_data - def request_device_and_user_code_tv(self): - client_id = self._config_tv.get('id') + def request_device_and_user_code(self, token_type): + login_type = self.TOKEN_TYPES.get(token_type) + if login_type == 'tv': + client_id = self._config_tv.get('id') + elif login_type == 'personal': + client_id = self._config.get('id') + else: + return None if not client_id: return None - return self.request_device_and_user_code(client_id=client_id) - def request_device_and_user_code(self, client_id=''): # https://developers.google.com/youtube/v3/guides/auth/devices headers = {'Host': 'accounts.google.com', 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' @@ -217,17 +235,16 @@ def request_device_and_user_code(self, client_id=''): ' Chrome/61.0.3163.100 Safari/537.36', 'Content-Type': 'application/x-www-form-urlencoded'} - client_id = client_id or self._config.get('id', '') post_data = {'client_id': client_id, 'scope': 'https://www.googleapis.com/auth/youtube'} config_type = self._get_config_type(client_id) - client = ''.join(( - '(config_type: |', config_type, - '| client_id: |', client_id[:3], '...', client_id[-5:], - '|)' - )) - log_debug('Requesting device and user code for {0}'.format(client)) + client = (('config_type: |{config_type}|\n' + 'client_id: |{id_start}...{id_end}|') + .format(config_type=config_type, + id_start=client_id[:3], + id_end=client_id[-5:])) + log_debug('Requesting device and user code\n{0}'.format(client)) json_data = self.request(self.DEVICE_CODE_URL, method='POST', @@ -236,8 +253,9 @@ def request_device_and_user_code(self, client_id=''): response_hook=LoginClient._response_hook, error_hook=LoginClient._error_hook, error_title='Login Failed: Unknown response', - error_info=('Device/user code request failed' - ' {client}:\n{{exc}}' + error_info=('Device/user code request failed\n' + '{client}\n' + '{{exc}}' .format(client=client)), raise_exc=True) return json_data @@ -248,7 +266,7 @@ def authenticate(self, username, password): 'User-Agent': 'GoogleAuth/1.4 (GT-I9100 KTU84Q)', 'content-type': 'application/x-www-form-urlencoded', 'Host': 'android.clients.google.com', - 'Connection': 'Keep-Alive', + 'Connection': 'keep-alive', 'Accept-Encoding': 'gzip'} post_data = { diff --git a/resources/lib/youtube_plugin/youtube/client/youtube.py b/resources/lib/youtube_plugin/youtube/client/youtube.py index d7c2d2a5e..005896c01 100644 --- a/resources/lib/youtube_plugin/youtube/client/youtube.py +++ b/resources/lib/youtube_plugin/youtube/client/youtube.py @@ -23,7 +23,6 @@ from ...kodion.compatibility import cpu_count, string_type, to_str from ...kodion.items import DirectoryItem from ...kodion.utils import ( - current_system_version, datetime_parser, strip_html_from_text, to_unicode, @@ -938,7 +937,7 @@ def get_channels(self, channel_id, **kwargs): if not isinstance(channel_id, string_type): channel_id = ','.join(channel_id) - params = {'part': 'snippet,contentDetails,brandingSettings'} + params = {'part': 'snippet,contentDetails,brandingSettings,statistics'} if channel_id != 'mine': params['id'] = channel_id else: @@ -990,7 +989,7 @@ def get_playlists(self, playlist_id, **kwargs): if not isinstance(playlist_id, string_type): playlist_id = ','.join(playlist_id) - params = {'part': 'snippet,contentDetails', + params = {'part': 'snippet,status,contentDetails', 'id': playlist_id} return self.api_request(method='GET', path='playlists', @@ -1551,7 +1550,7 @@ def _get_feed(output, channel_id, _headers=headers): } def _parse_feeds(feeds, - encode=not current_system_version.compatible(19, 0), + utf8=self._context.get_system_version().compatible(19), filters=subscription_filters, _ns=namespaces, _cache=cache): @@ -1567,7 +1566,7 @@ def _parse_feeds(feeds, content.encoding = 'utf-8' content = to_unicode(content.content).replace('\n', '') - root = ET.fromstring(to_str(content) if encode else content) + root = ET.fromstring(content if utf8 else to_str(content)) channel_name = (root.findtext('atom:title', '', _ns) .lower().replace(',', '')) feed_items = [{ @@ -1968,14 +1967,16 @@ def _error_hook(self, **kwargs): if getattr(exc, 'pass_data', False): data = json_data else: - data = None + data = kwargs['response'] if getattr(exc, 'raise_exc', False): exception = YouTubeException else: exception = None if not json_data or 'error' not in json_data: - return None, None, None, data, None, exception + info = 'Exception:\n\t|{exc!r}|' + details = kwargs + return None, info, details, data, None, exception details = json_data['error'] reason = details.get('errors', [{}])[0].get('reason', 'Unknown') @@ -2005,7 +2006,7 @@ def _error_hook(self, **kwargs): time_ms=timeout) info = ('API error: {reason}\n' - 'exc: |{exc}|\n' + 'exc: |{exc!r}|\n' 'message: |{message}|') details = {'reason': reason, 'message': message} return '', info, details, data, False, exception @@ -2040,6 +2041,8 @@ def api_request(self, # a config can decide if a token is allowed elif self._access_token and self._config.get('token-allowed', True): client_data['_access_token'] = self._access_token + elif self._access_token_tv: + client_data['_access_token'] = self._access_token_tv # abort if authentication is required but not available for request elif self.CLIENTS.get(version, {}).get('auth_required'): abort = True diff --git a/resources/lib/youtube_plugin/youtube/helper/stream_info.py b/resources/lib/youtube_plugin/youtube/helper/stream_info.py index d07bc20d5..71e25b5ad 100644 --- a/resources/lib/youtube_plugin/youtube/helper/stream_info.py +++ b/resources/lib/youtube_plugin/youtube/helper/stream_info.py @@ -30,6 +30,7 @@ urlencode, urljoin, urlsplit, + urlunsplit, xbmcvfs, ) from ...kodion.constants import PATHS, TEMP_PATH @@ -980,9 +981,7 @@ def _make_curl_headers(headers, cookies=None): '='.join((cookie.name, cookie.value)) for cookie in cookies ]) # Headers used in xbmc_items.video_playback_item' - return '&'.join([ - '='.join((key, quote(value))) for key, value in headers.items() - ]) + return urlencode(headers, safe='/', quote_via=quote) @staticmethod def _normalize_url(url): @@ -1589,17 +1588,15 @@ def load_stream_info(self, video_id): continue self._context.log_debug('Found widevine license url: {0}' .format(url)) - address, port = get_connect_address(self._context) license_info = { 'url': url, - 'proxy': ''.join(( - 'http://', - address, - ':', - str(port), + 'proxy': urlunsplit(( + 'http', + get_connect_address(self._context, as_netloc=True), PATHS.DRM, - '||R{{SSM}}|', - )), + '', + '', + )) + '||R{{SSM}}|R', 'token': self._access_token, } break @@ -2201,8 +2198,10 @@ def _filter_group(previous_group, previous_stream, item): )) if license_url: - license_url = (license_url.replace("&", "&") - .replace('"', """).replace("<", "<") + license_url = (license_url + .replace("&", "&") + .replace('"', """) + .replace("<", "<") .replace(">", ">")) output.extend(( '\t\t\t 0: - access_tokens[idx] = token + access_tokens[token_type] = token if not token_expiry or expiry < token_expiry: token_expiry = expiry - if any(access_tokens) and expiry: + if any(access_tokens) and token_expiry: access_manager.update_access_token( - dev_id, access_tokens, token_expiry, + dev_id, + access_token=access_tokens, + expiry=token_expiry, ) else: raise InvalidGrant @@ -312,21 +280,26 @@ def get_client(self, context): # reset access token # reset refresh token if InvalidGrant otherwise leave as-is # to retry later + if isinstance(exc, InvalidGrant): + refresh_token = '' + else: + refresh_token = None access_manager.update_access_token( dev_id, - refresh_token=('' if isinstance(exc, InvalidGrant) - else None), + refresh_token=refresh_token, ) - # in debug log the login status - self._logged_in = any(access_tokens) - if self._logged_in: + num_access_tokens = sum(1 for token in access_tokens if token) + + if num_access_tokens and access_tokens[1]: + self._logged_in = True context.log_debug('User is logged in') client.set_access_token( personal=access_tokens[1], tv=access_tokens[0], ) else: + self._logged_in = False context.log_debug('User is not logged in') client.set_access_token(personal='', tv='') @@ -966,7 +939,7 @@ def on_maintenance_actions(provider, context, re_match): success = False provider.reset_client() access_manager.update_access_token( - addon_id, access_token='', refresh_token='', + addon_id, access_token='', expiry=-1, refresh_token='', ) ui.refresh_container() ui.show_notification(localize('succeeded' if success else 'failed')) @@ -1646,6 +1619,7 @@ def handle_exception(self, context, exception_to_handle): context.get_access_manager().update_access_token( context.get_param('addon_id', None), access_token='', + expiry=-1, refresh_token='', ) ok_dialog = True