Repository: yt-dlp/yt-dlp
Pull request #10025 (2024-05-25 16:33:17)

[ie/LCI] Fix extractor
**IMPORTANT**: PRs without the template will be CLOSED

### LCI web site changed recently and LCI extractor could not work anymore

- So I extended the _VALID_URL regular expression to match the new additional level in URL
- I added code to parse the web page in order to get wat_id from nextjs data (without this, a wrong wat_id for an unrelated video is retrieved)
- I added a test

<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 all of the following options that apply:
- [x] I am the original author of this code and I am willing to release it under [Unlicense](http://unlicense.org/)
- [ ] 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))

</details>



Patch:
diff --git a/README.md b/README.md
index 0636d2f6e..1b4071132 100644
--- a/README.md
+++ b/README.md
@@ -2123,7 +2123,7 @@ # CHANGES FROM YOUTUBE-DL
 
 ### New features
 
-* Forked from [**yt-dlc@f9401f2**](https://github.com/blackjack4494/yt-dlc/commit/f9401f2a91987068139c5f757b12fc711d4c0cee) and merged with [**youtube-dl@be008e6**](https://github.com/ytdl-org/youtube-dl/commit/be008e657d79832642e2158557c899249c9e31cd) ([exceptions](https://github.com/yt-dlp/yt-dlp/issues/21))
+* Forked from [**yt-dlc@f9401f2**](https://github.com/blackjack4494/yt-dlc/commit/f9401f2a91987068139c5f757b12fc711d4c0cee) and merged with [**youtube-dl@a08f2b7**](https://github.com/ytdl-org/youtube-dl/commit/a08f2b7e4567cdc50c0614ee0a4ffdff49b8b6e6) ([exceptions](https://github.com/yt-dlp/yt-dlp/issues/21))
 
 * **[SponsorBlock Integration](#sponsorblock-options)**: You can mark/remove sponsor sections in YouTube videos by utilizing the [SponsorBlock](https://sponsor.ajay.app) API
 
@@ -2333,6 +2333,7 @@ #### No longer supported
     --write-annotations              No supported site has annotations now
     --no-write-annotations           Default
     --compat-options seperate-video-versions  No longer needed
+    --compat-options no-youtube-prefer-utc-upload-date  No longer supported
 
 #### Removed
 These options were deprecated since 2014 and have now been entirely removed
diff --git a/yt_dlp/extractor/_extractors.py b/yt_dlp/extractor/_extractors.py
index e287e04bc..e9cd38a65 100644
--- a/yt_dlp/extractor/_extractors.py
+++ b/yt_dlp/extractor/_extractors.py
@@ -308,6 +308,7 @@
 from .buzzfeed import BuzzFeedIE
 from .byutv import BYUtvIE
 from .c56 import C56IE
+from .caffeinetv import CaffeineTVIE
 from .callin import CallinIE
 from .caltrans import CaltransIE
 from .cam4 import CAM4IE
@@ -720,6 +721,7 @@
 from .gamestar import GameStarIE
 from .gaskrank import GaskrankIE
 from .gazeta import GazetaIE
+from .gbnews import GBNewsIE
 from .gdcvault import GDCVaultIE
 from .gedidigital import GediDigitalIE
 from .generic import GenericIE
@@ -2072,6 +2074,7 @@
 )
 from .tiktok import (
     DouyinIE,
+    TikTokCollectionIE,
     TikTokEffectIE,
     TikTokIE,
     TikTokLiveIE,
@@ -2501,7 +2504,15 @@
     YouNowLiveIE,
     YouNowMomentIE,
 )
-from .youporn import YouPornIE
+from .youporn import (
+    YouPornCategoryIE,
+    YouPornChannelIE,
+    YouPornCollectionIE,
+    YouPornIE,
+    YouPornStarIE,
+    YouPornTagIE,
+    YouPornVideosIE,
+)
 from .zaiko import (
     ZaikoETicketIE,
     ZaikoIE,
diff --git a/yt_dlp/extractor/caffeinetv.py b/yt_dlp/extractor/caffeinetv.py
new file mode 100644
index 000000000..aa107f858
--- /dev/null
+++ b/yt_dlp/extractor/caffeinetv.py
@@ -0,0 +1,74 @@
+from .common import InfoExtractor
+from ..utils import (
+    determine_ext,
+    int_or_none,
+    parse_iso8601,
+    traverse_obj,
+    urljoin,
+)
+
+
+class CaffeineTVIE(InfoExtractor):
+    _VALID_URL = r'https?://(?:www\.)?caffeine\.tv/[^/?#]+/video/(?P<id>[\da-f-]+)'
+    _TESTS = [{
+        'url': 'https://www.caffeine.tv/TsuSurf/video/cffc0a00-e73f-11ec-8080-80017d29f26e',
+        'info_dict': {
+            'id': 'cffc0a00-e73f-11ec-8080-80017d29f26e',
+            'ext': 'mp4',
+            'title': 'GOOOOD MORNINNNNN #highlights',
+            'timestamp': 1654702180,
+            'upload_date': '20220608',
+            'uploader': 'RahJON Wicc',
+            'uploader_id': 'TsuSurf',
+            'duration': 3145,
+            'age_limit': 17,
+            'thumbnail': 'https://www.caffeine.tv/broadcasts/776b6f84-9cd5-42e3-af1d-4a776eeed697/replay/lobby.jpg',
+            'comment_count': int,
+            'view_count': int,
+            'like_count': int,
+            'tags': ['highlights', 'battlerap'],
+        },
+        'params': {
+            'skip_download': 'm3u8',
+        },
+    }]
+
+    def _real_extract(self, url):
+        video_id = self._match_id(url)
+        json_data = self._download_json(
+            f'https://api.caffeine.tv/social/public/activity/{video_id}', video_id)
+        broadcast_info = traverse_obj(json_data, ('broadcast_info', {dict})) or {}
+
+        video_url = broadcast_info['video_url']
+        ext = determine_ext(video_url)
+        if ext == 'm3u8':
+            formats = self._extract_m3u8_formats(video_url, video_id, 'mp4')
+        else:
+            formats = [{'url': video_url}]
+
+        return {
+            'id': video_id,
+            'formats': formats,
+            **traverse_obj(json_data, {
+                'like_count': ('like_count', {int_or_none}),
+                'view_count': ('view_count', {int_or_none}),
+                'comment_count': ('comment_count', {int_or_none}),
+                'tags': ('tags', ..., {str}, {lambda x: x or None}),
+                'uploader': ('user', 'name', {str}),
+                'uploader_id': (((None, 'user'), 'username'), {str}, any),
+                'is_live': ('is_live', {bool}),
+            }),
+            **traverse_obj(broadcast_info, {
+                'title': ('broadcast_title', {str}),
+                'duration': ('content_duration', {int_or_none}),
+                'timestamp': ('broadcast_start_time', {parse_iso8601}),
+                'thumbnail': ('preview_image_path', {lambda x: urljoin(url, x)}),
+            }),
+            'age_limit': {
+                # assume Apple Store ratings: https://en.wikipedia.org/wiki/Mobile_software_content_rating_system
+                'FOUR_PLUS': 0,
+                'NINE_PLUS': 9,
+                'TWELVE_PLUS': 12,
+                'SEVENTEEN_PLUS': 17,
+            }.get(broadcast_info.get('content_rating'), 17),
+        }
diff --git a/yt_dlp/extractor/common.py b/yt_dlp/extractor/common.py
index a33cef354..38daad72e 100644
--- a/yt_dlp/extractor/common.py
+++ b/yt_dlp/extractor/common.py
@@ -3384,23 +3384,16 @@ def manifest_url(manifest):
         return formats
 
     def _find_jwplayer_data(self, webpage, video_id=None, transform_source=js_to_json):
-        mobj = re.search(
-            r'''(?s)jwplayer\s*\(\s*(?P<q>'|")(?!(?P=q)).+(?P=q)\s*\)(?!</script>).*?\.\s*setup\s*\(\s*(?P<options>(?:\([^)]*\)|[^)])+)\s*\)''',
-            webpage)
-        if mobj:
-            try:
-                jwplayer_data = self._parse_json(mobj.group('options'),
-                                                 video_id=video_id,
-                                                 transform_source=transform_source)
-            except ExtractorError:
-                pass
-            else:
-                if isinstance(jwplayer_data, dict):
-                    return jwplayer_data
+        return self._search_json(
+            r'''(?<!-)\bjwplayer\s*\(\s*(?P<q>'|")(?!(?P=q)).+(?P=q)\s*\)(?:(?!</script>).)*?\.\s*(?:setup\s*\(|(?P<load>load)\s*\(\s*\[)''',
+            webpage, 'JWPlayer data', video_id,
+            # must be a {...} or sequence, ending
+            contains_pattern=r'\{(?s:.*)}(?(load)(?:\s*,\s*\{(?s:.*)})*)', end_pattern=r'(?(load)\]|\))',
+            transform_source=transform_source, default=None)
 
-    def _extract_jwplayer_data(self, webpage, video_id, *args, **kwargs):
+    def _extract_jwplayer_data(self, webpage, video_id, *args, transform_source=js_to_json, **kwargs):
         jwplayer_data = self._find_jwplayer_data(
-            webpage, video_id, transform_source=js_to_json)
+            webpage, video_id, transform_source=transform_source)
         return self._parse_jwplayer_data(
             jwplayer_data, video_id, *args, **kwargs)
 
@@ -3432,22 +3425,14 @@ def _parse_jwplayer_data(self, jwplayer_data, video_id=None, require_title=True,
                 mpd_id=mpd_id, rtmp_params=rtmp_params, base_url=base_url)
 
             subtitles = {}
-            tracks = video_data.get('tracks')
-            if tracks and isinstance(tracks, list):
-                for track in tracks:
-                    if not isinstance(track, dict):
-                        continue
-                    track_kind = track.get('kind')
-                    if not track_kind or not isinstance(track_kind, str):
-                        continue
-                    if track_kind.lower() not in ('captions', 'subtitles'):
-                        continue
-                    track_url = urljoin(base_url, track.get('file'))
-                    if not track_url:
-                        continue
-                    subtitles.setdefault(track.get('label') or 'en', []).append({
-                        'url': self._proto_relative_url(track_url)
-                    })
+            for track in traverse_obj(video_data, (
+                    'tracks', lambda _, v: v['kind'].lower() in ('captions', 'subtitles'))):
+                track_url = urljoin(base_url, track.get('file'))
+                if not track_url:
+                    continue
+                subtitles.setdefault(track.get('label') or 'en', []).append({
+                    'url': self._proto_relative_url(track_url)
+                })
 
             entry = {
                 'id': this_video_id,
diff --git a/yt_dlp/extractor/gbnews.py b/yt_dlp/extractor/gbnews.py
new file mode 100644
index 000000000..bb1554eea
--- /dev/null
+++ b/yt_dlp/extractor/gbnews.py
@@ -0,0 +1,107 @@
+import functools
+
+from .common import InfoExtractor
+from ..utils import (
+    ExtractorError,
+    extract_attributes,
+    get_elements_html_by_class,
+    url_or_none,
+)
+from ..utils.traversal import traverse_obj
+
+
+class GBNewsIE(InfoExtractor):
+    IE_DESC = 'GB News clips, features and live streams'
+    _VALID_URL = r'https?://(?:www\.)?gbnews\.(?:uk|com)/(?:\w+/)?(?P<id>[^#?]+)'
+
+    _PLATFORM = 'safari'
+    _SSMP_URL = 'https://mm-v2.simplestream.com/ssmp/api.php'
+    _TESTS = [{
+        'url': 'https://www.gbnews.com/news/bbc-claudine-gay-harvard-university-antisemitism-row',
+        'info_dict': {
+            'id': '52264136',
+            'ext': 'mp4',
+            'thumbnail': r're:https?://www\.gbnews\.\w+/.+\.(?:jpe?g|png|webp)',
+            'display_id': 'bbc-claudine-gay-harvard-university-antisemitism-row',
+            'description': 'The post was criticised by former employers of the broadcaster',
+            'title': 'BBC deletes post after furious backlash over headline downplaying antisemitism',
+        },
+    }, {
+        'url': 'https://www.gbnews.com/royal/prince-harry-in-love-with-kate-meghan-markle-jealous-royal',
+        'info_dict': {
+            'id': '52328390',
+            'ext': 'mp4',
+            'thumbnail': r're:https?://www\.gbnews\.\w+/.+\.(?:jpe?g|png|webp)',
+            'display_id': 'prince-harry-in-love-with-kate-meghan-markle-jealous-royal',
+            'description': 'Ingrid Seward has published 17 books documenting the highs and lows of the Royal Family',
+            'title': 'Royal author claims Prince Harry was \'in love\' with Kate - Meghan was \'jealous\'',
+        }
+    }, {
+        'url': 'https://www.gbnews.uk/watchlive',
+        'info_dict': {
+            'id': '1069',
+            'ext': 'mp4',
+            'thumbnail': r're:https?://www\.gbnews\.\w+/.+\.(?:jpe?g|png|webp)',
+            'display_id': 'watchlive',
+            'live_status': 'is_live',
+            'title': r're:^GB News Live',
+        },
+        'params': {'skip_download': 'm3u8'},
+    }]
+
+    @functools.lru_cache
+    def _get_ss_endpoint(self, data_id, data_env):
+        if not data_id:
+            data_id = 'GB003'
+        if not data_env:
+            data_env = 'production'
+
+        json_data = self._download_json(
+            self._SSMP_URL, None, 'Downloading Simplestream JSON metadata', query={
+                'id': data_id,
+                'env': data_env,
+            })
+        meta_url = traverse_obj(json_data, ('response', 'api_hostname', {url_or_none}))
+        if not meta_url:
+            raise ExtractorError('No API host found')
+
+        return meta_url
+
+    def _real_extract(self, url):
+        display_id = self._match_id(url).rpartition('/')[2]
+        webpage = self._download_webpage(url, display_id)
+
+        video_data = None
+        elements = get_elements_html_by_class('simplestream', webpage)
+        for html_tag in elements:
+            attributes = extract_attributes(html_tag)
+            if 'sidebar' not in (attributes.get('class') or ''):
+                video_data = attributes
+        if not video_data:
+            raise ExtractorError('Could not find video element', expected=True)
+
+        endpoint_url = self._get_ss_endpoint(video_data.get('data-id'), video_data.get('data-env'))
+
+        uvid = video_data['data-uvid']
+        video_type = video_data.get('data-type')
+        if not video_type or video_type == 'vod':
+            video_type = 'show'
+        stream_data = self._download_json(
+            f'{endpoint_url}/api/{video_type}/stream/{uvid}',
+            uvid, 'Downloading stream JSON', query={
+                'key': video_data.get('data-key'),
+                'platform': self._PLATFORM,
+            })
+        if traverse_obj(stream_data, 'drm'):
+            self.report_drm(uvid)
+
+        return {
+            'id': uvid,
+            'display_id': display_id,
+            'title': self._og_search_title(webpage, default=None),
+            'description': self._og_search_description(webpage, default=None),
+            'formats': self._extract_m3u8_formats(traverse_obj(stream_data, (
+                'response', 'stream', {url_or_none})), uvid, 'mp4'),
+            'thumbnail': self._og_search_thumbnail(webpage, default=None),
+            'is_live': video_type == 'live',
+        }
diff --git a/yt_dlp/extractor/lci.py b/yt_dlp/extractor/lci.py
index e7d2f8a24..708cb548d 100644
--- a/yt_dlp/extractor/lci.py
+++ b/yt_dlp/extractor/lci.py
@@ -1,9 +1,25 @@
 from .common import InfoExtractor
+from .wat import WatIE
+from ..utils import ExtractorError, int_or_none
+from ..utils.traversal import traverse_obj
 
 
 class LCIIE(InfoExtractor):
-    _VALID_URL = r'https?://(?:www\.)?(?:lci|tf1info)\.fr/[^/]+/[\w-]+-(?P<id>\d+)\.html'
+    _VALID_URL = r'https?://(?:www\.)?(?:lci|tf1info)\.fr/(?:[^/?#]+/)+[\w-]+-(?P<id>\d+)\.html'
     _TESTS = [{
+        'url': 'https://www.tf1info.fr/replay-lci/videos/video-24h-pujadas-du-vendredi-24-mai-6708-2300831.html',
+        'info_dict': {
+            'id': '14113788',
+            'ext': 'mp4',
+            'title': '24H Pujadas du vendredi 24 mai 2024',
+            'thumbnail': 'https://photos.tf1.fr/1280/720/24h-pujadas-du-24-mai-2024-55bf2d-0@1x.jpg',
+            'upload_date': '20240524',
+            'duration': 6158,
+        },
+        'params': {
+            'skip_download': True,
+        },
+    }, {
         'url': 'https://www.tf1info.fr/politique/election-presidentielle-2022-second-tour-j-2-marine-le-pen-et-emmanuel-macron-en-interview-de-lci-vendredi-soir-2217486.html',
         'info_dict': {
             'id': '13875948',
@@ -24,5 +40,10 @@ class LCIIE(InfoExtractor):
     def _real_extract(self, url):
         video_id = self._match_id(url)
         webpage = self._download_webpage(url, video_id)
-        wat_id = self._search_regex(r'watId["\']?\s*:\s*["\']?(\d+)', webpage, 'wat id')
-        return self.url_result('wat:' + wat_id, 'Wat', wat_id)
+        next_data = self._search_nextjs_data(webpage, video_id)
+        wat_id = traverse_obj(next_data, (
+            'props', 'pageProps', 'page', 'tms', 'videos', {dict.keys}, ..., {int_or_none}, any))
+        if wat_id is None:
+            raise ExtractorError('Could not find wat_id')
+
+        return self.url_result(f'wat:{wat_id}', WatIE, str(wat_id))
diff --git a/yt_dlp/extractor/tiktok.py b/yt_dlp/extractor/tiktok.py
index 7772dd1f2..ab8efc19e 100644
--- a/yt_dlp/extractor/tiktok.py
+++ b/yt_dlp/extractor/tiktok.py
@@ -3,6 +3,7 @@
 import json
 import random
 import re
+import string
 import time
 import uuid
 
@@ -11,7 +12,6 @@
 from ..networking import HEADRequest
 from ..utils import (
     ExtractorError,
-    LazyList,
     UnsupportedError,
     UserNotLive,
     determine_ext,
@@ -236,7 +236,7 @@ def _extract_web_data_and_status(self, url, video_id, fatal=True):
 
         return video_data, status
 
-    def _get_subtitles(self, aweme_detail, aweme_id, user_url):
+    def _get_subtitles(self, aweme_detail, aweme_id, user_name):
         # TODO: Extract text positioning info
         subtitles = {}
         # aweme/detail endpoint subs
@@ -267,9 +267,9 @@ def _get_subtitles(self, aweme_detail, aweme_id, user_url):
                 })
         # webpage subs
         if not subtitles:
-            if user_url:  # only _parse_aweme_video_app needs to extract the webpage here
+            if user_name:  # only _parse_aweme_video_app needs to extract the webpage here
                 aweme_detail, _ = self._extract_web_data_and_status(
-                    f'{user_url}/video/{aweme_id}', aweme_id, fatal=False)
+                    self._create_url(user_name, aweme_id), aweme_id, fatal=False)
             for caption in traverse_obj(aweme_detail, ('video', 'subtitleInfos', lambda _, v: v['Url'])):
                 subtitles.setdefault(caption.get('LanguageCodeName') or 'en', []).append({
                     'ext': remove_start(caption.get('Format'), 'web'),
@@ -394,11 +394,7 @@ def extract_addr(addr, add_meta={}):
                 })
 
         stats_info = aweme_detail.get('statistics') or {}
-        author_info = aweme_detail.get('author') or {}
         music_info = aweme_detail.get('music') or {}
-        user_url = self._UPLOADER_URL_FORMAT % (traverse_obj(author_info,
-                                                             'sec_uid', 'id', 'uid', 'unique_id',
-                                                             expected_type=str_or_none, get_all=False))
         labels = traverse_obj(aweme_detail, ('hybrid_label', ..., 'text'), expected_type=str)
 
         contained_music_track = traverse_obj(
@@ -412,6 +408,13 @@ def extract_addr(addr, add_meta={}):
         else:
             music_track, music_author = music_info.get('title'), traverse_obj(music_info, ('author', {str}))
 
+        author_info = traverse_obj(aweme_detail, ('author', {
+            'uploader': ('unique_id', {str}),
+            'uploader_id': ('uid', {str_or_none}),
+            'channel': ('nickname', {str}),
+            'channel_id': ('sec_uid', {str}),
+        }))
+
         return {
             'id': aweme_id,
             **traverse_obj(aweme_detail, {
@@ -425,21 +428,20 @@ def extract_addr(addr, add_meta={}):
                 'repost_count': 'share_count',
                 'comment_count': 'comment_count',
             }, expected_type=int_or_none),
-            **traverse_obj(author_info, {
-                'uploader': ('unique_id', {str}),
-                'uploader_id': ('uid', {str_or_none}),
-                'creators': ('nickname', {str}, {lambda x: [x] if x else None}),  # for compat
-                'channel': ('nickname', {str}),
-                'channel_id': ('sec_uid', {str}),
-            }),
-            'uploader_url': user_url,
+            **author_info,
+            'channel_url': format_field(author_info, 'channel_id', self._UPLOADER_URL_FORMAT, default=None),
+            'uploader_url': format_field(
+                author_info, ['uploader', 'uploader_id'], self._UPLOADER_URL_FORMAT, default=None),
             'track': music_track,
             'album': str_or_none(music_info.get('album')) or None,
             'artists': re.split(r'(?:, | & )', music_author) if music_author else None,
             'formats': formats,
-            'subtitles': self.extract_subtitles(aweme_detail, aweme_id, user_url),
+            'subtitles': self.extract_subtitles(
+                aweme_detail, aweme_id, traverse_obj(author_info, 'uploader', 'uploader_id', 'channel_id')),
             'thumbnails': thumbnails,
-            'duration': int_or_none(traverse_obj(video_info, 'duration', ('download_addr', 'duration')), scale=1000),
+            'duration': (traverse_obj(video_info, (
+                (None, 'download_addr'), 'duration', {functools.partial(int_or_none, scale=1000)}, any))
+                or traverse_obj(music_info, ('duration', {int_or_none}))),
             'availability': self._availability(
                 is_private='Private' in labels,
                 needs_subscription='Friends only' in labels,
@@ -447,23 +449,17 @@ def extract_addr(addr, add_meta={}):
             '_format_sort_fields': ('quality', 'codec', 'size', 'br'),
         }
 
-    def _parse_aweme_video_web(self, aweme_detail, webpage_url, video_id):
-        video_info = aweme_detail['video']
-        author_info = traverse_obj(aweme_detail, 'authorInfo', 'author', expected_type=dict, default={})
-        music_info = aweme_detail.get('music') or {}
-        stats_info = aweme_detail.get('stats') or {}
-        channel_id = traverse_obj(author_info or aweme_detail, (('authorSecId', 'secUid'), {str}), get_all=False)
-        user_url = self._UPLOADER_URL_FORMAT % channel_id if channel_id else None
-
-        formats = []
-        width = int_or_none(video_info.get('width'))
-        height = int_or_none(video_info.get('height'))
-        ratio = try_call(lambda: width / height) or 0.5625
+    def _extract_web_formats(self, aweme_detail):
         COMMON_FORMAT_INFO = {
             'ext': 'mp4',
             'vcodec': 'h264',
             'acodec': 'aac',
         }
+        video_info = traverse_obj(aweme_detail, ('video', {dict})) or {}
+        play_width = int_or_none(video_info.get('width'))
+        play_height = int_or_none(video_info.get('height'))
+        ratio = try_call(lambda: play_width / play_height) or 0.5625
+        formats = []
 
         for bitrate_info in traverse_obj(video_info, ('bitrateInfo', lambda _, v: v['PlayAddr']['UrlList'])):
             format_info, res = self._parse_url_key(
@@ -488,7 +484,7 @@ def _parse_aweme_video_web(self, aweme_detail, webpage_url, video_id):
                 else:  # landscape: res/dimension is height
                     x = int(dimension * ratio)
                     format_info.update({
-                        'width': x - (x % 2),
+                        'width': x + (x % 2),
                         'height': dimension,
                     })
 
@@ -500,15 +496,15 @@ def _parse_aweme_video_web(self, aweme_detail, webpage_url, video_id):
                 })
 
         # We don't have res string for play formats, but need quality for sorting & de-duplication
-        play_quality = traverse_obj(formats, (lambda _, v: v['width'] == width, 'quality', any))
+        play_quality = traverse_obj(formats, (lambda _, v: v['width'] == play_width, 'quality', any))
 
         for play_url in traverse_obj(video_info, ('playAddr', ((..., 'src'), None), {url_or_none})):
             formats.append({
                 **COMMON_FORMAT_INFO,
                 'format_id': 'play',
                 'url': self._proto_relative_url(play_url),
-                'width': width,
-                'height': height,
+                'width': play_width,
+                'height': play_height,
                 'quality': play_quality,
             })
 
@@ -528,8 +524,8 @@ def _parse_aweme_video_web(self, aweme_detail, webpage_url, video_id):
             })
 
         # Is it a slideshow with only audio for download?
-        if not formats and traverse_obj(music_info, ('playUrl', {url_or_none})):
-            audio_url = music_info['playUrl']
+        if not formats and traverse_obj(aweme_detail, ('music', 'playUrl', {url_or_none})):
+            audio_url = aweme_detail['music']['playUrl']
             ext = traverse_obj(parse_qs(audio_url), (
                 'mime_type', -1, {lambda x: x.replace('_', '/')}, {mimetype2ext})) or 'm4a'
             formats.append({
@@ -540,23 +536,31 @@ def _parse_aweme_video_web(self, aweme_detail, webpage_url, video_id):
                 'vcodec': 'none',
             })
 
-        thumbnails = []
-        for thumb_url in traverse_obj(aweme_detail, (
-                (None, 'video'), ('thumbnail', 'cover', 'dynamicCover', 'originCover'), {url_or_none})):
-            thumbnails.append({
-                'url': self._proto_relative_url(thumb_url),
-                'width': width,
-                'height': height,
-            })
+        return formats
+
+    def _parse_aweme_video_web(self, aweme_detail, webpage_url, video_id, extract_flat=False):
+        author_info = traverse_obj(aweme_detail, (('authorInfo', 'author', None), {
+            'channel': ('nickname', {str}),
+            'channel_id': (('authorSecId', 'secUid'), {str}),
+            'uploader': (('uniqueId', 'author'), {str}),
+            'uploader_id': (('authorId', 'uid', 'id'), {str_or_none}),
+        }), get_all=False)
 
         return {
             'id': video_id,
-            **traverse_obj(music_info, {
+            'formats': None if extract_flat else self._extract_web_formats(aweme_detail),
+            'subtitles': None if extract_flat else self.extract_subtitles(aweme_detail, video_id, None),
+            'http_headers': {'Referer': webpage_url},
+            **author_info,
+            'channel_url': format_field(author_info, 'channel_id', self._UPLOADER_URL_FORMAT, default=None),
+            'uploader_url': format_field(
+                author_info, ['uploader', 'uploader_id'], self._UPLOADER_URL_FORMAT, default=None),
+            **traverse_obj(aweme_detail, ('music', {
                 'track': ('title', {str}),
                 'album': ('album', {str}, {lambda x: x or None}),
-                'artists': ('authorName', {str}, {lambda x: [x] if x else None}),
+                'artists': ('authorName', {str}, {lambda x: re.split(r'(?:, | & )', x) if x else None}),
                 'duration': ('duration', {int_or_none}),
-            }),
+            })),
             **traverse_obj(aweme_detail, {
                 'title': ('desc', {str}),
                 'description': ('desc', {str}),
@@ -564,26 +568,17 @@ def _parse_aweme_video_web(self, aweme_detail, webpage_url, video_id):
                 'duration': ('video', 'duration', {int_or_none}, {lambda x: x or None}),
                 'timestamp': ('createTime', {int_or_none}),
             }),
-            **traverse_obj(author_info or aweme_detail, {
-                'creators': ('nickname', {str}, {lambda x: [x] if x else None}),  # for compat
-                'channel': ('nickname', {str}),
-                'uploader': (('uniqueId', 'author'), {str}),
-                'uploader_id': (('authorId', 'uid', 'id'), {str_or_none}),
-            }, get_all=False),
-            **traverse_obj(stats_info, {
+            **traverse_obj(aweme_detail, ('stats', {
                 'view_count': 'playCount',
                 'like_count': 'diggCount',
                 'repost_count': 'shareCount',
                 'comment_count': 'commentCount',
-            }, expected_type=int_or_none),
-            'channel_id': channel_id,
-            'uploader_url': user_url,
-            'formats': formats,
-            'subtitles': self.extract_subtitles(aweme_detail, video_id, None),
-            'thumbnails': thumbnails,
-            'http_headers': {
-                'Referer': webpage_url,
-            }
+            }), expected_type=int_or_none),
+            'thumbnails': traverse_obj(aweme_detail, (
+                (None, 'video'), ('thumbnail', 'cover', 'dynamicCover', 'originCover'), {
+                    'url': ({url_or_none}, {self._proto_relative_url}),
+                },
+            )),
         }
 
 
@@ -620,21 +615,21 @@ class TikTokIE(TikTokBaseIE):
         'skip': '404 Not Found',
     }, {
         'url': 'https://www.tiktok.com/@patroxofficial/video/6742501081818877190?langCountry=en',
-        'md5': '6f3cf8cdd9b28cb8363fe0a9a160695b',
+        'md5': 'f21112672ee4ce05ca390fb6522e1b6f',
         'info_dict': {
             'id': '6742501081818877190',
             'ext': 'mp4',
             'title': 'md5:5e2a23877420bb85ce6521dbee39ba94',
             'description': 'md5:5e2a23877420bb85ce6521dbee39ba94',
             'duration': 27,
-            'height': 960,
-            'width': 540,
+            'height': 1024,
+            'width': 576,
             'uploader': 'patrox',
             'uploader_id': '18702747',
-            'uploader_url': 'https://www.tiktok.com/@MS4wLjABAAAAiFnldaILebi5heDoVU6bn4jBWWycX6-9U3xuNPqZ8Ws',
+            'uploader_url': 'https://www.tiktok.com/@patrox',
+            'channel_url': 'https://www.tiktok.com/@MS4wLjABAAAAiFnldaILebi5heDoVU6bn4jBWWycX6-9U3xuNPqZ8Ws',
             'channel_id': 'MS4wLjABAAAAiFnldaILebi5heDoVU6bn4jBWWycX6-9U3xuNPqZ8Ws',
             'channel': 'patroX',
-            'creators': ['patroX'],
             'thumbnail': r're:^https?://[\w\/\.\-]+(~[\w\-]+\.image)?',
             'upload_date': '20190930',
             'timestamp': 1569860870,
@@ -646,7 +641,7 @@ class TikTokIE(TikTokBaseIE):
             'track': 'Big Fun',
         },
     }, {
-        # Banned audio, only available on the app
+        # Banned audio, was available on the app, now works with web too
         'url': 'https://www.tiktok.com/@barudakhb_/video/6984138651336838402',
         'info_dict': {
             'id': '6984138651336838402',
@@ -655,9 +650,9 @@ class TikTokIE(TikTokBaseIE):
             'description': 'Balas @yolaaftwsr hayu yu ? #SquadRandom_ 🔥',
             'uploader': 'barudakhb_',
             'channel': 'md5:29f238c49bc0c176cb3cef1a9cea9fa6',
-            'creators': ['md5:29f238c49bc0c176cb3cef1a9cea9fa6'],
             'uploader_id': '6974687867511718913',
-            'uploader_url': 'https://www.tiktok.com/@MS4wLjABAAAAbhBwQC-R1iKoix6jDFsF-vBdfx2ABoDjaZrM9fX6arU3w71q3cOWgWuTXn1soZ7d',
+            'uploader_url': 'https://www.tiktok.com/@barudakhb_',
+            'channel_url': 'https://www.tiktok.com/@MS4wLjABAAAAbhBwQC-R1iKoix6jDFsF-vBdfx2ABoDjaZrM9fX6arU3w71q3cOWgWuTXn1soZ7d',
             'channel_id': 'MS4wLjABAAAAbhBwQC-R1iKoix6jDFsF-vBdfx2ABoDjaZrM9fX6arU3w71q3cOWgWuTXn1soZ7d',
             'track': 'Boka Dance',
             'artists': ['md5:29f238c49bc0c176cb3cef1a9cea9fa6'],
@@ -680,7 +675,6 @@ class TikTokIE(TikTokBaseIE):
             'description': 'Slap and Run!',
             'uploader': 'user440922249',
             'channel': 'Slap And Run',
-            'creators': ['Slap And Run'],
             'uploader_id': '7036055384943690754',
             'uploader_url': 'https://www.tiktok.com/@MS4wLjABAAAATh8Vewkn0LYM7Fo03iec3qKdeCUOcBIouRk1mkiag6h3o_pQu_dUXvZ2EZlGST7_',
             'channel_id': 'MS4wLjABAAAATh8Vewkn0LYM7Fo03iec3qKdeCUOcBIouRk1mkiag6h3o_pQu_dUXvZ2EZlGST7_',
@@ -694,7 +688,7 @@ class TikTokIE(TikTokBaseIE):
             'repost_count': int,
             'comment_count': int,
         },
-        'params': {'skip_download': True},  # XXX: unable to download video data: HTTP Error 403: Forbidden
+        'skip': 'This video is unavailable',
     }, {
         # Video without title and description
         'url': 'https://www.tiktok.com/@pokemonlife22/video/7059698374567611694',
@@ -705,9 +699,9 @@ class TikTokIE(TikTokBaseIE):
             'description': '',
             'uploader': 'pokemonlife22',
             'channel': 'Pokemon',
-            'creators': ['Pokemon'],
             'uploader_id': '6820838815978423302',
-            'uploader_url': 'https://www.tiktok.com/@MS4wLjABAAAA0tF1nBwQVVMyrGu3CqttkNgM68Do1OXUFuCY0CRQk8fEtSVDj89HqoqvbSTmUP2W',
+            'uploader_url': 'https://www.tiktok.com/@pokemonlife22',
+            'channel_url': 'https://www.tiktok.com/@MS4wLjABAAAA0tF1nBwQVVMyrGu3CqttkNgM68Do1OXUFuCY0CRQk8fEtSVDj89HqoqvbSTmUP2W',
             'channel_id': 'MS4wLjABAAAA0tF1nBwQVVMyrGu3CqttkNgM68Do1OXUFuCY0CRQk8fEtSVDj89HqoqvbSTmUP2W',
             'track': 'original sound',
             'timestamp': 1643714123,
@@ -752,13 +746,14 @@ class TikTokIE(TikTokBaseIE):
             'title': 'TikTok video #7139980461132074283',
             'description': '',
             'channel': 'Antaura',
-            'creators': ['Antaura'],
             'uploader': '_le_cannibale_',
             'uploader_id': '6604511138619654149',
-            'uploader_url': 'https://www.tiktok.com/@MS4wLjABAAAAoShJqaw_5gvy48y3azFeFcT4jeyKWbB0VVYasOCt2tTLwjNFIaDcHAM4D-QGXFOP',
+            'uploader_url': 'https://www.tiktok.com/@_le_cannibale_',
+            'channel_url': 'https://www.tiktok.com/@MS4wLjABAAAAoShJqaw_5gvy48y3azFeFcT4jeyKWbB0VVYasOCt2tTLwjNFIaDcHAM4D-QGXFOP',
             'channel_id': 'MS4wLjABAAAAoShJqaw_5gvy48y3azFeFcT4jeyKWbB0VVYasOCt2tTLwjNFIaDcHAM4D-QGXFOP',
             'artists': ['nathan !'],
             'track': 'grahamscott canon',
+            'duration': 10,
             'upload_date': '20220905',
             'timestamp': 1662406249,
             'view_count': int,
@@ -769,18 +764,18 @@ class TikTokIE(TikTokBaseIE):
         },
     }, {
         # only available via web
-        'url': 'https://www.tiktok.com/@moxypatch/video/7206382937372134662',  # FIXME
-        'md5': '6aba7fad816e8709ff2c149679ace165',
+        'url': 'https://www.tiktok.com/@moxypatch/video/7206382937372134662',
+        'md5': '4cdefa501ac8ac20bf04986e10916fea',
         'info_dict': {
             'id': '7206382937372134662',
             'ext': 'mp4',
             'title': 'md5:1d95c0b96560ca0e8a231af4172b2c0a',
             'description': 'md5:1d95c0b96560ca0e8a231af4172b2c0a',
             'channel': 'MoxyPatch',
-            'creators': ['MoxyPatch'],
             'uploader': 'moxypatch',
             'uploader_id': '7039142049363379205',
-            'uploader_url': 'https://www.tiktok.com/@MS4wLjABAAAAFhqKnngMHJSsifL0w1vFOP5kn3Ndo1ODp0XuIBkNMBCkALTvwILdpu12g3pTtL4V',
+            'uploader_url': 'https://www.tiktok.com/@moxypatch',
+            'channel_url': 'https://www.tiktok.com/@MS4wLjABAAAAFhqKnngMHJSsifL0w1vFOP5kn3Ndo1ODp0XuIBkNMBCkALTvwILdpu12g3pTtL4V',
             'channel_id': 'MS4wLjABAAAAFhqKnngMHJSsifL0w1vFOP5kn3Ndo1ODp0XuIBkNMBCkALTvwILdpu12g3pTtL4V',
             'artists': ['your worst nightmare'],
             'track': 'original sound',
@@ -809,7 +804,6 @@ class TikTokIE(TikTokBaseIE):
             'uploader_url': 'https://www.tiktok.com/@MS4wLjABAAAA-0bQT0CqebTRr6I4IkYvMDMKSRSJHLNPBo5HrSklJwyA2psXLSZG5FP-LMNpHnJd',
             'channel_id': 'MS4wLjABAAAA-0bQT0CqebTRr6I4IkYvMDMKSRSJHLNPBo5HrSklJwyA2psXLSZG5FP-LMNpHnJd',
             'channel': 'tate mcrae',
-            'creators': ['tate mcrae'],
             'artists': ['tate mcrae'],
             'track': 'original sound',
             'upload_date': '20220609',
@@ -821,7 +815,7 @@ class TikTokIE(TikTokBaseIE):
             'comment_count': int,
             'thumbnail': r're:^https://.+\.webp',
         },
-        'skip': 'Unavailable via feed API, no formats available via web',
+        'skip': 'Unavailable via feed API, only audio available via web',
     }, {
         # Slideshow, audio-only m4a format
         'url': 'https://www.tiktok.com/@hara_yoimiya/video/7253412088251534594',
@@ -833,13 +827,14 @@ class TikTokIE(TikTokBaseIE):
             'description': 'я ред флаг простите #переписка #щитпост #тревожныйтиппривязанности #рекомендации ',
             'uploader': 'hara_yoimiya',
             'uploader_id': '6582536342634676230',
-            'uploader_url': 'https://www.tiktok.com/@MS4wLjABAAAAIAlDxriiPWLE-p8p1R_0Bx8qWKfi-7zwmGhzU8Mv25W8sNxjfIKrol31qTczzuLB',
+            'uploader_url': 'https://www.tiktok.com/@hara_yoimiya',
+            'channel_url': 'https://www.tiktok.com/@MS4wLjABAAAAIAlDxriiPWLE-p8p1R_0Bx8qWKfi-7zwmGhzU8Mv25W8sNxjfIKrol31qTczzuLB',
             'channel_id': 'MS4wLjABAAAAIAlDxriiPWLE-p8p1R_0Bx8qWKfi-7zwmGhzU8Mv25W8sNxjfIKrol31qTczzuLB',
-            'channel': 'лампочка',
-            'creators': ['лампочка'],
+            'channel': 'лампочка(!)',
             'artists': ['Øneheart'],
             'album': 'watching the stars',
             'track': 'watching the stars',
+            'duration': 60,
             'upload_date': '20230708',
             'timestamp': 1688816612,
             'view_count': int,
@@ -876,102 +871,141 @@ def _real_extract(self, url):
 
 class TikTokUserIE(TikTokBaseIE):
     IE_NAME = 'tiktok:user'
-    _VALID_URL = r'https?://(?:www\.)?tiktok\.com/@(?P<id>[\w\.-]+)/?(?:$|[#?])'
-    _WORKING = False
+    _VALID_URL = r'(?:tiktokuser:|https?://(?:www\.)?tiktok\.com/@)(?P<id>[\w.-]+)/?(?:$|[#?])'
     _TESTS = [{
         'url': 'https://tiktok.com/@corgibobaa?lang=en',
         'playlist_mincount': 45,
         'info_dict': {
-            'id': '6935371178089399301',
+            'id': 'MS4wLjABAAAAepiJKgwWhulvCpSuUVsp7sgVVsFJbbNaLeQ6OQ0oAJERGDUIXhb2yxxHZedsItgT',
             'title': 'corgibobaa',
-            'thumbnail': r're:https://.+_1080x1080\.webp'
         },
-        'expected_warnings': ['Retrying']
     }, {
         'url': 'https://www.tiktok.com/@6820838815978423302',
         'playlist_mincount': 5,
         'info_dict': {
-            'id': '6820838815978423302',
+            'id': 'MS4wLjABAAAA0tF1nBwQVVMyrGu3CqttkNgM68Do1OXUFuCY0CRQk8fEtSVDj89HqoqvbSTmUP2W',
             'title': '6820838815978423302',
-            'thumbnail': r're:https://.+_1080x1080\.webp'
         },
-        'expected_warnings': ['Retrying']
     }, {
         'url': 'https://www.tiktok.com/@meme',
         'playlist_mincount': 593,
         'info_dict': {
-            'id': '79005827461758976',
+            'id': 'MS4wLjABAAAAiKfaDWeCsT3IHwY77zqWGtVRIy9v4ws1HbVi7auP1Vx7dJysU_hc5yRiGywojRD6',
             'title': 'meme',
-            'thumbnail': r're:https://.+_1080x1080\.webp'
         },
-        'expected_warnings': ['Retrying']
+    }, {
+        'url': 'tiktokuser:MS4wLjABAAAAM3R2BtjzVT-uAtstkl2iugMzC6AtnpkojJbjiOdDDrdsTiTR75-8lyWJCY5VvDrZ',
+        'playlist_mincount': 31,
+        'info_dict': {
+            'id': 'MS4wLjABAAAAM3R2BtjzVT-uAtstkl2iugMzC6AtnpkojJbjiOdDDrdsTiTR75-8lyWJCY5VvDrZ',
+        },
     }]
+    _USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:115.0) Gecko/20100101 Firefox/115.0'
+    _API_BASE_URL = 'https://www.tiktok.com/api/creator/item_list/'
 
-    r'''  # TODO: Fix by adding _signature to api_url
-    def _entries(self, webpage, user_id, username):
-        secuid = self._search_regex(r'\"secUid\":\"(?P<secUid>[^\"]+)', webpage, username)
-        verifyfp_cookie = self._get_cookies('https://www.tiktok.com').get('s_v_web_id')
-        if not verifyfp_cookie:
-            raise ExtractorError('Improper cookies (missing s_v_web_id).', expected=True)
-        api_url = f'https://m.tiktok.com/api/post/item_list/?aid=1988&cookie_enabled=true&count=30&verifyFp={verifyfp_cookie.value}&secUid={secuid}&cursor='
-        cursor = '0'
-        for page in itertools.count():
-            data_json = self._download_json(api_url + cursor, username, note='Downloading Page %d' % page)
-            for video in data_json.get('itemList', []):
-                video_id = video['id']
-                video_url = f'https://www.tiktok.com/@{user_id}/video/{video_id}'
-                yield self._url_result(video_url, 'TikTok', video_id, str_or_none(video.get('desc')))
-            if not data_json.get('hasMore'):
-                break
-            cursor = data_json['cursor']
-    '''
-
-    def _video_entries_api(self, webpage, user_id, username):
-        query = {
-            'user_id': user_id,
-            'count': 21,
-            'max_cursor': 0,
-            'min_cursor': 0,
-            'retry_type': 'no_retry',
-            'device_id': self._DEVICE_ID,  # Some endpoints don't like randomized device_id, so it isn't directly set in _call_api.
+    def _build_web_query(self, sec_uid, cursor):
+        return {
+            'aid': '1988',
+            'app_language': 'en',
+            'app_name': 'tiktok_web',
+            'browser_language': 'en-US',
+            'browser_name': 'Mozilla',
+            'browser_online': 'true',
+            'browser_platform': 'Win32',
+            'browser_version': '5.0 (Windows)',
+            'channel': 'tiktok_web',
+            'cookie_enabled': 'true',
+            'count': '15',
+            'cursor': cursor,
+            'device_id': self._DEVICE_ID,
+            'device_platform': 'web_pc',
+            'focus_state': 'true',
+            'from_page': 'user',
+            'history_len': '2',
+            'is_fullscreen': 'false',
+            'is_page_visible': 'true',
+            'language': 'en',
+            'os': 'windows',
+            'priority_region': '',
+            'referer': '',
+            'region': 'US',
+            'screen_height': '1080',
+            'screen_width': '1920',
+            'secUid': sec_uid,
+            'type': '1',  # pagination type: 0 == oldest-to-newest, 1 == newest-to-oldest
+            'tz_name': 'UTC',
+            'verifyFp': f'verify_{"".join(random.choices(string.hexdigits, k=7))}',
+            'webcast_language': 'en',
         }
 
+    def _entries(self, sec_uid, user_name):
+        display_id = user_name or sec_uid
+
+        cursor = int(time.time() * 1E3)
         for page in itertools.count(1):
-            for retry in self.RetryManager():
-                try:
-                    post_list = self._call_api(
-                        'aweme/post', query, username, note=f'Downloading user video list page {page}',
-                        errnote='Unable to download user video list')
-                except ExtractorError as e:
-                    if isinstance(e.cause, json.JSONDecodeError) and e.cause.pos == 0:
-                        retry.error = e
-                        continue
-                    raise
-            yield from post_list.get('aweme_list', [])
-            if not post_list.get('has_more'):
+            response = self._download_json(
+                self._API_BASE_URL, display_id, f'Downloading page {page}',
+                query=self._build_web_query(sec_uid, cursor), headers={'User-Agent': self._USER_AGENT})
+
+            for video in traverse_obj(response, ('itemList', lambda _, v: v['id'])):
+                video_id = video['id']
+                webpage_url = self._create_url(display_id, video_id)
+                yield self.url_result(
+                    webpage_url, TikTokIE,
+                    **self._parse_aweme_video_web(video, webpage_url, video_id, extract_flat=True))
+
+            old_cursor = cursor
+            cursor = traverse_obj(
+                response, ('itemList', -1, 'createTime', {functools.partial(int_or_none, invscale=1E3)}))
+            if not cursor:
+                # User may not have posted within this ~1 week lookback, so manually adjust cursor
+                cursor = old_cursor - 7 * 86_400_000
+            # In case 'hasMorePrevious' is wrong, break if we have gone back before TikTok existed
+            if cursor < 1472706000000 or not traverse_obj(response, 'hasMorePrevious'):
                 break
-            query['max_cursor'] = post_list['max_cursor']
-
-    def _entries_api(self, user_id, videos):
-        for video in videos:
-            yield {
-                **self._parse_aweme_video_app(video),
-                'extractor_key': TikTokIE.ie_key(),
-                'extractor': 'TikTok',
-                'webpage_url': f'https://tiktok.com/@{user_id}/video/{video["aweme_id"]}',
-            }
 
-    def _real_extract(self, url):
-        user_name = self._match_id(url)
-        webpage = self._download_webpage(url, user_name, headers={
-            'User-Agent': 'facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)'
-        })
-        user_id = self._html_search_regex(r'snssdk\d*://user/profile/(\d+)', webpage, 'user ID', default=None) or user_name
+    def _get_sec_uid(self, user_url, user_name, msg):
+        webpage = self._download_webpage(
+            user_url, user_name, fatal=False, headers={'User-Agent': 'Mozilla/5.0'},
+            note=f'Downloading {msg} webpage', errnote=f'Unable to download {msg} webpage') or ''
+        return (traverse_obj(self._get_universal_data(webpage, user_name),
+                             ('webapp.user-detail', 'userInfo', 'user', 'secUid', {str}))
+                or traverse_obj(self._get_sigi_state(webpage, user_name),
+                                ('LiveRoom', 'liveRoomUserInfo', 'user', 'secUid', {str}),
+                                ('UserModule', 'users', ..., 'secUid', {str}, any)))
 
-        videos = LazyList(self._video_entries_api(webpage, user_id, user_name))
-        thumbnail = traverse_obj(videos, (0, 'author', 'avatar_larger', 'url_list', 0))
+    def _real_extract(self, url):
+        user_name, sec_uid = self._match_id(url), None
+        if mobj := re.fullmatch(r'MS4wLjABAAAA[\w-]{64}', user_name):
+            user_name, sec_uid = None, mobj.group(0)
+        else:
+            sec_uid = (self._get_sec_uid(self._UPLOADER_URL_FORMAT % user_name, user_name, 'user')
+                       or self._get_sec_uid(self._UPLOADER_URL_FORMAT % f'{user_name}/live', user_name, 'live'))
+
+        if not sec_uid:
+            webpage = self._download_webpage(
+                f'https://www.tiktok.com/embed/@{user_name}', user_name,
+                note='Downloading user embed page', fatal=False) or ''
+            data = traverse_obj(self._search_json(
+                r'<script[^>]+\bid=[\'"]__FRONTITY_CONNECT_STATE__[\'"][^>]*>',
+                webpage, 'data', user_name, default={}),
+                ('source', 'data', f'/embed/@{user_name}', {dict}))
+
+            for aweme_id in traverse_obj(data, ('videoList', ..., 'id', {str})):
+                webpage_url = self._create_url(user_name, aweme_id)
+                video_data, _ = self._extract_web_data_and_status(webpage_url, aweme_id, fatal=False)
+                sec_uid = self._parse_aweme_video_web(
+                    video_data, webpage_url, aweme_id, extract_flat=True).get('channel_id')
+                if sec_uid:
+                    break
+
+        if not sec_uid:
+            raise ExtractorError(
+                'Unable to extract secondary user ID. If you are able to get the channel_id '
+                'from a video posted by this user, try using "tiktokuser:channel_id" as the '
+                'input URL (replacing `channel_id` with its actual value)', expected=True)
 
-        return self.playlist_result(self._entries_api(user_id, videos), user_id, user_name, thumbnail=thumbnail)
+        return self.playlist_result(self._entries(sec_uid, user_name), sec_uid, user_name)
 
 
 class TikTokBaseListIE(TikTokBaseIE):  # XXX: Conventionally, base classes should end with BaseIE/InfoExtractor
@@ -1083,6 +1117,64 @@ def _real_extract(self, url):
         return self.playlist_result(self._entries(tag_id, display_id), tag_id, display_id)
 
 
+class TikTokCollectionIE(TikTokBaseIE):
+    IE_NAME = 'tiktok:collection'
+    _VALID_URL = r'https?://www\.tiktok\.com/@(?P<user_id>[\w.-]+)/collection/(?P<title>[^/?#]+)-(?P<id>\d+)/?(?:[?#]|$)'
+    _TESTS = [{
+        # playlist should have exactly 9 videos
+        'url': 'https://www.tiktok.com/@imanoreotwe/collection/count-test-7371330159376370462',
+        'info_dict': {
+            'id': '7371330159376370462',
+            'title': 'imanoreotwe-count-test'
+        },
+        'playlist_count': 9
+    }, {
+        # tests returning multiple pages of a large collection
+        'url': 'https://www.tiktok.com/@imanoreotwe/collection/%F0%9F%98%82-7111887189571160875',
+        'info_dict': {
+            'id': '7111887189571160875',
+            'title': 'imanoreotwe-%F0%9F%98%82'
+        },
+        'playlist_mincount': 100
+    }]
+    _API_BASE_URL = 'https://www.tiktok.com/api/collection/item_list/'
+    _PAGE_COUNT = 30
+
+    def _build_web_query(self, collection_id, cursor):
+        return {
+            'aid': '1988',
+            'collectionId': collection_id,
+            'count': self._PAGE_COUNT,
+            'cursor': cursor,
+            'sourceType': '113',
+        }
+
+    def _entries(self, collection_id):
+        cursor = 0
+        for page in itertools.count(1):
+            response = self._download_json(
+                self._API_BASE_URL, collection_id, f'Downloading page {page}',
+                query=self._build_web_query(collection_id, cursor))
+
+            for video in traverse_obj(response, ('itemList', lambda _, v: v['id'])):
+                video_id = video['id']
+                author = traverse_obj(video, ('author', ('uniqueId', 'secUid', 'id'), {str}, any)) or '_'
+                webpage_url = self._create_url(author, video_id)
+                yield self.url_result(
+                    webpage_url, TikTokIE,
+                    **self._parse_aweme_video_web(video, webpage_url, video_id, extract_flat=True))
+
+            if not traverse_obj(response, 'hasMore'):
+                break
+            cursor += self._PAGE_COUNT
+
+    def _real_extract(self, url):
+        collection_id, title, user_name = self._match_valid_url(url).group('id', 'title', 'user_id')
+
+        return self.playlist_result(
+            self._entries(collection_id), collection_id, '-'.join((user_name, title)))
+
+
 class DouyinIE(TikTokBaseIE):
     _VALID_URL = r'https?://(?:www\.)?douyin\.com/video/(?P<id>[0-9]+)'
     _TESTS = [{
@@ -1098,7 +1190,6 @@ class DouyinIE(TikTokBaseIE):
             'uploader_url': 'https://www.douyin.com/user/MS4wLjABAAAAEKnfa654JAJ_N5lgZDQluwsxmY0lhfmEYNQBBkwGG98',
             'channel_id': 'MS4wLjABAAAAEKnfa654JAJ_N5lgZDQluwsxmY0lhfmEYNQBBkwGG98',
             'channel': '杨超越',
-            'creators': ['杨超越'],
             'duration': 19,
             'timestamp': 1620905839,
             'upload_date': '20210513',
@@ -1123,7 +1214,6 @@ class DouyinIE(TikTokBaseIE):
             'uploader_url': 'https://www.douyin.com/user/MS4wLjABAAAAZJpnglcjW2f_CMVcnqA_6oVBXKWMpH0F8LIHuUu8-lA',
             'channel_id': 'MS4wLjABAAAAZJpnglcjW2f_CMVcnqA_6oVBXKWMpH0F8LIHuUu8-lA',
             'channel': '杨超越工作室',
-            'creators': ['杨超越工作室'],
             'duration': 42,
             'timestamp': 1625739481,
             'upload_date': '20210708',
@@ -1148,7 +1238,6 @@ class DouyinIE(TikTokBaseIE):
             'uploader_url': 'https://www.douyin.com/user/MS4wLjABAAAAEKnfa654JAJ_N5lgZDQluwsxmY0lhfmEYNQBBkwGG98',
             'channel_id': 'MS4wLjABAAAAEKnfa654JAJ_N5lgZDQluwsxmY0lhfmEYNQBBkwGG98',
             'channel': '杨超越',
-            'creators': ['杨超越'],
             'duration': 17,
             'timestamp': 1619098692,
             'upload_date': '20210422',
@@ -1190,7 +1279,6 @@ class DouyinIE(TikTokBaseIE):
             'uploader_url': 'https://www.douyin.com/user/MS4wLjABAAAAEKnfa654JAJ_N5lgZDQluwsxmY0lhfmEYNQBBkwGG98',
             'channel_id': 'MS4wLjABAAAAEKnfa654JAJ_N5lgZDQluwsxmY0lhfmEYNQBBkwGG98',
             'channel': '杨超越',
-            'creators': ['杨超越'],
             'duration': 15,
             'timestamp': 1621261163,
             'upload_date': '20210517',
diff --git a/yt_dlp/extractor/youporn.py b/yt_dlp/extractor/youporn.py
index 6d4e31bf3..0e047aa16 100644
--- a/yt_dlp/extractor/youporn.py
+++ b/yt_dlp/extractor/youporn.py
@@ -1,19 +1,27 @@
+import itertools
 import re
 
 from .common import InfoExtractor
 from ..utils import (
+    ExtractorError,
+    clean_html,
     extract_attributes,
+    get_element_by_class,
+    get_element_by_id,
+    get_elements_html_by_class,
     int_or_none,
     merge_dicts,
-    str_to_int,
+    parse_count,
+    parse_qs,
     traverse_obj,
     unified_strdate,
     url_or_none,
+    urljoin,
 )
 
 
 class YouPornIE(InfoExtractor):
-    _VALID_URL = r'https?://(?:www\.)?youporn\.com/(?:watch|embed)/(?P<id>\d+)(?:/(?P<display_id>[^/?#&]+))?'
+    _VALID_URL = r'https?://(?:www\.)?youporn\.com/(?:watch|embed)/(?P<id>\d+)(?:/(?P<display_id>[^/?#&]+))?/?(?:[#?]|$)'
     _EMBED_REGEX = [r'<iframe[^>]+\bsrc=["\'](?P<url>(?:https?:)?//(?:www\.)?youporn\.com/embed/\d+)']
     _TESTS = [{
         'url': 'http://www.youporn.com/watch/505835/sex-ed-is-it-safe-to-masturbate-daily/',
@@ -34,7 +42,7 @@ class YouPornIE(InfoExtractor):
             'tags': list,
             'age_limit': 18,
         },
-        'skip': 'This video has been disabled',
+        'skip': 'This video has been deactivated',
     }, {
         # Unknown uploader
         'url': 'http://www.youporn.com/watch/561726/big-tits-awesome-brunette-on-amazing-webcam-show/?from=related3&al=2&from_id=561726&pos=4',
@@ -72,7 +80,6 @@ class YouPornIE(InfoExtractor):
             'id': '16290308',
             'age_limit': 18,
             'categories': [],
-            'description': str,  # TODO: detect/remove SEO spam description in ytdl backport
             'display_id': 'tinderspecial-trailer1',
             'duration': 298.0,
             'ext': 'mp4',
@@ -90,7 +97,17 @@ def _real_extract(self, url):
         video_id, display_id = self._match_valid_url(url).group('id', 'display_id')
         self._set_cookie('.youporn.com', 'age_verified', '1')
         webpage = self._download_webpage(f'https://www.youporn.com/watch/{video_id}', video_id)
-        definitions = self._search_json(r'\bplayervars\s*:', webpage, 'player vars', video_id)['mediaDefinitions']
+
+        watchable = self._search_regex(
+            r'''(<div\s[^>]*\bid\s*=\s*('|")?watch-container(?(2)\2|(?!-)\b)[^>]*>)''',
+            webpage, 'watchability', default=None)
+        if not watchable:
+            msg = re.split(r'\s{2}', clean_html(get_element_by_id('mainContent', webpage)) or '')[0]
+            raise ExtractorError(
+                f'{self.IE_NAME} says: {msg}' if msg else 'Video unavailable', expected=True)
+
+        player_vars = self._search_json(r'\bplayervars\s*:', webpage, 'player vars', video_id)
+        definitions = player_vars['mediaDefinitions']
 
         def get_format_data(data, stream_type):
             info_url = traverse_obj(data, (lambda _, v: v['format'] == stream_type, 'videoUrl', {url_or_none}, any))
@@ -143,8 +160,10 @@ def get_format_data(data, stream_type):
         thumbnail = self._search_regex(
             r'(?:imageurl\s*=|poster\s*:)\s*(["\'])(?P<thumbnail>.+?)\1',
             webpage, 'thumbnail', fatal=False, group='thumbnail')
-        duration = int_or_none(self._html_search_meta(
-            'video:duration', webpage, 'duration', fatal=False))
+        duration = traverse_obj(player_vars, ('duration', {int_or_none}))
+        if duration is None:
+            duration = int_or_none(self._html_search_meta(
+                'video:duration', webpage, 'duration', fatal=False))
 
         uploader = self._html_search_regex(
             r'(?s)<div[^>]+class=["\']submitByLink["\'][^>]*>(.+?)</div>',
@@ -160,11 +179,11 @@ def get_format_data(data, stream_type):
 
         view_count = None
         views = self._search_regex(
-            r'(<div[^>]+\bclass=["\']js_videoInfoViews["\']>)', webpage,
-            'views', default=None)
+            r'(<div [^>]*\bdata-value\s*=[^>]+>)\s*<label>Views:</label>',
+            webpage, 'views', default=None)
         if views:
-            view_count = str_to_int(extract_attributes(views).get('data-value'))
-        comment_count = str_to_int(self._search_regex(
+            view_count = parse_count(extract_attributes(views).get('data-value'))
+        comment_count = parse_count(self._search_regex(
             r'>All [Cc]omments? \(([\d,.]+)\)',
             webpage, 'comment count', default=None))
 
@@ -182,7 +201,8 @@ def extract_tag_box(regex, title):
 
         data = self._search_json_ld(webpage, video_id, expected_type='VideoObject', fatal=False)
         data.pop('url', None)
-        return merge_dicts(data, {
+
+        result = merge_dicts(data, {
             'id': video_id,
             'display_id': display_id,
             'title': title,
@@ -198,3 +218,350 @@ def extract_tag_box(regex, title):
             'age_limit': age_limit,
             'formats': formats,
         })
+
+        # Remove SEO spam "description"
+        description = result.get('description')
+        if description and description.startswith(f'Watch {result.get("title")} online'):
+            del result['description']
+
+        return result
+
+
+class YouPornListBase(InfoExtractor):
+    def _get_next_url(self, url, pl_id, html):
+        return urljoin(url, self._search_regex(
+            r'''<a [^>]*?\bhref\s*=\s*("|')(?P<url>(?:(?!\1)[^>])+)\1''',
+            get_element_by_id('next', html) or '', 'next page',
+            group='url', default=None))
+
+    @classmethod
+    def _get_title_from_slug(cls, title_slug):
+        return re.sub(r'[_-]', ' ', title_slug)
+
+    def _entries(self, url, pl_id, html=None, page_num=None):
+        start = page_num or 1
+        for page in itertools.count(start):
+            if not html:
+                html = self._download_webpage(
+                    url, pl_id, note=f'Downloading page {page}', fatal=page == start)
+            if not html:
+                return
+            for element in get_elements_html_by_class('video-title', html):
+                if video_url := traverse_obj(element, ({extract_attributes}, 'href', {lambda x: urljoin(url, x)})):
+                    yield self.url_result(video_url)
+
+            if page_num is not None:
+                return
+            next_url = self._get_next_url(url, pl_id, html)
+            if not next_url or next_url == url:
+                return
+            url = next_url
+            html = None
+
+    def _real_extract(self, url, html=None):
+        m_dict = self._match_valid_url(url).groupdict()
+        pl_id, page_type, sort = (m_dict.get(k) for k in ('id', 'type', 'sort'))
+        qs = {k: v[-1] for k, v in parse_qs(url).items() if v}
+
+        base_id = pl_id or 'YouPorn'
+        title = self._get_title_from_slug(base_id)
+        if page_type:
+            title = f'{page_type.capitalize()} {title}'
+        base_id = [base_id.lower()]
+        if sort is None:
+            title += ' videos'
+        else:
+            title = f'{title} videos by {re.sub(r"[_-]", " ", sort)}'
+            base_id.append(sort)
+        if qs:
+            filters = list(map('='.join, sorted(qs.items())))
+            title += f' ({",".join(filters)})'
+            base_id.extend(filters)
+        pl_id = '/'.join(base_id)
+
+        return self.playlist_result(
+            self._entries(url, pl_id, html=html, page_num=int_or_none(qs.get('page'))),
+            playlist_id=pl_id, playlist_title=title)
+
+
+class YouPornCategoryIE(YouPornListBase):
+    IE_DESC = 'YouPorn category, with sorting, filtering and pagination'
+    _VALID_URL = r'''(?x)
+        https?://(?:www\.)?youporn\.com/
+        (?P<type>category)/(?P<id>[^/?#&]+)
+        (?:/(?P<sort>popular|views|rating|time|duration))?/?(?:[#?]|$)
+    '''
+    _TESTS = [{
+        'note': 'Full list with pagination',
+        'url': 'https://www.youporn.com/category/popular-with-women/popular/',
+        'info_dict': {
+            'id': 'popular-with-women/popular',
+            'title': 'Category popular with women videos by popular',
+        },
+        'playlist_mincount': 39,
+    }, {
+        'note': 'Filtered paginated list with single page result',
+        'url': 'https://www.youporn.com/category/popular-with-women/duration/?min_minutes=10',
+        'info_dict': {
+            'id': 'popular-with-women/duration/min_minutes=10',
+            'title': 'Category popular with women videos by duration (min_minutes=10)',
+        },
+        'playlist_mincount': 2,
+        # 'playlist_maxcount': 30,
+    }, {
+        'note': 'Single page of full list',
+        'url': 'https://www.youporn.com/category/popular-with-women/popular?page=1',
+        'info_dict': {
+            'id': 'popular-with-women/popular/page=1',
+            'title': 'Category popular with women videos by popular (page=1)',
+        },
+        'playlist_count': 36,
+    }]
+
+
+class YouPornChannelIE(YouPornListBase):
+    IE_DESC = 'YouPorn channel, with sorting and pagination'
+    _VALID_URL = r'''(?x)
+        https?://(?:www\.)?youporn\.com/
+        (?P<type>channel)/(?P<id>[^/?#&]+)
+        (?:/(?P<sort>rating|views|duration))?/?(?:[#?]|$)
+    '''
+    _TESTS = [{
+        'note': 'Full list with pagination',
+        'url': 'https://www.youporn.com/channel/x-feeds/',
+        'info_dict': {
+            'id': 'x-feeds',
+            'title': 'Channel X-Feeds videos',
+        },
+        'playlist_mincount': 37,
+    }, {
+        'note': 'Single page of full list (no filters here)',
+        'url': 'https://www.youporn.com/channel/x-feeds/duration?page=1',
+        'info_dict': {
+            'id': 'x-feeds/duration/page=1',
+            'title': 'Channel X-Feeds videos by duration (page=1)',
+        },
+        'playlist_count': 24,
+    }]
+
+    @staticmethod
+    def _get_title_from_slug(title_slug):
+        return re.sub(r'_', ' ', title_slug).title()
+
+
+class YouPornCollectionIE(YouPornListBase):
+    IE_DESC = 'YouPorn collection (user playlist), with sorting and pagination'
+    _VALID_URL = r'''(?x)
+        https?://(?:www\.)?youporn\.com/
+        (?P<type>collection)s/videos/(?P<id>\d+)
+        (?:/(?P<sort>rating|views|time|duration))?/?(?:[#?]|$)
+    '''
+    _TESTS = [{
+        'note': 'Full list with pagination',
+        'url': 'https://www.youporn.com/collections/videos/33044251/',
+        'info_dict': {
+            'id': '33044251',
+            'title': 'Collection Sexy Lips videos',
+            'uploader': 'ph-littlewillyb',
+        },
+        'playlist_mincount': 50,
+    }, {
+        'note': 'Single page of full list (no filters here)',
+        'url': 'https://www.youporn.com/collections/videos/33044251/time?page=1',
+        'info_dict': {
+            'id': '33044251/time/page=1',
+            'title': 'Collection Sexy Lips videos by time (page=1)',
+            'uploader': 'ph-littlewillyb',
+        },
+        'playlist_count': 20,
+    }]
+
+    def _real_extract(self, url):
+        pl_id = self._match_id(url)
+        html = self._download_webpage(url, pl_id)
+        playlist = super()._real_extract(url, html=html)
+        infos = re.sub(r'\s+', ' ', clean_html(get_element_by_class(
+            'collection-infos', html)) or '')
+        title, uploader = self._search_regex(
+            r'^\s*Collection: (?P<title>.+?) \d+ VIDEOS \d+ VIEWS \d+ days LAST UPDATED From: (?P<uploader>[\w_-]+)',
+            infos, 'title/uploader', group=('title', 'uploader'), default=(None, None))
+        if title:
+            playlist.update({
+                'title': playlist['title'].replace(playlist['id'].split('/')[0], title),
+                'uploader': uploader,
+            })
+
+        return playlist
+
+
+class YouPornTagIE(YouPornListBase):
+    IE_DESC = 'YouPorn tag (porntags), with sorting, filtering and pagination'
+    _VALID_URL = r'''(?x)
+        https?://(?:www\.)?youporn\.com/
+        porn(?P<type>tag)s/(?P<id>[^/?#&]+)
+        (?:/(?P<sort>views|rating|time|duration))?/?(?:[#?]|$)
+    '''
+    _TESTS = [{
+        'note': 'Full list with pagination',
+        'url': 'https://www.youporn.com/porntags/austrian',
+        'info_dict': {
+            'id': 'austrian',
+            'title': 'Tag austrian videos',
+        },
+        'playlist_mincount': 33,
+        'expected_warnings': ['YouPorn tag pages are not correctly cached'],
+    }, {
+        'note': 'Filtered paginated list with single page result',
+        'url': 'https://www.youporn.com/porntags/austrian/duration/?min_minutes=10',
+        'info_dict': {
+            'id': 'austrian/duration/min_minutes=10',
+            'title': 'Tag austrian videos by duration (min_minutes=10)',
+        },
+        'playlist_mincount': 10,
+        # number of videos per page is (row x col) 2x3 + 6x4 + 2, or + 3,
+        # or more, varying with number of ads; let's set max as 9x4
+        # NB col 1 may not be shown in non-JS page with site CSS and zoom 100%
+        # 'playlist_maxcount': 32,
+        'expected_warnings': ['YouPorn tag pages are not correctly cached'],
+    }, {
+        'note': 'Single page of full list',
+        'url': 'https://www.youporn.com/porntags/austrian/?page=1',
+        'info_dict': {
+            'id': 'austrian/page=1',
+            'title': 'Tag austrian videos (page=1)',
+        },
+        'playlist_mincount': 32,
+        # 'playlist_maxcount': 34,
+        'expected_warnings': ['YouPorn tag pages are not correctly cached'],
+    }]
+
+    def _real_extract(self, url):
+        self.report_warning(
+            'YouPorn tag pages are not correctly cached and '
+            'often return incorrect results', only_once=True)
+        return super()._real_extract(url)
+
+
+class YouPornStarIE(YouPornListBase):
+    IE_DESC = 'YouPorn Pornstar, with description, sorting and pagination'
+    _VALID_URL = r'''(?x)
+        https?://(?:www\.)?youporn\.com/
+        (?P<type>pornstar)/(?P<id>[^/?#&]+)
+        (?:/(?P<sort>rating|views|duration))?/?(?:[#?]|$)
+    '''
+    _TESTS = [{
+        'note': 'Full list with pagination',
+        'url': 'https://www.youporn.com/pornstar/daynia/',
+        'info_dict': {
+            'id': 'daynia',
+            'title': 'Pornstar Daynia videos',
+            'description': r're:Daynia Rank \d+ Videos \d+ Views [\d,.]+ .+ Subscribers \d+',
+        },
+        'playlist_mincount': 40,
+    }, {
+        'note': 'Single page of full list (no filters here)',
+        'url': 'https://www.youporn.com/pornstar/daynia/?page=1',
+        'info_dict': {
+            'id': 'daynia/page=1',
+            'title': 'Pornstar Daynia videos (page=1)',
+            'description': 're:.{180,}',
+        },
+        'playlist_count': 26,
+    }]
+
+    @staticmethod
+    def _get_title_from_slug(title_slug):
+        return re.sub(r'_', ' ', title_slug).title()
+
+    def _real_extract(self, url):
+        pl_id = self._match_id(url)
+        html = self._download_webpage(url, pl_id)
+        playlist = super()._real_extract(url, html=html)
+        INFO_ELEMENT_RE = r'''(?x)
+            <div [^>]*\bclass\s*=\s*('|")(?:[\w$-]+\s+|\s)*?pornstar-info-wrapper(?:\s+[\w$-]+|\s)*\1[^>]*>
+            (?P<info>[\s\S]+?)(?:</div>\s*){6,}
+        '''
+
+        if infos := self._search_regex(INFO_ELEMENT_RE, html, 'infos', group='info', default=''):
+            infos = re.sub(
+                r'(?:\s*nl=nl)+\s*', ' ',
+                re.sub(r'(?u)\s+', ' ', clean_html(re.sub('\n', 'nl=nl', infos)))).replace('ribe Subsc', '')
+
+        return {
+            **playlist,
+            'description': infos.strip() or None,
+        }
+
+
+class YouPornVideosIE(YouPornListBase):
+    IE_DESC = 'YouPorn video (browse) playlists, with sorting, filtering and pagination'
+    _VALID_URL = r'''(?x)
+        https?://(?:www\.)?youporn\.com/
+            (?:(?P<id>browse)/)?
+            (?P<sort>(?(id)
+                (?:duration|rating|time|views)|
+                (?:most_(?:favou?rit|view)ed|recommended|top_rated)?))
+            (?:[/#?]|$)
+    '''
+    _TESTS = [{
+        'note': 'Full list with pagination (too long for test)',
+        'url': 'https://www.youporn.com/',
+        'info_dict': {
+            'id': 'youporn',
+            'title': 'YouPorn videos',
+        },
+        'only_matching': True,
+    }, {
+        'note': 'Full list with pagination (too long for test)',
+        'url': 'https://www.youporn.com/recommended',
+        'info_dict': {
+            'id': 'youporn/recommended',
+            'title': 'YouPorn videos by recommended',
+        },
+        'only_matching': True,
+    }, {
+        'note': 'Full list with pagination (too long for test)',
+        'url': 'https://www.youporn.com/top_rated',
+        'info_dict': {
+            'id': 'youporn/top_rated',
+            'title': 'YouPorn videos by top rated',
+        },
+        'only_matching': True,
+    }, {
+        'note': 'Full list with pagination (too long for test)',
+        'url': 'https://www.youporn.com/browse/time',
+        'info_dict': {
+            'id': 'browse/time',
+            'title': 'YouPorn videos by time',
+        },
+        'only_matching': True,
+    }, {
+        'note': 'Filtered paginated list with single page result',
+        'url': 'https://www.youporn.com/most_favorited/?res=VR&max_minutes=2',
+        'info_dict': {
+            'id': 'youporn/most_favorited/max_minutes=2/res=VR',
+            'title': 'YouPorn videos by most favorited (max_minutes=2,res=VR)',
+        },
+        'playlist_mincount': 10,
+        # 'playlist_maxcount': 28,
+    }, {
+        'note': 'Filtered paginated list with several pages',
+        'url': 'https://www.youporn.com/most_favorited/?res=VR&max_minutes=5',
+        'info_dict': {
+            'id': 'youporn/most_favorited/max_minutes=5/res=VR',
+            'title': 'YouPorn videos by most favorited (max_minutes=5,res=VR)',
+        },
+        'playlist_mincount': 45,
+    }, {
+        'note': 'Single page of full list',
+        'url': 'https://www.youporn.com/browse/time?page=1',
+        'info_dict': {
+            'id': 'browse/time/page=1',
+            'title': 'YouPorn videos by time (page=1)',
+        },
+        'playlist_count': 36,
+    }]
+
+    @staticmethod
+    def _get_title_from_slug(title_slug):
+        return 'YouPorn' if title_slug == 'browse' else title_slug
diff --git a/yt_dlp/extractor/youtube.py b/yt_dlp/extractor/youtube.py
index e676c5cde..54da4e362 100644
--- a/yt_dlp/extractor/youtube.py
+++ b/yt_dlp/extractor/youtube.py
@@ -1325,6 +1325,7 @@ class YoutubeIE(YoutubeBaseInfoExtractor):
                 'uploader_url': 'https://www.youtube.com/@PhilippHagemeister',
                 'uploader_id': '@PhilippHagemeister',
                 'heatmap': 'count:100',
+                'timestamp': 1349198244,
             }
         },
         {
@@ -1368,6 +1369,7 @@ class YoutubeIE(YoutubeBaseInfoExtractor):
                 'uploader_url': 'https://www.youtube.com/@PhilippHagemeister',
                 'uploader_id': '@PhilippHagemeister',
                 'heatmap': 'count:100',
+                'timestamp': 1349198244,
             },
             'params': {
                 'skip_download': True,
@@ -1454,6 +1456,7 @@ class YoutubeIE(YoutubeBaseInfoExtractor):
                 'comment_count': int,
                 'channel_is_verified': True,
                 'heatmap': 'count:100',
+                'timestamp': 1401991663,
             },
         },
         {
@@ -1513,6 +1516,7 @@ class YoutubeIE(YoutubeBaseInfoExtractor):
                 'uploader': 'Projekt Melody',
                 'uploader_url': 'https://www.youtube.com/@ProjektMelody',
                 'uploader_id': '@ProjektMelody',
+                'timestamp': 1577508724,
             },
         },
         {
@@ -1618,6 +1622,7 @@ class YoutubeIE(YoutubeBaseInfoExtractor):
                 'uploader_url': 'https://www.youtube.com/@Olympics',
                 'uploader_id': '@Olympics',
                 'channel_is_verified': True,
+                'timestamp': 1440707674,
             },
             'params': {
                 'skip_download': 'requires avconv',
@@ -1651,6 +1656,7 @@ class YoutubeIE(YoutubeBaseInfoExtractor):
                 'uploader': '孫ᄋᄅ',
                 'uploader_url': 'https://www.youtube.com/@AllenMeow',
                 'uploader_id': '@AllenMeow',
+                'timestamp': 1299776999,
             },
         },
         # url_encoded_fmt_stream_map is empty string
@@ -1794,6 +1800,7 @@ class YoutubeIE(YoutubeBaseInfoExtractor):
                 },
             }],
             'params': {'skip_download': True},
+            'skip': 'Not multifeed anymore',
         },
         {
             # Multifeed video with comma in title (see https://github.com/ytdl-org/youtube-dl/issues/8536)
@@ -1902,6 +1909,7 @@ class YoutubeIE(YoutubeBaseInfoExtractor):
                 'uploader': 'The Berkman Klein Center for Internet & Society',
                 'uploader_id': '@BKCHarvard',
                 'uploader_url': 'https://www.youtube.com/@BKCHarvard',
+                'timestamp': 1422422076,
             },
             'params': {
                 'skip_download': True,
@@ -1937,6 +1945,7 @@ class YoutubeIE(YoutubeBaseInfoExtractor):
                 'uploader_id': '@BernieSanders',
                 'channel_is_verified': True,
                 'heatmap': 'count:100',
+                'timestamp': 1447987198,
             },
             'params': {
                 'skip_download': True,
@@ -2000,6 +2009,7 @@ class YoutubeIE(YoutubeBaseInfoExtractor):
                 'uploader_id': '@Vsauce',
                 'comment_count': int,
                 'channel_is_verified': True,
+                'timestamp': 1484761047,
             },
             'params': {
                 'skip_download': True,
@@ -2155,6 +2165,7 @@ class YoutubeIE(YoutubeBaseInfoExtractor):
                 'uploader': 'l\'Or Vert asbl',
                 'uploader_url': 'https://www.youtube.com/@ElevageOrVert',
                 'uploader_id': '@ElevageOrVert',
+                'timestamp': 1497343210,
             },
             'params': {
                 'skip_download': True,
@@ -2193,6 +2204,7 @@ class YoutubeIE(YoutubeBaseInfoExtractor):
                 'uploader_id': '@Csharp-video-tutorialsBlogspot',
                 'channel_is_verified': True,
                 'heatmap': 'count:100',
+                'timestamp': 1377976349,
             },
             'params': {
                 'skip_download': True,
@@ -2275,6 +2287,7 @@ class YoutubeIE(YoutubeBaseInfoExtractor):
                 'uploader_id': '@CBSMornings',
                 'comment_count': int,
                 'channel_is_verified': True,
+                'timestamp': 1405513526,
             }
         },
         {
@@ -2292,7 +2305,7 @@ class YoutubeIE(YoutubeBaseInfoExtractor):
                 'view_count': int,
                 'channel': 'Walk around Japan',
                 'tags': ['Ueno Tokyo', 'Okachimachi Tokyo', 'Ameyoko Street', 'Tokyo attraction', 'Travel in Tokyo'],
-                'thumbnail': 'https://i.ytimg.com/vi_webp/cBvYw8_A0vQ/hqdefault.webp',
+                'thumbnail': 'https://i.ytimg.com/vi/cBvYw8_A0vQ/hqdefault.jpg',
                 'age_limit': 0,
                 'availability': 'public',
                 'channel_url': 'https://www.youtube.com/channel/UC3o_t8PzBmXf5S9b7GLx1Mw',
@@ -2302,6 +2315,7 @@ class YoutubeIE(YoutubeBaseInfoExtractor):
                 'uploader': 'Walk around Japan',
                 'uploader_url': 'https://www.youtube.com/@walkaroundjapan7124',
                 'uploader_id': '@walkaroundjapan7124',
+                'timestamp': 1605884416,
             },
             'params': {
                 'skip_download': True,
@@ -2397,6 +2411,7 @@ class YoutubeIE(YoutubeBaseInfoExtractor):
                 'comment_count': int,
                 'channel_is_verified': True,
                 'heatmap': 'count:100',
+                'timestamp': 1395685455,
             }, 'params': {'format': 'mhtml', 'skip_download': True}
         }, {
             # Ensure video upload_date is in UTC timezone (video was uploaded 1641170939)
@@ -2426,37 +2441,8 @@ class YoutubeIE(YoutubeBaseInfoExtractor):
                 'uploader_url': 'https://www.youtube.com/@LeonNguyen',
                 'uploader_id': '@LeonNguyen',
                 'heatmap': 'count:100',
+                'timestamp': 1641170939,
             }
