[ie/twitter:spaces] Support video spaces (#10789)
[yt-dlp3.git] / yt_dlp / extractor / peloton.py
blob5999d4a6a0fa277c9f23f5c5ffc3f87ebd860067
1 import json
2 import re
3 import urllib.parse
5 from .common import InfoExtractor
6 from ..networking.exceptions import HTTPError
7 from ..utils import (
8 ExtractorError,
9 float_or_none,
10 str_or_none,
11 traverse_obj,
12 url_or_none,
16 class PelotonIE(InfoExtractor):
17 IE_NAME = 'peloton'
18 _NETRC_MACHINE = 'peloton'
19 _VALID_URL = r'https?://members\.onepeloton\.com/classes/player/(?P<id>[a-f0-9]+)'
20 _TESTS = [{
21 'url': 'https://members.onepeloton.com/classes/player/0e9653eb53544eeb881298c8d7a87b86',
22 'info_dict': {
23 'id': '0e9653eb53544eeb881298c8d7a87b86',
24 'title': '20 min Chest & Back Strength',
25 'ext': 'mp4',
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',
32 'duration': 1389,
33 'categories': ['Strength'],
34 'tags': ['Workout Mat', 'Light Weights', 'Medium Weights'],
35 'is_live': False,
36 'chapters': 'count:1',
37 'subtitles': {'en': [{
38 'url': r're:^https?://.+',
39 'ext': 'vtt',
40 }]},
41 }, 'params': {
42 'skip_download': 'm3u8',
44 'skip': 'Account needed',
45 }, {
46 'url': 'https://members.onepeloton.com/classes/player/26603d53d6bb4de1b340514864a6a6a8',
47 'info_dict': {
48 'id': '26603d53d6bb4de1b340514864a6a6a8',
49 'title': '30 min Earth Day Run',
50 'ext': 'm4a',
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',
57 'duration': 1802,
58 'categories': ['Running'],
59 'is_live': False,
60 'chapters': 'count:3',
61 }, 'params': {
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()
76 try:
77 self._download_json(
78 'https://api.onepeloton.com/auth/login', video_id, note='Logging in',
79 data=json.dumps({
80 'username_or_email': username,
81 'password': password,
82 'with_pubsub': False,
83 }).encode(),
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')
90 else:
91 raise
93 def _get_token(self, video_id):
94 try:
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')
103 else:
104 raise
105 return subscription['token']
107 def _real_extract(self, url):
108 video_id = self._match_id(url)
109 try:
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)
115 else:
116 raise
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')
120 if not ride_data:
121 raise ExtractorError('Missing stream metadata')
122 token = self._get_token(video_id)
124 is_live = False
125 if ride_data.get('content_format') == 'audio':
126 url = self._MANIFEST_URL_TEMPLATE % (ride_data.get('vod_stream_url'), urllib.parse.quote(token))
127 formats = [{
128 'url': url,
129 'ext': 'm4a',
130 'format_id': 'audio',
131 'vcodec': 'none',
133 subtitles = {}
134 else:
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))
142 is_live = True
143 else:
144 raise ExtractorError('Missing video URL')
145 formats, subtitles = self._extract_m3u8_formats_and_subtitles(url, video_id, 'mp4')
147 if metadata.get('instructor_cues'):
148 subtitles['cues'] = [{
149 'data': json.dumps(metadata.get('instructor_cues')),
150 'ext': 'json',
153 category = ride_data.get('fitness_discipline_display_name')
154 chapters = [{
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'))]
160 return {
161 'id': video_id,
162 'title': ride_data.get('title'),
163 'formats': formats,
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')),
173 'is_live': is_live,
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]+)'
182 _TEST = {
183 'url': 'https://members.onepeloton.com/player/live/eedee2d19f804a9788f53aa8bd38eb1b',
184 'info_dict': {
185 'id': '32edc92d28044be5bf6c7b6f1f8d1cbc',
186 'title': '30 min HIIT Ride: Live from Home',
187 'ext': 'mp4',
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',
194 'duration': 2014,
195 'categories': ['Cycling'],
196 'is_live': False,
197 'chapters': 'count:3',
199 'params': {
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']))
212 else:
213 raise ExtractorError('Ride has not started', expected=True)
214 else:
215 raise ExtractorError('Missing video ID')