feat(init): Repository init
authorSebastian Golasch <public@asciidisco.com>
Thu, 26 Jan 2017 19:22:16 +0000 (20:22 +0100)
committerSebastian Golasch <public@asciidisco.com>
Thu, 26 Jan 2017 19:22:16 +0000 (20:22 +0100)
24 files changed:
.gitignore [new file with mode: 0644]
README.md [new file with mode: 0644]
__init__.py [new file with mode: 0644]
addon.py [new file with mode: 0644]
addon.xml [new file with mode: 0644]
resources/__init__.py [new file with mode: 0644]
resources/fanart.jpg [new file with mode: 0644]
resources/icon.png [new file with mode: 0644]
resources/language/English/strings.po [new file with mode: 0644]
resources/language/German/strings.po [new file with mode: 0644]
resources/lib/KodiHelper.py [new file with mode: 0644]
resources/lib/Library.py [new file with mode: 0644]
resources/lib/MSL.py [new file with mode: 0644]
resources/lib/MSLHttpRequestHandler.py [new file with mode: 0644]
resources/lib/Navigation.py [new file with mode: 0644]
resources/lib/NetflixSession.py [new file with mode: 0644]
resources/lib/__init__.py [new file with mode: 0644]
resources/lib/common.py [new file with mode: 0644]
resources/lib/utils.py [new file with mode: 0644]
resources/screenshot-01.jpg [new file with mode: 0644]
resources/screenshot-02.jpg [new file with mode: 0644]
resources/screenshot-03.jpg [new file with mode: 0644]
resources/settings.xml [new file with mode: 0644]
service.py [new file with mode: 0644]