-        }, {
-            # 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',
-                '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,
-                'uploader': 'Leon Nguyen',
-                'uploader_url': 'https://www.youtube.com/@LeonNguyen',
-                'uploader_id': '@LeonNguyen',
-                'heatmap': 'count:100',
-            },
-            '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',
@@ -2488,38 +2474,41 @@ class YoutubeIE(YoutubeBaseInfoExtractor):
                 'comment_count': int,
                 'channel_is_verified': True,
                 'heatmap': 'count:100',
+                'timestamp': 1641172509,
             }
         },
-        {   # continuous livestream. Microformat upload date should be preferred.
-            # Upload date was 2021-06-19 (not UTC), while stream start is 2021-11-27
-            'url': 'https://www.youtube.com/watch?v=kgx4WGK0oNU',
+        {   # continuous livestream.
+            # Upload date was 2022-07-12T05:12:29-07:00, while stream start is 2022-07-12T15:59:30+00:00
+            'url': 'https://www.youtube.com/watch?v=jfKfPfyJRdk',
             'info_dict': {
-                'id': 'kgx4WGK0oNU',
-                'title': r're:jazz\/lofi hip hop radio🌱chill beats to relax\/study to \[LIVE 24\/7\] \d{4}-\d{2}-\d{2} \d{2}:\d{2}',
+                'id': 'jfKfPfyJRdk',
                 'ext': 'mp4',
-                'channel_id': 'UC84whx2xxsiA1gXHXXqKGOA',
-                'availability': 'public',
+                'channel_id': 'UCSJ4gkVC6NrvII8umztf0Ow',
+                'like_count': int,
+                'uploader': 'Lofi Girl',
+                'categories': ['Music'],
+                'concurrent_view_count': int,
+                'playable_in_embed': True,
+                'timestamp': 1657627949,
+                'release_date': '20220712',
+                'channel_url': 'https://www.youtube.com/channel/UCSJ4gkVC6NrvII8umztf0Ow',
+                'description': 'md5:13a6f76df898f5674f9127139f3df6f7',
                 'age_limit': 0,
-                'release_timestamp': 1637975704,
-                'upload_date': '20210619',
-                'channel_url': 'https://www.youtube.com/channel/UC84whx2xxsiA1gXHXXqKGOA',
-                'live_status': 'is_live',
-                'thumbnail': 'https://i.ytimg.com/vi/kgx4WGK0oNU/maxresdefault.jpg',
-                'channel': 'Abao in Tokyo',
+                'thumbnail': 'https://i.ytimg.com/vi/jfKfPfyJRdk/maxresdefault.jpg',
+                'release_timestamp': 1657641570,
+                'uploader_url': 'https://www.youtube.com/@LofiGirl',
                 'channel_follower_count': int,
-                'release_date': '20211127',
-                'tags': 'count:39',
-                'categories': ['People & Blogs'],
-                'like_count': int,
+                'channel_is_verified': True,
+                'title': r're:^lofi hip hop radio 📚 - beats to relax/study to',
                 'view_count': int,
-                'playable_in_embed': True,
-                'description': 'md5:2ef1d002cad520f65825346e2084e49d',
-                'concurrent_view_count': int,
-                'uploader': 'Abao in Tokyo',
-                'uploader_url': 'https://www.youtube.com/@abaointokyo',
-                'uploader_id': '@abaointokyo',
+                'live_status': 'is_live',
+                'tags': 'count:32',
+                'channel': 'Lofi Girl',
+                'availability': 'public',
+                'upload_date': '20220712',
+                'uploader_id': '@LofiGirl',
             },
-            'params': {'skip_download': True}
+            'params': {'skip_download': True},
         }, {
             'url': 'https://www.youtube.com/watch?v=tjjjtzRLHvA',
             'info_dict': {
@@ -2545,6 +2534,7 @@ class YoutubeIE(YoutubeBaseInfoExtractor):
                 'uploader_id': '@lesmiscore',
                 'uploader': 'Lesmiscore',
                 'uploader_url': 'https://www.youtube.com/@lesmiscore',
+                'timestamp': 1648005313,
             }
         }, {
             # Prefer primary title+description language metadata by default
@@ -2572,6 +2562,7 @@ class YoutubeIE(YoutubeBaseInfoExtractor):
                 'uploader_url': 'https://www.youtube.com/@coletdjnz',
                 'uploader_id': '@coletdjnz',
                 'uploader': 'cole-dlp-test-acc',
+                'timestamp': 1662677394,
             },
             'params': {'skip_download': True}
         }, {
@@ -2585,7 +2576,7 @@ class YoutubeIE(YoutubeBaseInfoExtractor):
                 'duration': 5,
                 'live_status': 'not_live',
                 'channel_id': 'UCiu-3thuViMebBjw_5nWYrA',
-                'upload_date': '20220728',
+                'upload_date': '20220729',
                 'view_count': int,
                 'categories': ['People & Blogs'],
                 'thumbnail': r're:^https?://.*\.jpg',
@@ -2598,6 +2589,8 @@ class YoutubeIE(YoutubeBaseInfoExtractor):
                 'uploader_url': 'https://www.youtube.com/@coletdjnz',
                 'uploader_id': '@coletdjnz',
                 'uploader': 'cole-dlp-test-acc',
+                'timestamp': 1659073275,
+                'like_count': int,
             },
             'params': {'skip_download': True, 'extractor_args': {'youtube': {'lang': ['fr']}}},
             'expected_warnings': [r'Preferring "fr" translated fields'],
