peloton.py 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215
  1. import json
  2. import re
  3. import urllib.parse
  4. from .common import InfoExtractor
  5. from ..networking.exceptions import HTTPError
  6. from ..utils import (
  7. ExtractorError,
  8. float_or_none,
  9. str_or_none,
  10. traverse_obj,
  11. url_or_none,
  12. )
  13. class PelotonIE(InfoExtractor):
  14. IE_NAME = 'peloton'
  15. _NETRC_MACHINE = 'peloton'
  16. _VALID_URL = r'https?://members\.onepeloton\.com/classes/player/(?P<id>[a-f0-9]+)'
  17. _TESTS = [{
  18. 'url': 'https://members.onepeloton.com/classes/player/0e9653eb53544eeb881298c8d7a87b86',
  19. 'info_dict': {
  20. 'id': '0e9653eb53544eeb881298c8d7a87b86',
  21. 'title': '20 min Chest & Back Strength',
  22. 'ext': 'mp4',
  23. 'thumbnail': r're:^https?://.+\.jpg',
  24. 'description': 'md5:fcd5be9b9eda0194b470e13219050a66',
  25. 'creator': 'Chase Tucker',
  26. 'release_timestamp': 1556141400,
  27. 'timestamp': 1556141400,
  28. 'upload_date': '20190424',
  29. 'duration': 1389,
  30. 'categories': ['Strength'],
  31. 'tags': ['Workout Mat', 'Light Weights', 'Medium Weights'],
  32. 'is_live': False,
  33. 'chapters': 'count:1',
  34. 'subtitles': {'en': [{
  35. 'url': r're:^https?://.+',
  36. 'ext': 'vtt',
  37. }]},
  38. }, 'params': {
  39. 'skip_download': 'm3u8',
  40. },
  41. 'skip': 'Account needed',
  42. }, {
  43. 'url': 'https://members.onepeloton.com/classes/player/26603d53d6bb4de1b340514864a6a6a8',
  44. 'info_dict': {
  45. 'id': '26603d53d6bb4de1b340514864a6a6a8',
  46. 'title': '30 min Earth Day Run',
  47. 'ext': 'm4a',
  48. 'thumbnail': r're:https://.+\.jpg',
  49. 'description': 'md5:adc065a073934d7ee0475d217afe0c3d',
  50. 'creator': 'Selena Samuela',
  51. 'release_timestamp': 1587567600,
  52. 'timestamp': 1587567600,
  53. 'upload_date': '20200422',
  54. 'duration': 1802,
  55. 'categories': ['Running'],
  56. 'is_live': False,
  57. 'chapters': 'count:3',
  58. }, 'params': {
  59. 'skip_download': 'm3u8',
  60. },
  61. 'skip': 'Account needed',
  62. }]
  63. _MANIFEST_URL_TEMPLATE = '%s?hdnea=%s'
  64. def _start_session(self, video_id):
  65. self._download_webpage('https://api.onepeloton.com/api/started_client_session', video_id, note='Starting session')
  66. def _login(self, video_id):
  67. username, password = self._get_login_info()
  68. if not (username and password):
  69. self.raise_login_required()
  70. try:
  71. self._download_json(
  72. 'https://api.onepeloton.com/auth/login', video_id, note='Logging in',
  73. data=json.dumps({
  74. 'username_or_email': username,
  75. 'password': password,
  76. 'with_pubsub': False,
  77. }).encode(),
  78. headers={'Content-Type': 'application/json', 'User-Agent': 'web'})
  79. except ExtractorError as e:
  80. if isinstance(e.cause, HTTPError) and e.cause.status == 401:
  81. json_string = self._webpage_read_content(e.cause.response, None, video_id)
  82. res = self._parse_json(json_string, video_id)
  83. raise ExtractorError(res['message'], expected=res['message'] == 'Login failed')
  84. else:
  85. raise
  86. def _get_token(self, video_id):
  87. try:
  88. subscription = self._download_json(
  89. 'https://api.onepeloton.com/api/subscription/stream', video_id, note='Downloading token',
  90. data=json.dumps({}).encode(), headers={'Content-Type': 'application/json'})
  91. except ExtractorError as e:
  92. if isinstance(e.cause, HTTPError) and e.cause.status == 403:
  93. json_string = self._webpage_read_content(e.cause.response, None, video_id)
  94. res = self._parse_json(json_string, video_id)
  95. raise ExtractorError(res['message'], expected=res['message'] == 'Stream limit reached')
  96. else:
  97. raise
  98. return subscription['token']
  99. def _real_extract(self, url):
  100. video_id = self._match_id(url)
  101. try:
  102. self._start_session(video_id)
  103. except ExtractorError as e:
  104. if isinstance(e.cause, HTTPError) and e.cause.status == 401:
  105. self._login(video_id)
  106. self._start_session(video_id)
  107. else:
  108. raise
  109. metadata = self._download_json(f'https://api.onepeloton.com/api/ride/{video_id}/details?stream_source=multichannel', video_id)
  110. ride_data = metadata.get('ride')
  111. if not ride_data:
  112. raise ExtractorError('Missing stream metadata')
  113. token = self._get_token(video_id)
  114. is_live = False
  115. if ride_data.get('content_format') == 'audio':
  116. url = self._MANIFEST_URL_TEMPLATE % (ride_data.get('vod_stream_url'), urllib.parse.quote(token))
  117. formats = [{
  118. 'url': url,
  119. 'ext': 'm4a',
  120. 'format_id': 'audio',
  121. 'vcodec': 'none',
  122. }]
  123. subtitles = {}
  124. else:
  125. if ride_data.get('vod_stream_url'):
  126. url = 'https://members.onepeloton.com/.netlify/functions/m3u8-proxy?displayLanguage=en&acceptedSubtitles={}&url={}?hdnea={}'.format(
  127. ','.join([re.sub('^([a-z]+)-([A-Z]+)$', r'\1', caption) for caption in ride_data['captions']]),
  128. ride_data['vod_stream_url'],
  129. urllib.parse.quote(urllib.parse.quote(token)))
  130. elif ride_data.get('live_stream_url'):
  131. url = self._MANIFEST_URL_TEMPLATE % (ride_data.get('live_stream_url'), urllib.parse.quote(token))
  132. is_live = True
  133. else:
  134. raise ExtractorError('Missing video URL')
  135. formats, subtitles = self._extract_m3u8_formats_and_subtitles(url, video_id, 'mp4')
  136. if metadata.get('instructor_cues'):
  137. subtitles['cues'] = [{
  138. 'data': json.dumps(metadata.get('instructor_cues')),
  139. 'ext': 'json',
  140. }]
  141. category = ride_data.get('fitness_discipline_display_name')
  142. chapters = [{
  143. 'start_time': segment.get('start_time_offset'),
  144. 'end_time': segment.get('start_time_offset') + segment.get('length'),
  145. 'title': segment.get('name'),
  146. } for segment in traverse_obj(metadata, ('segments', 'segment_list'))]
  147. return {
  148. 'id': video_id,
  149. 'title': ride_data.get('title'),
  150. 'formats': formats,
  151. 'thumbnail': url_or_none(ride_data.get('image_url')),
  152. 'description': str_or_none(ride_data.get('description')),
  153. 'creator': traverse_obj(ride_data, ('instructor', 'name')),
  154. 'release_timestamp': ride_data.get('original_air_time'),
  155. 'timestamp': ride_data.get('original_air_time'),
  156. 'subtitles': subtitles,
  157. 'duration': float_or_none(ride_data.get('length')),
  158. 'categories': [category] if category else None,
  159. 'tags': traverse_obj(ride_data, ('equipment_tags', ..., 'name')),
  160. 'is_live': is_live,
  161. 'chapters': chapters,
  162. }
  163. class PelotonLiveIE(InfoExtractor):
  164. IE_NAME = 'peloton:live'
  165. IE_DESC = 'Peloton Live'
  166. _VALID_URL = r'https?://members\.onepeloton\.com/player/live/(?P<id>[a-f0-9]+)'
  167. _TEST = {
  168. 'url': 'https://members.onepeloton.com/player/live/eedee2d19f804a9788f53aa8bd38eb1b',
  169. 'info_dict': {
  170. 'id': '32edc92d28044be5bf6c7b6f1f8d1cbc',
  171. 'title': '30 min HIIT Ride: Live from Home',
  172. 'ext': 'mp4',
  173. 'thumbnail': r're:^https?://.+\.png',
  174. 'description': 'md5:f0d7d8ed3f901b7ee3f62c1671c15817',
  175. 'creator': 'Alex Toussaint',
  176. 'release_timestamp': 1587736620,
  177. 'timestamp': 1587736620,
  178. 'upload_date': '20200424',
  179. 'duration': 2014,
  180. 'categories': ['Cycling'],
  181. 'is_live': False,
  182. 'chapters': 'count:3',
  183. },
  184. 'params': {
  185. 'skip_download': 'm3u8',
  186. },
  187. 'skip': 'Account needed',
  188. }
  189. def _real_extract(self, url):
  190. workout_id = self._match_id(url)
  191. peloton = self._download_json(f'https://api.onepeloton.com/api/peloton/{workout_id}', workout_id)
  192. if peloton.get('ride_id'):
  193. if not peloton.get('is_live') or peloton.get('is_encore') or peloton.get('status') != 'PRE_START':
  194. return self.url_result('https://members.onepeloton.com/classes/player/{}'.format(peloton['ride_id']))
  195. else:
  196. raise ExtractorError('Ride has not started', expected=True)
  197. else:
  198. raise ExtractorError('Missing video ID')