fix(main-menue): Removed sending of root lolomo as it would need to be refreshed...
[plugin.video.netflix.git] / resources / lib / NetflixSession.py
1 #!/usr/bin/env python
2 # -*- coding: utf-8 -*-
3 # Module: NetflixSession
4 # Created on: 13.01.2017
5
6 import os
7 import json
8 from requests import session, cookies
9 from urllib import quote, unquote
10 from time import time
11 from base64 import urlsafe_b64encode
12 from bs4 import BeautifulSoup, SoupStrainer
13 from utils import noop
14 try:
15    import cPickle as pickle
16 except:
17    import pickle
18
19 class NetflixSession:
20     """Helps with login/session management of Netflix users & API data fetching"""
21
22     base_url = 'https://www.netflix.com'
23     """str: Secure Netflix url"""
24
25     urls = {
26         'login': '/login',
27         'browse': '/profiles/manage',
28         'video_list_ids': '/preflight',
29         'shakti': '/pathEvaluator',
30         'profiles':  '/profiles/manage',
31         'switch_profiles': '/profiles/switch',
32         'adult_pin': '/pin/service',
33         'metadata': '/metadata',
34         'set_video_rating': '/setVideoRating',
35         'update_my_list': '/playlistop'
36     }
37     """:obj:`dict` of :obj:`str` List of all static endpoints for HTML/JSON POST/GET requests"""
38
39     video_list_keys = ['user', 'genres', 'recommendations']
40     """:obj:`list` of :obj:`str` Divide the users video lists into 3 different categories (for easier digestion)"""
41
42     profiles = {}
43     """:obj:`dict`
44         Dict of user profiles, user id is the key:
45
46         "72ERT45...": {
47             "profileName": "username",
48             "avatar": "http://..../avatar.png",
49             "id": "72ERT45...",
50             "isAccountOwner": False,
51             "isActive": True,
52             "isFirstUse": False
53         }
54     """
55
56     user_data = {}
57     """:obj:`dict`
58         dict of user data (used for authentication):
59
60         {
61             "guid": "72ERT45...",
62             "authURL": "145637....",
63             "gpsModel": "harris"
64         }
65     """
66
67     api_data = {}
68     """:obj:`dict`
69         dict of api data (used to build up the api urls):
70
71         {
72             "API_BASE_URL": "/shakti",
73             "API_ROOT": "https://www.netflix.com/api",
74             "BUILD_IDENTIFIER": "113b89c9", "
75             ICHNAEA_ROOT": "/ichnaea"
76         }
77     """
78
79     esn = ''
80     """str: ESN - something like: NFCDCH-MC-D7D6F54LOPY8J416T72MQXX3RD20ME"""
81
82     def __init__(self, cookie_path, data_path, verify_ssl=True, log_fn=noop):
83         """Stores the cookie path for later use & instanciates a requests
84            session with a proper user agent & stored cookies/data if available
85
86         Parameters
87         ----------
88         cookie_path : :obj:`str`
89             Cookie location
90
91         data_path : :obj:`str`
92             User data cache location
93
94         log_fn : :obj:`fn`
95              optional log function
96         """
97         self.cookie_path = cookie_path
98         self.data_path = data_path
99         self.verify_ssl = verify_ssl
100         self.log = log_fn
101
102         # start session, fake chrome on the current platform (so that we get a proper widevine esn) & enable gzip
103         self.session = session()
104         self.session.headers.update({
105             'User-Agent': self._get_user_agent_for_current_platform(),
106             'Accept-Encoding': 'gzip'
107         })
108
109     def parse_login_form_fields (self, form_soup):
110         """Fetches all the inputfields from the login form, so that we
111            can build a request with all the fields needed besides the known email & password ones
112
113         Parameters
114         ----------
115         form_soup : :obj:`BeautifulSoup`
116             Instance of an BeautifulSoup documet or node containing the login form
117
118         Returns
119         -------
120             :obj:`dict` of :obj:`str`
121                 Dictionary of all input fields with their name as the key & the default
122                 value from the form field
123         """
124         login_input_fields = {}
125         login_inputs = form_soup.find_all('input')
126         # gather all form fields, set an empty string as the default value
127         for item in login_inputs:
128             keys = dict(item.attrs).keys()
129             if 'name' in keys and 'value' not in keys:
130                 login_input_fields[item['name']] = ''
131             elif 'name' in keys and 'value' in keys:
132                 login_input_fields[item['name']] = item['value']
133         return login_input_fields
134
135     def extract_inline_netflix_page_data (self, page_soup):
136         """Extracts all <script/> tags from the given document and parses the contents of each one of `em.
137         The contents of the parsable tags looks something like this:
138             <script>window.netflix = window.netflix || {} ; netflix.notification = {"constants":{"sessionLength":30,"ownerToken":"ZDD...};</script>
139         We use a JS parser to generate an AST of the code given & then parse that AST into a python dict.
140         This should be okay, as we´re only interested in a few static values & put the rest aside
141
142         Parameters
143         ----------
144         page_soup : :obj:`BeautifulSoup`
145             Instance of an BeautifulSoup document or node containing the complete page contents
146         Returns
147         -------
148             :obj:`list` of :obj:`dict`
149                 List of all the serialized data pulled out of the pagws <script/> tags
150         """
151         scripts = page_soup.find_all('script', attrs={'src': None});
152         self.log('Trying sloppy inline data parser')
153         inline_data = self._sloppy_parse_inline_data(scripts=scripts)
154         if self._verfify_auth_and_profiles_data(data=inline_data) != False:
155             self.log('Sloppy inline data parsing successfull')
156             return inline_data
157         self.log('Sloppy inline parser failed, trying JS parser')
158         return self._accurate_parse_inline_data(scripts=scripts)
159
160     def is_logged_in (self, account):
161         """Determines if a user is already logged in (with a valid cookie),
162            by fetching the index page with the current cookie & checking for the
163            `membership status` user data
164
165         Parameters
166         ----------
167         account : :obj:`dict` of :obj:`str`
168             Dict containing an email, country & a password property
169
170         Returns
171         -------
172         bool
173             User is already logged in (e.g. Cookie is valid) or not
174         """
175         is_logged_in = False
176         # load cookies
177         account_hash = self._generate_account_hash(account=account)
178         if self._load_cookies(filename=self.cookie_path + '_' + account_hash) == False:
179             return False
180         if self._load_data(filename=self.data_path + '_' + account_hash) == False:
181             # load the profiles page (to verify the user)
182             response = self._session_get(component='profiles')
183
184             # parse out the needed inline information
185             only_script_tags = SoupStrainer('script')
186             page_soup = BeautifulSoup(response.text, 'html.parser', parse_only=only_script_tags)
187             page_data = self._parse_page_contents(page_soup=page_soup)
188
189             # check if the cookie is still valid
190             for item in page_data:
191                 if 'profilesList' in dict(item).keys():
192                     if item['profilesList']['summary']['length'] >= 1:
193                         is_logged_in = True
194             return is_logged_in
195         return True
196
197     def logout (self):
198         """Delete all cookies and session data
199
200         Parameters
201         ----------
202         account : :obj:`dict` of :obj:`str`
203             Dict containing an email, country & a password property
204
205         """
206         self._delete_cookies(path=self.cookie_path)
207         self._delete_data(path=self.data_path)
208
209     def login (self, account):
210         """Try to log in a user with its credentials & stores the cookies if the action is successfull
211
212            Note: It fetches the HTML of the login page to extract the fields of the login form,
213            again, this is dirty, but as the fields & their values could change at any time, this
214            should be the most reliable way of retrieving the information
215
216         Parameters
217         ----------
218         account : :obj:`dict` of :obj:`str`
219             Dict containing an email, country & a password property
220
221         Returns
222         -------
223         bool
224             User could be logged in or not
225         """
226         response = self._session_get(component='login')
227         if response.status_code != 200:
228             return False;
229
230         # collect all the login fields & their contents and add the user credentials
231         page_soup = BeautifulSoup(response.text, 'html.parser')
232         login_form = page_soup.find(attrs={'class' : 'ui-label-text'}).findPrevious('form')
233         login_payload = self.parse_login_form_fields(form_soup=login_form)
234         if 'email' in login_payload:
235             login_payload['email'] = account['email']
236         if 'emailOrPhoneNumber' in login_payload:
237             login_payload['emailOrPhoneNumber'] = account['email']
238         login_payload['password'] = account['password']
239
240         # perform the login
241         login_response = self._session_post(component='login', data=login_payload)
242         login_soup = BeautifulSoup(login_response.text, 'html.parser')
243
244         # we know that the login was successfull if we find an HTML element with the class of 'profile-name'
245         if login_soup.find(attrs={'class' : 'profile-name'}) or login_soup.find(attrs={'class' : 'profile-icon'}):
246             # parse the needed inline information & store cookies for later requests
247             self._parse_page_contents(page_soup=login_soup)
248             account_hash = self._generate_account_hash(account=account)
249             self._save_cookies(filename=self.cookie_path + '_' + account_hash)
250             self._save_data(filename=self.data_path + '_' + account_hash)
251             return True
252         else:
253             return False
254
255     def switch_profile (self, profile_id, account):
256         """Switch the user profile based on a given profile id
257
258         Note: All available profiles & their ids can be found in the ´profiles´ property after a successfull login
259
260         Parameters
261         ----------
262         profile_id : :obj:`str`
263             User profile id
264
265         account : :obj:`dict` of :obj:`str`
266             Dict containing an email, country & a password property
267
268         Returns
269         -------
270         bool
271             User could be switched or not
272         """
273         payload = {
274             'switchProfileGuid': profile_id,
275             '_': int(time()),
276             'authURL': self.user_data['authURL']
277         }
278
279         response = self._session_get(component='switch_profiles', type='api', params=payload)
280         if response.status_code != 200:
281             return False
282
283         account_hash = self._generate_account_hash(account=account)
284         self.user_data['guid'] = profile_id;
285         return self._save_data(filename=self.data_path + '_' + account_hash)
286
287     def send_adult_pin (self, pin):
288         """Send the adult pin to Netflix in case an adult rated video requests it
289
290         Note: Once entered, it should last for the complete session (Not so sure about this)
291
292         Parameters
293         ----------
294         pin : :obj:`str`
295             The users adult pin
296
297         Returns
298         -------
299         bool
300             Pin was accepted or not
301         or
302         :obj:`dict` of :obj:`str`
303             Api call error
304         """
305         payload = {
306             'pin': pin,
307             'authURL': self.user_data['authURL']
308         }
309         response = self._session_get(component='adult_pin', params=payload)
310         pin_response = self._process_response(response=response, component=self._get_api_url_for(component='adult_pin'))
311         keys = pin_response.keys()
312         if 'success' in keys:
313             return True
314         if 'error' in keys:
315             return pin_response
316         return False
317
318     def add_to_list (self, video_id):
319         """Adds a video to "my list" on Netflix
320
321         Parameters
322         ----------
323         video_id : :obj:`str`
324             ID of th show/video/movie to be added
325
326         Returns
327         -------
328         bool
329             Adding was successfull
330         """
331         return self._update_my_list(video_id=video_id, operation='add')
332
333     def remove_from_list (self, video_id):
334         """Removes a video from "my list" on Netflix
335
336         Parameters
337         ----------
338         video_id : :obj:`str`
339             ID of th show/video/movie to be removed
340
341         Returns
342         -------
343         bool
344             Removing was successfull
345         """
346         return self._update_my_list(video_id=video_id, operation='remove')
347
348     def rate_video (self, video_id, rating):
349         """Rate a video on Netflix
350
351         Parameters
352         ----------
353         video_id : :obj:`str`
354             ID of th show/video/movie to be rated
355
356         rating : :obj:`int`
357             Rating, must be between 0 & 10
358
359         Returns
360         -------
361         bool
362             Rating successfull or not
363         """
364
365         # dirty rating validation
366         ratun = int(rating)
367         if rating > 10 or rating < 0:
368             return False
369
370         # In opposition to Kodi, Netflix uses a rating from 0 to in 0.5 steps
371         if rating != 0:
372             rating = rating / 2
373
374         headers = {
375             'Content-Type': 'application/json',
376             'Accept': 'application/json, text/javascript, */*',
377         }
378
379         params = {
380             'titleid': video_id,
381             'rating': rating
382         }
383
384         payload = json.dumps({
385             'authURL': self.user_data['authURL']
386         })
387
388         response = self._session_post(component='set_video_rating', type='api', params=params, headers=headers, data=payload)
389         return response.status_code == 200
390
391     def parse_video_list_ids (self, response_data):
392         """Parse the list of video ids e.g. rip out the parts we need
393
394         Parameters
395         ----------
396         response_data : :obj:`dict` of :obj:`str`
397             Parsed response JSON from the ´fetch_video_list_ids´ call
398
399         Returns
400         -------
401         :obj:`dict` of :obj:`dict`
402             Video list ids in the format:
403
404             {
405                 "genres": {
406                     "3589e2c6-ca3b-48b4-a72d-34f2c09ffbf4_11568367": {
407                         "displayName": "US-Serien",
408                         "id": "3589e2c6-ca3b-48b4-a72d-34f2c09ffbf4_11568367",
409                         "index": 3,
410                         "name": "genre",
411                         "size": 38
412                     },
413                     "3589e2c6-ca3b-48b4-a72d-34f2c09ffbf4_11568368": {
414                         "displayName": ...
415                     },
416                 },
417                 "user": {
418                     "3589e2c6-ca3b-48b4-a72d-34f2c09ffbf4_11568364": {
419                         "displayName": "Meine Liste",
420                         "id": "3589e2c6-ca3b-48b4-a72d-34f2c09ffbf4_11568364",
421                         "index": 0,
422                         "name": "queue",
423                         "size": 2
424                     },
425                     "3589e2c6-ca3b-48b4-a72d-34f2c09ffbf4_11568365": {
426                         "displayName": ...
427                     },
428                 },
429                 "recommendations": {
430                     "3589e2c6-ca3b-48b4-a72d-34f2c09ffbf4_11568382": {
431                         "displayName": "Passend zu Family Guy",
432                         "id": "3589e2c6-ca3b-48b4-a72d-34f2c09ffbf4_11568382",
433                         "index": 18,
434                         "name": "similars",
435                         "size": 33
436                     },
437                     "3589e2c6-ca3b-48b4-a72d-34f2c09ffbf4_11568397": {
438                         "displayName": ...
439                     }
440                 }
441             }
442         """
443         # prepare the return dictionary
444         video_list_ids = {}
445         for key in self.video_list_keys:
446             video_list_ids[key] = {}
447
448         # subcatogorize the lists by their context
449         video_lists = response_data['lists']
450         for video_list_id in video_lists.keys():
451             video_list = video_lists[video_list_id]
452             if video_list['context'] == 'genre':
453                 video_list_ids['genres'].update(self.parse_video_list_ids_entry(id=video_list_id, entry=video_list))
454             elif video_list['context'] == 'similars' or video_list['context'] == 'becauseYouAdded':
455                 video_list_ids['recommendations'].update(self.parse_video_list_ids_entry(id=video_list_id, entry=video_list))
456             else:
457                 video_list_ids['user'].update(self.parse_video_list_ids_entry(id=video_list_id, entry=video_list))
458
459         return video_list_ids
460
461     def parse_video_list_ids_entry (self, id, entry):
462         """Parse a video id entry e.g. rip out the parts we need
463
464         Parameters
465         ----------
466         response_data : :obj:`dict` of :obj:`str`
467             Dictionary entry from the ´fetch_video_list_ids´ call
468
469         Returns
470         -------
471         id : :obj:`str`
472             Unique id of the video list
473
474         entry : :obj:`dict` of :obj:`str`
475             Video list entry in the format:
476
477             "3589e2c6-ca3b-48b4-a72d-34f2c09ffbf4_11568382": {
478                 "displayName": "Passend zu Family Guy",
479                 "id": "3589e2c6-ca3b-48b4-a72d-34f2c09ffbf4_11568382",
480                 "index": 18,
481                 "name": "similars",
482                 "size": 33
483             }
484         """
485         return {
486             id: {
487                 'id': id,
488                 'index': entry['index'],
489                 'name': entry['context'],
490                 'displayName': entry['displayName'],
491                 'size': entry['length']
492             }
493         }
494
495     def parse_search_results (self, response_data):
496         """Parse the list of search results, rip out the parts we need
497            and extend it with detailed show informations
498
499         Parameters
500         ----------
501         response_data : :obj:`dict` of :obj:`str`
502             Parsed response JSON from the `fetch_search_results` call
503
504         Returns
505         -------
506         :obj:`dict` of :obj:`dict` of :obj:`str`
507             Search results in the format:
508
509             {
510                 "70136140": {
511                     "boxarts": "https://art-s.nflximg.net/0d7af/d5c72668c35d3da65ae031302bd4ae1bcc80d7af.jpg",
512                     "detail_text": "Die legend\u00e4re und mit 13 Emmys nominierte Serie von Gene Roddenberry inspirierte eine ganze Generation.",
513                     "id": "70136140",
514                     "season_id": "70109435",
515                     "synopsis": "Unter Befehl von Captain Kirk begibt sich die Besatzung des Raumschiffs Enterprise in die Tiefen des Weltraums, wo sie fremde Galaxien und neue Zivilisationen erforscht.",
516                     "title": "Star Trek",
517                     "type": "show"
518                 },
519                 "70158329": {
520                     "boxarts": ...
521                 }
522             }
523         """
524         search_results = {}
525         raw_search_results = response_data['value']['videos']
526         for entry_id in raw_search_results:
527             if self._is_size_key(key=entry_id) == False:
528                 # fetch information about each show & build up a proper search results dictionary
529                 show = self.parse_show_list_entry(id=entry_id, entry=raw_search_results[entry_id])
530                 show[entry_id].update(self.parse_show_information(id=entry_id, response_data=self.fetch_show_information(id=entry_id, type=show[entry_id]['type'])))
531                 search_results.update(show)
532         return search_results
533
534     def parse_show_list_entry (self, id, entry):
535         """Parse a show entry e.g. rip out the parts we need
536
537         Parameters
538         ----------
539         response_data : :obj:`dict` of :obj:`str`
540             Dictionary entry from the ´fetch_show_information´ call
541
542         id : :obj:`str`
543             Unique id of the video list
544
545         Returns
546         -------
547         entry : :obj:`dict` of :obj:`dict` of :obj:`str`
548             Show list entry in the format:
549
550             {
551                 "3589e2c6-ca3b-48b4-a72d-34f2c09ffbf4_11568382": {
552                     "id": "3589e2c6-ca3b-48b4-a72d-34f2c09ffbf4_11568382",
553                     "title": "Enterprise",
554                     "boxarts": "https://art-s.nflximg.net/.../smth.jpg",
555                     "type": "show"
556                 }
557             }
558         """
559         return {
560             id: {
561                 'id': id,
562                 'title': entry['title'],
563                 'boxarts': entry['boxarts']['_342x192']['jpg']['url'],
564                 'type': entry['summary']['type']
565             }
566         }
567
568     def parse_video_list (self, response_data):
569         """Parse a list of videos
570
571         Parameters
572         ----------
573         response_data : :obj:`dict` of :obj:`str`
574             Parsed response JSON from the `fetch_video_list` call
575
576         Returns
577         -------
578         :obj:`dict` of :obj:`dict`
579             Video list in the format:
580
581             {
582                 "372203": {
583                     "artwork": null,
584                     "boxarts": {
585                       "big": "https://art-s.nflximg.net/5e7d3/b3b48749843fd3a36db11c319ffa60f96b55e7d3.jpg",
586                       "small": "https://art-s.nflximg.net/57543/a039845c2eb9186dc26019576d895bf5a1957543.jpg"
587                     },
588                     "cast": [
589                       "Christine Elise",
590                       "Brad Dourif",
591                       "Grace Zabriskie",
592                       "Jenny Agutter",
593                       "John Lafia",
594                       "Gerrit Graham",
595                       "Peter Haskell",
596                       "Alex Vincent",
597                       "Beth Grant"
598                     ],
599                     "creators": [],
600                     "directors": [],
601                     "episode_count": null,
602                     "genres": [
603                       "Horrorfilme"
604                     ],
605                     "id": "372203",
606                     "in_my_list": true,
607                     "interesting_moment": "https://art-s.nflximg.net/09544/ed4b3073394b4469fb6ec22b9df81a4f5cb09544.jpg",
608                     "list_id": "9588df32-f957-40e4-9055-1f6f33b60103_46891306",
609                     "maturity": {
610                       "board": "FSK",
611                       "description": "Nur f\u00fcr Erwachsene geeignet.",
612                       "level": 1000,
613                       "value": "18"
614                     },
615                     "quality": "540",
616                     "rating": 3.1707757,
617                     "regular_synopsis": "Ein Spielzeughersteller erweckt aus Versehen die Seele der M\u00f6rderpuppe Chucky erneut zum Leben, die sich unmittelbar wieder ihren m\u00f6rderischen Aktivit\u00e4ten zuwendet.",
618                     "runtime": 5028,
619                     "seasons_count": null,
620                     "seasons_label": null,
621                     "synopsis": "Die allseits beliebte, von D\u00e4monen besessene M\u00f6rderpuppe ist wieder da und verbreitet erneut Horror und Schrecken.",
622                     "tags": [
623                       "Brutal",
624                       "Spannend"
625                     ],
626                     "title": "Chucky 2 \u2013 Die M\u00f6rderpuppe ist wieder da",
627                     "type": "movie",
628                     "watched": false,
629                     "year": 1990
630                 },
631                 "80011356": {
632                     "artwork": null,
633                     "boxarts": {
634                       "big": "https://art-s.nflximg.net/7c10d/5dcc3fc8f08487e92507627068cfe26ef727c10d.jpg",
635                       "small": "https://art-s.nflximg.net/5bc0e/f3be361b8c594929062f90a8d9c6eb57fb75bc0e.jpg"
636                     },
637                     "cast": [
638                       "Bjarne M\u00e4del"
639                     ],
640                     "creators": [],
641                     "directors": [
642                       "Arne Feldhusen"
643                     ],
644                     "episode_count": 24,
645                     "genres": [
646                       "Deutsche Serien",
647                       "Serien",
648                       "Comedyserien"
649                     ],
650                     "id": "80011356",
651                     "in_my_list": true,
652                     "interesting_moment": "https://art-s.nflximg.net/0188e/19cd705a71ee08c8d2609ae01cd8a97a86c0188e.jpg",
653                     "list_id": "9588df32-f957-40e4-9055-1f6f33b60103_46891306",
654                     "maturity": {
655                       "board": "FSF",
656                       "description": "Geeignet ab 12 Jahren.",
657                       "level": 80,
658                       "value": "12"
659                     },
660                     "quality": "720",
661                     "rating": 4.4394655,
662                     "regular_synopsis": "Comedy-Serie \u00fcber die Erlebnisse eines Tatortreinigers, der seine schmutzige Arbeit erst beginnen kann, wenn die Polizei die Tatortanalyse abgeschlossen hat.",
663                     "runtime": null,
664                     "seasons_count": 5,
665                     "seasons_label": "5 Staffeln",
666                     "synopsis": "In den meisten Krimiserien werden Mordf\u00e4lle auf faszinierende und spannende Weise gel\u00f6st. Diese Serie ist anders.",
667                     "tags": [
668                       "Zynisch"
669                     ],
670                     "title": "Der Tatortreiniger",
671                     "type": "show",
672                     "watched": false,
673                     "year": 2015
674                 },
675             }
676         """
677         video_list = {};
678         raw_video_list = response_data['value']
679         netflix_list_id = self.parse_netflix_list_id(video_list=raw_video_list);
680         for video_id in raw_video_list['videos']:
681             if self._is_size_key(key=video_id) == False:
682                 video_list.update(self.parse_video_list_entry(id=video_id, list_id=netflix_list_id, video=raw_video_list['videos'][video_id], persons=raw_video_list['person'], genres=raw_video_list['genres']))
683         return video_list
684
685     def parse_video_list_entry (self, id, list_id, video, persons, genres):
686         """Parse a video list entry e.g. rip out the parts we need
687
688         Parameters
689         ----------
690         id : :obj:`str`
691             Unique id of the video
692
693         list_id : :obj:`str`
694             Unique id of the containing list
695
696         video : :obj:`dict` of :obj:`str`
697             Video entry from the ´fetch_video_list´ call
698
699         persons : :obj:`dict` of :obj:`dict` of :obj:`str`
700             List of persons with reference ids
701
702         persons : :obj:`dict` of :obj:`dict` of :obj:`str`
703             List of genres with reference ids
704
705         Returns
706         -------
707         entry : :obj:`dict` of :obj:`dict` of :obj:`str`
708             Video list entry in the format:
709
710            {
711               "372203": {
712                 "artwork": null,
713                 "boxarts": {
714                   "big": "https://art-s.nflximg.net/5e7d3/b3b48749843fd3a36db11c319ffa60f96b55e7d3.jpg",
715                   "small": "https://art-s.nflximg.net/57543/a039845c2eb9186dc26019576d895bf5a1957543.jpg"
716                 },
717                 "cast": [
718                   "Christine Elise",
719                   "Brad Dourif",
720                   "Grace Zabriskie",
721                   "Jenny Agutter",
722                   "John Lafia",
723                   "Gerrit Graham",
724                   "Peter Haskell",
725                   "Alex Vincent",
726                   "Beth Grant"
727                 ],
728                 "creators": [],
729                 "directors": [],
730                 "episode_count": null,
731                 "genres": [
732                   "Horrorfilme"
733                 ],
734                 "id": "372203",
735                 "in_my_list": true,
736                 "interesting_moment": "https://art-s.nflximg.net/09544/ed4b3073394b4469fb6ec22b9df81a4f5cb09544.jpg",
737                 "list_id": "9588df32-f957-40e4-9055-1f6f33b60103_46891306",
738                 "maturity": {
739                   "board": "FSK",
740                   "description": "Nur f\u00fcr Erwachsene geeignet.",
741                   "level": 1000,
742                   "value": "18"
743                 },
744                 "quality": "540",
745                 "rating": 3.1707757,
746                 "regular_synopsis": "Ein Spielzeughersteller erweckt aus Versehen die Seele der M\u00f6rderpuppe Chucky erneut zum Leben, die sich unmittelbar wieder ihren m\u00f6rderischen Aktivit\u00e4ten zuwendet.",
747                 "runtime": 5028,
748                 "seasons_count": null,
749                 "seasons_label": null,
750                 "synopsis": "Die allseits beliebte, von D\u00e4monen besessene M\u00f6rderpuppe ist wieder da und verbreitet erneut Horror und Schrecken.",
751                 "tags": [
752                   "Brutal",
753                   "Spannend"
754                 ],
755                 "title": "Chucky 2 \u2013 Die M\u00f6rderpuppe ist wieder da",
756                 "type": "movie",
757                 "watched": false,
758                 "year": 1990
759               }
760             }
761         """
762         season_info = self.parse_season_information_for_video(video=video)
763         return {
764             id: {
765                 'id': id,
766                 'list_id': list_id,
767                 'title': video['title'],
768                 'synopsis': video['synopsis'],
769                 'regular_synopsis': video['regularSynopsis'],
770                 'type': video['summary']['type'],
771                 'rating': video['userRating']['average'],
772                 'episode_count': season_info['episode_count'],
773                 'seasons_label': season_info['seasons_label'],
774                 'seasons_count': season_info['seasons_count'],
775                 'in_my_list': video['queue']['inQueue'],
776                 'year': video['releaseYear'],
777                 'runtime': self.parse_runtime_for_video(video=video),
778                 'watched': video['watched'],
779                 'tags': self.parse_tags_for_video(video=video),
780                 'genres': self.parse_genres_for_video(video=video, genres=genres),
781                 'quality': self.parse_quality_for_video(video=video),
782                 'cast': self.parse_cast_for_video(video=video, persons=persons),
783                 'directors': self.parse_directors_for_video(video=video, persons=persons),
784                 'creators': self.parse_creators_for_video(video=video, persons=persons),
785                 'maturity': {
786                     'board': None if 'board' not in video['maturity']['rating'].keys() else video['maturity']['rating']['board'],
787                     'value': None if 'value' not in video['maturity']['rating'].keys() else video['maturity']['rating']['value'],
788                     'description': None if 'maturityDescription' not in video['maturity']['rating'].keys() else video['maturity']['rating']['maturityDescription'],
789                     'level': None if 'maturityLevel' not in video['maturity']['rating'].keys() else video['maturity']['rating']['maturityLevel']
790                 },
791                 'boxarts': {
792                     'small': video['boxarts']['_342x192']['jpg']['url'],
793                     'big': video['boxarts']['_1280x720']['jpg']['url']
794                 },
795                 'interesting_moment': None if 'interestingMoment' not in video.keys() else video['interestingMoment']['_665x375']['jpg']['url'],
796                 'artwork': video['artWorkByType']['BILLBOARD']['_1280x720']['jpg']['url'],
797             }
798         }
799
800     def parse_creators_for_video (self, video, persons):
801         """Matches ids with person names to generate a list of creators
802
803         Parameters
804         ----------
805         video : :obj:`dict` of :obj:`str`
806             Dictionary entry for one video entry
807
808         persons : :obj:`dict` of :obj:`str`
809             Raw resposne of all persons delivered by the API call
810
811         Returns
812         -------
813         :obj:`list` of :obj:`str`
814             List of creators
815         """
816         creators = []
817         for person_key in dict(persons).keys():
818             if self._is_size_key(key=person_key) == False and person_key != 'summary':
819                 for creator_key in dict(video['creators']).keys():
820                     if self._is_size_key(key=creator_key) == False and creator_key != 'summary':
821                         if video['creators'][creator_key][1] == person_key:
822                             creators.append(persons[person_key]['name'])
823         return creators
824
825     def parse_directors_for_video (self, video, persons):
826         """Matches ids with person names to generate a list of directors
827
828         Parameters
829         ----------
830         video : :obj:`dict` of :obj:`str`
831             Dictionary entry for one video entry
832
833         persons : :obj:`dict` of :obj:`str`
834             Raw resposne of all persons delivered by the API call
835
836         Returns
837         -------
838         :obj:`list` of :obj:`str`
839             List of directors
840         """
841         directors = []
842         for person_key in dict(persons).keys():
843             if self._is_size_key(key=person_key) == False and person_key != 'summary':
844                 for director_key in dict(video['directors']).keys():
845                     if self._is_size_key(key=director_key) == False and director_key != 'summary':
846                         if video['directors'][director_key][1] == person_key:
847                             directors.append(persons[person_key]['name'])
848         return directors
849
850     def parse_cast_for_video (self, video, persons):
851         """Matches ids with person names to generate a list of cast members
852
853         Parameters
854         ----------
855         video : :obj:`dict` of :obj:`str`
856             Dictionary entry for one video entry
857
858         persons : :obj:`dict` of :obj:`str`
859             Raw resposne of all persons delivered by the API call
860
861         Returns
862         -------
863         :obj:`list` of :obj:`str`
864             List of cast members
865         """
866         cast = []
867         for person_key in dict(persons).keys():
868             if self._is_size_key(key=person_key) == False and person_key != 'summary':
869                 for cast_key in dict(video['cast']).keys():
870                     if self._is_size_key(key=cast_key) == False and cast_key != 'summary':
871                         if video['cast'][cast_key][1] == person_key:
872                             cast.append(persons[person_key]['name'])
873         return cast
874
875     def parse_genres_for_video (self, video, genres):
876         """Matches ids with genre names to generate a list of genres for a video
877
878         Parameters
879         ----------
880         video : :obj:`dict` of :obj:`str`
881             Dictionary entry for one video entry
882
883         genres : :obj:`dict` of :obj:`str`
884             Raw resposne of all genres delivered by the API call
885
886         Returns
887         -------
888         :obj:`list` of :obj:`str`
889             List of genres
890         """
891         video_genres = []
892         for genre_key in dict(genres).keys():
893             if self._is_size_key(key=genre_key) == False and genre_key != 'summary':
894                 for show_genre_key in dict(video['genres']).keys():
895                     if self._is_size_key(key=show_genre_key) == False and show_genre_key != 'summary':
896                         if video['genres'][show_genre_key][1] == genre_key:
897                             video_genres.append(genres[genre_key]['name'])
898         return video_genres
899
900     def parse_tags_for_video (self, video):
901         """Parses a nested list of tags, removes the not needed meta information & returns a raw string list
902
903         Parameters
904         ----------
905         video : :obj:`dict` of :obj:`str`
906             Dictionary entry for one video entry
907
908         Returns
909         -------
910         :obj:`list` of :obj:`str`
911             List of tags
912         """
913         tags = []
914         for tag_key in dict(video['tags']).keys():
915             if self._is_size_key(key=tag_key) == False and tag_key != 'summary':
916                 tags.append(video['tags'][tag_key]['name'])
917         return tags
918
919     def parse_season_information_for_video (self, video):
920         """Checks if the fiven video is a show (series) and returns season & episode information
921
922         Parameters
923         ----------
924         video : :obj:`dict` of :obj:`str`
925             Dictionary entry for one video entry
926
927         Returns
928         -------
929         :obj:`dict` of :obj:`str`
930             Episode count / Season Count & Season label if given
931         """
932         season_info = {
933             'episode_count': None,
934             'seasons_label': None,
935             'seasons_count': None
936         }
937         if video['summary']['type'] == 'show':
938             season_info = {
939                 'episode_count': video['episodeCount'],
940                 'seasons_label': video['numSeasonsLabel'],
941                 'seasons_count': video['seasonCount']
942             }
943         return season_info
944
945     def parse_quality_for_video (self, video):
946         """Transforms Netflix quality information in video resolution info
947
948         Parameters
949         ----------
950         video : :obj:`dict` of :obj:`str`
951             Dictionary entry for one video entry
952
953         Returns
954         -------
955         :obj:`str`
956             Quality of the video
957         """
958         quality = '720'
959         if video['videoQuality']['hasHD']:
960             quality = '1080'
961         if video['videoQuality']['hasUltraHD']:
962             quality = '4000'
963         return quality
964
965     def parse_runtime_for_video (self, video):
966         """Checks if the video is a movie & returns the runtime if given
967
968         Parameters
969         ----------
970         video : :obj:`dict` of :obj:`str`
971             Dictionary entry for one video entry
972
973         Returns
974         -------
975         :obj:`str`
976             Runtime of the video (in seconds)
977         """
978         runtime = None
979         if video['summary']['type'] != 'show':
980             runtime = video['runtime']
981         return runtime
982
983     def parse_netflix_list_id (self, video_list):
984         """Parse a video list and extract the list id
985
986         Parameters
987         ----------
988         video_list : :obj:`dict` of :obj:`str`
989             Netflix video list
990
991         Returns
992         -------
993         entry : :obj:`str` or None
994             Netflix list id
995         """
996         netflix_list_id = None
997         if 'lists' in video_list.keys():
998             for video_id in video_list['lists']:
999                 if self._is_size_key(key=video_id) == False:
1000                     netflix_list_id = video_id;
1001         return netflix_list_id
1002
1003     def parse_show_information (self, id, response_data):
1004         """Parse extended show information (synopsis, seasons, etc.)
1005
1006         Parameters
1007         ----------
1008         id : :obj:`str`
1009             Video id
1010
1011         response_data : :obj:`dict` of :obj:`str`
1012             Parsed response JSON from the `fetch_show_information` call
1013
1014         Returns
1015         -------
1016         entry : :obj:`dict` of :obj:`str`
1017         Show information in the format:
1018             {
1019                 "season_id": "80113084",
1020                 "synopsis": "Aus verzweifelter Geldnot versucht sich der Familienvater und Drucker Jochen als Geldf\u00e4lscher und rutscht dabei immer mehr in die dunkle Welt des Verbrechens ab."
1021                 "detail_text": "I´m optional"
1022             }
1023         """
1024         show = {}
1025         raw_show = response_data['value']['videos'][id]
1026         show.update({'synopsis': raw_show['regularSynopsis']})
1027         if 'evidence' in raw_show:
1028             show.update({'detail_text': raw_show['evidence']['value']['text']})
1029         if 'seasonList' in raw_show:
1030             show.update({'season_id': raw_show['seasonList']['current'][1]})
1031         return show
1032
1033     def parse_seasons (self, id, response_data):
1034         """Parse a list of seasons for a given show
1035
1036         Parameters
1037         ----------
1038         id : :obj:`str`
1039             Season id
1040
1041         response_data : :obj:`dict` of :obj:`str`
1042             Parsed response JSON from the `fetch_seasons_for_show` call
1043
1044         Returns
1045         -------
1046         entry : :obj:`dict` of :obj:`dict` of :obj:`str`
1047         Season information in the format:
1048             {
1049                 "80113084": {
1050                     "id": 80113084,
1051                     "text": "Season 1",
1052                     "shortName": "St. 1",
1053                     "boxarts": {
1054                       "big": "https://art-s.nflximg.net/5e7d3/b3b48749843fd3a36db11c319ffa60f96b55e7d3.jpg",
1055                       "small": "https://art-s.nflximg.net/57543/a039845c2eb9186dc26019576d895bf5a1957543.jpg"
1056                     },
1057                     "interesting_moment": "https://art-s.nflximg.net/09544/ed4b3073394b4469fb6ec22b9df81a4f5cb09544.jpg"
1058                 },
1059                 "80113085": {
1060                     "id": 80113085,
1061                     "text": "Season 2",
1062                     "shortName": "St. 2",
1063                     "boxarts": {
1064                       "big": "https://art-s.nflximg.net/5e7d3/b3b48749843fd3a36db11c319ffa60f96b55e7d3.jpg",
1065                       "small": "https://art-s.nflximg.net/57543/a039845c2eb9186dc26019576d895bf5a1957543.jpg"
1066                     },
1067                     "interesting_moment": "https://art-s.nflximg.net/09544/ed4b3073394b4469fb6ec22b9df81a4f5cb09544.jpg"
1068                 }
1069             }
1070         """
1071         seasons = {}
1072         raw_seasons = response_data['value']
1073         for season in raw_seasons['seasons']:
1074             if self._is_size_key(key=season) == False:
1075                 seasons.update(self.parse_season_entry(season=raw_seasons['seasons'][season], videos=raw_seasons['videos']))
1076         return seasons
1077
1078     def parse_season_entry (self, season, videos):
1079         """Parse a season list entry e.g. rip out the parts we need
1080
1081         Parameters
1082         ----------
1083         season : :obj:`dict` of :obj:`str`
1084             Season entry from the `fetch_seasons_for_show` call
1085
1086         Returns
1087         -------
1088         entry : :obj:`dict` of :obj:`dict` of :obj:`str`
1089             Season list entry in the format:
1090
1091             {
1092                 "80113084": {
1093                     "id": 80113084,
1094                     "text": "Season 1",
1095                     "shortName": "St. 1",
1096                     "boxarts": {
1097                       "big": "https://art-s.nflximg.net/5e7d3/b3b48749843fd3a36db11c319ffa60f96b55e7d3.jpg",
1098                       "small": "https://art-s.nflximg.net/57543/a039845c2eb9186dc26019576d895bf5a1957543.jpg"
1099                     },
1100                     "interesting_moment": "https://art-s.nflximg.net/09544/ed4b3073394b4469fb6ec22b9df81a4f5cb09544.jpg"
1101                 }
1102             }
1103         """
1104         # get art video key
1105         video_key = ''
1106         for key in videos.keys():
1107             if self._is_size_key(key=key) == False:
1108                 video_key = key
1109         # get season index
1110         sorting = {}
1111         for idx in videos[video_key]['seasonList']:
1112             if self._is_size_key(key=idx) == False and idx != 'summary':
1113                 sorting[int(videos[video_key]['seasonList'][idx][1])] = int(idx)
1114         return {
1115             season['summary']['id']: {
1116                 'idx': sorting[season['summary']['id']],
1117                 'id': season['summary']['id'],
1118                 'text': season['summary']['name'],
1119                 'shortName': season['summary']['shortName'],
1120                 'boxarts': {
1121                     'small': videos[video_key]['boxarts']['_342x192']['jpg']['url'],
1122                     'big': videos[video_key]['boxarts']['_1280x720']['jpg']['url']
1123                 },
1124                 'interesting_moment': videos[video_key]['interestingMoment']['_665x375']['jpg']['url'],
1125             }
1126         }
1127
1128     def parse_episodes_by_season (self, response_data):
1129         """Parse episodes for a given season/episode list
1130
1131         Parameters
1132         ----------
1133         response_data : :obj:`dict` of :obj:`str`
1134             Parsed response JSON from the `fetch_seasons_for_show` call
1135
1136         Returns
1137         -------
1138         entry : :obj:`dict` of :obj:`dict` of :obj:`str`
1139         Season information in the format:
1140
1141         {
1142           "70251729": {
1143             "banner": "https://art-s.nflximg.net/63a36/c7fdfe6604ef2c22d085ac5dca5f69874e363a36.jpg",
1144             "duration": 1387,
1145             "episode": 1,
1146             "fanart": "https://art-s.nflximg.net/74e02/e7edcc5cc7dcda1e94d505df2f0a2f0d22774e02.jpg",
1147             "genres": [
1148               "Serien",
1149               "Comedyserien"
1150             ],
1151             "id": 70251729,
1152             "mediatype": "episode",
1153             "mpaa": "FSK 16",
1154             "my_list": false,
1155             "playcount": 0,
1156             "plot": "Als die Griffins und andere Einwohner von Quahog in die Villa von James Woods eingeladen werden, muss pl\u00f6tzlich ein Mord aufgekl\u00e4rt werden.",
1157             "poster": "https://art-s.nflximg.net/72fd6/57088715e8d436fdb6986834ab39124b0a972fd6.jpg",
1158             "rating": 3.9111512,
1159             "season": 9,
1160             "thumb": "https://art-s.nflximg.net/be686/07680670a68da8749eba607efb1ae37f9e3be686.jpg",
1161             "title": "Und dann gab es weniger (Teil 1)",
1162             "year": 2010,
1163             "bookmark": -1
1164           },
1165           "70251730": {
1166             "banner": "https://art-s.nflximg.net/63a36/c7fdfe6604ef2c22d085ac5dca5f69874e363a36.jpg",
1167             "duration": 1379,
1168             "episode": 2,
1169             "fanart": "https://art-s.nflximg.net/c472c/6c10f9578bf2c1d0a183c2ccb382931efcbc472c.jpg",
1170             "genres": [
1171               "Serien",
1172               "Comedyserien"
1173             ],
1174             "id": 70251730,
1175             "mediatype": "episode",
1176             "mpaa": "FSK 16",
1177             "my_list": false,
1178             "playcount": 1,
1179             "plot": "Wer ist der M\u00f6rder? Nach zahlreichen Morden wird immer wieder jemand anderes verd\u00e4chtigt.",
1180             "poster": "https://art-s.nflximg.net/72fd6/57088715e8d436fdb6986834ab39124b0a972fd6.jpg",
1181             "rating": 3.9111512,
1182             "season": 9,
1183             "thumb": "https://art-s.nflximg.net/15a08/857d59126641987bec302bb147a802a00d015a08.jpg",
1184             "title": "Und dann gab es weniger (Teil 2)",
1185             "year": 2010,
1186             "bookmark": 1234
1187           },
1188         }
1189         """
1190         episodes = {}
1191         raw_episodes = response_data['value']['videos']
1192         for episode_id in raw_episodes:
1193             if self._is_size_key(key=episode_id) == False:
1194                 if (raw_episodes[episode_id]['summary']['type'] == 'episode'):
1195                     episodes.update(self.parse_episode(episode=raw_episodes[episode_id], genres=response_data['value']['genres']))
1196         return episodes
1197
1198     def parse_episode (self, episode, genres=None):
1199         """Parse episode from an list of episodes by season
1200
1201         Parameters
1202         ----------
1203         episode : :obj:`dict` of :obj:`str`
1204             Episode entry from the `fetch_episodes_by_season` call
1205
1206         Returns
1207         -------
1208         entry : :obj:`dict` of :obj:`dict` of :obj:`str`
1209         Episode information in the format:
1210
1211         {
1212           "70251729": {
1213             "banner": "https://art-s.nflximg.net/63a36/c7fdfe6604ef2c22d085ac5dca5f69874e363a36.jpg",
1214             "duration": 1387,
1215             "episode": 1,
1216             "fanart": "https://art-s.nflximg.net/74e02/e7edcc5cc7dcda1e94d505df2f0a2f0d22774e02.jpg",
1217             "genres": [
1218               "Serien",
1219               "Comedyserien"
1220             ],
1221             "id": 70251729,
1222             "mediatype": "episode",
1223             "mpaa": "FSK 16",
1224             "my_list": false,
1225             "playcount": 0,
1226             "plot": "Als die Griffins und andere Einwohner von Quahog in die Villa von James Woods eingeladen werden, muss pl\u00f6tzlich ein Mord aufgekl\u00e4rt werden.",
1227             "poster": "https://art-s.nflximg.net/72fd6/57088715e8d436fdb6986834ab39124b0a972fd6.jpg",
1228             "rating": 3.9111512,
1229             "season": 9,
1230             "thumb": "https://art-s.nflximg.net/be686/07680670a68da8749eba607efb1ae37f9e3be686.jpg",
1231             "title": "Und dann gab es weniger (Teil 1)",
1232             "year": 2010,
1233             "bookmark": 1234
1234           },
1235         }
1236         """
1237         return {
1238             episode['summary']['id']: {
1239                 'id': episode['summary']['id'],
1240                 'episode': episode['summary']['episode'],
1241                 'season': episode['summary']['season'],
1242                 'plot': episode['info']['synopsis'],
1243                 'duration': episode['info']['runtime'],
1244                 'title': episode['info']['title'],
1245                 'year': episode['info']['releaseYear'],
1246                 'genres': self.parse_genres_for_video(video=episode, genres=genres),
1247                 'mpaa': str(episode['maturity']['rating']['board']) + ' ' + str(episode['maturity']['rating']['value']),
1248                 'maturity': episode['maturity'],
1249                 'playcount': (0, 1)[episode['watched']],
1250                 'rating': episode['userRating']['average'],
1251                 'thumb': episode['info']['interestingMoments']['url'],
1252                 'fanart': episode['interestingMoment']['_1280x720']['jpg']['url'],
1253                 'poster': episode['boxarts']['_1280x720']['jpg']['url'],
1254                 'banner': episode['boxarts']['_342x192']['jpg']['url'],
1255                 'mediatype': {'episode': 'episode', 'movie': 'movie'}[episode['summary']['type']],
1256                 'my_list': episode['queue']['inQueue'],
1257                 'bookmark': episode['bookmarkPosition']
1258             }
1259         }
1260
1261     def fetch_browse_list_contents (self):
1262         """Fetches the HTML data for the lists on the landing page (browse page) of Netflix
1263
1264         Returns
1265         -------
1266         :obj:`BeautifulSoup`
1267             Instance of an BeautifulSoup document containing the complete page contents
1268         """
1269         response = self._session_get(component='browse')
1270         return BeautifulSoup(response.text, 'html.parser')
1271
1272     def fetch_video_list_ids (self, list_from=0, list_to=50):
1273         """Fetches the JSON with detailed information based on the lists on the landing page (browse page) of Netflix
1274
1275         Parameters
1276         ----------
1277         list_from : :obj:`int`
1278             Start entry for pagination
1279
1280         list_to : :obj:`int`
1281             Last entry for pagination
1282
1283         Returns
1284         -------
1285         :obj:`dict` of :obj:`dict` of :obj:`str`
1286             Raw Netflix API call response or api call error
1287         """
1288         payload = {
1289             'fromRow': list_from,
1290             'toRow': list_to,
1291             'opaqueImageExtension': 'jpg',
1292             'transparentImageExtension': 'png',
1293             '_': int(time()),
1294             'authURL': self.user_data['authURL']
1295         }
1296
1297         response = self._session_get(component='video_list_ids', params=payload, type='api')
1298         return self._process_response(response=response, component=self._get_api_url_for(component='video_list_ids'))
1299
1300     def fetch_search_results (self, search_str, list_from=0, list_to=10):
1301         """Fetches the JSON which contains the results for the given search query
1302
1303         Parameters
1304         ----------
1305         search_str : :obj:`str`
1306             String to query Netflix search for
1307
1308         list_from : :obj:`int`
1309             Start entry for pagination
1310
1311         list_to : :obj:`int`
1312             Last entry for pagination
1313
1314         Returns
1315         -------
1316         :obj:`dict` of :obj:`dict` of :obj:`str`
1317             Raw Netflix API call response or api call error
1318         """
1319         # properly encode the search string
1320         encoded_search_string = quote(search_str)
1321
1322         paths = [
1323             ['search', encoded_search_string, 'titles', {'from': list_from, 'to': list_to}, ['summary', 'title']],
1324             ['search', encoded_search_string, 'titles', {'from': list_from, 'to': list_to}, 'boxarts', '_342x192', 'jpg'],
1325             ['search', encoded_search_string, 'titles', ['id', 'length', 'name', 'trackIds', 'requestId']],
1326             ['search', encoded_search_string, 'suggestions', 0, 'relatedvideos', {'from': list_from, 'to': list_to}, ['summary', 'title']],
1327             ['search', encoded_search_string, 'suggestions', 0, 'relatedvideos', {'from': list_from, 'to': list_to}, 'boxarts', '_342x192', 'jpg'],
1328             ['search', encoded_search_string, 'suggestions', 0, 'relatedvideos', ['id', 'length', 'name', 'trackIds', 'requestId']]
1329         ]
1330         response = self._path_request(paths=paths)
1331         return self._process_response(response=response, component='Search results')
1332
1333     def fetch_video_list (self, list_id, list_from=0, list_to=20):
1334         """Fetches the JSON which contains the contents of a given video list
1335
1336         Parameters
1337         ----------
1338         list_id : :obj:`str`
1339             Unique list id to query Netflix for
1340
1341         list_from : :obj:`int`
1342             Start entry for pagination
1343
1344         list_to : :obj:`int`
1345             Last entry for pagination
1346
1347         Returns
1348         -------
1349         :obj:`dict` of :obj:`dict` of :obj:`str`
1350             Raw Netflix API call response or api call error
1351         """
1352         paths = [
1353             ['lists', list_id, {'from': list_from, 'to': list_to}, ['summary', 'title', 'synopsis', 'regularSynopsis', 'evidence', 'queue', 'episodeCount', 'info', 'maturity', 'runtime', 'seasonCount', 'releaseYear', 'userRating', 'numSeasonsLabel', 'bookmarkPosition', 'watched', 'videoQuality']],
1354             ['lists', list_id, {'from': list_from, 'to': list_to}, 'cast', {'from': 0, 'to': 15}, ['id', 'name']],
1355             ['lists', list_id, {'from': list_from, 'to': list_to}, 'cast', 'summary'],
1356             ['lists', list_id, {'from': list_from, 'to': list_to}, 'genres', {'from': 0, 'to': 5}, ['id', 'name']],
1357             ['lists', list_id, {'from': list_from, 'to': list_to}, 'genres', 'summary'],
1358             ['lists', list_id, {'from': list_from, 'to': list_to}, 'tags', {'from': 0, 'to': 9}, ['id', 'name']],
1359             ['lists', list_id, {'from': list_from, 'to': list_to}, 'tags', 'summary'],
1360             ['lists', list_id, {'from': list_from, 'to': list_to}, ['creators', 'directors'], {'from': 0, 'to': 49}, ['id', 'name']],
1361             ['lists', list_id, {'from': list_from, 'to': list_to}, ['creators', 'directors'], 'summary'],
1362             ['lists', list_id, {'from': list_from, 'to': list_to}, 'bb2OGLogo', '_400x90', 'png'],
1363             ['lists', list_id, {'from': list_from, 'to': list_to}, 'boxarts', '_342x192', 'jpg'],
1364             ['lists', list_id, {'from': list_from, 'to': list_to}, 'boxarts', '_1280x720', 'jpg'],
1365             ['lists', list_id, {'from': list_from, 'to': list_to}, 'storyarts', '_1632x873', 'jpg'],
1366             ['lists', list_id, {'from': list_from, 'to': list_to}, 'interestingMoment', '_665x375', 'jpg'],
1367             ['lists', list_id, {'from': list_from, 'to': list_to}, 'artWorkByType', 'BILLBOARD', '_1280x720', 'jpg']
1368         ];
1369
1370         response = self._path_request(paths=paths)
1371         return self._process_response(response=response, component='Video list')
1372
1373     def fetch_video_list_information (self, video_ids):
1374         """Fetches the JSON which contains the detail information of a list of given video ids
1375
1376         Parameters
1377         ----------
1378         video_ids : :obj:`list` of :obj:`str`
1379             List of video ids to fetch detail data for
1380
1381         Returns
1382         -------
1383         :obj:`dict` of :obj:`dict` of :obj:`str`
1384             Raw Netflix API call response or api call error
1385         """
1386         paths = []
1387         for video_id in video_ids:
1388             paths.append(['videos', video_id, ['summary', 'title', 'synopsis', 'regularSynopsis', 'evidence', 'queue', 'episodeCount', 'info', 'maturity', 'runtime', 'seasonCount', 'releaseYear', 'userRating', 'numSeasonsLabel', 'bookmarkPosition', 'watched', 'videoQuality']])
1389             paths.append(['videos', video_id, 'cast', {'from': 0, 'to': 15}, ['id', 'name']])
1390             paths.append(['videos', video_id, 'cast', 'summary'])
1391             paths.append(['videos', video_id, 'genres', {'from': 0, 'to': 5}, ['id', 'name']])
1392             paths.append(['videos', video_id, 'genres', 'summary'])
1393             paths.append(['videos', video_id, 'tags', {'from': 0, 'to': 9}, ['id', 'name']])
1394             paths.append(['videos', video_id, 'tags', 'summary'])
1395             paths.append(['videos', video_id, ['creators', 'directors'], {'from': 0, 'to': 49}, ['id', 'name']])
1396             paths.append(['videos', video_id, ['creators', 'directors'], 'summary'])
1397             paths.append(['videos', video_id, 'bb2OGLogo', '_400x90', 'png'])
1398             paths.append(['videos', video_id, 'boxarts', '_342x192', 'jpg'])
1399             paths.append(['videos', video_id, 'boxarts', '_1280x720', 'jpg'])
1400             paths.append(['videos', video_id, 'storyarts', '_1632x873', 'jpg'])
1401             paths.append(['videos', video_id, 'interestingMoment', '_665x375', 'jpg'])
1402             paths.append(['videos', video_id, 'artWorkByType', 'BILLBOARD', '_1280x720', 'jpg'])
1403
1404         response = self._path_request(paths=paths)
1405         return self._process_response(response=response, component='fetch_video_list_information')
1406
1407     def fetch_metadata (self, id):
1408         """Fetches the JSON which contains the metadata for a given show/movie or season id
1409
1410         Parameters
1411         ----------
1412         id : :obj:`str`
1413             Show id, movie id or season id
1414
1415         Returns
1416         -------
1417         :obj:`dict` of :obj:`dict` of :obj:`str`
1418             Raw Netflix API call response or api call error
1419         """
1420         payload = {
1421             'movieid': id,
1422             'imageformat': 'jpg',
1423             '_': int(time())
1424         }
1425         response = self._session_get(component='metadata', params=payload, type='api')
1426         return self._process_response(response=response, component=self._get_api_url_for(component='metadata'))
1427
1428     def fetch_show_information (self, id, type):
1429         """Fetches the JSON which contains the detailed contents of a show
1430
1431         Parameters
1432         ----------
1433         id : :obj:`str`
1434             Unique show id to query Netflix for
1435
1436         type : :obj:`str`
1437             Can be 'movie' or 'show'
1438
1439         Returns
1440         -------
1441         :obj:`dict` of :obj:`dict` of :obj:`str`
1442             Raw Netflix API call response or api call error
1443         """
1444         # check if we have a show or a movie, the request made depends on this
1445         if type == 'show':
1446             paths = [
1447                 ['videos', id, ['requestId', 'regularSynopsis', 'evidence']],
1448                 ['videos', id, 'seasonList', 'current', 'summary']
1449             ]
1450         else:
1451             paths = [['videos', id, ['requestId', 'regularSynopsis', 'evidence']]]
1452         response = self._path_request(paths=paths)
1453         return self._process_response(response=response, component='Show information')
1454
1455     def fetch_seasons_for_show (self, id, list_from=0, list_to=30):
1456         """Fetches the JSON which contains the seasons of a given show
1457
1458         Parameters
1459         ----------
1460         id : :obj:`str`
1461             Unique show id to query Netflix for
1462
1463         list_from : :obj:`int`
1464             Start entry for pagination
1465
1466         list_to : :obj:`int`
1467             Last entry for pagination
1468
1469         Returns
1470         -------
1471         :obj:`dict` of :obj:`dict` of :obj:`str`
1472             Raw Netflix API call response or api call error
1473         """
1474         paths = [
1475             ['videos', id, 'seasonList', {'from': list_from, 'to': list_to}, 'summary'],
1476             ['videos', id, 'seasonList', 'summary'],
1477             ['videos', id, 'boxarts',  '_342x192', 'jpg'],
1478             ['videos', id, 'boxarts', '_1280x720', 'jpg'],
1479             ['videos', id, 'storyarts',  '_1632x873', 'jpg'],
1480             ['videos', id, 'interestingMoment', '_665x375', 'jpg']
1481         ]
1482         response = self._path_request(paths=paths)
1483         return self._process_response(response=response, component='Seasons')
1484
1485     def fetch_episodes_by_season (self, season_id, list_from=-1, list_to=40):
1486         """Fetches the JSON which contains the episodes of a given season
1487
1488         TODO: Add more metadata
1489
1490         Parameters
1491         ----------
1492         season_id : :obj:`str`
1493             Unique season_id id to query Netflix for
1494
1495         list_from : :obj:`int`
1496             Start entry for pagination
1497
1498         list_to : :obj:`int`
1499             Last entry for pagination
1500
1501         Returns
1502         -------
1503         :obj:`dict` of :obj:`dict` of :obj:`str`
1504             Raw Netflix API call response or api call error
1505         """
1506         paths = [
1507             ['seasons', season_id, 'episodes', {'from': list_from, 'to': list_to}, ['summary', 'queue', 'info', 'maturity', 'userRating', 'bookmarkPosition', 'creditOffset', 'watched', 'videoQuality']],
1508             #['videos', season_id, 'cast', {'from': 0, 'to': 15}, ['id', 'name']],
1509             #['videos', season_id, 'cast', 'summary'],
1510             #['videos', season_id, 'genres', {'from': 0, 'to': 5}, ['id', 'name']],
1511             #['videos', season_id, 'genres', 'summary'],
1512             #['videos', season_id, 'tags', {'from': 0, 'to': 9}, ['id', 'name']],
1513             #['videos', season_id, 'tags', 'summary'],
1514             #['videos', season_id, ['creators', 'directors'], {'from': 0, 'to': 49}, ['id', 'name']],
1515             #['videos', season_id, ['creators', 'directors'], 'summary'],
1516             ['seasons', season_id, 'episodes', {'from': list_from, 'to': list_to}, 'genres', {'from': 0, 'to': 1}, ['id', 'name']],
1517             ['seasons', season_id, 'episodes', {'from': list_from, 'to': list_to}, 'genres', 'summary'],
1518             ['seasons', season_id, 'episodes', {'from': list_from, 'to': list_to}, 'interestingMoment', '_1280x720', 'jpg'],
1519             ['seasons', season_id, 'episodes', {'from': list_from, 'to': list_to}, 'interestingMoment', '_665x375', 'jpg'],
1520             ['seasons', season_id, 'episodes', {'from': list_from, 'to': list_to}, 'boxarts', '_342x192', 'jpg'],
1521             ['seasons', season_id, 'episodes', {'from': list_from, 'to': list_to}, 'boxarts', '_1280x720', 'jpg']
1522         ]
1523         response = self._path_request(paths=paths)
1524         return self._process_response(response=response, component='fetch_episodes_by_season')
1525
1526     def refresh_session_data (self, account):
1527         """Reload the session data (profiles, user_data, api_data)
1528
1529         Parameters
1530         ----------
1531         account : :obj:`dict` of :obj:`str`
1532             Dict containing an email, country & a password property
1533         """
1534         # load the profiles page (to verify the user)
1535         response = self._session_get(component='profiles')
1536         # parse out the needed inline information
1537         only_script_tags = SoupStrainer('script')
1538         page_soup = BeautifulSoup(response.text, 'html.parser', parse_only=only_script_tags)
1539         page_data = self._parse_page_contents(page_soup=page_soup)
1540         account_hash = self._generate_account_hash(account=account)
1541         self._save_data(filename=self.data_path + '_' + account_hash)
1542
1543     def _path_request (self, paths):
1544         """Executes a post request against the shakti endpoint with Falcor style payload
1545
1546         Parameters
1547         ----------
1548         paths : :obj:`list` of :obj:`list`
1549             Payload with path querys for the Netflix Shakti API in Falcor style
1550
1551         Returns
1552         -------
1553         :obj:`requests.response`
1554             Response from a POST call made with Requests
1555         """
1556         headers = {
1557             'Content-Type': 'application/json',
1558             'Accept': 'application/json, text/javascript, */*',
1559         }
1560
1561         data = json.dumps({
1562             'paths': paths,
1563             'authURL': self.user_data['authURL']
1564         })
1565
1566         params = {
1567             'model': self.user_data['gpsModel']
1568         }
1569
1570         return self._session_post(component='shakti', type='api', params=params, headers=headers, data=data)
1571
1572     def _is_size_key (self, key):
1573         """Tiny helper that checks if a given key is called $size or size, as we need to check this often
1574
1575         Parameters
1576         ----------
1577         key : :obj:`str`
1578             Key to check the value for
1579
1580         Returns
1581         -------
1582         bool
1583             Key has a size value or not
1584         """
1585         return key == '$size' or key == 'size'
1586
1587     def _get_api_url_for (self, component):
1588         """Tiny helper that builds the url for a requested API endpoint component
1589
1590         Parameters
1591         ----------
1592         component : :obj:`str`
1593             Component endpoint to build the URL for
1594
1595         Returns
1596         -------
1597         :obj:`str`
1598             API Url
1599         """
1600         if self.api_data['API_ROOT'].find(self.api_data['API_BASE_URL']) > -1:
1601             return self.api_data['API_ROOT'] + '/' + self.api_data['BUILD_IDENTIFIER'] + self.urls[component]
1602         else:
1603             return self.api_data['API_ROOT'] + self.api_data['API_BASE_URL'] + '/' + self.api_data['BUILD_IDENTIFIER'] + self.urls[component]
1604
1605     def _get_document_url_for (self, component):
1606         """Tiny helper that builds the url for a requested document endpoint component
1607
1608         Parameters
1609         ----------
1610         component : :obj:`str`
1611             Component endpoint to build the URL for
1612
1613         Returns
1614         -------
1615         :obj:`str`
1616             Document Url
1617         """
1618         return self.base_url + self.urls[component]
1619
1620     def _process_response (self, response, component):
1621         """Tiny helper to check responses for API requests
1622
1623         Parameters
1624         ----------
1625         response : :obj:`requests.response`
1626             Response from a requests instance
1627
1628         component : :obj:`str`
1629             Component endpoint
1630
1631         Returns
1632         -------
1633         :obj:`dict` of :obj:`dict` of :obj:`str` or :obj:`dict` of :obj:`str`
1634             Raw Netflix API call response or api call error
1635         """
1636         # check if we´re not authorized to make thios call
1637         if response.status_code == 401:
1638             return {
1639                 'error': True,
1640                 'message': 'Session invalid',
1641                 'code': 401
1642             }
1643         # check if somethign else failed
1644         if response.status_code != 200:
1645             return {
1646                 'error': True,
1647                 'message': 'API call for "' + component + '" failed',
1648                 'code': response.status_code
1649             }
1650         # return the parsed response & everything´s fine
1651         return response.json()
1652
1653     def _to_unicode(self, str):
1654         '''Attempt to fix non uft-8 string into utf-8, using a limited set of encodings
1655
1656         Parameters
1657         ----------
1658         str : `str`
1659             String to decode
1660
1661         Returns
1662         -------
1663         `str`
1664             Decoded string
1665         '''
1666         # fuller list of encodings at http://docs.python.org/library/codecs.html#standard-encodings
1667         if not str:  return u''
1668         u = None
1669         # we could add more encodings here, as warranted.
1670         encodings = ('ascii', 'utf8', 'latin1')
1671         for enc in encodings:
1672             if u:  break
1673             try:
1674                 u = unicode(str,enc)
1675             except UnicodeDecodeError:
1676                 pass
1677         if not u:
1678             u = unicode(str, errors='replace')
1679         return u
1680
1681     def _update_my_list (self, video_id, operation):
1682         """Tiny helper to add & remove items from "my list"
1683
1684         Parameters
1685         ----------
1686         video_id : :obj:`str`
1687             ID of the show/movie to be added
1688
1689         operation : :obj:`str`
1690             Either "add" or "remove"
1691
1692         Returns
1693         -------
1694         bool
1695             Operation successfull
1696         """
1697         headers = {
1698             'Content-Type': 'application/json',
1699             'Accept': 'application/json, text/javascript, */*',
1700         }
1701
1702         payload = json.dumps({
1703             'operation': operation,
1704             'videoId': int(video_id),
1705             'authURL': self.user_data['authURL']
1706         })
1707
1708         response = self._session_post(component='update_my_list', type='api', headers=headers, data=payload)
1709         return response.status_code == 200
1710
1711     def _save_data(self, filename):
1712         """Tiny helper that stores session data from the session in a given file
1713
1714         Parameters
1715         ----------
1716         filename : :obj:`str`
1717             Complete path incl. filename that determines where to store the cookie
1718
1719         Returns
1720         -------
1721         bool
1722             Storage procedure was successfull
1723         """
1724         if not os.path.isdir(os.path.dirname(filename)):
1725             return False
1726         with open(filename, 'w') as f:
1727             f.truncate()
1728             pickle.dump({
1729                 'user_data': self.user_data,
1730                 'api_data': self.api_data,
1731                 'profiles': self.profiles
1732             }, f)
1733
1734     def _load_data(self, filename):
1735         """Tiny helper that loads session data into the active session from a given file
1736
1737         Parameters
1738         ----------
1739         filename : :obj:`str`
1740             Complete path incl. filename that determines where to load the data from
1741
1742         Returns
1743         -------
1744         bool
1745             Load procedure was successfull
1746         """
1747         if not os.path.isfile(filename):
1748             return False
1749
1750         with open(filename) as f:
1751             data = pickle.load(f)
1752             if data:
1753                 self.profiles = data['profiles']
1754                 self.user_data = data['user_data']
1755                 self.api_data = data['api_data']
1756             else:
1757                 return False
1758
1759     def _delete_data (self, path):
1760         """Tiny helper that deletes session data
1761
1762         Parameters
1763         ----------
1764         filename : :obj:`str`
1765             Complete path incl. filename that determines where to delete the files
1766
1767         """
1768         head, tail = os.path.split(path)
1769         for subdir, dirs, files in os.walk(head):
1770             for file in files:
1771                 if tail in file:
1772                     os.remove(os.path.join(subdir, file))
1773
1774     def _save_cookies(self, filename):
1775         """Tiny helper that stores cookies from the session in a given file
1776
1777         Parameters
1778         ----------
1779         filename : :obj:`str`
1780             Complete path incl. filename that determines where to store the cookie
1781
1782         Returns
1783         -------
1784         bool
1785             Storage procedure was successfull
1786         """
1787         if not os.path.isdir(os.path.dirname(filename)):
1788             return False
1789         with open(filename, 'w') as f:
1790             f.truncate()
1791             pickle.dump(self.session.cookies._cookies, f)
1792
1793     def _load_cookies(self, filename):
1794         """Tiny helper that loads cookies into the active session from a given file
1795
1796         Parameters
1797         ----------
1798         filename : :obj:`str`
1799             Complete path incl. filename that determines where to load the cookie from
1800
1801         Returns
1802         -------
1803         bool
1804             Load procedure was successfull
1805         """
1806         if not os.path.isfile(filename):
1807             return False
1808
1809         with open(filename) as f:
1810             _cookies = pickle.load(f)
1811             if _cookies:
1812                 jar = cookies.RequestsCookieJar()
1813                 jar._cookies = _cookies
1814                 self.session.cookies = jar
1815             else:
1816                 return False
1817
1818     def _delete_cookies (self, path):
1819         """Tiny helper that deletes cookie data
1820
1821         Parameters
1822         ----------
1823         filename : :obj:`str`
1824             Complete path incl. filename that determines where to delete the files
1825
1826         """
1827         head, tail = os.path.split(path)
1828         for subdir, dirs, files in os.walk(head):
1829             for file in files:
1830                 if tail in file:
1831                     os.remove(os.path.join(subdir, file))
1832
1833     def _generate_account_hash (self, account):
1834         """Generates a has for the given account (used for cookie verification)
1835
1836         Parameters
1837         ----------
1838         account : :obj:`dict` of :obj:`str`
1839             Dict containing an email, country & a password property
1840
1841         Returns
1842         -------
1843         :obj:`str`
1844             Account data hash
1845         """
1846         return urlsafe_b64encode(account['email'])
1847
1848     def _get_user_agent_for_current_platform (self):
1849         """Determines the user agent string for the current platform (to retrieve a valid ESN)
1850
1851         Returns
1852         -------
1853         :obj:`str`
1854             User Agent for platform
1855         """
1856         import platform
1857         if platform == 'linux' or platform == 'linux2':
1858             return 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36'
1859         elif platform == 'darwin':
1860             return 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36'
1861         elif platform == 'win32':
1862             return 'Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36'
1863         else:
1864             return 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36'
1865
1866     def _session_post (self, component, type='document', data={}, headers={}, params={}):
1867         """Executes a get request using requests for the current session & measures the duration of that request
1868
1869         Parameters
1870         ----------
1871         component : :obj:`str`
1872             Component to query
1873
1874         type : :obj:`str`
1875             Is it a document or API request ('document' is default)
1876
1877         data : :obj:`dict` of :obj:`str`
1878             Payload body as dict
1879
1880         header : :obj:`dict` of :obj:`str`
1881             Additional headers as dict
1882
1883         params : :obj:`dict` of :obj:`str`
1884             Request params
1885
1886         Returns
1887         -------
1888             :obj:`str`
1889                 Contents of the field to match
1890         """
1891         url = self._get_document_url_for(component=component) if type == 'document' else self._get_api_url_for(component=component)
1892         start = time()
1893         response = self.session.post(url=url, data=data, params=params, headers=headers, verify=self.verify_ssl)
1894         end = time()
1895         self.log('[POST] Request for "' + url + '" took ' + str(end - start) + ' seconds')
1896         return response
1897
1898     def _session_get (self, component, type='document', params={}):
1899         """Executes a get request using requests for the current session & measures the duration of that request
1900
1901         Parameters
1902         ----------
1903         component : :obj:`str`
1904             Component to query
1905
1906         type : :obj:`str`
1907             Is it a document or API request ('document' is default)
1908
1909         params : :obj:`dict` of :obj:`str`
1910             Request params
1911
1912         Returns
1913         -------
1914             :obj:`str`
1915                 Contents of the field to match
1916         """
1917         url = self._get_document_url_for(component=component) if type == 'document' else self._get_api_url_for(component=component)
1918         start = time()
1919         response = self.session.get(url=url, verify=self.verify_ssl, params=params)
1920         end = time()
1921         self.log('[GET] Request for "' + url + '" took ' + str(end - start) + ' seconds')
1922         return response
1923
1924     def _sloppy_parse_user_and_api_data (self, key, contents):
1925         """Try to find the user & API data from the inline js by using a string parser
1926
1927         Parameters
1928         ----------
1929         key : :obj:`str`
1930             Key to match in the inline js
1931
1932         contents : :obj:`str`
1933             Inline JS contents
1934
1935         Returns
1936         -------
1937             :obj:`str`
1938                 Contents of the field to match
1939         """
1940         key_start = contents.find(key + '"')
1941         if int(key_start) == -1:
1942             return None
1943         sub_contents = contents[int(key_start):]
1944         l = sub_contents.find('",')
1945         return contents[(int(key_start)+len(key)+3):int(key_start)+l].decode('string_escape')
1946
1947     def _sloppy_parse_profiles (self, contents):
1948         """Try to find the profile data from the inline js by using a string parser & parse/convert the result to JSON
1949
1950         Parameters
1951         ----------
1952         contents : :obj:`str`
1953             Inline JS contents
1954
1955         Returns
1956         -------
1957             :obj:`dict` of :obj:`str` or None
1958                 Profile data
1959         """
1960         profile_start = contents.find('profiles":')
1961         profile_list_start = contents.find('profilesList')
1962         if int(profile_start) > -1 and int(profile_list_start) > -1:
1963             try:
1964                 try:
1965                     return json.loads('{"a":{"' + contents[profile_start:profile_list_start-2].decode('string_escape') + '}}').get('a').get('profiles')
1966                 except ValueError, e:
1967                    return None
1968             except TypeError, e:
1969                 return None
1970         return None
1971
1972     def _sloppy_parse_avatars (self, contents):
1973         """Try to find the avatar data from the inline js by using a string parser & parse/convert the result to JSON
1974
1975         Parameters
1976         ----------
1977         contents : :obj:`str`
1978             Inline JS contents
1979
1980         Returns
1981         -------
1982             :obj:`dict` of :obj:`str` or None
1983                 Avatar data
1984         """
1985         avatars_start = contents.find('"nf":')
1986         avatars_list_start = contents.find('"profiles"')
1987         if int(avatars_start) > -1 and int(avatars_list_start) > -1:
1988             try:
1989                 try:
1990                     return json.loads('{' + contents[avatars_start:avatars_list_start-2].decode('string_escape') + '}')
1991                 except ValueError, e:
1992                    return None
1993             except TypeError, e:
1994                 return None
1995         return None
1996
1997     def _verfify_auth_and_profiles_data (self, data):
1998         """Checks if the authURL has at least a certain length & doesn't overrule a certain length & if the profiles dict exists
1999         Simple validity check for the sloppy data parser
2000
2001         Parameters
2002         ----------
2003         data : :obj:`dict` of :obj:`str`
2004             Parsed JS contents
2005
2006         Returns
2007         -------
2008             bool
2009                 Data is valid
2010         """
2011         if type(data.get('profiles')) == dict:
2012             if len(str(data.get('authURL', ''))) > 10 and len(str(data.get('authURL', ''))) < 50:
2013                 return True
2014         return False
2015
2016     def _sloppy_parse_inline_data (self, scripts):
2017         """Strips out all the needed user, api & profile data from the inline JS by string parsing
2018         Might fail, so if this doesn't succeed, a proper JS parser will chime in
2019
2020         Note: This has been added for performance reasons only
2021
2022         Parameters
2023         ----------
2024         scripts : :obj:`list` of :obj:`BeautifoulSoup`
2025             Script tags & contents from the Netflix browse page
2026
2027         Returns
2028         -------
2029             :obj:`dict` of :obj:`str`
2030                 Dict containijg user, api & profile data
2031         """
2032         inline_data = {};
2033         for script in scripts:
2034             contents = str(script.contents[0])
2035             important_data = ['authURL', 'API_BASE_URL', 'API_ROOT', 'BUILD_IDENTIFIER', 'ICHNAEA_ROOT', 'gpsModel', 'guid', 'esn']
2036             res = {}
2037             for key in important_data:
2038                 _res = self._sloppy_parse_user_and_api_data(key, contents)
2039                 if _res != None:
2040                     res.update({key: _res})
2041             if res != {}:
2042                 inline_data.update(res)
2043
2044             # parse profiles
2045             profiles = self._sloppy_parse_profiles(contents)
2046             avatars = self._sloppy_parse_avatars(contents)
2047             if profiles != None:
2048                 inline_data.update({'profiles': profiles})
2049             if avatars != None:
2050                 inline_data.update(avatars)
2051         return inline_data
2052
2053     def _accurate_parse_inline_data (self, scripts):
2054         """Uses a proper JS parser to fetch all the api, iser & profile data from within the inline JS
2055
2056         Note: This is slow but accurate
2057
2058         Parameters
2059         ----------
2060         scripts : :obj:`list` of :obj:`BeautifoulSoup`
2061             Script tags & contents from the Netflix browse page
2062
2063         Returns
2064         -------
2065             :obj:`dict` of :obj:`str`
2066                 Dict containing user, api & profile data
2067         """
2068         inline_data = []
2069         from pyjsparser import PyJsParser
2070         parser = PyJsParser()
2071         for script in scripts:
2072             data = {}
2073             # unicode escape that incoming script stuff
2074             contents = self._to_unicode(str(script.contents[0]))
2075             # parse the JS & load the declarations we´re interested in
2076             parsed = parser.parse(contents)
2077             if len(parsed['body']) > 1 and parsed['body'][1]['expression']['right'].get('properties', None) != None:
2078                 declarations = parsed['body'][1]['expression']['right']['properties']
2079                 for declaration in declarations:
2080                     for key in declaration:
2081                         # we found the correct path if the declaration is a dict & of type 'ObjectExpression'
2082                         if type(declaration[key]) is dict:
2083                             if declaration[key]['type'] == 'ObjectExpression':
2084                                 # add all static data recursivly
2085                                 for expression in declaration[key]['properties']:
2086                                     data[expression['key']['value']] = self._parse_rec(expression['value'])
2087                     inline_data.append(data)
2088         return inline_data
2089
2090     def _parse_rec (self, node):
2091         """Iterates over a JavaScript AST and return values found
2092
2093         Parameters
2094         ----------
2095         value : :obj:`dict`
2096             JS AST Expression
2097         Returns
2098         -------
2099         :obj:`dict` of :obj:`dict` or :obj:`str`
2100             Parsed contents of the node
2101         """
2102         if node['type'] == 'ObjectExpression':
2103             _ret = {}
2104             for prop in node['properties']:
2105                 _ret.update({prop['key']['value']: self._parse_rec(prop['value'])})
2106             return _ret
2107         if node['type'] == 'Literal':
2108             return node['value']
2109
2110     def _parse_user_data (self, netflix_page_data):
2111         """Parse out the user data from the big chunk of dicts we got from
2112            parsing the JSON-ish data from the netflix homepage
2113
2114         Parameters
2115         ----------
2116         netflix_page_data : :obj:`list`
2117             List of all the JSON-ish data that has been extracted from the Netflix homepage
2118             see: extract_inline_netflix_page_data
2119
2120         Returns
2121         -------
2122             :obj:`dict` of :obj:`str`
2123
2124             {
2125                 "guid": "72ERT45...",
2126                 "authURL": "145637....",
2127                 "gpsModel": "harris"
2128             }
2129         """
2130         user_data = {};
2131         important_fields = [
2132             'authURL',
2133             'gpsModel',
2134             'guid'
2135         ]
2136
2137         # values are accessible via dict (sloppy parsing successfull)
2138         if type(netflix_page_data) == dict:
2139             for important_field in important_fields:
2140                 user_data.update({important_field: netflix_page_data.get(important_field, '')})
2141             return user_data
2142
2143         # values are stored in lists (returned from JS parser)
2144         for item in netflix_page_data:
2145             if 'memberContext' in dict(item).keys():
2146                 for important_field in important_fields:
2147                     user_data.update({important_field: item['memberContext']['data']['userInfo'][important_field]})
2148
2149         return user_data
2150
2151     def _parse_profile_data (self, netflix_page_data):
2152         """Parse out the profile data from the big chunk of dicts we got from
2153            parsing the JSON-ish data from the netflix homepage
2154
2155         Parameters
2156         ----------
2157         netflix_page_data : :obj:`list`
2158             List of all the JSON-ish data that has been extracted from the Netflix homepage
2159             see: extract_inline_netflix_page_data
2160
2161         Returns
2162         -------
2163             :obj:`dict` of :obj:`dict
2164
2165             {
2166                 "72ERT45...": {
2167                     "profileName": "username",
2168                     "avatar": "http://..../avatar.png",
2169                     "id": "72ERT45...",
2170                     "isAccountOwner": False,
2171                     "isActive": True,
2172                     "isFirstUse": False
2173                 }
2174             }
2175         """
2176         profiles = {};
2177         important_fields = [
2178             'profileName',
2179             'isActive',
2180             'isFirstUse',
2181             'isAccountOwner'
2182         ]
2183
2184         # values are accessible via dict (sloppy parsing successfull)
2185         if type(netflix_page_data) == dict:
2186             for profile_id in netflix_page_data.get('profiles'):
2187                 if self._is_size_key(key=profile_id) == False and type(netflix_page_data['profiles'][profile_id]) == dict and netflix_page_data['profiles'][profile_id].get('avatar', False) != False:
2188                     profile = {'id': profile_id}
2189                     for important_field in important_fields:
2190                         profile.update({important_field: netflix_page_data['profiles'][profile_id]['summary'][important_field]})
2191                     avatar_base = netflix_page_data['nf'].get(netflix_page_data['profiles'][profile_id]['summary']['avatarName'], False);
2192                     avatar = 'https://secure.netflix.com/ffe/profiles/avatars_v2/320x320/PICON_029.png' if avatar_base == False else avatar_base['images']['byWidth']['320']['value']
2193                     profile.update({'avatar': avatar})
2194                     profiles.update({profile_id: profile})
2195             return profiles
2196
2197         # values are stored in lists (returned from JS parser)
2198         # TODO: get rid of this christmas tree of doom
2199         for item in netflix_page_data:
2200             if 'hasViewedRatingWelcomeModal' in dict(item).keys():
2201                 for profile_id in item:
2202                     if self._is_size_key(key=profile_id) == False and type(item[profile_id]) == dict and item[profile_id].get('avatar', False) != False:
2203                         profile = {'id': profile_id}
2204                         for important_field in important_fields:
2205                             profile.update({important_field: item[profile_id]['summary'][important_field]})
2206                         avatar_base = item['nf'].get(item[profile_id]['summary']['avatarName'], False);
2207                         avatar = 'https://secure.netflix.com/ffe/profiles/avatars_v2/320x320/PICON_029.png' if avatar_base == False else avatar_base['images']['byWidth']['320']['value']
2208                         profile.update({'avatar': avatar})
2209                         profiles.update({profile_id: profile})
2210         return profiles
2211
2212     def _parse_api_base_data (self, netflix_page_data):
2213         """Parse out the api url data from the big chunk of dicts we got from
2214            parsing the JSOn-ish data from the netflix homepage
2215
2216         Parameters
2217         ----------
2218         netflix_page_data : :obj:`list`
2219             List of all the JSON-ish data that has been extracted from the Netflix homepage
2220             see: extract_inline_netflix_page_data
2221
2222         Returns
2223         -------
2224             :obj:`dict` of :obj:`str
2225
2226             {
2227                 "API_BASE_URL": "/shakti",
2228                 "API_ROOT": "https://www.netflix.com/api",
2229                 "BUILD_IDENTIFIER": "113b89c9",
2230                 "ICHNAEA_ROOT": "/ichnaea"
2231             }
2232         """
2233         api_data = {};
2234         important_fields = [
2235             'API_BASE_URL',
2236             'API_ROOT',
2237             'BUILD_IDENTIFIER',
2238             'ICHNAEA_ROOT'
2239         ]
2240
2241         # values are accessible via dict (sloppy parsing successfull)
2242         if type(netflix_page_data) == dict:
2243             for important_field in important_fields:
2244                 api_data.update({important_field: netflix_page_data.get(important_field, '')})
2245             return api_data
2246
2247         for item in netflix_page_data:
2248             if 'serverDefs' in dict(item).keys():
2249                 for important_field in important_fields:
2250                     api_data.update({important_field: item['serverDefs']['data'][important_field]})
2251         return api_data
2252
2253     def _parse_esn_data (self, netflix_page_data):
2254         """Parse out the esn id data from the big chunk of dicts we got from
2255            parsing the JSOn-ish data from the netflix homepage
2256
2257         Parameters
2258         ----------
2259         netflix_page_data : :obj:`list`
2260             List of all the JSON-ish data that has been extracted from the Netflix homepage
2261             see: extract_inline_netflix_page_data
2262
2263         Returns
2264         -------
2265             :obj:`str` of :obj:`str
2266             ESN, something like: NFCDCH-MC-D7D6F54LOPY8J416T72MQXX3RD20ME
2267         """
2268         esn = ''
2269         # values are accessible via dict (sloppy parsing successfull)
2270         if type(netflix_page_data) == dict:
2271             return netflix_page_data.get('esn', '')
2272
2273         # values are stored in lists (returned from JS parser)
2274         for item in netflix_page_data:
2275             if 'esnGeneratorModel' in dict(item).keys():
2276                 esn = item['esnGeneratorModel']['data']['esn']
2277         return esn
2278
2279     def _parse_page_contents (self, page_soup):
2280         """Call all the parsers we need to extract all the session relevant data from the HTML page
2281            Directly assigns it to the NetflixSession instance
2282
2283         Parameters
2284         ----------
2285         page_soup : :obj:`BeautifulSoup`
2286             Instance of an BeautifulSoup document or node containing the complete page contents
2287         """
2288         netflix_page_data = self.extract_inline_netflix_page_data(page_soup=page_soup)
2289         self.user_data = self._parse_user_data(netflix_page_data=netflix_page_data)
2290         self.esn = self._parse_esn_data(netflix_page_data=netflix_page_data)
2291         self.api_data = self._parse_api_base_data(netflix_page_data=netflix_page_data)
2292         self.profiles = self._parse_profile_data(netflix_page_data=netflix_page_data)
2293         self.log('Found ESN "' + self.esn + '"')
2294         return netflix_page_data