--- /dev/null
+# 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
--- /dev/null
+#!/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:])
--- /dev/null
+<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+<addon id="plugin.video.netflix" name="Netflix" version="0.2.0" provider-name="tba">
+ <requires>
+ <import addon="xbmc.python" version="2.24.0"/>
+ <import addon="script.module.beautifulsoup" version="3.2.1"/>
+ <import addon="script.module.requests" version="2.12.4"/>
+ <import addon="script.module.pycryptodome" version="3.4.3"/>
+ <import addon="script.module.simplejson" version="3.3.0"/>
+ <import addon="inputstream.adaptive" version="1.0.6"/>
+ </requires>
+ <extension point="xbmc.python.pluginsource" library="addon.py">
+ <provides>video</provides>
+ </extension>
+ <extension point="xbmc.service" library="service.py" start="login" />
+ <extension point="xbmc.addon.metadata">
+ <summary lang="de">Netflix</summary>
+ <description lang="de">Addon für Netflix VOD Services</description>
+ <disclaimer lang="de">Möglicherweise sind einge Teile dieses Addons in Ihrem Land illegal, Sie sollten dies unbedingt vor der Installation überprüfen.</disclaimer>
+ <summary lang="en">Netflix</summary>
+ <description lang="en">Netflix VOD Services Addon</description>
+ <disclaimer lang="en">Some parts of this addon may not be legal in your country of residence - please check with your local laws before installing.</disclaimer>
+ <assets>
+ <icon>resources\icon.png</icon>
+ <fanart>resources\fanart.jpg</fanart>
+ <screenshot>resources\screenshot-01.jpg</screenshot>
+ <screenshot>resources\screenshot-02.jpg</screenshot>
+ <screenshot>resources\screenshot-03.jpg</screenshot>
+ </assets>
+ <platform>all</platform>
+ <license>GNU GENERAL PUBLIC LICENSE. Version 2, June 1991</license>
+ <forum>http://www.kodinerds.net/</forum>
+ <source>https://github.com/kodinerds/repo</source>
+ </extension>
+</addon>
--- /dev/null
+# 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 <EMAIL@ADDRESS>\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 ""
--- /dev/null
+# 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 <EMAIL@ADDRESS>\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"
--- /dev/null
+#!/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
--- /dev/null
+#!/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
--- /dev/null
+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
--- /dev/null
+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)
--- /dev/null
+#!/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)
--- /dev/null
+#!/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 <script/> tags from the given document and parses the contents of each one of `em.
+ The contents of the parsable tags looks something like this:
+
+ <script>window.netflix = window.netflix || {} ;
+ netflix.notification = {"constants":{"sessionLength":30,"ownerToken":"ZDD...};</script>
+
+ 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 <script/> tags contained in the page follow these pattern,
+ but the ones we need do, so we´re just catching any errors and applying a noop() function in case this happens,
+ as we´re not interested in those.
+
+ Note: Yes this is ugly & I´d like to avoid doing this, but Netflix leaves us no other choice,
+ as there are simply no api endpoints for the data, we need to extract them from HTML,
+ or better, JavaScript as we´re parsing the contents of <script/> tags
+
+ Parameters
+ ----------
+ page_soup : :obj:`BeautifulSoup`
+ Instance of an BeautifulSoup document or node containing the complete page contents
+
+ Returns
+ -------
+ :obj:`list` of :obj:`dict`
+ List of all the serialized data pulled out of the pagws <script/> tags
+ """
+ inline_data = [];
+ data_scripts = page_soup.findAll('script', attrs={'src': None});
+ for script in data_scripts:
+ # ugly part: try to parse the data & don't care about errors (as they will be some)
+ try:
+ # find the first occurance of the 'netflix.' string, assigning the contents to a global js var
+ str_index = str(script).find('netflix.')
+ # filter out the contents between the 'netflix.x =' & ';<script>'
+ stripped_data = str(script)[str_index:][(str(script)[str_index:].find('= ') + 2):].replace(';</script>', '').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'])
--- /dev/null
+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)
--- /dev/null
+#!/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
--- /dev/null
+<?xml version="1.0" encoding="utf-8" standalone="yes"?>
+<settings>
+ <category label="30014">
+ <setting id="email" type="text" label="30005" default="" />
+ <setting id="password" type="text" option="hidden" label="30004" default="" />
+ <setting id="logout" type="action" label="30017" action="RunPlugin(plugin://plugin.video.netflix/?action=logout)" option="close"/>
+ </category>
+ <category label="30025">
+ <setting id="enablelibraryfolder" type="bool" label="30026" default="false" />
+ <setting id="customlibraryfolder" type="folder" label="30027" enable="eq(-1,true)" default="special://profile/addon_data/plugin.video.netflix" source="auto" option="writeable" subsetting="true" />
+ </category>
+ <category label="30023">
+ <setting id="ssl_verification" type="bool" label="30024" default="true"/>
+ </category>
+ <category label="30015">
+ <setting id="logging" type="bool" label="30016" default="false"/>
+ </category>
+</settings>
--- /dev/null
+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")