From 27854c6fbe810992f76591a99ba7e6c3f958b060 Mon Sep 17 00:00:00 2001 From: emilsvennesson Date: Fri, 29 Dec 2017 20:06:24 +0100 Subject: [plugin.video.cmore] 0.2.0 --- plugin.video.cmore/LICENSE.txt | 21 ++ plugin.video.cmore/README.md | 22 ++ plugin.video.cmore/__init__.py | 1 + plugin.video.cmore/addon.py | 399 +++++++++++++++++++++ plugin.video.cmore/addon.xml | 31 ++ plugin.video.cmore/changelog.txt | 5 + plugin.video.cmore/default.py | 5 + plugin.video.cmore/resources/__init__.py | 1 + plugin.video.cmore/resources/fanart.jpg | Bin 0 -> 56624 bytes plugin.video.cmore/resources/icon.png | Bin 0 -> 52285 bytes .../language/resource.language.en_gb/strings.po | 146 ++++++++ plugin.video.cmore/resources/lib/Widevine.py | 29 ++ .../resources/lib/WidevineHTTPRequestHandler.py | 33 ++ plugin.video.cmore/resources/lib/__init__.py | 1 + plugin.video.cmore/resources/lib/cmore.py | 328 +++++++++++++++++ plugin.video.cmore/resources/lib/kodihelper.py | 237 ++++++++++++ plugin.video.cmore/resources/settings.xml | 15 + plugin.video.cmore/service.py | 52 +++ 18 files changed, 1326 insertions(+) create mode 100644 plugin.video.cmore/LICENSE.txt create mode 100644 plugin.video.cmore/README.md create mode 100644 plugin.video.cmore/__init__.py create mode 100644 plugin.video.cmore/addon.py create mode 100644 plugin.video.cmore/addon.xml create mode 100644 plugin.video.cmore/changelog.txt create mode 100644 plugin.video.cmore/default.py create mode 100644 plugin.video.cmore/resources/__init__.py create mode 100644 plugin.video.cmore/resources/fanart.jpg create mode 100644 plugin.video.cmore/resources/icon.png create mode 100644 plugin.video.cmore/resources/language/resource.language.en_gb/strings.po create mode 100644 plugin.video.cmore/resources/lib/Widevine.py create mode 100644 plugin.video.cmore/resources/lib/WidevineHTTPRequestHandler.py create mode 100644 plugin.video.cmore/resources/lib/__init__.py create mode 100644 plugin.video.cmore/resources/lib/cmore.py create mode 100644 plugin.video.cmore/resources/lib/kodihelper.py create mode 100644 plugin.video.cmore/resources/settings.xml create mode 100644 plugin.video.cmore/service.py diff --git a/plugin.video.cmore/LICENSE.txt b/plugin.video.cmore/LICENSE.txt new file mode 100644 index 0000000..7dbae24 --- /dev/null +++ b/plugin.video.cmore/LICENSE.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2017 Nils Emil Svensson + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugin.video.cmore/README.md b/plugin.video.cmore/README.md new file mode 100644 index 0000000..b1b8f5e --- /dev/null +++ b/plugin.video.cmore/README.md @@ -0,0 +1,22 @@ +# C More for Kodi # +This is a Kodi add-on that allows you to stream content from C More in Kodi. + +## Disclaimer ## +This add-on is unoffical and is not endorsed or supported by C More Entertainment in any way. Not all features may work or has been thoroughly tested. + +## Dependencies: ## + * script.module.requests >= 2.9.1 (http://mirrors.kodi.tv/addons/krypton/script.module.requests/) + * script.module.inputstreamhelper >= 0.2.2 (http://mirrors.kodi.tv/addons/krypton/script.module.inputstreamhelper/) + +This add-on requires Kodi 17.4 or higher with InputStream Adaptive installed. Kodi 18 is required for Android based devices and for subtitles support. + +## DRM protected streams ## +Most of C More's content is DRM protected and requires the proprietary decryption module Widevine CDM for playback. You will be prompted to install this if you're attempting to play a stream without the binary installed. + +Most Android devices have built-in support for Widevine DRM and doesn't require any additional binaries. You can see if your Android device supports Widevine DRM by using the [DRM Info](https://play.google.com/store/apps/details?id=com.androidfung.drminfo) app available in Play Store. + +## Support ## +Please report any issues or bug reports on the [GitHub Issues](https://github.com/emilsvennesson/kodi-cmore/issues) page. Remember to include a full, non-cut off Kodi debug log. See the [Kodi wiki page](http://kodi.wiki/view/Log_file/Advanced) for more detailed instructions on how to obtain the log file. + +## License ## +This add-on is licensed under the **The MIT License**. Please see the [LICENSE.txt](LICENSE.txt) file for details. diff --git a/plugin.video.cmore/__init__.py b/plugin.video.cmore/__init__.py new file mode 100644 index 0000000..b53149b --- /dev/null +++ b/plugin.video.cmore/__init__.py @@ -0,0 +1 @@ +# dummy file to init the directory diff --git a/plugin.video.cmore/addon.py b/plugin.video.cmore/addon.py new file mode 100644 index 0000000..a86c0fb --- /dev/null +++ b/plugin.video.cmore/addon.py @@ -0,0 +1,399 @@ +import sys +import urlparse +import json + +from resources.lib.kodihelper import KodiHelper + +base_url = sys.argv[0] +handle = int(sys.argv[1]) +helper = KodiHelper(base_url, handle) + + +def run(): + try: + router(sys.argv[2][1:]) # trim the leading '?' from the plugin call paramstring + except helper.c.CMoreError as error: + if error.value == 'SESSION_NOT_AUTHENTICATED' or error.value == 'User is not authenticated': + helper.login_process() + router(sys.argv[2][1:]) + else: + helper.dialog('ok', helper.language(30028), error.value) + + +def list_root_pages(): + for page in helper.c.root_pages[helper.c.locale]: + if page == 'start': + title = helper.language(30020) + elif page == 'movies': + title = helper.language(30021) + elif page == 'series': + title = helper.language(30022) + elif page == 'sports': + title = helper.language(30023) + elif page == 'tv': + title = helper.language(30024) + elif page == 'programs': + title = helper.language(30025) + elif page == 'kids': + title = helper.language(30026) + else: + title = page + + params = { + 'action': 'list_page', + 'page': page, + 'namespace': 'page', + 'root_page': 'true' + } + + helper.add_item(title, params=params) + helper.add_item(helper.language(30030), params={'action': 'search'}) # search + helper.eod() + + +def list_page(page=None, namespace=None, root_page=False, page_data=None, search_query=None): + if page: + page_dict = helper.c.parse_page(page, namespace, helper.get_as_bool(root_page)) + elif page_data: + page_dict = json.loads(page_data) + elif search_query: + page_dict = helper.c.get_search_data(search_query) + + if not page_dict: + if search_query: + helper.dialog('ok', helper.language(30017), '{0} \'{1}\'.'.format(helper.language(30031), search_query)) + helper.log('No page data found.') + return False + + for i in page_dict: + page = i.get('id') + # search queries doesn't include the videoId in response + if i.get('videoId') or search_query: + if i.get('type') == 'movie': + list_movie(i) + elif i.get('type') == 'series': + list_show(i) + elif i.get('type') == 'live_event': + list_live_event(i) + elif 'displayableDate' in i: + list_event_date(i) + elif 'channel' in i: + list_channel(i) + elif 'namespace' in i: # theme pages + list_genres(i, page) + elif 'page_data' in i: # parsed containers + list_containers(i) + helper.eod() + + +def list_genres(i, page): + params = { + 'action': 'list_page', + 'namespace': i['namespace'], + 'page': page, + 'root_page': 'false' + } + + helper.add_item(i['headline'], params) + + +def list_channel(i): + params = { + 'action': 'play', + 'video_id': i['channel']['videoId'] + } + + program_info = { + 'mediatype': 'video', + 'title': i['programs'][0]['title'], + 'genre': i['programs'][0].get('mainCategory'), + 'plot': i['programs'][0].get('description') + } + + channel_art = { + 'fanart': helper.c.get_image_url(i['programs'][0].get('landscapeImage')), + 'thumb': helper.c.get_image_url(i['programs'][0].get('landscapeImage')), + 'cover': helper.c.get_image_url(i['programs'][0].get('landscapeImage')), + 'icon': helper.c.get_image_url(i['channel']['logoUrl']) + } + + channel_colored = coloring(i['channel']['title'], 'live').encode('utf-8') + time_colored = coloring(i['programs'][0]['caption'], 'live').encode('utf-8') + program_title = i['programs'][0]['title'].encode('utf-8') + list_title = '[B]{0} {1}[/B]: {2}'.format(channel_colored, time_colored, program_title) + + helper.add_item(list_title, params=params, info=program_info, art=channel_art, content='episodes', playable=True) + + +def list_containers(i): + headline = i['attributes']['headline'].encode('utf-8') + if i['attributes'].get('subtitle'): + subtitle = i['attributes']['subtitle'].encode('utf-8') + title = '{0}: {1}'.format(subtitle, headline) + else: + title = headline + + params = { + 'action': 'list_page_with_page_data', + 'page_data': json.dumps(i['page_data']) + } + + helper.add_item(title, params) + + +def list_event_date(date): + params = { + 'action': 'list_page_with_page_data', + 'page_data': json.dumps(date['events']) + } + + helper.add_item(date['displayableDate'], params) + + +def coloring(text, meaning): + """Return the text wrapped in appropriate color markup.""" + if meaning == 'live': + color = 'FF03F12F' + elif meaning == 'archive': + color = 'FFFF0EE0' + elif meaning == 'upcoming': + color = 'FFF16C00' + + colored_text = '[COLOR=%s]%s[/COLOR]' % (color, text) + + return colored_text + + +def list_live_event(event): + if event.get('commentators'): + if ',' in event.get('commentators'): + commentators = event.get('commentators').split(',') + else: + commentators = [event['commentators']] + else: + commentators = [] + + if helper.c.parse_datetime(event['startTime']) > helper.c.get_current_time(): + event_status = 'upcoming' + params = {'action': 'noop'} + playable = False + else: + if 'liveEventEnd' in event: # if sports content is on-demand + event_status = 'archive' + else: + event_status = 'live' + params = { + 'action': 'play', + 'video_id': event['videoId'] + } + playable = True + + list_title = '[B]{0}:[/B] {1}'.format(coloring(event['displayableStartTime'].encode('utf-8'), event_status), event['title'].encode('utf-8')) + + event_info = { + 'mediatype': 'video', + 'title': event.get('title'), + 'plot': event.get('description'), + 'plotoutline': event.get('caption'), + 'year': int(event['year']) if event.get('year') else None, + 'genre': ', '.join(event['subCategories']) if event.get('subCategories') else None, + 'cast': commentators + } + + event_art = { + 'fanart': helper.c.get_image_url(event.get('landscapeImage')), + 'thumb': helper.c.get_image_url(event.get('landscapeImage')), + 'banner': helper.c.get_image_url(event.get('ultraforgeImage')), + 'cover': helper.c.get_image_url(event.get('landscapeImage')), + 'icon': helper.c.get_image_url(event.get('categoryIcon')) + } + + helper.add_item(list_title, params=params, info=event_info, art=event_art, content='episodes', playable=playable) + + +def list_movie(movie): + params = { + 'action': 'play', + 'video_id': movie['videoId'] + } + + movie_info = { + 'mediatype': 'movie', + 'title': movie.get('title'), + 'plot': movie.get('description') if movie.get('description') else movie.get('caption'), + 'cast': movie.get('actors', []), + 'genre': extract_genre_year(movie.get('caption'), 'genre'), + 'duration': movie.get('duration'), + 'year': int(extract_genre_year(movie.get('caption'), 'year')) + } + + movie_art = { + 'fanart': helper.c.get_image_url(movie.get('landscapeImage')), + 'thumb': helper.c.get_image_url(movie.get('posterImage')), + 'banner': helper.c.get_image_url(movie.get('ultraforgeImage')), + 'cover': helper.c.get_image_url(movie.get('landscapeImage')), + 'poster': helper.c.get_image_url(movie.get('posterImage')) + } + + helper.add_item(movie['title'], params=params, info=movie_info, art=movie_art, content='movies', playable=True) + + +def extract_genre_year(caption, what_to_extract): + """Try to extract the genre or year from the caption.""" + if what_to_extract == 'year': + list_index = 1 + else: + list_index = 0 + + if caption: + try: + return caption.split(', ')[list_index] + except IndexError: + pass + + return None + + +def list_show(show): + params = { + 'action': 'list_episodes_or_seasons', + 'page_id': show['id'] + } + + show_info = { + 'mediatype': 'tvshow', + 'title': show.get('title'), + 'plot': show.get('description') if show.get('description') else show.get('caption'), + 'cast': show.get('actors', []), + 'genre': extract_genre_year(show.get('caption'), 'genre'), + 'duration': show.get('duration'), + 'year': extract_genre_year(show.get('caption'), 'year') + } + + show_art = { + 'fanart': helper.c.get_image_url(show.get('landscapeImage')), + 'thumb': helper.c.get_image_url(show.get('posterImage')), + 'banner': helper.c.get_image_url(show.get('ultraforgeImage')), + 'cover': helper.c.get_image_url(show.get('landscapeImage')), + 'poster': helper.c.get_image_url(show.get('posterImage')) + } + + helper.add_item(show['title'], params=params, info=show_info, art=show_art, content='tvshows') + + +def list_episodes_or_seasons(page_id): + series = helper.c.get_content_details('series', page_id) + if len(series['availableSeasons']) > 1: + list_seasons(series) + else: + list_episodes(series_data=series) + + +def list_seasons(series): + for season in series['availableSeasons']: + title = '{0} {1}'.format(helper.language(30029), str(season)) + params = { + 'action': 'list_episodes', + 'page_id': series['id'], + 'season': str(season) + } + helper.add_item(title, params) + helper.eod() + + +def list_episodes(page_id=None, season=None, series_data=None): + if page_id: + series = helper.c.get_content_details('series', page_id, season) + else: + series = series_data + + for i in series['capiAssets']: + params = { + 'action': 'play', + 'video_id': i['videoId'] + } + + title = i.get('title').replace(': ', '').encode('utf-8') + + episode_info = { + 'mediatype': 'episode', + 'title': title, + 'tvshowtitle': i['seriesTitle'] if i.get('seriesTitle') else title, + 'plot': i.get('description'), + 'year': int(i['year']) if i.get('year') else None, + 'season': int(i['season']) if i.get('season') else None, + 'episode': int(i['episode']) if i.get('episode') else None, + 'genre': ', '.join(i['subCategories']) if i.get('subCategories') else None, + 'cast': i.get('actors', []), + 'duration': i.get('duration') + } + + list_title = add_season_episode_to_title(title, episode_info['season'], episode_info['episode']) + + episode_art = { + 'fanart': helper.c.get_image_url(i.get('landscapeImage')), + 'thumb': helper.c.get_image_url(i.get('landscapeImage')), + 'banner': helper.c.get_image_url(i.get('ultraforgeImage')), + 'cover': helper.c.get_image_url(i.get('landscapeImage')), + 'poster': helper.c.get_image_url(i.get('posterImage')) + } + + helper.add_item(list_title, params=params, info=episode_info, art=episode_art, content='episodes', playable=True) + helper.eod() + + +def add_season_episode_to_title(title, season, episode): + if season and episode: + if int(season) <= 9: + season_format = '0' + str(season) + else: + season_format = str(season) + if int(episode) <= 9: + episode_format = '0' + str(episode) + else: + episode_format = str(episode) + + return '[B]S{0}E{1}[/B] {2}'.format(season_format, episode_format, title) + else: + helper.log('No season/episode information found.') + return title + + +def search(): + search_query = helper.get_user_input(helper.language(30030)) + if search_query: + list_page(search_query=search_query) + else: + helper.log('No search query provided.') + return False + + +def router(paramstring): + """Router function that calls other functions depending on the provided paramstring.""" + params = dict(urlparse.parse_qsl(paramstring)) + if 'setting' in params: + if params['setting'] == 'get_operator': + helper.get_operator() + elif params['setting'] == 'set_locale': + helper.set_locale() + elif params['setting'] == 'reset_credentials': + helper.reset_credentials() + elif 'action' in params: + if helper.check_for_prerequisites(): + if params['action'] == 'noop': + pass + elif params['action'] == 'play': + helper.play_item(params['video_id']) + elif params['action'] == 'list_page': + list_page(params['page'], params['namespace'], params['root_page']) + elif params['action'] == 'list_page_with_page_data': + list_page(page_data=params['page_data']) + elif params['action'] == 'list_episodes_or_seasons': + list_episodes_or_seasons(params['page_id']) + elif params['action'] == 'list_episodes': + list_episodes(page_id=params['page_id'], season=params['season']) + elif params['action'] == 'search': + search() + else: + if helper.check_for_prerequisites(): + list_root_pages() diff --git a/plugin.video.cmore/addon.xml b/plugin.video.cmore/addon.xml new file mode 100644 index 0000000..d170533 --- /dev/null +++ b/plugin.video.cmore/addon.xml @@ -0,0 +1,31 @@ + + + + + + + + + video + + + + Watch content from C More. + Titta på innehåll från C More. + 2017.12.29 v0.2.0 + + Initial stable release. + + all + MIT License + https://github.com/emilsvennesson/kodi-cmore + sv dk no en + This add-on is completely unofficial and is not endorsed by C More Entertainment in any way. + + resources/icon.png + resources/fanart.jpg + + + diff --git a/plugin.video.cmore/changelog.txt b/plugin.video.cmore/changelog.txt new file mode 100644 index 0000000..9fce1eb --- /dev/null +++ b/plugin.video.cmore/changelog.txt @@ -0,0 +1,5 @@ +2017.12.29 v0.2.0 ++ Initial stable release. + +2017.04.29 v0.1.0 ++ Unreleased development version diff --git a/plugin.video.cmore/default.py b/plugin.video.cmore/default.py new file mode 100644 index 0000000..4014178 --- /dev/null +++ b/plugin.video.cmore/default.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +import addon + +if __name__ == '__main__': + addon.run() diff --git a/plugin.video.cmore/resources/__init__.py b/plugin.video.cmore/resources/__init__.py new file mode 100644 index 0000000..b53149b --- /dev/null +++ b/plugin.video.cmore/resources/__init__.py @@ -0,0 +1 @@ +# dummy file to init the directory diff --git a/plugin.video.cmore/resources/fanart.jpg b/plugin.video.cmore/resources/fanart.jpg new file mode 100644 index 0000000..129ee29 Binary files /dev/null and b/plugin.video.cmore/resources/fanart.jpg differ diff --git a/plugin.video.cmore/resources/icon.png b/plugin.video.cmore/resources/icon.png new file mode 100644 index 0000000..00e1162 Binary files /dev/null and b/plugin.video.cmore/resources/icon.png differ diff --git a/plugin.video.cmore/resources/language/resource.language.en_gb/strings.po b/plugin.video.cmore/resources/language/resource.language.en_gb/strings.po new file mode 100644 index 0000000..8ba1142 --- /dev/null +++ b/plugin.video.cmore/resources/language/resource.language.en_gb/strings.po @@ -0,0 +1,146 @@ +# C More language file +msgid "" +msgstr "" +"Project-Id-Version: kodi-cmore\n" +"Report-Msgid-Bugs-To: https://github.com/emilsvennesson/kodi-cmore\n" +"POT-Creation-Date: 2017-04-19 21:43+0000\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: English\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 "#30000" +msgid "Account" +msgstr "" + +msgctxt "#30001" +msgid "Country" +msgstr "" + +msgctxt "#30002" +msgid "Sweden" +msgstr "" + +msgctxt "#30003" +msgid "Denmark" +msgstr "" + +msgctxt "#30004" +msgid "Norway" +msgstr "" + +msgctxt "#30005" +msgid "Finland" +msgstr "" + +msgctxt "#30006" +msgid "Username" +msgstr "" + +msgctxt "#30007" +msgid "Password" +msgstr "" + +msgctxt "#30008" +msgid "Login with TV provider" +msgstr "" + +msgctxt "#30009" +msgid "C More account" +msgstr "" + +msgctxt "#30010" +msgid "Select TV provider" +msgstr "" + +msgctxt "#30011" +msgid "TV provider" +msgstr "" + +msgctxt "#30012" +msgid "Site" +msgstr "" + +msgctxt "#30013" +msgid "cmore.se" +msgstr "" + +msgctxt "#30014" +msgid "cmore.dk" +msgstr "" + +msgctxt "#30015" +msgid "cmore.no" +msgstr "" + +msgctxt "#30016" +msgid "cmore.fi" +msgstr "" + +msgctxt "#30017" +msgid "Information" +msgstr "" + +msgctxt "#30018" +msgid "Please enter your login credentials in the add-on settings." +msgstr "" + +msgctxt "#30019" +msgid "Reset TV provider" +msgstr "" + +msgctxt "#30020" +msgid "Start" +msgstr "" + +msgctxt "#30021" +msgid "Movies" +msgstr "" + +msgctxt "#30022" +msgid "Series" +msgstr "" + +msgctxt "#30023" +msgid "Sports" +msgstr "" + +msgctxt "#30024" +msgid "Channels" +msgstr "" + +msgctxt "#30025" +msgid "TV programs" +msgstr "" + +msgctxt "#30026" +msgid "Kids" +msgstr "" + +msgctxt "#30027" +msgid "Reset credentials" +msgstr "" + +msgctxt "#30028" +msgid "Error" +msgstr "" + +msgctxt "#30029" +msgid "Season" +msgstr "" + +msgctxt "#30030" +msgid "Search" +msgstr "" + +msgctxt "#30031" +msgid "No results found for" +msgstr "" + +msgctxt "#30032" +msgid "[B]Run the add-on to complete the setup process.[/B]" +msgstr "" diff --git a/plugin.video.cmore/resources/lib/Widevine.py b/plugin.video.cmore/resources/lib/Widevine.py new file mode 100644 index 0000000..d974bb3 --- /dev/null +++ b/plugin.video.cmore/resources/lib/Widevine.py @@ -0,0 +1,29 @@ +import json +import xml.etree.ElementTree as ET +from kodihelper import KodiHelper + +helper = KodiHelper() + + +class Widevine(object): + license_url = helper.c.config['settings']['drmProxy'] + + def get_kid(self, mpd_url): + """Parse the KID from the MPD manifest.""" + mpd_data = helper.c.make_request(mpd_url, 'get') + mpd_root = ET.fromstring(mpd_data) + + for i in mpd_root.iter('{urn:mpeg:dash:schema:mpd:2011}ContentProtection'): + if '{urn:mpeg:cenc:2013}default_KID' in i.attrib: + return i.attrib['{urn:mpeg:cenc:2013}default_KID'] + + def get_license(self, mpd_url, wv_challenge, token): + """Acquire the Widevine license from the license server and return it.""" + post_data = { + 'drm_info': [x for x in bytearray(wv_challenge)], # convert challenge to a list of bytes + 'kid': self.get_kid(mpd_url), + 'token': token + } + + wv_license = helper.c.make_request(self.license_url, 'post', payload=json.dumps(post_data)) + return wv_license diff --git a/plugin.video.cmore/resources/lib/WidevineHTTPRequestHandler.py b/plugin.video.cmore/resources/lib/WidevineHTTPRequestHandler.py new file mode 100644 index 0000000..ade620d --- /dev/null +++ b/plugin.video.cmore/resources/lib/WidevineHTTPRequestHandler.py @@ -0,0 +1,33 @@ +import BaseHTTPServer +import urlparse +import urllib + +from Widevine import Widevine + +wv = Widevine() + + +class WidevineHTTPRequestHandler(BaseHTTPServer.BaseHTTPRequestHandler): + def do_HEAD(self): + self.send_response(200) + + def do_POST(self): + length = int(self.headers['content-length']) + wv_challenge = self.rfile.read(length) + query = dict(urlparse.parse_qsl(urlparse.urlsplit(self.path).query)) + mpd_url = query['mpd_url'] + token = query['license_url'].split('token=')[1] + + try: + wv_license = wv.get_license(mpd_url, wv_challenge, token) + self.send_response(200) + self.end_headers() + self.wfile.write(wv_license) + self.finish() + except Exception as ex: + self.send_response(400) + self.wfile.write(ex.value) + + def log_message(self, format, *args): + """Disable the BaseHTTPServer log.""" + return diff --git a/plugin.video.cmore/resources/lib/__init__.py b/plugin.video.cmore/resources/lib/__init__.py new file mode 100644 index 0000000..b53149b --- /dev/null +++ b/plugin.video.cmore/resources/lib/__init__.py @@ -0,0 +1 @@ +# dummy file to init the directory diff --git a/plugin.video.cmore/resources/lib/cmore.py b/plugin.video.cmore/resources/lib/cmore.py new file mode 100644 index 0000000..4787383 --- /dev/null +++ b/plugin.video.cmore/resources/lib/cmore.py @@ -0,0 +1,328 @@ +# -*- coding: utf-8 -*- +""" +A Kodi-agnostic library for C More +""" +import os +import json +import codecs +import time +from datetime import datetime, timedelta + +import requests + + +class CMore(object): + def __init__(self, settings_folder, locale, debug=False): + self.debug = debug + self.locale = locale + self.locale_suffix = self.locale.split('_')[1].lower() + self.http_session = requests.Session() + self.settings_folder = settings_folder + self.credentials_file = os.path.join(settings_folder, 'credentials') + self.base_url = 'https://cmore-mobile-bff.b17g.services' + self.config_path = os.path.join(self.settings_folder, 'configuration.json') + self.config_version = '3.6.1' + self.config = self.get_config() + self.client = 'cmore-android' + # hopefully, this can be acquired dynamically in the future + self.root_pages = { + 'sv_SE': ['start', 'movies', 'series', 'sports', 'tv', 'programs', 'kids'], + 'da_DK': ['start', 'movies', 'series', 'sports', 'tv', 'kids'], + 'nb_NO': ['start', 'movies', 'series', 'tv', 'kids'] + } + + class CMoreError(Exception): + def __init__(self, value): + self.value = value + + def __str__(self): + return repr(self.value) + + def log(self, string): + """C More class log method.""" + if self.debug: + try: + print('[C More]: %s') % string + except UnicodeEncodeError: + # we can't anticipate everything in unicode they might throw at + # us, but we can handle a simple BOM + bom = unicode(codecs.BOM_UTF8, 'utf8') + print('[C More]: %s' % string.replace(bom, '')) + except: + pass + + def make_request(self, url, method, params=None, payload=None, headers=None): + """Make an HTTP request. Return the response.""" + self.log('Request URL: %s' % url) + self.log('Method: %s' % method) + if params: + self.log('Params: %s' % params) + if payload: + self.log('Payload: %s' % payload) + if headers: + self.log('Headers: %s' % headers) + + if method == 'get': + req = self.http_session.get(url, params=params, headers=headers) + elif method == 'put': + req = self.http_session.put(url, params=params, data=payload, headers=headers) + else: # post + req = self.http_session.post(url, params=params, data=payload, headers=headers) + self.log('Response code: %s' % req.status_code) + self.log('Response: %s' % req.content) + + return self.parse_response(req.content) + + def parse_response(self, response): + """Try to load JSON data into dict and raise potential API errors.""" + try: + response = json.loads(response) + if 'error' in response: + if 'message' in response['error']: + raise self.CMoreError(response['error']['message']) + elif 'description' in response['error']: + raise self.CMoreError(response['error']['description']) + elif 'code' in response['error']: + raise self.CMoreError(response['error']['error']) + + except ValueError: # when response is not in json + pass + + return response + + def get_config(self): + """Return the config in a dict. Re-download if the config version doesn't match self.config_version.""" + try: + config = json.load(open(self.config_path))['data'] + except IOError: + self.download_config() + config = json.load(open(self.config_path))['data'] + + config_version = int(str(config['settings']['currentAppVersion']).replace('.', '')) + version_to_use = int(str(self.config_version).replace('.', '')) + config_lang = config['bootstrap']['suggested_site']['locale'] + if version_to_use > config_version or config_lang != self.locale: + self.download_config() + config = json.load(open(self.config_path))['data'] + + return config + + def download_config(self): + """Download the C More app configuration.""" + url = self.base_url + '/configuration' + params = { + 'device': 'android_tab', + 'locale': self.locale + } + config_data = self.make_request(url, 'get', params=params) + with open(self.config_path, 'w') as fh_config: + fh_config.write(json.dumps(config_data)) + + def save_credentials(self, credentials): + """Save credentials in JSON format.""" + credentials_dict = json.loads(credentials)['data'] + if self.get_credentials().get('remember_me'): + credentials_dict['remember_me'] = {} + credentials_dict['remember_me']['token'] = self.get_credentials()['remember_me']['token'] # resave token + with open(self.credentials_file, 'w') as fh_credentials: + fh_credentials.write(json.dumps(credentials_dict)) + + def reset_credentials(self): + """Overwrite credentials with empty JSON data.""" + credentials = {} + with open(self.credentials_file, 'w') as fh_credentials: + fh_credentials.write(json.dumps(credentials)) + + def get_credentials(self): + """Get JSON credentials file from disk and load it into a dictionary.""" + try: + with open(self.credentials_file, 'r') as fh_credentials: + credentials_dict = json.loads(fh_credentials.read()) + return credentials_dict + except IOError: + self.reset_credentials() + with open(self.credentials_file, 'r') as fh_credentials: + return json.loads(fh_credentials.read()) + + def get_operators(self): + """Return a list of TV operators supported by the C More login system.""" + url = self.config['links']['accountAPI'] + 'operators' + params = { + 'client': self.client, + 'country_code': self.locale_suffix + } + data = self.make_request(url, 'get', params=params) + + return data['data']['operators'] + + def login(self, username=None, password=None, operator=None): + """Complete login process for C More.""" + url = self.config['links']['accountAPI'] + 'session' + params = { + 'client': self.client, + 'legacy': 'true' + } + + if self.get_credentials().get('remember_me'): + method = 'put' + payload = { + 'locale': self.locale, + 'remember_me': self.get_credentials()['remember_me']['token'] + } + else: + method = 'post' + payload = { + 'username': username, + 'password': password + } + if operator: + payload['country_code'] = self.locale_suffix + payload['operator'] = operator + + credentials = self.make_request(url, method, params=params, payload=payload) + self.save_credentials(json.dumps(credentials)) + + def get_page(self, page_id, namespace='page'): + url = self.config['links']['pageAPI'] + page_id + params = { + 'locale': self.locale, + 'namespace': namespace + } + headers = {'Authorization': 'Bearer {0}'.format(self.get_credentials().get('jwt_token'))} + data = self.make_request(url, 'get', params=params, headers=headers) + + return data['data'] + + def get_content_details(self, page_type, page_id, season=None, size='999', page='1'): + url = self.config['links']['contentDetailsAPI'] + '{0}/{1}'.format(page_type, page_id) + params = {'locale': self.locale} + if season: + params['season'] = season + params['size'] = size + params['page'] = page + + headers = {'Authorization': 'Bearer {0}'.format(self.get_credentials().get('jwt_token'))} + data = self.make_request(url, 'get', params=params, headers=headers) + + return data['data'] + + def parse_page(self, page_id, namespace='page', root_page=False): + page = self.get_page(page_id, namespace) + if 'targets' in page: + return page['targets'] # movie/series items on theme-pages + if 'nowPlaying' in page: + return page['nowPlaying'] # tv channels/program info + if 'scheduledEvents' in page: + return page['scheduledEvents'] # sports events + if page.get('containers'): + if page['containers'].get('page_link_container'): + if page['containers']['page_link_container']['pageLinks'] and root_page: + # no parsing needed as it's already in the 'correct' format + return page['containers']['page_link_container']['pageLinks'] + if 'genre_containers' in page['containers']: + return self.parse_containers(page['containers']['genre_containers']) + if 'section_containers' in page['containers']: + return self.parse_containers(page['containers']['section_containers']) + + # if nothing matches + self.log('Failed to parse page.') + return False + + def parse_containers(self, containers): + """Parse containers in a sane format. See addon.py for implementation examples.""" + parsed_containers = [] + for i in containers: + if i['pageLink']['id']: + parsed_containers.append(i['pageLink']) + else: + container = { + 'id': i['id'], + 'attributes': i['attributes'], + 'page_data': i['targets'] + + } + parsed_containers.append(container) + + return parsed_containers + + def get_stream(self, video_id): + """Return a dict with stream URL and Widevine license URL.""" + stream = {} + allowed_formats = ['ism', 'mpd'] + url = self.config['links']['vimondRestAPI'] + 'api/tve_web/asset/{0}/play.json'.format(video_id) + params = {'protocol': 'VUDASH'} + headers = {'Authorization': 'Bearer {0}'.format(self.get_credentials().get('vimond_token'))} + data_dict = self.make_request(url, 'get', params=params, headers=headers)['playback'] + stream['drm_protected'] = data_dict['drmProtected'] + + if isinstance(data_dict['items']['item'], list): + for i in data_dict['items']['item']: + if i['mediaFormat'] in allowed_formats: + stream['mpd_url'] = i['url'] + if stream['drm_protected']: + stream['license_url'] = i['license']['@uri'] + stream['drm_type'] = i['license']['@name'] + break + else: + stream['mpd_url'] = data_dict['items']['item']['url'] + if stream['drm_protected']: + stream['license_url'] = data_dict['items']['item']['license']['@uri'] + stream['drm_type'] = data_dict['items']['item']['license']['@name'] + + live_stream_offset = self.parse_stream_offset(video_id) + if live_stream_offset: + stream['mpd_url'] = '{0}?t={1}'.format(stream['mpd_url'], live_stream_offset) + + return stream + + def parse_stream_offset(self, video_id): + """Calculate offset parameter needed for on-demand sports content.""" + url = self.config['links']['vimondRestAPI'] + 'api/tve_web/asset/{0}.json'.format(video_id) + params = {'expand': 'metadata'} + headers = {'Authorization': 'Bearer {0}'.format(self.get_credentials().get('jwt_token'))} + data = self.make_request(url, 'get', params=params, headers=headers)['asset'] + + if 'live-event-end' in data['metadata']: + utc_time_difference = int(data['liveBroadcastTime'].split('+')[1][1]) + start_time_local = self.parse_datetime(data['liveBroadcastTime']) + end_time_local = self.parse_datetime(data['metadata']['live-event-end']['$']) + + start_time_utc = start_time_local - timedelta(hours=utc_time_difference) + end_time_utc = end_time_local - timedelta(hours=utc_time_difference) + offset = '{0}-{1}'.format(start_time_utc.isoformat(), end_time_utc.isoformat()) + return offset + else: + return None + + def get_image_url(self, image_url): + """Request the image from their image proxy. Can be extended to resize/add image effects automatically. + See https://imageproxy.b17g.services/docs for more information.""" + if image_url: + return '{0}?source={1}'.format(self.config['links']['imageProxy'], image_url) + else: + return None + + def get_search_data(self, query, page='0', page_size='300'): + url = self.config['links']['searchAPI'] + params = { + 'query': query, + 'page': page, + 'pageSize': page_size, + 'locale': self.locale + } + headers = {'Authorization': 'Bearer {0}'.format(self.get_credentials().get('jwt_token'))} + data = self.make_request(url, 'get', params=params, headers=headers) + + return data['data']['hits'] + + @staticmethod + def parse_datetime(event_date): + """Parse date string to datetime object.""" + date_time_format = '%Y-%m-%dT%H:%M:%S+' + event_date.split('+')[1] # summer/winter time changes format + datetime_obj = datetime(*(time.strptime(event_date, date_time_format)[0:6])) + return datetime_obj + + @staticmethod + def get_current_time(): + """Return the current local time.""" + return datetime.now() diff --git a/plugin.video.cmore/resources/lib/kodihelper.py b/plugin.video.cmore/resources/lib/kodihelper.py new file mode 100644 index 0000000..8dc64fd --- /dev/null +++ b/plugin.video.cmore/resources/lib/kodihelper.py @@ -0,0 +1,237 @@ +import os +import urllib +import re + +from cmore import CMore + +import xbmc +import xbmcvfs +import xbmcgui +import xbmcplugin +import inputstreamhelper +from xbmcaddon import Addon + + +class KodiHelper(object): + def __init__(self, base_url=None, handle=None): + addon = self.get_addon() + self.base_url = base_url + self.handle = handle + self.addon_path = xbmc.translatePath(addon.getAddonInfo('path')) + self.addon_profile = xbmc.translatePath(addon.getAddonInfo('profile')) + self.addon_name = addon.getAddonInfo('id') + self.addon_version = addon.getAddonInfo('version') + self.language = addon.getLocalizedString + self.logging_prefix = '[%s-%s]' % (self.addon_name, self.addon_version) + if not xbmcvfs.exists(self.addon_profile): + xbmcvfs.mkdir(self.addon_profile) + self.c = CMore(self.addon_profile, self.get_setting('locale'), True) + + def get_addon(self): + """Returns a fresh addon instance.""" + return Addon() + + def get_setting(self, setting_id): + addon = self.get_addon() + setting = addon.getSetting(setting_id) + if setting == 'true': + return True + elif setting == 'false': + return False + else: + return setting + + def set_setting(self, key, value): + return self.get_addon().setSetting(key, value) + + def log(self, string): + msg = '%s: %s' % (self.logging_prefix, string) + xbmc.log(msg=msg, level=xbmc.LOGDEBUG) + + def dialog(self, dialog_type, heading, message=None, options=None, nolabel=None, yeslabel=None): + dialog = xbmcgui.Dialog() + if dialog_type == 'ok': + dialog.ok(heading, message) + elif dialog_type == 'yesno': + return dialog.yesno(heading, message, nolabel=nolabel, yeslabel=yeslabel) + elif dialog_type == 'select': + ret = dialog.select(heading, options) + if ret > -1: + return ret + else: + return None + + def get_user_input(self, heading, hidden=False): + keyboard = xbmc.Keyboard('', heading, hidden) + keyboard.doModal() + if keyboard.isConfirmed(): + query = keyboard.getText() + self.log('User input string: %s' % query) + else: + query = None + + if query and len(query) > 0: + return query + else: + return None + + def get_numeric_input(self, heading): + dialog = xbmcgui.Dialog() + numeric_input = dialog.numeric(0, heading) + + if len(numeric_input) > 0: + return str(numeric_input) + else: + return None + + def check_for_prerequisites(self): + if self.set_locale(self.get_setting('locale')) and self.set_login_credentials() and self.check_for_credentials(): + return True + else: + return False + + def set_login_credentials(self): + username = self.get_setting('username') + password = self.get_setting('password') + + if self.get_setting('tv_provider_login'): + operator = self.get_operator(self.get_setting('operator')) + if not operator: + return False + else: + operator = None + self.set_setting('operator_title', '') + self.set_setting('operator', '') + + if not username or not password: + if operator: + return self.set_tv_provider_credentials() + else: + self.dialog('ok', self.language(30017), self.language(30018)) + self.get_addon().openSettings() + return False + else: + return True + + def check_for_credentials(self): + if not self.c.get_credentials(): + self.login_process() + return True + + def login_process(self): + username = self.get_setting('username') + password = self.get_setting('password') + operator = self.get_setting('operator') + self.c.login(username, password, operator) + + def set_tv_provider_credentials(self): + operator = self.get_setting('operator') + operators = self.c.get_operators() + for i in operators: + if operator == i['name']: + username_type = i['username'] + password_type = i['password'] + info_message = re.sub('<[^<]+?>', '', i['login']) # strip html tags + break + self.dialog('ok', self.get_setting('operator_title'), message=info_message) + username = self.get_user_input(username_type) + password = self.get_user_input(password_type, hidden=True) + + if username and password: + self.set_setting('username', username) + self.set_setting('password', password) + return True + else: + return False + + def set_locale(self, locale=None): + countries = ['sv_SE', 'da_DK', 'nb_NO'] + if not locale: + options = [self.language(30013), self.language(30014), self.language(30015)] + selected_locale = self.dialog('select', self.language(30012), options=options) + if selected_locale is None: + selected_locale = 0 # default to .se + self.set_setting('locale_title', options[selected_locale]) + self.set_setting('locale', countries[selected_locale]) + self.reset_credentials() # reset credentials when locale is changed + + return True + + def get_operator(self, operator=None): + if not operator: + self.set_setting('tv_provider_login', 'true') + operators = self.c.get_operators() + options = [x['title'] for x in operators] + + selected_operator = self.dialog('select', self.language(30010), options=options) + if selected_operator is not None: + operator = operators[selected_operator]['name'] + operator_title = operators[selected_operator]['title'] + self.set_setting('operator', operator) + self.set_setting('operator_title', operator_title) + + return self.get_setting('operator') + + def reset_credentials(self): + self.c.reset_credentials() + self.set_setting('operator', '') + self.set_setting('operator_title', '') + self.set_setting('username', '') + self.set_setting('password', '') + + def add_item(self, title, params, items=False, folder=True, playable=False, info=None, art=None, content=False): + addon = self.get_addon() + listitem = xbmcgui.ListItem(label=title) + + if playable: + listitem.setProperty('IsPlayable', 'true') + folder = False + if art: + listitem.setArt(art) + else: + art = { + 'icon': addon.getAddonInfo('icon'), + 'fanart': addon.getAddonInfo('fanart') + } + listitem.setArt(art) + if info: + listitem.setInfo('video', info) + if content: + xbmcplugin.setContent(self.handle, content) + + recursive_url = self.base_url + '?' + urllib.urlencode(params) + + if items is False: + xbmcplugin.addDirectoryItem(self.handle, recursive_url, listitem, folder) + else: + items.append((recursive_url, listitem, folder)) + return items + + def eod(self): + """Tell Kodi that the end of the directory listing is reached.""" + xbmcplugin.endOfDirectory(self.handle) + + def play_item(self, video_id): + wv_proxy_base = 'http://localhost:' + str(self.get_setting('wv_proxy_port')) + stream = self.c.get_stream(video_id) + if stream['drm_protected']: + drm = 'widevine' + else: + drm = None + + ia_helper = inputstreamhelper.Helper('mpd', drm=drm) + if ia_helper.check_inputstream(): + playitem = xbmcgui.ListItem(path=stream['mpd_url']) + playitem.setProperty('inputstreamaddon', 'inputstream.adaptive') + playitem.setProperty('inputstream.adaptive.manifest_type', 'mpd') + if drm: + playitem.setProperty('inputstream.adaptive.license_type', 'com.widevine.alpha') + wv_proxy_url = '{0}?mpd_url={1}&license_url={2}'.format(wv_proxy_base, stream['mpd_url'], stream['license_url']) + playitem.setProperty('inputstream.adaptive.license_key', wv_proxy_url + '||R{SSM}|') + xbmcplugin.setResolvedUrl(self.handle, True, listitem=playitem) + + def get_as_bool(self, string): + if string == 'true': + return True + else: + return False diff --git a/plugin.video.cmore/resources/settings.xml b/plugin.video.cmore/resources/settings.xml new file mode 100644 index 0000000..8813f5d --- /dev/null +++ b/plugin.video.cmore/resources/settings.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/plugin.video.cmore/service.py b/plugin.video.cmore/service.py new file mode 100644 index 0000000..568ddbb --- /dev/null +++ b/plugin.video.cmore/service.py @@ -0,0 +1,52 @@ +# Author: asciidisco +# Module: service +# Created on: 13.01.2017 +# License: MIT https://goo.gl/5bMj3H + +import threading +import SocketServer +import socket +from xbmc import Monitor +from resources.lib.kodihelper import KodiHelper +from resources.lib.WidevineHTTPRequestHandler import WidevineHTTPRequestHandler + +# helper function to select an unused port on the host machine +def select_unused_port(): + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.bind(('127.0.0.1', 0)) + addr, port = sock.getsockname() + sock.close() + return port + +helper = KodiHelper() + +# pick & store a port for the proxy service +wv_proxy_port = select_unused_port() +helper.set_setting('wv_proxy_port', str(wv_proxy_port)) +helper.log('Port {0} selected'.format(str(wv_proxy_port))) + +# server defaults +SocketServer.TCPServer.allow_reuse_address = True +# configure the proxy server +wv_proxy = SocketServer.TCPServer(('127.0.0.1', wv_proxy_port), WidevineHTTPRequestHandler) +wv_proxy.server_activate() +wv_proxy.timeout = 1 + +if __name__ == '__main__': + monitor = Monitor() + # start thread for proxy server + proxy_thread = threading.Thread(target=wv_proxy.serve_forever) + proxy_thread.daemon = True + proxy_thread.start() + + # kill the services if kodi monitor tells us to + while not monitor.abortRequested(): + if monitor.waitForAbort(5): + wv_proxy.shutdown() + break + + # wv-proxy service shutdown sequence + wv_proxy.server_close() + wv_proxy.socket.close() + wv_proxy.shutdown() + helper.log('wv-proxy stopped') -- cgit v1.2.3