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