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]"
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"

View File

@ -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"

View File

@ -35,18 +35,32 @@ def show_channel_menu(channel):
Channels().show_channel_menu(channel)
@routing.route('/tvguide/channel/<channel>')
def show_tvguide_channel(channel):
@routing.route('/channels/<channel>/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/<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 """
from resources.lib.modules.tvguide import TvGuide
TvGuide().show_tvguide_channel(channel)
TvGuide().show_channel(channel)
@routing.route('/tvguide/channel/<channel>/<date>')
def show_tvguide_detail(channel=None, date=None):
@routing.route('/channels/<channel>/tvguide/<date>')
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/<channel>')
def show_catalog_channel(channel):
@routing.route('/catalog/<channel>')
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/<channel>/<program>')
@routing.route('/catalog/<channel>/<program>')
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/<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):
""" 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)

View File

@ -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')

View File

@ -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'])

View File

@ -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,

View File

@ -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

View File

@ -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')

View File

@ -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:

View File

@ -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'<script type="application/json" data-drupal-selector="drupal-settings-json">(.*?)</script>', 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'<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
@ -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'<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
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):

View File

@ -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')

View File

@ -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', ''])