PageRenderTime 46ms CodeModel.GetById 17ms RepoModel.GetById 0ms app.codeStats 0ms

/yt_dlp/extractor/peloton.py

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