@@ -2663,6 +2656,7 @@ class YoutubeIE(YoutubeBaseInfoExtractor):
                 'uploader': 'Projekt Melody',
                 'uploader_id': '@ProjektMelody',
                 'uploader_url': 'https://www.youtube.com/@ProjektMelody',
+                'timestamp': 1577508724,
             },
             'params': {'extractor_args': {'youtube': {'player_client': ['tv_embedded']}}, 'format': '251-drc'},
         },
@@ -2697,6 +2691,7 @@ class YoutubeIE(YoutubeBaseInfoExtractor):
                 'uploader_id': '@sana_natori',
                 'channel_is_verified': True,
                 'heatmap': 'count:100',
+                'timestamp': 1671798112,
             },
         },
         {
@@ -2766,6 +2761,7 @@ class YoutubeIE(YoutubeBaseInfoExtractor):
                 'uploader_url': 'https://www.youtube.com/@ChristopherSykesDocumentaries',
                 'uploader_id': '@ChristopherSykesDocumentaries',
                 'heatmap': 'count:100',
+                'timestamp': 1211825920,
             },
             'params': {
                 'skip_download': True,
@@ -4622,19 +4618,31 @@ def process_language(container, base_url, lang_code, sub_name, query):
             'uploader_id': channel_handle,
             'uploader_url': format_field(channel_handle, None, 'https://www.youtube.com/%s', default=None),
         })
