Repository: yt-dlp/yt-dlp

Issue:
Support for Nebula Podcasts
### DO NOT REMOVE OR SKIP THE ISSUE TEMPLATE

- [X] I understand that I will be **blocked** if I *intentionally* remove or skip any mandatory\* field

### Checklist

- [X] I'm requesting a site-specific feature
- [X] I've verified that I have **updated yt-dlp to nightly or master** ([update instructions](https://github.com/yt-dlp/yt-dlp#update-channels))
- [X] I've checked that all provided URLs are playable in a browser with the same IP and same login details
- [X] I've searched [known issues](https://github.com/yt-dlp/yt-dlp/issues/3766) and the [bugtracker](https://github.com/yt-dlp/yt-dlp/issues?q=) for similar issues **including closed ones**. DO NOT post duplicates
- [X] I've read the [guidelines for opening an issue](https://github.com/yt-dlp/yt-dlp/blob/master/CONTRIBUTING.md#opening-an-issue)
- [X] I've read about [sharing account credentials](https://github.com/yt-dlp/yt-dlp/blob/master/CONTRIBUTING.md#are-you-willing-to-share-account-details-if-needed) and I'm willing to share it if required

### Region

USA

### Example URLs

https://nebula.tv/probablynotaliens/its-probably-not-aliens-holiday-special-protocol-atlantis

### Provide a description that is worded well enough to be understood

Nebula support works great for video links, but Nebula also hosts various podcasts. When providing yt-dlp with a Nebula podcast link, it tries to download a video associated with the link instead of the audio file.

### Provide verbose output that clearly demonstrates the problem

- [X] Run **your** yt-dlp command with **-vU** flag added (`yt-dlp -vU <your command line>`)
- [ ] If using API, add `'verbose': True` to `YoutubeDL` params instead
- [X] Copy the WHOLE output (starting with `[debug] Command-line config`) and insert it below

### Complete Verbose Output

```shell
[debug] Command-line config: ['-vU', 'https://nebula.tv/probablynotaliens/its-probably-not-aliens-holiday-special-protocol-atlantis']
[debug] User config "/home/user/.config/yt-dlp/config": ['--netrc']
[debug] Encodings: locale UTF-8, fs utf-8, pref UTF-8, out utf-8, error utf-8, screen utf-8
[debug] yt-dlp version stable@2023.11.16 from yt-dlp/yt-dlp [24f827875] (debian*)
[debug] Python 3.10.12 (CPython x86_64 64bit) - Linux-6.6.6-76060606-generic-x86_64-with-glibc2.35 (OpenSSL 3.0.2 15 Mar 2022, glibc 2.35)
[debug] exe versions: ffmpeg 4.4.2 (setts), ffprobe 4.4.2, rtmpdump 2.4
[debug] Optional libraries: Cryptodome-3.11.0, brotli-1.0.9, certifi-2020.06.20, mutagen-1.45.1, pyxattr-0.7.2, requests-2.25.1, secretstorage-3.3.1, sqlite3-3.37.2, urllib3-1.26.5, websockets-9.1
[debug] Proxy map: {}
[debug] Request Handlers: urllib
[debug] Loaded 1901 extractors
[debug] Fetching release info: https://api.github.com/repos/yt-dlp/yt-dlp/releases/latest
Latest version: stable@2023.11.16 from yt-dlp/yt-dlp
yt-dlp is up to date (stable@2023.11.16 from yt-dlp/yt-dlp)
[nebula:channel] Logging in to Nebula with supplied credentials
[nebula:channel] Authorizing to Nebula
[nebula:channel] Extracting URL: https://nebula.tv/probablynotaliens/its-probably-not-aliens-holiday-special-protocol-atlantis
[nebula:channel] probablynotaliens: Retrieving channel
ERROR: [nebula:channel] probablynotaliens: Unable to download JSON metadata: HTTP Error 404: Not Found (caused by <HTTPError 404: Not Found>); please report this issue on  https://github.com/yt-dlp/yt-dlp/issues?q= , filling out the appropriate issue template. Confirm you are on the latest version using  yt-dlp -U
  File "/usr/lib/python3/dist-packages/yt_dlp/extractor/common.py", line 715, in extract
    ie_result = self._real_extract(url)
  File "/usr/lib/python3/dist-packages/yt_dlp/extractor/nebula.py", line 273, in _real_extract
    channel = self._call_nebula_api(channel_url, collection_id, auth_type='bearer', note='Retrieving channel')
  File "/usr/lib/python3/dist-packages/yt_dlp/extractor/nebula.py", line 48, in _call_nebula_api
    return inner_call()
  File "/usr/lib/python3/dist-packages/yt_dlp/extractor/nebula.py", line 43, in inner_call
    return self._download_json(
  File "/usr/lib/python3/dist-packages/yt_dlp/extractor/common.py", line 1069, in download_content
    res = getattr(self, download_handle.__name__)(url_or_request, video_id, **kwargs)
  File "/usr/lib/python3/dist-packages/yt_dlp/extractor/common.py", line 1033, in download_handle
    res = self._download_webpage_handle(
  File "/usr/lib/python3/dist-packages/yt_dlp/extractor/common.py", line 903, in _download_webpage_handle
    urlh = self._request_webpage(url_or_request, video_id, note, errnote, fatal, data=data, headers=headers, query=query, expected_status=expected_status)
  File "/usr/lib/python3/dist-packages/yt_dlp/extractor/common.py", line 860, in _request_webpage
    raise ExtractorError(errmsg, cause=err)

  File "/usr/lib/python3/dist-packages/yt_dlp/networking/_urllib.py", line 396, in _send
    res = opener.open(urllib_req, timeout=float(request.extensions.get('timeout') or self.timeout))
  File "/usr/lib/python3.10/urllib/request.py", line 525, in open
    response = meth(req, response)
  File "/usr/lib/python3.10/urllib/request.py", line 634, in http_response
    response = self.parent.error(
  File "/usr/lib/python3.10/urllib/request.py", line 563, in error
    return self._call_chain(*args)
  File "/usr/lib/python3.10/urllib/request.py", line 496, in _call_chain
    result = func(*args)
  File "/usr/lib/python3.10/urllib/request.py", line 643, in http_error_default
    raise HTTPError(req.full_url, code, msg, hdrs, fp)
urllib.error.HTTPError: HTTP Error 404: Not Found

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "/usr/lib/python3/dist-packages/yt_dlp/YoutubeDL.py", line 4052, in urlopen
    return self._request_director.send(req)
  File "/usr/lib/python3/dist-packages/yt_dlp/networking/common.py", line 114, in send
    response = handler.send(request)
  File "/usr/lib/python3/dist-packages/yt_dlp/networking/_helper.py", line 204, in wrapper
    return func(self, *args, **kwargs)
  File "/usr/lib/python3/dist-packages/yt_dlp/networking/common.py", line 325, in send
    return self._send(request)
  File "/usr/lib/python3/dist-packages/yt_dlp/networking/_urllib.py", line 401, in _send
    raise HTTPError(UrllibResponseAdapter(e.fp), redirect_loop='redirect error' in str(e)) from e
yt_dlp.networking.exceptions.HTTPError: HTTP Error 404: Not Found

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "/usr/lib/python3/dist-packages/yt_dlp/extractor/common.py", line 847, in _request_webpage
    return self._downloader.urlopen(self._create_request(url_or_request, data, headers, query))
  File "/usr/lib/python3/dist-packages/yt_dlp/YoutubeDL.py", line 4074, in urlopen
    raise _CompatHTTPError(e) from e
yt_dlp.networking.exceptions._CompatHTTPError: HTTP Error 404: Not Found
```



