fox.py 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174
  1. import json
  2. import uuid
  3. from .common import InfoExtractor
  4. from ..compat import (
  5. compat_HTTPError,
  6. compat_str,
  7. compat_urllib_parse_unquote,
  8. )
  9. from ..utils import (
  10. ExtractorError,
  11. int_or_none,
  12. parse_age_limit,
  13. parse_duration,
  14. traverse_obj,
  15. try_get,
  16. unified_timestamp,
  17. url_or_none,
  18. )
  19. class FOXIE(InfoExtractor):
  20. _VALID_URL = r'https?://(?:www\.)?fox\.com/watch/(?P<id>[\da-fA-F]+)'
  21. _TESTS = [{
  22. # clip
  23. 'url': 'https://www.fox.com/watch/4b765a60490325103ea69888fb2bd4e8/',
  24. 'md5': 'ebd296fcc41dd4b19f8115d8461a3165',
  25. 'info_dict': {
  26. 'id': '4b765a60490325103ea69888fb2bd4e8',
  27. 'ext': 'mp4',
  28. 'title': 'Aftermath: Bruce Wayne Develops Into The Dark Knight',
  29. 'description': 'md5:549cd9c70d413adb32ce2a779b53b486',
  30. 'duration': 102,
  31. 'timestamp': 1504291893,
  32. 'upload_date': '20170901',
  33. 'creator': 'FOX',
  34. 'series': 'Gotham',
  35. 'age_limit': 14,
  36. 'episode': 'Aftermath: Bruce Wayne Develops Into The Dark Knight',
  37. 'thumbnail': r're:^https?://.*\.jpg$',
  38. },
  39. 'params': {
  40. 'skip_download': True,
  41. },
  42. }, {
  43. # episode, geo-restricted
  44. 'url': 'https://www.fox.com/watch/087036ca7f33c8eb79b08152b4dd75c1/',
  45. 'only_matching': True,
  46. }, {
  47. # sports event, geo-restricted
  48. 'url': 'https://www.fox.com/watch/b057484dade738d1f373b3e46216fa2c/',
  49. 'only_matching': True,
  50. }]
  51. _GEO_BYPASS = False
  52. _HOME_PAGE_URL = 'https://www.fox.com/'
  53. _API_KEY = '6E9S4bmcoNnZwVLOHywOv8PJEdu76cM9'
  54. _access_token = None
  55. _device_id = compat_str(uuid.uuid4())
  56. def _call_api(self, path, video_id, data=None):
  57. headers = {
  58. 'X-Api-Key': self._API_KEY,
  59. }
  60. if self._access_token:
  61. headers['Authorization'] = 'Bearer ' + self._access_token
  62. try:
  63. return self._download_json(
  64. 'https://api3.fox.com/v2.0/' + path,
  65. video_id, data=data, headers=headers)
  66. except ExtractorError as e:
  67. if isinstance(e.cause, compat_HTTPError) and e.cause.code == 403:
  68. entitlement_issues = self._parse_json(
  69. e.cause.read().decode(), video_id)['entitlementIssues']
  70. for e in entitlement_issues:
  71. if e.get('errorCode') == 1005:
  72. raise ExtractorError(
  73. 'This video is only available via cable service provider '
  74. 'subscription. You may want to use --cookies.', expected=True)
  75. messages = ', '.join([e['message'] for e in entitlement_issues])
  76. raise ExtractorError(messages, expected=True)
  77. raise
  78. def _real_initialize(self):
  79. if not self._access_token:
  80. mvpd_auth = self._get_cookies(self._HOME_PAGE_URL).get('mvpd-auth')
  81. if mvpd_auth:
  82. self._access_token = (self._parse_json(compat_urllib_parse_unquote(
  83. mvpd_auth.value), None, fatal=False) or {}).get('accessToken')
  84. if not self._access_token:
  85. self._access_token = self._call_api(
  86. 'login', None, json.dumps({
  87. 'deviceId': self._device_id,
  88. }).encode())['accessToken']
  89. def _real_extract(self, url):
  90. video_id = self._match_id(url)
  91. self._access_token = self._call_api(
  92. 'previewpassmvpd?device_id=%s&mvpd_id=TempPass_fbcfox_60min' % self._device_id,
  93. video_id)['accessToken']
  94. video = self._call_api('watch', video_id, data=json.dumps({
  95. 'capabilities': ['drm/widevine', 'fsdk/yo'],
  96. 'deviceWidth': 1280,
  97. 'deviceHeight': 720,
  98. 'maxRes': '720p',
  99. 'os': 'macos',
  100. 'osv': '',
  101. 'provider': {
  102. 'freewheel': {'did': self._device_id},
  103. 'vdms': {'rays': ''},
  104. 'dmp': {'kuid': '', 'seg': ''}
  105. },
  106. 'playlist': '',
  107. 'privacy': {'us': '1---'},
  108. 'siteSection': '',
  109. 'streamType': 'vod',
  110. 'streamId': video_id}).encode('utf-8'))
  111. title = video['name']
  112. release_url = video['url']
  113. try:
  114. m3u8_url = self._download_json(release_url, video_id)['playURL']
  115. except ExtractorError as e:
  116. if isinstance(e.cause, compat_HTTPError) and e.cause.code == 403:
  117. error = self._parse_json(e.cause.read().decode(), video_id)
  118. if error.get('exception') == 'GeoLocationBlocked':
  119. self.raise_geo_restricted(countries=['US'])
  120. raise ExtractorError(error['description'], expected=True)
  121. raise
  122. formats = self._extract_m3u8_formats(
  123. m3u8_url, video_id, 'mp4',
  124. entry_protocol='m3u8_native', m3u8_id='hls')
  125. data = try_get(
  126. video, lambda x: x['trackingData']['properties'], dict) or {}
  127. duration = int_or_none(video.get('durationInSeconds')) or int_or_none(
  128. video.get('duration')) or parse_duration(video.get('duration'))
  129. timestamp = unified_timestamp(video.get('datePublished'))
  130. creator = data.get('brand') or data.get('network') or video.get('network')
  131. series = video.get('seriesName') or data.get(
  132. 'seriesName') or data.get('show')
  133. subtitles = {}
  134. for doc_rel in video.get('documentReleases', []):
  135. rel_url = doc_rel.get('url')
  136. if not url or doc_rel.get('format') != 'SCC':
  137. continue
  138. subtitles['en'] = [{
  139. 'url': rel_url,
  140. 'ext': 'scc',
  141. }]
  142. break
  143. return {
  144. 'id': video_id,
  145. 'title': title,
  146. 'formats': formats,
  147. 'description': video.get('description'),
  148. 'duration': duration,
  149. 'timestamp': timestamp,
  150. 'age_limit': parse_age_limit(video.get('contentRating')),
  151. 'creator': creator,
  152. 'series': series,
  153. 'season_number': int_or_none(video.get('seasonNumber')),
  154. 'episode': video.get('name'),
  155. 'episode_number': int_or_none(video.get('episodeNumber')),
  156. 'thumbnail': traverse_obj(video, ('images', 'still', 'raw'), expected_type=url_or_none),
  157. 'release_year': int_or_none(video.get('releaseYear')),
  158. 'subtitles': subtitles,
  159. }