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