feat(init): Repository init
[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 self.netflix_session.is_logged_in(account=account) != True:
59             if self.establish_session(account=account) != True:
60                 return self.kodi_helper.show_login_failed_notification()
61
62         # check if we need to execute any actions before the actual routing
63         # gives back a dict of options routes might need
64         options = self.before_routing_action(params=params)
65
66         # check if one of the before routing options decided to killthe routing
67         if 'exit' in options:
68             return False
69         if 'action' not in params.keys():
70             # show the profiles
71             self.show_profiles()
72         elif params['action'] == 'video_lists':
73             # list lists that contain other lists (starting point with recommendations, search, etc.)
74             return self.show_video_lists()
75         elif params['action'] == 'video_list':
76             # show a list of shows/movies
77             type = None if 'type' not in params.keys() else params['type']
78             return self.show_video_list(video_list_id=params['video_list_id'], type=type)
79         elif params['action'] == 'season_list':
80             # list of seasons for a show
81             return self.show_seasons(show_id=params['show_id'])
82         elif params['action'] == 'episode_list':
83             # list of episodes for a season
84             return self.show_episode_list(season_id=params['season_id'])
85         elif params['action'] == 'rating':
86             return self.rate_on_netflix(video_id=params['id'])
87         elif params['action'] == 'remove_from_list':
88             # removes a title from the users list on Netflix
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             return self.add_to_list(video_id=params['id'])
93         elif params['action'] == 'user-items' and params['type'] != 'search':
94             # display the lists (recommendations, genres, etc.)
95             return self.show_user_list(type=params['type'])
96         elif params['action'] == 'play_video':
97             # play a video, check for adult pin if needed
98             adult_pin = None
99             if self.check_for_adult_pin(params=params):
100                 adult_pin = self.kodi_helper.show_adult_pin_dialog()
101                 if self.netflix_session.send_adult_pin(adult_pin=adult_pin) != True:
102                     return self.kodi_helper.show_wrong_adult_pin_notification()
103             self.play_video(video_id=params['video_id'], start_offset=params['start_offset'])
104         elif params['action'] == 'user-items' and params['type'] == 'search':
105             # if the user requested a search, ask for the term
106             term = self.kodi_helper.show_search_term_dialog()
107             return self.show_search_results(term=term)
108         else:
109             raise ValueError('Invalid paramstring: {0}!'.format(paramstring))
110         return True
111
112     @log
113     def play_video (self, video_id, start_offset):
114         """Starts video playback
115
116         Note: This is just a dummy, inputstream is needed to play the vids
117
118         Parameters
119         ----------
120         video_id : :obj:`str`
121             ID of the video that should be played
122
123         start_offset : :obj:`str`
124             Offset to resume playback from (in seconds)
125         """
126         # widevine esn
127         esn = self.netflix_session.esn
128         return self.kodi_helper.play_item(esn=esn, video_id=video_id, start_offset=start_offset)
129
130     def show_search_results (self, term):
131         """Display a list of search results
132
133         Parameters
134         ----------
135         term : :obj:`str`
136             String to lookup
137
138         Returns
139         -------
140         bool
141             If no results are available
142         """
143         has_search_results = False
144         search_results_raw = self.netflix_session.fetch_search_results(term=term)
145         # check for any errors
146         if self._is_dirty_response(response=search_results_raw):
147             return False
148
149         # determine if we found something
150         if 'search' in search_results_raw['value']:
151             for key in search_results_raw['value']['search'].keys():
152                 if self.netflix_session._is_size_key(key=key) == False:
153                     has_search_results = search_results_raw['value']['search'][key]['titles']['length'] > 0
154
155         # display that we haven't found a thing
156         if has_search_results == False:
157             return self.kodi_helper.build_no_search_results_available(build_url=self.build_url, action='search')
158
159         # list the search results
160         search_results = self.netflix_session.parse_search_results(response_data=search_results_raw)
161         # add more menaingful data to the search results
162         raw_search_contents = self.netflix_session.fetch_video_list_information(video_ids=search_results.keys())
163         # check for any errors
164         if self._is_dirty_response(response=raw_search_contents):
165             return False
166         search_contents = self.netflix_session.parse_video_list(response_data=raw_search_contents)
167         actions = {'movie': 'play_video', 'show': 'season_list'}
168         return self.kodi_helper.build_search_result_listing(video_list=search_contents, actions=actions, build_url=self.build_url)
169
170     def show_user_list (self, type):
171         """List the users lists for shows/movies for recommendations/genres based on the given type
172
173         Parameters
174         ----------
175         user_list_id : :obj:`str`
176             Type of list to display
177         """
178         video_list_ids_raw = self.netflix_session.fetch_video_list_ids()
179         # check for any errors
180         if self._is_dirty_response(response=video_list_ids_raw):
181             return False
182         video_list_ids = self.netflix_session.parse_video_list_ids(response_data=video_list_ids_raw)
183         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)
184
185     def show_episode_list (self, season_id):
186         """Lists all episodes for a given season
187
188         Parameters
189         ----------
190         season_id : :obj:`str`
191             ID of the season episodes should be displayed for
192         """
193         raw_episode_list = self.netflix_session.fetch_episodes_by_season(season_id=season_id)
194         # check for any errors
195         if self._is_dirty_response(response=raw_episode_list):
196             return False
197         # parse the raw Netflix data
198         episode_list = self.netflix_session.parse_episodes_by_season(response_data=raw_episode_list)
199
200         # sort seasons by number (they´re coming back unsorted from the api)
201         episodes_sorted = []
202         for episode_id in episode_list:
203             episodes_sorted.append(int(episode_list[episode_id]['episode']))
204             episodes_sorted.sort()
205
206         # list the episodes
207         return self.kodi_helper.build_episode_listing(episodes_sorted=episodes_sorted, episode_list=episode_list, build_url=self.build_url)
208
209     def show_seasons (self, show_id):
210         """Lists all seasons for a given show
211
212         Parameters
213         ----------
214         show_id : :obj:`str`
215             ID of the show seasons should be displayed for
216
217         Returns
218         -------
219         bool
220             If no seasons are available
221         """
222         season_list_raw = self.netflix_session.fetch_seasons_for_show(id=show_id);
223         # check for any errors
224         if self._is_dirty_response(response=season_list_raw):
225             return False
226         # check if we have sesons, announced shows that are not available yet have none
227         if 'seasons' not in season_list_raw['value']:
228             return self.kodi_helper.build_no_seasons_available()
229         # parse the seasons raw response from Netflix
230         season_list = self.netflix_session.parse_seasons(id=show_id, response_data=season_list_raw)
231         # sort seasons by index by default (they´re coming back unsorted from the api)
232         seasons_sorted = []
233         for season_id in season_list:
234             seasons_sorted.append(int(season_list[season_id]['shortName'].split(' ')[1]))
235             seasons_sorted.sort()
236         return self.kodi_helper.build_season_listing(seasons_sorted=seasons_sorted, season_list=season_list, build_url=self.build_url)
237
238     def show_video_list (self, video_list_id, type):
239         """List shows/movies based on the given video list id
240
241         Parameters
242         ----------
243         video_list_id : :obj:`str`
244             ID of the video list that should be displayed
245
246         type : :obj:`str`
247             None or 'queue' f.e. when it´s a special video lists
248         """
249         raw_video_list = self.netflix_session.fetch_video_list(list_id=video_list_id)
250         # check for any errors
251         if self._is_dirty_response(response=raw_video_list):
252             return False
253         # parse the video list ids
254         video_list = self.netflix_session.parse_video_list(response_data=raw_video_list)
255         actions = {'movie': 'play_video', 'show': 'season_list'}
256         return self.kodi_helper.build_video_listing(video_list=video_list, actions=actions, type=type, build_url=self.build_url)
257
258     def show_video_lists (self):
259         """List the users video lists (recommendations, my list, etc.)"""
260         # fetch video lists
261         raw_video_list_ids = self.netflix_session.fetch_video_list_ids()
262         # check for any errors
263         if self._is_dirty_response(response=raw_video_list_ids):
264             return False
265         # parse the video list ids
266         video_list_ids = self.netflix_session.parse_video_list_ids(response_data=raw_video_list_ids)
267         # defines an order for the user list, as Netflix changes the order at every request
268         user_list_order = ['queue', 'continueWatching', 'topTen', 'netflixOriginals', 'trendingNow', 'newRelease', 'popularTitles']
269         # define where to route the user
270         actions = {'recommendations': 'user-items', 'genres': 'user-items', 'search': 'user-items', 'default': 'video_list'}
271         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)
272
273     def show_profiles (self):
274         """List the profiles for the active account"""
275         self.netflix_session.refresh_session_data(account=self.kodi_helper.get_credentials())
276         profiles = self.netflix_session.profiles
277         return self.kodi_helper.build_profiles_listing(profiles=profiles, action='video_lists', build_url=self.build_url)
278
279     @log
280     def rate_on_netflix (self, video_id):
281         """Rate a show/movie/season/episode on Netflix
282
283         Parameters
284         ----------
285         video_list_id : :obj:`str`
286             ID of the video list that should be displayed
287         """
288         rating = self.kodi_helper.show_rating_dialog()
289         return self.netflix_session.rate_video(video_id=video_id, rating=rating)
290
291     @log
292     def remove_from_list (self, video_id):
293         """Remove an item from 'My List' & refresh the view
294
295         Parameters
296         ----------
297         video_list_id : :obj:`str`
298             ID of the video list that should be displayed
299         """
300         self.netflix_session.remove_from_list(video_id=video_id)
301         return self.kodi_helper.refresh()
302
303     @log
304     def add_to_list (self, video_id):
305         """Add an item to 'My List' & refresh the view
306
307         Parameters
308         ----------
309         video_list_id : :obj:`str`
310             ID of the video list that should be displayed
311         """
312         self.netflix_session.add_to_list(video_id=video_id)
313         return self.kodi_helper.refresh()
314
315     @log
316     def establish_session(self, account):
317         """Checks if we have an cookie with an active sessions, otherwise tries to login the user
318
319         Parameters
320         ----------
321         account : :obj:`dict` of :obj:`str`
322             Dict containing an email & a password property
323
324         Returns
325         -------
326         bool
327             If we don't have an active session & the user couldn't be logged in
328         """
329         if self.netflix_session.is_logged_in(account=account):
330             return True
331         else:
332             return self.netflix_session.login(account=account)
333
334     @log
335     def before_routing_action (self, params):
336         """Executes actions before the actual routing takes place:
337
338             - Check if account data has been stored, if not, asks for it
339             - Check if the profile should be changed (and changes if so)
340             - Establishes a session if no action route is given
341
342         Parameters
343         ----------
344         params : :obj:`dict` of :obj:`str`
345             Url query params
346
347         Returns
348         -------
349         :obj:`dict` of :obj:`str`
350             Options that can be provided by this hook & used later in the routing process
351         """
352         options = {}
353         credentials = self.kodi_helper.get_credentials()
354         # check if we have user settings, if not, set em
355         if credentials['email'] == '':
356             email = self.kodi_helper.show_email_dialog()
357             self.kodi_helper.set_setting(key='email', value=email)
358         if credentials['password'] == '':
359             password = self.kodi_helper.show_password_dialog()
360             self.kodi_helper.set_setting(key='password', value=password)
361         # persist & load main menu selection
362         if 'type' in params:
363             self.kodi_helper.set_main_menu_selection(type=params['type'])
364         options['main_menu_selection'] = self.kodi_helper.get_main_menu_selection()
365         # check and switch the profile if needed
366         if self.check_for_designated_profile_change(params=params):
367             self.netflix_session.switch_profile(profile_id=params['profile_id'], account=credentials)
368         # check login, in case of main menu
369         if 'action' not in params:
370             self.establish_session(account=credentials)
371         return options
372
373     def check_for_designated_profile_change (self, params):
374         """Checks if the profile needs to be switched
375
376         Parameters
377         ----------
378         params : :obj:`dict` of :obj:`str`
379             Url query params
380
381         Returns
382         -------
383         bool
384             Profile should be switched or not
385         """
386         # check if we need to switch the user
387         if 'guid' not in self.netflix_session.user_data:
388             return False
389         current_profile_id = self.netflix_session.user_data['guid']
390         return 'profile_id' in params and current_profile_id != params['profile_id']
391
392     def check_for_adult_pin (self, params):
393         """Checks if an adult pin is given in the query params
394
395         Parameters
396         ----------
397         params : :obj:`dict` of :obj:`str`
398             Url query params
399
400         Returns
401         -------
402         bool
403             Adult pin parameter exists or not
404         """
405         return (True, False)[params['pin'] == 'True']
406
407     def parse_paramters (self, paramstring):
408         """Tiny helper to convert a url paramstring into a dictionary
409
410         Parameters
411         ----------
412         paramstring : :obj:`str`
413             Url query params (in url string notation)
414
415         Returns
416         -------
417         :obj:`dict` of :obj:`str`
418             Url query params (as a dictionary)
419         """
420         return dict(parse_qsl(paramstring))
421
422     def _is_expired_session (self, response):
423         """Checks if a response error is based on an invalid session
424
425         Parameters
426         ----------
427         response : :obj:`dict` of :obj:`str`
428             Error response object
429
430         Returns
431         -------
432         bool
433             Error is based on an invalid session
434         """
435         return 'error' in response and 'code' in response and str(response['code']) == '401'
436
437     def _is_dirty_response (self, response):
438         """Checks if a response contains an error & if the error is based on an invalid session, it tries a relogin
439
440         Parameters
441         ----------
442         response : :obj:`dict` of :obj:`str`
443             Success response object or Error response object
444
445         Returns
446         -------
447         bool
448             Response contains error or not
449         """
450         # check for any errors
451         if 'error' in response:
452             # check if we do not have a valid session, in case that happens: (re)login
453             if self._is_expired_session(response=response):
454                 if self.establish_session(account=self.kodi_helper.get_credentials()):
455                     return True
456             self.log(msg='[ERROR]: ' + response['message'] + '::' + str(response['code']))
457             return True
458         return False
459
460     def build_url(self, query):
461         """Tiny helper to transform a dict into a url + querystring
462
463         Parameters
464         ----------
465         query : :obj:`dict` of  :obj:`str`
466             List of paramters to be url encoded
467
468         Returns
469         -------
470         str
471             Url + querystring based on the param
472         """
473         return self.base_url + '?' + urllib.urlencode(query)