Repository: yt-dlp/yt-dlp
Pull request #6898 (2023-04-24 04:29:35)

Niconico seriesIE no longer working after url update
Basically the url format for series pages on niconico changed from just pointing at the series to including the user information in the url. Example:
https://www.nicovideo.jp/series/110226 now redirects to https://www.nicovideo.jp/user/44113208/series/110226
Old urls are still valid through the redirect but the previous extractor's way of extracting  the videos is not so that was updated as well.
Also in lieu of adding a specific test for this new url format I just edited one of the old format ones since we had multiple.

<details open><summary>Template</summary> <!-- OPEN is intentional -->

### 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 all of the following options that apply:
- [x] I am the original author of this code and I am willing to release it under [Unlicense](http://unlicense.org/)

### What is the purpose of your *pull request*?
- [x] Fix or improvement to an extractor (Make sure to add/update tests)
</details>


Patch:
diff --git a/README.md b/README.md
index 35229f728..47da19011 100644
--- a/README.md
+++ b/README.md
@@ -463,15 +463,11 @@ ## Geo-restriction:
                                     specified by --proxy (or none, if the option
                                     is not present) is used for the actual
                                     downloading
-    --geo-bypass                    Bypass geographic restriction via faking
-                                    X-Forwarded-For HTTP header (default)
-    --no-geo-bypass                 Do not bypass geographic restriction via
-                                    faking X-Forwarded-For HTTP header
-    --geo-bypass-country CODE       Force bypass geographic restriction with
-                                    explicitly provided two-letter ISO 3166-2
-                                    country code
-    --geo-bypass-ip-block IP_BLOCK  Force bypass geographic restriction with
-                                    explicitly provided IP block in CIDR notation
+    --xff VALUE                     How to fake X-Forwarded-For HTTP header to
+                                    try bypassing geographic restriction. One of
+                                    "default" (Only when known to be useful),
+                                    "never", a two-letter ISO 3166-2 country
+                                    code, or an IP block in CIDR notation
 
 ## Video Selection:
     -I, --playlist-items ITEM_SPEC  Comma separated playlist_index of the items
@@ -752,6 +748,7 @@ ## Internet Shortcut Options:
 ## Verbosity and Simulation Options:
     -q, --quiet                     Activate quiet mode. If used with --verbose,
                                     print the log to stderr
+    --no-quiet                      Deactivate quiet mode. (Default)
     --no-warnings                   Ignore warnings
     -s, --simulate                  Do not download the video and do not write
                                     anything to disk
@@ -1246,7 +1243,7 @@ # OUTPUT TEMPLATE
 
 1. **Alternatives**: Alternate fields can be specified separated with a `,`. E.g. `%(release_date>%Y,upload_date>%Y|Unknown)s`
 
-1. **Replacement**: A replacement value can be specified using a `&` separator. If the field is *not* empty, this replacement value will be used instead of the actual field content. This is done after alternate fields are considered; thus the replacement is used if *any* of the alternative fields is *not* empty.
+1. **Replacement**: A replacement value can be specified using a `&` separator according to the [`str.format` mini-language](https://docs.python.org/3/library/string.html#format-specification-mini-language). If the field is *not* empty, this replacement value will be used instead of the actual field content. This is done after alternate fields are considered; thus the replacement is used if *any* of the alternative fields is *not* empty. E.g. `%(chapters&has chapters|no chapters)s`, `%(title&TITLE={:>20}|NO TITLE)s`
 
 1. **Default**: A literal default value can be specified for when the field is empty using a `|` separator. This overrides `--output-na-placeholder`. E.g. `%(uploader|Unknown)s`
 
@@ -2167,6 +2164,10 @@ #### Not recommended
     --youtube-skip-hls-manifest      --extractor-args "youtube:skip=hls" (Alias: --no-youtube-include-hls-manifest)
     --youtube-include-dash-manifest  Default (Alias: --no-youtube-skip-dash-manifest)
     --youtube-include-hls-manifest   Default (Alias: --no-youtube-skip-hls-manifest)
+    --geo-bypass                     --xff "default"
+    --no-geo-bypass                  --xff "never"
+    --geo-bypass-country CODE        --xff CODE
+    --geo-bypass-ip-block IP_BLOCK   --xff IP_BLOCK
 
 
 #### Developer options
diff --git a/yt_dlp/YoutubeDL.py b/yt_dlp/YoutubeDL.py
index 31f7645dc..dce6cf928 100644
--- a/yt_dlp/YoutubeDL.py
+++ b/yt_dlp/YoutubeDL.py
@@ -21,7 +21,7 @@
 import traceback
 import unicodedata
 import urllib.request
-from string import ascii_letters
+from string import Formatter, ascii_letters
 
 from .cache import Cache
 from .compat import compat_os_name, compat_shlex_quote
@@ -1156,7 +1156,7 @@ def prepare_outtmpl(self, outtmpl, info_dict, sanitize=False):
         }
         MATH_FIELD_RE = rf'(?:{FIELD_RE}|-?{NUMBER_RE})'
         MATH_OPERATORS_RE = r'(?:%s)' % '|'.join(map(re.escape, MATH_FUNCTIONS.keys()))
-        INTERNAL_FORMAT_RE = re.compile(rf'''(?x)
+        INTERNAL_FORMAT_RE = re.compile(rf'''(?xs)
             (?P<negate>-)?
             (?P<fields>{FIELD_RE})
             (?P<maths>(?:{MATH_OPERATORS_RE}{MATH_FIELD_RE})*)
@@ -1237,6 +1237,14 @@ def _dumpjson_default(obj):
                 return list(obj)
             return repr(obj)
 
+        class _ReplacementFormatter(Formatter):
+            def get_field(self, field_name, args, kwargs):
+                if field_name.isdigit():
+                    return args[0], -1
+                raise ValueError('Unsupported field')
+
+        replacement_formatter = _ReplacementFormatter()
+
         def create_key(outer_mobj):
             if not outer_mobj.group('has_key'):
                 return outer_mobj.group(0)
@@ -1258,7 +1266,13 @@ def create_key(outer_mobj):
             if fmt == 's' and value is not None and key in field_size_compat_map.keys():
                 fmt = f'0{field_size_compat_map[key]:d}d'
 
-            value = default if value is None else value if replacement is None else replacement
+            if value is None:
+                value = default
+            elif replacement is not None:
+                try:
+                    value = replacement_formatter.format(replacement, value)
+                except ValueError:
+                    value = na
 
             flags = outer_mobj.group('conversion') or ''
             str_fmt = f'{fmt[:-1]}s'
diff --git a/yt_dlp/__init__.py b/yt_dlp/__init__.py
index bdac1212c..47ee3cc02 100644
--- a/yt_dlp/__init__.py
+++ b/yt_dlp/__init__.py
@@ -396,12 +396,17 @@ def metadataparser_actions(f):
         except Exception as err:
             raise ValueError(f'Invalid playlist-items {opts.playlist_items!r}: {err}')
 
-    geo_bypass_code = opts.geo_bypass_ip_block or opts.geo_bypass_country
-    if geo_bypass_code is not None:
+    opts.geo_bypass_country, opts.geo_bypass_ip_block = None, None
+    if opts.geo_bypass.lower() not in ('default', 'never'):
         try:
-            GeoUtils.random_ipv4(geo_bypass_code)
+            GeoUtils.random_ipv4(opts.geo_bypass)
         except Exception:
-            raise ValueError('unsupported geo-bypass country or ip-block')
+            raise ValueError(f'Unsupported --xff "{opts.geo_bypass}"')
+        if len(opts.geo_bypass) == 2:
+            opts.geo_bypass_country = opts.geo_bypass
+        else:
+            opts.geo_bypass_ip_block = opts.geo_bypass
+    opts.geo_bypass = opts.geo_bypass.lower() != 'never'
 
     opts.match_filter = match_filter_func(opts.match_filter, opts.breaking_match_filter)
 
@@ -704,7 +709,8 @@ def parse_options(argv=None):
         'dumpjson', 'dump_single_json', 'getdescription', 'getduration', 'getfilename',
         'getformat', 'getid', 'getthumbnail', 'gettitle', 'geturl'
     ))
