feat(core): Adds library export for movies & shows, fixes for search, fixes for displ...
[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
166         # display that we haven't found a thing
167         if has_search_results == False:
168             return self.kodi_helper.build_no_search_results_available(build_url=self.build_url, action='search')
169
170         # list the search results
171         search_results = self.netflix_session.parse_search_results(response_data=search_results_raw)
172         # add more menaingful data to the search results
173         raw_search_contents = self.netflix_session.fetch_video_list_information(video_ids=search_results.keys())
174         # check for any errors
175         if self._is_dirty_response(response=raw_search_contents):
176             return False
177         search_contents = self.netflix_session.parse_video_list(response_data=raw_search_contents)
178         actions = {'movie': 'play_video', 'show': 'season_list'}
179         return self.kodi_helper.build_search_result_listing(video_list=search_contents, actions=actions, build_url=self.build_url)
180
181     def show_user_list (self, type):
182         """List the users lists for shows/movies for recommendations/genres based on the given type
183
184         Parameters
185         ----------
186         user_list_id : :obj:`str`
187             Type of list to display
188         """
189         video_list_ids_raw = self.netflix_session.fetch_video_list_ids()
190         # check for any errors
191         if self._is_dirty_response(response=video_list_ids_raw):
192             return False
193         video_list_ids = self.netflix_session.parse_video_list_ids(response_data=video_list_ids_raw)
194         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)
195
196     def show_episode_list (self, season_id):
197         """Lists all episodes for a given season
198
199         Parameters
200         ----------
201         season_id : :obj:`str`
202             ID of the season episodes should be displayed for
203         """
204         cache_id = 'episodes_' + season_id
205         if self.kodi_helper.has_cached_item(cache_id=cache_id):
206             episode_list = self.kodi_helper.get_cached_item(cache_id=cache_id)
207         else:
208             raw_episode_list = self.netflix_session.fetch_episodes_by_season(season_id=season_id)
209             # check for any errors
210             if self._is_dirty_response(response=raw_episode_list):
211                 return False
212             # parse the raw Netflix data
213             episode_list = self.netflix_session.parse_episodes_by_season(response_data=raw_episode_list)
214             self.kodi_helper.add_cached_item(cache_id=cache_id, contents=episode_list)
215
216         # sort seasons by number (they´re coming back unsorted from the api)
217         episodes_sorted = []
218         for episode_id in episode_list:
219             episodes_sorted.append(int(episode_list[episode_id]['episode']))
220             episodes_sorted.sort()
221
222         # list the episodes
223         return self.kodi_helper.build_episode_listing(episodes_sorted=episodes_sorted, episode_list=episode_list, build_url=self.build_url)
224
225     def show_seasons (self, show_id):
226         """Lists all seasons for a given show
227
228         Parameters
229         ----------
230         show_id : :obj:`str`
231             ID of the show seasons should be displayed for
232
233         Returns
234         -------
235         bool
236             If no seasons are available
237         """
238         cache_id = 'season_' + show_id
239         if self.kodi_helper.has_cached_item(cache_id=cache_id):
240             season_list = self.kodi_helper.get_cached_item(cache_id=cache_id)
241         else:
242             season_list_raw = self.netflix_session.fetch_seasons_for_show(id=show_id);
243             # check for any errors
244             if self._is_dirty_response(response=season_list_raw):
245                 return False
246             # check if we have sesons, announced shows that are not available yet have none
247             if 'seasons' not in season_list_raw['value']:
248                 return self.kodi_helper.build_no_seasons_available()
249             # parse the seasons raw response from Netflix
250             season_list = self.netflix_session.parse_seasons(id=show_id, response_data=season_list_raw)
251             self.kodi_helper.add_cached_item(cache_id=cache_id, contents=season_list)
252         # sort seasons by index by default (they´re coming back unsorted from the api)
253         seasons_sorted = []
254         for season_id in season_list:
255             seasons_sorted.append(int(season_list[season_id]['shortName'].split(' ')[1]))
256             seasons_sorted.sort()
257         return self.kodi_helper.build_season_listing(seasons_sorted=seasons_sorted, season_list=season_list, build_url=self.build_url)
258
259     def show_video_list (self, video_list_id, type):
260         """List shows/movies based on the given video list id
261
262         Parameters
263         ----------
264         video_list_id : :obj:`str`
265             ID of the video list that should be displayed
266
267         type : :obj:`str`
268             None or 'queue' f.e. when it´s a special video lists
269         """
270         if self.kodi_helper.has_cached_item(cache_id=type):
271             video_list = self.kodi_helper.get_cached_item(cache_id=type)
272         else:
273             raw_video_list = self.netflix_session.fetch_video_list(list_id=video_list_id)
274             # check for any errors
275             if self._is_dirty_response(response=raw_video_list):
276                 return False
277             # parse the video list ids
278             if 'videos' in raw_video_list['value'].keys():
279                 video_list = self.netflix_session.parse_video_list(response_data=raw_video_list)
280                 self.kodi_helper.add_cached_item(cache_id=type, contents=video_list)
281             else:
282                 video_list = []
283         actions = {'movie': 'play_video', 'show': 'season_list'}
284         return self.kodi_helper.build_video_listing(video_list=video_list, actions=actions, type=type, build_url=self.build_url)
285
286     def show_video_lists (self):
287         """List the users video lists (recommendations, my list, etc.)"""
288         cache_id='main_menu'
289         if self.kodi_helper.has_cached_item(cache_id=cache_id):
290             video_list_ids = self.kodi_helper.get_cached_item(cache_id=cache_id)
291         else:
292             # fetch video lists
293             raw_video_list_ids = self.netflix_session.fetch_video_list_ids()
294             # check for any errors
295             if self._is_dirty_response(response=raw_video_list_ids):
296                 return False
297             # parse the video list ids
298             video_list_ids = self.netflix_session.parse_video_list_ids(response_data=raw_video_list_ids)
299             self.kodi_helper.add_cached_item(cache_id=cache_id, contents=video_list_ids)
300         # defines an order for the user list, as Netflix changes the order at every request
301         user_list_order = ['queue', 'continueWatching', 'topTen', 'netflixOriginals', 'trendingNow', 'newRelease', 'popularTitles']
302         # define where to route the user
303         actions = {'recommendations': 'user-items', 'genres': 'user-items', 'search': 'user-items', 'default': 'video_list'}
304         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)
305
306     @log
307     def show_profiles (self):
308         """List the profiles for the active account"""
309         credentials = self.kodi_helper.get_credentials()
310         self.netflix_session.refresh_session_data(account=credentials)
311         profiles = self.netflix_session.profiles
312         return self.kodi_helper.build_profiles_listing(profiles=profiles, action='video_lists', build_url=self.build_url)
313
314     @log
315     def rate_on_netflix (self, video_id):
316         """Rate a show/movie/season/episode on Netflix
317
318         Parameters
319         ----------
320         video_list_id : :obj:`str`
321             ID of the video list that should be displayed
322         """
323         rating = self.kodi_helper.show_rating_dialog()
324         return self.netflix_session.rate_video(video_id=video_id, rating=rating)
325
326     @log
327     def remove_from_list (self, video_id):
328         """Remove an item from 'My List' & refresh the view
329
330         Parameters
331         ----------
332         video_list_id : :obj:`str`
333             ID of the video list that should be displayed
334         """
335         self.netflix_session.remove_from_list(video_id=video_id)
336         return self.kodi_helper.refresh()
337
338     @log
339     def add_to_list (self, video_id):
340         """Add an item to 'My List' & refresh the view
341
342         Parameters
343         ----------
344         video_list_id : :obj:`str`
345             ID of the video list that should be displayed
346         """
347         self.netflix_session.add_to_list(video_id=video_id)
348         return self.kodi_helper.refresh()
349
350     @log
351     def export_to_library (self, video_id, alt_title):
352         """Adds an item to the local library
353
354         Parameters
355         ----------
356         video_id : :obj:`str`
357             ID of the movie or show
358
359         alt_title : :obj:`str`
360             Alternative title (for the folder written to disc)
361         """
362         metadata = self.netflix_session.fetch_metadata(id=video_id)
363         # check for any errors
364         if self._is_dirty_response(response=metadata):
365             return False
366         video = metadata['video']
367
368         if video['type'] == 'movie':
369             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)
370         if video['type'] == 'show':
371             episodes = []
372             for season in video['seasons']:
373                 for episode in season['episodes']:
374                     episodes.append({'season': season['seq'], 'episode': episode['seq'], 'id': episode['id'], 'pin': episode['requiresAdultVerification']})
375
376             self.library.add_show(title=video['title'], alt_title=alt_title, episodes=episodes, build_url=self.build_url)
377         return self.kodi_helper.refresh()
378
379     @log
380     def remove_from_library (self, video_id, season=None, episode=None):
381         """Removes an item from the local library
382
383         Parameters
384         ----------
385         video_id : :obj:`str`
386             ID of the movie or show
387         """
388         metadata = self.netflix_session.fetch_metadata(id=video_id)
389         # check for any errors
390         if self._is_dirty_response(response=metadata):
391             return False
392         video = metadata['video']
393
394         if video['type'] == 'movie':
395             self.library.remove_movie(title=video['title'], year=video['year'])
396         if video['type'] == 'show':
397             self.library.remove_show(title=video['title'])
398         return self.kodi_helper.refresh()
399
400     @log
401     def establish_session(self, account):
402         """Checks if we have an cookie with an active sessions, otherwise tries to login the user
403
404         Parameters
405         ----------
406         account : :obj:`dict` of :obj:`str`
407             Dict containing an email & a password property
408
409         Returns
410         -------
411         bool
412             If we don't have an active session & the user couldn't be logged in
413         """
414         return True if self.netflix_session.is_logged_in(account=account) else self.netflix_session.login(account=account)
415
416     @log
417     def before_routing_action (self, params):
418         """Executes actions before the actual routing takes place:
419
420             - Check if account data has been stored, if not, asks for it
421             - Check if the profile should be changed (and changes if so)
422             - Establishes a session if no action route is given
423
424         Parameters
425         ----------
426         params : :obj:`dict` of :obj:`str`
427             Url query params
428
429         Returns
430         -------
431         :obj:`dict` of :obj:`str`
432             Options that can be provided by this hook & used later in the routing process
433         """
434         options = {}
435         credentials = self.kodi_helper.get_credentials()
436         # check if we have user settings, if not, set em
437         if credentials['email'] == '':
438             email = self.kodi_helper.show_email_dialog()
439             self.kodi_helper.set_setting(key='email', value=email)
440             credentials['email'] = email
441         if credentials['password'] == '':
442             password = self.kodi_helper.show_password_dialog()
443             self.kodi_helper.set_setting(key='password', value=password)
444             credentials['password'] = password
445         # persist & load main menu selection
446         if 'type' in params:
447             self.kodi_helper.set_main_menu_selection(type=params['type'])
448         options['main_menu_selection'] = self.kodi_helper.get_main_menu_selection()
449         # check and switch the profile if needed
450         if self.check_for_designated_profile_change(params=params):
451             self.kodi_helper.invalidate_memcache()
452             self.netflix_session.switch_profile(profile_id=params['profile_id'], account=credentials)
453         # check login, in case of main menu
454         if 'action' not in params:
455             self.establish_session(account=credentials)
456         return options
457
458     def check_for_designated_profile_change (self, params):
459         """Checks if the profile needs to be switched
460
461         Parameters
462         ----------
463         params : :obj:`dict` of :obj:`str`
464             Url query params
465
466         Returns
467         -------
468         bool
469             Profile should be switched or not
470         """
471         # check if we need to switch the user
472         if 'guid' not in self.netflix_session.user_data:
473             return False
474         current_profile_id = self.netflix_session.user_data['guid']
475         return 'profile_id' in params and current_profile_id != params['profile_id']
476
477     def check_for_adult_pin (self, params):
478         """Checks if an adult pin is given in the query params
479
480         Parameters
481         ----------
482         params : :obj:`dict` of :obj:`str`
483             Url query params
484
485         Returns
486         -------
487         bool
488             Adult pin parameter exists or not
489         """
490         return (True, False)[params['pin'] == 'True']
491
492     def parse_paramters (self, paramstring):
493         """Tiny helper to convert a url paramstring into a dictionary
494
495         Parameters
496         ----------
497         paramstring : :obj:`str`
498             Url query params (in url string notation)
499
500         Returns
501         -------
502         :obj:`dict` of :obj:`str`
503             Url query params (as a dictionary)
504         """
505         return dict(parse_qsl(paramstring))
506
507     def _is_expired_session (self, response):
508         """Checks if a response error is based on an invalid session
509
510         Parameters
511         ----------
512         response : :obj:`dict` of :obj:`str`
513             Error response object
514
515         Returns
516         -------
517         bool
518             Error is based on an invalid session
519         """
520         return 'error' in response and 'code' in response and str(response['code']) == '401'
521
522     def _is_dirty_response (self, response):
523         """Checks if a response contains an error & if the error is based on an invalid session, it tries a relogin
524
525         Parameters
526         ----------
527         response : :obj:`dict` of :obj:`str`
528             Success response object or Error response object
529
530         Returns
531         -------
532         bool
533             Response contains error or not
534         """
535         # check for any errors
536         if 'error' in response:
537             # check if we do not have a valid session, in case that happens: (re)login
538             if self._is_expired_session(response=response):
539                 if self.establish_session(account=self.kodi_helper.get_credentials()):
540                     return True
541             message = response['message'] if 'message' in response else ''
542             code = response['code'] if 'code' in response else ''
543             self.log(msg='[ERROR]: ' + message + '::' + str(code))
544             return True
545         return False
546
547     def build_url(self, query):
548         """Tiny helper to transform a dict into a url + querystring
549
550         Parameters
551         ----------
552         query : :obj:`dict` of  :obj:`str`
553             List of paramters to be url encoded
554
555         Returns
556         -------
557         str
558             Url + querystring based on the param
559         """
560         return self.base_url + '?' + urllib.urlencode(query)