Repository: yt-dlp/yt-dlp
Pull request #4771 (2022-08-26 23:13:23)

[extractor/youtube] Add compat-opt to revert "Prefer UTC upload date for videos"
**IMPORTANT**: PRs without the template will be CLOSED

### Description of your *pull request* and other information

</details>

<!--

Explanation of your *pull request* in arbitrary form goes here. Please **make sure the description explains the purpose and effect** of your *pull request* and is worded well enough to be understood. Provide as much **context and examples** as possible

-->

ADD DESCRIPTION HERE

Adds `--compat-options no-youtube-prefer-utc-upload-date` (open to suggestions for better name).

This reverts https://github.com/yt-dlp/yt-dlp/commit/992f9a730b49fd36fc422be8d802f98ebcdce418 and https://github.com/yt-dlp/yt-dlp/commit/17322130a954577bb03b833d5c435638e51e19f2 to prefer the non-UTC upload date in microformats. 

This is needed in some cases where the preference of the UTC date is problematic (e.g. compatibility reasons or us switching between UTC and non-UTC depending on if the video is a live stream etc.)


<details open><summary>Template</summary> <!-- OPEN is intentional -->

<!--

# PLEASE FOLLOW THE GUIDE BELOW

- You will be asked some questions, please read them **carefully** and answer honestly
- Put an `x` into all the boxes `[ ]` relevant to your *pull request* (like [x])
- Use *Preview* tab to see how your *pull request* will actually look like

-->

