Repository: yt-dlp/yt-dlp
Pull request #11142 (2024-09-30 20:40:29)

[ie/Telecinco] Fix extractor: Add headers
*IMPORTANT: PRs without the template will be CLOSED
Description of your pull request and other information

    add referer header to _extract_m3u8_formats
    Return http_header to mitele for proper origin and referer headers
    Properly usage of geo restriction. Allowing download with geo-restrict-proxy
    follow yt-dlp coding conventions, use traverse_obj

Fixes https://github.com/yt-dlp/yt-dlp/issues/10986
And fixes https://github.com/yt-dlp/yt-dlp/issues/11106
Template
Before submitting a pull request make sure you have:

    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)
    [Searched](https://github.com/yt-dlp/yt-dlp/search?q=is%3Apr&type=Issues) the bugtracker for similar pull requests

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 all of the following options that apply:

    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)
    partial code reference: https://github.com/yt-dlp/yt-dlp/issues/10986#issuecomment-2348156339 by @n0ttr

What is the purpose of your pull request?

    Fix or improvement to an extractor (Make sure to add/update tests)

DarkZeros: This commit was based on @grqz https://github.com/yt-dlp/yt-dlp/pull/11142 but with small modifications to make Mitele extractor also work ok.
grqz: does this pr also fix #11106? Please include it in your description if so
DarkZeros: > does this pr also fix #11106? Please include it in your description if so

Yes it does indeed fix that error. I tested locally. I didnt add any tests since i am outside the geo location, so i was not sure if those would work.
nadal28: > does this pr also fix #11106? Please include it in your description if so

is not fixing it for me: https://github.com/yt-dlp/yt-dlp/issues/11106#issuecomment-2386903822
wydengyre: @DarkZeros I'd like to help you get this merged by testing on my end. I'm located within Spain and have no trouble playing videos on cuatro.com.

I'm on MacOS Sonoma 14.7, running homebrew yt-dlp 2024.10.07.

On that release, I get the failure this is meant to fix:
```
mleon@Matthews-MacBook-Air yt-dlp % yt-dlp 'https://www.cuatro.com/callejeros/programas-completos/20240808/cuerpo-precio-programa-video_18_013209696.html'
[Telecinco] Extracting URL: https://www.cuatro.com/callejeros/programas-completos/20240808/cuerpo-precio-programa-video_18_013209696.html
[Telecinco] cuerpo-precio-programa-video_18_013209696: Downloading webpage
[Telecinco] ec988c1e-df01-4547-896e-87414eaec94d: Downloading config JSON
[Telecinco] ec988c1e-df01-4547-896e-87414eaec94d: Downloading JSON metadata
[Telecinco] ec988c1e-df01-4547-896e-87414eaec94d: Downloading JSON metadata
[Telecinco] ec988c1e-df01-4547-896e-87414eaec94d: Downloading JSON metadata
[Telecinco] ec988c1e-df01-4547-896e-87414eaec94d: Downloading m3u8 information
ERROR: [Telecinco] cuerpo-precio-programa-video_18_013209696: Failed to download m3u8 information: HTTP Error 403: Forbidden (caused by <HTTPError 403: Forbidden>)
```

Patch:
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index bd2e42d9a..495d3c630 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -240,7 +240,7 @@ jobs:
     permissions:
       contents: read
       actions: write  # For cleaning up cache
-    runs-on: macos-12
+    runs-on: macos-13
 
     steps:
       - uses: actions/checkout@v4
@@ -346,7 +346,7 @@ jobs:
   macos_legacy:
     needs: process
     if: inputs.macos_legacy
-    runs-on: macos-12
+    runs-on: macos-13
 
     steps:
       - uses: actions/checkout@v4
diff --git a/README.md b/README.md
index 1cafe51d5..fc38a529a 100644
--- a/README.md
+++ b/README.md
@@ -348,6 +348,13 @@ ## General Options:
                                     containing directory ("-" for stdin). Can be
                                     used multiple times and inside other
                                     configuration files
+    --plugin-dirs PATH              Path to an additional directory to search
+                                    for plugins. This option can be used
+                                    multiple times to add multiple directories.
+                                    Note that this currently only works for
+                                    extractor plugins; postprocessor plugins can
+                                    only be loaded from the default plugin
+                                    directories
     --flat-playlist                 Do not extract the videos of a playlist,
                                     only list them
     --no-flat-playlist              Fully extract the videos of a playlist
diff --git a/yt_dlp/__init__.py b/yt_dlp/__init__.py
index f598b6c2f..d976f5bbc 100644
--- a/yt_dlp/__init__.py
+++ b/yt_dlp/__init__.py
@@ -34,6 +34,7 @@
 )
 from .update import Updater
 from .utils import (
+    Config,
     NO_DEFAULT,
     POSTPROCESS_WHEN,
     DateRange,
@@ -967,6 +968,10 @@ def _real_main(argv=None):
 
     parser, opts, all_urls, ydl_opts = parse_options(argv)
 
+    # HACK: Set the plugin dirs early on
+    # TODO(coletdjnz): remove when plugin globals system is implemented
+    Config._plugin_dirs = opts.plugin_dirs
+
     # Dump user agent
     if opts.dump_user_agent:
         ua = traverse_obj(opts.headers, 'User-Agent', casesense=False, default=std_headers['User-Agent'])
diff --git a/yt_dlp/extractor/_extractors.py b/yt_dlp/extractor/_extractors.py
index 4b1f4c316..8d5936094 100644
--- a/yt_dlp/extractor/_extractors.py
+++ b/yt_dlp/extractor/_extractors.py
@@ -363,7 +363,10 @@
 )
 from .ccma import CCMAIE
 from .cctv import CCTVIE
-from .cda import CDAIE
+from .cda import (
+    CDAIE,
+    CDAFolderIE,
+)
 from .cellebrite import CellebriteIE
 from .ceskatelevize import CeskaTelevizeIE
 from .cgtn import CGTNIE
diff --git a/yt_dlp/extractor/adobepass.py b/yt_dlp/extractor/adobepass.py
index eb7e597e5..7cc15ec7b 100644
--- a/yt_dlp/extractor/adobepass.py
+++ b/yt_dlp/extractor/adobepass.py
@@ -1355,6 +1355,7 @@
 class AdobePassIE(InfoExtractor):  # XXX: Conventionally, base classes should end with BaseIE/InfoExtractor
     _SERVICE_PROVIDER_TEMPLATE = 'https://sp.auth.adobe.com/adobe-services/%s'
     _USER_AGENT = 'Mozilla/5.0 (X11; Linux i686; rv:47.0) Gecko/20100101 Firefox/47.0'
+    _MODERN_USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; rv:131.0) Gecko/20100101 Firefox/131.0'
     _MVPD_CACHE = 'ap-mvpd'
 
     _DOWNLOADING_LOGIN_PAGE = 'Downloading Provider Login Page'
