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