Add categories and clips (#23)

This commit is contained in:
Michaël Arnauts 2020-04-20 08:59:10 +02:00 committed by GitHub
parent 40af262ae6
commit b33062bd35
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 515 additions and 105 deletions

View File

@ -64,6 +64,22 @@ msgctxt "#30056"
msgid "Browse the Catalog for [B]{channel}[/B]" msgid "Browse the Catalog for [B]{channel}[/B]"
msgstr "" 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 ### CONTEXT MENU
msgctxt "#30102" msgctxt "#30102"

View File

@ -65,6 +65,22 @@ msgctxt "#30056"
msgid "Browse the Catalog for [B]{channel}[/B]" msgid "Browse the Catalog for [B]{channel}[/B]"
msgstr "Doorblader de catalogus voor [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 ### CONTEXT MENU
msgctxt "#30102" msgctxt "#30102"

View File

@ -35,18 +35,32 @@ def show_channel_menu(channel):
Channels().show_channel_menu(channel) Channels().show_channel_menu(channel)
@routing.route('/tvguide/channel/<channel>') @routing.route('/channels/<channel>/categories')
def show_tvguide_channel(channel): def show_channel_categories(channel):
""" Shows TV Channel categories """
from resources.lib.modules.channels import Channels
Channels().show_channel_categories(channel)
@routing.route('/channels/<channel>/categories/<category>')
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/<channel>/tvguide')
def show_channel_tvguide(channel):
""" Shows the dates in the tv guide """ """ Shows the dates in the tv guide """
from resources.lib.modules.tvguide import TvGuide from resources.lib.modules.tvguide import TvGuide
TvGuide().show_tvguide_channel(channel) TvGuide().show_channel(channel)
@routing.route('/tvguide/channel/<channel>/<date>') @routing.route('/channels/<channel>/tvguide/<date>')
def show_tvguide_detail(channel=None, date=None): def show_channel_tvguide_detail(channel=None, date=None):
""" Shows the programs of a specific date in the tv guide """ """ Shows the programs of a specific date in the tv guide """
from resources.lib.modules.tvguide import TvGuide from resources.lib.modules.tvguide import TvGuide
TvGuide().show_tvguide_detail(channel, date) TvGuide().show_detail(channel, date)
@routing.route('/catalog') @routing.route('/catalog')
@ -56,23 +70,30 @@ def show_catalog():
Catalog().show_catalog() Catalog().show_catalog()
@routing.route('/catalog/by-channel/<channel>') @routing.route('/catalog/<channel>')
def show_catalog_channel(channel): def show_channel_catalog(channel):
""" Show a category in the catalog """ """ Show a category in the catalog """
from resources.lib.modules.catalog import Catalog from resources.lib.modules.catalog import Catalog
Catalog().show_catalog_channel(channel) Catalog().show_catalog_channel(channel)
@routing.route('/catalog/program/<channel>/<program>') @routing.route('/catalog/<channel>/<program>')
def show_catalog_program(channel, program): def show_catalog_program(channel, program):
""" Show a program from the catalog """ """ Show a program from the catalog """
from resources.lib.modules.catalog import Catalog from resources.lib.modules.catalog import Catalog
Catalog().show_program(channel, program) Catalog().show_program(channel, program)
@routing.route('/catalog/program/<channel>/<program>/<season>') @routing.route('/catalog/<channel>/<program>/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/<channel>/<program>/season/<season>')
def show_catalog_program_season(channel, program, 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 from resources.lib.modules.catalog import Catalog
Catalog().show_program_season(channel, program, season) Catalog().show_program_season(channel, program, season)

View File

@ -22,7 +22,6 @@ class Catalog:
""" Initialise object """ """ Initialise object """
auth = AuthApi(kodiutils.get_setting('username'), kodiutils.get_setting('password'), kodiutils.get_tokens_path()) 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._api = ContentApi(auth, cache_path=kodiutils.get_cache_path())
self._menu = Menu()
def show_catalog(self): def show_catalog(self):
""" Show all the programs of all channels """ """ Show all the programs of all channels """
@ -34,7 +33,7 @@ class Catalog:
kodiutils.notification(message=str(ex)) kodiutils.notification(message=str(ex))
raise raise
listing = [self._menu.generate_titleitem(item) for item in items] listing = [Menu.generate_titleitem(item) for item in items]
# Sort items by title # Sort items by title
# Used for A-Z listing or when movies and episodes are mixed. # Used for A-Z listing or when movies and episodes are mixed.
@ -52,7 +51,7 @@ class Catalog:
listing = [] listing = []
for item in items: for item in items:
listing.append(self._menu.generate_titleitem(item)) listing.append(Menu.generate_titleitem(item))
# Sort items by title # Sort items by title
# Used for A-Z listing or when movies and episodes are mixed. # Used for A-Z listing or when movies and episodes are mixed.
@ -64,19 +63,19 @@ class Catalog:
:type program_id: str :type program_id: str
""" """
try: 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: except UnavailableException:
kodiutils.ok_dialog(message=kodiutils.localize(30717)) # This program is not available in the catalogue. kodiutils.ok_dialog(message=kodiutils.localize(30717)) # This program is not available in the catalogue.
kodiutils.end_of_directory() kodiutils.end_of_directory()
return 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.ok_dialog(message=kodiutils.localize(30717)) # This program is not available in the catalogue.
kodiutils.end_of_directory() kodiutils.end_of_directory()
return return
# Go directly to the season when we have only one season # Go directly to the season when we have only one season and no clips
if len(program.seasons) == 1: if not program.clips and len(program.seasons) == 1:
self.show_program_season(channel, program_id, list(program.seasons.values())[0].uuid) self.show_program_season(channel, program_id, list(program.seasons.values())[0].uuid)
return return
@ -85,7 +84,7 @@ class Catalog:
listing = [] listing = []
# Add an '* All seasons' entry when configured in Kodi # 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( listing.append(
TitleItem( TitleItem(
title='* %s' % kodiutils.localize(30204), # * All seasons 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. # Sort by label. Some programs return seasons unordered.
kodiutils.show_listing(listing, 30003, content='tvshows') kodiutils.show_listing(listing, 30003, content='tvshows')
@ -145,7 +163,25 @@ class Catalog:
# Show the episodes of the season that was selected # Show the episodes of the season that was selected
episodes = [e for e in program.episodes if e.season_uuid == season_uuid] 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. # Sort by episode number by default. Takes seasons into account.
kodiutils.show_listing(listing, 30003, content='episodes', sort=['episode', 'duration']) 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')

View File

@ -7,7 +7,10 @@ import logging
from resources.lib import kodiutils from resources.lib import kodiutils
from resources.lib.kodiutils import TitleItem 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 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') _LOGGER = logging.getLogger('channels')
@ -17,6 +20,8 @@ class Channels:
def __init__(self): def __init__(self):
""" Initialise object """ """ 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 @staticmethod
def show_channels(): def show_channels():
@ -33,7 +38,7 @@ class Channels:
( (
kodiutils.localize(30053, channel=channel.get('name')), # TV Guide for {channel} kodiutils.localize(30053, channel=channel.get('name')), # TV Guide for {channel}
'Container.Update(%s)' % '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) kodiutils.show_listing(listing, 30007)
@staticmethod @staticmethod
def show_channel_menu(key): def show_channel_menu(channel):
""" Shows a TV 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 # 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 = [ listing = [
TitleItem( TitleItem(
title=kodiutils.localize(30053, channel=channel.get('name')), # TV Guide for {channel} title=kodiutils.localize(30053, channel=channel_info.get('name')), # TV Guide for {channel}
path=kodiutils.url_for('show_tvguide_channel', channel=key), path=kodiutils.url_for('show_channel_tvguide', channel=channel),
art_dict={ art_dict={
'icon': 'DefaultAddonTvInfo.png', 'icon': 'DefaultAddonTvInfo.png',
'fanart': fanart, 'fanart': fanart,
}, },
info_dict={ 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( TitleItem(
title=kodiutils.localize(30055, channel=channel.get('name')), # Catalog for {channel} title=kodiutils.localize(30055, channel=channel_info.get('name')), # Catalog for {channel}
path=kodiutils.url_for('show_catalog_channel', channel=key), path=kodiutils.url_for('show_channel_catalog', channel=channel),
art_dict={ art_dict={
'icon': 'DefaultMovieTitle.png', 'icon': 'DefaultMovieTitle.png',
'fanart': fanart, 'fanart': fanart,
}, },
info_dict={ 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 # Add YouTube channels
if kodiutils.get_cond_visibility('System.HasAddon(plugin.video.youtube)') != 0: 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( listing.append(
TitleItem( TitleItem(
title=kodiutils.localize(30206, label=youtube.get('label')), # Watch {label} on YouTube 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']) 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'])

View File

@ -65,6 +65,7 @@ class Menu:
art_dict = { art_dict = {
'thumb': item.cover, 'thumb': item.cover,
'cover': item.cover, 'cover': item.cover,
'fanart': item.background or item.cover,
} }
info_dict = { info_dict = {
'title': item.title, 'title': item.title,
@ -78,9 +79,6 @@ class Menu:
# Program # Program
# #
if isinstance(item, Program): if isinstance(item, Program):
art_dict.update({
'fanart': item.background,
})
info_dict.update({ info_dict.update({
'mediatype': None, 'mediatype': None,
'season': len(item.seasons) if item.seasons else None, 'season': len(item.seasons) if item.seasons else None,
@ -102,9 +100,6 @@ class Menu:
# Episode # Episode
# #
if isinstance(item, Episode): if isinstance(item, Episode):
art_dict.update({
'fanart': item.cover,
})
info_dict.update({ info_dict.update({
'mediatype': 'episode', 'mediatype': 'episode',
'tvshowtitle': item.program_title, 'tvshowtitle': item.program_title,
@ -118,8 +113,21 @@ class Menu:
'duration': item.duration, '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'], return TitleItem(title=info_dict['title'],
path=kodiutils.url_for('play', uuid=item.uuid), path=path,
art_dict=art_dict, art_dict=art_dict,
info_dict=info_dict, info_dict=info_dict,
stream_dict=stream_dict, stream_dict=stream_dict,

View File

@ -6,6 +6,7 @@ from __future__ import absolute_import, division, unicode_literals
import logging import logging
from resources.lib import kodiutils from resources.lib import kodiutils
from resources.lib.modules.menu import Menu
from resources.lib.viervijfzes.auth import AuthApi from resources.lib.viervijfzes.auth import AuthApi
from resources.lib.viervijfzes.auth_awsidp import InvalidLoginException, AuthenticationException from resources.lib.viervijfzes.auth_awsidp import InvalidLoginException, AuthenticationException
from resources.lib.viervijfzes.content import ContentApi, UnavailableException, GeoblockedException from resources.lib.viervijfzes.content import ContentApi, UnavailableException, GeoblockedException
@ -18,6 +19,11 @@ class Player:
def __init__(self): def __init__(self):
""" Initialise object """ """ 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): def play_from_page(self, channel, path):
""" Play the requested item. """ Play the requested item.
@ -25,22 +31,37 @@ class Player:
:type path: string :type path: string
""" """
# Get episode information # 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 if episode.stream:
self.play(episode.uuid) # 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 @staticmethod
def play(item): def _resolve_stream(uuid):
""" Play the requested item. """ Resolve the stream for the requested item
:type item: string :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: try:
# Check if we have credentials # Check if we have credentials
if not kodiutils.get_setting('username') or not kodiutils.get_setting('password'): if not kodiutils.get_setting('username') or not kodiutils.get_setting('password'):
@ -49,28 +70,26 @@ class Player:
if confirm: if confirm:
kodiutils.open_settings() kodiutils.open_settings()
kodiutils.end_of_directory() kodiutils.end_of_directory()
return return None
# Fetch an auth token now # Fetch an auth token now
try: try:
auth = AuthApi(kodiutils.get_setting('username'), kodiutils.get_setting('password'), kodiutils.get_tokens_path()) auth = AuthApi(kodiutils.get_setting('username'), kodiutils.get_setting('password'), kodiutils.get_tokens_path())
# Get stream information # 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: except (InvalidLoginException, AuthenticationException) as ex:
_LOGGER.error(ex) _LOGGER.error(ex)
kodiutils.ok_dialog(message=kodiutils.localize(30702, error=str(ex))) kodiutils.ok_dialog(message=kodiutils.localize(30702, error=str(ex)))
kodiutils.end_of_directory() kodiutils.end_of_directory()
return return None
except GeoblockedException: except GeoblockedException:
kodiutils.ok_dialog(heading=kodiutils.localize(30709), message=kodiutils.localize(30710)) # This video is geo-blocked... kodiutils.ok_dialog(heading=kodiutils.localize(30709), message=kodiutils.localize(30710)) # This video is geo-blocked...
return return None
except UnavailableException: except UnavailableException:
kodiutils.ok_dialog(heading=kodiutils.localize(30711), message=kodiutils.localize(30712)) # The video is unavailable... kodiutils.ok_dialog(heading=kodiutils.localize(30711), message=kodiutils.localize(30712)) # The video is unavailable...
return return None
# Play this item
kodiutils.play(resolved_stream)

View File

@ -18,7 +18,6 @@ class Search:
def __init__(self): def __init__(self):
""" Initialise object """ """ Initialise object """
self._search = SearchApi() self._search = SearchApi()
self._menu = Menu()
def show_search(self, query=None): def show_search(self, query=None):
""" Shows the search dialog """ Shows the search dialog
@ -40,7 +39,7 @@ class Search:
return return
# Display results # 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. # Sort like we get our results back.
kodiutils.show_listing(listing, 30009, content='tvshows') kodiutils.show_listing(listing, 30009, content='tvshows')

View File

@ -12,11 +12,6 @@ from resources.lib.viervijfzes import STREAM_DICT
from resources.lib.viervijfzes.content import UnavailableException from resources.lib.viervijfzes.content import UnavailableException
from resources.lib.viervijfzes.epg import EpgApi 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') _LOGGER = logging.getLogger('tvguide')
@ -70,7 +65,7 @@ class TvGuide:
return dates return dates
def show_tvguide_channel(self, channel): def show_channel(self, channel):
""" Shows the dates in the tv guide """ Shows the dates in the tv guide
:type channel: str :type channel: str
""" """
@ -83,7 +78,7 @@ class TvGuide:
listing.append( listing.append(
TitleItem(title=title, 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={ art_dict={
'icon': 'DefaultYear.png', 'icon': 'DefaultYear.png',
'thumb': 'DefaultYear.png', 'thumb': 'DefaultYear.png',
@ -96,7 +91,7 @@ class TvGuide:
kodiutils.show_listing(listing, 30013, content='files', sort=['date']) 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 """ Shows the programs of a specific date in the tv guide
:type channel: str :type channel: str
:type date: str :type date: str
@ -108,6 +103,11 @@ class TvGuide:
kodiutils.end_of_directory() kodiutils.end_of_directory()
return return
try: # Python 3
from urllib.parse import quote
except ImportError: # Python 2
from urllib import quote
listing = [] listing = []
for program in programs: for program in programs:
if program.program_url: if program.program_url:

View File

@ -37,7 +37,8 @@ class GeoblockedException(Exception):
class Program: class Program:
""" Defines a 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 uuid: str
:type path: str :type path: str
@ -49,6 +50,7 @@ class Program:
:type background: str :type background: str
:type seasons: list[Season] :type seasons: list[Season]
:type episodes: list[Episode] :type episodes: list[Episode]
:type clips: list[Episode]
""" """
self.uuid = uuid self.uuid = uuid
self.path = path self.path = path
@ -60,6 +62,7 @@ class Program:
self.background = background self.background = background
self.seasons = seasons self.seasons = seasons
self.episodes = episodes self.episodes = episodes
self.clips = clips
def __repr__(self): def __repr__(self):
return "%r" % self.__dict__ return "%r" % self.__dict__
@ -94,8 +97,8 @@ class Season:
class Episode: class Episode:
""" Defines an 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, def __init__(self, uuid=None, nodeid=None, path=None, channel=None, program_title=None, title=None, description=None, cover=None, background=None,
season=None, season_uuid=None, number=None, rating=None, aired=None, expiry=None): duration=None, season=None, season_uuid=None, number=None, rating=None, aired=None, expiry=None, stream=None):
""" """
:type uuid: str :type uuid: str
:type nodeid: str :type nodeid: str
@ -105,6 +108,7 @@ class Episode:
:type title: str :type title: str
:type description: str :type description: str
:type cover: str :type cover: str
:type background: str
:type duration: int :type duration: int
:type season: int :type season: int
:type season_uuid: str :type season_uuid: str
@ -112,6 +116,7 @@ class Episode:
:type rating: str :type rating: str
:type aired: datetime :type aired: datetime
:type expiry: datetime :type expiry: datetime
:type stream: string
""" """
self.uuid = uuid self.uuid = uuid
self.nodeid = nodeid self.nodeid = nodeid
@ -121,6 +126,7 @@ class Episode:
self.title = title self.title = title
self.description = description self.description = description
self.cover = cover self.cover = cover
self.background = background
self.duration = duration self.duration = duration
self.season = season self.season = season
self.season_uuid = season_uuid self.season_uuid = season_uuid
@ -128,6 +134,28 @@ class Episode:
self.rating = rating self.rating = rating
self.aired = aired self.aired = aired
self.expiry = expiry 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): def __repr__(self):
return "%r" % self.__dict__ return "%r" % self.__dict__
@ -196,7 +224,7 @@ class ContentApi:
title=title)) title=title))
return programs 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. """ Get a Program object from the specified page.
:type channel: str :type channel: str
:type path: str :type path: str
@ -206,11 +234,18 @@ class ContentApi:
if channel not in CHANNELS: if channel not in CHANNELS:
raise Exception('Unknown channel %s' % channel) 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(): def update():
""" Fetch the program metadata by scraping """ """ Fetch the program metadata by scraping """
# Fetch webpage # Fetch webpage
page = self._get_url(CHANNELS[channel]['url'] + '/' + path) page = self._get_url(CHANNELS[channel]['url'] + '/' + path)
# Store a copy in the parent's raw_html var.
raw_html[0] = page
# Extract JSON # Extract JSON
regex_program = re.compile(r'data-hero="([^"]+)', re.DOTALL) regex_program = re.compile(r'data-hero="([^"]+)', re.DOTALL)
json_data = HTMLParser().unescape(regex_program.search(page).group(1)) json_data = HTMLParser().unescape(regex_program.search(page).group(1))
@ -220,41 +255,80 @@ class ContentApi:
# Fetch listing from cache or update if needed # Fetch listing from cache or update if needed
data = self._handle_cache(key=['program', channel, path], cache_mode=cache, update=update) data = self._handle_cache(key=['program', channel, path], cache_mode=cache, update=update)
if not data:
return None
program = self._parse_program_data(data) 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 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. """ Get a Episode object from the specified page.
:type channel: str :type channel: str
:type path: str :type path: str
:type cache: str
:rtype Episode :rtype Episode
NOTE: This function doesn't use an API.
""" """
if channel not in CHANNELS: if channel not in CHANNELS:
raise Exception('Unknown channel %s' % channel) raise Exception('Unknown channel %s' % channel)
# Load webpage def update():
page = self._get_url(CHANNELS[channel]['url'] + '/' + path) """ Fetch the program metadata by scraping """
# Load webpage
page = self._get_url(CHANNELS[channel]['url'] + '/' + path)
# Extract program JSON parser = HTMLParser()
parser = HTMLParser() program_json = None
regex_program = re.compile(r'data-hero="([^"]+)', re.DOTALL) episode_json = None
json_data = parser.unescape(regex_program.search(page).group(1))
data = json.loads(json_data)['data']
program = self._parse_program_data(data)
# Extract episode JSON # Extract video JSON by looking for a data-video tag
regex_episode = re.compile(r'<script type="application/json" data-drupal-selector="drupal-settings-json">(.*?)</script>', re.DOTALL) # This is not present on every page
json_data = parser.unescape(regex_episode.search(page).group(1)) regex_video_data = re.compile(r'data-video="([^"]+)"', re.DOTALL)
data = json.loads(json_data) 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 # Extract program JSON
# The episode we just found doesn't contain all information regex_program = re.compile(r'data-hero="([^"]+)', re.DOTALL)
for episode in program.episodes: result = regex_program.search(page)
if episode.nodeid == data['pageInfo']['nodeId']: if result:
return episode program_json_data = parser.unescape(result.group(1))
program_json = json.loads(program_json_data)['data']
# Extract episode JSON
regex_episode = re.compile(r'<script type="application/json" data-drupal-selector="drupal-settings-json">(.*?)</script>', 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 return None
@ -267,15 +341,146 @@ class ContentApi:
data = json.loads(response) data = json.loads(response)
return data['video']['S'] 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'<article([^>]+)>(.*?)</article>', 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'<a[^>]+?href="(?P<path>[^"]+)"[^>]+?>'
r'.*?<h3 class="poster-teaser__title"><span>(?P<title>[^<]*)</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 @staticmethod
def _parse_program_data(data): def _parse_program_data(data):
""" Parse the Program JSON. """ Parse the Program JSON.
:type data: dict :type data: dict
:rtype Program :rtype Program
""" """
if data is None:
return None
# Create Program info # Create Program info
program = Program( program = Program(
uuid=data['id'], uuid=data['id'],
@ -311,7 +516,7 @@ class ContentApi:
return program return program
@staticmethod @staticmethod
def _parse_episode_data(data, season_uuid): def _parse_episode_data(data, season_uuid=None):
""" Parse the Episode JSON. """ Parse the Episode JSON.
:type data: dict :type data: dict
:type season_uuid: str :type season_uuid: str
@ -337,13 +542,15 @@ class ContentApi:
title=data.get('title'), title=data.get('title'),
description=data.get('pageInfo', {}).get('description'), description=data.get('pageInfo', {}).get('description'),
cover=data.get('image'), cover=data.get('image'),
background=data.get('image'),
duration=data.get('duration'), duration=data.get('duration'),
season=data.get('seasonNumber'), season=data.get('seasonNumber'),
season_uuid=season_uuid, season_uuid=season_uuid,
number=episode_number, number=episode_number,
aired=datetime.fromtimestamp(data.get('createdDate')), aired=datetime.fromtimestamp(data.get('createdDate')),
expiry=datetime.fromtimestamp(data.get('unpublishDate')) if data.get('unpublishDate') else None, 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 return episode
@ -393,7 +600,7 @@ class ContentApi:
def _get_cache(self, key, allow_expired=False): def _get_cache(self, key, allow_expired=False):
""" Get an item from the cache """ """ Get an item from the cache """
filename = '.'.join(key) + '.json' filename = ('.'.join(key) + '.json').replace('/', '_')
fullpath = self._cache_path + filename fullpath = self._cache_path + filename
if not os.path.exists(fullpath): if not os.path.exists(fullpath):
@ -412,7 +619,7 @@ class ContentApi:
def _set_cache(self, key, data, ttl): def _set_cache(self, key, data, ttl):
""" Store an item in the cache """ """ Store an item in the cache """
filename = '.'.join(key) + '.json' filename = ('.'.join(key) + '.json').replace('/', '_')
fullpath = self._cache_path + filename fullpath = self._cache_path + filename
if not os.path.exists(self._cache_path): if not os.path.exists(self._cache_path):

View File

@ -10,7 +10,7 @@ import unittest
import resources.lib.kodiutils as kodiutils import resources.lib.kodiutils as kodiutils
from resources.lib.viervijfzes.auth import AuthApi 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') _LOGGER = logging.getLogger('test-api')
@ -27,14 +27,31 @@ class TestApi(unittest.TestCase):
self.assertIsInstance(programs, list) self.assertIsInstance(programs, list)
self.assertIsInstance(programs[0], Program) 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): def test_episodes(self):
for channel, program in [('vier', 'auwch'), ('vijf', 'zo-man-zo-vrouw')]: 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, Program)
self.assertIsInstance(program.seasons, dict) self.assertIsInstance(program.seasons, dict)
self.assertIsInstance(program.episodes, list) self.assertIsInstance(program.episodes, list)
self.assertIsInstance(program.episodes[0], Episode) 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.') @unittest.skipUnless(kodiutils.get_setting('username') and kodiutils.get_setting('password'), 'Skipping since we have no credentials.')
def test_get_stream(self): def test_get_stream(self):
program = self._api.get_program('vier', 'auwch') program = self._api.get_program('vier', 'auwch')

View File

@ -40,7 +40,7 @@ class TestRouting(unittest.TestCase):
routing.run([routing.url_for(addon.show_catalog), '0', '']) routing.run([routing.url_for(addon.show_catalog), '0', ''])
def test_catalog_channel_menu(self): 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): def test_catalog_program_menu(self):
routing.run([routing.url_for(addon.show_catalog_program, channel='vier', program='de-mol'), '0', '']) 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', '']) routing.run([routing.url_for(addon.show_search, query='de mol'), '0', ''])
def test_tvguide_menu(self): def test_tvguide_menu(self):
routing.run([routing.url_for(addon.show_tvguide_channel, channel='vier'), '0', '']) routing.run([routing.url_for(addon.show_channel_tvguide, 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_detail, channel='vier', date='today'), '0', ''])
def test_metadata_update(self): def test_metadata_update(self):
routing.run([routing.url_for(addon.metadata_update), '0', '']) routing.run([routing.url_for(addon.metadata_update), '0', ''])