From: Sebastian Golasch Date: Thu, 26 Jan 2017 19:22:16 +0000 (+0100) Subject: feat(init): Repository init X-Git-Url: http://git.code-monkey.de/?p=plugin.video.netflix.git;a=commitdiff_plain;h=85447a5fdfc7dff80e2272d77a5f94c0eddff1af feat(init): Repository init --- 85447a5fdfc7dff80e2272d77a5f94c0eddff1af diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e43b0f9 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..bc69919 --- /dev/null +++ b/README.md @@ -0,0 +1,29 @@ +# plugin.video.netflix + +Einstellungen: +-------------- + + - E-Mail/Passwort: Logindaten des Netflix-Accounts + - Verbose logging an/aus + - Logout (löscht gespeicherte Cookies & Benutzerdaten) + - Library Einstellugnen (noch nicht genutzt) + - SSL Einstellungen (noch nicht genutzt) + +Funktionen: +----------- + + - Wechsel zwischen Profilen + - Suche nach Filmen/Serien (Nicht nach Schauspielern, Genres) + - Anzeigen von Kategorien wie "Meine Liste", "Weil Sie x gesehen haben", "Mit dem Profil von x weiterschauen", "Originals" etc. + - Eingabe von "Adult Pin" bei FSK 18 Filmen/Serien + - Rating aus Kodi in Netflix + - Einträge auf Netflix in Watchlist (Meine Liste) hinzufügen/entfernen + +ToDo: +----- + + - Export in lokale Library + - Suche nach Schauspielern, Genres ermöglichen + - Fehlende Daten (Cast, bookmark position, etc.) den Show items in der Serien Video Liste hinzufügen + - Mit dem Profil von "x" weiterschauen - Statt der Liste der Shows direkt die Episode mit Bookmark anzeigen + - Sorting erweitern diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/addon.py b/addon.py new file mode 100644 index 0000000..6ac0874 --- /dev/null +++ b/addon.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# Module: default +# Created on: 13.01.2017 + +# import local classes +if __package__ is None: + import sys + import os + sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + from resources.lib.NetflixSession import NetflixSession + from resources.lib.KodiHelper import KodiHelper + from resources.lib.Navigation import Navigation + from resources.lib.Library import Library +else: + from .resources.lib.NetflixSession import NetflixSession + from .resources.lib.KodiHelper import KodiHelper + from .resources.lib.Navigation import Navigation + from .resources.lib.Library import Library + +# Setup plugin +plugin_handle = int(sys.argv[1]) +base_url = sys.argv[0] + +# init plugin libs +kodi_helper = KodiHelper( + plugin_handle=plugin_handle, + base_url=base_url +) +netflix_session = NetflixSession( + cookie_path=kodi_helper.cookie_path, + data_path=kodi_helper.data_path, + log_fn=kodi_helper.log +) +library = Library( + base_url=base_url, + #root_folder=kodi_helper.base_data_path, + root_folder='/Users/asciidisco/Desktop/lib', + library_settings=kodi_helper.get_custom_library_settings(), + log_fn=kodi_helper.log +) +navigation = Navigation( + netflix_session=netflix_session, + kodi_helper=kodi_helper, + library=library, + base_url=base_url, + log_fn=kodi_helper.log +) +kodi_helper.set_library(library=library) + +if __name__ == '__main__': + # Call the router function and pass the plugin call parameters to it. + # We use string slicing to trim the leading '?' from the plugin call paramstring + navigation.router(paramstring=sys.argv[2][1:]) diff --git a/addon.xml b/addon.xml new file mode 100644 index 0000000..140b595 --- /dev/null +++ b/addon.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + video + + + + Netflix + Addon für Netflix VOD Services + Möglicherweise sind einge Teile dieses Addons in Ihrem Land illegal, Sie sollten dies unbedingt vor der Installation überprüfen. + Netflix + Netflix VOD Services Addon + Some parts of this addon may not be legal in your country of residence - please check with your local laws before installing. + + resources\icon.png + resources\fanart.jpg + resources\screenshot-01.jpg + resources\screenshot-02.jpg + resources\screenshot-03.jpg + + all + GNU GENERAL PUBLIC LICENSE. Version 2, June 1991 + http://www.kodinerds.net/ + https://github.com/kodinerds/repo + + diff --git a/resources/__init__.py b/resources/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/resources/fanart.jpg b/resources/fanart.jpg new file mode 100644 index 0000000..099eaa8 Binary files /dev/null and b/resources/fanart.jpg differ diff --git a/resources/icon.png b/resources/icon.png new file mode 100644 index 0000000..4b47f9d Binary files /dev/null and b/resources/icon.png differ diff --git a/resources/language/English/strings.po b/resources/language/English/strings.po new file mode 100644 index 0000000..1005031 --- /dev/null +++ b/resources/language/English/strings.po @@ -0,0 +1,150 @@ +# Kodi Media Center language file +# Addon Name: Netflix +# Addon id: plugin.video.netflix +# Addon version: 0.2.0 +# Addon Provider: tba. +msgid "" +msgstr "" +"Project-Id-Version: XBMC-Addons\n" +"Report-Msgid-Bugs-To: alanwww1@xbmc.org\n" +"POT-Creation-Date: 2017-01-01 12:00+0000\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE\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 "Addon Summary" +msgid "Netflix" +msgstr "" + +msgctxt "Addon Description" +msgid "Netflix VOD Services Addon" +msgstr "" + +msgctxt "Addon Disclaimer" +msgid "Some parts of this addon may not be legal in your country of residence - please check with your local laws before installing." +msgstr "" + +msgctxt "#30001" +msgid "Recommendations" +msgstr "" + +msgctxt "#30002" +msgid "Adult Pin" +msgstr "" + +msgctxt "#30003" +msgid "Search term" +msgstr "" + +msgctxt "#30004" +msgid "Password" +msgstr "" + +msgctxt "#30005" +msgid "E-mail" +msgstr "" + +msgctxt "#30006" +msgid "Adult verification failed" +msgstr "" + +msgctxt "#30007" +msgid "Please Check your adult pin" +msgstr "" + +msgctxt "#30008" +msgid "Login failed" +msgstr "" + +msgctxt "#30009" +msgid "Please Check your credentials" +msgstr "" + +msgctxt "#30010" +msgid "Genres" +msgstr "" + +msgctxt "#30011" +msgid "Search" +msgstr "" + +msgctxt "#30012" +msgid "No seasons available" +msgstr "" + +msgctxt "#30013" +msgid "No matches found" +msgstr "" + +msgctxt "#30014" +msgid "Account" +msgstr "" + +msgctxt "#30015" +msgid "Logging" +msgstr "" + +msgctxt "#30016" +msgid "Verbose logging" +msgstr "" + +msgctxt "#30017" +msgid "Logout" +msgstr "" + +msgctxt "#30018" +msgid "Export to library" +msgstr "" + +msgctxt "#30019" +msgid "Rate on Netflix" +msgstr "" + +msgctxt "#30020" +msgid "Remove from 'My list'" +msgstr "" + +msgctxt "#30021" +msgid "Add to 'My list'" +msgstr "" + +msgctxt "#30022" +msgid "(between 0 & 10)" +msgstr "" + +msgctxt "#30023" +msgid "Expert" +msgstr "" + +msgctxt "#30024" +msgid "SSL verification" +msgstr "" + +msgctxt "#30025" +msgid "Library" +msgstr "" + +msgctxt "#30026" +msgid "Enable custom library folder" +msgstr "" + +msgctxt "#30027" +msgid "Custom library path" +msgstr "" + +msgctxt "#30028" +msgid "Playback error" +msgstr "" + +msgctxt "#30029" +msgid "Missing Inputstream addon" +msgstr "" + +msgctxt "#30030" +msgid "Remove from library" +msgstr "" diff --git a/resources/language/German/strings.po b/resources/language/German/strings.po new file mode 100644 index 0000000..671cc86 --- /dev/null +++ b/resources/language/German/strings.po @@ -0,0 +1,150 @@ +# Kodi Media Center language file +# Addon Name: Netflix +# Addon id: plugin.video.netflix +# Addon version: 0.2.0 +# Addon Provider: tba. +msgid "" +msgstr "" +"Project-Id-Version: XBMC-Addons\n" +"Report-Msgid-Bugs-To: alanwww1@xbmc.org\n" +"POT-Creation-Date: 2017-01-01 12:00+0000\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE\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 "Addon Summary" +msgid "Netflix" +msgstr "Netflix" + +msgctxt "Addon Description" +msgid "Netflix VOD Services Addon" +msgstr "Addon für Netflix VOD Services" + +msgctxt "Addon Disclaimer" +msgid "Some parts of this addon may not be legal in your country of residence - please check with your local laws before installing." +msgstr "Möglicherweise sind einge Teile dieses Addons in Ihrem Land illegal, Sie sollten dies unbedingt vor der Installation überprüfen." + +msgctxt "#30001" +msgid "Recommendations" +msgstr "Vorschläge" + +msgctxt "#30002" +msgid "Adult Pin" +msgstr "Pin" + +msgctxt "#30003" +msgid "Search term" +msgstr "Suchbegriff" + +msgctxt "#30004" +msgid "Password" +msgstr "Passwort" + +msgctxt "#30005" +msgid "E-mail" +msgstr "E-mail" + +msgctxt "#30006" +msgid "Adult verification failed" +msgstr "Kindersicherungs-PIN nicht korrekt" + +msgctxt "#30007" +msgid "Please Check your adult pin" +msgstr "Bitter überprüfen Sie Ihre Kindersicherungs-PIN" + +msgctxt "#30008" +msgid "Login failed" +msgstr "Login nicht erfolgreich" + +msgctxt "#30009" +msgid "Please Check your credentials" +msgstr "Bitte überprüfen Sie Ihre Login Daten" + +msgctxt "#30010" +msgid "Genres" +msgstr "Genres" + +msgctxt "#30011" +msgid "Search" +msgstr "Suche" + +msgctxt "#30012" +msgid "No seasons available" +msgstr "Keine Staffeln verfügbar" + +msgctxt "#30013" +msgid "No matches found" +msgstr "Keine Titel gefunden" + +msgctxt "#30014" +msgid "Account" +msgstr "Account" + +msgctxt "#30015" +msgid "Logging" +msgstr "Logging" + +msgctxt "#30016" +msgid "Verbose logging" +msgstr "Verbose logging" + +msgctxt "#30017" +msgid "Logout" +msgstr "Logout" + +msgctxt "#30018" +msgid "Export to library" +msgstr "In Bibliothek exportieren" + +msgctxt "#30019" +msgid "Rate on Netflix" +msgstr "Auf Netflix bewerten" + +msgctxt "#30020" +msgid "Remove from 'My list'" +msgstr "Von 'Meiner Liste' entfernen" + +msgctxt "#30021" +msgid "Add to 'My list'" +msgstr "Zu 'Meiner Liste' hinzufügen" + +msgctxt "#30022" +msgid "(between 0 & 10)" +msgstr "(zwischen 0 & 10)" + +msgctxt "#30023" +msgid "Expert" +msgstr "Experte" + +msgctxt "#30024" +msgid "SSL verification" +msgstr "SSL verifizierung" + +msgctxt "#30025" +msgid "Library" +msgstr "Bibliothek" + +msgctxt "#30026" +msgid "Enable custom library folder" +msgstr "Eigenen Bibliotheks ordner nutzen" + +msgctxt "#30027" +msgid "Custom library path" +msgstr "Pfad zur Bibliothek" + +msgctxt "#30028" +msgid "Playback error" +msgstr "Fehler beim abspielen" + +msgctxt "#30029" +msgid "Missing Inputstream addon" +msgstr "Inputstream nicht gefunden" + +msgctxt "#30030" +msgid "Remove from library" +msgstr "Aus Bibliothek entfernen" diff --git a/resources/lib/KodiHelper.py b/resources/lib/KodiHelper.py new file mode 100644 index 0000000..ff4e836 --- /dev/null +++ b/resources/lib/KodiHelper.py @@ -0,0 +1,785 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# Module: KodiHelper +# Created on: 13.01.2017 + +import os +import xbmcplugin +import xbmcgui +import xbmcaddon +import xbmc +import json + +class KodiHelper: + """Consumes all the configuration data from Kodi as well as turns data into lists of folders and videos""" + + msl_service_server_certificate = 'Cr0CCAMSEOVEukALwQ8307Y2+LVP+0MYh/HPkwUijgIwggEKAoIBAQDm875btoWUbGqQD8eAGuBlGY+Pxo8YF1LQR+Ex0pDONMet8EHslcZRBKNQ/09RZFTP0vrYimyYiBmk9GG+S0wB3CRITgweNE15cD33MQYyS3zpBd4z+sCJam2+jj1ZA4uijE2dxGC+gRBRnw9WoPyw7D8RuhGSJ95OEtzg3Ho+mEsxuE5xg9LM4+Zuro/9msz2bFgJUjQUVHo5j+k4qLWu4ObugFmc9DLIAohL58UR5k0XnvizulOHbMMxdzna9lwTw/4SALadEV/CZXBmswUtBgATDKNqjXwokohncpdsWSauH6vfS6FXwizQoZJ9TdjSGC60rUB2t+aYDm74cIuxAgMBAAE6EHRlc3QubmV0ZmxpeC5jb20SgAOE0y8yWw2Win6M2/bw7+aqVuQPwzS/YG5ySYvwCGQd0Dltr3hpik98WijUODUr6PxMn1ZYXOLo3eED6xYGM7Riza8XskRdCfF8xjj7L7/THPbixyn4mULsttSmWFhexzXnSeKqQHuoKmerqu0nu39iW3pcxDV/K7E6aaSr5ID0SCi7KRcL9BCUCz1g9c43sNj46BhMCWJSm0mx1XFDcoKZWhpj5FAgU4Q4e6f+S8eX39nf6D6SJRb4ap7Znzn7preIvmS93xWjm75I6UBVQGo6pn4qWNCgLYlGGCQCUm5tg566j+/g5jvYZkTJvbiZFwtjMW5njbSRwB3W4CrKoyxw4qsJNSaZRTKAvSjTKdqVDXV/U5HK7SaBA6iJ981/aforXbd2vZlRXO/2S+Maa2mHULzsD+S5l4/YGpSt7PnkCe25F+nAovtl/ogZgjMeEdFyd/9YMYjOS4krYmwp3yJ7m9ZzYCQ6I8RQN4x/yLlHG5RH/+WNLNUs6JAZ0fFdCmw=' + """str: MSL service certificate""" + + msl_service_server_url = 'http://localhost:%PORT%' + """str: MSL service url""" + + msl_service_port = 8000 + """str: MSL service port (TODO: Make it dynamic)""" + + def __init__ (self, plugin_handle, base_url): + """Fetches all needed info from Kodi & configures the baseline of the plugin + + Parameters + ---------- + plugin_handle : :obj:`int` + Plugin handle + + base_url : :obj:`str` + Plugin base url + """ + self.plugin_handle = plugin_handle + self.base_url = base_url + self.addon = xbmcaddon.Addon() + self.plugin = self.addon.getAddonInfo('name') + self.base_data_path = xbmc.translatePath(self.addon.getAddonInfo('profile')) + self.home_path = xbmc.translatePath('special://home') + self.plugin_path = self.addon.getAddonInfo('path') + self.cookie_path = self.base_data_path + 'COOKIE' + self.data_path = self.base_data_path + 'DATA' + self.config_path = os.path.join(self.base_data_path, 'config') + self.verb_log = self.addon.getSetting('logging') == 'true' + self.default_fanart = self.addon.getAddonInfo('fanart') + self.win = xbmcgui.Window(xbmcgui.getCurrentWindowId()) + self.library = None + + def refresh (self): + """Refrsh the current list""" + return xbmc.executebuiltin('Container.Refresh') + + def show_rating_dialog (self): + """Asks the user for a movie rating + + Returns + ------- + :obj:`int` + Movie rating between 0 & 10 + """ + dlg = xbmcgui.Dialog() + return dlg.numeric(heading=self.get_local_string(string_id=30019) + ' ' + self.get_local_string(string_id=30022), type=0) + + def show_adult_pin_dialog (self): + """Asks the user for the adult pin + + Returns + ------- + :obj:`int` + 4 digit adult pin needed for adult movies + """ + dlg = xbmcgui.Dialog() + return dlg.input(self.get_local_string(string_id=30002), type=xbmcgui.INPUT_NUMERIC) + + def show_search_term_dialog (self): + """Asks the user for a term to query the netflix search for + + Returns + ------- + term : :obj:`str` + Term to search for + """ + dlg = xbmcgui.Dialog() + return dlg.input(self.get_local_string(string_id=30003), type=xbmcgui.INPUT_ALPHANUM) + + def show_password_dialog (self): + """Asks the user for its Netflix password + + Returns + ------- + :obj:`str` + Netflix password + """ + dlg = xbmcgui.Dialog() + return dlg.input(self.get_local_string(string_id=30004), type=xbmcgui.INPUT_ALPHANUM) + + def show_email_dialog (self): + """Asks the user for its Netflix account email + + Returns + ------- + term : :obj:`str` + Netflix account email + """ + dlg = xbmcgui.Dialog() + return dlg.input(self.get_local_string(string_id=30005), type=xbmcgui.INPUT_ALPHANUM) + + def show_wrong_adult_pin_notification (self): + """Shows notification that a wrong adult pin was given + + Returns + ------- + bool + Dialog shown + """ + dialog = xbmcgui.Dialog() + dialog.notification(self.get_local_string(string_id=30006), self.get_local_string(string_id=30007), xbmcgui.NOTIFICATION_ERROR, 5000) + return True + + def show_login_failed_notification (self): + """Shows notification that the login failed + + Returns + ------- + bool + Dialog shown + """ + dialog = xbmcgui.Dialog() + dialog.notification(self.get_local_string(string_id=30008), self.get_local_string(string_id=30009), xbmcgui.NOTIFICATION_ERROR, 5000) + return True + + def show_missing_inputstream_addon_notification (self): + """Shows notification that the inputstream addon couldn't be found + + Returns + ------- + bool + Dialog shown + """ + dialog = xbmcgui.Dialog() + dialog.notification(self.get_local_string(string_id=30028), self.get_local_string(string_id=30029), xbmcgui.NOTIFICATION_ERROR, 5000) + return True + + def set_setting (key, value): + """Public interface for the addons setSetting method + + Returns + ------- + bool + Setting could be set or not + """ + return self.addon.setSetting(key, value) + + def get_credentials (self): + """Returns the users stored credentials + + Returns + ------- + :obj:`dict` of :obj:`str` + The users stored account data + """ + return { + 'email': self.addon.getSetting('email'), + 'password': self.addon.getSetting('password') + } + + def get_custom_library_settings (self): + """Returns the settings in regards to the custom library folder(s) + + Returns + ------- + :obj:`dict` of :obj:`str` + The users library settings + """ + return { + 'enablelibraryfolder': self.addon.getSetting('enablelibraryfolder'), + 'customlibraryfolder': self.addon.getSetting('customlibraryfolder') + } + + def set_main_menu_selection (self, type): + self.win.setProperty('main_menu_selection', type) + + def get_main_menu_selection (self): + return self.win.getProperty('main_menu_selection') + + def build_profiles_listing (self, profiles, action, build_url): + """Builds the profiles list Kodi screen + + Parameters + ---------- + profiles : :obj:`dict` of :obj:`str` + List of user profiles + + action : :obj:`str` + Action paramter to build the subsequent routes + + build_url : :obj:`fn` + Function to build the subsequent routes + + Returns + ------- + bool + List could be build + """ + for profile_id in profiles: + profile = profiles[profile_id] + url = build_url({'action': action, 'profile_id': profile_id}) + li = xbmcgui.ListItem(label=profile['profileName'], iconImage=profile['avatar']) + li.setProperty('fanart_image', self.default_fanart) + xbmcplugin.addDirectoryItem(handle=self.plugin_handle, url=url, listitem=li, isFolder=True) + xbmcplugin.addSortMethod(handle=self.plugin_handle, sortMethod=xbmcplugin.SORT_METHOD_LABEL) + xbmcplugin.endOfDirectory(self.plugin_handle) + return True + + def build_main_menu_listing (self, video_list_ids, user_list_order, actions, build_url): + """Builds the video lists (my list, continue watching, etc.) Kodi screen + + Parameters + ---------- + video_list_ids : :obj:`dict` of :obj:`str` + List of video lists + + user_list_order : :obj:`list` of :obj:`str` + Ordered user lists, to determine what should be displayed in the main menue + + actions : :obj:`dict` of :obj:`str` + Dictionary of actions to build subsequent routes + + build_url : :obj:`fn` + Function to build the subsequent routes + + Returns + ------- + bool + List could be build + """ + preselect_items = [] + for category in user_list_order: + for video_list_id in video_list_ids['user']: + if video_list_ids['user'][video_list_id]['name'] == category: + label = video_list_ids['user'][video_list_id]['displayName'] + if category == 'netflixOriginals': + label = label.capitalize() + li = xbmcgui.ListItem(label=label) + li.setProperty('fanart_image', self.default_fanart) + # determine action route + action = actions['default'] + if category in actions.keys(): + action = actions[category] + # determine if the item should be selected + preselect_items.append((False, True)[category == self.get_main_menu_selection()]) + url = build_url({'action': action, 'video_list_id': video_list_id, 'type': category}) + xbmcplugin.addDirectoryItem(handle=self.plugin_handle, url=url, listitem=li, isFolder=True) + + # add recommendations/genres as subfolders (save us some space on the home page) + i18n_ids = { + 'recommendations': self.get_local_string(30001), + 'genres': self.get_local_string(30010) + } + for type in i18n_ids.keys(): + # determine if the lists have contents + if len(video_list_ids[type]) > 0: + # determine action route + action = actions['default'] + if type in actions.keys(): + action = actions[type] + # determine if the item should be selected + preselect_items.append((False, True)[type == self.get_main_menu_selection()]) + li_rec = xbmcgui.ListItem(label=i18n_ids[type]) + li_rec.setProperty('fanart_image', self.default_fanart) + url_rec = build_url({'action': action, 'type': type}) + xbmcplugin.addDirectoryItem(handle=self.plugin_handle, url=url_rec, listitem=li_rec, isFolder=True) + + # add search as subfolder + action = actions['default'] + if 'search' in actions.keys(): + action = actions[type] + li_rec = xbmcgui.ListItem(label=self.get_local_string(30011)) + li_rec.setProperty('fanart_image', self.default_fanart) + url_rec = build_url({'action': action, 'type': 'search'}) + xbmcplugin.addDirectoryItem(handle=self.plugin_handle, url=url_rec, listitem=li_rec, isFolder=True) + + # no srting & close + xbmcplugin.addSortMethod(handle=self.plugin_handle, sortMethod=xbmcplugin.SORT_METHOD_UNSORTED) + xbmcplugin.endOfDirectory(self.plugin_handle) + + # (re)select the previously selected main menu entry + idx = 1 + preselected_list_item = None + for item in preselect_items: + idx += 1 + if item: + preselected_list_item = idx + if self.get_main_menu_selection() == 'search': + preselected_list_item = idx + 2 + if preselected_list_item != None: + xbmc.executebuiltin('SetFocus(%s, %s)' % (self.win.getFocusId(), preselected_list_item)) + + return True + + def build_video_listing (self, video_list, actions, type, build_url): + """Builds the video lists (my list, continue watching, etc.) contents Kodi screen + + Parameters + ---------- + video_list_ids : :obj:`dict` of :obj:`str` + List of video lists + + actions : :obj:`dict` of :obj:`str` + Dictionary of actions to build subsequent routes + + type : :obj:`str` + None or 'queue' f.e. when it´s a special video lists + + build_url : :obj:`fn` + Function to build the subsequent routes + + Returns + ------- + bool + List could be build + """ + for video_list_id in video_list: + video = video_list[video_list_id] + if type != 'queue' or (type == 'queue' and video['in_my_list'] == True): + li = xbmcgui.ListItem(label=video['title']) + # add some art to the item + li = self._generate_art_info(entry=video, li=li) + # it´s a show, so we need a subfolder & route (for seasons) + isFolder = True + url = build_url({'action': actions[video['type']], 'show_id': video_list_id}) + # lists can be mixed with shows & movies, therefor we need to check if its a movie, so play it right away + if video_list[video_list_id]['type'] == 'movie': + # it´s a movie, so we need no subfolder & a route to play it + isFolder = False + # check maturity index, to determine if we need the adult pin + needs_pin = (True, False)[int(video['maturity']['level']) >= 1000] + url = build_url({'action': 'play_video', 'video_id': video_list_id, 'pin': needs_pin}) + # add list item info + li = self._generate_entry_info(entry=video, li=li) + li = self._generate_context_menu_items(entry=video, li=li) + xbmcplugin.addDirectoryItem(handle=self.plugin_handle, url=url, listitem=li, isFolder=isFolder) + + xbmcplugin.addSortMethod(handle=self.plugin_handle, sortMethod=xbmcplugin.SORT_METHOD_LABEL) + xbmcplugin.endOfDirectory(self.plugin_handle) + return True + + def build_search_result_listing (self, video_list, actions, build_url): + """Builds the search results list Kodi screen + + Parameters + ---------- + video_list : :obj:`dict` of :obj:`str` + List of videos or shows + + actions : :obj:`dict` of :obj:`str` + Dictionary of actions to build subsequent routes + + build_url : :obj:`fn` + Function to build the subsequent routes + + Returns + ------- + bool + List could be build + """ + return self.build_video_listing(video_list=video_list, actions=actions, type='search', build_url=build_url) + + def build_no_seasons_available (self): + """Builds the season list screen if no seasons could be found + + Returns + ------- + bool + List could be build + """ + li = xbmcgui.ListItem(label=self.get_local_string(30012)) + xbmcplugin.addDirectoryItem(handle=self.plugin_handle, url='', listitem=li, isFolder=False) + xbmcplugin.endOfDirectory(self.plugin_handle) + return True + + def build_no_search_results_available (self, build_url, action): + """Builds the search results screen if no matches could be found + + Parameters + ---------- + action : :obj:`str` + Action paramter to build the subsequent routes + + build_url : :obj:`fn` + Function to build the subsequent routes + + Returns + ------- + bool + List could be build + """ + li = xbmcgui.ListItem(label=self.get_local_string(30013)) + xbmcplugin.addDirectoryItem(handle=self.plugin_handle, url=build_url({'action': action}), listitem=li, isFolder=False) + xbmcplugin.endOfDirectory(self.plugin_handle) + return True + + def build_user_sub_listing (self, video_list_ids, type, action, build_url): + """Builds the video lists screen for user subfolders (genres & recommendations) + + Parameters + ---------- + video_list_ids : :obj:`dict` of :obj:`str` + List of video lists + + type : :obj:`str` + List type (genre or recommendation) + + action : :obj:`str` + Action paramter to build the subsequent routes + + build_url : :obj:`fn` + Function to build the subsequent routes + + Returns + ------- + bool + List could be build + """ + for video_list_id in video_list_ids: + li = xbmcgui.ListItem(video_list_ids[video_list_id]['displayName']) + li.setProperty('fanart_image', self.default_fanart) + url = build_url({'action': action, 'video_list_id': video_list_id}) + xbmcplugin.addDirectoryItem(handle=self.plugin_handle, url=url, listitem=li, isFolder=True) + + xbmcplugin.addSortMethod(handle=self.plugin_handle, sortMethod=xbmcplugin.SORT_METHOD_LABEL) + xbmcplugin.endOfDirectory(self.plugin_handle) + return True + + def build_season_listing (self, seasons_sorted, season_list, build_url): + """Builds the season list screen for a show + + Parameters + ---------- + seasons_sorted : :obj:`list` of :obj:`str` + Sorted season indexes + + season_list : :obj:`dict` of :obj:`str` + List of season entries + + build_url : :obj:`fn` + Function to build the subsequent routes + + Returns + ------- + bool + List could be build + """ + for index in seasons_sorted: + for season_id in season_list: + season = season_list[season_id] + if int(season['shortName'].split(' ')[1]) == index: + li = xbmcgui.ListItem(label=season['text']) + # add some art to the item + li = self._generate_art_info(entry=season, li=li) + # add list item info + li = self._generate_entry_info(entry=season, li=li, base_info={'mediatype': 'season'}) + li = self._generate_context_menu_items(entry=season, li=li) + url = build_url({'action': 'episode_list', 'season_id': season_id}) + xbmcplugin.addDirectoryItem(handle=self.plugin_handle, url=url, listitem=li, isFolder=True) + + xbmcplugin.addSortMethod(handle=self.plugin_handle, sortMethod=xbmcplugin.SORT_METHOD_NONE) + xbmcplugin.endOfDirectory(self.plugin_handle) + return True + + def build_episode_listing (self, episodes_sorted, episode_list, build_url): + """Builds the episode list screen for a season of a show + + Parameters + ---------- + episodes_sorted : :obj:`list` of :obj:`str` + Sorted episode indexes + + episode_list : :obj:`dict` of :obj:`str` + List of episode entries + + build_url : :obj:`fn` + Function to build the subsequent routes + + Returns + ------- + bool + List could be build + """ + for index in episodes_sorted: + for episode_id in episode_list: + episode = episode_list[episode_id] + if int(episode['episode']) == index: + li = xbmcgui.ListItem(label=episode['title']) + # add some art to the item + li = self._generate_art_info(entry=episode, li=li) + # add list item info + li = self._generate_entry_info(entry=episode, li=li, base_info={'mediatype': 'episode'}) + li = self._generate_context_menu_items(entry=episode, li=li) + # check maturity index, to determine if we need the adult pin + needs_pin = (True, False)[int(episode['maturity']['rating']['maturityLevel']) >= 1000] + url = build_url({'action': 'play_video', 'video_id': episode_id, 'pin': needs_pin, 'start_offset': episode['bookmark']}) + xbmcplugin.addDirectoryItem(handle=self.plugin_handle, url=url, listitem=li, isFolder=False) + xbmcplugin.addSortMethod(handle=self.plugin_handle, sortMethod=xbmcplugin.SORT_METHOD_EPISODE) + xbmcplugin.endOfDirectory(self.plugin_handle) + return True + + def play_item (self, esn, video_id, start_offset=-1): + """Plays a video + + Parameters + ---------- + esn : :obj:`str` + ESN needed for Widevine/Inputstream + + video_id : :obj:`str` + ID of the video that should be played + + start_offset : :obj:`str` + Offset to resume playback from (in seconds) + + Returns + ------- + bool + List could be build + """ + inputstream_addon = self.get_inputstream_addon() + if inputstream_addon == None: + self.show_missing_inputstream_addon_notification() + self.log(msg='Inputstream addon not found') + return False + + # inputstream addon properties + msl_service_url = self.msl_service_server_url.replace('%PORT%', str(self.msl_service_port)) + play_item = xbmcgui.ListItem(path=msl_service_url + '/manifest?id=' + video_id) + play_item.setProperty(inputstream_addon + '.license_type', 'com.widevine.alpha') + play_item.setProperty(inputstream_addon + '.manifest_type', 'mpd') + play_item.setProperty(inputstream_addon + '.license_key', msl_service_url + '/license?id=' + video_id + '||b{SSM}!b{SID}|') + play_item.setProperty(inputstream_addon + '.server_certificate', self.msl_service_server_certificate) + play_item.setProperty('inputstreamaddon', inputstream_addon) + + # check if we have a bookmark e.g. start offset position + if int(start_offset) > 0: + play_item.setProperty('StartOffset', str(start_offset)) + return xbmcplugin.setResolvedUrl(self.plugin_handle, True, listitem=play_item) + + def _generate_art_info (self, entry, li): + """Adds the art info from an entry to a Kodi list item + + Parameters + ---------- + entry : :obj:`dict` of :obj:`str` + Entry that should be turned into a list item + + li : :obj:`XMBC.ListItem` + Kodi list item instance + + Returns + ------- + :obj:`XMBC.ListItem` + Kodi list item instance + """ + art = {'fanart': self.default_fanart} + if 'boxarts' in dict(entry).keys(): + art.update({ + 'poster': entry['boxarts']['big'], + 'landscape': entry['boxarts']['big'], + 'thumb': entry['boxarts']['small'], + 'fanart': entry['boxarts']['big'] + }) + if 'interesting_moment' in dict(entry).keys(): + art.update({ + 'poster': entry['interesting_moment'], + 'fanart': entry['interesting_moment'] + }) + if 'thumb' in dict(entry).keys(): + art.update({'thumb': entry['thumb']}) + if 'fanart' in dict(entry).keys(): + art.update({'fanart': entry['fanart']}) + if 'poster' in dict(entry).keys(): + art.update({'poster': entry['poster']}) + li.setArt(art) + return li + + def _generate_entry_info (self, entry, li, base_info={}): + """Adds the item info from an entry to a Kodi list item + + Parameters + ---------- + entry : :obj:`dict` of :obj:`str` + Entry that should be turned into a list item + + li : :obj:`XMBC.ListItem` + Kodi list item instance + + base_info : :obj:`dict` of :obj:`str` + Additional info that overrules the entry info + + Returns + ------- + :obj:`XMBC.ListItem` + Kodi list item instance + """ + infos = base_info + entry_keys = entry.keys() + if 'cast' in entry_keys and len(entry['cast']) > 0: + infos.update({'cast': entry['cast']}) + if 'creators' in entry_keys and len(entry['creators']) > 0: + infos.update({'writer': entry['creators'][0]}) + if 'directors' in entry_keys and len(entry['directors']) > 0: + infos.update({'director': entry['directors'][0]}) + if 'genres' in entry_keys and len(entry['genres']) > 0: + infos.update({'genre': entry['genres'][0]}) + if 'maturity' in entry_keys: + if 'mpaa' in entry_keys: + infos.update({'mpaa': entry['mpaa']}) + else: + infos.update({'mpaa': str(entry['maturity']['board']) + '-' + str(entry['maturity']['value'])}) + if 'rating' in entry_keys: + infos.update({'rating': int(entry['rating']) * 2}) + if 'synopsis' in entry_keys: + infos.update({'plot': entry['synopsis']}) + if 'plot' in entry_keys: + infos.update({'plot': entry['plot']}) + if 'runtime' in entry_keys: + infos.update({'duration': entry['runtime']}) + if 'duration' in entry_keys: + infos.update({'duration': entry['duration']}) + if 'seasons_label' in entry_keys: + infos.update({'season': entry['seasons_label']}) + if 'season' in entry_keys: + infos.update({'season': entry['season']}) + if 'title' in entry_keys: + infos.update({'title': entry['title']}) + if 'type' in entry_keys: + if entry['type'] == 'movie' or entry['type'] == 'episode': + li.setProperty('IsPlayable', 'true') + if 'mediatype' in entry_keys: + if entry['mediatype'] == 'movie' or entry['mediatype'] == 'episode': + li.setProperty('IsPlayable', 'true') + infos.update({'mediatype': entry['mediatype']}) + if 'watched' in entry_keys: + infos.update({'playcount': (1, 0)[entry['watched']]}) + if 'index' in entry_keys: + infos.update({'episode': entry['index']}) + if 'episode' in entry_keys: + infos.update({'episode': entry['episode']}) + if 'year' in entry_keys: + infos.update({'year': entry['year']}) + if 'quality' in entry_keys: + quality = {'width': '960', 'height': '540'} + if entry['quality'] == '720': + quality = {'width': '1280', 'height': '720'} + if entry['quality'] == '1080': + quality = {'width': '1920', 'height': '1080'} + li.addStreamInfo('video', quality) + li.setInfo('video', infos) + return li + + def _generate_context_menu_items (self, entry, li): + """Adds context menue items to a Kodi list item + + Parameters + ---------- + entry : :obj:`dict` of :obj:`str` + Entry that should be turned into a list item + + li : :obj:`XMBC.ListItem` + Kodi list item instance + Returns + ------- + :obj:`XMBC.ListItem` + Kodi list item instance + """ + items = [] + action = {} + entry_keys = entry.keys() + + # action item templates + url_tmpl = 'XBMC.RunPlugin(' + self.base_url + '?action=%action%&id=' + str(entry['id']) + ')' + actions = [ + ['export_to_library', self.get_local_string(30018), 'export'], + ['remove_from_library', self.get_local_string(30030), 'remove'], + ['rate_on_netflix', self.get_local_string(30019), 'rating'], + ['remove_from_my_list', self.get_local_string(30020), 'remove_from_list'], + ['add_to_my_list', self.get_local_string(30021), 'add_to_list'] + ] + + # build concrete action items + for action_item in actions: + action.update({action_item[0]: [action_item[1], url_tmpl.replace('%action%', action_item[2])]}) + + # add or remove the movie/show/season/episode from & to the users "My List" + if 'in_my_list' in entry_keys: + items.append(action['remove_from_my_list']) if entry['in_my_list'] else items.append(action['add_to_my_list']) + elif 'queue' in entry_keys: + items.append(action['remove_from_my_list']) if entry['queue'] else items.append(action['add_to_my_list']) + elif 'my_list' in entry_keys: + items.append(action['remove_from_my_list']) if entry['my_list'] else items.append(action['add_to_my_list']) + # rate the movie/show/season/episode on Netflix + items.append(action['rate_on_netflix']) + + # add possibility to export this movie/show/season/episode to a static/local library (and to remove it) + # TODO: Not yet finished, still needs implementation + #if 'type' in entry_keys: + #items.append(action['export_to_library']) + #items.append(action['remove_from_library']) + + # add it to the item + li.addContextMenuItems(items) + return li + + def log (self, msg, level=xbmc.LOGNOTICE): + """Adds a log entry to the Kodi log + + Parameters + ---------- + msg : :obj:`str` + Entry that should be turned into a list item + + level : :obj:`int` + Kodi log level + """ + if level == xbmc.LOGDEBUG and self.verb_log: + level = xbmc.LOGNOTICE + if isinstance(msg, unicode): + msg = msg.encode('utf-8') + xbmc.log('[%s] %s' % (self.plugin, msg.__str__()), level) + + def get_local_string (self, string_id): + """Returns the localized version of a string + + Parameters + ---------- + string_id : :obj:`int` + ID of the string that shoudl be fetched + + Returns + ------- + :obj:`str` + Requested string or empty string + """ + src = xbmc if string_id < 30000 else self.addon + locString = src.getLocalizedString(string_id) + if isinstance(locString, unicode): + locString = locString.encode('utf-8') + return locString + + def get_inputstream_addon (self): + """Checks if the inputstream addon is installed & enabled. + Returns the type of the inputstream addon used or None if not found + + Returns + ------- + :obj:`str` or None + Inputstream addon or None + """ + type = 'inputstream.adaptive' + payload = { + 'jsonrpc': '2.0', + 'id': 1, + 'method': 'Addons.GetAddonDetails', + 'params': { + 'addonid': type, + 'properties': ['enabled'] + } + } + response = xbmc.executeJSONRPC(json.dumps(payload)) + data = json.loads(response) + if not 'error' in data.keys(): + if data['result']['addon']['enabled'] == True: + return type + return None + + def set_library (self, library): + """Adds an instance of the Library class + + Parameters + ---------- + library : :obj:`Library` + instance of the Library class + """ + self.library = library diff --git a/resources/lib/Library.py b/resources/lib/Library.py new file mode 100644 index 0000000..e7e4f5b --- /dev/null +++ b/resources/lib/Library.py @@ -0,0 +1,215 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# Module: LibraryExporter +# Created on: 13.01.2017 + +import os +import pickle +from utils import noop + +class Library: + """Exports Netflix shows & movies to a local library folder (Not yet ready)""" + + series_label = 'shows' + movies_label = 'movies' + db_filename = 'lib.ndb' + + + def __init__ (self, base_url, root_folder, library_settings, log_fn=noop): + """Takes the instances & configuration options needed to drive the plugin + + Parameters + ---------- + base_url : :obj:`str` + Plugin base url + + root_folder : :obj:`str` + Cookie location + + library_settings : :obj:`str` + User data cache location + + library_db_path : :obj:`str` + User data cache location + + log_fn : :obj:`fn` + optional log function + """ + self.base_url = base_url + self.base_data_path = root_folder + self.enable_custom_library_folder = library_settings['enablelibraryfolder'] + self.custom_library_folder = library_settings['customlibraryfolder'] + self.log = log_fn + self.db_filepath = os.path.join(self.base_data_path, self.db_filename) + + # check for local library folder & set up the paths + if self.enable_custom_library_folder != 'true': + self.movie_path = os.path.join(self.base_data_path, self.series_label) + self.tvshow_path = os.path.join(self.base_data_path, self.movies_label) + else: + self.movie_path = os.path.join(self.custom_library_folder, self.movies_label) + self.tvshow_path = os.path.join(self.custom_library_folder, self.series_label) + + self.setup_local_netflix_library(source={ + self.movies_label: self.movie_path, + self.series_label: self.tvshow_path + }) + + self.db = self._load_local_db(filename=self.db_filepath) + + def setup_local_netflix_library (self, source): + for label in source: + if not os.path.exists(source[label]): + os.makedirs(source[label]) + + def write_strm_file(self, path, url): + with open(path, 'w+') as f: + f.write(url) + f.close() + + def _load_local_db (self, filename): + # if the db doesn't exist, create it + if not os.path.isfile(filename): + data = {self.movies_label: {}, self.series_label: {}} + self.log('Setup local library DB') + self._update_local_db(filename=filename, data=data) + return data + + with open(filename) as f: + data = pickle.load(f) + if data: + return data + else: + return {} + + def _update_local_db (self, filename, data): + if not os.path.isdir(os.path.dirname(filename)): + return False + with open(filename, 'w') as f: + f.truncate() + pickle.dump(data, f) + return True + + def movie_exists (self, title, year): + movie_meta = '%s (%d)' % (title, year) + return movie_meta in self.db[self.movies_label] + + def show_exists (self, title, year): + show_meta = '%s (%d)' % (title, year) + return show_meta in self.db[self.series_label] + + def season_exists (self, title, year, season): + if self.show_exists() == False: + return False + show_meta = '%s (%d)' % (title, year) + show_entry = self.db[self.series_label][show_meta] + return season in show_entry['seasons'] + + def episode_exists (self, title, year, season, episode): + if self.show_exists() == False: + return False + show_meta = '%s (%d)' % (title, year) + show_entry = self.db[self.series_label][show_meta] + episode_entry = 'S%02dE%02d' % (season, episode) + return episode_entry in show_entry['episodes'] + + def add_movie(self, title, year, video_id, pin, build_url): + movie_meta = '%s (%d)' % (title, year) + dirname = os.path.join(self.movie_path, movie_meta) + filename = os.path.join(dirname, movie_meta + '.strm') + if os.path.exists(filename): + return + if not os.path.exists(dirname): + os.makedirs(dirname) + if self.movie_exists(title=title, year=year) == False: + self.db[self.movies_label][movie_meta] = True + self._update_local_db(filename=self.db_filepath, db=self.db) + self.write_strm_file(path=filename, url=build_url({'action': 'play_video', 'video_id': video_id, 'pin': pin})) + + def add_show(self, title, year, episodes, build_url): + show_meta = '%s (%d)' % (title, year) + show_dir = os.path.join(self.tvshow_path, show_meta) + if not os.path.exists(show_dir): + os.makedirs(show_dir) + if self.show_exists(title, year) == False: + self.db[self.series_label][show_meta] = {'seasons': [], 'episodes': []} + for episode_id in episodes: + episode = episodes[episode_id] + self._add_episode(show_dir=show_dir, show_meta=show_meta, title=title, year=year, season=episode['season'], episode=episode['idx'], video_id=episode['id'], pin=episode['pin'], build_url=build_url) + self._update_local_db(filename=self.db_filepath, db=self.db) + return show_dir + + def _add_episode(self, title, year, show_dir, show_meta, season, episode, video_id, pin, build_url): + season = int(season) + episode = int(episode) + + # add season + if self.season_exists(title=title, year=year, season=season) == False: + self.db[self.series_label][show_meta]['seasons'].append(season) + + # add episode + episode_meta = 'S%02dE%02d' % (season, episode) + if self.episode_exists(title=title, year=year, season=season, episode=episode) == False: + self.db[self.series_label][show_meta]['episodes'].append(episode_meta) + + # create strm file + filename = episode_meta + '.strm' + filepath = os.path.join(show_dir, filename) + if os.path.exists(filepath): + return + self.write_strm_file(path=filepath, url=build_url({'action': 'play_video', 'video_id': video_id, 'pin': pin})) + + def remove_movie(self, title, year): + movie_meta = '%s (%d)' % (title, year) + del self.db[self.movies_label][movie_meta] + self._update_local_db(filename=self.db_filepath, db=self.db) + dirname = os.path.join(self.movie_path, movie_meta) + if os.path.exists(dirname): + os.rmtree(dirname) + return True + return False + + def remove_show(self, title, year): + show_meta = '%s (%d)' % (title, year) + del self.db[self.series_label][show_meta] + self._update_local_db(filename=self.db_filepath, db=self.db) + show_dir = os.path.join(self.tvshow_path, show_meta) + if os.path.exists(show_dir): + os.rmtree(show_dir) + return True + return False + + def remove_season(self, title, year, season): + season = int(season) + season_list = [] + episodes_list = [] + show_meta = '%s (%d)' % (title, year) + for season_entry in self.db[self.series_label][show_meta]['seasons']: + if season_entry != season: + season_list.append(season_entry) + self.db[self.series_label][show_meta]['seasons'] = season_list + show_dir = os.path.join(self.tvshow_path, show_meta) + if os.path.exists(show_dir): + show_files = [f for f in os.listdir(show_dir) if os.path.isfile(os.path.join(show_dir, f))] + for filename in show_files: + if 'S%02dE' % (season) in filename: + os.remove(os.path.join(show_dir, filename)) + else: + episodes_list.append(filename.replace('.strm', '')) + self.db[self.series_label][show_meta]['episodes'] = episodes_list + self._update_local_db(filename=self.db_filepath, db=self.db) + return True + + def remove_episode(self, title, year, season, episode): + episodes_list = [] + show_meta = '%s (%d)' % (title, year) + episode_meta = 'S%02dE%02d' % (season, episode) + show_dir = os.path.join(self.tvshow_path, show_meta) + if os.path.exists(os.path.join(show_dir, episode_meta + '.strm')): + os.remove(os.path.join(show_dir, episode_meta + '.strm')) + for episode_entry in self.db[self.series_label][show_meta]['episodes']: + if episode_meta != episode_entry: + episodes_list.append(episode_entry) + self.db[self.series_label][show_meta]['episodes'] = episodes_list + self._update_local_db(filename=self.db_filepath, db=self.db) + return True diff --git a/resources/lib/MSL.py b/resources/lib/MSL.py new file mode 100644 index 0000000..58a25f5 --- /dev/null +++ b/resources/lib/MSL.py @@ -0,0 +1,551 @@ +import base64 +import gzip +import json +import os +import pprint +import random +from StringIO import StringIO +from hmac import HMAC +import hashlib +import requests +import zlib + +import time +from Crypto.PublicKey import RSA +from Crypto.Cipher import PKCS1_OAEP +from Crypto.Cipher import AES +from Crypto.Random import get_random_bytes +# from Crypto.Hash import HMAC, SHA256 +from Crypto.Util import Padding +import xml.etree.ElementTree as ET +from lib import log + +pp = pprint.PrettyPrinter(indent=4) + +def base64key_decode(payload): + l = len(payload) % 4 + if l == 2: + payload += '==' + elif l == 3: + payload += '=' + elif l != 0: + raise ValueError('Invalid base64 string') + return base64.urlsafe_b64decode(payload.encode('utf-8')) + + +class MSL: + handshake_performed = False # Is a handshake already performed and the keys loaded + last_drm_context = '' + last_playback_context = '' + esn = "NFCDCH-LX-CQE0NU6PA5714R25VPLXVU2A193T36" + current_message_id = 0 + session = requests.session() + rndm = random.SystemRandom() + tokens = [] + endpoints = { + 'manifest': 'http://www.netflix.com/api/msl/NFCDCH-LX/cadmium/manifest', + 'license': 'http://www.netflix.com/api/msl/NFCDCH-LX/cadmium/license' + } + + def __init__(self, email, password): + """ + The Constructor checks for already existing crypto Keys. + If they exist it will load the existing keys + """ + self.email = email + self.password = password + + if self.file_exists('msl_data.json'): + self.__load_msl_data() + self.handshake_performed = True + elif self.file_exists('rsa_key.bin'): + log('RSA Keys do already exist load old ones') + self.__load_rsa_keys() + self.__perform_key_handshake() + else: + log('Create new RSA Keys') + # Create new Key Pair and save + self.rsa_key = RSA.generate(2048) + self.__save_rsa_keys() + self.__perform_key_handshake() + + def load_manifest(self, viewable_id): + manifest_request_data = { + 'method': 'manifest', + 'lookupType': 'PREPARE', + 'viewableIds': [viewable_id], + 'profiles': [ + 'playready-h264mpl30-dash', + 'playready-h264mpl31-dash', + 'heaac-2-dash', + 'dfxp-ls-sdh', + 'simplesdh', + 'nflx-cmisc', + 'BIF240', + 'BIF320' + ], + 'drmSystem': 'widevine', + 'appId': '14673889385265', + 'sessionParams': { + 'pinCapableClient': False, + 'uiplaycontext': 'null' + }, + 'sessionId': '14673889385265', + 'trackId': 0, + 'flavor': 'PRE_FETCH', + 'secureUrls': False, + 'supportPreviewContent': True, + 'forceClearStreams': False, + 'languages': ['de-DE'], + 'clientVersion': '4.0004.899.011', + 'uiVersion': 'akira' + } + request_data = self.__generate_msl_request_data(manifest_request_data) + + resp = self.session.post(self.endpoints['manifest'], request_data) + + + try: + resp.json() + log('MANIFEST RESPONE JSON: '+resp.text) + except ValueError: + # Maybe we have a CHUNKED response + resp = self.__parse_chunked_msl_response(resp.text) + data = self.__decrypt_payload_chunk(resp['payloads'][0]) + # pprint.pprint(data) + return self.__tranform_to_dash(data) + + + def get_license(self, challenge, sid): + + """ + std::time_t t = std::time(0); // t is an integer type + licenseRequestData["clientTime"] = (int)t; + //licenseRequestData["challengeBase64"] = challengeStr; + licenseRequestData["licenseType"] = "STANDARD"; + licenseRequestData["playbackContextId"] = playbackContextId;//"E1-BQFRAAELEB32o6Se-GFvjwEIbvDydEtfj6zNzEC3qwfweEPAL3gTHHT2V8rS_u1Mc3mw5BWZrUlKYIu4aArdjN8z_Z8t62E5jRjLMdCKMsVhlSJpiQx0MNW4aGqkYz-1lPh85Quo4I_mxVBG5lgd166B5NDizA8."; + licenseRequestData["drmContextIds"] = Json::arrayValue; + licenseRequestData["drmContextIds"].append(drmContextId); + + :param viewable_id: + :param challenge: + :param kid: + :return: + """ + + license_request_data = { + 'method': 'license', + 'licenseType': 'STANDARD', + 'clientVersion': '4.0004.899.011', + 'uiVersion': 'akira', + 'languages': ['de-DE'], + 'playbackContextId': self.last_playback_context, + 'drmContextIds': [self.last_drm_context], + 'challenges': [{ + 'dataBase64': challenge, + 'sessionId': sid + }], + 'clientTime': int(time.time()), + 'xid': int((int(time.time()) + 0.1612) * 1000) + + } + request_data = self.__generate_msl_request_data(license_request_data) + + resp = self.session.post(self.endpoints['license'], request_data) + + try: + resp.json() + log('LICENSE RESPONE JSON: '+resp.text) + except ValueError: + # Maybe we have a CHUNKED response + resp = self.__parse_chunked_msl_response(resp.text) + data = self.__decrypt_payload_chunk(resp['payloads'][0]) + # pprint.pprint(data) + if data['success'] is True: + return data['result']['licenses'][0]['data'] + else: + return '' + + + def __decrypt_payload_chunk(self, payloadchunk): + payloadchunk = json.JSONDecoder().decode(payloadchunk) + encryption_envelope = json.JSONDecoder().decode(base64.standard_b64decode(payloadchunk['payload'])) + # Decrypt the text + cipher = AES.new(self.encryption_key, AES.MODE_CBC, base64.standard_b64decode(encryption_envelope['iv'])) + plaintext = cipher.decrypt(base64.standard_b64decode(encryption_envelope['ciphertext'])) + # unpad the plaintext + plaintext = json.JSONDecoder().decode(Padding.unpad(plaintext, 16)) + data = plaintext['data'] + + # uncompress data if compressed + if plaintext['compressionalgo'] == 'GZIP': + data = zlib.decompress(base64.standard_b64decode(data), 16 + zlib.MAX_WBITS) + else: + data = base64.standard_b64decode(data) + + data = json.JSONDecoder().decode(data)[1]['payload']['data'] + data = base64.standard_b64decode(data) + return json.JSONDecoder().decode(data) + + + def __tranform_to_dash(self, manifest): + + self.save_file('/home/johannes/manifest.json', json.dumps(manifest)) + manifest = manifest['result']['viewables'][0] + + self.last_playback_context = manifest['playbackContextId'] + self.last_drm_context = manifest['drmContextId'] + + #Check for pssh + pssh = '' + if 'psshb64' in manifest: + if len(manifest['psshb64']) >= 1: + pssh = manifest['psshb64'][0] + + + + root = ET.Element('MPD') + root.attrib['xmlns'] = 'urn:mpeg:dash:schema:mpd:2011' + root.attrib['xmlns:cenc'] = 'urn:mpeg:cenc:2013' + + + seconds = manifest['runtime']/1000 + duration = "PT"+str(seconds)+".00S" + + period = ET.SubElement(root, 'Period', start='PT0S', duration=duration) + + # One Adaption Set for Video + for video_track in manifest['videoTracks']: + video_adaption_set = ET.SubElement(period, 'AdaptationSet', mimeType='video/mp4', contentType="video") + # Content Protection + protection = ET.SubElement(video_adaption_set, 'ContentProtection', + schemeIdUri='urn:uuid:EDEF8BA9-79D6-4ACE-A3C8-27DCD51D21ED') + if pssh is not '': + ET.SubElement(protection, 'cenc:pssh').text = pssh + + for downloadable in video_track['downloadables']: + rep = ET.SubElement(video_adaption_set, 'Representation', + width=str(downloadable['width']), + height=str(downloadable['height']), + bitrate=str(downloadable['bitrate']*8*1024), + mimeType='video/mp4') + + #BaseURL + ET.SubElement(rep, 'BaseURL').text = self.__get_base_url(downloadable['urls']) + # Init an Segment block + segment_base = ET.SubElement(rep, 'SegmentBase', indexRange="0-60000", indexRangeExact="true") + ET.SubElement(segment_base, 'Initialization', range='0-60000') + + + + # Multiple Adaption Set for audio + for audio_track in manifest['audioTracks']: + audio_adaption_set = ET.SubElement(period, 'AdaptationSet', + lang=audio_track['bcp47'], + contentType='audio', + mimeType='audio/mp4') + for downloadable in audio_track['downloadables']: + rep = ET.SubElement(audio_adaption_set, 'Representation', + codecs='aac', + bitrate=str(downloadable['bitrate'] * 8 * 1024), + mimeType='audio/mp4') + + #AudioChannel Config + ET.SubElement(rep, 'AudioChannelConfiguration', + schemeIdUri='urn:mpeg:dash:23003:3:audio_channel_configuration:2011', + value=str(audio_track['channelsCount'])) + + #BaseURL + ET.SubElement(rep, 'BaseURL').text = self.__get_base_url(downloadable['urls']) + # Index range + segment_base = ET.SubElement(rep, 'SegmentBase', indexRange="0-60000", indexRangeExact="true") + ET.SubElement(segment_base, 'Initialization', range='0-60000') + + + xml = ET.tostring(root, encoding='utf-8', method='xml') + xml = xml.replace('\n', '').replace('\r', '') + return xml + + def __get_base_url(self, urls): + for key in urls: + return urls[key] + + def __parse_chunked_msl_response(self, message): + i = 0 + opencount = 0 + closecount = 0 + header = "" + payloads = [] + old_end = 0 + + while i < len(message): + if message[i] == '{': + opencount = opencount + 1 + if message[i] == '}': + closecount = closecount + 1 + if opencount == closecount: + if header == "": + header = message[:i] + old_end = i + 1 + else: + payloads.append(message[old_end:i + 1]) + i += 1 + + return { + 'header': header, + 'payloads': payloads + } + + def __generate_msl_request_data(self, data): + header_encryption_envelope = self.__encrypt(self.__generate_msl_header()) + header = { + 'headerdata': base64.standard_b64encode(header_encryption_envelope), + 'signature': self.__sign(header_encryption_envelope), + 'mastertoken': self.mastertoken, + } + + # Serialize the given Data + serialized_data = json.dumps(data) + serialized_data = serialized_data.replace('"', '\\"') + serialized_data = '[{},{"headers":{},"path":"/cbp/cadmium-11","payload":{"data":"' + serialized_data + '"},"query":""}]\n' + + compressed_data = self.__compress_data(serialized_data) + + # Create FIRST Payload Chunks + first_payload = { + "messageid": self.current_message_id, + "data": compressed_data, + "compressionalgo": "GZIP", + "sequencenumber": 1, + "endofmsg": True + } + first_payload_encryption_envelope = self.__encrypt(json.dumps(first_payload)) + first_payload_chunk = { + 'payload': base64.standard_b64encode(first_payload_encryption_envelope), + 'signature': self.__sign(first_payload_encryption_envelope), + } + + + # Create Second Payload + second_payload = { + "messageid": self.current_message_id, + "data": "", + "endofmsg": True, + "sequencenumber": 2 + } + second_payload_encryption_envelope = self.__encrypt(json.dumps(second_payload)) + second_payload_chunk = { + 'payload': base64.standard_b64encode(second_payload_encryption_envelope), + 'signature': base64.standard_b64encode(self.__sign(second_payload_encryption_envelope)), + } + + request_data = json.dumps(header) + json.dumps(first_payload_chunk) # + json.dumps(second_payload_chunk) + return request_data + + + + def __compress_data(self, data): + # GZIP THE DATA + out = StringIO() + with gzip.GzipFile(fileobj=out, mode="w") as f: + f.write(data) + return base64.standard_b64encode(out.getvalue()) + + + def __generate_msl_header(self, is_handshake=False, is_key_request=False, compressionalgo="GZIP", encrypt=True): + """ + Function that generates a MSL header dict + :return: The base64 encoded JSON String of the header + """ + self.current_message_id = self.rndm.randint(0, pow(2, 52)) + + header_data = { + 'sender': self.esn, + 'handshake': is_handshake, + 'nonreplayable': False, + 'capabilities': { + 'languages': ["en-US"], + 'compressionalgos': [] + }, + 'recipient': 'Netflix', + 'renewable': True, + 'messageid': self.current_message_id, + 'timestamp': 1467733923 + } + + # Add compression algo if not empty + if compressionalgo is not "": + header_data['capabilities']['compressionalgos'].append(compressionalgo) + + # If this is a keyrequest act diffrent then other requests + if is_key_request: + public_key = base64.standard_b64encode(self.rsa_key.publickey().exportKey(format='DER')) + header_data['keyrequestdata'] = [{ + 'scheme': 'ASYMMETRIC_WRAPPED', + 'keydata': { + 'publickey': public_key, + 'mechanism': 'JWK_RSA', + 'keypairid': 'superKeyPair' + } + }] + else: + if 'usertoken' in self.tokens: + pass + else: + # Auth via email and password + header_data['userauthdata'] = { + 'scheme': 'EMAIL_PASSWORD', + 'authdata': { + 'email': self.email, + 'password': self.password + } + } + + return json.dumps(header_data) + + + + def __encrypt(self, plaintext): + """ + Encrypt the given Plaintext with the encryption key + :param plaintext: + :return: Serialized JSON String of the encryption Envelope + """ + iv = get_random_bytes(16) + encryption_envelope = { + 'ciphertext': '', + 'keyid': self.esn + '_' + str(self.sequence_number), + 'sha256': 'AA==', + 'iv': base64.standard_b64encode(iv) + } + # Padd the plaintext + plaintext = Padding.pad(plaintext, 16) + # Encrypt the text + cipher = AES.new(self.encryption_key, AES.MODE_CBC, iv) + ciphertext = cipher.encrypt(plaintext) + encryption_envelope['ciphertext'] = base64.standard_b64encode(ciphertext) + return json.dumps(encryption_envelope) + + def __sign(self, text): + #signature = hmac.new(self.sign_key, text, hashlib.sha256).digest() + signature = HMAC(self.sign_key, text, hashlib.sha256).digest() + + + # hmac = HMAC.new(self.sign_key, digestmod=SHA256) + # hmac.update(text) + return base64.standard_b64encode(signature) + + + def __perform_key_handshake(self): + + header = self.__generate_msl_header(is_key_request=True, is_handshake=True, compressionalgo="", encrypt=False) + request = { + 'entityauthdata': { + 'scheme': 'NONE', + 'authdata': { + 'identity': self.esn + } + }, + 'headerdata': base64.standard_b64encode(header), + 'signature': '', + } + log('Key Handshake Request:') + log(json.dumps(request)) + + + resp = self.session.post(self.endpoints['manifest'], json.dumps(request, sort_keys=True)) + if resp.status_code == 200: + resp = resp.json() + if 'errordata' in resp: + log('Key Exchange failed') + log(base64.standard_b64decode(resp['errordata'])) + return False + self.__parse_crypto_keys(json.JSONDecoder().decode(base64.standard_b64decode(resp['headerdata']))) + else: + log('Key Exchange failed') + log(resp.text) + + def __parse_crypto_keys(self, headerdata): + self.__set_master_token(headerdata['keyresponsedata']['mastertoken']) + # Init Decryption + encrypted_encryption_key = base64.standard_b64decode(headerdata['keyresponsedata']['keydata']['encryptionkey']) + encrypted_sign_key = base64.standard_b64decode(headerdata['keyresponsedata']['keydata']['hmackey']) + cipher_rsa = PKCS1_OAEP.new(self.rsa_key) + + # Decrypt encryption key + encryption_key_data = json.JSONDecoder().decode(cipher_rsa.decrypt(encrypted_encryption_key)) + self.encryption_key = base64key_decode(encryption_key_data['k']) + + # Decrypt sign key + sign_key_data = json.JSONDecoder().decode(cipher_rsa.decrypt(encrypted_sign_key)) + self.sign_key = base64key_decode(sign_key_data['k']) + + self.__save_msl_data() + self.handshake_performed = True + + def __load_msl_data(self): + msl_data = json.JSONDecoder().decode(self.load_file('msl_data.json')) + self.__set_master_token(msl_data['tokens']['mastertoken']) + self.encryption_key = base64.standard_b64decode(msl_data['encryption_key']) + self.sign_key = base64.standard_b64decode(msl_data['sign_key']) + + def __save_msl_data(self): + """ + Saves the keys and tokens in json file + :return: + """ + data = { + "encryption_key": base64.standard_b64encode(self.encryption_key), + 'sign_key': base64.standard_b64encode(self.sign_key), + 'tokens': { + 'mastertoken': self.mastertoken + } + } + serialized_data = json.JSONEncoder().encode(data) + self.save_file('msl_data.json', serialized_data) + + def __set_master_token(self, master_token): + self.mastertoken = master_token + self.sequence_number = json.JSONDecoder().decode(base64.standard_b64decode(master_token['tokendata']))[ + 'sequencenumber'] + + def __load_rsa_keys(self): + loaded_key = self.load_file('rsa_key.bin') + self.rsa_key = RSA.importKey(loaded_key) + + def __save_rsa_keys(self): + log('Save RSA Keys') + # Get the DER Base64 of the keys + encrypted_key = self.rsa_key.exportKey() + self.save_file('rsa_key.bin', encrypted_key) + + @staticmethod + def file_exists(filename): + """ + Checks if a given file exists + :param filename: The filename + :return: True if so + """ + return os.path.isfile(filename) + + @staticmethod + def save_file(filename, content): + """ + Saves the given content under given filename + :param filename: The filename + :param content: The content of the file + """ + with open(filename, 'w') as file_: + file_.write(content) + file_.flush() + + @staticmethod + def load_file(filename): + """ + Loads the content of a given filename + :param filename: The file to load + :return: The content of the file + """ + with open(filename) as file_: + file_content = file_.read() + return file_content diff --git a/resources/lib/MSLHttpRequestHandler.py b/resources/lib/MSLHttpRequestHandler.py new file mode 100644 index 0000000..4d7626b --- /dev/null +++ b/resources/lib/MSLHttpRequestHandler.py @@ -0,0 +1,46 @@ +import BaseHTTPServer +import base64 +from urlparse import urlparse, parse_qs + +from MSL import MSL +from lib import ADDON +email = ADDON.getSetting('email') +password = ADDON.getSetting('password') +msl = MSL(email, password) + +class MSLHttpRequestHandler(BaseHTTPServer.BaseHTTPRequestHandler): + + def do_HEAD(self): + self.send_response(200) + + def do_POST(self): + length = int(self.headers['content-length']) + post = self.rfile.read(length) + print post + data = post.split('!') + if len(data) is 2: + challenge = data[0] + sid = base64.standard_b64decode(data[1]) + b64license = msl.get_license(challenge, sid) + if b64license is not '': + self.send_response(200) + self.end_headers() + self.wfile.write(base64.standard_b64decode(b64license)) + self.finish() + else: + self.send_response(400) + else: + self.send_response(400) + + def do_GET(self): + url = urlparse(self.path) + params = parse_qs(url.query) + if 'id' not in params: + self.send_response(400, 'No id') + else: + # Get the manifest with the given id + data = msl.load_manifest(int(params['id'][0])) + self.send_response(200) + self.send_header('Content-type', 'application/xml') + self.end_headers() + self.wfile.write(data) diff --git a/resources/lib/Navigation.py b/resources/lib/Navigation.py new file mode 100644 index 0000000..c1e9397 --- /dev/null +++ b/resources/lib/Navigation.py @@ -0,0 +1,473 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# Module: Navigation +# Created on: 13.01.2017 + +import urllib +import time +from urlparse import parse_qsl +from utils import noop +from utils import log + +class Navigation: + """Routes to the correct subfolder, dispatches actions & acts as a controller for the Kodi view & the Netflix model""" + + def __init__ (self, netflix_session, kodi_helper, library, base_url, log_fn=noop): + """Takes the instances & configuration options needed to drive the plugin + + Parameters + ---------- + netflix_session : :obj:`NetflixSession` + instance of the NetflixSession class + + kodi_helper : :obj:`KodiHelper` + instance of the KodiHelper class + + library : :obj:`Library` + instance of the Library class + + base_url : :obj:`str` + plugin base url + + log_fn : :obj:`fn` + optional log function + """ + self.netflix_session = netflix_session + self.kodi_helper = kodi_helper + self.library = library + self.base_url = base_url + self.log = log_fn + + @log + def router (self, paramstring): + """Route to the requested subfolder & dispatch actions along the way + + Parameters + ---------- + paramstring : :obj:`str` + Url query params + """ + params = self.parse_paramters(paramstring=paramstring) + + # log out the user + if 'action' in params.keys() and params['action'] == 'logout': + return self.netflix_session.logout() + + # check login & try to relogin if necessary + account = self.kodi_helper.get_credentials() + if self.netflix_session.is_logged_in(account=account) != True: + if self.establish_session(account=account) != True: + return self.kodi_helper.show_login_failed_notification() + + # check if we need to execute any actions before the actual routing + # gives back a dict of options routes might need + options = self.before_routing_action(params=params) + + # check if one of the before routing options decided to killthe routing + if 'exit' in options: + return False + if 'action' not in params.keys(): + # show the profiles + self.show_profiles() + elif params['action'] == 'video_lists': + # list lists that contain other lists (starting point with recommendations, search, etc.) + return self.show_video_lists() + elif params['action'] == 'video_list': + # show a list of shows/movies + type = None if 'type' not in params.keys() else params['type'] + return self.show_video_list(video_list_id=params['video_list_id'], type=type) + elif params['action'] == 'season_list': + # list of seasons for a show + return self.show_seasons(show_id=params['show_id']) + elif params['action'] == 'episode_list': + # list of episodes for a season + return self.show_episode_list(season_id=params['season_id']) + elif params['action'] == 'rating': + return self.rate_on_netflix(video_id=params['id']) + elif params['action'] == 'remove_from_list': + # removes a title from the users list on Netflix + return self.remove_from_list(video_id=params['id']) + elif params['action'] == 'add_to_list': + # adds a title to the users list on Netflix + return self.add_to_list(video_id=params['id']) + elif params['action'] == 'user-items' and params['type'] != 'search': + # display the lists (recommendations, genres, etc.) + return self.show_user_list(type=params['type']) + elif params['action'] == 'play_video': + # play a video, check for adult pin if needed + adult_pin = None + if self.check_for_adult_pin(params=params): + adult_pin = self.kodi_helper.show_adult_pin_dialog() + if self.netflix_session.send_adult_pin(adult_pin=adult_pin) != True: + return self.kodi_helper.show_wrong_adult_pin_notification() + self.play_video(video_id=params['video_id'], start_offset=params['start_offset']) + elif params['action'] == 'user-items' and params['type'] == 'search': + # if the user requested a search, ask for the term + term = self.kodi_helper.show_search_term_dialog() + return self.show_search_results(term=term) + else: + raise ValueError('Invalid paramstring: {0}!'.format(paramstring)) + return True + + @log + def play_video (self, video_id, start_offset): + """Starts video playback + + Note: This is just a dummy, inputstream is needed to play the vids + + Parameters + ---------- + video_id : :obj:`str` + ID of the video that should be played + + start_offset : :obj:`str` + Offset to resume playback from (in seconds) + """ + # widevine esn + esn = self.netflix_session.esn + return self.kodi_helper.play_item(esn=esn, video_id=video_id, start_offset=start_offset) + + def show_search_results (self, term): + """Display a list of search results + + Parameters + ---------- + term : :obj:`str` + String to lookup + + Returns + ------- + bool + If no results are available + """ + has_search_results = False + search_results_raw = self.netflix_session.fetch_search_results(term=term) + # check for any errors + if self._is_dirty_response(response=search_results_raw): + return False + + # determine if we found something + if 'search' in search_results_raw['value']: + for key in search_results_raw['value']['search'].keys(): + if self.netflix_session._is_size_key(key=key) == False: + has_search_results = search_results_raw['value']['search'][key]['titles']['length'] > 0 + + # display that we haven't found a thing + if has_search_results == False: + return self.kodi_helper.build_no_search_results_available(build_url=self.build_url, action='search') + + # list the search results + search_results = self.netflix_session.parse_search_results(response_data=search_results_raw) + # add more menaingful data to the search results + raw_search_contents = self.netflix_session.fetch_video_list_information(video_ids=search_results.keys()) + # check for any errors + if self._is_dirty_response(response=raw_search_contents): + return False + search_contents = self.netflix_session.parse_video_list(response_data=raw_search_contents) + actions = {'movie': 'play_video', 'show': 'season_list'} + return self.kodi_helper.build_search_result_listing(video_list=search_contents, actions=actions, build_url=self.build_url) + + def show_user_list (self, type): + """List the users lists for shows/movies for recommendations/genres based on the given type + + Parameters + ---------- + user_list_id : :obj:`str` + Type of list to display + """ + video_list_ids_raw = self.netflix_session.fetch_video_list_ids() + # check for any errors + if self._is_dirty_response(response=video_list_ids_raw): + return False + video_list_ids = self.netflix_session.parse_video_list_ids(response_data=video_list_ids_raw) + return self.kodi_helper.build_user_sub_listing(video_list_ids=video_list_ids[type], type=type, action='video_list', build_url=self.build_url) + + def show_episode_list (self, season_id): + """Lists all episodes for a given season + + Parameters + ---------- + season_id : :obj:`str` + ID of the season episodes should be displayed for + """ + raw_episode_list = self.netflix_session.fetch_episodes_by_season(season_id=season_id) + # check for any errors + if self._is_dirty_response(response=raw_episode_list): + return False + # parse the raw Netflix data + episode_list = self.netflix_session.parse_episodes_by_season(response_data=raw_episode_list) + + # sort seasons by number (they´re coming back unsorted from the api) + episodes_sorted = [] + for episode_id in episode_list: + episodes_sorted.append(int(episode_list[episode_id]['episode'])) + episodes_sorted.sort() + + # list the episodes + return self.kodi_helper.build_episode_listing(episodes_sorted=episodes_sorted, episode_list=episode_list, build_url=self.build_url) + + def show_seasons (self, show_id): + """Lists all seasons for a given show + + Parameters + ---------- + show_id : :obj:`str` + ID of the show seasons should be displayed for + + Returns + ------- + bool + If no seasons are available + """ + season_list_raw = self.netflix_session.fetch_seasons_for_show(id=show_id); + # check for any errors + if self._is_dirty_response(response=season_list_raw): + return False + # check if we have sesons, announced shows that are not available yet have none + if 'seasons' not in season_list_raw['value']: + return self.kodi_helper.build_no_seasons_available() + # parse the seasons raw response from Netflix + season_list = self.netflix_session.parse_seasons(id=show_id, response_data=season_list_raw) + # sort seasons by index by default (they´re coming back unsorted from the api) + seasons_sorted = [] + for season_id in season_list: + seasons_sorted.append(int(season_list[season_id]['shortName'].split(' ')[1])) + seasons_sorted.sort() + return self.kodi_helper.build_season_listing(seasons_sorted=seasons_sorted, season_list=season_list, build_url=self.build_url) + + def show_video_list (self, video_list_id, type): + """List shows/movies based on the given video list id + + Parameters + ---------- + video_list_id : :obj:`str` + ID of the video list that should be displayed + + type : :obj:`str` + None or 'queue' f.e. when it´s a special video lists + """ + raw_video_list = self.netflix_session.fetch_video_list(list_id=video_list_id) + # check for any errors + if self._is_dirty_response(response=raw_video_list): + return False + # parse the video list ids + video_list = self.netflix_session.parse_video_list(response_data=raw_video_list) + actions = {'movie': 'play_video', 'show': 'season_list'} + return self.kodi_helper.build_video_listing(video_list=video_list, actions=actions, type=type, build_url=self.build_url) + + def show_video_lists (self): + """List the users video lists (recommendations, my list, etc.)""" + # fetch video lists + raw_video_list_ids = self.netflix_session.fetch_video_list_ids() + # check for any errors + if self._is_dirty_response(response=raw_video_list_ids): + return False + # parse the video list ids + video_list_ids = self.netflix_session.parse_video_list_ids(response_data=raw_video_list_ids) + # defines an order for the user list, as Netflix changes the order at every request + user_list_order = ['queue', 'continueWatching', 'topTen', 'netflixOriginals', 'trendingNow', 'newRelease', 'popularTitles'] + # define where to route the user + actions = {'recommendations': 'user-items', 'genres': 'user-items', 'search': 'user-items', 'default': 'video_list'} + return self.kodi_helper.build_main_menu_listing(video_list_ids=video_list_ids, user_list_order=user_list_order, actions=actions, build_url=self.build_url) + + def show_profiles (self): + """List the profiles for the active account""" + self.netflix_session.refresh_session_data(account=self.kodi_helper.get_credentials()) + profiles = self.netflix_session.profiles + return self.kodi_helper.build_profiles_listing(profiles=profiles, action='video_lists', build_url=self.build_url) + + @log + def rate_on_netflix (self, video_id): + """Rate a show/movie/season/episode on Netflix + + Parameters + ---------- + video_list_id : :obj:`str` + ID of the video list that should be displayed + """ + rating = self.kodi_helper.show_rating_dialog() + return self.netflix_session.rate_video(video_id=video_id, rating=rating) + + @log + def remove_from_list (self, video_id): + """Remove an item from 'My List' & refresh the view + + Parameters + ---------- + video_list_id : :obj:`str` + ID of the video list that should be displayed + """ + self.netflix_session.remove_from_list(video_id=video_id) + return self.kodi_helper.refresh() + + @log + def add_to_list (self, video_id): + """Add an item to 'My List' & refresh the view + + Parameters + ---------- + video_list_id : :obj:`str` + ID of the video list that should be displayed + """ + self.netflix_session.add_to_list(video_id=video_id) + return self.kodi_helper.refresh() + + @log + def establish_session(self, account): + """Checks if we have an cookie with an active sessions, otherwise tries to login the user + + Parameters + ---------- + account : :obj:`dict` of :obj:`str` + Dict containing an email & a password property + + Returns + ------- + bool + If we don't have an active session & the user couldn't be logged in + """ + if self.netflix_session.is_logged_in(account=account): + return True + else: + return self.netflix_session.login(account=account) + + @log + def before_routing_action (self, params): + """Executes actions before the actual routing takes place: + + - Check if account data has been stored, if not, asks for it + - Check if the profile should be changed (and changes if so) + - Establishes a session if no action route is given + + Parameters + ---------- + params : :obj:`dict` of :obj:`str` + Url query params + + Returns + ------- + :obj:`dict` of :obj:`str` + Options that can be provided by this hook & used later in the routing process + """ + options = {} + credentials = self.kodi_helper.get_credentials() + # check if we have user settings, if not, set em + if credentials['email'] == '': + email = self.kodi_helper.show_email_dialog() + self.kodi_helper.set_setting(key='email', value=email) + if credentials['password'] == '': + password = self.kodi_helper.show_password_dialog() + self.kodi_helper.set_setting(key='password', value=password) + # persist & load main menu selection + if 'type' in params: + self.kodi_helper.set_main_menu_selection(type=params['type']) + options['main_menu_selection'] = self.kodi_helper.get_main_menu_selection() + # check and switch the profile if needed + if self.check_for_designated_profile_change(params=params): + self.netflix_session.switch_profile(profile_id=params['profile_id'], account=credentials) + # check login, in case of main menu + if 'action' not in params: + self.establish_session(account=credentials) + return options + + def check_for_designated_profile_change (self, params): + """Checks if the profile needs to be switched + + Parameters + ---------- + params : :obj:`dict` of :obj:`str` + Url query params + + Returns + ------- + bool + Profile should be switched or not + """ + # check if we need to switch the user + if 'guid' not in self.netflix_session.user_data: + return False + current_profile_id = self.netflix_session.user_data['guid'] + return 'profile_id' in params and current_profile_id != params['profile_id'] + + def check_for_adult_pin (self, params): + """Checks if an adult pin is given in the query params + + Parameters + ---------- + params : :obj:`dict` of :obj:`str` + Url query params + + Returns + ------- + bool + Adult pin parameter exists or not + """ + return (True, False)[params['pin'] == 'True'] + + def parse_paramters (self, paramstring): + """Tiny helper to convert a url paramstring into a dictionary + + Parameters + ---------- + paramstring : :obj:`str` + Url query params (in url string notation) + + Returns + ------- + :obj:`dict` of :obj:`str` + Url query params (as a dictionary) + """ + return dict(parse_qsl(paramstring)) + + def _is_expired_session (self, response): + """Checks if a response error is based on an invalid session + + Parameters + ---------- + response : :obj:`dict` of :obj:`str` + Error response object + + Returns + ------- + bool + Error is based on an invalid session + """ + return 'error' in response and 'code' in response and str(response['code']) == '401' + + def _is_dirty_response (self, response): + """Checks if a response contains an error & if the error is based on an invalid session, it tries a relogin + + Parameters + ---------- + response : :obj:`dict` of :obj:`str` + Success response object or Error response object + + Returns + ------- + bool + Response contains error or not + """ + # check for any errors + if 'error' in response: + # check if we do not have a valid session, in case that happens: (re)login + if self._is_expired_session(response=response): + if self.establish_session(account=self.kodi_helper.get_credentials()): + return True + self.log(msg='[ERROR]: ' + response['message'] + '::' + str(response['code'])) + return True + return False + + def build_url(self, query): + """Tiny helper to transform a dict into a url + querystring + + Parameters + ---------- + query : :obj:`dict` of :obj:`str` + List of paramters to be url encoded + + Returns + ------- + str + Url + querystring based on the param + """ + return self.base_url + '?' + urllib.urlencode(query) diff --git a/resources/lib/NetflixSession.py b/resources/lib/NetflixSession.py new file mode 100644 index 0000000..b2416c2 --- /dev/null +++ b/resources/lib/NetflixSession.py @@ -0,0 +1,2010 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# Module: NetflixSession +# Created on: 13.01.2017 + +import sys +import os +import base64 +import time +import urllib +import json +import requests +import pickle +from BeautifulSoup import BeautifulSoup +from utils import strip_tags +from utils import noop + +class NetflixSession: + """Helps with login/session management of Netflix users & API data fetching""" + + base_url = 'https://www.netflix.com/' + """str: Secure Netflix url""" + + urls = { + 'login': '/login', + 'browse': '/browse', + 'video_list_ids': '/warmer', + 'shakti': '/pathEvaluator', + 'profiles': '/profiles', + 'switch_profiles': '/profiles/switch', + 'adult_pin': '/pin/service', + 'metadata': '/metadata', + 'set_video_rating': '/setVideoRating', + 'update_my_list': '/playlistop' + } + """:obj:`dict` of :obj:`str` List of all static endpoints for HTML/JSON POST/GET requests""" + + video_list_keys = ['user', 'genres', 'recommendations'] + """:obj:`list` of :obj:`str` Divide the users video lists into 3 different categories (for easier digestion)""" + + profiles = {} + """:obj:`dict` + Dict of user profiles, user id is the key: + + "72ERT45...": { + "profileName": "username", + "avatar": "http://..../avatar.png", + "id": "72ERT45...", + "isAccountOwner": False, + "isActive": True, + "isFirstUse": False + } + """ + + user_data = {} + """:obj:`dict` + dict of user data (used for authentication): + + { + "guid": "72ERT45...", + "authURL": "145637....", + "countryOfSignup": "DE", + "emailAddress": "foo@..", + "gpsModel": "harris", + "isAdultVerified": True, + "isInFreeTrial": False, + "isKids": False, + "isTestAccount": False, + "numProfiles": 5, + "pinEnabled": True + } + """ + + api_data = {} + """:obj:`dict` + dict of api data (used to build up the api urls): + + { + "API_BASE_URL": "/shakti", + "API_ROOT": "https://www.netflix.com/api", + "BUILD_IDENTIFIER": "113b89c9", " + ICHNAEA_ROOT": "/ichnaea" + } + """ + + esn = '' + """str: Widevine esn, something like: NFCDCH-MC-D7D6F54LOPY8J416T72MQXX3RD20ME""" + + def __init__(self, cookie_path, data_path, log_fn=noop): + """Stores the cookie path for later use & instanciates a requests + session with a proper user agent & stored cookies/data if available + + Parameters + ---------- + cookie_path : :obj:`str` + Cookie location + + data_path : :obj:`str` + User data cache location + + log_fn : :obj:`fn` + optional log function + """ + self.cookie_path = cookie_path + self.data_path = data_path + self.log = log_fn + + # start session, fake chrome (so that we get a proper widevine esn) & enable gzip + self.session = requests.session() + self.session.headers.update({ + 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/39.0.2171.95 Safari/537.36', + 'Accept-Encoding': 'gzip, deflate' + }) + + def parse_login_form_fields (self, form_soup): + """Fetches all the inputfields from the login form, so that we + can build a request with all the fields needed besides the known email & password ones + + Parameters + ---------- + form_soup : :obj:`BeautifulSoup` + Instance of an BeautifulSoup documet or node containing the login form + + Returns + ------- + :obj:`dict` of :obj:`str` + Dictionary of all input fields with their name as the key & the default + value from the form field + """ + login_input_fields = {} + login_inputs = form_soup.findAll('input') + # gather all form fields, set an empty string as the default value + for item in login_inputs: + keys = dict(item.attrs).keys() + if 'name' in keys and 'value' not in keys: + login_input_fields[item['name']] = '' + elif 'name' in keys and 'value' in keys: + login_input_fields[item['name']] = item['value'] + return login_input_fields + + def extract_inline_netflix_page_data (self, page_soup): + """Extracts all + + So we´re extracting every JavaScript object contained in the `netflix.x = {};` variable, + strip all html tags, unescape the whole thing & finally parse the resulting serialized JSON from this + operations. Errors are expected, as not all ', '').strip() + # unescape the contents as they contain characters a JSON parser chokes up upon + unescaped_data = stripped_data.decode('string_escape') + # strip all the HTML tags within the strings a JSON parser chokes up upon them + transformed_data = strip_tags(unescaped_data) + # parse the contents with a regular JSON parser, as they should be in a shape that ot actually works + try: + parsed_data = json.loads(transformed_data) + inline_data.append(parsed_data) + except ValueError, e: + noop() + except TypeError, e: + noop() + + return inline_data; + + def _parse_user_data (self, netflix_page_data): + """Parse out the user data from the big chunk of dicts we got from + parsing the JSON-ish data from the netflix homepage + + Parameters + ---------- + netflix_page_data : :obj:`list` + List of all the JSON-ish data that has been extracted from the Netflix homepage + see: extract_inline_netflix_page_data + + Returns + ------- + :obj:`dict` of :obj:`str` + + { + "guid": "72ERT45...", + "authURL": "145637....", + "countryOfSignup": "DE", + "emailAddress": "foo@..", + "gpsModel": "harris", + "isAdultVerified": True, + "isInFreeTrial": False, + "isKids": False, + "isTestAccount": False, + "numProfiles": 5, + "pinEnabled": True + } + """ + user_data = {}; + important_fields = [ + 'authURL', + 'countryOfSignup', + 'emailAddress', + 'gpsModel', + 'guid', + 'isAdultVerified', + 'isInFreeTrial', + 'isKids', + 'isTestAccount', + 'numProfiles', + 'pinEnabled' + ] + for item in netflix_page_data: + if 'models' in dict(item).keys(): + for important_field in important_fields: + user_data.update({important_field: item['models']['userInfo']['data'][important_field]}) + return user_data + + def _parse_profile_data (self, netflix_page_data): + """Parse out the profile data from the big chunk of dicts we got from + parsing the JSON-ish data from the netflix homepage + + Parameters + ---------- + netflix_page_data : :obj:`list` + List of all the JSON-ish data that has been extracted from the Netflix homepage + see: extract_inline_netflix_page_data + + Returns + ------- + :obj:`dict` of :obj:`dict + + { + "72ERT45...": { + "profileName": "username", + "avatar": "http://..../avatar.png", + "id": "72ERT45...", + "isAccountOwner": False, + "isActive": True, + "isFirstUse": False + } + } + """ + profiles = {}; + important_fields = [ + 'profileName', + 'isActive', + 'isFirstUse', + 'isAccountOwner' + ] + # TODO: get rid of this christmas tree of doom + for item in netflix_page_data: + if 'profiles' in dict(item).keys(): + for profile_id in item['profiles']: + if self._is_size_key(key=profile_id) == False: + profile = {'id': profile_id} + for important_field in important_fields: + profile.update({important_field: item['profiles'][profile_id]['summary'][important_field]}) + profile.update({'avatar': item['avatars']['nf'][item['profiles'][profile_id]['summary']['avatarName']]['images']['byWidth']['320']['value']}) + profiles.update({profile_id: profile}) + + return profiles + + def _parse_api_base_data (self, netflix_page_data): + """Parse out the api url data from the big chunk of dicts we got from + parsing the JSOn-ish data from the netflix homepage + + Parameters + ---------- + netflix_page_data : :obj:`list` + List of all the JSON-ish data that has been extracted from the Netflix homepage + see: extract_inline_netflix_page_data + + Returns + ------- + :obj:`dict` of :obj:`str + + { + "API_BASE_URL": "/shakti", + "API_ROOT": "https://www.netflix.com/api", + "BUILD_IDENTIFIER": "113b89c9", " + ICHNAEA_ROOT": "/ichnaea" + } + """ + api_data = {}; + important_fields = [ + 'API_BASE_URL', + 'API_ROOT', + 'BUILD_IDENTIFIER', + 'ICHNAEA_ROOT' + ] + for item in netflix_page_data: + if 'models' in dict(item).keys(): + for important_field in important_fields: + api_data.update({important_field: item['models']['serverDefs']['data'][important_field]}) + return api_data + + def _parse_esn_data (self, netflix_page_data): + """Parse out the esn id data from the big chunk of dicts we got from + parsing the JSOn-ish data from the netflix homepage + + Parameters + ---------- + netflix_page_data : :obj:`list` + List of all the JSON-ish data that has been extracted from the Netflix homepage + see: extract_inline_netflix_page_data + + Returns + ------- + :obj:`str` of :obj:`str + Widevine esn, something like: NFCDCH-MC-D7D6F54LOPY8J416T72MQXX3RD20ME + """ + esn = ''; + for item in netflix_page_data: + if 'models' in dict(item).keys(): + esn = item['models']['esnGeneratorModel']['data']['esn'] + return esn + + def _parse_page_contents (self, page_soup): + """Call all the parsers we need to extract all the session relevant data from the HTML page + Directly assigns it to the NetflixSession instance + + Parameters + ---------- + page_soup : :obj:`BeautifulSoup` + Instance of an BeautifulSoup document or node containing the complete page contents + """ + netflix_page_data = self.extract_inline_netflix_page_data(page_soup=page_soup) + self.user_data = self._parse_user_data(netflix_page_data=netflix_page_data) + self.esn = self._parse_esn_data(netflix_page_data=netflix_page_data) + self.api_data = self._parse_api_base_data(netflix_page_data=netflix_page_data) + self.profiles = self._parse_profile_data(netflix_page_data=netflix_page_data) + + def is_logged_in (self, account): + """Determines if a user is already logged in (with a valid cookie), + by fetching the index page with the current cookie & checking for the + `membership status` user data + + Parameters + ---------- + account : :obj:`dict` of :obj:`str` + Dict containing an email, country & a password property + + Returns + ------- + bool + User is already logged in (e.g. Cookie is valid) or not + """ + is_logged_in = False + # load cookies + account_hash = self._generate_account_hash(account=account) + if self._load_cookies(filename=self.cookie_path + '_' + account_hash) == False: + return False + if self._load_data(filename=self.data_path + '_' + account_hash) == False: + # load the profiles page (to verify the user) + response = self.session.get(self._get_document_url_for(component='profiles')) + + # parse out the needed inline information + page_soup = BeautifulSoup(response.text) + page_data = self.extract_inline_netflix_page_data(page_soup=page_soup) + self._parse_page_contents(page_soup=page_soup) + + # check if the cookie is still valid + for item in page_data: + if 'profilesList' in dict(item).keys(): + if item['profilesList']['summary']['length'] >= 1: + is_logged_in = True + return is_logged_in + return True + + def logout (self): + """Delete all cookies and session data + + Parameters + ---------- + account : :obj:`dict` of :obj:`str` + Dict containing an email, country & a password property + + """ + self._delete_cookies(path=self.cookie_path) + self._delete_data(path=self.data_path) + + def login (self, account): + """Try to log in a user with its credentials & stores the cookies if the action is successfull + + Note: It fetches the HTML of the login page to extract the fields of the login form, + again, this is dirty, but as the fields & their values coudl change at any time, this + should be the most reliable way of retrieving the information + + Parameters + ---------- + account : :obj:`dict` of :obj:`str` + Dict containing an email, country & a password property + + Returns + ------- + bool + User could be logged in or not + """ + response = self.session.get(self._get_document_url_for(component='login')) + if response.status_code != 200: + return False; + + # collect all the login fields & their contents and add the user credentials + page_soup = BeautifulSoup(response.text) + login_form = page_soup.find(attrs={'class' : 'ui-label-text'}).findPrevious('form') + login_payload = self.parse_login_form_fields(form_soup=login_form) + if 'email' in login_payload: + login_payload['email'] = account['email'] + if 'emailOrPhoneNumber' in login_payload: + login_payload['emailOrPhoneNumber'] = account['email'] + login_payload['password'] = account['password'] + + # perform the login + login_response = self.session.post(self._get_document_url_for(component='login'), data=login_payload) + login_soup = BeautifulSoup(login_response.text) + + # we know that the login was successfull if we find an HTML element with the class of 'profile-name' + if login_soup.find(attrs={'class' : 'profile-name'}) or login_soup.find(attrs={'class' : 'profile-icon'}): + # parse the needed inline information & store cookies for later requests + self._parse_page_contents(page_soup=login_soup) + account_hash = self._generate_account_hash(account=account) + self._save_cookies(filename=self.cookie_path + '_' + account_hash) + self._save_data(filename=self.data_path + '_' + account_hash) + return True + else: + return False + + def switch_profile (self, profile_id, account): + """Switch the user profile based on a given profile id + + Note: All available profiles & their ids can be found in the ´profiles´ property after a successfull login + + Parameters + ---------- + profile_id : :obj:`str` + User profile id + + account : :obj:`dict` of :obj:`str` + Dict containing an email, country & a password property + + Returns + ------- + bool + User could be switched or not + """ + payload = { + 'switchProfileGuid': profile_id, + '_': int(time.time()), + 'authURL': self.user_data['authURL'] + } + + response = self.session.get(self._get_api_url_for(component='switch_profiles'), params=payload); + if response.status_code != 200: + return False + + # fetch the index page again, so that we can fetch the corresponding user data + browse_response = self.session.get(self._get_document_url_for(component='browse')) + browse_soup = BeautifulSoup(browse_response.text) + self._parse_page_contents(page_soup=browse_soup) + account_hash = self._generate_account_hash(account=account) + self._save_data(filename=self.data_path + '_' + account_hash) + return True + + def send_adult_pin (self, pin): + """Send the adult pin to Netflix in case an adult rated video requests it + + Note: Once entered, it should last for the complete session (Not so sure about this) + + Parameters + ---------- + pin : :obj:`str` + The users adult pin + + Returns + ------- + bool + Pin was accepted or not + or + :obj:`dict` of :obj:`str` + Api call error + """ + payload = { + 'pin': pin, + 'authURL': self.user_data['authURL'] + } + url = self._get_api_url_for(component='adult_pin') + response = self.session.get(url, params=payload); + pin_response = self._process_response(response=response, component=url) + keys = pin_response.keys() + if 'success' in keys: + return True + if 'error' in keys: + return pin_response + return False + + def add_to_list (self, video_id): + """Adds a video to "my list" on Netflix + + Parameters + ---------- + video_id : :obj:`str` + ID of th show/video/movie to be added + + Returns + ------- + bool + Adding was successfull + """ + return self._update_my_list(video_id=video_id, operation='add') + + def remove_from_list (self, video_id): + """Removes a video from "my list" on Netflix + + Parameters + ---------- + video_id : :obj:`str` + ID of th show/video/movie to be removed + + Returns + ------- + bool + Removing was successfull + """ + return self._update_my_list(video_id=video_id, operation='remove') + + def rate_video (self, video_id, rating): + """Rate a video on Netflix + + Parameters + ---------- + video_id : :obj:`str` + ID of th show/video/movie to be rated + + rating : :obj:`int` + Rating, must be between 0 & 10 + + Returns + ------- + bool + Rating successfull or not + """ + + # dirty rating validation + ratun = int(rating) + if rating > 10 or rating < 0: + return False + + # In opposition to Kodi, Netflix uses a rating from 0 to in 0.5 steps + if rating != 0: + rating = rating / 2 + + headers = { + 'Content-Type': 'application/json', + 'Accept': 'application/json, text/javascript, */*', + } + + params = { + 'titleid': video_id, + 'rating': rating + } + + payload = json.dumps({ + 'authURL': self.user_data['authURL'] + }) + + response = self.session.post(self._get_api_url_for(component='set_video_rating'), params=params, headers=headers, data=payload) + return response.status_code == 200 + + def parse_video_list_ids (self, response_data): + """Parse the list of video ids e.g. rip out the parts we need + + Parameters + ---------- + response_data : :obj:`dict` of :obj:`str` + Parsed response JSON from the ´fetch_video_list_ids´ call + + Returns + ------- + :obj:`dict` of :obj:`dict` + Video list ids in the format: + + { + "genres": { + "3589e2c6-ca3b-48b4-a72d-34f2c09ffbf4_11568367": { + "displayName": "US-Serien", + "id": "3589e2c6-ca3b-48b4-a72d-34f2c09ffbf4_11568367", + "index": 3, + "name": "genre", + "size": 38 + }, + "3589e2c6-ca3b-48b4-a72d-34f2c09ffbf4_11568368": { + "displayName": ... + }, + }, + "user": { + "3589e2c6-ca3b-48b4-a72d-34f2c09ffbf4_11568364": { + "displayName": "Meine Liste", + "id": "3589e2c6-ca3b-48b4-a72d-34f2c09ffbf4_11568364", + "index": 0, + "name": "queue", + "size": 2 + }, + "3589e2c6-ca3b-48b4-a72d-34f2c09ffbf4_11568365": { + "displayName": ... + }, + }, + "recommendations": { + "3589e2c6-ca3b-48b4-a72d-34f2c09ffbf4_11568382": { + "displayName": "Passend zu Family Guy", + "id": "3589e2c6-ca3b-48b4-a72d-34f2c09ffbf4_11568382", + "index": 18, + "name": "similars", + "size": 33 + }, + "3589e2c6-ca3b-48b4-a72d-34f2c09ffbf4_11568397": { + "displayName": ... + } + } + } + """ + # prepare the return dictionary + video_list_ids = {} + for key in self.video_list_keys: + video_list_ids[key] = {} + + # subcatogorize the lists by their context + video_lists = response_data['lists'] + for video_list_id in video_lists.keys(): + video_list = video_lists[video_list_id] + if video_list['context'] == 'genre': + video_list_ids['genres'].update(self.parse_video_list_ids_entry(id=video_list_id, entry=video_list)) + elif video_list['context'] == 'similars' or video_list['context'] == 'becauseYouAdded': + video_list_ids['recommendations'].update(self.parse_video_list_ids_entry(id=video_list_id, entry=video_list)) + else: + video_list_ids['user'].update(self.parse_video_list_ids_entry(id=video_list_id, entry=video_list)) + + return video_list_ids + + def parse_video_list_ids_entry (self, id, entry): + """Parse a video id entry e.g. rip out the parts we need + + Parameters + ---------- + response_data : :obj:`dict` of :obj:`str` + Dictionary entry from the ´fetch_video_list_ids´ call + + Returns + ------- + id : :obj:`str` + Unique id of the video list + + entry : :obj:`dict` of :obj:`str` + Video list entry in the format: + + "3589e2c6-ca3b-48b4-a72d-34f2c09ffbf4_11568382": { + "displayName": "Passend zu Family Guy", + "id": "3589e2c6-ca3b-48b4-a72d-34f2c09ffbf4_11568382", + "index": 18, + "name": "similars", + "size": 33 + } + """ + return { + id: { + 'id': id, + 'index': entry['index'], + 'name': entry['context'], + 'displayName': entry['displayName'], + 'size': entry['length'] + } + } + + def parse_search_results (self, response_data): + """Parse the list of search results, rip out the parts we need + and extend it with detailed show informations + + Parameters + ---------- + response_data : :obj:`dict` of :obj:`str` + Parsed response JSON from the `fetch_search_results` call + + Returns + ------- + :obj:`dict` of :obj:`dict` of :obj:`str` + Search results in the format: + + { + "70136140": { + "boxarts": "https://art-s.nflximg.net/0d7af/d5c72668c35d3da65ae031302bd4ae1bcc80d7af.jpg", + "detail_text": "Die legend\u00e4re und mit 13 Emmys nominierte Serie von Gene Roddenberry inspirierte eine ganze Generation.", + "id": "70136140", + "season_id": "70109435", + "synopsis": "Unter Befehl von Captain Kirk begibt sich die Besatzung des Raumschiffs Enterprise in die Tiefen des Weltraums, wo sie fremde Galaxien und neue Zivilisationen erforscht.", + "title": "Star Trek", + "type": "show" + }, + "70158329": { + "boxarts": ... + } + } + """ + search_results = {} + raw_search_results = response_data['value']['videos'] + for entry_id in raw_search_results: + if self._is_size_key(key=entry_id) == False: + # fetch information about each show & build up a proper search results dictionary + show = self.parse_show_list_entry(id=entry_id, entry=raw_search_results[entry_id]) + show[entry_id].update(self.parse_show_information(id=entry_id, response_data=self.fetch_show_information(id=entry_id, type=show[entry_id]['type']))) + search_results.update(show) + return search_results + + def parse_show_list_entry (self, id, entry): + """Parse a show entry e.g. rip out the parts we need + + Parameters + ---------- + response_data : :obj:`dict` of :obj:`str` + Dictionary entry from the ´fetch_show_information´ call + + id : :obj:`str` + Unique id of the video list + + Returns + ------- + entry : :obj:`dict` of :obj:`dict` of :obj:`str` + Show list entry in the format: + + { + "3589e2c6-ca3b-48b4-a72d-34f2c09ffbf4_11568382": { + "id": "3589e2c6-ca3b-48b4-a72d-34f2c09ffbf4_11568382", + "title": "Enterprise", + "boxarts": "https://art-s.nflximg.net/.../smth.jpg", + "type": "show" + } + } + """ + return { + id: { + 'id': id, + 'title': entry['title'], + 'boxarts': entry['boxarts']['_342x192']['jpg']['url'], + 'type': entry['summary']['type'] + } + } + + def parse_video_list (self, response_data): + """Parse a list of videos + + Parameters + ---------- + response_data : :obj:`dict` of :obj:`str` + Parsed response JSON from the `fetch_video_list` call + + Returns + ------- + :obj:`dict` of :obj:`dict` + Video list in the format: + + { + "372203": { + "artwork": null, + "boxarts": { + "big": "https://art-s.nflximg.net/5e7d3/b3b48749843fd3a36db11c319ffa60f96b55e7d3.jpg", + "small": "https://art-s.nflximg.net/57543/a039845c2eb9186dc26019576d895bf5a1957543.jpg" + }, + "cast": [ + "Christine Elise", + "Brad Dourif", + "Grace Zabriskie", + "Jenny Agutter", + "John Lafia", + "Gerrit Graham", + "Peter Haskell", + "Alex Vincent", + "Beth Grant" + ], + "creators": [], + "directors": [], + "episode_count": null, + "genres": [ + "Horrorfilme" + ], + "id": "372203", + "in_my_list": true, + "interesting_moment": "https://art-s.nflximg.net/09544/ed4b3073394b4469fb6ec22b9df81a4f5cb09544.jpg", + "list_id": "9588df32-f957-40e4-9055-1f6f33b60103_46891306", + "maturity": { + "board": "FSK", + "description": "Nur f\u00fcr Erwachsene geeignet.", + "level": 1000, + "value": "18" + }, + "quality": "540", + "rating": 3.1707757, + "regular_synopsis": "Ein Spielzeughersteller erweckt aus Versehen die Seele der M\u00f6rderpuppe Chucky erneut zum Leben, die sich unmittelbar wieder ihren m\u00f6rderischen Aktivit\u00e4ten zuwendet.", + "runtime": 5028, + "seasons_count": null, + "seasons_label": null, + "synopsis": "Die allseits beliebte, von D\u00e4monen besessene M\u00f6rderpuppe ist wieder da und verbreitet erneut Horror und Schrecken.", + "tags": [ + "Brutal", + "Spannend" + ], + "title": "Chucky 2 \u2013 Die M\u00f6rderpuppe ist wieder da", + "type": "movie", + "watched": false, + "year": 1990 + }, + "80011356": { + "artwork": null, + "boxarts": { + "big": "https://art-s.nflximg.net/7c10d/5dcc3fc8f08487e92507627068cfe26ef727c10d.jpg", + "small": "https://art-s.nflximg.net/5bc0e/f3be361b8c594929062f90a8d9c6eb57fb75bc0e.jpg" + }, + "cast": [ + "Bjarne M\u00e4del" + ], + "creators": [], + "directors": [ + "Arne Feldhusen" + ], + "episode_count": 24, + "genres": [ + "Deutsche Serien", + "Serien", + "Comedyserien" + ], + "id": "80011356", + "in_my_list": true, + "interesting_moment": "https://art-s.nflximg.net/0188e/19cd705a71ee08c8d2609ae01cd8a97a86c0188e.jpg", + "list_id": "9588df32-f957-40e4-9055-1f6f33b60103_46891306", + "maturity": { + "board": "FSF", + "description": "Geeignet ab 12 Jahren.", + "level": 80, + "value": "12" + }, + "quality": "720", + "rating": 4.4394655, + "regular_synopsis": "Comedy-Serie \u00fcber die Erlebnisse eines Tatortreinigers, der seine schmutzige Arbeit erst beginnen kann, wenn die Polizei die Tatortanalyse abgeschlossen hat.", + "runtime": null, + "seasons_count": 5, + "seasons_label": "5 Staffeln", + "synopsis": "In den meisten Krimiserien werden Mordf\u00e4lle auf faszinierende und spannende Weise gel\u00f6st. Diese Serie ist anders.", + "tags": [ + "Zynisch" + ], + "title": "Der Tatortreiniger", + "type": "show", + "watched": false, + "year": 2015 + }, + } + """ + video_list = {}; + raw_video_list = response_data['value'] + netflix_list_id = self.parse_netflix_list_id(video_list=raw_video_list); + for video_id in raw_video_list['videos']: + if self._is_size_key(key=video_id) == False: + video_list.update(self.parse_video_list_entry(id=video_id, list_id=netflix_list_id, video=raw_video_list['videos'][video_id], persons=raw_video_list['person'], genres=raw_video_list['genres'])) + return video_list + + def parse_video_list_entry (self, id, list_id, video, persons, genres): + """Parse a video list entry e.g. rip out the parts we need + + Parameters + ---------- + id : :obj:`str` + Unique id of the video + + list_id : :obj:`str` + Unique id of the containing list + + video : :obj:`dict` of :obj:`str` + Video entry from the ´fetch_video_list´ call + + persons : :obj:`dict` of :obj:`dict` of :obj:`str` + List of persons with reference ids + + persons : :obj:`dict` of :obj:`dict` of :obj:`str` + List of genres with reference ids + + Returns + ------- + entry : :obj:`dict` of :obj:`dict` of :obj:`str` + Video list entry in the format: + + { + "372203": { + "artwork": null, + "boxarts": { + "big": "https://art-s.nflximg.net/5e7d3/b3b48749843fd3a36db11c319ffa60f96b55e7d3.jpg", + "small": "https://art-s.nflximg.net/57543/a039845c2eb9186dc26019576d895bf5a1957543.jpg" + }, + "cast": [ + "Christine Elise", + "Brad Dourif", + "Grace Zabriskie", + "Jenny Agutter", + "John Lafia", + "Gerrit Graham", + "Peter Haskell", + "Alex Vincent", + "Beth Grant" + ], + "creators": [], + "directors": [], + "episode_count": null, + "genres": [ + "Horrorfilme" + ], + "id": "372203", + "in_my_list": true, + "interesting_moment": "https://art-s.nflximg.net/09544/ed4b3073394b4469fb6ec22b9df81a4f5cb09544.jpg", + "list_id": "9588df32-f957-40e4-9055-1f6f33b60103_46891306", + "maturity": { + "board": "FSK", + "description": "Nur f\u00fcr Erwachsene geeignet.", + "level": 1000, + "value": "18" + }, + "quality": "540", + "rating": 3.1707757, + "regular_synopsis": "Ein Spielzeughersteller erweckt aus Versehen die Seele der M\u00f6rderpuppe Chucky erneut zum Leben, die sich unmittelbar wieder ihren m\u00f6rderischen Aktivit\u00e4ten zuwendet.", + "runtime": 5028, + "seasons_count": null, + "seasons_label": null, + "synopsis": "Die allseits beliebte, von D\u00e4monen besessene M\u00f6rderpuppe ist wieder da und verbreitet erneut Horror und Schrecken.", + "tags": [ + "Brutal", + "Spannend" + ], + "title": "Chucky 2 \u2013 Die M\u00f6rderpuppe ist wieder da", + "type": "movie", + "watched": false, + "year": 1990 + } + } + """ + season_info = self.parse_season_information_for_video(video=video) + return { + id: { + 'id': id, + 'list_id': list_id, + 'title': video['title'], + 'synopsis': video['synopsis'], + 'regular_synopsis': video['regularSynopsis'], + 'type': video['summary']['type'], + 'rating': video['userRating']['average'], + 'episode_count': season_info['episode_count'], + 'seasons_label': season_info['seasons_label'], + 'seasons_count': season_info['seasons_count'], + 'in_my_list': video['queue']['inQueue'], + 'year': video['releaseYear'], + 'runtime': self.parse_runtime_for_video(video=video), + 'watched': video['watched'], + 'tags': self.parse_tags_for_video(video=video), + 'genres': self.parse_genres_for_video(video=video, genres=genres), + 'quality': self.parse_quality_for_video(video=video), + 'cast': self.parse_cast_for_video(video=video, persons=persons), + 'directors': self.parse_directors_for_video(video=video, persons=persons), + 'creators': self.parse_creators_for_video(video=video, persons=persons), + 'maturity': { + 'board': None if 'board' not in video['maturity']['rating'].keys() else video['maturity']['rating']['board'], + 'value': None if 'value' not in video['maturity']['rating'].keys() else video['maturity']['rating']['value'], + 'description': None if 'maturityDescription' not in video['maturity']['rating'].keys() else video['maturity']['rating']['maturityDescription'], + 'level': None if 'maturityLevel' not in video['maturity']['rating'].keys() else video['maturity']['rating']['maturityLevel'] + }, + 'boxarts': { + 'small': video['boxarts']['_342x192']['jpg']['url'], + 'big': video['boxarts']['_1280x720']['jpg']['url'] + }, + 'interesting_moment': None if 'interestingMoment' not in video.keys() else video['interestingMoment']['_665x375']['jpg']['url'], + 'artwork': video['artWorkByType']['BILLBOARD']['_1280x720']['jpg']['url'], + } + } + + def parse_creators_for_video (self, video, persons): + """Matches ids with person names to generate a list of creators + + Parameters + ---------- + video : :obj:`dict` of :obj:`str` + Dictionary entry for one video entry + + persons : :obj:`dict` of :obj:`str` + Raw resposne of all persons delivered by the API call + + Returns + ------- + :obj:`list` of :obj:`str` + List of creators + """ + creators = [] + for person_key in dict(persons).keys(): + if self._is_size_key(key=person_key) == False and person_key != 'summary': + for creator_key in dict(video['creators']).keys(): + if self._is_size_key(key=creator_key) == False and creator_key != 'summary': + if video['creators'][creator_key][1] == person_key: + creators.append(persons[person_key]['name']) + return creators + + def parse_directors_for_video (self, video, persons): + """Matches ids with person names to generate a list of directors + + Parameters + ---------- + video : :obj:`dict` of :obj:`str` + Dictionary entry for one video entry + + persons : :obj:`dict` of :obj:`str` + Raw resposne of all persons delivered by the API call + + Returns + ------- + :obj:`list` of :obj:`str` + List of directors + """ + directors = [] + for person_key in dict(persons).keys(): + if self._is_size_key(key=person_key) == False and person_key != 'summary': + for director_key in dict(video['directors']).keys(): + if self._is_size_key(key=director_key) == False and director_key != 'summary': + if video['directors'][director_key][1] == person_key: + directors.append(persons[person_key]['name']) + return directors + + def parse_cast_for_video (self, video, persons): + """Matches ids with person names to generate a list of cast members + + Parameters + ---------- + video : :obj:`dict` of :obj:`str` + Dictionary entry for one video entry + + persons : :obj:`dict` of :obj:`str` + Raw resposne of all persons delivered by the API call + + Returns + ------- + :obj:`list` of :obj:`str` + List of cast members + """ + cast = [] + for person_key in dict(persons).keys(): + if self._is_size_key(key=person_key) == False and person_key != 'summary': + for cast_key in dict(video['cast']).keys(): + if self._is_size_key(key=cast_key) == False and cast_key != 'summary': + if video['cast'][cast_key][1] == person_key: + cast.append(persons[person_key]['name']) + return cast + + def parse_genres_for_video (self, video, genres): + """Matches ids with genre names to generate a list of genres for a video + + Parameters + ---------- + video : :obj:`dict` of :obj:`str` + Dictionary entry for one video entry + + genres : :obj:`dict` of :obj:`str` + Raw resposne of all genres delivered by the API call + + Returns + ------- + :obj:`list` of :obj:`str` + List of genres + """ + video_genres = [] + for genre_key in dict(genres).keys(): + if self._is_size_key(key=genre_key) == False and genre_key != 'summary': + for show_genre_key in dict(video['genres']).keys(): + if self._is_size_key(key=show_genre_key) == False and show_genre_key != 'summary': + if video['genres'][show_genre_key][1] == genre_key: + video_genres.append(genres[genre_key]['name']) + return video_genres + + def parse_tags_for_video (self, video): + """Parses a nested list of tags, removes the not needed meta information & returns a raw string list + + Parameters + ---------- + video : :obj:`dict` of :obj:`str` + Dictionary entry for one video entry + + Returns + ------- + :obj:`list` of :obj:`str` + List of tags + """ + tags = [] + for tag_key in dict(video['tags']).keys(): + if self._is_size_key(key=tag_key) == False and tag_key != 'summary': + tags.append(video['tags'][tag_key]['name']) + return tags + + def parse_season_information_for_video (self, video): + """Checks if the fiven video is a show (series) and returns season & episode information + + Parameters + ---------- + video : :obj:`dict` of :obj:`str` + Dictionary entry for one video entry + + Returns + ------- + :obj:`dict` of :obj:`str` + Episode count / Season Count & Season label if given + """ + season_info = { + 'episode_count': None, + 'seasons_label': None, + 'seasons_count': None + } + if video['summary']['type'] == 'show': + season_info = { + 'episode_count': video['episodeCount'], + 'seasons_label': video['numSeasonsLabel'], + 'seasons_count': video['seasonCount'] + } + return season_info + + def parse_quality_for_video (self, video): + """Transforms Netflix quality information in video resolution info + + Parameters + ---------- + video : :obj:`dict` of :obj:`str` + Dictionary entry for one video entry + + Returns + ------- + :obj:`str` + Quality of the video + """ + quality = '540' + if video['videoQuality']['hasHD']: + quality = '720' + if video['videoQuality']['hasUltraHD']: + quality = '1080' + return quality + + def parse_runtime_for_video (self, video): + """Checks if the video is a movie & returns the runtime if given + + Parameters + ---------- + video : :obj:`dict` of :obj:`str` + Dictionary entry for one video entry + + Returns + ------- + :obj:`str` + Runtime of the video (in seconds) + """ + runtime = None + if video['summary']['type'] != 'show': + runtime = video['runtime'] + return runtime + + def parse_netflix_list_id (self, video_list): + """Parse a video list and extract the list id + + Parameters + ---------- + video_list : :obj:`dict` of :obj:`str` + Netflix video list + + Returns + ------- + entry : :obj:`str` or None + Netflix list id + """ + netflix_list_id = None + if 'lists' in video_list.keys(): + for video_id in video_list['lists']: + if self._is_size_key(key=video_id) == False: + netflix_list_id = video_id; + return netflix_list_id + + def parse_show_information (self, id, response_data): + """Parse extended show information (synopsis, seasons, etc.) + + Parameters + ---------- + id : :obj:`str` + Video id + + response_data : :obj:`dict` of :obj:`str` + Parsed response JSON from the `fetch_show_information` call + + Returns + ------- + entry : :obj:`dict` of :obj:`str` + Show information in the format: + { + "season_id": "80113084", + "synopsis": "Aus verzweifelter Geldnot versucht sich der Familienvater und Drucker Jochen als Geldf\u00e4lscher und rutscht dabei immer mehr in die dunkle Welt des Verbrechens ab." + "detail_text": "I´m optional" + } + """ + show = {} + raw_show = response_data['value']['videos'][id] + show.update({'synopsis': raw_show['regularSynopsis']}) + if 'evidence' in raw_show: + show.update({'detail_text': raw_show['evidence']['value']['text']}) + if 'seasonList' in raw_show: + show.update({'season_id': raw_show['seasonList']['current'][1]}) + return show + + def parse_seasons (self, id, response_data): + """Parse a list of seasons for a given show + + Parameters + ---------- + id : :obj:`str` + Season id + + response_data : :obj:`dict` of :obj:`str` + Parsed response JSON from the `fetch_seasons_for_show` call + + Returns + ------- + entry : :obj:`dict` of :obj:`dict` of :obj:`str` + Season information in the format: + { + "80113084": { + "id": 80113084, + "text": "Season 1", + "shortName": "St. 1", + "boxarts": { + "big": "https://art-s.nflximg.net/5e7d3/b3b48749843fd3a36db11c319ffa60f96b55e7d3.jpg", + "small": "https://art-s.nflximg.net/57543/a039845c2eb9186dc26019576d895bf5a1957543.jpg" + }, + "interesting_moment": "https://art-s.nflximg.net/09544/ed4b3073394b4469fb6ec22b9df81a4f5cb09544.jpg" + }, + "80113085": { + "id": 80113085, + "text": "Season 2", + "shortName": "St. 2", + "boxarts": { + "big": "https://art-s.nflximg.net/5e7d3/b3b48749843fd3a36db11c319ffa60f96b55e7d3.jpg", + "small": "https://art-s.nflximg.net/57543/a039845c2eb9186dc26019576d895bf5a1957543.jpg" + }, + "interesting_moment": "https://art-s.nflximg.net/09544/ed4b3073394b4469fb6ec22b9df81a4f5cb09544.jpg" + } + } + """ + seasons = {} + raw_seasons = response_data['value'] + for season in raw_seasons['seasons']: + if self._is_size_key(key=season) == False: + seasons.update(self.parse_season_entry(season=raw_seasons['seasons'][season], videos=raw_seasons['videos'])) + return seasons + + def parse_season_entry (self, season, videos): + """Parse a season list entry e.g. rip out the parts we need + + Parameters + ---------- + season : :obj:`dict` of :obj:`str` + Season entry from the `fetch_seasons_for_show` call + + Returns + ------- + entry : :obj:`dict` of :obj:`dict` of :obj:`str` + Season list entry in the format: + + { + "80113084": { + "id": 80113084, + "text": "Season 1", + "shortName": "St. 1", + "boxarts": { + "big": "https://art-s.nflximg.net/5e7d3/b3b48749843fd3a36db11c319ffa60f96b55e7d3.jpg", + "small": "https://art-s.nflximg.net/57543/a039845c2eb9186dc26019576d895bf5a1957543.jpg" + }, + "interesting_moment": "https://art-s.nflximg.net/09544/ed4b3073394b4469fb6ec22b9df81a4f5cb09544.jpg" + } + } + """ + # get art video key + video_key = '' + for key in videos.keys(): + if self._is_size_key(key=key) == False: + video_key = key + return { + season['summary']['id']: { + 'id': season['summary']['id'], + 'text': season['summary']['name'], + 'shortName': season['summary']['shortName'], + 'boxarts': { + 'small': videos[video_key]['boxarts']['_342x192']['jpg']['url'], + 'big': videos[video_key]['boxarts']['_1280x720']['jpg']['url'] + }, + 'interesting_moment': videos[video_key]['interestingMoment']['_665x375']['jpg']['url'], + } + } + + def parse_episodes_by_season (self, response_data): + """Parse episodes for a given season/episode list + + Parameters + ---------- + response_data : :obj:`dict` of :obj:`str` + Parsed response JSON from the `fetch_seasons_for_show` call + + Returns + ------- + entry : :obj:`dict` of :obj:`dict` of :obj:`str` + Season information in the format: + + { + "70251729": { + "banner": "https://art-s.nflximg.net/63a36/c7fdfe6604ef2c22d085ac5dca5f69874e363a36.jpg", + "duration": 1387, + "episode": 1, + "fanart": "https://art-s.nflximg.net/74e02/e7edcc5cc7dcda1e94d505df2f0a2f0d22774e02.jpg", + "genres": [ + "Serien", + "Comedyserien" + ], + "id": 70251729, + "mediatype": "episode", + "mpaa": "FSK 16", + "my_list": false, + "playcount": 0, + "plot": "Als die Griffins und andere Einwohner von Quahog in die Villa von James Woods eingeladen werden, muss pl\u00f6tzlich ein Mord aufgekl\u00e4rt werden.", + "poster": "https://art-s.nflximg.net/72fd6/57088715e8d436fdb6986834ab39124b0a972fd6.jpg", + "rating": 3.9111512, + "season": 9, + "thumb": "https://art-s.nflximg.net/be686/07680670a68da8749eba607efb1ae37f9e3be686.jpg", + "title": "Und dann gab es weniger (Teil 1)", + "year": 2010, + "bookmark": -1 + }, + "70251730": { + "banner": "https://art-s.nflximg.net/63a36/c7fdfe6604ef2c22d085ac5dca5f69874e363a36.jpg", + "duration": 1379, + "episode": 2, + "fanart": "https://art-s.nflximg.net/c472c/6c10f9578bf2c1d0a183c2ccb382931efcbc472c.jpg", + "genres": [ + "Serien", + "Comedyserien" + ], + "id": 70251730, + "mediatype": "episode", + "mpaa": "FSK 16", + "my_list": false, + "playcount": 1, + "plot": "Wer ist der M\u00f6rder? Nach zahlreichen Morden wird immer wieder jemand anderes verd\u00e4chtigt.", + "poster": "https://art-s.nflximg.net/72fd6/57088715e8d436fdb6986834ab39124b0a972fd6.jpg", + "rating": 3.9111512, + "season": 9, + "thumb": "https://art-s.nflximg.net/15a08/857d59126641987bec302bb147a802a00d015a08.jpg", + "title": "Und dann gab es weniger (Teil 2)", + "year": 2010, + "bookmark": 1234 + }, + } + """ + episodes = {} + raw_episodes = response_data['value']['videos'] + for episode_id in raw_episodes: + if self._is_size_key(key=episode_id) == False: + if (raw_episodes[episode_id]['summary']['type'] == 'episode'): + episodes.update(self.parse_episode(episode=raw_episodes[episode_id], genres=response_data['value']['genres'])) + return episodes + + def parse_episode (self, episode, genres=None): + """Parse episode from an list of episodes by season + + Parameters + ---------- + episode : :obj:`dict` of :obj:`str` + Episode entry from the `fetch_episodes_by_season` call + + Returns + ------- + entry : :obj:`dict` of :obj:`dict` of :obj:`str` + Episode information in the format: + + { + "70251729": { + "banner": "https://art-s.nflximg.net/63a36/c7fdfe6604ef2c22d085ac5dca5f69874e363a36.jpg", + "duration": 1387, + "episode": 1, + "fanart": "https://art-s.nflximg.net/74e02/e7edcc5cc7dcda1e94d505df2f0a2f0d22774e02.jpg", + "genres": [ + "Serien", + "Comedyserien" + ], + "id": 70251729, + "mediatype": "episode", + "mpaa": "FSK 16", + "my_list": false, + "playcount": 0, + "plot": "Als die Griffins und andere Einwohner von Quahog in die Villa von James Woods eingeladen werden, muss pl\u00f6tzlich ein Mord aufgekl\u00e4rt werden.", + "poster": "https://art-s.nflximg.net/72fd6/57088715e8d436fdb6986834ab39124b0a972fd6.jpg", + "rating": 3.9111512, + "season": 9, + "thumb": "https://art-s.nflximg.net/be686/07680670a68da8749eba607efb1ae37f9e3be686.jpg", + "title": "Und dann gab es weniger (Teil 1)", + "year": 2010, + "bookmark": 1234 + }, + } + """ + return { + episode['summary']['id']: { + 'id': episode['summary']['id'], + 'episode': episode['summary']['episode'], + 'season': episode['summary']['season'], + 'plot': episode['info']['synopsis'], + 'duration': episode['info']['runtime'], + 'title': episode['info']['title'], + 'year': episode['info']['releaseYear'], + 'genres': self.parse_genres_for_video(video=episode, genres=genres), + 'mpaa': str(episode['maturity']['rating']['board']) + ' ' + str(episode['maturity']['rating']['value']), + 'maturity': episode['maturity'], + 'playcount': (0, 1)[episode['watched']], + 'rating': episode['userRating']['average'], + 'thumb': episode['info']['interestingMoments']['url'], + 'fanart': episode['interestingMoment']['_1280x720']['jpg']['url'], + 'poster': episode['boxarts']['_1280x720']['jpg']['url'], + 'banner': episode['boxarts']['_342x192']['jpg']['url'], + 'mediatype': {'episode': 'episode', 'movie': 'movie'}[episode['summary']['type']], + 'my_list': episode['queue']['inQueue'], + 'bookmark': episode['bookmarkPosition'] + } + } + + def fetch_browse_list_contents (self): + """Fetches the HTML data for the lists on the landing page (browse page) of Netflix + + Returns + ------- + :obj:`BeautifulSoup` + Instance of an BeautifulSoup document containing the complete page contents + """ + response = self.session.get(self._get_document_url_for(component='browse')) + return BeautifulSoup(response.text) + + def fetch_video_list_ids (self, list_from=0, list_to=50): + """Fetches the JSON with detailed information based on the lists on the landing page (browse page) of Netflix + + Parameters + ---------- + list_from : :obj:`int` + Start entry for pagination + + list_to : :obj:`int` + Last entry for pagination + + Returns + ------- + :obj:`dict` of :obj:`dict` of :obj:`str` + Raw Netflix API call response or api call error + """ + payload = { + 'fromRow': list_from, + 'toRow': list_to, + 'opaqueImageExtension': 'jpg', + 'transparentImageExtension': 'png', + '_': int(time.time()), + 'authURL': self.user_data['authURL'] + } + url = self._get_api_url_for(component='video_list_ids') + response = self.session.get(url, params=payload); + return self._process_response(response=response, component=url) + + def fetch_search_results (self, search_str, list_from=0, list_to=48): + """Fetches the JSON which contains the results for the given search query + + Parameters + ---------- + search_str : :obj:`str` + String to query Netflix search for + + list_from : :obj:`int` + Start entry for pagination + + list_to : :obj:`int` + Last entry for pagination + + Returns + ------- + :obj:`dict` of :obj:`dict` of :obj:`str` + Raw Netflix API call response or api call error + """ + # properly encode the search string + encoded_search_string = urllib.quote(search_str) + + paths = [ + ['search', encoded_search_string, 'titles', {'from': list_from, 'to': list_to}, ['summary', 'title']], + ['search', encoded_search_string, 'titles', {'from': list_from, 'to': list_to}, 'boxarts', '_342x192', 'jpg'], + ['search', encoded_search_string, 'titles', ['id', 'length', 'name', 'trackIds', 'requestId']] + ] + response = self._path_request(paths=paths) + return self._process_response(response=response, component='Search results') + + def fetch_video_list (self, list_id, list_from=0, list_to=20): + """Fetches the JSON which contains the contents of a given video list + + Parameters + ---------- + list_id : :obj:`str` + Unique list id to query Netflix for + + list_from : :obj:`int` + Start entry for pagination + + list_to : :obj:`int` + Last entry for pagination + + Returns + ------- + :obj:`dict` of :obj:`dict` of :obj:`str` + Raw Netflix API call response or api call error + """ + paths = [ + ['lists', list_id, {'from': list_from, 'to': list_to}, ['summary', 'title', 'synopsis', 'regularSynopsis', 'evidence', 'queue', 'episodeCount', 'info', 'maturity', 'runtime', 'seasonCount', 'releaseYear', 'userRating', 'numSeasonsLabel', 'bookmarkPosition', 'watched', 'videoQuality']], + ['lists', list_id, {'from': list_from, 'to': list_to}, 'cast', {'from': 0, 'to': 15}, ['id', 'name']], + ['lists', list_id, {'from': list_from, 'to': list_to}, 'cast', 'summary'], + ['lists', list_id, {'from': list_from, 'to': list_to}, 'genres', {'from': 0, 'to': 5}, ['id', 'name']], + ['lists', list_id, {'from': list_from, 'to': list_to}, 'genres', 'summary'], + ['lists', list_id, {'from': list_from, 'to': list_to}, 'tags', {'from': 0, 'to': 9}, ['id', 'name']], + ['lists', list_id, {'from': list_from, 'to': list_to}, 'tags', 'summary'], + ['lists', list_id, {'from': list_from, 'to': list_to}, ['creators', 'directors'], {'from': 0, 'to': 49}, ['id', 'name']], + ['lists', list_id, {'from': list_from, 'to': list_to}, ['creators', 'directors'], 'summary'], + ['lists', list_id, {'from': list_from, 'to': list_to}, 'bb2OGLogo', '_400x90', 'png'], + ['lists', list_id, {'from': list_from, 'to': list_to}, 'boxarts', '_342x192', 'jpg'], + ['lists', list_id, {'from': list_from, 'to': list_to}, 'boxarts', '_1280x720', 'jpg'], + ['lists', list_id, {'from': list_from, 'to': list_to}, 'storyarts', '_1632x873', 'jpg'], + ['lists', list_id, {'from': list_from, 'to': list_to}, 'interestingMoment', '_665x375', 'jpg'], + ['lists', list_id, {'from': list_from, 'to': list_to}, 'artWorkByType', 'BILLBOARD', '_1280x720', 'jpg'] + ]; + + response = self._path_request(paths=paths) + return self._process_response(response=response, component='Video list') + + def fetch_video_list_information (self, video_ids): + """Fetches the JSON which contains the detail information of a list of given video ids + + Parameters + ---------- + video_ids : :obj:`list` of :obj:`str` + List of video ids to fetch detail data for + + Returns + ------- + :obj:`dict` of :obj:`dict` of :obj:`str` + Raw Netflix API call response or api call error + """ + paths = [] + for video_id in video_ids: + paths.append(['videos', video_id, ['summary', 'title', 'synopsis', 'regularSynopsis', 'evidence', 'queue', 'episodeCount', 'info', 'maturity', 'runtime', 'seasonCount', 'releaseYear', 'userRating', 'numSeasonsLabel', 'bookmarkPosition', 'watched', 'videoQuality']]) + paths.append(['videos', video_id, 'cast', {'from': 0, 'to': 15}, ['id', 'name']]) + paths.append(['videos', video_id, 'cast', 'summary']) + paths.append(['videos', video_id, 'genres', {'from': 0, 'to': 5}, ['id', 'name']]) + paths.append(['videos', video_id, 'genres', 'summary']) + paths.append(['videos', video_id, 'tags', {'from': 0, 'to': 9}, ['id', 'name']]) + paths.append(['videos', video_id, 'tags', 'summary']) + paths.append(['videos', video_id, ['creators', 'directors'], {'from': 0, 'to': 49}, ['id', 'name']]) + paths.append(['videos', video_id, ['creators', 'directors'], 'summary']) + paths.append(['videos', video_id, 'bb2OGLogo', '_400x90', 'png']) + paths.append(['videos', video_id, 'boxarts', '_342x192', 'jpg']) + paths.append(['videos', video_id, 'boxarts', '_1280x720', 'jpg']) + paths.append(['videos', video_id, 'storyarts', '_1632x873', 'jpg']) + paths.append(['videos', video_id, 'interestingMoment', '_665x375', 'jpg']) + paths.append(['videos', video_id, 'artWorkByType', 'BILLBOARD', '_1280x720', 'jpg']) + + response = self._path_request(paths=paths) + return self._process_response(response=response, component='fetch_video_list_information') + + def fetch_metadata (self, id): + """Fetches the JSON which contains the metadata for a given show/movie or season id + + Parameters + ---------- + id : :obj:`str` + Show id, movie id or season id + + Returns + ------- + :obj:`dict` of :obj:`dict` of :obj:`str` + Raw Netflix API call response or api call error + """ + payload = { + 'movieid': id, + 'imageformat': 'jpg', + '_': int(time.time()) + } + url = self._get_api_url_for(component='metadata') + response = self.session.get(url, params=payload); + return self._process_response(response=response, component=url) + + def fetch_show_information (self, id, type): + """Fetches the JSON which contains the detailed contents of a show + + Parameters + ---------- + id : :obj:`str` + Unique show id to query Netflix for + + type : :obj:`str` + Can be 'movie' or 'show' + + Returns + ------- + :obj:`dict` of :obj:`dict` of :obj:`str` + Raw Netflix API call response or api call error + """ + # check if we have a show or a movie, the request made depends on this + if type == 'show': + paths = [ + ['videos', id, ['requestId', 'regularSynopsis', 'evidence']], + ['videos', id, 'seasonList', 'current', 'summary'] + ] + else: + paths = [['videos', id, ['requestId', 'regularSynopsis', 'evidence']]] + response = self._path_request(paths=paths) + return self._process_response(response=response, component='Show information') + + def fetch_seasons_for_show (self, id, list_from=0, list_to=30): + """Fetches the JSON which contains the seasons of a given show + + Parameters + ---------- + id : :obj:`str` + Unique show id to query Netflix for + + list_from : :obj:`int` + Start entry for pagination + + list_to : :obj:`int` + Last entry for pagination + + Returns + ------- + :obj:`dict` of :obj:`dict` of :obj:`str` + Raw Netflix API call response or api call error + """ + paths = [ + ['videos', id, 'seasonList', {'from': list_from, 'to': list_to}, 'summary'], + ['videos', id, 'seasonList', 'summary'], + ['videos', id, 'boxarts', '_342x192', 'jpg'], + ['videos', id, 'boxarts', '_1280x720', 'jpg'], + ['videos', id, 'storyarts', '_1632x873', 'jpg'], + ['videos', id, 'interestingMoment', '_665x375', 'jpg'] + ] + response = self._path_request(paths=paths) + return self._process_response(response=response, component='Seasons') + + def fetch_episodes_by_season (self, season_id, list_from=-1, list_to=40): + """Fetches the JSON which contains the episodes of a given season + + TODO: Add more metadata + + Parameters + ---------- + season_id : :obj:`str` + Unique season_id id to query Netflix for + + list_from : :obj:`int` + Start entry for pagination + + list_to : :obj:`int` + Last entry for pagination + + Returns + ------- + :obj:`dict` of :obj:`dict` of :obj:`str` + Raw Netflix API call response or api call error + """ + paths = [ + ['seasons', season_id, 'episodes', {'from': list_from, 'to': list_to}, ['summary', 'queue', 'info', 'maturity', 'userRating', 'bookmarkPosition', 'creditOffset', 'watched', 'videoQuality']], + #['videos', season_id, 'cast', {'from': 0, 'to': 15}, ['id', 'name']], + #['videos', season_id, 'cast', 'summary'], + #['videos', season_id, 'genres', {'from': 0, 'to': 5}, ['id', 'name']], + #['videos', season_id, 'genres', 'summary'], + #['videos', season_id, 'tags', {'from': 0, 'to': 9}, ['id', 'name']], + #['videos', season_id, 'tags', 'summary'], + #['videos', season_id, ['creators', 'directors'], {'from': 0, 'to': 49}, ['id', 'name']], + #['videos', season_id, ['creators', 'directors'], 'summary'], + ['seasons', season_id, 'episodes', {'from': list_from, 'to': list_to}, 'genres', {'from': 0, 'to': 1}, ['id', 'name']], + ['seasons', season_id, 'episodes', {'from': list_from, 'to': list_to}, 'genres', 'summary'], + ['seasons', season_id, 'episodes', {'from': list_from, 'to': list_to}, 'interestingMoment', '_1280x720', 'jpg'], + ['seasons', season_id, 'episodes', {'from': list_from, 'to': list_to}, 'interestingMoment', '_665x375', 'jpg'], + ['seasons', season_id, 'episodes', {'from': list_from, 'to': list_to}, 'boxarts', '_342x192', 'jpg'], + ['seasons', season_id, 'episodes', {'from': list_from, 'to': list_to}, 'boxarts', '_1280x720', 'jpg'] + ] + response = self._path_request(paths=paths) + return self._process_response(response=response, component='fetch_episodes_by_season') + + def refresh_session_data (self, account): + """Reload the session data (profiles, user_data, api_data) + + Parameters + ---------- + account : :obj:`dict` of :obj:`str` + Dict containing an email, country & a password property + """ + # load the profiles page (to verify the user) + response = self.session.get(self._get_document_url_for(component='profiles')) + + # parse out the needed inline information + page_soup = BeautifulSoup(response.text) + page_data = self.extract_inline_netflix_page_data(page_soup=page_soup) + self._parse_page_contents(page_soup) + account_hash = self._generate_account_hash(account=account) + self._save_data(filename=self.data_path + '_' + account_hash) + + def _path_request (self, paths): + """Executes a post request against the shakti endpoint with Falcor style payload + + Parameters + ---------- + paths : :obj:`list` of :obj:`list` + Payload with path querys for the Netflix Shakti API in Falcor style + + Returns + ------- + :obj:`requests.response` + Response from a POST call made with Requests + """ + headers = { + 'Content-Type': 'application/json', + 'Accept': 'application/json, text/javascript, */*', + } + + data = json.dumps({ + 'paths': paths, + 'authURL': self.user_data['authURL'] + }) + + params = { + 'withSize': True, + 'materialize': True, + 'model': self.user_data['gpsModel'] + } + + return self.session.post(self._get_api_url_for(component='shakti'), params=params, headers=headers, data=data) + + def _is_size_key (self, key): + """Tiny helper that checks if a given key is called $size or size, as we need to check this often + + Parameters + ---------- + key : :obj:`str` + Key to check the value for + + Returns + ------- + bool + Key has a size value or not + """ + return key == '$size' or key == 'size' + + def _get_api_url_for (self, component): + """Tiny helper that builds the url for a requested API endpoint component + + Parameters + ---------- + component : :obj:`str` + Component endpoint to build the URL for + + Returns + ------- + :obj:`str` + API Url + """ + return self.api_data['API_ROOT'] + self.api_data['API_BASE_URL'] + '/' + self.api_data['BUILD_IDENTIFIER'] + self.urls[component] + + def _get_document_url_for (self, component): + """Tiny helper that builds the url for a requested document endpoint component + + Parameters + ---------- + component : :obj:`str` + Component endpoint to build the URL for + + Returns + ------- + :obj:`str` + Document Url + """ + return self.base_url + self.urls[component] + + def _process_response (self, response, component): + """Tiny helper to check responses for API requests + + Parameters + ---------- + response : :obj:`requests.response` + Response from a requests instance + + component : :obj:`str` + Component endpoint + + Returns + ------- + :obj:`dict` of :obj:`dict` of :obj:`str` or :obj:`dict` of :obj:`str` + Raw Netflix API call response or api call error + """ + # check if we´re not authorized to make thios call + if response.status_code == 401: + return { + 'error': True, + 'message': 'Session invalid', + 'code': 401 + } + # check if somethign else failed + if response.status_code != 200: + return { + 'error': True, + 'message': 'API call for "' + component + '" failed', + 'code': response.status_code + } + # return the parsed response & everything´s fine + return response.json() + + def _update_my_list (self, video_id, operation): + """Tiny helper to add & remove items from "my list" + + Parameters + ---------- + video_id : :obj:`str` + ID of the show/movie to be added + + operation : :obj:`str` + Either "add" or "remove" + + Returns + ------- + bool + Operation successfull + """ + headers = { + 'Content-Type': 'application/json', + 'Accept': 'application/json, text/javascript, */*', + } + + payload = json.dumps({ + 'operation': operation, + 'videoId': int(video_id), + 'authURL': self.user_data['authURL'] + }) + + response = self.session.post(self._get_api_url_for(component='update_my_list'), headers=headers, data=payload) + return response.status_code == 200 + + def _save_data(self, filename): + """Tiny helper that stores session data from the session in a given file + + Parameters + ---------- + filename : :obj:`str` + Complete path incl. filename that determines where to store the cookie + + Returns + ------- + bool + Storage procedure was successfull + """ + if not os.path.isdir(os.path.dirname(filename)): + return False + with open(filename, 'w') as f: + f.truncate() + pickle.dump({ + 'user_data': self.user_data, + 'api_data': self.api_data, + 'profiles': self.profiles + }, f) + + def _load_data(self, filename): + """Tiny helper that loads session data into the active session from a given file + + Parameters + ---------- + filename : :obj:`str` + Complete path incl. filename that determines where to load the data from + + Returns + ------- + bool + Load procedure was successfull + """ + if not os.path.isfile(filename): + return False + + with open(filename) as f: + data = pickle.load(f) + if data: + self.profiles = data['profiles'] + self.user_data = data['user_data'] + self.api_data = data['api_data'] + else: + return False + + def _delete_data (self, path): + """Tiny helper that deletes session data + + Parameters + ---------- + filename : :obj:`str` + Complete path incl. filename that determines where to delete the files + + """ + head, tail = os.path.split(path) + for subdir, dirs, files in os.walk(head): + for file in files: + if tail in file: + os.remove(os.path.join(subdir, file)) + + def _save_cookies(self, filename): + """Tiny helper that stores cookies from the session in a given file + + Parameters + ---------- + filename : :obj:`str` + Complete path incl. filename that determines where to store the cookie + + Returns + ------- + bool + Storage procedure was successfull + """ + if not os.path.isdir(os.path.dirname(filename)): + return False + with open(filename, 'w') as f: + f.truncate() + pickle.dump(self.session.cookies._cookies, f) + + def _load_cookies(self, filename): + """Tiny helper that loads cookies into the active session from a given file + + Parameters + ---------- + filename : :obj:`str` + Complete path incl. filename that determines where to load the cookie from + + Returns + ------- + bool + Load procedure was successfull + """ + if not os.path.isfile(filename): + return False + + with open(filename) as f: + cookies = pickle.load(f) + if cookies: + jar = requests.cookies.RequestsCookieJar() + jar._cookies = cookies + self.session.cookies = jar + else: + return False + + def _delete_cookies (self, path): + """Tiny helper that deletes cookie data + + Parameters + ---------- + filename : :obj:`str` + Complete path incl. filename that determines where to delete the files + + """ + head, tail = os.path.split(path) + for subdir, dirs, files in os.walk(head): + for file in files: + if tail in file: + os.remove(os.path.join(subdir, file)) + + def _generate_account_hash (self, account): + """Generates a has for the given account (used for cookie verification) + + Parameters + ---------- + account : :obj:`dict` of :obj:`str` + Dict containing an email, country & a password property + + Returns + ------- + :obj:`str` + Account data hash + """ + return base64.urlsafe_b64encode(account['email']) diff --git a/resources/lib/__init__.py b/resources/lib/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/resources/lib/common.py b/resources/lib/common.py new file mode 100644 index 0000000..161bcae --- /dev/null +++ b/resources/lib/common.py @@ -0,0 +1,18 @@ +import os +import xbmc +import xbmcaddon +import xbmcgui +import xbmcvfs + +ADDON = xbmcaddon.Addon() +ADDONVERSION = ADDON.getAddonInfo('version') +ADDONNAME = ADDON.getAddonInfo('name') +ADDONPATH = ADDON.getAddonInfo('path').decode('utf-8') +ADDONPROFILE = xbmc.translatePath( ADDON.getAddonInfo('profile') ).decode('utf-8') +ICON = ADDON.getAddonInfo('icon') + +def log(txt): + if isinstance (txt,str): + txt = txt.decode("utf-8") + message = u'%s: %s' % ("service.msl", txt) + xbmc.log(msg=message.encode("utf-8"), level=xbmc.LOGDEBUG) diff --git a/resources/lib/utils.py b/resources/lib/utils.py new file mode 100644 index 0000000..25cd285 --- /dev/null +++ b/resources/lib/utils.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# Module: utils +# Created on: 13.01.2017 + +# strips html from input +# used the kick out the junk, when parsing the inline JS objects of the Netflix homepage +from HTMLParser import HTMLParser +class MLStripper(HTMLParser): + def __init__(self): + self.reset() + self.fed = [] + def handle_data(self, d): + self.fed.append(d) + def get_data(self): + return ''.join(self.fed) + +def strip_tags(html): + s = MLStripper() + s.feed(html) + return s.get_data() + +# Takes everything, does nothing, classic no operation function +def noop (**kwargs): + return True + +# log decorator +def log(f, name=None): + if name is None: + name = f.func_name + def wrapped(*args, **kwargs): + that = args[0] + class_name = that.__class__.__name__ + arguments = '' + for key, value in kwargs.iteritems(): + if key != 'account' and key != 'credentials': + arguments += ":%s = %s:" % (key, value) + if arguments != '': + that.log('"' + class_name + '::' + name + '" called with arguments ' + arguments) + else: + that.log('"' + class_name + '::' + name + '" called') + result = f(*args, **kwargs) + that.log('"' + class_name + '::' + name + '" returned: ' + str(result)) + return result + wrapped.__doc__ = f.__doc__ + return wrapped diff --git a/resources/screenshot-01.jpg b/resources/screenshot-01.jpg new file mode 100644 index 0000000..9f1d398 Binary files /dev/null and b/resources/screenshot-01.jpg differ diff --git a/resources/screenshot-02.jpg b/resources/screenshot-02.jpg new file mode 100644 index 0000000..56c6596 Binary files /dev/null and b/resources/screenshot-02.jpg differ diff --git a/resources/screenshot-03.jpg b/resources/screenshot-03.jpg new file mode 100644 index 0000000..2ef950b Binary files /dev/null and b/resources/screenshot-03.jpg differ diff --git a/resources/settings.xml b/resources/settings.xml new file mode 100644 index 0000000..3f9f9c0 --- /dev/null +++ b/resources/settings.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/service.py b/service.py new file mode 100644 index 0000000..8600979 --- /dev/null +++ b/service.py @@ -0,0 +1,28 @@ +import threading +import SocketServer +import xbmc +from resources.lib.common import log +from resources.lib.MSLHttpRequestHandler import MSLHttpRequestHandler + +PORT = 8000 +Handler = MSLHttpRequestHandler +SocketServer.TCPServer.allow_reuse_address = True +server = SocketServer.TCPServer(('127.0.0.1', PORT), Handler) +server.server_activate() +server.timeout = 1 + +if __name__ == '__main__': + monitor = xbmc.Monitor() + thread = threading.Thread(target=server.serve_forever) + thread.daemon = True + thread.start() + + while not monitor.abortRequested(): + if monitor.waitForAbort(5): + server.shutdown() + break + + server.server_close() + server.socket.close() + server.shutdown() + log("Stopped MSL Service")