+
+        # We only want timestamp IF it has time precision AND a timezone
+        # Currently the uploadDate in microformats appears to be in US/Pacific timezone.
+        timestamp = (
+            parse_iso8601(get_first(microformats, 'uploadDate'), timezone=NO_DEFAULT)
+            or parse_iso8601(search_meta('uploadDate'), timezone=NO_DEFAULT)
+        )
+        upload_date = (
+            dt.datetime.fromtimestamp(timestamp, dt.timezone.utc).strftime('%Y%m%d') if timestamp else
+            (
+                unified_strdate(get_first(microformats, 'uploadDate'))
+                or unified_strdate(search_meta('uploadDate'))
+            ))
+
+        # In the case we cannot get the timestamp:
         # The upload date for scheduled, live and past live streams / premieres in microformats
         # may be different from the stream date. Although not in UTC, we will prefer it in this case.
         # See: https://github.com/yt-dlp/yt-dlp/pull/2223#issuecomment-1008485139
-        upload_date = (
-            unified_strdate(get_first(microformats, 'uploadDate'))
-            or unified_strdate(search_meta('uploadDate')))
-        if not upload_date or (
-            live_status in ('not_live', None)
-            and 'no-youtube-prefer-utc-upload-date' not in self.get_param('compat_opts', [])
-        ):
+        if not upload_date or (not timestamp and live_status in ('not_live', None)):
+            # this should be in UTC, as configured in the cookie/client context
             upload_date = strftime_or_none(
                 self._parse_time_text(self._get_text(vpir, 'dateText'))) or upload_date
+
         info['upload_date'] = upload_date
+        info['timestamp'] = timestamp
 
         if upload_date and live_status not in ('is_live', 'post_live', 'is_upcoming'):
             # Newly uploaded videos' HLS formats are potentially problematic and need to be checked
diff --git a/yt_dlp/options.py b/yt_dlp/options.py
index faa1ee563..997b575cd 100644
--- a/yt_dlp/options.py
+++ b/yt_dlp/options.py
@@ -478,7 +478,7 @@ def _alias_callback(option, opt_str, value, parser, opts, nargs):
             }, 'aliases': {
                 'youtube-dl': ['all', '-multistreams', '-playlist-match-filter', '-manifest-filesize-approx'],
                 'youtube-dlc': ['all', '-no-youtube-channel-redirect', '-no-live-chat', '-playlist-match-filter', '-manifest-filesize-approx'],
-                '2021': ['2022', 'no-certifi', 'filename-sanitization', 'no-youtube-prefer-utc-upload-date'],
+                '2021': ['2022', 'no-certifi', 'filename-sanitization'],
                 '2022': ['2023', 'no-external-downloader-progress', 'playlist-match-filter', 'prefer-legacy-http-handler', 'manifest-filesize-approx'],
                 '2023': [],
             }
diff --git a/yt_dlp/utils/_utils.py b/yt_dlp/utils/_utils.py
index b63766912..5f458ea45 100644
--- a/yt_dlp/utils/_utils.py
+++ b/yt_dlp/utils/_utils.py
@@ -1134,7 +1134,7 @@ def is_path_like(f):
     return isinstance(f, (str, bytes, os.PathLike))
 
 