@@ -1454,7 +1455,11 @@ def extract_redirect_url(html, url=None, fatal=False):
                             'no_iframe': 'false',
                             'domain_name': 'adobe.com',
                             'redirect_url': url,
-                        })
+                        }, headers={
+                            # yt-dlp's default user-agent is usually too old for Comcast_SSO
+                            # See: https://github.com/yt-dlp/yt-dlp/issues/10848
+                            'User-Agent': self._MODERN_USER_AGENT,
+                        } if mso_id == 'Comcast_SSO' else None)
                 elif not self._cookies_passed:
                     raise_mvpd_required()
 
diff --git a/yt_dlp/extractor/cbc.py b/yt_dlp/extractor/cbc.py
index 40224f63f..b44c23fa1 100644
--- a/yt_dlp/extractor/cbc.py
+++ b/yt_dlp/extractor/cbc.py
@@ -4,7 +4,6 @@
 import re
 import time
 import urllib.parse
-import xml.etree.ElementTree
 
 from .common import InfoExtractor
 from ..networking import HEADRequest
@@ -12,7 +11,6 @@
     ExtractorError,
     float_or_none,
     int_or_none,
-    join_nonempty,
     js_to_json,
     mimetype2ext,
     orderedSet,
@@ -524,14 +522,13 @@ class CBCGemIE(InfoExtractor):
     _TESTS = [{
         # This is a normal, public, TV show video
         'url': 'https://gem.cbc.ca/media/schitts-creek/s06e01',
-        'md5': '93dbb31c74a8e45b378cf13bd3f6f11e',
         'info_dict': {
             'id': 'schitts-creek/s06e01',
             'ext': 'mp4',
             'title': 'Smoke Signals',
             'description': 'md5:929868d20021c924020641769eb3e7f1',
-            'thumbnail': 'https://images.radio-canada.ca/v1/synps-cbc/episode/perso/cbc_schitts_creek_season_06e01_thumbnail_v01.jpg?im=Resize=(Size)',
-            'duration': 1314,
+            'thumbnail': r're:https://images\.radio-canada\.ca/[^#?]+/cbc_schitts_creek_season_06e01_thumbnail_v01\.jpg',
+            'duration': 1324,
             'categories': ['comedy'],
             'series': 'Schitt\'s Creek',
             'season': 'Season 6',
@@ -539,19 +536,21 @@ class CBCGemIE(InfoExtractor):
             'episode': 'Smoke Signals',
             'episode_number': 1,
             'episode_id': 'schitts-creek/s06e01',
+            'upload_date': '20210618',
+            'timestamp': 1623988800,
+            'release_date': '20200107',
+            'release_timestamp': 1578427200,
         },
         'params': {'format': 'bv'},
-        'skip': 'Geo-restricted to Canada',
     }, {
         # This video requires an account in the browser, but works fine in yt-dlp
         'url': 'https://gem.cbc.ca/media/schitts-creek/s01e01',
-        'md5': '297a9600f554f2258aed01514226a697',
         'info_dict': {
             'id': 'schitts-creek/s01e01',
             'ext': 'mp4',
             'title': 'The Cup Runneth Over',
             'description': 'md5:9bca14ea49ab808097530eb05a29e797',
-            'thumbnail': 'https://images.radio-canada.ca/v1/synps-cbc/episode/perso/cbc_schitts_creek_season_01e01_thumbnail_v01.jpg?im=Resize=(Size)',
+            'thumbnail': r're:https://images\.radio-canada\.ca/[^#?]+/cbc_schitts_creek_season_01e01_thumbnail_v01\.jpg',
             'series': 'Schitt\'s Creek',
             'season_number': 1,
             'season': 'Season 1',
@@ -560,9 +559,12 @@ class CBCGemIE(InfoExtractor):
             'episode_id': 'schitts-creek/s01e01',
             'duration': 1309,
             'categories': ['comedy'],
+            'upload_date': '20210617',
+            'timestamp': 1623902400,
+            'release_date': '20151124',
+            'release_timestamp': 1448323200,
         },
         'params': {'format': 'bv'},
-        'skip': 'Geo-restricted to Canada',
     }, {
         'url': 'https://gem.cbc.ca/nadiyas-family-favourites/s01e01',
         'only_matching': True,
@@ -631,38 +633,6 @@ def _real_initialize(self):
             return
         self._claims_token = self.cache.load(self._NETRC_MACHINE, 'claims_token')
 
-    def _find_secret_formats(self, formats, video_id):
-        """ Find a valid video url and convert it to the secret variant """
-        base_format = next((f for f in formats if f.get('vcodec') != 'none'), None)
-        if not base_format:
-            return
-
-        base_url = re.sub(r'(Manifest\(.*?),filter=[\w-]+(.*?\))', r'\1\2', base_format['url'])
-        url = re.sub(r'(Manifest\(.*?),format=[\w-]+(.*?\))', r'\1\2', base_url)
-
-        secret_xml = self._download_xml(url, video_id, note='Downloading secret XML', fatal=False)
-        if not isinstance(secret_xml, xml.etree.ElementTree.Element):
-            return
-
-        for child in secret_xml:
-            if child.attrib.get('Type') != 'video':
-                continue
-            for video_quality in child:
-                bitrate = int_or_none(video_quality.attrib.get('Bitrate'))
-                if not bitrate or 'Index' not in video_quality.attrib:
-                    continue
-                height = int_or_none(video_quality.attrib.get('MaxHeight'))
-
-                yield {
-                    **base_format,
-                    'format_id': join_nonempty('sec', height),
-                    # Note: \g<1> is necessary instead of \1 since bitrate is a number
-                    'url': re.sub(r'(QualityLevels\()\d+(\))', fr'\g<1>{bitrate}\2', base_url),
-                    'width': int_or_none(video_quality.attrib.get('MaxWidth')),
-                    'tbr': bitrate / 1000.0,
-                    'height': height,
-                }
-
     def _real_extract(self, url):
         video_id = self._match_id(url)
         video_info = self._download_json(
@@ -676,7 +646,6 @@ def _real_extract(self, url):
         else:
             headers = {}
         m3u8_info = self._download_json(video_info['playSession']['url'], video_id, headers=headers)
-        m3u8_url = m3u8_info.get('url')
 
         if m3u8_info.get('errorCode') == 1:
             self.raise_geo_restricted(countries=['CA'])
@@ -685,9 +654,9 @@ def _real_extract(self, url):
         elif m3u8_info.get('errorCode') != 0:
             raise ExtractorError(f'{self.IE_NAME} said: {m3u8_info.get("errorCode")} - {m3u8_info.get("message")}')
 
-        formats = self._extract_m3u8_formats(m3u8_url, video_id, m3u8_id='hls')
+        formats = self._extract_m3u8_formats(
+            m3u8_info['url'], video_id, 'mp4', m3u8_id='hls', query={'manifestType': ''})
         self._remove_duplicate_formats(formats)
-        formats.extend(self._find_secret_formats(formats, video_id))
 
         for fmt in formats:
             if fmt.get('vcodec') == 'none':
@@ -703,20 +672,21 @@ def _real_extract(self, url):
 
         return {
             'id': video_id,
-            'title': video_info['title'],
-            'description': video_info.get('description'),
-            'thumbnail': video_info.get('image'),
-            'series': video_info.get('series'),
-            'season_number': video_info.get('season'),
-            'season': f'Season {video_info.get("season")}',
-            'episode_number': video_info.get('episode'),
-            'episode': video_info.get('title'),
             'episode_id': video_id,
-            'duration': video_info.get('duration'),
-            'categories': [video_info.get('category')],
             'formats': formats,
-            'release_timestamp': video_info.get('airDate'),
-            'timestamp': video_info.get('availableDate'),
+            **traverse_obj(video_info, {
+                'title': ('title', {str}),
+                'episode': ('title', {str}),
+                'description': ('description', {str}),
+                'thumbnail': ('image', {url_or_none}),
+                'series': ('series', {str}),
+                'season_number': ('season', {int_or_none}),
+                'episode_number': ('episode', {int_or_none}),
+                'duration': ('duration', {int_or_none}),
+                'categories': ('category', {str}, all),
+                'release_timestamp': ('airDate', {int_or_none(scale=1000)}),
+                'timestamp': ('availableDate', {int_or_none(scale=1000)}),
+            }),
         }
 
 
diff --git a/yt_dlp/extractor/cda.py b/yt_dlp/extractor/cda.py
index 62ee8b17f..b2738e492 100644
--- a/yt_dlp/extractor/cda.py
+++ b/yt_dlp/extractor/cda.py
@@ -12,6 +12,7 @@
 from ..compat import compat_ord
 from ..utils import (
     ExtractorError,
+    OnDemandPagedList,
     float_or_none,
     int_or_none,
     merge_dicts,
@@ -351,3 +352,50 @@ def extract_format(page, version):
             extract_format(webpage, resolution)
 
         return merge_dicts(info_dict, info)
+
+
+class CDAFolderIE(InfoExtractor):
+    _MAX_PAGE_SIZE = 36
+    _VALID_URL = r'https?://(?:www\.)?cda\.pl/(?P<channel>\w+)/folder/(?P<id>\d+)'
+    _TESTS = [
+        {
+            'url': 'https://www.cda.pl/domino264/folder/31188385',
+            'info_dict': {
+                'id': '31188385',
+                'title': 'SERIA DRUGA',
+            },
+            'playlist_mincount': 13,
+        },
+        {
+            'url': 'https://www.cda.pl/smiechawaTV/folder/2664592/vfilm',
+            'info_dict': {
+                'id': '2664592',
+                'title': 'VideoDowcipy - wszystkie odcinki',
+            },
+            'playlist_mincount': 71,
+        },
+        {
+            'url': 'https://www.cda.pl/DeliciousBeauty/folder/19129979/vfilm',
+            'info_dict': {
+                'id': '19129979',
+                'title': 'TESTY KOSMETYKÓW',
+            },
+            'playlist_mincount': 139,
+        }]
+
+    def _real_extract(self, url):
+        folder_id, channel = self._match_valid_url(url).group('id', 'channel')
+
+        webpage = self._download_webpage(url, folder_id)
+
+        def extract_page_entries(page):
+            webpage = self._download_webpage(
+                f'https://www.cda.pl/{channel}/folder/{folder_id}/vfilm/{page + 1}', folder_id,
+                f'Downloading page {page + 1}', expected_status=404)
+            items = re.findall(r'<a[^>]+href="/video/([0-9a-z]+)"', webpage)
+            for video_id in items:
+                yield self.url_result(f'https://www.cda.pl/video/{video_id}', CDAIE, video_id)
+
+        return self.playlist_result(
+            OnDemandPagedList(extract_page_entries, self._MAX_PAGE_SIZE),
+            folder_id, self._og_search_title(webpage))
diff --git a/yt_dlp/extractor/funk.py b/yt_dlp/extractor/funk.py
index 8bdea3fce..ef8ea72a8 100644
--- a/yt_dlp/extractor/funk.py
+++ b/yt_dlp/extractor/funk.py
@@ -3,7 +3,7 @@
 
 
 class FunkIE(InfoExtractor):
-    _VALID_URL = r'https?://(?:www\.|origin\.)?funk\.net/(?:channel|playlist)/[^/]+/(?P<display_id>[0-9a-z-]+)-(?P<id>\d+)'
+    _VALID_URL = r'https?://(?:(?:www|origin|play)\.)?funk\.net/(?:channel|playlist)/[^/?#]+/(?P<display_id>[0-9a-z-]+)-(?P<id>\d+)'
     _TESTS = [{
         'url': 'https://www.funk.net/channel/ba-793/die-lustigsten-instrumente-aus-dem-internet-teil-2-1155821',
         'md5': '8610449476156f338761a75391b0017d',
@@ -27,6 +27,9 @@ class FunkIE(InfoExtractor):
     }, {
         'url': 'https://www.funk.net/playlist/neuesteVideos/kameras-auf-dem-fusion-festival-1618699',
         'only_matching': True,
+    }, {
+        'url': 'https://play.funk.net/playlist/neuesteVideos/george-floyd-wenn-die-polizei-toetet-der-fall-2004391',
+        'only_matching': True,
     }]
 
     def _real_extract(self, url):
diff --git a/yt_dlp/extractor/imgur.py b/yt_dlp/extractor/imgur.py
index f0c3419d4..e2644e6a4 100644
--- a/yt_dlp/extractor/imgur.py
+++ b/yt_dlp/extractor/imgur.py
@@ -37,7 +37,7 @@ def get_description(s):
 
 
 class ImgurIE(ImgurBaseIE):
-    _VALID_URL = r'https?://(?:i\.)?imgur\.com/(?!(?:a|gallery|t|topic|r)/)(?P<id>[a-zA-Z0-9]+)'
+    _VALID_URL = r'https?://(?:i\.)?imgur\.com/(?!(?:a|gallery|t|topic|r)/)(?:[^/?#]+-)?(?P<id>[a-zA-Z0-9]+)'
 
     _TESTS = [{
         'url': 'https://imgur.com/A61SaA1',
@@ -54,6 +54,22 @@ class ImgurIE(ImgurBaseIE):
             'like_count': int,
             'thumbnail': 'https://i.imgur.com/A61SaA1h.jpg',
         },
+    }, {
+        # Test with URL slug
+        'url': 'https://imgur.com/mrw-gifv-is-up-running-without-any-bugs-A61SaA1',
+        'info_dict': {
+            'id': 'A61SaA1',
+            'ext': 'mp4',
+            'title': 'MRW gifv is up and running without any bugs',
+            'timestamp': 1416446068,
+            'upload_date': '20141120',
+            'dislike_count': int,
+            'comment_count': int,
+            'release_timestamp': 1416446068,
+            'release_date': '20141120',
+            'like_count': int,
+            'thumbnail': 'https://i.imgur.com/A61SaA1h.jpg',
+        },
     }, {
         'url': 'https://i.imgur.com/A61SaA1.gifv',
         'only_matching': True,
@@ -92,6 +108,7 @@ class ImgurIE(ImgurBaseIE):
             'comment_count': int,
             'release_timestamp': 1710491255,
             'release_date': '20240315',
+            'thumbnail': 'https://i.imgur.com/zV03bd5h.jpg',
         },
     }]
 
@@ -208,7 +225,10 @@ def og_get_size(media_type):
             }), get_all=False),
             'id': video_id,
             'formats': formats,
-            'thumbnail': url_or_none(search('thumbnailUrl')),
+            'thumbnails': [{
+                'url': thumbnail_url,
+                'http_headers': {'Accept': '*/*'},
+            }] if (thumbnail_url := search(['thumbnailUrl', 'twitter:image', 'og:image'])) else None,
             'http_headers': {'Accept': '*/*'},
         }
 
@@ -252,17 +272,9 @@ def _real_extract(self, url):
 
 class ImgurGalleryIE(ImgurGalleryBaseIE):
     IE_NAME = 'imgur:gallery'
-    _VALID_URL = r'https?://(?:i\.)?imgur\.com/(?:gallery|(?:t(?:opic)?|r)/[^/?#]+)/(?P<id>[a-zA-Z0-9]+)'
+    _VALID_URL = r'https?://(?:i\.)?imgur\.com/(?:gallery|(?:t(?:opic)?|r)/[^/?#]+)/(?:[^/?#]+-)?(?P<id>[a-zA-Z0-9]+)'
 
     _TESTS = [{
-        'url': 'http://imgur.com/gallery/Q95ko',
-        'info_dict': {
-            'id': 'Q95ko',
-            'title': 'Adding faces make every GIF better',
-        },
-        'playlist_count': 25,
-        'skip': 'Zoinks! You\'ve taken a wrong turn.',
-    }, {
         # TODO: static images - replace with animated/video gallery
         'url': 'http://imgur.com/topic/Aww/ll5Vk',
         'only_matching': True,
@@ -280,7 +292,27 @@ class ImgurGalleryIE(ImgurGalleryBaseIE):
             'release_timestamp': 1358554297,
             'thumbnail': 'https://i.imgur.com/YcAQlkxh.jpg',
             'release_date': '20130119',
-            'uploader_url': 'https://i.imgur.com/u3R4I2S_d.png?maxwidth=290&fidelity=grand',
+            'uploader_url': 'https://i.imgur.com/N5Flb2v_d.png?maxwidth=290&fidelity=grand',
+            'comment_count': int,
+            'dislike_count': int,
+            'like_count': int,
+        },
+    }, {
+        # Test with slug
+        'url': 'https://imgur.com/gallery/classic-steve-carell-gif-cracks-me-up-everytime-repost-downvotes-YcAQlkx',
+        'add_ies': ['Imgur'],
+        'info_dict': {
+            'id': 'YcAQlkx',
+            'ext': 'mp4',
+            'title': 'Classic Steve Carell gif...cracks me up everytime....damn the repost downvotes....',
+            'timestamp': 1358554297,
+            'upload_date': '20130119',
+            'uploader_id': '1648642',
+            'uploader': 'wittyusernamehere',
+            'release_timestamp': 1358554297,
+            'release_date': '20130119',
+            'thumbnail': 'https://i.imgur.com/YcAQlkxh.jpg',
+            'uploader_url': 'https://i.imgur.com/N5Flb2v_d.png?maxwidth=290&fidelity=grand',
             'comment_count': int,
             'dislike_count': int,
             'like_count': int,
@@ -317,6 +349,13 @@ class ImgurGalleryIE(ImgurGalleryBaseIE):
             'title': 'Penguins !',
         },
         'playlist_count': 3,
+    }, {
+        'url': 'https://imgur.com/t/unmuted/penguins-penguins-6lAn9VQ',
+        'info_dict': {
+            'id': '6lAn9VQ',
+            'title': 'Penguins !',
+        },
+        'playlist_count': 3,
     }, {
         'url': 'https://imgur.com/t/unmuted/kx2uD3C',
         'add_ies': ['Imgur'],
@@ -357,7 +396,7 @@ class ImgurGalleryIE(ImgurGalleryBaseIE):
 
 class ImgurAlbumIE(ImgurGalleryBaseIE):
     IE_NAME = 'imgur:album'
-    _VALID_URL = r'https?://(?:i\.)?imgur\.com/a/(?P<id>[a-zA-Z0-9]+)'
+    _VALID_URL = r'https?://(?:i\.)?imgur\.com/a/(?:[^/?#]+-)?(?P<id>[a-zA-Z0-9]+)'
     _GALLERY = False
     _TESTS = [{
         # TODO: only static images - replace with animated/video gallery
@@ -372,6 +411,14 @@ class ImgurAlbumIE(ImgurGalleryBaseIE):
             'title': 'enen-no-shouboutai',
         },
         'playlist_count': 2,
+    }, {
+        # Test with URL slug
+        'url': 'https://imgur.com/a/enen-no-shouboutai-iX265HX',
+        'info_dict': {
+            'id': 'iX265HX',
+            'title': 'enen-no-shouboutai',
+        },
+        'playlist_count': 2,
     }, {
         'url': 'https://imgur.com/a/8pih2Ed',
         'info_dict': {
diff --git a/yt_dlp/extractor/mitele.py b/yt_dlp/extractor/mitele.py
index ea2998672..3573a2a3f 100644
--- a/yt_dlp/extractor/mitele.py
+++ b/yt_dlp/extractor/mitele.py
@@ -1,14 +1,13 @@
-from .telecinco import TelecincoIE
+from .telecinco import TelecincoBaseIE
 from ..utils import (
     int_or_none,
     parse_iso8601,
 )
 
 
-class MiTeleIE(TelecincoIE):  # XXX: Do not subclass from concrete IE
+class MiTeleIE(TelecincoBaseIE):
     IE_DESC = 'mitele.es'
     _VALID_URL = r'https?://(?:www\.)?mitele\.es/(?:[^/]+/)+(?P<id>[^/]+)/player'
-
     _TESTS = [{
         'url': 'http://www.mitele.es/programas-tv/diario-de/57b0dfb9c715da65618b4afa/player',
         'info_dict': {
@@ -27,6 +26,7 @@ class MiTeleIE(TelecincoIE):  # XXX: Do not subclass from concrete IE
             'timestamp': 1471209401,
             'upload_date': '20160814',
         },
+        'skip': 'HTTP Error 404 Not Found',
     }, {
         # no explicit title
         'url': 'http://www.mitele.es/programas-tv/cuarto-milenio/57b0de3dc915da14058b4876/player',
@@ -49,6 +49,26 @@ class MiTeleIE(TelecincoIE):  # XXX: Do not subclass from concrete IE
         'params': {
             'skip_download': True,
         },
+        'skip': 'HTTP Error 404 Not Found',
+    }, {
+        'url': 'https://www.mitele.es/programas-tv/horizonte/temporada-5/programa-171-40_013480051/player/',
+        'info_dict': {
+            'id': '7adbe22e-cd41-4787-afa4-36f3da7c2c6f',
+            'ext': 'mp4',
+            'title': 'Horizonte Temporada 5 Programa 171',
+            'description': 'md5:97f1fb712c5ac27e5693a8b3c5c0c6e3',
+            'episode': 'Las Zonas de Bajas Emisiones, a debate',
+            'episode_number': 171,
+            'season': 'Season 5',
+            'season_number': 5,
+            'series': 'Horizonte',
+            'duration': 7012,
+            'upload_date': '20240927',
+            'timestamp': 1727416450,
+            'thumbnail': 'https://album.mediaset.es/eimg/2024/09/27/horizonte-171_9f02.jpg',
+            'age_limit': 12,
+        },
+        'params': {'geo_bypass_country': 'ES'},
     }, {
         'url': 'http://www.mitele.es/series-online/la-que-se-avecina/57aac5c1c915da951a8b45ed/player',
         'only_matching': True,
diff --git a/yt_dlp/extractor/substack.py b/yt_dlp/extractor/substack.py
index 30cb322dc..b70d40f2c 100644
--- a/yt_dlp/extractor/substack.py
+++ b/yt_dlp/extractor/substack.py
@@ -2,7 +2,13 @@
 import urllib.parse
 
 from .common import InfoExtractor
-from ..utils import js_to_json, str_or_none, traverse_obj
+from ..networking import HEADRequest
+from ..utils import (
+    determine_ext,
+    js_to_json,
+    str_or_none,
+)
+from ..utils.traversal import traverse_obj
 
 
 class SubstackIE(InfoExtractor):
@@ -43,6 +49,19 @@ class SubstackIE(InfoExtractor):
             'uploader': "Andrew Zimmern's Spilled Milk ",
             'uploader_id': '577659',
         },
+    }, {
+        # Podcast that needs its file extension resolved to mp3
+        'url': 'https://persuasion1.substack.com/p/summers',
+        'md5': '1456a755d46084744facdfac9edf900f',
+        'info_dict': {
+            'id': '141970405',
+            'ext': 'mp3',
+            'title': 'Larry Summers on What Went Wrong on Campus',
+            'description': 'Yascha Mounk and Larry Summers also discuss the promise and perils of artificial intelligence.',
+            'thumbnail': r're:https://substackcdn\.com/image/.+\.jpeg',
+            'uploader': 'Persuasion',
+            'uploader_id': '61579',
+        },
     }]
 
     @classmethod
@@ -89,7 +108,15 @@ def _real_extract(self, url):
         post_type = webpage_info['post']['type']
         formats, subtitles = [], {}
         if post_type == 'podcast':
-            formats, subtitles = [{'url': webpage_info['post']['podcast_url']}], {}
+            fmt = {'url': webpage_info['post']['podcast_url']}
+            if not determine_ext(fmt['url'], default_ext=None):
+                # The redirected format URL expires but the original URL doesn't,
+                # so we only want to extract the extension from this request
+                fmt['ext'] = determine_ext(self._request_webpage(
+                    HEADRequest(fmt['url']), display_id,
+                    'Resolving podcast file extension',
+                    'Podcast URL is invalid').url)
+            formats.append(fmt)
         elif post_type == 'video':
             formats, subtitles = self._extract_video_formats(webpage_info['post']['videoUpload']['id'], canonical_url)
         else:
diff --git a/yt_dlp/extractor/telecinco.py b/yt_dlp/extractor/telecinco.py
index 7a9dcd71c..9ef621446 100644
--- a/yt_dlp/extractor/telecinco.py
+++ b/yt_dlp/extractor/telecinco.py
@@ -2,15 +2,69 @@
 import re
 
 from .common import InfoExtractor
+from ..networking.exceptions import HTTPError
 from ..utils import (
+    ExtractorError,
     clean_html,
     int_or_none,
+    join_nonempty,
     str_or_none,
-    try_get,
+    traverse_obj,
+    update_url,
+    url_or_none,
 )
 
 
-class TelecincoIE(InfoExtractor):
+class TelecincoBaseIE(InfoExtractor):
+    def _parse_content(self, content, url):
+        video_id = content['dataMediaId']
+        config = self._download_json(
+            content['dataConfig'], video_id, 'Downloading config JSON')
+        services = config['services']
+        caronte = self._download_json(services['caronte'], video_id)
+        if traverse_obj(caronte, ('dls', 0, 'drm', {bool})):
+            self.report_drm(video_id)
+
+        stream = caronte['dls'][0]['stream']
+        headers = {
+            'Referer': url,
+            'Origin': re.match(r'https?://[^/]+', url).group(0),
+        }
+        geo_headers = {**headers, **self.geo_verification_headers()}
+
+        try:
+            cdn = self._download_json(
+                caronte['cerbero'], video_id, data=json.dumps({
+                    'bbx': caronte['bbx'],
+                    'gbx': self._download_json(services['gbx'], video_id)['gbx'],
+                }).encode(), headers={
+                    'Content-Type': 'application/json',
+                    **geo_headers,
+                })['tokens']['1']['cdn']
+        except ExtractorError as error:
+            if isinstance(error.cause, HTTPError) and error.cause.status == 403:
+                error_code = traverse_obj(
+                    self._webpage_read_content(error.cause.response, caronte['cerbero'], video_id, fatal=False),
+                    ({json.loads}, 'code', {int}))
+                if error_code == 4038:
+                    self.raise_geo_restricted(countries=['ES'])
+            raise
+
+        formats = self._extract_m3u8_formats(
+            update_url(stream, query=cdn), video_id, 'mp4', m3u8_id='hls', headers=geo_headers)
+
+        return {
+            'id': video_id,
+            'title': traverse_obj(config, ('info', 'title', {str})),
+            'formats': formats,
+            'thumbnail': (traverse_obj(content, ('dataPoster', {url_or_none}))
+                          or traverse_obj(config, 'poster', 'imageUrl', expected_type=url_or_none)),
+            'duration': traverse_obj(content, ('dataDuration', {int_or_none})),
+            'http_headers': headers,
+        }
+
+
+class TelecincoIE(TelecincoBaseIE):
     IE_DESC = 'telecinco.es, cuatro.com and mediaset.es'
     _VALID_URL = r'https?://(?:www\.)?(?:telecinco\.es|cuatro\.com|mediaset\.es)/(?:[^/]+/)+(?P<id>.+?)\.html'
 
@@ -30,6 +84,7 @@ class TelecincoIE(InfoExtractor):
                 'duration': 662,
             },
         }],
+        'skip': 'HTTP Error 410 Gone',
     }, {
         'url': 'http://www.cuatro.com/deportes/futbol/barcelona/Leo_Messi-Champions-Roma_2_2052780128.html',
         'md5': 'c86fe0d99e3bdb46b7950d38bf6ef12a',
@@ -40,23 +95,24 @@ class TelecincoIE(InfoExtractor):
             'description': 'md5:a62ecb5f1934fc787107d7b9a2262805',
             'duration': 79,
         },
+        'skip': 'Redirects to main page',
     }, {
         'url': 'http://www.mediaset.es/12meses/campanas/doylacara/conlatratanohaytrato/Ayudame-dar-cara-trata-trato_2_1986630220.html',
-        'md5': 'eddb50291df704ce23c74821b995bcac',
+        'md5': '5ce057f43f30b634fbaf0f18c71a140a',
         'info_dict': {
             'id': 'aywerkD2Sv1vGNqq9b85Q2',
             'ext': 'mp4',
             'title': '#DOYLACARA. Con la trata no hay trato',
-            'description': 'md5:2771356ff7bfad9179c5f5cd954f1477',
             'duration': 50,
+            'thumbnail': 'https://album.mediaset.es/eimg/2017/11/02/1tlQLO5Q3mtKT24f3EaC24.jpg',
         },
     }, {
         # video in opening's content
         'url': 'https://www.telecinco.es/vivalavida/fiorella-sobrina-edmundo-arrocet-entrevista_18_2907195140.html',
         'info_dict': {
-            'id': '2907195140',
+            'id': '1691427',
             'title': 'La surrealista entrevista a la sobrina de Edmundo Arrocet: "No puedes venir aquí y tomarnos por tontos"',
-            'description': 'md5:73f340a7320143d37ab895375b2bf13a',
+            'description': r're:Fiorella, la sobrina de Edmundo Arrocet, concedió .{727}',
         },
         'playlist': [{
             'md5': 'adb28c37238b675dad0f042292f209a7',
@@ -65,6 +121,7 @@ class TelecincoIE(InfoExtractor):
                 'ext': 'mp4',
                 'title': 'La surrealista entrevista a la sobrina de Edmundo Arrocet: "No puedes venir aquí y tomarnos por tontos"',
                 'duration': 1015,
+                'thumbnail': 'https://album.mediaset.es/eimg/2020/02/29/5opaC37lUhKlZ7FoDhiVC.jpg',
             },
         }],
         'params': {
@@ -81,66 +138,29 @@ class TelecincoIE(InfoExtractor):
         'only_matching': True,
     }]
 
