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