Repository: yt-dlp/yt-dlp

Issue:
Use authinfo.gpg instead of netrc for authentication
### Checklist

- [X] I'm reporting a feature request
- [X] I've verified that I'm running yt-dlp version **2021.11.10.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

Currently, there doesn't seem to be a way to use a gpg-encrypted `~/.authinfo.gpg` file instead of an unencrypted `~/.netrc` file. I use an `authinfo.gpg` file as it's a common way of providing credentials in Emacs. Encryption is obviously preferable over plain-text files for account credentials. I understand that including GPG encryption directly could be considered feature creep, so I'd suggest providing a flag provides the credentials via command output, similar to how `isync` does it. Here's an example of how I use this feature in `isync`'s config:

```
PassCmd "gpg --decrypt ~/.authinfo.gpg 2> /dev/null | grep outlook.office365.com | grep domain.com | sed 's/.*password \\([^ ]\\+\\).*/\\1/'"
```

Perhaps the netrc file could be provided like so:

```
--netrc-cmd "gpg --decrypt ~/.authinfo.gpg 2> /dev/null"
```

The rest of the netrc functionality would work as usual: the `netrc-cmd` flag would expect a decrypted netrc file as the return value.

When trying to use the `~/authinfo.gpg` file directly as-is, a predictable error is shown as the file cannot be parsed:

```
[debug] Command-line config: ['-Uv', 'https://twitch.tv/redacted']
[debug] User config file: /home/john/.config/yt-dlp/config
[debug] User config: ['--netrc', '--netrc-location', '~/.authinfo.gpg']
[debug] Encodings: locale UTF-8, fs utf-8, out utf-8, err utf-8, pref UTF-8
[debug] yt-dlp version 2021.11.10.1
[debug] Python version 3.9.7 (CPython 64bit) - Linux-5.15.2-arch1-1-x86_64-with-glibc2.33
[debug] exe versions: ffmpeg 4.4.1 (setts), ffprobe 4.4.1, rtmpdump 2.4
[debug] Optional libraries: Cryptodome, mutagen, sqlite, websockets
[debug] Proxy map: {}
yt-dlp is up to date (2021.11.10.1)
ERROR: 'utf-8' codec can't decode byte 0x85 in position 0: invalid start byte
Traceback (most recent call last):
  File "/home/john/.local/lib/python3.9/site-packages/yt_dlp/YoutubeDL.py", line 1311, in wrapper
    return func(self, *args, **kwargs)
  File "/home/john/.local/lib/python3.9/site-packages/yt_dlp/YoutubeDL.py", line 1336, in __extract_info
    ie_result = ie.extract(url)
  File "/home/john/.local/lib/python3.9/site-packages/yt_dlp/extractor/common.py", line 593, in extract
    self.initialize()
  File "/home/john/.local/lib/python3.9/site-packages/yt_dlp/extractor/common.py", line 501, in initialize
    self._real_initialize()
  File "/home/john/.local/lib/python3.9/site-packages/yt_dlp/extractor/twitch.py", line 58, in _real_initialize
    self._login()
  File "/home/john/.local/lib/python3.9/site-packages/yt_dlp/extractor/twitch.py", line 61, in _login
    username, password = self._get_login_info()
  File "/home/john/.local/lib/python3.9/site-packages/yt_dlp/extractor/common.py", line 1219, in _get_login_info
    username, password = self._get_netrc_login_info(netrc_machine)
  File "/home/john/.local/lib/python3.9/site-packages/yt_dlp/extractor/common.py", line 1191, in _get_netrc_login_info
    info = netrc.netrc(file=netrc_file).authenticators(netrc_machine)
  File "/usr/lib/python3.9/netrc.py", line 30, in __init__
    self._parse(file, fp, default_netrc)
  File "/usr/lib/python3.9/netrc.py", line 39, in _parse
    toplevel = tt = lexer.get_token()
  File "/usr/lib/python3.9/shlex.py", line 109, in get_token
    raw = self.read_token()
  File "/usr/lib/python3.9/shlex.py", line 140, in read_token
    nextchar = self.instream.read(1)
  File "/usr/lib/python3.9/codecs.py", line 322, in decode
    (result, consumed) = self._buffer_decode(data, self.errors, final)
UnicodeDecodeError: 'utf-8' codec can't decode byte 0x85 in position 0: invalid start byte
```

Discussion:
pukkandan: For ref: some of the disucussion is in https://github.com/yt-dlp/yt-dlp/issues/6676
pukkandan: > I also updated the README, is there another place where documentation should be updated ?

https://github.com/yt-dlp/yt-dlp/blob/68be95bd0ca3f76aa63c9812935bd826b3a42e53/yt_dlp/YoutubeDL.py#L192


> The exclusion mechanism seems a bit clunky to me, but I can't think of another way that wouldn't be a lot of different comparisons. Maybe there is some python magic I don't know about that makes it possible to do an exclusive comparaison of 3 boolean variables 🤔

```py
sum(map(bool, (opts.usenetrc, opts.netrc_cmd, opts.username))) > 1
```

> For now I kept the logic of having the passord on the first line of the output, and the username on the second one, I'll change it if you have a specific format in mind you prefer.

See https://github.com/yt-dlp/yt-dlp/issues/6676#issuecomment-1491427891, but I am open to discussion
NDagestad: From #6676 :

> I was thinking we expect netrc format? So `--netrc-cmd "cat ~/.netrc"` will work and other commands can return multiple machines if needed without parsing extractor key.

I though about doing that in the beginning, but it was more work and it mean every command would need to format their output like the netrc command. I can see the usefulness of being able to have the command be a nearly drop in replacement for the encrypted .netrc file. But the netrc library takes a path as argument, and that is surprisingly annoying to work around.
I have a  few solutions that can work, but they all have their drawbacks:
* Use a plain file instead of the FIFO. This has the disadvantage of writing the credentials to the disk, which I would imagine most people who don't want their password in a clear text file won't be very happy about.
* Create a FIFO, redirect the output to it and pass it to the netrc lib. Nothing is written on disk but for this, we need to use the low level os.{open,read,write} functions, which I think are not portable (it looks like we want to support Windows as well and not just unixes).
* Parse the netrc format by hand <- not a big fan of this idea even if it looks like a very simple format
* Patch the netrc library to be able to take a file object instead of a path. This would be the best of these options, but it might take a very long time to trickle down in the different distributions if it is accepted upstream at all.

In the end, I think it is better to have a very simple format for the command output, and rather have people do the parsing in the command itself, something like `gpg -d ~/.authinfo.gpg | sed -n 's/machine {} login \(.*\) password \(.*\)/\2\n\1/p'` could be enough for that kind of usecase.
But the format "password\nusername" is not something I strongly attached to, 

> `%(...)s` support can also be added in future if needed like we did for `--exec`. `{}` is fine for now. But should it be replaced with `ie_key()` or with `_NETRC_MACHINE`? I think the latter is better.

the `_get_netrc_login_info` handler function uses `_NETRC_MACHINE`, I think it makes sens for them to both uses the same selector, the  `--netrc-cmd "cat ~/.netrc"` behaviour would not work for all extractors if they uses different ones. (e.g. Nebula at least has different values for it)
pukkandan: > But the netrc library takes a path as argument, and that is surprisingly annoying to work around.

Untested, but something like this should work:

```py
class stream_netrc(netrc.netrc):
    def __init__(self, stream):
        self.hosts = {}
        self.macros = {}
        self._parse('-', stream, False)
```