-def extract_timezone(date_str):
+def extract_timezone(date_str, default=None):
     m = re.search(
         r'''(?x)
             ^.{8,}?                                              # >=8 char non-TZ prefix, if present
@@ -1146,21 +1146,25 @@ def extract_timezone(date_str):
                 (?P<hours>[0-9]{2}):?(?P<minutes>[0-9]{2})       # hh[:]mm
             $)
         ''', date_str)
+    timezone = None
+
     if not m:
         m = re.search(r'\d{1,2}:\d{1,2}(?:\.\d+)?(?P<tz>\s*[A-Z]+)$', date_str)
         timezone = TIMEZONE_NAMES.get(m and m.group('tz').strip())
         if timezone is not None:
             date_str = date_str[:-len(m.group('tz'))]
-        timezone = dt.timedelta(hours=timezone or 0)
+            timezone = dt.timedelta(hours=timezone)
     else:
         date_str = date_str[:-len(m.group('tz'))]
-        if not m.group('sign'):
-            timezone = dt.timedelta()
-        else:
+        if m.group('sign'):
             sign = 1 if m.group('sign') == '+' else -1
             timezone = dt.timedelta(
                 hours=sign * int(m.group('hours')),
                 minutes=sign * int(m.group('minutes')))
+
+    if timezone is None and default is not NO_DEFAULT:
+        timezone = default or dt.timedelta()
+
     return timezone, date_str
 
 
