Repository: yt-dlp/yt-dlp
Pull request #5483 (2022-11-09 03:43:02)

[extractor/unsupported] Add extractor
**IMPORTANT**: PRs without the template will be CLOSED

### Description of your *pull request* and other information

</details>

<!--

Explanation of your *pull request* in arbitrary form goes here. Please **make sure the description explains the purpose and effect** of your *pull request* and is worded well enough to be understood. Provide as much **context and examples** as possible

-->

ADD DESCRIPTION HERE

As discussed in discord. Raise an error for sites that are known to be entirely DRM'd and will not be supported by yt-dlp. This is for when we have to remove support for a site because it has gone full-DRM.

I've added some to start with that have commonly shown up in our issue tracker. Open to suggestions of more to add.

<details open><summary>Template</summary> <!-- OPEN is intentional -->

<!--

# PLEASE FOLLOW THE GUIDE BELOW

- You will be asked some questions, please read them **carefully** and answer honestly
- Put an `x` into all the boxes `[ ]` relevant to your *pull request* (like [x])
- Use *Preview* tab to see how your *pull request* will actually look like

-->

### Before submitting a *pull request* make sure you have:
- [X] At least skimmed through [contributing guidelines](https://github.com/yt-dlp/yt-dlp/blob/master/CONTRIBUTING.md#developer-instructions) including [yt-dlp coding conventions](https://github.com/yt-dlp/yt-dlp/blob/master/CONTRIBUTING.md#yt-dlp-coding-conventions)
- [X] [Searched](https://github.com/yt-dlp/yt-dlp/search?q=is%3Apr&type=Issues) the bugtracker for similar pull requests
- [X] Checked the code with [flake8](https://pypi.python.org/pypi/flake8) and [ran relevant tests](https://github.com/yt-dlp/yt-dlp/blob/master/CONTRIBUTING.md#developer-instructions)

### In order to be accepted and merged into yt-dlp each piece of code must be in public domain or released under [Unlicense](http://unlicense.org/). Check one of the following options:
- [X] I am the original author of this code and I am willing to release it under [Unlicense](http://unlicense.org/)
- [ ] I am not the original author of this code but it is in public domain or released under [Unlicense](http://unlicense.org/) (provide reliable evidence)

### What is the purpose of your *pull request*?
- [ ] Fix or improvement to an extractor (Make sure to add/update tests)
- [X] New extractor ([Piracy websites will not be accepted](https://github.com/yt-dlp/yt-dlp/blob/master/CONTRIBUTING.md#is-the-website-primarily-used-for-piracy))
- [ ] Core bug fix/improvement
- [X] New feature (It is strongly [recommended to open an issue first](https://github.com/yt-dlp/yt-dlp/blob/master/CONTRIBUTING.md#adding-new-feature-or-making-overarching-changes))


coletdjnz: After this we can look into removing some of the extractors where the sites have gone full DRM, and add a message in this one to let users know.

Some that came across (will need double checking):
- rmcbfmplay.com / rmcstory.bfmtv / rmcdecouverte.bfmtv
   - https://github.com/yt-dlp/yt-dlp/issues/3594, https://github.com/yt-dlp/yt-dlp/issues/309
- TVNowIE
   - https://github.com/yt-dlp/yt-dlp/issues/1345
pukkandan: > After this we can look into removing some of the extractors where the sites have gone full DRM, and add a message in this one to let users know.

If the current extractor gives correct error message, it is better to leave it be because
1. Just in case there is still non-DRM videos left
2. The metadata extraction may be useful to someone. No need to intentionally break it

Patch:
diff --git a/README.md b/README.md
index e094ccba7..e9ea99ebf 100644
--- a/README.md
+++ b/README.md
@@ -88,7 +88,7 @@ # NEW FEATURES
     * Supports some (but not all) age-gated content without cookies
     * Download livestreams from the start using `--live-from-start` (*experimental*)
     * `255kbps` audio is extracted (if available) from YouTube Music when premium cookies are given
-    * Redirect channel's home URL automatically to `/video` to preserve the old behaviour
+    * Channel URLs download all uploads of the channel, including shorts and live
 
 * **Cookies from browser**: Cookies can be automatically extracted from all major web browsers using `--cookies-from-browser BROWSER[+KEYRING][:PROFILE][::CONTAINER]`
 
@@ -142,7 +142,7 @@ ### Differences in default behavior
 * `playlist_index` behaves differently when used with options like `--playlist-reverse` and `--playlist-items`. See [#302](https://github.com/yt-dlp/yt-dlp/issues/302) for details. You can use `--compat-options playlist-index` if you want to keep the earlier behavior
 * The output of `-F` is listed in a new format. Use `--compat-options list-formats` to revert this
 * Live chats (if available) are considered as subtitles. Use `--sub-langs all,-live_chat` to download all subtitles except live chat. You can also use `--compat-options no-live-chat` to prevent any live chat/danmaku from downloading
-* YouTube channel URLs are automatically redirected to `/video`. Append a `/featured` to the URL to download only the videos in the home page. If the channel does not have a videos tab, we try to download the equivalent `UU` playlist instead. For all other tabs, if the channel does not show the requested tab, an error will be raised. Also, `/live` URLs raise an error if there are no live videos instead of silently downloading the entire channel. You may use `--compat-options no-youtube-channel-redirect` to revert all these redirections
+* YouTube channel URLs download all uploads of the channel. To download only the videos in a specific tab, pass the tab's URL. If the channel does not show the requested tab, an error will be raised. Also, `/live` URLs raise an error if there are no live videos instead of silently downloading the entire channel. You may use `--compat-options no-youtube-channel-redirect` to revert all these redirections
 * Unavailable videos are also listed for YouTube playlists. Use `--compat-options no-youtube-unavailable-videos` to remove this
 * The upload dates extracted from YouTube are in UTC [when available](https://github.com/yt-dlp/yt-dlp/blob/89e4d86171c7b7c997c77d4714542e0383bf0db0/yt_dlp/extractor/youtube.py#L3898-L3900). Use `--compat-options no-youtube-prefer-utc-upload-date` to prefer the non-UTC upload date.
 * If `ffmpeg` is used as the downloader, the downloading and merging of formats happen in a single step when possible. Use `--compat-options no-direct-merge` to revert this
diff --git a/yt_dlp/extractor/_extractors.py b/yt_dlp/extractor/_extractors.py
index 97da309c5..0ca8b3e06 100644
--- a/yt_dlp/extractor/_extractors.py
+++ b/yt_dlp/extractor/_extractors.py
@@ -2023,6 +2023,7 @@
 from .unistra import UnistraIE
 from .unity import UnityIE
 from .unscripted import UnscriptedNewsVideoIE
+from .unsupported import KnownDRMIE
 from .uol import UOLIE
 from .uplynk import (
     UplynkIE,
diff --git a/yt_dlp/extractor/bitchute.py b/yt_dlp/extractor/bitchute.py
index f4b6a9a0e..9e3d6337a 100644
--- a/yt_dlp/extractor/bitchute.py
+++ b/yt_dlp/extractor/bitchute.py
@@ -8,6 +8,7 @@
     OnDemandPagedList,
     clean_html,
     get_element_by_class,
+    get_element_by_id,
     get_elements_html_by_class,
     int_or_none,
     orderedSet,
@@ -49,6 +50,16 @@ class BitChuteIE(InfoExtractor):
             'upload_date': '20181113',
         },
         'params': {'check_formats': None},
+    }, {
+        # restricted video
+        'url': 'https://www.bitchute.com/video/WEnQU7XGcTdl/',
+        'info_dict': {
+            'id': 'WEnQU7XGcTdl',
+            'ext': 'mp4',
+            'title': 'Impartial Truth - Ein Letzter Appell an die Vernunft',
+        },
+        'params': {'skip_download': True},
+        'skip': 'Georestricted in DE',
     }, {
         'url': 'https://www.bitchute.com/embed/lbb5G1hjPhw/',
         'only_matching': True,
@@ -56,6 +67,7 @@ class BitChuteIE(InfoExtractor):
         'url': 'https://www.bitchute.com/torrent/Zee5BE49045h/szoMrox2JEI.webtorrent',
         'only_matching': True,
     }]
+    _GEO_BYPASS = False
 
     _HEADERS = {
         'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/69.0.3497.57 Safari/537.36',
@@ -78,11 +90,18 @@ def _check_format(self, video_url, video_id):
                 'filesize': int_or_none(response.headers.get('Content-Length'))
             }
 
+    def _raise_if_restricted(self, webpage):
+        page_title = clean_html(get_element_by_class('page-title', webpage)) or ''
+        if re.fullmatch(r'(?:Channel|Video) Restricted', page_title):
+            reason = clean_html(get_element_by_id('page-detail', webpage)) or page_title
+            self.raise_geo_restricted(reason)
+
     def _real_extract(self, url):
         video_id = self._match_id(url)
         webpage = self._download_webpage(
             f'https://www.bitchute.com/video/{video_id}', video_id, headers=self._HEADERS)
 
+        self._raise_if_restricted(webpage)
         publish_date = clean_html(get_element_by_class('video-publish-date', webpage))
         entries = self._parse_html5_media_entries(url, webpage, video_id)
 
diff --git a/yt_dlp/extractor/unsupported.py b/yt_dlp/extractor/unsupported.py
new file mode 100644
index 000000000..87ad87ca2
--- /dev/null
+++ b/yt_dlp/extractor/unsupported.py
@@ -0,0 +1,93 @@
+from .common import InfoExtractor
+from ..utils import classproperty, ExtractorError
+
+
+class KnownDRMIE(InfoExtractor):
+    IE_DESC = False
+    IE_NAME = 'unsupported:drm'
+    UNSUPPORTED_URLS = (
+        r'play\.hbomax\.com',
+        r'channel(?:4|5)\.com',
+        r'peacocktv\.com',
+        r'(?:[\w\.]+\.)?disneyplus\.com',
+        r'open\.spotify\.com/(?:track|playlist|album|artist)',
+        r'tvnz\.co\.nz',
+        r'oneplus\.ch',
+        r'artstation\.com/learning/courses',
+        r'philo\.com',
+        r'(?:[\w\.]+\.)?mech-plus\.com',
+        r'aha\.video',
+        r'mubi\.com',
+        r'vootkids\.com'
+    )
+
+    _TESTS = [{
+        # https://github.com/yt-dlp/yt-dlp/issues/4309
+        'url': 'https://www.peacocktv.com',
+        'only_matching': True,
+    }, {
+        # https://github.com/yt-dlp/yt-dlp/issues/1719,
+        'url': 'https://www.channel4.com',
+        'only_matching': True,
+    }, {
+        # https://github.com/yt-dlp/yt-dlp/issues/1548
+        'url': 'https://www.channel5.com',
+        'only_matching': True,
+    }, {
+        'url': r'https://hsesn.apps.disneyplus.com',
+        'only_matching': True,
+    }, {
+        'url': r'https://www.disneyplus.com',
+        'only_matching': True,
+    }, {
+        'url': 'https://open.spotify.com/artist/',
+        'only_matching': True,
+    }, {
+        'url': 'https://open.spotify.com/track/',
+        'only_matching': True,
+    }, {
+        # TVNZ: https://github.com/yt-dlp/yt-dlp/issues/4122
+        'url': 'https://tvnz.co.nz',
+        'only_matching': True,
+    }, {
+        # https://github.com/yt-dlp/yt-dlp/issues/1922
+        'url': 'https://www.oneplus.ch',
+        'only_matching': True,
+    }, {
+        # https://github.com/yt-dlp/yt-dlp/issues/1140
+        'url': 'https://www.artstation.com/learning/courses/',
+        'only_matching': True,
+    }, {
+        # https://github.com/yt-dlp/yt-dlp/issues/3544
+        'url': 'https://www.philo.com',
+        'only_matching': True,
+    }, {
+        # https://github.com/yt-dlp/yt-dlp/issues/3533
+        'url': 'https://www.mech-plus.com/',
+        'only_matching': True,
+    }, {
+        'url': 'https://watch.mech-plus.com/',
+        'only_matching': True,
+    }, {
+        # https://github.com/yt-dlp/yt-dlp/issues/2934
+        'url': 'https://www.aha.video',
+        'only_matching': True,
+    }, {
+        # https://github.com/yt-dlp/yt-dlp/issues/2743
+        'url': 'https://mubi.com',
+        'only_matching': True,
+    }, {
+        # https://github.com/yt-dlp/yt-dlp/issues/3287
+        'url': 'https://www.vootkids.com',
+        'only_matching': True,
+    }]
+
+    @classproperty
+    def _VALID_URL(cls):
+        return rf'https?://(?:www\.)?(?:{"|".join(cls.UNSUPPORTED_URLS)})'
+
+    def _real_extract(self, url):
+        raise ExtractorError(
+            f'The requested site is known to use DRM protection. It will {self._downloader._format_err("NOT", self._downloader.Styles.EMPHASIS)} be supported by yt-dlp. '
+            f'Please {self._downloader._format_err("DO NOT", self._downloader.Styles.ERROR)} open an issue, unless you have evidence that it is not DRM protected.',
+            expected=True)
diff --git a/yt_dlp/extractor/youtube.py b/yt_dlp/extractor/youtube.py
index 804d0ea34..33419e74a 100644
--- a/yt_dlp/extractor/youtube.py
+++ b/yt_dlp/extractor/youtube.py
@@ -4589,13 +4589,16 @@ def _entries(self, tab, item_id, ytcfg, account_syncid, visitor_data):
 
     @staticmethod
     def _extract_selected_tab(tabs, fatal=True):
-        for tab in tabs:
-            renderer = dict_get(tab, ('tabRenderer', 'expandableTabRenderer')) or {}
-            if renderer.get('selected') is True:
-                return renderer
-        else:
-            if fatal:
-                raise ExtractorError('Unable to find selected tab')
+        for tab_renderer in tabs:
+            if tab_renderer.get('selected'):
+                return tab_renderer
+        if fatal:
+            raise ExtractorError('Unable to find selected tab')
+
+    @staticmethod
+    def _extract_tab_renderers(response):
+        return traverse_obj(
+            response, ('contents', 'twoColumnBrowseResultsRenderer', 'tabs', ..., ('tabRenderer', 'expandableTabRenderer')), expected_type=dict)
 
     def _extract_from_tabs(self, item_id, ytcfg, data, tabs):
         playlist_id = title = description = channel_url = channel_name = channel_id = None
@@ -4897,8 +4900,7 @@ def _extract_data(self, url, item_id, ytcfg=None, fatal=True, webpage_fatal=Fals
             webpage, data = self._extract_webpage(url, item_id, fatal=webpage_fatal)
             ytcfg = ytcfg or self.extract_ytcfg(item_id, webpage)
             # Reject webpage data if redirected to home page without explicitly requesting
-            selected_tab = self._extract_selected_tab(traverse_obj(
-                data, ('contents', 'twoColumnBrowseResultsRenderer', 'tabs'), expected_type=list, default=[]), fatal=False) or {}
+            selected_tab = self._extract_selected_tab(self._extract_tab_renderers(data), fatal=False) or {}
             if (url != 'https://www.youtube.com/feed/recommended'
                     and selected_tab.get('tabIdentifier') == 'FEwhat_to_watch'  # Home page
                     and 'no-youtube-channel-redirect' not in self.get_param('compat_opts', [])):
@@ -5392,18 +5394,19 @@ class YoutubeTabIE(YoutubeTabBaseInfoExtractor):
             'categories': ['News & Politics'],
             'tags': list,
             'like_count': int,
-            'release_timestamp': 1642502819,
+            'release_timestamp': int,
             'channel': 'Sky News',
             'channel_id': 'UCoMdktPbSTixAyNGwb-UYkQ',
             'age_limit': 0,
             'view_count': int,
-            'thumbnail': 'https://i.ytimg.com/vi/GgL890LIznQ/maxresdefault_live.jpg',
+            'thumbnail': r're:https?://i\.ytimg\.com/vi/[^/]+/maxresdefault(?:_live)?\.jpg',
             'playable_in_embed': True,
-            'release_date': '20220118',
+            'release_date': r're:\d+',
             'availability': 'public',
             'live_status': 'is_live',
             'channel_url': 'https://www.youtube.com/channel/UCoMdktPbSTixAyNGwb-UYkQ',
-            'channel_follower_count': int
+            'channel_follower_count': int,
+            'concurrent_view_count': int,
         },
         'params': {
             'skip_download': True,
@@ -5538,16 +5541,14 @@ class YoutubeTabIE(YoutubeTabBaseInfoExtractor):
         ],
         'playlist_mincount': 101,
     }, {
-        'note': 'Topic without a UU playlist',
+        # Destination channel with only a hidden self tab (tab id is UCtFRv9O2AHqOZjjynzrv-xg)
+        # Treat as a general feed
         'url': 'https://www.youtube.com/channel/UCtFRv9O2AHqOZjjynzrv-xg',
         'info_dict': {
             'id': 'UCtFRv9O2AHqOZjjynzrv-xg',
             'title': 'UCtFRv9O2AHqOZjjynzrv-xg',
             'tags': [],
         },
-        'expected_warnings': [
-            'the playlist redirect gave error',
-        ],
         'playlist_mincount': 9,
     }, {
         'note': 'Youtube music Album',
@@ -5615,6 +5616,7 @@ class YoutubeTabIE(YoutubeTabBaseInfoExtractor):
             'skip_download': True,
             'extractor_args': {'youtubetab': {'skip': ['webpage']}}
         },
+        'skip': 'Query for sorting no longer works',
     }, {
         'note': 'API Fallback: Topic, should redirect to playlist?list=UU...',
         'url': 'https://music.youtube.com/browse/UC9ALqqC4aIeG5iDs7i90Bfw',
@@ -5633,10 +5635,6 @@ class YoutubeTabIE(YoutubeTabBaseInfoExtractor):
             'uploader_url': 'https://www.youtube.com/channel/UC9ALqqC4aIeG5iDs7i90Bfw',
             'availability': 'public',
         },
-        'expected_warnings': [
-            'does not have a videos tab',
-            r'[Uu]navailable videos (are|will be) hidden',
-        ],
         'playlist_mincount': 101,
         'params': {
             'skip_download': True,
@@ -5715,13 +5713,155 @@ class YoutubeTabIE(YoutubeTabBaseInfoExtractor):
         },
         'playlist_mincount': 50,
 
+    }, {
+        # Channel with a real live tab (not to be mistaken with streams tab)
+        # Do not treat like it should redirect to live stream
+        'url': 'https://www.youtube.com/channel/UCEH7P7kyJIkS_gJf93VYbmg/live',
+        'info_dict': {
+            'id': 'UCEH7P7kyJIkS_gJf93VYbmg',
+            'title': 'UCEH7P7kyJIkS_gJf93VYbmg - Live',
+            'tags': [],
+        },
+        'playlist_mincount': 20,
+    }, {
+        # Tab name is not the same as tab id
+        'url': 'https://www.youtube.com/channel/UCQvWX73GQygcwXOTSf_VDVg/letsplay',
+        'info_dict': {
+            'id': 'UCQvWX73GQygcwXOTSf_VDVg',
+            'title': 'UCQvWX73GQygcwXOTSf_VDVg - Let\'s play',
+            'tags': [],
+        },
+        'playlist_mincount': 8,
+    }, {
+        # Home tab id is literally home. Not to get mistaken with featured
+        'url': 'https://www.youtube.com/channel/UCQvWX73GQygcwXOTSf_VDVg/home',
+        'info_dict': {
+            'id': 'UCQvWX73GQygcwXOTSf_VDVg',
+            'title': 'UCQvWX73GQygcwXOTSf_VDVg - Home',
+            'tags': [],
+        },
+        'playlist_mincount': 8,
+    }, {
+        # Should get three playlists for videos, shorts and streams tabs
+        'url': 'https://www.youtube.com/channel/UCK9V2B22uJYu3N7eR_BT9QA',
+        'info_dict': {
+            'id': 'UCK9V2B22uJYu3N7eR_BT9QA',
+            'title': 'Uploads for UCK9V2B22uJYu3N7eR_BT9QA'
+        },
+        'playlist_count': 3,
+    }, {
+        # Shorts tab with channel with handle
+        'url': 'https://www.youtube.com/@NotJustBikes/shorts',
+        'info_dict': {
+            'id': 'UC0intLFzLaudFG-xAvUEO-A',
+            'title': 'Not Just Bikes - Shorts',
+            'tags': 'count:12',
+            'uploader': 'Not Just Bikes',
+            'channel_url': 'https://www.youtube.com/channel/UC0intLFzLaudFG-xAvUEO-A',
+            'description': 'md5:7513148b1f02b924783157d84c4ea555',
+            'channel_follower_count': int,
+            'uploader_id': 'UC0intLFzLaudFG-xAvUEO-A',
+            'channel_id': 'UC0intLFzLaudFG-xAvUEO-A',
+            'uploader_url': 'https://www.youtube.com/channel/UC0intLFzLaudFG-xAvUEO-A',
+            'channel': 'Not Just Bikes',
+        },
+        'playlist_mincount': 10,
+    }, {
+        # Streams tab
+        'url': 'https://www.youtube.com/channel/UC3eYAvjCVwNHgkaGbXX3sig/streams',
+        'info_dict': {
+            'id': 'UC3eYAvjCVwNHgkaGbXX3sig',
+            'title': '中村悠一 - Live',
+            'tags': 'count:7',
+            'channel_id': 'UC3eYAvjCVwNHgkaGbXX3sig',
+            'channel_url': 'https://www.youtube.com/channel/UC3eYAvjCVwNHgkaGbXX3sig',
+            'uploader_id': 'UC3eYAvjCVwNHgkaGbXX3sig',
+            'channel': '中村悠一',
+            'uploader_url': 'https://www.youtube.com/channel/UC3eYAvjCVwNHgkaGbXX3sig',
+            'channel_follower_count': int,
+            'uploader': '中村悠一',
+            'description': 'md5:e744f6c93dafa7a03c0c6deecb157300',
+        },
+        'playlist_mincount': 60,
+    }, {
+        # Channel with no uploads and hence no videos, streams, shorts tabs or uploads playlist. This should fail.
+        # See test_youtube_lists
+        'url': 'https://www.youtube.com/channel/UC2yXPzFejc422buOIzn_0CA',
+        'only_matching': True,
+    }, {
+        # No uploads and no UCID given. Should fail with no uploads error
+        # See test_youtube_lists
+        'url': 'https://www.youtube.com/news',
+        'only_matching': True
+    }, {
+        # No videos tab but has a shorts tab
+        'url': 'https://www.youtube.com/c/TKFShorts',
+        'info_dict': {
+            'id': 'UCgJ5_1F6yJhYLnyMszUdmUg',
+            'title': 'Shorts Break - Shorts',
+            'tags': 'count:32',
+            'channel_id': 'UCgJ5_1F6yJhYLnyMszUdmUg',
+            'channel': 'Shorts Break',
+            'description': 'md5:a6c234cf3d50d878ef8721e34457cd11',
+            'uploader': 'Shorts Break',
+            'channel_follower_count': int,
+            'uploader_id': 'UCgJ5_1F6yJhYLnyMszUdmUg',
+            'uploader_url': 'https://www.youtube.com/channel/UCgJ5_1F6yJhYLnyMszUdmUg',
+            'channel_url': 'https://www.youtube.com/channel/UCgJ5_1F6yJhYLnyMszUdmUg',
+        },
+        'playlist_mincount': 30,
+    }, {
+        # Trending Now Tab. tab id is empty
+        'url': 'https://www.youtube.com/feed/trending',
+        'info_dict': {
+            'id': 'trending',
+            'title': 'trending - Now',
+            'tags': [],
+        },
+        'playlist_mincount': 30,
+    }, {
+        # Trending Gaming Tab. tab id is empty
+        'url': 'https://www.youtube.com/feed/trending?bp=4gIcGhpnYW1pbmdfY29ycHVzX21vc3RfcG9wdWxhcg%3D%3D',
+        'info_dict': {
+            'id': 'trending',
+            'title': 'trending - Gaming',
+            'tags': [],
+        },
+        'playlist_mincount': 30,
     }]
 
     @classmethod
     def suitable(cls, url):
         return False if YoutubeIE.suitable(url) else super().suitable(url)
 
-    _URL_RE = re.compile(rf'(?P<pre>{_VALID_URL})(?(not_channel)|(?P<tab>/\w+))?(?P<post>.*)$')
+    _URL_RE = re.compile(rf'(?P<pre>{_VALID_URL})(?(not_channel)|(?P<tab>/[^?#/]+))?(?P<post>.*)$')
+
+    def _get_url_mobj(self, url):
+        mobj = self._URL_RE.match(url).groupdict()
+        mobj.update((k, '') for k, v in mobj.items() if v is None)
+        return mobj
+
+    def _extract_tab_id_and_name(self, tab, base_url='https://www.youtube.com'):
+        tab_name = (tab.get('title') or '').lower()
+        tab_url = urljoin(base_url, traverse_obj(
+            tab, ('endpoint', 'commandMetadata', 'webCommandMetadata', 'url')))
+
+        tab_id = (traverse_obj(tab, 'tabIdentifier', expected_type=str)
+                  or tab_url and self._get_url_mobj(tab_url)['tab'][1:])
+        if tab_id:
+            return tab_id, tab_name
+
+        # Fallback to tab name if we cannot get the tab id.
+        # XXX: should we strip non-ascii letters? e.g. in case of 'let's play' tab example on special gaming channel
+        # Note that in the case of translated tab name this may result in an empty string, which we don't want.
+        self.write_debug(f'Falling back to selected tab name: {tab_name}')
+        return {
+            'home': 'featured',
+            'live': 'streams',
+        }.get(tab_name, tab_name), tab_name
+
+    def _has_tab(self, tabs, tab_id):
+        return any(self._extract_tab_id_and_name(tab)[0] == tab_id for tab in tabs)
 
     @YoutubeTabBaseInfoExtractor.passthrough_smuggled_data
     def _real_extract(self, url, smuggled_data):
@@ -5730,14 +5870,8 @@ def _real_extract(self, url, smuggled_data):
             urllib.parse.urlparse(url)._replace(netloc='www.youtube.com'))
         compat_opts = self.get_param('compat_opts', [])
 
-        def get_mobj(url):
-            mobj = self._URL_RE.match(url).groupdict()
-            mobj.update((k, '') for k, v in mobj.items() if v is None)
-            return mobj
-
-        mobj, redirect_warning = get_mobj(url), None
-        # Youtube returns incomplete data if tabname is not lower case
-        pre, tab, post, is_channel = mobj['pre'], mobj['tab'].lower(), mobj['post'], not mobj['not_channel']
+        mobj = self._get_url_mobj(url)
+        pre, tab, post, is_channel = mobj['pre'], mobj['tab'], mobj['post'], not mobj['not_channel']
         if is_channel:
             if smuggled_data.get('is_music_url'):
                 if item_id[:2] == 'VL':  # Youtube music VL channels have an equivalent playlist
@@ -5750,19 +5884,16 @@ def get_mobj(url):
                                         get_all=False, expected_type=str)
                     if not murl:
                         raise ExtractorError('Failed to resolve album to playlist')
-                    return self.url_result(murl, ie=YoutubeTabIE.ie_key())
+                    return self.url_result(murl, YoutubeTabIE)
                 elif mobj['channel_type'] == 'browse':  # Youtube music /browse/ should be changed to /channel/
                     pre = f'https://www.youtube.com/channel/{item_id}'
 
-        original_tab_name = tab
+        original_tab_id = tab[1:]
         if is_channel and not tab and 'no-youtube-channel-redirect' not in compat_opts:
-            # Home URLs should redirect to /videos/
-            redirect_warning = ('A channel/user page was given. All the channel\'s videos will be downloaded. '
-                                'To download only the videos in the home page, add a "/featured" to the URL')
             tab = '/videos'
 
         url = ''.join((pre, tab, post))
-        mobj = get_mobj(url)
+        mobj = self._get_url_mobj(url)
 
         # Handle both video/playlist URLs
         qs = parse_qs(url)
@@ -5775,77 +5906,94 @@ def get_mobj(url):
             # Common mistake: https://www.youtube.com/watch?list=playlist_id
             self.report_warning(f'A video URL was given without video ID. Trying to download playlist {playlist_id}')
             url = f'https://www.youtube.com/playlist?list={playlist_id}'
-            mobj = get_mobj(url)
+            mobj = self._get_url_mobj(url)
 
-        if video_id and playlist_id:
-            if self.get_param('noplaylist'):
-                self.to_screen(f'Downloading just video {video_id} because of --no-playlist')
-                return self.url_result(f'https://www.youtube.com/watch?v={video_id}',
-                                       ie=YoutubeIE.ie_key(), video_id=video_id)
-            self.to_screen(f'Downloading playlist {playlist_id}; add --no-playlist to just download video {video_id}')
+        if not self._yes_playlist(playlist_id, video_id):
+            return self.url_result(
+                f'https://www.youtube.com/watch?v={video_id}', YoutubeIE, video_id)
 
         data, ytcfg = self._extract_data(url, item_id)
 
         # YouTube may provide a non-standard redirect to the regional channel
         # See: https://github.com/yt-dlp/yt-dlp/issues/2694
+        # https://support.google.com/youtube/answer/2976814#zippy=,conditional-redirects
         redirect_url = traverse_obj(
             data, ('onResponseReceivedActions', ..., 'navigateAction', 'endpoint', 'commandMetadata', 'webCommandMetadata', 'url'), get_all=False)
         if redirect_url and 'no-youtube-channel-redirect' not in compat_opts:
             redirect_url = ''.join((
                 urljoin('https://www.youtube.com', redirect_url), mobj['tab'], mobj['post']))
-            self.to_screen(f'This playlist is likely not available in your region. Following redirect to regional playlist {redirect_url}')
-            return self.url_result(redirect_url, ie=YoutubeTabIE.ie_key())
+            self.to_screen(f'This playlist is likely not available in your region. Following conditional redirect to {redirect_url}')
+            return self.url_result(redirect_url, YoutubeTabIE)
 
-        tabs = traverse_obj(data, ('contents', 'twoColumnBrowseResultsRenderer', 'tabs'), expected_type=list)
-        if tabs:
+        tab_results = []
+        tabs = self._extract_tab_renderers(data)
+        if is_channel and tabs and 'no-youtube-channel-redirect' not in compat_opts:
             selected_tab = self._extract_selected_tab(tabs)
-            selected_tab_url = urljoin(
-                url, traverse_obj(selected_tab, ('endpoint', 'commandMetadata', 'webCommandMetadata', 'url')))
-            translated_tab_name = selected_tab.get('title', '').lower()
-
-            # Prefer tab name from tab url as it is always in en,
-            # but only when preferred lang is set as it may not extract reliably in all cases.
-            selected_tab_name = (self._preferred_lang in (None, 'en') and translated_tab_name
-                                 or selected_tab_url and get_mobj(selected_tab_url)['tab'][1:]  # primary
-                                 or translated_tab_name)
-
-            if selected_tab_name == 'home':
-                selected_tab_name = 'featured'
-            requested_tab_name = mobj['tab'][1:]
-
-            if 'no-youtube-channel-redirect' not in compat_opts:
-                if requested_tab_name == 'live':  # Live tab should have redirected to the video
-                    raise UserNotLive(video_id=mobj['id'])
-                if requested_tab_name not in ('', selected_tab_name):
-                    redirect_warning = f'The channel does not have a {requested_tab_name} tab'
-                    if not original_tab_name:
-                        if item_id[:2] == 'UC':
-                            # Topic channels don't have /videos. Use the equivalent playlist instead
-                            pl_id = f'UU{item_id[2:]}'
-                            pl_url = f'https://www.youtube.com/playlist?list={pl_id}'
-                            try:
-                                data, ytcfg = self._extract_data(pl_url, pl_id, ytcfg=ytcfg, fatal=True, webpage_fatal=True)
-                            except ExtractorError:
-                                redirect_warning += ' and the playlist redirect gave error'
-                            else:
-                                item_id, url, selected_tab_name = pl_id, pl_url, requested_tab_name
-                                redirect_warning += f'. Redirecting to playlist {pl_id} instead'
-                        if selected_tab_name and selected_tab_name != requested_tab_name:
-                            redirect_warning += f'. {selected_tab_name} tab is being downloaded instead'
+            selected_tab_id, selected_tab_name = self._extract_tab_id_and_name(selected_tab, url)  # NB: Name may be translated
+            self.write_debug(f'Selected tab: {selected_tab_id!r} ({selected_tab_name}), Requested tab: {original_tab_id!r}')
+
+            if not original_tab_id and selected_tab_name:
+                self.to_screen('Channel URLs download all uploads of the channel. '
+                               'To download only the videos in a specific tab, pass the tab\'s URL')
+                if self._has_tab(tabs, 'streams'):
+                    tab_results.append(self.url_result(''.join((pre, '/streams', post))))
+                if self._has_tab(tabs, 'shorts'):
+                    tab_results.append(self.url_result(''.join((pre, '/shorts', post))))
+                # XXX: Members-only tab should also be extracted
+
+                if not tab_results and selected_tab_id != 'videos':
+                    # Channel does not have streams, shorts or videos tabs
+                    if item_id[:2] != 'UC':
+                        raise ExtractorError('This channel has no uploads', expected=True)
+
+                    # Topic channels don't have /videos. Use the equivalent playlist instead
+                    pl_id = f'UU{item_id[2:]}'
+                    pl_url = f'https://www.youtube.com/playlist?list={pl_id}'
+                    try:
+                        data, ytcfg = self._extract_data(pl_url, pl_id, ytcfg=ytcfg, fatal=True, webpage_fatal=True)
+                    except ExtractorError:
+                        raise ExtractorError('This channel has no uploads', expected=True)
                     else:
-                        raise ExtractorError(redirect_warning, expected=True)
+                        item_id, url = pl_id, pl_url
+                        self.to_screen(
+                            f'The channel does not have a videos, shorts, or live tab. Redirecting to playlist {pl_id} instead')
+
+                elif tab_results and selected_tab_id != 'videos':
+                    # When there are shorts/live tabs but not videos tab
+                    url, data = ''.join((pre, post)), None
+
+            elif (original_tab_id or 'videos') != selected_tab_id:
+                if original_tab_id == 'live':
+                    # Live tab should have redirected to the video
+                    # Except in the case the channel has an actual live tab
+                    # Example: https://www.youtube.com/channel/UCEH7P7kyJIkS_gJf93VYbmg/live
+                    raise UserNotLive(video_id=mobj['id'])
+                elif selected_tab_name:
+                    raise ExtractorError(f'This channel does not have a {original_tab_id} tab', expected=True)
+
+                # For channels such as https://www.youtube.com/channel/UCtFRv9O2AHqOZjjynzrv-xg
+                url = f'{pre}{post}'
 
-        if redirect_warning:
-            self.to_screen(redirect_warning)
         self.write_debug(f'Final URL: {url}')
 
         # YouTube sometimes provides a button to reload playlist with unavailable videos.
         if 'no-youtube-unavailable-videos' not in compat_opts:
             data = self._reload_with_unavailable_videos(item_id, data, ytcfg) or data
         self._extract_and_report_alerts(data, only_once=True)
-        tabs = traverse_obj(data, ('contents', 'twoColumnBrowseResultsRenderer', 'tabs'), expected_type=list)
+
+        tabs = self._extract_tab_renderers(data)
         if tabs:
-            return self._extract_from_tabs(item_id, ytcfg, data, tabs)
+            tab_results[:0] = [self._extract_from_tabs(item_id, ytcfg, data, tabs)]
+            tab_results[0].update({
+                'extractor_key': YoutubeTabIE.ie_key(),
+                'extractor': YoutubeTabIE.IE_NAME,
+                'webpage_url': url,
+            })
+
+        if len(tab_results) == 1:
+            return tab_results[0]
+        elif len(tab_results) > 1:
+            return self.playlist_result(tab_results, item_id, title=f'Uploads for {item_id}')
 
         playlist = traverse_obj(
             data, ('contents', 'twoColumnWatchNextResults', 'playlist', 'playlist'), expected_type=dict)
@@ -5857,8 +6005,7 @@ def get_mobj(url):
         if video_id:
             if mobj['tab'] != '/live':  # live tab is expected to redirect to video
                 self.report_warning(f'Unable to recognize playlist. Downloading just video {video_id}')
-            return self.url_result(f'https://www.youtube.com/watch?v={video_id}',
-                                   ie=YoutubeIE.ie_key(), video_id=video_id)
+            return self.url_result(f'https://www.youtube.com/watch?v={video_id}', YoutubeIE, video_id)
 
         raise ExtractorError('Unable to recognize tab page')
 
@@ -5891,12 +6038,13 @@ class YoutubePlaylistIE(InfoExtractor):
             'uploader_id': 'UCKSpbfbl5kRQpTdL7kMc-1Q',
             'description': 'md5:8fa6f52abb47a9552002fa3ddfc57fc2',
             'view_count': int,
-            'uploader_url': 'https://www.youtube.com/user/Wickydoo',
+            'uploader_url': 'https://www.youtube.com/c/WickmanVT',
             'modified_date': r're:\d{8}',
             'channel_id': 'UCKSpbfbl5kRQpTdL7kMc-1Q',
             'channel': 'Wickman',
             'tags': [],
-            'channel_url': 'https://www.youtube.com/user/Wickydoo',
+            'channel_url': 'https://www.youtube.com/c/WickmanVT',
+            'availability': 'public',
         },
         'playlist_mincount': 29,
     }, {
@@ -5926,7 +6074,7 @@ class YoutubePlaylistIE(InfoExtractor):
             'uploader_url': 'https://www.youtube.com/channel/UCEI1-PVPcYXjB73Hfelbmaw',
             'availability': 'public',
         },
-        'expected_warnings': [r'[Uu]navailable videos (are|will be) hidden'],
+        'expected_warnings': [r'[Uu]navailable videos? (is|are|will be) hidden'],
     }, {
         'url': 'http://www.youtube.com/embed/_xDOZElKyNU?list=PLsyOSbh5bs16vubvKePAQ1x3PhKavfBIl',
         'playlist_mincount': 455,


Test patch:
diff --git a/test/test_youtube_lists.py b/test/test_youtube_lists.py
index c2dd0ac30..b3f323e21 100644
--- a/test/test_youtube_lists.py
+++ b/test/test_youtube_lists.py
@@ -10,6 +10,7 @@
 
 from test.helper import FakeYDL, is_download_test
 from yt_dlp.extractor import YoutubeIE, YoutubeTabIE
+from yt_dlp.utils import ExtractorError
 
 
 @is_download_test
@@ -53,6 +54,18 @@ def test_youtube_flat_playlist_extraction(self):
         self.assertEqual(video['duration'], 10)
         self.assertEqual(video['uploader'], 'Philipp Hagemeister')
 
+    def test_youtube_channel_no_uploads(self):
+        dl = FakeYDL()
+        dl.params['extract_flat'] = True
+        ie = YoutubeTabIE(dl)
+        # no uploads
+        with self.assertRaisesRegex(ExtractorError, r'no uploads'):
+            ie.extract('https://www.youtube.com/channel/UC2yXPzFejc422buOIzn_0CA')
+
+        # no uploads and no UCID given
+        with self.assertRaisesRegex(ExtractorError, r'no uploads'):
+            ie.extract('https://www.youtube.com/news')
+
 
 if __name__ == '__main__':
     unittest.main()