5 from .common
import InfoExtractor
6 from ..networking
.exceptions
import HTTPError
16 class PelotonIE(InfoExtractor
):
18 _NETRC_MACHINE
= 'peloton'
19 _VALID_URL
= r
'https?://members\.onepeloton\.com/classes/player/(?P<id>[a-f0-9]+)'
21 'url': 'https://members.onepeloton.com/classes/player/0e9653eb53544eeb881298c8d7a87b86',
23 'id': '0e9653eb53544eeb881298c8d7a87b86',
24 'title': '20 min Chest & Back Strength',
26 'thumbnail': r
're:^https?://.+\.jpg',
27 'description': 'md5:fcd5be9b9eda0194b470e13219050a66',
28 'creator': 'Chase Tucker',
29 'release_timestamp': 1556141400,
30 'timestamp': 1556141400,
31 'upload_date': '20190424',
33 'categories': ['Strength'],
34 'tags': ['Workout Mat', 'Light Weights', 'Medium Weights'],
36 'chapters': 'count:1',
37 'subtitles': {'en': [{
38 'url': r
're:^https?://.+',
42 'skip_download': 'm3u8',
44 'skip': 'Account needed',
46 'url': 'https://members.onepeloton.com/classes/player/26603d53d6bb4de1b340514864a6a6a8',
48 'id': '26603d53d6bb4de1b340514864a6a6a8',
49 'title': '30 min Earth Day Run',
51 'thumbnail': r
're:https://.+\.jpg',
52 'description': 'md5:adc065a073934d7ee0475d217afe0c3d',
53 'creator': 'Selena Samuela',
54 'release_timestamp': 1587567600,
55 'timestamp': 1587567600,
56 'upload_date': '20200422',
58 'categories': ['Running'],
60 'chapters': 'count:3',
62 'skip_download': 'm3u8',
64 'skip': 'Account needed',
67 _MANIFEST_URL_TEMPLATE
= '%s?hdnea=%s'
69 def _start_session(self
, video_id
):
70 self
._download
_webpage
('https://api.onepeloton.com/api/started_client_session', video_id
, note
='Starting session')
72 def _login(self
, video_id
):
73 username
, password
= self
._get
_login
_info
()
74 if not (username
and password
):
75 self
.raise_login_required()
78 'https://api.onepeloton.com/auth/login', video_id
, note
='Logging in',
80 'username_or_email': username
,
84 headers
={'Content-Type': 'application/json', 'User-Agent': 'web'}
)
85 except ExtractorError
as e
:
86 if isinstance(e
.cause
, HTTPError
) and e
.cause
.status
== 401:
87 json_string
= self
._webpage
_read
_content
(e
.cause
.response
, None, video_id
)
88 res
= self
._parse
_json
(json_string
, video_id
)
89 raise ExtractorError(res
['message'], expected
=res
['message'] == 'Login failed')
93 def _get_token(self
, video_id
):
95 subscription
= self
._download
_json
(
96 'https://api.onepeloton.com/api/subscription/stream', video_id
, note
='Downloading token',
97 data
=json
.dumps({}).encode(), headers={'Content-Type': 'application/json'}
)
98 except ExtractorError
as e
:
99 if isinstance(e
.cause
, HTTPError
) and e
.cause
.status
== 403:
100 json_string
= self
._webpage
_read
_content
(e
.cause
.response
, None, video_id
)
101 res
= self
._parse
_json
(json_string
, video_id
)
102 raise ExtractorError(res
['message'], expected
=res
['message'] == 'Stream limit reached')
105 return subscription
['token']
107 def _real_extract(self
, url
):
108 video_id
= self
._match
_id
(url
)
110 self
._start
_session
(video_id
)
111 except ExtractorError
as e
:
112 if isinstance(e
.cause
, HTTPError
) and e
.cause
.status
== 401:
113 self
._login
(video_id
)
114 self
._start
_session
(video_id
)
118 metadata
= self
._download
_json
(f
'https://api.onepeloton.com/api/ride/{video_id}/details?stream_source=multichannel', video_id
)
119 ride_data
= metadata
.get('ride')
121 raise ExtractorError('Missing stream metadata')
122 token
= self
._get
_token
(video_id
)
125 if ride_data
.get('content_format') == 'audio':
126 url
= self
._MANIFEST
_URL
_TEMPLATE
% (ride_data
.get('vod_stream_url'), urllib
.parse
.quote(token
))
130 'format_id': 'audio',
135 if ride_data
.get('vod_stream_url'):
136 url
= 'https://members.onepeloton.com/.netlify/functions/m3u8-proxy?displayLanguage=en&acceptedSubtitles={}&url={}?hdnea={}'.format(
137 ','.join([re
.sub('^([a-z]+)-([A-Z]+)$', r
'\1', caption
) for caption
in ride_data
['captions']]),
138 ride_data
['vod_stream_url'],
139 urllib
.parse
.quote(urllib
.parse
.quote(token
)))
140 elif ride_data
.get('live_stream_url'):
141 url
= self
._MANIFEST
_URL
_TEMPLATE
% (ride_data
.get('live_stream_url'), urllib
.parse
.quote(token
))
144 raise ExtractorError('Missing video URL')
145 formats
, subtitles
= self
._extract
_m
3u8_formats
_and
_subtitles
(url
, video_id
, 'mp4')
147 if metadata
.get('instructor_cues'):
148 subtitles
['cues'] = [{
149 'data': json
.dumps(metadata
.get('instructor_cues')),
153 category
= ride_data
.get('fitness_discipline_display_name')
155 'start_time': segment
.get('start_time_offset'),
156 'end_time': segment
.get('start_time_offset') + segment
.get('length'),
157 'title': segment
.get('name'),
158 } for segment
in traverse_obj(metadata
, ('segments', 'segment_list'))]
162 'title': ride_data
.get('title'),
164 'thumbnail': url_or_none(ride_data
.get('image_url')),
165 'description': str_or_none(ride_data
.get('description')),
166 'creator': traverse_obj(ride_data
, ('instructor', 'name')),
167 'release_timestamp': ride_data
.get('original_air_time'),
168 'timestamp': ride_data
.get('original_air_time'),
169 'subtitles': subtitles
,
170 'duration': float_or_none(ride_data
.get('length')),
171 'categories': [category
] if category
else None,
172 'tags': traverse_obj(ride_data
, ('equipment_tags', ..., 'name')),
174 'chapters': chapters
,
178 class PelotonLiveIE(InfoExtractor
):
179 IE_NAME
= 'peloton:live'
180 IE_DESC
= 'Peloton Live'
181 _VALID_URL
= r
'https?://members\.onepeloton\.com/player/live/(?P<id>[a-f0-9]+)'
183 'url': 'https://members.onepeloton.com/player/live/eedee2d19f804a9788f53aa8bd38eb1b',
185 'id': '32edc92d28044be5bf6c7b6f1f8d1cbc',
186 'title': '30 min HIIT Ride: Live from Home',
188 'thumbnail': r
're:^https?://.+\.png',
189 'description': 'md5:f0d7d8ed3f901b7ee3f62c1671c15817',
190 'creator': 'Alex Toussaint',
191 'release_timestamp': 1587736620,
192 'timestamp': 1587736620,
193 'upload_date': '20200424',
195 'categories': ['Cycling'],
197 'chapters': 'count:3',
200 'skip_download': 'm3u8',
202 'skip': 'Account needed',
205 def _real_extract(self
, url
):
206 workout_id
= self
._match
_id
(url
)
207 peloton
= self
._download
_json
(f
'https://api.onepeloton.com/api/peloton/{workout_id}', workout_id
)
209 if peloton
.get('ride_id'):
210 if not peloton
.get('is_live') or peloton
.get('is_encore') or peloton
.get('status') != 'PRE_START':
211 return self
.url_result('https://members.onepeloton.com/classes/player/{}'.format(peloton
['ride_id']))
213 raise ExtractorError('Ride has not started', expected
=True)
215 raise ExtractorError('Missing video ID')