2 # -*- coding: utf-8 -*-
3 # Module: NetflixSession
4 # Created on: 13.01.2017
14 from BeautifulSoup import BeautifulSoup
15 from utils import strip_tags
16 from utils import noop
19 """Helps with login/session management of Netflix users & API data fetching"""
21 base_url = 'https://www.netflix.com/'
22 """str: Secure Netflix url"""
27 'video_list_ids': '/warmer',
28 'shakti': '/pathEvaluator',
29 'profiles': '/profiles',
30 'switch_profiles': '/profiles/switch',
31 'adult_pin': '/pin/service',
32 'metadata': '/metadata',
33 'set_video_rating': '/setVideoRating',
34 'update_my_list': '/playlistop'
36 """:obj:`dict` of :obj:`str` List of all static endpoints for HTML/JSON POST/GET requests"""
38 video_list_keys = ['user', 'genres', 'recommendations']
39 """:obj:`list` of :obj:`str` Divide the users video lists into 3 different categories (for easier digestion)"""
43 Dict of user profiles, user id is the key:
46 "profileName": "username",
47 "avatar": "http://..../avatar.png",
49 "isAccountOwner": False,
57 dict of user data (used for authentication):
61 "authURL": "145637....",
62 "countryOfSignup": "DE",
63 "emailAddress": "foo@..",
65 "isAdultVerified": True,
66 "isInFreeTrial": False,
68 "isTestAccount": False,
76 dict of api data (used to build up the api urls):
79 "API_BASE_URL": "/shakti",
80 "API_ROOT": "https://www.netflix.com/api",
81 "BUILD_IDENTIFIER": "113b89c9", "
82 ICHNAEA_ROOT": "/ichnaea"
87 """str: Widevine esn, something like: NFCDCH-MC-D7D6F54LOPY8J416T72MQXX3RD20ME"""
89 def __init__(self, cookie_path, data_path, log_fn=noop):
90 """Stores the cookie path for later use & instanciates a requests
91 session with a proper user agent & stored cookies/data if available
95 cookie_path : :obj:`str`
98 data_path : :obj:`str`
99 User data cache location
102 optional log function
104 self.cookie_path = cookie_path
105 self.data_path = data_path
108 # start session, fake chrome (so that we get a proper widevine esn) & enable gzip
109 self.session = requests.session()
110 self.session.headers.update({
111 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/39.0.2171.95 Safari/537.36',
112 'Accept-Encoding': 'gzip, deflate'
115 def parse_login_form_fields (self, form_soup):
116 """Fetches all the inputfields from the login form, so that we
117 can build a request with all the fields needed besides the known email & password ones
121 form_soup : :obj:`BeautifulSoup`
122 Instance of an BeautifulSoup documet or node containing the login form
126 :obj:`dict` of :obj:`str`
127 Dictionary of all input fields with their name as the key & the default
128 value from the form field
130 login_input_fields = {}
131 login_inputs = form_soup.findAll('input')
132 # gather all form fields, set an empty string as the default value
133 for item in login_inputs:
134 keys = dict(item.attrs).keys()
135 if 'name' in keys and 'value' not in keys:
136 login_input_fields[item['name']] = ''
137 elif 'name' in keys and 'value' in keys:
138 login_input_fields[item['name']] = item['value']
139 return login_input_fields
141 def extract_inline_netflix_page_data (self, page_soup):
142 """Extracts all <script/> tags from the given document and parses the contents of each one of `em.
143 The contents of the parsable tags looks something like this:
145 <script>window.netflix = window.netflix || {} ;
146 netflix.notification = {"constants":{"sessionLength":30,"ownerToken":"ZDD...};</script>
148 So we´re extracting every JavaScript object contained in the `netflix.x = {};` variable,
149 strip all html tags, unescape the whole thing & finally parse the resulting serialized JSON from this
150 operations. Errors are expected, as not all <script/> tags contained in the page follow these pattern,
151 but the ones we need do, so we´re just catching any errors and applying a noop() function in case this happens,
152 as we´re not interested in those.
154 Note: Yes this is ugly & I´d like to avoid doing this, but Netflix leaves us no other choice,
155 as there are simply no api endpoints for the data, we need to extract them from HTML,
156 or better, JavaScript as we´re parsing the contents of <script/> tags
160 page_soup : :obj:`BeautifulSoup`
161 Instance of an BeautifulSoup document or node containing the complete page contents
165 :obj:`list` of :obj:`dict`
166 List of all the serialized data pulled out of the pagws <script/> tags
169 data_scripts = page_soup.findAll('script', attrs={'src': None});
170 for script in data_scripts:
171 # ugly part: try to parse the data & don't care about errors (as they will be some)
173 # find the first occurance of the 'netflix.' string, assigning the contents to a global js var
174 str_index = str(script).find('netflix.')
175 # filter out the contents between the 'netflix.x =' & ';<script>'
176 stripped_data = str(script)[str_index:][(str(script)[str_index:].find('= ') + 2):].replace(';</script>', '').strip()
177 # unescape the contents as they contain characters a JSON parser chokes up upon
178 unescaped_data = stripped_data.decode('string_escape')
179 # strip all the HTML tags within the strings a JSON parser chokes up upon them
180 transformed_data = strip_tags(unescaped_data)
181 # parse the contents with a regular JSON parser, as they should be in a shape that ot actually works
183 parsed_data = json.loads(transformed_data)
184 inline_data.append(parsed_data)
185 except ValueError, e:
192 def _parse_user_data (self, netflix_page_data):
193 """Parse out the user data from the big chunk of dicts we got from
194 parsing the JSON-ish data from the netflix homepage
198 netflix_page_data : :obj:`list`
199 List of all the JSON-ish data that has been extracted from the Netflix homepage
200 see: extract_inline_netflix_page_data
204 :obj:`dict` of :obj:`str`
207 "guid": "72ERT45...",
208 "authURL": "145637....",
209 "countryOfSignup": "DE",
210 "emailAddress": "foo@..",
211 "gpsModel": "harris",
212 "isAdultVerified": True,
213 "isInFreeTrial": False,
215 "isTestAccount": False,
234 for item in netflix_page_data:
235 if 'models' in dict(item).keys():
236 for important_field in important_fields:
237 user_data.update({important_field: item['models']['userInfo']['data'][important_field]})
240 def _parse_profile_data (self, netflix_page_data):
241 """Parse out the profile data from the big chunk of dicts we got from
242 parsing the JSON-ish data from the netflix homepage
246 netflix_page_data : :obj:`list`
247 List of all the JSON-ish data that has been extracted from the Netflix homepage
248 see: extract_inline_netflix_page_data
252 :obj:`dict` of :obj:`dict
256 "profileName": "username",
257 "avatar": "http://..../avatar.png",
259 "isAccountOwner": False,
272 # TODO: get rid of this christmas tree of doom
273 for item in netflix_page_data:
274 if 'profiles' in dict(item).keys():
275 for profile_id in item['profiles']:
276 if self._is_size_key(key=profile_id) == False:
277 profile = {'id': profile_id}
278 for important_field in important_fields:
279 profile.update({important_field: item['profiles'][profile_id]['summary'][important_field]})
280 profile.update({'avatar': item['avatars']['nf'][item['profiles'][profile_id]['summary']['avatarName']]['images']['byWidth']['320']['value']})
281 profiles.update({profile_id: profile})
285 def _parse_api_base_data (self, netflix_page_data):
286 """Parse out the api url data from the big chunk of dicts we got from
287 parsing the JSOn-ish data from the netflix homepage
291 netflix_page_data : :obj:`list`
292 List of all the JSON-ish data that has been extracted from the Netflix homepage
293 see: extract_inline_netflix_page_data
297 :obj:`dict` of :obj:`str
300 "API_BASE_URL": "/shakti",
301 "API_ROOT": "https://www.netflix.com/api",
302 "BUILD_IDENTIFIER": "113b89c9", "
303 ICHNAEA_ROOT": "/ichnaea"
313 for item in netflix_page_data:
314 if 'models' in dict(item).keys():
315 for important_field in important_fields:
316 api_data.update({important_field: item['models']['serverDefs']['data'][important_field]})
319 def _parse_esn_data (self, netflix_page_data):
320 """Parse out the esn id data from the big chunk of dicts we got from
321 parsing the JSOn-ish data from the netflix homepage
325 netflix_page_data : :obj:`list`
326 List of all the JSON-ish data that has been extracted from the Netflix homepage
327 see: extract_inline_netflix_page_data
331 :obj:`str` of :obj:`str
332 Widevine esn, something like: NFCDCH-MC-D7D6F54LOPY8J416T72MQXX3RD20ME
335 for item in netflix_page_data:
336 if 'models' in dict(item).keys():
337 esn = item['models']['esnGeneratorModel']['data']['esn']
340 def _parse_page_contents (self, page_soup):
341 """Call all the parsers we need to extract all the session relevant data from the HTML page
342 Directly assigns it to the NetflixSession instance
346 page_soup : :obj:`BeautifulSoup`
347 Instance of an BeautifulSoup document or node containing the complete page contents
349 netflix_page_data = self.extract_inline_netflix_page_data(page_soup=page_soup)
350 self.user_data = self._parse_user_data(netflix_page_data=netflix_page_data)
351 self.esn = self._parse_esn_data(netflix_page_data=netflix_page_data)
352 self.api_data = self._parse_api_base_data(netflix_page_data=netflix_page_data)
353 self.profiles = self._parse_profile_data(netflix_page_data=netflix_page_data)
355 def is_logged_in (self, account):
356 """Determines if a user is already logged in (with a valid cookie),
357 by fetching the index page with the current cookie & checking for the
358 `membership status` user data
362 account : :obj:`dict` of :obj:`str`
363 Dict containing an email, country & a password property
368 User is already logged in (e.g. Cookie is valid) or not
372 account_hash = self._generate_account_hash(account=account)
373 if self._load_cookies(filename=self.cookie_path + '_' + account_hash) == False:
375 if self._load_data(filename=self.data_path + '_' + account_hash) == False:
376 # load the profiles page (to verify the user)
377 response = self.session.get(self._get_document_url_for(component='profiles'))
379 # parse out the needed inline information
380 page_soup = BeautifulSoup(response.text)
381 page_data = self.extract_inline_netflix_page_data(page_soup=page_soup)
382 self._parse_page_contents(page_soup=page_soup)
384 # check if the cookie is still valid
385 for item in page_data:
386 if 'profilesList' in dict(item).keys():
387 if item['profilesList']['summary']['length'] >= 1:
393 """Delete all cookies and session data
397 account : :obj:`dict` of :obj:`str`
398 Dict containing an email, country & a password property
401 self._delete_cookies(path=self.cookie_path)
402 self._delete_data(path=self.data_path)
404 def login (self, account):
405 """Try to log in a user with its credentials & stores the cookies if the action is successfull
407 Note: It fetches the HTML of the login page to extract the fields of the login form,
408 again, this is dirty, but as the fields & their values coudl change at any time, this
409 should be the most reliable way of retrieving the information
413 account : :obj:`dict` of :obj:`str`
414 Dict containing an email, country & a password property
419 User could be logged in or not
421 response = self.session.get(self._get_document_url_for(component='login'))
422 if response.status_code != 200:
425 # collect all the login fields & their contents and add the user credentials
426 page_soup = BeautifulSoup(response.text)
427 login_form = page_soup.find(attrs={'class' : 'ui-label-text'}).findPrevious('form')
428 login_payload = self.parse_login_form_fields(form_soup=login_form)
429 if 'email' in login_payload:
430 login_payload['email'] = account['email']
431 if 'emailOrPhoneNumber' in login_payload:
432 login_payload['emailOrPhoneNumber'] = account['email']
433 login_payload['password'] = account['password']
436 login_response = self.session.post(self._get_document_url_for(component='login'), data=login_payload)
437 login_soup = BeautifulSoup(login_response.text)
439 # we know that the login was successfull if we find an HTML element with the class of 'profile-name'
440 if login_soup.find(attrs={'class' : 'profile-name'}) or login_soup.find(attrs={'class' : 'profile-icon'}):
441 # parse the needed inline information & store cookies for later requests
442 self._parse_page_contents(page_soup=login_soup)
443 account_hash = self._generate_account_hash(account=account)
444 self._save_cookies(filename=self.cookie_path + '_' + account_hash)
445 self._save_data(filename=self.data_path + '_' + account_hash)
450 def switch_profile (self, profile_id, account):
451 """Switch the user profile based on a given profile id
453 Note: All available profiles & their ids can be found in the ´profiles´ property after a successfull login
457 profile_id : :obj:`str`
460 account : :obj:`dict` of :obj:`str`
461 Dict containing an email, country & a password property
466 User could be switched or not
469 'switchProfileGuid': profile_id,
470 '_': int(time.time()),
471 'authURL': self.user_data['authURL']
474 response = self.session.get(self._get_api_url_for(component='switch_profiles'), params=payload);
475 if response.status_code != 200:
478 # fetch the index page again, so that we can fetch the corresponding user data
479 browse_response = self.session.get(self._get_document_url_for(component='browse'))
480 browse_soup = BeautifulSoup(browse_response.text)
481 self._parse_page_contents(page_soup=browse_soup)
482 account_hash = self._generate_account_hash(account=account)
483 self._save_data(filename=self.data_path + '_' + account_hash)
486 def send_adult_pin (self, pin):
487 """Send the adult pin to Netflix in case an adult rated video requests it
489 Note: Once entered, it should last for the complete session (Not so sure about this)
499 Pin was accepted or not
501 :obj:`dict` of :obj:`str`
506 'authURL': self.user_data['authURL']
508 url = self._get_api_url_for(component='adult_pin')
509 response = self.session.get(url, params=payload);
510 pin_response = self._process_response(response=response, component=url)
511 keys = pin_response.keys()
512 if 'success' in keys:
518 def add_to_list (self, video_id):
519 """Adds a video to "my list" on Netflix
523 video_id : :obj:`str`
524 ID of th show/video/movie to be added
529 Adding was successfull
531 return self._update_my_list(video_id=video_id, operation='add')
533 def remove_from_list (self, video_id):
534 """Removes a video from "my list" on Netflix
538 video_id : :obj:`str`
539 ID of th show/video/movie to be removed
544 Removing was successfull
546 return self._update_my_list(video_id=video_id, operation='remove')
548 def rate_video (self, video_id, rating):
549 """Rate a video on Netflix
553 video_id : :obj:`str`
554 ID of th show/video/movie to be rated
557 Rating, must be between 0 & 10
562 Rating successfull or not
565 # dirty rating validation
567 if rating > 10 or rating < 0:
570 # In opposition to Kodi, Netflix uses a rating from 0 to in 0.5 steps
575 'Content-Type': 'application/json',
576 'Accept': 'application/json, text/javascript, */*',
584 payload = json.dumps({
585 'authURL': self.user_data['authURL']
588 response = self.session.post(self._get_api_url_for(component='set_video_rating'), params=params, headers=headers, data=payload)
589 return response.status_code == 200
591 def parse_video_list_ids (self, response_data):
592 """Parse the list of video ids e.g. rip out the parts we need
596 response_data : :obj:`dict` of :obj:`str`
597 Parsed response JSON from the ´fetch_video_list_ids´ call
601 :obj:`dict` of :obj:`dict`
602 Video list ids in the format:
606 "3589e2c6-ca3b-48b4-a72d-34f2c09ffbf4_11568367": {
607 "displayName": "US-Serien",
608 "id": "3589e2c6-ca3b-48b4-a72d-34f2c09ffbf4_11568367",
613 "3589e2c6-ca3b-48b4-a72d-34f2c09ffbf4_11568368": {
618 "3589e2c6-ca3b-48b4-a72d-34f2c09ffbf4_11568364": {
619 "displayName": "Meine Liste",
620 "id": "3589e2c6-ca3b-48b4-a72d-34f2c09ffbf4_11568364",
625 "3589e2c6-ca3b-48b4-a72d-34f2c09ffbf4_11568365": {
630 "3589e2c6-ca3b-48b4-a72d-34f2c09ffbf4_11568382": {
631 "displayName": "Passend zu Family Guy",
632 "id": "3589e2c6-ca3b-48b4-a72d-34f2c09ffbf4_11568382",
637 "3589e2c6-ca3b-48b4-a72d-34f2c09ffbf4_11568397": {
643 # prepare the return dictionary
645 for key in self.video_list_keys:
646 video_list_ids[key] = {}
648 # subcatogorize the lists by their context
649 video_lists = response_data['lists']
650 for video_list_id in video_lists.keys():
651 video_list = video_lists[video_list_id]
652 if video_list['context'] == 'genre':
653 video_list_ids['genres'].update(self.parse_video_list_ids_entry(id=video_list_id, entry=video_list))
654 elif video_list['context'] == 'similars' or video_list['context'] == 'becauseYouAdded':
655 video_list_ids['recommendations'].update(self.parse_video_list_ids_entry(id=video_list_id, entry=video_list))
657 video_list_ids['user'].update(self.parse_video_list_ids_entry(id=video_list_id, entry=video_list))
659 return video_list_ids
661 def parse_video_list_ids_entry (self, id, entry):
662 """Parse a video id entry e.g. rip out the parts we need
666 response_data : :obj:`dict` of :obj:`str`
667 Dictionary entry from the ´fetch_video_list_ids´ call
672 Unique id of the video list
674 entry : :obj:`dict` of :obj:`str`
675 Video list entry in the format:
677 "3589e2c6-ca3b-48b4-a72d-34f2c09ffbf4_11568382": {
678 "displayName": "Passend zu Family Guy",
679 "id": "3589e2c6-ca3b-48b4-a72d-34f2c09ffbf4_11568382",
688 'index': entry['index'],
689 'name': entry['context'],
690 'displayName': entry['displayName'],
691 'size': entry['length']
695 def parse_search_results (self, response_data):
696 """Parse the list of search results, rip out the parts we need
697 and extend it with detailed show informations
701 response_data : :obj:`dict` of :obj:`str`
702 Parsed response JSON from the `fetch_search_results` call
706 :obj:`dict` of :obj:`dict` of :obj:`str`
707 Search results in the format:
711 "boxarts": "https://art-s.nflximg.net/0d7af/d5c72668c35d3da65ae031302bd4ae1bcc80d7af.jpg",
712 "detail_text": "Die legend\u00e4re und mit 13 Emmys nominierte Serie von Gene Roddenberry inspirierte eine ganze Generation.",
714 "season_id": "70109435",
715 "synopsis": "Unter Befehl von Captain Kirk begibt sich die Besatzung des Raumschiffs Enterprise in die Tiefen des Weltraums, wo sie fremde Galaxien und neue Zivilisationen erforscht.",
716 "title": "Star Trek",
725 raw_search_results = response_data['value']['videos']
726 for entry_id in raw_search_results:
727 if self._is_size_key(key=entry_id) == False:
728 # fetch information about each show & build up a proper search results dictionary
729 show = self.parse_show_list_entry(id=entry_id, entry=raw_search_results[entry_id])
730 show[entry_id].update(self.parse_show_information(id=entry_id, response_data=self.fetch_show_information(id=entry_id, type=show[entry_id]['type'])))
731 search_results.update(show)
732 return search_results
734 def parse_show_list_entry (self, id, entry):
735 """Parse a show entry e.g. rip out the parts we need
739 response_data : :obj:`dict` of :obj:`str`
740 Dictionary entry from the ´fetch_show_information´ call
743 Unique id of the video list
747 entry : :obj:`dict` of :obj:`dict` of :obj:`str`
748 Show list entry in the format:
751 "3589e2c6-ca3b-48b4-a72d-34f2c09ffbf4_11568382": {
752 "id": "3589e2c6-ca3b-48b4-a72d-34f2c09ffbf4_11568382",
753 "title": "Enterprise",
754 "boxarts": "https://art-s.nflximg.net/.../smth.jpg",
762 'title': entry['title'],
763 'boxarts': entry['boxarts']['_342x192']['jpg']['url'],
764 'type': entry['summary']['type']
768 def parse_video_list (self, response_data):
769 """Parse a list of videos
773 response_data : :obj:`dict` of :obj:`str`
774 Parsed response JSON from the `fetch_video_list` call
778 :obj:`dict` of :obj:`dict`
779 Video list in the format:
785 "big": "https://art-s.nflximg.net/5e7d3/b3b48749843fd3a36db11c319ffa60f96b55e7d3.jpg",
786 "small": "https://art-s.nflximg.net/57543/a039845c2eb9186dc26019576d895bf5a1957543.jpg"
801 "episode_count": null,
807 "interesting_moment": "https://art-s.nflximg.net/09544/ed4b3073394b4469fb6ec22b9df81a4f5cb09544.jpg",
808 "list_id": "9588df32-f957-40e4-9055-1f6f33b60103_46891306",
811 "description": "Nur f\u00fcr Erwachsene geeignet.",
817 "regular_synopsis": "Ein Spielzeughersteller erweckt aus Versehen die Seele der M\u00f6rderpuppe Chucky erneut zum Leben, die sich unmittelbar wieder ihren m\u00f6rderischen Aktivit\u00e4ten zuwendet.",
819 "seasons_count": null,
820 "seasons_label": null,
821 "synopsis": "Die allseits beliebte, von D\u00e4monen besessene M\u00f6rderpuppe ist wieder da und verbreitet erneut Horror und Schrecken.",
826 "title": "Chucky 2 \u2013 Die M\u00f6rderpuppe ist wieder da",
834 "big": "https://art-s.nflximg.net/7c10d/5dcc3fc8f08487e92507627068cfe26ef727c10d.jpg",
835 "small": "https://art-s.nflximg.net/5bc0e/f3be361b8c594929062f90a8d9c6eb57fb75bc0e.jpg"
852 "interesting_moment": "https://art-s.nflximg.net/0188e/19cd705a71ee08c8d2609ae01cd8a97a86c0188e.jpg",
853 "list_id": "9588df32-f957-40e4-9055-1f6f33b60103_46891306",
856 "description": "Geeignet ab 12 Jahren.",
862 "regular_synopsis": "Comedy-Serie \u00fcber die Erlebnisse eines Tatortreinigers, der seine schmutzige Arbeit erst beginnen kann, wenn die Polizei die Tatortanalyse abgeschlossen hat.",
865 "seasons_label": "5 Staffeln",
866 "synopsis": "In den meisten Krimiserien werden Mordf\u00e4lle auf faszinierende und spannende Weise gel\u00f6st. Diese Serie ist anders.",
870 "title": "Der Tatortreiniger",
878 raw_video_list = response_data['value']
879 netflix_list_id = self.parse_netflix_list_id(video_list=raw_video_list);
880 for video_id in raw_video_list['videos']:
881 if self._is_size_key(key=video_id) == False:
882 video_list.update(self.parse_video_list_entry(id=video_id, list_id=netflix_list_id, video=raw_video_list['videos'][video_id], persons=raw_video_list['person'], genres=raw_video_list['genres']))
885 def parse_video_list_entry (self, id, list_id, video, persons, genres):
886 """Parse a video list entry e.g. rip out the parts we need
891 Unique id of the video
894 Unique id of the containing list
896 video : :obj:`dict` of :obj:`str`
897 Video entry from the ´fetch_video_list´ call
899 persons : :obj:`dict` of :obj:`dict` of :obj:`str`
900 List of persons with reference ids
902 persons : :obj:`dict` of :obj:`dict` of :obj:`str`
903 List of genres with reference ids
907 entry : :obj:`dict` of :obj:`dict` of :obj:`str`
908 Video list entry in the format:
914 "big": "https://art-s.nflximg.net/5e7d3/b3b48749843fd3a36db11c319ffa60f96b55e7d3.jpg",
915 "small": "https://art-s.nflximg.net/57543/a039845c2eb9186dc26019576d895bf5a1957543.jpg"
930 "episode_count": null,
936 "interesting_moment": "https://art-s.nflximg.net/09544/ed4b3073394b4469fb6ec22b9df81a4f5cb09544.jpg",
937 "list_id": "9588df32-f957-40e4-9055-1f6f33b60103_46891306",
940 "description": "Nur f\u00fcr Erwachsene geeignet.",
946 "regular_synopsis": "Ein Spielzeughersteller erweckt aus Versehen die Seele der M\u00f6rderpuppe Chucky erneut zum Leben, die sich unmittelbar wieder ihren m\u00f6rderischen Aktivit\u00e4ten zuwendet.",
948 "seasons_count": null,
949 "seasons_label": null,
950 "synopsis": "Die allseits beliebte, von D\u00e4monen besessene M\u00f6rderpuppe ist wieder da und verbreitet erneut Horror und Schrecken.",
955 "title": "Chucky 2 \u2013 Die M\u00f6rderpuppe ist wieder da",
962 season_info = self.parse_season_information_for_video(video=video)
967 'title': video['title'],
968 'synopsis': video['synopsis'],
969 'regular_synopsis': video['regularSynopsis'],
970 'type': video['summary']['type'],
971 'rating': video['userRating']['average'],
972 'episode_count': season_info['episode_count'],
973 'seasons_label': season_info['seasons_label'],
974 'seasons_count': season_info['seasons_count'],
975 'in_my_list': video['queue']['inQueue'],
976 'year': video['releaseYear'],
977 'runtime': self.parse_runtime_for_video(video=video),
978 'watched': video['watched'],
979 'tags': self.parse_tags_for_video(video=video),
980 'genres': self.parse_genres_for_video(video=video, genres=genres),
981 'quality': self.parse_quality_for_video(video=video),
982 'cast': self.parse_cast_for_video(video=video, persons=persons),
983 'directors': self.parse_directors_for_video(video=video, persons=persons),
984 'creators': self.parse_creators_for_video(video=video, persons=persons),
986 'board': None if 'board' not in video['maturity']['rating'].keys() else video['maturity']['rating']['board'],
987 'value': None if 'value' not in video['maturity']['rating'].keys() else video['maturity']['rating']['value'],
988 'description': None if 'maturityDescription' not in video['maturity']['rating'].keys() else video['maturity']['rating']['maturityDescription'],
989 'level': None if 'maturityLevel' not in video['maturity']['rating'].keys() else video['maturity']['rating']['maturityLevel']
992 'small': video['boxarts']['_342x192']['jpg']['url'],
993 'big': video['boxarts']['_1280x720']['jpg']['url']
995 'interesting_moment': None if 'interestingMoment' not in video.keys() else video['interestingMoment']['_665x375']['jpg']['url'],
996 'artwork': video['artWorkByType']['BILLBOARD']['_1280x720']['jpg']['url'],
1000 def parse_creators_for_video (self, video, persons):
1001 """Matches ids with person names to generate a list of creators
1005 video : :obj:`dict` of :obj:`str`
1006 Dictionary entry for one video entry
1008 persons : :obj:`dict` of :obj:`str`
1009 Raw resposne of all persons delivered by the API call
1013 :obj:`list` of :obj:`str`
1017 for person_key in dict(persons).keys():
1018 if self._is_size_key(key=person_key) == False and person_key != 'summary':
1019 for creator_key in dict(video['creators']).keys():
1020 if self._is_size_key(key=creator_key) == False and creator_key != 'summary':
1021 if video['creators'][creator_key][1] == person_key:
1022 creators.append(persons[person_key]['name'])
1025 def parse_directors_for_video (self, video, persons):
1026 """Matches ids with person names to generate a list of directors
1030 video : :obj:`dict` of :obj:`str`
1031 Dictionary entry for one video entry
1033 persons : :obj:`dict` of :obj:`str`
1034 Raw resposne of all persons delivered by the API call
1038 :obj:`list` of :obj:`str`
1042 for person_key in dict(persons).keys():
1043 if self._is_size_key(key=person_key) == False and person_key != 'summary':
1044 for director_key in dict(video['directors']).keys():
1045 if self._is_size_key(key=director_key) == False and director_key != 'summary':
1046 if video['directors'][director_key][1] == person_key:
1047 directors.append(persons[person_key]['name'])
1050 def parse_cast_for_video (self, video, persons):
1051 """Matches ids with person names to generate a list of cast members
1055 video : :obj:`dict` of :obj:`str`
1056 Dictionary entry for one video entry
1058 persons : :obj:`dict` of :obj:`str`
1059 Raw resposne of all persons delivered by the API call
1063 :obj:`list` of :obj:`str`
1064 List of cast members
1067 for person_key in dict(persons).keys():
1068 if self._is_size_key(key=person_key) == False and person_key != 'summary':
1069 for cast_key in dict(video['cast']).keys():
1070 if self._is_size_key(key=cast_key) == False and cast_key != 'summary':
1071 if video['cast'][cast_key][1] == person_key:
1072 cast.append(persons[person_key]['name'])
1075 def parse_genres_for_video (self, video, genres):
1076 """Matches ids with genre names to generate a list of genres for a video
1080 video : :obj:`dict` of :obj:`str`
1081 Dictionary entry for one video entry
1083 genres : :obj:`dict` of :obj:`str`
1084 Raw resposne of all genres delivered by the API call
1088 :obj:`list` of :obj:`str`
1092 for genre_key in dict(genres).keys():
1093 if self._is_size_key(key=genre_key) == False and genre_key != 'summary':
1094 for show_genre_key in dict(video['genres']).keys():
1095 if self._is_size_key(key=show_genre_key) == False and show_genre_key != 'summary':
1096 if video['genres'][show_genre_key][1] == genre_key:
1097 video_genres.append(genres[genre_key]['name'])
1100 def parse_tags_for_video (self, video):
1101 """Parses a nested list of tags, removes the not needed meta information & returns a raw string list
1105 video : :obj:`dict` of :obj:`str`
1106 Dictionary entry for one video entry
1110 :obj:`list` of :obj:`str`
1114 for tag_key in dict(video['tags']).keys():
1115 if self._is_size_key(key=tag_key) == False and tag_key != 'summary':
1116 tags.append(video['tags'][tag_key]['name'])
1119 def parse_season_information_for_video (self, video):
1120 """Checks if the fiven video is a show (series) and returns season & episode information
1124 video : :obj:`dict` of :obj:`str`
1125 Dictionary entry for one video entry
1129 :obj:`dict` of :obj:`str`
1130 Episode count / Season Count & Season label if given
1133 'episode_count': None,
1134 'seasons_label': None,
1135 'seasons_count': None
1137 if video['summary']['type'] == 'show':
1139 'episode_count': video['episodeCount'],
1140 'seasons_label': video['numSeasonsLabel'],
1141 'seasons_count': video['seasonCount']
1145 def parse_quality_for_video (self, video):
1146 """Transforms Netflix quality information in video resolution info
1150 video : :obj:`dict` of :obj:`str`
1151 Dictionary entry for one video entry
1156 Quality of the video
1159 if video['videoQuality']['hasHD']:
1161 if video['videoQuality']['hasUltraHD']:
1165 def parse_runtime_for_video (self, video):
1166 """Checks if the video is a movie & returns the runtime if given
1170 video : :obj:`dict` of :obj:`str`
1171 Dictionary entry for one video entry
1176 Runtime of the video (in seconds)
1179 if video['summary']['type'] != 'show':
1180 runtime = video['runtime']
1183 def parse_netflix_list_id (self, video_list):
1184 """Parse a video list and extract the list id
1188 video_list : :obj:`dict` of :obj:`str`
1193 entry : :obj:`str` or None
1196 netflix_list_id = None
1197 if 'lists' in video_list.keys():
1198 for video_id in video_list['lists']:
1199 if self._is_size_key(key=video_id) == False:
1200 netflix_list_id = video_id;
1201 return netflix_list_id
1203 def parse_show_information (self, id, response_data):
1204 """Parse extended show information (synopsis, seasons, etc.)
1211 response_data : :obj:`dict` of :obj:`str`
1212 Parsed response JSON from the `fetch_show_information` call
1216 entry : :obj:`dict` of :obj:`str`
1217 Show information in the format:
1219 "season_id": "80113084",
1220 "synopsis": "Aus verzweifelter Geldnot versucht sich der Familienvater und Drucker Jochen als Geldf\u00e4lscher und rutscht dabei immer mehr in die dunkle Welt des Verbrechens ab."
1221 "detail_text": "I´m optional"
1225 raw_show = response_data['value']['videos'][id]
1226 show.update({'synopsis': raw_show['regularSynopsis']})
1227 if 'evidence' in raw_show:
1228 show.update({'detail_text': raw_show['evidence']['value']['text']})
1229 if 'seasonList' in raw_show:
1230 show.update({'season_id': raw_show['seasonList']['current'][1]})
1233 def parse_seasons (self, id, response_data):
1234 """Parse a list of seasons for a given show
1241 response_data : :obj:`dict` of :obj:`str`
1242 Parsed response JSON from the `fetch_seasons_for_show` call
1246 entry : :obj:`dict` of :obj:`dict` of :obj:`str`
1247 Season information in the format:
1252 "shortName": "St. 1",
1254 "big": "https://art-s.nflximg.net/5e7d3/b3b48749843fd3a36db11c319ffa60f96b55e7d3.jpg",
1255 "small": "https://art-s.nflximg.net/57543/a039845c2eb9186dc26019576d895bf5a1957543.jpg"
1257 "interesting_moment": "https://art-s.nflximg.net/09544/ed4b3073394b4469fb6ec22b9df81a4f5cb09544.jpg"
1262 "shortName": "St. 2",
1264 "big": "https://art-s.nflximg.net/5e7d3/b3b48749843fd3a36db11c319ffa60f96b55e7d3.jpg",
1265 "small": "https://art-s.nflximg.net/57543/a039845c2eb9186dc26019576d895bf5a1957543.jpg"
1267 "interesting_moment": "https://art-s.nflximg.net/09544/ed4b3073394b4469fb6ec22b9df81a4f5cb09544.jpg"
1272 raw_seasons = response_data['value']
1273 for season in raw_seasons['seasons']:
1274 if self._is_size_key(key=season) == False:
1275 seasons.update(self.parse_season_entry(season=raw_seasons['seasons'][season], videos=raw_seasons['videos']))
1278 def parse_season_entry (self, season, videos):
1279 """Parse a season list entry e.g. rip out the parts we need
1283 season : :obj:`dict` of :obj:`str`
1284 Season entry from the `fetch_seasons_for_show` call
1288 entry : :obj:`dict` of :obj:`dict` of :obj:`str`
1289 Season list entry in the format:
1295 "shortName": "St. 1",
1297 "big": "https://art-s.nflximg.net/5e7d3/b3b48749843fd3a36db11c319ffa60f96b55e7d3.jpg",
1298 "small": "https://art-s.nflximg.net/57543/a039845c2eb9186dc26019576d895bf5a1957543.jpg"
1300 "interesting_moment": "https://art-s.nflximg.net/09544/ed4b3073394b4469fb6ec22b9df81a4f5cb09544.jpg"
1306 for key in videos.keys():
1307 if self._is_size_key(key=key) == False:
1310 season['summary']['id']: {
1311 'id': season['summary']['id'],
1312 'text': season['summary']['name'],
1313 'shortName': season['summary']['shortName'],
1315 'small': videos[video_key]['boxarts']['_342x192']['jpg']['url'],
1316 'big': videos[video_key]['boxarts']['_1280x720']['jpg']['url']
1318 'interesting_moment': videos[video_key]['interestingMoment']['_665x375']['jpg']['url'],
1322 def parse_episodes_by_season (self, response_data):
1323 """Parse episodes for a given season/episode list
1327 response_data : :obj:`dict` of :obj:`str`
1328 Parsed response JSON from the `fetch_seasons_for_show` call
1332 entry : :obj:`dict` of :obj:`dict` of :obj:`str`
1333 Season information in the format:
1337 "banner": "https://art-s.nflximg.net/63a36/c7fdfe6604ef2c22d085ac5dca5f69874e363a36.jpg",
1340 "fanart": "https://art-s.nflximg.net/74e02/e7edcc5cc7dcda1e94d505df2f0a2f0d22774e02.jpg",
1346 "mediatype": "episode",
1350 "plot": "Als die Griffins und andere Einwohner von Quahog in die Villa von James Woods eingeladen werden, muss pl\u00f6tzlich ein Mord aufgekl\u00e4rt werden.",
1351 "poster": "https://art-s.nflximg.net/72fd6/57088715e8d436fdb6986834ab39124b0a972fd6.jpg",
1352 "rating": 3.9111512,
1354 "thumb": "https://art-s.nflximg.net/be686/07680670a68da8749eba607efb1ae37f9e3be686.jpg",
1355 "title": "Und dann gab es weniger (Teil 1)",
1360 "banner": "https://art-s.nflximg.net/63a36/c7fdfe6604ef2c22d085ac5dca5f69874e363a36.jpg",
1363 "fanart": "https://art-s.nflximg.net/c472c/6c10f9578bf2c1d0a183c2ccb382931efcbc472c.jpg",
1369 "mediatype": "episode",
1373 "plot": "Wer ist der M\u00f6rder? Nach zahlreichen Morden wird immer wieder jemand anderes verd\u00e4chtigt.",
1374 "poster": "https://art-s.nflximg.net/72fd6/57088715e8d436fdb6986834ab39124b0a972fd6.jpg",
1375 "rating": 3.9111512,
1377 "thumb": "https://art-s.nflximg.net/15a08/857d59126641987bec302bb147a802a00d015a08.jpg",
1378 "title": "Und dann gab es weniger (Teil 2)",
1385 raw_episodes = response_data['value']['videos']
1386 for episode_id in raw_episodes:
1387 if self._is_size_key(key=episode_id) == False:
1388 if (raw_episodes[episode_id]['summary']['type'] == 'episode'):
1389 episodes.update(self.parse_episode(episode=raw_episodes[episode_id], genres=response_data['value']['genres']))
1392 def parse_episode (self, episode, genres=None):
1393 """Parse episode from an list of episodes by season
1397 episode : :obj:`dict` of :obj:`str`
1398 Episode entry from the `fetch_episodes_by_season` call
1402 entry : :obj:`dict` of :obj:`dict` of :obj:`str`
1403 Episode information in the format:
1407 "banner": "https://art-s.nflximg.net/63a36/c7fdfe6604ef2c22d085ac5dca5f69874e363a36.jpg",
1410 "fanart": "https://art-s.nflximg.net/74e02/e7edcc5cc7dcda1e94d505df2f0a2f0d22774e02.jpg",
1416 "mediatype": "episode",
1420 "plot": "Als die Griffins und andere Einwohner von Quahog in die Villa von James Woods eingeladen werden, muss pl\u00f6tzlich ein Mord aufgekl\u00e4rt werden.",
1421 "poster": "https://art-s.nflximg.net/72fd6/57088715e8d436fdb6986834ab39124b0a972fd6.jpg",
1422 "rating": 3.9111512,
1424 "thumb": "https://art-s.nflximg.net/be686/07680670a68da8749eba607efb1ae37f9e3be686.jpg",
1425 "title": "Und dann gab es weniger (Teil 1)",
1432 episode['summary']['id']: {
1433 'id': episode['summary']['id'],
1434 'episode': episode['summary']['episode'],
1435 'season': episode['summary']['season'],
1436 'plot': episode['info']['synopsis'],
1437 'duration': episode['info']['runtime'],
1438 'title': episode['info']['title'],
1439 'year': episode['info']['releaseYear'],
1440 'genres': self.parse_genres_for_video(video=episode, genres=genres),
1441 'mpaa': str(episode['maturity']['rating']['board']) + ' ' + str(episode['maturity']['rating']['value']),
1442 'maturity': episode['maturity'],
1443 'playcount': (0, 1)[episode['watched']],
1444 'rating': episode['userRating']['average'],
1445 'thumb': episode['info']['interestingMoments']['url'],
1446 'fanart': episode['interestingMoment']['_1280x720']['jpg']['url'],
1447 'poster': episode['boxarts']['_1280x720']['jpg']['url'],
1448 'banner': episode['boxarts']['_342x192']['jpg']['url'],
1449 'mediatype': {'episode': 'episode', 'movie': 'movie'}[episode['summary']['type']],
1450 'my_list': episode['queue']['inQueue'],
1451 'bookmark': episode['bookmarkPosition']
1455 def fetch_browse_list_contents (self):
1456 """Fetches the HTML data for the lists on the landing page (browse page) of Netflix
1460 :obj:`BeautifulSoup`
1461 Instance of an BeautifulSoup document containing the complete page contents
1463 response = self.session.get(self._get_document_url_for(component='browse'))
1464 return BeautifulSoup(response.text)
1466 def fetch_video_list_ids (self, list_from=0, list_to=50):
1467 """Fetches the JSON with detailed information based on the lists on the landing page (browse page) of Netflix
1471 list_from : :obj:`int`
1472 Start entry for pagination
1474 list_to : :obj:`int`
1475 Last entry for pagination
1479 :obj:`dict` of :obj:`dict` of :obj:`str`
1480 Raw Netflix API call response or api call error
1483 'fromRow': list_from,
1485 'opaqueImageExtension': 'jpg',
1486 'transparentImageExtension': 'png',
1487 '_': int(time.time()),
1488 'authURL': self.user_data['authURL']
1490 url = self._get_api_url_for(component='video_list_ids')
1491 response = self.session.get(url, params=payload);
1492 return self._process_response(response=response, component=url)
1494 def fetch_search_results (self, search_str, list_from=0, list_to=48):
1495 """Fetches the JSON which contains the results for the given search query
1499 search_str : :obj:`str`
1500 String to query Netflix search for
1502 list_from : :obj:`int`
1503 Start entry for pagination
1505 list_to : :obj:`int`
1506 Last entry for pagination
1510 :obj:`dict` of :obj:`dict` of :obj:`str`
1511 Raw Netflix API call response or api call error
1513 # properly encode the search string
1514 encoded_search_string = urllib.quote(search_str)
1517 ['search', encoded_search_string, 'titles', {'from': list_from, 'to': list_to}, ['summary', 'title']],
1518 ['search', encoded_search_string, 'titles', {'from': list_from, 'to': list_to}, 'boxarts', '_342x192', 'jpg'],
1519 ['search', encoded_search_string, 'titles', ['id', 'length', 'name', 'trackIds', 'requestId']]
1521 response = self._path_request(paths=paths)
1522 return self._process_response(response=response, component='Search results')
1524 def fetch_video_list (self, list_id, list_from=0, list_to=20):
1525 """Fetches the JSON which contains the contents of a given video list
1529 list_id : :obj:`str`
1530 Unique list id to query Netflix for
1532 list_from : :obj:`int`
1533 Start entry for pagination
1535 list_to : :obj:`int`
1536 Last entry for pagination
1540 :obj:`dict` of :obj:`dict` of :obj:`str`
1541 Raw Netflix API call response or api call error
1544 ['lists', list_id, {'from': list_from, 'to': list_to}, ['summary', 'title', 'synopsis', 'regularSynopsis', 'evidence', 'queue', 'episodeCount', 'info', 'maturity', 'runtime', 'seasonCount', 'releaseYear', 'userRating', 'numSeasonsLabel', 'bookmarkPosition', 'watched', 'videoQuality']],
1545 ['lists', list_id, {'from': list_from, 'to': list_to}, 'cast', {'from': 0, 'to': 15}, ['id', 'name']],
1546 ['lists', list_id, {'from': list_from, 'to': list_to}, 'cast', 'summary'],
1547 ['lists', list_id, {'from': list_from, 'to': list_to}, 'genres', {'from': 0, 'to': 5}, ['id', 'name']],
1548 ['lists', list_id, {'from': list_from, 'to': list_to}, 'genres', 'summary'],
1549 ['lists', list_id, {'from': list_from, 'to': list_to}, 'tags', {'from': 0, 'to': 9}, ['id', 'name']],
1550 ['lists', list_id, {'from': list_from, 'to': list_to}, 'tags', 'summary'],
1551 ['lists', list_id, {'from': list_from, 'to': list_to}, ['creators', 'directors'], {'from': 0, 'to': 49}, ['id', 'name']],
1552 ['lists', list_id, {'from': list_from, 'to': list_to}, ['creators', 'directors'], 'summary'],
1553 ['lists', list_id, {'from': list_from, 'to': list_to}, 'bb2OGLogo', '_400x90', 'png'],
1554 ['lists', list_id, {'from': list_from, 'to': list_to}, 'boxarts', '_342x192', 'jpg'],
1555 ['lists', list_id, {'from': list_from, 'to': list_to}, 'boxarts', '_1280x720', 'jpg'],
1556 ['lists', list_id, {'from': list_from, 'to': list_to}, 'storyarts', '_1632x873', 'jpg'],
1557 ['lists', list_id, {'from': list_from, 'to': list_to}, 'interestingMoment', '_665x375', 'jpg'],
1558 ['lists', list_id, {'from': list_from, 'to': list_to}, 'artWorkByType', 'BILLBOARD', '_1280x720', 'jpg']
1561 response = self._path_request(paths=paths)
1562 return self._process_response(response=response, component='Video list')
1564 def fetch_video_list_information (self, video_ids):
1565 """Fetches the JSON which contains the detail information of a list of given video ids
1569 video_ids : :obj:`list` of :obj:`str`
1570 List of video ids to fetch detail data for
1574 :obj:`dict` of :obj:`dict` of :obj:`str`
1575 Raw Netflix API call response or api call error
1578 for video_id in video_ids:
1579 paths.append(['videos', video_id, ['summary', 'title', 'synopsis', 'regularSynopsis', 'evidence', 'queue', 'episodeCount', 'info', 'maturity', 'runtime', 'seasonCount', 'releaseYear', 'userRating', 'numSeasonsLabel', 'bookmarkPosition', 'watched', 'videoQuality']])
1580 paths.append(['videos', video_id, 'cast', {'from': 0, 'to': 15}, ['id', 'name']])
1581 paths.append(['videos', video_id, 'cast', 'summary'])
1582 paths.append(['videos', video_id, 'genres', {'from': 0, 'to': 5}, ['id', 'name']])
1583 paths.append(['videos', video_id, 'genres', 'summary'])
1584 paths.append(['videos', video_id, 'tags', {'from': 0, 'to': 9}, ['id', 'name']])
1585 paths.append(['videos', video_id, 'tags', 'summary'])
1586 paths.append(['videos', video_id, ['creators', 'directors'], {'from': 0, 'to': 49}, ['id', 'name']])
1587 paths.append(['videos', video_id, ['creators', 'directors'], 'summary'])
1588 paths.append(['videos', video_id, 'bb2OGLogo', '_400x90', 'png'])
1589 paths.append(['videos', video_id, 'boxarts', '_342x192', 'jpg'])
1590 paths.append(['videos', video_id, 'boxarts', '_1280x720', 'jpg'])
1591 paths.append(['videos', video_id, 'storyarts', '_1632x873', 'jpg'])
1592 paths.append(['videos', video_id, 'interestingMoment', '_665x375', 'jpg'])
1593 paths.append(['videos', video_id, 'artWorkByType', 'BILLBOARD', '_1280x720', 'jpg'])
1595 response = self._path_request(paths=paths)
1596 return self._process_response(response=response, component='fetch_video_list_information')
1598 def fetch_metadata (self, id):
1599 """Fetches the JSON which contains the metadata for a given show/movie or season id
1604 Show id, movie id or season id
1608 :obj:`dict` of :obj:`dict` of :obj:`str`
1609 Raw Netflix API call response or api call error
1613 'imageformat': 'jpg',
1614 '_': int(time.time())
1616 url = self._get_api_url_for(component='metadata')
1617 response = self.session.get(url, params=payload);
1618 return self._process_response(response=response, component=url)
1620 def fetch_show_information (self, id, type):
1621 """Fetches the JSON which contains the detailed contents of a show
1626 Unique show id to query Netflix for
1629 Can be 'movie' or 'show'
1633 :obj:`dict` of :obj:`dict` of :obj:`str`
1634 Raw Netflix API call response or api call error
1636 # check if we have a show or a movie, the request made depends on this
1639 ['videos', id, ['requestId', 'regularSynopsis', 'evidence']],
1640 ['videos', id, 'seasonList', 'current', 'summary']
1643 paths = [['videos', id, ['requestId', 'regularSynopsis', 'evidence']]]
1644 response = self._path_request(paths=paths)
1645 return self._process_response(response=response, component='Show information')
1647 def fetch_seasons_for_show (self, id, list_from=0, list_to=30):
1648 """Fetches the JSON which contains the seasons of a given show
1653 Unique show id to query Netflix for
1655 list_from : :obj:`int`
1656 Start entry for pagination
1658 list_to : :obj:`int`
1659 Last entry for pagination
1663 :obj:`dict` of :obj:`dict` of :obj:`str`
1664 Raw Netflix API call response or api call error
1667 ['videos', id, 'seasonList', {'from': list_from, 'to': list_to}, 'summary'],
1668 ['videos', id, 'seasonList', 'summary'],
1669 ['videos', id, 'boxarts', '_342x192', 'jpg'],
1670 ['videos', id, 'boxarts', '_1280x720', 'jpg'],
1671 ['videos', id, 'storyarts', '_1632x873', 'jpg'],
1672 ['videos', id, 'interestingMoment', '_665x375', 'jpg']
1674 response = self._path_request(paths=paths)
1675 return self._process_response(response=response, component='Seasons')
1677 def fetch_episodes_by_season (self, season_id, list_from=-1, list_to=40):
1678 """Fetches the JSON which contains the episodes of a given season
1680 TODO: Add more metadata
1684 season_id : :obj:`str`
1685 Unique season_id id to query Netflix for
1687 list_from : :obj:`int`
1688 Start entry for pagination
1690 list_to : :obj:`int`
1691 Last entry for pagination
1695 :obj:`dict` of :obj:`dict` of :obj:`str`
1696 Raw Netflix API call response or api call error
1699 ['seasons', season_id, 'episodes', {'from': list_from, 'to': list_to}, ['summary', 'queue', 'info', 'maturity', 'userRating', 'bookmarkPosition', 'creditOffset', 'watched', 'videoQuality']],
1700 #['videos', season_id, 'cast', {'from': 0, 'to': 15}, ['id', 'name']],
1701 #['videos', season_id, 'cast', 'summary'],
1702 #['videos', season_id, 'genres', {'from': 0, 'to': 5}, ['id', 'name']],
1703 #['videos', season_id, 'genres', 'summary'],
1704 #['videos', season_id, 'tags', {'from': 0, 'to': 9}, ['id', 'name']],
1705 #['videos', season_id, 'tags', 'summary'],
1706 #['videos', season_id, ['creators', 'directors'], {'from': 0, 'to': 49}, ['id', 'name']],
1707 #['videos', season_id, ['creators', 'directors'], 'summary'],
1708 ['seasons', season_id, 'episodes', {'from': list_from, 'to': list_to}, 'genres', {'from': 0, 'to': 1}, ['id', 'name']],
1709 ['seasons', season_id, 'episodes', {'from': list_from, 'to': list_to}, 'genres', 'summary'],
1710 ['seasons', season_id, 'episodes', {'from': list_from, 'to': list_to}, 'interestingMoment', '_1280x720', 'jpg'],
1711 ['seasons', season_id, 'episodes', {'from': list_from, 'to': list_to}, 'interestingMoment', '_665x375', 'jpg'],
1712 ['seasons', season_id, 'episodes', {'from': list_from, 'to': list_to}, 'boxarts', '_342x192', 'jpg'],
1713 ['seasons', season_id, 'episodes', {'from': list_from, 'to': list_to}, 'boxarts', '_1280x720', 'jpg']
1715 response = self._path_request(paths=paths)
1716 return self._process_response(response=response, component='fetch_episodes_by_season')
1718 def refresh_session_data (self, account):
1719 """Reload the session data (profiles, user_data, api_data)
1723 account : :obj:`dict` of :obj:`str`
1724 Dict containing an email, country & a password property
1726 # load the profiles page (to verify the user)
1727 response = self.session.get(self._get_document_url_for(component='profiles'))
1729 # parse out the needed inline information
1730 page_soup = BeautifulSoup(response.text)
1731 page_data = self.extract_inline_netflix_page_data(page_soup=page_soup)
1732 self._parse_page_contents(page_soup)
1733 account_hash = self._generate_account_hash(account=account)
1734 self._save_data(filename=self.data_path + '_' + account_hash)
1736 def _path_request (self, paths):
1737 """Executes a post request against the shakti endpoint with Falcor style payload
1741 paths : :obj:`list` of :obj:`list`
1742 Payload with path querys for the Netflix Shakti API in Falcor style
1746 :obj:`requests.response`
1747 Response from a POST call made with Requests
1750 'Content-Type': 'application/json',
1751 'Accept': 'application/json, text/javascript, */*',
1756 'authURL': self.user_data['authURL']
1761 'materialize': True,
1762 'model': self.user_data['gpsModel']
1765 return self.session.post(self._get_api_url_for(component='shakti'), params=params, headers=headers, data=data)
1767 def _is_size_key (self, key):
1768 """Tiny helper that checks if a given key is called $size or size, as we need to check this often
1773 Key to check the value for
1778 Key has a size value or not
1780 return key == '$size' or key == 'size'
1782 def _get_api_url_for (self, component):
1783 """Tiny helper that builds the url for a requested API endpoint component
1787 component : :obj:`str`
1788 Component endpoint to build the URL for
1795 return self.api_data['API_ROOT'] + self.api_data['API_BASE_URL'] + '/' + self.api_data['BUILD_IDENTIFIER'] + self.urls[component]
1797 def _get_document_url_for (self, component):
1798 """Tiny helper that builds the url for a requested document endpoint component
1802 component : :obj:`str`
1803 Component endpoint to build the URL for
1810 return self.base_url + self.urls[component]
1812 def _process_response (self, response, component):
1813 """Tiny helper to check responses for API requests
1817 response : :obj:`requests.response`
1818 Response from a requests instance
1820 component : :obj:`str`
1825 :obj:`dict` of :obj:`dict` of :obj:`str` or :obj:`dict` of :obj:`str`
1826 Raw Netflix API call response or api call error
1828 # check if we´re not authorized to make thios call
1829 if response.status_code == 401:
1832 'message': 'Session invalid',
1835 # check if somethign else failed
1836 if response.status_code != 200:
1839 'message': 'API call for "' + component + '" failed',
1840 'code': response.status_code
1842 # return the parsed response & everything´s fine
1843 return response.json()
1845 def _update_my_list (self, video_id, operation):
1846 """Tiny helper to add & remove items from "my list"
1850 video_id : :obj:`str`
1851 ID of the show/movie to be added
1853 operation : :obj:`str`
1854 Either "add" or "remove"
1859 Operation successfull
1862 'Content-Type': 'application/json',
1863 'Accept': 'application/json, text/javascript, */*',
1866 payload = json.dumps({
1867 'operation': operation,
1868 'videoId': int(video_id),
1869 'authURL': self.user_data['authURL']
1872 response = self.session.post(self._get_api_url_for(component='update_my_list'), headers=headers, data=payload)
1873 return response.status_code == 200
1875 def _save_data(self, filename):
1876 """Tiny helper that stores session data from the session in a given file
1880 filename : :obj:`str`
1881 Complete path incl. filename that determines where to store the cookie
1886 Storage procedure was successfull
1888 if not os.path.isdir(os.path.dirname(filename)):
1890 with open(filename, 'w') as f:
1893 'user_data': self.user_data,
1894 'api_data': self.api_data,
1895 'profiles': self.profiles
1898 def _load_data(self, filename):
1899 """Tiny helper that loads session data into the active session from a given file
1903 filename : :obj:`str`
1904 Complete path incl. filename that determines where to load the data from
1909 Load procedure was successfull
1911 if not os.path.isfile(filename):
1914 with open(filename) as f:
1915 data = pickle.load(f)
1917 self.profiles = data['profiles']
1918 self.user_data = data['user_data']
1919 self.api_data = data['api_data']
1923 def _delete_data (self, path):
1924 """Tiny helper that deletes session data
1928 filename : :obj:`str`
1929 Complete path incl. filename that determines where to delete the files
1932 head, tail = os.path.split(path)
1933 for subdir, dirs, files in os.walk(head):
1936 os.remove(os.path.join(subdir, file))
1938 def _save_cookies(self, filename):
1939 """Tiny helper that stores cookies from the session in a given file
1943 filename : :obj:`str`
1944 Complete path incl. filename that determines where to store the cookie
1949 Storage procedure was successfull
1951 if not os.path.isdir(os.path.dirname(filename)):
1953 with open(filename, 'w') as f:
1955 pickle.dump(self.session.cookies._cookies, f)
1957 def _load_cookies(self, filename):
1958 """Tiny helper that loads cookies into the active session from a given file
1962 filename : :obj:`str`
1963 Complete path incl. filename that determines where to load the cookie from
1968 Load procedure was successfull
1970 if not os.path.isfile(filename):
1973 with open(filename) as f:
1974 cookies = pickle.load(f)
1976 jar = requests.cookies.RequestsCookieJar()
1977 jar._cookies = cookies
1978 self.session.cookies = jar
1982 def _delete_cookies (self, path):
1983 """Tiny helper that deletes cookie data
1987 filename : :obj:`str`
1988 Complete path incl. filename that determines where to delete the files
1991 head, tail = os.path.split(path)
1992 for subdir, dirs, files in os.walk(head):
1995 os.remove(os.path.join(subdir, file))
1997 def _generate_account_hash (self, account):
1998 """Generates a has for the given account (used for cookie verification)
2002 account : :obj:`dict` of :obj:`str`
2003 Dict containing an email, country & a password property
2010 return base64.urlsafe_b64encode(account['email'])