Ref: https://github.com/python/cpython/blob/3.11/Lib/netrc.py#L67-L78


> I think it makes sense for them to both uses the same selector,

Agreed
NDagestad: > Untested, but something like this should work:
>
> ...
> 
> Ref: https://github.com/python/cpython/blob/3.11/Lib/netrc.py#L67-L78

Oh well, that worked perfectly! 
Do you want that class somewhere accessible so it can be reused by other parts of the yt-dlp ?
pukkandan: put it in `utils.py`
NDagestad: Sorry I didn't notify that I applied the changes suggested, so I don't know I you have seen it.
No rush though.
NDagestad: Rebased on latest commits; is there something blocking for this to get merged ?
bashonly: In the future, please avoid force-pushing in the middle of review. You can just do a merge commit

It will be reviewed when time permits
pukkandan: https://github.com/yt-dlp/yt-dlp/pull/6682/commits/48525a63ae3a67133f0218b90ba11d72dedef4ea is not fully tested coz Ive gtg. Once it is, and the doc is corrected, this can be merged.
NDagestad: From my testing this works without problem. Maybe the example is not the most pertinent though, maybe replacing it with `--netrc-cmd "gpg --decrypt ~/.authinfo.gpg 2> /dev/null"` like proposed in #1706 would make sens to more people.
pukkandan: > Maybe the example is not the most pertinent though, maybe replacing it with `--netrc-cmd "gpg --decrypt ~/.authinfo.gpg 2> /dev/null"` like proposed in #1706 would make sens to more people.

I agree that is easier to understand.

