2 # -*- coding: utf-8 -*-
3 # Module: NetflixSession
4 # Created on: 13.01.2017
14 import cPickle as pickle
17 from bs4 import BeautifulSoup, SoupStrainer
18 from pyjsparser import PyJsParser
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:
148 <script>window.netflix = window.netflix || {} ; netflix.notification = {"constants":{"sessionLength":30,"ownerToken":"ZDD...};</script>
149 We use a JS parser to generate an AST of the code given & then parse that AST into a python dict.
150 This should be okay, as we´re only interested in a few static values & put the rest aside
154 page_soup : :obj:`BeautifulSoup`
155 Instance of an BeautifulSoup document or node containing the complete page contents
158 :obj:`list` of :obj:`dict`
159 List of all the serialized data pulled out of the pagws <script/> tags
162 parser = PyJsParser()
163 data_scripts = page_soup.find_all('script', attrs={'src': None});
164 for script in data_scripts:
166 # unicode escape that incoming script stuff
167 contents = self._to_unicode(str(script.contents[0]))
168 # parse the JS & load the declarations we´re interested in
169 declarations = parser.parse(contents)['body'][1]['expression']['right']['properties'];
170 for declaration in declarations:
171 for key in declaration:
172 # we found the correct path if the declaration is a dict & of type 'ObjectExpression'
173 if type(declaration[key]) is dict:
174 if declaration[key]['type'] == 'ObjectExpression':
175 # add all static data recursivly
176 for expression in declaration[key]['properties']:
177 data[expression['key']['value']] = self._parse_rec(expression['value'])
178 inline_data.append(data)
181 def _parse_rec (self, node):
182 """Iterates over a JavaScript AST and retu values found
189 :obj:`dict` of :obj:`dict` or :obj:`str`
190 Parsed contents of the node
192 if node['type'] == 'ObjectExpression':
194 for prop in node['properties']:
195 _ret.update({prop['key']['value']: self._parse_rec(prop['value'])})
197 if node['type'] == 'Literal':
200 def _parse_user_data (self, netflix_page_data):
201 """Parse out the user data from the big chunk of dicts we got from
202 parsing the JSON-ish data from the netflix homepage
206 netflix_page_data : :obj:`list`
207 List of all the JSON-ish data that has been extracted from the Netflix homepage
208 see: extract_inline_netflix_page_data
212 :obj:`dict` of :obj:`str`
215 "guid": "72ERT45...",
216 "authURL": "145637....",
217 "countryOfSignup": "DE",
218 "emailAddress": "foo@..",
219 "gpsModel": "harris",
220 "isAdultVerified": True,
221 "isInFreeTrial": False,
223 "isTestAccount": False,
242 for item in netflix_page_data:
243 if 'memberContext' in dict(item).keys():
244 for important_field in important_fields:
245 user_data.update({important_field: item['memberContext']['data']['userInfo'][important_field]})
248 def _parse_profile_data (self, netflix_page_data):
249 """Parse out the profile data from the big chunk of dicts we got from
250 parsing the JSON-ish data from the netflix homepage
254 netflix_page_data : :obj:`list`
255 List of all the JSON-ish data that has been extracted from the Netflix homepage
256 see: extract_inline_netflix_page_data
260 :obj:`dict` of :obj:`dict
264 "profileName": "username",
265 "avatar": "http://..../avatar.png",
267 "isAccountOwner": False,
280 # TODO: get rid of this christmas tree of doom
281 for item in netflix_page_data:
282 if 'hasViewedRatingWelcomeModal' in dict(item).keys():
283 for profile_id in item:
284 if self._is_size_key(key=profile_id) == False and type(item[profile_id]) == dict and item[profile_id].get('avatar', False) != False:
285 profile = {'id': profile_id}
286 for important_field in important_fields:
287 profile.update({important_field: item[profile_id]['summary'][important_field]})
288 avatar_base = item['nf'].get(item[profile_id]['summary']['avatarName'], False);
289 avatar = 'https://secure.netflix.com/ffe/profiles/avatars_v2/320x320/PICON_029.png' if avatar_base == False else avatar_base['images']['byWidth']['320']['value']
290 profile.update({'avatar': avatar})
291 profiles.update({profile_id: profile})
294 def _parse_api_base_data (self, netflix_page_data):
295 """Parse out the api url data from the big chunk of dicts we got from
296 parsing the JSOn-ish data from the netflix homepage
300 netflix_page_data : :obj:`list`
301 List of all the JSON-ish data that has been extracted from the Netflix homepage
302 see: extract_inline_netflix_page_data
306 :obj:`dict` of :obj:`str
309 "API_BASE_URL": "/shakti",
310 "API_ROOT": "https://www.netflix.com/api",
311 "BUILD_IDENTIFIER": "113b89c9", "
312 ICHNAEA_ROOT": "/ichnaea"
322 for item in netflix_page_data:
323 if 'serverDefs' in dict(item).keys():
324 for important_field in important_fields:
325 api_data.update({important_field: item['serverDefs']['data'][important_field]})
328 def _parse_esn_data (self, netflix_page_data):
329 """Parse out the esn id data from the big chunk of dicts we got from
330 parsing the JSOn-ish data from the netflix homepage
334 netflix_page_data : :obj:`list`
335 List of all the JSON-ish data that has been extracted from the Netflix homepage
336 see: extract_inline_netflix_page_data
340 :obj:`str` of :obj:`str
341 Widevine esn, something like: NFCDCH-MC-D7D6F54LOPY8J416T72MQXX3RD20ME
344 for item in netflix_page_data:
345 if 'esnGeneratorModel' in dict(item).keys():
346 esn = item['esnGeneratorModel']['data']['esn']
349 def _parse_page_contents (self, page_soup):
350 """Call all the parsers we need to extract all the session relevant data from the HTML page
351 Directly assigns it to the NetflixSession instance
355 page_soup : :obj:`BeautifulSoup`
356 Instance of an BeautifulSoup document or node containing the complete page contents
358 netflix_page_data = self.extract_inline_netflix_page_data(page_soup=page_soup)
359 self.user_data = self._parse_user_data(netflix_page_data=netflix_page_data)
360 self.esn = self._parse_esn_data(netflix_page_data=netflix_page_data)
361 self.api_data = self._parse_api_base_data(netflix_page_data=netflix_page_data)
362 self.profiles = self._parse_profile_data(netflix_page_data=netflix_page_data)
363 return netflix_page_data
365 def is_logged_in (self, account):
366 """Determines if a user is already logged in (with a valid cookie),
367 by fetching the index page with the current cookie & checking for the
368 `membership status` user data
372 account : :obj:`dict` of :obj:`str`
373 Dict containing an email, country & a password property
378 User is already logged in (e.g. Cookie is valid) or not
382 account_hash = self._generate_account_hash(account=account)
383 if self._load_cookies(filename=self.cookie_path + '_' + account_hash) == False:
385 if self._load_data(filename=self.data_path + '_' + account_hash) == False:
386 # load the profiles page (to verify the user)
387 response = self.session.get(self._get_document_url_for(component='profiles'), verify=self.verify_ssl)
389 # parse out the needed inline information
390 only_script_tags = SoupStrainer('script')
391 page_soup = BeautifulSoup(response.text, 'html.parser', parse_only=only_script_tags)
392 page_data = self._parse_page_contents(page_soup=page_soup)
394 # check if the cookie is still valid
395 for item in page_data:
396 if 'profilesList' in dict(item).keys():
397 if item['profilesList']['summary']['length'] >= 1:
403 """Delete all cookies and session data
407 account : :obj:`dict` of :obj:`str`
408 Dict containing an email, country & a password property
411 self._delete_cookies(path=self.cookie_path)
412 self._delete_data(path=self.data_path)
414 def login (self, account):
415 """Try to log in a user with its credentials & stores the cookies if the action is successfull
417 Note: It fetches the HTML of the login page to extract the fields of the login form,
418 again, this is dirty, but as the fields & their values coudl change at any time, this
419 should be the most reliable way of retrieving the information
423 account : :obj:`dict` of :obj:`str`
424 Dict containing an email, country & a password property
429 User could be logged in or not
431 response = self.session.get(self._get_document_url_for(component='login'), verify=self.verify_ssl)
432 if response.status_code != 200:
435 # collect all the login fields & their contents and add the user credentials
436 page_soup = BeautifulSoup(response.text, 'html.parser')
437 login_form = page_soup.find(attrs={'class' : 'ui-label-text'}).findPrevious('form')
438 login_payload = self.parse_login_form_fields(form_soup=login_form)
439 if 'email' in login_payload:
440 login_payload['email'] = account['email']
441 if 'emailOrPhoneNumber' in login_payload:
442 login_payload['emailOrPhoneNumber'] = account['email']
443 login_payload['password'] = account['password']
446 login_response = self.session.post(self._get_document_url_for(component='login'), data=login_payload, verify=self.verify_ssl)
447 login_soup = BeautifulSoup(login_response.text, 'html.parser')
449 # we know that the login was successfull if we find an HTML element with the class of 'profile-name'
450 if login_soup.find(attrs={'class' : 'profile-name'}) or login_soup.find(attrs={'class' : 'profile-icon'}):
451 # parse the needed inline information & store cookies for later requests
452 self._parse_page_contents(page_soup=login_soup)
453 account_hash = self._generate_account_hash(account=account)
454 self._save_cookies(filename=self.cookie_path + '_' + account_hash)
455 self._save_data(filename=self.data_path + '_' + account_hash)
460 def switch_profile (self, profile_id, account):
461 """Switch the user profile based on a given profile id
463 Note: All available profiles & their ids can be found in the ´profiles´ property after a successfull login
467 profile_id : :obj:`str`
470 account : :obj:`dict` of :obj:`str`
471 Dict containing an email, country & a password property
476 User could be switched or not
479 'switchProfileGuid': profile_id,
480 '_': int(time.time()),
481 'authURL': self.user_data['authURL']
484 response = self.session.get(self._get_api_url_for(component='switch_profiles'), params=payload, verify=self.verify_ssl);
485 if response.status_code != 200:
488 # fetch the index page again, so that we can fetch the corresponding user data
489 browse_response = self.session.get(self._get_document_url_for(component='browse'), verify=self.verify_ssl)
490 only_script_tags = SoupStrainer('script')
491 browse_soup = BeautifulSoup(browse_response.text, 'html.parser', parse_only=only_script_tags)
492 self._parse_page_contents(page_soup=browse_soup)
493 account_hash = self._generate_account_hash(account=account)
494 self.user_data['guid'] = profile_id;
495 self._save_data(filename=self.data_path + '_' + account_hash)
498 def send_adult_pin (self, pin):
499 """Send the adult pin to Netflix in case an adult rated video requests it
501 Note: Once entered, it should last for the complete session (Not so sure about this)
511 Pin was accepted or not
513 :obj:`dict` of :obj:`str`
518 'authURL': self.user_data['authURL']
520 url = self._get_api_url_for(component='adult_pin')
521 response = self.session.get(url, params=payload, verify=self.verify_ssl);
522 pin_response = self._process_response(response=response, component=url)
523 keys = pin_response.keys()
524 if 'success' in keys:
530 def add_to_list (self, video_id):
531 """Adds a video to "my list" on Netflix
535 video_id : :obj:`str`
536 ID of th show/video/movie to be added
541 Adding was successfull
543 return self._update_my_list(video_id=video_id, operation='add')
545 def remove_from_list (self, video_id):
546 """Removes a video from "my list" on Netflix
550 video_id : :obj:`str`
551 ID of th show/video/movie to be removed
556 Removing was successfull
558 return self._update_my_list(video_id=video_id, operation='remove')
560 def rate_video (self, video_id, rating):
561 """Rate a video on Netflix
565 video_id : :obj:`str`
566 ID of th show/video/movie to be rated
569 Rating, must be between 0 & 10
574 Rating successfull or not
577 # dirty rating validation
579 if rating > 10 or rating < 0:
582 # In opposition to Kodi, Netflix uses a rating from 0 to in 0.5 steps
587 'Content-Type': 'application/json',
588 'Accept': 'application/json, text/javascript, */*',
596 payload = json.dumps({
597 'authURL': self.user_data['authURL']
600 response = self.session.post(self._get_api_url_for(component='set_video_rating'), params=params, headers=headers, data=payload, verify=self.verify_ssl)
601 return response.status_code == 200
603 def parse_video_list_ids (self, response_data):
604 """Parse the list of video ids e.g. rip out the parts we need
608 response_data : :obj:`dict` of :obj:`str`
609 Parsed response JSON from the ´fetch_video_list_ids´ call
613 :obj:`dict` of :obj:`dict`
614 Video list ids in the format:
618 "3589e2c6-ca3b-48b4-a72d-34f2c09ffbf4_11568367": {
619 "displayName": "US-Serien",
620 "id": "3589e2c6-ca3b-48b4-a72d-34f2c09ffbf4_11568367",
625 "3589e2c6-ca3b-48b4-a72d-34f2c09ffbf4_11568368": {
630 "3589e2c6-ca3b-48b4-a72d-34f2c09ffbf4_11568364": {
631 "displayName": "Meine Liste",
632 "id": "3589e2c6-ca3b-48b4-a72d-34f2c09ffbf4_11568364",
637 "3589e2c6-ca3b-48b4-a72d-34f2c09ffbf4_11568365": {
642 "3589e2c6-ca3b-48b4-a72d-34f2c09ffbf4_11568382": {
643 "displayName": "Passend zu Family Guy",
644 "id": "3589e2c6-ca3b-48b4-a72d-34f2c09ffbf4_11568382",
649 "3589e2c6-ca3b-48b4-a72d-34f2c09ffbf4_11568397": {
655 # prepare the return dictionary
657 for key in self.video_list_keys:
658 video_list_ids[key] = {}
660 # subcatogorize the lists by their context
661 video_lists = response_data['lists']
662 for video_list_id in video_lists.keys():
663 video_list = video_lists[video_list_id]
664 if video_list['context'] == 'genre':
665 video_list_ids['genres'].update(self.parse_video_list_ids_entry(id=video_list_id, entry=video_list))
666 elif video_list['context'] == 'similars' or video_list['context'] == 'becauseYouAdded':
667 video_list_ids['recommendations'].update(self.parse_video_list_ids_entry(id=video_list_id, entry=video_list))
669 video_list_ids['user'].update(self.parse_video_list_ids_entry(id=video_list_id, entry=video_list))
671 return video_list_ids
673 def parse_video_list_ids_entry (self, id, entry):
674 """Parse a video id entry e.g. rip out the parts we need
678 response_data : :obj:`dict` of :obj:`str`
679 Dictionary entry from the ´fetch_video_list_ids´ call
684 Unique id of the video list
686 entry : :obj:`dict` of :obj:`str`
687 Video list entry in the format:
689 "3589e2c6-ca3b-48b4-a72d-34f2c09ffbf4_11568382": {
690 "displayName": "Passend zu Family Guy",
691 "id": "3589e2c6-ca3b-48b4-a72d-34f2c09ffbf4_11568382",
700 'index': entry['index'],
701 'name': entry['context'],
702 'displayName': entry['displayName'],
703 'size': entry['length']
707 def parse_search_results (self, response_data):
708 """Parse the list of search results, rip out the parts we need
709 and extend it with detailed show informations
713 response_data : :obj:`dict` of :obj:`str`
714 Parsed response JSON from the `fetch_search_results` call
718 :obj:`dict` of :obj:`dict` of :obj:`str`
719 Search results in the format:
723 "boxarts": "https://art-s.nflximg.net/0d7af/d5c72668c35d3da65ae031302bd4ae1bcc80d7af.jpg",
724 "detail_text": "Die legend\u00e4re und mit 13 Emmys nominierte Serie von Gene Roddenberry inspirierte eine ganze Generation.",
726 "season_id": "70109435",
727 "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.",
728 "title": "Star Trek",
737 raw_search_results = response_data['value']['videos']
738 for entry_id in raw_search_results:
739 if self._is_size_key(key=entry_id) == False:
740 # fetch information about each show & build up a proper search results dictionary
741 show = self.parse_show_list_entry(id=entry_id, entry=raw_search_results[entry_id])
742 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'])))
743 search_results.update(show)
744 return search_results
746 def parse_show_list_entry (self, id, entry):
747 """Parse a show entry e.g. rip out the parts we need
751 response_data : :obj:`dict` of :obj:`str`
752 Dictionary entry from the ´fetch_show_information´ call
755 Unique id of the video list
759 entry : :obj:`dict` of :obj:`dict` of :obj:`str`
760 Show list entry in the format:
763 "3589e2c6-ca3b-48b4-a72d-34f2c09ffbf4_11568382": {
764 "id": "3589e2c6-ca3b-48b4-a72d-34f2c09ffbf4_11568382",
765 "title": "Enterprise",
766 "boxarts": "https://art-s.nflximg.net/.../smth.jpg",
774 'title': entry['title'],
775 'boxarts': entry['boxarts']['_342x192']['jpg']['url'],
776 'type': entry['summary']['type']
780 def parse_video_list (self, response_data):
781 """Parse a list of videos
785 response_data : :obj:`dict` of :obj:`str`
786 Parsed response JSON from the `fetch_video_list` call
790 :obj:`dict` of :obj:`dict`
791 Video list in the format:
797 "big": "https://art-s.nflximg.net/5e7d3/b3b48749843fd3a36db11c319ffa60f96b55e7d3.jpg",
798 "small": "https://art-s.nflximg.net/57543/a039845c2eb9186dc26019576d895bf5a1957543.jpg"
813 "episode_count": null,
819 "interesting_moment": "https://art-s.nflximg.net/09544/ed4b3073394b4469fb6ec22b9df81a4f5cb09544.jpg",
820 "list_id": "9588df32-f957-40e4-9055-1f6f33b60103_46891306",
823 "description": "Nur f\u00fcr Erwachsene geeignet.",
829 "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.",
831 "seasons_count": null,
832 "seasons_label": null,
833 "synopsis": "Die allseits beliebte, von D\u00e4monen besessene M\u00f6rderpuppe ist wieder da und verbreitet erneut Horror und Schrecken.",
838 "title": "Chucky 2 \u2013 Die M\u00f6rderpuppe ist wieder da",
846 "big": "https://art-s.nflximg.net/7c10d/5dcc3fc8f08487e92507627068cfe26ef727c10d.jpg",
847 "small": "https://art-s.nflximg.net/5bc0e/f3be361b8c594929062f90a8d9c6eb57fb75bc0e.jpg"
864 "interesting_moment": "https://art-s.nflximg.net/0188e/19cd705a71ee08c8d2609ae01cd8a97a86c0188e.jpg",
865 "list_id": "9588df32-f957-40e4-9055-1f6f33b60103_46891306",
868 "description": "Geeignet ab 12 Jahren.",
874 "regular_synopsis": "Comedy-Serie \u00fcber die Erlebnisse eines Tatortreinigers, der seine schmutzige Arbeit erst beginnen kann, wenn die Polizei die Tatortanalyse abgeschlossen hat.",
877 "seasons_label": "5 Staffeln",
878 "synopsis": "In den meisten Krimiserien werden Mordf\u00e4lle auf faszinierende und spannende Weise gel\u00f6st. Diese Serie ist anders.",
882 "title": "Der Tatortreiniger",
890 raw_video_list = response_data['value']
891 netflix_list_id = self.parse_netflix_list_id(video_list=raw_video_list);
892 for video_id in raw_video_list['videos']:
893 if self._is_size_key(key=video_id) == False:
894 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']))
897 def parse_video_list_entry (self, id, list_id, video, persons, genres):
898 """Parse a video list entry e.g. rip out the parts we need
903 Unique id of the video
906 Unique id of the containing list
908 video : :obj:`dict` of :obj:`str`
909 Video entry from the ´fetch_video_list´ call
911 persons : :obj:`dict` of :obj:`dict` of :obj:`str`
912 List of persons with reference ids
914 persons : :obj:`dict` of :obj:`dict` of :obj:`str`
915 List of genres with reference ids
919 entry : :obj:`dict` of :obj:`dict` of :obj:`str`
920 Video list entry in the format:
926 "big": "https://art-s.nflximg.net/5e7d3/b3b48749843fd3a36db11c319ffa60f96b55e7d3.jpg",
927 "small": "https://art-s.nflximg.net/57543/a039845c2eb9186dc26019576d895bf5a1957543.jpg"
942 "episode_count": null,
948 "interesting_moment": "https://art-s.nflximg.net/09544/ed4b3073394b4469fb6ec22b9df81a4f5cb09544.jpg",
949 "list_id": "9588df32-f957-40e4-9055-1f6f33b60103_46891306",
952 "description": "Nur f\u00fcr Erwachsene geeignet.",
958 "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.",
960 "seasons_count": null,
961 "seasons_label": null,
962 "synopsis": "Die allseits beliebte, von D\u00e4monen besessene M\u00f6rderpuppe ist wieder da und verbreitet erneut Horror und Schrecken.",
967 "title": "Chucky 2 \u2013 Die M\u00f6rderpuppe ist wieder da",
974 season_info = self.parse_season_information_for_video(video=video)
979 'title': video['title'],
980 'synopsis': video['synopsis'],
981 'regular_synopsis': video['regularSynopsis'],
982 'type': video['summary']['type'],
983 'rating': video['userRating']['average'],
984 'episode_count': season_info['episode_count'],
985 'seasons_label': season_info['seasons_label'],
986 'seasons_count': season_info['seasons_count'],
987 'in_my_list': video['queue']['inQueue'],
988 'year': video['releaseYear'],
989 'runtime': self.parse_runtime_for_video(video=video),
990 'watched': video['watched'],
991 'tags': self.parse_tags_for_video(video=video),
992 'genres': self.parse_genres_for_video(video=video, genres=genres),
993 'quality': self.parse_quality_for_video(video=video),
994 'cast': self.parse_cast_for_video(video=video, persons=persons),
995 'directors': self.parse_directors_for_video(video=video, persons=persons),
996 'creators': self.parse_creators_for_video(video=video, persons=persons),
998 'board': None if 'board' not in video['maturity']['rating'].keys() else video['maturity']['rating']['board'],
999 'value': None if 'value' not in video['maturity']['rating'].keys() else video['maturity']['rating']['value'],
1000 'description': None if 'maturityDescription' not in video['maturity']['rating'].keys() else video['maturity']['rating']['maturityDescription'],
1001 'level': None if 'maturityLevel' not in video['maturity']['rating'].keys() else video['maturity']['rating']['maturityLevel']
1004 'small': video['boxarts']['_342x192']['jpg']['url'],
1005 'big': video['boxarts']['_1280x720']['jpg']['url']
1007 'interesting_moment': None if 'interestingMoment' not in video.keys() else video['interestingMoment']['_665x375']['jpg']['url'],
1008 'artwork': video['artWorkByType']['BILLBOARD']['_1280x720']['jpg']['url'],
1012 def parse_creators_for_video (self, video, persons):
1013 """Matches ids with person names to generate a list of creators
1017 video : :obj:`dict` of :obj:`str`
1018 Dictionary entry for one video entry
1020 persons : :obj:`dict` of :obj:`str`
1021 Raw resposne of all persons delivered by the API call
1025 :obj:`list` of :obj:`str`
1029 for person_key in dict(persons).keys():
1030 if self._is_size_key(key=person_key) == False and person_key != 'summary':
1031 for creator_key in dict(video['creators']).keys():
1032 if self._is_size_key(key=creator_key) == False and creator_key != 'summary':
1033 if video['creators'][creator_key][1] == person_key:
1034 creators.append(persons[person_key]['name'])
1037 def parse_directors_for_video (self, video, persons):
1038 """Matches ids with person names to generate a list of directors
1042 video : :obj:`dict` of :obj:`str`
1043 Dictionary entry for one video entry
1045 persons : :obj:`dict` of :obj:`str`
1046 Raw resposne of all persons delivered by the API call
1050 :obj:`list` of :obj:`str`
1054 for person_key in dict(persons).keys():
1055 if self._is_size_key(key=person_key) == False and person_key != 'summary':
1056 for director_key in dict(video['directors']).keys():
1057 if self._is_size_key(key=director_key) == False and director_key != 'summary':
1058 if video['directors'][director_key][1] == person_key:
1059 directors.append(persons[person_key]['name'])
1062 def parse_cast_for_video (self, video, persons):
1063 """Matches ids with person names to generate a list of cast members
1067 video : :obj:`dict` of :obj:`str`
1068 Dictionary entry for one video entry
1070 persons : :obj:`dict` of :obj:`str`
1071 Raw resposne of all persons delivered by the API call
1075 :obj:`list` of :obj:`str`
1076 List of cast members
1079 for person_key in dict(persons).keys():
1080 if self._is_size_key(key=person_key) == False and person_key != 'summary':
1081 for cast_key in dict(video['cast']).keys():
1082 if self._is_size_key(key=cast_key) == False and cast_key != 'summary':
1083 if video['cast'][cast_key][1] == person_key:
1084 cast.append(persons[person_key]['name'])
1087 def parse_genres_for_video (self, video, genres):
1088 """Matches ids with genre names to generate a list of genres for a video
1092 video : :obj:`dict` of :obj:`str`
1093 Dictionary entry for one video entry
1095 genres : :obj:`dict` of :obj:`str`
1096 Raw resposne of all genres delivered by the API call
1100 :obj:`list` of :obj:`str`
1104 for genre_key in dict(genres).keys():
1105 if self._is_size_key(key=genre_key) == False and genre_key != 'summary':
1106 for show_genre_key in dict(video['genres']).keys():
1107 if self._is_size_key(key=show_genre_key) == False and show_genre_key != 'summary':
1108 if video['genres'][show_genre_key][1] == genre_key:
1109 video_genres.append(genres[genre_key]['name'])
1112 def parse_tags_for_video (self, video):
1113 """Parses a nested list of tags, removes the not needed meta information & returns a raw string list
1117 video : :obj:`dict` of :obj:`str`
1118 Dictionary entry for one video entry
1122 :obj:`list` of :obj:`str`
1126 for tag_key in dict(video['tags']).keys():
1127 if self._is_size_key(key=tag_key) == False and tag_key != 'summary':
1128 tags.append(video['tags'][tag_key]['name'])
1131 def parse_season_information_for_video (self, video):
1132 """Checks if the fiven video is a show (series) and returns season & episode information
1136 video : :obj:`dict` of :obj:`str`
1137 Dictionary entry for one video entry
1141 :obj:`dict` of :obj:`str`
1142 Episode count / Season Count & Season label if given
1145 'episode_count': None,
1146 'seasons_label': None,
1147 'seasons_count': None
1149 if video['summary']['type'] == 'show':
1151 'episode_count': video['episodeCount'],
1152 'seasons_label': video['numSeasonsLabel'],
1153 'seasons_count': video['seasonCount']
1157 def parse_quality_for_video (self, video):
1158 """Transforms Netflix quality information in video resolution info
1162 video : :obj:`dict` of :obj:`str`
1163 Dictionary entry for one video entry
1168 Quality of the video
1171 if video['videoQuality']['hasHD']:
1173 if video['videoQuality']['hasUltraHD']:
1177 def parse_runtime_for_video (self, video):
1178 """Checks if the video is a movie & returns the runtime if given
1182 video : :obj:`dict` of :obj:`str`
1183 Dictionary entry for one video entry
1188 Runtime of the video (in seconds)
1191 if video['summary']['type'] != 'show':
1192 runtime = video['runtime']
1195 def parse_netflix_list_id (self, video_list):
1196 """Parse a video list and extract the list id
1200 video_list : :obj:`dict` of :obj:`str`
1205 entry : :obj:`str` or None
1208 netflix_list_id = None
1209 if 'lists' in video_list.keys():
1210 for video_id in video_list['lists']:
1211 if self._is_size_key(key=video_id) == False:
1212 netflix_list_id = video_id;
1213 return netflix_list_id
1215 def parse_show_information (self, id, response_data):
1216 """Parse extended show information (synopsis, seasons, etc.)
1223 response_data : :obj:`dict` of :obj:`str`
1224 Parsed response JSON from the `fetch_show_information` call
1228 entry : :obj:`dict` of :obj:`str`
1229 Show information in the format:
1231 "season_id": "80113084",
1232 "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."
1233 "detail_text": "I´m optional"
1237 raw_show = response_data['value']['videos'][id]
1238 show.update({'synopsis': raw_show['regularSynopsis']})
1239 if 'evidence' in raw_show:
1240 show.update({'detail_text': raw_show['evidence']['value']['text']})
1241 if 'seasonList' in raw_show:
1242 show.update({'season_id': raw_show['seasonList']['current'][1]})
1245 def parse_seasons (self, id, response_data):
1246 """Parse a list of seasons for a given show
1253 response_data : :obj:`dict` of :obj:`str`
1254 Parsed response JSON from the `fetch_seasons_for_show` call
1258 entry : :obj:`dict` of :obj:`dict` of :obj:`str`
1259 Season information in the format:
1264 "shortName": "St. 1",
1266 "big": "https://art-s.nflximg.net/5e7d3/b3b48749843fd3a36db11c319ffa60f96b55e7d3.jpg",
1267 "small": "https://art-s.nflximg.net/57543/a039845c2eb9186dc26019576d895bf5a1957543.jpg"
1269 "interesting_moment": "https://art-s.nflximg.net/09544/ed4b3073394b4469fb6ec22b9df81a4f5cb09544.jpg"
1274 "shortName": "St. 2",
1276 "big": "https://art-s.nflximg.net/5e7d3/b3b48749843fd3a36db11c319ffa60f96b55e7d3.jpg",
1277 "small": "https://art-s.nflximg.net/57543/a039845c2eb9186dc26019576d895bf5a1957543.jpg"
1279 "interesting_moment": "https://art-s.nflximg.net/09544/ed4b3073394b4469fb6ec22b9df81a4f5cb09544.jpg"
1284 raw_seasons = response_data['value']
1285 for season in raw_seasons['seasons']:
1286 if self._is_size_key(key=season) == False:
1287 seasons.update(self.parse_season_entry(season=raw_seasons['seasons'][season], videos=raw_seasons['videos']))
1290 def parse_season_entry (self, season, videos):
1291 """Parse a season list entry e.g. rip out the parts we need
1295 season : :obj:`dict` of :obj:`str`
1296 Season entry from the `fetch_seasons_for_show` call
1300 entry : :obj:`dict` of :obj:`dict` of :obj:`str`
1301 Season list entry in the format:
1307 "shortName": "St. 1",
1309 "big": "https://art-s.nflximg.net/5e7d3/b3b48749843fd3a36db11c319ffa60f96b55e7d3.jpg",
1310 "small": "https://art-s.nflximg.net/57543/a039845c2eb9186dc26019576d895bf5a1957543.jpg"
1312 "interesting_moment": "https://art-s.nflximg.net/09544/ed4b3073394b4469fb6ec22b9df81a4f5cb09544.jpg"
1318 for key in videos.keys():
1319 if self._is_size_key(key=key) == False:
1323 for idx in videos[video_key]['seasonList']:
1324 if self._is_size_key(key=idx) == False and idx != 'summary':
1325 sorting[int(videos[video_key]['seasonList'][idx][1])] = int(idx)
1327 season['summary']['id']: {
1328 'idx': sorting[season['summary']['id']],
1329 'id': season['summary']['id'],
1330 'text': season['summary']['name'],
1331 'shortName': season['summary']['shortName'],
1333 'small': videos[video_key]['boxarts']['_342x192']['jpg']['url'],
1334 'big': videos[video_key]['boxarts']['_1280x720']['jpg']['url']
1336 'interesting_moment': videos[video_key]['interestingMoment']['_665x375']['jpg']['url'],
1340 def parse_episodes_by_season (self, response_data):
1341 """Parse episodes for a given season/episode list
1345 response_data : :obj:`dict` of :obj:`str`
1346 Parsed response JSON from the `fetch_seasons_for_show` call
1350 entry : :obj:`dict` of :obj:`dict` of :obj:`str`
1351 Season information in the format:
1355 "banner": "https://art-s.nflximg.net/63a36/c7fdfe6604ef2c22d085ac5dca5f69874e363a36.jpg",
1358 "fanart": "https://art-s.nflximg.net/74e02/e7edcc5cc7dcda1e94d505df2f0a2f0d22774e02.jpg",
1364 "mediatype": "episode",
1368 "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.",
1369 "poster": "https://art-s.nflximg.net/72fd6/57088715e8d436fdb6986834ab39124b0a972fd6.jpg",
1370 "rating": 3.9111512,
1372 "thumb": "https://art-s.nflximg.net/be686/07680670a68da8749eba607efb1ae37f9e3be686.jpg",
1373 "title": "Und dann gab es weniger (Teil 1)",
1378 "banner": "https://art-s.nflximg.net/63a36/c7fdfe6604ef2c22d085ac5dca5f69874e363a36.jpg",
1381 "fanart": "https://art-s.nflximg.net/c472c/6c10f9578bf2c1d0a183c2ccb382931efcbc472c.jpg",
1387 "mediatype": "episode",
1391 "plot": "Wer ist der M\u00f6rder? Nach zahlreichen Morden wird immer wieder jemand anderes verd\u00e4chtigt.",
1392 "poster": "https://art-s.nflximg.net/72fd6/57088715e8d436fdb6986834ab39124b0a972fd6.jpg",
1393 "rating": 3.9111512,
1395 "thumb": "https://art-s.nflximg.net/15a08/857d59126641987bec302bb147a802a00d015a08.jpg",
1396 "title": "Und dann gab es weniger (Teil 2)",
1403 raw_episodes = response_data['value']['videos']
1404 for episode_id in raw_episodes:
1405 if self._is_size_key(key=episode_id) == False:
1406 if (raw_episodes[episode_id]['summary']['type'] == 'episode'):
1407 episodes.update(self.parse_episode(episode=raw_episodes[episode_id], genres=response_data['value']['genres']))
1410 def parse_episode (self, episode, genres=None):
1411 """Parse episode from an list of episodes by season
1415 episode : :obj:`dict` of :obj:`str`
1416 Episode entry from the `fetch_episodes_by_season` call
1420 entry : :obj:`dict` of :obj:`dict` of :obj:`str`
1421 Episode information in the format:
1425 "banner": "https://art-s.nflximg.net/63a36/c7fdfe6604ef2c22d085ac5dca5f69874e363a36.jpg",
1428 "fanart": "https://art-s.nflximg.net/74e02/e7edcc5cc7dcda1e94d505df2f0a2f0d22774e02.jpg",
1434 "mediatype": "episode",
1438 "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.",
1439 "poster": "https://art-s.nflximg.net/72fd6/57088715e8d436fdb6986834ab39124b0a972fd6.jpg",
1440 "rating": 3.9111512,
1442 "thumb": "https://art-s.nflximg.net/be686/07680670a68da8749eba607efb1ae37f9e3be686.jpg",
1443 "title": "Und dann gab es weniger (Teil 1)",
1450 episode['summary']['id']: {
1451 'id': episode['summary']['id'],
1452 'episode': episode['summary']['episode'],
1453 'season': episode['summary']['season'],
1454 'plot': episode['info']['synopsis'],
1455 'duration': episode['info']['runtime'],
1456 'title': episode['info']['title'],
1457 'year': episode['info']['releaseYear'],
1458 'genres': self.parse_genres_for_video(video=episode, genres=genres),
1459 'mpaa': str(episode['maturity']['rating']['board']) + ' ' + str(episode['maturity']['rating']['value']),
1460 'maturity': episode['maturity'],
1461 'playcount': (0, 1)[episode['watched']],
1462 'rating': episode['userRating']['average'],
1463 'thumb': episode['info']['interestingMoments']['url'],
1464 'fanart': episode['interestingMoment']['_1280x720']['jpg']['url'],
1465 'poster': episode['boxarts']['_1280x720']['jpg']['url'],
1466 'banner': episode['boxarts']['_342x192']['jpg']['url'],
1467 'mediatype': {'episode': 'episode', 'movie': 'movie'}[episode['summary']['type']],
1468 'my_list': episode['queue']['inQueue'],
1469 'bookmark': episode['bookmarkPosition']
1473 def fetch_browse_list_contents (self):
1474 """Fetches the HTML data for the lists on the landing page (browse page) of Netflix
1478 :obj:`BeautifulSoup`
1479 Instance of an BeautifulSoup document containing the complete page contents
1481 response = self.session.get(self._get_document_url_for(component='browse'), verify=self.verify_ssl)
1482 return BeautifulSoup(response.text, 'html.parser')
1484 def fetch_video_list_ids (self, list_from=0, list_to=50):
1485 """Fetches the JSON with detailed information based on the lists on the landing page (browse page) of Netflix
1489 list_from : :obj:`int`
1490 Start entry for pagination
1492 list_to : :obj:`int`
1493 Last entry for pagination
1497 :obj:`dict` of :obj:`dict` of :obj:`str`
1498 Raw Netflix API call response or api call error
1501 'fromRow': list_from,
1503 'opaqueImageExtension': 'jpg',
1504 'transparentImageExtension': 'png',
1505 '_': int(time.time()),
1506 'authURL': self.user_data['authURL']
1508 url = self._get_api_url_for(component='video_list_ids')
1509 response = self.session.get(url, params=payload, verify=self.verify_ssl);
1510 return self._process_response(response=response, component=url)
1512 def fetch_search_results (self, search_str, list_from=0, list_to=10):
1513 """Fetches the JSON which contains the results for the given search query
1517 search_str : :obj:`str`
1518 String to query Netflix search for
1520 list_from : :obj:`int`
1521 Start entry for pagination
1523 list_to : :obj:`int`
1524 Last entry for pagination
1528 :obj:`dict` of :obj:`dict` of :obj:`str`
1529 Raw Netflix API call response or api call error
1531 # properly encode the search string
1532 encoded_search_string = urllib.quote(search_str)
1535 ['search', encoded_search_string, 'titles', {'from': list_from, 'to': list_to}, ['summary', 'title']],
1536 ['search', encoded_search_string, 'titles', {'from': list_from, 'to': list_to}, 'boxarts', '_342x192', 'jpg'],
1537 ['search', encoded_search_string, 'titles', ['id', 'length', 'name', 'trackIds', 'requestId']],
1538 ['search', encoded_search_string, 'suggestions', 0, 'relatedvideos', {'from': list_from, 'to': list_to}, ['summary', 'title']],
1539 ['search', encoded_search_string, 'suggestions', 0, 'relatedvideos', {'from': list_from, 'to': list_to}, 'boxarts', '_342x192', 'jpg'],
1540 ['search', encoded_search_string, 'suggestions', 0, 'relatedvideos', ['id', 'length', 'name', 'trackIds', 'requestId']]
1542 response = self._path_request(paths=paths)
1543 return self._process_response(response=response, component='Search results')
1545 def fetch_video_list (self, list_id, list_from=0, list_to=20):
1546 """Fetches the JSON which contains the contents of a given video list
1550 list_id : :obj:`str`
1551 Unique list id to query Netflix for
1553 list_from : :obj:`int`
1554 Start entry for pagination
1556 list_to : :obj:`int`
1557 Last entry for pagination
1561 :obj:`dict` of :obj:`dict` of :obj:`str`
1562 Raw Netflix API call response or api call error
1565 ['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']],
1566 ['lists', list_id, {'from': list_from, 'to': list_to}, 'cast', {'from': 0, 'to': 15}, ['id', 'name']],
1567 ['lists', list_id, {'from': list_from, 'to': list_to}, 'cast', 'summary'],
1568 ['lists', list_id, {'from': list_from, 'to': list_to}, 'genres', {'from': 0, 'to': 5}, ['id', 'name']],
1569 ['lists', list_id, {'from': list_from, 'to': list_to}, 'genres', 'summary'],
1570 ['lists', list_id, {'from': list_from, 'to': list_to}, 'tags', {'from': 0, 'to': 9}, ['id', 'name']],
1571 ['lists', list_id, {'from': list_from, 'to': list_to}, 'tags', 'summary'],
1572 ['lists', list_id, {'from': list_from, 'to': list_to}, ['creators', 'directors'], {'from': 0, 'to': 49}, ['id', 'name']],
1573 ['lists', list_id, {'from': list_from, 'to': list_to}, ['creators', 'directors'], 'summary'],
1574 ['lists', list_id, {'from': list_from, 'to': list_to}, 'bb2OGLogo', '_400x90', 'png'],
1575 ['lists', list_id, {'from': list_from, 'to': list_to}, 'boxarts', '_342x192', 'jpg'],
1576 ['lists', list_id, {'from': list_from, 'to': list_to}, 'boxarts', '_1280x720', 'jpg'],
1577 ['lists', list_id, {'from': list_from, 'to': list_to}, 'storyarts', '_1632x873', 'jpg'],
1578 ['lists', list_id, {'from': list_from, 'to': list_to}, 'interestingMoment', '_665x375', 'jpg'],
1579 ['lists', list_id, {'from': list_from, 'to': list_to}, 'artWorkByType', 'BILLBOARD', '_1280x720', 'jpg']
1582 response = self._path_request(paths=paths)
1583 return self._process_response(response=response, component='Video list')
1585 def fetch_video_list_information (self, video_ids):
1586 """Fetches the JSON which contains the detail information of a list of given video ids
1590 video_ids : :obj:`list` of :obj:`str`
1591 List of video ids to fetch detail data for
1595 :obj:`dict` of :obj:`dict` of :obj:`str`
1596 Raw Netflix API call response or api call error
1599 for video_id in video_ids:
1600 paths.append(['videos', video_id, ['summary', 'title', 'synopsis', 'regularSynopsis', 'evidence', 'queue', 'episodeCount', 'info', 'maturity', 'runtime', 'seasonCount', 'releaseYear', 'userRating', 'numSeasonsLabel', 'bookmarkPosition', 'watched', 'videoQuality']])
1601 paths.append(['videos', video_id, 'cast', {'from': 0, 'to': 15}, ['id', 'name']])
1602 paths.append(['videos', video_id, 'cast', 'summary'])
1603 paths.append(['videos', video_id, 'genres', {'from': 0, 'to': 5}, ['id', 'name']])
1604 paths.append(['videos', video_id, 'genres', 'summary'])
1605 paths.append(['videos', video_id, 'tags', {'from': 0, 'to': 9}, ['id', 'name']])
1606 paths.append(['videos', video_id, 'tags', 'summary'])
1607 paths.append(['videos', video_id, ['creators', 'directors'], {'from': 0, 'to': 49}, ['id', 'name']])
1608 paths.append(['videos', video_id, ['creators', 'directors'], 'summary'])
1609 paths.append(['videos', video_id, 'bb2OGLogo', '_400x90', 'png'])
1610 paths.append(['videos', video_id, 'boxarts', '_342x192', 'jpg'])
1611 paths.append(['videos', video_id, 'boxarts', '_1280x720', 'jpg'])
1612 paths.append(['videos', video_id, 'storyarts', '_1632x873', 'jpg'])
1613 paths.append(['videos', video_id, 'interestingMoment', '_665x375', 'jpg'])
1614 paths.append(['videos', video_id, 'artWorkByType', 'BILLBOARD', '_1280x720', 'jpg'])
1616 response = self._path_request(paths=paths)
1617 return self._process_response(response=response, component='fetch_video_list_information')
1619 def fetch_metadata (self, id):
1620 """Fetches the JSON which contains the metadata for a given show/movie or season id
1625 Show id, movie id or season id
1629 :obj:`dict` of :obj:`dict` of :obj:`str`
1630 Raw Netflix API call response or api call error
1634 'imageformat': 'jpg',
1635 '_': int(time.time())
1637 url = self._get_api_url_for(component='metadata')
1638 response = self.session.get(url, params=payload, verify=self.verify_ssl);
1639 return self._process_response(response=response, component=url)
1641 def fetch_show_information (self, id, type):
1642 """Fetches the JSON which contains the detailed contents of a show
1647 Unique show id to query Netflix for
1650 Can be 'movie' or 'show'
1654 :obj:`dict` of :obj:`dict` of :obj:`str`
1655 Raw Netflix API call response or api call error
1657 # check if we have a show or a movie, the request made depends on this
1660 ['videos', id, ['requestId', 'regularSynopsis', 'evidence']],
1661 ['videos', id, 'seasonList', 'current', 'summary']
1664 paths = [['videos', id, ['requestId', 'regularSynopsis', 'evidence']]]
1665 response = self._path_request(paths=paths)
1666 return self._process_response(response=response, component='Show information')
1668 def fetch_seasons_for_show (self, id, list_from=0, list_to=30):
1669 """Fetches the JSON which contains the seasons of a given show
1674 Unique show id to query Netflix for
1676 list_from : :obj:`int`
1677 Start entry for pagination
1679 list_to : :obj:`int`
1680 Last entry for pagination
1684 :obj:`dict` of :obj:`dict` of :obj:`str`
1685 Raw Netflix API call response or api call error
1688 ['videos', id, 'seasonList', {'from': list_from, 'to': list_to}, 'summary'],
1689 ['videos', id, 'seasonList', 'summary'],
1690 ['videos', id, 'boxarts', '_342x192', 'jpg'],
1691 ['videos', id, 'boxarts', '_1280x720', 'jpg'],
1692 ['videos', id, 'storyarts', '_1632x873', 'jpg'],
1693 ['videos', id, 'interestingMoment', '_665x375', 'jpg']
1695 response = self._path_request(paths=paths)
1696 return self._process_response(response=response, component='Seasons')
1698 def fetch_episodes_by_season (self, season_id, list_from=-1, list_to=40):
1699 """Fetches the JSON which contains the episodes of a given season
1701 TODO: Add more metadata
1705 season_id : :obj:`str`
1706 Unique season_id id to query Netflix for
1708 list_from : :obj:`int`
1709 Start entry for pagination
1711 list_to : :obj:`int`
1712 Last entry for pagination
1716 :obj:`dict` of :obj:`dict` of :obj:`str`
1717 Raw Netflix API call response or api call error
1720 ['seasons', season_id, 'episodes', {'from': list_from, 'to': list_to}, ['summary', 'queue', 'info', 'maturity', 'userRating', 'bookmarkPosition', 'creditOffset', 'watched', 'videoQuality']],
1721 #['videos', season_id, 'cast', {'from': 0, 'to': 15}, ['id', 'name']],
1722 #['videos', season_id, 'cast', 'summary'],
1723 #['videos', season_id, 'genres', {'from': 0, 'to': 5}, ['id', 'name']],
1724 #['videos', season_id, 'genres', 'summary'],
1725 #['videos', season_id, 'tags', {'from': 0, 'to': 9}, ['id', 'name']],
1726 #['videos', season_id, 'tags', 'summary'],
1727 #['videos', season_id, ['creators', 'directors'], {'from': 0, 'to': 49}, ['id', 'name']],
1728 #['videos', season_id, ['creators', 'directors'], 'summary'],
1729 ['seasons', season_id, 'episodes', {'from': list_from, 'to': list_to}, 'genres', {'from': 0, 'to': 1}, ['id', 'name']],
1730 ['seasons', season_id, 'episodes', {'from': list_from, 'to': list_to}, 'genres', 'summary'],
1731 ['seasons', season_id, 'episodes', {'from': list_from, 'to': list_to}, 'interestingMoment', '_1280x720', 'jpg'],
1732 ['seasons', season_id, 'episodes', {'from': list_from, 'to': list_to}, 'interestingMoment', '_665x375', 'jpg'],
1733 ['seasons', season_id, 'episodes', {'from': list_from, 'to': list_to}, 'boxarts', '_342x192', 'jpg'],
1734 ['seasons', season_id, 'episodes', {'from': list_from, 'to': list_to}, 'boxarts', '_1280x720', 'jpg']
1736 response = self._path_request(paths=paths)
1737 return self._process_response(response=response, component='fetch_episodes_by_season')
1739 def refresh_session_data (self, account):
1740 """Reload the session data (profiles, user_data, api_data)
1744 account : :obj:`dict` of :obj:`str`
1745 Dict containing an email, country & a password property
1747 # load the profiles page (to verify the user)
1748 response = self.session.get(self._get_document_url_for(component='profiles'), verify=self.verify_ssl)
1749 # parse out the needed inline information
1750 only_script_tags = SoupStrainer('script')
1751 page_soup = BeautifulSoup(response.text, 'html.parser', parse_only=only_script_tags)
1752 page_data = self._parse_page_contents(page_soup=page_soup)
1753 account_hash = self._generate_account_hash(account=account)
1754 self._save_data(filename=self.data_path + '_' + account_hash)
1756 def _path_request (self, paths):
1757 """Executes a post request against the shakti endpoint with Falcor style payload
1761 paths : :obj:`list` of :obj:`list`
1762 Payload with path querys for the Netflix Shakti API in Falcor style
1766 :obj:`requests.response`
1767 Response from a POST call made with Requests
1770 'Content-Type': 'application/json',
1771 'Accept': 'application/json, text/javascript, */*',
1776 'authURL': self.user_data['authURL']
1781 'materialize': True,
1782 'model': self.user_data['gpsModel']
1785 return self.session.post(self._get_api_url_for(component='shakti'), params=params, headers=headers, data=data, verify=self.verify_ssl)
1787 def _is_size_key (self, key):
1788 """Tiny helper that checks if a given key is called $size or size, as we need to check this often
1793 Key to check the value for
1798 Key has a size value or not
1800 return key == '$size' or key == 'size'
1802 def _get_api_url_for (self, component):
1803 """Tiny helper that builds the url for a requested API endpoint component
1807 component : :obj:`str`
1808 Component endpoint to build the URL for
1815 return self.api_data['API_ROOT'] + self.api_data['API_BASE_URL'] + '/' + self.api_data['BUILD_IDENTIFIER'] + self.urls[component]
1817 def _get_document_url_for (self, component):
1818 """Tiny helper that builds the url for a requested document endpoint component
1822 component : :obj:`str`
1823 Component endpoint to build the URL for
1830 return self.base_url + self.urls[component]
1832 def _process_response (self, response, component):
1833 """Tiny helper to check responses for API requests
1837 response : :obj:`requests.response`
1838 Response from a requests instance
1840 component : :obj:`str`
1845 :obj:`dict` of :obj:`dict` of :obj:`str` or :obj:`dict` of :obj:`str`
1846 Raw Netflix API call response or api call error
1848 # check if we´re not authorized to make thios call
1849 if response.status_code == 401:
1852 'message': 'Session invalid',
1855 # check if somethign else failed
1856 if response.status_code != 200:
1859 'message': 'API call for "' + component + '" failed',
1860 'code': response.status_code
1862 # return the parsed response & everything´s fine
1863 return response.json()
1865 def _to_unicode(self, str):
1866 '''Attempt to fix non uft-8 string into utf-8, using a limited set of encodings
1878 # fuller list of encodings at http://docs.python.org/library/codecs.html#standard-encodings
1879 if not str: return u''
1881 # we could add more encodings here, as warranted.
1882 encodings = ('ascii', 'utf8', 'latin1')
1883 for enc in encodings:
1886 u = unicode(str,enc)
1887 except UnicodeDecodeError:
1890 u = unicode(str, errors='replace')
1893 def _update_my_list (self, video_id, operation):
1894 """Tiny helper to add & remove items from "my list"
1898 video_id : :obj:`str`
1899 ID of the show/movie to be added
1901 operation : :obj:`str`
1902 Either "add" or "remove"
1907 Operation successfull
1910 'Content-Type': 'application/json',
1911 'Accept': 'application/json, text/javascript, */*',
1914 payload = json.dumps({
1915 'operation': operation,
1916 'videoId': int(video_id),
1917 'authURL': self.user_data['authURL']
1920 response = self.session.post(self._get_api_url_for(component='update_my_list'), headers=headers, data=payload, verify=self.verify_ssl)
1921 return response.status_code == 200
1923 def _save_data(self, filename):
1924 """Tiny helper that stores session data from the session in a given file
1928 filename : :obj:`str`
1929 Complete path incl. filename that determines where to store the cookie
1934 Storage procedure was successfull
1936 if not os.path.isdir(os.path.dirname(filename)):
1938 with open(filename, 'w') as f:
1941 'user_data': self.user_data,
1942 'api_data': self.api_data,
1943 'profiles': self.profiles
1946 def _load_data(self, filename):
1947 """Tiny helper that loads session data into the active session from a given file
1951 filename : :obj:`str`
1952 Complete path incl. filename that determines where to load the data from
1957 Load procedure was successfull
1959 if not os.path.isfile(filename):
1962 with open(filename) as f:
1963 data = pickle.load(f)
1965 self.profiles = data['profiles']
1966 self.user_data = data['user_data']
1967 self.api_data = data['api_data']
1971 def _delete_data (self, path):
1972 """Tiny helper that deletes session data
1976 filename : :obj:`str`
1977 Complete path incl. filename that determines where to delete the files
1980 head, tail = os.path.split(path)
1981 for subdir, dirs, files in os.walk(head):
1984 os.remove(os.path.join(subdir, file))
1986 def _save_cookies(self, filename):
1987 """Tiny helper that stores cookies from the session in a given file
1991 filename : :obj:`str`
1992 Complete path incl. filename that determines where to store the cookie
1997 Storage procedure was successfull
1999 if not os.path.isdir(os.path.dirname(filename)):
2001 with open(filename, 'w') as f:
2003 pickle.dump(self.session.cookies._cookies, f)
2005 def _load_cookies(self, filename):
2006 """Tiny helper that loads cookies into the active session from a given file
2010 filename : :obj:`str`
2011 Complete path incl. filename that determines where to load the cookie from
2016 Load procedure was successfull
2018 if not os.path.isfile(filename):
2021 with open(filename) as f:
2022 cookies = pickle.load(f)
2024 jar = requests.cookies.RequestsCookieJar()
2025 jar._cookies = cookies
2026 self.session.cookies = jar
2030 def _delete_cookies (self, path):
2031 """Tiny helper that deletes cookie data
2035 filename : :obj:`str`
2036 Complete path incl. filename that determines where to delete the files
2039 head, tail = os.path.split(path)
2040 for subdir, dirs, files in os.walk(head):
2043 os.remove(os.path.join(subdir, file))
2045 def _generate_account_hash (self, account):
2046 """Generates a has for the given account (used for cookie verification)
2050 account : :obj:`dict` of :obj:`str`
2051 Dict containing an email, country & a password property
2058 return base64.urlsafe_b64encode(account['email'])