]> jfr.im git - yt-dlp.git/blame - yt_dlp/extractor/peloton.py
[test/download] Fallback test to `bv`
[yt-dlp.git] / yt_dlp / extractor / peloton.py
CommitLineData
356ac009
I
1# coding: utf-8
2from __future__ import unicode_literals
3
4import json
5import re
6
7from .common import InfoExtractor
8from ..compat import (
9 compat_HTTPError,
10 compat_urllib_parse,
11)
12from ..utils import (
13 ExtractorError,
14 float_or_none,
15 str_or_none,
16 traverse_obj,
17 url_or_none,
18)
19
20
21class 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
184class 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')