From 762bf5e724b660504b431e7579f998715286b53a Mon Sep 17 00:00:00 2001 From: Alex Waite Date: Thu, 17 Aug 2017 14:44:09 +0200 Subject: [plugin.video.nfl.gamepass] 0.11.0 (#1372) --- .../resources/language/Dutch/strings.po | 56 +- .../resources/language/English/strings.po | 18 +- .../resources/language/German/strings.po | 28 +- .../resources/language/Japanese/strings.po | 32 +- .../resources/language/Russian/strings.po | 28 +- .../resources/language/Ukrainian/strings.po | 28 +- plugin.video.nfl.gamepass/resources/lib/pigskin.py | 658 ++++++++------------- plugin.video.nfl.gamepass/resources/settings.xml | 5 +- 8 files changed, 412 insertions(+), 441 deletions(-) (limited to 'plugin.video.nfl.gamepass/resources') diff --git a/plugin.video.nfl.gamepass/resources/language/Dutch/strings.po b/plugin.video.nfl.gamepass/resources/language/Dutch/strings.po index 9f61d78..7164f27 100644 --- a/plugin.video.nfl.gamepass/resources/language/Dutch/strings.po +++ b/plugin.video.nfl.gamepass/resources/language/Dutch/strings.po @@ -78,8 +78,8 @@ msgid "Choose a game version" msgstr "Kies een wedstrijd versie" msgctxt "#30020" -msgid "Date/time in local time" -msgstr "Datum/tijd in lokale tijd" +msgid "Time Notation" +msgstr "Tijd Weergave" msgctxt "#30021" msgid "Error" @@ -87,15 +87,15 @@ msgstr "Fout" msgctxt "#30022" msgid "Due to broadcast restrictions, NFL Game Pass Domestic is currently unavailable. Please try again later." -msgstr "Vanwege uitzend beperkingen, NFL Game Pass Domestic is momenteel niet beschikbaar. Probeer het later opnieuw." +msgstr "Vanwege uitzendbeperkingen is NFL Game Pass Domestic momenteel niet beschikbaar. Probeer het later opnieuw." msgctxt "#30023" msgid "Logging into NFL Game Pass failed. Make sure that your account information is correct and your subscription is valid." -msgstr "Inloggen in NFL Game Pass mislukt. Zorg ervoor dat uw account informatie correct zijn en uw abonnement geldig is." +msgstr "Inloggen bij NFL Game Pass is mislukt. Zorg ervoor dat uw accountgegevens correct zijn en uw abonnement geldig is." msgctxt "#30024" msgid "Unexpected error ='(. Please enable debuging for both the addon and Kodi, and submit a bug report." -msgstr "" +msgstr "Onverwachte fout ='(. Schakel debug logging in voor zowel de addon als Kodi en verstuur een bug report." msgctxt "#30025" msgid "Hide game length" @@ -106,12 +106,12 @@ msgid "No" msgstr "Nee" msgctxt "#30027" -msgid "Yes, with 12-hour clock (AM/PM)" -msgstr "Ja, met 12-uurs klok (AM/PM)" +msgid "12-hour clock (AM/PM)" +msgstr "12-uurs klok (AM/PM)" msgctxt "#30028" -msgid "Yes, with 24-hour clock" -msgstr "Ja, met 24-uurs klok" +msgid "24-hour clock" +msgstr "24-uurs klok" msgctxt "#30029" msgid "General" @@ -127,40 +127,60 @@ msgstr "Coaches Film" msgctxt "#30033" msgid "Proxy Settings" -msgstr "" +msgstr "Proxy Instellingen" msgctxt "#30034" msgid "Use an HTTP proxy to access Game Pass" -msgstr "" +msgstr "Gebruik een HTTP proxy om Game Pass te benaderen" msgctxt "#30035" msgid "Protocol" -msgstr "" +msgstr "Protocol" msgctxt "#30036" msgid "Server" -msgstr "" +msgstr "Server" msgctxt "#30037" msgid "Port" -msgstr "" +msgstr "Poort" msgctxt "#30038" msgid "Username" -msgstr "" +msgstr "Gebruikersnaam" msgctxt "#30039" msgid "Password" -msgstr "" +msgstr "Wachtwoord" msgctxt "#30042" msgid "Enable 'Basic' Authentication" -msgstr "" +msgstr "Gebruik 'Eenvoudige' Authenticatie" msgctxt "#30043" msgid "There was a problem playing that stream" -msgstr "" +msgstr "Er was een probleem bij het afspelen van de stream" msgctxt "#30044" msgid "Some shows are known to not work. Please file a bug if the show is available and works in the official app." msgstr "" + +msgctxt "#30045" +msgid "No valid stream URL was found." +msgstr "Geen geldige stream URL gevonden." + +msgctxt "#30046" +msgid "There is currently no data available for this week." +msgstr "Er zijn op dit moment geen gegevens beschikbaar voor deze week." + +msgctxt "#30047" +msgid "PRESEASON WEEK {0}" +msgstr "VOORSEIZOEN WEEK {0}" + +msgctxt "#30048" +msgid "WEEK {0}" +msgstr "WEEK {0}" + +msgctxt "#30049" +msgid "Use InputStream Adaptive" +msgstr "Gebruik Inputstream Adaptive" diff --git a/plugin.video.nfl.gamepass/resources/language/English/strings.po b/plugin.video.nfl.gamepass/resources/language/English/strings.po index 2303fe4..1f0afc6 100644 --- a/plugin.video.nfl.gamepass/resources/language/English/strings.po +++ b/plugin.video.nfl.gamepass/resources/language/English/strings.po @@ -78,7 +78,7 @@ msgid "Choose a game version" msgstr "" msgctxt "#30020" -msgid "Localize Game Date/Time" +msgid "Time Notation" msgstr "" msgctxt "#30021" @@ -106,11 +106,11 @@ msgid "No" msgstr "" msgctxt "#30027" -msgid "Yes, with 12-hour clock (AM/PM)" +msgid "12-hour clock (AM/PM)" msgstr "" msgctxt "#30028" -msgid "Yes, with 24-hour clock" +msgid "24-hour clock" msgstr "" msgctxt "#30029" @@ -172,3 +172,15 @@ msgstr "" msgctxt "#30046" msgid "There is currently no data available for this week." msgstr "" + +msgctxt "#30047" +msgid "PRESEASON WEEK {0}" +msgstr "" + +msgctxt "#30048" +msgid "WEEK {0}" +msgstr "" + +msgctxt "#30049" +msgid "Use InputStream Adaptive" +msgstr "" diff --git a/plugin.video.nfl.gamepass/resources/language/German/strings.po b/plugin.video.nfl.gamepass/resources/language/German/strings.po index 19df51e..e630cc0 100644 --- a/plugin.video.nfl.gamepass/resources/language/German/strings.po +++ b/plugin.video.nfl.gamepass/resources/language/German/strings.po @@ -78,8 +78,8 @@ msgid "Choose a game version" msgstr "Wähle eine Spiel Version" msgctxt "#30020" -msgid "Date/time in local time" -msgstr "Uhrzeit in lokaler Zeitzone" +msgid "Time Notation" +msgstr "" msgctxt "#30021" msgid "Error" @@ -106,11 +106,11 @@ msgid "No" msgstr "" msgctxt "#30027" -msgid "Yes, with 12-hour clock (AM/PM)" +msgid "12-hour clock (AM/PM)" msgstr "" msgctxt "#30028" -msgid "Yes, with 24-hour clock" +msgid "24-hour clock" msgstr "" msgctxt "#30029" @@ -164,3 +164,23 @@ msgstr "" msgctxt "#30044" msgid "Some shows are known to not work. Please file a bug if the show is available and works in the official app." msgstr "" + +msgctxt "#30045" +msgid "No valid stream URL was found." +msgstr "" + +msgctxt "#30046" +msgid "There is currently no data available for this week." +msgstr "" + +msgctxt "#30047" +msgid "PRESEASON WEEK {0}" +msgstr "" + +msgctxt "#30048" +msgid "WEEK {0}" +msgstr "" + +msgctxt "#30049" +msgid "Use InputStream Adaptive" +msgstr "" diff --git a/plugin.video.nfl.gamepass/resources/language/Japanese/strings.po b/plugin.video.nfl.gamepass/resources/language/Japanese/strings.po index fb818fb..77b15d7 100644 --- a/plugin.video.nfl.gamepass/resources/language/Japanese/strings.po +++ b/plugin.video.nfl.gamepass/resources/language/Japanese/strings.po @@ -79,8 +79,8 @@ msgid "Choose a game version" msgstr "再生バージョン選択" msgctxt "#30020" -msgid "Localize Game Date/Time" -msgstr "現地時間" +msgid "Time Notation" +msgstr "" msgctxt "#30021" msgid "Error" @@ -107,12 +107,12 @@ msgid "No" msgstr "いいえ" msgctxt "#30027" -msgid "Yes, with 12-hour clock (AM/PM)" -msgstr "はい、12時間制 (AM/PM)" +msgid "12-hour clock (AM/PM)" +msgstr "12時間制 (AM/PM)" msgctxt "#30028" -msgid "Yes, with 24-hour clock" -msgstr "はい、24時間制" +msgid "24-hour clock" +msgstr "24時間制" msgctxt "#30029" msgid "General" @@ -165,3 +165,23 @@ msgstr "再生に問題が発生しました" msgctxt "#30044" msgid "Some shows are known to not work. Please file a bug if the show is available and works in the official app." msgstr "再生できない試合があります。公式アプリで再生できる場合、バグをレポートしてください。" + +msgctxt "#30045" +msgid "No valid stream URL was found." +msgstr "" + +msgctxt "#30046" +msgid "There is currently no data available for this week." +msgstr "" + +msgctxt "#30047" +msgid "PRESEASON WEEK {0}" +msgstr "" + +msgctxt "#30048" +msgid "WEEK {0}" +msgstr "" + +msgctxt "#30049" +msgid "Use InputStream Adaptive" +msgstr "" diff --git a/plugin.video.nfl.gamepass/resources/language/Russian/strings.po b/plugin.video.nfl.gamepass/resources/language/Russian/strings.po index c5c9bfc..7f5f6b1 100644 --- a/plugin.video.nfl.gamepass/resources/language/Russian/strings.po +++ b/plugin.video.nfl.gamepass/resources/language/Russian/strings.po @@ -78,8 +78,8 @@ msgid "Choose a game version" msgstr "Выбирать вручную" msgctxt "#30020" -msgid "Localize Game Date/Time" -msgstr "Локализировать время игр" +msgid "Time Notation" +msgstr "" msgctxt "#30021" msgid "Error" @@ -106,12 +106,12 @@ msgid "No" msgstr "Нет" msgctxt "#30027" -msgid "Yes, with 12-hour clock (AM/PM)" -msgstr "Да, 12ч (AM/PM)" +msgid "12-hour clock (AM/PM)" +msgstr "12ч (AM/PM)" msgctxt "#30028" -msgid "Yes, with 24-hour clock" -msgstr "Да, 24ч" +msgid "24-hour clock" +msgstr "24ч" msgctxt "#30029" msgid "General" @@ -172,3 +172,19 @@ msgstr "Некоторые шоу могут не работать. Пожалу msgctxt "#30045" msgid "No valid stream URL was found." msgstr "Не возможно найти URL для потока." + +msgctxt "#30046" +msgid "There is currently no data available for this week." +msgstr "" + +msgctxt "#30047" +msgid "PRESEASON WEEK {0}" +msgstr "" + +msgctxt "#30048" +msgid "WEEK {0}" +msgstr "" + +msgctxt "#30049" +msgid "Use InputStream Adaptive" +msgstr "" diff --git a/plugin.video.nfl.gamepass/resources/language/Ukrainian/strings.po b/plugin.video.nfl.gamepass/resources/language/Ukrainian/strings.po index 383af0b..5de8e67 100644 --- a/plugin.video.nfl.gamepass/resources/language/Ukrainian/strings.po +++ b/plugin.video.nfl.gamepass/resources/language/Ukrainian/strings.po @@ -78,8 +78,8 @@ msgid "Choose a game version" msgstr "Вибирати версію гри" msgctxt "#30020" -msgid "Localize Game Date/Time" -msgstr "Локалізувати час гри" +msgid "Time Notation" +msgstr "" msgctxt "#30021" msgid "Error" @@ -106,12 +106,12 @@ msgid "No" msgstr "Ні" msgctxt "#30027" -msgid "Yes, with 12-hour clock (AM/PM)" -msgstr "Так, 12г (AM/PM)" +msgid "12-hour clock (AM/PM)" +msgstr "12г (AM/PM)" msgctxt "#30028" -msgid "Yes, with 24-hour clock" -msgstr "Так, 24г" +msgid "24-hour clock" +msgstr "24г" msgctxt "#30029" msgid "General" @@ -172,3 +172,19 @@ msgstr "Деякі шоу можуть не працювати. Будь лас msgctxt "#30045" msgid "No valid stream URL was found." msgstr "Неможливо знайти URL для потоку." + +msgctxt "#30046" +msgid "There is currently no data available for this week." +msgstr "" + +msgctxt "#30047" +msgid "PRESEASON WEEK {0}" +msgstr "" + +msgctxt "#30048" +msgid "WEEK {0}" +msgstr "" + +msgctxt "#30049" +msgid "Use InputStream Adaptive" +msgstr "" diff --git a/plugin.video.nfl.gamepass/resources/lib/pigskin.py b/plugin.video.nfl.gamepass/resources/lib/pigskin.py index 96e24de..0432993 100644 --- a/plugin.video.nfl.gamepass/resources/lib/pigskin.py +++ b/plugin.video.nfl.gamepass/resources/lib/pigskin.py @@ -2,36 +2,32 @@ A Kodi-agnostic library for NFL Game Pass """ import codecs -import cookielib -import hashlib -import random -import m3u8 -import re +import uuid import sys +import json +import calendar +import time import urllib -from traceback import format_exc -from uuid import getnode as get_mac -from urlparse import urlsplit +import xml.etree.ElementTree as ET +from datetime import datetime, timedelta import requests -import xmltodict - +import m3u8 class pigskin(object): - def __init__(self, proxy_config, cookie_file, debug=False): + def __init__(self, proxy_config, debug=False): self.debug = debug - self.subscription = '' - self.base_url = 'https://gp.nfl.com/nflgp' - self.servlets_url = 'https://gp.nfl.com/nflgp/servlets' - self.simpleconsole_url = self.servlets_url + '/simpleconsole' - self.boxscore_url = '' - self.image_url = '' - self.locEDLBaseUrl = '' - self.non_seasonal_shows = {} - self.seasonal_shows = {} - self.nflnSeasons = [] - + self.base_url = 'https://www.nflgamepass.com' + self.user_agent = 'Firefox' self.http_session = requests.Session() + self.access_token = None + self.refresh_token = None + self.config = self.make_request(self.base_url + '/api/en/content/v1/web/config', 'get') + self.client_id = self.config['modules']['API']['CLIENT_ID'] + self.nfln_shows = {} + self.nfln_seasons = [] + self.parse_shows() + if proxy_config is not None: proxy_url = self.build_proxy_url(proxy_config) if proxy_url != '': @@ -39,44 +35,11 @@ class pigskin(object): 'http': proxy_url, 'https': proxy_url, } - self.cookie_jar = cookielib.LWPCookieJar(cookie_file) - try: - self.cookie_jar.load(ignore_discard=True, ignore_expires=True) - except IOError: - pass - self.http_session.cookies = self.cookie_jar - - # get needed URLs from simpleconsole - # no auth needed, so we can get this info without invoking a login - url = self.simpleconsole_url - post_data = {'isFlex': 'true'} - sc_data = self.make_request(url=url, method='post', payload=post_data) - try: - url_dict = xmltodict.parse(sc_data) - self.boxscore_url = url_dict['result']['pbpFeedPrefix'] - self.image_url = url_dict['result']['config']['locProgramImage'] - self.locEDLBaseUrl = url_dict['result']['config']['locEDL'].replace('/edl/nflgp/', '') - - self.log('boxscore url: %s' % self.boxscore_url) - self.log('image url: %s' % self.image_url) - self.log('locEDLBaseUrl: %s' % self.locEDLBaseUrl) - except xmltodict.expat.ExpatError: - self.log('Failed to parse contents of the "simpleconsole".') - self.log('pigskin __init__-ing failed. Time to debug!') - return None - - # get subscription type - if '' in sc_data: - self.subscription = 'domestic' - self.log('NFL Game Pass Domestic detected.') - else: - self.subscription = 'international' - self.log('NFL Game Pass International detected.') self.log('Debugging enabled.') self.log('Python Version: %s' % sys.version) - class LoginFailure(Exception): + class GamePassError(Exception): def __init__(self, value): self.value = value @@ -95,6 +58,43 @@ class pigskin(object): 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) + + def parse_response(self, req): + """Try to load JSON data into dict and raise potential errors.""" + try: + response = json.loads(req.content) + except ValueError: # if response is not json + response = req.content + + if isinstance(response, dict): + for key in response.keys(): + if key.lower() == 'message': + if response[key]: # raise all messages as GamePassError if message is not empty + raise self.GamePassError(response[key]) + + return response + def build_proxy_url(self, config): proxy_url = '' @@ -129,393 +129,259 @@ class pigskin(object): return proxy_url - def check_for_coachestape(self, game_id, season): - """Return whether coaches tape is available for a given game.""" - url = self.boxscore_url + '/' + season + '/' + game_id + '.xml' - boxscore = self.make_request(url=url, method='get') - - try: - boxscore_dict = xmltodict.parse(boxscore, encoding='cp1252') - except xmltodict.expat.ExpatError: - try: - boxscore_dict = xmltodict.parse(boxscore) - except xmltodict.expat.ExpatError: - return False - - try: - if boxscore_dict['dataset']['@coach'] == 'true': - return True - else: - return False - except KeyError: - return False - - def check_for_subscription(self): - """Return whether a subscription and user name are detected. Determines - whether a login was successful.""" - url = self.simpleconsole_url - post_data = {'isFlex': 'true'} - sc_data = self.make_request(url=url, method='post', payload=post_data) - - if '' not in sc_data: - self.log('No user name detected in Game Pass response.') - return False - elif '' not in sc_data: - self.log('No subscription detected in Game Pass response.') - return False - else: - self.log('Subscription and user name detected in Game Pass response.') - return True - - def gen_plid(self): - """Return a "unique" MD5 hash. Getting the video path requires a plid, - which looks like MD5 and always changes. Reusing a plid does not work, - so our guess is that it's a id for each instance of the player. + def login(self, username, password): + """Blindly authenticate to Game Pass. Use has_subscription() to + determine success. """ - rand = random.getrandbits(10) - mac_address = str(get_mac()) - md5 = hashlib.md5(str(rand) + mac_address) - return md5.hexdigest() - - def get_coaches_playIDs(self, game_id, season): - """Return a dict of play IDs with associated play descriptions.""" - playIDs = {} - url = self.boxscore_url + '/' + season + '/' + game_id + '.xml' - boxscore = self.make_request(url=url, method='get') - - try: - boxscore_dict = xmltodict.parse(boxscore, encoding='cp1252') - except xmltodict.expat.ExpatError: - try: - boxscore_dict = xmltodict.parse(boxscore) - except xmltodict.expat.ExpatError: - return False - - for row in boxscore_dict['dataset']['table']['row']: - playIDs[row['@PlayID']] = row['@PlayDescription'] - - return playIDs - - def get_coaches_url(self, game_id, game_date, event_id): - """Return the URL for a coaches-film play.""" - self.get_current_season_and_week() # set cookies - url = self.servlets_url + '/publishpoint' - - post_data = {'id': game_id, 'type': 'game', 'nt': '1', 'gt': 'coach', - 'event': event_id, 'bitrate': '1600', 'gdate': game_date} - headers = {'User-Agent': 'iPad'} - coach_data = self.make_request(url=url, method='post', payload=post_data, headers=headers) - coach_dict = xmltodict.parse(coach_data)['result'] - - return coach_dict['path'] - - def get_current_season_and_week(self): - """Return the current season and week_code (e.g. 210) in a dict.""" - url = self.simpleconsole_url - post_data = {'isFlex': 'true'} - sc_data = self.make_request(url=url, method='post', payload=post_data) - - sc_dict = xmltodict.parse(sc_data)['result'] - current_s_w = {sc_dict['currentSeason']: sc_dict['currentWeek']} - return current_s_w - - def parse_shows(self, sc_dict): - """Parse return from /simpleconsole request to build shows list dynamically""" - try: - # All (nearly) NFL Network Shows - show_dict = {} - for show in sc_dict['nflnShows']['show']: - name = show['name'] - season_dict = {} - - for season in show['seasons']['season']: - if isinstance(season, dict): - season_id = season['@catId'] - season_name = season['#text'] - else: - season_id = show['seasons']['season']['@catId'] - season_name = show['seasons']['season']['#text'] - - # Trim season name to just the year if year is present - # Common season names: '2014', 'Season 2014', and 'Archives' - try: - season_name = re.findall(r"\d{4}(?!\d)", season_name)[0] - except IndexError: - pass - - season_dict[season_name] = season_id - - if season_name not in self.nflnSeasons: - self.nflnSeasons.append(season_name) - - show_dict[name] = season_dict - - # RedZone is "special" and is returned separately in the XML - rz_dict = {} - for season in sc_dict['redZoneCats']['cat']: - rz_dict[season['@season']] = season['@id'] - - if season['@season'] not in self.nflnSeasons: - self.nflnSeasons.append(season['@season']) - - show_dict['RedZone Archives'] = rz_dict - - self.seasonal_shows.update(show_dict) - except KeyError: - self.log('Parsing shows failed') - raise - - def get_publishpoint_streams(self, video_id, stream_type=None, game_type=None): - """Return the URL for a stream.""" - streams = {} - self.get_current_season_and_week() # set cookies - url = self.servlets_url + '/publishpoint' - - if video_id == 'nfl_network': - post_data = {'id': '1', 'type': 'channel', 'nt': '1'} - elif video_id == 'redzone': - post_data = {'id': '2', 'type': 'channel', 'nt': '1'} - elif stream_type == 'game': - post_data = {'id': video_id, 'type': stream_type, 'nt': '1', 'gt': game_type} - else: - post_data = {'id': video_id, 'type': stream_type, 'nt': '1'} - - headers = {'User-Agent': 'iPad'} - m3u8_data = self.make_request(url=url, method='post', payload=post_data, headers=headers) - m3u8_dict = xmltodict.parse(m3u8_data)['result'] - self.log('NFL Dict %s' % m3u8_dict) - - m3u8_url = m3u8_dict['path'].replace('_ipad', '') - m3u8_param = m3u8_url.split('?', 1)[-1] - # I /hate/ lying with User-Agent. - # Huge points for making this work without lying. - m3u8_header = {'Cookie': 'nlqptid=' + m3u8_param, - 'User-Agent': 'Safari/537.36 Mozilla/5.0 AppleWebKit/537.36 Chrome/31.0.1650.57', - 'Accept-encoding': 'identity, gzip, deflate', - 'Connection': 'keep-alive'} - - try: - m3u8_manifest = self.make_request(url=m3u8_url, method='get') - except: - m3u8_manifest = False - - if m3u8_manifest: - m3u8_obj = m3u8.loads(m3u8_manifest) - if m3u8_obj.is_variant: # if this m3u8 contains links to other m3u8s - for playlist in m3u8_obj.playlists: - bitrate = int(playlist.stream_info.bandwidth) / 1000 - streams[str(bitrate)] = m3u8_url[:m3u8_url.rfind('/') + 1] + playlist.uri + '?' + m3u8_url.split('?')[1] + '|' + urllib.urlencode(m3u8_header) - else: - streams['sole available'] = m3u8_url - - return streams - - def get_shows(self, season): - """Return a list of all shows for a season.""" - seasons_shows = self.non_seasonal_shows.keys() - for show_name, show_codes in self.seasonal_shows.items(): - if season in show_codes: - seasons_shows.append(show_name) + url = self.config['modules']['API']['LOGIN'] + post_data = { + 'username': username, + 'password': password, + 'client_id': self.client_id, + 'grant_type': 'password' + } + data = self.make_request(url, 'post', payload=post_data) + self.access_token = data['access_token'] + self.refresh_token = data['refresh_token'] + self.check_for_subscription() - return sorted(seasons_shows) + return True - def get_shows_episodes(self, show_name, season=None): - """Return a list of episodes for a show. Return empty list if none are - found or if an error occurs. - """ - url = self.servlets_url + '/browse' - try: - cid = self.seasonal_shows[show_name][season] - except KeyError: - try: - cid = self.non_seasonal_shows[show_name] - except KeyError: - return [] + def check_for_subscription(self): + """Returns True if a subscription is detected. Raises error_unauthorised on failure.""" + url = self.config['modules']['API']['USER_PROFILE'] + headers = {'Authorization': 'Bearer {0}'.format(self.access_token)} + self.make_request(url, 'get', headers=headers) - if show_name == 'NFL RedZone Archives': - ps = 17 - else: - ps = 50 + return True + def refresh_tokens(self): + """Refreshes authorization tokens.""" + url = self.config['modules']['API']['LOGIN'] post_data = { - 'isFlex': 'true', - 'cid': cid, - 'pm': 0, - 'ps': ps, - 'pn': 1 + 'refresh_token': self.refresh_token, + 'client_id': self.client_id, + 'grant_type': 'refresh_token' } + data = self.make_request(url, 'post', payload=post_data) + self.access_token = data['access_token'] + self.refresh_token = data['refresh_token'] - archive_data = self.make_request(url=url, method='post', payload=post_data) - archive_dict = xmltodict.parse(archive_data)['result'] - - try: - items = archive_dict['programs']['program'] - # if only one episode is returned, we explicitly put it into a list - if isinstance(items, dict): - items = [items] - return items - except TypeError: - return [] + return True def get_seasons_and_weeks(self): """Return a multidimensional array of all seasons and weeks.""" seasons_and_weeks = {} try: - url = self.locEDLBaseUrl + '/mobile/weeks_v2.xml' - s_w_data = self.make_request(url=url, method='get') - s_w_data_dict = xmltodict.parse(s_w_data) + url = self.config['modules']['ROUTES_DATA_PROVIDERS']['games'] + seasons = self.make_request(url, 'get') except: self.log('Acquiring season and week data failed.') raise try: - for season in s_w_data_dict['seasons']['season']: - year = season['@season'] - season_dict = {} - - for week in season['week']: - if week['@section'] == "pre": # preseason - week_code = '1' + week['@value'].zfill(2) - season_dict[week_code] = week - else: # regular season and post season - week_code = '2' + week['@value'].zfill(2) - season_dict[week_code] = week - - seasons_and_weeks[year] = season_dict + for season in seasons['modules']['mainMenu']['seasonStructureList']: + weeks = [] + year = str(season['season']) + for season_type in season['seasonTypes']: + for week in season_type['weeks']: + week_dict = { + 'week_number': str(week['number']), + 'week_name': week['weekNameAbbr'], + 'season_type': season_type['seasonType'] + } + weeks.append(week_dict) + + seasons_and_weeks[year] = weeks except KeyError: self.log('Parsing season and week data failed.') raise return seasons_and_weeks - def get_weeks_games(self, season, week_code): - """Return a list of games for a week.""" - url = self.servlets_url + '/games' - post_data = { - 'isFlex': 'true', - 'season': season, - 'week': week_code + def get_current_season_and_week(self): + """Return the current season, season type, and week in a dict.""" + try: + url = self.config['modules']['ROUTES_DATA_PROVIDERS']['games'] + seasons = self.make_request(url, 'get') + except: + self.log('Acquiring season and week data failed.') + raise + + current_s_w = { + 'season': seasons['modules']['meta']['currentContext']['currentSeason'], + 'season_type': seasons['modules']['meta']['currentContext']['currentSeasonType'], + 'week': str(seasons['modules']['meta']['currentContext']['currentWeek']) } - game_data = self.make_request(url=url, method='post', payload=post_data) - game_data_dict = xmltodict.parse(game_data)['result'] - if game_data_dict['games']: - games = game_data_dict['games']['game'] - # if only one game is returned, we explicitly put it into a list - if isinstance(games, dict): - games = [games] + return current_s_w - return games + def get_weeks_games(self, season, season_type, week): + try: + url = self.config['modules']['ROUTES_DATA_PROVIDERS']['games_detail'].replace(':seasonType', season_type).replace(':season', season).replace(':week', week) + games_data = self.make_request(url, 'get') + # collect the games from all keys in 'modules' + games = [g for x in games_data['modules'].keys() for g in games_data['modules'][x]['content']] + except: + self.log('Acquiring games data failed.') + raise + + return sorted(games, key=lambda x: x['gameDateTimeUtc']) + + def has_coaches_tape(self, game_id, season): + """Return whether coaches tape is available for a given game.""" + url = self.config['modules']['ROUTES_DATA_PROVIDERS']['game_page'].replace(':season', season).replace(':gameslug', game_id) + response = self.make_request(url, 'get') + coaches_tape = response['modules']['singlegame']['content'][0]['coachfilmVideo'] + if coaches_tape: + self.log('Coaches Tape found.') + return coaches_tape['videoId'] else: - return None + self.log('No Coaches Tape found for this game.') + return False - def login(self, username=None, password=None): - """Complete login process for Game Pass. Errors (auth issues, blackout, - etc) are raised as LoginFailure. - """ - if self.check_for_subscription(): - self.log('Already logged into Game Pass %s' % self.subscription) + + def has_condensed_game(self, game_id, season): + """Return whether condensed game version is available.""" + url = self.config['modules']['ROUTES_DATA_PROVIDERS']['game_page'].replace(':season', season).replace(':gameslug', game_id) + response = self.make_request(url, 'get') + condensed = response['modules']['singlegame']['content'][0]['condensedVideo'] + if condensed: + self.log('Condensed game found.') + return condensed['videoId'] else: - if username and password: - self.log('Not (yet) logged into %s' % self.subscription) - self.login_to_account(username, password) - if not self.check_for_subscription(): - raise self.LoginFailure('%s login failed' % self.subscription) - elif self.subscription == 'domestic' and self.service_blackout(): - raise self.LoginFailure('Game Pass Domestic Blackout') + self.log('No condensed version was found for this game.') + return False + + def get_stream(self, video_id, game_type=None, username=None): + """Return the URL for a stream.""" + self.refresh_tokens() + + if video_id == 'nfl_network': + diva_config_url = self.config['modules']['DIVA']['HTML5']['SETTINGS']['Live24x7'] + url = self.config['modules']['ROUTES_DATA_PROVIDERS']['network'] + response = self.make_request(url, 'get') + video_id = response['modules']['networkLiveVideo']['content'][0]['videoId'] + else: + if game_type == 'live': + diva_config_url = self.config['modules']['DIVA']['HTML5']['SETTINGS']['LiveNoData'] else: - self.log('No username and password supplied.') - raise self.LoginFailure('No username and password supplied.') + diva_config_url = self.config['modules']['DIVA']['HTML5']['SETTINGS']['VodNoData'] + + diva_config_data = self.make_request(diva_config_url.replace('device', 'html5'), 'get') + diva_config_root = ET.fromstring(diva_config_data) + for i in diva_config_root.iter('parameter'): + if i.attrib['name'] == 'processingUrlCallPath': + processing_url = i.attrib['value'] + elif i.attrib['name'] == 'videoDataPath': + stream_request_url = i.attrib['value'].replace('{V.ID}', video_id) + akamai_xml_data = self.make_request(stream_request_url, 'get') + akamai_xml_root = ET.fromstring(akamai_xml_data) + for i in akamai_xml_root.iter('videoSource'): + if i.attrib['format'] == 'ChromeCast': + for text in i.itertext(): + if 'http' in text: + m3u8_url = text + break - def login_to_account(self, username, password): - """Blindly authenticate to Game Pass. Use check_for_subscription() to - determine success. - """ - url = self.base_url + '/secure/nfllogin' post_data = { - 'username': username, - 'password': password + 'Type': '1', + 'User': '', + 'VideoId': video_id, + 'VideoSource': m3u8_url, + 'VideoKind': 'Video', + 'AssetState': '3', + 'PlayerType': 'HTML5', + 'other': '{0}|{1}|web|{1}|undefined|{2}' .format(str(uuid.uuid4()), self.access_token, self.user_agent, username) } - self.make_request(url=url, method='post', payload=post_data) - def make_request(self, url, method, payload=None, headers=None): - """Make an http request. Return the response.""" - self.log('Request URL: %s' % url) - self.log('Headers: %s' % headers) + response = self.make_request(processing_url, 'post', payload=json.dumps(post_data)) - try: - if method == 'get': - req = self.http_session.get(url, params=payload, headers=headers, allow_redirects=False) - else: # post - req = self.http_session.post(url, data=payload, headers=headers, allow_redirects=False) - req.raise_for_status() - self.log('Response code: %s' % req.status_code) - self.log('Response: %s' % req.content) - self.cookie_jar.save(ignore_discard=True, ignore_expires=False) - return req.content - except requests.exceptions.HTTPError as error: - self.log('An HTTP error occurred: %s' % error) - raise - except requests.exceptions.ProxyError: - self.log('Error connecting to proxy server') - raise - except requests.exceptions.ConnectionError as error: - self.log('Connection Error: - %s' % error.message) - raise - except requests.exceptions.RequestException as error: - self.log('Error: - %s' % error.value) - raise + return self.parse_m3u8_manifest(response['ContentUrl']) - def parse_manifest(self, manifest): - """Return a dict of the supplied XML manifest. Builds and adds - "full_url" for convenience. - """ + def parse_m3u8_manifest(self, manifest_url): + """Return the manifest URL along with its bitrate.""" streams = {} - manifest_dict = xmltodict.parse(manifest) - - for stream in manifest_dict['channel']['streamDatas']['streamData']: - try: - url_path = stream['@url'] - bitrate = url_path[(url_path.rindex('_') + 1):url_path.rindex('.')] - try: - stream['full_url'] = 'http://%s%s.m3u8' % (stream['httpservers']['httpserver']['@name'], url_path) - except TypeError: # if multiple servers are returned, use the first in the list - stream['full_url'] = 'http://%s%s.m3u8' % (stream['httpservers']['httpserver'][0]['@name'], url_path) - - streams[bitrate] = stream - except KeyError: - self.log(format_exc()) + m3u8_header = { + 'Connection': 'keep-alive', + 'User-Agent': self.user_agent + } + streams['manifest_url'] = manifest_url + '|' + urllib.urlencode(m3u8_header) + streams['bitrates'] = {} + m3u8_manifest = self.make_request(manifest_url, 'get') + m3u8_obj = m3u8.loads(m3u8_manifest) + for playlist in m3u8_obj.playlists: + bitrate = int(playlist.stream_info.bandwidth) / 1000 + streams['bitrates'][bitrate] = manifest_url[:manifest_url.rfind('/manifest') + 1] + playlist.uri + '?' + manifest_url.split('?')[1] + '|' + urllib.urlencode(m3u8_header) return streams def redzone_on_air(self): """Return whether RedZone Live is currently broadcasting.""" - url = self.simpleconsole_url - post_data = {'isFlex': 'true'} - sc_data = self.make_request(url=url, method='post', payload=post_data) + url = self.config['modules']['ROUTES_DATA_PROVIDERS']['redzone'] + response = self.make_request(url, 'get') + if not response['modules']['redZoneLive']['content']: + return False + else: + return True - sc_dict = xmltodict.parse(sc_data)['result'] + def parse_shows(self): + """Dynamically parse the NFL Network shows into a dict.""" + show_dict = {} + url = self.config['modules']['API']['NETWORK_PROGRAMS'] + response = self.make_request(url, 'get') + + for show in response['modules']['programs']: + season_dict = {} + for season in show['seasons']: + season_name = season['value'] + season_id = season['slug'] + season_dict[season_name] = season_id + if season_name not in self.nfln_seasons: + self.nfln_seasons.append(season_name) + show_dict[show['title']] = season_dict + self.nfln_shows.update(show_dict) - # Dynamically parse NFL-Network shows - self.parse_shows(sc_dict) + def get_shows(self, season): + """Return a list of all shows for a season.""" + seasons_shows = [] - # Check if RedZone is Live - if sc_dict['rzPhase'] in ('pre', 'in'): - self.log('RedZone is on air.') - return True - else: - self.log('RedZone is not on air.') - return False + for show_name, show_codes in self.nfln_shows.items(): + if season in show_codes: + seasons_shows.append(show_name) - def service_blackout(self): - """Return whether Game Pass is blacked out.""" - url = self.base_url + '/secure/schedule' - blackout_message = ('Due to broadcast restrictions, NFL Game Pass is currently unavailable.' - ' Please check back later.') - service_data = self.make_request(url=url, method='get') + return sorted(seasons_shows) - if blackout_message in service_data: - return True + def get_shows_episodes(self, show_name, season=None): + """Return a list of episodes for a show. Return empty list if none are + found or if an error occurs.""" + url = self.config['modules']['API']['NETWORK_PROGRAMS'] + programs = self.make_request(url, 'get')['modules']['programs'] + for show in programs: + if show_name == show['title']: + selected_show = show + break + season_slug = [x['slug'] for x in selected_show['seasons'] if season == x['value']][0] + request_url = self.config['modules']['API']['NETWORK_EPISODES'] + episodes_url = request_url.replace(':seasonSlug', season_slug).replace(':tvShowSlug', selected_show['slug']) + + return self.make_request(episodes_url, 'get')['modules']['archive']['content'] + + def parse_datetime(self, date_string, localize=False): + """Parse NFL Game Pass date string to datetime object.""" + date_time_format = '%Y-%m-%dT%H:%M:%S.%fZ' + datetime_obj = datetime(*(time.strptime(date_string, date_time_format)[0:6])) + if localize: + return self.utc_to_local(datetime_obj) else: - return False + return datetime_obj + + @staticmethod + def utc_to_local(utc_dt): + """Convert UTC time to local time.""" + # get integer timestamp to avoid precision lost + timestamp = calendar.timegm(utc_dt.timetuple()) + local_dt = datetime.fromtimestamp(timestamp) + assert utc_dt.resolution >= timedelta(microseconds=1) + return local_dt.replace(microsecond=utc_dt.microsecond) diff --git a/plugin.video.nfl.gamepass/resources/settings.xml b/plugin.video.nfl.gamepass/resources/settings.xml index ee5512f..0b0a6a3 100644 --- a/plugin.video.nfl.gamepass/resources/settings.xml +++ b/plugin.video.nfl.gamepass/resources/settings.xml @@ -4,9 +4,10 @@ - + + - + -- cgit v1.2.3