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