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