chore(performance): Further speed up of imports
[plugin.video.netflix.git] / resources / lib / Navigation.py
1 #!/usr/bin/env python
2 # -*- coding: utf-8 -*-
3 # Module: Navigation
4 # Created on: 13.01.2017
5
6 from urllib import urlencode, unquote
7 from urlparse import parse_qsl
8 from utils import noop, log
9
10 class Navigation:
11     """Routes to the correct subfolder, dispatches actions & acts as a controller for the Kodi view & the Netflix model"""
12
13     def __init__ (self, netflix_session, kodi_helper, library, base_url, log_fn=noop):
14         """Takes the instances & configuration options needed to drive the plugin
15
16         Parameters
17         ----------
18         netflix_session : :obj:`NetflixSession`
19             instance of the NetflixSession class
20
21         kodi_helper : :obj:`KodiHelper`
22             instance of the KodiHelper class
23
24         library : :obj:`Library`
25             instance of the Library class
26
27         base_url : :obj:`str`
28             plugin base url
29
30         log_fn : :obj:`fn`
31              optional log function
32         """
33         self.netflix_session = netflix_session
34         self.kodi_helper = kodi_helper
35         self.library = library
36         self.base_url = base_url
37         self.log = log_fn
38
39     @log
40     def router (self, paramstring):
41         """Route to the requested subfolder & dispatch actions along the way
42
43         Parameters
44         ----------
45         paramstring : :obj:`str`
46             Url query params
47         """
48         params = self.parse_paramters(paramstring=paramstring)
49
50         # log out the user
51         if 'action' in params.keys() and params['action'] == 'logout':
52             return self.netflix_session.logout()
53
54         # check login & try to relogin if necessary
55         account = self.kodi_helper.get_credentials()
56         if account['email'] != '' and account['password'] != '':
57             if self.netflix_session.is_logged_in(account=account) != True:
58                 if self.establish_session(account=account) != True:
59                     return self.kodi_helper.show_login_failed_notification()
60
61         # check if we need to execute any actions before the actual routing
62         # gives back a dict of options routes might need
63         options = self.before_routing_action(params=params)
64
65         # check if one of the before routing options decided to killthe routing
66         if 'exit' in options:
67             return False
68         if 'action' not in params.keys():
69             # show the profiles
70             return self.show_profiles()
71         elif params['action'] == 'video_lists':
72             # list lists that contain other lists (starting point with recommendations, search, etc.)
73             return self.show_video_lists()
74         elif params['action'] == 'video_list':
75             # show a list of shows/movies
76             type = None if 'type' not in params.keys() else params['type']
77             return self.show_video_list(video_list_id=params['video_list_id'], type=type)
78         elif params['action'] == 'season_list':
79             # list of seasons for a show
80             return self.show_seasons(show_id=params['show_id'])
81         elif params['action'] == 'episode_list':
82             # list of episodes for a season
83             return self.show_episode_list(season_id=params['season_id'])
84         elif params['action'] == 'rating':
85             return self.rate_on_netflix(video_id=params['id'])
86         elif params['action'] == 'remove_from_list':
87             # removes a title from the users list on Netflix
88             self.kodi_helper.invalidate_memcache()
89             return self.remove_from_list(video_id=params['id'])
90         elif params['action'] == 'add_to_list':
91             # adds a title to the users list on Netflix
92             self.kodi_helper.invalidate_memcache()
93             return self.add_to_list(video_id=params['id'])
94         elif params['action'] == 'export':
95             # adds a title to the users list on Netflix
96             alt_title = self.kodi_helper.show_add_to_library_title_dialog(original_title=unquote(params['title']).decode('utf8'))
97             return self.export_to_library(video_id=params['id'], alt_title=alt_title)
98         elif params['action'] == 'remove':
99             # adds a title to the users list on Netflix
100             return self.remove_from_library(video_id=params['id'])
101         elif params['action'] == 'user-items' and params['type'] != 'search':
102             # display the lists (recommendations, genres, etc.)
103             return self.show_user_list(type=params['type'])
104         elif params['action'] == 'play_video':
105             self.play_video(video_id=params['video_id'], start_offset=params.get('start_offset', -1))
106         elif params['action'] == 'user-items' and params['type'] == 'search':
107             # if the user requested a search, ask for the term
108             term = self.kodi_helper.show_search_term_dialog()
109             return self.show_search_results(term=term)
110         else:
111             raise ValueError('Invalid paramstring: {0}!'.format(paramstring))
112         return True
113
114     @log
115     def play_video (self, video_id, start_offset):
116         """Starts video playback
117
118         Note: This is just a dummy, inputstream is needed to play the vids
119
120         Parameters
121         ----------
122         video_id : :obj:`str`
123             ID of the video that should be played
124
125         start_offset : :obj:`str`
126             Offset to resume playback from (in seconds)
127         """
128         # widevine esn
129         esn = self.netflix_session.esn
130         return self.kodi_helper.play_item(esn=esn, video_id=video_id, start_offset=start_offset)
131
132     @log
133     def show_search_results (self, term):
134         """Display a list of search results
135
136         Parameters
137         ----------
138         term : :obj:`str`
139             String to lookup
140
141         Returns
142         -------
143         bool
144             If no results are available
145         """
146         has_search_results = False
147         search_results_raw = self.netflix_session.fetch_search_results(search_str=term)
148         # check for any errors
149         if self._is_dirty_response(response=search_results_raw):
150             return False
151
152         # determine if we found something
153         if 'search' in search_results_raw['value']:
154             for key in search_results_raw['value']['search'].keys():
155                 if self.netflix_session._is_size_key(key=key) == False:
156                     has_search_results = search_results_raw['value']['search'][key]['titles']['length'] > 0
157                     if has_search_results == False:
158                         if search_results_raw['value']['search'][key].get('suggestions', False) != False:
159                             for entry in search_results_raw['value']['search'][key]['suggestions']:
160                                 if self.netflix_session._is_size_key(key=entry) == False:
161                                     if search_results_raw['value']['search'][key]['suggestions'][entry]['relatedvideos']['length'] > 0:
162                                         has_search_results = True
163
164
165         # display that we haven't found a thing
166         if has_search_results == False:
167             return self.kodi_helper.build_no_search_results_available(build_url=self.build_url, action='search')
168
169         # list the search results
170         search_results = self.netflix_session.parse_search_results(response_data=search_results_raw)
171         # add more menaingful data to the search results
172         raw_search_contents = self.netflix_session.fetch_video_list_information(video_ids=search_results.keys())
173         # check for any errors
174         if self._is_dirty_response(response=raw_search_contents):
175             return False
176         search_contents = self.netflix_session.parse_video_list(response_data=raw_search_contents)
177         actions = {'movie': 'play_video', 'show': 'season_list'}
178         return self.kodi_helper.build_search_result_listing(video_list=search_contents, actions=actions, build_url=self.build_url)
179
180     def show_user_list (self, type):
181         """List the users lists for shows/movies for recommendations/genres based on the given type
182
183         Parameters
184         ----------
185         user_list_id : :obj:`str`
186             Type of list to display
187         """
188         video_list_ids_raw = self.netflix_session.fetch_video_list_ids()
189         # check for any errors
190         if self._is_dirty_response(response=video_list_ids_raw):
191             return False
192         video_list_ids = self.netflix_session.parse_video_list_ids(response_data=video_list_ids_raw)
193         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)
194
195     def show_episode_list (self, season_id):
196         """Lists all episodes for a given season
197
198         Parameters
199         ----------
200         season_id : :obj:`str`
201             ID of the season episodes should be displayed for
202         """
203         cache_id = 'episodes_' + season_id
204         if self.kodi_helper.has_cached_item(cache_id=cache_id):
205             episode_list = self.kodi_helper.get_cached_item(cache_id=cache_id)
206         else:
207             raw_episode_list = self.netflix_session.fetch_episodes_by_season(season_id=season_id)
208             # check for any errors
209             if self._is_dirty_response(response=raw_episode_list):
210                 return False
211             # parse the raw Netflix data
212             episode_list = self.netflix_session.parse_episodes_by_season(response_data=raw_episode_list)
213             self.kodi_helper.add_cached_item(cache_id=cache_id, contents=episode_list)
214
215         # sort seasons by number (they´re coming back unsorted from the api)
216         episodes_sorted = []
217         for episode_id in episode_list:
218             episodes_sorted.append(int(episode_list[episode_id]['episode']))
219             episodes_sorted.sort()
220
221         # list the episodes
222         return self.kodi_helper.build_episode_listing(episodes_sorted=episodes_sorted, episode_list=episode_list, build_url=self.build_url)
223
224     def show_seasons (self, show_id):
225         """Lists all seasons for a given show
226
227         Parameters
228         ----------
229         show_id : :obj:`str`
230             ID of the show seasons should be displayed for
231
232         Returns
233         -------
234         bool
235             If no seasons are available
236         """
237         cache_id = 'season_' + show_id
238         if self.kodi_helper.has_cached_item(cache_id=cache_id):
239             season_list = self.kodi_helper.get_cached_item(cache_id=cache_id)
240         else:
241             season_list_raw = self.netflix_session.fetch_seasons_for_show(id=show_id);
242             # check for any errors
243             if self._is_dirty_response(response=season_list_raw):
244                 return False
245             # check if we have sesons, announced shows that are not available yet have none
246             if 'seasons' not in season_list_raw['value']:
247                 return self.kodi_helper.build_no_seasons_available()
248             # parse the seasons raw response from Netflix
249             season_list = self.netflix_session.parse_seasons(id=show_id, response_data=season_list_raw)
250             self.kodi_helper.add_cached_item(cache_id=cache_id, contents=season_list)
251         # sort seasons by index by default (they´re coming back unsorted from the api)
252         seasons_sorted = []
253         for season_id in season_list:
254             seasons_sorted.append(int(season_list[season_id]['idx']))
255             seasons_sorted.sort()
256         return self.kodi_helper.build_season_listing(seasons_sorted=seasons_sorted, season_list=season_list, build_url=self.build_url)
257
258     def show_video_list (self, video_list_id, type):
259         """List shows/movies based on the given video list id
260
261         Parameters
262         ----------
263         video_list_id : :obj:`str`
264             ID of the video list that should be displayed
265
266         type : :obj:`str`
267             None or 'queue' f.e. when it´s a special video lists
268         """
269         if self.kodi_helper.has_cached_item(cache_id=video_list_id):
270             video_list = self.kodi_helper.get_cached_item(cache_id=video_list_id)
271         else:
272             raw_video_list = self.netflix_session.fetch_video_list(list_id=video_list_id)
273             # check for any errors
274             if self._is_dirty_response(response=raw_video_list):
275                 return False
276             # parse the video list ids
277             if 'videos' in raw_video_list['value'].keys():
278                 video_list = self.netflix_session.parse_video_list(response_data=raw_video_list)
279                 self.kodi_helper.add_cached_item(cache_id=video_list_id, contents=video_list)
280             else:
281                 video_list = []
282         actions = {'movie': 'play_video', 'show': 'season_list'}
283         return self.kodi_helper.build_video_listing(video_list=video_list, actions=actions, type=type, build_url=self.build_url)
284
285     def show_video_lists (self):
286         """List the users video lists (recommendations, my list, etc.)"""
287         cache_id='main_menu'
288         if self.kodi_helper.has_cached_item(cache_id=cache_id):
289             video_list_ids = self.kodi_helper.get_cached_item(cache_id=cache_id)
290         else:
291             # fetch video lists
292             raw_video_list_ids = self.netflix_session.fetch_video_list_ids()
293             # check for any errors
294             if self._is_dirty_response(response=raw_video_list_ids):
295                 return False
296             # parse the video list ids
297             video_list_ids = self.netflix_session.parse_video_list_ids(response_data=raw_video_list_ids)
298             self.kodi_helper.add_cached_item(cache_id=cache_id, contents=video_list_ids)
299         # defines an order for the user list, as Netflix changes the order at every request
300         user_list_order = ['queue', 'continueWatching', 'topTen', 'netflixOriginals', 'trendingNow', 'newRelease', 'popularTitles']
301         # define where to route the user
302         actions = {'recommendations': 'user-items', 'genres': 'user-items', 'search': 'user-items', 'default': 'video_list'}
303         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)
304
305     @log
306     def show_profiles (self):
307         """List the profiles for the active account"""
308         credentials = self.kodi_helper.get_credentials()
309         self.netflix_session.refresh_session_data(account=credentials)
310         profiles = self.netflix_session.profiles
311         return self.kodi_helper.build_profiles_listing(profiles=profiles, action='video_lists', build_url=self.build_url)
312
313     @log
314     def rate_on_netflix (self, video_id):
315         """Rate a show/movie/season/episode on Netflix
316
317         Parameters
318         ----------
319         video_list_id : :obj:`str`
320             ID of the video list that should be displayed
321         """
322         rating = self.kodi_helper.show_rating_dialog()
323         return self.netflix_session.rate_video(video_id=video_id, rating=rating)
324
325     @log
326     def remove_from_list (self, video_id):
327         """Remove an item from 'My List' & refresh the view
328
329         Parameters
330         ----------
331         video_list_id : :obj:`str`
332             ID of the video list that should be displayed
333         """
334         self.netflix_session.remove_from_list(video_id=video_id)
335         return self.kodi_helper.refresh()
336
337     @log
338     def add_to_list (self, video_id):
339         """Add an item to 'My List' & refresh the view
340
341         Parameters
342         ----------
343         video_list_id : :obj:`str`
344             ID of the video list that should be displayed
345         """
346         self.netflix_session.add_to_list(video_id=video_id)
347         return self.kodi_helper.refresh()
348
349     @log
350     def export_to_library (self, video_id, alt_title):
351         """Adds an item to the local library
352
353         Parameters
354         ----------
355         video_id : :obj:`str`
356             ID of the movie or show
357
358         alt_title : :obj:`str`
359             Alternative title (for the folder written to disc)
360         """
361         metadata = self.netflix_session.fetch_metadata(id=video_id)
362         # check for any errors
363         if self._is_dirty_response(response=metadata):
364             return False
365         video = metadata['video']
366
367         if video['type'] == 'movie':
368             self.library.add_movie(title=video['title'], alt_title=alt_title, year=video['year'], video_id=video_id, build_url=self.build_url)
369         if video['type'] == 'show':
370             episodes = []
371             for season in video['seasons']:
372                 for episode in season['episodes']:
373                     episodes.append({'season': season['seq'], 'episode': episode['seq'], 'id': episode['id']})
374
375             self.library.add_show(title=video['title'], alt_title=alt_title, episodes=episodes, build_url=self.build_url)
376         return self.kodi_helper.refresh()
377
378     @log
379     def remove_from_library (self, video_id, season=None, episode=None):
380         """Removes an item from the local library
381
382         Parameters
383         ----------
384         video_id : :obj:`str`
385             ID of the movie or show
386         """
387         metadata = self.netflix_session.fetch_metadata(id=video_id)
388         # check for any errors
389         if self._is_dirty_response(response=metadata):
390             return False
391         video = metadata['video']
392
393         if video['type'] == 'movie':
394             self.library.remove_movie(title=video['title'], year=video['year'])
395         if video['type'] == 'show':
396             self.library.remove_show(title=video['title'])
397         return self.kodi_helper.refresh()
398
399     @log
400     def establish_session(self, account):
401         """Checks if we have an cookie with an active sessions, otherwise tries to login the user
402
403         Parameters
404         ----------
405         account : :obj:`dict` of :obj:`str`
406             Dict containing an email & a password property
407
408         Returns
409         -------
410         bool
411             If we don't have an active session & the user couldn't be logged in
412         """
413         return True if self.netflix_session.is_logged_in(account=account) else self.netflix_session.login(account=account)
414
415     @log
416     def before_routing_action (self, params):
417         """Executes actions before the actual routing takes place:
418
419             - Check if account data has been stored, if not, asks for it
420             - Check if the profile should be changed (and changes if so)
421             - Establishes a session if no action route is given
422
423         Parameters
424         ----------
425         params : :obj:`dict` of :obj:`str`
426             Url query params
427
428         Returns
429         -------
430         :obj:`dict` of :obj:`str`
431             Options that can be provided by this hook & used later in the routing process
432         """
433         options = {}
434         credentials = self.kodi_helper.get_credentials()
435         # check if we have user settings, if not, set em
436         if credentials['email'] == '':
437             email = self.kodi_helper.show_email_dialog()
438             self.kodi_helper.set_setting(key='email', value=email)
439             credentials['email'] = email
440         if credentials['password'] == '':
441             password = self.kodi_helper.show_password_dialog()
442             self.kodi_helper.set_setting(key='password', value=password)
443             credentials['password'] = password
444         # persist & load main menu selection
445         if 'type' in params:
446             self.kodi_helper.set_main_menu_selection(type=params['type'])
447         options['main_menu_selection'] = self.kodi_helper.get_main_menu_selection()
448         # check and switch the profile if needed
449         if self.check_for_designated_profile_change(params=params):
450             self.kodi_helper.invalidate_memcache()
451             self.netflix_session.switch_profile(profile_id=params['profile_id'], account=credentials)
452         # check login, in case of main menu
453         if 'action' not in params:
454             self.establish_session(account=credentials)
455         return options
456
457     def check_for_designated_profile_change (self, params):
458         """Checks if the profile needs to be switched
459
460         Parameters
461         ----------
462         params : :obj:`dict` of :obj:`str`
463             Url query params
464
465         Returns
466         -------
467         bool
468             Profile should be switched or not
469         """
470         # check if we need to switch the user
471         if 'guid' not in self.netflix_session.user_data:
472             return False
473         current_profile_id = self.netflix_session.user_data['guid']
474         return 'profile_id' in params and current_profile_id != params['profile_id']
475
476     def parse_paramters (self, paramstring):
477         """Tiny helper to convert a url paramstring into a dictionary
478
479         Parameters
480         ----------
481         paramstring : :obj:`str`
482             Url query params (in url string notation)
483
484         Returns
485         -------
486         :obj:`dict` of :obj:`str`
487             Url query params (as a dictionary)
488         """
489         return dict(parse_qsl(paramstring))
490
491     def _is_expired_session (self, response):
492         """Checks if a response error is based on an invalid session
493
494         Parameters
495         ----------
496         response : :obj:`dict` of :obj:`str`
497             Error response object
498
499         Returns
500         -------
501         bool
502             Error is based on an invalid session
503         """
504         return 'error' in response and 'code' in response and str(response['code']) == '401'
505
506     def _is_dirty_response (self, response):
507         """Checks if a response contains an error & if the error is based on an invalid session, it tries a relogin
508
509         Parameters
510         ----------
511         response : :obj:`dict` of :obj:`str`
512             Success response object or Error response object
513
514         Returns
515         -------
516         bool
517             Response contains error or not
518         """
519         # check for any errors
520         if 'error' in response:
521             # check if we do not have a valid session, in case that happens: (re)login
522             if self._is_expired_session(response=response):
523                 if self.establish_session(account=self.kodi_helper.get_credentials()):
524                     return True
525             message = response['message'] if 'message' in response else ''
526             code = response['code'] if 'code' in response else ''
527             self.log(msg='[ERROR]: ' + message + '::' + str(code))
528             return True
529         return False
530
531     def build_url(self, query):
532         """Tiny helper to transform a dict into a url + querystring
533
534         Parameters
535         ----------
536         query : :obj:`dict` of  :obj:`str`
537             List of paramters to be url encoded
538
539         Returns
540         -------
541         str
542             Url + querystring based on the param
543         """
544         return self.base_url + '?' + urlencode(query)