From 8d8af92a5c6f1efedebb0f93b9f8fd095e50727f Mon Sep 17 00:00:00 2001 From: lulu <44802077+phoenixthrush@users.noreply.github.com> Date: Sat, 19 Jul 2025 03:53:56 +0200 Subject: [PATCH 1/9] Use API endpoint instead for both VR and non-VR --- yt_dlp/extractor/stripchat.py | 125 ++++++++++++++++++++++------------ 1 file changed, 83 insertions(+), 42 deletions(-) diff --git a/yt_dlp/extractor/stripchat.py b/yt_dlp/extractor/stripchat.py index 84846042f3..91d73334a2 100644 --- a/yt_dlp/extractor/stripchat.py +++ b/yt_dlp/extractor/stripchat.py @@ -1,61 +1,102 @@ from .common import InfoExtractor -from ..utils import ( - ExtractorError, - UserNotLive, - lowercase_escape, - traverse_obj, -) class StripchatIE(InfoExtractor): - _VALID_URL = r'https?://stripchat\.com/(?P[^/?#]+)' - _TESTS = [{ - 'url': 'https://stripchat.com/Joselin_Flower', - 'info_dict': { - 'id': 'Joselin_Flower', - 'ext': 'mp4', - 'title': 're:^Joselin_Flower [0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}$', - 'description': str, - 'is_live': True, - 'age_limit': 18, + _VALID_URL = r'https?://(?:vr\.)?stripchat\.com/(?:cam/)?(?P[^/?&#]+)' + _TESTS = [ + { + 'url': 'https://vr.stripchat.com/cam/Heather_Ivy', + 'info_dict': { + 'id': 'Heather_Ivy', + 'ext': 'mp4', + 'title': 're:^Heather_Ivy [0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}$', + 'age_limit': 18, + 'is_live': True, + }, + 'params': { + 'skip_download': True, + }, + 'skip': 'Stream might be offline', }, - 'skip': 'Room is offline', - }, { - 'url': 'https://stripchat.com/Rakhijaan@xh', - 'only_matching': True, - }] + { + 'url': 'https://stripchat.com/Heather_Ivy', + 'info_dict': { + 'id': 'Heather_Ivy', + 'ext': 'mp4', + 'title': 're:^Heather_Ivy [0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}$', + 'age_limit': 18, + 'is_live': True, + }, + 'params': { + 'skip_download': True, + }, + 'skip': 'Stream might be offline', + } + ] def _real_extract(self, url): video_id = self._match_id(url) - webpage = self._download_webpage(url, video_id, headers=self.geo_verification_headers()) - data = self._search_json( - r']*>\s*window\.__PRELOADED_STATE__\s*=', - webpage, 'data', video_id, transform_source=lowercase_escape) + is_vr = 'vr.stripchat.com' in url + + # The API is the same for both VR and non-VR + # f'https://vr.stripchat.com/api/vr/v2/models/username/{video_id}' + api_url = f'https://stripchat.com/api/vr/v2/models/username/{video_id}' + api_json = self._download_json(api_url, video_id) + + # You can retrieve this value from "model.id," "streamName," or "cam.streamName" + model_id = api_json.get('streamName') - if traverse_obj(data, ('viewCam', 'show', {dict})): - raise ExtractorError('Model is in a private show', expected=True) - if not traverse_obj(data, ('viewCam', 'model', 'isLive', {bool})): - raise UserNotLive(video_id=video_id) + # Contains 'eu23', for example, with server '20' as the fallback + host_str = api_json.get('model', {}).get('broadcastServer', '') + host = ''.join([c for c in host_str if c.isdigit()]) or 20 - model_id = data['viewCam']['model']['id'] + if is_vr: + base_url = f'https://media-hls.doppiocdn.net/b-hls-{host}/{model_id}_vr/{model_id}_vr' + # e.g. ['2160p60', '1440p60'] + video_presets = api_json.get('broadcastSettings', {}).get('presets', {}).get('vr', {}) + else: + base_url = f'https://media-hls.doppiocdn.net/b-hls-{host}/{model_id}/{model_id}' + # e.g. ['960p', '480p', '240p', '160p', '160p_blurred'] + video_presets = api_json.get('broadcastSettings', {}).get('presets', {}).get('default', {}) formats = [] - # HLS hosts are currently found in .configV3.static.features.hlsFallback.fallbackDomains[] - # The rest of the path is for backwards compatibility and to guard against A/B testing - for host in traverse_obj(data, ((('config', 'data'), ('configV3', 'static')), ( - (('features', 'featuresV2'), 'hlsFallback', 'fallbackDomains', ...), 'hlsStreamHost'))): - formats = self._extract_m3u8_formats( - f'https://edge-hls.{host}/hls/{model_id}/master/{model_id}_auto.m3u8', - video_id, ext='mp4', m3u8_id='hls', fatal=False, live=True) - if formats: - break - if not formats: - self.raise_no_formats('Unable to extract stream host', video_id=video_id) + + # This does not work because the m3u8 url is incorrect + # formats = self._extract_m3u8_formats( + # f'{base_url}_auto.m3u8', + # video_id, ext='mp4', m3u8_id='hls', fatal=False, live=True + # ) + + # The resolution should be omitted for best quality (source) that is often much higher than 2160p60 on VR + formats.append({ + 'url': f'{base_url}.m3u8', + 'ext': 'mp4', + 'protocol': 'm3u8_native', + 'format_id': 'source', + 'quality': 10, + 'is_live': True + }) + + # Add all other available presets + for index, resolution in enumerate(video_presets): + if isinstance(resolution, str): + formats.append({ + 'url': f'{base_url}_{resolution}.m3u8', + 'ext': 'mp4', + 'protocol': 'm3u8_native', + 'format_id': f'hls_{resolution}', + # The qualities are already sorted by entry point + 'quality': 9 - index, + 'is_live': True, + }) + + # You can also use previewUrlThumbBig and previewUrlThumbSmall + preview_url = api_json.get('model', {}).get('previewUrl', {}) return { 'id': video_id, 'title': video_id, - 'description': self._og_search_description(webpage), + 'thumbnail': preview_url, 'is_live': True, 'formats': formats, # Stripchat declares the RTA meta-tag, but in an non-standard format so _rta_search() can't be used From 0e5e678f2b14a13e46c65326d5fa492cb7177f31 Mon Sep 17 00:00:00 2001 From: lulu <44802077+phoenixthrush@users.noreply.github.com> Date: Sat, 19 Jul 2025 04:14:16 +0200 Subject: [PATCH 2/9] Add URL substring sanitization and Fix Trailing comma missing --- yt_dlp/extractor/stripchat.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/yt_dlp/extractor/stripchat.py b/yt_dlp/extractor/stripchat.py index 91d73334a2..8c162024bb 100644 --- a/yt_dlp/extractor/stripchat.py +++ b/yt_dlp/extractor/stripchat.py @@ -1,6 +1,5 @@ from .common import InfoExtractor - class StripchatIE(InfoExtractor): _VALID_URL = r'https?://(?:vr\.)?stripchat\.com/(?:cam/)?(?P[^/?&#]+)' _TESTS = [ @@ -31,12 +30,12 @@ class StripchatIE(InfoExtractor): 'skip_download': True, }, 'skip': 'Stream might be offline', - } + }, ] def _real_extract(self, url): video_id = self._match_id(url) - is_vr = 'vr.stripchat.com' in url + is_vr = url.startswith('http://vr.stripchat.com') or url.startswith('https://vr.stripchat.com') # The API is the same for both VR and non-VR # f'https://vr.stripchat.com/api/vr/v2/models/username/{video_id}' @@ -74,7 +73,7 @@ class StripchatIE(InfoExtractor): 'protocol': 'm3u8_native', 'format_id': 'source', 'quality': 10, - 'is_live': True + 'is_live': True, }) # Add all other available presets From 4be1b54c55542ce837f330d411a9488db94bd883 Mon Sep 17 00:00:00 2001 From: lulu <44802077+phoenixthrush@users.noreply.github.com> Date: Sat, 19 Jul 2025 04:21:17 +0200 Subject: [PATCH 3/9] Fix URL substring sanitization --- yt_dlp/extractor/stripchat.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/yt_dlp/extractor/stripchat.py b/yt_dlp/extractor/stripchat.py index 8c162024bb..a8a9daa4b9 100644 --- a/yt_dlp/extractor/stripchat.py +++ b/yt_dlp/extractor/stripchat.py @@ -1,5 +1,8 @@ +import urllib + from .common import InfoExtractor + class StripchatIE(InfoExtractor): _VALID_URL = r'https?://(?:vr\.)?stripchat\.com/(?:cam/)?(?P[^/?&#]+)' _TESTS = [ @@ -35,7 +38,7 @@ class StripchatIE(InfoExtractor): def _real_extract(self, url): video_id = self._match_id(url) - is_vr = url.startswith('http://vr.stripchat.com') or url.startswith('https://vr.stripchat.com') + is_vr = urllib.urlparse(url).hostname == 'vr.stripchat.com' # The API is the same for both VR and non-VR # f'https://vr.stripchat.com/api/vr/v2/models/username/{video_id}' From 6860d0a74afaa3a038f54268f34e2bcf746b8d5a Mon Sep 17 00:00:00 2001 From: lulu <44802077+phoenixthrush@users.noreply.github.com> Date: Sat, 19 Jul 2025 04:50:39 +0200 Subject: [PATCH 4/9] Fix 'urllib' has no attribute 'urlparse' --- yt_dlp/extractor/stripchat.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/yt_dlp/extractor/stripchat.py b/yt_dlp/extractor/stripchat.py index a8a9daa4b9..99eb2fbfcf 100644 --- a/yt_dlp/extractor/stripchat.py +++ b/yt_dlp/extractor/stripchat.py @@ -1,4 +1,4 @@ -import urllib +import urllib.parse from .common import InfoExtractor @@ -38,7 +38,7 @@ class StripchatIE(InfoExtractor): def _real_extract(self, url): video_id = self._match_id(url) - is_vr = urllib.urlparse(url).hostname == 'vr.stripchat.com' + is_vr = urllib.parse.urlparse(url).hostname == 'vr.stripchat.com' # The API is the same for both VR and non-VR # f'https://vr.stripchat.com/api/vr/v2/models/username/{video_id}' From 61994a77e6eefaf6a38a647e86cca6d08ed3a3e1 Mon Sep 17 00:00:00 2001 From: lulu <44802077+phoenixthrush@users.noreply.github.com> Date: Sat, 19 Jul 2025 04:55:43 +0200 Subject: [PATCH 5/9] Use b-hls-20 as Host --- yt_dlp/extractor/stripchat.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/yt_dlp/extractor/stripchat.py b/yt_dlp/extractor/stripchat.py index 99eb2fbfcf..7d6b174446 100644 --- a/yt_dlp/extractor/stripchat.py +++ b/yt_dlp/extractor/stripchat.py @@ -49,8 +49,9 @@ class StripchatIE(InfoExtractor): model_id = api_json.get('streamName') # Contains 'eu23', for example, with server '20' as the fallback - host_str = api_json.get('model', {}).get('broadcastServer', '') - host = ''.join([c for c in host_str if c.isdigit()]) or 20 + # host_str = api_json.get('model', {}).get('broadcastServer', '') + # host = ''.join([c for c in host_str if c.isdigit()]) or 20 + host = 20 if is_vr: base_url = f'https://media-hls.doppiocdn.net/b-hls-{host}/{model_id}_vr/{model_id}_vr' From 24be0085f04a30fc1ad9470ce4b2e83dff17a8a1 Mon Sep 17 00:00:00 2001 From: lulu <44802077+phoenixthrush@users.noreply.github.com> Date: Sat, 19 Jul 2025 13:25:23 +0200 Subject: [PATCH 6/9] Remove unnecessary urllib import --- yt_dlp/extractor/stripchat.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/yt_dlp/extractor/stripchat.py b/yt_dlp/extractor/stripchat.py index 7d6b174446..024ce9a51b 100644 --- a/yt_dlp/extractor/stripchat.py +++ b/yt_dlp/extractor/stripchat.py @@ -1,6 +1,5 @@ -import urllib.parse - from .common import InfoExtractor +from ..utils import base_url as get_base_url class StripchatIE(InfoExtractor): @@ -38,7 +37,7 @@ class StripchatIE(InfoExtractor): def _real_extract(self, url): video_id = self._match_id(url) - is_vr = urllib.parse.urlparse(url).hostname == 'vr.stripchat.com' + is_vr = get_base_url(url) in ('https://vr.stripchat.com/cam/', 'http://vr.stripchat.com/cam/') # The API is the same for both VR and non-VR # f'https://vr.stripchat.com/api/vr/v2/models/username/{video_id}' From 2be705a9836cdd23e01827c2bc9d4bfaa314034d Mon Sep 17 00:00:00 2001 From: lulu <44802077+phoenixthrush@users.noreply.github.com> Date: Sat, 19 Jul 2025 13:57:14 +0200 Subject: [PATCH 7/9] [ie/Stripchat] Add offline and private show detection --- yt_dlp/extractor/stripchat.py | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/yt_dlp/extractor/stripchat.py b/yt_dlp/extractor/stripchat.py index 024ce9a51b..7a8fba5a3a 100644 --- a/yt_dlp/extractor/stripchat.py +++ b/yt_dlp/extractor/stripchat.py @@ -1,5 +1,11 @@ from .common import InfoExtractor -from ..utils import base_url as get_base_url +from ..utils import ( + ExtractorError, + UserNotLive, +) +from ..utils import ( + base_url as get_base_url, +) class StripchatIE(InfoExtractor): @@ -44,11 +50,19 @@ class StripchatIE(InfoExtractor): api_url = f'https://stripchat.com/api/vr/v2/models/username/{video_id}' api_json = self._download_json(api_url, video_id) + model = api_json.get('model', {}) + + if model.get('status', {}) == 'off': + raise UserNotLive(video_id=video_id) + + if api_json.get('cam', {}).get('show', {}).get('details', {}).get('startMode', {}) == 'private': + raise ExtractorError('Room is currently in a private show', expected=True) + # You can retrieve this value from "model.id," "streamName," or "cam.streamName" model_id = api_json.get('streamName') # Contains 'eu23', for example, with server '20' as the fallback - # host_str = api_json.get('model', {}).get('broadcastServer', '') + # host_str = model.get('broadcastServer', '') # host = ''.join([c for c in host_str if c.isdigit()]) or 20 host = 20 @@ -63,12 +77,6 @@ class StripchatIE(InfoExtractor): formats = [] - # This does not work because the m3u8 url is incorrect - # formats = self._extract_m3u8_formats( - # f'{base_url}_auto.m3u8', - # video_id, ext='mp4', m3u8_id='hls', fatal=False, live=True - # ) - # The resolution should be omitted for best quality (source) that is often much higher than 2160p60 on VR formats.append({ 'url': f'{base_url}.m3u8', @@ -93,7 +101,7 @@ class StripchatIE(InfoExtractor): }) # You can also use previewUrlThumbBig and previewUrlThumbSmall - preview_url = api_json.get('model', {}).get('previewUrl', {}) + preview_url = model.get('previewUrl', {}) return { 'id': video_id, From 6e461aaf7240fb50726fc18976bdd96c18e48603 Mon Sep 17 00:00:00 2001 From: lulu <44802077+phoenixthrush@users.noreply.github.com> Date: Sun, 20 Jul 2025 02:26:15 +0200 Subject: [PATCH 8/9] [ie/Stripchat] Fix NoneType crash --- yt_dlp/extractor/stripchat.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/yt_dlp/extractor/stripchat.py b/yt_dlp/extractor/stripchat.py index 7a8fba5a3a..96303985e1 100644 --- a/yt_dlp/extractor/stripchat.py +++ b/yt_dlp/extractor/stripchat.py @@ -55,7 +55,12 @@ class StripchatIE(InfoExtractor): if model.get('status', {}) == 'off': raise UserNotLive(video_id=video_id) - if api_json.get('cam', {}).get('show', {}).get('details', {}).get('startMode', {}) == 'private': + cam = api_json.get('cam') or {} + show = cam.get('show') or {} + details = show.get('details') or {} + start_mode = details.get('startMode') + + if start_mode == 'private': raise ExtractorError('Room is currently in a private show', expected=True) # You can retrieve this value from "model.id," "streamName," or "cam.streamName" From 6bee0016d7c06831e15a1a70ff1e7c391d41b4b4 Mon Sep 17 00:00:00 2001 From: lulu <44802077+phoenixthrush@users.noreply.github.com> Date: Sun, 20 Jul 2025 02:59:25 +0200 Subject: [PATCH 9/9] [ie/Stripchat] Use a different M3U8 URL This eliminates the need for a host and also displays all the qualities correctly when the -F flag is used. --- yt_dlp/extractor/stripchat.py | 43 +++++------------------------------ 1 file changed, 6 insertions(+), 37 deletions(-) diff --git a/yt_dlp/extractor/stripchat.py b/yt_dlp/extractor/stripchat.py index 96303985e1..b25579d8db 100644 --- a/yt_dlp/extractor/stripchat.py +++ b/yt_dlp/extractor/stripchat.py @@ -58,52 +58,21 @@ class StripchatIE(InfoExtractor): cam = api_json.get('cam') or {} show = cam.get('show') or {} details = show.get('details') or {} - start_mode = details.get('startMode') - if start_mode == 'private': + if details.get('startMode') == 'private': raise ExtractorError('Room is currently in a private show', expected=True) # You can retrieve this value from "model.id," "streamName," or "cam.streamName" model_id = api_json.get('streamName') - # Contains 'eu23', for example, with server '20' as the fallback - # host_str = model.get('broadcastServer', '') - # host = ''.join([c for c in host_str if c.isdigit()]) or 20 - host = 20 - if is_vr: - base_url = f'https://media-hls.doppiocdn.net/b-hls-{host}/{model_id}_vr/{model_id}_vr' - # e.g. ['2160p60', '1440p60'] - video_presets = api_json.get('broadcastSettings', {}).get('presets', {}).get('vr', {}) + m3u8_url = f'https://edge-hls.doppiocdn.net/hls/{model_id}_vr/master/{model_id}_vr_auto.m3u8' else: - base_url = f'https://media-hls.doppiocdn.net/b-hls-{host}/{model_id}/{model_id}' - # e.g. ['960p', '480p', '240p', '160p', '160p_blurred'] - video_presets = api_json.get('broadcastSettings', {}).get('presets', {}).get('default', {}) - - formats = [] - - # The resolution should be omitted for best quality (source) that is often much higher than 2160p60 on VR - formats.append({ - 'url': f'{base_url}.m3u8', - 'ext': 'mp4', - 'protocol': 'm3u8_native', - 'format_id': 'source', - 'quality': 10, - 'is_live': True, - }) + m3u8_url = f'https://edge-hls.doppiocdn.net/hls/{model_id}/master/{model_id}_auto.m3u8' - # Add all other available presets - for index, resolution in enumerate(video_presets): - if isinstance(resolution, str): - formats.append({ - 'url': f'{base_url}_{resolution}.m3u8', - 'ext': 'mp4', - 'protocol': 'm3u8_native', - 'format_id': f'hls_{resolution}', - # The qualities are already sorted by entry point - 'quality': 9 - index, - 'is_live': True, - }) + formats = self._extract_m3u8_formats( + m3u8_url, video_id, ext='mp4', m3u8_id='hls', fatal=False, live=True, + ) # You can also use previewUrlThumbBig and previewUrlThumbSmall preview_url = model.get('previewUrl', {})