Discussion:
Not sure if the site has changed or I overlooked something that requires art19 extractor, but it seems the podcast url can be directly extracted like this.

```Python
class NebulaPodcastIE(NebulaBaseIE):
    IE_NAME = 'nebula:podcast'
    _VALID_URL = rf'{_BASE_URL_RE}/(?!myshows|library|videos/)(?P<id>[-\w]+/[-\w]+)/?(?:$|[?#])'

    def _real_extract(self, url):
        slug = self._match_id(url)
        data = self._call_api(
            f'https://content.api.nebula.app/content/{slug}/?include=lessons',
            slug, note='Retrieving podcast episode')

        return traverse_obj(data, {
            'id': ('id', {str}),
            'url': ('episode_url', {url_or_none}),
            'title': ('title', {str}),
        })
```

Resolving patch:
diff --git a/yt_dlp/extractor/nebula.py b/yt_dlp/extractor/nebula.py
--- a/yt_dlp/extractor/nebula.py
+++ b/yt_dlp/extractor/nebula.py
@@ -1,6 +1,7 @@
 import itertools
 import json
 
+from .art19 import Art19IE
 from .common import InfoExtractor
 from ..networking.exceptions import HTTPError
 from ..utils import (
@@ -112,7 +113,8 @@ def _extract_video_metadata(self, episode):
 
 
 class NebulaIE(NebulaBaseIE):
-    _VALID_URL = rf'{_BASE_URL_RE}/videos/(?P<id>[-\w]+)'
+    IE_NAME = 'nebula:video'
+    _VALID_URL = rf'{_BASE_URL_RE}/videos/(?P<id>[\w-]+)'
     _TESTS = [{
         'url': 'https://nebula.tv/videos/that-time-disney-remade-beauty-and-the-beast',
         'info_dict': {
@@ -236,8 +238,8 @@ def _real_extract(self, url):
 
 
 class NebulaClassIE(NebulaBaseIE):
-    IE_NAME = 'nebula:class'
-    _VALID_URL = rf'{_BASE_URL_RE}/(?P<id>[-\w]+)/(?P<ep>\d+)'
+    IE_NAME = 'nebula:media'
+    _VALID_URL = rf'{_BASE_URL_RE}/(?!(?:myshows|library|videos)/)(?P<id>[\w-]+)/(?P<ep>[\w-]+)/?(?:$|[?#])'
     _TESTS = [{
         'url': 'https://nebula.tv/copyright-for-fun-and-profit/14',
         'info_dict': {
@@ -253,6 +255,46 @@ class NebulaClassIE(NebulaBaseIE):
             'title': 'Photos, Sculpture, and Video',
         },
         'params': {'skip_download': 'm3u8'},
+    }, {
+        'url': 'https://nebula.tv/extremitiespodcast/pyramiden-the-high-arctic-soviet-ghost-town',
+        'info_dict': {
+            'ext': 'mp3',
+            'id': '018f65f0-0033-4021-8f87-2d132beb19aa',
+            'description': 'md5:05d2b23ab780c955e2511a2b9127acff',
+            'series_id': '335e8159-d663-491a-888f-1732285706ac',
+            'modified_timestamp': 1599091504,
+            'episode_id': '018f65f0-0033-4021-8f87-2d132beb19aa',
+            'series': 'Extremities',
+            'modified_date': '20200903',
+            'upload_date': '20200902',
+            'title': 'Pyramiden: The High-Arctic Soviet Ghost Town',
+            'release_timestamp': 1571237958,
+            'thumbnail': r're:^https?://content\.production\.cdn\.art19\.com.*\.jpeg$',
+            'duration': 1546.05714,
+            'timestamp': 1599085608,
+            'release_date': '20191016',
+        },
+    }, {
+        'url': 'https://nebula.tv/thelayover/the-layover-episode-1',
+        'info_dict': {
+            'ext': 'mp3',
+            'id': '9d74a762-00bb-45a8-9e8d-9ed47c04a1d0',
+            'episode_number': 1,
+            'thumbnail': r're:^https?://content\.production\.cdn\.art19\.com.*\.jpeg$',
+            'release_date': '20230304',
+            'modified_date': '20230403',
+            'series': 'The Layover',
+            'episode_id': '9d74a762-00bb-45a8-9e8d-9ed47c04a1d0',
+            'modified_timestamp': 1680554566,
+            'duration': 3130.46401,
+            'release_timestamp': 1677943800,
+            'title': 'The Layover — Episode 1',
+            'series_id': '874303a5-4900-4626-a4b6-2aacac34466a',
+            'upload_date': '20230303',
+            'episode': 'Episode 1',
+            'timestamp': 1677883672,
+            'description': 'md5:002cca89258e3bc7c268d5b8c24ba482',
+        },
     }]
 
     def _real_extract(self, url):
@@ -268,16 +310,38 @@ def _real_extract(self, url):
 
         metadata = self._call_api(
             f'https://content.api.nebula.app/content/{slug}/{episode}/?include=lessons',
-            slug, note='Fetching video metadata')
-        return {
-            **self._extract_video_metadata(metadata),
-            **self._extract_formats(metadata['id'], slug),
-        }
+            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)'
+    _VALID_URL = rf'{_BASE_URL_RE}/(?P<id>myshows|library/latest-videos)/?(?:$|[?#])'
     _TESTS = [{
         'url': 'https://nebula.tv/myshows',
         'playlist_mincount': 1,
@@ -310,7 +374,7 @@ def _real_extract(self, url):
 
 class NebulaChannelIE(NebulaBaseIE):
     IE_NAME = 'nebula:channel'
-    _VALID_URL = rf'{_BASE_URL_RE}/(?!myshows|library|videos/)(?P<id>[-\w]+)/?(?:$|[?#])'
+    _VALID_URL = rf'{_BASE_URL_RE}/(?!myshows|library|videos)(?P<id>[\w-]+)/?(?:$|[?#])'
     _TESTS = [{
         'url': 'https://nebula.tv/tom-scott-presents-money',
         'info_dict': {
@@ -343,6 +407,14 @@ class NebulaChannelIE(NebulaBaseIE):
             '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):
@@ -365,6 +437,17 @@ def _generate_class_entries(self, channel):
                 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')
+            if not next_url:
+                break
+
     def _real_extract(self, url):
         collection_slug = self._match_id(url)
         channel = self._call_api(
@@ -373,6 +456,8 @@ def _real_extract(self, url):
 
         if channel.get('type') == 'class':
             entries = self._generate_class_entries(channel)
+        elif channel.get('type') == 'podcast_channel':
+            entries = self._generate_podcast_entries(channel['id'], collection_slug)
         else:
             entries = self._generate_playlist_entries(channel['id'], collection_slug)
 


Test patch: