Repository: yt-dlp/yt-dlp

Issue:
Add `--plugin-dir` and/or `--no-plugins` options
### Checklist

- [X] I'm reporting a feature request
- [X] I've looked through the [README](https://github.com/yt-dlp/yt-dlp#readme)
- [X] I've verified that I'm running yt-dlp version **2022.03.08.1**. ([update instructions](https://github.com/yt-dlp/yt-dlp#update))
- [X] I've searched 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)

### Description

I've got a directory: `${HOME}/bin/ytdlp_plugins/`

On the command line, if I type `yt<TAB>` in the command position, it gives me options to expand for both `yt-dlp` and `ytdlp_plugins`. This is annoying, since I'm not running anything from ytdlp_plugins directly from the command line.

Request: Can there be an option to put the `ytdlp_plugins` directory in an alternate location? So it doesn't trip up tab-completion on the command line?

Ideally, I'd like to have a `--plugins-dir` (or similar) option, so I can put the plugins directly **not** in my executables path.



Discussion:
Grub4K: I think this should be implemented using an environment variable, similar to `YTDLP_NO_PLUGINS`. The commandline flag will then simply modify the env var.

The points up for discussion and my suggestions:
- Should multiple inputs be merged (yes, env var appends last)
- Should we have a sentinel to reset all (yes, `none`)
- What should the name for the option be (`--plugin-dir`)
- What should the name for the env var be (`YTDLP_PLUGIN_DIRS`)
coletdjnz: > I think this should be implemented using an environment variable, similar to `YTDLP_NO_PLUGINS`. The commandline flag will then simply modify the env var.
> 
> The points up for discussion and my suggestions:
> - Should multiple inputs be merged (yes, env var appends last)
> - Should we have a sentinel to reset all (yes, `none`)
> - What should the name for the option be (`--plugin-dir`)
> - What should the name for the env var be (`YTDLP_PLUGIN_DIRS`)

We should avoid modifying an env var imo. Only reas from one.

Otherwise that state will persist

Patch:
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/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/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/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..f41f57ed1 100644
--- a/yt_dlp/extractor/youtube.py
+++ b/yt_dlp/extractor/youtube.py
@@ -4701,11 +4701,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 27ebfefbc..ea748898f 100644
--- a/yt_dlp/utils/_utils.py
+++ b/yt_dlp/utils/_utils.py
@@ -4897,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