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