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