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