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