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