-    def _parse_content(self, content, url):
-        video_id = content['dataMediaId']
-        config = self._download_json(
-            content['dataConfig'], video_id, 'Downloading config JSON')
-        title = config['info']['title']
-        services = config['services']
-        caronte = self._download_json(services['caronte'], video_id)
-        stream = caronte['dls'][0]['stream']
-        headers = self.geo_verification_headers()
-        headers.update({
-            'Content-Type': 'application/json;charset=UTF-8',
-            'Origin': re.match(r'https?://[^/]+', url).group(0),
-        })
-        cdn = self._download_json(
-            caronte['cerbero'], video_id, data=json.dumps({
-                'bbx': caronte['bbx'],
-                'gbx': self._download_json(services['gbx'], video_id)['gbx'],
-            }).encode(), headers=headers)['tokens']['1']['cdn']
-        formats = self._extract_m3u8_formats(
-            stream + '?' + cdn, video_id, 'mp4', 'm3u8_native', m3u8_id='hls')
-
-        return {
-            'id': video_id,
-            'title': title,
-            'formats': formats,
-            'thumbnail': content.get('dataPoster') or config.get('poster', {}).get('imageUrl'),
-            'duration': int_or_none(content.get('dataDuration')),
-        }
-
     def _real_extract(self, url):
         display_id = self._match_id(url)
         webpage = self._download_webpage(url, display_id)
