60625a3cc1d5eae729fed63b06cf2bb9b5bc2521
[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, 'html.parser')
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(browse_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.user_data['guid'] = profile_id;
495         self._save_data(filename=self.data_path + '_' + account_hash)
496         return True
497
498     def send_adult_pin (self, pin):
499         """Send the adult pin to Netflix in case an adult rated video requests it
500
501         Note: Once entered, it should last for the complete session (Not so sure about this)
502
503         Parameters
504         ----------
505         pin : :obj:`str`
506             The users adult pin
507
508         Returns
509         -------
510         bool
511             Pin was accepted or not
512         or
513         :obj:`dict` of :obj:`str`
514             Api call error
515         """
516         payload = {
517             'pin': pin,
518             'authURL': self.user_data['authURL']
519         }
520         url = self._get_api_url_for(component='adult_pin')
521         response = self.session.get(url, params=payload, verify=self.verify_ssl);
522         pin_response = self._process_response(response=response, component=url)
523         keys = pin_response.keys()
524         if 'success' in keys:
525             return True
526         if 'error' in keys:
527             return pin_response
528         return False
529
530     def add_to_list (self, video_id):
531         """Adds a video to "my list" on Netflix
532
533         Parameters
534         ----------
535         video_id : :obj:`str`
536             ID of th show/video/movie to be added
537
538         Returns
539         -------
540         bool
541             Adding was successfull
542         """
543         return self._update_my_list(video_id=video_id, operation='add')
544
545     def remove_from_list (self, video_id):
546         """Removes a video from "my list" on Netflix
547
548         Parameters
549         ----------
550         video_id : :obj:`str`
551             ID of th show/video/movie to be removed
552
553         Returns
554         -------
555         bool
556             Removing was successfull
557         """
558         return self._update_my_list(video_id=video_id, operation='remove')
559
560     def rate_video (self, video_id, rating):
561         """Rate a video on Netflix
562
563         Parameters
564         ----------
565         video_id : :obj:`str`
566             ID of th show/video/movie to be rated
567
568         rating : :obj:`int`
569             Rating, must be between 0 & 10
570
571         Returns
572         -------
573         bool
574             Rating successfull or not
575         """
576
577         # dirty rating validation
578         ratun = int(rating)
579         if rating > 10 or rating < 0:
580             return False
581
582         # In opposition to Kodi, Netflix uses a rating from 0 to in 0.5 steps
583         if rating != 0:
584             rating = rating / 2
585
586         headers = {
587             'Content-Type': 'application/json',
588             'Accept': 'application/json, text/javascript, */*',
589         }
590
591         params = {
592             'titleid': video_id,
593             'rating': rating
594         }
595
596         payload = json.dumps({
597             'authURL': self.user_data['authURL']
598         })
599
600         response = self.session.post(self._get_api_url_for(component='set_video_rating'), params=params, headers=headers, data=payload, verify=self.verify_ssl)
601         return response.status_code == 200
602
603     def parse_video_list_ids (self, response_data):
604         """Parse the list of video ids e.g. rip out the parts we need
605
606         Parameters
607         ----------
608         response_data : :obj:`dict` of :obj:`str`
609             Parsed response JSON from the ´fetch_video_list_ids´ call
610
611         Returns
612         -------
613         :obj:`dict` of :obj:`dict`
614             Video list ids in the format:
615
616             {
617                 "genres": {
618                     "3589e2c6-ca3b-48b4-a72d-34f2c09ffbf4_11568367": {
619                         "displayName": "US-Serien",
620                         "id": "3589e2c6-ca3b-48b4-a72d-34f2c09ffbf4_11568367",
621                         "index": 3,
622                         "name": "genre",
623                         "size": 38
624                     },
625                     "3589e2c6-ca3b-48b4-a72d-34f2c09ffbf4_11568368": {
626                         "displayName": ...
627                     },
628                 },
629                 "user": {
630                     "3589e2c6-ca3b-48b4-a72d-34f2c09ffbf4_11568364": {
631                         "displayName": "Meine Liste",
632                         "id": "3589e2c6-ca3b-48b4-a72d-34f2c09ffbf4_11568364",
633                         "index": 0,
634                         "name": "queue",
635                         "size": 2
636                     },
637                     "3589e2c6-ca3b-48b4-a72d-34f2c09ffbf4_11568365": {
638                         "displayName": ...
639                     },
640                 },
641                 "recommendations": {
642                     "3589e2c6-ca3b-48b4-a72d-34f2c09ffbf4_11568382": {
643                         "displayName": "Passend zu Family Guy",
644                         "id": "3589e2c6-ca3b-48b4-a72d-34f2c09ffbf4_11568382",
645                         "index": 18,
646                         "name": "similars",
647                         "size": 33
648                     },
649                     "3589e2c6-ca3b-48b4-a72d-34f2c09ffbf4_11568397": {
650                         "displayName": ...
651                     }
652                 }
653             }
654         """
655         # prepare the return dictionary
656         video_list_ids = {}
657         for key in self.video_list_keys:
658             video_list_ids[key] = {}
659
660         # subcatogorize the lists by their context
661         video_lists = response_data['lists']
662         for video_list_id in video_lists.keys():
663             video_list = video_lists[video_list_id]
664             if video_list['context'] == 'genre':
665                 video_list_ids['genres'].update(self.parse_video_list_ids_entry(id=video_list_id, entry=video_list))
666             elif video_list['context'] == 'similars' or video_list['context'] == 'becauseYouAdded':
667                 video_list_ids['recommendations'].update(self.parse_video_list_ids_entry(id=video_list_id, entry=video_list))
668             else:
669                 video_list_ids['user'].update(self.parse_video_list_ids_entry(id=video_list_id, entry=video_list))
670
671         return video_list_ids
672
673     def parse_video_list_ids_entry (self, id, entry):
674         """Parse a video id entry e.g. rip out the parts we need
675
676         Parameters
677         ----------
678         response_data : :obj:`dict` of :obj:`str`
679             Dictionary entry from the ´fetch_video_list_ids´ call
680
681         Returns
682         -------
683         id : :obj:`str`
684             Unique id of the video list
685
686         entry : :obj:`dict` of :obj:`str`
687             Video list entry in the format:
688
689             "3589e2c6-ca3b-48b4-a72d-34f2c09ffbf4_11568382": {
690                 "displayName": "Passend zu Family Guy",
691                 "id": "3589e2c6-ca3b-48b4-a72d-34f2c09ffbf4_11568382",
692                 "index": 18,
693                 "name": "similars",
694                 "size": 33
695             }
696         """
697         return {
698             id: {
699                 'id': id,
700                 'index': entry['index'],
701                 'name': entry['context'],
702                 'displayName': entry['displayName'],
703                 'size': entry['length']
704             }
705         }
706
707     def parse_search_results (self, response_data):
708         """Parse the list of search results, rip out the parts we need
709            and extend it with detailed show informations
710
711         Parameters
712         ----------
713         response_data : :obj:`dict` of :obj:`str`
714             Parsed response JSON from the `fetch_search_results` call
715
716         Returns
717         -------
718         :obj:`dict` of :obj:`dict` of :obj:`str`
719             Search results in the format:
720
721             {
722                 "70136140": {
723                     "boxarts": "https://art-s.nflximg.net/0d7af/d5c72668c35d3da65ae031302bd4ae1bcc80d7af.jpg",
724                     "detail_text": "Die legend\u00e4re und mit 13 Emmys nominierte Serie von Gene Roddenberry inspirierte eine ganze Generation.",
725                     "id": "70136140",
726                     "season_id": "70109435",
727                     "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.",
728                     "title": "Star Trek",
729                     "type": "show"
730                 },
731                 "70158329": {
732                     "boxarts": ...
733                 }
734             }
735         """
736         search_results = {}
737         raw_search_results = response_data['value']['videos']
738         for entry_id in raw_search_results:
739             if self._is_size_key(key=entry_id) == False:
740                 # fetch information about each show & build up a proper search results dictionary
741                 show = self.parse_show_list_entry(id=entry_id, entry=raw_search_results[entry_id])
742                 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'])))
743                 search_results.update(show)
744         return search_results
745
746     def parse_show_list_entry (self, id, entry):
747         """Parse a show entry e.g. rip out the parts we need
748
749         Parameters
750         ----------
751         response_data : :obj:`dict` of :obj:`str`
752             Dictionary entry from the ´fetch_show_information´ call
753
754         id : :obj:`str`
755             Unique id of the video list
756
757         Returns
758         -------
759         entry : :obj:`dict` of :obj:`dict` of :obj:`str`
760             Show list entry in the format:
761
762             {
763                 "3589e2c6-ca3b-48b4-a72d-34f2c09ffbf4_11568382": {
764                     "id": "3589e2c6-ca3b-48b4-a72d-34f2c09ffbf4_11568382",
765                     "title": "Enterprise",
766                     "boxarts": "https://art-s.nflximg.net/.../smth.jpg",
767                     "type": "show"
768                 }
769             }
770         """
771         return {
772             id: {
773                 'id': id,
774                 'title': entry['title'],
775                 'boxarts': entry['boxarts']['_342x192']['jpg']['url'],
776                 'type': entry['summary']['type']
777             }
778         }
779
780     def parse_video_list (self, response_data):
781         """Parse a list of videos
782
783         Parameters
784         ----------
785         response_data : :obj:`dict` of :obj:`str`
786             Parsed response JSON from the `fetch_video_list` call
787
788         Returns
789         -------
790         :obj:`dict` of :obj:`dict`
791             Video list in the format:
792
793             {
794                 "372203": {
795                     "artwork": null,
796                     "boxarts": {
797                       "big": "https://art-s.nflximg.net/5e7d3/b3b48749843fd3a36db11c319ffa60f96b55e7d3.jpg",
798                       "small": "https://art-s.nflximg.net/57543/a039845c2eb9186dc26019576d895bf5a1957543.jpg"
799                     },
800                     "cast": [
801                       "Christine Elise",
802                       "Brad Dourif",
803                       "Grace Zabriskie",
804                       "Jenny Agutter",
805                       "John Lafia",
806                       "Gerrit Graham",
807                       "Peter Haskell",
808                       "Alex Vincent",
809                       "Beth Grant"
810                     ],
811                     "creators": [],
812                     "directors": [],
813                     "episode_count": null,
814                     "genres": [
815                       "Horrorfilme"
816                     ],
817                     "id": "372203",
818                     "in_my_list": true,
819                     "interesting_moment": "https://art-s.nflximg.net/09544/ed4b3073394b4469fb6ec22b9df81a4f5cb09544.jpg",
820                     "list_id": "9588df32-f957-40e4-9055-1f6f33b60103_46891306",
821                     "maturity": {
822                       "board": "FSK",
823                       "description": "Nur f\u00fcr Erwachsene geeignet.",
824                       "level": 1000,
825                       "value": "18"
826                     },
827                     "quality": "540",
828                     "rating": 3.1707757,
829                     "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.",
830                     "runtime": 5028,
831                     "seasons_count": null,
832                     "seasons_label": null,
833                     "synopsis": "Die allseits beliebte, von D\u00e4monen besessene M\u00f6rderpuppe ist wieder da und verbreitet erneut Horror und Schrecken.",
834                     "tags": [
835                       "Brutal",
836                       "Spannend"
837                     ],
838                     "title": "Chucky 2 \u2013 Die M\u00f6rderpuppe ist wieder da",
839                     "type": "movie",
840                     "watched": false,
841                     "year": 1990
842                 },
843                 "80011356": {
844                     "artwork": null,
845                     "boxarts": {
846                       "big": "https://art-s.nflximg.net/7c10d/5dcc3fc8f08487e92507627068cfe26ef727c10d.jpg",
847                       "small": "https://art-s.nflximg.net/5bc0e/f3be361b8c594929062f90a8d9c6eb57fb75bc0e.jpg"
848                     },
849                     "cast": [
850                       "Bjarne M\u00e4del"
851                     ],
852                     "creators": [],
853                     "directors": [
854                       "Arne Feldhusen"
855                     ],
856                     "episode_count": 24,
857                     "genres": [
858                       "Deutsche Serien",
859                       "Serien",
860                       "Comedyserien"
861                     ],
862                     "id": "80011356",
863                     "in_my_list": true,
864                     "interesting_moment": "https://art-s.nflximg.net/0188e/19cd705a71ee08c8d2609ae01cd8a97a86c0188e.jpg",
865                     "list_id": "9588df32-f957-40e4-9055-1f6f33b60103_46891306",
866                     "maturity": {
867                       "board": "FSF",
868                       "description": "Geeignet ab 12 Jahren.",
869                       "level": 80,
870                       "value": "12"
871                     },
872                     "quality": "720",
873                     "rating": 4.4394655,
874                     "regular_synopsis": "Comedy-Serie \u00fcber die Erlebnisse eines Tatortreinigers, der seine schmutzige Arbeit erst beginnen kann, wenn die Polizei die Tatortanalyse abgeschlossen hat.",
875                     "runtime": null,
876                     "seasons_count": 5,
877                     "seasons_label": "5 Staffeln",
878                     "synopsis": "In den meisten Krimiserien werden Mordf\u00e4lle auf faszinierende und spannende Weise gel\u00f6st. Diese Serie ist anders.",
879                     "tags": [
880                       "Zynisch"
881                     ],
882                     "title": "Der Tatortreiniger",
883                     "type": "show",
884                     "watched": false,
885                     "year": 2015
886                 },
887             }
888         """
889         video_list = {};
890         raw_video_list = response_data['value']
891         netflix_list_id = self.parse_netflix_list_id(video_list=raw_video_list);
892         for video_id in raw_video_list['videos']:
893             if self._is_size_key(key=video_id) == False:
894                 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']))
895         return video_list
896
897     def parse_video_list_entry (self, id, list_id, video, persons, genres):
898         """Parse a video list entry e.g. rip out the parts we need
899
900         Parameters
901         ----------
902         id : :obj:`str`
903             Unique id of the video
904
905         list_id : :obj:`str`
906             Unique id of the containing list
907
908         video : :obj:`dict` of :obj:`str`
909             Video entry from the ´fetch_video_list´ call
910
911         persons : :obj:`dict` of :obj:`dict` of :obj:`str`
912             List of persons with reference ids
913
914         persons : :obj:`dict` of :obj:`dict` of :obj:`str`
915             List of genres with reference ids
916
917         Returns
918         -------
919         entry : :obj:`dict` of :obj:`dict` of :obj:`str`
920             Video list entry in the format:
921
922            {
923               "372203": {
924                 "artwork": null,
925                 "boxarts": {
926                   "big": "https://art-s.nflximg.net/5e7d3/b3b48749843fd3a36db11c319ffa60f96b55e7d3.jpg",
927                   "small": "https://art-s.nflximg.net/57543/a039845c2eb9186dc26019576d895bf5a1957543.jpg"
928                 },
929                 "cast": [
930                   "Christine Elise",
931                   "Brad Dourif",
932                   "Grace Zabriskie",
933                   "Jenny Agutter",
934                   "John Lafia",
935                   "Gerrit Graham",
936                   "Peter Haskell",
937                   "Alex Vincent",
938                   "Beth Grant"
939                 ],
940                 "creators": [],
941                 "directors": [],
942                 "episode_count": null,
943                 "genres": [
944                   "Horrorfilme"
945                 ],
946                 "id": "372203",
947                 "in_my_list": true,
948                 "interesting_moment": "https://art-s.nflximg.net/09544/ed4b3073394b4469fb6ec22b9df81a4f5cb09544.jpg",
949                 "list_id": "9588df32-f957-40e4-9055-1f6f33b60103_46891306",
950                 "maturity": {
951                   "board": "FSK",
952                   "description": "Nur f\u00fcr Erwachsene geeignet.",
953                   "level": 1000,
954                   "value": "18"
955                 },
956                 "quality": "540",
957                 "rating": 3.1707757,
958                 "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.",
959                 "runtime": 5028,
960                 "seasons_count": null,
961                 "seasons_label": null,
962                 "synopsis": "Die allseits beliebte, von D\u00e4monen besessene M\u00f6rderpuppe ist wieder da und verbreitet erneut Horror und Schrecken.",
963                 "tags": [
964                   "Brutal",
965                   "Spannend"
966                 ],
967                 "title": "Chucky 2 \u2013 Die M\u00f6rderpuppe ist wieder da",
968                 "type": "movie",
969                 "watched": false,
970                 "year": 1990
971               }
972             }
973         """
974         season_info = self.parse_season_information_for_video(video=video)
975         return {
976             id: {
977                 'id': id,
978                 'list_id': list_id,
979                 'title': video['title'],
980                 'synopsis': video['synopsis'],
981                 'regular_synopsis': video['regularSynopsis'],
982                 'type': video['summary']['type'],
983                 'rating': video['userRating']['average'],
984                 'episode_count': season_info['episode_count'],
985                 'seasons_label': season_info['seasons_label'],
986                 'seasons_count': season_info['seasons_count'],
987                 'in_my_list': video['queue']['inQueue'],
988                 'year': video['releaseYear'],
989                 'runtime': self.parse_runtime_for_video(video=video),
990                 'watched': video['watched'],
991                 'tags': self.parse_tags_for_video(video=video),
992                 'genres': self.parse_genres_for_video(video=video, genres=genres),
993                 'quality': self.parse_quality_for_video(video=video),
994                 'cast': self.parse_cast_for_video(video=video, persons=persons),
995                 'directors': self.parse_directors_for_video(video=video, persons=persons),
996                 'creators': self.parse_creators_for_video(video=video, persons=persons),
997                 'maturity': {
998                     'board': None if 'board' not in video['maturity']['rating'].keys() else video['maturity']['rating']['board'],
999                     'value': None if 'value' not in video['maturity']['rating'].keys() else video['maturity']['rating']['value'],
1000                     'description': None if 'maturityDescription' not in video['maturity']['rating'].keys() else video['maturity']['rating']['maturityDescription'],
1001                     'level': None if 'maturityLevel' not in video['maturity']['rating'].keys() else video['maturity']['rating']['maturityLevel']
1002                 },
1003                 'boxarts': {
1004                     'small': video['boxarts']['_342x192']['jpg']['url'],
1005                     'big': video['boxarts']['_1280x720']['jpg']['url']
1006                 },
1007                 'interesting_moment': None if 'interestingMoment' not in video.keys() else video['interestingMoment']['_665x375']['jpg']['url'],
1008                 'artwork': video['artWorkByType']['BILLBOARD']['_1280x720']['jpg']['url'],
1009             }
1010         }
1011
1012     def parse_creators_for_video (self, video, persons):
1013         """Matches ids with person names to generate a list of creators
1014
1015         Parameters
1016         ----------
1017         video : :obj:`dict` of :obj:`str`
1018             Dictionary entry for one video entry
1019
1020         persons : :obj:`dict` of :obj:`str`
1021             Raw resposne of all persons delivered by the API call
1022
1023         Returns
1024         -------
1025         :obj:`list` of :obj:`str`
1026             List of creators
1027         """
1028         creators = []
1029         for person_key in dict(persons).keys():
1030             if self._is_size_key(key=person_key) == False and person_key != 'summary':
1031                 for creator_key in dict(video['creators']).keys():
1032                     if self._is_size_key(key=creator_key) == False and creator_key != 'summary':
1033                         if video['creators'][creator_key][1] == person_key:
1034                             creators.append(persons[person_key]['name'])
1035         return creators
1036
1037     def parse_directors_for_video (self, video, persons):
1038         """Matches ids with person names to generate a list of directors
1039
1040         Parameters
1041         ----------
1042         video : :obj:`dict` of :obj:`str`
1043             Dictionary entry for one video entry
1044
1045         persons : :obj:`dict` of :obj:`str`
1046             Raw resposne of all persons delivered by the API call
1047
1048         Returns
1049         -------
1050         :obj:`list` of :obj:`str`
1051             List of directors
1052         """
1053         directors = []
1054         for person_key in dict(persons).keys():
1055             if self._is_size_key(key=person_key) == False and person_key != 'summary':
1056                 for director_key in dict(video['directors']).keys():
1057                     if self._is_size_key(key=director_key) == False and director_key != 'summary':
1058                         if video['directors'][director_key][1] == person_key:
1059                             directors.append(persons[person_key]['name'])
1060         return directors
1061
1062     def parse_cast_for_video (self, video, persons):
1063         """Matches ids with person names to generate a list of cast members
1064
1065         Parameters
1066         ----------
1067         video : :obj:`dict` of :obj:`str`
1068             Dictionary entry for one video entry
1069
1070         persons : :obj:`dict` of :obj:`str`
1071             Raw resposne of all persons delivered by the API call
1072
1073         Returns
1074         -------
1075         :obj:`list` of :obj:`str`
1076             List of cast members
1077         """
1078         cast = []
1079         for person_key in dict(persons).keys():
1080             if self._is_size_key(key=person_key) == False and person_key != 'summary':
1081                 for cast_key in dict(video['cast']).keys():
1082                     if self._is_size_key(key=cast_key) == False and cast_key != 'summary':
1083                         if video['cast'][cast_key][1] == person_key:
1084                             cast.append(persons[person_key]['name'])
1085         return cast
1086
1087     def parse_genres_for_video (self, video, genres):
1088         """Matches ids with genre names to generate a list of genres for a video
1089
1090         Parameters
1091         ----------
1092         video : :obj:`dict` of :obj:`str`
1093             Dictionary entry for one video entry
1094
1095         genres : :obj:`dict` of :obj:`str`
1096             Raw resposne of all genres delivered by the API call
1097
1098         Returns
1099         -------
1100         :obj:`list` of :obj:`str`
1101             List of genres
1102         """
1103         video_genres = []
1104         for genre_key in dict(genres).keys():
1105             if self._is_size_key(key=genre_key) == False and genre_key != 'summary':
1106                 for show_genre_key in dict(video['genres']).keys():
1107                     if self._is_size_key(key=show_genre_key) == False and show_genre_key != 'summary':
1108                         if video['genres'][show_genre_key][1] == genre_key:
1109                             video_genres.append(genres[genre_key]['name'])
1110         return video_genres
1111
1112     def parse_tags_for_video (self, video):
1113         """Parses a nested list of tags, removes the not needed meta information & returns a raw string list
1114
1115         Parameters
1116         ----------
1117         video : :obj:`dict` of :obj:`str`
1118             Dictionary entry for one video entry
1119
1120         Returns
1121         -------
1122         :obj:`list` of :obj:`str`
1123             List of tags
1124         """
1125         tags = []
1126         for tag_key in dict(video['tags']).keys():
1127             if self._is_size_key(key=tag_key) == False and tag_key != 'summary':
1128                 tags.append(video['tags'][tag_key]['name'])
1129         return tags
1130
1131     def parse_season_information_for_video (self, video):
1132         """Checks if the fiven video is a show (series) and returns season & episode information
1133
1134         Parameters
1135         ----------
1136         video : :obj:`dict` of :obj:`str`
1137             Dictionary entry for one video entry
1138
1139         Returns
1140         -------
1141         :obj:`dict` of :obj:`str`
1142             Episode count / Season Count & Season label if given
1143         """
1144         season_info = {
1145             'episode_count': None,
1146             'seasons_label': None,
1147             'seasons_count': None
1148         }
1149         if video['summary']['type'] == 'show':
1150             season_info = {
1151                 'episode_count': video['episodeCount'],
1152                 'seasons_label': video['numSeasonsLabel'],
1153                 'seasons_count': video['seasonCount']
1154             }
1155         return season_info
1156
1157     def parse_quality_for_video (self, video):
1158         """Transforms Netflix quality information in video resolution info
1159
1160         Parameters
1161         ----------
1162         video : :obj:`dict` of :obj:`str`
1163             Dictionary entry for one video entry
1164
1165         Returns
1166         -------
1167         :obj:`str`
1168             Quality of the video
1169         """
1170         quality = '720'
1171         if video['videoQuality']['hasHD']:
1172             quality = '1080'
1173         if video['videoQuality']['hasUltraHD']:
1174             quality = '4000'
1175         return quality
1176
1177     def parse_runtime_for_video (self, video):
1178         """Checks if the video is a movie & returns the runtime if given
1179
1180         Parameters
1181         ----------
1182         video : :obj:`dict` of :obj:`str`
1183             Dictionary entry for one video entry
1184
1185         Returns
1186         -------
1187         :obj:`str`
1188             Runtime of the video (in seconds)
1189         """
1190         runtime = None
1191         if video['summary']['type'] != 'show':
1192             runtime = video['runtime']
1193         return runtime
1194
1195     def parse_netflix_list_id (self, video_list):
1196         """Parse a video list and extract the list id
1197
1198         Parameters
1199         ----------
1200         video_list : :obj:`dict` of :obj:`str`
1201             Netflix video list
1202
1203         Returns
1204         -------
1205         entry : :obj:`str` or None
1206             Netflix list id
1207         """
1208         netflix_list_id = None
1209         if 'lists' in video_list.keys():
1210             for video_id in video_list['lists']:
1211                 if self._is_size_key(key=video_id) == False:
1212                     netflix_list_id = video_id;
1213         return netflix_list_id
1214
1215     def parse_show_information (self, id, response_data):
1216         """Parse extended show information (synopsis, seasons, etc.)
1217
1218         Parameters
1219         ----------
1220         id : :obj:`str`
1221             Video id
1222
1223         response_data : :obj:`dict` of :obj:`str`
1224             Parsed response JSON from the `fetch_show_information` call
1225
1226         Returns
1227         -------
1228         entry : :obj:`dict` of :obj:`str`
1229         Show information in the format:
1230             {
1231                 "season_id": "80113084",
1232                 "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."
1233                 "detail_text": "I´m optional"
1234             }
1235         """
1236         show = {}
1237         raw_show = response_data['value']['videos'][id]
1238         show.update({'synopsis': raw_show['regularSynopsis']})
1239         if 'evidence' in raw_show:
1240             show.update({'detail_text': raw_show['evidence']['value']['text']})
1241         if 'seasonList' in raw_show:
1242             show.update({'season_id': raw_show['seasonList']['current'][1]})
1243         return show
1244
1245     def parse_seasons (self, id, response_data):
1246         """Parse a list of seasons for a given show
1247
1248         Parameters
1249         ----------
1250         id : :obj:`str`
1251             Season id
1252
1253         response_data : :obj:`dict` of :obj:`str`
1254             Parsed response JSON from the `fetch_seasons_for_show` call
1255
1256         Returns
1257         -------
1258         entry : :obj:`dict` of :obj:`dict` of :obj:`str`
1259         Season information in the format:
1260             {
1261                 "80113084": {
1262                     "id": 80113084,
1263                     "text": "Season 1",
1264                     "shortName": "St. 1",
1265                     "boxarts": {
1266                       "big": "https://art-s.nflximg.net/5e7d3/b3b48749843fd3a36db11c319ffa60f96b55e7d3.jpg",
1267                       "small": "https://art-s.nflximg.net/57543/a039845c2eb9186dc26019576d895bf5a1957543.jpg"
1268                     },
1269                     "interesting_moment": "https://art-s.nflximg.net/09544/ed4b3073394b4469fb6ec22b9df81a4f5cb09544.jpg"
1270                 },
1271                 "80113085": {
1272                     "id": 80113085,
1273                     "text": "Season 2",
1274                     "shortName": "St. 2",
1275                     "boxarts": {
1276                       "big": "https://art-s.nflximg.net/5e7d3/b3b48749843fd3a36db11c319ffa60f96b55e7d3.jpg",
1277                       "small": "https://art-s.nflximg.net/57543/a039845c2eb9186dc26019576d895bf5a1957543.jpg"
1278                     },
1279                     "interesting_moment": "https://art-s.nflximg.net/09544/ed4b3073394b4469fb6ec22b9df81a4f5cb09544.jpg"
1280                 }
1281             }
1282         """
1283         seasons = {}
1284         raw_seasons = response_data['value']
1285         for season in raw_seasons['seasons']:
1286             if self._is_size_key(key=season) == False:
1287                 seasons.update(self.parse_season_entry(season=raw_seasons['seasons'][season], videos=raw_seasons['videos']))
1288         return seasons
1289
1290     def parse_season_entry (self, season, videos):
1291         """Parse a season list entry e.g. rip out the parts we need
1292
1293         Parameters
1294         ----------
1295         season : :obj:`dict` of :obj:`str`
1296             Season entry from the `fetch_seasons_for_show` call
1297
1298         Returns
1299         -------
1300         entry : :obj:`dict` of :obj:`dict` of :obj:`str`
1301             Season list entry in the format:
1302
1303             {
1304                 "80113084": {
1305                     "id": 80113084,
1306                     "text": "Season 1",
1307                     "shortName": "St. 1",
1308                     "boxarts": {
1309                       "big": "https://art-s.nflximg.net/5e7d3/b3b48749843fd3a36db11c319ffa60f96b55e7d3.jpg",
1310                       "small": "https://art-s.nflximg.net/57543/a039845c2eb9186dc26019576d895bf5a1957543.jpg"
1311                     },
1312                     "interesting_moment": "https://art-s.nflximg.net/09544/ed4b3073394b4469fb6ec22b9df81a4f5cb09544.jpg"
1313                 }
1314             }
1315         """
1316         # get art video key
1317         video_key = ''
1318         for key in videos.keys():
1319             if self._is_size_key(key=key) == False:
1320                 video_key = key
1321         # get season index
1322         sorting = {}
1323         for idx in videos[video_key]['seasonList']:
1324             if self._is_size_key(key=idx) == False and idx != 'summary':
1325                 sorting[int(videos[video_key]['seasonList'][idx][1])] = int(idx)
1326         return {
1327             season['summary']['id']: {
1328                 'idx': sorting[season['summary']['id']],
1329                 'id': season['summary']['id'],
1330                 'text': season['summary']['name'],
1331                 'shortName': season['summary']['shortName'],
1332                 'boxarts': {
1333                     'small': videos[video_key]['boxarts']['_342x192']['jpg']['url'],
1334                     'big': videos[video_key]['boxarts']['_1280x720']['jpg']['url']
1335                 },
1336                 'interesting_moment': videos[video_key]['interestingMoment']['_665x375']['jpg']['url'],
1337             }
1338         }
1339
1340     def parse_episodes_by_season (self, response_data):
1341         """Parse episodes for a given season/episode list
1342
1343         Parameters
1344         ----------
1345         response_data : :obj:`dict` of :obj:`str`
1346             Parsed response JSON from the `fetch_seasons_for_show` call
1347
1348         Returns
1349         -------
1350         entry : :obj:`dict` of :obj:`dict` of :obj:`str`
1351         Season information in the format:
1352
1353         {
1354           "70251729": {
1355             "banner": "https://art-s.nflximg.net/63a36/c7fdfe6604ef2c22d085ac5dca5f69874e363a36.jpg",
1356             "duration": 1387,
1357             "episode": 1,
1358             "fanart": "https://art-s.nflximg.net/74e02/e7edcc5cc7dcda1e94d505df2f0a2f0d22774e02.jpg",
1359             "genres": [
1360               "Serien",
1361               "Comedyserien"
1362             ],
1363             "id": 70251729,
1364             "mediatype": "episode",
1365             "mpaa": "FSK 16",
1366             "my_list": false,
1367             "playcount": 0,
1368             "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.",
1369             "poster": "https://art-s.nflximg.net/72fd6/57088715e8d436fdb6986834ab39124b0a972fd6.jpg",
1370             "rating": 3.9111512,
1371             "season": 9,
1372             "thumb": "https://art-s.nflximg.net/be686/07680670a68da8749eba607efb1ae37f9e3be686.jpg",
1373             "title": "Und dann gab es weniger (Teil 1)",
1374             "year": 2010,
1375             "bookmark": -1
1376           },
1377           "70251730": {
1378             "banner": "https://art-s.nflximg.net/63a36/c7fdfe6604ef2c22d085ac5dca5f69874e363a36.jpg",
1379             "duration": 1379,
1380             "episode": 2,
1381             "fanart": "https://art-s.nflximg.net/c472c/6c10f9578bf2c1d0a183c2ccb382931efcbc472c.jpg",
1382             "genres": [
1383               "Serien",
1384               "Comedyserien"
1385             ],
1386             "id": 70251730,
1387             "mediatype": "episode",
1388             "mpaa": "FSK 16",
1389             "my_list": false,
1390             "playcount": 1,
1391             "plot": "Wer ist der M\u00f6rder? Nach zahlreichen Morden wird immer wieder jemand anderes verd\u00e4chtigt.",
1392             "poster": "https://art-s.nflximg.net/72fd6/57088715e8d436fdb6986834ab39124b0a972fd6.jpg",
1393             "rating": 3.9111512,
1394             "season": 9,
1395             "thumb": "https://art-s.nflximg.net/15a08/857d59126641987bec302bb147a802a00d015a08.jpg",
1396             "title": "Und dann gab es weniger (Teil 2)",
1397             "year": 2010,
1398             "bookmark": 1234
1399           },
1400         }
1401         """
1402         episodes = {}
1403         raw_episodes = response_data['value']['videos']
1404         for episode_id in raw_episodes:
1405             if self._is_size_key(key=episode_id) == False:
1406                 if (raw_episodes[episode_id]['summary']['type'] == 'episode'):
1407                     episodes.update(self.parse_episode(episode=raw_episodes[episode_id], genres=response_data['value']['genres']))
1408         return episodes
1409
1410     def parse_episode (self, episode, genres=None):
1411         """Parse episode from an list of episodes by season
1412
1413         Parameters
1414         ----------
1415         episode : :obj:`dict` of :obj:`str`
1416             Episode entry from the `fetch_episodes_by_season` call
1417
1418         Returns
1419         -------
1420         entry : :obj:`dict` of :obj:`dict` of :obj:`str`
1421         Episode information in the format:
1422
1423         {
1424           "70251729": {
1425             "banner": "https://art-s.nflximg.net/63a36/c7fdfe6604ef2c22d085ac5dca5f69874e363a36.jpg",
1426             "duration": 1387,
1427             "episode": 1,
1428             "fanart": "https://art-s.nflximg.net/74e02/e7edcc5cc7dcda1e94d505df2f0a2f0d22774e02.jpg",
1429             "genres": [
1430               "Serien",
1431               "Comedyserien"
1432             ],
1433             "id": 70251729,
1434             "mediatype": "episode",
1435             "mpaa": "FSK 16",
1436             "my_list": false,
1437             "playcount": 0,
1438             "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.",
1439             "poster": "https://art-s.nflximg.net/72fd6/57088715e8d436fdb6986834ab39124b0a972fd6.jpg",
1440             "rating": 3.9111512,
1441             "season": 9,
1442             "thumb": "https://art-s.nflximg.net/be686/07680670a68da8749eba607efb1ae37f9e3be686.jpg",
1443             "title": "Und dann gab es weniger (Teil 1)",
1444             "year": 2010,
1445             "bookmark": 1234
1446           },
1447         }
1448         """
1449         return {
1450             episode['summary']['id']: {
1451                 'id': episode['summary']['id'],
1452                 'episode': episode['summary']['episode'],
1453                 'season': episode['summary']['season'],
1454                 'plot': episode['info']['synopsis'],
1455                 'duration': episode['info']['runtime'],
1456                 'title': episode['info']['title'],
1457                 'year': episode['info']['releaseYear'],
1458                 'genres': self.parse_genres_for_video(video=episode, genres=genres),
1459                 'mpaa': str(episode['maturity']['rating']['board']) + ' ' + str(episode['maturity']['rating']['value']),
1460                 'maturity': episode['maturity'],
1461                 'playcount': (0, 1)[episode['watched']],
1462                 'rating': episode['userRating']['average'],
1463                 'thumb': episode['info']['interestingMoments']['url'],
1464                 'fanart': episode['interestingMoment']['_1280x720']['jpg']['url'],
1465                 'poster': episode['boxarts']['_1280x720']['jpg']['url'],
1466                 'banner': episode['boxarts']['_342x192']['jpg']['url'],
1467                 'mediatype': {'episode': 'episode', 'movie': 'movie'}[episode['summary']['type']],
1468                 'my_list': episode['queue']['inQueue'],
1469                 'bookmark': episode['bookmarkPosition']
1470             }
1471         }
1472
1473     def fetch_browse_list_contents (self):
1474         """Fetches the HTML data for the lists on the landing page (browse page) of Netflix
1475
1476         Returns
1477         -------
1478         :obj:`BeautifulSoup`
1479             Instance of an BeautifulSoup document containing the complete page contents
1480         """
1481         response = self.session.get(self._get_document_url_for(component='browse'), verify=self.verify_ssl)
1482         return BeautifulSoup(response.text, 'html.parser')
1483
1484     def fetch_video_list_ids (self, list_from=0, list_to=50):
1485         """Fetches the JSON with detailed information based on the lists on the landing page (browse page) of Netflix
1486
1487         Parameters
1488         ----------
1489         list_from : :obj:`int`
1490             Start entry for pagination
1491
1492         list_to : :obj:`int`
1493             Last entry for pagination
1494
1495         Returns
1496         -------
1497         :obj:`dict` of :obj:`dict` of :obj:`str`
1498             Raw Netflix API call response or api call error
1499         """
1500         payload = {
1501             'fromRow': list_from,
1502             'toRow': list_to,
1503             'opaqueImageExtension': 'jpg',
1504             'transparentImageExtension': 'png',
1505             '_': int(time.time()),
1506             'authURL': self.user_data['authURL']
1507         }
1508         url = self._get_api_url_for(component='video_list_ids')
1509         response = self.session.get(url, params=payload, verify=self.verify_ssl);
1510         return self._process_response(response=response, component=url)
1511
1512     def fetch_search_results (self, search_str, list_from=0, list_to=10):
1513         """Fetches the JSON which contains the results for the given search query
1514
1515         Parameters
1516         ----------
1517         search_str : :obj:`str`
1518             String to query Netflix search for
1519
1520         list_from : :obj:`int`
1521             Start entry for pagination
1522
1523         list_to : :obj:`int`
1524             Last entry for pagination
1525
1526         Returns
1527         -------
1528         :obj:`dict` of :obj:`dict` of :obj:`str`
1529             Raw Netflix API call response or api call error
1530         """
1531         # properly encode the search string
1532         encoded_search_string = urllib.quote(search_str)
1533
1534         paths = [
1535             ['search', encoded_search_string, 'titles', {'from': list_from, 'to': list_to}, ['summary', 'title']],
1536             ['search', encoded_search_string, 'titles', {'from': list_from, 'to': list_to}, 'boxarts', '_342x192', 'jpg'],
1537             ['search', encoded_search_string, 'titles', ['id', 'length', 'name', 'trackIds', 'requestId']],
1538             ['search', encoded_search_string, 'suggestions', 0, 'relatedvideos', {'from': list_from, 'to': list_to}, ['summary', 'title']],
1539             ['search', encoded_search_string, 'suggestions', 0, 'relatedvideos', {'from': list_from, 'to': list_to}, 'boxarts', '_342x192', 'jpg'],
1540             ['search', encoded_search_string, 'suggestions', 0, 'relatedvideos', ['id', 'length', 'name', 'trackIds', 'requestId']]
1541         ]
1542         response = self._path_request(paths=paths)
1543         return self._process_response(response=response, component='Search results')
1544
1545     def fetch_video_list (self, list_id, list_from=0, list_to=20):
1546         """Fetches the JSON which contains the contents of a given video list
1547
1548         Parameters
1549         ----------
1550         list_id : :obj:`str`
1551             Unique list id to query Netflix for
1552
1553         list_from : :obj:`int`
1554             Start entry for pagination
1555
1556         list_to : :obj:`int`
1557             Last entry for pagination
1558
1559         Returns
1560         -------
1561         :obj:`dict` of :obj:`dict` of :obj:`str`
1562             Raw Netflix API call response or api call error
1563         """
1564         paths = [
1565             ['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']],
1566             ['lists', list_id, {'from': list_from, 'to': list_to}, 'cast', {'from': 0, 'to': 15}, ['id', 'name']],
1567             ['lists', list_id, {'from': list_from, 'to': list_to}, 'cast', 'summary'],
1568             ['lists', list_id, {'from': list_from, 'to': list_to}, 'genres', {'from': 0, 'to': 5}, ['id', 'name']],
1569             ['lists', list_id, {'from': list_from, 'to': list_to}, 'genres', 'summary'],
1570             ['lists', list_id, {'from': list_from, 'to': list_to}, 'tags', {'from': 0, 'to': 9}, ['id', 'name']],
1571             ['lists', list_id, {'from': list_from, 'to': list_to}, 'tags', 'summary'],
1572             ['lists', list_id, {'from': list_from, 'to': list_to}, ['creators', 'directors'], {'from': 0, 'to': 49}, ['id', 'name']],
1573             ['lists', list_id, {'from': list_from, 'to': list_to}, ['creators', 'directors'], 'summary'],
1574             ['lists', list_id, {'from': list_from, 'to': list_to}, 'bb2OGLogo', '_400x90', 'png'],
1575             ['lists', list_id, {'from': list_from, 'to': list_to}, 'boxarts', '_342x192', 'jpg'],
1576             ['lists', list_id, {'from': list_from, 'to': list_to}, 'boxarts', '_1280x720', 'jpg'],
1577             ['lists', list_id, {'from': list_from, 'to': list_to}, 'storyarts', '_1632x873', 'jpg'],
1578             ['lists', list_id, {'from': list_from, 'to': list_to}, 'interestingMoment', '_665x375', 'jpg'],
1579             ['lists', list_id, {'from': list_from, 'to': list_to}, 'artWorkByType', 'BILLBOARD', '_1280x720', 'jpg']
1580         ];
1581
1582         response = self._path_request(paths=paths)
1583         return self._process_response(response=response, component='Video list')
1584
1585     def fetch_video_list_information (self, video_ids):
1586         """Fetches the JSON which contains the detail information of a list of given video ids
1587
1588         Parameters
1589         ----------
1590         video_ids : :obj:`list` of :obj:`str`
1591             List of video ids to fetch detail data for
1592
1593         Returns
1594         -------
1595         :obj:`dict` of :obj:`dict` of :obj:`str`
1596             Raw Netflix API call response or api call error
1597         """
1598         paths = []
1599         for video_id in video_ids:
1600             paths.append(['videos', video_id, ['summary', 'title', 'synopsis', 'regularSynopsis', 'evidence', 'queue', 'episodeCount', 'info', 'maturity', 'runtime', 'seasonCount', 'releaseYear', 'userRating', 'numSeasonsLabel', 'bookmarkPosition', 'watched', 'videoQuality']])
1601             paths.append(['videos', video_id, 'cast', {'from': 0, 'to': 15}, ['id', 'name']])
1602             paths.append(['videos', video_id, 'cast', 'summary'])
1603             paths.append(['videos', video_id, 'genres', {'from': 0, 'to': 5}, ['id', 'name']])
1604             paths.append(['videos', video_id, 'genres', 'summary'])
1605             paths.append(['videos', video_id, 'tags', {'from': 0, 'to': 9}, ['id', 'name']])
1606             paths.append(['videos', video_id, 'tags', 'summary'])
1607             paths.append(['videos', video_id, ['creators', 'directors'], {'from': 0, 'to': 49}, ['id', 'name']])
1608             paths.append(['videos', video_id, ['creators', 'directors'], 'summary'])
1609             paths.append(['videos', video_id, 'bb2OGLogo', '_400x90', 'png'])
1610             paths.append(['videos', video_id, 'boxarts', '_342x192', 'jpg'])
1611             paths.append(['videos', video_id, 'boxarts', '_1280x720', 'jpg'])
1612             paths.append(['videos', video_id, 'storyarts', '_1632x873', 'jpg'])
1613             paths.append(['videos', video_id, 'interestingMoment', '_665x375', 'jpg'])
1614             paths.append(['videos', video_id, 'artWorkByType', 'BILLBOARD', '_1280x720', 'jpg'])
1615
1616         response = self._path_request(paths=paths)
1617         return self._process_response(response=response, component='fetch_video_list_information')
1618
1619     def fetch_metadata (self, id):
1620         """Fetches the JSON which contains the metadata for a given show/movie or season id
1621
1622         Parameters
1623         ----------
1624         id : :obj:`str`
1625             Show id, movie id or season id
1626
1627         Returns
1628         -------
1629         :obj:`dict` of :obj:`dict` of :obj:`str`
1630             Raw Netflix API call response or api call error
1631         """
1632         payload = {
1633             'movieid': id,
1634             'imageformat': 'jpg',
1635             '_': int(time.time())
1636         }
1637         url = self._get_api_url_for(component='metadata')
1638         response = self.session.get(url, params=payload, verify=self.verify_ssl);
1639         return self._process_response(response=response, component=url)
1640
1641     def fetch_show_information (self, id, type):
1642         """Fetches the JSON which contains the detailed contents of a show
1643
1644         Parameters
1645         ----------
1646         id : :obj:`str`
1647             Unique show id to query Netflix for
1648
1649         type : :obj:`str`
1650             Can be 'movie' or 'show'
1651
1652         Returns
1653         -------
1654         :obj:`dict` of :obj:`dict` of :obj:`str`
1655             Raw Netflix API call response or api call error
1656         """
1657         # check if we have a show or a movie, the request made depends on this
1658         if type == 'show':
1659             paths = [
1660                 ['videos', id, ['requestId', 'regularSynopsis', 'evidence']],
1661                 ['videos', id, 'seasonList', 'current', 'summary']
1662             ]
1663         else:
1664             paths = [['videos', id, ['requestId', 'regularSynopsis', 'evidence']]]
1665         response = self._path_request(paths=paths)
1666         return self._process_response(response=response, component='Show information')
1667
1668     def fetch_seasons_for_show (self, id, list_from=0, list_to=30):
1669         """Fetches the JSON which contains the seasons of a given show
1670
1671         Parameters
1672         ----------
1673         id : :obj:`str`
1674             Unique show id to query Netflix for
1675
1676         list_from : :obj:`int`
1677             Start entry for pagination
1678
1679         list_to : :obj:`int`
1680             Last entry for pagination
1681
1682         Returns
1683         -------
1684         :obj:`dict` of :obj:`dict` of :obj:`str`
1685             Raw Netflix API call response or api call error
1686         """
1687         paths = [
1688             ['videos', id, 'seasonList', {'from': list_from, 'to': list_to}, 'summary'],
1689             ['videos', id, 'seasonList', 'summary'],
1690             ['videos', id, 'boxarts',  '_342x192', 'jpg'],
1691             ['videos', id, 'boxarts', '_1280x720', 'jpg'],
1692             ['videos', id, 'storyarts',  '_1632x873', 'jpg'],
1693             ['videos', id, 'interestingMoment', '_665x375', 'jpg']
1694         ]
1695         response = self._path_request(paths=paths)
1696         return self._process_response(response=response, component='Seasons')
1697
1698     def fetch_episodes_by_season (self, season_id, list_from=-1, list_to=40):
1699         """Fetches the JSON which contains the episodes of a given season
1700
1701         TODO: Add more metadata
1702
1703         Parameters
1704         ----------
1705         season_id : :obj:`str`
1706             Unique season_id id to query Netflix for
1707
1708         list_from : :obj:`int`
1709             Start entry for pagination
1710
1711         list_to : :obj:`int`
1712             Last entry for pagination
1713
1714         Returns
1715         -------
1716         :obj:`dict` of :obj:`dict` of :obj:`str`
1717             Raw Netflix API call response or api call error
1718         """
1719         paths = [
1720             ['seasons', season_id, 'episodes', {'from': list_from, 'to': list_to}, ['summary', 'queue', 'info', 'maturity', 'userRating', 'bookmarkPosition', 'creditOffset', 'watched', 'videoQuality']],
1721             #['videos', season_id, 'cast', {'from': 0, 'to': 15}, ['id', 'name']],
1722             #['videos', season_id, 'cast', 'summary'],
1723             #['videos', season_id, 'genres', {'from': 0, 'to': 5}, ['id', 'name']],
1724             #['videos', season_id, 'genres', 'summary'],
1725             #['videos', season_id, 'tags', {'from': 0, 'to': 9}, ['id', 'name']],
1726             #['videos', season_id, 'tags', 'summary'],
1727             #['videos', season_id, ['creators', 'directors'], {'from': 0, 'to': 49}, ['id', 'name']],
1728             #['videos', season_id, ['creators', 'directors'], 'summary'],
1729             ['seasons', season_id, 'episodes', {'from': list_from, 'to': list_to}, 'genres', {'from': 0, 'to': 1}, ['id', 'name']],
1730             ['seasons', season_id, 'episodes', {'from': list_from, 'to': list_to}, 'genres', 'summary'],
1731             ['seasons', season_id, 'episodes', {'from': list_from, 'to': list_to}, 'interestingMoment', '_1280x720', 'jpg'],
1732             ['seasons', season_id, 'episodes', {'from': list_from, 'to': list_to}, 'interestingMoment', '_665x375', 'jpg'],
1733             ['seasons', season_id, 'episodes', {'from': list_from, 'to': list_to}, 'boxarts', '_342x192', 'jpg'],
1734             ['seasons', season_id, 'episodes', {'from': list_from, 'to': list_to}, 'boxarts', '_1280x720', 'jpg']
1735         ]
1736         response = self._path_request(paths=paths)
1737         return self._process_response(response=response, component='fetch_episodes_by_season')
1738
1739     def refresh_session_data (self, account):
1740         """Reload the session data (profiles, user_data, api_data)
1741
1742         Parameters
1743         ----------
1744         account : :obj:`dict` of :obj:`str`
1745             Dict containing an email, country & a password property
1746         """
1747         # load the profiles page (to verify the user)
1748         response = self.session.get(self._get_document_url_for(component='profiles'), verify=self.verify_ssl)
1749         # parse out the needed inline information
1750         only_script_tags = SoupStrainer('script')
1751         page_soup = BeautifulSoup(response.text, 'html.parser', parse_only=only_script_tags)
1752         page_data = self._parse_page_contents(page_soup=page_soup)
1753         account_hash = self._generate_account_hash(account=account)
1754         self._save_data(filename=self.data_path + '_' + account_hash)
1755
1756     def _path_request (self, paths):
1757         """Executes a post request against the shakti endpoint with Falcor style payload
1758
1759         Parameters
1760         ----------
1761         paths : :obj:`list` of :obj:`list`
1762             Payload with path querys for the Netflix Shakti API in Falcor style
1763
1764         Returns
1765         -------
1766         :obj:`requests.response`
1767             Response from a POST call made with Requests
1768         """
1769         headers = {
1770             'Content-Type': 'application/json',
1771             'Accept': 'application/json, text/javascript, */*',
1772         }
1773
1774         data = json.dumps({
1775             'paths': paths,
1776             'authURL': self.user_data['authURL']
1777         })
1778
1779         params = {
1780             'withSize': True,
1781             'materialize': True,
1782             'model': self.user_data['gpsModel']
1783         }
1784
1785         return self.session.post(self._get_api_url_for(component='shakti'), params=params, headers=headers, data=data, verify=self.verify_ssl)
1786
1787     def _is_size_key (self, key):
1788         """Tiny helper that checks if a given key is called $size or size, as we need to check this often
1789
1790         Parameters
1791         ----------
1792         key : :obj:`str`
1793             Key to check the value for
1794
1795         Returns
1796         -------
1797         bool
1798             Key has a size value or not
1799         """
1800         return key == '$size' or key == 'size'
1801
1802     def _get_api_url_for (self, component):
1803         """Tiny helper that builds the url for a requested API endpoint component
1804
1805         Parameters
1806         ----------
1807         component : :obj:`str`
1808             Component endpoint to build the URL for
1809
1810         Returns
1811         -------
1812         :obj:`str`
1813             API Url
1814         """
1815         return self.api_data['API_ROOT'] + self.api_data['API_BASE_URL'] + '/' + self.api_data['BUILD_IDENTIFIER'] + self.urls[component]
1816
1817     def _get_document_url_for (self, component):
1818         """Tiny helper that builds the url for a requested document endpoint component
1819
1820         Parameters
1821         ----------
1822         component : :obj:`str`
1823             Component endpoint to build the URL for
1824
1825         Returns
1826         -------
1827         :obj:`str`
1828             Document Url
1829         """
1830         return self.base_url + self.urls[component]
1831
1832     def _process_response (self, response, component):
1833         """Tiny helper to check responses for API requests
1834
1835         Parameters
1836         ----------
1837         response : :obj:`requests.response`
1838             Response from a requests instance
1839
1840         component : :obj:`str`
1841             Component endpoint
1842
1843         Returns
1844         -------
1845         :obj:`dict` of :obj:`dict` of :obj:`str` or :obj:`dict` of :obj:`str`
1846             Raw Netflix API call response or api call error
1847         """
1848         # check if we´re not authorized to make thios call
1849         if response.status_code == 401:
1850             return {
1851                 'error': True,
1852                 'message': 'Session invalid',
1853                 'code': 401
1854             }
1855         # check if somethign else failed
1856         if response.status_code != 200:
1857             return {
1858                 'error': True,
1859                 'message': 'API call for "' + component + '" failed',
1860                 'code': response.status_code
1861             }
1862         # return the parsed response & everything´s fine
1863         return response.json()
1864
1865     def _to_unicode(self, str):
1866         '''Attempt to fix non uft-8 string into utf-8, using a limited set of encodings
1867
1868         Parameters
1869         ----------
1870         str : `str`
1871             String to decode
1872
1873         Returns
1874         -------
1875         `str`
1876             Decoded string
1877         '''
1878         # fuller list of encodings at http://docs.python.org/library/codecs.html#standard-encodings
1879         if not str:  return u''
1880         u = None
1881         # we could add more encodings here, as warranted.
1882         encodings = ('ascii', 'utf8', 'latin1')
1883         for enc in encodings:
1884             if u:  break
1885             try:
1886                 u = unicode(str,enc)
1887             except UnicodeDecodeError:
1888                 pass
1889         if not u:
1890             u = unicode(str, errors='replace')
1891         return u
1892
1893     def _update_my_list (self, video_id, operation):
1894         """Tiny helper to add & remove items from "my list"
1895
1896         Parameters
1897         ----------
1898         video_id : :obj:`str`
1899             ID of the show/movie to be added
1900
1901         operation : :obj:`str`
1902             Either "add" or "remove"
1903
1904         Returns
1905         -------
1906         bool
1907             Operation successfull
1908         """
1909         headers = {
1910             'Content-Type': 'application/json',
1911             'Accept': 'application/json, text/javascript, */*',
1912         }
1913
1914         payload = json.dumps({
1915             'operation': operation,
1916             'videoId': int(video_id),
1917             'authURL': self.user_data['authURL']
1918         })
1919
1920         response = self.session.post(self._get_api_url_for(component='update_my_list'), headers=headers, data=payload, verify=self.verify_ssl)
1921         return response.status_code == 200
1922
1923     def _save_data(self, filename):
1924         """Tiny helper that stores session data from the session in a given file
1925
1926         Parameters
1927         ----------
1928         filename : :obj:`str`
1929             Complete path incl. filename that determines where to store the cookie
1930
1931         Returns
1932         -------
1933         bool
1934             Storage procedure was successfull
1935         """
1936         if not os.path.isdir(os.path.dirname(filename)):
1937             return False
1938         with open(filename, 'w') as f:
1939             f.truncate()
1940             pickle.dump({
1941                 'user_data': self.user_data,
1942                 'api_data': self.api_data,
1943                 'profiles': self.profiles
1944             }, f)
1945
1946     def _load_data(self, filename):
1947         """Tiny helper that loads session data into the active session from a given file
1948
1949         Parameters
1950         ----------
1951         filename : :obj:`str`
1952             Complete path incl. filename that determines where to load the data from
1953
1954         Returns
1955         -------
1956         bool
1957             Load procedure was successfull
1958         """
1959         if not os.path.isfile(filename):
1960             return False
1961
1962         with open(filename) as f:
1963             data = pickle.load(f)
1964             if data:
1965                 self.profiles = data['profiles']
1966                 self.user_data = data['user_data']
1967                 self.api_data = data['api_data']
1968             else:
1969                 return False
1970
1971     def _delete_data (self, path):
1972         """Tiny helper that deletes session data
1973
1974         Parameters
1975         ----------
1976         filename : :obj:`str`
1977             Complete path incl. filename that determines where to delete the files
1978
1979         """
1980         head, tail = os.path.split(path)
1981         for subdir, dirs, files in os.walk(head):
1982             for file in files:
1983                 if tail in file:
1984                     os.remove(os.path.join(subdir, file))
1985
1986     def _save_cookies(self, filename):
1987         """Tiny helper that stores cookies from the session in a given file
1988
1989         Parameters
1990         ----------
1991         filename : :obj:`str`
1992             Complete path incl. filename that determines where to store the cookie
1993
1994         Returns
1995         -------
1996         bool
1997             Storage procedure was successfull
1998         """
1999         if not os.path.isdir(os.path.dirname(filename)):
2000             return False
2001         with open(filename, 'w') as f:
2002             f.truncate()
2003             pickle.dump(self.session.cookies._cookies, f)
2004
2005     def _load_cookies(self, filename):
2006         """Tiny helper that loads cookies into the active session from a given file
2007
2008         Parameters
2009         ----------
2010         filename : :obj:`str`
2011             Complete path incl. filename that determines where to load the cookie from
2012
2013         Returns
2014         -------
2015         bool
2016             Load procedure was successfull
2017         """
2018         if not os.path.isfile(filename):
2019             return False
2020
2021         with open(filename) as f:
2022             cookies = pickle.load(f)
2023             if cookies:
2024                 jar = requests.cookies.RequestsCookieJar()
2025                 jar._cookies = cookies
2026                 self.session.cookies = jar
2027             else:
2028                 return False
2029
2030     def _delete_cookies (self, path):
2031         """Tiny helper that deletes cookie data
2032
2033         Parameters
2034         ----------
2035         filename : :obj:`str`
2036             Complete path incl. filename that determines where to delete the files
2037
2038         """
2039         head, tail = os.path.split(path)
2040         for subdir, dirs, files in os.walk(head):
2041             for file in files:
2042                 if tail in file:
2043                     os.remove(os.path.join(subdir, file))
2044
2045     def _generate_account_hash (self, account):
2046         """Generates a has for the given account (used for cookie verification)
2047
2048         Parameters
2049         ----------
2050         account : :obj:`dict` of :obj:`str`
2051             Dict containing an email, country & a password property
2052
2053         Returns
2054         -------
2055         :obj:`str`
2056             Account data hash
2057         """
2058         return base64.urlsafe_b64encode(account['email'])