diff --git a/.gitignore b/.gitignore
new file mode 100644 (file)
index 0000000..e43b0f9
--- /dev/null
@@ -0,0 +1 @@
+.DS_Store
diff --git a/README.md b/README.md
new file mode 100644 (file)
index 0000000..bc69919
--- /dev/null
+++ b/README.md
@@ -0,0 +1,29 @@
+# plugin.video.netflix
+
+Einstellungen:
+--------------
+
+  - E-Mail/Passwort: Logindaten des Netflix-Accounts
+  - Verbose logging an/aus
+  - Logout (löscht gespeicherte Cookies & Benutzerdaten)
+  - Library Einstellugnen (noch nicht genutzt)
+  - SSL Einstellungen (noch nicht genutzt)
+
+Funktionen:
+-----------
+
+  - Wechsel zwischen Profilen
+  - Suche nach Filmen/Serien (Nicht nach Schauspielern, Genres)
+  - Anzeigen von Kategorien wie "Meine Liste", "Weil Sie x gesehen haben", "Mit dem Profil von x weiterschauen", "Originals" etc.
+  - Eingabe von "Adult Pin" bei FSK 18 Filmen/Serien
+  - Rating aus Kodi in Netflix  
+  - Einträge auf Netflix in Watchlist (Meine Liste) hinzufügen/entfernen
+
+ToDo:
+-----
+
+  - Export in lokale Library
+  - Suche nach Schauspielern, Genres ermöglichen
+  - Fehlende Daten (Cast, bookmark position, etc.) den Show items in der Serien Video Liste hinzufügen
+  - Mit dem Profil von "x" weiterschauen - Statt der Liste der Shows direkt die Episode mit Bookmark anzeigen
+  - Sorting erweitern
diff --git a/__init__.py b/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/addon.py b/addon.py
new file mode 100644 (file)
index 0000000..6ac0874
--- /dev/null
+++ b/addon.py
@@ -0,0 +1,54 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+# Module: default
+# Created on: 13.01.2017
+
+# import local classes
+if __package__ is None:
+    import sys
+    import os
+    sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+    from resources.lib.NetflixSession import NetflixSession
+    from resources.lib.KodiHelper import KodiHelper
+    from resources.lib.Navigation import Navigation
+    from resources.lib.Library import Library
+else:
+    from .resources.lib.NetflixSession import NetflixSession
+    from .resources.lib.KodiHelper import KodiHelper
+    from .resources.lib.Navigation import Navigation
+    from .resources.lib.Library import Library
+
+# Setup plugin
+plugin_handle = int(sys.argv[1])
+base_url = sys.argv[0]
+
+# init plugin libs
+kodi_helper = KodiHelper(
+    plugin_handle=plugin_handle,
+    base_url=base_url
+)
+netflix_session = NetflixSession(
+    cookie_path=kodi_helper.cookie_path,
+    data_path=kodi_helper.data_path,
+    log_fn=kodi_helper.log
+)
+library = Library(
+    base_url=base_url,
+    #root_folder=kodi_helper.base_data_path,
+    root_folder='/Users/asciidisco/Desktop/lib',
+    library_settings=kodi_helper.get_custom_library_settings(),
+    log_fn=kodi_helper.log
+)
+navigation = Navigation(
+    netflix_session=netflix_session,
+    kodi_helper=kodi_helper,
+    library=library,
+    base_url=base_url,
+    log_fn=kodi_helper.log
+)
+kodi_helper.set_library(library=library)
+
+if __name__ == '__main__':
+    # Call the router function and pass the plugin call parameters to it.
+    # We use string slicing to trim the leading '?' from the plugin call paramstring
+    navigation.router(paramstring=sys.argv[2][1:])
diff --git a/addon.xml b/addon.xml
new file mode 100644 (file)
index 0000000..140b595
--- /dev/null
+++ b/addon.xml
@@ -0,0 +1,34 @@
+<?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>
diff --git a/resources/__init__.py b/resources/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/resources/fanart.jpg b/resources/fanart.jpg
new file mode 100644 (file)
index 0000000..099eaa8
Binary files /dev/null and b/resources/fanart.jpg differ
diff --git a/resources/icon.png b/resources/icon.png
new file mode 100644 (file)
index 0000000..4b47f9d
Binary files /dev/null and b/resources/icon.png differ
diff --git a/resources/language/English/strings.po b/resources/language/English/strings.po
new file mode 100644 (file)
index 0000000..1005031
--- /dev/null
@@ -0,0 +1,150 @@
+# Kodi Media Center language file
+# Addon Name: Netflix
+# Addon id: plugin.video.netflix
+# Addon version: 0.2.0
+# Addon Provider: tba.
+msgid ""
+msgstr ""
+"Project-Id-Version: XBMC-Addons\n"
+"Report-Msgid-Bugs-To: alanwww1@xbmc.org\n"
+"POT-Creation-Date: 2017-01-01 12:00+0000\n"
+"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
+"Last-Translator: FULL NAME <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 ""
diff --git a/resources/language/German/strings.po b/resources/language/German/strings.po
new file mode 100644 (file)
index 0000000..671cc86
--- /dev/null
@@ -0,0 +1,150 @@
+# Kodi Media Center language file
+# Addon Name: Netflix
+# Addon id: plugin.video.netflix
+# Addon version: 0.2.0
+# Addon Provider: tba.
+msgid ""
+msgstr ""
+"Project-Id-Version: XBMC-Addons\n"
+"Report-Msgid-Bugs-To: alanwww1@xbmc.org\n"
+"POT-Creation-Date: 2017-01-01 12:00+0000\n"
+"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
+"Last-Translator: FULL NAME <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"
diff --git a/resources/lib/KodiHelper.py b/resources/lib/KodiHelper.py
new file mode 100644 (file)
index 0000000..ff4e836
--- /dev/null
@@ -0,0 +1,785 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+# Module: KodiHelper
+# Created on: 13.01.2017
+
+import os
+import xbmcplugin
+import xbmcgui
+import xbmcaddon
+import xbmc
+import json
+
+class KodiHelper:
+    """Consumes all the configuration data from Kodi as well as turns data into lists of folders and videos"""
+
+    msl_service_server_certificate = 'Cr0CCAMSEOVEukALwQ8307Y2+LVP+0MYh/HPkwUijgIwggEKAoIBAQDm875btoWUbGqQD8eAGuBlGY+Pxo8YF1LQR+Ex0pDONMet8EHslcZRBKNQ/09RZFTP0vrYimyYiBmk9GG+S0wB3CRITgweNE15cD33MQYyS3zpBd4z+sCJam2+jj1ZA4uijE2dxGC+gRBRnw9WoPyw7D8RuhGSJ95OEtzg3Ho+mEsxuE5xg9LM4+Zuro/9msz2bFgJUjQUVHo5j+k4qLWu4ObugFmc9DLIAohL58UR5k0XnvizulOHbMMxdzna9lwTw/4SALadEV/CZXBmswUtBgATDKNqjXwokohncpdsWSauH6vfS6FXwizQoZJ9TdjSGC60rUB2t+aYDm74cIuxAgMBAAE6EHRlc3QubmV0ZmxpeC5jb20SgAOE0y8yWw2Win6M2/bw7+aqVuQPwzS/YG5ySYvwCGQd0Dltr3hpik98WijUODUr6PxMn1ZYXOLo3eED6xYGM7Riza8XskRdCfF8xjj7L7/THPbixyn4mULsttSmWFhexzXnSeKqQHuoKmerqu0nu39iW3pcxDV/K7E6aaSr5ID0SCi7KRcL9BCUCz1g9c43sNj46BhMCWJSm0mx1XFDcoKZWhpj5FAgU4Q4e6f+S8eX39nf6D6SJRb4ap7Znzn7preIvmS93xWjm75I6UBVQGo6pn4qWNCgLYlGGCQCUm5tg566j+/g5jvYZkTJvbiZFwtjMW5njbSRwB3W4CrKoyxw4qsJNSaZRTKAvSjTKdqVDXV/U5HK7SaBA6iJ981/aforXbd2vZlRXO/2S+Maa2mHULzsD+S5l4/YGpSt7PnkCe25F+nAovtl/ogZgjMeEdFyd/9YMYjOS4krYmwp3yJ7m9ZzYCQ6I8RQN4x/yLlHG5RH/+WNLNUs6JAZ0fFdCmw='
+    """str: MSL service certificate"""
+
+    msl_service_server_url = 'http://localhost:%PORT%'
+    """str: MSL service url"""
+
+    msl_service_port = 8000
+    """str: MSL service port (TODO: Make it dynamic)"""
+
+    def __init__ (self, plugin_handle, base_url):
+        """Fetches all needed info from Kodi & configures the baseline of the plugin
+
+        Parameters
+        ----------
+        plugin_handle : :obj:`int`
+            Plugin handle
+
+        base_url : :obj:`str`
+            Plugin base url
+        """
+        self.plugin_handle = plugin_handle
+        self.base_url = base_url
+        self.addon = xbmcaddon.Addon()
+        self.plugin = self.addon.getAddonInfo('name')
+        self.base_data_path = xbmc.translatePath(self.addon.getAddonInfo('profile'))
+        self.home_path = xbmc.translatePath('special://home')
+        self.plugin_path = self.addon.getAddonInfo('path')
+        self.cookie_path = self.base_data_path + 'COOKIE'
+        self.data_path = self.base_data_path + 'DATA'
+        self.config_path = os.path.join(self.base_data_path, 'config')
+        self.verb_log = self.addon.getSetting('logging') == 'true'
+        self.default_fanart = self.addon.getAddonInfo('fanart')
+        self.win = xbmcgui.Window(xbmcgui.getCurrentWindowId())
+        self.library = None
+
+    def refresh (self):
+        """Refrsh the current list"""
+        return xbmc.executebuiltin('Container.Refresh')
+
+    def show_rating_dialog (self):
+        """Asks the user for a movie rating
+
+        Returns
+        -------
+        :obj:`int`
+            Movie rating between 0 & 10
+        """
+        dlg = xbmcgui.Dialog()
+        return dlg.numeric(heading=self.get_local_string(string_id=30019) + ' ' + self.get_local_string(string_id=30022), type=0)
+
+    def show_adult_pin_dialog (self):
+        """Asks the user for the adult pin
+
+        Returns
+        -------
+        :obj:`int`
+            4 digit adult pin needed for adult movies
+        """
+        dlg = xbmcgui.Dialog()
+        return dlg.input(self.get_local_string(string_id=30002), type=xbmcgui.INPUT_NUMERIC)
+
+    def show_search_term_dialog (self):
+        """Asks the user for a term to query the netflix search for
+
+        Returns
+        -------
+        term : :obj:`str`
+            Term to search for
+        """
+        dlg = xbmcgui.Dialog()
+        return dlg.input(self.get_local_string(string_id=30003), type=xbmcgui.INPUT_ALPHANUM)
+
+    def show_password_dialog (self):
+        """Asks the user for its Netflix password
+
+        Returns
+        -------
+        :obj:`str`
+            Netflix password
+        """
+        dlg = xbmcgui.Dialog()
+        return dlg.input(self.get_local_string(string_id=30004), type=xbmcgui.INPUT_ALPHANUM)
+
+    def show_email_dialog (self):
+        """Asks the user for its Netflix account email
+
+        Returns
+        -------
+        term : :obj:`str`
+            Netflix account email
+        """
+        dlg = xbmcgui.Dialog()
+        return dlg.input(self.get_local_string(string_id=30005), type=xbmcgui.INPUT_ALPHANUM)
+
+    def show_wrong_adult_pin_notification (self):
+        """Shows notification that a wrong adult pin was given
+
+        Returns
+        -------
+        bool
+            Dialog shown
+        """
+        dialog = xbmcgui.Dialog()
+        dialog.notification(self.get_local_string(string_id=30006), self.get_local_string(string_id=30007), xbmcgui.NOTIFICATION_ERROR, 5000)
+        return True
+
+    def show_login_failed_notification (self):
+        """Shows notification that the login failed
+
+        Returns
+        -------
+        bool
+            Dialog shown
+        """
+        dialog = xbmcgui.Dialog()
+        dialog.notification(self.get_local_string(string_id=30008), self.get_local_string(string_id=30009), xbmcgui.NOTIFICATION_ERROR, 5000)
+        return True
+
+    def show_missing_inputstream_addon_notification (self):
+        """Shows notification that the inputstream addon couldn't be found
+
+        Returns
+        -------
+        bool
+            Dialog shown
+        """
+        dialog = xbmcgui.Dialog()
+        dialog.notification(self.get_local_string(string_id=30028), self.get_local_string(string_id=30029), xbmcgui.NOTIFICATION_ERROR, 5000)
+        return True
+
+    def set_setting (key, value):
+        """Public interface for the addons setSetting method
+
+        Returns
+        -------
+        bool
+            Setting could be set or not
+        """
+        return self.addon.setSetting(key, value)
+
+    def get_credentials (self):
+        """Returns the users stored credentials
+
+        Returns
+        -------
+        :obj:`dict` of :obj:`str`
+            The users stored account data
+        """
+        return {
+            'email': self.addon.getSetting('email'),
+            'password': self.addon.getSetting('password')
+        }
+
+    def get_custom_library_settings (self):
+        """Returns the settings in regards to the custom library folder(s)
+
+        Returns
+        -------
+        :obj:`dict` of :obj:`str`
+            The users library settings
+        """
+        return {
+            'enablelibraryfolder': self.addon.getSetting('enablelibraryfolder'),
+            'customlibraryfolder': self.addon.getSetting('customlibraryfolder')
+        }
+
+    def set_main_menu_selection (self, type):
+        self.win.setProperty('main_menu_selection', type)
+
+    def get_main_menu_selection (self):
+        return self.win.getProperty('main_menu_selection')
+
+    def build_profiles_listing (self, profiles, action, build_url):
+        """Builds the profiles list Kodi screen
+
+        Parameters
+        ----------
+        profiles : :obj:`dict` of :obj:`str`
+            List of user profiles
+
+        action : :obj:`str`
+            Action paramter to build the subsequent routes
+
+        build_url : :obj:`fn`
+            Function to build the subsequent routes
+
+        Returns
+        -------
+        bool
+            List could be build
+        """
+        for profile_id in profiles:
+            profile = profiles[profile_id]
+            url = build_url({'action': action, 'profile_id': profile_id})
+            li = xbmcgui.ListItem(label=profile['profileName'], iconImage=profile['avatar'])
+            li.setProperty('fanart_image', self.default_fanart)
+            xbmcplugin.addDirectoryItem(handle=self.plugin_handle, url=url, listitem=li, isFolder=True)
+            xbmcplugin.addSortMethod(handle=self.plugin_handle, sortMethod=xbmcplugin.SORT_METHOD_LABEL)
+        xbmcplugin.endOfDirectory(self.plugin_handle)
+        return True
+
+    def build_main_menu_listing (self, video_list_ids, user_list_order, actions, build_url):
+        """Builds the video lists (my list, continue watching, etc.) Kodi screen
+
+        Parameters
+        ----------
+        video_list_ids : :obj:`dict` of :obj:`str`
+            List of video lists
+
+        user_list_order : :obj:`list` of :obj:`str`
+            Ordered user lists, to determine what should be displayed in the main menue
+
+        actions : :obj:`dict` of :obj:`str`
+            Dictionary of actions to build subsequent routes
+
+        build_url : :obj:`fn`
+            Function to build the subsequent routes
+
+        Returns
+        -------
+        bool
+            List could be build
+        """
+        preselect_items = []
+        for category in user_list_order:
+            for video_list_id in video_list_ids['user']:
+                if video_list_ids['user'][video_list_id]['name'] == category:
+                    label = video_list_ids['user'][video_list_id]['displayName']
+                    if category == 'netflixOriginals':
+                        label = label.capitalize()
+                    li = xbmcgui.ListItem(label=label)
+                    li.setProperty('fanart_image', self.default_fanart)
+                    # determine action route
+                    action = actions['default']
+                    if category in actions.keys():
+                        action = actions[category]
+                    # determine if the item should be selected
+                    preselect_items.append((False, True)[category == self.get_main_menu_selection()])
+                    url = build_url({'action': action, 'video_list_id': video_list_id, 'type': category})
+                    xbmcplugin.addDirectoryItem(handle=self.plugin_handle, url=url, listitem=li, isFolder=True)
+
+        # add recommendations/genres as subfolders (save us some space on the home page)
+        i18n_ids = {
+            'recommendations': self.get_local_string(30001),
+            'genres': self.get_local_string(30010)
+        }
+        for type in i18n_ids.keys():
+            # determine if the lists have contents
+            if len(video_list_ids[type]) > 0:
+                # determine action route
+                action = actions['default']
+                if type in actions.keys():
+                    action = actions[type]
+                # determine if the item should be selected
+                preselect_items.append((False, True)[type == self.get_main_menu_selection()])
+                li_rec = xbmcgui.ListItem(label=i18n_ids[type])
+                li_rec.setProperty('fanart_image', self.default_fanart)
+                url_rec = build_url({'action': action, 'type': type})
+                xbmcplugin.addDirectoryItem(handle=self.plugin_handle, url=url_rec, listitem=li_rec, isFolder=True)
+
+        # add search as subfolder
+        action = actions['default']
+        if 'search' in actions.keys():
+            action = actions[type]
+        li_rec = xbmcgui.ListItem(label=self.get_local_string(30011))
+        li_rec.setProperty('fanart_image', self.default_fanart)
+        url_rec = build_url({'action': action, 'type': 'search'})
+        xbmcplugin.addDirectoryItem(handle=self.plugin_handle, url=url_rec, listitem=li_rec, isFolder=True)
+
+        # no srting & close
+        xbmcplugin.addSortMethod(handle=self.plugin_handle, sortMethod=xbmcplugin.SORT_METHOD_UNSORTED)
+        xbmcplugin.endOfDirectory(self.plugin_handle)
+
+        # (re)select the previously selected main menu entry
+        idx = 1
+        preselected_list_item = None
+        for item in preselect_items:
+            idx += 1
+            if item:
+                preselected_list_item = idx
+        if self.get_main_menu_selection() == 'search':
+            preselected_list_item = idx + 2
+        if preselected_list_item != None:
+            xbmc.executebuiltin('SetFocus(%s, %s)' % (self.win.getFocusId(), preselected_list_item))
+
+        return True
+
+    def build_video_listing (self, video_list, actions, type, build_url):
+        """Builds the video lists (my list, continue watching, etc.) contents Kodi screen
+
+        Parameters
+        ----------
+        video_list_ids : :obj:`dict` of :obj:`str`
+            List of video lists
+
+        actions : :obj:`dict` of :obj:`str`
+            Dictionary of actions to build subsequent routes
+
+        type : :obj:`str`
+            None or 'queue' f.e. when it´s a special video lists
+
+        build_url : :obj:`fn`
+            Function to build the subsequent routes
+
+        Returns
+        -------
+        bool
+            List could be build
+        """
+        for video_list_id in video_list:
+            video = video_list[video_list_id]
+            if type != 'queue' or (type == 'queue' and video['in_my_list'] == True):
+                li = xbmcgui.ListItem(label=video['title'])
+                # add some art to the item
+                li = self._generate_art_info(entry=video, li=li)
+                # it´s a show, so we need a subfolder & route (for seasons)
+                isFolder = True
+                url = build_url({'action': actions[video['type']], 'show_id': video_list_id})
+                # lists can be mixed with shows & movies, therefor we need to check if its a movie, so play it right away
+                if video_list[video_list_id]['type'] == 'movie':
+                    # it´s a movie, so we need no subfolder & a route to play it
+                    isFolder = False
+                    # check maturity index, to determine if we need the adult pin
+                    needs_pin = (True, False)[int(video['maturity']['level']) >= 1000]
+                    url = build_url({'action': 'play_video', 'video_id': video_list_id, 'pin': needs_pin})
+                # add list item info
+                li = self._generate_entry_info(entry=video, li=li)
+                li = self._generate_context_menu_items(entry=video, li=li)
+                xbmcplugin.addDirectoryItem(handle=self.plugin_handle, url=url, listitem=li, isFolder=isFolder)
+
+        xbmcplugin.addSortMethod(handle=self.plugin_handle, sortMethod=xbmcplugin.SORT_METHOD_LABEL)
+        xbmcplugin.endOfDirectory(self.plugin_handle)
+        return True
+
+    def build_search_result_listing (self, video_list, actions, build_url):
+        """Builds the search results list Kodi screen
+
+        Parameters
+        ----------
+        video_list : :obj:`dict` of :obj:`str`
+            List of videos or shows
+
+        actions : :obj:`dict` of :obj:`str`
+            Dictionary of actions to build subsequent routes
+
+        build_url : :obj:`fn`
+            Function to build the subsequent routes
+
+        Returns
+        -------
+        bool
+            List could be build
+        """
+        return self.build_video_listing(video_list=video_list, actions=actions, type='search', build_url=build_url)
+
+    def build_no_seasons_available (self):
+        """Builds the season list screen if no seasons could be found
+
+        Returns
+        -------
+        bool
+            List could be build
+        """
+        li = xbmcgui.ListItem(label=self.get_local_string(30012))
+        xbmcplugin.addDirectoryItem(handle=self.plugin_handle, url='', listitem=li, isFolder=False)
+        xbmcplugin.endOfDirectory(self.plugin_handle)
+        return True
+
+    def build_no_search_results_available (self, build_url, action):
+        """Builds the search results screen if no matches could be found
+
+        Parameters
+        ----------
+        action : :obj:`str`
+            Action paramter to build the subsequent routes
+
+        build_url : :obj:`fn`
+            Function to build the subsequent routes
+
+        Returns
+        -------
+        bool
+            List could be build
+        """
+        li = xbmcgui.ListItem(label=self.get_local_string(30013))
+        xbmcplugin.addDirectoryItem(handle=self.plugin_handle, url=build_url({'action': action}), listitem=li, isFolder=False)
+        xbmcplugin.endOfDirectory(self.plugin_handle)
+        return True
+
+    def build_user_sub_listing (self, video_list_ids, type, action, build_url):
+        """Builds the video lists screen for user subfolders (genres & recommendations)
+
+        Parameters
+        ----------
+        video_list_ids : :obj:`dict` of :obj:`str`
+            List of video lists
+
+        type : :obj:`str`
+            List type (genre or recommendation)
+
+        action : :obj:`str`
+            Action paramter to build the subsequent routes
+
+        build_url : :obj:`fn`
+            Function to build the subsequent routes
+
+        Returns
+        -------
+        bool
+            List could be build
+        """
+        for video_list_id in video_list_ids:
+            li = xbmcgui.ListItem(video_list_ids[video_list_id]['displayName'])
+            li.setProperty('fanart_image', self.default_fanart)
+            url = build_url({'action': action, 'video_list_id': video_list_id})
+            xbmcplugin.addDirectoryItem(handle=self.plugin_handle, url=url, listitem=li, isFolder=True)
+
+        xbmcplugin.addSortMethod(handle=self.plugin_handle, sortMethod=xbmcplugin.SORT_METHOD_LABEL)
+        xbmcplugin.endOfDirectory(self.plugin_handle)
+        return True
+
+    def build_season_listing (self, seasons_sorted, season_list, build_url):
+        """Builds the season list screen for a show
+
+        Parameters
+        ----------
+        seasons_sorted : :obj:`list` of :obj:`str`
+            Sorted season indexes
+
+        season_list : :obj:`dict` of :obj:`str`
+            List of season entries
+
+        build_url : :obj:`fn`
+            Function to build the subsequent routes
+
+        Returns
+        -------
+        bool
+            List could be build
+        """
+        for index in seasons_sorted:
+            for season_id in season_list:
+                season = season_list[season_id]
+                if int(season['shortName'].split(' ')[1]) == index:
+                    li = xbmcgui.ListItem(label=season['text'])
+                    # add some art to the item
+                    li = self._generate_art_info(entry=season, li=li)
+                    # add list item info
+                    li = self._generate_entry_info(entry=season, li=li, base_info={'mediatype': 'season'})
+                    li = self._generate_context_menu_items(entry=season, li=li)
+                    url = build_url({'action': 'episode_list', 'season_id': season_id})
+                    xbmcplugin.addDirectoryItem(handle=self.plugin_handle, url=url, listitem=li, isFolder=True)
+
+        xbmcplugin.addSortMethod(handle=self.plugin_handle, sortMethod=xbmcplugin.SORT_METHOD_NONE)
+        xbmcplugin.endOfDirectory(self.plugin_handle)
+        return True
+
+    def build_episode_listing (self, episodes_sorted, episode_list, build_url):
+        """Builds the episode list screen for a season of a show
+
+        Parameters
+        ----------
+        episodes_sorted : :obj:`list` of :obj:`str`
+            Sorted episode indexes
+
+        episode_list : :obj:`dict` of :obj:`str`
+            List of episode entries
+
+        build_url : :obj:`fn`
+            Function to build the subsequent routes
+
+        Returns
+        -------
+        bool
+            List could be build
+        """
+        for index in episodes_sorted:
+            for episode_id in episode_list:
+                episode = episode_list[episode_id]
+                if int(episode['episode']) == index:
+                    li = xbmcgui.ListItem(label=episode['title'])
+                    # add some art to the item
+                    li = self._generate_art_info(entry=episode, li=li)
+                    # add list item info
+                    li = self._generate_entry_info(entry=episode, li=li, base_info={'mediatype': 'episode'})
+                    li = self._generate_context_menu_items(entry=episode, li=li)
+                    # check maturity index, to determine if we need the adult pin
+                    needs_pin = (True, False)[int(episode['maturity']['rating']['maturityLevel']) >= 1000]
+                    url = build_url({'action': 'play_video', 'video_id': episode_id, 'pin': needs_pin, 'start_offset': episode['bookmark']})
+                    xbmcplugin.addDirectoryItem(handle=self.plugin_handle, url=url, listitem=li, isFolder=False)
+        xbmcplugin.addSortMethod(handle=self.plugin_handle, sortMethod=xbmcplugin.SORT_METHOD_EPISODE)
+        xbmcplugin.endOfDirectory(self.plugin_handle)
+        return True
+
+    def play_item (self, esn, video_id, start_offset=-1):
+        """Plays a video
+
+        Parameters
+        ----------
+        esn : :obj:`str`
+            ESN needed for Widevine/Inputstream
+
+        video_id : :obj:`str`
+            ID of the video that should be played
+
+        start_offset : :obj:`str`
+            Offset to resume playback from (in seconds)
+
+        Returns
+        -------
+        bool
+            List could be build
+        """
+        inputstream_addon = self.get_inputstream_addon()
+        if inputstream_addon == None:
+            self.show_missing_inputstream_addon_notification()
+            self.log(msg='Inputstream addon not found')
+            return False
+
+        # inputstream addon properties
+        msl_service_url = self.msl_service_server_url.replace('%PORT%', str(self.msl_service_port))
+        play_item = xbmcgui.ListItem(path=msl_service_url + '/manifest?id=' + video_id)
+        play_item.setProperty(inputstream_addon + '.license_type', 'com.widevine.alpha')
+        play_item.setProperty(inputstream_addon + '.manifest_type', 'mpd')
+        play_item.setProperty(inputstream_addon + '.license_key', msl_service_url + '/license?id=' + video_id + '||b{SSM}!b{SID}|')
+        play_item.setProperty(inputstream_addon + '.server_certificate', self.msl_service_server_certificate)
+        play_item.setProperty('inputstreamaddon', inputstream_addon)
+
+        # check if we have a bookmark e.g. start offset position
+        if int(start_offset) > 0:
+            play_item.setProperty('StartOffset', str(start_offset))
+        return xbmcplugin.setResolvedUrl(self.plugin_handle, True, listitem=play_item)
+
+    def _generate_art_info (self, entry, li):
+        """Adds the art info from an entry to a Kodi list item
+
+        Parameters
+        ----------
+        entry : :obj:`dict` of :obj:`str`
+            Entry that should be turned into a list item
+
+        li : :obj:`XMBC.ListItem`
+            Kodi list item instance
+
+        Returns
+        -------
+        :obj:`XMBC.ListItem`
+            Kodi list item instance
+        """
+        art = {'fanart': self.default_fanart}
+        if 'boxarts' in dict(entry).keys():
+            art.update({
+                'poster': entry['boxarts']['big'],
+                'landscape': entry['boxarts']['big'],
+                'thumb': entry['boxarts']['small'],
+                'fanart': entry['boxarts']['big']
+            })
+        if 'interesting_moment' in dict(entry).keys():
+            art.update({
+                'poster': entry['interesting_moment'],
+                'fanart': entry['interesting_moment']
+            })
+        if 'thumb' in dict(entry).keys():
+            art.update({'thumb': entry['thumb']})
+        if 'fanart' in dict(entry).keys():
+            art.update({'fanart': entry['fanart']})
+        if 'poster' in dict(entry).keys():
+            art.update({'poster': entry['poster']})
+        li.setArt(art)
+        return li
+
+    def _generate_entry_info (self, entry, li, base_info={}):
+        """Adds the item info from an entry to a Kodi list item
+
+        Parameters
+        ----------
+        entry : :obj:`dict` of :obj:`str`
+            Entry that should be turned into a list item
+
+        li : :obj:`XMBC.ListItem`
+            Kodi list item instance
+
+        base_info : :obj:`dict` of :obj:`str`
+            Additional info that overrules the entry info
+
+        Returns
+        -------
+        :obj:`XMBC.ListItem`
+            Kodi list item instance
+        """
+        infos = base_info
+        entry_keys = entry.keys()
+        if 'cast' in entry_keys and len(entry['cast']) > 0:
+            infos.update({'cast': entry['cast']})
+        if 'creators' in entry_keys and len(entry['creators']) > 0:
+            infos.update({'writer': entry['creators'][0]})
+        if 'directors' in entry_keys and len(entry['directors']) > 0:
+            infos.update({'director': entry['directors'][0]})
+        if 'genres' in entry_keys and len(entry['genres']) > 0:
+            infos.update({'genre': entry['genres'][0]})
+        if 'maturity' in entry_keys:
+            if 'mpaa' in entry_keys:
+                infos.update({'mpaa': entry['mpaa']})
+            else:
+                infos.update({'mpaa': str(entry['maturity']['board']) + '-' + str(entry['maturity']['value'])})
+        if 'rating' in entry_keys:
+            infos.update({'rating': int(entry['rating']) * 2})
+        if 'synopsis' in entry_keys:
+            infos.update({'plot': entry['synopsis']})
+        if 'plot' in entry_keys:
+            infos.update({'plot': entry['plot']})
+        if 'runtime' in entry_keys:
+            infos.update({'duration': entry['runtime']})
+        if 'duration' in entry_keys:
+            infos.update({'duration': entry['duration']})
+        if 'seasons_label' in entry_keys:
+            infos.update({'season': entry['seasons_label']})
+        if 'season' in entry_keys:
+            infos.update({'season': entry['season']})
+        if 'title' in entry_keys:
+            infos.update({'title': entry['title']})
+        if 'type' in entry_keys:
+            if entry['type'] == 'movie' or entry['type'] == 'episode':
+                li.setProperty('IsPlayable', 'true')
+        if 'mediatype' in entry_keys:
+            if entry['mediatype'] == 'movie' or entry['mediatype'] == 'episode':
+                li.setProperty('IsPlayable', 'true')
+                infos.update({'mediatype': entry['mediatype']})
+        if 'watched' in entry_keys:
+            infos.update({'playcount': (1, 0)[entry['watched']]})
+        if 'index' in entry_keys:
+            infos.update({'episode': entry['index']})
+        if 'episode' in entry_keys:
+            infos.update({'episode': entry['episode']})
+        if 'year' in entry_keys:
+            infos.update({'year': entry['year']})
+        if 'quality' in entry_keys:
+            quality = {'width': '960', 'height': '540'}
+            if entry['quality'] == '720':
+                quality = {'width': '1280', 'height': '720'}
+            if entry['quality'] == '1080':
+                quality = {'width': '1920', 'height': '1080'}
+            li.addStreamInfo('video', quality)
+        li.setInfo('video', infos)
+        return li
+
+    def _generate_context_menu_items (self, entry, li):
+        """Adds context menue items to a Kodi list item
+
+        Parameters
+        ----------
+        entry : :obj:`dict` of :obj:`str`
+            Entry that should be turned into a list item
+
+        li : :obj:`XMBC.ListItem`
+            Kodi list item instance
+        Returns
+        -------
+        :obj:`XMBC.ListItem`
+            Kodi list item instance
+        """
+        items = []
+        action = {}
+        entry_keys = entry.keys()
+
+        # action item templates
+        url_tmpl = 'XBMC.RunPlugin(' + self.base_url + '?action=%action%&id=' + str(entry['id']) + ')'
+        actions = [
+            ['export_to_library', self.get_local_string(30018), 'export'],
+            ['remove_from_library', self.get_local_string(30030), 'remove'],
+            ['rate_on_netflix', self.get_local_string(30019), 'rating'],
+            ['remove_from_my_list', self.get_local_string(30020), 'remove_from_list'],
+            ['add_to_my_list', self.get_local_string(30021), 'add_to_list']
+        ]
+
+        # build concrete action items
+        for action_item in actions:
+            action.update({action_item[0]: [action_item[1], url_tmpl.replace('%action%', action_item[2])]})
+
+        # add or remove the movie/show/season/episode from & to the users "My List"
+        if 'in_my_list' in entry_keys:
+            items.append(action['remove_from_my_list']) if entry['in_my_list'] else items.append(action['add_to_my_list'])
+        elif 'queue' in entry_keys:
+            items.append(action['remove_from_my_list']) if entry['queue'] else items.append(action['add_to_my_list'])
+        elif 'my_list' in entry_keys:
+            items.append(action['remove_from_my_list']) if entry['my_list'] else items.append(action['add_to_my_list'])
+        # rate the movie/show/season/episode on Netflix
+        items.append(action['rate_on_netflix'])
+
+        # add possibility to export this movie/show/season/episode to a static/local library (and to remove it)
+        # TODO: Not yet finished, still needs implementation
+        #if 'type' in entry_keys:
+            #items.append(action['export_to_library'])
+            #items.append(action['remove_from_library'])
+
+        # add it to the item
+        li.addContextMenuItems(items)
+        return li
+
+    def log (self, msg, level=xbmc.LOGNOTICE):
+        """Adds a log entry to the Kodi log
+
+        Parameters
+        ----------
+        msg : :obj:`str`
+            Entry that should be turned into a list item
+
+        level : :obj:`int`
+            Kodi log level
+        """
+        if level == xbmc.LOGDEBUG and self.verb_log:
+            level = xbmc.LOGNOTICE
+        if isinstance(msg, unicode):
+            msg = msg.encode('utf-8')
+        xbmc.log('[%s] %s' % (self.plugin, msg.__str__()), level)
+
+    def get_local_string (self, string_id):
+        """Returns the localized version of a string
+
+        Parameters
+        ----------
+        string_id : :obj:`int`
+            ID of the string that shoudl be fetched
+
+        Returns
+        -------
+        :obj:`str`
+            Requested string or empty string
+        """
+        src = xbmc if string_id < 30000 else self.addon
+        locString = src.getLocalizedString(string_id)
+        if isinstance(locString, unicode):
+            locString = locString.encode('utf-8')
+        return locString
+
+    def get_inputstream_addon (self):
+        """Checks if the inputstream addon is installed & enabled.
+           Returns the type of the inputstream addon used or None if not found
+
+        Returns
+        -------
+        :obj:`str` or None
+            Inputstream addon or None
+        """
+        type = 'inputstream.adaptive'
+        payload = {
+            'jsonrpc': '2.0',
+            'id': 1,
+            'method': 'Addons.GetAddonDetails',
+            'params': {
+                'addonid': type,
+                'properties': ['enabled']
+            }
+        }
+        response = xbmc.executeJSONRPC(json.dumps(payload))
+        data = json.loads(response)
+        if not 'error' in data.keys():
+            if data['result']['addon']['enabled'] == True:
+                return type
+        return None
+
+    def set_library (self, library):
+        """Adds an instance of the Library class
+
+        Parameters
+        ----------
+        library : :obj:`Library`
+            instance of the Library class
+        """
+        self.library = library
diff --git a/resources/lib/Library.py b/resources/lib/Library.py
new file mode 100644 (file)
index 0000000..e7e4f5b
--- /dev/null
@@ -0,0 +1,215 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+# Module: LibraryExporter
+# Created on: 13.01.2017
+
+import os
+import pickle
+from utils import noop
+
+class Library:
+    """Exports Netflix shows & movies to a local library folder (Not yet ready)"""
+
+    series_label = 'shows'
+    movies_label = 'movies'
+    db_filename = 'lib.ndb'
+
+
+    def __init__ (self, base_url, root_folder, library_settings, log_fn=noop):
+        """Takes the instances & configuration options needed to drive the plugin
+
+        Parameters
+        ----------
+        base_url : :obj:`str`
+            Plugin base url
+
+        root_folder : :obj:`str`
+            Cookie location
+
+        library_settings : :obj:`str`
+            User data cache location
+
+        library_db_path : :obj:`str`
+            User data cache location
+
+        log_fn : :obj:`fn`
+             optional log function
+        """
+        self.base_url = base_url
+        self.base_data_path = root_folder
+        self.enable_custom_library_folder = library_settings['enablelibraryfolder']
+        self.custom_library_folder = library_settings['customlibraryfolder']
+        self.log = log_fn
+        self.db_filepath = os.path.join(self.base_data_path, self.db_filename)
+
+        # check for local library folder & set up the paths
+        if self.enable_custom_library_folder != 'true':
+            self.movie_path = os.path.join(self.base_data_path, self.series_label)
+            self.tvshow_path = os.path.join(self.base_data_path, self.movies_label)
+        else:
+            self.movie_path = os.path.join(self.custom_library_folder, self.movies_label)
+            self.tvshow_path = os.path.join(self.custom_library_folder, self.series_label)
+
+        self.setup_local_netflix_library(source={
+            self.movies_label: self.movie_path,
+            self.series_label: self.tvshow_path
+        })
+
+        self.db = self._load_local_db(filename=self.db_filepath)
+
+    def setup_local_netflix_library (self, source):
+        for label in source:
+            if not os.path.exists(source[label]):
+                os.makedirs(source[label])
+
+    def write_strm_file(self, path, url):
+        with open(path, 'w+') as f:
+            f.write(url)
+            f.close()
+
+    def _load_local_db (self, filename):
+        # if the db doesn't exist, create it
+        if not os.path.isfile(filename):
+            data = {self.movies_label: {}, self.series_label: {}}
+            self.log('Setup local library DB')
+            self._update_local_db(filename=filename, data=data)
+            return data
+
+        with open(filename) as f:
+            data = pickle.load(f)
+            if data:
+                return data
+            else:
+                return {}
+
+    def _update_local_db (self, filename, data):
+        if not os.path.isdir(os.path.dirname(filename)):
+            return False
+        with open(filename, 'w') as f:
+            f.truncate()
+            pickle.dump(data, f)
+        return True
+
+    def movie_exists (self, title, year):
+        movie_meta = '%s (%d)' % (title, year)
+        return movie_meta in self.db[self.movies_label]
+
+    def show_exists (self, title, year):
+        show_meta = '%s (%d)' % (title, year)
+        return show_meta in self.db[self.series_label]
+
+    def season_exists (self, title, year, season):
+        if self.show_exists() == False:
+            return False
+        show_meta = '%s (%d)' % (title, year)
+        show_entry = self.db[self.series_label][show_meta]
+        return season in show_entry['seasons']
+
+    def episode_exists (self, title, year, season, episode):
+        if self.show_exists() == False:
+            return False
+        show_meta = '%s (%d)' % (title, year)
+        show_entry = self.db[self.series_label][show_meta]
+        episode_entry = 'S%02dE%02d' % (season, episode)
+        return episode_entry in show_entry['episodes']
+
+    def add_movie(self, title, year, video_id, pin, build_url):
+        movie_meta = '%s (%d)' % (title, year)
+        dirname = os.path.join(self.movie_path, movie_meta)
+        filename = os.path.join(dirname, movie_meta + '.strm')
+        if os.path.exists(filename):
+            return
+        if not os.path.exists(dirname):
+            os.makedirs(dirname)
+        if self.movie_exists(title=title, year=year) == False:
+            self.db[self.movies_label][movie_meta] = True
+            self._update_local_db(filename=self.db_filepath, db=self.db)
+        self.write_strm_file(path=filename, url=build_url({'action': 'play_video', 'video_id': video_id, 'pin': pin}))
+
+    def add_show(self, title, year, episodes, build_url):
+        show_meta = '%s (%d)' % (title, year)
+        show_dir = os.path.join(self.tvshow_path, show_meta)
+        if not os.path.exists(show_dir):
+            os.makedirs(show_dir)
+        if self.show_exists(title, year) == False:
+            self.db[self.series_label][show_meta] = {'seasons': [], 'episodes': []}
+        for episode_id in episodes:
+            episode = episodes[episode_id]
+            self._add_episode(show_dir=show_dir, show_meta=show_meta, title=title, year=year, season=episode['season'], episode=episode['idx'], video_id=episode['id'], pin=episode['pin'], build_url=build_url)
+        self._update_local_db(filename=self.db_filepath, db=self.db)
+        return show_dir
+
+    def _add_episode(self, title, year, show_dir, show_meta, season, episode, video_id, pin, build_url):
+        season = int(season)
+        episode = int(episode)
+
+        # add season
+        if self.season_exists(title=title, year=year, season=season) == False:
+            self.db[self.series_label][show_meta]['seasons'].append(season)
+
+        # add episode
+        episode_meta = 'S%02dE%02d' % (season, episode)
+        if self.episode_exists(title=title, year=year, season=season, episode=episode) == False:
+            self.db[self.series_label][show_meta]['episodes'].append(episode_meta)
+
+        # create strm file
+        filename = episode_meta + '.strm'
+        filepath = os.path.join(show_dir, filename)
+        if os.path.exists(filepath):
+            return
+        self.write_strm_file(path=filepath, url=build_url({'action': 'play_video', 'video_id': video_id, 'pin': pin}))
+
+    def remove_movie(self, title, year):
+        movie_meta = '%s (%d)' % (title, year)
+        del self.db[self.movies_label][movie_meta]
+        self._update_local_db(filename=self.db_filepath, db=self.db)
+        dirname = os.path.join(self.movie_path, movie_meta)
+        if os.path.exists(dirname):
+            os.rmtree(dirname)
+            return True
+        return False
+
+    def remove_show(self, title, year):
+        show_meta = '%s (%d)' % (title, year)
+        del self.db[self.series_label][show_meta]
+        self._update_local_db(filename=self.db_filepath, db=self.db)
+        show_dir = os.path.join(self.tvshow_path, show_meta)
+        if os.path.exists(show_dir):
+            os.rmtree(show_dir)
+            return True
+        return False
+
+    def remove_season(self, title, year, season):
+        season = int(season)
+        season_list = []
+        episodes_list = []
+        show_meta = '%s (%d)' % (title, year)
+        for season_entry in self.db[self.series_label][show_meta]['seasons']:
+            if season_entry != season:
+                season_list.append(season_entry)
+        self.db[self.series_label][show_meta]['seasons'] = season_list
+        show_dir = os.path.join(self.tvshow_path, show_meta)
+        if os.path.exists(show_dir):
+            show_files = [f for f in os.listdir(show_dir) if os.path.isfile(os.path.join(show_dir, f))]
+            for filename in show_files:
+                if 'S%02dE' % (season) in filename:
+                    os.remove(os.path.join(show_dir, filename))
+                else:
+                    episodes_list.append(filename.replace('.strm', ''))
+            self.db[self.series_label][show_meta]['episodes'] = episodes_list
+        self._update_local_db(filename=self.db_filepath, db=self.db)
+        return True
+
+    def remove_episode(self, title, year, season, episode):
+        episodes_list = []
+        show_meta = '%s (%d)' % (title, year)
+        episode_meta = 'S%02dE%02d' % (season, episode)
+        show_dir = os.path.join(self.tvshow_path, show_meta)
+        if os.path.exists(os.path.join(show_dir, episode_meta + '.strm')):
+            os.remove(os.path.join(show_dir, episode_meta + '.strm'))
+        for episode_entry in self.db[self.series_label][show_meta]['episodes']:
+            if episode_meta != episode_entry:
+                episodes_list.append(episode_entry)
+        self.db[self.series_label][show_meta]['episodes'] = episodes_list
+        self._update_local_db(filename=self.db_filepath, db=self.db)
+        return True
diff --git a/resources/lib/MSL.py b/resources/lib/MSL.py
new file mode 100644 (file)
index 0000000..58a25f5
--- /dev/null
@@ -0,0 +1,551 @@
+import base64
+import gzip
+import json
+import os
+import pprint
+import random
+from StringIO import StringIO
+from hmac import HMAC
+import hashlib
+import requests
+import zlib
+
+import time
+from Crypto.PublicKey import RSA
+from Crypto.Cipher import PKCS1_OAEP
+from Crypto.Cipher import AES
+from Crypto.Random import get_random_bytes
+# from Crypto.Hash import HMAC, SHA256
+from Crypto.Util import Padding
+import xml.etree.ElementTree as ET
+from lib import log
+
+pp = pprint.PrettyPrinter(indent=4)
+
+def base64key_decode(payload):
+    l = len(payload) % 4
+    if l == 2:
+        payload += '=='
+    elif l == 3:
+        payload += '='
+    elif l != 0:
+        raise ValueError('Invalid base64 string')
+    return base64.urlsafe_b64decode(payload.encode('utf-8'))
+
+
+class MSL:
+    handshake_performed = False  # Is a handshake already performed and the keys loaded
+    last_drm_context = ''
+    last_playback_context = ''
+    esn = "NFCDCH-LX-CQE0NU6PA5714R25VPLXVU2A193T36"
+    current_message_id = 0
+    session = requests.session()
+    rndm = random.SystemRandom()
+    tokens = []
+    endpoints = {
+        'manifest': 'http://www.netflix.com/api/msl/NFCDCH-LX/cadmium/manifest',
+        'license': 'http://www.netflix.com/api/msl/NFCDCH-LX/cadmium/license'
+    }
+
+    def __init__(self, email, password):
+        """
+        The Constructor checks for already existing crypto Keys.
+        If they exist it will load the existing keys
+        """
+        self.email = email
+        self.password = password
+
+        if self.file_exists('msl_data.json'):
+            self.__load_msl_data()
+            self.handshake_performed = True
+        elif self.file_exists('rsa_key.bin'):
+            log('RSA Keys do already exist load old ones')
+            self.__load_rsa_keys()
+            self.__perform_key_handshake()
+        else:
+            log('Create new RSA Keys')
+            # Create new Key Pair and save
+            self.rsa_key = RSA.generate(2048)
+            self.__save_rsa_keys()
+            self.__perform_key_handshake()
+
+    def load_manifest(self, viewable_id):
+        manifest_request_data = {
+            'method': 'manifest',
+            'lookupType': 'PREPARE',
+            'viewableIds': [viewable_id],
+            'profiles': [
+                'playready-h264mpl30-dash',
+                'playready-h264mpl31-dash',
+                'heaac-2-dash',
+                'dfxp-ls-sdh',
+                'simplesdh',
+                'nflx-cmisc',
+                'BIF240',
+                'BIF320'
+            ],
+            'drmSystem': 'widevine',
+            'appId': '14673889385265',
+            'sessionParams': {
+                'pinCapableClient': False,
+                'uiplaycontext': 'null'
+            },
+            'sessionId': '14673889385265',
+            'trackId': 0,
+            'flavor': 'PRE_FETCH',
+            'secureUrls': False,
+            'supportPreviewContent': True,
+            'forceClearStreams': False,
+            'languages': ['de-DE'],
+            'clientVersion': '4.0004.899.011',
+            'uiVersion': 'akira'
+        }
+        request_data = self.__generate_msl_request_data(manifest_request_data)
+
+        resp = self.session.post(self.endpoints['manifest'], request_data)
+
+
+        try:
+            resp.json()
+            log('MANIFEST RESPONE JSON: '+resp.text)
+        except ValueError:
+            # Maybe we have a CHUNKED response
+            resp = self.__parse_chunked_msl_response(resp.text)
+            data = self.__decrypt_payload_chunk(resp['payloads'][0])
+            # pprint.pprint(data)
+            return self.__tranform_to_dash(data)
+
+
+    def get_license(self, challenge, sid):
+
+        """
+            std::time_t t = std::time(0);  // t is an integer type
+    licenseRequestData["clientTime"] = (int)t;
+    //licenseRequestData["challengeBase64"] = challengeStr;
+    licenseRequestData["licenseType"] = "STANDARD";
+    licenseRequestData["playbackContextId"] = playbackContextId;//"E1-BQFRAAELEB32o6Se-GFvjwEIbvDydEtfj6zNzEC3qwfweEPAL3gTHHT2V8rS_u1Mc3mw5BWZrUlKYIu4aArdjN8z_Z8t62E5jRjLMdCKMsVhlSJpiQx0MNW4aGqkYz-1lPh85Quo4I_mxVBG5lgd166B5NDizA8.";
+    licenseRequestData["drmContextIds"] = Json::arrayValue;
+    licenseRequestData["drmContextIds"].append(drmContextId);
+
+        :param viewable_id:
+        :param challenge:
+        :param kid:
+        :return:
+        """
+
+        license_request_data = {
+            'method': 'license',
+            'licenseType': 'STANDARD',
+            'clientVersion': '4.0004.899.011',
+            'uiVersion': 'akira',
+            'languages': ['de-DE'],
+            'playbackContextId': self.last_playback_context,
+            'drmContextIds': [self.last_drm_context],
+            'challenges': [{
+                'dataBase64': challenge,
+                'sessionId': sid
+            }],
+            'clientTime': int(time.time()),
+            'xid': int((int(time.time()) + 0.1612) * 1000)
+
+        }
+        request_data = self.__generate_msl_request_data(license_request_data)
+
+        resp = self.session.post(self.endpoints['license'], request_data)
+
+        try:
+            resp.json()
+            log('LICENSE RESPONE JSON: '+resp.text)
+        except ValueError:
+            # Maybe we have a CHUNKED response
+            resp = self.__parse_chunked_msl_response(resp.text)
+            data = self.__decrypt_payload_chunk(resp['payloads'][0])
+            # pprint.pprint(data)
+            if data['success'] is True:
+                return data['result']['licenses'][0]['data']
+            else:
+                return ''
+
+
+    def __decrypt_payload_chunk(self, payloadchunk):
+        payloadchunk = json.JSONDecoder().decode(payloadchunk)
+        encryption_envelope = json.JSONDecoder().decode(base64.standard_b64decode(payloadchunk['payload']))
+        # Decrypt the text
+        cipher = AES.new(self.encryption_key, AES.MODE_CBC, base64.standard_b64decode(encryption_envelope['iv']))
+        plaintext = cipher.decrypt(base64.standard_b64decode(encryption_envelope['ciphertext']))
+        # unpad the plaintext
+        plaintext = json.JSONDecoder().decode(Padding.unpad(plaintext, 16))
+        data = plaintext['data']
+
+        # uncompress data if compressed
+        if plaintext['compressionalgo'] == 'GZIP':
+            data = zlib.decompress(base64.standard_b64decode(data), 16 + zlib.MAX_WBITS)
+        else:
+            data = base64.standard_b64decode(data)
+
+        data = json.JSONDecoder().decode(data)[1]['payload']['data']
+        data = base64.standard_b64decode(data)
+        return json.JSONDecoder().decode(data)
+
+
+    def __tranform_to_dash(self, manifest):
+
+        self.save_file('/home/johannes/manifest.json', json.dumps(manifest))
+        manifest = manifest['result']['viewables'][0]
+
+        self.last_playback_context = manifest['playbackContextId']
+        self.last_drm_context = manifest['drmContextId']
+
+        #Check for pssh
+        pssh = ''
+        if 'psshb64' in manifest:
+            if len(manifest['psshb64']) >= 1:
+                pssh = manifest['psshb64'][0]
+
+
+
+        root = ET.Element('MPD')
+        root.attrib['xmlns'] = 'urn:mpeg:dash:schema:mpd:2011'
+        root.attrib['xmlns:cenc'] = 'urn:mpeg:cenc:2013'
+
+
+        seconds = manifest['runtime']/1000
+        duration = "PT"+str(seconds)+".00S"
+
+        period = ET.SubElement(root, 'Period', start='PT0S', duration=duration)
+
+        # One Adaption Set for Video
+        for video_track in manifest['videoTracks']:
+            video_adaption_set = ET.SubElement(period, 'AdaptationSet', mimeType='video/mp4', contentType="video")
+            # Content Protection
+            protection = ET.SubElement(video_adaption_set, 'ContentProtection',
+                          schemeIdUri='urn:uuid:EDEF8BA9-79D6-4ACE-A3C8-27DCD51D21ED')
+            if pssh is not '':
+                ET.SubElement(protection, 'cenc:pssh').text = pssh
+
+            for downloadable in video_track['downloadables']:
+                rep = ET.SubElement(video_adaption_set, 'Representation',
+                                    width=str(downloadable['width']),
+                                    height=str(downloadable['height']),
+                                    bitrate=str(downloadable['bitrate']*8*1024),
+                                    mimeType='video/mp4')
+
+                #BaseURL
+                ET.SubElement(rep, 'BaseURL').text = self.__get_base_url(downloadable['urls'])
+                # Init an Segment block
+                segment_base = ET.SubElement(rep, 'SegmentBase', indexRange="0-60000", indexRangeExact="true")
+                ET.SubElement(segment_base, 'Initialization', range='0-60000')
+
+
+
+        # Multiple Adaption Set for audio
+        for audio_track in manifest['audioTracks']:
+            audio_adaption_set = ET.SubElement(period, 'AdaptationSet',
+                                               lang=audio_track['bcp47'],
+                                               contentType='audio',
+                                               mimeType='audio/mp4')
+            for downloadable in audio_track['downloadables']:
+                rep = ET.SubElement(audio_adaption_set, 'Representation',
+                                    codecs='aac',
+                                    bitrate=str(downloadable['bitrate'] * 8 * 1024),
+                                    mimeType='audio/mp4')
+
+                #AudioChannel Config
+                ET.SubElement(rep, 'AudioChannelConfiguration',
+                              schemeIdUri='urn:mpeg:dash:23003:3:audio_channel_configuration:2011',
+                              value=str(audio_track['channelsCount']))
+
+                #BaseURL
+                ET.SubElement(rep, 'BaseURL').text = self.__get_base_url(downloadable['urls'])
+                # Index range
+                segment_base = ET.SubElement(rep, 'SegmentBase', indexRange="0-60000", indexRangeExact="true")
+                ET.SubElement(segment_base, 'Initialization', range='0-60000')
+
+
+        xml = ET.tostring(root, encoding='utf-8', method='xml')
+        xml = xml.replace('\n', '').replace('\r', '')
+        return xml
+
+    def __get_base_url(self, urls):
+        for key in urls:
+            return urls[key]
+
+    def __parse_chunked_msl_response(self, message):
+        i = 0
+        opencount = 0
+        closecount = 0
+        header = ""
+        payloads = []
+        old_end = 0
+
+        while i < len(message):
+            if message[i] == '{':
+                opencount = opencount + 1
+            if message[i] == '}':
+                closecount = closecount + 1
+            if opencount == closecount:
+                if header == "":
+                    header = message[:i]
+                    old_end = i + 1
+                else:
+                    payloads.append(message[old_end:i + 1])
+            i += 1
+
+        return {
+            'header': header,
+            'payloads': payloads
+        }
+
+    def __generate_msl_request_data(self, data):
+        header_encryption_envelope = self.__encrypt(self.__generate_msl_header())
+        header = {
+            'headerdata': base64.standard_b64encode(header_encryption_envelope),
+            'signature': self.__sign(header_encryption_envelope),
+            'mastertoken': self.mastertoken,
+        }
+
+        # Serialize the given Data
+        serialized_data = json.dumps(data)
+        serialized_data = serialized_data.replace('"', '\\"')
+        serialized_data = '[{},{"headers":{},"path":"/cbp/cadmium-11","payload":{"data":"' + serialized_data + '"},"query":""}]\n'
+
+        compressed_data = self.__compress_data(serialized_data)
+
+        # Create FIRST Payload Chunks
+        first_payload = {
+            "messageid": self.current_message_id,
+            "data": compressed_data,
+            "compressionalgo": "GZIP",
+            "sequencenumber": 1,
+            "endofmsg": True
+        }
+        first_payload_encryption_envelope = self.__encrypt(json.dumps(first_payload))
+        first_payload_chunk = {
+            'payload': base64.standard_b64encode(first_payload_encryption_envelope),
+            'signature': self.__sign(first_payload_encryption_envelope),
+        }
+
+
+        # Create Second Payload
+        second_payload = {
+            "messageid": self.current_message_id,
+            "data": "",
+            "endofmsg": True,
+            "sequencenumber": 2
+        }
+        second_payload_encryption_envelope = self.__encrypt(json.dumps(second_payload))
+        second_payload_chunk = {
+            'payload': base64.standard_b64encode(second_payload_encryption_envelope),
+            'signature': base64.standard_b64encode(self.__sign(second_payload_encryption_envelope)),
+        }
+
+        request_data = json.dumps(header) + json.dumps(first_payload_chunk) # + json.dumps(second_payload_chunk)
+        return request_data
+
+
+
+    def __compress_data(self, data):
+        # GZIP THE DATA
+        out = StringIO()
+        with gzip.GzipFile(fileobj=out, mode="w") as f:
+            f.write(data)
+        return base64.standard_b64encode(out.getvalue())
+
+
+    def __generate_msl_header(self, is_handshake=False, is_key_request=False, compressionalgo="GZIP", encrypt=True):
+        """
+        Function that generates a MSL header dict
+        :return: The base64 encoded JSON String of the header
+        """
+        self.current_message_id = self.rndm.randint(0, pow(2, 52))
+
+        header_data = {
+            'sender': self.esn,
+            'handshake': is_handshake,
+            'nonreplayable': False,
+            'capabilities': {
+                'languages': ["en-US"],
+                'compressionalgos': []
+            },
+            'recipient': 'Netflix',
+            'renewable': True,
+            'messageid': self.current_message_id,
+            'timestamp': 1467733923
+        }
+
+        # Add compression algo if not empty
+        if compressionalgo is not "":
+            header_data['capabilities']['compressionalgos'].append(compressionalgo)
+
+        # If this is a keyrequest act diffrent then other requests
+        if is_key_request:
+            public_key = base64.standard_b64encode(self.rsa_key.publickey().exportKey(format='DER'))
+            header_data['keyrequestdata'] = [{
+                'scheme': 'ASYMMETRIC_WRAPPED',
+                'keydata': {
+                    'publickey': public_key,
+                    'mechanism': 'JWK_RSA',
+                    'keypairid': 'superKeyPair'
+                }
+            }]
+        else:
+            if 'usertoken' in self.tokens:
+                pass
+            else:
+                # Auth via email and password
+                header_data['userauthdata'] = {
+                    'scheme': 'EMAIL_PASSWORD',
+                    'authdata': {
+                        'email': self.email,
+                        'password': self.password
+                    }
+                }
+
+        return json.dumps(header_data)
+
+
+
+    def __encrypt(self, plaintext):
+        """
+        Encrypt the given Plaintext with the encryption key
+        :param plaintext:
+        :return: Serialized JSON String of the encryption Envelope
+        """
+        iv = get_random_bytes(16)
+        encryption_envelope = {
+            'ciphertext': '',
+            'keyid': self.esn + '_' + str(self.sequence_number),
+            'sha256': 'AA==',
+            'iv': base64.standard_b64encode(iv)
+        }
+        # Padd the plaintext
+        plaintext = Padding.pad(plaintext, 16)
+        # Encrypt the text
+        cipher = AES.new(self.encryption_key, AES.MODE_CBC, iv)
+        ciphertext = cipher.encrypt(plaintext)
+        encryption_envelope['ciphertext'] = base64.standard_b64encode(ciphertext)
+        return json.dumps(encryption_envelope)
+
+    def __sign(self, text):
+        #signature = hmac.new(self.sign_key, text, hashlib.sha256).digest()
+        signature = HMAC(self.sign_key, text, hashlib.sha256).digest()
+
+
+        # hmac = HMAC.new(self.sign_key, digestmod=SHA256)
+        # hmac.update(text)
+        return base64.standard_b64encode(signature)
+
+
+    def __perform_key_handshake(self):
+
+        header = self.__generate_msl_header(is_key_request=True, is_handshake=True, compressionalgo="", encrypt=False)
+        request = {
+            'entityauthdata': {
+                'scheme': 'NONE',
+                'authdata': {
+                    'identity': self.esn
+                }
+            },
+            'headerdata': base64.standard_b64encode(header),
+            'signature': '',
+        }
+        log('Key Handshake Request:')
+        log(json.dumps(request))
+
+
+        resp = self.session.post(self.endpoints['manifest'], json.dumps(request, sort_keys=True))
+        if resp.status_code == 200:
+            resp = resp.json()
+            if 'errordata' in resp:
+                log('Key Exchange failed')
+                log(base64.standard_b64decode(resp['errordata']))
+                return False
+            self.__parse_crypto_keys(json.JSONDecoder().decode(base64.standard_b64decode(resp['headerdata'])))
+        else:
+            log('Key Exchange failed')
+            log(resp.text)
+
+    def __parse_crypto_keys(self, headerdata):
+        self.__set_master_token(headerdata['keyresponsedata']['mastertoken'])
+        # Init Decryption
+        encrypted_encryption_key = base64.standard_b64decode(headerdata['keyresponsedata']['keydata']['encryptionkey'])
+        encrypted_sign_key = base64.standard_b64decode(headerdata['keyresponsedata']['keydata']['hmackey'])
+        cipher_rsa = PKCS1_OAEP.new(self.rsa_key)
+
+        # Decrypt encryption key
+        encryption_key_data = json.JSONDecoder().decode(cipher_rsa.decrypt(encrypted_encryption_key))
+        self.encryption_key = base64key_decode(encryption_key_data['k'])
+
+        # Decrypt sign key
+        sign_key_data = json.JSONDecoder().decode(cipher_rsa.decrypt(encrypted_sign_key))
+        self.sign_key = base64key_decode(sign_key_data['k'])
+
+        self.__save_msl_data()
+        self.handshake_performed = True
+
+    def __load_msl_data(self):
+        msl_data = json.JSONDecoder().decode(self.load_file('msl_data.json'))
+        self.__set_master_token(msl_data['tokens']['mastertoken'])
+        self.encryption_key = base64.standard_b64decode(msl_data['encryption_key'])
+        self.sign_key = base64.standard_b64decode(msl_data['sign_key'])
+
+    def __save_msl_data(self):
+        """
+        Saves the keys and tokens in json file
+        :return:
+        """
+        data = {
+            "encryption_key": base64.standard_b64encode(self.encryption_key),
+            'sign_key': base64.standard_b64encode(self.sign_key),
+            'tokens': {
+                'mastertoken': self.mastertoken
+            }
+        }
+        serialized_data = json.JSONEncoder().encode(data)
+        self.save_file('msl_data.json', serialized_data)
+
+    def __set_master_token(self, master_token):
+        self.mastertoken = master_token
+        self.sequence_number = json.JSONDecoder().decode(base64.standard_b64decode(master_token['tokendata']))[
+            'sequencenumber']
+
+    def __load_rsa_keys(self):
+        loaded_key = self.load_file('rsa_key.bin')
+        self.rsa_key = RSA.importKey(loaded_key)
+
+    def __save_rsa_keys(self):
+        log('Save RSA Keys')
+        # Get the DER Base64 of the keys
+        encrypted_key = self.rsa_key.exportKey()
+        self.save_file('rsa_key.bin', encrypted_key)
+
+    @staticmethod
+    def file_exists(filename):
+        """
+        Checks if a given file exists
+        :param filename: The filename
+        :return: True if so
+        """
+        return os.path.isfile(filename)
+
+    @staticmethod
+    def save_file(filename, content):
+        """
+        Saves the given content under given filename
+        :param filename: The filename
+        :param content: The content of the file
+        """
+        with open(filename, 'w') as file_:
+            file_.write(content)
+            file_.flush()
+
+    @staticmethod
+    def load_file(filename):
+        """
+        Loads the content of a given filename
+        :param filename: The file to load
+        :return: The content of the file
+        """
+        with open(filename) as file_:
+            file_content = file_.read()
+        return file_content
diff --git a/resources/lib/MSLHttpRequestHandler.py b/resources/lib/MSLHttpRequestHandler.py
new file mode 100644 (file)
index 0000000..4d7626b
--- /dev/null
@@ -0,0 +1,46 @@
+import BaseHTTPServer
+import base64
+from urlparse import urlparse, parse_qs
+
+from MSL import MSL
+from lib import ADDON
+email = ADDON.getSetting('email')
+password = ADDON.getSetting('password')
+msl = MSL(email, password)
+
+class MSLHttpRequestHandler(BaseHTTPServer.BaseHTTPRequestHandler):
+
+    def do_HEAD(self):
+        self.send_response(200)
+
+    def do_POST(self):
+        length = int(self.headers['content-length'])
+        post = self.rfile.read(length)
+        print post
+        data = post.split('!')
+        if len(data) is 2:
+            challenge = data[0]
+            sid = base64.standard_b64decode(data[1])
+            b64license = msl.get_license(challenge, sid)
+            if b64license is not '':
+                self.send_response(200)
+                self.end_headers()
+                self.wfile.write(base64.standard_b64decode(b64license))
+                self.finish()
+            else:
+                self.send_response(400)
+        else:
+            self.send_response(400)
+
+    def do_GET(self):
+        url = urlparse(self.path)
+        params = parse_qs(url.query)
+        if 'id' not in params:
+            self.send_response(400, 'No id')
+        else:
+            # Get the manifest with the given id
+            data = msl.load_manifest(int(params['id'][0]))
+            self.send_response(200)
+            self.send_header('Content-type', 'application/xml')
+            self.end_headers()
+            self.wfile.write(data)
diff --git a/resources/lib/Navigation.py b/resources/lib/Navigation.py
new file mode 100644 (file)
index 0000000..c1e9397
--- /dev/null
@@ -0,0 +1,473 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+# Module: Navigation
+# Created on: 13.01.2017
+
+import urllib
+import time
+from urlparse import parse_qsl
+from utils import noop
+from utils import log
+
+class Navigation:
+    """Routes to the correct subfolder, dispatches actions & acts as a controller for the Kodi view & the Netflix model"""
+
+    def __init__ (self, netflix_session, kodi_helper, library, base_url, log_fn=noop):
+        """Takes the instances & configuration options needed to drive the plugin
+
+        Parameters
+        ----------
+        netflix_session : :obj:`NetflixSession`
+            instance of the NetflixSession class
+
+        kodi_helper : :obj:`KodiHelper`
+            instance of the KodiHelper class
+
+        library : :obj:`Library`
+            instance of the Library class
+
+        base_url : :obj:`str`
+            plugin base url
+
+        log_fn : :obj:`fn`
+             optional log function
+        """
+        self.netflix_session = netflix_session
+        self.kodi_helper = kodi_helper
+        self.library = library
+        self.base_url = base_url
+        self.log = log_fn
+
+    @log
+    def router (self, paramstring):
+        """Route to the requested subfolder & dispatch actions along the way
+
+        Parameters
+        ----------
+        paramstring : :obj:`str`
+            Url query params
+        """
+        params = self.parse_paramters(paramstring=paramstring)
+
+        # log out the user
+        if 'action' in params.keys() and params['action'] == 'logout':
+            return self.netflix_session.logout()
+
+        # check login & try to relogin if necessary
+        account = self.kodi_helper.get_credentials()
+        if self.netflix_session.is_logged_in(account=account) != True:
+            if self.establish_session(account=account) != True:
+                return self.kodi_helper.show_login_failed_notification()
+
+        # check if we need to execute any actions before the actual routing
+        # gives back a dict of options routes might need
+        options = self.before_routing_action(params=params)
+
+        # check if one of the before routing options decided to killthe routing
+        if 'exit' in options:
+            return False
+        if 'action' not in params.keys():
+            # show the profiles
+            self.show_profiles()
+        elif params['action'] == 'video_lists':
+            # list lists that contain other lists (starting point with recommendations, search, etc.)
+            return self.show_video_lists()
+        elif params['action'] == 'video_list':
+            # show a list of shows/movies
+            type = None if 'type' not in params.keys() else params['type']
+            return self.show_video_list(video_list_id=params['video_list_id'], type=type)
+        elif params['action'] == 'season_list':
+            # list of seasons for a show
+            return self.show_seasons(show_id=params['show_id'])
+        elif params['action'] == 'episode_list':
+            # list of episodes for a season
+            return self.show_episode_list(season_id=params['season_id'])
+        elif params['action'] == 'rating':
+            return self.rate_on_netflix(video_id=params['id'])
+        elif params['action'] == 'remove_from_list':
+            # removes a title from the users list on Netflix
+            return self.remove_from_list(video_id=params['id'])
+        elif params['action'] == 'add_to_list':
+            # adds a title to the users list on Netflix
+            return self.add_to_list(video_id=params['id'])
+        elif params['action'] == 'user-items' and params['type'] != 'search':
+            # display the lists (recommendations, genres, etc.)
+            return self.show_user_list(type=params['type'])
+        elif params['action'] == 'play_video':
+            # play a video, check for adult pin if needed
+            adult_pin = None
+            if self.check_for_adult_pin(params=params):
+                adult_pin = self.kodi_helper.show_adult_pin_dialog()
+                if self.netflix_session.send_adult_pin(adult_pin=adult_pin) != True:
+                    return self.kodi_helper.show_wrong_adult_pin_notification()
+            self.play_video(video_id=params['video_id'], start_offset=params['start_offset'])
+        elif params['action'] == 'user-items' and params['type'] == 'search':
+            # if the user requested a search, ask for the term
+            term = self.kodi_helper.show_search_term_dialog()
+            return self.show_search_results(term=term)
+        else:
+            raise ValueError('Invalid paramstring: {0}!'.format(paramstring))
+        return True
+
+    @log
+    def play_video (self, video_id, start_offset):
+        """Starts video playback
+
+        Note: This is just a dummy, inputstream is needed to play the vids
+
+        Parameters
+        ----------
+        video_id : :obj:`str`
+            ID of the video that should be played
+
+        start_offset : :obj:`str`
+            Offset to resume playback from (in seconds)
+        """
+        # widevine esn
+        esn = self.netflix_session.esn
+        return self.kodi_helper.play_item(esn=esn, video_id=video_id, start_offset=start_offset)
+
+    def show_search_results (self, term):
+        """Display a list of search results
+
+        Parameters
+        ----------
+        term : :obj:`str`
+            String to lookup
+
+        Returns
+        -------
+        bool
+            If no results are available
+        """
+        has_search_results = False
+        search_results_raw = self.netflix_session.fetch_search_results(term=term)
+        # check for any errors
+        if self._is_dirty_response(response=search_results_raw):
+            return False
+
+        # determine if we found something
+        if 'search' in search_results_raw['value']:
+            for key in search_results_raw['value']['search'].keys():
+                if self.netflix_session._is_size_key(key=key) == False:
+                    has_search_results = search_results_raw['value']['search'][key]['titles']['length'] > 0
+
+        # display that we haven't found a thing
+        if has_search_results == False:
+            return self.kodi_helper.build_no_search_results_available(build_url=self.build_url, action='search')
+
+        # list the search results
+        search_results = self.netflix_session.parse_search_results(response_data=search_results_raw)
+        # add more menaingful data to the search results
+        raw_search_contents = self.netflix_session.fetch_video_list_information(video_ids=search_results.keys())
+        # check for any errors
+        if self._is_dirty_response(response=raw_search_contents):
+            return False
+        search_contents = self.netflix_session.parse_video_list(response_data=raw_search_contents)
+        actions = {'movie': 'play_video', 'show': 'season_list'}
+        return self.kodi_helper.build_search_result_listing(video_list=search_contents, actions=actions, build_url=self.build_url)
+
+    def show_user_list (self, type):
+        """List the users lists for shows/movies for recommendations/genres based on the given type
+
+        Parameters
+        ----------
+        user_list_id : :obj:`str`
+            Type of list to display
+        """
+        video_list_ids_raw = self.netflix_session.fetch_video_list_ids()
+        # check for any errors
+        if self._is_dirty_response(response=video_list_ids_raw):
+            return False
+        video_list_ids = self.netflix_session.parse_video_list_ids(response_data=video_list_ids_raw)
+        return self.kodi_helper.build_user_sub_listing(video_list_ids=video_list_ids[type], type=type, action='video_list', build_url=self.build_url)
+
+    def show_episode_list (self, season_id):
+        """Lists all episodes for a given season
+
+        Parameters
+        ----------
+        season_id : :obj:`str`
+            ID of the season episodes should be displayed for
+        """
+        raw_episode_list = self.netflix_session.fetch_episodes_by_season(season_id=season_id)
+        # check for any errors
+        if self._is_dirty_response(response=raw_episode_list):
+            return False
+        # parse the raw Netflix data
+        episode_list = self.netflix_session.parse_episodes_by_season(response_data=raw_episode_list)
+
+        # sort seasons by number (they´re coming back unsorted from the api)
+        episodes_sorted = []
+        for episode_id in episode_list:
+            episodes_sorted.append(int(episode_list[episode_id]['episode']))
+            episodes_sorted.sort()
+
+        # list the episodes
+        return self.kodi_helper.build_episode_listing(episodes_sorted=episodes_sorted, episode_list=episode_list, build_url=self.build_url)
+
+    def show_seasons (self, show_id):
+        """Lists all seasons for a given show
+
+        Parameters
+        ----------
+        show_id : :obj:`str`
+            ID of the show seasons should be displayed for
+
+        Returns
+        -------
+        bool
+            If no seasons are available
+        """
+        season_list_raw = self.netflix_session.fetch_seasons_for_show(id=show_id);
+        # check for any errors
+        if self._is_dirty_response(response=season_list_raw):
+            return False
+        # check if we have sesons, announced shows that are not available yet have none
+        if 'seasons' not in season_list_raw['value']:
+            return self.kodi_helper.build_no_seasons_available()
+        # parse the seasons raw response from Netflix
+        season_list = self.netflix_session.parse_seasons(id=show_id, response_data=season_list_raw)
+        # sort seasons by index by default (they´re coming back unsorted from the api)
+        seasons_sorted = []
+        for season_id in season_list:
+            seasons_sorted.append(int(season_list[season_id]['shortName'].split(' ')[1]))
+            seasons_sorted.sort()
+        return self.kodi_helper.build_season_listing(seasons_sorted=seasons_sorted, season_list=season_list, build_url=self.build_url)
+
+    def show_video_list (self, video_list_id, type):
+        """List shows/movies based on the given video list id
+
+        Parameters
+        ----------
+        video_list_id : :obj:`str`
+            ID of the video list that should be displayed
+
+        type : :obj:`str`
+            None or 'queue' f.e. when it´s a special video lists
+        """
+        raw_video_list = self.netflix_session.fetch_video_list(list_id=video_list_id)
+        # check for any errors
+        if self._is_dirty_response(response=raw_video_list):
+            return False
+        # parse the video list ids
+        video_list = self.netflix_session.parse_video_list(response_data=raw_video_list)
+        actions = {'movie': 'play_video', 'show': 'season_list'}
+        return self.kodi_helper.build_video_listing(video_list=video_list, actions=actions, type=type, build_url=self.build_url)
+
+    def show_video_lists (self):
+        """List the users video lists (recommendations, my list, etc.)"""
+        # fetch video lists
+        raw_video_list_ids = self.netflix_session.fetch_video_list_ids()
+        # check for any errors
+        if self._is_dirty_response(response=raw_video_list_ids):
+            return False
+        # parse the video list ids
+        video_list_ids = self.netflix_session.parse_video_list_ids(response_data=raw_video_list_ids)
+        # defines an order for the user list, as Netflix changes the order at every request
+        user_list_order = ['queue', 'continueWatching', 'topTen', 'netflixOriginals', 'trendingNow', 'newRelease', 'popularTitles']
+        # define where to route the user
+        actions = {'recommendations': 'user-items', 'genres': 'user-items', 'search': 'user-items', 'default': 'video_list'}
+        return self.kodi_helper.build_main_menu_listing(video_list_ids=video_list_ids, user_list_order=user_list_order, actions=actions, build_url=self.build_url)
+
+    def show_profiles (self):
+        """List the profiles for the active account"""
+        self.netflix_session.refresh_session_data(account=self.kodi_helper.get_credentials())
+        profiles = self.netflix_session.profiles
+        return self.kodi_helper.build_profiles_listing(profiles=profiles, action='video_lists', build_url=self.build_url)
+
+    @log
+    def rate_on_netflix (self, video_id):
+        """Rate a show/movie/season/episode on Netflix
+
+        Parameters
+        ----------
+        video_list_id : :obj:`str`
+            ID of the video list that should be displayed
+        """
+        rating = self.kodi_helper.show_rating_dialog()
+        return self.netflix_session.rate_video(video_id=video_id, rating=rating)
+
+    @log
+    def remove_from_list (self, video_id):
+        """Remove an item from 'My List' & refresh the view
+
+        Parameters
+        ----------
+        video_list_id : :obj:`str`
+            ID of the video list that should be displayed
+        """
+        self.netflix_session.remove_from_list(video_id=video_id)
+        return self.kodi_helper.refresh()
+
+    @log
+    def add_to_list (self, video_id):
+        """Add an item to 'My List' & refresh the view
+
+        Parameters
+        ----------
+        video_list_id : :obj:`str`
+            ID of the video list that should be displayed
+        """
+        self.netflix_session.add_to_list(video_id=video_id)
+        return self.kodi_helper.refresh()
+
+    @log
+    def establish_session(self, account):
+        """Checks if we have an cookie with an active sessions, otherwise tries to login the user
+
+        Parameters
+        ----------
+        account : :obj:`dict` of :obj:`str`
+            Dict containing an email & a password property
+
+        Returns
+        -------
+        bool
+            If we don't have an active session & the user couldn't be logged in
+        """
+        if self.netflix_session.is_logged_in(account=account):
+            return True
+        else:
+            return self.netflix_session.login(account=account)
+
+    @log
+    def before_routing_action (self, params):
+        """Executes actions before the actual routing takes place:
+
+            - Check if account data has been stored, if not, asks for it
+            - Check if the profile should be changed (and changes if so)
+            - Establishes a session if no action route is given
+
+        Parameters
+        ----------
+        params : :obj:`dict` of :obj:`str`
+            Url query params
+
+        Returns
+        -------
+        :obj:`dict` of :obj:`str`
+            Options that can be provided by this hook & used later in the routing process
+        """
+        options = {}
+        credentials = self.kodi_helper.get_credentials()
+        # check if we have user settings, if not, set em
+        if credentials['email'] == '':
+            email = self.kodi_helper.show_email_dialog()
+            self.kodi_helper.set_setting(key='email', value=email)
+        if credentials['password'] == '':
+            password = self.kodi_helper.show_password_dialog()
+            self.kodi_helper.set_setting(key='password', value=password)
+        # persist & load main menu selection
+        if 'type' in params:
+            self.kodi_helper.set_main_menu_selection(type=params['type'])
+        options['main_menu_selection'] = self.kodi_helper.get_main_menu_selection()
+        # check and switch the profile if needed
+        if self.check_for_designated_profile_change(params=params):
+            self.netflix_session.switch_profile(profile_id=params['profile_id'], account=credentials)
+        # check login, in case of main menu
+        if 'action' not in params:
+            self.establish_session(account=credentials)
+        return options
+
+    def check_for_designated_profile_change (self, params):
+        """Checks if the profile needs to be switched
+
+        Parameters
+        ----------
+        params : :obj:`dict` of :obj:`str`
+            Url query params
+
+        Returns
+        -------
+        bool
+            Profile should be switched or not
+        """
+        # check if we need to switch the user
+        if 'guid' not in self.netflix_session.user_data:
+            return False
+        current_profile_id = self.netflix_session.user_data['guid']
+        return 'profile_id' in params and current_profile_id != params['profile_id']
+
+    def check_for_adult_pin (self, params):
+        """Checks if an adult pin is given in the query params
+
+        Parameters
+        ----------
+        params : :obj:`dict` of :obj:`str`
+            Url query params
+
+        Returns
+        -------
+        bool
+            Adult pin parameter exists or not
+        """
+        return (True, False)[params['pin'] == 'True']
+
+    def parse_paramters (self, paramstring):
+        """Tiny helper to convert a url paramstring into a dictionary
+
+        Parameters
+        ----------
+        paramstring : :obj:`str`
+            Url query params (in url string notation)
+
+        Returns
+        -------
+        :obj:`dict` of :obj:`str`
+            Url query params (as a dictionary)
+        """
+        return dict(parse_qsl(paramstring))
+
+    def _is_expired_session (self, response):
+        """Checks if a response error is based on an invalid session
+
+        Parameters
+        ----------
+        response : :obj:`dict` of :obj:`str`
+            Error response object
+
+        Returns
+        -------
+        bool
+            Error is based on an invalid session
+        """
+        return 'error' in response and 'code' in response and str(response['code']) == '401'
+
+    def _is_dirty_response (self, response):
+        """Checks if a response contains an error & if the error is based on an invalid session, it tries a relogin
+
+        Parameters
+        ----------
+        response : :obj:`dict` of :obj:`str`
+            Success response object or Error response object
+
+        Returns
+        -------
+        bool
+            Response contains error or not
+        """
+        # check for any errors
+        if 'error' in response:
+            # check if we do not have a valid session, in case that happens: (re)login
+            if self._is_expired_session(response=response):
+                if self.establish_session(account=self.kodi_helper.get_credentials()):
+                    return True
+            self.log(msg='[ERROR]: ' + response['message'] + '::' + str(response['code']))
+            return True
+        return False
+
+    def build_url(self, query):
+        """Tiny helper to transform a dict into a url + querystring
+
+        Parameters
+        ----------
+        query : :obj:`dict` of  :obj:`str`
+            List of paramters to be url encoded
+
+        Returns
+        -------
+        str
+            Url + querystring based on the param
+        """
+        return self.base_url + '?' + urllib.urlencode(query)
diff --git a/resources/lib/NetflixSession.py b/resources/lib/NetflixSession.py
new file mode 100644 (file)
index 0000000..b2416c2
--- /dev/null
@@ -0,0 +1,2010 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+# Module: NetflixSession
+# Created on: 13.01.2017
+
+import sys
+import os
+import base64
+import time
+import urllib
+import json
+import requests
+import pickle
+from BeautifulSoup import BeautifulSoup
+from utils import strip_tags
+from utils import noop
+
+class NetflixSession:
+    """Helps with login/session management of Netflix users & API data fetching"""
+
+    base_url = 'https://www.netflix.com/'
+    """str: Secure Netflix url"""
+
+    urls = {
+        'login': '/login',
+        'browse': '/browse',
+        'video_list_ids': '/warmer',
+        'shakti': '/pathEvaluator',
+        'profiles':  '/profiles',
+        'switch_profiles': '/profiles/switch',
+        'adult_pin': '/pin/service',
+        'metadata': '/metadata',
+        'set_video_rating': '/setVideoRating',
+        'update_my_list': '/playlistop'
+    }
+    """:obj:`dict` of :obj:`str` List of all static endpoints for HTML/JSON POST/GET requests"""
+
+    video_list_keys = ['user', 'genres', 'recommendations']
+    """:obj:`list` of :obj:`str` Divide the users video lists into 3 different categories (for easier digestion)"""
+
+    profiles = {}
+    """:obj:`dict`
+        Dict of user profiles, user id is the key:
+
+        "72ERT45...": {
+            "profileName": "username",
+            "avatar": "http://..../avatar.png",
+            "id": "72ERT45...",
+            "isAccountOwner": False,
+            "isActive": True,
+            "isFirstUse": False
+        }
+    """
+
+    user_data = {}
+    """:obj:`dict`
+        dict of user data (used for authentication):
+
+        {
+            "guid": "72ERT45...",
+            "authURL": "145637....",
+            "countryOfSignup": "DE",
+            "emailAddress": "foo@..",
+            "gpsModel": "harris",
+            "isAdultVerified": True,
+            "isInFreeTrial": False,
+            "isKids": False,
+            "isTestAccount": False,
+            "numProfiles": 5,
+            "pinEnabled": True
+        }
+    """
+
+    api_data = {}
+    """:obj:`dict`
+        dict of api data (used to build up the api urls):
+
+        {
+            "API_BASE_URL": "/shakti",
+            "API_ROOT": "https://www.netflix.com/api",
+            "BUILD_IDENTIFIER": "113b89c9", "
+            ICHNAEA_ROOT": "/ichnaea"
+        }
+    """
+
+    esn = ''
+    """str: Widevine esn, something like: NFCDCH-MC-D7D6F54LOPY8J416T72MQXX3RD20ME"""
+
+    def __init__(self, cookie_path, data_path, log_fn=noop):
+        """Stores the cookie path for later use & instanciates a requests
+           session with a proper user agent & stored cookies/data if available
+
+        Parameters
+        ----------
+        cookie_path : :obj:`str`
+            Cookie location
+
+        data_path : :obj:`str`
+            User data cache location
+
+        log_fn : :obj:`fn`
+             optional log function
+        """
+        self.cookie_path = cookie_path
+        self.data_path = data_path
+        self.log = log_fn
+
+        # start session, fake chrome (so that we get a proper widevine esn) & enable gzip
+        self.session = requests.session()
+        self.session.headers.update({
+            'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/39.0.2171.95 Safari/537.36',
+            'Accept-Encoding': 'gzip, deflate'
+        })
+
+    def parse_login_form_fields (self, form_soup):
+        """Fetches all the inputfields from the login form, so that we
+           can build a request with all the fields needed besides the known email & password ones
+
+        Parameters
+        ----------
+        form_soup : :obj:`BeautifulSoup`
+            Instance of an BeautifulSoup documet or node containing the login form
+
+        Returns
+        -------
+            :obj:`dict` of :obj:`str`
+                Dictionary of all input fields with their name as the key & the default
+                value from the form field
+        """
+        login_input_fields = {}
+        login_inputs = form_soup.findAll('input')
+        # gather all form fields, set an empty string as the default value
+        for item in login_inputs:
+            keys = dict(item.attrs).keys()
+            if 'name' in keys and 'value' not in keys:
+                login_input_fields[item['name']] = ''
+            elif 'name' in keys and 'value' in keys:
+                login_input_fields[item['name']] = item['value']
+        return login_input_fields
+
+    def extract_inline_netflix_page_data (self, page_soup):
+        """Extracts all <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'])
diff --git a/resources/lib/__init__.py b/resources/lib/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/resources/lib/common.py b/resources/lib/common.py
new file mode 100644 (file)
index 0000000..161bcae
--- /dev/null
@@ -0,0 +1,18 @@
+import os
+import xbmc
+import xbmcaddon
+import xbmcgui
+import xbmcvfs
+
+ADDON = xbmcaddon.Addon()
+ADDONVERSION = ADDON.getAddonInfo('version')
+ADDONNAME = ADDON.getAddonInfo('name')
+ADDONPATH = ADDON.getAddonInfo('path').decode('utf-8')
+ADDONPROFILE = xbmc.translatePath( ADDON.getAddonInfo('profile') ).decode('utf-8')
+ICON = ADDON.getAddonInfo('icon')
+
+def log(txt):
+    if isinstance (txt,str):
+        txt = txt.decode("utf-8")
+    message = u'%s: %s' % ("service.msl", txt)
+    xbmc.log(msg=message.encode("utf-8"), level=xbmc.LOGDEBUG)
diff --git a/resources/lib/utils.py b/resources/lib/utils.py
new file mode 100644 (file)
index 0000000..25cd285
--- /dev/null
@@ -0,0 +1,46 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+# Module: utils
+# Created on: 13.01.2017
+
+# strips html from input
+# used the kick out the junk, when parsing the inline JS objects of the Netflix homepage
+from HTMLParser import HTMLParser
+class MLStripper(HTMLParser):
+    def __init__(self):
+        self.reset()
+        self.fed = []
+    def handle_data(self, d):
+        self.fed.append(d)
+    def get_data(self):
+        return ''.join(self.fed)
+
+def strip_tags(html):
+    s = MLStripper()
+    s.feed(html)
+    return s.get_data()
+
+# Takes everything, does nothing, classic no operation function
+def noop (**kwargs):
+    return True
+
+# log decorator
+def log(f, name=None):
+    if name is None:
+        name = f.func_name
+    def wrapped(*args, **kwargs):
+        that = args[0]
+        class_name = that.__class__.__name__
+        arguments = ''
+        for key, value in kwargs.iteritems():
+            if key != 'account' and key != 'credentials':
+                arguments += ":%s = %s:" % (key, value)
+        if arguments != '':
+            that.log('"' + class_name + '::' + name + '" called with arguments ' + arguments)
+        else:
+            that.log('"' + class_name + '::' + name + '" called')
+        result = f(*args, **kwargs)
+        that.log('"' + class_name + '::' + name + '" returned: ' + str(result))
+        return result
+    wrapped.__doc__ = f.__doc__
+    return wrapped
diff --git a/resources/screenshot-01.jpg b/resources/screenshot-01.jpg
new file mode 100644 (file)
index 0000000..9f1d398
Binary files /dev/null and b/resources/screenshot-01.jpg differ
diff --git a/resources/screenshot-02.jpg b/resources/screenshot-02.jpg
new file mode 100644 (file)
index 0000000..56c6596
Binary files /dev/null and b/resources/screenshot-02.jpg differ
diff --git a/resources/screenshot-03.jpg b/resources/screenshot-03.jpg
new file mode 100644 (file)
index 0000000..2ef950b
Binary files /dev/null and b/resources/screenshot-03.jpg differ
diff --git a/resources/settings.xml b/resources/settings.xml
new file mode 100644 (file)
index 0000000..3f9f9c0
--- /dev/null
@@ -0,0 +1,18 @@
+<?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>
diff --git a/service.py b/service.py
new file mode 100644 (file)
index 0000000..8600979
--- /dev/null
@@ -0,0 +1,28 @@
+import threading
+import SocketServer
+import xbmc
+from resources.lib.common import log
+from resources.lib.MSLHttpRequestHandler import MSLHttpRequestHandler
+
+PORT = 8000
+Handler = MSLHttpRequestHandler
+SocketServer.TCPServer.allow_reuse_address = True
+server = SocketServer.TCPServer(('127.0.0.1', PORT), Handler)
+server.server_activate()
+server.timeout = 1
+
+if __name__ == '__main__':
+    monitor = xbmc.Monitor()
+    thread = threading.Thread(target=server.serve_forever)
+    thread.daemon = True
+    thread.start()
+
+    while not monitor.abortRequested():
+        if monitor.waitForAbort(5):
+            server.shutdown()
+            break
+
+    server.server_close()
+    server.socket.close()
+    server.shutdown()
+    log("Stopped MSL Service")