-        article = self._parse_json(self._search_regex(
-            r'window\.\$REACTBASE_STATE\.article(?:_multisite)?\s*=\s*({.+})',
-            webpage, 'article'), display_id)['article']
-        title = article.get('title')
-        description = clean_html(article.get('leadParagraph')) or ''
+        article = self._search_json(
+            r'window\.\$REACTBASE_STATE\.article(?:_multisite)?\s*=',
+            webpage, 'article', display_id)['article']
+        description = traverse_obj(article, ('leadParagraph', {clean_html}, filter))
+
         if article.get('editorialType') != 'VID':
             entries = []
-            body = [article.get('opening')]
-            body.extend(try_get(article, lambda x: x['body'], list) or [])
-            for p in body:
-                if not isinstance(p, dict):
-                    continue
-                content = p.get('content')
-                if not content:
-                    continue
+
+            for p in traverse_obj(article, ((('opening', all), 'body'), lambda _, v: v['content'])):
+                content = p['content']
                 type_ = p.get('type')
-                if type_ == 'paragraph':
-                    content_str = str_or_none(content)
-                    if content_str:
-                        description += content_str
-                    continue
-                if type_ == 'video' and isinstance(content, dict):
+                if type_ == 'paragraph' and isinstance(content, str):
+                    description = join_nonempty(description, content, delim='')
+                elif type_ == 'video' and isinstance(content, dict):
                     entries.append(self._parse_content(content, url))
+
             return self.playlist_result(
-                entries, str_or_none(article.get('id')), title, description)
-        content = article['opening']['content']
-        info = self._parse_content(content, url)
-        info.update({
-            'description': description,
-        })
+                entries, str_or_none(article.get('id')),
+                traverse_obj(article, ('title', {str})), clean_html(description))
+
+        info = self._parse_content(article['opening']['content'], url)
+        info['description'] = description
         return info
diff --git a/yt_dlp/extractor/twitter.py b/yt_dlp/extractor/twitter.py
index aca94df2d..5adaf1639 100644
--- a/yt_dlp/extractor/twitter.py
+++ b/yt_dlp/extractor/twitter.py
@@ -934,14 +934,13 @@ class TwitterIE(TwitterBaseIE):
             'uploader_id': 'MoniqueCamarra',
             'live_status': 'was_live',
             'release_timestamp': 1658417414,
-            'description': 'md5:acce559345fd49f129c20dbcda3f1201',
+            'description': r're:Twitter Space participated by Sergej Sumlenny.+',
             'timestamp': 1658407771,
             'release_date': '20220721',
             'upload_date': '20220721',
         },
         'add_ie': ['TwitterSpaces'],
         'params': {'skip_download': 'm3u8'},
