diff --git a/resources/lib/addon.py b/resources/lib/addon.py index b51cfb2..7d8529d 100644 --- a/resources/lib/addon.py +++ b/resources/lib/addon.py @@ -63,6 +63,13 @@ def auth_clear_cache(): Authentication().clear_cache() +@routing.route('/catalog/detail/') +def show_catalog_detail(detail): + """ Show a detail page that can be a Movie or a Program """ + from resources.lib.modules.catalog import Catalog + Catalog().show_detail(detail) + + @routing.route('/catalog/program/') def show_catalog_program(program): """ Show a program from the catalog """ diff --git a/resources/lib/modules/catalog.py b/resources/lib/modules/catalog.py index 8e3eeac..68b795f 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.kodiutils import TitleItem from resources.lib.modules.menu import Menu -from resources.lib.streamz import STOREFRONT_MAIN, STOREFRONT_MOVIES, STOREFRONT_PAGE_CONTINUE_WATCHING, STOREFRONT_SERIES, Category +from resources.lib.streamz import STOREFRONT_MAIN, STOREFRONT_MOVIES, STOREFRONT_SERIES, Category, Movie from resources.lib.streamz.api import CACHE_PREVENT, Api from resources.lib.streamz.auth import Auth from resources.lib.streamz.exceptions import UnavailableException @@ -24,13 +24,33 @@ def __init__(self): auth = Auth(kodiutils.get_tokens_path()) self._api = Api(auth.get_tokens()) - def show_program(self, program): + def show_detail(self, detail): + """ Show a detail page. A Program shows its seasons, a Movie shows a single playable item. + + :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(30712)) # The video is unavailable and can't be played right now. + kodiutils.end_of_directory() + return + + if isinstance(detail_obj, Movie): + kodiutils.show_listing([Menu.generate_titleitem(detail_obj)], detail_obj.name, content='movies') + return + + self.show_program(detail, program_obj=detail_obj) + + def show_program(self, program, program_obj=None): """ Show a program from the catalog. :type program: str + :type program_obj: Program """ try: - program_obj = self._api.get_program(program, cache=CACHE_PREVENT) # Use CACHE_PREVENT since we want fresh data + if program_obj is None: + program_obj = self._api.get_program(program, cache=CACHE_PREVENT) # Use CACHE_PREVENT since we want fresh data except UnavailableException: kodiutils.ok_dialog(message=kodiutils.localize(30712)) # The video is unavailable and can't be played right now. kodiutils.end_of_directory() @@ -201,7 +221,11 @@ def mylist_del(self, content_id): def show_continuewatching(self): """ Show the items in "Continue Watching". """ - category = self._api.get_storefront_category(STOREFRONT_MAIN, STOREFRONT_PAGE_CONTINUE_WATCHING) + category_id = self._api.get_continue_watching_id() + if not category_id: + kodiutils.show_listing([], 30019, content='episodes') + return + category = self._api.get_storefront_category(STOREFRONT_MAIN, category_id) listing = [] for item in category.content: diff --git a/resources/lib/modules/menu.py b/resources/lib/modules/menu.py index c526fbd..6861457 100644 --- a/resources/lib/modules/menu.py +++ b/resources/lib/modules/menu.py @@ -8,7 +8,7 @@ from resources.lib import kodiutils from resources.lib.kodiutils import TitleItem from resources.lib.streamz import (PRODUCT_STREAMZ, PRODUCT_STREAMZ_KIDS, STOREFRONT_KIDS, STOREFRONT_MAIN, STOREFRONT_MAIN_KIDS, STOREFRONT_MOVIES, - STOREFRONT_SERIES, Episode, Movie, Program) + STOREFRONT_SERIES, Episode, Movie, Program, Teaser) from resources.lib.streamz.auth import Auth _LOGGER = logging.getLogger(__name__) @@ -240,6 +240,32 @@ def generate_titleitem(cls, item, progress=False): is_playable=True, ) + # + # Teaser (a Movie or a Program, we only know once we open it) + # + 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.detail_id) + )] + else: + context_menu = [( + kodiutils.localize(30100), # Add to My List + 'Container.Update(%s)' % + kodiutils.url_for('mylist_add', content_id=item.detail_id) + )] + + return TitleItem( + title=item.name, + path=kodiutils.url_for('show_catalog_detail', detail=item.detail_id), + art_dict=art_dict, + info_dict=info_dict, + prop_dict=prop_dict, + context_menu=context_menu, + ) + # # Program # diff --git a/resources/lib/modules/player.py b/resources/lib/modules/player.py index be6e385..87f5d46 100644 --- a/resources/lib/modules/player.py +++ b/resources/lib/modules/player.py @@ -36,8 +36,15 @@ def play(self, category, item): return try: + # Movies are opened by their detail id, but are played by their playable id + stream_id = item + if category in ['movies', 'oneoffs']: + movie = self._api.get_movie(item) + if movie and movie.playable_id: + stream_id = movie.playable_id + # Get stream information - resolved_stream = self._stream.get_stream(category, item) + resolved_stream = self._stream.get_stream(category, stream_id) except UnavailableException: kodiutils.ok_dialog(message=kodiutils.localize(30712)) # The video is unavailable... diff --git a/resources/lib/streamz/__init__.py b/resources/lib/streamz/__init__.py index c2532a5..291000f 100644 --- a/resources/lib/streamz/__init__.py +++ b/resources/lib/streamz/__init__.py @@ -8,12 +8,11 @@ # These seem to be hardcoded STOREFRONT_MAIN = 'main' -STOREFRONT_MOVIES = 'movies' +STOREFRONT_MOVIES = 'films' STOREFRONT_SERIES = 'series' STOREFRONT_KIDS = 'kids' STOREFRONT_MAIN_KIDS = 'main' -STOREFRONT_PAGE_CONTINUE_WATCHING = '7574469c-5d78-4878-84f5-c5729442eee4' PRODUCT_STREAMZ = 'STREAMZ' PRODUCT_STREAMZ_KIDS = 'STREAMZ_KIDS' @@ -66,7 +65,7 @@ class Movie: """ Defines a Movie """ def __init__(self, movie_id=None, name=None, description=None, year=None, poster=None, thumb=None, fanart=None, duration=None, - remaining=None, geoblocked=None, channel=None, legal=None, aired=None, my_list=None, available=True): + remaining=None, geoblocked=None, channel=None, legal=None, aired=None, my_list=None, available=True, playable_id=None): """ :type movie_id: str :type name: str @@ -83,8 +82,10 @@ def __init__(self, movie_id=None, name=None, description=None, year=None, poster :type aired: str :type my_list: bool :type available: bool + :type playable_id: str """ self.movie_id = movie_id + self.playable_id = playable_id self.name = name self.description = description if description else '' self.year = year @@ -104,6 +105,33 @@ def __repr__(self): return "%r" % self.__dict__ +class Teaser: + """ Defines a teaser for a detail page of which we don't know yet if it's a Movie or a Program """ + + def __init__(self, detail_id=None, name=None, description=None, poster=None, thumb=None, fanart=None, my_list=None, available=True): + """ + :type detail_id: str + :type name: str + :type description: str + :type poster: str + :type thumb: str + :type fanart: str + :type my_list: bool + :type available: bool + """ + self.detail_id = detail_id + self.name = name + self.description = description if description else '' + self.poster = poster + self.thumb = thumb + self.fanart = fanart + self.my_list = my_list + self.available = available + + def __repr__(self): + return "%r" % self.__dict__ + + class Program: """ Defines a Program """ diff --git a/resources/lib/streamz/api.py b/resources/lib/streamz/api.py index a417d5e..83f69f0 100644 --- a/resources/lib/streamz/api.py +++ b/resources/lib/streamz/api.py @@ -7,7 +7,7 @@ import logging from resources.lib import kodiutils -from resources.lib.streamz import API_ANDROID_ENDPOINT, API_ENDPOINT, Category, Episode, Movie, Program, Season, util +from resources.lib.streamz import API_ANDROID_ENDPOINT, API_ENDPOINT, STOREFRONT_MAIN, Category, Episode, Movie, Program, Season, Teaser, util _LOGGER = logging.getLogger(__name__) @@ -24,6 +24,8 @@ CONTENT_TYPE_PROGRAM = 'PROGRAM' CONTENT_TYPE_EPISODE = 'EPISODE' +ROW_TYPES_CATEGORY = ['SWIMLANE_DEFAULT', 'SWIMLANE_PORTRAIT', 'SWIMLANE_LANDSCAPE', 'SWIMLANE_TOP10', 'CONTINUE_WATCHING'] + class Api: """ Streamz API """ @@ -51,43 +53,49 @@ def get_storefront(self, storefront): """ Returns a storefront. :param str storefront: The ID of the storefront. - :rtype: list[Category|Program|Movie] + :rtype: list[Category|Teaser] """ - response = util.http_get(API_ENDPOINT + '/%s/storefronts/%s' % (self._mode(), storefront), - token=self._tokens.access_token, - profile=self._tokens.profile) - result = json.loads(response.text) + result = self._get_storefront_json(storefront) items = [] for row in result.get('rows', []): - if row.get('rowType') in ['SWIMLANE_DEFAULT', 'SWIMLANE_PORTRAIT', 'SWIMLANE_LANDSCAPE']: + if row.get('rowType') in ROW_TYPES_CATEGORY: items.append(Category( category_id=row.get('id'), - title=row.get('title'), + title=(row.get('title') or '').strip(), )) continue 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.extend(self._parse_teaser(item) for item in row.get('teasers', [])) 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)) + if row.get('teaser'): + items.append(self._parse_teaser(row.get('teaser'))) continue _LOGGER.debug('Skipping recommendation %s with type %s', row.get('title'), row.get('rowType')) - return items + return [item for item in items if item] + + def get_continue_watching_id(self): + """ Returns the ID of the "Continue watching" row on the main storefront, or None. + + :rtype: str + """ + result = self._get_storefront_json(STOREFRONT_MAIN) + for row in result.get('rows', []): + if row.get('rowType') == 'CONTINUE_WATCHING': + return row.get('id') + return None + + def _get_storefront_json(self, storefront): + """ Fetch the raw json of a storefront. """ + response = util.http_get(API_ENDPOINT + '/%s/storefronts/%s' % (self._mode(), storefront), + token=self._tokens.access_token, + profile=self._tokens.profile) + return json.loads(response.text) def get_storefront_category(self, storefront, category): """ Returns a storefront. @@ -101,18 +109,9 @@ def get_storefront_category(self, storefront, category): profile=self._tokens.profile) result = json.loads(response.text) - 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)) - - elif item.get('target', {}).get('type') == CONTENT_TYPE_EPISODE: - items.append(self._parse_episode_teaser(item)) + items = [self._parse_teaser(item) for item in result.get('row', {}).get('teasers', [])] - return Category(category_id=category, title=result.get('row', {}).get('title'), content=items) + return Category(category_id=category, title=result.get('row', {}).get('title'), content=[item for item in items if item]) def get_mylist(self, content_filter=None, cache=CACHE_ONLY): """ Returns the contents of My List """ @@ -126,18 +125,11 @@ def get_mylist(self, content_filter=None, cache=CACHE_ONLY): result = json.loads(response.text) - 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)) + items = [self._parse_teaser(item, cache=cache) for item in result.get('teasers', [])] + if content_filter: + items = [item for item in items if isinstance(item, content_filter)] - 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 + return [item for item in items if item] def add_mylist(self, content_id): """ Add an item to My List. """ @@ -153,46 +145,38 @@ def del_mylist(self, content_id): profile=self._tokens.profile) kodiutils.set_cache(['swimlane', 'my-list'], None) - def get_movie(self, movie_id, cache=CACHE_AUTO): - """ Get the details of the specified movie. + def get_detail(self, detail_id, cache=CACHE_AUTO): + """ Get the details of the specified detail page. Returns a Movie or a Program, or None + when only the cache may be used and nothing is cached. - :type movie_id: str + :type detail_id: str :type cache: int - :rtype Movie + :rtype Movie|Program """ + detail = None if cache in [CACHE_AUTO, CACHE_ONLY]: - # Try to fetch from cache - movie = kodiutils.get_cache(['movie', movie_id]) - if movie is None and cache == CACHE_ONLY: + detail = kodiutils.get_cache(['detail', detail_id]) + if detail is None and cache == CACHE_ONLY: return None - else: - movie = None - if not movie: - # Fetch from API - response = util.http_get(API_ENDPOINT + '/%s/detail/%s' % (self._mode(), movie_id), - token=self._tokens.access_token, - profile=self._tokens.profile) - movie = json.loads(response.text) - kodiutils.set_cache(['movie', movie_id], movie) + if not detail: + detail = self._get_detail_json(detail_id) + kodiutils.set_cache(['detail', detail_id], detail) - return Movie( - movie_id=movie.get('id'), - name=movie.get('name'), - description=movie.get('description'), - duration=movie.get('durationSeconds'), - poster=movie.get('portraitTeaserImageUrl'), - thumb=movie.get('landscapeTeaserImageUrl'), - 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')), - # my_list=program.get('addedToMyList'), # Don't use addedToMyList, since we might have cached this info - available=movie.get('blockedFor') != 'SUBSCRIPTION', - ) + if detail.get('seasonPicker') is None: + return self._parse_movie_detail(detail) + + return self._parse_program_detail(detail_id, detail) + + def get_movie(self, movie_id, cache=CACHE_AUTO): + """ Get the details of the specified movie. + + :type movie_id: str + :type cache: int + :rtype Movie + """ + detail = self.get_detail(movie_id, cache=cache) + return detail if isinstance(detail, Movie) else None def get_program(self, program_id, cache=CACHE_AUTO): """ Get the details of the specified program. @@ -201,77 +185,96 @@ def get_program(self, program_id, cache=CACHE_AUTO): :type cache: int :rtype Program """ - if cache in [CACHE_AUTO, CACHE_ONLY]: - # Try to fetch from cache - program = kodiutils.get_cache(['program', program_id]) - if program is None and cache == CACHE_ONLY: - return None - else: - program = None + detail = self.get_detail(program_id, cache=cache) + return detail if isinstance(detail, Program) else None - if not program: - # Fetch from API - response = util.http_get(API_ENDPOINT + '/%s/detail/%s' % (self._mode(), program_id), - token=self._tokens.access_token, - profile=self._tokens.profile) - program = json.loads(response.text) - kodiutils.set_cache(['program', program_id], program) + def _get_detail_json(self, detail_id, season_index=None): + """ Fetch the raw json of a detail page, optionally for a specific season. """ + url = API_ENDPOINT + '/%s/detail3/%s' % (self._mode(), detail_id) + if season_index is not None: + url += '?selectedSeasonIndex=%s' % season_index + response = util.http_get(url, token=self._tokens.access_token, profile=self._tokens.profile) + return json.loads(response.text) - channel = self._parse_channel(program.get('channelLogoUrl')) + @staticmethod + def _parse_year(detail): + """ Return the production year from the header labels, if there is one. """ + for label in detail.get('headerLabels') or []: + text = (label.get('label') or '').strip() + if len(text) == 4 and text.isdigit(): + return int(text) + return None - seasons = {} - for item_season in program.get('seasonIndices', []): - episodes = {} + def _parse_movie_detail(self, movie): + """ Parse a detail page of a movie. """ + return Movie( + movie_id=movie.get('id'), + playable_id=(movie.get('primaryButton') or {}).get('playableId'), + name=(movie.get('title') or {}).get('label'), + description=movie.get('description'), + duration=movie.get('durationSeconds'), + thumb=movie.get('landscapeTeaserImageUrl'), + fanart=movie.get('backgroundImageUrl'), + year=self._parse_year(movie), + my_list=movie.get('addedToMyList'), + available=bool((movie.get('primaryButton') or {}).get('playableId')), + ) + + def _parse_program_detail(self, program_id, program): + """ Parse a detail page of a program, fetching every season. """ + name = (program.get('title') or {}).get('label') + picker = program.get('seasonPicker') or {} + selected = picker.get('selected') or {} - # Fetch season - season_response = util.http_get(API_ENDPOINT + '/%s/detail/%s?selectedSeasonIndex=%s' % (self._mode(), program_id, item_season), - token=self._tokens.access_token, - profile=self._tokens.profile) - season = json.loads(season_response.text).get('selectedSeason') + seasons = {} + for season_index in picker.get('indices', []): + if season_index == selected.get('index'): + season = selected + else: + season = (self._get_detail_json(program_id, season_index).get('seasonPicker') or {}).get('selected') or {} + episodes = {} for item_episode in season.get('episodes', []): + progress = item_episode.get('userProgressPercentage') or 0 + duration = item_episode.get('durationSeconds') or 0 episodes[item_episode.get('index')] = Episode( episode_id=item_episode.get('id'), program_id=program_id, - program_name=program.get('name'), + program_name=name, number=item_episode.get('index'), - season=item_season, - name=item_episode.get('name'), + season=season_index, + name=self._strip_episode_number(item_episode.get('title')), description=item_episode.get('description'), - duration=item_episode.get('durationSeconds'), + duration=duration, 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), - available=item_episode.get('blockedFor') != 'SUBSCRIPTION', + progress=int(duration * progress / 100) if progress < 100 else 0, + watched=progress >= 100, + available=not item_episode.get('blockingOverlayId'), ) - seasons[item_season] = Season( - number=item_season, + seasons[season_index] = Season( + number=season_index, episodes=episodes, - channel=channel, - legal=program.get('legalIcons'), ) return Program( program_id=program.get('id'), - name=program.get('name'), + name=name, description=program.get('description'), 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 - available=program.get('blockedFor') != 'SUBSCRIPTION', + my_list=program.get('addedToMyList'), ) + @staticmethod + def _strip_episode_number(title): + """ Episode titles look like "1. Title", remove the number since Kodi shows it already. """ + if title and '. ' in title and title.split('. ', 1)[0].isdigit(): + return title.split('. ', 1)[1] + return title + @staticmethod def get_episode_from_program(program, episode_id): """ Extract the specified episode from the program data. @@ -344,7 +347,7 @@ def get_episode(self, episode_id): def do_search(self, search): """ Do a search in the full catalog. :type search: str - :rtype list[Union[Movie, Program]] + :rtype list[Teaser] """ response = util.http_get(API_ENDPOINT + '/%s/search?query=%s' % (self._mode(), kodiutils.to_unicode(quote(kodiutils.from_unicode(search)))), @@ -354,72 +357,51 @@ 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)) - return items - - def _parse_movie_teaser(self, item, cache=CACHE_ONLY): - """ Parse the movie json and return a Movie instance. - :type item: dict - :type cache: int - :rtype Movie - """ - movie = self.get_movie(item.get('target', {}).get('id'), cache=cache) - if movie: - movie.available = item.get('blockedFor') != 'SUBSCRIPTION' - return movie + items.extend(self._parse_teaser(item) for item in category.get('teasers', [])) + return [item for item in items if item] - return Movie( - movie_id=item.get('target', {}).get('id'), - name=item.get('title'), - thumb=item.get('imageUrl'), - geoblocked=item.get('blockedFor') == 'GEO', - available=item.get('blockedFor') != 'SUBSCRIPTION', - ) + def _parse_teaser(self, item, cache=CACHE_ONLY): + """ Parse a teaser. When the detail page is cached we know if it is a Movie or a Program, + otherwise we return a Teaser that resolves the type when opened. Teasers that point to a + playable episode (like in "Continue watching") are returned as an Episode. - def _parse_program_teaser(self, item, cache=CACHE_ONLY): - """ Parse the program json and return a Program instance. :type item: dict :type cache: int - :rtype Program + :rtype Movie|Program|Episode|Teaser """ - program = self.get_program(item.get('target', {}).get('id'), cache=cache) - if program: - program.available = item.get('blockedFor') != 'SUBSCRIPTION' - return program - - return Program( - program_id=item.get('target', {}).get('id'), - name=item.get('title'), - thumb=item.get('imageUrl'), - geoblocked=item.get('blockedFor') == 'GEO', - available=item.get('blockedFor') != 'SUBSCRIPTION', - ) + detail_id = item.get('detailId') or (item.get('target') or {}).get('id') + if not detail_id: + return None - 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) - episode = self.get_episode_from_program(program, item.get('target', {}).get('id')) if program else None + name = item.get('title') + thumb = item.get('imageUrl') or item.get('landscapeImageUrl') or item.get('largeImageUrl') + fanart = item.get('heroImageUrl') or thumb + + if item.get('playableId'): + progress = item.get('userProgressPercentage') or 0 + return Episode( + episode_id=item.get('playableId'), + program_id=detail_id, + program_name=name, + name=item.get('seasonLabel') or name, + description=item.get('description'), + thumb=thumb, + fanart=fanart, + watched=progress >= 100, + ) - return Episode( - episode_id=item.get('target', {}).get('id'), - program_id=item.get('target', {}).get('programId'), - program_name=item.get('title'), - name=item.get('label'), - description=episode.description if episode else None, - geoblocked=item.get('blockedFor') == 'GEO', - thumb=item.get('imageUrl'), - progress=item.get('playerPositionSeconds'), - watched=False, - remaining=item.get('remainingDaysAvailable'), + detail = self.get_detail(detail_id, cache=cache) + if detail: + return detail + + return Teaser( + detail_id=detail_id, + name=name, + description=item.get('description'), + poster=item.get('portraitImageUrl'), + thumb=thumb, + fanart=fanart, + my_list=item.get('addedToMyList'), ) @staticmethod diff --git a/resources/lib/streamz/auth.py b/resources/lib/streamz/auth.py index 6906032..a3af08b 100644 --- a/resources/lib/streamz/auth.py +++ b/resources/lib/streamz/auth.py @@ -6,7 +6,6 @@ import json import logging import os -import uuid from requests import HTTPError @@ -91,7 +90,7 @@ def authorize(self): """ Start the authorization flow. """ response = util.http_post('https://login.streamz.be/oauth/device/code', form={ 'client_id': self.CLIENT_ID, - 'scope': 'openid', + 'scope': 'openid offline_access', }) auth_info = json.loads(response.text) @@ -117,23 +116,19 @@ def authorize_check(self): return False raise - # Store these tokens + # Store these tokens. The access token from login.streamz.be is encrypted (not a JWT) and + # is refused by the DPG API, so we authenticate with the signed id token instead. auth_info = json.loads(response.text) self._account.id_token = auth_info.get('id_token') + self._account.access_token = self._account.id_token self._account.refresh_token = auth_info.get('refresh_token') + self._save_cache() - # Fetch an actual token we can use - response = util.http_post('https://lfvp-api.dpgmedia.net/STREAMZ/tokens', data={ - 'device': { - 'id': str(uuid.uuid4()), - 'name': 'Streamz Addon on Kodi', - }, + # Register the id token with the DPG API + util.http_post(API_ENDPOINT + '/STREAMZ/id-token', data={ 'idToken': self._account.id_token, }) - self._account.access_token = json.loads(response.text).get('lfvpToken') - self._save_cache() - return True def get_tokens(self): @@ -142,17 +137,23 @@ def get_tokens(self): if not self._account.access_token: return None - # Return our current token if it is still valid. - if self._account.is_valid_token() and self._account.profile and self._account.product: - return self._account - - # We can refresh our old token so it's valid again - response = util.http_post('https://lfvp-api.dpgmedia.net/STREAMZ/tokens/refresh', data={ - 'lfvpToken': self._account.access_token, - }) + # Refresh the OAuth access token when it has expired + if not self._account.is_valid_token(): + if not self._account.refresh_token: + raise NoLoginException + response = util.http_post('https://login.streamz.be/oauth/token', form={ + 'refresh_token': self._account.refresh_token, + 'client_id': self.CLIENT_ID, + 'grant_type': 'refresh_token', + }) + auth_info = json.loads(response.text) + self._account.id_token = auth_info.get('id_token') + self._account.access_token = self._account.id_token + self._account.refresh_token = auth_info.get('refresh_token', self._account.refresh_token) + self._save_cache() - # Get JWT from reply - self._account.access_token = json.loads(response.text).get('lfvpToken') + if self._account.profile and self._account.product: + return self._account # We always use the main profile if not self._account.profile: @@ -178,8 +179,8 @@ def get_profiles(self): birthdate=profile.get('birthDate'), color=profile.get('color', {}).get('start'), color2=profile.get('color', {}).get('end'), - main_profile=profile.get('mainProfile'), - kids_profile=profile.get('kidsProfile'), + main_profile=profile.get('mainProfile', profile.get('type') == 'MAIN'), + kids_profile=profile.get('kidsProfile', profile.get('type') == 'KIDS'), ) for profile in result.get('profiles') ] diff --git a/resources/lib/streamz/stream.py b/resources/lib/streamz/stream.py index 548a37e..5d368f1 100644 --- a/resources/lib/streamz/stream.py +++ b/resources/lib/streamz/stream.py @@ -111,23 +111,21 @@ def _get_video_info(self, strtype, stream_id, player_token): :param str player_token: :rtype: dict """ - url = 'https://videoplayer-service.api.persgroep.cloud/config/%s/%s' % (strtype, stream_id) - _LOGGER.debug('Getting video info from %s', url) + url = 'https://videoplayer-service.dpgmedia.net/play-config/%s' % stream_id + _LOGGER.debug('Getting video info from %s (%s)', url, strtype) response = util.http_post(url, params={ 'startPosition': '0.0', 'autoPlay': 'true', }, data={ - "deviceType": "android-phone", - "zone": "streamz" + 'deviceType': 'android-tv', + 'zone': 'streamz', }, headers={ 'Accept': 'application/json', 'x-api-key': self._API_KEY, - # 'x-dpg-correlation-id': '', - 'Popcorn-SDK-Version': '6', - 'User-Agent': 'Dalvik/2.1.0 (Linux; U; Android 6.0.1; MotoG3 Build/MPIS24.107-55-2-17)', + 'Popcorn-SDK-Version': '8', 'Authorization': 'Bearer ' + player_token, }) diff --git a/resources/lib/streamz/util.py b/resources/lib/streamz/util.py index 4877961..f51ffe4 100644 --- a/resources/lib/streamz/util.py +++ b/resources/lib/streamz/util.py @@ -32,11 +32,11 @@ def close(self): # Setup a static session that can be reused for all calls SESSION = requests.Session() SESSION.headers = { - 'User-Agent': 'STREAMZ/15.231023 (be.vmma.streamz; build:18041; Android 23) okhttp/4.11.0', - 'x-app-version': '15', - 'x-persgroep-mobile-app': 'true', - 'x-persgroep-os': 'android', - 'x-persgroep-os-version': '28', + 'Accept': 'application/json;image/webp', + 'Accept-Encoding': 'gzip', + 'User-Agent': 'STREAMZ/28.260923 (be.vmma.streamz; build:37208; Android TV 28) okhttp/4.12.0', + 'x-app-version': '28', + 'lfvp-device-segment': 'TV>Android', } PROXIES = kodiutils.get_proxies() @@ -166,7 +166,7 @@ def _request(method, url, params=None, form=None, data=None, token=None, profile headers = {} if token: - headers['lfvp-auth'] = token + headers['Authorization'] = 'Bearer ' + token if profile: headers['x-dpp-profile'] = profile diff --git a/tests/test_api.py b/tests/test_api.py index ba13ff4..e1d0345 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -9,7 +9,7 @@ import unittest from resources.lib import kodiutils -from resources.lib.streamz import STOREFRONT_MAIN, STOREFRONT_MOVIES, STOREFRONT_PAGE_CONTINUE_WATCHING, STOREFRONT_SERIES, Episode, Movie, Program +from resources.lib.streamz import STOREFRONT_MAIN, STOREFRONT_MOVIES, STOREFRONT_SERIES, Episode, Movie, Program from resources.lib.streamz.api import Api from resources.lib.streamz.auth import Auth from resources.lib.streamz.exceptions import UnavailableException @@ -43,7 +43,10 @@ def test_recommendations(self): self.assertIsInstance(serie_recommendations, list) def test_continuewatching(self): - result = self.api.get_storefront_category(STOREFRONT_MAIN, STOREFRONT_PAGE_CONTINUE_WATCHING) + category_id = self.api.get_continue_watching_id() + if category_id is None: + self.skipTest('No "Continue watching" row for this profile') + result = self.api.get_storefront_category(STOREFRONT_MAIN, category_id) self.assertIsInstance(result.content, list) def test_mylist(self):