From 871312f3428b6de16fe7c980963d2a4ddae7ec13 Mon Sep 17 00:00:00 2001 From: Alexander Seiler Date: Thu, 22 Nov 2018 13:51:39 +0100 Subject: [PATCH] Add library code. --- addon.xml | 22 + lib/srgssr.py | 1113 +++++++++++++++++ lib/utils.py | 371 ++++++ resources/icon.png | Bin 0 -> 7237 bytes .../resource.language.de_de/strings.po | 68 + .../resource.language.en_gb/strings.po | 68 + .../resource.language.fr_fr/strings.po | 68 + .../resource.language.it_it/strings.po | 68 + 8 files changed, 1778 insertions(+) create mode 100644 addon.xml create mode 100644 lib/srgssr.py create mode 100644 lib/utils.py create mode 100644 resources/icon.png create mode 100644 resources/language/resource.language.de_de/strings.po create mode 100644 resources/language/resource.language.en_gb/strings.po create mode 100644 resources/language/resource.language.fr_fr/strings.po create mode 100644 resources/language/resource.language.it_it/strings.po diff --git a/addon.xml b/addon.xml new file mode 100644 index 0000000..2ef4e41 --- /dev/null +++ b/addon.xml @@ -0,0 +1,22 @@ + + + + + + + + + + Access the SRG SSR media libraries. + Zugriff auf die Mediatheken von SRG SSR. + This addon allows a plugin to use the media libraries of SRG SSR. + Dieses Addon erlaubt Plugins den Zugriff auf die Mediatheken von SRG SSR. + all + GNU GENERAL PUBLIC LICENSE. Version 3, June 2007 + seileralex@gmail.com + https://github.com/goggle/script.module.srgssr + + resources/icon.png + + + diff --git a/lib/srgssr.py b/lib/srgssr.py new file mode 100644 index 0000000..310b1a1 --- /dev/null +++ b/lib/srgssr.py @@ -0,0 +1,1113 @@ +# -*- coding: utf-8 -*- + +# Copyright (C) 2018 Alexander Seiler +# +# +# This file is part of script.module.srgssr. +# +# script.module.srgssr is free software: you can redistribute it and/or +# modify it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# script.module.srgssr is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with script.module.srgssr. +# If not, see . + +import os +import sys +import re +import traceback + +import datetime +import json +import socket +import urllib2 +import urllib +import urlparse + +import xbmc +import xbmcgui +import xbmcplugin +import xbmcaddon + +from simplecache import SimpleCache +import utils + +ADDON_ID = 'script.module.srgssr' +REAL_SETTINGS = xbmcaddon.Addon(id=ADDON_ID) +ADDON_NAME = REAL_SETTINGS.getAddonInfo('name') +ADDON_VERSION = REAL_SETTINGS.getAddonInfo('version') +ICON = REAL_SETTINGS.getAddonInfo('icon') +LANGUAGE = REAL_SETTINGS.getLocalizedString +TIMEOUT = 30 + +IDREGEX = r'[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|\d+' + +FAVOURITE_SHOWS_FILENAME = 'favourite_shows.json' + +socket.setdefaulttimeout(TIMEOUT) + + +def get_params(): + return dict(urlparse.parse_qsl(sys.argv[2][1:])) + + +class SRGSSR(object): + def __init__(self, plugin_handle, bu='srf', addon_id=ADDON_ID): + self.handle = plugin_handle + self.cache = SimpleCache() + self.real_settings = xbmcaddon.Addon(id=addon_id) + self.bu = bu + self.addon_id = addon_id + self.icon = self.real_settings.getAddonInfo('icon') + self.fanart = self.real_settings.getAddonInfo('fanart') + self.language = LANGUAGE + self.plugin_language = self.real_settings.getLocalizedString + self.host_url = 'https://www.%s.ch' % bu + + # Plugin options: + self.debug = self.get_boolean_setting( + 'Enable_Debugging') + self.segments = self.get_boolean_setting( + 'Enable_Show_Segments') + self.segments_topics = self.get_boolean_setting( + 'Enable_Segments_Topics') + self.subtitles = self.get_boolean_setting( + 'Extract_Subtitles') + self.prefer_hd = self.get_boolean_setting( + 'Prefer_HD') + self.number_of_episodes = 10 + + def get_boolean_setting(self, setting): + return self.real_settings.getSetting(setting) == 'true' + + def log(self, msg, level=xbmc.LOGDEBUG): + """ + Logs a message using Kodi's logging interface. + + Keyword arguments: + msg -- the message to log + level -- the logging level + """ + if isinstance(msg, str): + msg = msg.decode('utf-8') + if self.debug: + if level == xbmc.LOGERROR: + msg += ' ,' + traceback.format_exc() + message = ADDON_ID + '-' + ADDON_VERSION + '-' + msg + xbmc.log(msg=message.encode('utf-8'), level=level) + + @staticmethod + def build_url(mode=None, name=None, url=None, page_hash=None, page=None): + """Build a URL for the Kodi plugin. + + Keyword arguments: + mode -- an integer representing the mode + name -- a string containing some information, e.g. a video id + url -- a plugin URL, if another plugin/script needs to called + page_hash -- a string (used to get additional videos through the API) + page -- an integer used to indicate the current page in + the list of items + """ + if mode: + mode = str(mode) + if page: + page = str(page) + added = False + queries = (url, mode, name, page_hash, page) + query_names = ('url', 'mode', 'name', 'page_hash', 'page') + purl = sys.argv[0] + # purl='script.module.srgssr' + for query, qname in zip(queries, query_names): + if query: + add = '?' if not added else '&' + purl += '%s%s=%s' % (add, qname, urllib.quote_plus(query)) + added = True + return purl + + def open_url(self, url, use_cache=True): + """Open and read the content given by a URL. + + Keyword arguments: + url -- the URL to open as a string + use_cache -- boolean to indicate if the cache provided by the + Kodi module SimpleCache should be used (default: True) + """ + self.log('open_url, url = ' + str(url)) + try: + cache_response = None + if use_cache: + cache_response = self.cache.get( + ADDON_NAME + '.openURL, url = %s' % url) + if not cache_response: + request = urllib2.Request(url) + request.add_header( + 'User-Agent', + ('Mozilla/5.0 (X11; Linux x86_64; rv:59.0)' + 'Gecko/20100101 Firefox/59.0')) + response = urllib2.urlopen(request, timeout=TIMEOUT).read() + self.cache.set( + ADDON_NAME + '.openURL, url = %s' % url, + response, + expiration=datetime.timedelta(hours=2)) + return self.cache.get(ADDON_NAME + '.openURL, url = %s' % url) + except urllib2.URLError as err: + self.log("openURL Failed! " + str(err), xbmc.LOGERROR) + except socket.timeout as err: + self.log("openURL Failed! " + str(err), xbmc.LOGERROR) + except Exception as err: + self.log("openURL Failed! " + str(err), xbmc.LOGERROR) + xbmcgui.Dialog().notification( + ADDON_NAME, LANGUAGE(30100), ICON, 4000) + return '' + + def build_main_menu(self, identifiers=[]): + """ + Builds the main menu of the plugin: + + Keyword arguments: + identifiers -- A list of strings containing the identifiers + of the menus to display. + """ + self.log('build_main_menu') + main_menu_list = [ + { + # All shows + 'identifier': 'All_Shows', + 'name': self.plugin_language(30050), + 'mode': 10, + 'displayItem': self.get_boolean_setting('All_Shows') + }, { + # Favourite shows + 'identifier': 'Favourite_Shows', + 'name': self.plugin_language(30051), + 'mode': 11, + 'displayItem': self.get_boolean_setting('Favourite_Shows') + }, { + # Newest favourite shows + 'identifier': 'Newest_Favourite_Shows', + 'name': self.plugin_language(30052), + 'mode': 12, + 'displayItem': self.get_boolean_setting( + 'Newest_Favourite_Shows') + }, { + # Recommendations + 'identifier': 'Recommendations', + 'name': self.plugin_language(30053), + 'mode': 16, + 'displayItem': self.get_boolean_setting('Recommendations') + }, { + # Newest shows + 'identifier': 'Newest_Shows', + 'name': self.plugin_language(30054), + 'mode': 13, + 'displayItem': self.get_boolean_setting('Newest_Shows') + }, { + # Most clicked shows + 'identifier': 'Most_Clicked_Shows', + 'name': self.plugin_language(30055), + 'mode': 14, + 'displayItem': self.get_boolean_setting('Most_Clicked_Shows') + }, { + # Soon offline + 'identifier': 'Soon_Offline', + 'name': self.plugin_language(30056), + 'mode': 15, + 'displayItem': self.get_boolean_setting('Soon_Offline') + }, { + # Shows by date + 'identifier': 'Shows_By_Date', + 'name': self.plugin_language(30057), + 'mode': 17, + 'displayItem': self.get_boolean_setting('Shows_By_Date') + }, { + # Live TV + 'identifier': 'Live_TV', + 'name': self.plugin_language(30072), + 'mode': 26, + 'displayItem': self.get_boolean_setting('Live_TV') + }, { + # SRF.ch live + 'identifier': 'SRF_Live', + 'name': self.plugin_language(30070), + 'mode': 18, + 'displayItem': self.get_boolean_setting('SRF_Live') + }, { + # SRF on YouTube + 'identifier': 'SRF_YouTube', + 'name': self.plugin_language(30074), + 'mode': 30, + 'displayItem': self.get_boolean_setting('SRF_YouTube') + }, { + # RTS on YouTube + 'identifier': 'RTS_YouTube', + 'name': self.plugin_language(30075), + 'mode': 30, + 'displayItem': self.get_boolean_setting('RTS_YouTube') + } + ] + for item in main_menu_list: + if item['displayItem'] and item['identifier'] in identifiers: + list_item = xbmcgui.ListItem(item['name']) + list_item.setProperty('IsPlayable', 'false') + list_item.setArt({'thumb': self.icon}) + purl = self.build_url( + mode=item['mode'], name=item['identifier']) + xbmcplugin.addDirectoryItem( + handle=self.handle, url=purl, + listitem=list_item, isFolder=True) + + def read_all_available_shows(self): + """ + Downloads a list of all available shows and returns this list. + """ + json_url = ('http://il.srgssr.ch/integrationlayer/1.0/ue/%s/tv/' + 'assetGroup/editorialPlayerAlphabetical.json') % self.bu + json_response = json.loads(self.open_url(json_url)) + try: + show_list = json_response['AssetGroups']['Show'] + except KeyError: + self.log('read_all_available_shows: No shows found.') + return [] + if not isinstance(show_list, list) or not show_list: + self.log('read_all_available_shows: No shows found.') + return [] + return show_list + + def build_all_shows_menu(self, favids=None): + """ + Builds a list of folders containing the names of all the current + shows. + + Keyword arguments: + favids -- A list of show ids (strings) respresenting the favourite + shows. If such a list is provided, only the folders for + the shows on that list will be build. (default: None) + """ + self.log('build_all_shows_menu') + show_list = self.read_all_available_shows() + + list_items = [] + for jse in show_list: + try: + title = utils.str_or_none(jse['title']) + show_id = utils.str_or_none(jse['id']) + except KeyError: + self.log( + 'build_all_shows_menu: Skipping, no title or id found.') + continue + + # Skip if we build the 'favourite show menu' and the current + # show id is not in our favourites: + if favids is not None and show_id not in favids: + continue + + list_item = xbmcgui.ListItem(label=title) + list_item.setProperty('IsPlayable', 'false') + list_item.setInfo( + 'video', + { + 'title': title, + 'plot': utils.str_or_none(jse.get('lead')), + } + ) + + try: + image_url = utils.str_or_none( + jse['Image']['ImageRepresentations'] + ['ImageRepresentation'][0]['url']) + thumbnail = image_url + '/scale/width/668'\ + if image_url else self.icon + banner = image_url.replace( + 'WEBVISUAL', + 'HEADER_%s_PLAYER' % self.bu.upper() + ) if image_url else None + except (KeyError, IndexError): + image_url = self.fanart + thumbnail = self.icon + + list_item.setArt({ + 'thumb': thumbnail, + 'poster': image_url, + 'banner': banner, + }) + url = self.build_url(mode=20, name=show_id) + list_items.append((url, list_item, True)) + xbmcplugin.addDirectoryItems( + self.handle, list_items, totalItems=len(list_items)) + + def build_favourite_shows_menu(self): + """ + Builds a list of folders for the favourite shows. + """ + self.log('build_favourite_shows_menu') + favourite_show_ids = self.read_favourite_show_ids() + self.build_all_shows_menu(favids=favourite_show_ids) + + def build_newest_favourite_menu(self, page=1): + """ + Builds a Kodi list of the newest favourite shows. + + Keyword arguments: + page -- an integer indicating the current page on the + list (default: 1) + """ + self.log('build_newest_favourite_menu') + number_of_days = 30 + show_ids = self.read_favourite_show_ids() + + # TODO: This depends on the local time settings + now = datetime.datetime.now() + current_month_date = datetime.date.today().strftime('%m-%Y') + list_of_episodes_dict = [] + banners = {} + for sid in show_ids: + json_url = ('%s/play/tv/show/%s/latestEpisodes?numberOfEpisodes=%d' + '&tillMonth=%s') % (self.host_url, sid, number_of_days, + current_month_date) + self.log('build_newest_favourite_menu. Open URL %s.' % json_url) + response = json.loads(self.open_url(json_url)) + try: + banner_image = utils.str_or_none( + response['show']['bannerImageUrl'], default='') + if banner_image.endswith('/3x1'): + banner_image += '/scale/width/1000' + except KeyError: + banner_image = None + + episode_list = response.get('episodes', []) + for episode in episode_list: + date_time = utils.parse_datetime( + utils.str_or_none(episode.get('date'), default='')) + if date_time and \ + date_time >= now + datetime.timedelta(-number_of_days): + list_of_episodes_dict.append(episode) + banners.update({episode.get('id'): banner_image}) + sorted_list_of_episodes_dict = sorted( + list_of_episodes_dict, key=lambda k: utils.parse_datetime( + utils.str_or_none(k.get('date'), default='')), reverse=True) + try: + page = int(page) + except TypeError: + page = 1 + reduced_list = sorted_list_of_episodes_dict[ + (page - 1)*self.number_of_episodes:page*self.number_of_episodes] + for episode in reduced_list: + segments = episode.get('segments', []) + is_folder = True if segments and self.segments else False + self.build_entry( + episode, banner=banners.get(episode.get('id')), + is_folder=is_folder) + + if len(sorted_list_of_episodes_dict) > page * self.number_of_episodes: + next_item = xbmcgui.ListItem(label=LANGUAGE(30073)) # Next page + next_item.setProperty('IsPlayable', 'false') + purl = self.build_url(mode=12, page=page+1) + xbmcplugin.addDirectoryItem( + self.handle, purl, next_item, isFolder=True) + + def build_show_menu(self, show_id, page_hash=None): + """ + Builds a list of videos (can be folders in case of segmented videos) + for a show given by its show id. + + Keyword arguments: + show_id -- the id of the show + page_hash -- the page hash to get the list of + another page (default: None) + """ + self.log('build_show_menu, show_id = %s, page_hash=%s' % (show_id, + page_hash)) + # TODO: This depends on the local time settings + current_month_date = datetime.date.today().strftime('%m-%Y') + if not page_hash: + json_url = ('%s/play/tv/show/%s/latestEpisodes?numberOfEpisodes=%d' + '&tillMonth=%s') % (self.host_url, show_id, + self.number_of_episodes, + current_month_date) + else: + json_url = ('%s/play/tv/show/%s/latestEpisodes?nextPageHash=%s' + '&tillMonth=%s') % (self.host_url, show_id, page_hash, + current_month_date) + self.log('build_show_menu. Open URL %s' % json_url) + json_response = json.loads(self.open_url(json_url)) + + try: + banner_image = utils.str_or_none( + json_response['show']['bannerImageUrl'], default='') + if banner_image.endswith('/3x1'): + banner_image += '/scale/width/1000' + except KeyError: + banner_image = None + + next_page_hash = None + if 'nextPageUrl' in json_response: + next_page_url = utils.str_or_none( + json_response.get('nextPageUrl'), default='') + next_page_hash_regex = r'nextPageHash=(?P[0-9a-f]+)' + match = re.search(next_page_hash_regex, next_page_url) + if match: + next_page_hash = match.group('hash') + + json_episode_list = json_response.get('episodes', []) + if not json_episode_list: + self.log('No episodes for show %s found.' % show_id) + return + + for episode_entry in json_episode_list: + segments = episode_entry.get('segments', []) + enable_segments = True if self.segments and segments else False + self.build_entry( + episode_entry, banner=banner_image, is_folder=enable_segments) + + if next_page_hash and page_hash != next_page_hash: + self.log('page_hash: %s' % page_hash) + self.log('next_hash: %s' % next_page_hash) + next_item = xbmcgui.ListItem(label=LANGUAGE(30073)) # Next page + next_item.setProperty('IsPlayable', 'false') + url = self.build_url( + mode=20, name=show_id, page_hash=next_page_hash) + xbmcplugin.addDirectoryItem( + self.handle, url, next_item, isFolder=True) + + def build_topics_overview_menu(self, newest_or_most_clicked): + """ + Builds a list of folders, where each folders represents a + topic (e.g. News). + + Keyword arguments: + newest_or_most_clicked -- a string (either 'Newest' or 'Most clicked') + """ + self.log('build_topics_overview_menu, newest_or_most_clicked = %s' % + newest_or_most_clicked) + if newest_or_most_clicked == 'Newest': + mode = 22 + elif newest_or_most_clicked == 'Most clicked': + mode = 23 + else: + self.log('build_topics_overview_menu: Unknown mode, \ + must be "Newest" or "Most clicked".') + return + topics_url = self.host_url + '/play/tv/topicList' + topics_json = json.loads(self.open_url(topics_url)) + if not isinstance(topics_json, list) or not topics_json: + self.log('No topics found.') + return + for elem in topics_json: + list_item = xbmcgui.ListItem(label=elem.get('title')) + list_item.setProperty('IsPlayable', 'false') + list_item.setArt({'thumb': self.icon}) + name = elem.get('id') + if name: + purl = self.build_url(mode=mode, name=name) + xbmcplugin.addDirectoryItem( + handle=self.handle, url=purl, + listitem=list_item, isFolder=True) + + def extract_id_list(self, url): + """ + Opens a webpage and extracts video ids (of the form "id": "") + from JavaScript snippets. + + Keyword argmuents: + url -- the URL of the webpage + """ + self.log('extract_id_list, url = %s' % url) + response = self.open_url(url) + string_response = utils.str_or_none(response, default='') + if not string_response: + self.log('No video ids found on %s' % url) + return [] + readable_string_response = string_response.replace('"', '"') + id_regex = r'''(?x) + \"id\" + \s*:\s* + \" + (?P + %s + ) + \" + ''' % IDREGEX + id_list = [m.group('id') for m in re.finditer( + id_regex, readable_string_response)] + return id_list + + def build_topics_menu(self, name, topic_id=None, page=1): + """ + Builds a list of videos (can also be folders) for a given topic. + + Keyword arguments: + name -- the type of the list, can be 'Newest', 'Most clicked', + 'Soon offline' or 'Trending'. + topic_id -- the SRF topic id for the given topic, this is only needed + for the types 'Newest' and 'Most clicked' (default: None) + page -- an integer representing the current page in the list + """ + self.log('build_topics_menu, name = %s, topic_id = %s, page = %s' % + (name, topic_id, page)) + number_of_videos = 50 + if name == 'Newest': + url = '%s/play/tv/topic/%s/latest?numberOfVideos=%s' % ( + self.host_url, topic_id, number_of_videos) + mode = 22 + elif name == 'Most clicked': + url = '%s/play/tv/topic/%s/mostClicked?numberOfVideos=%s' % ( + self.host_url, topic_id, number_of_videos) + mode = 23 + elif name == 'Soon offline': + url = '%s/play/tv/videos/soon-offline-videos?numberOfVideos=%s' % ( + self.host_url, number_of_videos) + mode = 15 + elif name == 'Trending': + url = ('%s/play/tv/videos/trending?numberOfVideos=%s' + '&onlyEpisodes=true&includeEditorialPicks=true') % ( + self.host_url, number_of_videos) + mode = 16 + else: + self.log('build_topics_menu: Unknown mode.') + return + + id_list = self.extract_id_list(url) + try: + page = int(page) + except TypeError: + page = 1 + + reduced_id_list = id_list[(page - 1) * self.number_of_episodes: + page * self.number_of_episodes] + for vid in reduced_id_list: + self.build_episode_menu( + vid, include_segments=False, + segment_option=self.segments_topics) + + try: + vid = id_list[page*self.number_of_episodes] + next_item = xbmcgui.ListItem(label=LANGUAGE(30073)) # Next page + next_item.setProperty('IsPlayable', 'false') + name = topic_id if topic_id else '' + purl = self.build_url(mode=mode, name=name, page=page+1) + xbmcplugin.addDirectoryItem( + handle=self.handle, url=purl, + listitem=next_item, isFolder=True) + except IndexError: + return + + def build_episode_menu(self, video_id, include_segments=True, + segment_option=False): + """ + Builds a list entry for a episode by a given video id. + The segment entries for that episode can be included too. + The video id can be an id of a segment. In this case an + entry for the segment will be created. + + Keyword arguments: + video_id -- the id of the video + include_segments -- indicates if the segments (if available) of the + video should be included in the list + (default: True) + segment_option -- Which segment option to use. + (default: False) + """ + self.log('build_episode_menu, video_id = %s, include_segments = %s' % + (video_id, include_segments)) + json_url = ('https://il.srgssr.ch/integrationlayer/2.0/%s/' + 'mediaComposition/video/%s.json') % (self.bu, video_id) + self.log('build_episode_menu. Open URL %s' % json_url) + try: + json_response = json.loads(self.open_url(json_url)) + except Exception: + self.log('build_episode_menu: Cannot open media json for %s.' + % video_id) + return + + chapter_urn = json_response.get('chapterUrn', '') + segment_urn = json_response.get('segmentUrn', '') + + id_regex = r'[a-z]+:[a-z]+:[a-z]+:(?P.+)' + match_chapter_id = re.match(id_regex, chapter_urn) + match_segment_id = re.match(id_regex, segment_urn) + chapter_id = match_chapter_id.group('id') if match_chapter_id else None + segment_id = match_segment_id.group('id') if match_segment_id else None + + if not chapter_id: + self.log('build_episode_menu: No valid chapter URN \ + available for video_id %s' % video_id) + return + + try: + banner = utils.str_or_none( + json_response['show']['bannerImageUrl'], default='') + if banner.endswith('/3x1'): + banner += '/scale/width/1000' + except KeyError: + banner = None + + json_chapter_list = json_response.get('chapterList', []) + json_chapter = None + for chapter in json_chapter_list: + if chapter.get('id') == chapter_id: + json_chapter = chapter + break + if not json_chapter: + self.log('build_episode_menu: No chapter ID found \ + for video_id %s' % video_id) + return + + json_segment_list = json_chapter.get('segmentList', []) + if video_id == chapter_id: + if include_segments: + # Generate entries for the whole video and + # all the segments of this video. + self.build_entry(json_chapter, banner) + for segment in json_segment_list: + self.build_entry(segment, banner) + else: + if segment_option and json_segment_list: + # Generate a folder for the video + self.build_entry(json_chapter, banner, is_folder=True) + else: + # Generate a simple playable item for the video + self.build_entry(json_chapter, banner) + else: + json_segment = None + for segment in json_segment_list: + if segment.get('id') == segment_id: + json_segment = segment + break + if not json_segment: + self.log('build_episode_menu: No segment ID found \ + for video_id %s' % video_id) + return + # Generate a simple playable item for the video + self.build_entry(json_segment, banner) + + def build_entry(self, json_entry, banner=None, is_folder=False): + """ + Builds an list item for a video or folder by giving the json part, + describing this video. + + Keyword arguments: + json_entry -- the part of the json describing the video + banner -- URL of the show's banner (default: None) + is_folder -- indicates if the item is a folder (default: False) + """ + self.log('build_entry') + title = json_entry.get('title') + vid = json_entry.get('id') + description = json_entry.get('description') + + image = json_entry.get('imageUrl', '') + # RTS image links have a strange appendix '/16x9'. + # This needs to be removed from the URL: + image = image.strip('/16x9') + + duration = utils.int_or_none(json_entry.get('duration'), scale=1000) + if not duration: + duration = utils.get_duration(json_entry.get('duration')) + date_string = utils.str_or_none(json_entry.get('date'), default='') + dto = utils.parse_datetime(date_string) + kodi_date_string = dto.strftime('%Y-%m-%d') if dto else None + + list_item = xbmcgui.ListItem(label=title) + list_item.setInfo( + 'video', + { + 'title': title, + 'plot': description, + 'duration': duration, + 'aired': kodi_date_string, + } + ) + list_item.setArt({ + 'thumb': image, + 'poster': image, + 'banner': banner, + }) + subs = json_entry.get('subtitleList', []) + if subs and self.subtitles: + subtitle_list = [x.get('url') for x in subs if + x.get('format') == 'VTT'] + if subtitle_list: + list_item.setSubtitles(subtitle_list) + else: + self.log('No WEBVTT subtitles found for video id %s.' % vid) + if is_folder: + list_item.setProperty('IsPlayable', 'false') + url = self.build_url(mode=21, name=vid) + else: + list_item.setProperty('IsPlayable', 'true') + url = self.build_url(mode=50, name=vid) + xbmcplugin.addDirectoryItem( + self.handle, url, list_item, isFolder=is_folder) + + def build_dates_overview_menu(self): + """ + Builds the menu containing the folders for episodes of + the last 10 days. + """ + self.log('build_dates_overview_menu') + + def folder_name(dato): + """ + Generates a Kodi folder name from an date object. + + Keyword arguments: + dato -- a date object + """ + weekdays = ( + self.language(30060), # Monday + self.language(30061), # Tuesday + self.language(30062), # Wednesday + self.language(30063), # Thursday + self.language(30064), # Friday + self.language(30065), # Saturday + self.language(30066) # Sunday + ) + today = datetime.date.today() + if dato == today: + name = self.language(30058) # Today + elif dato == today + datetime.timedelta(-1): + name = self.language(30059) # Yesterday + else: + name = '%s, %s' % (weekdays[dato.weekday()], + dato.strftime('%d.%m.%Y')) + return name + + current_date = datetime.date.today() + number_of_days = 7 + + for i in range(number_of_days): + dato = current_date + datetime.timedelta(-i) + list_item = xbmcgui.ListItem(label=folder_name(dato)) + list_item.setArt({'thumb': self.icon}) + name = dato.strftime('%d-%m-%Y') + purl = self.build_url(mode=24, name=name) + xbmcplugin.addDirectoryItem( + handle=self.handle, url=purl, + listitem=list_item, isFolder=True) + + choose_item = xbmcgui.ListItem(label=LANGUAGE(30071)) # Choose date + choose_item.setArt({'thumb': self.icon}) + purl = self.build_url(mode=25) + xbmcplugin.addDirectoryItem( + handle=self.handle, url=purl, + listitem=choose_item, isFolder=True) + + def pick_date(self): + """ + Opens a date choosing dialog and lets the user input a date. + Redirects to the date menu of the chosen date. + In case of failure or abortion redirects to the date + overview menu. + """ + date_picker = xbmcgui.Dialog().numeric( + 1, LANGUAGE(30071), None) # Choose date + if date_picker is not None: + date_elems = date_picker.split('/') + try: + day = int(date_elems[0]) + month = int(date_elems[1]) + year = int(date_elems[2]) + chosen_date = datetime.date(year, month, day) + name = chosen_date.strftime('%d-%m-%Y') + self.build_date_menu(name) + except (ValueError, IndexError): + self.log('pick_date: Invalid date chosen.') + self.build_dates_overview_menu() + else: + self.build_dates_overview_menu() + + def build_date_menu(self, date_string): + """ + Builds a list of episodes of a given date. + + Keyword arguments: + date_string -- a string representing date in the form %d-%m-%Y, + e.g. 12-03-2017 + """ + self.log('build_date_menu, date_string = %s' % date_string) + + url = self.host_url + '/play/tv/programDay/%s' % date_string + id_list = self.extract_id_list(url) + + for vid in id_list: + self.build_episode_menu( + vid, include_segments=False, + segment_option=self.segments) + + def get_auth_url(self, url, segment_data=None): + """ + Returns the authenticated URL from a given stream URL. + + Keyword arguments: + url -- a given stream URL + """ + self.log('get_auth_url, url = %s' % url) + spl = urlparse.urlparse(url).path.split('/') + token = json.loads( + self.open_url( + 'http://tp.srgssr.ch/akahd/token?acl=/%s/%s/*' % + (spl[1], spl[2]), use_cache=False)) or {} + auth_params = token.get('token', {}).get('authparams') + if segment_data: + # timestep_string = self._get_timestep_token(segment_data) + # url += ('?' if '?' not in url else '&') + timestep_string + pass + if auth_params: + url += ('?' if '?' not in url else '&') + auth_params + return url + + def play_video(self, video_id): + """ + Gets the video stream information of a video and starts to play it. + + Keyword arguments: + video_id -- the video of the video to play + """ + self.log('play_video, video_id = %s' % video_id) + json_url = ('https://il.srgssr.ch/integrationlayer/2.0/%s/' + 'mediaComposition/video/%s.json') % (self.bu, video_id) + self.log('play_video. Open URL %s' % json_url) + json_response = json.loads(self.open_url(json_url)) + + chapter_list = json_response.get('chapterList', []) + if not chapter_list: + self.log('play_video: no stream URL found.') + return + + first_chapter = chapter_list[0] + resource_list = first_chapter.get('resourceList', []) + if not resource_list: + self.log('play_video: no stream URL found.') + return + + stream_urls = { + 'SD': '', + 'HD': '', + } + for resource in resource_list: + if resource.get('protocol') == 'HLS': + for key in ('SD', 'HD'): + if resource.get('quality') == key: + stream_urls[key] = resource.get('url') + + if not stream_urls['SD'] and not stream_urls['HD']: + self.log('play_video: no stream URL found.') + return + + stream_url = stream_urls['HD'] if ( + stream_urls['HD'] and self.prefer_hd)\ + or not stream_urls['SD'] else stream_urls['SD'] + self.log('play_video, stream_url = %s' % stream_url) + auth_url = self.get_auth_url(stream_url) + + start_time = end_time = None + if 'segmentUrn' in json_response: # video_id is the ID of a segment + segment_list = first_chapter.get('segmentList', []) + for segment in segment_list: + if segment.get('id') == video_id: + start_time = utils.float_or_none( + segment.get('markIn'), scale=1000) + end_time = utils.float_or_none( + segment.get('markOut'), scale=1000) + break + + if start_time and end_time: + parsed_url = urlparse.urlparse(auth_url) + query_list = urlparse.parse_qsl(parsed_url.query) + updated_query_list = [] + for query in query_list: + if query[0] == 'start' or query[0] == 'end': + continue + updated_query_list.append(query) + updated_query_list.append( + ('start', utils.CompatStr(start_time))) + updated_query_list.append( + ('end', utils.CompatStr(end_time))) + new_query = utils.assemble_query_string(updated_query_list) + surl_result = urlparse.ParseResult( + parsed_url.scheme, parsed_url.netloc, + parsed_url.path, parsed_url.params, + new_query, parsed_url.fragment) + auth_url = surl_result.geturl() + self.log('play_video, auth_url = %s' % auth_url) + play_item = xbmcgui.ListItem(video_id, path=auth_url) + xbmcplugin.setResolvedUrl(self.handle, True, play_item) + + def play_livestream(self, stream_url): + """ + Plays a livestream, given a unauthenticated stream url. + + Keyword arguments: + stream_url -- the stream url + """ + auth_url = self.get_auth_url(stream_url) + play_item = xbmcgui.ListItem('Live', path=auth_url) + xbmcplugin.setResolvedUrl(self.handle, True, play_item) + + def manage_favourite_shows(self): + """ + Opens a Kodi multiselect dialog to let the user choose + his/her personal favourite show list. + """ + show_list = self.read_all_available_shows() + stored_favids = self.read_favourite_show_ids() + names = [x['title'] for x in show_list] + ids = [x['id'] for x in show_list] + + preselect_inds = [] + for stored_id in stored_favids: + try: + preselect_inds.append(ids.index(stored_id)) + except ValueError: + pass + ancient_ids = [x for x in stored_favids if x not in ids] + + dialog = xbmcgui.Dialog() + # Choose your favourite shows + selected_inds = dialog.multiselect( + LANGUAGE(30069), names, preselect=preselect_inds) + + if selected_inds is not None: + new_favids = [ids[ind] for ind in selected_inds] + # Keep the old show ids: + new_favids += ancient_ids + + self.write_favourite_show_ids(new_favids) + + def read_favourite_show_ids(self): + """ + Reads the show ids from the file defined by the global + variable FAVOURITE_SHOWS_FILENAMES and returns a list + containing these ids. + An empty list will be returned in case of failure. + """ + path = xbmc.translatePath( + self.real_settings.getAddonInfo('profile')).decode("utf-8") + file_path = os.path.join(path, FAVOURITE_SHOWS_FILENAME) + try: + with open(file_path, 'r') as f: + json_file = json.load(f) + try: + return [entry['id'] for entry in json_file] + except KeyError: + self.log('Unexpected file structure for %s.' % + FAVOURITE_SHOWS_FILENAME) + return [] + except (IOError, TypeError): + return [] + + def write_favourite_show_ids(self, show_ids): + """ + Writes a list of show ids to the file defined by the global + variable FAVOURITE_SHOWS_FILENAME. + + Keyword arguments: + show_ids -- a list of show ids (as strings) + """ + show_ids_dict_list = [{'id': show_id} for show_id in show_ids] + path = xbmc.translatePath( + self.real_settings.getAddonInfo('profile')).decode("utf-8") + file_path = os.path.join(path, FAVOURITE_SHOWS_FILENAME) + if not os.path.exists(path): + os.makedirs(path) + with open(file_path, 'w') as f: + json.dump(show_ids_dict_list, f) + + def build_tv_menu(self): + """ + Builds the overview over the TV channels. + """ + overview_url = '%s/play/tv/live/overview' % self.host_url + overview_json = json.loads( + self.open_url(overview_url, use_cache=False)) + urns = [x['urn'] for x in overview_json['teaser']] + for urn in urns: + json_url = ('https://il.srgssr.ch/integrationlayer/2.0/' + 'mediaComposition/byUrn/%s.json') % urn + info_json = json.loads(self.open_url(json_url, use_cache=False)) + try: + json_entry = info_json['chapterList'][0] + except (KeyError, IndexError): + self.log('build_tv_menu: Unexpected json structure ' + 'for element %s' % urn) + continue + self.build_entry(json_entry) + + def build_live_menu(self, extract_srf3=False): + """ + Builds the menu listing the currently available livestreams. + """ + def get_live_ids(): + """ + Downloads the main webpage and scrapes it for + possible livestreams. If some live events were found, a list + of live ids will be returned, otherwise an empty list. + """ + webpage = self.open_url(self.host_url, use_cache=False) + id_regex = r'data-sport-id=\"(?P\d+)\"' + live_ids = [] + try: + for match in re.finditer(id_regex, webpage): + live_ids.append(match.group('live_id')) + except StopIteration: + pass + return live_ids + + def get_srf3_live_ids(): + """ + Returns a list of Radio SRF 3 video streams. + """ + url = 'https://www.srf.ch/radio-srf-3' + webpage = self.open_url(url, use_cache=False) + video_id_regex = r'''(?x) + popupvideoplayer\?id= + (?P + [a-f0-9]{8}- + [a-f0-9]{4}- + [a-f0-9]{4}- + [a-f0-9]{4}- + [a-f0-9]{12} + ) + ''' + live_ids = [] + try: + for match in re.finditer(video_id_regex, webpage): + live_ids.append(match.group('video_id')) + except StopIteration: + pass + return live_ids + + live_ids = get_live_ids() + for lid in live_ids: + api_url = ('https://event.api.swisstxt.ch/v1/events/' + '%s/byEventItemId/?eids=%s') % (self.bu, lid) + try: + live_json = json.loads(self.open_url(api_url)) + entry = live_json[0] + except Exception: + self.log('build_live_menu: No entry found ' + 'for live id %s.' % lid) + continue + if entry.get('streamType') == 'noStream': + continue + title = entry.get('title') + stream_url = entry.get('hls') + image = entry.get('imageUrl') + item = xbmcgui.ListItem(label=title) + item.setProperty('IsPlayable', 'true') + item.setArt({'thumb': image}) + purl = self.build_url(mode=51, name=stream_url) + xbmcplugin.addDirectoryItem( + self.handle, purl, item, isFolder=False) + + if extract_srf3: + srf3_ids = get_srf3_live_ids() + for vid in srf3_ids: + self.build_episode_menu(vid, include_segments=False) diff --git a/lib/utils.py b/lib/utils.py new file mode 100644 index 0000000..d685dd3 --- /dev/null +++ b/lib/utils.py @@ -0,0 +1,371 @@ +# -*- coding: utf-8 -*- + +# Copyright (C) 2018 Alexander Seiler +# +# +# This file is part of script.module.srgssr. +# +# script.module.srgssr is free software: you can redistribute it and/or +# modify it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# script.module.srgssr is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with script.module.srgssr. +# If not, see . + +import datetime +import re + +try: + CompatStr = unicode # Python2 +except NameError: + CompatStr = str # Python3 + + +def assemble_query_string(query_list): + """ + Assembles a query for an URL and returns the assembled query string. + + Keyword arguments: + query_list -- a list of queries + """ + return '&'.join(['{}={}'.format(k, v) for (k, v) in query_list]) + + +def str_or_none(inp, default=None): + """ + Convert an input to a string (if possible), otherwise + return a default value. + + Keyword arguments: + inp -- input + default -- the default value to return (default: None) + """ + if inp is None: + return default + try: + return CompatStr(inp, 'utf-8') + except TypeError: + return inp + + +def int_or_none(val, scale=1, invscale=1, default=None): + """ + Convert an input value to an integer (if possible), otherwise + return a default value. + + Keyword arguments: + val -- input value + scale -- divide the input by this value (default: 1) + invscale -- multiply the input by this value (default: 1) + default -- the default return value (default: None) + """ + if val == '': + val = None + if val is None: + return default + try: + return int(val) * invscale // scale + except ValueError: + return default + + +def float_or_none(val, scale=1, invscale=1, default=None): + """ + Convert an input value to a float (if possible), otherwise + return a default value. + + Keyword arguments: + val -- input value + scale -- divide the input by this value (default: 1) + invscale -- multiply the input by this value (default: 1) + default -- the default return value (default: None) + """ + if val == '': + val = None + if val is None: + return default + try: + return float(val) * float(invscale) / float(scale) + except ValueError: + return default + + +def get_duration(duration_string): + """ + Converts a duration string into an integer respresenting the + total duration in seconds. There are three possible input string + forms possible, either + :: + or + : + or + + In case of failure a NoneType will be returned. + + Keyword arguments: + duration_string -- a string of the above Form. + """ + if not isinstance(duration_string, CompatStr): + return None + durrex = r'(((?P\d+):)?(?P\d+):)?(?P\d+)' + match = re.match(durrex, duration_string) + if match: + hour = int(match.group('hour')) if match.group('hour') else 0 + minute = int(match.group('minute')) if match.group('minute') else 0 + second = int(match.group('second')) + return 60 * 60 * hour + 60 * minute + second + # log('Cannot convert duration string: &s' % duration_string) + return None + + +def parse_datetime(input_string): + """ + Tries to create a datetime object from a given input string. There are + several different forms of input strings supported, for more details + have a look in the documentations of the called functions. In case + of failure, a NoneType will be returned. + + Keyword arguments: + input_string -- a string to convert into a datetime object + """ + # input_string = str(input_string) + # print(input_string) + date_time = _parse_weekday_time(input_string) + if date_time: + return date_time + date_time = _parse_date_time(input_string) + if date_time: + return date_time + date_time = _parse_date_time_tz(input_string) + if not date_time: + # log('parse_datetime: Could not parse input string %s' % input_string) + pass + return date_time + + +def _parse_date_time_tz(input_string): + """ + Creates a datetime object from a string of the form + %Y-%m-%dT%H:%M:%S + where represents the timezone info and is of the form + (+|-)%H:%M. + A NoneType will be returned in the case where it was not possible + to create a datetime object. + + Keyword arguments: + input_string -- a string of the above form + """ + dt_regex = r'''(?x) + (?P
+ \d{4}-\d{2}-\d{2}T\d{2}(:|h)\d{2}:\d{2} + ) + (?P + [-+]\d{2}(:|h)\d{2} + ) + ''' + match = re.match(dt_regex, input_string) + if match: + dts = match.group('dt') + # We ignore timezone information for now + try: + # Strange behavior of strptime in Kodi? + # dt = datetime.datetime.strptime(dts, '%Y-%m-%dT%H:%M:%S') + # results in a TypeError in some cases... + year = int(dts[0:4]) + month = int(dts[5:7]) + day = int(dts[8:10]) + hour = int(dts[11:13]) + minute = int(dts[14:16]) + second = int(dts[17:19]) + date_time = datetime.datetime( + year, month, day, hour, minute, second) + return date_time + except ValueError: + return None + return None + + +def _parse_weekday_time(input_string): + """ + Creates a datetime object from a string of the form + ,? %H:%M(:S)? + where is either a german name of a weekday + ('Montag', 'Dienstag', ...) or 'gestern', 'heute', 'morgen'. + Other supported languages are English, French and Italian. + If it is not possible to create a datetime object from + the given input string, a NoneType will be returned. + + Keyword arguments: + input_string -- a string of the above form + """ + weekdays_german = ( + 'Montag', + 'Dienstag', + 'Mittwoch', + 'Donnerstag', + 'Freitag', + 'Samstag', + 'Sonntag', + ) + special_weekdays_german = ( + 'gestern', + 'heute', + 'morgen', + ) + identifiers_german = weekdays_german + special_weekdays_german + + weekdays_french = ( + 'Lundi', + 'Mardi', + 'Mercredi', + 'Jeudi', + 'Vendredi', + 'Samedi', + 'Dimanche', + ) + special_weekdays_french = ( + 'hier', + 'aujourd\'hui', + 'demain', + ) + identifiers_french = weekdays_french + special_weekdays_french + + weekdays_italian = ( + 'Lunedì', + 'Martedì', + 'Mercoledì', + 'Giovedì', + 'Venerdì', + 'Sabato', + 'Domenica', + ) + special_weekdays_italian = ( + 'ieri', + 'oggi', + 'domani', + ) + identifiers_italian = weekdays_italian + special_weekdays_italian + + weekdays_english = ( + 'Monday', + 'Tuesday', + 'Wednesday', + 'Thursday', + 'Friday', + 'Saturday', + 'Sunday', + ) + special_weekdays_english = ( + 'yesterday', + 'today', + 'tomorrow', + ) + identifiers_english = weekdays_english + special_weekdays_english + + identifiers = { + 'german': identifiers_german, + 'french': identifiers_french, + 'italian': identifiers_italian, + 'english': identifiers_english, + } + + recent_date_regex = r'''(?x) + (?P[a-zA-z\'ì]+) + \s*,\s* + (?P\d{2})(:|h) + (?P\d{2}) + (: + (?P\d{2}) + )? + ''' + recent_date_match = re.match(recent_date_regex, input_string) + if recent_date_match: + # This depends on correct date settings in Kodi... + today = datetime.date.today() + # wdl = [x for x in weekdays if input_string.startswith(x)] + for key in identifiers.keys(): + wdl = [x for x in identifiers[key] if re.match( + x, input_string, re.IGNORECASE)] + lang = key + if wdl: + break + if not wdl: + return None + index = identifiers[lang].index(wdl[0]) + if index == 9: # tomorrow + offset = datetime.timedelta(1) + elif index == 8: # today + offset = datetime.timedelta(0) + elif index == 7: # yesterday + offset = datetime.timedelta(-1) + else: # Monday, Tuesday, ..., Sunday + days_off_pos = (today.weekday() - index) % 7 + offset = datetime.timedelta(-days_off_pos) + try: + hour = int(recent_date_match.group('hour')) + minute = int(recent_date_match.group('minute')) + time = datetime.time(hour, minute) + except ValueError: + return None + try: + second = int(recent_date_match.group('second')) + time = datetime.time(hour, minute, second) + except (ValueError, TypeError): + pass + date_time = datetime.datetime.combine(today, time) + offset + else: + return None + return date_time + + +def _parse_date_time(input_string): + """ + Creates a datetime object from a string of the following form: + %d.%m.%Y,? %H:%M(:%S)? + + Note that the delimiter between the date and the time is optional, and also + the seconds in the time are optional. + + If the given string cannot be transformed into a appropriate datetime + object, a NoneType will be returned. + + Keyword arguments: + input_string -- the date and time in the above form + """ + full_date_regex = r'''(?x) + (?P\d{2})\. + (?P\d{2})\. + (?P\d{4}) + \s*,?\s* + (?P\d{2})(:|h) + (?P\d{2}) + (: + (?P\d{2}) + )? + ''' + full_date_match = re.match(full_date_regex, input_string) + if full_date_match: + try: + year = int(full_date_match.group('year')) + month = int(full_date_match.group('month')) + day = int(full_date_match.group('day')) + hour = int(full_date_match.group('hour')) + minute = int(full_date_match.group('minute')) + date_time = datetime.datetime(year, month, day, hour, minute) + except ValueError: + return None + try: + second = int(full_date_match.group('second')) + date_time = datetime.datetime( + year, month, day, hour, minute, second) + return date_time + except (ValueError, TypeError): + return date_time + return None diff --git a/resources/icon.png b/resources/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..ca4c53adbfa8d06d0e282d45f9cad195e89d571e GIT binary patch literal 7237 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 literal 0 HcmV?d00001 diff --git a/resources/language/resource.language.de_de/strings.po b/resources/language/resource.language.de_de/strings.po new file mode 100644 index 0000000..af63383 --- /dev/null +++ b/resources/language/resource.language.de_de/strings.po @@ -0,0 +1,68 @@ +# Kodi Media Center language file +# Addon Name: SRG SSR +# Addon id: script.module.srgssr +# Addon Provider: Alexander Seiler +msgid "" +msgstr "" +"Project-Id-Version: script.module.srgssr\n" +"Report-Msgid-Bugs-To: seileralex@gmail.com\n" +"POT-Creation-Date: 2018-04-11 10:32+0200\n" +"PO-Revision-Date: 2018-11-22 10:22+0100\n" +"Last-Translator: Alexander Seiler\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Language: de\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +msgctxt "#30058" +msgid "Today" +msgstr "Heute" + +msgctxt "#30059" +msgid "Yesterday" +msgstr "Gestern" + +msgctxt "#30060" +msgid "Monday" +msgstr "Montag" + +msgctxt "#30061" +msgid "Tuesday" +msgstr "Dienstag" + +msgctxt "#30062" +msgid "Wednesday" +msgstr "Mittwoch" + +msgctxt "#30063" +msgid "Thursday" +msgstr "Donnerstag" + +msgctxt "#30064" +msgid "Friday" +msgstr "Freitag" + +msgctxt "#30065" +msgid "Saturday" +msgstr "Samstag" + +msgctxt "#30066" +msgid "Sunday" +msgstr "Sonntag" + +msgctxt "#30069" +msgid "Choose your favourite shows" +msgstr "Wählen Sie Ihre bevorzugten Sendungen" + +msgctxt "#30071" +msgid "Choose date" +msgstr "Wähle Datum" + +msgctxt "#30073" +msgid ">> Next page" +msgstr ">> Nächste Seite" + +msgctxt "#30100" +msgid "Failed to open URL." +msgstr "Konnte URL nicht öffnen." diff --git a/resources/language/resource.language.en_gb/strings.po b/resources/language/resource.language.en_gb/strings.po new file mode 100644 index 0000000..54eb93c --- /dev/null +++ b/resources/language/resource.language.en_gb/strings.po @@ -0,0 +1,68 @@ +# Kodi Media Center language file +# Addon Name: SRG SSR +# Addon id: script.module.srgssr +# Addon Provider: Alexander Seiler +msgid "" +msgstr "" +"Project-Id-Version: script.module.srgssr\n" +"Report-Msgid-Bugs-To: seileralex@gmail.com\n" +"POT-Creation-Date: 2018-04-11 10:28+0200\n" +"PO-Revision-Date: 2018-11-22 11:40+0100\n" +"Last-Translator: Alexander Seiler\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Language: en\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +msgctxt "#30058" +msgid "Today" +msgstr "" + +msgctxt "#30059" +msgid "Yesterday" +msgstr "" + +msgctxt "#30060" +msgid "Monday" +msgstr "" + +msgctxt "#30061" +msgid "Tuesday" +msgstr "" + +msgctxt "#30062" +msgid "Wednesday" +msgstr "" + +msgctxt "#30063" +msgid "Thursday" +msgstr "" + +msgctxt "#30064" +msgid "Friday" +msgstr "" + +msgctxt "#30065" +msgid "Saturday" +msgstr "" + +msgctxt "#30066" +msgid "Sunday" +msgstr "" + +msgctxt "#30069" +msgid "Choose your favourite shows" +msgstr "" + +msgctxt "#30071" +msgid "Choose date" +msgstr "" + +msgctxt "#30073" +msgid ">> Next page" +msgstr "" + +msgctxt "#30100" +msgid "Failed to open URL." +msgstr "" diff --git a/resources/language/resource.language.fr_fr/strings.po b/resources/language/resource.language.fr_fr/strings.po new file mode 100644 index 0000000..01c4399 --- /dev/null +++ b/resources/language/resource.language.fr_fr/strings.po @@ -0,0 +1,68 @@ +# Kodi Media Center language file +# Addon Name: SRG SSR +# Addon id: script.module.srgssr +# Addon Provider: Alexander Seiler +msgid "" +msgstr "" +"Project-Id-Version: script.module.srgssr\n" +"Report-Msgid-Bugs-To: seileralex@gmail.com\n" +"POT-Creation-Date: 2018-11-05 15:26+0100\n" +"PO-Revision-Date: 2018-11-22 13:26+0100\n" +"Last-Translator: Nicolas Mielec\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Language: fr\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +msgctxt "#30058" +msgid "Today" +msgstr "Aujourd'hui" + +msgctxt "#30059" +msgid "Yesterday" +msgstr "Hier" + +msgctxt "#30060" +msgid "Monday" +msgstr "Lundi" + +msgctxt "#30061" +msgid "Tuesday" +msgstr "Mardi" + +msgctxt "#30062" +msgid "Wednesday" +msgstr "Mercredi" + +msgctxt "#30063" +msgid "Thursday" +msgstr "Jeudi" + +msgctxt "#30064" +msgid "Friday" +msgstr "Vendredi" + +msgctxt "#30065" +msgid "Saturday" +msgstr "Samedi" + +msgctxt "#30066" +msgid "Sunday" +msgstr "Dimanche" + +msgctxt "#30069" +msgid "Choose your favourite shows" +msgstr "Sélectionnez vos émissions favorites" + +msgctxt "#30071" +msgid "Choose date" +msgstr "Sélectionnez la date" + +msgctxt "#30073" +msgid ">> Next page" +msgstr ">> Page suivante" + +msgctxt "#30100" +msgid "Failed to open URL." +msgstr "Erreur lors de l'ouverture de l'URL" diff --git a/resources/language/resource.language.it_it/strings.po b/resources/language/resource.language.it_it/strings.po new file mode 100644 index 0000000..92b7b21 --- /dev/null +++ b/resources/language/resource.language.it_it/strings.po @@ -0,0 +1,68 @@ +# Kodi Media Center language file +# Addon Name: SRG SSR +# Addon id: script.module.srgssr +# Addon Provider: Alexander Seiler +msgid "" +msgstr "" +"Project-Id-Version: script.module.srgssr\n" +"Report-Msgid-Bugs-To: seileralex@gmail.com\n" +"POT-Creation-Date: 2018-10-30 15:58+0100\n" +"PO-Revision-Date: 2018-11-22 12:10+0100\n" +"Last-Translator: Alexander Seiler\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Language: it\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +msgctxt "#30058" +msgid "Today" +msgstr "Oggi" + +msgctxt "#30059" +msgid "Yesterday" +msgstr "Ieri" + +msgctxt "#30060" +msgid "Monday" +msgstr "Lunedì" + +msgctxt "#30061" +msgid "Tuesday" +msgstr "Martedì" + +msgctxt "#30062" +msgid "Wednesday" +msgstr "Mercoledì" + +msgctxt "#30063" +msgid "Thursday" +msgstr "Giovedì" + +msgctxt "#30064" +msgid "Friday" +msgstr "Venerdì" + +msgctxt "#30065" +msgid "Saturday" +msgstr "Sabato" + +msgctxt "#30066" +msgid "Sunday" +msgstr "Domenica" + +msgctxt "#30069" +msgid "Choose your favourite shows" +msgstr "Scegli i tui show preferiti" + +msgctxt "#30071" +msgid "Choose date" +msgstr "Sceglia una data" + +msgctxt "#30073" +msgid ">> Next page" +msgstr ">> Prossima pagina" + +msgctxt "#30100" +msgid "Failed to open URL." +msgstr "Apertura URL fallita."