-        'skip': 'Requires authentication',
     }, {
         # URL specifies video number but --yes-playlist
         'url': 'https://twitter.com/CTVJLaidlaw/status/1600649710662213632/video/1',
@@ -1856,8 +1855,6 @@ def _build_graphql_query(self, space_id):
 
     def _real_extract(self, url):
         space_id = self._match_id(url)
-        if not self.is_logged_in:
-            self.raise_login_required('Twitter Spaces require authentication')
         space_data = self._call_graphql_api('HPEisOmj1epUNLCWTYhUWw/AudioSpaceById', space_id)['audioSpace']
         if not space_data:
             raise ExtractorError('Twitter Space not found', expected=True)
diff --git a/yt_dlp/extractor/youtube.py b/yt_dlp/extractor/youtube.py
index 6acc42fc0..728cb0696 100644
--- a/yt_dlp/extractor/youtube.py
+++ b/yt_dlp/extractor/youtube.py
@@ -114,6 +114,7 @@
         },
         'INNERTUBE_CONTEXT_CLIENT_NAME': 67,
     },
+    # This client now requires sign-in for every video
     'web_creator': {
         'INNERTUBE_CONTEXT': {
             'client': {
@@ -153,6 +154,7 @@
         'REQUIRE_JS_PLAYER': False,
         'REQUIRE_PO_TOKEN': True,
     },
+    # This client now requires sign-in for every video
     'android_creator': {
         'INNERTUBE_CONTEXT': {
             'client': {
@@ -200,21 +202,6 @@
         'REQUIRE_JS_PLAYER': False,
         'PLAYER_PARAMS': '2AMB',
     },
-    # This client only has legacy formats and storyboards
-    'android_producer': {
-        'INNERTUBE_CONTEXT': {
-            'client': {
-                'clientName': 'ANDROID_PRODUCER',
-                'clientVersion': '0.111.1',
-                'androidSdkVersion': 30,
-                'userAgent': 'com.google.android.apps.youtube.producer/0.111.1 (Linux; U; Android 11) gzip',
-                'osName': 'Android',
-                'osVersion': '11',
-            },
-        },
-        'INNERTUBE_CONTEXT_CLIENT_NAME': 91,
-        'REQUIRE_JS_PLAYER': False,
-    },
     # iOS clients have HLS live streams. Setting device model to get 60fps formats.
     # See: https://github.com/TeamNewPipe/NewPipeExtractor/issues/680#issuecomment-1002724558
     'ios': {
@@ -247,6 +234,7 @@
         'INNERTUBE_CONTEXT_CLIENT_NAME': 26,
         'REQUIRE_JS_PLAYER': False,
     },
+    # This client now requires sign-in for every video
     'ios_creator': {
         'INNERTUBE_CONTEXT': {
             'client': {
@@ -282,8 +270,9 @@
         },
         'INNERTUBE_CONTEXT_CLIENT_NAME': 7,
     },
-    # This client can access age restricted videos (unless the uploader has disabled the 'allow embedding' option)
-    # See: https://github.com/zerodytrash/YouTube-Internal-Clients
+    # This client now requires sign-in for every video
+    # It was previously an age-gate workaround for videos that were `playable_in_embed`
+    # It may still be useful if signed into an EU account that is not age-verified
     'tv_embedded': {
         'INNERTUBE_CONTEXT': {
             'client': {
@@ -1525,6 +1514,7 @@ class YoutubeIE(YoutubeBaseInfoExtractor):
                 'heatmap': 'count:100',
                 'timestamp': 1401991663,
             },
+            'skip': 'Age-restricted; requires authentication',
         },
         {
             'note': 'Age-gate video with embed allowed in public site',
@@ -1555,6 +1545,7 @@ class YoutubeIE(YoutubeBaseInfoExtractor):
                 'comment_count': int,
                 'channel_is_verified': True,
             },
+            'skip': 'Age-restricted; requires authentication',
         },
         {
             'note': 'Age-gate video embedable only with clientScreen=EMBED',
@@ -1585,6 +1576,7 @@ class YoutubeIE(YoutubeBaseInfoExtractor):
                 'uploader_id': '@ProjektMelody',
                 'timestamp': 1577508724,
             },
+            'skip': 'Age-restricted; requires authentication',
         },
         {
             'note': 'Non-Agegated non-embeddable video',
@@ -2356,6 +2348,7 @@ class YoutubeIE(YoutubeBaseInfoExtractor):
                 'channel_is_verified': True,
                 'timestamp': 1405513526,
             },
+            'skip': 'Age-restricted; requires authentication',
         },
         {
             # restricted location, https://github.com/ytdl-org/youtube-dl/issues/28685
@@ -2726,6 +2719,7 @@ class YoutubeIE(YoutubeBaseInfoExtractor):
                 'timestamp': 1577508724,
             },
             'params': {'extractor_args': {'youtube': {'player_client': ['tv_embedded']}}, 'format': '251-drc'},
+            'skip': 'Age-restricted; requires authentication',
         },
         {
             'url': 'https://www.youtube.com/live/qVv6vCqciTM',
@@ -3953,26 +3947,15 @@ def append_client(*client_names):
                 else:
                     prs.append(pr)
 
-            # tv_embedded can work around age-gate and age-verification IF the video is embeddable
-            if self._is_agegated(pr) and variant != 'tv_embedded':
-                append_client(f'tv_embedded.{base_client}')
-
-            # Unauthenticated users will only get tv_embedded client formats if age-gated
-            if self._is_agegated(pr) and not self.is_authenticated:
-                self.to_screen(
-                    f'{video_id}: This video is age-restricted; some formats may be missing '
-                    f'without authentication. {self._login_hint()}', only_once=True)
-
             # EU countries require age-verification for accounts to access age-restricted videos
             # If account is not age-verified, _is_agegated() will be truthy for non-embedded clients
-            # If embedding is disabled for the video, _is_unplayable() will be truthy for tv_embedded
-            embedding_is_disabled = variant == 'tv_embedded' and self._is_unplayable(pr)
-            if self.is_authenticated and (self._is_agegated(pr) or embedding_is_disabled):
+            if self.is_authenticated and self._is_agegated(pr):
                 self.to_screen(
                     f'{video_id}: This video is age-restricted and YouTube is requiring '
                     'account age-verification; some formats may be missing', only_once=True)
                 # web_creator and mediaconnect can work around the age-verification requirement
-                # _producer, _testsuite, & _vr variants can also work around age-verification
+                # _testsuite & _vr variants can also work around age-verification
+                # tv_embedded may(?) still work around age-verification if the video is embeddable
                 append_client('web_creator', 'mediaconnect')
 
         prs.extend(deprioritized_prs)
@@ -4701,11 +4684,12 @@ def process_language(container, base_url, lang_code, sub_name, query):
                 headers=self.generate_api_headers(ytcfg=master_ytcfg),
                 note='Downloading initial data API JSON')
 
+        COMMENTS_SECTION_IDS = ('comment-item-section', 'engagement-panel-comments-section')
         info['comment_count'] = traverse_obj(initial_data, (
             'contents', 'twoColumnWatchNextResults', 'results', 'results', 'contents', ..., 'itemSectionRenderer',
             'contents', ..., 'commentsEntryPointHeaderRenderer', 'commentCount',
         ), (
-            'engagementPanels', lambda _, v: v['engagementPanelSectionListRenderer']['panelIdentifier'] == 'comment-item-section',
+            'engagementPanels', lambda _, v: v['engagementPanelSectionListRenderer']['panelIdentifier'] in COMMENTS_SECTION_IDS,
             'engagementPanelSectionListRenderer', 'header', 'engagementPanelTitleHeaderRenderer', 'contextualInfo',
         ), expected_type=self._get_count, get_all=False)
 
diff --git a/yt_dlp/options.py b/yt_dlp/options.py
index 9980b7fc3..c3a647da7 100644
--- a/yt_dlp/options.py
+++ b/yt_dlp/options.py
@@ -408,6 +408,14 @@ def _alias_callback(option, opt_str, value, parser, opts, nargs):
         help=(
             'Location of the main configuration file; either the path to the config or its containing directory '
             '("-" for stdin). Can be used multiple times and inside other configuration files'))
+    general.add_option(
+        '--plugin-dirs',
+        dest='plugin_dirs', metavar='PATH', action='append',
+        help=(
+            'Path to an additional directory to search for plugins. '
+            'This option can be used multiple times to add multiple directories. '
+            'Note that this currently only works for extractor plugins; '
+            'postprocessor plugins can only be loaded from the default plugin directories'))
     general.add_option(
         '--flat-playlist',
         action='store_const', dest='extract_flat', const='in_playlist', default=False,
diff --git a/yt_dlp/plugins.py b/yt_dlp/plugins.py
index d777d14e7..204558d60 100644
--- a/yt_dlp/plugins.py
+++ b/yt_dlp/plugins.py
@@ -15,6 +15,7 @@
 
 from .compat import functools  # isort: split
 from .utils import (
+    Config,
     get_executable_path,
     get_system_config_dirs,
     get_user_config_dirs,
@@ -84,6 +85,12 @@ def _get_package_paths(*root_paths, containing_folder='plugins'):
         with contextlib.suppress(ValueError):  # Added when running __main__.py directly
             candidate_locations.remove(Path(__file__).parent)
 
+        # TODO(coletdjnz): remove when plugin globals system is implemented
+        if Config._plugin_dirs:
+            candidate_locations.extend(_get_package_paths(
+                *Config._plugin_dirs,
+                containing_folder=''))
+
         parts = Path(*fullname.split('.'))
         for path in orderedSet(candidate_locations, lazy=True):
             candidate = path / parts
diff --git a/yt_dlp/utils/_utils.py b/yt_dlp/utils/_utils.py
index dd12466b8..ea748898f 100644
--- a/yt_dlp/utils/_utils.py
+++ b/yt_dlp/utils/_utils.py
@@ -824,14 +824,18 @@ class Popen(subprocess.Popen):
         _startupinfo = None
 
     @staticmethod
-    def _fix_pyinstaller_ld_path(env):
-        """Restore LD_LIBRARY_PATH when using PyInstaller
-            Ref: https://github.com/pyinstaller/pyinstaller/blob/develop/doc/runtime-information.rst#ld_library_path--libpath-considerations
-                 https://github.com/yt-dlp/yt-dlp/issues/4573
-        """
+    def _fix_pyinstaller_issues(env):
         if not hasattr(sys, '_MEIPASS'):
             return
 
+        # Force spawning independent subprocesses for exes bundled with PyInstaller>=6.10
+        # Ref: https://pyinstaller.org/en/v6.10.0/CHANGES.html#incompatible-changes
+        #      https://github.com/yt-dlp/yt-dlp/issues/11259
+        env['PYINSTALLER_RESET_ENVIRONMENT'] = '1'
+
+        # Restore LD_LIBRARY_PATH when using PyInstaller
+        # Ref: https://pyinstaller.org/en/v6.10.0/runtime-information.html#ld-library-path-libpath-considerations
+        #      https://github.com/yt-dlp/yt-dlp/issues/4573
         def _fix(key):
             orig = env.get(f'{key}_ORIG')
             if orig is None:
@@ -845,7 +849,7 @@ def _fix(key):
     def __init__(self, args, *remaining, env=None, text=False, shell=False, **kwargs):
         if env is None:
             env = os.environ.copy()
-        self._fix_pyinstaller_ld_path(env)
+        self._fix_pyinstaller_issues(env)
 
         self.__text_mode = kwargs.get('encoding') or kwargs.get('errors') or text or kwargs.get('universal_newlines')
         if text is True:
@@ -4893,6 +4897,10 @@ class Config:
     filename = None
     __initialized = False
 
+    # Internal only, do not use! Hack to enable --plugin-dirs
+    # TODO(coletdjnz): remove when plugin globals system is implemented
+    _plugin_dirs = None
+
     def __init__(self, parser, label=None):
         self.parser, self.label = parser, label
         self._loaded_paths, self.configs = set(), []


Test patch:
diff --git a/test/test_plugins.py b/test/test_plugins.py
index c82158e9f..77545d136 100644
--- a/test/test_plugins.py
+++ b/test/test_plugins.py
@@ -10,6 +10,7 @@
 sys.path.append(str(TEST_DATA_DIR))
 importlib.invalidate_caches()
 
+from yt_dlp.utils import Config
 from yt_dlp.plugins import PACKAGE_NAME, directories, load_plugins
 
 
@@ -68,6 +69,24 @@ def test_importing_zipped_module(self):
             os.remove(zip_path)
             importlib.invalidate_caches()  # reset the import caches
 
+    def test_plugin_dirs(self):
+        # Internal plugin dirs hack for CLI --plugin-dirs
+        # To be replaced with proper system later
+        custom_plugin_dir = TEST_DATA_DIR / 'plugin_packages'
+        Config._plugin_dirs = [str(custom_plugin_dir)]
+        importlib.invalidate_caches()  # reset the import caches
+
+        try:
+            package = importlib.import_module(f'{PACKAGE_NAME}.extractor')
+            self.assertIn(custom_plugin_dir / 'testpackage' / PACKAGE_NAME / 'extractor', map(Path, package.__path__))
+
+            plugins_ie = load_plugins('extractor', 'IE')
+            self.assertIn('PackagePluginIE', plugins_ie.keys())
+
+        finally:
+            Config._plugin_dirs = []
+            importlib.invalidate_caches()  # reset the import caches
+
 
 if __name__ == '__main__':
     unittest.main()
diff --git a/test/testdata/plugin_packages/testpackage/yt_dlp_plugins/extractor/package.py b/test/testdata/plugin_packages/testpackage/yt_dlp_plugins/extractor/package.py
new file mode 100644
index 000000000..b860300d8
--- /dev/null
+++ b/test/testdata/plugin_packages/testpackage/yt_dlp_plugins/extractor/package.py
@@ -0,0 +1,5 @@
+from yt_dlp.extractor.common import InfoExtractor
+
+
+class PackagePluginIE(InfoExtractor):
+    pass