fox.py 6.6 KB

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