From b0976bc1b439d881585d37a79e3ea60fbeefa306 Mon Sep 17 00:00:00 2001 From: Sebastian Golasch Date: Fri, 3 Feb 2017 17:07:44 +0100 Subject: [PATCH] feat(core): Adds library export for movies & shows, fixes for search, fixes for displaying empty lists, in memory caching, per setting defined ssl verification, update to bs4, moves msl port & certificate to settings --- addon.py | 2 +- addon.xml | 6 +- resources/language/English/strings.po | 4 + resources/language/German/strings.po | 4 + resources/lib/KodiHelper.py | 177 ++++++++++--- resources/lib/Library.py | 350 +++++++++++++++++++++----- resources/lib/Navigation.py | 163 +++++++++--- resources/lib/NetflixSession.py | 43 ++-- resources/settings.xml | 2 + 9 files changed, 603 insertions(+), 148 deletions(-) diff --git a/addon.py b/addon.py index 80355f3..9303b97 100644 --- a/addon.py +++ b/addon.py @@ -30,10 +30,10 @@ kodi_helper = KodiHelper( netflix_session = NetflixSession( cookie_path=kodi_helper.cookie_path, data_path=kodi_helper.data_path, + verify_ssl=kodi_helper.get_ssl_verification_setting(), log_fn=kodi_helper.log ) library = Library( - base_url=base_url, root_folder=kodi_helper.base_data_path, library_settings=kodi_helper.get_custom_library_settings(), log_fn=kodi_helper.log diff --git a/addon.xml b/addon.xml index 140b595..f797fef 100644 --- a/addon.xml +++ b/addon.xml @@ -1,12 +1,12 @@ - + - + - + video diff --git a/resources/language/English/strings.po b/resources/language/English/strings.po index 1005031..eb5e378 100644 --- a/resources/language/English/strings.po +++ b/resources/language/English/strings.po @@ -148,3 +148,7 @@ msgstr "" msgctxt "#30030" msgid "Remove from library" msgstr "" + +msgctxt "#30031" +msgid "Change library title" +msgstr "" diff --git a/resources/language/German/strings.po b/resources/language/German/strings.po index 671cc86..d82af9c 100644 --- a/resources/language/German/strings.po +++ b/resources/language/German/strings.po @@ -148,3 +148,7 @@ msgstr "Inputstream nicht gefunden" msgctxt "#30030" msgid "Remove from library" msgstr "Aus Bibliothek entfernen" + +msgctxt "#30031" +msgid "Change library title" +msgstr "Export titel ändern" diff --git a/resources/lib/KodiHelper.py b/resources/lib/KodiHelper.py index ff4e836..44d992b 100644 --- a/resources/lib/KodiHelper.py +++ b/resources/lib/KodiHelper.py @@ -4,24 +4,23 @@ # Created on: 13.01.2017 import os +import urllib import xbmcplugin import xbmcgui import xbmcaddon import xbmc import json +try: + import cPickle as pickle +except: + import pickle 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 @@ -47,6 +46,7 @@ class KodiHelper: self.default_fanart = self.addon.getAddonInfo('fanart') self.win = xbmcgui.Window(xbmcgui.getCurrentWindowId()) self.library = None + self.setup_memcache() def refresh (self): """Refrsh the current list""" @@ -79,11 +79,30 @@ class KodiHelper: Returns ------- - term : :obj:`str` + :obj:`str` Term to search for """ dlg = xbmcgui.Dialog() - return dlg.input(self.get_local_string(string_id=30003), type=xbmcgui.INPUT_ALPHANUM) + term = dlg.input(self.get_local_string(string_id=30003), type=xbmcgui.INPUT_ALPHANUM) + if len(term) == 0: + term = ' ' + return term + + def show_add_to_library_title_dialog (self, original_title): + """Asks the user for an alternative title for the show/movie that gets exported to the local library + + Parameters + ---------- + original_title : :obj:`str` + Original title of the show (as suggested by the addon) + + Returns + ------- + :obj:`str` + Title to persist + """ + dlg = xbmcgui.Dialog() + return dlg.input(heading=self.get_local_string(string_id=30031), defaultt=original_title, type=xbmcgui.INPUT_ALPHANUM) def show_password_dialog (self): """Asks the user for its Netflix password @@ -143,7 +162,7 @@ class KodiHelper: 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): + def set_setting (self, key, value): """Public interface for the addons setSetting method Returns @@ -179,12 +198,96 @@ class KodiHelper: 'customlibraryfolder': self.addon.getSetting('customlibraryfolder') } + def get_ssl_verification_setting (self): + """Returns the setting that describes if we should verify the ssl transport when loading data + + Returns + ------- + bool + Verify or not + """ + return self.addon.getSetting('ssl_verification') == 'true' + def set_main_menu_selection (self, type): + """Persist the chosen main menu entry in memory + + Parameters + ---------- + type : :obj:`str` + Selected menu item + """ self.win.setProperty('main_menu_selection', type) def get_main_menu_selection (self): + """Gets the persisted chosen main menu entry from memory + + Returns + ------- + :obj:`str` + The last chosen main menu entry + """ return self.win.getProperty('main_menu_selection') + def setup_memcache (self): + """Sets up the memory cache if not existant""" + cached_items = self.win.getProperty('memcache') + # no cache setup yet, create one + if len(cached_items) < 1: + self.win.setProperty('memcache', pickle.dumps({})) + + def invalidate_memcache (self): + """Invalidates the memory cache""" + self.win.setProperty('memcache', pickle.dumps({})) + + def has_cached_item (self, cache_id): + """Checks if the requested item is in memory cache + + Parameters + ---------- + cache_id : :obj:`str` + ID of the cache entry + + Returns + ------- + bool + Item is cached + """ + cached_items = pickle.loads(self.win.getProperty('memcache')) + return cache_id in cached_items.keys() + + def get_cached_item (self, cache_id): + """Returns an item from the in memory cache + + Parameters + ---------- + cache_id : :obj:`str` + ID of the cache entry + + Returns + ------- + mixed + Contents of the requested cache item or none + """ + cached_items = pickle.loads(self.win.getProperty('memcache')) + if self.has_cached_item(cache_id) != True: + return None + return cached_items[cache_id] + + def add_cached_item (self, cache_id, contents): + """Adds an item to the in memory cache + + Parameters + ---------- + cache_id : :obj:`str` + ID of the cache entry + + contents : mixed + Cache entry contents + """ + cached_items = pickle.loads(self.win.getProperty('memcache')) + cached_items.update({cache_id: contents}) + self.win.setProperty('memcache', pickle.dumps(cached_items)) + def build_profiles_listing (self, profiles, action, build_url): """Builds the profiles list Kodi screen @@ -288,16 +391,12 @@ class KodiHelper: # (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 + preselected_list_item = idx if item else None + preselected_list_item = idx + 1 if self.get_main_menu_selection() == 'search' else preselected_list_item if preselected_list_item != None: - xbmc.executebuiltin('SetFocus(%s, %s)' % (self.win.getFocusId(), preselected_list_item)) - + xbmc.executebuiltin('ActivateWindowAndFocus(%s, %s)' % (str(self.win.getFocusId()), str(preselected_list_item))) return True def build_video_listing (self, video_list, actions, type, build_url): @@ -344,6 +443,10 @@ class KodiHelper: xbmcplugin.addDirectoryItem(handle=self.plugin_handle, url=url, listitem=li, isFolder=isFolder) xbmcplugin.addSortMethod(handle=self.plugin_handle, sortMethod=xbmcplugin.SORT_METHOD_LABEL) + xbmcplugin.addSortMethod(handle=self.plugin_handle, sortMethod=xbmcplugin.SORT_METHOD_TITLE) + xbmcplugin.addSortMethod(handle=self.plugin_handle, sortMethod=xbmcplugin.SORT_METHOD_VIDEO_YEAR) + xbmcplugin.addSortMethod(handle=self.plugin_handle, sortMethod=xbmcplugin.SORT_METHOD_GENRE) + xbmcplugin.addSortMethod(handle=self.plugin_handle, sortMethod=xbmcplugin.SORT_METHOD_LASTPLAYED) xbmcplugin.endOfDirectory(self.plugin_handle) return True @@ -467,6 +570,10 @@ class KodiHelper: xbmcplugin.addDirectoryItem(handle=self.plugin_handle, url=url, listitem=li, isFolder=True) xbmcplugin.addSortMethod(handle=self.plugin_handle, sortMethod=xbmcplugin.SORT_METHOD_NONE) + xbmcplugin.addSortMethod(handle=self.plugin_handle, sortMethod=xbmcplugin.SORT_METHOD_VIDEO_YEAR) + xbmcplugin.addSortMethod(handle=self.plugin_handle, sortMethod=xbmcplugin.SORT_METHOD_LABEL) + xbmcplugin.addSortMethod(handle=self.plugin_handle, sortMethod=xbmcplugin.SORT_METHOD_LASTPLAYED) + xbmcplugin.addSortMethod(handle=self.plugin_handle, sortMethod=xbmcplugin.SORT_METHOD_TITLE) xbmcplugin.endOfDirectory(self.plugin_handle) return True @@ -503,7 +610,14 @@ class KodiHelper: 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.addSortMethod(handle=self.plugin_handle, sortMethod=xbmcplugin.SORT_METHOD_NONE) + xbmcplugin.addSortMethod(handle=self.plugin_handle, sortMethod=xbmcplugin.SORT_METHOD_VIDEO_YEAR) + xbmcplugin.addSortMethod(handle=self.plugin_handle, sortMethod=xbmcplugin.SORT_METHOD_LABEL) + xbmcplugin.addSortMethod(handle=self.plugin_handle, sortMethod=xbmcplugin.SORT_METHOD_LASTPLAYED) + xbmcplugin.addSortMethod(handle=self.plugin_handle, sortMethod=xbmcplugin.SORT_METHOD_TITLE) + xbmcplugin.addSortMethod(handle=self.plugin_handle, sortMethod=xbmcplugin.SORT_METHOD_DURATION) xbmcplugin.endOfDirectory(self.plugin_handle) return True @@ -533,12 +647,12 @@ class KodiHelper: return False # inputstream addon properties - msl_service_url = self.msl_service_server_url.replace('%PORT%', str(self.msl_service_port)) + msl_service_url = self.msl_service_server_url.replace('%PORT%', str(self.addon.getSetting('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(inputstream_addon + '.server_certificate', self.addon.getSetting('msl_service_certificate')) play_item.setProperty('inputstreamaddon', inputstream_addon) # check if we have a bookmark e.g. start offset position @@ -679,7 +793,8 @@ class KodiHelper: entry_keys = entry.keys() # action item templates - url_tmpl = 'XBMC.RunPlugin(' + self.base_url + '?action=%action%&id=' + str(entry['id']) + ')' + encoded_title = urllib.urlencode({'title': entry['title'].encode('utf-8')}) if 'title' in entry else '' + url_tmpl = 'XBMC.RunPlugin(' + self.base_url + '?action=%action%&id=' + str(entry['id']) + '&' + encoded_title + ')' actions = [ ['export_to_library', self.get_local_string(30018), 'export'], ['remove_from_library', self.get_local_string(30030), 'remove'], @@ -703,10 +818,15 @@ class KodiHelper: 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']) + if 'type' in entry_keys: + # add/remove movie + if entry['type'] == 'movie': + action_type = 'remove_from_library' if self.library.movie_exists(title=entry['title'], year=entry['year']) else 'export_to_library' + items.append(action[action_type]) + # add/remove show + if entry['type'] == 'show' and 'title' in entry_keys: + action_type = 'remove_from_library' if self.library.show_exists(title=entry['title']) else 'export_to_library' + items.append(action[action_type]) # add it to the item li.addContextMenuItems(items) @@ -723,11 +843,12 @@ class KodiHelper: 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) + if self.verb_log: + 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 diff --git a/resources/lib/Library.py b/resources/lib/Library.py index e7e4f5b..8c1d841 100644 --- a/resources/lib/Library.py +++ b/resources/lib/Library.py @@ -4,25 +4,30 @@ # Created on: 13.01.2017 import os -import pickle +import shutil +try: + import cPickle as pickle +except: + import pickle from utils import noop class Library: - """Exports Netflix shows & movies to a local library folder (Not yet ready)""" + """Exports Netflix shows & movies to a local library folder""" series_label = 'shows' + """str: Label to identify shows""" + movies_label = 'movies' - db_filename = 'lib.ndb' + """str: Label to identify movies""" + db_filename = 'lib.ndb' + """str: (File)Name of the store for the database dump that contains all shows/movies added to the library""" - def __init__ (self, base_url, root_folder, library_settings, log_fn=noop): + def __init__ (self, 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 @@ -35,44 +40,71 @@ class Library: 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) + self.log = log_fn # 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) + lib_path = self.base_data_path if self.enable_custom_library_folder != 'true' else self.custom_library_folder + self.movie_path = os.path.join(lib_path, self.movies_label) + self.tvshow_path = os.path.join(lib_path, self.series_label) + # check if we need to setup the base folder structure & do so if needed self.setup_local_netflix_library(source={ self.movies_label: self.movie_path, self.series_label: self.tvshow_path }) + # load the local db self.db = self._load_local_db(filename=self.db_filepath) def setup_local_netflix_library (self, source): + """Sets up the basic directories + + Parameters + ---------- + source : :obj:`dict` of :obj:`str` + Dicitionary with directories to be created + """ for label in source: if not os.path.exists(source[label]): os.makedirs(source[label]) def write_strm_file(self, path, url): + """Writes the stream file that Kodi can use to integrate it into the DB + + Parameters + ---------- + path : :obj:`str` + Filepath of the file to be created + + url : :obj:`str` + Stream url + """ with open(path, 'w+') as f: f.write(url) f.close() def _load_local_db (self, filename): + """Loads the local db file and parses it, creates one if not existent + + Parameters + ---------- + filename : :obj:`str` + Filepath of db file + + Returns + ------- + :obj:`dict` + Parsed contents of the db file + """ # 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) + self._update_local_db(filename=filename, db=data) return data with open(filename) as f: @@ -82,75 +114,216 @@ class Library: else: return {} - def _update_local_db (self, filename, data): + def _update_local_db (self, filename, db): + """Updates the local db file with new data + + Parameters + ---------- + filename : :obj:`str` + Filepath of db file + + db : :obj:`dict` + Database contents + + Returns + ------- + bool + Update has been successfully executed + """ if not os.path.isdir(os.path.dirname(filename)): return False with open(filename, 'w') as f: f.truncate() - pickle.dump(data, f) + pickle.dump(db, f) return True def movie_exists (self, title, year): + """Checks if a movie is already present in the local DB + + Parameters + ---------- + title : :obj:`str` + Title of the movie + + year : :obj:`int` + Release year of the movie + + Returns + ------- + bool + Movie exists in DB + """ 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) + def show_exists (self, title): + """Checks if a show is present in the local DB + + Parameters + ---------- + title : :obj:`str` + Title of the show + + Returns + ------- + bool + Show exists in DB + """ + show_meta = '%s' % (title) return show_meta in self.db[self.series_label] - def season_exists (self, title, year, season): - if self.show_exists() == False: + def season_exists (self, title, season): + """Checks if a season is present in the local DB + + Parameters + ---------- + title : :obj:`str` + Title of the show + + season : :obj:`int` + Season sequence number + + Returns + ------- + bool + Season of show exists in DB + """ + if self.show_exists(title) == False: return False - show_meta = '%s (%d)' % (title, year) - show_entry = self.db[self.series_label][show_meta] + show_entry = self.db[self.series_label][title] return season in show_entry['seasons'] - def episode_exists (self, title, year, season, episode): - if self.show_exists() == False: + def episode_exists (self, title, season, episode): + """Checks if an episode if a show is present in the local DB + + Parameters + ---------- + title : :obj:`str` + Title of the show + + season : :obj:`int` + Season sequence number + + episode : :obj:`int` + Episode sequence number + + Returns + ------- + bool + Episode of show exists in DB + """ + if self.show_exists(title) == False: return False - show_meta = '%s (%d)' % (title, year) - show_entry = self.db[self.series_label][show_meta] + show_entry = self.db[self.series_label][title] 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): + def add_movie (self, title, alt_title, year, video_id, pin, build_url): + """Adds a movie to the local db, generates & persists the strm file + + Parameters + ---------- + title : :obj:`str` + Title of the show + + alt_title : :obj:`str` + Alternative title given by the user + + year : :obj:`int` + Release year of the show + + video_id : :obj:`str` + ID of the video to be played + + pin : bool + Needs adult pin + + build_url : :obj:`fn` + Function to generate the stream url + """ + movie_meta = '%s (%d)' % (title, year) - dirname = os.path.join(self.movie_path, movie_meta) + folder = alt_title + dirname = os.path.join(self.movie_path, folder) 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.db[self.movies_label][movie_meta] = {'alt_title': alt_title} 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) + def add_show (self, title, alt_title, episodes, build_url): + """Adds a show to the local db, generates & persists the strm files + + Note: Can also used to store complete seasons or single episodes, it all depends on + what is present in the episodes dictionary + + Parameters + ---------- + title : :obj:`str` + Title of the show + + alt_title : :obj:`str` + Alternative title given by the user + + episodes : :obj:`dict` of :obj:`dict` + Episodes that need to be added + + build_url : :obj:`fn` + Function to generate the stream url + """ + show_meta = '%s' % (title) + folder = alt_title + show_dir = os.path.join(self.tvshow_path, folder) 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) + if self.show_exists(title) == False: + self.db[self.series_label][show_meta] = {'seasons': [], 'episodes': [], 'alt_title': alt_title} + for episode in episodes: + self._add_episode(show_dir=show_dir, title=title, season=episode['season'], episode=episode['episode'], 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): + def _add_episode (self, title, show_dir, season, episode, video_id, pin, build_url): + """Adds a single episode to the local DB, generates & persists the strm file + + Parameters + ---------- + title : :obj:`str` + Title of the show + + show_dir : :obj:`str` + Directory that holds the stream files for that show + + season : :obj:`int` + Season sequence number + + episode : :obj:`int` + Episode sequence number + + video_id : :obj:`str` + ID of the video to be played + + pin : bool + Needs adult pin + + build_url : :obj:`fn` + Function to generate the stream 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) + if self.season_exists(title=title, season=season) == False: + self.db[self.series_label][title]['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) + if self.episode_exists(title=title, season=season, episode=episode) == False: + self.db[self.series_label][title]['episodes'].append(episode_meta) # create strm file filename = episode_meta + '.strm' @@ -159,36 +332,79 @@ class Library: 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): + def remove_movie (self, title, year): + """Removes the DB entry & the strm file for the movie given + + Parameters + ---------- + title : :obj:`str` + Title of the movie + + year : :obj:`int` + Release year of the movie + + Returns + ------- + bool + Delete successfull + """ movie_meta = '%s (%d)' % (title, year) + folder = self.db[self.movies_label][movie_meta]['alt_title'] 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) + dirname = os.path.join(self.movie_path, folder) if os.path.exists(dirname): - os.rmtree(dirname) + shutil.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] + def remove_show (self, title): + """Removes the DB entry & the strm files for the show given + + Parameters + ---------- + title : :obj:`str` + Title of the show + + Returns + ------- + bool + Delete successfull + """ + folder = self.db[self.series_label][title]['alt_title'] + del self.db[self.series_label][title] self._update_local_db(filename=self.db_filepath, db=self.db) - show_dir = os.path.join(self.tvshow_path, show_meta) + show_dir = os.path.join(self.tvshow_path, folder) if os.path.exists(show_dir): - os.rmtree(show_dir) + shutil.rmtree(show_dir) return True return False - def remove_season(self, title, year, season): + def remove_season (self, title, season): + """Removes the DB entry & the strm files for a season of a show given + + Parameters + ---------- + title : :obj:`str` + Title of the show + + season : :obj:`int` + Season sequence number + + Returns + ------- + bool + Delete successfull + """ season = int(season) season_list = [] episodes_list = [] - show_meta = '%s (%d)' % (title, year) + show_meta = '%s' % (title) 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) + show_dir = os.path.join(self.tvshow_path, self.db[self.series_label][show_meta]['alt_title']) 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: @@ -200,11 +416,29 @@ class Library: self._update_local_db(filename=self.db_filepath, db=self.db) return True - def remove_episode(self, title, year, season, episode): + def remove_episode (self, title, season, episode): + """Removes the DB entry & the strm files for an episode of a show given + + Parameters + ---------- + title : :obj:`str` + Title of the show + + season : :obj:`int` + Season sequence number + + episode : :obj:`int` + Episode sequence number + + Returns + ------- + bool + Delete successfull + """ episodes_list = [] - show_meta = '%s (%d)' % (title, year) + show_meta = '%s' % (title) episode_meta = 'S%02dE%02d' % (season, episode) - show_dir = os.path.join(self.tvshow_path, show_meta) + show_dir = os.path.join(self.tvshow_path, self.db[self.series_label][show_meta]['alt_title']) 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']: diff --git a/resources/lib/Navigation.py b/resources/lib/Navigation.py index c1e9397..e934645 100644 --- a/resources/lib/Navigation.py +++ b/resources/lib/Navigation.py @@ -55,9 +55,10 @@ class Navigation: # 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() + if account['email'] != '' and account['password'] != '': + 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 @@ -86,10 +87,19 @@ class Navigation: return self.rate_on_netflix(video_id=params['id']) elif params['action'] == 'remove_from_list': # removes a title from the users list on Netflix + self.kodi_helper.invalidate_memcache() return self.remove_from_list(video_id=params['id']) elif params['action'] == 'add_to_list': # adds a title to the users list on Netflix + self.kodi_helper.invalidate_memcache() return self.add_to_list(video_id=params['id']) + elif params['action'] == 'export': + # adds a title to the users list on Netflix + alt_title = self.kodi_helper.show_add_to_library_title_dialog(original_title=urllib.unquote(params['title']).decode('utf8')) + return self.export_to_library(video_id=params['id'], alt_title=alt_title) + elif params['action'] == 'remove': + # adds a title to the users list on Netflix + return self.remove_from_library(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']) @@ -127,6 +137,7 @@ class Navigation: esn = self.netflix_session.esn return self.kodi_helper.play_item(esn=esn, video_id=video_id, start_offset=start_offset) + @log def show_search_results (self, term): """Display a list of search results @@ -141,7 +152,7 @@ class Navigation: If no results are available """ has_search_results = False - search_results_raw = self.netflix_session.fetch_search_results(term=term) + search_results_raw = self.netflix_session.fetch_search_results(search_str=term) # check for any errors if self._is_dirty_response(response=search_results_raw): return False @@ -190,12 +201,17 @@ class Navigation: 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) + cache_id = 'episodes_' + season_id + if self.kodi_helper.has_cached_item(cache_id=cache_id): + episode_list = self.kodi_helper.get_cached_item(cache_id=cache_id) + else: + 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) + self.kodi_helper.add_cached_item(cache_id=cache_id, contents=episode_list) # sort seasons by number (they´re coming back unsorted from the api) episodes_sorted = [] @@ -219,15 +235,20 @@ class Navigation: 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) + cache_id = 'season_' + show_id + if self.kodi_helper.has_cached_item(cache_id=cache_id): + season_list = self.kodi_helper.get_cached_item(cache_id=cache_id) + else: + 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) + self.kodi_helper.add_cached_item(cache_id=cache_id, contents=season_list) # sort seasons by index by default (they´re coming back unsorted from the api) seasons_sorted = [] for season_id in season_list: @@ -246,33 +267,47 @@ class Navigation: 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) + if self.kodi_helper.has_cached_item(cache_id=type): + video_list = self.kodi_helper.get_cached_item(cache_id=type) + else: + 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 + if 'videos' in raw_video_list['value'].keys(): + video_list = self.netflix_session.parse_video_list(response_data=raw_video_list) + self.kodi_helper.add_cached_item(cache_id=type, contents=video_list) + else: + 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) + cache_id='main_menu' + if self.kodi_helper.has_cached_item(cache_id=cache_id): + video_list_ids = self.kodi_helper.get_cached_item(cache_id=cache_id) + else: + # 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) + self.kodi_helper.add_cached_item(cache_id=cache_id, contents=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) + @log def show_profiles (self): """List the profiles for the active account""" - self.netflix_session.refresh_session_data(account=self.kodi_helper.get_credentials()) + credentials = self.kodi_helper.get_credentials() + self.netflix_session.refresh_session_data(account=credentials) profiles = self.netflix_session.profiles return self.kodi_helper.build_profiles_listing(profiles=profiles, action='video_lists', build_url=self.build_url) @@ -312,6 +347,56 @@ class Navigation: self.netflix_session.add_to_list(video_id=video_id) return self.kodi_helper.refresh() + @log + def export_to_library (self, video_id, alt_title): + """Adds an item to the local library + + Parameters + ---------- + video_id : :obj:`str` + ID of the movie or show + + alt_title : :obj:`str` + Alternative title (for the folder written to disc) + """ + metadata = self.netflix_session.fetch_metadata(id=video_id) + # check for any errors + if self._is_dirty_response(response=metadata): + return False + video = metadata['video'] + + if video['type'] == 'movie': + self.library.add_movie(title=video['title'], alt_title=alt_title, year=video['year'], video_id=video_id, pin=video['requiresPin'], build_url=self.build_url) + if video['type'] == 'show': + episodes = [] + for season in video['seasons']: + for episode in season['episodes']: + episodes.append({'season': season['seq'], 'episode': episode['seq'], 'id': episode['id'], 'pin': episode['requiresAdultVerification']}) + + self.library.add_show(title=video['title'], alt_title=alt_title, episodes=episodes, build_url=self.build_url) + return self.kodi_helper.refresh() + + @log + def remove_from_library (self, video_id, season=None, episode=None): + """Removes an item from the local library + + Parameters + ---------- + video_id : :obj:`str` + ID of the movie or show + """ + metadata = self.netflix_session.fetch_metadata(id=video_id) + # check for any errors + if self._is_dirty_response(response=metadata): + return False + video = metadata['video'] + + if video['type'] == 'movie': + self.library.remove_movie(title=video['title'], year=video['year']) + if video['type'] == 'show': + self.library.remove_show(title=video['title']) + 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 @@ -326,10 +411,7 @@ class Navigation: 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) + return True if self.netflix_session.is_logged_in(account=account) else self.netflix_session.login(account=account) @log def before_routing_action (self, params): @@ -355,15 +437,18 @@ class Navigation: if credentials['email'] == '': email = self.kodi_helper.show_email_dialog() self.kodi_helper.set_setting(key='email', value=email) + credentials['email'] = email if credentials['password'] == '': password = self.kodi_helper.show_password_dialog() self.kodi_helper.set_setting(key='password', value=password) + credentials['password'] = 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.kodi_helper.invalidate_memcache() self.netflix_session.switch_profile(profile_id=params['profile_id'], account=credentials) # check login, in case of main menu if 'action' not in params: @@ -453,7 +538,9 @@ class Navigation: 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'])) + message = response['message'] if 'message' in response else '' + code = response['code'] if 'code' in response else '' + self.log(msg='[ERROR]: ' + message + '::' + str(code)) return True return False diff --git a/resources/lib/NetflixSession.py b/resources/lib/NetflixSession.py index b2416c2..2ba0cbb 100644 --- a/resources/lib/NetflixSession.py +++ b/resources/lib/NetflixSession.py @@ -10,15 +10,18 @@ import time import urllib import json import requests -import pickle -from BeautifulSoup import BeautifulSoup +try: + import cPickle as pickle +except: + import pickle +from bs4 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/' + base_url = 'https://www.netflix.com' """str: Secure Netflix url""" urls = { @@ -86,7 +89,7 @@ class NetflixSession: esn = '' """str: Widevine esn, something like: NFCDCH-MC-D7D6F54LOPY8J416T72MQXX3RD20ME""" - def __init__(self, cookie_path, data_path, log_fn=noop): + def __init__(self, cookie_path, data_path, verify_ssl=True, log_fn=noop): """Stores the cookie path for later use & instanciates a requests session with a proper user agent & stored cookies/data if available @@ -103,6 +106,7 @@ class NetflixSession: """ self.cookie_path = cookie_path self.data_path = data_path + self.verify_ssl = verify_ssl self.log = log_fn # start session, fake chrome (so that we get a proper widevine esn) & enable gzip @@ -128,7 +132,7 @@ class NetflixSession: value from the form field """ login_input_fields = {} - login_inputs = form_soup.findAll('input') + login_inputs = form_soup.find_all('input') # gather all form fields, set an empty string as the default value for item in login_inputs: keys = dict(item.attrs).keys() @@ -166,7 +170,7 @@ class NetflixSession: List of all the serialized data pulled out of the pagws