mirror of
				https://github.com/yt-dlp/yt-dlp.git
				synced 2025-10-31 14:45:14 +00:00 
			
		
		
		
	[peloton] Add extractor (#192)
Authored by: IONECarter, capntrips, pukkandan
This commit is contained in:
		
							
								
								
									
										222
									
								
								yt_dlp/extractor/peloton.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										222
									
								
								yt_dlp/extractor/peloton.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,222 @@ | ||||
| # coding: utf-8 | ||||
| from __future__ import unicode_literals | ||||
|  | ||||
| import json | ||||
| import re | ||||
|  | ||||
| from .common import InfoExtractor | ||||
| from ..compat import ( | ||||
|     compat_HTTPError, | ||||
|     compat_urllib_parse, | ||||
| ) | ||||
| from ..utils import ( | ||||
|     ExtractorError, | ||||
|     float_or_none, | ||||
|     str_or_none, | ||||
|     traverse_obj, | ||||
|     url_or_none, | ||||
| ) | ||||
|  | ||||
|  | ||||
| class PelotonIE(InfoExtractor): | ||||
|     IE_NAME = 'peloton' | ||||
|     _NETRC_MACHINE = 'peloton' | ||||
|     _VALID_URL = r'https?://members\.onepeloton\.com/classes/player/(?P<id>[a-f0-9]+)' | ||||
|     _TESTS = [{ | ||||
|         'url': 'https://members.onepeloton.com/classes/player/0e9653eb53544eeb881298c8d7a87b86', | ||||
|         'info_dict': { | ||||
|             'id': '0e9653eb53544eeb881298c8d7a87b86', | ||||
|             'title': '20 min Chest & Back Strength', | ||||
|             'ext': 'mp4', | ||||
|             'thumbnail': r're:^https?://.+\.jpg', | ||||
|             'description': 'md5:fcd5be9b9eda0194b470e13219050a66', | ||||
|             'creator': 'Chase Tucker', | ||||
|             'release_timestamp': 1556141400, | ||||
|             'timestamp': 1556141400, | ||||
|             'upload_date': '20190424', | ||||
|             'duration': 1389, | ||||
|             'categories': ['Strength'], | ||||
|             'tags': ['Workout Mat', 'Light Weights', 'Medium Weights'], | ||||
|             'is_live': False, | ||||
|             'chapters': 'count:1', | ||||
|             'subtitles': {'en': [{ | ||||
|                 'url': r're:^https?://.+', | ||||
|                 'ext': 'vtt' | ||||
|             }]}, | ||||
|         }, 'params': { | ||||
|             'skip_download': 'm3u8', | ||||
|         }, | ||||
|         '_skip': 'Account needed' | ||||
|     }, { | ||||
|         'url': 'https://members.onepeloton.com/classes/player/26603d53d6bb4de1b340514864a6a6a8', | ||||
|         'info_dict': { | ||||
|             'id': '26603d53d6bb4de1b340514864a6a6a8', | ||||
|             'title': '30 min Earth Day Run', | ||||
|             'ext': 'm4a', | ||||
|             'thumbnail': r're:https://.+\.jpg', | ||||
|             'description': 'md5:adc065a073934d7ee0475d217afe0c3d', | ||||
|             'creator': 'Selena Samuela', | ||||
|             'release_timestamp': 1587567600, | ||||
|             'timestamp': 1587567600, | ||||
|             'upload_date': '20200422', | ||||
|             'duration': 1802, | ||||
|             'categories': ['Running'], | ||||
|             'is_live': False, | ||||
|             'chapters': 'count:3' | ||||
|         }, 'params': { | ||||
|             'skip_download': 'm3u8', | ||||
|         }, | ||||
|         '_skip': 'Account needed' | ||||
|     }] | ||||
|  | ||||
|     _MANIFEST_URL_TEMPLATE = '%s?hdnea=%s' | ||||
|  | ||||
|     def _start_session(self, video_id): | ||||
|         self._download_webpage('https://api.onepeloton.com/api/started_client_session', video_id, note='Starting session') | ||||
|  | ||||
|     def _login(self, video_id): | ||||
|         username, password = self._get_login_info() | ||||
|         if not (username and password): | ||||
|             self.raise_login_required() | ||||
|         try: | ||||
|             self._download_json( | ||||
|                 'https://api.onepeloton.com/auth/login', video_id, note='Logging in', | ||||
|                 data=json.dumps({ | ||||
|                     'username_or_email': username, | ||||
|                     'password': password, | ||||
|                     'with_pubsub': False | ||||
|                 }).encode(), | ||||
|                 headers={'Content-Type': 'application/json', 'User-Agent': 'web'}) | ||||
|         except ExtractorError as e: | ||||
|             if isinstance(e.cause, compat_HTTPError) and e.cause.code == 401: | ||||
|                 json_string = self._webpage_read_content(e.cause, None, video_id) | ||||
|                 res = self._parse_json(json_string, video_id) | ||||
|                 raise ExtractorError(res['message'], expected=res['message'] == 'Login failed') | ||||
|             else: | ||||
|                 raise | ||||
|  | ||||
|     def _get_token(self, video_id): | ||||
|         try: | ||||
|             subscription = self._download_json( | ||||
|                 'https://api.onepeloton.com/api/subscription/stream', video_id, note='Downloading token', | ||||
|                 data=json.dumps({}).encode(), headers={'Content-Type': 'application/json'}) | ||||
|         except ExtractorError as e: | ||||
|             if isinstance(e.cause, compat_HTTPError) and e.cause.code == 403: | ||||
|                 json_string = self._webpage_read_content(e.cause, None, video_id) | ||||
|                 res = self._parse_json(json_string, video_id) | ||||
|                 raise ExtractorError(res['message'], expected=res['message'] == 'Stream limit reached') | ||||
|             else: | ||||
|                 raise | ||||
|         return subscription['token'] | ||||
|  | ||||
|     def _real_extract(self, url): | ||||
|         video_id = self._match_id(url) | ||||
|         try: | ||||
|             self._start_session(video_id) | ||||
|         except ExtractorError as e: | ||||
|             if isinstance(e.cause, compat_HTTPError) and e.cause.code == 401: | ||||
|                 self._login(video_id) | ||||
|                 self._start_session(video_id) | ||||
|             else: | ||||
|                 raise | ||||
|  | ||||
|         metadata = self._download_json('https://api.onepeloton.com/api/ride/%s/details?stream_source=multichannel' % video_id, video_id) | ||||
|         ride_data = metadata.get('ride') | ||||
|         if not ride_data: | ||||
|             raise ExtractorError('Missing stream metadata') | ||||
|         token = self._get_token(video_id) | ||||
|  | ||||
|         is_live = False | ||||
|         if ride_data.get('content_format') == 'audio': | ||||
|             url = self._MANIFEST_URL_TEMPLATE % (ride_data.get('vod_stream_url'), compat_urllib_parse.quote(token)) | ||||
|             formats = [{ | ||||
|                 'url': url, | ||||
|                 'ext': 'm4a', | ||||
|                 'format_id': 'audio', | ||||
|                 'vcodec': 'none', | ||||
|             }] | ||||
|             subtitles = {} | ||||
|         else: | ||||
|             if ride_data.get('vod_stream_url'): | ||||
|                 url = 'https://members.onepeloton.com/.netlify/functions/m3u8-proxy?displayLanguage=en&acceptedSubtitles=%s&url=%s?hdnea=%s' % ( | ||||
|                     ','.join([re.sub('^([a-z]+)-([A-Z]+)$', r'\1', caption) for caption in ride_data['captions']]), | ||||
|                     ride_data['vod_stream_url'], | ||||
|                     compat_urllib_parse.quote(compat_urllib_parse.quote(token))) | ||||
|             elif ride_data.get('live_stream_url'): | ||||
|                 url = self._MANIFEST_URL_TEMPLATE % (ride_data.get('live_stream_url'), compat_urllib_parse.quote(token)) | ||||
|                 is_live = True | ||||
|             else: | ||||
|                 raise ExtractorError('Missing video URL') | ||||
|             formats, subtitles = self._extract_m3u8_formats_and_subtitles(url, video_id, 'mp4') | ||||
|  | ||||
|         if metadata.get('instructor_cues'): | ||||
|             subtitles['cues'] = [{ | ||||
|                 'data': json.dumps(metadata.get('instructor_cues')), | ||||
|                 'ext': 'json' | ||||
|             }] | ||||
|  | ||||
|         category = ride_data.get('fitness_discipline_display_name') | ||||
|         chapters = [{ | ||||
|             'start_time': segment.get('start_time_offset'), | ||||
|             'end_time': segment.get('start_time_offset') + segment.get('length'), | ||||
|             'title': segment.get('name') | ||||
|         } for segment in traverse_obj(metadata, ('segments', 'segment_list'))] | ||||
|  | ||||
|         self._sort_formats(formats) | ||||
|         return { | ||||
|             'id': video_id, | ||||
|             'title': ride_data.get('title'), | ||||
|             'formats': formats, | ||||
|             'thumbnail': url_or_none(ride_data.get('image_url')), | ||||
|             'description': str_or_none(ride_data.get('description')), | ||||
|             'creator': traverse_obj(ride_data, ('instructor', 'name')), | ||||
|             'release_timestamp': ride_data.get('original_air_time'), | ||||
|             'timestamp': ride_data.get('original_air_time'), | ||||
|             'subtitles': subtitles, | ||||
|             'duration': float_or_none(ride_data.get('length')), | ||||
|             'categories': [category] if category else None, | ||||
|             'tags': traverse_obj(ride_data, ('equipment_tags', ..., 'name')), | ||||
|             'is_live': is_live, | ||||
|             'chapters': chapters | ||||
|         } | ||||
|  | ||||
|  | ||||
| class PelotonLiveIE(InfoExtractor): | ||||
|     IE_NAME = 'peloton:live' | ||||
|     IE_DESC = 'Peloton Live' | ||||
|     _VALID_URL = r'https?://members\.onepeloton\.com/player/live/(?P<id>[a-f0-9]+)' | ||||
|     _TEST = { | ||||
|         'url': 'https://members.onepeloton.com/player/live/eedee2d19f804a9788f53aa8bd38eb1b', | ||||
|         'info_dict': { | ||||
|             'id': '32edc92d28044be5bf6c7b6f1f8d1cbc', | ||||
|             'title': '30 min HIIT Ride: Live from Home', | ||||
|             'ext': 'mp4', | ||||
|             'thumbnail': r're:^https?://.+\.png', | ||||
|             'description': 'md5:f0d7d8ed3f901b7ee3f62c1671c15817', | ||||
|             'creator': 'Alex Toussaint', | ||||
|             'release_timestamp': 1587736620, | ||||
|             'timestamp': 1587736620, | ||||
|             'upload_date': '20200424', | ||||
|             'duration': 2014, | ||||
|             'categories': ['Cycling'], | ||||
|             'is_live': False, | ||||
|             'chapters': 'count:3' | ||||
|         }, | ||||
|         'params': { | ||||
|             'format': 'bestvideo', | ||||
|             'skip_download': 'm3u8', | ||||
|         }, | ||||
|         '_skip': 'Account needed' | ||||
|     } | ||||
|  | ||||
|     def _real_extract(self, url): | ||||
|         workout_id = self._match_id(url) | ||||
|         peloton = self._download_json(f'https://api.onepeloton.com/api/peloton/{workout_id}', workout_id) | ||||
|  | ||||
|         if peloton.get('ride_id'): | ||||
|             if not peloton.get('is_live') or peloton.get('is_encore') or peloton.get('status') != 'PRE_START': | ||||
|                 return self.url_result('https://members.onepeloton.com/classes/player/%s' % peloton['ride_id']) | ||||
|             else: | ||||
|                 raise ExtractorError('Ride has not started', expected=True) | ||||
|         else: | ||||
|             raise ExtractorError('Missing video ID') | ||||
		Reference in New Issue
	
	Block a user
	 IONECarter
					IONECarter