### Before submitting a *pull request* make sure you have:
- [X] At least skimmed through [contributing guidelines](https://github.com/yt-dlp/yt-dlp/blob/master/CONTRIBUTING.md#developer-instructions) including [yt-dlp coding conventions](https://github.com/yt-dlp/yt-dlp/blob/master/CONTRIBUTING.md#yt-dlp-coding-conventions)
- [X] [Searched](https://github.com/yt-dlp/yt-dlp/search?q=is%3Apr&type=Issues) the bugtracker for similar pull requests
- [X] Checked the code with [flake8](https://pypi.python.org/pypi/flake8) and [ran relevant tests](https://github.com/yt-dlp/yt-dlp/blob/master/CONTRIBUTING.md#developer-instructions)

### In order to be accepted and merged into yt-dlp each piece of code must be in public domain or released under [Unlicense](http://unlicense.org/). Check one of the following options:
- [X] I am the original author of this code and I am willing to release it under [Unlicense](http://unlicense.org/)
- [ ] I am not the original author of this code but it is in public domain or released under [Unlicense](http://unlicense.org/) (provide reliable evidence)

### What is the purpose of your *pull request*?
- [X] Fix or improvement to an extractor (Make sure to add/update tests)
- [ ] New extractor ([Piracy websites will not be accepted](https://github.com/yt-dlp/yt-dlp/blob/master/CONTRIBUTING.md#is-the-website-primarily-used-for-piracy))
- [ ] Core bug fix/improvement
- [ ] New feature (It is strongly [recommended to open an issue first](https://github.com/yt-dlp/yt-dlp/blob/master/CONTRIBUTING.md#adding-new-feature-or-making-overarching-changes))


pukkandan: > This is needed in some cases where the preference of the UTC date is problematic (e.g. compatibility reasons or us switching between UTC and non-UTC depending on if the video is a live stream etc.)

The timestamp was always meant to be UTC. It being not so in previous versions is a bug. So compatibility is not an issue here. Can you explain your usecase?
ivan: I suggested this feature earlier because I have a large number of YouTube captures (going back to like 2010) with the non-UTC `upload_date` and I sometimes capture the same video again; having different `upload_date`s for the same video ID is very undesired for me. I carefully maintained things to avoid the UTC date and I would have to carry my own patch forever without this feature.
coletdjnz: > Name suggestions: `no-youtube-utc-upload-date` `no-youtube-utc`

`no-youtube-utc` is too broad imo.

`no-youtube-utc-upload-date` could work

Patch:
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index efacecd3c..45c5a43cc 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -194,7 +194,7 @@ jobs:
     - name: Install Requirements
       run: |  # Custom pyinstaller built with https://github.com/yt-dlp/pyinstaller-builds
           python -m pip install --upgrade pip setuptools wheel py2exe
-          pip install "https://yt-dlp.github.io/Pyinstaller-Builds/x86_64/pyinstaller-5.2-py3-none-any.whl" -r requirements.txt
+          pip install "https://yt-dlp.github.io/Pyinstaller-Builds/x86_64/pyinstaller-5.3-py3-none-any.whl" -r requirements.txt
 
     - name: Prepare
       run: |
@@ -230,7 +230,7 @@ jobs:
     - name: Install Requirements
       run: |
           python -m pip install --upgrade pip setuptools wheel
-          pip install "https://yt-dlp.github.io/Pyinstaller-Builds/i686/pyinstaller-5.2-py3-none-any.whl" -r requirements.txt
+          pip install "https://yt-dlp.github.io/Pyinstaller-Builds/i686/pyinstaller-5.3-py3-none-any.whl" -r requirements.txt
 
     - name: Prepare
       run: |
diff --git a/README.md b/README.md
index 8957711dd..83ab309c6 100644
--- a/README.md
+++ b/README.md
@@ -141,6 +141,7 @@ ### Differences in default behavior
 * Live chats (if available) are considered as subtitles. Use `--sub-langs all,-live_chat` to download all subtitles except live chat. You can also use `--compat-options no-live-chat` to prevent any live chat/danmaku from downloading
 * Youtube channel URLs are automatically redirected to `/video`. Append a `/featured` to the URL to download only the videos in the home page. If the channel does not have a videos tab, we try to download the equivalent `UU` playlist instead. For all other tabs, if the channel does not show the requested tab, an error will be raised. Also, `/live` URLs raise an error if there are no live videos instead of silently downloading the entire channel. You may use `--compat-options no-youtube-channel-redirect` to revert all these redirections
 * Unavailable videos are also listed for youtube playlists. Use `--compat-options no-youtube-unavailable-videos` to remove this
+* The upload dates extracted from YouTube are in UTC [when available](https://github.com/yt-dlp/yt-dlp/blob/89e4d86171c7b7c997c77d4714542e0383bf0db0/yt_dlp/extractor/youtube.py#L3898-L3900). Use `--compat-options no-youtube-prefer-utc-upload-date` to prefer the non-UTC upload date.
 * If `ffmpeg` is used as the downloader, the downloading and merging of formats happen in a single step when possible. Use `--compat-options no-direct-merge` to revert this
 * Thumbnail embedding in `mp4` is done with mutagen if possible. Use `--compat-options embed-thumbnail-atomicparsley` to force the use of AtomicParsley instead
 * Some private fields such as filenames are removed by default from the infojson. Use `--no-clean-infojson` or `--compat-options no-clean-infojson` to revert this
@@ -706,18 +707,20 @@ ## Filesystem Options:
                                     and dump cookie jar in
     --no-cookies                    Do not read/dump cookies from/to file
                                     (default)
-    --cookies-from-browser BROWSER[+KEYRING][:PROFILE]
-                                    The name of the browser and (optionally) the
-                                    name/path of the profile to load cookies
-                                    from, separated by a ":". Currently
-                                    supported browsers are: brave, chrome,
-                                    chromium, edge, firefox, opera, safari,
-                                    vivaldi. By default, the most recently
-                                    accessed profile is used. The keyring used
-                                    for decrypting Chromium cookies on Linux can
-                                    be (optionally) specified after the browser
-                                    name separated by a "+". Currently supported
-                                    keyrings are: basictext, gnomekeyring, kwallet
+    --cookies-from-browser BROWSER[+KEYRING][:PROFILE][::CONTAINER]
+                                    The name of the browser to load cookies
+                                    from. Currently supported browsers are:
+                                    brave, chrome, chromium, edge, firefox,
+                                    opera, safari, vivaldi. Optionally, the
+                                    KEYRING used for decrypting Chromium cookies
+                                    on Linux, the name/path of the PROFILE to
+                                    load cookies from, and the CONTAINER name
+                                    (if Firefox) ("none" for no container) can
+                                    be given with their respective seperators.
+                                    By default, all containers of the most
+                                    recently accessed profile are used.
+                                    Currently supported keyrings are: basictext,
+                                    gnomekeyring, kwallet
     --no-cookies-from-browser       Do not load cookies from browser (default)
     --cache-dir DIR                 Location in the filesystem where youtube-dl
                                     can store some downloaded information (such
diff --git a/yt_dlp/YoutubeDL.py b/yt_dlp/YoutubeDL.py
index 4330006cc..10c17ea00 100644
--- a/yt_dlp/YoutubeDL.py
+++ b/yt_dlp/YoutubeDL.py
@@ -90,6 +90,7 @@
     args_to_str,
     bug_reports_message,
     date_from_str,
+    deprecation_warning,
     determine_ext,
     determine_protocol,
     encode_compat_str,
@@ -303,8 +304,9 @@ class YoutubeDL:
                        should act on each input URL as opposed to for the entire queue
     cookiefile:        File name or text stream from where cookies should be read and dumped to
     cookiesfrombrowser:  A tuple containing the name of the browser, the profile
-                       name/path from where cookies are loaded, and the name of the
-                       keyring, e.g. ('chrome', ) or ('vivaldi', 'default', 'BASICTEXT')
+                       name/path from where cookies are loaded, the name of the keyring,
+                       and the container name, e.g. ('chrome', ) or
+                       ('vivaldi', 'default', 'BASICTEXT') or ('firefox', 'default', None, 'Meta')
     legacyserverconnect: Explicitly allow HTTPS connection to servers that do not
                        support RFC 5746 secure renegotiation
     nocheckcertificate:  Do not verify SSL certificates
@@ -631,7 +633,7 @@ def check_deprecated(param, option, suggestion):
         for msg in self.params.get('_warnings', []):
             self.report_warning(msg)
         for msg in self.params.get('_deprecation_warnings', []):
-            self.deprecation_warning(msg)
+            self.deprecated_feature(msg)
 
         self.params['compat_opts'] = set(self.params.get('compat_opts', ()))
         if 'list-formats' in self.params['compat_opts']:
@@ -835,9 +837,11 @@ def _write_string(self, message, out=None, only_once=False):
     def to_stdout(self, message, skip_eol=False, quiet=None):
         """Print message to stdout"""
         if quiet is not None:
-            self.deprecation_warning('"YoutubeDL.to_stdout" no longer accepts the argument quiet. Use "YoutubeDL.to_screen" instead')
+            self.deprecation_warning('"YoutubeDL.to_stdout" no longer accepts the argument quiet. '
+                                     'Use "YoutubeDL.to_screen" instead')
         if skip_eol is not False:
-            self.deprecation_warning('"YoutubeDL.to_stdout" no longer accepts the argument skip_eol. Use "YoutubeDL.to_screen" instead')
+            self.deprecation_warning('"YoutubeDL.to_stdout" no longer accepts the argument skip_eol. '
+                                     'Use "YoutubeDL.to_screen" instead')
         self._write_string(f'{self._bidi_workaround(message)}\n', self._out_files.out)
 
     def to_screen(self, message, skip_eol=False, quiet=None):
@@ -973,11 +977,14 @@ def report_warning(self, message, only_once=False):
                 return
             self.to_stderr(f'{self._format_err("WARNING:", self.Styles.WARNING)} {message}', only_once)
 
-    def deprecation_warning(self, message):
+    def deprecation_warning(self, message, *, stacklevel=0):
+        deprecation_warning(
+            message, stacklevel=stacklevel + 1, printer=self.report_error, is_error=False)
+
+    def deprecated_feature(self, message):
         if self.params.get('logger') is not None:
-            self.params['logger'].warning(f'DeprecationWarning: {message}')
-        else:
-            self.to_stderr(f'{self._format_err("DeprecationWarning:", self.Styles.ERROR)} {message}', True)
+            self.params['logger'].warning(f'Deprecated Feature: {message}')
+        self.to_stderr(f'{self._format_err("Deprecated Feature:", self.Styles.ERROR)} {message}', True)
 
     def report_error(self, message, *args, **kwargs):
         '''
diff --git a/yt_dlp/__init__.py b/yt_dlp/__init__.py
index e9234e6f4..552f29bd9 100644
--- a/yt_dlp/__init__.py
+++ b/yt_dlp/__init__.py
@@ -63,6 +63,8 @@
 )
 from .YoutubeDL import YoutubeDL
 
+_IN_CLI = False
+
 
 def _exit(status=0, *args):
     for msg in args:
@@ -344,10 +346,16 @@ def parse_chapters(name, value):
 
     # Cookies from browser
     if opts.cookiesfrombrowser:
-        mobj = re.match(r'(?P<name>[^+:]+)(\s*\+\s*(?P<keyring>[^:]+))?(\s*:(?P<profile>.+))?', opts.cookiesfrombrowser)
+        container = None
+        mobj = re.fullmatch(r'''(?x)
+            (?P<name>[^+:]+)
+            (?:\s*\+\s*(?P<keyring>[^:]+))?
+            (?:\s*:\s*(?P<profile>.+?))?
+            (?:\s*::\s*(?P<container>.+))?
+        ''', opts.cookiesfrombrowser)
         if mobj is None:
             raise ValueError(f'invalid cookies from browser arguments: {opts.cookiesfrombrowser}')
-        browser_name, keyring, profile = mobj.group('name', 'keyring', 'profile')
+        browser_name, keyring, profile, container = mobj.group('name', 'keyring', 'profile', 'container')
         browser_name = browser_name.lower()
         if browser_name not in SUPPORTED_BROWSERS:
             raise ValueError(f'unsupported browser specified for cookies: "{browser_name}". '
@@ -357,7 +365,7 @@ def parse_chapters(name, value):
             if keyring not in SUPPORTED_KEYRINGS:
                 raise ValueError(f'unsupported keyring specified for cookies: "{keyring}". '
                                  f'Supported keyrings are: {", ".join(sorted(SUPPORTED_KEYRINGS))}')
-        opts.cookiesfrombrowser = (browser_name, profile, keyring)
+        opts.cookiesfrombrowser = (browser_name, profile or None, keyring, container or None)
 
     # MetadataParser
     def metadataparser_actions(f):
diff --git a/yt_dlp/__main__.py b/yt_dlp/__main__.py
index ff5d71d3c..895918c27 100644
--- a/yt_dlp/__main__.py
+++ b/yt_dlp/__main__.py
@@ -14,4 +14,5 @@
 import yt_dlp
 
 if __name__ == '__main__':
+    yt_dlp._IN_CLI = True
     yt_dlp.main()
diff --git a/yt_dlp/cache.py b/yt_dlp/cache.py
index 602cb9edb..4f9fb78d3 100644
--- a/yt_dlp/cache.py
+++ b/yt_dlp/cache.py
@@ -51,15 +51,15 @@ def store(self, section, key, data, dtype='json'):
             tb = traceback.format_exc()
             self._ydl.report_warning(f'Writing cache to {fn!r} failed: {tb}')
 
-    def _validate(self, data, after):
+    def _validate(self, data, min_ver):
         version = traverse_obj(data, 'yt-dlp_version')
         if not version:  # Backward compatibility
             data, version = {'data': data}, '2022.08.19'
-        if not after or version_tuple(version) > version_tuple(after):
+        if not min_ver or version_tuple(version) >= version_tuple(min_ver):
             return data['data']
-        self._ydl.write_debug(f'Discarding old cache from version {version} (need {after})')
+        self._ydl.write_debug(f'Discarding old cache from version {version} (needs {min_ver})')
 
-    def load(self, section, key, dtype='json', default=None, *, after=None):
+    def load(self, section, key, dtype='json', default=None, *, min_ver=None):
         assert dtype in ('json',)
 
         if not self.enabled:
@@ -70,7 +70,7 @@ def load(self, section, key, dtype='json', default=None, *, after=None):
             try:
                 with open(cache_fn, encoding='utf-8') as cachef:
                     self._ydl.write_debug(f'Loading {section}.{key} from cache')
-                    return self._validate(json.load(cachef), after)
+                    return self._validate(json.load(cachef), min_ver)
             except (ValueError, KeyError):
                 try:
                     file_size = os.path.getsize(cache_fn)
diff --git a/yt_dlp/cookies.py b/yt_dlp/cookies.py
index 1a164bb31..9100f46ac 100644
--- a/yt_dlp/cookies.py
+++ b/yt_dlp/cookies.py
@@ -3,6 +3,7 @@
 import http.cookiejar
 import json
 import os
+import re
 import shutil
 import struct
 import subprocess
@@ -24,7 +25,7 @@
     sqlite3,
 )
 from .minicurses import MultilinePrinter, QuietMultilinePrinter
-from .utils import Popen, YoutubeDLCookieJar, error_to_str, expand_path
+from .utils import Popen, YoutubeDLCookieJar, error_to_str, expand_path, try_call
 
 CHROMIUM_BASED_BROWSERS = {'brave', 'chrome', 'chromium', 'edge', 'opera', 'vivaldi'}
 SUPPORTED_BROWSERS = CHROMIUM_BASED_BROWSERS | {'firefox', 'safari'}
@@ -85,8 +86,9 @@ def _create_progress_bar(logger):
 def load_cookies(cookie_file, browser_specification, ydl):
     cookie_jars = []
     if browser_specification is not None:
-        browser_name, profile, keyring = _parse_browser_specification(*browser_specification)
-        cookie_jars.append(extract_cookies_from_browser(browser_name, profile, YDLLogger(ydl), keyring=keyring))
+        browser_name, profile, keyring, container = _parse_browser_specification(*browser_specification)
+        cookie_jars.append(
+            extract_cookies_from_browser(browser_name, profile, YDLLogger(ydl), keyring=keyring, container=container))
 
     if cookie_file is not None:
         is_filename = YoutubeDLCookieJar.is_path(cookie_file)
@@ -101,9 +103,9 @@ def load_cookies(cookie_file, browser_specification, ydl):
     return _merge_cookie_jars(cookie_jars)
 
 
-def extract_cookies_from_browser(browser_name, profile=None, logger=YDLLogger(), *, keyring=None):
+def extract_cookies_from_browser(browser_name, profile=None, logger=YDLLogger(), *, keyring=None, container=None):
     if browser_name == 'firefox':
-        return _extract_firefox_cookies(profile, logger)
+        return _extract_firefox_cookies(profile, container, logger)
     elif browser_name == 'safari':
         return _extract_safari_cookies(profile, logger)
     elif browser_name in CHROMIUM_BASED_BROWSERS:
@@ -112,7 +114,7 @@ def extract_cookies_from_browser(browser_name, profile=None, logger=YDLLogger(),
         raise ValueError(f'unknown browser: {browser_name}')
 
 
-def _extract_firefox_cookies(profile, logger):
+def _extract_firefox_cookies(profile, container, logger):
     logger.info('Extracting cookies from firefox')
     if not sqlite3:
         logger.warning('Cannot extract cookies from firefox without sqlite3 support. '
@@ -131,11 +133,36 @@ def _extract_firefox_cookies(profile, logger):
         raise FileNotFoundError(f'could not find firefox cookies database in {search_root}')
     logger.debug(f'Extracting cookies from: "{cookie_database_path}"')
 
+    container_id = None
+    if container not in (None, 'none'):
+        containers_path = os.path.join(os.path.dirname(cookie_database_path), 'containers.json')
+        if not os.path.isfile(containers_path) or not os.access(containers_path, os.R_OK):
+            raise FileNotFoundError(f'could not read containers.json in {search_root}')
+        with open(containers_path, 'r') as containers:
+            identities = json.load(containers).get('identities', [])
+        container_id = next((context.get('userContextId') for context in identities if container in (
+            context.get('name'),
+            try_call(lambda: re.fullmatch(r'userContext([^\.]+)\.label', context['l10nID']).group())
+        )), None)
+        if not isinstance(container_id, int):
+            raise ValueError(f'could not find firefox container "{container}" in containers.json')
+
     with tempfile.TemporaryDirectory(prefix='yt_dlp') as tmpdir:
         cursor = None
         try:
             cursor = _open_database_copy(cookie_database_path, tmpdir)
-            cursor.execute('SELECT host, name, value, path, expiry, isSecure FROM moz_cookies')
+            if isinstance(container_id, int):
+                logger.debug(
+                    f'Only loading cookies from firefox container "{container}", ID {container_id}')
+                cursor.execute(
+                    'SELECT host, name, value, path, expiry, isSecure FROM moz_cookies WHERE originAttributes LIKE ? OR originAttributes LIKE ?',
+                    (f'%userContextId={container_id}', f'%userContextId={container_id}&%'))
+            elif container == 'none':
+                logger.debug('Only loading cookies not belonging to any container')
+                cursor.execute(
+                    'SELECT host, name, value, path, expiry, isSecure FROM moz_cookies WHERE NOT INSTR(originAttributes,"userContextId=")')
+            else:
+                cursor.execute('SELECT host, name, value, path, expiry, isSecure FROM moz_cookies')
             jar = YoutubeDLCookieJar()
             with _create_progress_bar(logger) as progress_bar:
                 table = cursor.fetchall()
@@ -948,11 +975,11 @@ def _is_path(value):
     return os.path.sep in value
 
 
-def _parse_browser_specification(browser_name, profile=None, keyring=None):
+def _parse_browser_specification(browser_name, profile=None, keyring=None, container=None):
     if browser_name not in SUPPORTED_BROWSERS:
         raise ValueError(f'unsupported browser: "{browser_name}"')
     if keyring not in (None, *SUPPORTED_KEYRINGS):
         raise ValueError(f'unsupported keyring: "{keyring}"')
     if profile is not None and _is_path(profile):
         profile = os.path.expanduser(profile)
-    return browser_name, profile, keyring
+    return browser_name, profile, keyring, container
diff --git a/yt_dlp/downloader/common.py b/yt_dlp/downloader/common.py
index 4962c0cf8..9ade4269e 100644
--- a/yt_dlp/downloader/common.py
+++ b/yt_dlp/downloader/common.py
@@ -92,6 +92,7 @@ def _set_ydl(self, ydl):
 
         for func in (
             'deprecation_warning',
+            'deprecated_feature',
             'report_error',
             'report_file_already_downloaded',
             'report_warning',
diff --git a/yt_dlp/downloader/external.py b/yt_dlp/downloader/external.py
index 9859a7b33..d117c06e0 100644
--- a/yt_dlp/downloader/external.py
+++ b/yt_dlp/downloader/external.py
@@ -515,16 +515,14 @@ class AVconvFD(FFmpegFD):
     if name.endswith('FD') and name not in ('ExternalFD', 'FragmentFD')
 }
 
-_BY_EXE = {klass.EXE_NAME: klass for klass in _BY_NAME.values()}
-
 
 def list_external_downloaders():
     return sorted(_BY_NAME.keys())
 
 
 def get_external_downloader(external_downloader):
-    """ Given the name of the executable, see whether we support the given
-        downloader . """
-    # Drop .exe extension on Windows
+    """ Given the name of the executable, see whether we support the given downloader """
     bn = os.path.splitext(os.path.basename(external_downloader))[0]
-    return _BY_NAME.get(bn, _BY_EXE.get(bn))
+    return _BY_NAME.get(bn) or next((
+        klass for klass in _BY_NAME.values() if klass.EXE_NAME in bn
+    ), None)
diff --git a/yt_dlp/downloader/fragment.py b/yt_dlp/downloader/fragment.py
index b1d3127c3..a5d70d0d4 100644
--- a/yt_dlp/downloader/fragment.py
+++ b/yt_dlp/downloader/fragment.py
@@ -65,8 +65,8 @@ class FragmentFD(FileDownloader):
     """
 
     def report_retry_fragment(self, err, frag_index, count, retries):
-        self.deprecation_warning(
-            'yt_dlp.downloader.FragmentFD.report_retry_fragment is deprecated. Use yt_dlp.downloader.FileDownloader.report_retry instead')
+        self.deprecation_warning('yt_dlp.downloader.FragmentFD.report_retry_fragment is deprecated. '
+                                 'Use yt_dlp.downloader.FileDownloader.report_retry instead')
         return self.report_retry(err, count, retries, frag_index)
 
     def report_skip_fragment(self, frag_index, err=None):
diff --git a/yt_dlp/extractor/_extractors.py b/yt_dlp/extractor/_extractors.py
index 60e1b716f..8368e9315 100644
--- a/yt_dlp/extractor/_extractors.py
+++ b/yt_dlp/extractor/_extractors.py
@@ -470,6 +470,7 @@
     EpiconIE,
     EpiconSeriesIE,
 )
+from .epoch import EpochIE
 from .eporner import EpornerIE
 from .eroprofile import (
     EroProfileIE,
@@ -491,6 +492,7 @@
 from .esri import EsriVideoIE
 from .europa import EuropaIE
 from .europeantour import EuropeanTourIE
+from .eurosport import EurosportIE
 from .euscreen import EUScreenIE
 from .expotv import ExpoTVIE
 from .expressen import ExpressenIE
@@ -1083,6 +1085,7 @@
     NewgroundsPlaylistIE,
     NewgroundsUserIE,
 )
+from .newspicks import NewsPicksIE
 from .newstube import NewstubeIE
 from .newsy import NewsyIE
 from .nextmedia import (
@@ -1732,6 +1735,12 @@
 from .teletask import TeleTaskIE
 from .telewebion import TelewebionIE
 from .tempo import TempoIE
+from .tencent import (
+    VQQSeriesIE,
+    VQQVideoIE,
+    WeTvEpisodeIE,
+    WeTvSeriesIE,
+)
 from .tennistv import TennisTVIE
 from .tenplay import TenPlayIE
 from .testurl import TestURLIE
@@ -1791,6 +1800,10 @@
 from .toutv import TouTvIE
 from .toypics import ToypicsUserIE, ToypicsIE
 from .traileraddict import TrailerAddictIE
+from .triller import (
+    TrillerIE,
+    TrillerUserIE,
+)
 from .trilulilu import TriluliluIE
 from .trovo import (
     TrovoIE,
@@ -2096,7 +2109,6 @@
     WeiboMobileIE
 )
 from .weiqitv import WeiqiTVIE
-from .wetv import WeTvEpisodeIE, WeTvSeriesIE
 from .wikimedia import WikimediaIE
 from .willow import WillowIE
 from .wimtv import WimTVIE
diff --git a/yt_dlp/extractor/bilibili.py b/yt_dlp/extractor/bilibili.py
index 17c974d49..59f5791d1 100644
--- a/yt_dlp/extractor/bilibili.py
+++ b/yt_dlp/extractor/bilibili.py
@@ -218,6 +218,9 @@ def _real_extract(self, url):
 
         durl = traverse_obj(video_info, ('dash', 'video'))
         audios = traverse_obj(video_info, ('dash', 'audio')) or []
+        flac_audio = traverse_obj(video_info, ('dash', 'flac', 'audio'))
+        if flac_audio:
+            audios.append(flac_audio)
         entries = []
 
         RENDITIONS = ('qn=80&quality=80&type=', 'quality=2&type=mp4')
diff --git a/yt_dlp/extractor/common.py b/yt_dlp/extractor/common.py
index 6337a13a4..b79221955 100644
--- a/yt_dlp/extractor/common.py
+++ b/yt_dlp/extractor/common.py
@@ -1766,9 +1766,8 @@ def _get_field_setting(self, field, key):
             if field not in self.settings:
                 if key in ('forced', 'priority'):
                     return False
-                self.ydl.deprecation_warning(
-                    f'Using arbitrary fields ({field}) for format sorting is deprecated '
-                    'and may be removed in a future version')
+                self.ydl.deprecated_feature(f'Using arbitrary fields ({field}) for format sorting is '
+                                            'deprecated and may be removed in a future version')
                 self.settings[field] = {}
             propObj = self.settings[field]
             if key not in propObj:
@@ -1853,9 +1852,8 @@ def add_item(field, reverse, closest, limit_text):
                 if self._get_field_setting(field, 'type') == 'alias':
                     alias, field = field, self._get_field_setting(field, 'field')
                     if self._get_field_setting(alias, 'deprecated'):
-                        self.ydl.deprecation_warning(
-                            f'Format sorting alias {alias} is deprecated '
-                            f'and may be removed in a future version. Please use {field} instead')
+                        self.ydl.deprecated_feature(f'Format sorting alias {alias} is deprecated and may '
+                                                    'be removed in a future version. Please use {field} instead')
                 reverse = match.group('reverse') is not None
                 closest = match.group('separator') == '~'
                 limit_text = match.group('limit')
@@ -3262,7 +3260,7 @@ def _media_formats(src, cur_media_type, type_info=None):
                 'subtitles': {},
             }
             media_attributes = extract_attributes(media_tag)
-            src = strip_or_none(media_attributes.get('src'))
+            src = strip_or_none(dict_get(media_attributes, ('src', 'data-video-src', 'data-src', 'data-source')))
             if src:
                 f = parse_content_type(media_attributes.get('type'))
                 _, formats = _media_formats(src, media_type, f)
@@ -3273,7 +3271,7 @@ def _media_formats(src, cur_media_type, type_info=None):
                     s_attr = extract_attributes(source_tag)
                     # data-video-src and data-src are non standard but seen
                     # several times in the wild
-                    src = strip_or_none(dict_get(s_attr, ('src', 'data-video-src', 'data-src')))
+                    src = strip_or_none(dict_get(s_attr, ('src', 'data-video-src', 'data-src', 'data-source')))
                     if not src:
                         continue
                     f = parse_content_type(s_attr.get('type'))
diff --git a/yt_dlp/extractor/crunchyroll.py b/yt_dlp/extractor/crunchyroll.py
index d4968c13b..141d8c5a7 100644
--- a/yt_dlp/extractor/crunchyroll.py
+++ b/yt_dlp/extractor/crunchyroll.py
@@ -720,15 +720,20 @@ class CrunchyrollBetaBaseIE(CrunchyrollBaseIE):
 
     def _get_params(self, lang):
         if not CrunchyrollBetaBaseIE.params:
+            if self._get_cookies(f'https://beta.crunchyroll.com/{lang}').get('etp_rt'):
+                grant_type, key = 'etp_rt_cookie', 'accountAuthClientId'
+            else:
+                grant_type, key = 'client_id', 'anonClientId'
+
             initial_state, app_config = self._get_beta_embedded_json(self._download_webpage(
                 f'https://beta.crunchyroll.com/{lang}', None, note='Retrieving main page'), None)
             api_domain = app_config['cxApiParams']['apiDomain']
-            basic_token = str(base64.b64encode(('%s:' % app_config['cxApiParams']['accountAuthClientId']).encode('ascii')), 'ascii')
+
             auth_response = self._download_json(
-                f'{api_domain}/auth/v1/token', None, note='Authenticating with cookie',
+                f'{api_domain}/auth/v1/token', None, note=f'Authenticating with grant_type={grant_type}',
                 headers={
-                    'Authorization': 'Basic ' + basic_token
-                }, data='grant_type=etp_rt_cookie'.encode('ascii'))
+                    'Authorization': 'Basic ' + str(base64.b64encode(('%s:' % app_config['cxApiParams'][key]).encode('ascii')), 'ascii')
+                }, data=f'grant_type={grant_type}'.encode('ascii'))
             policy_response = self._download_json(
                 f'{api_domain}/index/v2', None, note='Retrieving signed policy',
                 headers={
@@ -747,21 +752,6 @@ def _get_params(self, lang):
             CrunchyrollBetaBaseIE.params = (api_domain, bucket, params)
         return CrunchyrollBetaBaseIE.params
 
-    def _redirect_from_beta(self, url, lang, internal_id, display_id, is_episode, iekey):
-        initial_state, app_config = self._get_beta_embedded_json(self._download_webpage(url, display_id), display_id)
-        content_data = initial_state['content']['byId'][internal_id]
-        if is_episode:
-            video_id = content_data['external_id'].split('.')[1]
-            series_id = content_data['episode_metadata']['series_slug_title']
-        else:
-            series_id = content_data['slug_title']
-        series_id = re.sub(r'-{2,}', '-', series_id)
-        url = f'https://www.crunchyroll.com/{lang}{series_id}'
-        if is_episode:
-            url = url + f'/{display_id}-{video_id}'
-        self.to_screen(f'{display_id}: Not logged in. Redirecting to non-beta site - {url}')
-        return self.url_result(url, iekey, display_id)
-
 
 class CrunchyrollBetaIE(CrunchyrollBetaBaseIE):
     IE_NAME = 'crunchyroll:beta'
@@ -800,10 +790,6 @@ class CrunchyrollBetaIE(CrunchyrollBetaBaseIE):
 
     def _real_extract(self, url):
         lang, internal_id, display_id = self._match_valid_url(url).group('lang', 'id', 'display_id')
-
-        if not self._get_cookies(url).get('etp_rt'):
-            return self._redirect_from_beta(url, lang, internal_id, display_id, True, CrunchyrollIE.ie_key())
-
         api_domain, bucket, params = self._get_params(lang)
 
         episode_response = self._download_json(
@@ -897,10 +883,6 @@ class CrunchyrollBetaShowIE(CrunchyrollBetaBaseIE):
 
     def _real_extract(self, url):
         lang, internal_id, display_id = self._match_valid_url(url).group('lang', 'id', 'display_id')
-
-        if not self._get_cookies(url).get('etp_rt'):
-            return self._redirect_from_beta(url, lang, internal_id, display_id, False, CrunchyrollShowPlaylistIE.ie_key())
-
         api_domain, bucket, params = self._get_params(lang)
 
         series_response = self._download_json(
diff --git a/yt_dlp/extractor/epoch.py b/yt_dlp/extractor/epoch.py
new file mode 100644
index 000000000..13eeabe3e
--- /dev/null
+++ b/yt_dlp/extractor/epoch.py
@@ -0,0 +1,46 @@
+from .common import InfoExtractor
+
+
+class EpochIE(InfoExtractor):
+    _VALID_URL = r'https?://www.theepochtimes\.com/[\w-]+_(?P<id>\d+).html'
+    _TESTS = [
+        {
+            'url': 'https://www.theepochtimes.com/they-can-do-audio-video-physical-surveillance-on-you-24h-365d-a-year-rex-lee-on-intrusive-apps_4661688.html',
+            'info_dict': {
+                'id': 'a3dd732c-4750-4bc8-8156-69180668bda1',
+                'ext': 'mp4',
+                'title': '‘They Can Do Audio, Video, Physical Surveillance on You 24H/365D a Year’: Rex Lee on Intrusive Apps',
+            }
+        },
+        {
+            'url': 'https://www.theepochtimes.com/the-communist-partys-cyberattacks-on-america-explained-rex-lee-talks-tech-hybrid-warfare_4342413.html',
+            'info_dict': {
+                'id': '276c7f46-3bbf-475d-9934-b9bbe827cf0a',
+                'ext': 'mp4',
+                'title': 'The Communist Party’s Cyberattacks on America Explained; Rex Lee Talks Tech Hybrid Warfare',
+            }
+        },
+        {
+            'url': 'https://www.theepochtimes.com/kash-patel-a-6-year-saga-of-government-corruption-from-russiagate-to-mar-a-lago_4690250.html',
+            'info_dict': {
+                'id': 'aa9ceecd-a127-453d-a2de-7153d6fd69b6',
+                'ext': 'mp4',
+                'title': 'Kash Patel: A ‘6-Year-Saga’ of Government Corruption, From Russiagate to Mar-a-Lago',
+            }
+        },
+    ]
+
+    def _real_extract(self, url):
+        video_id = self._match_id(url)
+        webpage = self._download_webpage(url, video_id)
+
+        youmaker_video_id = self._search_regex(r'data-trailer="[\w-]+" data-id="([\w-]+)"', webpage, 'url')
+        formats, subtitles = self._extract_m3u8_formats_and_subtitles(
+            f'http://vs1.youmaker.com/assets/{youmaker_video_id}/playlist.m3u8', video_id, 'mp4', m3u8_id='hls')
+
+        return {
+            'id': youmaker_video_id,
+            'formats': formats,
+            'subtitles': subtitles,
+            'title': self._html_extract_title(webpage)
+        }
diff --git a/yt_dlp/extractor/eurosport.py b/yt_dlp/extractor/eurosport.py
new file mode 100644
index 000000000..5681499fb
--- /dev/null
+++ b/yt_dlp/extractor/eurosport.py
@@ -0,0 +1,99 @@
+from .common import InfoExtractor
+from ..utils import traverse_obj
+
+
+class EurosportIE(InfoExtractor):
+    _VALID_URL = r'https?://www\.eurosport\.com/\w+/[\w-]+/\d+/[\w-]+_(?P<id>vid\d+)'
+    _TESTS = [{
+        'url': 'https://www.eurosport.com/tennis/roland-garros/2022/highlights-rafael-nadal-brushes-aside-caper-ruud-to-win-record-extending-14th-french-open-title_vid1694147/video.shtml',
+        'info_dict': {
+            'id': '2480939',
+            'ext': 'mp4',
+            'title': 'Highlights: Rafael Nadal brushes aside Caper Ruud to win record-extending 14th French Open title',
+            'description': 'md5:b564db73ecfe4b14ebbd8e62a3692c76',
+            'thumbnail': 'https://imgresizer.eurosport.com/unsafe/1280x960/smart/filters:format(jpeg)/origin-imgresizer.eurosport.com/2022/06/05/3388285-69245968-2560-1440.png',
+            'duration': 195.0,
+            'display_id': 'vid1694147',
+            'timestamp': 1654446698,
+            'upload_date': '20220605',
+        }
+    }, {
+        'url': 'https://www.eurosport.com/tennis/roland-garros/2022/watch-the-top-five-shots-from-men-s-final-as-rafael-nadal-beats-casper-ruud-to-seal-14th-french-open_vid1694283/video.shtml',
+        'info_dict': {
+            'id': '2481254',
+            'ext': 'mp4',
+            'title': 'md5:149dcc5dfb38ab7352acc008cc9fb071',
+            'duration': 130.0,
+            'thumbnail': 'https://imgresizer.eurosport.com/unsafe/1280x960/smart/filters:format(jpeg)/origin-imgresizer.eurosport.com/2022/06/05/3388422-69248708-2560-1440.png',
+            'description': 'md5:a0c8a7f6b285e48ae8ddbe7aa85cfee6',
+            'display_id': 'vid1694283',
+            'timestamp': 1654456090,
+            'upload_date': '20220605',
+        }
+    }, {
+        # geo-fence but can bypassed by xff
+        'url': 'https://www.eurosport.com/cycling/tour-de-france-femmes/2022/incredible-ride-marlen-reusser-storms-to-stage-4-win-at-tour-de-france-femmes_vid1722221/video.shtml',
+        'info_dict': {
+            'id': '2582552',
+            'ext': 'mp4',
+            'title': '‘Incredible ride!’ - Marlen Reusser storms to Stage 4 win at Tour de France Femmes',
+            'duration': 188.0,
+            'display_id': 'vid1722221',
+            'timestamp': 1658936167,
+            'thumbnail': 'https://imgresizer.eurosport.com/unsafe/1280x960/smart/filters:format(jpeg)/origin-imgresizer.eurosport.com/2022/07/27/3423347-69852108-2560-1440.jpg',
+            'description': 'md5:32bbe3a773ac132c57fb1e8cca4b7c71',
+            'upload_date': '20220727',
+        }
+    }]
+
+    _TOKEN = None
+
+    # actually defined in https://netsport.eurosport.io/?variables={"databaseId":<databaseId>,"playoutType":"VDP"}&extensions={"persistedQuery":{"version":1 ..
+    # but this method require to get sha256 hash
+    _GEO_COUNTRIES = ['DE', 'NL', 'EU', 'IT', 'FR']  # Not complete list but it should work
+
+    def _real_initialize(self):
+        if EurosportIE._TOKEN is None:
+            EurosportIE._TOKEN = self._download_json(
+                'https://eu3-prod-direct.eurosport.com/token?realm=eurosport', None,
+                'Trying to get token')['data']['attributes']['token']
+
+    def _real_extract(self, url):
+        display_id = self._match_id(url)
+        webpage = self._download_webpage(url, display_id)
+
+        json_data = self._download_json(
+            f'https://eu3-prod-direct.eurosport.com/playback/v2/videoPlaybackInfo/sourceSystemId/eurosport-{display_id}',
+            display_id, query={'usePreAuth': True}, headers={'Authorization': f'Bearer {EurosportIE._TOKEN}'})['data']
+
+        json_ld_data = self._search_json_ld(webpage, display_id)
+
+        formats, subtitles = [], {}
+        for stream_type in json_data['attributes']['streaming']:
+            if stream_type == 'hls':
+                fmts, subs = self._extract_m3u8_formats_and_subtitles(
+                    traverse_obj(json_data, ('attributes', 'streaming', stream_type, 'url')), display_id, ext='mp4')
+            elif stream_type == 'dash':
+                fmts, subs = self._extract_mpd_formats_and_subtitles(
+                    traverse_obj(json_data, ('attributes', 'streaming', stream_type, 'url')), display_id)
+            elif stream_type == 'mss':
+                fmts, subs = self._extract_ism_formats_and_subtitles(
+                    traverse_obj(json_data, ('attributes', 'streaming', stream_type, 'url')), display_id)
+
+            formats.extend(fmts)
+            self._merge_subtitles(subs, target=subtitles)
+
+        self._sort_formats(formats)
+
+        return {
+            'id': json_data['id'],
+            'title': json_ld_data.get('title') or self._og_search_title(webpage),
+            'display_id': display_id,
+            'formats': formats,
+            'subtitles': subtitles,
+            'thumbnails': json_ld_data.get('thumbnails'),
+            'description': (json_ld_data.get('description')
+                            or self._html_search_meta(['og:description', 'description'], webpage)),
+            'duration': json_ld_data.get('duration'),
+            'timestamp': json_ld_data.get('timestamp'),
+        }
diff --git a/yt_dlp/extractor/huya.py b/yt_dlp/extractor/huya.py
index 9dd5e41b3..6d6f09956 100644
--- a/yt_dlp/extractor/huya.py
+++ b/yt_dlp/extractor/huya.py
@@ -6,7 +6,6 @@
 from ..utils import (
     ExtractorError,
     int_or_none,
-    js_to_json,
     str_or_none,
     try_get,
     unescapeHTML,
@@ -55,11 +54,7 @@ class HuyaLiveIE(InfoExtractor):
     def _real_extract(self, url):
         video_id = self._match_id(url)
         webpage = self._download_webpage(url, video_id=video_id)
-        json_stream = self._search_regex(r'"stream":\s+"([a-zA-Z0-9+=/]+)"', webpage, 'stream', default=None)
-        if not json_stream:
-            raise ExtractorError('Video is offline', expected=True)
-        stream_data = self._parse_json(compat_b64decode(json_stream).decode(), video_id=video_id,
-                                       transform_source=js_to_json)
+        stream_data = self._search_json(r'stream:\s+', webpage, 'stream', video_id=video_id, default=None)
         room_info = try_get(stream_data, lambda x: x['data'][0]['gameLiveInfo'])
         if not room_info:
             raise ExtractorError('Can not extract the room info', expected=True)
@@ -67,6 +62,8 @@ def _real_extract(self, url):
         screen_type = room_info.get('screenType')
         live_source_type = room_info.get('liveSourceType')
         stream_info_list = stream_data['data'][0]['gameStreamInfoList']
+        if not stream_info_list:
+            raise ExtractorError('Video is offline', expected=True)
         formats = []
         for stream_info in stream_info_list:
             stream_url = stream_info.get('sFlvUrl')
diff --git a/yt_dlp/extractor/mediaset.py b/yt_dlp/extractor/mediaset.py
index 0671c29a6..ebe894f74 100644
--- a/yt_dlp/extractor/mediaset.py
+++ b/yt_dlp/extractor/mediaset.py
@@ -172,31 +172,27 @@ class MediasetIE(ThePlatformBaseIE):
     }]
 
     def _extract_from_webpage(self, url, webpage):
-        def _qs(url):
-            return parse_qs(url)
-
         def _program_guid(qs):
             return qs.get('programGuid', [None])[0]
 
-        entries = []
         for mobj in re.finditer(
                 r'<iframe\b[^>]+\bsrc=(["\'])(?P<url>(?:https?:)?//(?:www\.)?video\.mediaset\.it/player/playerIFrame(?:Twitter)?\.shtml.*?)\1',
                 webpage):
             embed_url = mobj.group('url')
-            embed_qs = _qs(embed_url)
+            embed_qs = parse_qs(embed_url)
             program_guid = _program_guid(embed_qs)
             if program_guid:
-                entries.append(embed_url)
+                yield self.url_result(embed_url)
                 continue
+
             video_id = embed_qs.get('id', [None])[0]
             if not video_id:
                 continue
             urlh = self._request_webpage(embed_url, video_id, note='Following embed URL redirect')
             embed_url = urlh.geturl()
-            program_guid = _program_guid(_qs(embed_url))
+            program_guid = _program_guid(parse_qs(embed_url))
             if program_guid:
-                entries.append(embed_url)
-        return entries
+                yield self.url_result(embed_url)
 
     def _parse_smil_formats(self, smil, smil_url, video_id, namespace=None, f4m_params=None, transform_rtmp_url=None):
         for video in smil.findall(self._xpath_ns('.//video', namespace)):
diff --git a/yt_dlp/extractor/newspicks.py b/yt_dlp/extractor/newspicks.py
new file mode 100644
index 000000000..0232d5357
--- /dev/null
+++ b/yt_dlp/extractor/newspicks.py
@@ -0,0 +1,54 @@
+import re
+
+from .common import InfoExtractor
+from ..utils import ExtractorError
+
+
+class NewsPicksIE(InfoExtractor):
+    _VALID_URL = r'https://newspicks.com/movie-series/(?P<channel_id>\d+)\?movieId=(?P<id>\d+)'
+
+    _TESTS = [{
+        'url': 'https://newspicks.com/movie-series/11?movieId=1813',
+        'info_dict': {
+            'id': '1813',
+            'title': '日本の課題を破壊せよ【ゲスト：成田悠輔】',
+            'description': 'md5:09397aad46d6ded6487ff13f138acadf',
+            'channel': 'HORIE ONE',
+            'channel_id': '11',
+            'release_date': '20220117',
+            'thumbnail': r're:https://.+jpg',
+            'ext': 'mp4',
+        },
+    }]
+
+    def _real_extract(self, url):
+        video_id, channel_id = self._match_valid_url(url).group('id', 'channel_id')
+        webpage = self._download_webpage(url, video_id)
+        entries = self._parse_html5_media_entries(
+            url, webpage.replace('movie-for-pc', 'movie'), video_id, 'hls')
+        if not entries:
+            raise ExtractorError('No HTML5 media elements found')
+        info = entries[0]
+        self._sort_formats(info['formats'])
+
+        title = self._html_search_meta('og:title', webpage, fatal=False)
+        description = self._html_search_meta(
+            ('og:description', 'twitter:title'), webpage, fatal=False)
+        channel = self._html_search_regex(
+            r'value="11".+?<div\s+class="title">(.+?)</div', webpage, 'channel name', fatal=False)
+        if not title or not channel:
+            title, channel = re.split(r'\s*|\s*', self._html_extract_title(webpage))
+
+        release_date = self._search_regex(
+            r'<span\s+class="on-air-date">\s*(\d+)年(\d+)月(\d+)日\s*</span>',
+            webpage, 'release date', fatal=False, group=(1, 2, 3))
+
+        info.update({
+            'id': video_id,
+            'title': title,
+            'description': description,
+            'channel': channel,
+            'channel_id': channel_id,
+            'release_date': ('%04d%02d%02d' % tuple(map(int, release_date))) if release_date else None,
+        })
+        return info
diff --git a/yt_dlp/extractor/openload.py b/yt_dlp/extractor/openload.py
index 4bba7bdd0..d2756a006 100644
--- a/yt_dlp/extractor/openload.py
+++ b/yt_dlp/extractor/openload.py
@@ -52,6 +52,8 @@ class PhantomJSwrapper:
     This class is experimental.
     """
 
+    INSTALL_HINT = 'Please download it from https://phantomjs.org/download.html'
+
     _BASE_JS = R'''
         phantom.onError = function(msg, trace) {{
           var msgStack = ['PHANTOM ERROR: ' + msg];
@@ -110,8 +112,7 @@ def __init__(self, extractor, required_version=None, timeout=10000):
 
         self.exe = check_executable('phantomjs', ['-v'])
         if not self.exe:
-            raise ExtractorError(
-                'PhantomJS not found, Please download it from https://phantomjs.org/download.html', expected=True)
+            raise ExtractorError(f'PhantomJS not found, {self.INSTALL_HINT}', expected=True)
 
         self.extractor = extractor
 
@@ -237,6 +238,6 @@ def execute(self, jscode, video_id=None, *, note='Executing JS'):
         except Exception as e:
             raise ExtractorError(f'{note} failed: Unable to run PhantomJS binary', cause=e)
         if returncode:
-            raise ExtractorError(f'{note} failed:\n{stderr.strip()}')
+            raise ExtractorError(f'{note} failed with returncode {returncode}:\n{stderr.strip()}')
 
         return stdout
diff --git a/yt_dlp/extractor/stripchat.py b/yt_dlp/extractor/stripchat.py
index 7214184bf..2e84729bd 100644
--- a/yt_dlp/extractor/stripchat.py
+++ b/yt_dlp/extractor/stripchat.py
@@ -29,9 +29,7 @@ class StripchatIE(InfoExtractor):
 
     def _real_extract(self, url):
         video_id = self._match_id(url)
-        webpage = self._download_webpage(
-            'https://stripchat.com/%s/' % video_id, video_id,
-            headers=self.geo_verification_headers())
+        webpage = self._download_webpage(url, video_id, headers=self.geo_verification_headers())
 
         data = self._parse_json(
             self._search_regex(
diff --git a/yt_dlp/extractor/tencent.py b/yt_dlp/extractor/tencent.py
new file mode 100644
index 000000000..c755407d3
--- /dev/null
+++ b/yt_dlp/extractor/tencent.py
@@ -0,0 +1,369 @@
+import functools
+import random
+import re
+import string
+import time
+
+from .common import InfoExtractor
+from ..aes import aes_cbc_encrypt_bytes
+from ..utils import (
+    ExtractorError,
+    determine_ext,
+    int_or_none,
+    js_to_json,
+    traverse_obj,
+    urljoin,
+)
+
+
+class TencentBaseIE(InfoExtractor):
+    """Subclasses must set _API_URL, _APP_VERSION, _PLATFORM, _HOST, _REFERER"""
+
+    def _get_ckey(self, video_id, url, guid):
+        ua = self.get_param('http_headers')['User-Agent']
+
+        payload = (f'{video_id}|{int(time.time())}|mg3c3b04ba|{self._APP_VERSION}|{guid}|'
+                   f'{self._PLATFORM}|{url[:48]}|{ua.lower()[:48]}||Mozilla|Netscape|Windows x86_64|00|')
+
+        return aes_cbc_encrypt_bytes(
+            bytes(f'|{sum(map(ord, payload))}|{payload}', 'utf-8'),
+            b'Ok\xda\xa3\x9e/\x8c\xb0\x7f^r-\x9e\xde\xf3\x14',
+            b'\x01PJ\xf3V\xe6\x19\xcf.B\xbb\xa6\x8c?p\xf9',
+            padding_mode='whitespace').hex().upper()
+
+    def _get_video_api_response(self, video_url, video_id, series_id, subtitle_format, video_format, video_quality):
+        guid = ''.join([random.choice(string.digits + string.ascii_lowercase) for _ in range(16)])
+        ckey = self._get_ckey(video_id, video_url, guid)
+        query = {
+            'vid': video_id,
+            'cid': series_id,
+            'cKey': ckey,
+            'encryptVer': '8.1',
+            'spcaptiontype': '1' if subtitle_format == 'vtt' else '0',
+            'sphls': '2' if video_format == 'hls' else '0',
+            'dtype': '3' if video_format == 'hls' else '0',
+            'defn': video_quality,
+            'spsrt': '2',  # Enable subtitles
+            'sphttps': '1',  # Enable HTTPS
+            'otype': 'json',
+            'spwm': '1',
+            # For SHD
+            'host': self._HOST,
+            'referer': self._REFERER,
+            'ehost': video_url,
+            'appVer': self._APP_VERSION,
+            'platform': self._PLATFORM,
+            # For VQQ
+            'guid': guid,
+            'flowid': ''.join(random.choice(string.digits + string.ascii_lowercase) for _ in range(32)),
+        }
+
+        return self._search_json(r'QZOutputJson=', self._download_webpage(
+            self._API_URL, video_id, query=query), 'api_response', video_id)
+
+    def _extract_video_formats_and_subtitles(self, api_response, video_id):
+        video_response = api_response['vl']['vi'][0]
+        video_width, video_height = video_response.get('vw'), video_response.get('vh')
+
+        formats, subtitles = [], {}
+        for video_format in video_response['ul']['ui']:
+            if video_format.get('hls'):
+                fmts, subs = self._extract_m3u8_formats_and_subtitles(
+                    video_format['url'] + video_format['hls']['pt'], video_id, 'mp4', fatal=False)
+                for f in fmts:
+                    f.update({'width': video_width, 'height': video_height})
+
+                formats.extend(fmts)
+                self._merge_subtitles(subs, target=subtitles)
+            else:
+                formats.append({
+                    'url': f'{video_format["url"]}{video_response["fn"]}?vkey={video_response["fvkey"]}',
+                    'width': video_width,
+                    'height': video_height,
+                    'ext': 'mp4',
+                })
+
+        return formats, subtitles
+
+    def _extract_video_native_subtitles(self, api_response, subtitles_format):
+        subtitles = {}
+        for subtitle in traverse_obj(api_response, ('sfl', 'fi')) or ():
+            subtitles.setdefault(subtitle['lang'].lower(), []).append({
+                'url': subtitle['url'],
+                'ext': subtitles_format,
+                'protocol': 'm3u8_native' if determine_ext(subtitle['url']) == 'm3u8' else 'http',
+            })
+
+        return subtitles
+
+    def _extract_all_video_formats_and_subtitles(self, url, video_id, series_id):
+        formats, subtitles = [], {}
+        for video_format, subtitle_format, video_quality in (
+                # '': 480p, 'shd': 720p, 'fhd': 1080p
+                ('mp4', 'srt', ''), ('hls', 'vtt', 'shd'), ('hls', 'vtt', 'fhd')):
+            api_response = self._get_video_api_response(
+                url, video_id, series_id, subtitle_format, video_format, video_quality)
+
+            if api_response.get('em') != 0 and api_response.get('exem') != 0:
+                if '您所在区域暂无此内容版权' in api_response.get('msg'):
+                    self.raise_geo_restricted()
+                raise ExtractorError(f'Tencent said: {api_response.get("msg")}')
+
+            fmts, subs = self._extract_video_formats_and_subtitles(api_response, video_id)
+            native_subtitles = self._extract_video_native_subtitles(api_response, subtitle_format)
+
+            formats.extend(fmts)
+            self._merge_subtitles(subs, native_subtitles, target=subtitles)
+
+        self._sort_formats(formats)
+        return formats, subtitles
+
+    def _get_clean_title(self, title):
+        return re.sub(
+            r'\s*[_\-]\s*(?:Watch online|腾讯视频|(?:高清)?1080P在线观看平台).*?$',
+            '', title or '').strip() or None
+
+
+class VQQBaseIE(TencentBaseIE):
+    _VALID_URL_BASE = r'https?://v\.qq\.com'
+
+    _API_URL = 'https://h5vv6.video.qq.com/getvinfo'
+    _APP_VERSION = '3.5.57'
+    _PLATFORM = '10901'
+    _HOST = 'v.qq.com'
+    _REFERER = 'v.qq.com'
+
+    def _get_webpage_metadata(self, webpage, video_id):
+        return self._parse_json(
+            self._search_regex(
+                r'(?s)<script[^>]*>[^<]*window\.__pinia\s*=\s*([^<]+)</script>',
+                webpage, 'pinia data', fatal=False),
+            video_id, transform_source=js_to_json, fatal=False)
+
+
+class VQQVideoIE(VQQBaseIE):
+    IE_NAME = 'vqq:video'
+    _VALID_URL = VQQBaseIE._VALID_URL_BASE + r'/x/(?:page|cover/(?P<series_id>\w+))/(?P<id>\w+)'
+
+    _TESTS = [{
+        'url': 'https://v.qq.com/x/page/q326831cny0.html',
+        'md5': '826ef93682df09e3deac4a6e6e8cdb6e',
+        'info_dict': {
+            'id': 'q326831cny0',
+            'ext': 'mp4',
+            'title': '我是选手：雷霆裂阵，终极时刻',
+            'description': 'md5:e7ed70be89244017dac2a835a10aeb1e',
+            'thumbnail': r're:^https?://[^?#]+q326831cny0',
+        },
+    }, {
+        'url': 'https://v.qq.com/x/page/o3013za7cse.html',
+        'md5': 'b91cbbeada22ef8cc4b06df53e36fa21',
+        'info_dict': {
+            'id': 'o3013za7cse',
+            'ext': 'mp4',
+            'title': '欧阳娜娜VLOG',
+            'description': 'md5:29fe847497a98e04a8c3826e499edd2e',
+            'thumbnail': r're:^https?://[^?#]+o3013za7cse',
+        },
+    }, {
+        'url': 'https://v.qq.com/x/cover/7ce5noezvafma27/a00269ix3l8.html',
+        'md5': '71459c5375c617c265a22f083facce67',
+        'info_dict': {
+            'id': 'a00269ix3l8',
+            'ext': 'mp4',
+            'title': '鸡毛飞上天 第01集',
+            'description': 'md5:8cae3534327315b3872fbef5e51b5c5b',
+            'thumbnail': r're:^https?://[^?#]+7ce5noezvafma27',
+            'series': '鸡毛飞上天',
+        },
+    }, {
+        'url': 'https://v.qq.com/x/cover/mzc00200p29k31e/s0043cwsgj0.html',
+        'md5': '96b9fd4a189fdd4078c111f21d7ac1bc',
+        'info_dict': {
+            'id': 's0043cwsgj0',
+            'ext': 'mp4',
+            'title': '第1集：如何快乐吃糖？',
+            'description': 'md5:1d8c3a0b8729ae3827fa5b2d3ebd5213',
+            'thumbnail': r're:^https?://[^?#]+s0043cwsgj0',
+            'series': '青年理工工作者生活研究所',
+        },
+    }]
+
+    def _real_extract(self, url):
+        video_id, series_id = self._match_valid_url(url).group('id', 'series_id')
+        webpage = self._download_webpage(url, video_id)
+        webpage_metadata = self._get_webpage_metadata(webpage, video_id)
+
+        formats, subtitles = self._extract_all_video_formats_and_subtitles(url, video_id, series_id)
+        return {
+            'id': video_id,
+            'title': self._get_clean_title(self._og_search_title(webpage)
+                                           or traverse_obj(webpage_metadata, ('global', 'videoInfo', 'title'))),
+            'description': (self._og_search_description(webpage)
+                            or traverse_obj(webpage_metadata, ('global', 'videoInfo', 'desc'))),
+            'formats': formats,
+            'subtitles': subtitles,
+            'thumbnail': (self._og_search_thumbnail(webpage)
+                          or traverse_obj(webpage_metadata, ('global', 'videoInfo', 'pic160x90'))),
+            'series': traverse_obj(webpage_metadata, ('global', 'coverInfo', 'title')),
+        }
+
+
+class VQQSeriesIE(VQQBaseIE):
+    IE_NAME = 'vqq:series'
+    _VALID_URL = VQQBaseIE._VALID_URL_BASE + r'/x/cover/(?P<id>\w+)\.html/?(?:[?#]|$)'
+
+    _TESTS = [{
+        'url': 'https://v.qq.com/x/cover/7ce5noezvafma27.html',
+        'info_dict': {
+            'id': '7ce5noezvafma27',
+            'title': '鸡毛飞上天',
+            'description': 'md5:8cae3534327315b3872fbef5e51b5c5b',
+        },
+        'playlist_count': 55,
+    }, {
+        'url': 'https://v.qq.com/x/cover/oshd7r0vy9sfq8e.html',
+        'info_dict': {
+            'id': 'oshd7r0vy9sfq8e',
+            'title': '恋爱细胞2',
+            'description': 'md5:9d8a2245679f71ca828534b0f95d2a03',
+        },
+        'playlist_count': 12,
+    }]
+
+    def _real_extract(self, url):
+        series_id = self._match_id(url)
+        webpage = self._download_webpage(url, series_id)
+        webpage_metadata = self._get_webpage_metadata(webpage, series_id)
+
+        episode_paths = [f'/x/cover/{series_id}/{video_id}.html' for video_id in re.findall(
+            r'<div[^>]+data-vid="(?P<video_id>[^"]+)"[^>]+class="[^"]+episode-item-rect--number',
+            webpage)]
+
+        return self.playlist_from_matches(
+            episode_paths, series_id, ie=VQQVideoIE, getter=functools.partial(urljoin, url),
+            title=self._get_clean_title(traverse_obj(webpage_metadata, ('coverInfo', 'title'))
+                                        or self._og_search_title(webpage)),
+            description=(traverse_obj(webpage_metadata, ('coverInfo', 'description'))
+                         or self._og_search_description(webpage)))
+
+
+class WeTvBaseIE(TencentBaseIE):
+    _VALID_URL_BASE = r'https?://(?:www\.)?wetv\.vip/(?:[^?#]+/)?play'
+
+    _API_URL = 'https://play.wetv.vip/getvinfo'
+    _APP_VERSION = '3.5.57'
+    _PLATFORM = '4830201'
+    _HOST = 'wetv.vip'
+    _REFERER = 'wetv.vip'
+
+    def _get_webpage_metadata(self, webpage, video_id):
+        return self._parse_json(
+            traverse_obj(self._search_nextjs_data(webpage, video_id), ('props', 'pageProps', 'data')),
+            video_id, fatal=False)
+
+
+class WeTvEpisodeIE(WeTvBaseIE):
+    IE_NAME = 'wetv:episode'
+    _VALID_URL = WeTvBaseIE._VALID_URL_BASE + r'/(?P<series_id>\w+)(?:-[^?#]+)?/(?P<id>\w+)(?:-[^?#]+)?'
+
+    _TESTS = [{
+        'url': 'https://wetv.vip/en/play/air11ooo2rdsdi3-Cute-Programmer/v0040pr89t9-EP1-Cute-Programmer',
+        'md5': '0c70fdfaa5011ab022eebc598e64bbbe',
+        'info_dict': {
+            'id': 'v0040pr89t9',
+            'ext': 'mp4',
+            'title': 'EP1: Cute Programmer',
+            'description': 'md5:e87beab3bf9f392d6b9e541a63286343',
+            'thumbnail': r're:^https?://[^?#]+air11ooo2rdsdi3',
+            'series': 'Cute Programmer',
+            'episode': 'Episode 1',
+            'episode_number': 1,
+            'duration': 2835,
+        },
+    }, {
+        'url': 'https://wetv.vip/en/play/u37kgfnfzs73kiu/p0039b9nvik',
+        'md5': '3b3c15ca4b9a158d8d28d5aa9d7c0a49',
+        'info_dict': {
+            'id': 'p0039b9nvik',
+            'ext': 'mp4',
+            'title': 'EP1: You Are My Glory',
+            'description': 'md5:831363a4c3b4d7615e1f3854be3a123b',
+            'thumbnail': r're:^https?://[^?#]+u37kgfnfzs73kiu',
+            'series': 'You Are My Glory',
+            'episode': 'Episode 1',
+            'episode_number': 1,
+            'duration': 2454,
+        },
+    }, {
+        'url': 'https://wetv.vip/en/play/lcxgwod5hapghvw-WeTV-PICK-A-BOO/i0042y00lxp-Zhao-Lusi-Describes-The-First-Experiences-She-Had-In-Who-Rules-The-World-%7C-WeTV-PICK-A-BOO',
+        'md5': '71133f5c2d5d6cad3427e1b010488280',
+        'info_dict': {
+            'id': 'i0042y00lxp',
+            'ext': 'mp4',
+            'title': 'md5:f7a0857dbe5fbbe2e7ad630b92b54e6a',
+            'description': 'md5:76260cb9cdc0ef76826d7ca9d92fadfa',
+            'thumbnail': r're:^https?://[^?#]+lcxgwod5hapghvw',
+            'series': 'WeTV PICK-A-BOO',
+            'episode': 'Episode 0',
+            'episode_number': 0,
+            'duration': 442,
+        },
+    }]
+
+    def _real_extract(self, url):
+        video_id, series_id = self._match_valid_url(url).group('id', 'series_id')
+        webpage = self._download_webpage(url, video_id)
+        webpage_metadata = self._get_webpage_metadata(webpage, video_id)
+
+        formats, subtitles = self._extract_all_video_formats_and_subtitles(url, video_id, series_id)
+        return {
+            'id': video_id,
+            'title': self._get_clean_title(self._og_search_title(webpage)
+                                           or traverse_obj(webpage_metadata, ('coverInfo', 'title'))),
+            'description': (traverse_obj(webpage_metadata, ('coverInfo', 'description'))
+                            or self._og_search_description(webpage)),
+            'formats': formats,
+            'subtitles': subtitles,
+            'thumbnail': self._og_search_thumbnail(webpage),
+            'duration': int_or_none(traverse_obj(webpage_metadata, ('videoInfo', 'duration'))),
+            'series': traverse_obj(webpage_metadata, ('coverInfo', 'title')),
+            'episode_number': int_or_none(traverse_obj(webpage_metadata, ('videoInfo', 'episode'))),
+        }
+
+
+class WeTvSeriesIE(WeTvBaseIE):
+    _VALID_URL = WeTvBaseIE._VALID_URL_BASE + r'/(?P<id>\w+)(?:-[^/?#]+)?/?(?:[?#]|$)'
+
+    _TESTS = [{
+        'url': 'https://wetv.vip/play/air11ooo2rdsdi3-Cute-Programmer',
+        'info_dict': {
+            'id': 'air11ooo2rdsdi3',
+            'title': 'Cute Programmer',
+            'description': 'md5:e87beab3bf9f392d6b9e541a63286343',
+        },
+        'playlist_count': 30,
+    }, {
+        'url': 'https://wetv.vip/en/play/u37kgfnfzs73kiu-You-Are-My-Glory',
+        'info_dict': {
+            'id': 'u37kgfnfzs73kiu',
+            'title': 'You Are My Glory',
+            'description': 'md5:831363a4c3b4d7615e1f3854be3a123b',
+        },
+        'playlist_count': 32,
+    }]
+
+    def _real_extract(self, url):
+        series_id = self._match_id(url)
+        webpage = self._download_webpage(url, series_id)
+        webpage_metadata = self._get_webpage_metadata(webpage, series_id)
+
+        episode_paths = ([f'/play/{series_id}/{episode["vid"]}' for episode in webpage_metadata.get('videoList')]
+                         or re.findall(r'<a[^>]+class="play-video__link"[^>]+href="(?P<path>[^"]+)', webpage))
+
+        return self.playlist_from_matches(
+            episode_paths, series_id, ie=WeTvEpisodeIE, getter=functools.partial(urljoin, url),
+            title=self._get_clean_title(traverse_obj(webpage_metadata, ('coverInfo', 'title'))
+                                        or self._og_search_title(webpage)),
+            description=(traverse_obj(webpage_metadata, ('coverInfo', 'description'))
+                         or self._og_search_description(webpage)))
diff --git a/yt_dlp/extractor/triller.py b/yt_dlp/extractor/triller.py
new file mode 100644
index 000000000..c199da91d
--- /dev/null
+++ b/yt_dlp/extractor/triller.py
@@ -0,0 +1,304 @@
+import itertools
+import json
+
+from .common import InfoExtractor
+from ..utils import (
+    int_or_none,
+    str_or_none,
+    traverse_obj,
+    unified_strdate,
+    unified_timestamp,
+    url_basename,
+    ExtractorError,
+)
+
+
+class TrillerBaseIE(InfoExtractor):
+    _NETRC_MACHINE = 'triller'
+    _AUTH_TOKEN = None
+    _API_BASE_URL = 'https://social.triller.co/v1.5'
+
+    def _perform_login(self, username, password):
+        if self._AUTH_TOKEN:
+            return
+
+        user_check = self._download_json(
+            f'{self._API_BASE_URL}/api/user/is-valid-username', None, note='Checking username',
+            fatal=False, expected_status=400, headers={
+                'Content-Type': 'application/json',
+                'Origin': 'https://triller.co',
+            }, data=json.dumps({'username': username}, separators=(',', ':')).encode('utf-8'))
+        if user_check.get('status'):  # endpoint returns "status":false if username exists
+            raise ExtractorError('Unable to login: Invalid username', expected=True)
+
+        credentials = {
+            'username': username,
+            'password': password,
+        }
+        login = self._download_json(
+            f'{self._API_BASE_URL}/user/auth', None, note='Logging in',
+            fatal=False, expected_status=400, headers={
+                'Content-Type': 'application/json',
+                'Origin': 'https://triller.co',
+            }, data=json.dumps(credentials, separators=(',', ':')).encode('utf-8'))
+        if not login.get('auth_token'):
+            if login.get('error') == 1008:
+                raise ExtractorError('Unable to login: Incorrect password', expected=True)
+            raise ExtractorError('Unable to login')
+
+        self._AUTH_TOKEN = login['auth_token']
+
+    def _get_comments(self, video_id, limit=15):
+        comment_info = self._download_json(
+            f'{self._API_BASE_URL}/api/videos/{video_id}/comments_v2',
+            video_id, fatal=False, note='Downloading comments API JSON',
+            headers={'Origin': 'https://triller.co'}, query={'limit': limit}) or {}
+        if not comment_info.get('comments'):
+            return
+        for comment_dict in comment_info['comments']:
+            yield {
+                'author': traverse_obj(comment_dict, ('author', 'username')),
+                'author_id': traverse_obj(comment_dict, ('author', 'user_id')),
+                'id': comment_dict.get('id'),
+                'text': comment_dict.get('body'),
+                'timestamp': unified_timestamp(comment_dict.get('timestamp')),
+            }
+
+    def _check_user_info(self, user_info):
+        if not user_info:
+            self.report_warning('Unable to extract user info')
+        elif user_info.get('private') and not user_info.get('followed_by_me'):
+            raise ExtractorError('This video is private', expected=True)
+        elif traverse_obj(user_info, 'blocked_by_user', 'blocking_user'):
+            raise ExtractorError('The author of the video is blocked', expected=True)
+        return user_info
+
+    def _parse_video_info(self, video_info, username, user_info=None):
+        video_uuid = video_info.get('video_uuid')
+        video_id = video_info.get('id')
+
+        formats = []
+        video_url = traverse_obj(video_info, 'video_url', 'stream_url')
+        if video_url:
+            formats.append({
+                'url': video_url,
+                'ext': 'mp4',
+                'vcodec': 'h264',
+                'width': video_info.get('width'),
+                'height': video_info.get('height'),
+                'format_id': url_basename(video_url).split('.')[0],
+                'filesize': video_info.get('filesize'),
+            })
+        video_set = video_info.get('video_set') or []
+        for video in video_set:
+            resolution = video.get('resolution') or ''
+            formats.append({
+                'url': video['url'],
+                'ext': 'mp4',
+                'vcodec': video.get('codec'),
+                'vbr': int_or_none(video.get('bitrate'), 1000),
+                'width': int_or_none(resolution.split('x')[0]),
+                'height': int_or_none(resolution.split('x')[1]),
+                'format_id': url_basename(video['url']).split('.')[0],
+            })
+        audio_url = video_info.get('audio_url')
+        if audio_url:
+            formats.append({
+                'url': audio_url,
+                'ext': 'm4a',
+                'format_id': url_basename(audio_url).split('.')[0],
+            })
+
+        manifest_url = video_info.get('transcoded_url')
+        if manifest_url:
+            formats.extend(self._extract_m3u8_formats(
+                manifest_url, video_id, 'mp4', entry_protocol='m3u8_native',
+                m3u8_id='hls', fatal=False))
+        self._sort_formats(formats)
+
+        comment_count = int_or_none(video_info.get('comment_count'))
+
+        user_info = user_info or traverse_obj(video_info, 'user', default={})
+
+        return {
+            'id': str_or_none(video_id) or video_uuid,
+            'title': video_info.get('description') or f'Video by {username}',
+            'thumbnail': video_info.get('thumbnail_url'),
+            'description': video_info.get('description'),
+            'uploader': str_or_none(username),
+            'uploader_id': str_or_none(user_info.get('user_id')),
+            'creator': str_or_none(user_info.get('name')),
+            'timestamp': unified_timestamp(video_info.get('timestamp')),
+            'upload_date': unified_strdate(video_info.get('timestamp')),
+            'duration': int_or_none(video_info.get('duration')),
+            'view_count': int_or_none(video_info.get('play_count')),
+            'like_count': int_or_none(video_info.get('likes_count')),
+            'artist': str_or_none(video_info.get('song_artist')),
+            'track': str_or_none(video_info.get('song_title')),
+            'webpage_url': f'https://triller.co/@{username}/video/{video_uuid}',
+            'uploader_url': f'https://triller.co/@{username}',
+            'extractor_key': TrillerIE.ie_key(),
+            'extractor': TrillerIE.IE_NAME,
+            'formats': formats,
+            'comment_count': comment_count,
+            '__post_extractor': self.extract_comments(video_id, comment_count),
+        }
+
+
+class TrillerIE(TrillerBaseIE):
+    _VALID_URL = r'''(?x)
+            https?://(?:www\.)?triller\.co/
+            @(?P<username>[\w\._]+)/video/
+            (?P<id>[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12})
+        '''
+    _TESTS = [{
+        'url': 'https://triller.co/@theestallion/video/2358fcd7-3df2-4c77-84c8-1d091610a6cf',
+        'md5': '228662d783923b60d78395fedddc0a20',
+        'info_dict': {
+            'id': '71595734',
+            'ext': 'mp4',
+            'title': 'md5:9a2bf9435c5c4292678996a464669416',
+            'thumbnail': r're:^https://uploads\.cdn\.triller\.co/.+\.jpg$',
+            'description': 'md5:9a2bf9435c5c4292678996a464669416',
+            'uploader': 'theestallion',
+            'uploader_id': '18992236',
+            'creator': 'Megan Thee Stallion',
+            'timestamp': 1660598222,
+            'upload_date': '20220815',
+            'duration': 47,
+            'height': 3840,
+            'width': 2160,
+            'view_count': int,
+            'like_count': int,
+            'artist': 'Megan Thee Stallion',
+            'track': 'Her',
+            'webpage_url': 'https://triller.co/@theestallion/video/2358fcd7-3df2-4c77-84c8-1d091610a6cf',
+            'uploader_url': 'https://triller.co/@theestallion',
+            'comment_count': int,
+        }
+    }, {
+        'url': 'https://triller.co/@charlidamelio/video/46c6fcfa-aa9e-4503-a50c-68444f44cddc',
+        'md5': '874055f462af5b0699b9dbb527a505a0',
+        'info_dict': {
+            'id': '71621339',
+            'ext': 'mp4',
+            'title': 'md5:4c91ea82760fe0fffb71b8c3aa7295fc',
+            'thumbnail': r're:^https://uploads\.cdn\.triller\.co/.+\.jpg$',
+            'description': 'md5:4c91ea82760fe0fffb71b8c3aa7295fc',
+            'uploader': 'charlidamelio',
+            'uploader_id': '1875551',
+            'creator': 'charli damelio',
+            'timestamp': 1660773354,
+            'upload_date': '20220817',
+            'duration': 16,
+            'height': 1920,
+            'width': 1080,
+            'view_count': int,
+            'like_count': int,
+            'artist': 'Dixie',
+            'track': 'Someone to Blame',
+            'webpage_url': 'https://triller.co/@charlidamelio/video/46c6fcfa-aa9e-4503-a50c-68444f44cddc',
+            'uploader_url': 'https://triller.co/@charlidamelio',
+            'comment_count': int,
+        }
+    }]
+
+    def _real_extract(self, url):
+        username, video_uuid = self._match_valid_url(url).group('username', 'id')
+
+        video_info = traverse_obj(self._download_json(
+            f'{self._API_BASE_URL}/api/videos/{video_uuid}',
+            video_uuid, note='Downloading video info API JSON',
+            errnote='Unable to download video info API JSON',
+            headers={
+                'Origin': 'https://triller.co',
+            }), ('videos', 0))
+        if not video_info:
+            raise ExtractorError('No video info found in API response')
+
+        user_info = self._check_user_info(video_info.get('user') or {})
+        return self._parse_video_info(video_info, username, user_info)
+
+
+class TrillerUserIE(TrillerBaseIE):
+    _VALID_URL = r'https?://(?:www\.)?triller\.co/@(?P<id>[\w\._]+)/?(?:$|[#?])'
+    _TESTS = [{
+        # first videos request only returns 2 videos
+        'url': 'https://triller.co/@theestallion',
+        'playlist_mincount': 9,
+        'info_dict': {
+            'id': '18992236',
+            'title': 'theestallion',
+            'thumbnail': r're:^https://uploads\.cdn\.triller\.co/.+\.jpg$',
+        }
+    }, {
+        'url': 'https://triller.co/@charlidamelio',
+        'playlist_mincount': 25,
+        'info_dict': {
+            'id': '1875551',
+            'title': 'charlidamelio',
+            'thumbnail': r're:^https://uploads\.cdn\.triller\.co/.+\.jpg$',
+        }
+    }]
+
+    def _real_initialize(self):
+        if not self._AUTH_TOKEN:
+            guest = self._download_json(
+                f'{self._API_BASE_URL}/user/create_guest',
+                None, note='Creating guest session', data=b'', headers={
+                    'Origin': 'https://triller.co',
+                }, query={
+                    'platform': 'Web',
+                    'app_version': '',
+                })
+            if not guest.get('auth_token'):
+                raise ExtractorError('Unable to fetch required auth token for user extraction')
+
+            self._AUTH_TOKEN = guest['auth_token']
+
+    def _extract_video_list(self, username, user_id, limit=6):
+        query = {
+            'limit': limit,
+        }
+        for page in itertools.count(1):
+            for retry in self.RetryManager():
+                try:
+                    video_list = self._download_json(
+                        f'{self._API_BASE_URL}/api/users/{user_id}/videos',
+                        username, note=f'Downloading user video list page {page}',
+                        errnote='Unable to download user video list', headers={
+                            'Authorization': f'Bearer {self._AUTH_TOKEN}',
+                            'Origin': 'https://triller.co',
+                        }, query=query)
+                except ExtractorError as e:
+                    if isinstance(e.cause, json.JSONDecodeError) and e.cause.pos == 0:
+                        retry.error = e
+                        continue
+                    raise
+            if not video_list.get('videos'):
+                break
+            yield from video_list['videos']
+            query['before_time'] = traverse_obj(video_list, ('videos', -1, 'timestamp'))
+            if not query['before_time']:
+                break
+
+    def _entries(self, videos, username, user_info):
+        for video in videos:
+            yield self._parse_video_info(video, username, user_info)
+
+    def _real_extract(self, url):
+        username = self._match_id(url)
+        user_info = self._check_user_info(self._download_json(
+            f'{self._API_BASE_URL}/api/users/by_username/{username}',
+            username, note='Downloading user info',
+            errnote='Failed to download user info', headers={
+                'Authorization': f'Bearer {self._AUTH_TOKEN}',
+                'Origin': 'https://triller.co',
+            }).get('user', {}))
+
+        user_id = str_or_none(user_info.get('user_id'))
+        videos = self._extract_video_list(username, user_id)
+        thumbnail = user_info.get('avatar_url')
+
+        return self.playlist_result(
+            self._entries(videos, username, user_info), user_id, username, thumbnail=thumbnail)
diff --git a/yt_dlp/extractor/wetv.py b/yt_dlp/extractor/wetv.py
deleted file mode 100644
index ea2d0517e..000000000
--- a/yt_dlp/extractor/wetv.py
+++ /dev/null
@@ -1,208 +0,0 @@
-import functools
-import re
-import time
-
-from .common import InfoExtractor
-from ..aes import aes_cbc_encrypt_bytes
-from ..utils import determine_ext, int_or_none, traverse_obj, urljoin
-
-
-class WeTvBaseIE(InfoExtractor):
-    _VALID_URL_BASE = r'https?://(?:www\.)?wetv\.vip/(?:[^?#]+/)?play'
-
-    def _get_ckey(self, video_id, url, app_version, platform):
-        ua = self.get_param('http_headers')['User-Agent']
-
-        payload = (f'{video_id}|{int(time.time())}|mg3c3b04ba|{app_version}|0000000000000000|'
-                   f'{platform}|{url[:48]}|{ua.lower()[:48]}||Mozilla|Netscape|Win32|00|')
-
-        return aes_cbc_encrypt_bytes(
-            bytes(f'|{sum(map(ord, payload))}|{payload}', 'utf-8'),
-            b'Ok\xda\xa3\x9e/\x8c\xb0\x7f^r-\x9e\xde\xf3\x14',
-            b'\x01PJ\xf3V\xe6\x19\xcf.B\xbb\xa6\x8c?p\xf9',
-            padding_mode='whitespace').hex()
-
-    def _get_video_api_response(self, video_url, video_id, series_id, subtitle_format, video_format, video_quality):
-        app_version = '3.5.57'
-        platform = '4830201'
-
-        ckey = self._get_ckey(video_id, video_url, app_version, platform)
-        query = {
-            'vid': video_id,
-            'cid': series_id,
-            'cKey': ckey,
-            'encryptVer': '8.1',
-            'spcaptiontype': '1' if subtitle_format == 'vtt' else '0',  # 0 - SRT, 1 - VTT
-            'sphls': '1' if video_format == 'hls' else '0',  # 0 - MP4, 1 - HLS
-            'defn': video_quality,  # '': 480p, 'shd': 720p, 'fhd': 1080p
-            'spsrt': '1',  # Enable subtitles
-            'sphttps': '1',  # Enable HTTPS
-            'otype': 'json',  # Response format: xml, json,
-            'dtype': '1',
-            'spwm': '1',
-            'host': 'wetv.vip',  # These three values are needed for SHD
-            'referer': 'wetv.vip',
-            'ehost': video_url,
-            'appVer': app_version,
-            'platform': platform,
-        }
-
-        return self._search_json(r'QZOutputJson=', self._download_webpage(
-            'https://play.wetv.vip/getvinfo', video_id, query=query), 'api_response', video_id)
-
-    def _get_webpage_metadata(self, webpage, video_id):
-        return self._parse_json(
-            traverse_obj(self._search_nextjs_data(webpage, video_id), ('props', 'pageProps', 'data')),
-            video_id, fatal=False)
-
-
-class WeTvEpisodeIE(WeTvBaseIE):
-    IE_NAME = 'wetv:episode'
-    _VALID_URL = WeTvBaseIE._VALID_URL_BASE + r'/(?P<series_id>\w+)(?:-[^?#]+)?/(?P<id>\w+)(?:-[^?#]+)?'
-
-    _TESTS = [{
-        'url': 'https://wetv.vip/en/play/air11ooo2rdsdi3-Cute-Programmer/v0040pr89t9-EP1-Cute-Programmer',
-        'md5': 'a046f565c9dce9b263a0465a422cd7bf',
-        'info_dict': {
-            'id': 'v0040pr89t9',
-            'ext': 'mp4',
-            'title': 'EP1: Cute Programmer',
-            'description': 'md5:e87beab3bf9f392d6b9e541a63286343',
-            'thumbnail': r're:^https?://[^?#]+air11ooo2rdsdi3',
-            'series': 'Cute Programmer',
-            'episode': 'Episode 1',
-            'episode_number': 1,
-            'duration': 2835,
-        },
-    }, {
-        'url': 'https://wetv.vip/en/play/u37kgfnfzs73kiu/p0039b9nvik',
-        'md5': '4d9d69bcfd11da61f4aae64fc6b316b3',
-        'info_dict': {
-            'id': 'p0039b9nvik',
-            'ext': 'mp4',
-            'title': 'EP1: You Are My Glory',
-            'description': 'md5:831363a4c3b4d7615e1f3854be3a123b',
-            'thumbnail': r're:^https?://[^?#]+u37kgfnfzs73kiu',
-            'series': 'You Are My Glory',
-            'episode': 'Episode 1',
-            'episode_number': 1,
-            'duration': 2454,
-        },
-    }, {
-        'url': 'https://wetv.vip/en/play/lcxgwod5hapghvw-WeTV-PICK-A-BOO/i0042y00lxp-Zhao-Lusi-Describes-The-First-Experiences-She-Had-In-Who-Rules-The-World-%7C-WeTV-PICK-A-BOO',
-        'md5': '71133f5c2d5d6cad3427e1b010488280',
-        'info_dict': {
-            'id': 'i0042y00lxp',
-            'ext': 'mp4',
-            'title': 'md5:f7a0857dbe5fbbe2e7ad630b92b54e6a',
-            'description': 'md5:76260cb9cdc0ef76826d7ca9d92fadfa',
-            'thumbnail': r're:^https?://[^?#]+lcxgwod5hapghvw',
-            'series': 'WeTV PICK-A-BOO',
-            'episode': 'Episode 0',
-            'episode_number': 0,
-            'duration': 442,
-        },
-    }]
-
-    def _extract_video_formats_and_subtitles(self, api_response, video_id, video_quality):
-        video_response = api_response['vl']['vi'][0]
-        video_width = video_response.get('vw')
-        video_height = video_response.get('vh')
-
-        formats, subtitles = [], {}
-        for video_format in video_response['ul']['ui']:
-            if video_format.get('hls'):
-                fmts, subs = self._extract_m3u8_formats_and_subtitles(
-                    video_format['url'] + video_format['hls']['pname'], video_id, 'mp4', fatal=False)
-                for f in fmts:
-                    f['width'] = video_width
-                    f['height'] = video_height
-
-                formats.extend(fmts)
-                self._merge_subtitles(subs, target=subtitles)
-            else:
-                formats.append({
-                    'url': f'{video_format["url"]}{video_response["fn"]}?vkey={video_response["fvkey"]}',
-                    'width': video_width,
-                    'height': video_height,
-                    'ext': 'mp4',
-                })
-
-        return formats, subtitles
-
-    def _extract_video_subtitles(self, api_response, subtitles_format):
-        subtitles = {}
-        for subtitle in traverse_obj(api_response, ('sfl', 'fi')):
-            subtitles.setdefault(subtitle['lang'].lower(), []).append({
-                'url': subtitle['url'],
-                'ext': subtitles_format,
-                'protocol': 'm3u8_native' if determine_ext(subtitle['url']) == 'm3u8' else 'http',
-            })
-
-        return subtitles
-
-    def _real_extract(self, url):
-        video_id, series_id = self._match_valid_url(url).group('id', 'series_id')
-        webpage = self._download_webpage(url, video_id)
-
-        formats, subtitles = [], {}
-        for video_format, subtitle_format, video_quality in (('mp4', 'srt', ''), ('hls', 'vtt', 'shd'), ('hls', 'vtt', 'fhd')):
-            api_response = self._get_video_api_response(url, video_id, series_id, subtitle_format, video_format, video_quality)
-
-            fmts, subs = self._extract_video_formats_and_subtitles(api_response, video_id, video_quality)
-            native_subtitles = self._extract_video_subtitles(api_response, subtitle_format)
-
-            formats.extend(fmts)
-            self._merge_subtitles(subs, native_subtitles, target=subtitles)
-
-        self._sort_formats(formats)
-        webpage_metadata = self._get_webpage_metadata(webpage, video_id)
-
-        return {
-            'id': video_id,
-            'title': (self._og_search_title(webpage)
-                      or traverse_obj(webpage_metadata, ('coverInfo', 'description'))),
-            'description': (self._og_search_description(webpage)
-                            or traverse_obj(webpage_metadata, ('coverInfo', 'description'))),
-            'formats': formats,
-            'subtitles': subtitles,
-            'thumbnail': self._og_search_thumbnail(webpage),
-            'duration': int_or_none(traverse_obj(webpage_metadata, ('videoInfo', 'duration'))),
-            'series': traverse_obj(webpage_metadata, ('coverInfo', 'title')),
-            'episode_number': int_or_none(traverse_obj(webpage_metadata, ('videoInfo', 'episode'))),
-        }
-
-
-class WeTvSeriesIE(WeTvBaseIE):
-    _VALID_URL = WeTvBaseIE._VALID_URL_BASE + r'/(?P<id>\w+)(?:-[^/?#]+)?/?(?:[?#]|$)'
-
-    _TESTS = [{
-        'url': 'https://wetv.vip/play/air11ooo2rdsdi3-Cute-Programmer',
-        'info_dict': {
-            'id': 'air11ooo2rdsdi3',
-            'title': 'Cute Programmer',
-            'description': 'md5:e87beab3bf9f392d6b9e541a63286343',
-        },
-        'playlist_count': 30,
-    }, {
-        'url': 'https://wetv.vip/en/play/u37kgfnfzs73kiu-You-Are-My-Glory',
-        'info_dict': {
-            'id': 'u37kgfnfzs73kiu',
-            'title': 'You Are My Glory',
-            'description': 'md5:831363a4c3b4d7615e1f3854be3a123b',
-        },
-        'playlist_count': 32,
-    }]
-
-    def _real_extract(self, url):
-        series_id = self._match_id(url)
-        webpage = self._download_webpage(url, series_id)
-        webpage_metadata = self._get_webpage_metadata(webpage, series_id)
-
-        episode_paths = (re.findall(r'<a[^>]+class="play-video__link"[^>]+href="(?P<path>[^"]+)', webpage)
-                         or [f'/{series_id}/{episode["vid"]}' for episode in webpage_metadata.get('videoList')])
-
-        return self.playlist_from_matches(
-            episode_paths, series_id, ie=WeTvEpisodeIE, getter=functools.partial(urljoin, url),
-            title=traverse_obj(webpage_metadata, ('coverInfo', 'title')) or self._og_search_title(webpage),
-            description=traverse_obj(webpage_metadata, ('coverInfo', 'description')) or self._og_search_description(webpage))
diff --git a/yt_dlp/extractor/youtube.py b/yt_dlp/extractor/youtube.py
index b30dadf9f..b1eda0d07 100644
--- a/yt_dlp/extractor/youtube.py
+++ b/yt_dlp/extractor/youtube.py
@@ -2159,6 +2159,35 @@ class YoutubeIE(YoutubeBaseInfoExtractor):
                 'comment_count': int,
                 'channel_follower_count': int
             }
+        }, {
+            # Same video as above, but with --compat-opt no-youtube-prefer-utc-upload-date
+            'url': 'https://www.youtube.com/watch?v=2NUZ8W2llS4',
+            'info_dict': {
+                'id': '2NUZ8W2llS4',
+                'ext': 'mp4',
+                'title': 'The NP that test your phone performance 🙂',
+                'description': 'md5:144494b24d4f9dfacb97c1bbef5de84d',
+                'uploader': 'Leon Nguyen',
+                'uploader_id': 'VNSXIII',
+                'uploader_url': 'http://www.youtube.com/user/VNSXIII',
+                'channel_id': 'UCRqNBSOHgilHfAczlUmlWHA',
+                'channel_url': 'https://www.youtube.com/channel/UCRqNBSOHgilHfAczlUmlWHA',
+                'duration': 21,
+                'view_count': int,
+                'age_limit': 0,
+                'categories': ['Gaming'],
+                'tags': 'count:23',
+                'playable_in_embed': True,
+                'live_status': 'not_live',
+                'upload_date': '20220102',
+                'like_count': int,
+                'availability': 'public',
+                'channel': 'Leon Nguyen',
+                'thumbnail': 'https://i.ytimg.com/vi_webp/2NUZ8W2llS4/maxresdefault.webp',
+                'comment_count': int,
+                'channel_follower_count': int
+            },
+            'params': {'compat_opts': ['no-youtube-prefer-utc-upload-date']}
         }, {
             # date text is premiered video, ensure upload date in UTC (published 1641172509)
             'url': 'https://www.youtube.com/watch?v=mzZzzBU6lrM',
@@ -2670,7 +2699,7 @@ def _extract_n_function_name(self, jscode):
 
     def _extract_n_function_code(self, video_id, player_url):
         player_id = self._extract_player_info(player_url)
-        func_code = self.cache.load('youtube-nsig', player_id, after='2022.08.19.1')
+        func_code = self.cache.load('youtube-nsig', player_id, min_ver='2022.08.19.2')
         jscode = func_code or self._load_player(video_id, player_url)
         jsi = JSInterpreter(jscode)
 
@@ -2959,8 +2988,8 @@ def extract_thread(contents):
         # YouTube comments have a max depth of 2
         max_depth = int_or_none(get_single_config_arg('max_comment_depth'))
         if max_depth:
-            self._downloader.deprecation_warning(
-                '[youtube] max_comment_depth extractor argument is deprecated. Set max replies in the max-comments extractor argument instead.')
+            self._downloader.deprecated_feature('[youtube] max_comment_depth extractor argument is deprecated. '
+                                                'Set max replies in the max-comments extractor argument instead')
         if max_depth == 1 and parent:
             return
 
@@ -3282,7 +3311,8 @@ def _extract_formats_and_subtitles(self, streaming_data, video_id, player_url, i
                 except ExtractorError as e:
                     phantomjs_hint = ''
                     if isinstance(e, JSInterpreter.Exception):
-                        phantomjs_hint = f'         Install {self._downloader._format_err("PhantomJS", self._downloader.Styles.EMPHASIS)} to workaround the issue\n'
+                        phantomjs_hint = (f'         Install {self._downloader._format_err("PhantomJS", self._downloader.Styles.EMPHASIS)} '
+                                          f'to workaround the issue. {PhantomJSwrapper.INSTALL_HINT}\n')
                     self.report_warning(
                         f'nsig extraction failed: You may experience throttling for some formats\n{phantomjs_hint}'
                         f'         n = {query["n"][0]} ; player = {player_url}', video_id=video_id, only_once=True)
@@ -3919,7 +3949,12 @@ def process_language(container, base_url, lang_code, sub_name, query):
         upload_date = (
             unified_strdate(get_first(microformats, 'uploadDate'))
             or unified_strdate(search_meta('uploadDate')))
-        if not upload_date or (not info.get('is_live') and not info.get('was_live') and info.get('live_status') != 'is_upcoming'):
+        if not upload_date or (
+            not info.get('is_live')
+            and not info.get('was_live')
+            and info.get('live_status') != 'is_upcoming'
+            and 'no-youtube-prefer-utc-upload-date' not in self.get_param('compat_opts', [])
+        ):
             upload_date = strftime_or_none(self._extract_time_text(vpir, 'dateText')[0], '%Y%m%d') or upload_date
         info['upload_date'] = upload_date
 
diff --git a/yt_dlp/jsinterp.py b/yt_dlp/jsinterp.py
index cadb013a3..51c7beed4 100644
--- a/yt_dlp/jsinterp.py
+++ b/yt_dlp/jsinterp.py
@@ -18,10 +18,11 @@
 
 
 def _js_bit_op(op):
+    def zeroise(x):
+        return 0 if x in (None, JS_Undefined) else x
+
     def wrapped(a, b):
-        def zeroise(x):
-            return 0 if x in (None, JS_Undefined) else x
-        return op(zeroise(a), zeroise(b))
+        return op(zeroise(a), zeroise(b)) & 0xffffffff
 
     return wrapped
 
@@ -172,7 +173,14 @@ def wrap_interpreter(cls, f):
         def interpret_statement(self, stmt, local_vars, allow_recursion, *args, **kwargs):
             if cls.ENABLED and stmt.strip():
                 cls.write(stmt, level=allow_recursion)
-            ret, should_ret = f(self, stmt, local_vars, allow_recursion, *args, **kwargs)
+            try:
+                ret, should_ret = f(self, stmt, local_vars, allow_recursion, *args, **kwargs)
+            except Exception as e:
+                if cls.ENABLED:
+                    if isinstance(e, ExtractorError):
+                        e = e.orig_msg
+                    cls.write('=> Raises:', e, '<-|', stmt, level=allow_recursion)
+                raise
             if cls.ENABLED and stmt.strip():
                 cls.write(['->', '=>'][should_ret], repr(ret), '<-|', stmt, level=allow_recursion)
             return ret, should_ret
@@ -226,7 +234,7 @@ def _regex_flags(cls, expr):
 
     @staticmethod
     def _separate(expr, delim=',', max_split=None):
-        OP_CHARS = '+-*/%&|^=<>!,;{}()[]:'
+        OP_CHARS = '+-*/%&|^=<>!,;{}:'
         if not expr:
             return
         counters = {k: 0 for k in _MATCHING_PARENS.values()}
@@ -504,7 +512,7 @@ def dict_item(key, val):
                 (?P<op>{"|".join(map(re.escape, set(_OPERATORS) - _COMP_OPERATORS))})?
                 =(?!=)(?P<expr>.*)$
             )|(?P<return>
-                (?!if|return|true|false|null|undefined)(?P<name>{_NAME_RE})$
+                (?!if|return|true|false|null|undefined|NaN)(?P<name>{_NAME_RE})$
             )|(?P<indexing>
                 (?P<in>{_NAME_RE})\[(?P<idx>.+)\]$
             )|(?P<attribute>
@@ -539,6 +547,8 @@ def dict_item(key, val):
             raise JS_Continue()
         elif expr == 'undefined':
             return JS_Undefined, should_return
+        elif expr == 'NaN':
+            return float('NaN'), should_return
 
         elif m and m.group('return'):
             return local_vars.get(m.group('name'), JS_Undefined), should_return
@@ -683,6 +693,13 @@ def eval_method():
                         return obj.index(idx, start)
                     except ValueError:
                         return -1
+                elif member == 'charCodeAt':
+                    assertion(isinstance(obj, str), 'must be applied on a string')
+                    assertion(len(argvals) == 1, 'takes exactly one argument')
+                    idx = argvals[0] if isinstance(argvals[0], int) else 0
+                    if idx >= len(obj):
+                        return None
+                    return ord(obj[idx])
 
                 idx = int(member) if isinstance(obj, list) else member
                 return obj[idx](argvals, allow_recursion=allow_recursion)
@@ -784,7 +801,7 @@ def resf(args, kwargs={}, allow_recursion=100):
             global_stack[0].update(itertools.zip_longest(argnames, args, fillvalue=None))
             global_stack[0].update(kwargs)
             var_stack = LocalNameSpace(*global_stack)
-            ret, should_abort = self.interpret_statement(code.replace('\n', ''), var_stack, allow_recursion - 1)
+            ret, should_abort = self.interpret_statement(code.replace('\n', ' '), var_stack, allow_recursion - 1)
             if should_abort:
                 return ret
         return resf
diff --git a/yt_dlp/options.py b/yt_dlp/options.py
index a0db9bc02..0fbf1f028 100644
--- a/yt_dlp/options.py
+++ b/yt_dlp/options.py
@@ -25,6 +25,7 @@
     OUTTMPL_TYPES,
     POSTPROCESS_WHEN,
     Config,
+    deprecation_warning,
     expand_path,
     format_field,
     get_executable_path,
@@ -443,6 +444,7 @@ def _alias_callback(option, opt_str, value, parser, opts, nargs):
                 'multistreams', 'no-live-chat', 'playlist-index', 'list-formats', 'no-direct-merge',
                 'no-youtube-channel-redirect', 'no-youtube-unavailable-videos', 'no-attach-info-json', 'embed-metadata',
                 'embed-thumbnail-atomicparsley', 'seperate-video-versions', 'no-clean-infojson', 'no-keep-subs', 'no-certifi',
+                'no-youtube-prefer-utc-upload-date'
             }, 'aliases': {
                 'youtube-dl': ['all', '-multistreams'],
                 'youtube-dlc': ['all', '-no-youtube-channel-redirect', '-no-live-chat'],
@@ -1399,14 +1401,15 @@ def _alias_callback(option, opt_str, value, parser, opts, nargs):
         help='Do not read/dump cookies from/to file (default)')
     filesystem.add_option(
         '--cookies-from-browser',
-        dest='cookiesfrombrowser', metavar='BROWSER[+KEYRING][:PROFILE]',
+        dest='cookiesfrombrowser', metavar='BROWSER[+KEYRING][:PROFILE][::CONTAINER]',
         help=(
-            'The name of the browser and (optionally) the name/path of '
-            'the profile to load cookies from, separated by a ":". '
+            'The name of the browser to load cookies from. '
             f'Currently supported browsers are: {", ".join(sorted(SUPPORTED_BROWSERS))}. '
-            'By default, the most recently accessed profile is used. '
-            'The keyring used for decrypting Chromium cookies on Linux can be '
-            '(optionally) specified after the browser name separated by a "+". '
+            'Optionally, the KEYRING used for decrypting Chromium cookies on Linux, '
+            'the name/path of the PROFILE to load cookies from, '
+            'and the CONTAINER name (if Firefox) ("none" for no container) '
+            'can be given with their respective seperators. '
+            'By default, all containers of the most recently accessed profile are used. '
             f'Currently supported keyrings are: {", ".join(map(str.lower, sorted(SUPPORTED_KEYRINGS)))}'))
     filesystem.add_option(
         '--no-cookies-from-browser',
@@ -1864,7 +1867,6 @@ def _alias_callback(option, opt_str, value, parser, opts, nargs):
 
 
 def _hide_login_info(opts):
-    write_string(
-        'DeprecationWarning: "yt_dlp.options._hide_login_info" is deprecated and may be removed in a future version. '
-        'Use "yt_dlp.utils.Config.hide_login_info" instead\n')
+    deprecation_warning(f'"{__name__}._hide_login_info" is deprecated and may be removed '
+                        'in a future version. Use "yt_dlp.utils.Config.hide_login_info" instead')
     return Config.hide_login_info(opts)
diff --git a/yt_dlp/postprocessor/common.py b/yt_dlp/postprocessor/common.py
index 20d890df0..44feda427 100644
--- a/yt_dlp/postprocessor/common.py
+++ b/yt_dlp/postprocessor/common.py
@@ -7,10 +7,10 @@
     PostProcessingError,
     RetryManager,
     _configuration_args,
+    deprecation_warning,
     encodeFilename,
     network_exceptions,
     sanitized_Request,
-    write_string,
 )
 
 
@@ -73,10 +73,14 @@ def report_warning(self, text, *args, **kwargs):
         if self._downloader:
             return self._downloader.report_warning(text, *args, **kwargs)
 
-    def deprecation_warning(self, text):
+    def deprecation_warning(self, msg):
+        warn = getattr(self._downloader, 'deprecation_warning', deprecation_warning)
+        return warn(msg, stacklevel=1)
+
+    def deprecated_feature(self, msg):
         if self._downloader:
-            return self._downloader.deprecation_warning(text)
-        write_string(f'DeprecationWarning: {text}')
+            return self._downloader.deprecated_feature(msg)
+        return deprecation_warning(msg, stacklevel=1)
 
     def report_error(self, text, *args, **kwargs):
         self.deprecation_warning('"yt_dlp.postprocessor.PostProcessor.report_error" is deprecated. '
diff --git a/yt_dlp/postprocessor/ffmpeg.py b/yt_dlp/postprocessor/ffmpeg.py
index a1f367ae4..76f9d29c5 100644
--- a/yt_dlp/postprocessor/ffmpeg.py
+++ b/yt_dlp/postprocessor/ffmpeg.py
@@ -15,6 +15,7 @@
     Popen,
     PostProcessingError,
     _get_exe_version_output,
+    deprecation_warning,
     detect_exe_version,
     determine_ext,
     dfxp2srt,
@@ -30,7 +31,6 @@
     traverse_obj,
     variadic,
     write_json_file,
-    write_string,
 )
 
 EXT_TO_OUT_FORMATS = {
@@ -187,8 +187,8 @@ def _get_version(self, kind):
         else:
             self.probe_basename = basename
         if basename == self._ffmpeg_to_avconv[kind]:
-            self.deprecation_warning(
-                f'Support for {self._ffmpeg_to_avconv[kind]} is deprecated and may be removed in a future version. Use {kind} instead')
+            self.deprecated_feature(f'Support for {self._ffmpeg_to_avconv[kind]} is deprecated and '
+                                    f'may be removed in a future version. Use {kind} instead')
         return version
 
     @functools.cached_property
@@ -1064,7 +1064,7 @@ def __init__(self, downloader=None, format=None):
 
     @classmethod
     def is_webp(cls, path):
-        write_string(f'DeprecationWarning: {cls.__module__}.{cls.__name__}.is_webp is deprecated')
+        deprecation_warning(f'{cls.__module__}.{cls.__name__}.is_webp is deprecated')
         return imghdr.what(path) == 'webp'
 
     def fixup_webp(self, info, idx=-1):
diff --git a/yt_dlp/update.py b/yt_dlp/update.py
index e82cdf451..026bc12aa 100644
--- a/yt_dlp/update.py
+++ b/yt_dlp/update.py
@@ -14,6 +14,7 @@
 from .utils import (
     Popen,
     cached_method,
+    deprecation_warning,
     shell_quote,
     system_identifier,
     traverse_obj,
@@ -302,11 +303,8 @@ def run_update(ydl):
 def update_self(to_screen, verbose, opener):
     import traceback
 
-    from .utils import write_string
-
-    write_string(
-        'DeprecationWarning: "yt_dlp.update.update_self" is deprecated and may be removed in a future version. '
-        'Use "yt_dlp.update.run_update(ydl)" instead\n')
+    deprecation_warning(f'"{__name__}.update_self" is deprecated and may be removed '
+                        f'in a future version. Use "{__name__}.run_update(ydl)" instead')
 
     printfn = to_screen
 
diff --git a/yt_dlp/utils.py b/yt_dlp/utils.py
index 957c7eaa7..00f2fbf42 100644
--- a/yt_dlp/utils.py
+++ b/yt_dlp/utils.py
@@ -828,8 +828,8 @@ def escapeHTML(text):
 
 
 def process_communicate_or_kill(p, *args, **kwargs):
-    write_string('DeprecationWarning: yt_dlp.utils.process_communicate_or_kill is deprecated '
-                 'and may be removed in a future version. Use yt_dlp.utils.Popen.communicate_or_kill instead')
+    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')
     return Popen.communicate_or_kill(p, *args, **kwargs)
 
 
@@ -840,12 +840,35 @@ class Popen(subprocess.Popen):
     else:
         _startupinfo = None
 
-    def __init__(self, *args, text=False, **kwargs):
+    @staticmethod
+    def _fix_pyinstaller_ld_path(env):
+        """Restore LD_LIBRARY_PATH when using PyInstaller
+            Ref: https://github.com/pyinstaller/pyinstaller/blob/develop/doc/runtime-information.rst#ld_library_path--libpath-considerations
+                 https://github.com/yt-dlp/yt-dlp/issues/4573
+        """
+        if not hasattr(sys, '_MEIPASS'):
+            return
+
+        def _fix(key):
+            orig = env.get(f'{key}_ORIG')
+            if orig is None:
+                env.pop(key, None)
+            else:
+                env[key] = orig
+
+        _fix('LD_LIBRARY_PATH')  # Linux
+        _fix('DYLD_LIBRARY_PATH')  # macOS
+
+    def __init__(self, *args, env=None, text=False, **kwargs):
+        if env is None:
+            env = os.environ.copy()
+        self._fix_pyinstaller_ld_path(env)
+
         if text is True:
             kwargs['universal_newlines'] = True  # For 3.6 compatibility
             kwargs.setdefault('encoding', 'utf-8')
             kwargs.setdefault('errors', 'replace')
-        super().__init__(*args, **kwargs, startupinfo=self._startupinfo)
+        super().__init__(*args, env=env, **kwargs, startupinfo=self._startupinfo)
 
     def communicate_or_kill(self, *args, **kwargs):
         try:
@@ -1934,7 +1957,7 @@ def __eq__(self, other):
 
 def platform_name():
     """ Returns the platform name as a str """
-    write_string('DeprecationWarning: yt_dlp.utils.platform_name is deprecated, use platform.platform instead')
+    deprecation_warning(f'"{__name__}.platform_name" is deprecated, use "platform.platform" instead')
     return platform.platform()
 
 
@@ -1980,6 +2003,23 @@ def write_string(s, out=None, encoding=None):
     out.flush()
 
 
+def deprecation_warning(msg, *, printer=None, stacklevel=0, **kwargs):
+    from . import _IN_CLI
+    if _IN_CLI:
+        if msg in deprecation_warning._cache:
+            return
+        deprecation_warning._cache.add(msg)
+        if printer:
+            return printer(f'{msg}{bug_reports_message()}', **kwargs)
+        return write_string(f'ERROR: {msg}{bug_reports_message()}\n', **kwargs)
+    else:
+        import warnings
+        warnings.warn(DeprecationWarning(msg), stacklevel=stacklevel + 3)
+
+
+deprecation_warning._cache = set()
+
+
 def bytes_to_intlist(bs):
     if not bs:
         return []
@@ -4862,8 +4902,8 @@ def decode_base_n(string, n=None, table=None):
 
 
 def decode_base(value, digits):
-    write_string('DeprecationWarning: yt_dlp.utils.decode_base is deprecated '
-                 'and may be removed in a future version. Use yt_dlp.decode_base_n instead')
+    deprecation_warning(f'{__name__}.decode_base is deprecated and may be removed '
+                        f'in a future version. Use {__name__}.decode_base_n instead')
     return decode_base_n(value, table=digits)
 
 
@@ -5332,8 +5372,8 @@ def _traverse_obj(obj, path, _current_depth=0):
 
 
 def traverse_dict(dictn, keys, casesense=True):
-    write_string('DeprecationWarning: yt_dlp.utils.traverse_dict is deprecated '
-                 'and may be removed in a future version. Use yt_dlp.utils.traverse_obj instead')
+    deprecation_warning(f'"{__name__}.traverse_dict" is deprecated and may be removed '
+                        f'in a future version. Use "{__name__}.traverse_obj" instead')
     return traverse_obj(dictn, keys, casesense=casesense, is_user_input=True, traverse_string=True)
 
 


Test patch:
diff --git a/test/test_jsinterp.py b/test/test_jsinterp.py
index 863e52458..4b6e22bac 100644
--- a/test/test_jsinterp.py
+++ b/test/test_jsinterp.py
@@ -129,6 +129,11 @@ def test_precedence(self):
         self.assertEqual(jsi.call_function('x'), [20, 20, 30, 40, 50])
 
     def test_builtins(self):
+        jsi = JSInterpreter('''
+        function x() { return NaN }
+        ''')
+        self.assertTrue(math.isnan(jsi.call_function('x')))
+
         jsi = JSInterpreter('''
         function x() { return new Date('Wednesday 31 December 1969 18:01:26 MDT') - 0; }
         ''')
@@ -347,6 +352,22 @@ def test_regex(self):
         ''')
         self.assertEqual(jsi.call_function('x').flags & re.I, re.I)
 
+    def test_char_code_at(self):
+        jsi = JSInterpreter('function x(i){return "test".charCodeAt(i)}')
+        self.assertEqual(jsi.call_function('x', 0), 116)
+        self.assertEqual(jsi.call_function('x', 1), 101)
+        self.assertEqual(jsi.call_function('x', 2), 115)
+        self.assertEqual(jsi.call_function('x', 3), 116)
+        self.assertEqual(jsi.call_function('x', 4), None)
+        self.assertEqual(jsi.call_function('x', 'not_a_number'), 116)
+
+    def test_bitwise_operators_overflow(self):
+        jsi = JSInterpreter('function x(){return -524999584 << 5}')
+        self.assertEqual(jsi.call_function('x'), 379882496)
+
+        jsi = JSInterpreter('function x(){return 1236566549 << 5}')
+        self.assertEqual(jsi.call_function('x'), 915423904)
+
 
 if __name__ == '__main__':
     unittest.main()