From bd3fe6f3a85f400ce3df64d6e44512a7ba8b3394 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Arnauts?= Date: Tue, 3 Nov 2020 12:07:14 +0100 Subject: [PATCH] Integrate Movies and TV Series into the Kodi library (#9) * Movie and TV Series integration into the Kodi library * Automatically update library when adding/removing from My List --- README.md | 29 +++ addon.xml | 2 + .../resource.language.en_gb/strings.po | 41 +++- .../resource.language.nl_nl/strings.po | 40 ++++ resources/lib/addon.py | 74 +++++++ resources/lib/kodiutils.py | 34 ++- resources/lib/modules/catalog.py | 5 +- resources/lib/modules/library.py | 195 ++++++++++++++++++ resources/lib/modules/menu.py | 7 +- resources/lib/modules/metadata.py | 2 +- resources/lib/streamz/__init__.py | 4 +- resources/lib/streamz/api.py | 98 ++++++--- resources/settings.xml | 9 + tests/test_api.py | 8 + 14 files changed, 511 insertions(+), 37 deletions(-) create mode 100644 resources/lib/modules/library.py diff --git a/README.md b/README.md index 3aa6d11..bf2c995 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,35 @@ De volgende features worden ondersteund: * Afspelen van films en series * Volledig overzicht van alle content * Zoeken in de volledige catalogus +* Integratie met Kodi bibliotheek + +## Integratie met Kodi + +Je kan deze Add-on gebruiken als medialocatie in Kodi zodat de films en series ook in je Kodi bibliotheek geindexeerd staan. Ze worden uiteraard nog steeds +gewoon gestreamed. + +Ga hiervoor naar **Instellingen** > **Media** > **Bibliotheek** > **Video's...** (bij bronnen beheren). Kies vervolgens **Toevoegen video's...** en geef +onderstaande locatie in door **< Geen >** te kiezen. Geef vervolgens de naam op en kies OK. Stel daarna de opties in zoals hieronder opgegeven en bevestig met OK. +Stem daarna toe om deze locaties te scannen. + +* Films: + * Locatie: `plugin://plugin.video.streamz/library/movies/` + * Naam: **Streamz - Films** + * Opties: + * Deze map bevat: **Speelfilms** + * Kies informatieleverancier: **Local information only** + * Films staan in aparte folders die overeenkomen met de filmtitel: **Uit** + * Ook onderliggende mappen scannen : **Uit** + * Locatie uitsluiten van bibliotheekupdates: **Uit** + +* Series: + * Locatie: `plugin://plugin.video.streamz/library/tvshows/` + * Naam: **Streamz - Series** + * Opties: + * Deze map bevat: **Series** + * Kies informatieleverancier: **Local information only** + * Geselecteerde map bevat één enkele serie: **Uit** + * Locatie uitsluiten van bibliotheekupdates: **Uit** ## Screenshots diff --git a/addon.xml b/addon.xml index a95272e..b3d5352 100644 --- a/addon.xml +++ b/addon.xml @@ -12,6 +12,8 @@ video + library/movies/ + library/tvshows/ diff --git a/resources/language/resource.language.en_gb/strings.po b/resources/language/resource.language.en_gb/strings.po index bfde860..ef898f0 100644 --- a/resources/language/resource.language.en_gb/strings.po +++ b/resources/language/resource.language.en_gb/strings.po @@ -185,7 +185,6 @@ msgctxt "#30716" msgid "Updating metadata ({index}/{total})..." msgstr "" - ### SETTINGS msgctxt "#30800" msgid "Credentials" @@ -303,6 +302,46 @@ msgctxt "#30864" msgid "Up Next settings…" msgstr "" +msgctxt "#30870" +msgid "Kodi Library" +msgstr "" + +msgctxt "#30871" +msgid "Indexing" +msgstr "" + +msgctxt "#30872" +msgid "Add video locations to the Kodi Library… [COLOR gray](See README.md)[/COLOR]" +msgstr "" + +msgctxt "#30873" +msgid "Index the following movies" +msgstr "" + +msgctxt "#30874" +msgid "Index the following series" +msgstr "" + +msgctxt "#30875" +msgid "Full catalog" +msgstr "" + +msgctxt "#30876" +msgid "Items on 'My List' only" +msgstr "" + +msgctxt "#30877" +msgid "Maintenance" +msgstr "" + +msgctxt "#30878" +msgid "Refresh Library…" +msgstr "" + +msgctxt "#30879" +msgid "Clean Library…" +msgstr "" + msgctxt "#30880" msgid "Expert" msgstr "" diff --git a/resources/language/resource.language.nl_nl/strings.po b/resources/language/resource.language.nl_nl/strings.po index 2ade9d9..d079694 100644 --- a/resources/language/resource.language.nl_nl/strings.po +++ b/resources/language/resource.language.nl_nl/strings.po @@ -304,6 +304,46 @@ msgctxt "#30864" msgid "Up Next settings…" msgstr "Up Next instellingen…" +msgctxt "#30870" +msgid "Kodi Library" +msgstr "Kodi-bibliotheek" + +msgctxt "#30871" +msgid "Indexing" +msgstr "Indexeren" + +msgctxt "#30872" +msgid "Add video locations to the Kodi Library… [COLOR gray](See README.md)[/COLOR]" +msgstr "Videolocaties toevoegen aan de Kodi-bibliotheek… [COLOR gray](Zie README.md)[/COLOR]" + +msgctxt "#30873" +msgid "Index the following movies" +msgstr "Indexeer de volgende films" + +msgctxt "#30874" +msgid "Index the following series" +msgstr "Indexeer de volgende series" + +msgctxt "#30875" +msgid "Full catalog" +msgstr "Volledige catalogus" + +msgctxt "#30876" +msgid "Items on 'My List' only" +msgstr "Enkel items op 'Mijn lijst'" + +msgctxt "#30877" +msgid "Maintenance" +msgstr "Onderhoud" + +msgctxt "#30878" +msgid "Refresh Library…" +msgstr "Verniew bibliotheek…" + +msgctxt "#30879" +msgid "Clean Library…" +msgstr "Bibliotheek opkuisen…" + msgctxt "#30880" msgid "Expert" msgstr "Expert" diff --git a/resources/lib/addon.py b/resources/lib/addon.py index 702a7ce..cb3bfec 100644 --- a/resources/lib/addon.py +++ b/resources/lib/addon.py @@ -80,6 +80,74 @@ def show_catalog_program_season(program, season): Catalog().show_program_season(program, int(season)) +@routing.route('/library/movies/') +def library_movies(): + """ Show a list of all movies for integration into the Kodi Library """ + from resources.lib.modules.library import Library + + # Library seems to have issues with folder mode + movie = routing.args.get('movie', [])[0] if routing.args.get('movie') else None + + if 'check_exists' in routing.args.get('kodi_action', []): + Library().check_library_movie(movie) + return + + if 'refresh_info' in routing.args.get('kodi_action', []): + Library().show_library_movies(movie) + return + + if movie: + play('movies', movie) + else: + Library().show_library_movies() + + +@routing.route('/library/tvshows/') +def library_tvshows(): + """ Show a list of all tv series for integration into the Kodi Library """ + from resources.lib.modules.library import Library + + # Library seems to have issues with folder mode + program = routing.args.get('program', [])[0] if routing.args.get('program') else None + episode = routing.args.get('episode', [])[0] if routing.args.get('episode') else None + + if 'check_exists' in routing.args.get('kodi_action', []): + Library().check_library_tvshow(program) + return + + if 'refresh_info' in routing.args.get('kodi_action', []): + Library().show_library_tvshows(program) + return + + if episode: + play('episodes', episode) + elif program: + Library().show_library_tvshows_program(program) + else: + Library().show_library_tvshows() + + +@routing.route('/library/configure') +def library_configure(): + """ Show information on how to enable the library integration """ + from resources.lib.modules.library import Library + Library().configure() + + +@routing.route('/library/update') +def library_update(): + """ Refresh the library. """ + from resources.lib.modules.library import Library + Library().update() + + +@routing.route('/library/clean') +def library_clean(): + """ Clean the library. """ + from resources.lib.modules.library import Library + Library().clean() + + @routing.route('/catalog/recommendations/') def show_recommendations(storefront): """ Shows the recommendations of a storefront """ @@ -107,6 +175,9 @@ def mylist_add(video_type, content_id): from resources.lib.modules.catalog import Catalog Catalog().mylist_add(video_type, content_id) + from resources.lib.modules.library import Library + Library().mylist_added(video_type, content_id) + @routing.route('/catalog/mylist/del//') def mylist_del(video_type, content_id): @@ -114,6 +185,9 @@ def mylist_del(video_type, content_id): from resources.lib.modules.catalog import Catalog Catalog().mylist_del(video_type, content_id) + from resources.lib.modules.library import Library + Library().mylist_removed(video_type, content_id) + @routing.route('/catalog/continuewatching') def show_continuewatching(): diff --git a/resources/lib/kodiutils.py b/resources/lib/kodiutils.py index 39055d4..98b1636 100644 --- a/resources/lib/kodiutils.py +++ b/resources/lib/kodiutils.py @@ -224,6 +224,14 @@ def play(stream, license_key=None, title=None, art_dict=None, info_dict=None, pr xbmcplugin.setResolvedUrl(routing.handle, True, listitem=play_item) +def library_return_status(success): + """Notify Kodi about the status of a listitem.""" + from resources.lib.addon import routing + + _LOGGER.debug('Returning status %s', success) + xbmcplugin.setResolvedUrl(routing.handle, success, listitem=xbmcgui.ListItem()) + + def get_search_string(heading='', message=''): """Ask the user for a search string""" search_string = None @@ -245,6 +253,14 @@ def ok_dialog(heading='', message=''): return Dialog().ok(heading=heading, message=message) +def textviewer(heading='', text='', usemono=False): + """Show Kodi's textviewer dialog""" + from xbmcgui import Dialog + if not heading: + heading = addon_name() + Dialog().textviewer(heading=heading, text=text, usemono=usemono) + + def show_context_menu(items): """Show Kodi's OK dialog""" from xbmcgui import Dialog @@ -562,7 +578,6 @@ def get_cache(key, ttl=None): import time fullpath = os.path.join(get_cache_path(), '.'.join(key)) - if not xbmcvfs.exists(fullpath): return None @@ -584,12 +599,18 @@ def get_cache(key, ttl=None): def set_cache(key, data): """ Store an item in the cache :type key: list[str] - :type data: str + :type data: any """ - if not xbmcvfs.exists(get_cache_path()): - xbmcvfs.mkdirs(get_cache_path()) + fullpath = get_cache_path() + '/' + if not xbmcvfs.exists(fullpath): + xbmcvfs.mkdirs(fullpath) - fullpath = os.path.join(get_cache_path(), '.'.join(key)) + fullpath = os.path.join(fullpath, '.'.join(key)) + + if data is None: + # Remove from cache + xbmcvfs.delete(fullpath) + return fdesc = xbmcvfs.File(fullpath, 'w') @@ -602,8 +623,7 @@ def set_cache(key, data): def invalidate_cache(ttl=None): """ Clear the cache """ - fullpath = get_cache_path() - + fullpath = get_cache_path() + '/' if not xbmcvfs.exists(fullpath): return diff --git a/resources/lib/modules/catalog.py b/resources/lib/modules/catalog.py index 21ba6b3..536c977 100644 --- a/resources/lib/modules/catalog.py +++ b/resources/lib/modules/catalog.py @@ -184,7 +184,7 @@ def show_recommendations_category(self, storefront, category): listing.append(Menu.generate_titleitem(item)) # Sort categories by default like in Streamz. - kodiutils.show_listing(listing, 30015, content='tvshows') + kodiutils.show_listing(listing, 30015, content='tvshows', sort=['unsorted', 'label', 'year', 'duration']) def show_mylist(self): """ Show the items in "My List". """ @@ -196,7 +196,7 @@ def show_mylist(self): listing.append(Menu.generate_titleitem(item)) # Sort categories by default like in Streamz. - kodiutils.show_listing(listing, 30017, content='tvshows') + kodiutils.show_listing(listing, 30017, content='tvshows', sort=['unsorted', 'label', 'year', 'duration']) def mylist_add(self, video_type, content_id): """ Add an item to "My List". @@ -215,7 +215,6 @@ def mylist_del(self, video_type, content_id): """ self._api.del_mylist(video_type, content_id) kodiutils.end_of_directory() - kodiutils.container_refresh() def show_continuewatching(self): """ Show the items in "Continue Watching". """ diff --git a/resources/lib/modules/library.py b/resources/lib/modules/library.py new file mode 100644 index 0000000..bb843ef --- /dev/null +++ b/resources/lib/modules/library.py @@ -0,0 +1,195 @@ +# -*- coding: utf-8 -*- +""" Library module """ + +from __future__ import absolute_import, division, unicode_literals + +import logging + +from resources.lib import kodiutils +from resources.lib.modules.menu import Menu +from resources.lib.streamz import Movie, Program +from resources.lib.streamz.api import CACHE_AUTO, CACHE_PREVENT, CONTENT_TYPE_MOVIE, CONTENT_TYPE_PROGRAM, Api +from resources.lib.streamz.auth import Auth + +_LOGGER = logging.getLogger(__name__) + +LIBRARY_FULL_CATALOG = 0 +LIBRARY_ONLY_MYLIST = 1 + + +class Library: + """ Menu code related to the catalog """ + + def __init__(self): + """ Initialise object """ + self._auth = Auth(kodiutils.get_setting('username'), + kodiutils.get_setting('password'), + kodiutils.get_setting('loginprovider'), + kodiutils.get_setting('profile'), + kodiutils.get_tokens_path()) + self._api = Api(self._auth) + + def show_library_movies(self, movie=None): + """ Return a list of the movies that should be exported. """ + if movie is None: + if kodiutils.get_setting_int('library_movies') == LIBRARY_FULL_CATALOG: + # Full catalog + # Use cache if available, fetch from api otherwise so we get rich metadata for new content + items = self._api.get_items(content_filter=Movie, cache=CACHE_AUTO) + else: + # Only favourites, use cache if available, fetch from api otherwise + items = self._api.get_swimlane('my-list', content_filter=Movie) + else: + items = [self._api.get_movie(movie)] + + listing = [] + for item in items: + title_item = Menu.generate_titleitem(item) + # title_item.path = kodiutils.url_for('library_movies', movie=item.movie_id) # We need a trailing / + title_item.path = 'plugin://plugin.video.streamz/library/movies/?movie=%s' % item.movie_id + listing.append(title_item) + + kodiutils.show_listing(listing, 30003, content='movies', sort=['label', 'year', 'duration']) + + def show_library_tvshows(self, program=None): + """ Return a list of the series that should be exported. """ + if program is None: + if kodiutils.get_setting_int('library_tvshows') == LIBRARY_FULL_CATALOG: + # Full catalog + # Use cache if available, fetch from api otherwise so we get rich metadata for new content + # NOTE: We should probably use CACHE_PREVENT here, so we can pick up new episodes, but we can't since that would + # require a massive amount of API calls for each update. We do this only for programs in 'My list'. + items = self._api.get_items(content_filter=Program, cache=CACHE_AUTO) + else: + # Only favourites, don't use cache, fetch from api + # If we use CACHE_AUTO, we will miss updates until the user manually opens the program in the Add-on + items = self._api.get_swimlane('my-list', content_filter=Program, cache=CACHE_PREVENT) + else: + # Fetch only a single program + items = [self._api.get_program(program, cache=CACHE_PREVENT)] + + listing = [] + for item in items: + title_item = Menu.generate_titleitem(item) + # title_item.path = kodiutils.url_for('library_tvshows', program=item.program_id) # We need a trailing / + title_item.path = 'plugin://plugin.video.streamz/library/tvshows/?program={program_id}'.format(program_id=item.program_id) + listing.append(title_item) + + kodiutils.show_listing(listing, 30003, content='tvshows', sort=['label', 'year', 'duration']) + + def show_library_tvshows_program(self, program): + """ Return a list of the episodes that should be exported. """ + program_obj = self._api.get_program(program) + + listing = [] + for season in list(program_obj.seasons.values()): + for item in list(season.episodes.values()): + title_item = Menu.generate_titleitem(item) + # title_item.path = kodiutils.url_for('library_tvshows', program=item.program_id, episode=item.episode_id) + title_item.path = 'plugin://plugin.video.streamz/library/tvshows/?program={program_id}&episode={episode_id}'.format(program_id=item.program_id, + episode_id=item.episode_id) + listing.append(title_item) + + # Sort by episode number by default. Takes seasons into account. + kodiutils.show_listing(listing, 30003, content='episodes', sort=['episode', 'duration']) + + def check_library_movie(self, movie): + """ Check if the given movie is still available. """ + _LOGGER.debug('Checking if movie %s is still available', movie) + + # Our parent path always exists + if movie is None: + kodiutils.library_return_status(True) + return + + if kodiutils.get_setting_int('library_movies') == LIBRARY_FULL_CATALOG: + id_list = self._api.get_catalog_ids() + else: + id_list = self._api.get_mylist_ids() + + kodiutils.library_return_status(movie in id_list) + + def check_library_tvshow(self, program): + """ Check if the given program is still available. """ + _LOGGER.debug('Checking if program %s is still available', program) + + # Our parent path always exists + if program is None: + kodiutils.library_return_status(True) + return + + if kodiutils.get_setting_int('library_tvshows') == LIBRARY_FULL_CATALOG: + id_list = self._api.get_catalog_ids() + else: + id_list = self._api.get_mylist_ids() + + kodiutils.library_return_status(program in id_list) + + @staticmethod + def mylist_added(video_type, content_id): + """ Something has been added to My List. We want to index this. """ + if video_type == CONTENT_TYPE_MOVIE: + if kodiutils.get_setting_int('library_movies') != LIBRARY_ONLY_MYLIST: + return + # This unfortunately adds the movie to the database with the wrong parent path: + # Library().update('plugin://plugin.video.streamz/library/movies/?movie=%s&kodi_action=refresh_info' % content_id) + Library().update('plugin://plugin.video.streamz/library/movies/') + + elif video_type == CONTENT_TYPE_PROGRAM: + if kodiutils.get_setting_int('library_tvshows') != LIBRARY_ONLY_MYLIST: + return + Library().update('plugin://plugin.video.streamz/library/tvshows/?program=%s&kodi_action=refresh_info' % content_id) + + @staticmethod + def mylist_removed(video_type, content_id): + """ Something has been removed from My List. We want to de-index this. """ + if video_type == CONTENT_TYPE_MOVIE: + if kodiutils.get_setting_int('library_movies') != LIBRARY_ONLY_MYLIST: + return + Library().clean('plugin://plugin.video.streamz/library/movies/?movie=%s' % content_id) + + elif video_type == CONTENT_TYPE_PROGRAM: + if kodiutils.get_setting_int('library_tvshows') != LIBRARY_ONLY_MYLIST: + return + Library().clean('plugin://plugin.video.streamz/library/tvshows/?program=%s' % content_id) + + @staticmethod + def configure(): + """ Configure the library integration. """ + # There seems to be no way to add sources automatically. + # * https://forum.kodi.tv/showthread.php?tid=228840 + + # Open the sources view + kodiutils.execute_builtin('ActivateWindow(Videos,sources://video/)') + + @staticmethod + def update(path=None): + """ Update the library integration. """ + _LOGGER.debug('Scanning %s', path) + if path: + # We can use this to instantly add something to the library when we've added it to 'My List'. + kodiutils.jsonrpc(method='VideoLibrary.Scan', params=dict( + directory=path, + showdialogs=False, + )) + else: + kodiutils.jsonrpc(method='VideoLibrary.Scan') + + @staticmethod + def clean(path=None): + """ Cleanup the library integration. """ + _LOGGER.debug('Cleaning %s', path) + if path: + # We can use this to instantly remove something from the library when we've removed it from 'My List'. + # This only works from Kodi 19 however. See https://github.com/xbmc/xbmc/pull/18562 + if kodiutils.kodi_version_major() > 18: + kodiutils.jsonrpc(method='VideoLibrary.Clean', params=dict( + directory=path, + showdialogs=False, + )) + else: + kodiutils.jsonrpc(method='VideoLibrary.Clean', params=dict( + showdialogs=False, + )) + else: + kodiutils.jsonrpc(method='VideoLibrary.Clean') diff --git a/resources/lib/modules/menu.py b/resources/lib/modules/menu.py index 586dfbc..78a0ff1 100644 --- a/resources/lib/modules/menu.py +++ b/resources/lib/modules/menu.py @@ -253,6 +253,7 @@ def generate_titleitem(cls, item, progress=False): art_dict=art_dict, info_dict=info_dict, stream_dict=stream_dict, + prop_dict=prop_dict, context_menu=context_menu, is_playable=True, ) @@ -278,15 +279,19 @@ def generate_titleitem(cls, item, progress=False): 'fanart': item.image, }) info_dict.update({ - 'mediatype': None, + 'mediatype': 'tvshow', 'season': len(item.seasons), }) + prop_dict.update({ + 'hash': item.content_hash, + }) return TitleItem( title=item.name, path=kodiutils.url_for('show_catalog_program', program=item.program_id), art_dict=art_dict, info_dict=info_dict, + prop_dict=prop_dict, context_menu=context_menu, ) diff --git a/resources/lib/modules/metadata.py b/resources/lib/modules/metadata.py index 4faaf38..1ee5671 100644 --- a/resources/lib/modules/metadata.py +++ b/resources/lib/modules/metadata.py @@ -45,7 +45,7 @@ def fetch_metadata(self, callback=None): :type callback: callable """ - # Fetch all items from the catalog + # Fetch a list of all items from the catalog items = self._api.get_items() count = len(items) diff --git a/resources/lib/streamz/__init__.py b/resources/lib/streamz/__init__.py index 43901a9..3e6dcd0 100644 --- a/resources/lib/streamz/__init__.py +++ b/resources/lib/streamz/__init__.py @@ -97,7 +97,7 @@ class Program: """ Defines a Program """ def __init__(self, program_id=None, name=None, description=None, cover=None, image=None, seasons=None, - geoblocked=None, channel=None, legal=None, my_list=None): + geoblocked=None, channel=None, legal=None, my_list=None, content_hash=None): """ :type program_id: str :type name: str @@ -109,6 +109,7 @@ def __init__(self, program_id=None, name=None, description=None, cover=None, ima :type channel: str :type legal: str :type my_list: bool + :type content_hash: str """ self.program_id = program_id self.name = name @@ -120,6 +121,7 @@ def __init__(self, program_id=None, name=None, description=None, cover=None, ima self.channel = channel self.legal = legal self.my_list = my_list + self.content_hash = content_hash def __repr__(self): return "%r" % self.__dict__ diff --git a/resources/lib/streamz/api.py b/resources/lib/streamz/api.py index 0c45d8e..0abdad1 100644 --- a/resources/lib/streamz/api.py +++ b/resources/lib/streamz/api.py @@ -3,6 +3,7 @@ from __future__ import absolute_import, division, unicode_literals +import hashlib import json import logging @@ -78,11 +79,8 @@ def get_recommendations(self, storefront): return categories - def get_swimlane(self, swimlane): - """ Returns the contents of a swimlane (My List, Continue Watching). - - :param str swimlane: The name of the swimlane to fetch. - """ + def get_swimlane(self, swimlane, content_filter=None, cache=CACHE_ONLY): + """ Returns the contents of My List """ response = util.http_get(API_ENDPOINT + '/%s/main/swimlane/%s' % (self._mode(), swimlane), token=self._tokens.jwt_token, profile=self._tokens.profile) @@ -95,14 +93,14 @@ def get_swimlane(self, swimlane): items = [] for item in result.get('teasers'): - if item.get('target', {}).get('type') == CONTENT_TYPE_MOVIE: - items.append(self._parse_movie_teaser(item)) + if item.get('target', {}).get('type') == CONTENT_TYPE_MOVIE and content_filter in [None, Movie]: + items.append(self._parse_movie_teaser(item, cache=cache)) - elif item.get('target', {}).get('type') == CONTENT_TYPE_PROGRAM: - items.append(self._parse_program_teaser(item)) + elif item.get('target', {}).get('type') == CONTENT_TYPE_PROGRAM and content_filter in [None, Program]: + items.append(self._parse_program_teaser(item, cache=cache)) - elif item.get('target', {}).get('type') == CONTENT_TYPE_EPISODE: - items.append(self._parse_episode_teaser(item)) + elif item.get('target', {}).get('type') == CONTENT_TYPE_EPISODE and content_filter in [None, Episode]: + items.append(self._parse_episode_teaser(item, cache=cache)) return items @@ -111,12 +109,14 @@ def add_mylist(self, video_type, content_id): util.http_put(API_ENDPOINT + '/%s/userData/myList/%s/%s' % (self._mode(), video_type, content_id), token=self._tokens.jwt_token, profile=self._tokens.profile) + kodiutils.set_cache(['swimlane', 'my-list'], None) def del_mylist(self, video_type, content_id): """ Delete an item from My List. """ util.http_delete(API_ENDPOINT + '/%s/userData/myList/%s/%s' % (self._mode(), video_type, content_id), token=self._tokens.jwt_token, profile=self._tokens.profile) + kodiutils.set_cache(['swimlane', 'my-list'], None) def get_categories(self): """ Get a list of all the categories. @@ -137,11 +137,13 @@ def get_categories(self): return categories - def get_items(self, category=None): + def get_items(self, category=None, content_filter=None, cache=CACHE_ONLY): """ Get a list of all the items in a category. :type category: str - :rtype list[Union[Movie, Program]] + :type content_filter: class + :type cache: int + :rtype list[resources.lib.streamz.Movie | resources.lib.streamz.Program] """ # Fetch from API response = util.http_get(API_ENDPOINT + '/%s/catalog' % self._mode(), @@ -153,11 +155,11 @@ def get_items(self, category=None): items = [] for item in content: - if item.get('target', {}).get('type') == CONTENT_TYPE_MOVIE: - items.append(self._parse_movie_teaser(item)) + if item.get('target', {}).get('type') == CONTENT_TYPE_MOVIE and content_filter in [None, Movie]: + items.append(self._parse_movie_teaser(item, cache=cache)) - elif item.get('target', {}).get('type') == CONTENT_TYPE_PROGRAM: - items.append(self._parse_program_teaser(item)) + elif item.get('target', {}).get('type') == CONTENT_TYPE_PROGRAM and content_filter in [None, Program]: + items.append(self._parse_program_teaser(item, cache=cache)) return items @@ -198,6 +200,7 @@ def get_movie(self, movie_id, cache=CACHE_AUTO): legal=movie.get('legalIcons'), # aired=movie.get('broadcastTimestamp'), channel=self._parse_channel(movie.get('channelLogoUrl')), + # my_list=program.get('addedToMyList'), # Don't use addedToMyList, since we might have cached this info ) def get_program(self, program_id, cache=CACHE_AUTO): @@ -226,6 +229,10 @@ def get_program(self, program_id, cache=CACHE_AUTO): channel = self._parse_channel(program.get('channelLogoUrl')) + # Calculate a hash value of the ids of all episodes + program_hash = hashlib.md5() + program_hash.update(program.get('id')) + seasons = {} for item_season in program.get('seasons', []): episodes = {} @@ -249,6 +256,7 @@ def get_program(self, program_id, cache=CACHE_AUTO): progress=item_episode.get('playerPositionSeconds', 0), watched=item_episode.get('doneWatching', False), ) + program_hash.update(item_episode.get('id')) seasons[item_season.get('index')] = Season( number=item_season.get('index'), @@ -270,6 +278,8 @@ def get_program(self, program_id, cache=CACHE_AUTO): seasons=seasons, channel=channel, legal=program.get('legalIcons'), + content_hash=program_hash.hexdigest().upper(), + # my_list=program.get('addedToMyList'), # Don't use addedToMyList, since we might have cached this info ) @staticmethod @@ -341,6 +351,45 @@ def get_episode(self, episode_id): next_episode=next_episode, ) + def get_mylist_ids(self): + """ Returns the IDs of the contents of My List """ + # Try to fetch from cache + items = kodiutils.get_cache(['mylist_id'], 300) # 5 minutes ttl + if items: + return items + + # Fetch from API + response = util.http_get(API_ENDPOINT + '/%s/main/swimlane/%s' % (self._mode(), 'my-list'), + token=self._tokens.jwt_token, + profile=self._tokens.profile) + + # Result can be empty + result = json.loads(response.text) if response.text else [] + + items = [item.get('target', {}).get('id') for item in result.get('teasers', [])] + + kodiutils.set_cache(['mylist_id'], items) + return items + + def get_catalog_ids(self): + """ Returns the IDs of the contents of the Catalog """ + # Try to fetch from cache + items = kodiutils.get_cache(['catalog_id'], 300) # 5 minutes ttl + if items: + return items + + # Fetch from API + response = util.http_get(API_ENDPOINT + '/%s/catalog' % self._mode(), + params={'pageSize': 2000, 'filter': None}, + token=self._tokens.jwt_token, + profile=self._tokens.profile) + info = json.loads(response.text) + + items = [item.get('target', {}).get('id') for item in info.get('pagedTeasers', {}).get('content', [])] + + kodiutils.set_cache(['catalog_id'], items) + return items + def do_search(self, search): """ Do a search in the full catalog. :type search: str @@ -362,12 +411,13 @@ def do_search(self, search): items.append(self._parse_program_teaser(item)) return items - def _parse_movie_teaser(self, item): + def _parse_movie_teaser(self, item, cache=CACHE_ONLY): """ Parse the movie json and return an Movie instance. :type item: dict + :type cache: int :rtype Movie """ - movie = self.get_movie(item.get('target', {}).get('id'), cache=CACHE_ONLY) + movie = self.get_movie(item.get('target', {}).get('id'), cache=cache) if movie: # We have a cover from the overview that we don't have in the details movie.cover = item.get('imageUrl') @@ -381,12 +431,13 @@ def _parse_movie_teaser(self, item): geoblocked=item.get('geoBlocked'), ) - def _parse_program_teaser(self, item): + def _parse_program_teaser(self, item, cache=CACHE_ONLY): """ Parse the program json and return an Program instance. :type item: dict + :type cache: int :rtype Program """ - program = self.get_program(item.get('target', {}).get('id'), cache=CACHE_ONLY) + program = self.get_program(item.get('target', {}).get('id'), cache=cache) if program: # We have a cover from the overview that we don't have in the details program.cover = item.get('imageUrl') @@ -400,12 +451,13 @@ def _parse_program_teaser(self, item): geoblocked=item.get('geoBlocked'), ) - def _parse_episode_teaser(self, item): + def _parse_episode_teaser(self, item, cache=CACHE_ONLY): """ Parse the episode json and return an Episode instance. :type item: dict + :type cache: int :rtype Episode """ - program = self.get_program(item.get('target', {}).get('programId'), cache=CACHE_ONLY) + program = self.get_program(item.get('target', {}).get('programId'), cache=cache) episode = self.get_episode_from_program(program, item.get('target', {}).get('id')) if program else None return Episode( diff --git a/resources/settings.xml b/resources/settings.xml index e33b4d4..48282cc 100644 --- a/resources/settings.xml +++ b/resources/settings.xml @@ -27,6 +27,15 @@ + + + + + + + + + diff --git a/tests/test_api.py b/tests/test_api.py index 33563ad..35c0203 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -56,6 +56,14 @@ def test_search(self): results = self.api.do_search('huis') self.assertIsInstance(results, list) + def test_mylist_ids(self): + mylist = self.api.get_mylist_ids() + self.assertIsInstance(mylist, list) + + def test_catalog_ids(self): + mylist = self.api.get_catalog_ids() + self.assertIsInstance(mylist, list) + def test_errors(self): with self.assertRaises(UnavailableException): self.api.get_movie('0')