From 3c757d5ed2527b17881eb65c67ddbe0d1335771f Mon Sep 17 00:00:00 2001 From: coletdjnz Date: Fri, 23 Sep 2022 21:52:11 +0000 Subject: [PATCH] [extractor/wistia] Add support for channels (#4819) Fixes https://github.com/yt-dlp/yt-dlp/issues/4748 Related: https://github.com/yt-dlp/yt-dlp/issues/4985 Authored by: coletdjnz --- yt_dlp/extractor/_extractors.py | 1 + yt_dlp/extractor/generic.py | 30 ---- yt_dlp/extractor/wistia.py | 237 +++++++++++++++++++++++++++----- 3 files changed, 201 insertions(+), 67 deletions(-) diff --git a/yt_dlp/extractor/_extractors.py b/yt_dlp/extractor/_extractors.py index e24787136..c2575bc92 100644 --- a/yt_dlp/extractor/_extractors.py +++ b/yt_dlp/extractor/_extractors.py @@ -2142,6 +2142,7 @@ from .whowatch import WhoWatchIE from .wistia import ( WistiaIE, WistiaPlaylistIE, + WistiaChannelIE, ) from .worldstarhiphop import WorldStarHipHopIE from .wppilot import ( diff --git a/yt_dlp/extractor/generic.py b/yt_dlp/extractor/generic.py index fadc0819b..672034c6d 100644 --- a/yt_dlp/extractor/generic.py +++ b/yt_dlp/extractor/generic.py @@ -873,24 +873,6 @@ class GenericIE(InfoExtractor): 'thumbnail': r're:^https?://.*\.jpg$', }, }, - # Wistia embed - { - 'url': 'http://study.com/academy/lesson/north-american-exploration-failed-colonies-of-spain-france-england.html#lesson', - 'md5': 'b9676d24bf30945d97060638fbfe77f0', - 'info_dict': { - 'id': '5vd7p4bct5', - 'ext': 'bin', - 'title': 'md5:db27290a04ae306319b0b5cce3cdf7bd', - 'description': 'md5:e835b7808e11aaef29ccdc28888437af', - 'duration': 623.019, - 'uploader': 'study.com', - 'timestamp': 1663258727, - 'upload_date': '20220915', - 'filesize': 29798093, - 'age_limit': 0, - 'thumbnail': r're:^https?://.+\.jpg$', - }, - }, # Wistia standard embed (async) { 'url': 'https://www.getdrip.com/university/brennan-dunn-drip-workshop/', @@ -908,18 +890,6 @@ class GenericIE(InfoExtractor): }, 'skip': 'webpage 404 not found', }, - # Wistia embed with video IDs in query - { - 'url': 'https://amplitude.com/amplify-sessions?amp%5Bwmediaid%5D=pz0m0l0if3&%5Bwvideo%5D=pz0m0l0if3&wchannelid=emyjmwjf79&wmediaid=i8um783bdt', - 'info_dict': { - 'id': 'md5:922795280019b3a70ca133330a4b0108', - 'title': 'Amplify Sessions - Amplitude', - 'description': 'md5:3d271bdee219417bb1c35eeb0937b923', - 'age_limit': 0, - 'thumbnail': r're:^https?://.+\.jpg$', - }, - 'playlist_count': 3, - }, # Soundcloud embed { 'url': 'http://nakedsecurity.sophos.com/2014/10/29/sscc-171-are-you-sure-that-1234-is-a-bad-password-podcast/', diff --git a/yt_dlp/extractor/wistia.py b/yt_dlp/extractor/wistia.py index ba7497493..e1e5855c2 100644 --- a/yt_dlp/extractor/wistia.py +++ b/yt_dlp/extractor/wistia.py @@ -1,30 +1,36 @@ import re +import urllib.error +import urllib.parse +from base64 import b64decode from .common import InfoExtractor from ..utils import ( ExtractorError, float_or_none, int_or_none, - try_call, + parse_qs, + traverse_obj, try_get, + update_url_query, ) class WistiaBaseIE(InfoExtractor): _VALID_ID_REGEX = r'(?P[a-z0-9]{10})' _VALID_URL_BASE = r'https?://(?:\w+\.)?wistia\.(?:net|com)/(?:embed/)?' - _EMBED_BASE_URL = 'http://fast.wistia.com/embed/' + _EMBED_BASE_URL = 'http://fast.wistia.net/embed/' def _download_embed_config(self, config_type, config_id, referer): - base_url = self._EMBED_BASE_URL + '%ss/%s' % (config_type, config_id) + base_url = self._EMBED_BASE_URL + '%s/%s' % (config_type, config_id) embed_config = self._download_json( base_url + '.json', config_id, headers={ 'Referer': referer if referer.startswith('http') else base_url, # Some videos require this. }) - if isinstance(embed_config, dict) and embed_config.get('error'): + error = traverse_obj(embed_config, 'error') + if error: raise ExtractorError( - 'Error while getting the playlist', expected=True) + f'Error while getting the playlist: {error}', expected=True) return embed_config @@ -114,10 +120,38 @@ class WistiaBaseIE(InfoExtractor): 'subtitles': subtitles, } + @classmethod + def _extract_from_webpage(cls, url, webpage): + from .teachable import TeachableIE + + if list(TeachableIE._extract_embed_urls(url, webpage)): + return + + yield from super()._extract_from_webpage(url, webpage) + + @classmethod + def _extract_wistia_async_embed(cls, webpage): + # https://wistia.com/support/embed-and-share/video-on-your-website + # https://wistia.com/support/embed-and-share/channel-embeds + yield from re.finditer( + r'''(?sx) + <(?:div|section)[^>]+class=([\"'])(?:(?!\1).)*?(?Pwistia[a-z_0-9]+)\s*\bwistia_async_(?P[a-z0-9]{10})\b(?:(?!\1).)*?\1 + ''', webpage) + + @classmethod + def _extract_url_media_id(cls, url): + mobj = re.search(r'(?:wmediaid|wvideo(?:id)?)]?=(?P[a-z0-9]{10})', urllib.parse.unquote_plus(url)) + if mobj: + return mobj.group('id') + class WistiaIE(WistiaBaseIE): _VALID_URL = r'(?:wistia:|%s(?:iframe|medias)/)%s' % (WistiaBaseIE._VALID_URL_BASE, WistiaBaseIE._VALID_ID_REGEX) - _EMBED_REGEX = [r'<(?:meta[^>]+?content|(?:iframe|script)[^>]+?src)=["\'](?P(?:https?:)?//(?:fast\.)?wistia\.(?:net|com)/embed/(?:iframe|medias)/[a-z0-9]{10})'] + _EMBED_REGEX = [ + r'''(?x) + <(?:meta[^>]+?content|(?:iframe|script)[^>]+?src)=["\'] + (?P(?:https?:)?//(?:fast\.)?wistia\.(?:net|com)/embed/(?:iframe|medias)/[a-z0-9]{10}) + '''] _TESTS = [{ # with hls video 'url': 'wistia:807fafadvk', @@ -131,7 +165,20 @@ class WistiaIE(WistiaBaseIE): 'timestamp': 1463607249, 'duration': 4987.11, }, - 'skip': 'webpage 404 not found', + 'skip': 'video unavailable', + }, { + 'url': 'wistia:a6ndpko1wg', + 'md5': '10c1ce9c4dde638202513ed17a3767bd', + 'info_dict': { + 'id': 'a6ndpko1wg', + 'ext': 'bin', + 'title': 'Episode 2: Boxed Water\'s retention is thirsty', + 'upload_date': '20210324', + 'description': 'md5:da5994c2c2d254833b412469d9666b7a', + 'duration': 966.0, + 'timestamp': 1616614369, + 'thumbnail': 'https://embed-ssl.wistia.com/deliveries/53dc60239348dc9b9fba3755173ea4c2.bin', + } }, { 'url': 'wistia:5vd7p4bct5', 'md5': 'b9676d24bf30945d97060638fbfe77f0', @@ -159,41 +206,53 @@ class WistiaIE(WistiaBaseIE): 'only_matching': True, }] - # https://wistia.com/support/embed-and-share/video-on-your-website + _WEBPAGE_TESTS = [{ + 'url': 'https://www.weidert.com/blog/wistia-channels-video-marketing-tool', + 'info_dict': { + 'id': 'cqwukac3z1', + 'ext': 'bin', + 'title': 'How Wistia Channels Can Help Capture Inbound Value From Your Video Content', + 'duration': 158.125, + 'timestamp': 1618974400, + 'description': 'md5:27abc99a758573560be72600ef95cece', + 'upload_date': '20210421', + 'thumbnail': 'https://embed-ssl.wistia.com/deliveries/6c551820ae950cdee2306d6cbe9ef742.bin', + } + }, { + 'url': 'https://study.com/academy/lesson/north-american-exploration-failed-colonies-of-spain-france-england.html#lesson', + 'md5': 'b9676d24bf30945d97060638fbfe77f0', + 'info_dict': { + 'id': '5vd7p4bct5', + 'ext': 'bin', + 'title': 'paywall_north-american-exploration-failed-colonies-of-spain-france-england', + 'upload_date': '20220915', + 'timestamp': 1663258727, + 'duration': 623.019, + 'thumbnail': 'https://embed-ssl.wistia.com/deliveries/83e6ec693e2c05a0ce65809cbaead86a.bin', + 'description': 'a Paywall Videos video', + }, + }] + + def _real_extract(self, url): + video_id = self._match_id(url) + embed_config = self._download_embed_config('medias', video_id, url) + return self._extract_media(embed_config) + @classmethod def _extract_embed_urls(cls, url, webpage): urls = list(super()._extract_embed_urls(url, webpage)) - - for match in re.finditer( - r'''(?sx) - ]+class=(["'])(?:(?!\1).)*?\bwistia_async_(?P[a-z0-9]{10})\b(?:(?!\1).)*?\1 - ''', webpage): - urls.append('wistia:%s' % match.group('id')) - for match in re.finditer(r'(?:data-wistia-?id=["\']|Wistia\.embed\(["\']|id=["\']wistia_)(?P[a-z0-9]{10})', webpage): - urls.append('wistia:%s' % match.group('id')) - for match in re.finditer(r'(?:wmediaid|wvideo(?:id)?)(?:%5D)?=(?P[a-z0-9]{10})', url): + for match in cls._extract_wistia_async_embed(webpage): + if match.group('type') != 'wistia_channel': + urls.append('wistia:%s' % match.group('id')) + for match in re.finditer(r'(?:data-wistia-?id=["\']|Wistia\.embed\(["\']|id=["\']wistia_)(?P[a-z0-9]{10})', + webpage): urls.append('wistia:%s' % match.group('id')) + if not WistiaChannelIE._extract_embed_urls(url, webpage): # Fallback + media_id = cls._extract_url_media_id(url) + if media_id: + urls.append('wistia:%s' % match.group('id')) return urls - @classmethod - def _extract_from_webpage(cls, url, webpage): - from .teachable import TeachableIE - - if list(TeachableIE._extract_embed_urls(url, webpage)): - return - - for entry in super()._extract_from_webpage(url, webpage): - yield { - **entry, - '_type': 'url_transparent', - 'uploader': try_call(lambda: re.match(r'(?:https?://)?([^/]+)/', url).group(1)), - } - - def _real_extract(self, url): - video_id = self._match_id(url) - embed_config = self._download_embed_config('media', video_id, url) - return self._extract_media(embed_config) - class WistiaPlaylistIE(WistiaBaseIE): _VALID_URL = r'%splaylists/%s' % (WistiaBaseIE._VALID_URL_BASE, WistiaBaseIE._VALID_ID_REGEX) @@ -208,7 +267,7 @@ class WistiaPlaylistIE(WistiaBaseIE): def _real_extract(self, url): playlist_id = self._match_id(url) - playlist = self._download_embed_config('playlist', playlist_id, url) + playlist = self._download_embed_config('playlists', playlist_id, url) entries = [] for media in (try_get(playlist, lambda x: x[0]['medias']) or []): @@ -218,3 +277,107 @@ class WistiaPlaylistIE(WistiaBaseIE): entries.append(self._extract_media(embed_config)) return self.playlist_result(entries, playlist_id) + + +class WistiaChannelIE(WistiaBaseIE): + _VALID_URL = r'(?:wistiachannel:|%schannel/)%s' % (WistiaBaseIE._VALID_URL_BASE, WistiaBaseIE._VALID_ID_REGEX) + + _TESTS = [{ + # JSON Embed API returns 403, should fall back to webpage + 'url': 'https://fast.wistia.net/embed/channel/yvyvu7wjbg?wchannelid=yvyvu7wjbg', + 'info_dict': { + 'id': 'yvyvu7wjbg', + 'title': 'Copysmith Tutorials and Education!', + 'description': 'Learn all things Copysmith via short and informative videos!' + }, + 'playlist_mincount': 7, + 'expected_warnings': ['falling back to webpage'], + }, { + 'url': 'https://fast.wistia.net/embed/channel/3802iirk0l', + 'info_dict': { + 'id': '3802iirk0l', + 'title': 'The Roof', + }, + 'playlist_mincount': 20, + }, { + # link to popup video, follow --no-playlist + 'url': 'https://fast.wistia.net/embed/channel/3802iirk0l?wchannelid=3802iirk0l&wmediaid=sp5dqjzw3n', + 'info_dict': { + 'id': 'sp5dqjzw3n', + 'ext': 'bin', + 'title': 'The Roof S2: The Modern CRO', + 'thumbnail': 'https://embed-ssl.wistia.com/deliveries/dadfa9233eaa505d5e0c85c23ff70741.bin', + 'duration': 86.487, + 'description': 'A sales leader on The Roof? Man, they really must be letting anyone up here this season.\n', + 'timestamp': 1619790290, + 'upload_date': '20210430', + }, + 'params': {'noplaylist': True, 'skip_download': True}, + }] + _WEBPAGE_TESTS = [{ + 'url': 'https://www.profitwell.com/recur/boxed-out', + 'info_dict': { + 'id': '6jyvmqz6zs', + 'title': 'Boxed Out', + 'description': 'md5:14a8a93a1dbe236718e6a59f8c8c7bae', + }, + 'playlist_mincount': 30, + }, { + # section instead of div + 'url': 'https://360learning.com/studio/onboarding-joei/', + 'info_dict': { + 'id': 'z874k93n2o', + 'title': 'Onboarding Joei.', + 'description': 'Coming to you weekly starting Feb 19th.', + }, + 'playlist_mincount': 20, + }, { + 'url': 'https://amplitude.com/amplify-sessions?amp%5Bwmediaid%5D=pz0m0l0if3&%5Bwvideo%5D=pz0m0l0if3&wchannelid=emyjmwjf79&wmediaid=i8um783bdt', + 'info_dict': { + 'id': 'pz0m0l0if3', + 'title': 'A Framework for Improving Product Team Performance', + 'ext': 'bin', + 'timestamp': 1653935275, + 'upload_date': '20220530', + 'description': 'Learn how to help your company improve and achieve your product related goals.', + 'duration': 1854.39, + 'thumbnail': 'https://embed-ssl.wistia.com/deliveries/12fd19e56413d9d6f04e2185c16a6f8854e25226.bin', + }, + 'params': {'noplaylist': True, 'skip_download': True}, + }] + + def _real_extract(self, url): + channel_id = self._match_id(url) + media_id = self._extract_url_media_id(url) + if not self._yes_playlist(channel_id, media_id, playlist_label='channel'): + return self.url_result(f'wistia:{media_id}', 'Wistia') + + try: + data = self._download_embed_config('channel', channel_id, url) + except (ExtractorError, urllib.error.HTTPError): + # Some channels give a 403 from the JSON API + self.report_warning('Failed to download channel data from API, falling back to webpage.') + webpage = self._download_webpage(f'https://fast.wistia.net/embed/channel/{channel_id}', channel_id) + data = self._parse_json( + self._search_regex(r'wchanneljsonp-%s\'\]\s*=[^\"]*\"([A-Za-z0-9=/]*)' % channel_id, webpage, 'jsonp', channel_id), + channel_id, transform_source=lambda x: urllib.parse.unquote_plus(b64decode(x).decode('utf-8'))) + + # XXX: can there be more than one series? + series = traverse_obj(data, ('series', 0), default={}) + + entries = [ + self.url_result(f'wistia:{video["hashedId"]}', WistiaIE, title=video.get('name')) + for video in traverse_obj(series, ('sections', ..., 'videos', ...)) or [] + if video.get('hashedId') + ] + + return self.playlist_result( + entries, channel_id, playlist_title=series.get('title'), playlist_description=series.get('description')) + + @classmethod + def _extract_embed_urls(cls, url, webpage): + yield from super()._extract_embed_urls(url, webpage) + for match in cls._extract_wistia_async_embed(webpage): + if match.group('type') == 'wistia_channel': + # original url may contain wmediaid query param + yield update_url_query(f'wistiachannel:{match.group("id")}', parse_qs(url))