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