@@ -1172,10 +1176,9 @@ def parse_iso8601(date_str, delimiter='T', timezone=None):
 
     date_str = re.sub(r'\.[0-9]+', '', date_str)
 
-    if timezone is None:
-        timezone, date_str = extract_timezone(date_str)
+    timezone, date_str = extract_timezone(date_str, timezone)
 
-    with contextlib.suppress(ValueError):
+    with contextlib.suppress(ValueError, TypeError):
         date_format = f'%Y-%m-%d{delimiter}%H:%M:%S'
         dt_ = dt.datetime.strptime(date_str, date_format) - timezone
         return calendar.timegm(dt_.timetuple())


Test patch:
diff --git a/test/test_utils.py b/test/test_utils.py
index 816cf03f6..77fadbbea 100644
--- a/test/test_utils.py
+++ b/test/test_utils.py
@@ -5,6 +5,7 @@
 import sys
 import unittest
 import warnings
+import datetime as dt
 
 sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
 
@@ -27,6 +28,7 @@
     ExtractorError,
     InAdvancePagedList,
     LazyList,
+    NO_DEFAULT,
     OnDemandPagedList,
     Popen,
     age_restricted,
@@ -768,6 +770,11 @@ def test_encode_compat_str(self):
 
     def test_parse_iso8601(self):
         self.assertEqual(parse_iso8601('2014-03-23T23:04:26+0100'), 1395612266)
+        self.assertEqual(parse_iso8601('2014-03-23T23:04:26-07:00'), 1395641066)
+        self.assertEqual(parse_iso8601('2014-03-23T23:04:26', timezone=dt.timedelta(hours=-7)), 1395641066)
+        self.assertEqual(parse_iso8601('2014-03-23T23:04:26', timezone=NO_DEFAULT), None)
+        # default does not override timezone in date_str
+        self.assertEqual(parse_iso8601('2014-03-23T23:04:26-07:00', timezone=dt.timedelta(hours=-10)), 1395641066)
         self.assertEqual(parse_iso8601('2014-03-23T22:04:26+0000'), 1395612266)
         self.assertEqual(parse_iso8601('2014-03-23T22:04:26Z'), 1395612266)
         self.assertEqual(parse_iso8601('2014-03-23T22:04:26.1234Z'), 1395612266)