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