From b33062bd3511d963676c745e678dd00d3336b864 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Arnauts?= Date: Mon, 20 Apr 2020 08:59:10 +0200 Subject: [PATCH] Add categories and clips (#23) --- .../resource.language.en_gb/strings.po | 16 ++ .../resource.language.nl_nl/strings.po | 16 ++ resources/lib/addon.py | 43 ++- resources/lib/modules/catalog.py | 54 +++- resources/lib/modules/channels.py | 97 ++++++- resources/lib/modules/menu.py | 22 +- resources/lib/modules/player.py | 59 ++-- resources/lib/modules/search.py | 3 +- resources/lib/modules/tvguide.py | 16 +- resources/lib/viervijfzes/content.py | 267 ++++++++++++++++-- tests/test_api.py | 21 +- tests/test_routing.py | 6 +- 12 files changed, 515 insertions(+), 105 deletions(-) diff --git a/resources/language/resource.language.en_gb/strings.po b/resources/language/resource.language.en_gb/strings.po index 345d60d..c9cb801 100644 --- a/resources/language/resource.language.en_gb/strings.po +++ b/resources/language/resource.language.en_gb/strings.po @@ -64,6 +64,22 @@ msgctxt "#30056" msgid "Browse the Catalog for [B]{channel}[/B]" msgstr "" +msgctxt "#30057" +msgid "Categories for [B]{channel}[/B]" +msgstr "" + +msgctxt "#30058" +msgid "Browse the Categories for [B]{channel}[/B]" +msgstr "" + +msgctxt "#30059" +msgid "Clips of [B]{program}[/B]" +msgstr "" + +msgctxt "#30060" +msgid "Watch short clips of [B]{program}[/B]" +msgstr "" + ### CONTEXT MENU msgctxt "#30102" diff --git a/resources/language/resource.language.nl_nl/strings.po b/resources/language/resource.language.nl_nl/strings.po index 4aab7b5..ca0db7d 100644 --- a/resources/language/resource.language.nl_nl/strings.po +++ b/resources/language/resource.language.nl_nl/strings.po @@ -65,6 +65,22 @@ msgctxt "#30056" msgid "Browse the Catalog for [B]{channel}[/B]" msgstr "Doorblader de catalogus voor [B]{channel}[/B]" +msgctxt "#30057" +msgid "Categories for [B]{channel}[/B]" +msgstr "Categoriƫn voor [B]{channel}[/B]" + +msgctxt "#30058" +msgid "Browse the Categories for [B]{channel}[/B]" +msgstr "Doorblader de categoriƫn van [B]{channel}[/B]" + +msgctxt "#30059" +msgid "Clips of [B]{program}[/B]" +msgstr "Clips van [B]{program}[/B]" + +msgctxt "#30060" +msgid "Watch short clips of [B]{program}[/B]" +msgstr "Bekijk korte videoclips van [B]{program}[/B]" + ### CONTEXT MENU msgctxt "#30102" diff --git a/resources/lib/addon.py b/resources/lib/addon.py index cd326bc..2140fa3 100644 --- a/resources/lib/addon.py +++ b/resources/lib/addon.py @@ -35,18 +35,32 @@ def show_channel_menu(channel): Channels().show_channel_menu(channel) -@routing.route('/tvguide/channel/') -def show_tvguide_channel(channel): +@routing.route('/channels//categories') +def show_channel_categories(channel): + """ Shows TV Channel categories """ + from resources.lib.modules.channels import Channels + Channels().show_channel_categories(channel) + + +@routing.route('/channels//categories/') +def show_channel_category(channel, category): + """ Shows TV Channel categories """ + from resources.lib.modules.channels import Channels + Channels().show_channel_category(channel, category) + + +@routing.route('/channels//tvguide') +def show_channel_tvguide(channel): """ Shows the dates in the tv guide """ from resources.lib.modules.tvguide import TvGuide - TvGuide().show_tvguide_channel(channel) + TvGuide().show_channel(channel) -@routing.route('/tvguide/channel//') -def show_tvguide_detail(channel=None, date=None): +@routing.route('/channels//tvguide/') +def show_channel_tvguide_detail(channel=None, date=None): """ Shows the programs of a specific date in the tv guide """ from resources.lib.modules.tvguide import TvGuide - TvGuide().show_tvguide_detail(channel, date) + TvGuide().show_detail(channel, date) @routing.route('/catalog') @@ -56,23 +70,30 @@ def show_catalog(): Catalog().show_catalog() -@routing.route('/catalog/by-channel/') -def show_catalog_channel(channel): +@routing.route('/catalog/') +def show_channel_catalog(channel): """ Show a category in the catalog """ from resources.lib.modules.catalog import Catalog Catalog().show_catalog_channel(channel) -@routing.route('/catalog/program//') +@routing.route('/catalog//') def show_catalog_program(channel, program): """ Show a program from the catalog """ from resources.lib.modules.catalog import Catalog Catalog().show_program(channel, program) -@routing.route('/catalog/program///') +@routing.route('/catalog///clips') +def show_catalog_program_clips(channel, program): + """ Show the clips from a program """ + from resources.lib.modules.catalog import Catalog + Catalog().show_program_clips(channel, program) + + +@routing.route('/catalog///season/') def show_catalog_program_season(channel, program, season): - """ Show a program from the catalog """ + """ Show a season from a program """ from resources.lib.modules.catalog import Catalog Catalog().show_program_season(channel, program, season) diff --git a/resources/lib/modules/catalog.py b/resources/lib/modules/catalog.py index d2da86c..5e28b24 100644 --- a/resources/lib/modules/catalog.py +++ b/resources/lib/modules/catalog.py @@ -22,7 +22,6 @@ class Catalog: """ Initialise object """ auth = AuthApi(kodiutils.get_setting('username'), kodiutils.get_setting('password'), kodiutils.get_tokens_path()) self._api = ContentApi(auth, cache_path=kodiutils.get_cache_path()) - self._menu = Menu() def show_catalog(self): """ Show all the programs of all channels """ @@ -34,7 +33,7 @@ class Catalog: kodiutils.notification(message=str(ex)) raise - listing = [self._menu.generate_titleitem(item) for item in items] + listing = [Menu.generate_titleitem(item) for item in items] # Sort items by title # Used for A-Z listing or when movies and episodes are mixed. @@ -52,7 +51,7 @@ class Catalog: listing = [] for item in items: - listing.append(self._menu.generate_titleitem(item)) + listing.append(Menu.generate_titleitem(item)) # Sort items by title # Used for A-Z listing or when movies and episodes are mixed. @@ -64,19 +63,19 @@ class Catalog: :type program_id: str """ try: - program = self._api.get_program(channel, program_id, cache=CACHE_PREVENT) # Use CACHE_PREVENT since we want fresh data + program = self._api.get_program(channel, program_id, extract_clips=True, 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 catalogue. kodiutils.end_of_directory() return - if not program.episodes: + if not program.episodes and not program.clips: kodiutils.ok_dialog(message=kodiutils.localize(30717)) # This program is not available in the catalogue. kodiutils.end_of_directory() return - # Go directly to the season when we have only one season - if len(program.seasons) == 1: + # Go directly to the season when we have only one season and no clips + if not program.clips and len(program.seasons) == 1: self.show_program_season(channel, program_id, list(program.seasons.values())[0].uuid) return @@ -85,7 +84,7 @@ class Catalog: listing = [] # Add an '* All seasons' entry when configured in Kodi - if kodiutils.get_global_setting('videolibrary.showallitems') is True: + if program.seasons and kodiutils.get_global_setting('videolibrary.showallitems') is True: listing.append( TitleItem( title='* %s' % kodiutils.localize(30204), # * All seasons @@ -122,6 +121,25 @@ class Catalog: ) ) + # Add Clips + if program.clips: + listing.append( + TitleItem( + title=kodiutils.localize(30059, program=program.title), # Clips for {program} + path=kodiutils.url_for('show_catalog_program_clips', channel=channel, program=program_id), + art_dict={ + 'fanart': program.background, + }, + info_dict={ + 'tvshowtitle': program.title, + 'title': kodiutils.localize(30059, program=program.title), # Clips for {program} + 'plot': kodiutils.localize(30060, program=program.title), # Watch short clips of {program} + 'set': program.title, + 'studio': studio, + } + ) + ) + # Sort by label. Some programs return seasons unordered. kodiutils.show_listing(listing, 30003, content='tvshows') @@ -145,7 +163,25 @@ class Catalog: # Show the episodes of the season that was selected episodes = [e for e in program.episodes if e.season_uuid == season_uuid] - listing = [self._menu.generate_titleitem(episode) for episode in episodes] + listing = [Menu.generate_titleitem(episode) for episode in episodes] # Sort by episode number by default. Takes seasons into account. kodiutils.show_listing(listing, 30003, content='episodes', sort=['episode', 'duration']) + + def show_program_clips(self, channel, program_id): + """ Show the clips of a program from the catalog + :type channel: str + :type program_id: str + """ + try: + # We need to query the backend, since we don't cache clips. + program = self._api.get_program(channel, program_id, extract_clips=True, cache=CACHE_PREVENT) + except UnavailableException: + kodiutils.ok_dialog(message=kodiutils.localize(30717)) # This program is not available in the catalogue. + kodiutils.end_of_directory() + return + + listing = [Menu.generate_titleitem(episode) for episode in program.clips] + + # Sort like we get our results back. + kodiutils.show_listing(listing, 30003, content='episodes') diff --git a/resources/lib/modules/channels.py b/resources/lib/modules/channels.py index 45a2c09..844efb6 100644 --- a/resources/lib/modules/channels.py +++ b/resources/lib/modules/channels.py @@ -7,7 +7,10 @@ import logging from resources.lib import kodiutils from resources.lib.kodiutils import TitleItem +from resources.lib.modules.menu import Menu from resources.lib.viervijfzes import CHANNELS, STREAM_DICT +from resources.lib.viervijfzes.auth import AuthApi +from resources.lib.viervijfzes.content import ContentApi, CACHE_ONLY, CACHE_AUTO _LOGGER = logging.getLogger('channels') @@ -17,6 +20,8 @@ class Channels: def __init__(self): """ Initialise object """ + auth = AuthApi(kodiutils.get_setting('username'), kodiutils.get_setting('password'), kodiutils.get_tokens_path()) + self._api = ContentApi(auth, cache_path=kodiutils.get_cache_path()) @staticmethod def show_channels(): @@ -33,7 +38,7 @@ class Channels: ( kodiutils.localize(30053, channel=channel.get('name')), # TV Guide for {channel} 'Container.Update(%s)' % - kodiutils.url_for('show_tvguide_channel', channel=channel.get('epg')) + kodiutils.url_for('show_channel_tvguide', channel=channel.get('epg')) ) ] @@ -60,43 +65,54 @@ class Channels: kodiutils.show_listing(listing, 30007) @staticmethod - def show_channel_menu(key): + def show_channel_menu(channel): """ Shows a TV channel - :type key: str + :type channel: str """ - channel = CHANNELS[key] + channel_info = CHANNELS[channel] # Lookup the high resolution logo based on the channel name - fanart = '{path}/resources/logos/{logo}'.format(path=kodiutils.addon_path(), logo=channel.get('background')) + fanart = '{path}/resources/logos/{logo}'.format(path=kodiutils.addon_path(), logo=channel_info.get('background')) listing = [ TitleItem( - title=kodiutils.localize(30053, channel=channel.get('name')), # TV Guide for {channel} - path=kodiutils.url_for('show_tvguide_channel', channel=key), + title=kodiutils.localize(30053, channel=channel_info.get('name')), # TV Guide for {channel} + path=kodiutils.url_for('show_channel_tvguide', channel=channel), art_dict={ 'icon': 'DefaultAddonTvInfo.png', 'fanart': fanart, }, info_dict={ - 'plot': kodiutils.localize(30054, channel=channel.get('name')), # Browse the TV Guide for {channel} + 'plot': kodiutils.localize(30054, channel=channel_info.get('name')), # Browse the TV Guide for {channel} } ), TitleItem( - title=kodiutils.localize(30055, channel=channel.get('name')), # Catalog for {channel} - path=kodiutils.url_for('show_catalog_channel', channel=key), + title=kodiutils.localize(30055, channel=channel_info.get('name')), # Catalog for {channel} + path=kodiutils.url_for('show_channel_catalog', channel=channel), art_dict={ 'icon': 'DefaultMovieTitle.png', 'fanart': fanart, }, info_dict={ - 'plot': kodiutils.localize(30056, channel=channel.get('name')), # Browse the Catalog for {channel} + 'plot': kodiutils.localize(30056, channel=channel_info.get('name')), # Browse the Catalog for {channel} } - ) + ), + TitleItem( + title=kodiutils.localize(30057, channel=channel_info.get('name')), # Categories for {channel} + path=kodiutils.url_for('show_channel_categories', channel=channel), + art_dict={ + 'icon': 'DefaultGenre.png', + 'fanart': fanart, + }, + info_dict={ + 'plot': kodiutils.localize(30058, channel=channel_info.get('name')), # Browse the Categories for {channel} + } + ), ] # Add YouTube channels if kodiutils.get_cond_visibility('System.HasAddon(plugin.video.youtube)') != 0: - for youtube in channel.get('youtube', []): + for youtube in channel_info.get('youtube', []): listing.append( TitleItem( title=kodiutils.localize(30206, label=youtube.get('label')), # Watch {label} on YouTube @@ -108,3 +124,58 @@ class Channels: ) kodiutils.show_listing(listing, 30007, sort=['unsorted']) + + def show_channel_categories(self, channel): + """ Shows the categories of a channel + :type channel: str + """ + categories = self._api.get_categories(channel) + + listing = [ + TitleItem( + title=category.title, + path=kodiutils.url_for('show_channel_category', channel=category.channel, category=category.uuid), + art_dict={ + 'icon': 'DefaultGenre.png', + }, + ) for category in categories + ] + + kodiutils.show_listing(listing, 30007, sort=['unsorted']) + + def show_channel_category(self, channel, category_id): + """ Shows a selected category of a channel + :type channel: str + :type category_id: str + """ + categories = self._api.get_categories(channel) + + # Extract selected category + category = next(category for category in categories if category.uuid == category_id) + if not category: + raise Exception('Unknown category') + + # Add programs + listing_programs = [] + for item in category.programs: + program = self._api.get_program(channel, item.path, CACHE_ONLY) # Get program details, but from cache only + + if program: + listing_programs.append(Menu.generate_titleitem(program)) + else: + listing_programs.append(Menu.generate_titleitem(item)) + + # Add episodes + listing_episodes = [] + for item in category.episodes: + # We don't have the Program Name without making a request to the page, so we use CACHE_AUTO instead of CACHE_ONLY. + # This will make a request for each item in this view (about 12 items), but it goes quite fast. + # Results are cached, so this will only happen once. + episode = self._api.get_episode(channel, item.path, CACHE_AUTO) + + if episode: + listing_episodes.append(Menu.generate_titleitem(episode)) + else: + listing_episodes.append(Menu.generate_titleitem(item)) + + kodiutils.show_listing(listing_programs + listing_episodes, 30007, content='tvshows', sort=['unsorted']) diff --git a/resources/lib/modules/menu.py b/resources/lib/modules/menu.py index 3db2c3d..ae7acdc 100644 --- a/resources/lib/modules/menu.py +++ b/resources/lib/modules/menu.py @@ -65,6 +65,7 @@ class Menu: art_dict = { 'thumb': item.cover, 'cover': item.cover, + 'fanart': item.background or item.cover, } info_dict = { 'title': item.title, @@ -78,9 +79,6 @@ class Menu: # Program # if isinstance(item, Program): - art_dict.update({ - 'fanart': item.background, - }) info_dict.update({ 'mediatype': None, 'season': len(item.seasons) if item.seasons else None, @@ -102,9 +100,6 @@ class Menu: # Episode # if isinstance(item, Episode): - art_dict.update({ - 'fanart': item.cover, - }) info_dict.update({ 'mediatype': 'episode', 'tvshowtitle': item.program_title, @@ -118,8 +113,21 @@ class Menu: 'duration': item.duration, }) + if item.path: + try: # Python 3 + from urllib.parse import quote + except ImportError: # Python 2 + from urllib import quote + + # We don't have an UUID, and first need to fetch the video information from the page + path = kodiutils.url_for('play_from_page', channel=item.channel, page=quote(item.path, safe='')) + else: + # We have an UUID and can play this item directly + # This is not preferred since we will lack metadata + path = kodiutils.url_for('play', uuid=item.uuid) + return TitleItem(title=info_dict['title'], - path=kodiutils.url_for('play', uuid=item.uuid), + path=path, art_dict=art_dict, info_dict=info_dict, stream_dict=stream_dict, diff --git a/resources/lib/modules/player.py b/resources/lib/modules/player.py index 3ddf60b..571c711 100644 --- a/resources/lib/modules/player.py +++ b/resources/lib/modules/player.py @@ -6,6 +6,7 @@ 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.viervijfzes.auth import AuthApi from resources.lib.viervijfzes.auth_awsidp import InvalidLoginException, AuthenticationException from resources.lib.viervijfzes.content import ContentApi, UnavailableException, GeoblockedException @@ -18,6 +19,11 @@ class Player: def __init__(self): """ Initialise object """ + auth = AuthApi(kodiutils.get_setting('username'), kodiutils.get_setting('password'), kodiutils.get_tokens_path()) + self._api = ContentApi(auth, cache_path=kodiutils.get_cache_path()) + + # Workaround for Raspberry Pi 3 and older + kodiutils.set_global_setting('videoplayer.useomxplayer', True) def play_from_page(self, channel, path): """ Play the requested item. @@ -25,22 +31,37 @@ class Player: :type path: string """ # Get episode information - episode = ContentApi().get_episode(channel, path) + episode = self._api.get_episode(channel, path) + resolved_stream = None - # Play this now we have the uuid - self.play(episode.uuid) + if episode.stream: + # We already have a resolved stream. Nice! + # We don't need credentials for these streams. + resolved_stream = episode.stream + _LOGGER.info('Already got a resolved stream: %s', resolved_stream) + + if episode.uuid: + # Lookup the stream + resolved_stream = self._resolve_stream(episode.uuid) + _LOGGER.info('Resolved stream: %s', resolved_stream) + + if resolved_stream: + titleitem = Menu.generate_titleitem(episode) + kodiutils.play(resolved_stream, info_dict=titleitem.info_dict, art_dict=titleitem.art_dict, prop_dict=titleitem.prop_dict) + + def play(self, uuid): + """ Play the requested item. + :type uuid: string + """ + # Lookup the stream + resolved_stream = self._resolve_stream(uuid) + kodiutils.play(resolved_stream) @staticmethod - def play(item): - """ Play the requested item. - :type item: string + def _resolve_stream(uuid): + """ Resolve the stream for the requested item + :type uuid: string """ - - # Workaround for Raspberry Pi 3 and older - omxplayer = kodiutils.get_global_setting('videoplayer.useomxplayer') - if omxplayer is False: - kodiutils.set_global_setting('videoplayer.useomxplayer', True) - try: # Check if we have credentials if not kodiutils.get_setting('username') or not kodiutils.get_setting('password'): @@ -49,28 +70,26 @@ class Player: if confirm: kodiutils.open_settings() kodiutils.end_of_directory() - return + return None # Fetch an auth token now try: auth = AuthApi(kodiutils.get_setting('username'), kodiutils.get_setting('password'), kodiutils.get_tokens_path()) # Get stream information - resolved_stream = ContentApi(auth).get_stream_by_uuid(item) + resolved_stream = ContentApi(auth).get_stream_by_uuid(uuid) + return resolved_stream except (InvalidLoginException, AuthenticationException) as ex: _LOGGER.error(ex) kodiutils.ok_dialog(message=kodiutils.localize(30702, error=str(ex))) kodiutils.end_of_directory() - return + return None except GeoblockedException: kodiutils.ok_dialog(heading=kodiutils.localize(30709), message=kodiutils.localize(30710)) # This video is geo-blocked... - return + return None except UnavailableException: kodiutils.ok_dialog(heading=kodiutils.localize(30711), message=kodiutils.localize(30712)) # The video is unavailable... - return - - # Play this item - kodiutils.play(resolved_stream) + return None diff --git a/resources/lib/modules/search.py b/resources/lib/modules/search.py index 74f0c5a..c5e81a3 100644 --- a/resources/lib/modules/search.py +++ b/resources/lib/modules/search.py @@ -18,7 +18,6 @@ class Search: def __init__(self): """ Initialise object """ self._search = SearchApi() - self._menu = Menu() def show_search(self, query=None): """ Shows the search dialog @@ -40,7 +39,7 @@ class Search: return # Display results - listing = [self._menu.generate_titleitem(item) for item in items] + listing = [Menu.generate_titleitem(item) for item in items] # Sort like we get our results back. kodiutils.show_listing(listing, 30009, content='tvshows') diff --git a/resources/lib/modules/tvguide.py b/resources/lib/modules/tvguide.py index 7fa5f5b..ab0ae1c 100644 --- a/resources/lib/modules/tvguide.py +++ b/resources/lib/modules/tvguide.py @@ -12,11 +12,6 @@ from resources.lib.viervijfzes import STREAM_DICT from resources.lib.viervijfzes.content import UnavailableException from resources.lib.viervijfzes.epg import EpgApi -try: # Python 3 - from urllib.parse import quote -except ImportError: # Python 2 - from urllib import quote - _LOGGER = logging.getLogger('tvguide') @@ -70,7 +65,7 @@ class TvGuide: return dates - def show_tvguide_channel(self, channel): + def show_channel(self, channel): """ Shows the dates in the tv guide :type channel: str """ @@ -83,7 +78,7 @@ class TvGuide: listing.append( TitleItem(title=title, - path=kodiutils.url_for('show_tvguide_detail', channel=channel, date=day.get('key')), + path=kodiutils.url_for('show_channel_tvguide_detail', channel=channel, date=day.get('key')), art_dict={ 'icon': 'DefaultYear.png', 'thumb': 'DefaultYear.png', @@ -96,7 +91,7 @@ class TvGuide: kodiutils.show_listing(listing, 30013, content='files', sort=['date']) - def show_tvguide_detail(self, channel=None, date=None): + def show_detail(self, channel=None, date=None): """ Shows the programs of a specific date in the tv guide :type channel: str :type date: str @@ -108,6 +103,11 @@ class TvGuide: kodiutils.end_of_directory() return + try: # Python 3 + from urllib.parse import quote + except ImportError: # Python 2 + from urllib import quote + listing = [] for program in programs: if program.program_url: diff --git a/resources/lib/viervijfzes/content.py b/resources/lib/viervijfzes/content.py index 5a34740..7a135ed 100644 --- a/resources/lib/viervijfzes/content.py +++ b/resources/lib/viervijfzes/content.py @@ -37,7 +37,8 @@ class GeoblockedException(Exception): class Program: """ Defines a Program. """ - def __init__(self, uuid=None, path=None, channel=None, title=None, description=None, aired=None, cover=None, background=None, seasons=None, episodes=None): + def __init__(self, uuid=None, path=None, channel=None, title=None, description=None, aired=None, cover=None, background=None, seasons=None, episodes=None, + clips=None): """ :type uuid: str :type path: str @@ -49,6 +50,7 @@ class Program: :type background: str :type seasons: list[Season] :type episodes: list[Episode] + :type clips: list[Episode] """ self.uuid = uuid self.path = path @@ -60,6 +62,7 @@ class Program: self.background = background self.seasons = seasons self.episodes = episodes + self.clips = clips def __repr__(self): return "%r" % self.__dict__ @@ -94,8 +97,8 @@ class Season: class Episode: """ Defines an Episode. """ - def __init__(self, uuid=None, nodeid=None, path=None, channel=None, program_title=None, title=None, description=None, cover=None, duration=None, - season=None, season_uuid=None, number=None, rating=None, aired=None, expiry=None): + def __init__(self, uuid=None, nodeid=None, path=None, channel=None, program_title=None, title=None, description=None, cover=None, background=None, + duration=None, season=None, season_uuid=None, number=None, rating=None, aired=None, expiry=None, stream=None): """ :type uuid: str :type nodeid: str @@ -105,6 +108,7 @@ class Episode: :type title: str :type description: str :type cover: str + :type background: str :type duration: int :type season: int :type season_uuid: str @@ -112,6 +116,7 @@ class Episode: :type rating: str :type aired: datetime :type expiry: datetime + :type stream: string """ self.uuid = uuid self.nodeid = nodeid @@ -121,6 +126,7 @@ class Episode: self.title = title self.description = description self.cover = cover + self.background = background self.duration = duration self.season = season self.season_uuid = season_uuid @@ -128,6 +134,28 @@ class Episode: self.rating = rating self.aired = aired self.expiry = expiry + self.stream = stream + + def __repr__(self): + return "%r" % self.__dict__ + + +class Category: + """ Defines a Category. """ + + def __init__(self, uuid=None, channel=None, title=None, programs=None, episodes=None): + """ + :type uuid: str + :type channel: str + :type title: str + :type programs: List[Program] + :type episodes: List[Episode] + """ + self.uuid = uuid + self.channel = channel + self.title = title + self.programs = programs + self.episodes = episodes def __repr__(self): return "%r" % self.__dict__ @@ -196,7 +224,7 @@ class ContentApi: title=title)) return programs - def get_program(self, channel, path, cache=CACHE_AUTO): + def get_program(self, channel, path, extract_clips=False, cache=CACHE_AUTO): """ Get a Program object from the specified page. :type channel: str :type path: str @@ -206,11 +234,18 @@ class ContentApi: if channel not in CHANNELS: raise Exception('Unknown channel %s' % channel) + # We want to use the html to extract clips + # This is the worst hack, since Python 2.7 doesn't support nonlocal + raw_html = [None] + def update(): """ Fetch the program metadata by scraping """ # Fetch webpage page = self._get_url(CHANNELS[channel]['url'] + '/' + path) + # Store a copy in the parent's raw_html var. + raw_html[0] = page + # Extract JSON regex_program = re.compile(r'data-hero="([^"]+)', re.DOTALL) json_data = HTMLParser().unescape(regex_program.search(page).group(1)) @@ -220,41 +255,80 @@ class ContentApi: # Fetch listing from cache or update if needed data = self._handle_cache(key=['program', channel, path], cache_mode=cache, update=update) + if not data: + return None program = self._parse_program_data(data) + # Also extract clips if we did a real HTTP call + if extract_clips and raw_html[0]: + clips = self._extract_videos(raw_html[0], channel) + program.clips = clips + return program - def get_episode(self, channel, path): + def get_episode(self, channel, path, cache=CACHE_AUTO): """ Get a Episode object from the specified page. :type channel: str :type path: str + :type cache: str :rtype Episode - NOTE: This function doesn't use an API. """ if channel not in CHANNELS: raise Exception('Unknown channel %s' % channel) - # Load webpage - page = self._get_url(CHANNELS[channel]['url'] + '/' + path) + def update(): + """ Fetch the program metadata by scraping """ + # Load webpage + page = self._get_url(CHANNELS[channel]['url'] + '/' + path) - # Extract program JSON - parser = HTMLParser() - regex_program = re.compile(r'data-hero="([^"]+)', re.DOTALL) - json_data = parser.unescape(regex_program.search(page).group(1)) - data = json.loads(json_data)['data'] - program = self._parse_program_data(data) + parser = HTMLParser() + program_json = None + episode_json = None - # Extract episode JSON - regex_episode = re.compile(r'', re.DOTALL) - json_data = parser.unescape(regex_episode.search(page).group(1)) - data = json.loads(json_data) + # Extract video JSON by looking for a data-video tag + # This is not present on every page + regex_video_data = re.compile(r'data-video="([^"]+)"', re.DOTALL) + result = regex_video_data.search(page) + if result: + video_id = json.loads(parser.unescape(result.group(1)))['id'] + video_json_data = self._get_url('%s/video/%s' % (self.SITE_APIS[channel], video_id)) + video_json = json.loads(video_json_data) + return dict(video=video_json) - # Lookup the episode in the program JSON based on the nodeId - # The episode we just found doesn't contain all information - for episode in program.episodes: - if episode.nodeid == data['pageInfo']['nodeId']: - return episode + # Extract program JSON + regex_program = re.compile(r'data-hero="([^"]+)', re.DOTALL) + result = regex_program.search(page) + if result: + program_json_data = parser.unescape(result.group(1)) + program_json = json.loads(program_json_data)['data'] + + # Extract episode JSON + regex_episode = re.compile(r'', re.DOTALL) + result = regex_episode.search(page) + if result: + episode_json_data = parser.unescape(result.group(1)) + episode_json = json.loads(episode_json_data) + + return dict(program=program_json, episode=episode_json) + + # Fetch listing from cache or update if needed + data = self._handle_cache(key=['episode', channel, path], cache_mode=cache, update=update) + if not data: + return None + + if 'video' in data and data['video']: + # We have found detailed episode information + episode = self._parse_episode_data(data['video']) + return episode + + if 'program' in data and 'episode' in data and data['program'] and data['episode']: + # We don't have detailed episode information + # We need to lookup the episode in the program JSON + program = self._parse_program_data(data['program']) + for episode in program.episodes: + if episode.nodeid == data['episode']['pageInfo']['nodeId']: + return episode return None @@ -267,15 +341,146 @@ class ContentApi: data = json.loads(response) return data['video']['S'] + def get_categories(self, channel): + """ Get a list of all categories of the specified channel. + :type channel: str + :rtype list[Category] + """ + if channel not in CHANNELS: + raise Exception('Unknown channel %s' % channel) + + # Load webpage + raw_html = self._get_url(CHANNELS[channel]['url']) + + # Categories regexes + regex_articles = re.compile(r']+)>(.*?)', re.DOTALL) + regex_submenu_id = re.compile(r'data-submenu-id="([^"]*)"') # splitted since the order might change + regex_submenu_title = re.compile(r'data-submenu-title="([^"]*)"') + + categories = [] + for result in regex_articles.finditer(raw_html): + article_info_html = result.group(1) + article_html = result.group(2) + category_title = regex_submenu_title.search(article_info_html).group(1) + category_id = regex_submenu_id.search(article_info_html).group(1) + + # Skip empty categories or 'All programs' + if not category_id or category_id == 'programmas': + continue + + # Extract items + programs = self._extract_programs(article_html, channel) + episodes = self._extract_videos(article_html, channel) + categories.append(Category(uuid=category_id, channel=channel, title=category_title, programs=programs, episodes=episodes)) + + return categories + + @staticmethod + def _extract_programs(html, channel): + """ Extract Programs from HTML code """ + parser = HTMLParser() + + # Item regexes + regex_item = re.compile(r']+?href="(?P[^"]+)"[^>]+?>' + r'.*?

(?P[^<]*)</span></h3>.*?' + r'</a>', re.DOTALL) + + # Extract items + programs = [] + for item in regex_item.finditer(html): + path = item.group('path') + if path.startswith('/video'): + continue + + title = parser.unescape(item.group('title')) + + # Program + programs.append(Program( + path=path.lstrip('/'), + channel=channel, + title=title, + )) + + return programs + + @staticmethod + def _extract_videos(html, channel): + """ Extract videos from HTML code """ + parser = HTMLParser() + + # Item regexes + regex_item = re.compile(r'<a[^>]+?href="(?P<path>[^"]+)"[^>]+?>.*?</a>', re.DOTALL) + + # Episode regexes + regex_episode_title = re.compile(r'<h3 class="(?:poster|card|image)-teaser__title">(?:<span>)?([^<]*)(?:</span>)?</h3>') + regex_episode_program = re.compile(r'<div class="card-teaser__label">([^<]*)</div>') + regex_episode_duration = re.compile(r'data-duration="([^"]*)"') + regex_episode_video_id = re.compile(r'data-videoid="([^"]*)"') + regex_episode_image = re.compile(r'data-background-image="([^"]*)"') + regex_episode_timestamp = re.compile(r'data-timestamp="([^"]*)"') + + # Extract items + episodes = [] + for item in regex_item.finditer(html): + item_html = item.group(0) + path = item.group('path') + + # Extract title + try: + title = parser.unescape(regex_episode_title.search(item_html).group(1)) + except AttributeError: + continue + + # This is not a episode + if not path.startswith('/video'): + continue + + try: + episode_program = regex_episode_program.search(item_html).group(1) + except AttributeError: + _LOGGER.warning('Found no episode_program for %s', title) + episode_program = None + try: + episode_duration = int(regex_episode_duration.search(item_html).group(1)) + except AttributeError: + _LOGGER.warning('Found no episode_duration for %s', title) + episode_duration = None + try: + episode_video_id = regex_episode_video_id.search(item_html).group(1) + except AttributeError: + _LOGGER.warning('Found no episode_video_id for %s', title) + episode_video_id = None + try: + episode_image = parser.unescape(regex_episode_image.search(item_html).group(1)) + except AttributeError: + _LOGGER.warning('Found no episode_image for %s', title) + episode_image = None + try: + episode_timestamp = int(regex_episode_timestamp.search(item_html).group(1)) + except AttributeError: + _LOGGER.warning('Found no episode_timestamp for %s', title) + episode_timestamp = None + + # Episode + episodes.append(Episode( + path=path.lstrip('/'), + channel=channel, + title=title, + duration=episode_duration, + uuid=episode_video_id, + aired=datetime.fromtimestamp(episode_timestamp) if episode_timestamp else None, + cover=episode_image, + program_title=episode_program, + )) + + return episodes + @staticmethod def _parse_program_data(data): """ Parse the Program JSON. :type data: dict :rtype Program """ - if data is None: - return None - # Create Program info program = Program( uuid=data['id'], @@ -311,7 +516,7 @@ class ContentApi: return program @staticmethod - def _parse_episode_data(data, season_uuid): + def _parse_episode_data(data, season_uuid=None): """ Parse the Episode JSON. :type data: dict :type season_uuid: str @@ -337,13 +542,15 @@ class ContentApi: title=data.get('title'), description=data.get('pageInfo', {}).get('description'), cover=data.get('image'), + background=data.get('image'), duration=data.get('duration'), season=data.get('seasonNumber'), season_uuid=season_uuid, number=episode_number, aired=datetime.fromtimestamp(data.get('createdDate')), expiry=datetime.fromtimestamp(data.get('unpublishDate')) if data.get('unpublishDate') else None, - rating=data.get('parentalRating') + rating=data.get('parentalRating'), + stream=data.get('path'), ) return episode @@ -393,7 +600,7 @@ class ContentApi: def _get_cache(self, key, allow_expired=False): """ Get an item from the cache """ - filename = '.'.join(key) + '.json' + filename = ('.'.join(key) + '.json').replace('/', '_') fullpath = self._cache_path + filename if not os.path.exists(fullpath): @@ -412,7 +619,7 @@ class ContentApi: def _set_cache(self, key, data, ttl): """ Store an item in the cache """ - filename = '.'.join(key) + '.json' + filename = ('.'.join(key) + '.json').replace('/', '_') fullpath = self._cache_path + filename if not os.path.exists(self._cache_path): diff --git a/tests/test_api.py b/tests/test_api.py index f19990a..ad82cb9 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -10,7 +10,7 @@ import unittest import resources.lib.kodiutils as kodiutils from resources.lib.viervijfzes.auth import AuthApi -from resources.lib.viervijfzes.content import ContentApi, Program, Episode +from resources.lib.viervijfzes.content import ContentApi, Program, Episode, Category, CACHE_PREVENT _LOGGER = logging.getLogger('test-api') @@ -27,14 +27,31 @@ class TestApi(unittest.TestCase): self.assertIsInstance(programs, list) self.assertIsInstance(programs[0], Program) + def test_categories(self): + for channel in ['vier', 'vijf', 'zes']: + categories = self._api.get_categories(channel) + self.assertIsInstance(categories, list) + if categories: + self.assertIsInstance(categories[0], Category) + def test_episodes(self): for channel, program in [('vier', 'auwch'), ('vijf', 'zo-man-zo-vrouw')]: - program = self._api.get_program(channel, program) + program = self._api.get_program(channel, program, cache=CACHE_PREVENT) self.assertIsInstance(program, Program) self.assertIsInstance(program.seasons, dict) self.assertIsInstance(program.episodes, list) self.assertIsInstance(program.episodes[0], Episode) + def test_clips(self): + for channel, program in [('vier', 'gert-late-night'), ('zes', 'macgyver')]: + program = self._api.get_program(channel, program, extract_clips=True, cache=CACHE_PREVENT) + + self.assertIsInstance(program.clips, list) + self.assertIsInstance(program.clips[0], Episode) + + episode = self._api.get_episode(channel, program.clips[0].path, cache=CACHE_PREVENT) + self.assertIsInstance(episode, Episode) + @unittest.skipUnless(kodiutils.get_setting('username') and kodiutils.get_setting('password'), 'Skipping since we have no credentials.') def test_get_stream(self): program = self._api.get_program('vier', 'auwch') diff --git a/tests/test_routing.py b/tests/test_routing.py index 3d2c432..3004a55 100644 --- a/tests/test_routing.py +++ b/tests/test_routing.py @@ -40,7 +40,7 @@ class TestRouting(unittest.TestCase): routing.run([routing.url_for(addon.show_catalog), '0', '']) def test_catalog_channel_menu(self): - routing.run([routing.url_for(addon.show_catalog_channel, channel='vier'), '0', '']) + routing.run([routing.url_for(addon.show_channel_catalog, channel='vier'), '0', '']) def test_catalog_program_menu(self): routing.run([routing.url_for(addon.show_catalog_program, channel='vier', program='de-mol'), '0', '']) @@ -53,8 +53,8 @@ class TestRouting(unittest.TestCase): routing.run([routing.url_for(addon.show_search, query='de mol'), '0', '']) def test_tvguide_menu(self): - routing.run([routing.url_for(addon.show_tvguide_channel, channel='vier'), '0', '']) - routing.run([routing.url_for(addon.show_tvguide_detail, channel='vier', date='today'), '0', '']) + routing.run([routing.url_for(addon.show_channel_tvguide, channel='vier'), '0', '']) + routing.run([routing.url_for(addon.show_channel_tvguide_detail, channel='vier', date='today'), '0', '']) def test_metadata_update(self): routing.run([routing.url_for(addon.metadata_update), '0', ''])