-    opts.quiet = opts.quiet or any_getting or opts.print_json or bool(opts.forceprint)
+    if opts.quiet is None:
+        opts.quiet = any_getting or opts.print_json or bool(opts.forceprint)
 
     playlist_pps = [pp for pp in postprocessors if pp.get('when') == 'playlist']
     write_playlist_infojson = (opts.writeinfojson and not opts.clean_infojson
diff --git a/yt_dlp/extractor/_extractors.py b/yt_dlp/extractor/_extractors.py
index a81682e43..b82f52bca 100644
--- a/yt_dlp/extractor/_extractors.py
+++ b/yt_dlp/extractor/_extractors.py
@@ -721,7 +721,6 @@
 from .heise import HeiseIE
 from .hellporno import HellPornoIE
 from .helsinki import HelsinkiIE
-from .hentaistigma import HentaiStigmaIE
 from .hgtv import HGTVComShowIE
 from .hketv import HKETVIE
 from .hidive import HiDiveIE
@@ -1622,6 +1621,11 @@
 from .rtp import RTPIE
 from .rtrfm import RTRFMIE
 from .rts import RTSIE
+from .rtvcplay import (
+    RTVCPlayIE,
+    RTVCPlayEmbedIE,
+    RTVCKalturaIE,
+)
 from .rtve import (
     RTVEALaCartaIE,
     RTVEAudioIE,
@@ -1691,6 +1695,7 @@
 )
 from .scrolller import ScrolllerIE
 from .seeker import SeekerIE
+from .senalcolombia import SenalColombiaLiveIE
 from .senategov import SenateISVPIE, SenateGovIE
 from .sendtonews import SendtoNewsIE
 from .servus import ServusIE
diff --git a/yt_dlp/extractor/hentaistigma.py b/yt_dlp/extractor/hentaistigma.py
deleted file mode 100644
index ca5ffc2ae..000000000
--- a/yt_dlp/extractor/hentaistigma.py
+++ /dev/null
@@ -1,37 +0,0 @@
-from .common import InfoExtractor
-
-
-class HentaiStigmaIE(InfoExtractor):
-    _VALID_URL = r'^https?://hentai\.animestigma\.com/(?P<id>[^/]+)'
-    _TEST = {
-        'url': 'http://hentai.animestigma.com/inyouchuu-etsu-bonus/',
-        'md5': '4e3d07422a68a4cc363d8f57c8bf0d23',
-        'info_dict': {
-            'id': 'inyouchuu-etsu-bonus',
-            'ext': 'mp4',
-            'title': 'Inyouchuu Etsu Bonus',
-            'age_limit': 18,
-        }
-    }
-
-    def _real_extract(self, url):
-        video_id = self._match_id(url)
-
-        webpage = self._download_webpage(url, video_id)
-
-        title = self._html_search_regex(
-            r'<h2[^>]+class="posttitle"[^>]*><a[^>]*>([^<]+)</a>',
-            webpage, 'title')
-        wrap_url = self._html_search_regex(
-            r'<iframe[^>]+src="([^"]+mp4)"', webpage, 'wrapper url')
-        wrap_webpage = self._download_webpage(wrap_url, video_id)
-
-        video_url = self._html_search_regex(
-            r'file\s*:\s*"([^"]+)"', wrap_webpage, 'video url')
-
-        return {
-            'id': video_id,
-            'url': video_url,
-            'title': title,
-            'age_limit': 18,
-        }
diff --git a/yt_dlp/extractor/niconico.py b/yt_dlp/extractor/niconico.py
index cacefeb42..30b4d7216 100644
--- a/yt_dlp/extractor/niconico.py
+++ b/yt_dlp/extractor/niconico.py
@@ -660,10 +660,10 @@ def _real_extract(self, url):
 
 class NiconicoSeriesIE(InfoExtractor):
     IE_NAME = 'niconico:series'
-    _VALID_URL = r'https?://(?:(?:www\.|sp\.)?nicovideo\.jp|nico\.ms)/series/(?P<id>\d+)'
+    _VALID_URL = r'https?://(?:(?:www\.|sp\.)?nicovideo\.jp(?:/user/\d+)?|nico\.ms)/series/(?P<id>\d+)'
 
     _TESTS = [{
-        'url': 'https://www.nicovideo.jp/series/110226',
+        'url': 'https://www.nicovideo.jp/user/44113208/series/110226',
         'info_dict': {
             'id': '110226',
             'title': 'ご立派ァ！のシリーズ',
@@ -683,7 +683,7 @@ class NiconicoSeriesIE(InfoExtractor):
 
     def _real_extract(self, url):
         list_id = self._match_id(url)
-        webpage = self._download_webpage(f'https://www.nicovideo.jp/series/{list_id}', list_id)
+        webpage = self._download_webpage(url, list_id)
 
         title = self._search_regex(
             (r'<title>「(.+)（全',
@@ -691,10 +691,9 @@ def _real_extract(self, url):
             webpage, 'title', fatal=False)
         if title:
             title = unescapeHTML(title)
-        playlist = [
-            self.url_result(f'https://www.nicovideo.jp/watch/{v_id}', video_id=v_id)
-            for v_id in re.findall(r'data-href=[\'"](?:https://www\.nicovideo\.jp)?/watch/([a-z0-9]+)', webpage)]
-        return self.playlist_result(playlist, list_id, title)
+        json_data = next(self._yield_json_ld(webpage, None, fatal=False))
+        return self.playlist_from_matches(
+            traverse_obj(json_data, ('itemListElement', ..., 'url')), list_id, title, ie=NiconicoIE)
 
 
 class NiconicoHistoryIE(NiconicoPlaylistBaseIE):
diff --git a/yt_dlp/extractor/rtvcplay.py b/yt_dlp/extractor/rtvcplay.py
new file mode 100644
index 000000000..741c47262
--- /dev/null
+++ b/yt_dlp/extractor/rtvcplay.py
@@ -0,0 +1,285 @@
+import re
+
+from .common import InfoExtractor, ExtractorError
+from ..utils import (
+    clean_html,
+    determine_ext,
+    int_or_none,
+    float_or_none,
+    js_to_json,
+    mimetype2ext,
+    traverse_obj,
+    urljoin,
+    url_or_none,
+)
+
+
+class RTVCPlayBaseIE(InfoExtractor):
+    _BASE_VALID_URL = r'https?://(?:www\.)?rtvcplay\.co'
+
+    def _extract_player_config(self, webpage, video_id):
+        return self._search_json(
+            r'<script\b[^>]*>[^<]*(?:var|let|const)\s+config\s*=', re.sub(r'"\s*\+\s*"', '', webpage),
+            'player_config', video_id, transform_source=js_to_json)
+
+    def _extract_formats_and_subtitles_player_config(self, player_config, video_id):
+        formats, subtitles = [], {}
+        for source in traverse_obj(player_config, ('sources', ..., lambda _, v: url_or_none(v['url']))):
+            ext = mimetype2ext(source.get('mimetype'), default=determine_ext(source['url']))
+            if ext == 'm3u8':
+                fmts, subs = self._extract_m3u8_formats_and_subtitles(
+                    source['url'], video_id, 'mp4', fatal=False)
+                formats.extend(fmts)
+                self._merge_subtitles(subs, target=subtitles)
+            else:
+                formats.append({
+                    'url': source['url'],
+                    'ext': ext,
+                })
+
+        return formats, subtitles
+
+
+class RTVCPlayIE(RTVCPlayBaseIE):
+    _VALID_URL = RTVCPlayBaseIE._BASE_VALID_URL + r'/(?P<category>(?!embed)[^/]+)/(?:[^?#]+/)?(?P<id>[\w-]+)'
+
+    _TESTS = [{
+        'url': 'https://www.rtvcplay.co/en-vivo/canal-institucional',
+        'info_dict': {
+            'id': 'canal-institucional',
+            'title': r're:^Canal Institucional',
+            'description': 'md5:eff9e548394175928059320c006031ea',
+            'thumbnail': r're:^https?://.*\.(?:jpg|png)',
+            'live_status': 'is_live',
+            'ext': 'mp4',
+        },
+        'params': {
+            'skip_download': 'Livestream',
+        },
+    }, {
+        'url': 'https://www.rtvcplay.co/en-vivo/senal-colombia',
+        'info_dict': {
+            'id': 'senal-colombia',
+            'title': r're:^Señal Colombia',
+            'description': 'md5:799f16a401d97f40c33a2c6a3e2a507b',
+            'thumbnail': r're:^https?://.*\.(?:jpg|png)',
+            'live_status': 'is_live',
+            'ext': 'mp4',
+        },
+        'params': {
+            'skip_download': 'Livestream',
+        },
+    }, {
+        'url': 'https://www.rtvcplay.co/en-vivo/radio-nacional',
+        'info_dict': {
+            'id': 'radio-nacional',
+            'title': r're:^Radio Nacional',
+            'description': 'md5:5de009bc6a9fa79d2a6cf0b73f977d53',
+            'thumbnail': r're:^https?://.*\.(?:jpg|png)',
+            'live_status': 'is_live',
+            'ext': 'mp4',
+        },
+        'params': {
+            'skip_download': 'Livestream',
+        },
+    }, {
+        'url': 'https://www.rtvcplay.co/peliculas-ficcion/senoritas',
+        'md5': '1288ee6f6d1330d880f98bff2ed710a3',
+        'info_dict': {
+            'id': 'senoritas',
+            'title': 'Señoritas',
+            'description': 'md5:f095a2bb52cb6cf279daf6302f86fb32',
+            'thumbnail': r're:^https?://.*\.(?:jpg|png)',
+            'ext': 'mp4',
+        },
+    }, {
+        'url': 'https://www.rtvcplay.co/competencias-basicas-ciudadanas-y-socioemocionales/profe-en-tu-casa/james-regresa-clases-28022022',
+        'md5': 'f040a7380a269ad633cf837384d5e9fc',
+        'info_dict': {
+            'id': 'james-regresa-clases-28022022',
+            'title': 'James regresa a clases - 28/02/2022',
+            'description': 'md5:c5dcdf757c7ab29305e8763c6007e675',
+            'ext': 'mp4',
+        },
+    }, {
+        'url': 'https://www.rtvcplay.co/peliculas-documentales/llinas-el-cerebro-y-el-universo',
+        'info_dict': {
+            'id': 'llinas-el-cerebro-y-el-universo',
+            'title': 'Llinás, el cerebro y el universo',
+            'description': 'md5:add875bf2309bb52b3e8b9b06116d9b0',
+            'thumbnail': r're:^https?://.*\.(?:jpg|png)',
+        },
+        'playlist_mincount': 3,
+    }, {
+        'url': 'https://www.rtvcplay.co/competencias-basicas-ciudadanas-y-socioemocionales/profe-en-tu-casa',
+        'info_dict': {
+            'id': 'profe-en-tu-casa',
+            'title': 'Profe en tu casa',
+            'description': 'md5:47dbe20e263194413b1db2a2805a4f2e',
+            'thumbnail': r're:^https?://.*\.(?:jpg|png)',
+        },
+        'playlist_mincount': 537,
+    }, {
+        'url': 'https://www.rtvcplay.co/series-al-oido/relato-de-un-naufrago-una-travesia-del-periodismo-a-la-literatura',
+        'info_dict': {
+            'id': 'relato-de-un-naufrago-una-travesia-del-periodismo-a-la-literatura',
+            'title': 'Relato de un náufrago: una travesía del periodismo a la literatura',
+            'description': 'md5:6da28fdca4a5a568ea47ef65ef775603',
+            'thumbnail': r're:^https?://.*\.(?:jpg|png)',
+        },
+        'playlist_mincount': 5,
+    }, {
+        'url': 'https://www.rtvcplay.co/series-al-oido/diez-versiones',
+        'info_dict': {
+            'id': 'diez-versiones',
+            'title': 'Diez versiones',
+            'description': 'md5:997471ed971cb3fd8e41969457675306',
+            'thumbnail': r're:^https?://.*\.(?:jpg|png)',
+        },
+        'playlist_mincount': 20,
+    }]
+
+    def _real_extract(self, url):
+        video_id, category = self._match_valid_url(url).group('id', 'category')
+        webpage = self._download_webpage(url, video_id)
+
+        hydration = self._search_json(
+            r'window\.__RTVCPLAY_STATE__\s*=', webpage, 'hydration',
+            video_id, transform_source=js_to_json)['content']['currentContent']
+
+        asset_id = traverse_obj(hydration, ('video', 'assetid'))
+        if asset_id:
+            hls_url = hydration['base_url_hls'].replace('[node:field_asset_id]', asset_id)
+        else:
+            hls_url = traverse_obj(hydration, ('channel', 'hls'))
+
+        metadata = traverse_obj(hydration, {
+            'title': 'title',
+            'description': 'description',
+            'thumbnail': ((('channel', 'image', 'logo'), ('resource', 'image', 'cover_desktop')), 'path'),
+        }, get_all=False)
+
+        # Probably it's a program's page
+        if not hls_url:
+            seasons = traverse_obj(
+                hydration, ('widgets', lambda _, y: y['type'] == 'seasonList', 'contents'),
+                get_all=False)
+            if not seasons:
+                podcast_episodes = hydration.get('audios')
+                if not podcast_episodes:
+                    raise ExtractorError('Could not find asset_id nor program playlist nor podcast episodes')
+
+                return self.playlist_result([
+                    self.url_result(episode['file'], url_transparent=True, **traverse_obj(episode, {
+                        'title': 'title',
+                        'description': ('description', {clean_html}),
+                        'episode_number': ('chapter_number', {float_or_none}, {int_or_none}),
+                        'season_number': ('season', {int_or_none}),
+                    })) for episode in podcast_episodes], video_id, **metadata)
+
+            entries = [self.url_result(
+                urljoin(url, episode['slug']), url_transparent=True,
+                **traverse_obj(season, {
+                    'season': 'title',
+                    'season_number': ('season', {int_or_none}),
+                }), **traverse_obj(episode, {
+                    'title': 'title',
+                    'thumbnail': ('image', 'cover', 'path'),
+                    'episode_number': ('chapter_number', {int_or_none}),
+                })) for season in seasons for episode in traverse_obj(season, ('contents', ...))]
+
+            return self.playlist_result(entries, video_id, **metadata)
+
+        formats, subtitles = self._extract_m3u8_formats_and_subtitles(hls_url, video_id, 'mp4')
+
+        return {
+            'id': video_id,
+            'formats': formats,
+            'subtitles': subtitles,
+            'is_live': category == 'en-vivo',
+            **metadata,
+        }
+
+
+class RTVCPlayEmbedIE(RTVCPlayBaseIE):
+    _VALID_URL = RTVCPlayBaseIE._BASE_VALID_URL + r'/embed/(?P<id>[\w-]+)'
+
+    _TESTS = [{
+        'url': 'https://www.rtvcplay.co/embed/72b0e699-248b-4929-a4a8-3782702fa7f9',
+        'md5': 'ed529aeaee7aa2a72afe91ac7d1177a8',
+        'info_dict': {
+            'id': '72b0e699-248b-4929-a4a8-3782702fa7f9',
+            'title': 'Tráiler: Señoritas',
+            'thumbnail': r're:^https?://.*\.(?:jpg|png)',
+            'ext': 'mp4',
+        }
+    }]
+
+    def _real_extract(self, url):
+        video_id = self._match_id(url)
+        webpage = self._download_webpage(url, video_id)
+
+        player_config = self._extract_player_config(webpage, video_id)
+        formats, subtitles = self._extract_formats_and_subtitles_player_config(player_config, video_id)
+
+        asset_id = traverse_obj(player_config, ('rtvcplay', 'assetid'))
+        metadata = {} if not asset_id else self._download_json(
+            f'https://cms.rtvcplay.co/api/v1/video/asset-id/{asset_id}', video_id, fatal=False)
+
+        return {
+            'id': video_id,
+            'formats': formats,
+            'subtitles': subtitles,
+            **traverse_obj(metadata, {
+                'title': 'title',
+                'description': 'description',
+                'thumbnail': ('image', ..., 'thumbnail', 'path'),
+            }, get_all=False)
+        }
+
+
+class RTVCKalturaIE(RTVCPlayBaseIE):
+    _VALID_URL = r'https?://media\.rtvc\.gov\.co/kalturartvc/(?P<id>[\w-]+)'
+
+    _TESTS = [{
+        'url': 'https://media.rtvc.gov.co/kalturartvc/indexSC.html',
+        'info_dict': {
+            'id': 'indexSC',
+            'title': r're:^Señal Colombia',
+            'description': 'md5:799f16a401d97f40c33a2c6a3e2a507b',
+            'thumbnail': r're:^https?://.*\.(?:jpg|png)',
+            'live_status': 'is_live',
+            'ext': 'mp4',
+        },
+        'params': {
+            'skip_download': 'Livestream',
+        },
+    }]
+
+    def _real_extract(self, url):
+        video_id = self._match_id(url)
+        webpage = self._download_webpage(url, video_id)
+
+        player_config = self._extract_player_config(webpage, video_id)
+        formats, subtitles = self._extract_formats_and_subtitles_player_config(player_config, video_id)
+
+        channel_id = traverse_obj(player_config, ('rtvcplay', 'channelId'))
+        metadata = {} if not channel_id else self._download_json(
+            f'https://cms.rtvcplay.co/api/v1/taxonomy_term/streaming/{channel_id}', video_id, fatal=False)
+
+        fmts, subs = self._extract_m3u8_formats_and_subtitles(
+            traverse_obj(metadata, ('channel', 'hls')), video_id, 'mp4', fatal=False)
+        formats.extend(fmts)
+        self._merge_subtitles(subs, target=subtitles)
+
+        return {
+            'id': video_id,
+            'formats': formats,
+            'subtitles': subtitles,
+            'is_live': True,
+            **traverse_obj(metadata, {
+                'title': 'title',
+                'description': 'description',
+                'thumbnail': ('channel', 'image', 'logo', 'path'),
+            })
+        }
diff --git a/yt_dlp/extractor/senalcolombia.py b/yt_dlp/extractor/senalcolombia.py
new file mode 100644
index 000000000..f3c066da7
--- /dev/null
+++ b/yt_dlp/extractor/senalcolombia.py
@@ -0,0 +1,31 @@
+from .common import InfoExtractor
+from .rtvcplay import RTVCKalturaIE
+
+
+class SenalColombiaLiveIE(InfoExtractor):
+    _VALID_URL = r'https?://(?:www\.)?senalcolombia\.tv/(?P<id>senal-en-vivo)'
+
+    _TESTS = [{
+        'url': 'https://www.senalcolombia.tv/senal-en-vivo',
+        'info_dict': {
+            'id': 'indexSC',
+            'title': 're:^Señal Colombia',
+            'description': 'md5:799f16a401d97f40c33a2c6a3e2a507b',
+            'thumbnail': r're:^https?://.*\.(?:jpg|png)',
+            'live_status': 'is_live',
+            'ext': 'mp4',
+        },
+        'params': {
+            'skip_download': 'Livestream',
+        },
+    }]
+
+    def _real_extract(self, url):
+        display_id = self._match_id(url)
+        webpage = self._download_webpage(url, display_id)
+
+        hydration = self._search_json(
+            r'<script\b[^>]*data-drupal-selector\s*=\s*"[^"]*drupal-settings-json[^"]*"[^>]*>',
+            webpage, 'hydration', display_id)
+
+        return self.url_result(hydration['envivosrc'], RTVCKalturaIE, display_id)
diff --git a/yt_dlp/options.py b/yt_dlp/options.py
index 84aeda7f1..362a648cd 100644
--- a/yt_dlp/options.py
+++ b/yt_dlp/options.py
@@ -243,7 +243,7 @@ def _dict_from_options_callback(
         if multiple_keys:
             allowed_keys = fr'({allowed_keys})(,({allowed_keys}))*'
         mobj = re.match(
-            fr'(?i)(?P<keys>{allowed_keys}){delimiter}(?P<val>.*)$',
+            fr'(?is)(?P<keys>{allowed_keys}){delimiter}(?P<val>.*)$',
             value[0] if multiple_args else value)
         if mobj is not None:
             keys, val = mobj.group('keys').split(','), mobj.group('val')
@@ -519,22 +519,27 @@ def _alias_callback(option, opt_str, value, parser, opts, nargs):
         '--cn-verification-proxy',
         dest='cn_verification_proxy', default=None, metavar='URL',
         help=optparse.SUPPRESS_HELP)
+    geo.add_option(
+        '--xff', metavar='VALUE',
+        dest='geo_bypass', default="default",
+        help=(
+            'How to fake X-Forwarded-For HTTP header to try bypassing geographic restriction. '
+            'One of "default" (Only when known to be useful), "never", '
+            'a two-letter ISO 3166-2 country code, or an IP block in CIDR notation'))
     geo.add_option(
         '--geo-bypass',
-        action='store_true', dest='geo_bypass', default=True,
-        help='Bypass geographic restriction via faking X-Forwarded-For HTTP header (default)')
+        action='store_const', dest='geo_bypass', const='default',
+        help=optparse.SUPPRESS_HELP)
     geo.add_option(
         '--no-geo-bypass',
-        action='store_false', dest='geo_bypass',
-        help='Do not bypass geographic restriction via faking X-Forwarded-For HTTP header')
+        action='store_const', dest='geo_bypass', const='never',
+        help=optparse.SUPPRESS_HELP)
     geo.add_option(
-        '--geo-bypass-country', metavar='CODE',
-        dest='geo_bypass_country', default=None,
-        help='Force bypass geographic restriction with explicitly provided two-letter ISO 3166-2 country code')
+        '--geo-bypass-country', metavar='CODE', dest='geo_bypass',
+        help=optparse.SUPPRESS_HELP)
     geo.add_option(
-        '--geo-bypass-ip-block', metavar='IP_BLOCK',
-        dest='geo_bypass_ip_block', default=None,
-        help='Force bypass geographic restriction with explicitly provided IP block in CIDR notation')
+        '--geo-bypass-ip-block', metavar='IP_BLOCK', dest='geo_bypass',
+        help=optparse.SUPPRESS_HELP)
 
     selection = optparse.OptionGroup(parser, 'Video Selection')
     selection.add_option(
@@ -1079,8 +1084,12 @@ def _alias_callback(option, opt_str, value, parser, opts, nargs):
     verbosity = optparse.OptionGroup(parser, 'Verbosity and Simulation Options')
     verbosity.add_option(
         '-q', '--quiet',
-        action='store_true', dest='quiet', default=False,
+        action='store_true', dest='quiet', default=None,
         help='Activate quiet mode. If used with --verbose, print the log to stderr')
+    verbosity.add_option(
+        '--no-quiet',
+        action='store_false', dest='quiet',
+        help='Deactivate quiet mode. (Default)')
     verbosity.add_option(
         '--no-warnings',
         dest='no_warnings', action='store_true', default=False,
diff --git a/yt_dlp/utils.py b/yt_dlp/utils.py
index 746a2885d..f69311462 100644
--- a/yt_dlp/utils.py
+++ b/yt_dlp/utils.py
@@ -5528,7 +5528,6 @@ def traverse_obj(
                             If no `default` is given and the last path branches, a `list` of results
                             is always returned. If a path ends on a `dict` that result will always be a `dict`.
     """
-    is_sequence = lambda x: isinstance(x, collections.abc.Sequence) and not isinstance(x, (str, bytes))
     casefold = lambda k: k.casefold() if isinstance(k, str) else k
 
     if isinstance(expected_type, type):
@@ -5564,7 +5563,7 @@ def apply_key(key, obj, is_last):
             branching = True
             if isinstance(obj, collections.abc.Mapping):
                 result = obj.values()
-            elif is_sequence(obj):
+            elif isinstance(obj, collections.abc.Iterable) and not isinstance(obj, (str, bytes)):
                 result = obj
             elif isinstance(obj, re.Match):
                 result = obj.groups()
@@ -5578,7 +5577,7 @@ def apply_key(key, obj, is_last):
             branching = True
             if isinstance(obj, collections.abc.Mapping):
                 iter_obj = obj.items()
-            elif is_sequence(obj):
+            elif isinstance(obj, collections.abc.Iterable) and not isinstance(obj, (str, bytes)):
                 iter_obj = enumerate(obj)
             elif isinstance(obj, re.Match):
                 iter_obj = itertools.chain(
@@ -5614,7 +5613,7 @@ def apply_key(key, obj, is_last):
                 result = next((v for k, v in obj.groupdict().items() if casefold(k) == key), None)
 
         elif isinstance(key, (int, slice)):
-            if is_sequence(obj):
+            if isinstance(obj, collections.abc.Sequence) and not isinstance(obj, (str, bytes)):
                 branching = isinstance(key, slice)
                 with contextlib.suppress(IndexError):
                     result = obj[key]


Test patch:
diff --git a/test/test_YoutubeDL.py b/test/test_YoutubeDL.py
index 8da1e5e4b..3c26bd7c6 100644
--- a/test/test_YoutubeDL.py
+++ b/test/test_YoutubeDL.py
@@ -822,6 +822,10 @@ def expect_same_infodict(out):
         test('%(title&foo|baz)s.bar', 'baz.bar')
         test('%(x,id&foo|baz)s.bar', 'foo.bar')
         test('%(x,title&foo|baz)s.bar', 'baz.bar')
+        test('%(id&a\nb|)s', ('a\nb', 'a b'))
+        test('%(id&hi {:>10} {}|)s', 'hi       1234 1234')
+        test(R'%(id&{0} {}|)s', 'NA')
+        test(R'%(id&{0.1}|)s', 'NA')
 
         # Laziness
         def gen():
diff --git a/test/test_utils.py b/test/test_utils.py
index d4a301583..f2f3b8170 100644
--- a/test/test_utils.py
+++ b/test/test_utils.py
@@ -2016,6 +2016,8 @@ def test_traverse_obj(self):
                          msg='nested `...` queries should work')
         self.assertCountEqual(traverse_obj(_TEST_DATA, (..., ..., 'index')), range(4),
                               msg='`...` query result should be flattened')
+        self.assertEqual(traverse_obj(range(4), ...), list(range(4)),
+                         msg='`...` should accept iterables')
 
         # Test function as key
         self.assertEqual(traverse_obj(_TEST_DATA, lambda x, y: x == 'urls' and isinstance(y, list)),
@@ -2023,6 +2025,8 @@ def test_traverse_obj(self):
                          msg='function as query key should perform a filter based on (key, value)')
         self.assertCountEqual(traverse_obj(_TEST_DATA, lambda _, x: isinstance(x[0], str)), {'str'},
                               msg='exceptions in the query function should be catched')
+        self.assertEqual(traverse_obj(range(4), lambda _, x: x % 2 == 0), [0, 2],
+                         msg='function key should accept iterables')
         if __debug__:
             with self.assertRaises(Exception, msg='Wrong function signature should raise in debug'):
                 traverse_obj(_TEST_DATA, lambda a: ...)