2 # -*- coding: utf-8 -*-
3 # Module: NetflixSession
4 # Created on: 13.01.2017
14 import cPickle as pickle
17 from bs4 import BeautifulSoup
18 from utils import strip_tags
19 from utils import noop
22 """Helps with login/session management of Netflix users & API data fetching"""
24 base_url = 'https://www.netflix.com'
25 """str: Secure Netflix url"""
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'
39 """:obj:`dict` of :obj:`str` List of all static endpoints for HTML/JSON POST/GET requests"""
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)"""
46 Dict of user profiles, user id is the key:
49 "profileName": "username",
50 "avatar": "http://..../avatar.png",
52 "isAccountOwner": False,
60 dict of user data (used for authentication):
64 "authURL": "145637....",
65 "countryOfSignup": "DE",
66 "emailAddress": "foo@..",
68 "isAdultVerified": True,
69 "isInFreeTrial": False,
71 "isTestAccount": False,
79 dict of api data (used to build up the api urls):
82 "API_BASE_URL": "/shakti",
83 "API_ROOT": "https://www.netflix.com/api",
84 "BUILD_IDENTIFIER": "113b89c9", "
85 ICHNAEA_ROOT": "/ichnaea"
90 """str: Widevine esn, something like: NFCDCH-MC-D7D6F54LOPY8J416T72MQXX3RD20ME"""
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
98 cookie_path : :obj:`str`
101 data_path : :obj:`str`
102 User data cache location
105 optional log function
107 self.cookie_path = cookie_path
108 self.data_path = data_path
109 self.verify_ssl = verify_ssl
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'
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
125 form_soup : :obj:`BeautifulSoup`
126 Instance of an BeautifulSoup documet or node containing the login form
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
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
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:
149 <script>window.netflix = window.netflix || {} ;
150 netflix.notification = {"constants":{"sessionLength":30,"ownerToken":"ZDD...};</script>
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.
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
164 page_soup : :obj:`BeautifulSoup`
165 Instance of an BeautifulSoup document or node containing the complete page contents
169 :obj:`list` of :obj:`dict`
170 List of all the serialized data pulled out of the pagws <script/> tags
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)
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
187 parsed_data = json.loads(transformed_data)
188 inline_data.append(parsed_data)
189 except ValueError, e:
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
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
208 :obj:`dict` of :obj:`str`
211 "guid": "72ERT45...",
212 "authURL": "145637....",
213 "countryOfSignup": "DE",
214 "emailAddress": "foo@..",
215 "gpsModel": "harris",
216 "isAdultVerified": True,
217 "isInFreeTrial": False,
219 "isTestAccount": False,
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]})
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
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
256 :obj:`dict` of :obj:`dict
260 "profileName": "username",
261 "avatar": "http://..../avatar.png",
263 "isAccountOwner": False,
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})
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
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
301 :obj:`dict` of :obj:`str
304 "API_BASE_URL": "/shakti",
305 "API_ROOT": "https://www.netflix.com/api",
306 "BUILD_IDENTIFIER": "113b89c9", "
307 ICHNAEA_ROOT": "/ichnaea"
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]})
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
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
335 :obj:`str` of :obj:`str
336 Widevine esn, something like: NFCDCH-MC-D7D6F54LOPY8J416T72MQXX3RD20ME
339 for item in netflix_page_data:
340 if 'models' in dict(item).keys():
341 esn = item['models']['esnGeneratorModel']['data']['esn']
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
350 page_soup : :obj:`BeautifulSoup`
351 Instance of an BeautifulSoup document or node containing the complete page contents
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)
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
366 account : :obj:`dict` of :obj:`str`
367 Dict containing an email, country & a password property
372 User is already logged in (e.g. Cookie is valid) or not
376 account_hash = self._generate_account_hash(account=account)
377 if self._load_cookies(filename=self.cookie_path + '_' + account_hash) == 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)
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)
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:
397 """Delete all cookies and session data
401 account : :obj:`dict` of :obj:`str`
402 Dict containing an email, country & a password property
405 self._delete_cookies(path=self.cookie_path)
406 self._delete_data(path=self.data_path)
408 def login (self, account):
409 """Try to log in a user with its credentials & stores the cookies if the action is successfull
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
417 account : :obj:`dict` of :obj:`str`
418 Dict containing an email, country & a password property
423 User could be logged in or not
425 response = self.session.get(self._get_document_url_for(component='login'), verify=self.verify_ssl)
426 if response.status_code != 200:
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']
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)
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)
454 def switch_profile (self, profile_id, account):
455 """Switch the user profile based on a given profile id
457 Note: All available profiles & their ids can be found in the ´profiles´ property after a successfull login
461 profile_id : :obj:`str`
464 account : :obj:`dict` of :obj:`str`
465 Dict containing an email, country & a password property
470 User could be switched or not
473 'switchProfileGuid': profile_id,
474 '_': int(time.time()),
475 'authURL': self.user_data['authURL']
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:
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)
490 def send_adult_pin (self, pin):
491 """Send the adult pin to Netflix in case an adult rated video requests it
493 Note: Once entered, it should last for the complete session (Not so sure about this)
503 Pin was accepted or not
505 :obj:`dict` of :obj:`str`
510 'authURL': self.user_data['authURL']
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:
522 def add_to_list (self, video_id):
523 """Adds a video to "my list" on Netflix
527 video_id : :obj:`str`
528 ID of th show/video/movie to be added
533 Adding was successfull
535 return self._update_my_list(video_id=video_id, operation='add')
537 def remove_from_list (self, video_id):
538 """Removes a video from "my list" on Netflix
542 video_id : :obj:`str`
543 ID of th show/video/movie to be removed
548 Removing was successfull
550 return self._update_my_list(video_id=video_id, operation='remove')
552 def rate_video (self, video_id, rating):
553 """Rate a video on Netflix
557 video_id : :obj:`str`
558 ID of th show/video/movie to be rated
561 Rating, must be between 0 & 10
566 Rating successfull or not
569 # dirty rating validation
571 if rating > 10 or rating < 0:
574 # In opposition to Kodi, Netflix uses a rating from 0 to in 0.5 steps
579 'Content-Type': 'application/json',
580 'Accept': 'application/json, text/javascript, */*',
588 payload = json.dumps({
589 'authURL': self.user_data['authURL']
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
595 def parse_video_list_ids (self, response_data):
596 """Parse the list of video ids e.g. rip out the parts we need
600 response_data : :obj:`dict` of :obj:`str`
601 Parsed response JSON from the ´fetch_video_list_ids´ call
605 :obj:`dict` of :obj:`dict`
606 Video list ids in the format:
610 "3589e2c6-ca3b-48b4-a72d-34f2c09ffbf4_11568367": {
611 "displayName": "US-Serien",
612 "id": "3589e2c6-ca3b-48b4-a72d-34f2c09ffbf4_11568367",
617 "3589e2c6-ca3b-48b4-a72d-34f2c09ffbf4_11568368": {
622 "3589e2c6-ca3b-48b4-a72d-34f2c09ffbf4_11568364": {
623 "displayName": "Meine Liste",
624 "id": "3589e2c6-ca3b-48b4-a72d-34f2c09ffbf4_11568364",
629 "3589e2c6-ca3b-48b4-a72d-34f2c09ffbf4_11568365": {
634 "3589e2c6-ca3b-48b4-a72d-34f2c09ffbf4_11568382": {
635 "displayName": "Passend zu Family Guy",
636 "id": "3589e2c6-ca3b-48b4-a72d-34f2c09ffbf4_11568382",
641 "3589e2c6-ca3b-48b4-a72d-34f2c09ffbf4_11568397": {
647 # prepare the return dictionary
649 for key in self.video_list_keys:
650 video_list_ids[key] = {}
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))
661 video_list_ids['user'].update(self.parse_video_list_ids_entry(id=video_list_id, entry=video_list))
663 return video_list_ids
665 def parse_video_list_ids_entry (self, id, entry):
666 """Parse a video id entry e.g. rip out the parts we need
670 response_data : :obj:`dict` of :obj:`str`
671 Dictionary entry from the ´fetch_video_list_ids´ call
676 Unique id of the video list
678 entry : :obj:`dict` of :obj:`str`
679 Video list entry in the format:
681 "3589e2c6-ca3b-48b4-a72d-34f2c09ffbf4_11568382": {
682 "displayName": "Passend zu Family Guy",
683 "id": "3589e2c6-ca3b-48b4-a72d-34f2c09ffbf4_11568382",
692 'index': entry['index'],
693 'name': entry['context'],
694 'displayName': entry['displayName'],
695 'size': entry['length']
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
705 response_data : :obj:`dict` of :obj:`str`
706 Parsed response JSON from the `fetch_search_results` call
710 :obj:`dict` of :obj:`dict` of :obj:`str`
711 Search results in the format:
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.",
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",
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
738 def parse_show_list_entry (self, id, entry):
739 """Parse a show entry e.g. rip out the parts we need
743 response_data : :obj:`dict` of :obj:`str`
744 Dictionary entry from the ´fetch_show_information´ call
747 Unique id of the video list
751 entry : :obj:`dict` of :obj:`dict` of :obj:`str`
752 Show list entry in the format:
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",
766 'title': entry['title'],
767 'boxarts': entry['boxarts']['_342x192']['jpg']['url'],
768 'type': entry['summary']['type']
772 def parse_video_list (self, response_data):
773 """Parse a list of videos
777 response_data : :obj:`dict` of :obj:`str`
778 Parsed response JSON from the `fetch_video_list` call
782 :obj:`dict` of :obj:`dict`
783 Video list in the format:
789 "big": "https://art-s.nflximg.net/5e7d3/b3b48749843fd3a36db11c319ffa60f96b55e7d3.jpg",
790 "small": "https://art-s.nflximg.net/57543/a039845c2eb9186dc26019576d895bf5a1957543.jpg"
805 "episode_count": null,
811 "interesting_moment": "https://art-s.nflximg.net/09544/ed4b3073394b4469fb6ec22b9df81a4f5cb09544.jpg",
812 "list_id": "9588df32-f957-40e4-9055-1f6f33b60103_46891306",
815 "description": "Nur f\u00fcr Erwachsene geeignet.",
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.",
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.",
830 "title": "Chucky 2 \u2013 Die M\u00f6rderpuppe ist wieder da",
838 "big": "https://art-s.nflximg.net/7c10d/5dcc3fc8f08487e92507627068cfe26ef727c10d.jpg",
839 "small": "https://art-s.nflximg.net/5bc0e/f3be361b8c594929062f90a8d9c6eb57fb75bc0e.jpg"
856 "interesting_moment": "https://art-s.nflximg.net/0188e/19cd705a71ee08c8d2609ae01cd8a97a86c0188e.jpg",
857 "list_id": "9588df32-f957-40e4-9055-1f6f33b60103_46891306",
860 "description": "Geeignet ab 12 Jahren.",
866 "regular_synopsis": "Comedy-Serie \u00fcber die Erlebnisse eines Tatortreinigers, der seine schmutzige Arbeit erst beginnen kann, wenn die Polizei die Tatortanalyse abgeschlossen hat.",
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.",
874 "title": "Der Tatortreiniger",
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']))
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
895 Unique id of the video
898 Unique id of the containing list
900 video : :obj:`dict` of :obj:`str`
901 Video entry from the ´fetch_video_list´ call
903 persons : :obj:`dict` of :obj:`dict` of :obj:`str`
904 List of persons with reference ids
906 persons : :obj:`dict` of :obj:`dict` of :obj:`str`
907 List of genres with reference ids
911 entry : :obj:`dict` of :obj:`dict` of :obj:`str`
912 Video list entry in the format:
918 "big": "https://art-s.nflximg.net/5e7d3/b3b48749843fd3a36db11c319ffa60f96b55e7d3.jpg",
919 "small": "https://art-s.nflximg.net/57543/a039845c2eb9186dc26019576d895bf5a1957543.jpg"
934 "episode_count": null,
940 "interesting_moment": "https://art-s.nflximg.net/09544/ed4b3073394b4469fb6ec22b9df81a4f5cb09544.jpg",
941 "list_id": "9588df32-f957-40e4-9055-1f6f33b60103_46891306",
944 "description": "Nur f\u00fcr Erwachsene geeignet.",
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.",
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.",
959 "title": "Chucky 2 \u2013 Die M\u00f6rderpuppe ist wieder da",
966 season_info = self.parse_season_information_for_video(video=video)
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),
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']
996 'small': video['boxarts']['_342x192']['jpg']['url'],
997 'big': video['boxarts']['_1280x720']['jpg']['url']
999 'interesting_moment': None if 'interestingMoment' not in video.keys() else video['interestingMoment']['_665x375']['jpg']['url'],
1000 'artwork': video['artWorkByType']['BILLBOARD']['_1280x720']['jpg']['url'],
1004 def parse_creators_for_video (self, video, persons):
1005 """Matches ids with person names to generate a list of creators
1009 video : :obj:`dict` of :obj:`str`
1010 Dictionary entry for one video entry
1012 persons : :obj:`dict` of :obj:`str`
1013 Raw resposne of all persons delivered by the API call
1017 :obj:`list` of :obj:`str`
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'])
1029 def parse_directors_for_video (self, video, persons):
1030 """Matches ids with person names to generate a list of directors
1034 video : :obj:`dict` of :obj:`str`
1035 Dictionary entry for one video entry
1037 persons : :obj:`dict` of :obj:`str`
1038 Raw resposne of all persons delivered by the API call
1042 :obj:`list` of :obj:`str`
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'])
1054 def parse_cast_for_video (self, video, persons):
1055 """Matches ids with person names to generate a list of cast members
1059 video : :obj:`dict` of :obj:`str`
1060 Dictionary entry for one video entry
1062 persons : :obj:`dict` of :obj:`str`
1063 Raw resposne of all persons delivered by the API call
1067 :obj:`list` of :obj:`str`
1068 List of cast members
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'])
1079 def parse_genres_for_video (self, video, genres):
1080 """Matches ids with genre names to generate a list of genres for a video
1084 video : :obj:`dict` of :obj:`str`
1085 Dictionary entry for one video entry
1087 genres : :obj:`dict` of :obj:`str`
1088 Raw resposne of all genres delivered by the API call
1092 :obj:`list` of :obj:`str`
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'])
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
1109 video : :obj:`dict` of :obj:`str`
1110 Dictionary entry for one video entry
1114 :obj:`list` of :obj:`str`
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'])
1123 def parse_season_information_for_video (self, video):
1124 """Checks if the fiven video is a show (series) and returns season & episode information
1128 video : :obj:`dict` of :obj:`str`
1129 Dictionary entry for one video entry
1133 :obj:`dict` of :obj:`str`
1134 Episode count / Season Count & Season label if given
1137 'episode_count': None,
1138 'seasons_label': None,
1139 'seasons_count': None
1141 if video['summary']['type'] == 'show':
1143 'episode_count': video['episodeCount'],
1144 'seasons_label': video['numSeasonsLabel'],
1145 'seasons_count': video['seasonCount']
1149 def parse_quality_for_video (self, video):
1150 """Transforms Netflix quality information in video resolution info
1154 video : :obj:`dict` of :obj:`str`
1155 Dictionary entry for one video entry
1160 Quality of the video
1163 if video['videoQuality']['hasHD']:
1165 if video['videoQuality']['hasUltraHD']:
1169 def parse_runtime_for_video (self, video):
1170 """Checks if the video is a movie & returns the runtime if given
1174 video : :obj:`dict` of :obj:`str`
1175 Dictionary entry for one video entry
1180 Runtime of the video (in seconds)
1183 if video['summary']['type'] != 'show':
1184 runtime = video['runtime']
1187 def parse_netflix_list_id (self, video_list):
1188 """Parse a video list and extract the list id
1192 video_list : :obj:`dict` of :obj:`str`
1197 entry : :obj:`str` or None
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
1207 def parse_show_information (self, id, response_data):
1208 """Parse extended show information (synopsis, seasons, etc.)
1215 response_data : :obj:`dict` of :obj:`str`
1216 Parsed response JSON from the `fetch_show_information` call
1220 entry : :obj:`dict` of :obj:`str`
1221 Show information in the format:
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"
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]})
1237 def parse_seasons (self, id, response_data):
1238 """Parse a list of seasons for a given show
1245 response_data : :obj:`dict` of :obj:`str`
1246 Parsed response JSON from the `fetch_seasons_for_show` call
1250 entry : :obj:`dict` of :obj:`dict` of :obj:`str`
1251 Season information in the format:
1256 "shortName": "St. 1",
1258 "big": "https://art-s.nflximg.net/5e7d3/b3b48749843fd3a36db11c319ffa60f96b55e7d3.jpg",
1259 "small": "https://art-s.nflximg.net/57543/a039845c2eb9186dc26019576d895bf5a1957543.jpg"
1261 "interesting_moment": "https://art-s.nflximg.net/09544/ed4b3073394b4469fb6ec22b9df81a4f5cb09544.jpg"
1266 "shortName": "St. 2",
1268 "big": "https://art-s.nflximg.net/5e7d3/b3b48749843fd3a36db11c319ffa60f96b55e7d3.jpg",
1269 "small": "https://art-s.nflximg.net/57543/a039845c2eb9186dc26019576d895bf5a1957543.jpg"
1271 "interesting_moment": "https://art-s.nflximg.net/09544/ed4b3073394b4469fb6ec22b9df81a4f5cb09544.jpg"
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']))
1282 def parse_season_entry (self, season, videos):
1283 """Parse a season list entry e.g. rip out the parts we need
1287 season : :obj:`dict` of :obj:`str`
1288 Season entry from the `fetch_seasons_for_show` call
1292 entry : :obj:`dict` of :obj:`dict` of :obj:`str`
1293 Season list entry in the format:
1299 "shortName": "St. 1",
1301 "big": "https://art-s.nflximg.net/5e7d3/b3b48749843fd3a36db11c319ffa60f96b55e7d3.jpg",
1302 "small": "https://art-s.nflximg.net/57543/a039845c2eb9186dc26019576d895bf5a1957543.jpg"
1304 "interesting_moment": "https://art-s.nflximg.net/09544/ed4b3073394b4469fb6ec22b9df81a4f5cb09544.jpg"
1310 for key in videos.keys():
1311 if self._is_size_key(key=key) == False:
1314 season['summary']['id']: {
1315 'id': season['summary']['id'],
1316 'text': season['summary']['name'],
1317 'shortName': season['summary']['shortName'],
1319 'small': videos[video_key]['boxarts']['_342x192']['jpg']['url'],
1320 'big': videos[video_key]['boxarts']['_1280x720']['jpg']['url']
1322 'interesting_moment': videos[video_key]['interestingMoment']['_665x375']['jpg']['url'],
1326 def parse_episodes_by_season (self, response_data):
1327 """Parse episodes for a given season/episode list
1331 response_data : :obj:`dict` of :obj:`str`
1332 Parsed response JSON from the `fetch_seasons_for_show` call
1336 entry : :obj:`dict` of :obj:`dict` of :obj:`str`
1337 Season information in the format:
1341 "banner": "https://art-s.nflximg.net/63a36/c7fdfe6604ef2c22d085ac5dca5f69874e363a36.jpg",
1344 "fanart": "https://art-s.nflximg.net/74e02/e7edcc5cc7dcda1e94d505df2f0a2f0d22774e02.jpg",
1350 "mediatype": "episode",
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,
1358 "thumb": "https://art-s.nflximg.net/be686/07680670a68da8749eba607efb1ae37f9e3be686.jpg",
1359 "title": "Und dann gab es weniger (Teil 1)",
1364 "banner": "https://art-s.nflximg.net/63a36/c7fdfe6604ef2c22d085ac5dca5f69874e363a36.jpg",
1367 "fanart": "https://art-s.nflximg.net/c472c/6c10f9578bf2c1d0a183c2ccb382931efcbc472c.jpg",
1373 "mediatype": "episode",
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,
1381 "thumb": "https://art-s.nflximg.net/15a08/857d59126641987bec302bb147a802a00d015a08.jpg",
1382 "title": "Und dann gab es weniger (Teil 2)",
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']))
1396 def parse_episode (self, episode, genres=None):
1397 """Parse episode from an list of episodes by season
1401 episode : :obj:`dict` of :obj:`str`
1402 Episode entry from the `fetch_episodes_by_season` call
1406 entry : :obj:`dict` of :obj:`dict` of :obj:`str`
1407 Episode information in the format:
1411 "banner": "https://art-s.nflximg.net/63a36/c7fdfe6604ef2c22d085ac5dca5f69874e363a36.jpg",
1414 "fanart": "https://art-s.nflximg.net/74e02/e7edcc5cc7dcda1e94d505df2f0a2f0d22774e02.jpg",
1420 "mediatype": "episode",
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,
1428 "thumb": "https://art-s.nflximg.net/be686/07680670a68da8749eba607efb1ae37f9e3be686.jpg",
1429 "title": "Und dann gab es weniger (Teil 1)",
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']
1459 def fetch_browse_list_contents (self):
1460 """Fetches the HTML data for the lists on the landing page (browse page) of Netflix
1464 :obj:`BeautifulSoup`
1465 Instance of an BeautifulSoup document containing the complete page contents
1467 response = self.session.get(self._get_document_url_for(component='browse'), verify=self.verify_ssl)
1468 return BeautifulSoup(response.text)
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
1475 list_from : :obj:`int`
1476 Start entry for pagination
1478 list_to : :obj:`int`
1479 Last entry for pagination
1483 :obj:`dict` of :obj:`dict` of :obj:`str`
1484 Raw Netflix API call response or api call error
1487 'fromRow': list_from,
1489 'opaqueImageExtension': 'jpg',
1490 'transparentImageExtension': 'png',
1491 '_': int(time.time()),
1492 'authURL': self.user_data['authURL']
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)
1498 def fetch_search_results (self, search_str, list_from=0, list_to=48):
1499 """Fetches the JSON which contains the results for the given search query
1503 search_str : :obj:`str`
1504 String to query Netflix search for
1506 list_from : :obj:`int`
1507 Start entry for pagination
1509 list_to : :obj:`int`
1510 Last entry for pagination
1514 :obj:`dict` of :obj:`dict` of :obj:`str`
1515 Raw Netflix API call response or api call error
1517 # properly encode the search string
1518 encoded_search_string = urllib.quote(search_str)
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']]
1525 response = self._path_request(paths=paths)
1526 return self._process_response(response=response, component='Search results')
1528 def fetch_video_list (self, list_id, list_from=0, list_to=20):
1529 """Fetches the JSON which contains the contents of a given video list
1533 list_id : :obj:`str`
1534 Unique list id to query Netflix for
1536 list_from : :obj:`int`
1537 Start entry for pagination
1539 list_to : :obj:`int`
1540 Last entry for pagination
1544 :obj:`dict` of :obj:`dict` of :obj:`str`
1545 Raw Netflix API call response or api call error
1548 ['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']],
1549 ['lists', list_id, {'from': list_from, 'to': list_to}, 'cast', {'from': 0, 'to': 15}, ['id', 'name']],
1550 ['lists', list_id, {'from': list_from, 'to': list_to}, 'cast', 'summary'],
1551 ['lists', list_id, {'from': list_from, 'to': list_to}, 'genres', {'from': 0, 'to': 5}, ['id', 'name']],
1552 ['lists', list_id, {'from': list_from, 'to': list_to}, 'genres', 'summary'],
1553 ['lists', list_id, {'from': list_from, 'to': list_to}, 'tags', {'from': 0, 'to': 9}, ['id', 'name']],
1554 ['lists', list_id, {'from': list_from, 'to': list_to}, 'tags', 'summary'],
1555 ['lists', list_id, {'from': list_from, 'to': list_to}, ['creators', 'directors'], {'from': 0, 'to': 49}, ['id', 'name']],
1556 ['lists', list_id, {'from': list_from, 'to': list_to}, ['creators', 'directors'], 'summary'],
1557 ['lists', list_id, {'from': list_from, 'to': list_to}, 'bb2OGLogo', '_400x90', 'png'],
1558 ['lists', list_id, {'from': list_from, 'to': list_to}, 'boxarts', '_342x192', 'jpg'],
1559 ['lists', list_id, {'from': list_from, 'to': list_to}, 'boxarts', '_1280x720', 'jpg'],
1560 ['lists', list_id, {'from': list_from, 'to': list_to}, 'storyarts', '_1632x873', 'jpg'],
1561 ['lists', list_id, {'from': list_from, 'to': list_to}, 'interestingMoment', '_665x375', 'jpg'],
1562 ['lists', list_id, {'from': list_from, 'to': list_to}, 'artWorkByType', 'BILLBOARD', '_1280x720', 'jpg']
1565 response = self._path_request(paths=paths)
1566 return self._process_response(response=response, component='Video list')
1568 def fetch_video_list_information (self, video_ids):
1569 """Fetches the JSON which contains the detail information of a list of given video ids
1573 video_ids : :obj:`list` of :obj:`str`
1574 List of video ids to fetch detail data for
1578 :obj:`dict` of :obj:`dict` of :obj:`str`
1579 Raw Netflix API call response or api call error
1582 for video_id in video_ids:
1583 paths.append(['videos', video_id, ['summary', 'title', 'synopsis', 'regularSynopsis', 'evidence', 'queue', 'episodeCount', 'info', 'maturity', 'runtime', 'seasonCount', 'releaseYear', 'userRating', 'numSeasonsLabel', 'bookmarkPosition', 'watched', 'videoQuality']])
1584 paths.append(['videos', video_id, 'cast', {'from': 0, 'to': 15}, ['id', 'name']])
1585 paths.append(['videos', video_id, 'cast', 'summary'])
1586 paths.append(['videos', video_id, 'genres', {'from': 0, 'to': 5}, ['id', 'name']])
1587 paths.append(['videos', video_id, 'genres', 'summary'])
1588 paths.append(['videos', video_id, 'tags', {'from': 0, 'to': 9}, ['id', 'name']])
1589 paths.append(['videos', video_id, 'tags', 'summary'])
1590 paths.append(['videos', video_id, ['creators', 'directors'], {'from': 0, 'to': 49}, ['id', 'name']])
1591 paths.append(['videos', video_id, ['creators', 'directors'], 'summary'])
1592 paths.append(['videos', video_id, 'bb2OGLogo', '_400x90', 'png'])
1593 paths.append(['videos', video_id, 'boxarts', '_342x192', 'jpg'])
1594 paths.append(['videos', video_id, 'boxarts', '_1280x720', 'jpg'])
1595 paths.append(['videos', video_id, 'storyarts', '_1632x873', 'jpg'])
1596 paths.append(['videos', video_id, 'interestingMoment', '_665x375', 'jpg'])
1597 paths.append(['videos', video_id, 'artWorkByType', 'BILLBOARD', '_1280x720', 'jpg'])
1599 response = self._path_request(paths=paths)
1600 return self._process_response(response=response, component='fetch_video_list_information')
1602 def fetch_metadata (self, id):
1603 """Fetches the JSON which contains the metadata for a given show/movie or season id
1608 Show id, movie id or season id
1612 :obj:`dict` of :obj:`dict` of :obj:`str`
1613 Raw Netflix API call response or api call error
1617 'imageformat': 'jpg',
1618 '_': int(time.time())
1620 url = self._get_api_url_for(component='metadata')
1621 response = self.session.get(url, params=payload, verify=self.verify_ssl);
1622 return self._process_response(response=response, component=url)
1624 def fetch_show_information (self, id, type):
1625 """Fetches the JSON which contains the detailed contents of a show
1630 Unique show id to query Netflix for
1633 Can be 'movie' or 'show'
1637 :obj:`dict` of :obj:`dict` of :obj:`str`
1638 Raw Netflix API call response or api call error
1640 # check if we have a show or a movie, the request made depends on this
1643 ['videos', id, ['requestId', 'regularSynopsis', 'evidence']],
1644 ['videos', id, 'seasonList', 'current', 'summary']
1647 paths = [['videos', id, ['requestId', 'regularSynopsis', 'evidence']]]
1648 response = self._path_request(paths=paths)
1649 return self._process_response(response=response, component='Show information')
1651 def fetch_seasons_for_show (self, id, list_from=0, list_to=30):
1652 """Fetches the JSON which contains the seasons of a given show
1657 Unique show id to query Netflix for
1659 list_from : :obj:`int`
1660 Start entry for pagination
1662 list_to : :obj:`int`
1663 Last entry for pagination
1667 :obj:`dict` of :obj:`dict` of :obj:`str`
1668 Raw Netflix API call response or api call error
1671 ['videos', id, 'seasonList', {'from': list_from, 'to': list_to}, 'summary'],
1672 ['videos', id, 'seasonList', 'summary'],
1673 ['videos', id, 'boxarts', '_342x192', 'jpg'],
1674 ['videos', id, 'boxarts', '_1280x720', 'jpg'],
1675 ['videos', id, 'storyarts', '_1632x873', 'jpg'],
1676 ['videos', id, 'interestingMoment', '_665x375', 'jpg']
1678 response = self._path_request(paths=paths)
1679 return self._process_response(response=response, component='Seasons')
1681 def fetch_episodes_by_season (self, season_id, list_from=-1, list_to=40):
1682 """Fetches the JSON which contains the episodes of a given season
1684 TODO: Add more metadata
1688 season_id : :obj:`str`
1689 Unique season_id id to query Netflix for
1691 list_from : :obj:`int`
1692 Start entry for pagination
1694 list_to : :obj:`int`
1695 Last entry for pagination
1699 :obj:`dict` of :obj:`dict` of :obj:`str`
1700 Raw Netflix API call response or api call error
1703 ['seasons', season_id, 'episodes', {'from': list_from, 'to': list_to}, ['summary', 'queue', 'info', 'maturity', 'userRating', 'bookmarkPosition', 'creditOffset', 'watched', 'videoQuality']],
1704 #['videos', season_id, 'cast', {'from': 0, 'to': 15}, ['id', 'name']],
1705 #['videos', season_id, 'cast', 'summary'],
1706 #['videos', season_id, 'genres', {'from': 0, 'to': 5}, ['id', 'name']],
1707 #['videos', season_id, 'genres', 'summary'],
1708 #['videos', season_id, 'tags', {'from': 0, 'to': 9}, ['id', 'name']],
1709 #['videos', season_id, 'tags', 'summary'],
1710 #['videos', season_id, ['creators', 'directors'], {'from': 0, 'to': 49}, ['id', 'name']],
1711 #['videos', season_id, ['creators', 'directors'], 'summary'],
1712 ['seasons', season_id, 'episodes', {'from': list_from, 'to': list_to}, 'genres', {'from': 0, 'to': 1}, ['id', 'name']],
1713 ['seasons', season_id, 'episodes', {'from': list_from, 'to': list_to}, 'genres', 'summary'],
1714 ['seasons', season_id, 'episodes', {'from': list_from, 'to': list_to}, 'interestingMoment', '_1280x720', 'jpg'],
1715 ['seasons', season_id, 'episodes', {'from': list_from, 'to': list_to}, 'interestingMoment', '_665x375', 'jpg'],
1716 ['seasons', season_id, 'episodes', {'from': list_from, 'to': list_to}, 'boxarts', '_342x192', 'jpg'],
1717 ['seasons', season_id, 'episodes', {'from': list_from, 'to': list_to}, 'boxarts', '_1280x720', 'jpg']
1719 response = self._path_request(paths=paths)
1720 return self._process_response(response=response, component='fetch_episodes_by_season')
1722 def refresh_session_data (self, account):
1723 """Reload the session data (profiles, user_data, api_data)
1727 account : :obj:`dict` of :obj:`str`
1728 Dict containing an email, country & a password property
1730 # load the profiles page (to verify the user)
1731 response = self.session.get(self._get_document_url_for(component='profiles'), verify=self.verify_ssl)
1732 # parse out the needed inline information
1733 page_soup = BeautifulSoup(response.text)
1734 page_data = self.extract_inline_netflix_page_data(page_soup=page_soup)
1735 self._parse_page_contents(page_soup)
1736 account_hash = self._generate_account_hash(account=account)
1737 self._save_data(filename=self.data_path + '_' + account_hash)
1739 def _path_request (self, paths):
1740 """Executes a post request against the shakti endpoint with Falcor style payload
1744 paths : :obj:`list` of :obj:`list`
1745 Payload with path querys for the Netflix Shakti API in Falcor style
1749 :obj:`requests.response`
1750 Response from a POST call made with Requests
1753 'Content-Type': 'application/json',
1754 'Accept': 'application/json, text/javascript, */*',
1759 'authURL': self.user_data['authURL']
1764 'materialize': True,
1765 'model': self.user_data['gpsModel']
1768 return self.session.post(self._get_api_url_for(component='shakti'), params=params, headers=headers, data=data, verify=self.verify_ssl)
1770 def _is_size_key (self, key):
1771 """Tiny helper that checks if a given key is called $size or size, as we need to check this often
1776 Key to check the value for
1781 Key has a size value or not
1783 return key == '$size' or key == 'size'
1785 def _get_api_url_for (self, component):
1786 """Tiny helper that builds the url for a requested API endpoint component
1790 component : :obj:`str`
1791 Component endpoint to build the URL for
1798 return self.api_data['API_ROOT'] + self.api_data['API_BASE_URL'] + '/' + self.api_data['BUILD_IDENTIFIER'] + self.urls[component]
1800 def _get_document_url_for (self, component):
1801 """Tiny helper that builds the url for a requested document endpoint component
1805 component : :obj:`str`
1806 Component endpoint to build the URL for
1813 return self.base_url + self.urls[component]
1815 def _process_response (self, response, component):
1816 """Tiny helper to check responses for API requests
1820 response : :obj:`requests.response`
1821 Response from a requests instance
1823 component : :obj:`str`
1828 :obj:`dict` of :obj:`dict` of :obj:`str` or :obj:`dict` of :obj:`str`
1829 Raw Netflix API call response or api call error
1831 # check if we´re not authorized to make thios call
1832 if response.status_code == 401:
1835 'message': 'Session invalid',
1838 # check if somethign else failed
1839 if response.status_code != 200:
1842 'message': 'API call for "' + component + '" failed',
1843 'code': response.status_code
1845 # return the parsed response & everything´s fine
1846 return response.json()
1848 def _update_my_list (self, video_id, operation):
1849 """Tiny helper to add & remove items from "my list"
1853 video_id : :obj:`str`
1854 ID of the show/movie to be added
1856 operation : :obj:`str`
1857 Either "add" or "remove"
1862 Operation successfull
1865 'Content-Type': 'application/json',
1866 'Accept': 'application/json, text/javascript, */*',
1869 payload = json.dumps({
1870 'operation': operation,
1871 'videoId': int(video_id),
1872 'authURL': self.user_data['authURL']
1875 response = self.session.post(self._get_api_url_for(component='update_my_list'), headers=headers, data=payload, verify=self.verify_ssl)
1876 return response.status_code == 200
1878 def _save_data(self, filename):
1879 """Tiny helper that stores session data from the session in a given file
1883 filename : :obj:`str`
1884 Complete path incl. filename that determines where to store the cookie
1889 Storage procedure was successfull
1891 if not os.path.isdir(os.path.dirname(filename)):
1893 with open(filename, 'w') as f:
1896 'user_data': self.user_data,
1897 'api_data': self.api_data,
1898 'profiles': self.profiles
1901 def _load_data(self, filename):
1902 """Tiny helper that loads session data into the active session from a given file
1906 filename : :obj:`str`
1907 Complete path incl. filename that determines where to load the data from
1912 Load procedure was successfull
1914 if not os.path.isfile(filename):
1917 with open(filename) as f:
1918 data = pickle.load(f)
1920 self.profiles = data['profiles']
1921 self.user_data = data['user_data']
1922 self.api_data = data['api_data']
1926 def _delete_data (self, path):
1927 """Tiny helper that deletes session data
1931 filename : :obj:`str`
1932 Complete path incl. filename that determines where to delete the files
1935 head, tail = os.path.split(path)
1936 for subdir, dirs, files in os.walk(head):
1939 os.remove(os.path.join(subdir, file))
1941 def _save_cookies(self, filename):
1942 """Tiny helper that stores cookies from the session in a given file
1946 filename : :obj:`str`
1947 Complete path incl. filename that determines where to store the cookie
1952 Storage procedure was successfull
1954 if not os.path.isdir(os.path.dirname(filename)):
1956 with open(filename, 'w') as f:
1958 pickle.dump(self.session.cookies._cookies, f)
1960 def _load_cookies(self, filename):
1961 """Tiny helper that loads cookies into the active session from a given file
1965 filename : :obj:`str`
1966 Complete path incl. filename that determines where to load the cookie from
1971 Load procedure was successfull
1973 if not os.path.isfile(filename):
1976 with open(filename) as f:
1977 cookies = pickle.load(f)
1979 jar = requests.cookies.RequestsCookieJar()
1980 jar._cookies = cookies
1981 self.session.cookies = jar
1985 def _delete_cookies (self, path):
1986 """Tiny helper that deletes cookie data
1990 filename : :obj:`str`
1991 Complete path incl. filename that determines where to delete the files
1994 head, tail = os.path.split(path)
1995 for subdir, dirs, files in os.walk(head):
1998 os.remove(os.path.join(subdir, file))
2000 def _generate_account_hash (self, account):
2001 """Generates a has for the given account (used for cookie verification)
2005 account : :obj:`dict` of :obj:`str`
2006 Dict containing an email, country & a password property
2013 return base64.urlsafe_b64encode(account['email'])