diff --git a/resources/lib/addon.py b/resources/lib/addon.py index 949cadb..089bc8e 100644 --- a/resources/lib/addon.py +++ b/resources/lib/addon.py @@ -80,6 +80,11 @@ def show_tvguide_detail(channel=None, date=None): from resources.lib.modules.tvguide import TvGuide TvGuide().show_tvguide_detail(channel, date) +@routing.route('/catalog/detail/') +def show_detail(item): + """ Show a program from the catalog """ + from resources.lib.modules.catalog import Catalog + Catalog().show_detail(item) @routing.route('/catalog/program/') def show_catalog_program(program): diff --git a/resources/lib/modules/catalog.py b/resources/lib/modules/catalog.py index 8861ba0..f1c4776 100644 --- a/resources/lib/modules/catalog.py +++ b/resources/lib/modules/catalog.py @@ -8,7 +8,7 @@ from resources.lib import kodiutils from resources.lib.modules import CHANNELS from resources.lib.modules.menu import Menu -from resources.lib.vtmgo import STOREFRONT_MAIN, STOREFRONT_MOVIES, STOREFRONT_SHORTIES, Category +from resources.lib.vtmgo import STOREFRONT_MAIN, STOREFRONT_MOVIES, STOREFRONT_SHORTIES, Category, Program, Movie from resources.lib.vtmgo.exceptions import UnavailableException from resources.lib.vtmgo.vtmgo import CACHE_PREVENT, ApiUpdateRequired, VtmGo from resources.lib.vtmgo.vtmgoauth import VtmGoAuth @@ -24,6 +24,84 @@ def __init__(self): auth = VtmGoAuth(kodiutils.get_tokens_path()) self._api = VtmGo(auth.get_tokens()) + def show_detail(self, detail): + """ Show a detail from the catalog + :type detail: str + """ + try: + detail_obj = self._api.get_detail(detail, cache=CACHE_PREVENT) # Use CACHE_PREVENT since we want fresh data + except UnavailableException: + kodiutils.ok_dialog(message=kodiutils.localize(30717)) # This program is not available in the VTM GO catalogue. + kodiutils.end_of_directory() + return + + if isinstance(detail_obj, Program): + program_obj = detail_obj + program = detail + + # Go directly to the season when we have only one season + if len(program_obj.seasons) == 1: + self.show_program_season(program, list(program_obj.seasons.values())[0].number) + return + + studio = CHANNELS.get(program_obj.channel, {}).get('studio_icon') + + listing = [] + + # Add an '* All seasons' entry when configured in Kodi + if kodiutils.get_global_setting('videolibrary.showallitems') is True: + listing.append(kodiutils.TitleItem( + title='* %s' % kodiutils.localize(30204), # * All seasons + path=kodiutils.url_for('show_catalog_program_season', program=program, season=-1), + art_dict=dict( + poster=program_obj.poster, + thumb=program_obj.thumb, + landscape=program_obj.thumb, + fanart=program_obj.fanart, + ), + info_dict=dict( + mediatype='season', + tvshowtitle=program_obj.name, + title=kodiutils.localize(30204), # All seasons + tagline=program_obj.description, + set=program_obj.name, + studio=studio, + mpaa=', '.join(program_obj.legal) if hasattr(program_obj, 'legal') and program_obj.legal else kodiutils.localize(30216), # All ages + ), + )) + + # Add the seasons + for season in list(program_obj.seasons.values()): + listing.append(kodiutils.TitleItem( + title=kodiutils.localize(30205, season=season.number), # Season {season} + path=kodiutils.url_for('show_catalog_program_season', program=program, season=season.number), + art_dict=dict( + poster=program_obj.poster, + thumb=program_obj.thumb, + landscape=program_obj.thumb, + fanart=program_obj.fanart, + ), + info_dict=dict( + mediatype='season', + tvshowtitle=program_obj.name, + title=kodiutils.localize(30205, season=season.number), # Season {season} + tagline=program_obj.description, + set=program_obj.name, + studio=studio, + mpaa=', '.join(program_obj.legal) if hasattr(program_obj, 'legal') and program_obj.legal else kodiutils.localize(30216), # All ages + ), + )) + + # Sort by label. Some programs return seasons unordered. + kodiutils.show_listing(listing, program_obj.name, content='tvshows', sort=['label']) + return + + if isinstance(detail_obj, Movie): + listing = [] + listing.append(Menu.generate_titleitem(detail_obj)) + kodiutils.show_listing(listing, 30017, content='files', sort=['unsorted', 'label', 'year', 'duration']) + return + def show_program(self, program): """ Show a program from the catalog :type program: str diff --git a/resources/lib/modules/menu.py b/resources/lib/modules/menu.py index b85900e..a4f5199 100644 --- a/resources/lib/modules/menu.py +++ b/resources/lib/modules/menu.py @@ -7,7 +7,7 @@ from resources.lib import kodiutils from resources.lib.modules import CHANNELS -from resources.lib.vtmgo import STOREFRONT_KIDS, STOREFRONT_MAIN, STOREFRONT_MOVIES, STOREFRONT_SHORTIES, Episode, Movie, Program +from resources.lib.vtmgo import STOREFRONT_KIDS, STOREFRONT_MAIN, STOREFRONT_MOVIES, STOREFRONT_SHORTIES, Episode, Movie, Program, Teaser _LOGGER = logging.getLogger(__name__) @@ -146,7 +146,7 @@ def format_plot(obj): plot += '\n' # Add remaining - if hasattr(obj, 'remaining') and obj.remaining is not None: + if hasattr(obj, 'remaining') and obj.remaining: if obj.remaining == 0: plot += 'ยป ' + kodiutils.localize(30208) + "\n" # Available until midnight elif obj.remaining == 1: @@ -166,7 +166,7 @@ def format_plot(obj): plot += kodiutils.localize(30207) # Geo-blocked plot += '\n' - if hasattr(obj, 'description'): + if hasattr(obj, 'description') and obj.description: plot += obj.description plot += '\n\n' @@ -188,8 +188,8 @@ def generate_titleitem(cls, item, progress=False): info_dict = { 'title': item.name, 'plot': cls.format_plot(item), - 'studio': CHANNELS.get(item.channel, {}).get('studio_icon'), - 'mpaa': ', '.join(item.legal) if hasattr(item, 'legal') and item.legal else kodiutils.localize(30216), # All ages + # 'studio': CHANNELS.get(item.channel, {}).get('studio_icon'), + # 'mpaa': ', '.join(item.legal) if hasattr(item, 'legal') and item.legal else kodiutils.localize(30216), # All ages } prop_dict = {} @@ -317,4 +317,37 @@ def generate_titleitem(cls, item, progress=False): is_playable=True, ) + # + # Teaser + # + if isinstance(item, Teaser): + # if item.my_list: + # context_menu = [( + # kodiutils.localize(30101), # Remove from My List + # 'Container.Update(%s)' % + # kodiutils.url_for('mylist_del', content_id=item.program_id) + # )] + # else: + # context_menu = [( + # kodiutils.localize(30100), # Add to My List + # 'Container.Update(%s)' % + # kodiutils.url_for('mylist_add', content_id=item.program_id) + # )] + + # info_dict.update({ + # 'mediatype': 'tvshow', + # 'year': item.year, + # 'season': len(item.seasons), + # }) + + return kodiutils.TitleItem( + title=info_dict['title'], + path=kodiutils.url_for('show_detail', item=item.detail_id), + art_dict=art_dict, + info_dict=info_dict, + prop_dict=prop_dict, + # context_menu=context_menu, + ) + + raise Exception('Unknown video_type') diff --git a/resources/lib/vtmgo/__init__.py b/resources/lib/vtmgo/__init__.py index afe7afa..41d4d27 100644 --- a/resources/lib/vtmgo/__init__.py +++ b/resources/lib/vtmgo/__init__.py @@ -96,6 +96,28 @@ def __init__(self, category_id=None, title=None, content=None): def __repr__(self): return "%r" % self.__dict__ +class Teaser: + """ Defines a Teaser """ + + def __init__(self, detail_id=None, name=None, description=None, poster=None, thumb=None, fanart=None): + """ + :type detail_id: str + :type name: str + :type description: str + :type poster: str + :type thumb: str + :type fanart: str + """ + self.detail_id = detail_id + self.name = name + self.description = description + self.poster = poster + self.thumb = thumb + self.fanart = fanart + + def __repr__(self): + return "%r" % self.__dict__ + class Movie: """ Defines a Movie """ diff --git a/resources/lib/vtmgo/util.py b/resources/lib/vtmgo/util.py index 841238d..297d7d7 100644 --- a/resources/lib/vtmgo/util.py +++ b/resources/lib/vtmgo/util.py @@ -17,8 +17,8 @@ # Setup a static session that can be reused for all calls SESSION = requests.Session() SESSION.headers = { - 'User-Agent': 'VTM_GO/15.231023 (be.vmma.vtm.zenderapp; build:18041; Android 23) okhttp/4.11.0', - 'x-app-version': '15', + 'User-Agent': 'VTM_GO/17.240626 (be.vmma.vtm.zenderapp; build:19069; Android 28) okhttp/4.12.0', + 'x-app-version': '17', 'x-persgroep-mobile-app': 'true', 'x-persgroep-os': 'android', 'x-persgroep-os-version': '28', diff --git a/resources/lib/vtmgo/vtmgo.py b/resources/lib/vtmgo/vtmgo.py index d4b456e..e97b42e 100644 --- a/resources/lib/vtmgo/vtmgo.py +++ b/resources/lib/vtmgo/vtmgo.py @@ -7,7 +7,7 @@ import logging from resources.lib import kodiutils -from resources.lib.vtmgo import API_ANDROID_ENDPOINT, API_ENDPOINT, Category, Episode, LiveChannel, LiveChannelEpg, Movie, Program, Season, util +from resources.lib.vtmgo import API_ANDROID_ENDPOINT, API_ENDPOINT, Category, Episode, LiveChannel, LiveChannelEpg, Movie, Program, Season, util, Teaser _LOGGER = logging.getLogger(__name__) @@ -74,20 +74,22 @@ def get_storefront(self, storefront): if row.get('rowType') == 'CAROUSEL': for item in row.get('teasers'): - if item.get('target', {}).get('type') == CONTENT_TYPE_MOVIE: - items.append(self._parse_movie_teaser(item)) - - elif item.get('target', {}).get('type') == CONTENT_TYPE_PROGRAM: - items.append(self._parse_program_teaser(item)) + items.append(self._parse_teaser(item)) + # if item.get('target', {}).get('type') == CONTENT_TYPE_MOVIE: + # items.append(self._parse_movie_teaser(item)) + # + # elif item.get('target', {}).get('type') == CONTENT_TYPE_PROGRAM: + # items.append(self._parse_program_teaser(item)) continue if row.get('rowType') in ['TOP_BANNER', 'MARKETING_BLOCK']: item = row.get('teaser') - if item.get('target', {}).get('type') == CONTENT_TYPE_MOVIE: - items.append(self._parse_movie_teaser(item)) - - elif item.get('target', {}).get('type') == CONTENT_TYPE_PROGRAM: - items.append(self._parse_program_teaser(item)) + items.append(self._parse_detail_teaser(item)) + # if item.get('target', {}).get('type') == CONTENT_TYPE_MOVIE: + # items.append(self._parse_movie_teaser(item)) + # + # elif item.get('target', {}).get('type') == CONTENT_TYPE_PROGRAM: + # items.append(self._parse_program_teaser(item)) continue _LOGGER.debug('Skipping recommendation %s with type %s', row.get('title'), row.get('rowType')) @@ -108,11 +110,12 @@ def get_storefront_category(self, storefront, category): items = [] for item in result.get('row', {}).get('teasers'): - if item.get('target', {}).get('type') == CONTENT_TYPE_MOVIE: - items.append(self._parse_movie_teaser(item)) - - elif item.get('target', {}).get('type') == CONTENT_TYPE_PROGRAM: - items.append(self._parse_program_teaser(item)) + items.append(self._parse_teaser(item)) + # if item.get('target', {}).get('type') == CONTENT_TYPE_MOVIE: + # items.append(self._parse_movie_teaser(item)) + # + # elif item.get('target', {}).get('type') == CONTENT_TYPE_PROGRAM: + # items.append(self._parse_program_teaser(item)) return Category(category_id=category, title=result.get('row', {}).get('title'), content=items) @@ -130,14 +133,15 @@ def get_mylist(self, content_filter=None, cache=CACHE_ONLY): items = [] for item in result.get('teasers', []): - 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 and content_filter in [None, Program]: - items.append(self._parse_program_teaser(item, cache=cache)) - - elif item.get('target', {}).get('type') == CONTENT_TYPE_EPISODE and content_filter in [None, Episode]: - items.append(self._parse_episode_teaser(item, cache=cache)) + items.append(self._parse_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 and content_filter in [None, Program]: + # items.append(self._parse_program_teaser(item, cache=cache)) + # + # 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 @@ -192,6 +196,106 @@ def get_live_channel(self, key): channels = self.get_live_channels() return next(c for c in channels if c.key == key) + def get_detail(self, detail_id, cache=CACHE_AUTO): + """ Get the details of the specified program. + :type detail_id: str + :type cache: int + :rtype Program + """ + if cache in [CACHE_AUTO, CACHE_ONLY]: + # Try to fetch from cache + detail = kodiutils.get_cache(['detail', detail_id]) + if detail is None and cache == CACHE_ONLY: + return None + else: + detail = None + + if not detail: + # Fetch from API + response = util.http_get(API_ENDPOINT + '/%s/detail/%s' % (self._mode(), detail_id), + token=self._tokens.access_token if self._tokens else None, + profile=self._tokens.profile if self._tokens else None) + detail = json.loads(response.text) + kodiutils.set_cache(['detail', detail_id], detail) + + # channel = self._parse_channel(detail.get('channelLogoUrl')) + + if detail.get('selectedSeason') is None: + # Movie + movie = detail + + return Movie( + movie_id=movie.get('id'), + name=movie.get('name'), + description=movie.get('description'), + duration=movie.get('durationSeconds'), + thumb=movie.get('landscapeTeaserImageUrl'), + # portraitthumb=movie.get('portraitTeaserImageUrl'), + fanart=movie.get('backgroundImageUrl'), + # year=movie.get('productionYear'), + # geoblocked=movie.get('blockedFor') == 'GEO', + # remaining=movie.get('remainingDaysAvailable'), + # legal=movie.get('legalIcons'), + # aired=movie.get('broadcastTimestamp'), + # channel=self._parse_channel(movie.get('channelLogoUrl')), + ) + + else: + # Program + program = detail + + seasons = {} + for item_season in detail.get('seasonIndices', []): + episodes = [] + + # Fetch season + season_response = util.http_get(API_ENDPOINT + '/%s/detail/%s?selectedSeasonIndex=%s' % (self._mode(), detail_id, item_season), + token=self._tokens.access_token if self._tokens else None, + profile=self._tokens.profile if self._tokens else None) + season = json.loads(season_response.text).get('selectedSeason') + + for item_episode in season.get('episodes', []): + episodes.append(Episode( + episode_id=item_episode.get('id'), + program_id=detail_id, + program_name=program.get('name'), + number=item_episode.get('index'), + season=item_season, + name=item_episode.get('name'), + description=item_episode.get('description'), + duration=item_episode.get('durationSeconds'), + thumb=item_episode.get('imageUrl'), + fanart=item_episode.get('imageUrl'), + # geoblocked=program.get('blockedFor') == 'GEO', + # remaining=item_episode.get('remainingDaysAvailable'), + # channel=channel, + # legal=program.get('legalIcons'), + aired=item_episode.get('broadcastTimestamp'), + progress=item_episode.get('playerPositionSeconds', 0), + watched=item_episode.get('doneWatching', False), + )) + + seasons[item_season] = Season( + number=item_season, + episodes=episodes, + # channel=channel, + legal=program.get('legalIcons'), + ) + + return Program( + program_id=program.get('id'), + name=program.get('name'), + description=program.get('description'), + year=program.get('productionYear'), + thumb=program.get('landscapeTeaserImageUrl'), + fanart=program.get('backgroundImageUrl'), + geoblocked=program.get('blockedFor') == 'GEO', + seasons=seasons, + # channel=channel, + legal=program.get('legalIcons'), + # my_list=program.get('addedToMyList'), # Don't use addedToMyList, since we might have cached this info + ) + def get_movie(self, movie_id, cache=CACHE_AUTO): """ Get the details of the specified movie. :type movie_id: str @@ -389,11 +493,12 @@ def do_search(self, search): items = [] for category in results.get('results', []): for item in category.get('teasers'): - if item.get('target', {}).get('type') == CONTENT_TYPE_MOVIE: - items.append(self._parse_movie_teaser(item)) - - elif item.get('target', {}).get('type') == CONTENT_TYPE_PROGRAM: - items.append(self._parse_program_teaser(item)) + items.append(self._parse_teaser(item)) + # if item.get('target', {}).get('type') == CONTENT_TYPE_MOVIE: + # items.append(self._parse_movie_teaser(item)) + # + # elif item.get('target', {}).get('type') == CONTENT_TYPE_PROGRAM: + # items.append(self._parse_program_teaser(item)) return items @staticmethod @@ -405,40 +510,72 @@ def get_product(): except (IndexError, AttributeError): return None - def _parse_movie_teaser(self, item, cache=CACHE_ONLY): - """ Parse the movie json and return an Movie instance. + def _parse_teaser(self, item, cache=CACHE_ONLY): + """ Parse the teaser json and return a Teaser instance. :type item: dict :type cache: int :rtype Movie """ - movie = self.get_movie(item.get('target', {}).get('id'), cache=cache) - if movie: - return movie + # movie = self.get_movie(item.get('target', {}).get('id'), cache=cache) + # if movie: + # return movie - return Movie( - movie_id=item.get('target', {}).get('id'), + return Teaser( + detail_id=item.get('detailId'), name=item.get('title'), - thumb=item.get('imageUrl'), - geoblocked=item.get('blockedFor') == 'GEO', + thumb=item.get('imageUrl') ) - def _parse_program_teaser(self, item, cache=CACHE_ONLY): - """ Parse the program json and return an Program instance. + def _parse_detail_teaser(self, item, cache=CACHE_ONLY): + """ Parse the teaser json and return a Teaser instance. :type item: dict :type cache: int - :rtype Program + :rtype Movie """ - program = self.get_program(item.get('target', {}).get('id'), cache=cache) - if program: - return program + # movie = self.get_movie(item.get('target', {}).get('id'), cache=cache) + # if movie: + # return movie - return Program( - program_id=item.get('target', {}).get('id'), + return Teaser( + detail_id=item.get('target', {}).get('id'), name=item.get('title'), - thumb=item.get('largeImageUrl'), - geoblocked=item.get('blockedFor') == 'GEO', + thumb=item.get('imageUrl') ) + # 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) + # if movie: + # return movie + # + # return Movie( + # movie_id=item.get('target', {}).get('id'), + # name=item.get('title'), + # thumb=item.get('imageUrl'), + # geoblocked=item.get('blockedFor') == 'GEO', + # ) + + # 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) + # if program: + # return program + # + # return Program( + # program_id=item.get('target', {}).get('id'), + # name=item.get('title'), + # thumb=item.get('largeImageUrl'), + # geoblocked=item.get('blockedFor') == 'GEO', + # ) + def _parse_episode_teaser(self, item, cache=CACHE_ONLY): """ Parse the episode json and return an Episode instance. :type item: dict diff --git a/resources/lib/vtmgo/vtmgostream.py b/resources/lib/vtmgo/vtmgostream.py index 7ac1cb7..7c811ce 100644 --- a/resources/lib/vtmgo/vtmgostream.py +++ b/resources/lib/vtmgo/vtmgostream.py @@ -157,7 +157,7 @@ def _get_video_info(self, strtype, stream_id, player_token): :param str player_token: :rtype: dict """ - url = 'https://videoplayer-service.dpgmedia.net/config/%s/%s' % (strtype, stream_id) + url = 'https://videoplayer-service.dpgmedia.net/play-config/%s' % stream_id _LOGGER.debug('Getting video info from %s', url) response = util.http_post(url, params={ @@ -171,7 +171,7 @@ def _get_video_info(self, strtype, stream_id, player_token): headers={ 'Accept': 'application/json', 'x-api-key': self._V6_API_KEY, - 'Popcorn-SDK-Version': '6', + 'Popcorn-SDK-Version': '7', 'Authorization': 'Bearer ' + player_token, })