+ }]
+
+ def _real_extract(self, url):
+ slug, episode = self._match_valid_url(url).group('id', 'ep')
+ url, smuggled_data = unsmuggle_url(url, {})
+ if smuggled_data.get('id'):
+ return {
+ 'id': smuggled_data['id'],
+ 'display_id': slug,
+ 'title': '',
+ **self._extract_formats(smuggled_data['id'], slug),
+ }
+
+ metadata = self._call_api(
+ f'https://content.api.nebula.app/content/{slug}/{episode}/?include=lessons',
+ slug, note='Fetching class/podcast metadata')
+ content_type = metadata.get('type')
+ if content_type == 'lesson':
+ return {
+ **self._extract_video_metadata(metadata),
+ **self._extract_formats(metadata['id'], slug),
+ }
+ elif content_type == 'podcast_episode':
+ episode_url = metadata['episode_url']
+ if not episode_url and metadata.get('premium'):
+ self.raise_login_required()
+
+ if Art19IE.suitable(episode_url):
+ return self.url_result(episode_url, Art19IE)
+ return traverse_obj(metadata, {
+ 'id': ('id', {str}),
+ 'url': ('episode_url', {url_or_none}),
+ 'title': ('title', {str}),
+ 'description': ('description', {str}),
+ 'timestamp': ('published_at', {parse_iso8601}),
+ 'duration': ('duration', {int_or_none}),
+ 'channel_id': ('channel_id', {str}),
+ 'chnanel': ('channel_title', {str}),
+ 'thumbnail': ('assets', 'regular', {url_or_none}),
+ })
+
+ raise ExtractorError(f'Unexpected content type {content_type!r}')
+
+
+class NebulaSubscriptionsIE(NebulaBaseIE):
+ IE_NAME = 'nebula:subscriptions'
+ _VALID_URL = rf'{_BASE_URL_RE}/(?P<id>myshows|library/latest-videos)/?(?:$|[?#])'
+ _TESTS = [{
+ 'url': 'https://nebula.tv/myshows',
+ 'playlist_mincount': 1,
+ 'info_dict': {
+ 'id': 'myshows',
+ },
+ }]
+
+ def _generate_playlist_entries(self):
+ next_url = update_url_query('https://content.api.nebula.app/video_episodes/', {
+ 'following': 'true',
+ 'include': 'engagement',
+ 'ordering': '-published_at',
+ })
+ for page_num in itertools.count(1):
+ channel = self._call_api(
+ next_url, 'myshows', note=f'Retrieving subscriptions page {page_num}')
+ for episode in channel['results']:
+ metadata = self._extract_video_metadata(episode)
+ yield self.url_result(smuggle_url(
+ f'https://nebula.tv/videos/{metadata["display_id"]}',
+ {'id': episode['id']}), NebulaIE, url_transparent=True, **metadata)
+ next_url = channel.get('next')
+ if not next_url:
+ return
+
+ def _real_extract(self, url):
+ return self.playlist_result(self._generate_playlist_entries(), 'myshows')
+
+
+class NebulaChannelIE(NebulaBaseIE):
+ IE_NAME = 'nebula:channel'
+ _VALID_URL = rf'{_BASE_URL_RE}/(?!myshows|library|videos)(?P<id>[\w-]+)/?(?:$|[?#])'
+ _TESTS = [{
+ 'url': 'https://nebula.tv/tom-scott-presents-money',
+ 'info_dict': {
+ 'id': 'tom-scott-presents-money',
+ 'title': 'Tom Scott Presents: Money',
+ 'description': 'Tom Scott hosts a series all about trust, negotiation and money.',
+ },
+ 'playlist_count': 5,
+ }, {
+ 'url': 'https://nebula.tv/lindsayellis',
+ 'info_dict': {
+ 'id': 'lindsayellis',
+ 'title': 'Lindsay Ellis',
+ 'description': 'Enjoy these hottest of takes on Disney, Transformers, and Musicals.',
+ },
+ 'playlist_mincount': 2,
+ }, {
+ 'url': 'https://nebula.tv/johnnyharris',
+ 'info_dict': {
+ 'id': 'johnnyharris',
+ 'title': 'Johnny Harris',
+ 'description': 'I make videos about maps and many other things.',
+ },
+ 'playlist_mincount': 90,
+ }, {
+ 'url': 'https://nebula.tv/copyright-for-fun-and-profit',
+ 'info_dict': {
+ 'id': 'copyright-for-fun-and-profit',
+ 'title': 'Copyright for Fun and Profit',
+ 'description': 'md5:6690248223eed044a9f11cd5a24f9742',
+ },
+ 'playlist_count': 23,
+ }, {
+ 'url': 'https://nebula.tv/trussissuespodcast',
+ 'info_dict': {
+ 'id': 'trussissuespodcast',
+ 'title': 'The TLDR News Podcast',
+ 'description': 'md5:a08c4483bc0b705881d3e0199e721385',
+ },
+ 'playlist_mincount': 80,
+ }]
+
+ def _generate_playlist_entries(self, collection_id, collection_slug):
+ next_url = f'https://content.api.nebula.app/video_channels/{collection_id}/video_episodes/?ordering=-published_at'
+ for page_num in itertools.count(1):
+ episodes = self._call_api(next_url, collection_slug, note=f'Retrieving channel page {page_num}')
+ for episode in episodes['results']:
+ metadata = self._extract_video_metadata(episode)
+ yield self.url_result(smuggle_url(
+ episode.get('share_url') or f'https://nebula.tv/videos/{metadata["display_id"]}',
+ {'id': episode['id']}), NebulaIE, url_transparent=True, **metadata)
+ next_url = episodes.get('next')
+ if not next_url:
+ break
+
+ def _generate_class_entries(self, channel):
+ for lesson in channel['lessons']:
+ metadata = self._extract_video_metadata(lesson)
+ yield self.url_result(smuggle_url(
+ lesson.get('share_url') or f'https://nebula.tv/{metadata["class_slug"]}/{metadata["slug"]}',
+ {'id': lesson['id']}), NebulaClassIE, url_transparent=True, **metadata)
+
+ def _generate_podcast_entries(self, collection_id, collection_slug):
+ next_url = f'https://content.api.nebula.app/podcast_channels/{collection_id}/podcast_episodes/?ordering=-published_at&premium=true'
+ for page_num in itertools.count(1):
+ episodes = self._call_api(next_url, collection_slug, note=f'Retrieving podcast page {page_num}')
+
+ for episode in traverse_obj(episodes, ('results', lambda _, v: url_or_none(v['share_url']))):
+ yield self.url_result(episode['share_url'], NebulaClassIE)
+ next_url = episodes.get('next')