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 avatar_base = item['avatars']['nf'].get(item['profiles'][profile_id]['summary']['avatarName'], False);
285 avatar = 'https://secure.netflix.com/ffe/profiles/avatars_v2/320x320/PICON_029.png' if avatar_base == False else avatar_base['images']['byWidth']['320']['value']
286 profile.update({'avatar': avatar})
287 profiles.update({profile_id: profile})
291 def _parse_api_base_data (self, netflix_page_data):
292 """Parse out the api url data from the big chunk of dicts we got from
293 parsing the JSOn-ish data from the netflix homepage
297 netflix_page_data : :obj:`list`
298 List of all the JSON-ish data that has been extracted from the Netflix homepage
299 see: extract_inline_netflix_page_data
303 :obj:`dict` of :obj:`str
306 "API_BASE_URL": "/shakti",
307 "API_ROOT": "https://www.netflix.com/api",
308 "BUILD_IDENTIFIER": "113b89c9", "
309 ICHNAEA_ROOT": "/ichnaea"
319 for item in netflix_page_data:
320 if 'models' in dict(item).keys():
321 for important_field in important_fields:
322 api_data.update({important_field: item['models']['serverDefs']['data'][important_field]})
325 def _parse_esn_data (self, netflix_page_data):
326 """Parse out the esn id data from the big chunk of dicts we got from
327 parsing the JSOn-ish data from the netflix homepage
331 netflix_page_data : :obj:`list`
332 List of all the JSON-ish data that has been extracted from the Netflix homepage
333 see: extract_inline_netflix_page_data
337 :obj:`str` of :obj:`str
338 Widevine esn, something like: NFCDCH-MC-D7D6F54LOPY8J416T72MQXX3RD20ME
341 for item in netflix_page_data:
342 if 'models' in dict(item).keys():
343 esn = item['models']['esnGeneratorModel']['data']['esn']
346 def _parse_page_contents (self, page_soup):
347 """Call all the parsers we need to extract all the session relevant data from the HTML page
348 Directly assigns it to the NetflixSession instance
352 page_soup : :obj:`BeautifulSoup`
353 Instance of an BeautifulSoup document or node containing the complete page contents
355 netflix_page_data = self.extract_inline_netflix_page_data(page_soup=page_soup)
356 self.user_data = self._parse_user_data(netflix_page_data=netflix_page_data)
357 self.esn = self._parse_esn_data(netflix_page_data=netflix_page_data)
358 self.api_data = self._parse_api_base_data(netflix_page_data=netflix_page_data)
359 self.profiles = self._parse_profile_data(netflix_page_data=netflix_page_data)
361 def is_logged_in (self, account):
362 """Determines if a user is already logged in (with a valid cookie),
363 by fetching the index page with the current cookie & checking for the
364 `membership status` user data
368 account : :obj:`dict` of :obj:`str`
369 Dict containing an email, country & a password property
374 User is already logged in (e.g. Cookie is valid) or not
378 account_hash = self._generate_account_hash(account=account)
379 if self._load_cookies(filename=self.cookie_path + '_' + account_hash) == False:
381 if self._load_data(filename=self.data_path + '_' + account_hash) == False:
382 # load the profiles page (to verify the user)
383 response = self.session.get(self._get_document_url_for(component='profiles'), verify=self.verify_ssl)
385 # parse out the needed inline information
386 page_soup = BeautifulSoup(response.text)
387 page_data = self.extract_inline_netflix_page_data(page_soup=page_soup)
388 self._parse_page_contents(page_soup=page_soup)
390 # check if the cookie is still valid
391 for item in page_data:
392 if 'profilesList' in dict(item).keys():
393 if item['profilesList']['summary']['length'] >= 1:
399 """Delete all cookies and session data
403 account : :obj:`dict` of :obj:`str`
404 Dict containing an email, country & a password property
407 self._delete_cookies(path=self.cookie_path)
408 self._delete_data(path=self.data_path)
410 def login (self, account):
411 """Try to log in a user with its credentials & stores the cookies if the action is successfull
413 Note: It fetches the HTML of the login page to extract the fields of the login form,
414 again, this is dirty, but as the fields & their values coudl change at any time, this
415 should be the most reliable way of retrieving the information
419 account : :obj:`dict` of :obj:`str`
420 Dict containing an email, country & a password property
425 User could be logged in or not
427 response = self.session.get(self._get_document_url_for(component='login'), verify=self.verify_ssl)
428 if response.status_code != 200:
431 # collect all the login fields & their contents and add the user credentials
432 page_soup = BeautifulSoup(response.text)
433 login_form = page_soup.find(attrs={'class' : 'ui-label-text'}).findPrevious('form')
434 login_payload = self.parse_login_form_fields(form_soup=login_form)
435 if 'email' in login_payload:
436 login_payload['email'] = account['email']
437 if 'emailOrPhoneNumber' in login_payload:
438 login_payload['emailOrPhoneNumber'] = account['email']
439 login_payload['password'] = account['password']
442 login_response = self.session.post(self._get_document_url_for(component='login'), data=login_payload, verify=self.verify_ssl)
443 login_soup = BeautifulSoup(login_response.text)
445 # we know that the login was successfull if we find an HTML element with the class of 'profile-name'
446 if login_soup.find(attrs={'class' : 'profile-name'}) or login_soup.find(attrs={'class' : 'profile-icon'}):
447 # parse the needed inline information & store cookies for later requests
448 self._parse_page_contents(page_soup=login_soup)
449 account_hash = self._generate_account_hash(account=account)
450 self._save_cookies(filename=self.cookie_path + '_' + account_hash)
451 self._save_data(filename=self.data_path + '_' + account_hash)
456 def switch_profile (self, profile_id, account):
457 """Switch the user profile based on a given profile id
459 Note: All available profiles & their ids can be found in the ´profiles´ property after a successfull login
463 profile_id : :obj:`str`
466 account : :obj:`dict` of :obj:`str`
467 Dict containing an email, country & a password property
472 User could be switched or not
475 'switchProfileGuid': profile_id,
476 '_': int(time.time()),
477 'authURL': self.user_data['authURL']
480 response = self.session.get(self._get_api_url_for(component='switch_profiles'), params=payload, verify=self.verify_ssl);
481 if response.status_code != 200:
484 # fetch the index page again, so that we can fetch the corresponding user data
485 browse_response = self.session.get(self._get_document_url_for(component='browse'), verify=self.verify_ssl)
486 browse_soup = BeautifulSoup(browse_response.text)
487 self._parse_page_contents(page_soup=browse_soup)
488 account_hash = self._generate_account_hash(account=account)
489 self._save_data(filename=self.data_path + '_' + account_hash)
492 def send_adult_pin (self, pin):
493 """Send the adult pin to Netflix in case an adult rated video requests it
495 Note: Once entered, it should last for the complete session (Not so sure about this)
505 Pin was accepted or not
507 :obj:`dict` of :obj:`str`
512 'authURL': self.user_data['authURL']
514 url = self._get_api_url_for(component='adult_pin')
515 response = self.session.get(url, params=payload, verify=self.verify_ssl);
516 pin_response = self._process_response(response=response, component=url)
517 keys = pin_response.keys()
518 if 'success' in keys:
524 def add_to_list (self, video_id):
525 """Adds a video to "my list" on Netflix
529 video_id : :obj:`str`
530 ID of th show/video/movie to be added
535 Adding was successfull
537 return self._update_my_list(video_id=video_id, operation='add')
539 def remove_from_list (self, video_id):
540 """Removes a video from "my list" on Netflix
544 video_id : :obj:`str`
545 ID of th show/video/movie to be removed
550 Removing was successfull
552 return self._update_my_list(video_id=video_id, operation='remove')
554 def rate_video (self, video_id, rating):
555 """Rate a video on Netflix
559 video_id : :obj:`str`
560 ID of th show/video/movie to be rated
563 Rating, must be between 0 & 10
568 Rating successfull or not
571 # dirty rating validation
573 if rating > 10 or rating < 0:
576 # In opposition to Kodi, Netflix uses a rating from 0 to in 0.5 steps
581 'Content-Type': 'application/json',
582 'Accept': 'application/json, text/javascript, */*',
590 payload = json.dumps({
591 'authURL': self.user_data['authURL']
594 response = self.session.post(self._get_api_url_for(component='set_video_rating'), params=params, headers=headers, data=payload, verify=self.verify_ssl)
595 return response.status_code == 200
597 def parse_video_list_ids (self, response_data):
598 """Parse the list of video ids e.g. rip out the parts we need
602 response_data : :obj:`dict` of :obj:`str`
603 Parsed response JSON from the ´fetch_video_list_ids´ call
607 :obj:`dict` of :obj:`dict`
608 Video list ids in the format:
612 "3589e2c6-ca3b-48b4-a72d-34f2c09ffbf4_11568367": {
613 "displayName": "US-Serien",
614 "id": "3589e2c6-ca3b-48b4-a72d-34f2c09ffbf4_11568367",
619 "3589e2c6-ca3b-48b4-a72d-34f2c09ffbf4_11568368": {
624 "3589e2c6-ca3b-48b4-a72d-34f2c09ffbf4_11568364": {
625 "displayName": "Meine Liste",
626 "id": "3589e2c6-ca3b-48b4-a72d-34f2c09ffbf4_11568364",
631 "3589e2c6-ca3b-48b4-a72d-34f2c09ffbf4_11568365": {
636 "3589e2c6-ca3b-48b4-a72d-34f2c09ffbf4_11568382": {
637 "displayName": "Passend zu Family Guy",
638 "id": "3589e2c6-ca3b-48b4-a72d-34f2c09ffbf4_11568382",
643 "3589e2c6-ca3b-48b4-a72d-34f2c09ffbf4_11568397": {
649 # prepare the return dictionary
651 for key in self.video_list_keys:
652 video_list_ids[key] = {}
654 # subcatogorize the lists by their context
655 video_lists = response_data['lists']
656 for video_list_id in video_lists.keys():
657 video_list = video_lists[video_list_id]
658 if video_list['context'] == 'genre':
659 video_list_ids['genres'].update(self.parse_video_list_ids_entry(id=video_list_id, entry=video_list))
660 elif video_list['context'] == 'similars' or video_list['context'] == 'becauseYouAdded':
661 video_list_ids['recommendations'].update(self.parse_video_list_ids_entry(id=video_list_id, entry=video_list))
663 video_list_ids['user'].update(self.parse_video_list_ids_entry(id=video_list_id, entry=video_list))
665 return video_list_ids
667 def parse_video_list_ids_entry (self, id, entry):
668 """Parse a video id entry e.g. rip out the parts we need
672 response_data : :obj:`dict` of :obj:`str`
673 Dictionary entry from the ´fetch_video_list_ids´ call
678 Unique id of the video list
680 entry : :obj:`dict` of :obj:`str`
681 Video list entry in the format:
683 "3589e2c6-ca3b-48b4-a72d-34f2c09ffbf4_11568382": {
684 "displayName": "Passend zu Family Guy",
685 "id": "3589e2c6-ca3b-48b4-a72d-34f2c09ffbf4_11568382",
694 'index': entry['index'],
695 'name': entry['context'],
696 'displayName': entry['displayName'],
697 'size': entry['length']
701 def parse_search_results (self, response_data):
702 """Parse the list of search results, rip out the parts we need
703 and extend it with detailed show informations
707 response_data : :obj:`dict` of :obj:`str`
708 Parsed response JSON from the `fetch_search_results` call
712 :obj:`dict` of :obj:`dict` of :obj:`str`
713 Search results in the format:
717 "boxarts": "https://art-s.nflximg.net/0d7af/d5c72668c35d3da65ae031302bd4ae1bcc80d7af.jpg",
718 "detail_text": "Die legend\u00e4re und mit 13 Emmys nominierte Serie von Gene Roddenberry inspirierte eine ganze Generation.",
720 "season_id": "70109435",
721 "synopsis": "Unter Befehl von Captain Kirk begibt sich die Besatzung des Raumschiffs Enterprise in die Tiefen des Weltraums, wo sie fremde Galaxien und neue Zivilisationen erforscht.",
722 "title": "Star Trek",
731 raw_search_results = response_data['value']['videos']
732 for entry_id in raw_search_results:
733 if self._is_size_key(key=entry_id) == False:
734 # fetch information about each show & build up a proper search results dictionary
735 show = self.parse_show_list_entry(id=entry_id, entry=raw_search_results[entry_id])
736 show[entry_id].update(self.parse_show_information(id=entry_id, response_data=self.fetch_show_information(id=entry_id, type=show[entry_id]['type'])))
737 search_results.update(show)
738 return search_results
740 def parse_show_list_entry (self, id, entry):
741 """Parse a show entry e.g. rip out the parts we need
745 response_data : :obj:`dict` of :obj:`str`
746 Dictionary entry from the ´fetch_show_information´ call
749 Unique id of the video list
753 entry : :obj:`dict` of :obj:`dict` of :obj:`str`
754 Show list entry in the format:
757 "3589e2c6-ca3b-48b4-a72d-34f2c09ffbf4_11568382": {
758 "id": "3589e2c6-ca3b-48b4-a72d-34f2c09ffbf4_11568382",
759 "title": "Enterprise",
760 "boxarts": "https://art-s.nflximg.net/.../smth.jpg",
768 'title': entry['title'],
769 'boxarts': entry['boxarts']['_342x192']['jpg']['url'],
770 'type': entry['summary']['type']
774 def parse_video_list (self, response_data):
775 """Parse a list of videos
779 response_data : :obj:`dict` of :obj:`str`
780 Parsed response JSON from the `fetch_video_list` call
784 :obj:`dict` of :obj:`dict`
785 Video list in the format:
791 "big": "https://art-s.nflximg.net/5e7d3/b3b48749843fd3a36db11c319ffa60f96b55e7d3.jpg",
792 "small": "https://art-s.nflximg.net/57543/a039845c2eb9186dc26019576d895bf5a1957543.jpg"
807 "episode_count": null,
813 "interesting_moment": "https://art-s.nflximg.net/09544/ed4b3073394b4469fb6ec22b9df81a4f5cb09544.jpg",
814 "list_id": "9588df32-f957-40e4-9055-1f6f33b60103_46891306",
817 "description": "Nur f\u00fcr Erwachsene geeignet.",
823 "regular_synopsis": "Ein Spielzeughersteller erweckt aus Versehen die Seele der M\u00f6rderpuppe Chucky erneut zum Leben, die sich unmittelbar wieder ihren m\u00f6rderischen Aktivit\u00e4ten zuwendet.",
825 "seasons_count": null,
826 "seasons_label": null,
827 "synopsis": "Die allseits beliebte, von D\u00e4monen besessene M\u00f6rderpuppe ist wieder da und verbreitet erneut Horror und Schrecken.",
832 "title": "Chucky 2 \u2013 Die M\u00f6rderpuppe ist wieder da",
840 "big": "https://art-s.nflximg.net/7c10d/5dcc3fc8f08487e92507627068cfe26ef727c10d.jpg",
841 "small": "https://art-s.nflximg.net/5bc0e/f3be361b8c594929062f90a8d9c6eb57fb75bc0e.jpg"
858 "interesting_moment": "https://art-s.nflximg.net/0188e/19cd705a71ee08c8d2609ae01cd8a97a86c0188e.jpg",
859 "list_id": "9588df32-f957-40e4-9055-1f6f33b60103_46891306",
862 "description": "Geeignet ab 12 Jahren.",
868 "regular_synopsis": "Comedy-Serie \u00fcber die Erlebnisse eines Tatortreinigers, der seine schmutzige Arbeit erst beginnen kann, wenn die Polizei die Tatortanalyse abgeschlossen hat.",
871 "seasons_label": "5 Staffeln",
872 "synopsis": "In den meisten Krimiserien werden Mordf\u00e4lle auf faszinierende und spannende Weise gel\u00f6st. Diese Serie ist anders.",
876 "title": "Der Tatortreiniger",
884 raw_video_list = response_data['value']
885 netflix_list_id = self.parse_netflix_list_id(video_list=raw_video_list);
886 for video_id in raw_video_list['videos']:
887 if self._is_size_key(key=video_id) == False:
888 video_list.update(self.parse_video_list_entry(id=video_id, list_id=netflix_list_id, video=raw_video_list['videos'][video_id], persons=raw_video_list['person'], genres=raw_video_list['genres']))
891 def parse_video_list_entry (self, id, list_id, video, persons, genres):
892 """Parse a video list entry e.g. rip out the parts we need
897 Unique id of the video
900 Unique id of the containing list
902 video : :obj:`dict` of :obj:`str`
903 Video entry from the ´fetch_video_list´ call
905 persons : :obj:`dict` of :obj:`dict` of :obj:`str`
906 List of persons with reference ids
908 persons : :obj:`dict` of :obj:`dict` of :obj:`str`
909 List of genres with reference ids
913 entry : :obj:`dict` of :obj:`dict` of :obj:`str`
914 Video list entry in the format:
920 "big": "https://art-s.nflximg.net/5e7d3/b3b48749843fd3a36db11c319ffa60f96b55e7d3.jpg",
921 "small": "https://art-s.nflximg.net/57543/a039845c2eb9186dc26019576d895bf5a1957543.jpg"
936 "episode_count": null,
942 "interesting_moment": "https://art-s.nflximg.net/09544/ed4b3073394b4469fb6ec22b9df81a4f5cb09544.jpg",
943 "list_id": "9588df32-f957-40e4-9055-1f6f33b60103_46891306",
946 "description": "Nur f\u00fcr Erwachsene geeignet.",
952 "regular_synopsis": "Ein Spielzeughersteller erweckt aus Versehen die Seele der M\u00f6rderpuppe Chucky erneut zum Leben, die sich unmittelbar wieder ihren m\u00f6rderischen Aktivit\u00e4ten zuwendet.",
954 "seasons_count": null,
955 "seasons_label": null,
956 "synopsis": "Die allseits beliebte, von D\u00e4monen besessene M\u00f6rderpuppe ist wieder da und verbreitet erneut Horror und Schrecken.",
961 "title": "Chucky 2 \u2013 Die M\u00f6rderpuppe ist wieder da",
968 season_info = self.parse_season_information_for_video(video=video)
973 'title': video['title'],
974 'synopsis': video['synopsis'],
975 'regular_synopsis': video['regularSynopsis'],
976 'type': video['summary']['type'],
977 'rating': video['userRating']['average'],
978 'episode_count': season_info['episode_count'],
979 'seasons_label': season_info['seasons_label'],
980 'seasons_count': season_info['seasons_count'],
981 'in_my_list': video['queue']['inQueue'],
982 'year': video['releaseYear'],
983 'runtime': self.parse_runtime_for_video(video=video),
984 'watched': video['watched'],
985 'tags': self.parse_tags_for_video(video=video),
986 'genres': self.parse_genres_for_video(video=video, genres=genres),
987 'quality': self.parse_quality_for_video(video=video),
988 'cast': self.parse_cast_for_video(video=video, persons=persons),
989 'directors': self.parse_directors_for_video(video=video, persons=persons),
990 'creators': self.parse_creators_for_video(video=video, persons=persons),
992 'board': None if 'board' not in video['maturity']['rating'].keys() else video['maturity']['rating']['board'],
993 'value': None if 'value' not in video['maturity']['rating'].keys() else video['maturity']['rating']['value'],
994 'description': None if 'maturityDescription' not in video['maturity']['rating'].keys() else video['maturity']['rating']['maturityDescription'],
995 'level': None if 'maturityLevel' not in video['maturity']['rating'].keys() else video['maturity']['rating']['maturityLevel']
998 'small': video['boxarts']['_342x192']['jpg']['url'],
999 'big': video['boxarts']['_1280x720']['jpg']['url']
1001 'interesting_moment': None if 'interestingMoment' not in video.keys() else video['interestingMoment']['_665x375']['jpg']['url'],
1002 'artwork': video['artWorkByType']['BILLBOARD']['_1280x720']['jpg']['url'],
1006 def parse_creators_for_video (self, video, persons):
1007 """Matches ids with person names to generate a list of creators
1011 video : :obj:`dict` of :obj:`str`
1012 Dictionary entry for one video entry
1014 persons : :obj:`dict` of :obj:`str`
1015 Raw resposne of all persons delivered by the API call
1019 :obj:`list` of :obj:`str`
1023 for person_key in dict(persons).keys():
1024 if self._is_size_key(key=person_key) == False and person_key != 'summary':
1025 for creator_key in dict(video['creators']).keys():
1026 if self._is_size_key(key=creator_key) == False and creator_key != 'summary':
1027 if video['creators'][creator_key][1] == person_key:
1028 creators.append(persons[person_key]['name'])
1031 def parse_directors_for_video (self, video, persons):
1032 """Matches ids with person names to generate a list of directors
1036 video : :obj:`dict` of :obj:`str`
1037 Dictionary entry for one video entry
1039 persons : :obj:`dict` of :obj:`str`
1040 Raw resposne of all persons delivered by the API call
1044 :obj:`list` of :obj:`str`
1048 for person_key in dict(persons).keys():
1049 if self._is_size_key(key=person_key) == False and person_key != 'summary':
1050 for director_key in dict(video['directors']).keys():
1051 if self._is_size_key(key=director_key) == False and director_key != 'summary':
1052 if video['directors'][director_key][1] == person_key:
1053 directors.append(persons[person_key]['name'])
1056 def parse_cast_for_video (self, video, persons):
1057 """Matches ids with person names to generate a list of cast members
1061 video : :obj:`dict` of :obj:`str`
1062 Dictionary entry for one video entry
1064 persons : :obj:`dict` of :obj:`str`
1065 Raw resposne of all persons delivered by the API call
1069 :obj:`list` of :obj:`str`
1070 List of cast members
1073 for person_key in dict(persons).keys():
1074 if self._is_size_key(key=person_key) == False and person_key != 'summary':
1075 for cast_key in dict(video['cast']).keys():
1076 if self._is_size_key(key=cast_key) == False and cast_key != 'summary':
1077 if video['cast'][cast_key][1] == person_key:
1078 cast.append(persons[person_key]['name'])
1081 def parse_genres_for_video (self, video, genres):
1082 """Matches ids with genre names to generate a list of genres for a video
1086 video : :obj:`dict` of :obj:`str`
1087 Dictionary entry for one video entry
1089 genres : :obj:`dict` of :obj:`str`
1090 Raw resposne of all genres delivered by the API call
1094 :obj:`list` of :obj:`str`
1098 for genre_key in dict(genres).keys():
1099 if self._is_size_key(key=genre_key) == False and genre_key != 'summary':
1100 for show_genre_key in dict(video['genres']).keys():
1101 if self._is_size_key(key=show_genre_key) == False and show_genre_key != 'summary':
1102 if video['genres'][show_genre_key][1] == genre_key:
1103 video_genres.append(genres[genre_key]['name'])
1106 def parse_tags_for_video (self, video):
1107 """Parses a nested list of tags, removes the not needed meta information & returns a raw string list
1111 video : :obj:`dict` of :obj:`str`
1112 Dictionary entry for one video entry
1116 :obj:`list` of :obj:`str`
1120 for tag_key in dict(video['tags']).keys():
1121 if self._is_size_key(key=tag_key) == False and tag_key != 'summary':
1122 tags.append(video['tags'][tag_key]['name'])
1125 def parse_season_information_for_video (self, video):
1126 """Checks if the fiven video is a show (series) and returns season & episode information
1130 video : :obj:`dict` of :obj:`str`
1131 Dictionary entry for one video entry
1135 :obj:`dict` of :obj:`str`
1136 Episode count / Season Count & Season label if given
1139 'episode_count': None,
1140 'seasons_label': None,
1141 'seasons_count': None
1143 if video['summary']['type'] == 'show':
1145 'episode_count': video['episodeCount'],
1146 'seasons_label': video['numSeasonsLabel'],
1147 'seasons_count': video['seasonCount']
1151 def parse_quality_for_video (self, video):
1152 """Transforms Netflix quality information in video resolution info
1156 video : :obj:`dict` of :obj:`str`
1157 Dictionary entry for one video entry
1162 Quality of the video
1165 if video['videoQuality']['hasHD']:
1167 if video['videoQuality']['hasUltraHD']:
1171 def parse_runtime_for_video (self, video):
1172 """Checks if the video is a movie & returns the runtime if given
1176 video : :obj:`dict` of :obj:`str`
1177 Dictionary entry for one video entry
1182 Runtime of the video (in seconds)
1185 if video['summary']['type'] != 'show':
1186 runtime = video['runtime']
1189 def parse_netflix_list_id (self, video_list):
1190 """Parse a video list and extract the list id
1194 video_list : :obj:`dict` of :obj:`str`
1199 entry : :obj:`str` or None
1202 netflix_list_id = None
1203 if 'lists' in video_list.keys():
1204 for video_id in video_list['lists']:
1205 if self._is_size_key(key=video_id) == False:
1206 netflix_list_id = video_id;
1207 return netflix_list_id
1209 def parse_show_information (self, id, response_data):
1210 """Parse extended show information (synopsis, seasons, etc.)
1217 response_data : :obj:`dict` of :obj:`str`
1218 Parsed response JSON from the `fetch_show_information` call
1222 entry : :obj:`dict` of :obj:`str`
1223 Show information in the format:
1225 "season_id": "80113084",
1226 "synopsis": "Aus verzweifelter Geldnot versucht sich der Familienvater und Drucker Jochen als Geldf\u00e4lscher und rutscht dabei immer mehr in die dunkle Welt des Verbrechens ab."
1227 "detail_text": "I´m optional"
1231 raw_show = response_data['value']['videos'][id]
1232 show.update({'synopsis': raw_show['regularSynopsis']})
1233 if 'evidence' in raw_show:
1234 show.update({'detail_text': raw_show['evidence']['value']['text']})
1235 if 'seasonList' in raw_show:
1236 show.update({'season_id': raw_show['seasonList']['current'][1]})
1239 def parse_seasons (self, id, response_data):
1240 """Parse a list of seasons for a given show
1247 response_data : :obj:`dict` of :obj:`str`
1248 Parsed response JSON from the `fetch_seasons_for_show` call
1252 entry : :obj:`dict` of :obj:`dict` of :obj:`str`
1253 Season information in the format:
1258 "shortName": "St. 1",
1260 "big": "https://art-s.nflximg.net/5e7d3/b3b48749843fd3a36db11c319ffa60f96b55e7d3.jpg",
1261 "small": "https://art-s.nflximg.net/57543/a039845c2eb9186dc26019576d895bf5a1957543.jpg"
1263 "interesting_moment": "https://art-s.nflximg.net/09544/ed4b3073394b4469fb6ec22b9df81a4f5cb09544.jpg"
1268 "shortName": "St. 2",
1270 "big": "https://art-s.nflximg.net/5e7d3/b3b48749843fd3a36db11c319ffa60f96b55e7d3.jpg",
1271 "small": "https://art-s.nflximg.net/57543/a039845c2eb9186dc26019576d895bf5a1957543.jpg"
1273 "interesting_moment": "https://art-s.nflximg.net/09544/ed4b3073394b4469fb6ec22b9df81a4f5cb09544.jpg"
1278 raw_seasons = response_data['value']
1279 for season in raw_seasons['seasons']:
1280 if self._is_size_key(key=season) == False:
1281 seasons.update(self.parse_season_entry(season=raw_seasons['seasons'][season], videos=raw_seasons['videos']))
1284 def parse_season_entry (self, season, videos):
1285 """Parse a season list entry e.g. rip out the parts we need
1289 season : :obj:`dict` of :obj:`str`
1290 Season entry from the `fetch_seasons_for_show` call
1294 entry : :obj:`dict` of :obj:`dict` of :obj:`str`
1295 Season list entry in the format:
1301 "shortName": "St. 1",
1303 "big": "https://art-s.nflximg.net/5e7d3/b3b48749843fd3a36db11c319ffa60f96b55e7d3.jpg",
1304 "small": "https://art-s.nflximg.net/57543/a039845c2eb9186dc26019576d895bf5a1957543.jpg"
1306 "interesting_moment": "https://art-s.nflximg.net/09544/ed4b3073394b4469fb6ec22b9df81a4f5cb09544.jpg"
1312 for key in videos.keys():
1313 if self._is_size_key(key=key) == False:
1316 season['summary']['id']: {
1317 'id': season['summary']['id'],
1318 'text': season['summary']['name'],
1319 'shortName': season['summary']['shortName'],
1321 'small': videos[video_key]['boxarts']['_342x192']['jpg']['url'],
1322 'big': videos[video_key]['boxarts']['_1280x720']['jpg']['url']
1324 'interesting_moment': videos[video_key]['interestingMoment']['_665x375']['jpg']['url'],
1328 def parse_episodes_by_season (self, response_data):
1329 """Parse episodes for a given season/episode list
1333 response_data : :obj:`dict` of :obj:`str`
1334 Parsed response JSON from the `fetch_seasons_for_show` call
1338 entry : :obj:`dict` of :obj:`dict` of :obj:`str`
1339 Season information in the format:
1343 "banner": "https://art-s.nflximg.net/63a36/c7fdfe6604ef2c22d085ac5dca5f69874e363a36.jpg",
1346 "fanart": "https://art-s.nflximg.net/74e02/e7edcc5cc7dcda1e94d505df2f0a2f0d22774e02.jpg",
1352 "mediatype": "episode",
1356 "plot": "Als die Griffins und andere Einwohner von Quahog in die Villa von James Woods eingeladen werden, muss pl\u00f6tzlich ein Mord aufgekl\u00e4rt werden.",
1357 "poster": "https://art-s.nflximg.net/72fd6/57088715e8d436fdb6986834ab39124b0a972fd6.jpg",
1358 "rating": 3.9111512,
1360 "thumb": "https://art-s.nflximg.net/be686/07680670a68da8749eba607efb1ae37f9e3be686.jpg",
1361 "title": "Und dann gab es weniger (Teil 1)",
1366 "banner": "https://art-s.nflximg.net/63a36/c7fdfe6604ef2c22d085ac5dca5f69874e363a36.jpg",
1369 "fanart": "https://art-s.nflximg.net/c472c/6c10f9578bf2c1d0a183c2ccb382931efcbc472c.jpg",
1375 "mediatype": "episode",
1379 "plot": "Wer ist der M\u00f6rder? Nach zahlreichen Morden wird immer wieder jemand anderes verd\u00e4chtigt.",
1380 "poster": "https://art-s.nflximg.net/72fd6/57088715e8d436fdb6986834ab39124b0a972fd6.jpg",
1381 "rating": 3.9111512,
1383 "thumb": "https://art-s.nflximg.net/15a08/857d59126641987bec302bb147a802a00d015a08.jpg",
1384 "title": "Und dann gab es weniger (Teil 2)",
1391 raw_episodes = response_data['value']['videos']
1392 for episode_id in raw_episodes:
1393 if self._is_size_key(key=episode_id) == False:
1394 if (raw_episodes[episode_id]['summary']['type'] == 'episode'):
1395 episodes.update(self.parse_episode(episode=raw_episodes[episode_id], genres=response_data['value']['genres']))
1398 def parse_episode (self, episode, genres=None):
1399 """Parse episode from an list of episodes by season
1403 episode : :obj:`dict` of :obj:`str`
1404 Episode entry from the `fetch_episodes_by_season` call
1408 entry : :obj:`dict` of :obj:`dict` of :obj:`str`
1409 Episode information in the format:
1413 "banner": "https://art-s.nflximg.net/63a36/c7fdfe6604ef2c22d085ac5dca5f69874e363a36.jpg",
1416 "fanart": "https://art-s.nflximg.net/74e02/e7edcc5cc7dcda1e94d505df2f0a2f0d22774e02.jpg",
1422 "mediatype": "episode",
1426 "plot": "Als die Griffins und andere Einwohner von Quahog in die Villa von James Woods eingeladen werden, muss pl\u00f6tzlich ein Mord aufgekl\u00e4rt werden.",
1427 "poster": "https://art-s.nflximg.net/72fd6/57088715e8d436fdb6986834ab39124b0a972fd6.jpg",
1428 "rating": 3.9111512,
1430 "thumb": "https://art-s.nflximg.net/be686/07680670a68da8749eba607efb1ae37f9e3be686.jpg",
1431 "title": "Und dann gab es weniger (Teil 1)",
1438 episode['summary']['id']: {
1439 'id': episode['summary']['id'],
1440 'episode': episode['summary']['episode'],
1441 'season': episode['summary']['season'],
1442 'plot': episode['info']['synopsis'],
1443 'duration': episode['info']['runtime'],
1444 'title': episode['info']['title'],
1445 'year': episode['info']['releaseYear'],
1446 'genres': self.parse_genres_for_video(video=episode, genres=genres),
1447 'mpaa': str(episode['maturity']['rating']['board']) + ' ' + str(episode['maturity']['rating']['value']),
1448 'maturity': episode['maturity'],
1449 'playcount': (0, 1)[episode['watched']],
1450 'rating': episode['userRating']['average'],
1451 'thumb': episode['info']['interestingMoments']['url'],
1452 'fanart': episode['interestingMoment']['_1280x720']['jpg']['url'],
1453 'poster': episode['boxarts']['_1280x720']['jpg']['url'],
1454 'banner': episode['boxarts']['_342x192']['jpg']['url'],
1455 'mediatype': {'episode': 'episode', 'movie': 'movie'}[episode['summary']['type']],
1456 'my_list': episode['queue']['inQueue'],
1457 'bookmark': episode['bookmarkPosition']
1461 def fetch_browse_list_contents (self):
1462 """Fetches the HTML data for the lists on the landing page (browse page) of Netflix
1466 :obj:`BeautifulSoup`
1467 Instance of an BeautifulSoup document containing the complete page contents
1469 response = self.session.get(self._get_document_url_for(component='browse'), verify=self.verify_ssl)
1470 return BeautifulSoup(response.text)
1472 def fetch_video_list_ids (self, list_from=0, list_to=50):
1473 """Fetches the JSON with detailed information based on the lists on the landing page (browse page) of Netflix
1477 list_from : :obj:`int`
1478 Start entry for pagination
1480 list_to : :obj:`int`
1481 Last entry for pagination
1485 :obj:`dict` of :obj:`dict` of :obj:`str`
1486 Raw Netflix API call response or api call error
1489 'fromRow': list_from,
1491 'opaqueImageExtension': 'jpg',
1492 'transparentImageExtension': 'png',
1493 '_': int(time.time()),
1494 'authURL': self.user_data['authURL']
1496 url = self._get_api_url_for(component='video_list_ids')
1497 response = self.session.get(url, params=payload, verify=self.verify_ssl);
1498 return self._process_response(response=response, component=url)
1500 def fetch_search_results (self, search_str, list_from=0, list_to=10):
1501 """Fetches the JSON which contains the results for the given search query
1505 search_str : :obj:`str`
1506 String to query Netflix search for
1508 list_from : :obj:`int`
1509 Start entry for pagination
1511 list_to : :obj:`int`
1512 Last entry for pagination
1516 :obj:`dict` of :obj:`dict` of :obj:`str`
1517 Raw Netflix API call response or api call error
1519 # properly encode the search string
1520 encoded_search_string = urllib.quote(search_str)
1523 ['search', encoded_search_string, 'titles', {'from': list_from, 'to': list_to}, ['summary', 'title']],
1524 ['search', encoded_search_string, 'titles', {'from': list_from, 'to': list_to}, 'boxarts', '_342x192', 'jpg'],
1525 ['search', encoded_search_string, 'titles', ['id', 'length', 'name', 'trackIds', 'requestId']],
1526 ['search', encoded_search_string, 'suggestions', 0, 'relatedvideos', {'from': list_from, 'to': list_to}, ['summary', 'title']],
1527 ['search', encoded_search_string, 'suggestions', 0, 'relatedvideos', {'from': list_from, 'to': list_to}, 'boxarts', '_342x192', 'jpg'],
1528 ['search', encoded_search_string, 'suggestions', 0, 'relatedvideos', ['id', 'length', 'name', 'trackIds', 'requestId']]
1530 response = self._path_request(paths=paths)
1531 return self._process_response(response=response, component='Search results')
1533 def fetch_video_list (self, list_id, list_from=0, list_to=20):
1534 """Fetches the JSON which contains the contents of a given video list
1538 list_id : :obj:`str`
1539 Unique list id to query Netflix for
1541 list_from : :obj:`int`
1542 Start entry for pagination
1544 list_to : :obj:`int`
1545 Last entry for pagination
1549 :obj:`dict` of :obj:`dict` of :obj:`str`
1550 Raw Netflix API call response or api call error
1553 ['lists', list_id, {'from': list_from, 'to': list_to}, ['summary', 'title', 'synopsis', 'regularSynopsis', 'evidence', 'queue', 'episodeCount', 'info', 'maturity', 'runtime', 'seasonCount', 'releaseYear', 'userRating', 'numSeasonsLabel', 'bookmarkPosition', 'watched', 'videoQuality']],
1554 ['lists', list_id, {'from': list_from, 'to': list_to}, 'cast', {'from': 0, 'to': 15}, ['id', 'name']],
1555 ['lists', list_id, {'from': list_from, 'to': list_to}, 'cast', 'summary'],
1556 ['lists', list_id, {'from': list_from, 'to': list_to}, 'genres', {'from': 0, 'to': 5}, ['id', 'name']],
1557 ['lists', list_id, {'from': list_from, 'to': list_to}, 'genres', 'summary'],
1558 ['lists', list_id, {'from': list_from, 'to': list_to}, 'tags', {'from': 0, 'to': 9}, ['id', 'name']],
1559 ['lists', list_id, {'from': list_from, 'to': list_to}, 'tags', 'summary'],
1560 ['lists', list_id, {'from': list_from, 'to': list_to}, ['creators', 'directors'], {'from': 0, 'to': 49}, ['id', 'name']],
1561 ['lists', list_id, {'from': list_from, 'to': list_to}, ['creators', 'directors'], 'summary'],
1562 ['lists', list_id, {'from': list_from, 'to': list_to}, 'bb2OGLogo', '_400x90', 'png'],
1563 ['lists', list_id, {'from': list_from, 'to': list_to}, 'boxarts', '_342x192', 'jpg'],
1564 ['lists', list_id, {'from': list_from, 'to': list_to}, 'boxarts', '_1280x720', 'jpg'],
1565 ['lists', list_id, {'from': list_from, 'to': list_to}, 'storyarts', '_1632x873', 'jpg'],
1566 ['lists', list_id, {'from': list_from, 'to': list_to}, 'interestingMoment', '_665x375', 'jpg'],
1567 ['lists', list_id, {'from': list_from, 'to': list_to}, 'artWorkByType', 'BILLBOARD', '_1280x720', 'jpg']
1570 response = self._path_request(paths=paths)
1571 return self._process_response(response=response, component='Video list')
1573 def fetch_video_list_information (self, video_ids):
1574 """Fetches the JSON which contains the detail information of a list of given video ids
1578 video_ids : :obj:`list` of :obj:`str`
1579 List of video ids to fetch detail data for
1583 :obj:`dict` of :obj:`dict` of :obj:`str`
1584 Raw Netflix API call response or api call error
1587 for video_id in video_ids:
1588 paths.append(['videos', video_id, ['summary', 'title', 'synopsis', 'regularSynopsis', 'evidence', 'queue', 'episodeCount', 'info', 'maturity', 'runtime', 'seasonCount', 'releaseYear', 'userRating', 'numSeasonsLabel', 'bookmarkPosition', 'watched', 'videoQuality']])
1589 paths.append(['videos', video_id, 'cast', {'from': 0, 'to': 15}, ['id', 'name']])
1590 paths.append(['videos', video_id, 'cast', 'summary'])
1591 paths.append(['videos', video_id, 'genres', {'from': 0, 'to': 5}, ['id', 'name']])
1592 paths.append(['videos', video_id, 'genres', 'summary'])
1593 paths.append(['videos', video_id, 'tags', {'from': 0, 'to': 9}, ['id', 'name']])
1594 paths.append(['videos', video_id, 'tags', 'summary'])
1595 paths.append(['videos', video_id, ['creators', 'directors'], {'from': 0, 'to': 49}, ['id', 'name']])
1596 paths.append(['videos', video_id, ['creators', 'directors'], 'summary'])
1597 paths.append(['videos', video_id, 'bb2OGLogo', '_400x90', 'png'])
1598 paths.append(['videos', video_id, 'boxarts', '_342x192', 'jpg'])
1599 paths.append(['videos', video_id, 'boxarts', '_1280x720', 'jpg'])
1600 paths.append(['videos', video_id, 'storyarts', '_1632x873', 'jpg'])
1601 paths.append(['videos', video_id, 'interestingMoment', '_665x375', 'jpg'])
1602 paths.append(['videos', video_id, 'artWorkByType', 'BILLBOARD', '_1280x720', 'jpg'])
1604 response = self._path_request(paths=paths)
1605 return self._process_response(response=response, component='fetch_video_list_information')
1607 def fetch_metadata (self, id):
1608 """Fetches the JSON which contains the metadata for a given show/movie or season id
1613 Show id, movie id or season id
1617 :obj:`dict` of :obj:`dict` of :obj:`str`
1618 Raw Netflix API call response or api call error
1622 'imageformat': 'jpg',
1623 '_': int(time.time())
1625 url = self._get_api_url_for(component='metadata')
1626 response = self.session.get(url, params=payload, verify=self.verify_ssl);
1627 return self._process_response(response=response, component=url)
1629 def fetch_show_information (self, id, type):
1630 """Fetches the JSON which contains the detailed contents of a show
1635 Unique show id to query Netflix for
1638 Can be 'movie' or 'show'
1642 :obj:`dict` of :obj:`dict` of :obj:`str`
1643 Raw Netflix API call response or api call error
1645 # check if we have a show or a movie, the request made depends on this
1648 ['videos', id, ['requestId', 'regularSynopsis', 'evidence']],
1649 ['videos', id, 'seasonList', 'current', 'summary']
1652 paths = [['videos', id, ['requestId', 'regularSynopsis', 'evidence']]]
1653 response = self._path_request(paths=paths)
1654 return self._process_response(response=response, component='Show information')
1656 def fetch_seasons_for_show (self, id, list_from=0, list_to=30):
1657 """Fetches the JSON which contains the seasons of a given show
1662 Unique show id to query Netflix for
1664 list_from : :obj:`int`
1665 Start entry for pagination
1667 list_to : :obj:`int`
1668 Last entry for pagination
1672 :obj:`dict` of :obj:`dict` of :obj:`str`
1673 Raw Netflix API call response or api call error
1676 ['videos', id, 'seasonList', {'from': list_from, 'to': list_to}, 'summary'],
1677 ['videos', id, 'seasonList', 'summary'],
1678 ['videos', id, 'boxarts', '_342x192', 'jpg'],
1679 ['videos', id, 'boxarts', '_1280x720', 'jpg'],
1680 ['videos', id, 'storyarts', '_1632x873', 'jpg'],
1681 ['videos', id, 'interestingMoment', '_665x375', 'jpg']
1683 response = self._path_request(paths=paths)
1684 return self._process_response(response=response, component='Seasons')
1686 def fetch_episodes_by_season (self, season_id, list_from=-1, list_to=40):
1687 """Fetches the JSON which contains the episodes of a given season
1689 TODO: Add more metadata
1693 season_id : :obj:`str`
1694 Unique season_id id to query Netflix for
1696 list_from : :obj:`int`
1697 Start entry for pagination
1699 list_to : :obj:`int`
1700 Last entry for pagination
1704 :obj:`dict` of :obj:`dict` of :obj:`str`
1705 Raw Netflix API call response or api call error
1708 ['seasons', season_id, 'episodes', {'from': list_from, 'to': list_to}, ['summary', 'queue', 'info', 'maturity', 'userRating', 'bookmarkPosition', 'creditOffset', 'watched', 'videoQuality']],
1709 #['videos', season_id, 'cast', {'from': 0, 'to': 15}, ['id', 'name']],
1710 #['videos', season_id, 'cast', 'summary'],
1711 #['videos', season_id, 'genres', {'from': 0, 'to': 5}, ['id', 'name']],
1712 #['videos', season_id, 'genres', 'summary'],
1713 #['videos', season_id, 'tags', {'from': 0, 'to': 9}, ['id', 'name']],
1714 #['videos', season_id, 'tags', 'summary'],
1715 #['videos', season_id, ['creators', 'directors'], {'from': 0, 'to': 49}, ['id', 'name']],
1716 #['videos', season_id, ['creators', 'directors'], 'summary'],
1717 ['seasons', season_id, 'episodes', {'from': list_from, 'to': list_to}, 'genres', {'from': 0, 'to': 1}, ['id', 'name']],
1718 ['seasons', season_id, 'episodes', {'from': list_from, 'to': list_to}, 'genres', 'summary'],
1719 ['seasons', season_id, 'episodes', {'from': list_from, 'to': list_to}, 'interestingMoment', '_1280x720', 'jpg'],
1720 ['seasons', season_id, 'episodes', {'from': list_from, 'to': list_to}, 'interestingMoment', '_665x375', 'jpg'],
1721 ['seasons', season_id, 'episodes', {'from': list_from, 'to': list_to}, 'boxarts', '_342x192', 'jpg'],
1722 ['seasons', season_id, 'episodes', {'from': list_from, 'to': list_to}, 'boxarts', '_1280x720', 'jpg']
1724 response = self._path_request(paths=paths)
1725 return self._process_response(response=response, component='fetch_episodes_by_season')
1727 def refresh_session_data (self, account):
1728 """Reload the session data (profiles, user_data, api_data)
1732 account : :obj:`dict` of :obj:`str`
1733 Dict containing an email, country & a password property
1735 # load the profiles page (to verify the user)
1736 response = self.session.get(self._get_document_url_for(component='profiles'), verify=self.verify_ssl)
1737 # parse out the needed inline information
1738 page_soup = BeautifulSoup(response.text)
1739 page_data = self.extract_inline_netflix_page_data(page_soup=page_soup)
1740 self._parse_page_contents(page_soup)
1741 account_hash = self._generate_account_hash(account=account)
1742 self._save_data(filename=self.data_path + '_' + account_hash)
1744 def _path_request (self, paths):
1745 """Executes a post request against the shakti endpoint with Falcor style payload
1749 paths : :obj:`list` of :obj:`list`
1750 Payload with path querys for the Netflix Shakti API in Falcor style
1754 :obj:`requests.response`
1755 Response from a POST call made with Requests
1758 'Content-Type': 'application/json',
1759 'Accept': 'application/json, text/javascript, */*',
1764 'authURL': self.user_data['authURL']
1769 'materialize': True,
1770 'model': self.user_data['gpsModel']
1773 return self.session.post(self._get_api_url_for(component='shakti'), params=params, headers=headers, data=data, verify=self.verify_ssl)
1775 def _is_size_key (self, key):
1776 """Tiny helper that checks if a given key is called $size or size, as we need to check this often
1781 Key to check the value for
1786 Key has a size value or not
1788 return key == '$size' or key == 'size'
1790 def _get_api_url_for (self, component):
1791 """Tiny helper that builds the url for a requested API endpoint component
1795 component : :obj:`str`
1796 Component endpoint to build the URL for
1803 return self.api_data['API_ROOT'] + self.api_data['API_BASE_URL'] + '/' + self.api_data['BUILD_IDENTIFIER'] + self.urls[component]
1805 def _get_document_url_for (self, component):
1806 """Tiny helper that builds the url for a requested document endpoint component
1810 component : :obj:`str`
1811 Component endpoint to build the URL for
1818 return self.base_url + self.urls[component]
1820 def _process_response (self, response, component):
1821 """Tiny helper to check responses for API requests
1825 response : :obj:`requests.response`
1826 Response from a requests instance
1828 component : :obj:`str`
1833 :obj:`dict` of :obj:`dict` of :obj:`str` or :obj:`dict` of :obj:`str`
1834 Raw Netflix API call response or api call error
1836 # check if we´re not authorized to make thios call
1837 if response.status_code == 401:
1840 'message': 'Session invalid',
1843 # check if somethign else failed
1844 if response.status_code != 200:
1847 'message': 'API call for "' + component + '" failed',
1848 'code': response.status_code
1850 # return the parsed response & everything´s fine
1851 return response.json()
1853 def _update_my_list (self, video_id, operation):
1854 """Tiny helper to add & remove items from "my list"
1858 video_id : :obj:`str`
1859 ID of the show/movie to be added
1861 operation : :obj:`str`
1862 Either "add" or "remove"
1867 Operation successfull
1870 'Content-Type': 'application/json',
1871 'Accept': 'application/json, text/javascript, */*',
1874 payload = json.dumps({
1875 'operation': operation,
1876 'videoId': int(video_id),
1877 'authURL': self.user_data['authURL']
1880 response = self.session.post(self._get_api_url_for(component='update_my_list'), headers=headers, data=payload, verify=self.verify_ssl)
1881 return response.status_code == 200
1883 def _save_data(self, filename):
1884 """Tiny helper that stores session data from the session in a given file
1888 filename : :obj:`str`
1889 Complete path incl. filename that determines where to store the cookie
1894 Storage procedure was successfull
1896 if not os.path.isdir(os.path.dirname(filename)):
1898 with open(filename, 'w') as f:
1901 'user_data': self.user_data,
1902 'api_data': self.api_data,
1903 'profiles': self.profiles
1906 def _load_data(self, filename):
1907 """Tiny helper that loads session data into the active session from a given file
1911 filename : :obj:`str`
1912 Complete path incl. filename that determines where to load the data from
1917 Load procedure was successfull
1919 if not os.path.isfile(filename):
1922 with open(filename) as f:
1923 data = pickle.load(f)
1925 self.profiles = data['profiles']
1926 self.user_data = data['user_data']
1927 self.api_data = data['api_data']
1931 def _delete_data (self, path):
1932 """Tiny helper that deletes session data
1936 filename : :obj:`str`
1937 Complete path incl. filename that determines where to delete the files
1940 head, tail = os.path.split(path)
1941 for subdir, dirs, files in os.walk(head):
1944 os.remove(os.path.join(subdir, file))
1946 def _save_cookies(self, filename):
1947 """Tiny helper that stores cookies from the session in a given file
1951 filename : :obj:`str`
1952 Complete path incl. filename that determines where to store the cookie
1957 Storage procedure was successfull
1959 if not os.path.isdir(os.path.dirname(filename)):
1961 with open(filename, 'w') as f:
1963 pickle.dump(self.session.cookies._cookies, f)
1965 def _load_cookies(self, filename):
1966 """Tiny helper that loads cookies into the active session from a given file
1970 filename : :obj:`str`
1971 Complete path incl. filename that determines where to load the cookie from
1976 Load procedure was successfull
1978 if not os.path.isfile(filename):
1981 with open(filename) as f:
1982 cookies = pickle.load(f)
1984 jar = requests.cookies.RequestsCookieJar()
1985 jar._cookies = cookies
1986 self.session.cookies = jar
1990 def _delete_cookies (self, path):
1991 """Tiny helper that deletes cookie data
1995 filename : :obj:`str`
1996 Complete path incl. filename that determines where to delete the files
1999 head, tail = os.path.split(path)
2000 for subdir, dirs, files in os.walk(head):
2003 os.remove(os.path.join(subdir, file))
2005 def _generate_account_hash (self, account):
2006 """Generates a has for the given account (used for cookie verification)
2010 account : :obj:`dict` of :obj:`str`
2011 Dict containing an email, country & a password property
2018 return base64.urlsafe_b64encode(account['email'])