01426de1de4c8d8744ea7c1e67f0af24234c2485
[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(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.get('start_offset', -1))
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             print '---Le DEBUG--'
262             print season_list[season_id]
263             print '---Le DEBUG--'
264             seasons_sorted.append(int(season_list[season_id]['id']))
265             seasons_sorted.sort()
266         return self.kodi_helper.build_season_listing(seasons_sorted=seasons_sorted, season_list=season_list, build_url=self.build_url)
267
268     def show_video_list (self, video_list_id, type):
269         """List shows/movies based on the given video list id
270
271         Parameters
272         ----------
273         video_list_id : :obj:`str`
274             ID of the video list that should be displayed
275
276         type : :obj:`str`
277             None or 'queue' f.e. when it´s a special video lists
278         """
279         if self.kodi_helper.has_cached_item(cache_id=video_list_id):
280             video_list = self.kodi_helper.get_cached_item(cache_id=video_list_id)
281         else:
282             raw_video_list = self.netflix_session.fetch_video_list(list_id=video_list_id)
283             # check for any errors
284             if self._is_dirty_response(response=raw_video_list):
285                 return False
286             # parse the video list ids
287             if 'videos' in raw_video_list['value'].keys():
288                 video_list = self.netflix_session.parse_video_list(response_data=raw_video_list)
289                 self.kodi_helper.add_cached_item(cache_id=video_list_id, contents=video_list)
290             else:
291                 video_list = []
292         actions = {'movie': 'play_video', 'show': 'season_list'}
293         return self.kodi_helper.build_video_listing(video_list=video_list, actions=actions, type=type, build_url=self.build_url)
294
295     def show_video_lists (self):
296         """List the users video lists (recommendations, my list, etc.)"""
297         cache_id='main_menu'
298         if self.kodi_helper.has_cached_item(cache_id=cache_id):
299             video_list_ids = self.kodi_helper.get_cached_item(cache_id=cache_id)
300         else:
301             # fetch video lists
302             raw_video_list_ids = self.netflix_session.fetch_video_list_ids()
303             # check for any errors
304             if self._is_dirty_response(response=raw_video_list_ids):
305                 return False
306             # parse the video list ids
307             video_list_ids = self.netflix_session.parse_video_list_ids(response_data=raw_video_list_ids)
308             self.kodi_helper.add_cached_item(cache_id=cache_id, contents=video_list_ids)
309         # defines an order for the user list, as Netflix changes the order at every request
310         user_list_order = ['queue', 'continueWatching', 'topTen', 'netflixOriginals', 'trendingNow', 'newRelease', 'popularTitles']
311         # define where to route the user
312         actions = {'recommendations': 'user-items', 'genres': 'user-items', 'search': 'user-items', 'default': 'video_list'}
313         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)
314
315     @log
316     def show_profiles (self):
317         """List the profiles for the active account"""
318         credentials = self.kodi_helper.get_credentials()
319         self.netflix_session.refresh_session_data(account=credentials)
320         profiles = self.netflix_session.profiles
321         return self.kodi_helper.build_profiles_listing(profiles=profiles, action='video_lists', build_url=self.build_url)
322
323     @log
324     def rate_on_netflix (self, video_id):
325         """Rate a show/movie/season/episode on Netflix
326
327         Parameters
328         ----------
329         video_list_id : :obj:`str`
330             ID of the video list that should be displayed
331         """
332         rating = self.kodi_helper.show_rating_dialog()
333         return self.netflix_session.rate_video(video_id=video_id, rating=rating)
334
335     @log
336     def remove_from_list (self, video_id):
337         """Remove an item from 'My List' & refresh the view
338
339         Parameters
340         ----------
341         video_list_id : :obj:`str`
342             ID of the video list that should be displayed
343         """
344         self.netflix_session.remove_from_list(video_id=video_id)
345         return self.kodi_helper.refresh()
346
347     @log
348     def add_to_list (self, video_id):
349         """Add an item to 'My List' & refresh the view
350
351         Parameters
352         ----------
353         video_list_id : :obj:`str`
354             ID of the video list that should be displayed
355         """
356         self.netflix_session.add_to_list(video_id=video_id)
357         return self.kodi_helper.refresh()
358
359     @log
360     def export_to_library (self, video_id, alt_title):
361         """Adds an item to the local library
362
363         Parameters
364         ----------
365         video_id : :obj:`str`
366             ID of the movie or show
367
368         alt_title : :obj:`str`
369             Alternative title (for the folder written to disc)
370         """
371         metadata = self.netflix_session.fetch_metadata(id=video_id)
372         # check for any errors
373         if self._is_dirty_response(response=metadata):
374             return False
375         video = metadata['video']
376
377         if video['type'] == 'movie':
378             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)
379         if video['type'] == 'show':
380             episodes = []
381             for season in video['seasons']:
382                 for episode in season['episodes']:
383                     episodes.append({'season': season['seq'], 'episode': episode['seq'], 'id': episode['id'], 'pin': episode['requiresAdultVerification']})
384
385             self.library.add_show(title=video['title'], alt_title=alt_title, episodes=episodes, build_url=self.build_url)
386         return self.kodi_helper.refresh()
387
388     @log
389     def remove_from_library (self, video_id, season=None, episode=None):
390         """Removes an item from the local library
391
392         Parameters
393         ----------
394         video_id : :obj:`str`
395             ID of the movie or show
396         """
397         metadata = self.netflix_session.fetch_metadata(id=video_id)
398         # check for any errors
399         if self._is_dirty_response(response=metadata):
400             return False
401         video = metadata['video']
402
403         if video['type'] == 'movie':
404             self.library.remove_movie(title=video['title'], year=video['year'])
405         if video['type'] == 'show':
406             self.library.remove_show(title=video['title'])
407         return self.kodi_helper.refresh()
408
409     @log
410     def establish_session(self, account):
411         """Checks if we have an cookie with an active sessions, otherwise tries to login the user
412
413         Parameters
414         ----------
415         account : :obj:`dict` of :obj:`str`
416             Dict containing an email & a password property
417
418         Returns
419         -------
420         bool
421             If we don't have an active session & the user couldn't be logged in
422         """
423         return True if self.netflix_session.is_logged_in(account=account) else self.netflix_session.login(account=account)
424
425     @log
426     def before_routing_action (self, params):
427         """Executes actions before the actual routing takes place:
428
429             - Check if account data has been stored, if not, asks for it
430             - Check if the profile should be changed (and changes if so)
431             - Establishes a session if no action route is given
432
433         Parameters
434         ----------
435         params : :obj:`dict` of :obj:`str`
436             Url query params
437
438         Returns
439         -------
440         :obj:`dict` of :obj:`str`
441             Options that can be provided by this hook & used later in the routing process
442         """
443         options = {}
444         credentials = self.kodi_helper.get_credentials()
445         # check if we have user settings, if not, set em
446         if credentials['email'] == '':
447             email = self.kodi_helper.show_email_dialog()
448             self.kodi_helper.set_setting(key='email', value=email)
449             credentials['email'] = email
450         if credentials['password'] == '':
451             password = self.kodi_helper.show_password_dialog()
452             self.kodi_helper.set_setting(key='password', value=password)
453             credentials['password'] = password
454         # persist & load main menu selection
455         if 'type' in params:
456             self.kodi_helper.set_main_menu_selection(type=params['type'])
457         options['main_menu_selection'] = self.kodi_helper.get_main_menu_selection()
458         # check and switch the profile if needed
459         if self.check_for_designated_profile_change(params=params):
460             self.kodi_helper.invalidate_memcache()
461             self.netflix_session.switch_profile(profile_id=params['profile_id'], account=credentials)
462         # check login, in case of main menu
463         if 'action' not in params:
464             self.establish_session(account=credentials)
465         return options
466
467     def check_for_designated_profile_change (self, params):
468         """Checks if the profile needs to be switched
469
470         Parameters
471         ----------
472         params : :obj:`dict` of :obj:`str`
473             Url query params
474
475         Returns
476         -------
477         bool
478             Profile should be switched or not
479         """
480         # check if we need to switch the user
481         if 'guid' not in self.netflix_session.user_data:
482             return False
483         current_profile_id = self.netflix_session.user_data['guid']
484         return 'profile_id' in params and current_profile_id != params['profile_id']
485
486     def check_for_adult_pin (self, params):
487         """Checks if an adult pin is given in the query params
488
489         Parameters
490         ----------
491         params : :obj:`dict` of :obj:`str`
492             Url query params
493
494         Returns
495         -------
496         bool
497             Adult pin parameter exists or not
498         """
499         return (True, False)[params['pin'] == 'True']
500
501     def parse_paramters (self, paramstring):
502         """Tiny helper to convert a url paramstring into a dictionary
503
504         Parameters
505         ----------
506         paramstring : :obj:`str`
507             Url query params (in url string notation)
508
509         Returns
510         -------
511         :obj:`dict` of :obj:`str`
512             Url query params (as a dictionary)
513         """
514         return dict(parse_qsl(paramstring))
515
516     def _is_expired_session (self, response):
517         """Checks if a response error is based on an invalid session
518
519         Parameters
520         ----------
521         response : :obj:`dict` of :obj:`str`
522             Error response object
523
524         Returns
525         -------
526         bool
527             Error is based on an invalid session
528         """
529         return 'error' in response and 'code' in response and str(response['code']) == '401'
530
531     def _is_dirty_response (self, response):
532         """Checks if a response contains an error & if the error is based on an invalid session, it tries a relogin
533
534         Parameters
535         ----------
536         response : :obj:`dict` of :obj:`str`
537             Success response object or Error response object
538
539         Returns
540         -------
541         bool
542             Response contains error or not
543         """
544         # check for any errors
545         if 'error' in response:
546             # check if we do not have a valid session, in case that happens: (re)login
547             if self._is_expired_session(response=response):
548                 if self.establish_session(account=self.kodi_helper.get_credentials()):
549                     return True
550             message = response['message'] if 'message' in response else ''
551             code = response['code'] if 'code' in response else ''
552             self.log(msg='[ERROR]: ' + message + '::' + str(code))
553             return True
554         return False
555
556     def build_url(self, query):
557         """Tiny helper to transform a dict into a url + querystring
558
559         Parameters
560         ----------
561         query : :obj:`dict` of  :obj:`str`
562             List of paramters to be url encoded
563
564         Returns
565         -------
566         str
567             Url + querystring based on the param
568         """
569         return self.base_url + '?' + urllib.urlencode(query)