Patch:
diff --git a/README.md b/README.md
index ce555c66f..9a00da903 100644
--- a/README.md
+++ b/README.md
@@ -49,7 +49,7 @@
     * [Extractor Options](#extractor-options)
 * [CONFIGURATION](#configuration)
     * [Configuration file encoding](#configuration-file-encoding)
-    * [Authentication with .netrc file](#authentication-with-netrc-file)
+    * [Authentication with netrc](#authentication-with-netrc)
     * [Notes about environment variables](#notes-about-environment-variables)
 * [OUTPUT TEMPLATE](#output-template)
     * [Output template examples](#output-template-examples)
@@ -157,14 +157,15 @@ ### Differences in default behavior
 * `certifi` will be used for SSL root certificates, if installed. If you want to use system certificates (e.g. self-signed), use `--compat-options no-certifi`
 * yt-dlp's sanitization of invalid characters in filenames is different/smarter than in youtube-dl. You can use `--compat-options filename-sanitization` to revert to youtube-dl's behavior
 * yt-dlp tries to parse the external downloader outputs into the standard progress output if possible (Currently implemented: [~~aria2c~~](https://github.com/yt-dlp/yt-dlp/issues/5931)). You can use `--compat-options no-external-downloader-progress` to get the downloader output as-is
+* yt-dlp versions between 2021.09.01 and 2023.01.02 applies `--match-filter` to nested playlists. This was an unintentional side-effect of [8f18ac](https://github.com/yt-dlp/yt-dlp/commit/8f18aca8717bb0dd49054555af8d386e5eda3a88) and is fixed in [d7b460](https://github.com/yt-dlp/yt-dlp/commit/d7b460d0e5fc710950582baed2e3fc616ed98a80). Use `--compat-options playlist-match-filter` to revert this
 
 For ease of use, a few more compat options are available:
 
 * `--compat-options all`: Use all compat options (Do NOT use)
-* `--compat-options youtube-dl`: Same as `--compat-options all,-multistreams`
-* `--compat-options youtube-dlc`: Same as `--compat-options all,-no-live-chat,-no-youtube-channel-redirect`
+* `--compat-options youtube-dl`: Same as `--compat-options all,-multistreams,-playlist-match-filter`
+* `--compat-options youtube-dlc`: Same as `--compat-options all,-no-live-chat,-no-youtube-channel-redirect,-playlist-match-filter`
 * `--compat-options 2021`: Same as `--compat-options 2022,no-certifi,filename-sanitization,no-youtube-prefer-utc-upload-date`
-* `--compat-options 2022`: Same as `--compat-options no-external-downloader-progress`. Use this to enable all future compat options
+* `--compat-options 2022`: Same as `--compat-options playlist-match-filter,no-external-downloader-progress`. Use this to enable all future compat options
 
 
 # INSTALLATION
@@ -728,7 +729,7 @@ ## Filesystem Options:
                                     By default, all containers of the most
                                     recently accessed profile are used.
                                     Currently supported keyrings are: basictext,
-                                    gnomekeyring, kwallet
+                                    gnomekeyring, kwallet, kwallet5, kwallet6
     --no-cookies-from-browser       Do not load cookies from browser (default)
     --cache-dir DIR                 Location in the filesystem where yt-dlp can
                                     store some downloaded information (such as
@@ -909,6 +910,8 @@ ## Authentication Options:
     --netrc-location PATH           Location of .netrc authentication data;
                                     either the path or its containing directory.
                                     Defaults to ~/.netrc
+    --netrc-cmd NETRC_CMD           Command to execute to get the credentials
+                                    credentials for an extractor.
     --video-password PASSWORD       Video password (vimeo, youku)
     --ap-mso MSO                    Adobe Pass multiple-system operator (TV
                                     provider) identifier, use --ap-list-mso for
@@ -1202,7 +1205,7 @@ ### Configuration file encoding
 
 If you want your file to be decoded differently, add `# coding: ENCODING` to the beginning of the file (e.g. `# coding: shift-jis`). There must be no characters before that, even spaces or BOM.
 
-### Authentication with `.netrc` file
+### Authentication with netrc
 
 You may also want to configure automatic credentials storage for extractors that support authentication (by providing login and password with `--username` and `--password`) in order not to pass credentials as command line arguments on every yt-dlp execution and prevent tracking plain text passwords in the shell command history. You can achieve this using a [`.netrc` file](https://stackoverflow.com/tags/.netrc/info) on a per-extractor basis. For that you will need to create a `.netrc` file in `--netrc-location` and restrict permissions to read/write by only you:
 ```
@@ -1222,6 +1225,15 @@ ### Authentication with `.netrc` file
 
 The default location of the .netrc file is `~` (see below).
 
+As an alternative to using the `.netrc` file, which has the disadvantage of keeping your passwords in a plain text file, you can configure a custom shell command to provide the credentials for an extractor. This is done by providing the `--netrc-cmd` parameter, it shall output the credentials in the netrc format and return `0` on success, other values will be treated as an error. `{}` in the command will be replaced by the name of the extractor to make it possible to select the credentials for the right extractor.
+To use braces in the command, they need to be escaped by doubling them. (see example bellow)
+
+E.g. To use an encrypted `.netrc` file stored as `.authinfo.gpg`
+```
+yt-dlp --netrc-cmd 'gpg --decrypt ~/.authinfo.gpg' https://www.youtube.com/watch?v=BaW_jenozKc
+```
+
+
 ### Notes about environment variables
 * Environment variables are normally specified as `${VARIABLE}`/`$VARIABLE` on UNIX and `%VARIABLE%` on Windows; but is always shown as `${VARIABLE}` in this documentation
 * yt-dlp also allow using UNIX-style variables on Windows for path-like options; e.g. `--output`, `--config-location`
@@ -1850,6 +1862,9 @@ ### wrestleuniverse
 #### twitchstream (Twitch)
 * `client_id`: Client ID value to be sent with GraphQL requests, e.g. `twitchstream:client_id=kimne78kx3ncx6brgo4mv6wki5h1ko`
 
+#### nhkradirulive (NHK らじる★らじる LIVE)
+* `area`: Which regional variation to extract. Valid areas are: `sapporo`, `sendai`, `tokyo`, `nagoya`, `osaka`, `hiroshima`, `matsuyama`, `fukuoka`. Defaults to `tokyo`
+
 **Note**: These options may be changed/removed in the future without concern for backward compatibility
 
 <!-- MANPAGE: MOVE "INSTALLATION" SECTION HERE -->
diff --git a/yt_dlp/YoutubeDL.py b/yt_dlp/YoutubeDL.py
index b4923920f..e51bceef3 100644
--- a/yt_dlp/YoutubeDL.py
+++ b/yt_dlp/YoutubeDL.py
@@ -190,6 +190,7 @@ class YoutubeDL:
     ap_password:       Multiple-system operator account password.
     usenetrc:          Use netrc for authentication instead.
     netrc_location:    Location of the netrc file. Defaults to ~/.netrc.
+    netrc_cmd:         Use a shell command to get credentials
     verbose:           Print additional info to stdout.
     quiet:             Do not print messages to stdout.
     no_warnings:       Do not print out anything for warnings.
@@ -1286,17 +1287,17 @@ 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'
 
-            if value is None:
-                value = default
-            elif replacement is not None:
+            if None not in (value, replacement):
                 try:
                     value = replacement_formatter.format(replacement, value)
                 except ValueError:
-                    value = na
+                    value, default = None, na
 
             flags = outer_mobj.group('conversion') or ''
             str_fmt = f'{fmt[:-1]}s'
-            if fmt[-1] == 'l':  # list
+            if value is None:
+                value, fmt = default, 's'
+            elif fmt[-1] == 'l':  # list
                 delim = '\n' if '#' in flags else ', '
                 value, fmt = delim.join(map(str, variadic(value, allowed_types=(str, bytes)))), str_fmt
             elif fmt[-1] == 'j':  # json
@@ -1403,7 +1404,7 @@ def prepare_filename(self, info_dict, dir_type='', *, outtmpl=None, warn=False):
 
     def _match_entry(self, info_dict, incomplete=False, silent=False):
         """Returns None if the file should be downloaded"""
-        _type = info_dict.get('_type', 'video')
+        _type = 'video' if 'playlist-match-filter' in self.params['compat_opts'] else info_dict.get('_type', 'video')
         assert incomplete or _type == 'video', 'Only video result can be considered complete'
 
         video_title = info_dict.get('title', info_dict.get('id', 'entry'))
diff --git a/yt_dlp/__init__.py b/yt_dlp/__init__.py
index 137c9503f..46edd88d3 100644
--- a/yt_dlp/__init__.py
+++ b/yt_dlp/__init__.py
@@ -188,8 +188,8 @@ def validate_minmax(min_val, max_val, min_name, max_name=None):
         raise ValueError(f'{max_name} "{max_val}" must be must be greater than or equal to {min_name} "{min_val}"')
 
     # Usernames and passwords
-    validate(not opts.usenetrc or (opts.username is None and opts.password is None),
-             '.netrc', msg='using {name} conflicts with giving username/password')
+    validate(sum(map(bool, (opts.usenetrc, opts.netrc_cmd, opts.username))) <= 1, '.netrc',
+             msg='{name}, netrc command and username/password are mutually exclusive options')
     validate(opts.password is None or opts.username is not None, 'account username', msg='{name} missing')
     validate(opts.ap_password is None or opts.ap_username is not None,
              'TV Provider account username', msg='{name} missing')
@@ -741,6 +741,7 @@ def parse_options(argv=None):
     return ParsedOptions(parser, opts, urls, {
         'usenetrc': opts.usenetrc,
         'netrc_location': opts.netrc_location,
+        'netrc_cmd': opts.netrc_cmd,
         'username': opts.username,
         'password': opts.password,
         'twofactor': opts.twofactor,
diff --git a/yt_dlp/cookies.py b/yt_dlp/cookies.py
index e46d19341..8693e0b4a 100644
--- a/yt_dlp/cookies.py
+++ b/yt_dlp/cookies.py
@@ -705,11 +705,11 @@ class _LinuxKeyring(Enum):
     https://chromium.googlesource.com/chromium/src/+/refs/heads/main/components/os_crypt/sync/key_storage_util_linux.h
     SelectedLinuxBackend
     """
-    KWALLET4 = auto()  # this value is just called KWALLET in the chromium source but it is for KDE4 only
+    KWALLET = auto()  # KDE4
     KWALLET5 = auto()
     KWALLET6 = auto()
-    GNOME_KEYRING = auto()
-    BASIC_TEXT = auto()
+    GNOMEKEYRING = auto()
+    BASICTEXT = auto()
 
 
 SUPPORTED_KEYRINGS = _LinuxKeyring.__members__.keys()
@@ -803,7 +803,7 @@ def _choose_linux_keyring(logger):
     desktop_environment = _get_linux_desktop_environment(os.environ, logger)
     logger.debug(f'detected desktop environment: {desktop_environment.name}')
     if desktop_environment == _LinuxDesktopEnvironment.KDE4:
-        linux_keyring = _LinuxKeyring.KWALLET4
+        linux_keyring = _LinuxKeyring.KWALLET
     elif desktop_environment == _LinuxDesktopEnvironment.KDE5:
         linux_keyring = _LinuxKeyring.KWALLET5
     elif desktop_environment == _LinuxDesktopEnvironment.KDE6:
@@ -811,9 +811,9 @@ def _choose_linux_keyring(logger):
     elif desktop_environment in (
         _LinuxDesktopEnvironment.KDE3, _LinuxDesktopEnvironment.LXQT, _LinuxDesktopEnvironment.OTHER
     ):
-        linux_keyring = _LinuxKeyring.BASIC_TEXT
+        linux_keyring = _LinuxKeyring.BASICTEXT
     else:
-        linux_keyring = _LinuxKeyring.GNOME_KEYRING
+        linux_keyring = _LinuxKeyring.GNOMEKEYRING
     return linux_keyring
 
 
@@ -828,7 +828,7 @@ def _get_kwallet_network_wallet(keyring, logger):
     """
     default_wallet = 'kdewallet'
     try:
-        if keyring == _LinuxKeyring.KWALLET4:
+        if keyring == _LinuxKeyring.KWALLET:
             service_name = 'org.kde.kwalletd'
             wallet_path = '/modules/kwalletd'
         elif keyring == _LinuxKeyring.KWALLET5:
@@ -929,11 +929,11 @@ def _get_linux_keyring_password(browser_keyring_name, keyring, logger):
     keyring = _LinuxKeyring[keyring] if keyring else _choose_linux_keyring(logger)
     logger.debug(f'Chosen keyring: {keyring.name}')
 
-    if keyring in (_LinuxKeyring.KWALLET4, _LinuxKeyring.KWALLET5, _LinuxKeyring.KWALLET6):
+    if keyring in (_LinuxKeyring.KWALLET, _LinuxKeyring.KWALLET5, _LinuxKeyring.KWALLET6):
         return _get_kwallet_password(browser_keyring_name, keyring, logger)
-    elif keyring == _LinuxKeyring.GNOME_KEYRING:
+    elif keyring == _LinuxKeyring.GNOMEKEYRING:
         return _get_gnome_keyring_password(browser_keyring_name, logger)
-    elif keyring == _LinuxKeyring.BASIC_TEXT:
+    elif keyring == _LinuxKeyring.BASICTEXT:
         # when basic text is chosen, all cookies are stored as v10 (so no keyring password is required)
         return None
     assert False, f'Unknown keyring {keyring}'
diff --git a/yt_dlp/downloader/fragment.py b/yt_dlp/downloader/fragment.py
index 53b4b604e..458167216 100644
--- a/yt_dlp/downloader/fragment.py
+++ b/yt_dlp/downloader/fragment.py
@@ -173,6 +173,9 @@ def _prepare_frag_download(self, ctx):
             **self.params,
             'noprogress': True,
             'test': False,
+            'sleep_interval': 0,
+            'max_sleep_interval': 0,
+            'sleep_interval_subtitles': 0,
         })
         tmpfilename = self.temp_name(ctx['filename'])
         open_mode = 'wb'
diff --git a/yt_dlp/extractor/_extractors.py b/yt_dlp/extractor/_extractors.py
index 10e132b4b..ff659a7a2 100644
--- a/yt_dlp/extractor/_extractors.py
+++ b/yt_dlp/extractor/_extractors.py
@@ -1260,6 +1260,7 @@
     NhkForSchoolProgramListIE,
     NhkRadioNewsPageIE,
     NhkRadiruIE,
+    NhkRadiruLiveIE,
 )
 from .nhl import NHLIE
 from .nick import (
@@ -2426,7 +2427,10 @@
     ZenYandexChannelIE,
 )
 from .yapfiles import YapFilesIE
-from .yappy import YappyIE
+from .yappy import (
+    YappyIE,
+    YappyProfileIE,
+)
 from .yesjapan import YesJapanIE
 from .yinyuetai import YinYueTaiIE
 from .yle_areena import YleAreenaIE
@@ -2444,7 +2448,10 @@
 from .youporn import YouPornIE
 from .yourporn import YourPornIE
 from .yourupload import YourUploadIE
-from .zaiko import ZaikoIE
+from .zaiko import (
+    ZaikoIE,
+    ZaikoETicketIE,
+)
 from .zapiks import ZapiksIE
 from .zattoo import (
     BBVTVIE,
diff --git a/yt_dlp/extractor/common.py b/yt_dlp/extractor/common.py
index ca2164a5d..f11a67358 100644
--- a/yt_dlp/extractor/common.py
+++ b/yt_dlp/extractor/common.py
@@ -13,6 +13,7 @@
 import os
 import random
 import re
+import subprocess
 import sys
 import time
 import types
@@ -34,6 +35,7 @@
     GeoUtils,
     HEADRequest,
     LenientJSONDecoder,
+    Popen,
     RegexNotFoundError,
     RetryManager,
     UnsupportedError,
@@ -70,6 +72,7 @@
     smuggle_url,
     str_or_none,
     str_to_int,
+    netrc_from_content,
     strip_or_none,
     traverse_obj,
     truncate_string,
@@ -535,7 +538,7 @@ class InfoExtractor:
     _EMBED_REGEX = []
 
     def _login_hint(self, method=NO_DEFAULT, netrc=None):
-        password_hint = f'--username and --password, or --netrc ({netrc or self._NETRC_MACHINE}) to provide account credentials'
+        password_hint = f'--username and --password, --netrc-cmd, or --netrc ({netrc or self._NETRC_MACHINE}) to provide account credentials'
         return {
             None: '',
             'any': f'Use --cookies, --cookies-from-browser, {password_hint}',
@@ -1291,45 +1294,47 @@ def _html_search_regex(self, pattern, string, name, default=NO_DEFAULT, fatal=Tr
         return clean_html(res)
 
     def _get_netrc_login_info(self, netrc_machine=None):
-        username = None
-        password = None
         netrc_machine = netrc_machine or self._NETRC_MACHINE
 
-        if self.get_param('usenetrc', False):
-            try:
-                netrc_file = compat_expanduser(self.get_param('netrc_location') or '~')
-                if os.path.isdir(netrc_file):
-                    netrc_file = os.path.join(netrc_file, '.netrc')
-                info = netrc.netrc(file=netrc_file).authenticators(netrc_machine)
-                if info is not None:
-                    username = info[0]
-                    password = info[2]
-                else:
-                    raise netrc.NetrcParseError(
-                        'No authenticators for %s' % netrc_machine)
-            except (OSError, netrc.NetrcParseError) as err:
-                self.report_warning(
-                    'parsing .netrc: %s' % error_to_compat_str(err))
+        cmd = self.get_param('netrc_cmd', '').format(netrc_machine)
+        if cmd:
+            self.to_screen(f'Executing command: {cmd}')
+            stdout, _, ret = Popen.run(cmd, text=True, shell=True, stdout=subprocess.PIPE)
+            if ret != 0:
+                raise OSError(f'Command returned error code {ret}')
+            info = netrc_from_content(stdout).authenticators(netrc_machine)
 
-        return username, password
+        elif self.get_param('usenetrc', False):
+            netrc_file = compat_expanduser(self.get_param('netrc_location') or '~')
+            if os.path.isdir(netrc_file):
+                netrc_file = os.path.join(netrc_file, '.netrc')
+            info = netrc.netrc(netrc_file).authenticators(netrc_machine)
+
+        else:
+            return None, None
+        if not info:
+            raise netrc.NetrcParseError(f'No authenticators for {netrc_machine}')
+        return info[0], info[2]
 
     def _get_login_info(self, username_option='username', password_option='password', netrc_machine=None):
         """
         Get the login info as (username, password)
         First look for the manually specified credentials using username_option
         and password_option as keys in params dictionary. If no such credentials
-        available look in the netrc file using the netrc_machine or _NETRC_MACHINE
-        value.
+        are available try the netrc_cmd if it is defined or look in the
+        netrc file using the netrc_machine or _NETRC_MACHINE value.
         If there's no info available, return (None, None)
         """
 
-        # Attempt to use provided username and password or .netrc data
         username = self.get_param(username_option)
         if username is not None:
             password = self.get_param(password_option)
         else:
-            username, password = self._get_netrc_login_info(netrc_machine)
-
+            try:
+                username, password = self._get_netrc_login_info(netrc_machine)
+            except (OSError, netrc.NetrcParseError) as err:
+                self.report_warning(f'Failed to parse .netrc: {err}')
+                return None, None
         return username, password
 
     def _get_tfa_info(self, note='two-factor verification code'):
diff --git a/yt_dlp/extractor/nhk.py b/yt_dlp/extractor/nhk.py
index a3efa326a..fbd6a18f6 100644
--- a/yt_dlp/extractor/nhk.py
+++ b/yt_dlp/extractor/nhk.py
@@ -2,12 +2,15 @@
 
 from .common import InfoExtractor
 from ..utils import (
+    ExtractorError,
+    int_or_none,
+    join_nonempty,
     parse_duration,
     traverse_obj,
     unescapeHTML,
     unified_timestamp,
+    url_or_none,
     urljoin,
-    url_or_none
 )
 
 
@@ -492,3 +495,73 @@ class NhkRadioNewsPageIE(InfoExtractor):
 
     def _real_extract(self, url):
         return self.url_result('https://www.nhk.or.jp/radio/ondemand/detail.html?p=F261_01', NhkRadiruIE)
+
+
+class NhkRadiruLiveIE(InfoExtractor):
+    _GEO_COUNTRIES = ['JP']
+    _VALID_URL = r'https?://www\.nhk\.or\.jp/radio/player/\?ch=(?P<id>r[12]|fm)'
+    _TESTS = [{
+        # radio 1, no area specified
+        'url': 'https://www.nhk.or.jp/radio/player/?ch=r1',
+        'info_dict': {
+            'id': 'r1-tokyo',
+            'title': 're:^ＮＨＫネットラジオ第1 東京.+$',
+            'ext': 'm4a',
+            'thumbnail': 'https://www.nhk.or.jp/common/img/media/r1-200x200.png',
+            'live_status': 'is_live',
+        },
+    }, {
+        # radio 2, area specified
+        # (the area doesnt actually matter, r2 is national)
+        'url': 'https://www.nhk.or.jp/radio/player/?ch=r2',
+        'params': {'extractor_args': {'nhkradirulive': {'area': ['fukuoka']}}},
+        'info_dict': {
+            'id': 'r2-fukuoka',
+            'title': 're:^ＮＨＫネットラジオ第2 福岡.+$',
+            'ext': 'm4a',
+            'thumbnail': 'https://www.nhk.or.jp/common/img/media/r2-200x200.png',
+            'live_status': 'is_live',
+        },
+    }, {
+        # fm, area specified
+        'url': 'https://www.nhk.or.jp/radio/player/?ch=fm',
+        'params': {'extractor_args': {'nhkradirulive': {'area': ['sapporo']}}},
+        'info_dict': {
+            'id': 'fm-sapporo',
+            'title': 're:^ＮＨＫネットラジオＦＭ 札幌.+$',
+            'ext': 'm4a',
+            'thumbnail': 'https://www.nhk.or.jp/common/img/media/fm-200x200.png',
+            'live_status': 'is_live',
+        }
+    }]
+
+    _NOA_STATION_IDS = {'r1': 'n1', 'r2': 'n2', 'fm': 'n3'}
+
+    def _real_extract(self, url):
+        station = self._match_id(url)
+        area = self._configuration_arg('area', ['tokyo'])[0]
+
+        config = self._download_xml(
+            'https://www.nhk.or.jp/radio/config/config_web.xml', station, 'Downloading area information')
+        data = config.find(f'.//data//area[.="{area}"]/..')
+
+        if not data:
+            raise ExtractorError('Invalid area. Valid areas are: %s' % ', '.join(
+                [i.text for i in config.findall('.//data//area')]), expected=True)
+
+        noa_info = self._download_json(
+            f'https:{config.find(".//url_program_noa").text}'.format(area=data.find('areakey').text),
+            station, note=f'Downloading {area} station metadata')
+        present_info = traverse_obj(noa_info, ('nowonair_list', self._NOA_STATION_IDS.get(station), 'present'))
+
+        return {
+            'title': ' '.join(traverse_obj(present_info, (('service', 'area',), 'name', {str}))),
+            'id': join_nonempty(station, area),
+            'thumbnails': traverse_obj(present_info, ('service', 'images', ..., {
+                'url': 'url',
+                'width': ('width', {int_or_none}),
+                'height': ('height', {int_or_none}),
+            })),
+            'formats': self._extract_m3u8_formats(data.find(f'{station}hls').text, station),
+            'is_live': True,
+        }
diff --git a/yt_dlp/extractor/tagesschau.py b/yt_dlp/extractor/tagesschau.py
index ea0532c24..e23b490b0 100644
--- a/yt_dlp/extractor/tagesschau.py
+++ b/yt_dlp/extractor/tagesschau.py
@@ -2,10 +2,12 @@
 
 from .common import InfoExtractor
 from ..utils import (
-    js_to_json,
+    UnsupportedError,
     extract_attributes,
-    try_get,
     int_or_none,
+    js_to_json,
+    parse_iso8601,
+    try_get,
 )
 
 
@@ -14,36 +16,38 @@ class TagesschauIE(InfoExtractor):
 
     _TESTS = [{
         'url': 'http://www.tagesschau.de/multimedia/video/video-102143.html',
-        'md5': '7a7287612fa881a1ae1d087df45c2fd6',
+        'md5': 'ccb9359bf8c4795836e43759f3408a93',
         'info_dict': {
             'id': 'video-102143-1',
             'ext': 'mp4',
             'title': 'Regierungsumbildung in Athen: Neue Minister in Griechenland vereidigt',
+            'duration': 138,
         },
     }, {
         'url': 'http://www.tagesschau.de/multimedia/sendung/ts-5727.html',
-        'md5': '3c54c1f6243d279b706bde660ceec633',
+        'md5': '5c15e8f3da049e48829ec9786d835536',
         'info_dict': {
             'id': 'ts-5727-1',
             'ext': 'mp4',
             'title': 'Ganze Sendung',
+            'duration': 932,
         },
     }, {
         # exclusive audio
         'url': 'http://www.tagesschau.de/multimedia/audio/audio-29417.html',
-        'md5': '4cf22023c285f35e99c24d290ba58cc9',
+        'md5': '4bff8f23504df56a0d86ed312d654182',
         'info_dict': {
             'id': 'audio-29417-1',
             'ext': 'mp3',
-            'title': 'Brasilianischer Präsident Bolsonaro unter Druck: Corona-Bericht wird vorgestellt',
+            'title': 'EU-Gipfel: Im Verbrennerstreit hat Deutschland maximalen Schaden angerichtet',
         },
     }, {
         'url': 'http://www.tagesschau.de/inland/bnd-303.html',
-        'md5': '12cfb212d9325b5ba0d52b625f1aa61c',
+        'md5': 'f049fa1698d7564e9ca4c3325108f034',
         'info_dict': {
             'id': 'bnd-303-1',
-            'ext': 'mp4',
-            'title': 'SPD-Gruppenbild mit Bärbel Bas nach der Fraktionssitzung | dpa',
+            'ext': 'mp3',
+            'title': 'Das Siegel des Bundesnachrichtendienstes | dpa',
         },
     }, {
         'url': 'http://www.tagesschau.de/inland/afd-parteitag-135.html',
@@ -51,13 +55,24 @@ class TagesschauIE(InfoExtractor):
             'id': 'afd-parteitag-135',
             'title': 'AfD',
         },
-        'playlist_count': 20,
+        'playlist_mincount': 15,
     }, {
         'url': 'https://www.tagesschau.de/multimedia/audio/audio-29417~player.html',
         'info_dict': {
             'id': 'audio-29417-1',
             'ext': 'mp3',
-            'title': 'Brasilianischer Präsident Bolsonaro unter Druck: Corona-Bericht wird vorgestellt',
+            'title': 'EU-Gipfel: Im Verbrennerstreit hat Deutschland maximalen Schaden angerichtet',
+        },
+    }, {
+        'url': 'https://www.tagesschau.de/multimedia/audio/podcast-11km-327.html',
+        'info_dict': {
+            'id': 'podcast-11km-327',
+            'ext': 'mp3',
+            'title': 'Gewalt in der Kita – Wenn Erzieher:innen schweigen',
+            'upload_date': '20230322',
+            'timestamp': 1679482808,
+            'thumbnail': 'https://www.tagesschau.de/multimedia/audio/podcast-11km-329~_v-original.jpg',
+            'description': 'md5:dad059931fe4b3693e3656e93a249848',
         },
     }, {
         'url': 'http://www.tagesschau.de/multimedia/sendung/tsg-3771.html',
@@ -117,7 +132,7 @@ def _real_extract(self, url):
                 formats = []
                 if media_url.endswith('master.m3u8'):
                     formats = self._extract_m3u8_formats(media_url, video_id, 'mp4', m3u8_id='hls')
-                elif media_url.endswith('.hi.mp3') and media_url.startswith('https://download'):
+                elif media_url.endswith('.mp3'):
                     formats = [{
                         'url': media_url,
                         'vcodec': 'none',
@@ -130,20 +145,19 @@ def _real_extract(self, url):
                     'duration': int_or_none(try_get(video, lambda x: x['mc']['_duration'])),
                     'formats': formats
                 })
+
+        if not entries:
+            raise UnsupportedError(url)
+
         if len(entries) > 1:
             return self.playlist_result(entries, display_id, title)
-        formats = entries[0]['formats']
-        video_info = self._search_json_ld(webpage, video_id)
-        description = video_info.get('description')
-        thumbnail = self._og_search_thumbnail(webpage) or video_info.get('thumbnail')
-        timestamp = video_info.get('timestamp')
-        title = title or video_info.get('description')
 
         return {
             'id': display_id,
             'title': title,
-            'thumbnail': thumbnail,
-            'formats': formats,
-            'timestamp': timestamp,
-            'description': description,
+            'thumbnail': self._og_search_thumbnail(webpage),
+            'formats': entries[0]['formats'],
+            'timestamp': parse_iso8601(self._html_search_meta('date', webpage)),
+            'description': self._og_search_description(webpage),
+            'duration': entries[0]['duration'],
         }
diff --git a/yt_dlp/extractor/yappy.py b/yt_dlp/extractor/yappy.py
index f168bdbf9..7b3d0cb81 100644
--- a/yt_dlp/extractor/yappy.py
+++ b/yt_dlp/extractor/yappy.py
@@ -1,9 +1,10 @@
 from .common import InfoExtractor
 from ..utils import (
+    OnDemandPagedList,
     int_or_none,
     traverse_obj,
     unified_timestamp,
-    url_or_none
+    url_or_none,
 )
 
 
@@ -97,3 +98,30 @@ def _real_extract(self, url):
             'categories': traverse_obj(media_data, ('categories', ..., 'name')) or None,
             'repost_count': int_or_none(media_data.get('sharingCount'))
         }
+
+
+class YappyProfileIE(InfoExtractor):
+    _VALID_URL = r'https?://yappy\.media/profile/(?P<id>\w+)'
+    _TESTS = [{
+        'url': 'https://yappy.media/profile/59a0c8c485e5410b9c43474bf4c6a373',
+        'info_dict': {
+            'id': '59a0c8c485e5410b9c43474bf4c6a373',
+        },
+        'playlist_mincount': 527,
+    }]
+
+    def _real_extract(self, url):
+        profile_id = self._match_id(url)
+
+        def fetch_page(page_num):
+            page_num += 1
+            videos = self._download_json(
+                f'https://yappy.media/api/video/list/{profile_id}?page={page_num}',
+                profile_id, f'Downloading profile page {page_num} JSON')
+
+            for video in traverse_obj(videos, ('results', lambda _, v: v['uuid'])):
+                yield self.url_result(
+                    f'https://yappy.media/video/{video["uuid"]}', YappyIE,
+                    video['uuid'], video.get('description'))
+
+        return self.playlist_result(OnDemandPagedList(fetch_page, 15), profile_id)
diff --git a/yt_dlp/extractor/youtube.py b/yt_dlp/extractor/youtube.py
index 606f24d04..4daa4f50e 100644
--- a/yt_dlp/extractor/youtube.py
+++ b/yt_dlp/extractor/youtube.py
@@ -3848,6 +3848,8 @@ def build_fragments(f):
                     f'{video_id}: Some formats are possibly damaged. They will be deprioritized', only_once=True)
 
             client_name = fmt.get(STREAMING_DATA_CLIENT_NAME)
+            name = fmt.get('qualityLabel') or quality.replace('audio_quality_', '') or ''
+            fps = int_or_none(fmt.get('fps')) or 0
             dct = {
                 'asr': int_or_none(fmt.get('audioSampleRate')),
                 'filesize': int_or_none(fmt.get('contentLength')),
@@ -3855,16 +3857,16 @@ def build_fragments(f):
                 'format_note': join_nonempty(
                     join_nonempty(audio_track.get('displayName'),
                                   language_preference > 0 and ' (default)', delim=''),
-                    fmt.get('qualityLabel') or quality.replace('audio_quality_', ''),
-                    fmt.get('isDrc') and 'DRC',
+                    name, fmt.get('isDrc') and 'DRC',
                     try_get(fmt, lambda x: x['projectionType'].replace('RECTANGULAR', '').lower()),
                     try_get(fmt, lambda x: x['spatialAudioType'].replace('SPATIAL_AUDIO_TYPE_', '').lower()),
                     throttled and 'THROTTLED', is_damaged and 'DAMAGED',
                     (self.get_param('verbose') or all_formats) and client_name,
                     delim=', '),
                 # Format 22 is likely to be damaged. See https://github.com/yt-dlp/yt-dlp/issues/3372
-                'source_preference': -10 if throttled else -5 if itag == '22' else -1,
-                'fps': int_or_none(fmt.get('fps')) or None,
+                'source_preference': ((-10 if throttled else -5 if itag == '22' else -1)
+                                      + (100 if 'Premium' in name else 0)),
+                'fps': fps if fps > 1 else None,  # For some formats, fps is wrongly returned as 1
                 'audio_channels': fmt.get('audioChannels'),
                 'height': height,
                 'quality': q(quality) - bool(fmt.get('isDrc')) / 2,
@@ -3935,6 +3937,8 @@ def process_manifest_format(f, proto, client_name, itag):
                 f['quality'] = q(res_qualities[min(res_qualities, key=lambda x: abs(x - f['height']))])
             if self.get_param('verbose'):
                 f['format_note'] = join_nonempty(f.get('format_note'), client_name, delim=', ')
+            if f.get('fps') and f['fps'] <= 1:
+                del f['fps']
             return True
 
         subtitles = {}
diff --git a/yt_dlp/extractor/zaiko.py b/yt_dlp/extractor/zaiko.py
index 59fc64c5a..84cee4445 100644
--- a/yt_dlp/extractor/zaiko.py
+++ b/yt_dlp/extractor/zaiko.py
@@ -1,3 +1,5 @@
+import base64
+
 from .common import InfoExtractor
 from ..utils import (
     ExtractorError,
@@ -5,12 +7,33 @@
     int_or_none,
     str_or_none,
     traverse_obj,
+    try_call,
     unescapeHTML,
     url_or_none,
 )
 
 
-class ZaikoIE(InfoExtractor):
+class ZaikoBaseIE(InfoExtractor):
+    def _download_real_webpage(self, url, video_id):
+        webpage, urlh = self._download_webpage_handle(url, video_id)
+        final_url = urlh.geturl()
+        if 'zaiko.io/login' in final_url:
+            self.raise_login_required()
+        elif '/_buy/' in final_url:
+            raise ExtractorError('Your account does not have tickets to this event', expected=True)
+        return webpage
+
+    def _parse_vue_element_attr(self, name, string, video_id):
+        page_elem = self._search_regex(rf'(<{name}[^>]+>)', string, name)
+        attrs = {}
+        for key, value in extract_attributes(page_elem).items():
+            if key.startswith(':'):
+                attrs[key[1:]] = self._parse_json(
+                    value, video_id, transform_source=unescapeHTML, fatal=False)
+        return attrs
+
+
+class ZaikoIE(ZaikoBaseIE):
     _VALID_URL = r'https?://(?:[\w-]+\.)?zaiko\.io/event/(?P<id>\d+)/stream(?:/\d+)+'
     _TESTS = [{
         'url': 'https://zaiko.io/event/324868/stream/20571/20571',
@@ -30,24 +53,10 @@ class ZaikoIE(InfoExtractor):
         'params': {'skip_download': 'm3u8'},
     }]
 
-    def _parse_vue_element_attr(self, name, string, video_id):
-        page_elem = self._search_regex(rf'(<{name}[^>]+>)', string, name)
-        attrs = {}
-        for key, value in extract_attributes(page_elem).items():
-            if key.startswith(':'):
-                attrs[key[1:]] = self._parse_json(
-                    value, video_id, transform_source=unescapeHTML, fatal=False)
-        return attrs
-
     def _real_extract(self, url):
         video_id = self._match_id(url)
 
-        webpage, urlh = self._download_webpage_handle(url, video_id)
-        final_url = urlh.geturl()
-        if 'zaiko.io/login' in final_url:
-            self.raise_login_required()
-        elif '/_buy/' in final_url:
-            raise ExtractorError('Your account does not have tickets to this event', expected=True)
+        webpage = self._download_real_webpage(url, video_id)
         stream_meta = self._parse_vue_element_attr('stream-page', webpage, video_id)
 
         player_page = self._download_webpage(
@@ -90,3 +99,32 @@ def _real_extract(self, url):
                 'thumbnail': ('poster_url', {url_or_none}),
             })),
         }
+
+
+class ZaikoETicketIE(ZaikoBaseIE):
+    _VALID_URL = r'https?://(?:www.)?zaiko\.io/account/eticket/(?P<id>[\w=-]{49})'
+    _TESTS = [{
+        'url': 'https://zaiko.io/account/eticket/TZjMwMzQ2Y2EzMXwyMDIzMDYwNzEyMTMyNXw1MDViOWU2Mw==',
+        'playlist_count': 1,
+        'info_dict': {
+            'id': 'f30346ca31-20230607121325-505b9e63',
+            'title': 'ZAIKO STREAMING TEST',
+            'thumbnail': 'https://media.zkocdn.net/pf_1/1_3wdyjcjyupseatkwid34u',
+        },
+        'skip': 'Only available with the ticketholding account',
+    }]
+
+    def _real_extract(self, url):
+        ticket_id = self._match_id(url)
+        ticket_id = try_call(
+            lambda: base64.urlsafe_b64decode(ticket_id[1:]).decode().replace('|', '-')) or ticket_id
+
+        webpage = self._download_real_webpage(url, ticket_id)
+        eticket = self._parse_vue_element_attr('eticket', webpage, ticket_id)
+
+        return self.playlist_result(
+            [self.url_result(stream, ZaikoIE) for stream in traverse_obj(eticket, ('streams', ..., 'url'))],
+            ticket_id, **traverse_obj(eticket, ('ticket-details', {
+                'title': 'event_name',
+                'thumbnail': 'event_img_url',
+            })))
diff --git a/yt_dlp/options.py b/yt_dlp/options.py
index fecc27403..b174a24af 100644
--- a/yt_dlp/options.py
+++ b/yt_dlp/options.py
@@ -467,15 +467,15 @@ def _alias_callback(option, opt_str, value, parser, opts, nargs):
         callback_kwargs={
             'allowed_values': {
                 'filename', 'filename-sanitization', 'format-sort', 'abort-on-error', 'format-spec', 'no-playlist-metafiles',
-                'multistreams', 'no-live-chat', 'playlist-index', 'list-formats', 'no-direct-merge',
+                'multistreams', 'no-live-chat', 'playlist-index', 'list-formats', 'no-direct-merge', 'playlist-match-filter',
                 'no-attach-info-json', 'embed-thumbnail-atomicparsley', 'no-external-downloader-progress',
                 'embed-metadata', 'seperate-video-versions', 'no-clean-infojson', 'no-keep-subs', 'no-certifi',
                 'no-youtube-channel-redirect', 'no-youtube-unavailable-videos', 'no-youtube-prefer-utc-upload-date',
             }, 'aliases': {
-                'youtube-dl': ['all', '-multistreams'],
-                'youtube-dlc': ['all', '-no-youtube-channel-redirect', '-no-live-chat'],
+                'youtube-dl': ['all', '-multistreams', '-playlist-match-filter'],
+                'youtube-dlc': ['all', '-no-youtube-channel-redirect', '-no-live-chat', '-playlist-match-filter'],
                 '2021': ['2022', 'no-certifi', 'filename-sanitization', 'no-youtube-prefer-utc-upload-date'],
-                '2022': ['no-external-downloader-progress'],
+                '2022': ['no-external-downloader-progress', 'playlist-match-filter'],
             }
         }, help=(
             'Options that can help keep compatibility with youtube-dl or youtube-dlc '
@@ -720,6 +720,10 @@ def _alias_callback(option, opt_str, value, parser, opts, nargs):
         '--netrc-location',
         dest='netrc_location', metavar='PATH',
         help='Location of .netrc authentication data; either the path or its containing directory. Defaults to ~/.netrc')
+    authentication.add_option(
+        '--netrc-cmd',
+        dest='netrc_cmd', metavar='NETRC_CMD',
+        help='Command to execute to get the credentials for an extractor.')
     authentication.add_option(
         '--video-password',
         dest='videopassword', metavar='PASSWORD',
diff --git a/yt_dlp/update.py b/yt_dlp/update.py
index 6c9bdaf1c..4790075eb 100644
--- a/yt_dlp/update.py
+++ b/yt_dlp/update.py
@@ -149,7 +149,7 @@ def __init__(self, ydl, target=None):
                     f'You are switching to an {self.ydl._format_err("unofficial", "red")} executable '
                     f'from {self.ydl._format_err(self._target_repo, self.ydl.Styles.EMPHASIS)}. '
                     f'Run {self.ydl._format_err("at your own risk", "light red")}')
-                self.restart = self._blocked_restart
+                self._block_restart('Automatically restarting into custom builds is disabled for security reasons')
         else:
             self._target_repo = UPDATE_SOURCES.get(self.target_channel)
             if not self._target_repo:
@@ -294,6 +294,7 @@ def update(self):
         if (_VERSION_RE.fullmatch(self.target_tag[5:])
                 and version_tuple(self.target_tag[5:]) < (2023, 3, 2)):
             self.ydl.report_warning('You are downgrading to a version without --update-to')
+            self._block_restart('Cannot automatically restart to a version without --update-to')
 
         directory = os.path.dirname(self.filename)
         if not os.access(self.filename, os.W_OK):
@@ -381,11 +382,11 @@ def restart(self):
         _, _, returncode = Popen.run(self.cmd)
         return returncode
 
-    def _blocked_restart(self):
-        self._report_error(
-            'Automatically restarting into custom builds is disabled for security reasons. '
-            'Restart yt-dlp to use the updated version', expected=True)
-        return self.ydl._download_retcode
+    def _block_restart(self, msg):
+        def wrapper():
+            self._report_error(f'{msg}. Restart yt-dlp to use the updated version', expected=True)
+            return self.ydl._download_retcode
+        self.restart = wrapper
 
 
 def run_update(ydl):
diff --git a/yt_dlp/utils/_utils.py b/yt_dlp/utils/_utils.py
index 646210116..28c2785cb 100644
--- a/yt_dlp/utils/_utils.py
+++ b/yt_dlp/utils/_utils.py
@@ -25,6 +25,7 @@
 import locale
 import math
 import mimetypes
+import netrc
 import operator
 import os
 import platform
@@ -864,6 +865,13 @@ def escapeHTML(text):
     )
 
 
+class netrc_from_content(netrc.netrc):
+    def __init__(self, content):
+        self.hosts, self.macros = {}, {}
+        with io.StringIO(content) as stream:
+            self._parse('-', stream, False)
+
+
 def process_communicate_or_kill(p, *args, **kwargs):
     deprecation_warning(f'"{__name__}.process_communicate_or_kill" is deprecated and may be removed '
                         f'in a future version. Use "{__name__}.Popen.communicate_or_kill" instead')
@@ -2452,7 +2460,10 @@ def strftime_or_none(timestamp, date_format, default=None):
         if isinstance(timestamp, (int, float)):  # unix timestamp
             # Using naive datetime here can break timestamp() in Windows
             # Ref: https://github.com/yt-dlp/yt-dlp/issues/5185, https://github.com/python/cpython/issues/94414
-            datetime_object = datetime.datetime.fromtimestamp(timestamp, datetime.timezone.utc)
+            # Also, datetime.datetime.fromtimestamp breaks for negative timestamps
+            # Ref: https://github.com/yt-dlp/yt-dlp/issues/6706#issuecomment-1496842642
+            datetime_object = (datetime.datetime.fromtimestamp(0, datetime.timezone.utc)
+                               + datetime.timedelta(seconds=timestamp))
         elif isinstance(timestamp, str):  # assume YYYYMMDD
             datetime_object = datetime.datetime.strptime(timestamp, '%Y%m%d')
         date_format = re.sub(  # Support %s on windows
@@ -3299,7 +3310,7 @@ def q(qid):
 '''
 
 
-STR_FORMAT_TYPES = 'diouxXeEfFgGcrs'
+STR_FORMAT_TYPES = 'diouxXeEfFgGcrsa'
 
 
 def limit_length(s, length):
@@ -5669,6 +5680,7 @@ def orderedSet_from_options(options, alias_dict, *, use_regex=False, start=None)
     return orderedSet(requested)
 
 
+# TODO: Rewrite
 class FormatSorter:
     regex = r' *((?P<reverse>\+)?(?P<field>[a-zA-Z0-9_]+)((?P<separator>[~:])(?P<limit>.*?))?)? *$'
 
@@ -5717,8 +5729,10 @@ class FormatSorter:
         'source': {'convert': 'float', 'field': 'source_preference', 'default': -1},
 
         'codec': {'type': 'combined', 'field': ('vcodec', 'acodec')},
-        'br': {'type': 'combined', 'field': ('tbr', 'vbr', 'abr'), 'same_limit': True},
-        'size': {'type': 'combined', 'same_limit': True, 'field': ('filesize', 'fs_approx')},
+        'br': {'type': 'multiple', 'field': ('tbr', 'vbr', 'abr'),
+               'function': lambda it: next(filter(None, it), None)},
+        'size': {'type': 'multiple', 'field': ('filesize', 'fs_approx'),
+                 'function': lambda it: next(filter(None, it), None)},
         'ext': {'type': 'combined', 'field': ('vext', 'aext')},
         'res': {'type': 'multiple', 'field': ('height', 'width'),
                 'function': lambda it: (lambda l: min(l) if l else 0)(tuple(filter(None, it)))},
@@ -5949,13 +5963,15 @@ def calculate_preference(self, format):
             format['preference'] = -100
 
         # Determine missing bitrates
-        if format.get('tbr') is None:
-            if format.get('vbr') is not None and format.get('abr') is not None:
-                format['tbr'] = format.get('vbr', 0) + format.get('abr', 0)
-        else:
-            if format.get('vcodec') != 'none' and format.get('vbr') is None:
-                format['vbr'] = format.get('tbr') - format.get('abr', 0)
-            if format.get('acodec') != 'none' and format.get('abr') is None:
-                format['abr'] = format.get('tbr') - format.get('vbr', 0)
+        if format.get('vcodec') == 'none':
+            format['vbr'] = 0
+        if format.get('acodec') == 'none':
+            format['abr'] = 0
+        if not format.get('vbr') and format.get('vcodec') != 'none':
+            format['vbr'] = try_call(lambda: format['tbr'] - format['abr']) or None
+        if not format.get('abr') and format.get('acodec') != 'none':
+            format['abr'] = try_call(lambda: format['tbr'] - format['vbr']) or None
+        if not format.get('tbr'):
+            format['tbr'] = try_call(lambda: format['vbr'] + format['abr']) or None
 
         return tuple(self._calculate_field_preference(format, field) for field in self._order)


Test patch:
diff --git a/test/test_InfoExtractor.py b/test/test_InfoExtractor.py
index 1f60abfd2..b7dee496a 100644
--- a/test/test_InfoExtractor.py
+++ b/test/test_InfoExtractor.py
@@ -917,8 +917,6 @@ def test_parse_m3u8_formats(self):
                     'acodec': 'mp4a.40.2',
                     'video_ext': 'mp4',
                     'audio_ext': 'none',
-                    'vbr': 263.851,
-                    'abr': 0,
                 }, {
                     'format_id': '577',
                     'format_index': None,
@@ -936,8 +934,6 @@ def test_parse_m3u8_formats(self):
                     'acodec': 'mp4a.40.2',
                     'video_ext': 'mp4',
                     'audio_ext': 'none',
-                    'vbr': 577.61,
-                    'abr': 0,
                 }, {
                     'format_id': '915',
                     'format_index': None,
@@ -955,8 +951,6 @@ def test_parse_m3u8_formats(self):
                     'acodec': 'mp4a.40.2',
                     'video_ext': 'mp4',
                     'audio_ext': 'none',
-                    'vbr': 915.905,
-                    'abr': 0,
                 }, {
                     'format_id': '1030',
                     'format_index': None,
@@ -974,8 +968,6 @@ def test_parse_m3u8_formats(self):
                     'acodec': 'mp4a.40.2',
                     'video_ext': 'mp4',
                     'audio_ext': 'none',
-                    'vbr': 1030.138,
-                    'abr': 0,
                 }, {
                     'format_id': '1924',
                     'format_index': None,
@@ -993,8 +985,6 @@ def test_parse_m3u8_formats(self):
                     'acodec': 'mp4a.40.2',
                     'video_ext': 'mp4',
                     'audio_ext': 'none',
-                    'vbr': 1924.009,
-                    'abr': 0,
                 }],
                 {
                     'en': [{
diff --git a/test/test_YoutubeDL.py b/test/test_YoutubeDL.py
index ee6c52713..ccc9e36f3 100644
--- a/test/test_YoutubeDL.py
+++ b/test/test_YoutubeDL.py
@@ -755,7 +755,7 @@ def expect_same_infodict(out):
         test('%(id)d %(id)r', "1234 '1234'")
         test('%(id)r %(height)r', "'1234' 1080")
         test('%(ext)s-%(ext|def)d', 'mp4-def')
-        test('%(width|0)04d', '0000')
+        test('%(width|0)04d', '0')
         test('a%(width|b)d', 'ab', outtmpl_na_placeholder='none')
 
         FORMATS = self.outtmpl_info['formats']