Repository: yt-dlp/yt-dlp

Issue:
Multi media tweets only download the first one
### DO NOT REMOVE OR SKIP THE ISSUE TEMPLATE

- [X] I understand that I will be **blocked** if I remove or skip any mandatory\* field

### Checklist

- [X] I'm requesting a site-specific feature
- [X] I've verified that I'm running yt-dlp version **2022.10.04** ([update instructions](https://github.com/yt-dlp/yt-dlp#update)) or later (specify commit)
- [X] I've checked that all provided URLs are playable in a browser with the same IP and same login details
- [X] I've searched the [bugtracker](https://github.com/yt-dlp/yt-dlp/issues?q=) for similar issues **including closed ones**. DO NOT post duplicates
- [X] I've read the [guidelines for opening an issue](https://github.com/yt-dlp/yt-dlp/blob/master/CONTRIBUTING.md#opening-an-issue)
- [X] I've read about [sharing account credentials](https://github.com/yt-dlp/yt-dlp/blob/master/CONTRIBUTING.md#are-you-willing-to-share-account-details-if-needed) and I'm willing to share it if required

### Region

USA

### Example URLs

https://twitter.com/UltimaShadowX/status/1577719286659006464
https://twitter.com/yoolakbop/status/1577721250087960576

### Provide a description that is worded well enough to be understood

twitter recently added a feature where you can put multiple gifs, videos, and images in a single tweet. when trying to download a tweet containing multiple videos/gifs, it just downloads the first one. when trying to download one of these tweets, it should either download all the content, or give a popup asking which content to download

### Provide verbose output that clearly demonstrates the problem

- [X] Run **your** yt-dlp command with **-vU** flag added (`yt-dlp -vU <your command line>`)
- [X] Copy the WHOLE output (starting with `[debug] Command-line config`) and insert it below

### Complete Verbose Output

```shell
[debug] Command-line config: ['-vU', 'https://twitter.com/UltimaShadowX/status/1577719286659006464']
[debug] User config "C:\Users\Lise\AppData\Roaming\yt-dlp\config.txt": ['-o', 'C:\\Users\\Lise\\Desktop\\youtube-dl\\%(title)s.%(ext)s']
[debug] Encodings: locale cp1252, fs utf-8, pref cp1252, out utf-8, error utf-8, screen utf-8
[debug] yt-dlp version 2022.10.04 [4e0511f] (win32_exe)
[debug] Python 3.8.10 (CPython 64bit) - Windows-10-10.0.19044-SP0
[debug] Checking exe version: ffmpeg -bsfs
[debug] Checking exe version: ffprobe -bsfs
[debug] exe versions: ffmpeg 5.0-essentials_build-www.gyan.dev (setts), ffprobe 5.0-essentials_build-www.gyan.dev
[debug] Optional libraries: Cryptodome-3.15.0, brotli-1.0.9, certifi-2022.09.24, mutagen-1.45.1, sqlite3-2.6.0, websockets-10.3
[debug] Proxy map: {}
[debug] Loaded 1690 extractors
[debug] Fetching release info: https://api.github.com/repos/yt-dlp/yt-dlp/releases/latest
Latest version: 2022.10.04, Current version: 2022.10.04
yt-dlp is up to date (2022.10.04)
[debug] [twitter] Extracting URL: https://twitter.com/UltimaShadowX/status/1577719286659006464
[twitter] 1577719286659006464: Downloading guest token
[twitter] 1577719286659006464: Downloading JSON metadata
[twitter] 1577719286659006464: Downloading m3u8 information
[debug] Sort order given by extractor: res, br, size, proto
[debug] Formats sorted by: hasvid, ie_pref, res, tbr, vbr, abr, filesize, fs_approx, proto, lang, quality, fps, hdr:12(7), vcodec:vp9.2(10), channels, acodec, asr, vext, aext, hasaud, source, id
[debug] Default format spec: bestvideo*+bestaudio/best
[info] 1577719286659006464: Downloading 1 format(s): http-2176
[debug] Invoking http downloader on "https://video.twimg.com/ext_tw_video/1577719236209922048/pu/vid/1280x720/-vn5a15qiFtZbg9q.mp4?tag=12"
[debug] File locking is not supported. Proceeding without locking
[download] Destination: C:\Users\Lise\Desktop\youtube-dl\Ultima ｜ #вʟм - Test.mp4
[download] 100% of  356.99KiB in 00:00:00 at 1.47MiB/s
```


Discussion:

Patch:
diff --git a/yt_dlp/cookies.py b/yt_dlp/cookies.py
index 3032d0712..8ca7cea2c 100644
--- a/yt_dlp/cookies.py
+++ b/yt_dlp/cookies.py
@@ -999,8 +999,9 @@ def _parse_browser_specification(browser_name, profile=None, keyring=None, conta
 class LenientSimpleCookie(http.cookies.SimpleCookie):
     """More lenient version of http.cookies.SimpleCookie"""
     # From https://github.com/python/cpython/blob/v3.10.7/Lib/http/cookies.py
-    _LEGAL_KEY_CHARS = r"\w\d!#%&'~_`><@,:/\$\*\+\-\.\^\|\)\(\?\}\{\="
-    _LEGAL_VALUE_CHARS = _LEGAL_KEY_CHARS + r"\[\]"
+    # We use Morsel's legal key chars to avoid errors on setting values
+    _LEGAL_KEY_CHARS = r'\w\d' + re.escape('!#$%&\'*+-.:^_`|~')
+    _LEGAL_VALUE_CHARS = _LEGAL_KEY_CHARS + re.escape('(),/<=>?@[]{}')
 
     _RESERVED = {
         "expires",
@@ -1046,25 +1047,17 @@ def load(self, data):
             return super().load(data)
 
         morsel = None
-        index = 0
-        length = len(data)
-
-        while 0 <= index < length:
-            match = self._COOKIE_PATTERN.search(data, index)
-            if not match:
-                break
-
-            index = match.end(0)
-            if match.group("bad"):
+        for match in self._COOKIE_PATTERN.finditer(data):
+            if match.group('bad'):
                 morsel = None
                 continue
 
-            key, value = match.group("key", "val")
+            key, value = match.group('key', 'val')
 
-            if key[0] == "$":
-                if morsel is not None:
-                    morsel[key[1:]] = True
-                continue
+            is_attribute = False
+            if key.startswith('$'):
+                key = key[1:]
+                is_attribute = True
 
             lower_key = key.lower()
             if lower_key in self._RESERVED:
@@ -1081,6 +1074,9 @@ def load(self, data):
 
                 morsel[key] = value
 
+            elif is_attribute:
+                morsel = None
+
             elif value is not None:
                 morsel = self.get(key, http.cookies.Morsel())
                 real_value, coded_value = self.value_decode(value)
diff --git a/yt_dlp/extractor/_extractors.py b/yt_dlp/extractor/_extractors.py
index 06be8f822..d514f9a89 100644
--- a/yt_dlp/extractor/_extractors.py
+++ b/yt_dlp/extractor/_extractors.py
@@ -1181,6 +1181,7 @@
 from .noovo import NoovoIE
 from .normalboots import NormalbootsIE
 from .nosvideo import NosVideoIE
+from .nosnl import NOSNLArticleIE
 from .nova import (
     NovaEmbedIE,
     NovaIE,
@@ -1344,6 +1345,7 @@
     PluralsightIE,
     PluralsightCourseIE,
 )
+from .podbayfm import PodbayFMIE, PodbayFMChannelIE
 from .podchaser import PodchaserIE
 from .podomatic import PodomaticIE
 from .pokemon import (
@@ -2164,7 +2166,10 @@
     WistiaPlaylistIE,
     WistiaChannelIE,
 )
-from .wordpress import WordpressPlaylistEmbedIE
+from .wordpress import (
+    WordpressPlaylistEmbedIE,
+    WordpressMiniAudioPlayerEmbedIE,
+)
 from .worldstarhiphop import WorldStarHipHopIE
 from .wppilot import (
     WPPilotIE,
diff --git a/yt_dlp/extractor/common.py b/yt_dlp/extractor/common.py
index 31a45b37a..10d44d95a 100644
--- a/yt_dlp/extractor/common.py
+++ b/yt_dlp/extractor/common.py
@@ -66,6 +66,7 @@
     sanitize_filename,
     sanitize_url,
     sanitized_Request,
+    smuggle_url,
     str_or_none,
     str_to_int,
     strip_or_none,
@@ -1467,10 +1468,6 @@ def _json_ld(self, json_ld, video_id, fatal=True, expected_type=None):
         if not json_ld:
             return {}
         info = {}
-        if not isinstance(json_ld, (list, tuple, dict)):
-            return info
-        if isinstance(json_ld, dict):
-            json_ld = [json_ld]
 
         INTERACTION_TYPE_MAP = {
             'CommentAction': 'comment',
@@ -1570,11 +1567,13 @@ def extract_video_object(e):
             extract_chapter_information(e)
 
         def traverse_json_ld(json_ld, at_top_level=True):
-            for e in json_ld:
+            for e in variadic(json_ld):
+                if not isinstance(e, dict):
+                    continue
                 if at_top_level and '@context' not in e:
                     continue
                 if at_top_level and set(e.keys()) == {'@context', '@graph'}:
-                    traverse_json_ld(variadic(e['@graph'], allowed_types=(dict,)), at_top_level=False)
+                    traverse_json_ld(e['@graph'], at_top_level=False)
                     break
                 if expected_type is not None and not is_type(e, expected_type):
                     continue
@@ -1629,8 +1628,8 @@ def traverse_json_ld(json_ld, at_top_level=True):
                     continue
                 else:
                     break
-        traverse_json_ld(json_ld)
 
+        traverse_json_ld(json_ld)
         return filter_dict(info)
 
     def _search_nextjs_data(self, webpage, video_id, *, transform_source=None, fatal=True, **kw):
@@ -3875,6 +3874,12 @@ def _error_or_warning(self, err, _count=None, _retries=0, *, fatal=True):
     def RetryManager(self, **kwargs):
         return RetryManager(self.get_param('extractor_retries', 3), self._error_or_warning, **kwargs)
 
+    def _extract_generic_embeds(self, url, *args, info_dict={}, note='Extracting generic embeds', **kwargs):
+        display_id = traverse_obj(info_dict, 'display_id', 'id')
+        self.to_screen(f'{format_field(display_id, None, "%s: ")}{note}')
+        return self._downloader.get_info_extractor('Generic')._extract_embeds(
+            smuggle_url(url, {'block_ies': [self.ie_key()]}), *args, **kwargs)
+
     @classmethod
     def extract_from_webpage(cls, ydl, url, webpage):
         ie = (cls if isinstance(cls._extract_from_webpage, types.MethodType)
diff --git a/yt_dlp/extractor/generic.py b/yt_dlp/extractor/generic.py
index 73422f937..ad4e3c5b8 100644
--- a/yt_dlp/extractor/generic.py
+++ b/yt_dlp/extractor/generic.py
@@ -1,5 +1,6 @@
 import os
 import re
+import types
 import urllib.parse
 import xml.etree.ElementTree
 
@@ -2463,6 +2464,21 @@ class GenericIE(InfoExtractor):
                 'duration': 111.0,
             }
         },
+        {
+            'note': 'JSON LD with unexpected data type',
+            'url': 'https://www.autoweek.nl/autotests/artikel/porsche-911-gt3-rs-rij-impressie-2/',
+            'info_dict': {
+                'id': 'porsche-911-gt3-rs-rij-impressie-2',
+                'ext': 'mp4',
+                'title': 'Test: Porsche 911 GT3 RS',
+                'description': 'Je ziet het niet, maar het is er wel. Downforce, hebben we het dan over. En in de nieuwe Porsche 911 GT3 RS is er zelfs heel veel downforce.',
+                'timestamp': 1664920902,
+                'upload_date': '20221004',
+                'thumbnail': r're:^https://media.autoweek.nl/m/.+\.jpg$',
+                'age_limit': 0,
+                'direct': True,
+            }
+        }
     ]
 
     def report_following_redirect(self, new_url):
@@ -2594,6 +2610,7 @@ def _real_extract(self, url):
                     default_search += ':'
                 return self.url_result(default_search + url)
 
+        original_url = url
         url, smuggled_data = unsmuggle_url(url, {})
         force_videoid = None
         is_intentional = smuggled_data.get('to_generic')
@@ -2745,7 +2762,20 @@ def _real_extract(self, url):
             'age_limit': self._rta_search(webpage),
         })
 
-        domain_name = self._search_regex(r'^(?:https?://)?([^/]*)/.*', url, 'video uploader', default=None)
+        self._downloader.write_debug('Looking for embeds')
+        embeds = list(self._extract_embeds(original_url, webpage, urlh=full_response, info_dict=info_dict))
+        if len(embeds) == 1:
+            return {**info_dict, **embeds[0]}
+        elif embeds:
+            return self.playlist_result(embeds, **info_dict)
+        raise UnsupportedError(url)
+
+    def _extract_embeds(self, url, webpage, *, urlh=None, info_dict={}):
+        """Returns an iterator of video entries"""
+        info_dict = types.MappingProxyType(info_dict)  # Prevents accidental mutation
+        video_id = traverse_obj(info_dict, 'display_id', 'id') or self._generic_id(url)
+        url, smuggled_data = unsmuggle_url(url, {})
+        actual_url = urlh.geturl() if urlh else url
 
         # Sometimes embedded video player is hidden behind percent encoding
         # (e.g. https://github.com/ytdl-org/youtube-dl/issues/2448)
@@ -2761,31 +2791,19 @@ def _real_extract(self, url):
             lambda x: unescapeHTML(x.group(0)), webpage)
 
         # TODO: Move to respective extractors
-        self._downloader.write_debug('Looking for Brightcove embeds')
         bc_urls = BrightcoveLegacyIE._extract_brightcove_urls(webpage)
         if bc_urls:
-            entries = [{
-                '_type': 'url',
-                'url': smuggle_url(bc_url, {'Referer': url}),
-                'ie_key': 'BrightcoveLegacy'
-            } for bc_url in bc_urls]
-
-            return {
-                '_type': 'playlist',
-                'title': info_dict['title'],
-                'id': video_id,
-                'entries': entries,
-            }
+            return [self.url_result(smuggle_url(bc_url, {'Referer': url}), BrightcoveLegacyIE)
+                    for bc_url in bc_urls]
         bc_urls = BrightcoveNewIE._extract_brightcove_urls(self, webpage)
         if bc_urls:
-            return self.playlist_from_matches(
-                bc_urls, video_id, info_dict['title'],
-                getter=lambda x: smuggle_url(x, {'referrer': url}),
-                ie='BrightcoveNew')
+            return [self.url_result(smuggle_url(bc_url, {'Referer': url}), BrightcoveNewIE)
+                    for bc_url in bc_urls]
 
-        self._downloader.write_debug('Looking for embeds')
         embeds = []
         for ie in self._downloader._ies.values():
+            if ie.ie_key() in smuggled_data.get('block_ies', []):
+                continue
             gen = ie.extract_from_webpage(self._downloader, url, webpage)
             current_embeds = []
             try:
@@ -2794,35 +2812,26 @@ def _real_extract(self, url):
             except self.StopExtraction:
                 self.report_detected(f'{ie.IE_NAME} exclusive embed', len(current_embeds),
                                      embeds and 'discarding other embeds')
-                embeds = current_embeds
-                break
+                return current_embeds
             except StopIteration:
                 self.report_detected(f'{ie.IE_NAME} embed', len(current_embeds))
                 embeds.extend(current_embeds)
 
-        del current_embeds
-        if len(embeds) == 1:
-            return {**info_dict, **embeds[0]}
-        elif embeds:
-            return self.playlist_result(embeds, **info_dict)
+        if embeds:
+            return embeds
 
         jwplayer_data = self._find_jwplayer_data(
             webpage, video_id, transform_source=js_to_json)
         if jwplayer_data:
             if isinstance(jwplayer_data.get('playlist'), str):
                 self.report_detected('JW Player playlist')
-                return {
-                    **info_dict,
-                    '_type': 'url',
-                    'ie_key': 'JWPlatform',
-                    'url': jwplayer_data['playlist'],
-                }
+                return [self.url_result(jwplayer_data['playlist'], 'JWPlatform')]
             try:
                 info = self._parse_jwplayer_data(
                     jwplayer_data, video_id, require_title=False, base_url=url)
                 if traverse_obj(info, 'formats', ('entries', ..., 'formats')):
                     self.report_detected('JW Player data')
-                    return merge_dicts(info, info_dict)
+                    return [info]
             except ExtractorError:
                 # See https://github.com/ytdl-org/youtube-dl/pull/16735
                 pass
@@ -2850,7 +2859,7 @@ def _real_extract(self, url):
                     src_type = src_type.lower()
                 ext = determine_ext(src).lower()
                 if src_type == 'video/youtube':
-                    return self.url_result(src, YoutubeIE.ie_key())
+                    return [self.url_result(src, YoutubeIE.ie_key())]
                 if src_type == 'application/dash+xml' or ext == 'mpd':
                     fmts, subs = self._extract_mpd_formats_and_subtitles(
                         src, video_id, mpd_id='dash', fatal=False)
@@ -2868,7 +2877,7 @@ def _real_extract(self, url):
                         'ext': (mimetype2ext(src_type)
                                 or ext if ext in KNOWN_EXTENSIONS else 'mp4'),
                         'http_headers': {
-                            'Referer': full_response.geturl(),
+                            'Referer': actual_url,
                         },
                     })
             # https://docs.videojs.com/player#addRemoteTextTrack
@@ -2883,28 +2892,26 @@ def _real_extract(self, url):
                     'url': urllib.parse.urljoin(url, src),
                     'name': sub.get('label'),
                     'http_headers': {
-                        'Referer': full_response.geturl(),
+                        'Referer': actual_url,
                     },
                 })
             if formats or subtitles:
                 self.report_detected('video.js embed')
                 self._sort_formats(formats)
-                info_dict['formats'] = formats
-                info_dict['subtitles'] = subtitles
-                return info_dict
+                return [{'formats': formats, 'subtitles': subtitles}]
 
         # Looking for http://schema.org/VideoObject
         json_ld = self._search_json_ld(webpage, video_id, default={})
         if json_ld.get('url') not in (url, None):
             self.report_detected('JSON LD')
-            return merge_dicts({
+            return [merge_dicts({
                 '_type': 'video' if json_ld.get('ext') else 'url_transparent',
                 'url': smuggle_url(json_ld['url'], {
                     'force_videoid': video_id,
                     'to_generic': True,
                     'http_headers': {'Referer': url},
                 }),
-            }, json_ld, info_dict)
+            }, json_ld)]
 
         def check_video(vurl):
             if YoutubeIE.suitable(vurl):
@@ -2975,13 +2982,13 @@ def filter_video(urls):
 
                 self._sort_formats(formats)
 
-                return {
+                return [{
                     'id': flashvars['video_id'],
                     'display_id': display_id,
                     'title': title,
                     'thumbnail': thumbnail,
                     'formats': formats,
-                }
+                }]
         if not found:
             # Broaden the search a little bit
             found = filter_video(re.findall(r'[^A-Za-z0-9]?(?:file|source)=(http[^\'"&]*)', webpage))
@@ -3035,17 +3042,14 @@ def filter_video(urls):
                 webpage)
             if not found:
                 # Look also in Refresh HTTP header
-                refresh_header = full_response.headers.get('Refresh')
+                refresh_header = urlh and urlh.headers.get('Refresh')
                 if refresh_header:
                     found = re.search(REDIRECT_REGEX, refresh_header)
             if found:
                 new_url = urllib.parse.urljoin(url, unescapeHTML(found.group(1)))
                 if new_url != url:
                     self.report_following_redirect(new_url)
-                    return {
-                        '_type': 'url',
-                        'url': new_url,
-                    }
+                    return [self.url_result(new_url)]
                 else:
                     found = None
 
@@ -3056,10 +3060,12 @@ def filter_video(urls):
             embed_url = self._html_search_meta('twitter:player', webpage, default=None)
             if embed_url and embed_url != url:
                 self.report_detected('twitter:player iframe')
-                return self.url_result(embed_url)
+                return [self.url_result(embed_url)]
 
         if not found:
-            raise UnsupportedError(url)
+            return []
+
+        domain_name = self._search_regex(r'^(?:https?://)?([^/]*)/.*', url, 'video uploader', default=None)
 
         entries = []
         for video_url in orderedSet(found):
@@ -3075,7 +3081,7 @@ def filter_video(urls):
 
             video_id = os.path.splitext(video_id)[0]
             headers = {
-                'referer': full_response.geturl()
+                'referer': actual_url
             }
 
             entry_info_dict = {
@@ -3099,7 +3105,7 @@ def filter_video(urls):
             if ext == 'smil':
                 entry_info_dict = {**self._extract_smil_info(video_url, video_id), **entry_info_dict}
             elif ext == 'xspf':
-                return self.playlist_result(self._extract_xspf_playlist(video_url, video_id), video_id)
+                return [self._extract_xspf_playlist(video_url, video_id)]
             elif ext == 'm3u8':
                 entry_info_dict['formats'], entry_info_dict['subtitles'] = self._extract_m3u8_formats_and_subtitles(video_url, video_id, ext='mp4', headers=headers)
             elif ext == 'mpd':
@@ -3129,14 +3135,9 @@ def filter_video(urls):
 
             entries.append(entry_info_dict)
 
-        if len(entries) == 1:
-            return merge_dicts(entries[0], info_dict)
-        else:
+        if len(entries) > 1:
             for num, e in enumerate(entries, start=1):
                 # 'url' results don't have a title
                 if e.get('title') is not None:
                     e['title'] = '%s (%d)' % (e['title'], num)
-            return {
-                '_type': 'playlist',
-                'entries': entries,
-            }
+        return entries
diff --git a/yt_dlp/extractor/nosnl.py b/yt_dlp/extractor/nosnl.py
new file mode 100644
index 000000000..eba94c416
--- /dev/null
+++ b/yt_dlp/extractor/nosnl.py
@@ -0,0 +1,95 @@
+from .common import InfoExtractor
+from ..utils import parse_duration, parse_iso8601, traverse_obj
+
+
+class NOSNLArticleIE(InfoExtractor):
+    _VALID_URL = r'https?://nos\.nl/((?!video)(\w+/)?\w+/)\d+-(?P<display_id>[\w-]+)'
+    _TESTS = [
+        {
+            # only 1 video
+            'url': 'https://nos.nl/nieuwsuur/artikel/2440353-verzakking-door-droogte-dreigt-tot-een-miljoen-kwetsbare-huizen',
+            'info_dict': {
+                'id': '2440340',
+                'ext': 'mp4',
+                'description': 'md5:5f83185d902ac97af3af4bed7ece3db5',
+                'title': '\'We hebben een huis vol met scheuren\'',
+                'duration': 95.0,
+                'thumbnail': 'https://cdn.nos.nl/image/2022/08/12/887149/3840x2160a.jpg',
+            }
+        }, {
+            # more than 1 video
+            'url': 'https://nos.nl/artikel/2440409-vannacht-sliepen-weer-enkele-honderden-asielzoekers-in-ter-apel-buiten',
+            'info_dict': {
+                'id': '2440409',
+                'title': 'Vannacht sliepen weer enkele honderden asielzoekers in Ter Apel buiten',
+                'description': 'Er werd wel geprobeerd om kwetsbare migranten onderdak te bieden, zegt het COA.',
+                'tags': ['aanmeldcentrum', 'Centraal Orgaan opvang asielzoekers', 'COA', 'asielzoekers', 'Ter Apel'],
+                'modified_timestamp': 1660452773,
+                'modified_date': '20220814',
+                'upload_date': '20220813',
+                'thumbnail': 'https://cdn.nos.nl/image/2022/07/18/880346/1024x576a.jpg',
+                'timestamp': 1660401384,
+            },
+            'playlist_count': 2,
+        }, {
+            # audio + video
+            'url': 'https://nos.nl/artikel/2440789-wekdienst-16-8-groningse-acties-tien-jaar-na-zware-aardbeving-femke-bol-in-actie-op-ek-atletiek',
+            'info_dict': {
+                'id': '2440789',
+                'title': 'Wekdienst 16/8: Groningse acties tien jaar na zware aardbeving • Femke Bol in actie op EK atletiek ',
+                'description': 'Nieuws, weer, verkeer: met dit overzicht begin je geïnformeerd aan de dag.',
+                'tags': ['wekdienst'],
+                'modified_date': '20220816',
+                'modified_timestamp': 1660625449,
+                'timestamp': 1660625449,
+                'upload_date': '20220816',
+                'thumbnail': 'https://cdn.nos.nl/image/2022/08/16/888178/1024x576a.jpg',
+            },
+            'playlist_count': 2,
+        }
+    ]
+
+    def _entries(self, nextjs_json, display_id):
+        for item in nextjs_json['items']:
+            if item.get('type') == 'video':
+                formats, subtitle = self._extract_m3u8_formats_and_subtitles(
+                    traverse_obj(item, ('source', 'url')), display_id, ext='mp4')
+                yield {
+                    'id': str(item['id']),
+                    'title': item.get('title'),
+                    'description': item.get('description'),
+                    'formats': formats,
+                    'subtitles': subtitle,
+                    'duration': parse_duration(item.get('duration')),
+                    'thumbnails': [{
+                        'url': traverse_obj(image, ('url', ...), get_all=False),
+                        'width': image.get('width'),
+                        'height': image.get('height')
+                    } for image in traverse_obj(item, ('imagesByRatio', ...))[0]],
+                }
+
+            elif item.get('type') == 'audio':
+                yield {
+                    'id': str(item['id']),
+                    'title': item.get('title'),
+                    'url': traverse_obj(item, ('media', 'src')),
+                    'ext': 'mp3',
+                }
+
+    def _real_extract(self, url):
+        display_id = self._match_valid_url(url).group('display_id')
+        webpage = self._download_webpage(url, display_id)
+
+        nextjs_json = self._search_nextjs_data(webpage, display_id)['props']['pageProps']['data']
+        return {
+            '_type': 'playlist',
+            'entries': self._entries(nextjs_json, display_id),
+            'id': str(nextjs_json['id']),
+            'title': nextjs_json.get('title') or self._html_search_meta(['title', 'og:title', 'twitter:title'], webpage),
+            'description': (nextjs_json.get('description')
+                            or self._html_search_meta(['description', 'twitter:description', 'og:description'], webpage)),
+            'tags': nextjs_json.get('keywords'),
+            'modified_timestamp': parse_iso8601(nextjs_json.get('modifiedAt')),
+            'thumbnail': nextjs_json.get('shareImageSrc') or self._html_search_meta(['og:image', 'twitter:image'], webpage),
+            'timestamp': parse_iso8601(nextjs_json.get('publishedAt'))
+        }
diff --git a/yt_dlp/extractor/podbayfm.py b/yt_dlp/extractor/podbayfm.py
new file mode 100644
index 000000000..2a26fd2b3
--- /dev/null
+++ b/yt_dlp/extractor/podbayfm.py
@@ -0,0 +1,75 @@
+from .common import InfoExtractor
+from ..utils import OnDemandPagedList, int_or_none, jwt_decode_hs256, try_call
+
+
+def result_from_props(props, episode_id=None):
+    return {
+        'id': props.get('podcast_id') or episode_id,
+        'title': props.get('title'),
+        'url': props['mediaURL'],
+        'ext': 'mp3',
+        'thumbnail': try_call(lambda: jwt_decode_hs256(props['image'])['url']),
+        'timestamp': props.get('timestamp'),
+        'duration': int_or_none(props.get('duration')),
+    }
+
+
+class PodbayFMIE(InfoExtractor):
+    _VALID_URL = r'https?://podbay\.fm/p/[^/]*/e/(?P<id>[^/]*)/?(?:[\?#].*)?$'
+    _TESTS = [{
+        'url': 'https://podbay.fm/p/behind-the-bastards/e/1647338400',
+        'md5': '98b41285dcf7989d105a4ed0404054cf',
+        'info_dict': {
+            'id': '1647338400',
+            'title': 'Part One: Kissinger',
+            'ext': 'mp3',
+            'thumbnail': r're:^https?://.*\.jpg',
+            'timestamp': 1647338400,
+            'duration': 5001,
+            'upload_date': '20220315',
+        },
+    }]
+
+    def _real_extract(self, url):
+        episode_id = self._match_id(url)
+        webpage = self._download_webpage(url, episode_id)
+        data = self._search_nextjs_data(webpage, episode_id)
+        return result_from_props(data['props']['pageProps']['episode'], episode_id)
+
+
+class PodbayFMChannelIE(InfoExtractor):
+    _VALID_URL = r'https?://podbay\.fm/p/(?P<id>[^/]*)/?(?:[\?#].*)?$'
+    _TESTS = [{
+        'url': 'https://podbay.fm/p/behind-the-bastards',
+        'info_dict': {
+            'id': 'behind-the-bastards',
+            'title': 'Behind the Bastards',
+        },
+    }]
+    _PAGE_SIZE = 10
+
+    def _fetch_page(self, channel_id, pagenum):
+        return self._download_json(
+            f'https://podbay.fm/api/podcast?reverse=true&page={pagenum}&slug={channel_id}',
+            channel_id)['podcast']
+
+    @staticmethod
+    def _results_from_page(channel_id, page):
+        return [{
+            **result_from_props(e),
+            'extractor': PodbayFMIE.IE_NAME,
+            'extractor_key': PodbayFMIE.ie_key(),
+            # somehow they use timestamps as the episode identifier
+            'webpage_url': f'https://podbay.fm/p/{channel_id}/e/{e["timestamp"]}',
+        } for e in page['episodes']]
+
+    def _real_extract(self, url):
+        channel_id = self._match_id(url)
+
+        first_page = self._fetch_page(channel_id, 0)
+        entries = OnDemandPagedList(
+            lambda pagenum: self._results_from_page(
+                channel_id, self._fetch_page(channel_id, pagenum) if pagenum else first_page),
+            self._PAGE_SIZE)
+
+        return self.playlist_result(entries, channel_id, first_page.get('title'))
diff --git a/yt_dlp/extractor/sbs.py b/yt_dlp/extractor/sbs.py
index 6bb499930..45320339d 100644
--- a/yt_dlp/extractor/sbs.py
+++ b/yt_dlp/extractor/sbs.py
@@ -12,6 +12,7 @@ class SBSIE(InfoExtractor):
             ondemand(?:
                 /video/(?:single/)?|
                 /movie/[^/]+/|
+                /(?:tv|news)-series/(?:[^/]+/){3}|
                 .*?\bplay=|/watch/
             )|news/(?:embeds/)?video/
         )(?P<id>[0-9]+)'''
@@ -63,6 +64,12 @@ class SBSIE(InfoExtractor):
         'note': 'Live stream',
         'url': 'https://www.sbs.com.au/ondemand/video/1726824003663/sbs-24x7-live-stream-nsw',
         'only_matching': True,
+    }, {
+        'url': 'https://www.sbs.com.au/ondemand/news-series/dateline/dateline-2022/dateline-s2022-ep26/2072245827515',
+        'only_matching': True,
+    }, {
+        'url': 'https://www.sbs.com.au/ondemand/tv-series/the-handmaids-tale/season-5/the-handmaids-tale-s5-ep1/2065631811776',
+        'only_matching': True,
     }]
 
     def _real_extract(self, url):
diff --git a/yt_dlp/extractor/twitter.py b/yt_dlp/extractor/twitter.py
index d516aafa2..771a58ab4 100644
--- a/yt_dlp/extractor/twitter.py
+++ b/yt_dlp/extractor/twitter.py
@@ -1,6 +1,7 @@
 import re
 
 from .common import InfoExtractor
+from .periscope import PeriscopeBaseIE, PeriscopeIE
 from ..compat import (
     compat_HTTPError,
     compat_parse_qs,
@@ -8,25 +9,22 @@
     compat_urllib_parse_urlparse,
 )
 from ..utils import (
-    dict_get,
     ExtractorError,
-    format_field,
+    dict_get,
     float_or_none,
+    format_field,
     int_or_none,
+    make_archive_id,
+    str_or_none,
+    strip_or_none,
     traverse_obj,
     try_get,
-    strip_or_none,
     unified_timestamp,
     update_url_query,
     url_or_none,
     xpath_text,
 )
 
-from .periscope import (
-    PeriscopeBaseIE,
-    PeriscopeIE,
-)
-
 
 class TwitterBaseIE(InfoExtractor):
     _API_BASE = 'https://api.twitter.com/1.1/'
@@ -85,7 +83,7 @@ def _search_dimensions_in_video_url(a_format, video_url):
 
     def _call_api(self, path, video_id, query={}):
         headers = {
-            'Authorization': 'Bearer AAAAAAAAAAAAAAAAAAAAAPYXBAAAAAAACLXUNDekMxqa8h%2F40K4moUkGsoc%3DTYfbDKbT3jJPCEVnMYqilB28NHfOPqkca3qaAxGfsyKCs0wRbw',
+            'Authorization': 'Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA',
         }
         token = self._get_cookies(self._API_BASE).get('ct0')
         if token:
@@ -202,7 +200,8 @@ class TwitterIE(TwitterBaseIE):
     _TESTS = [{
         'url': 'https://twitter.com/freethenipple/status/643211948184596480',
         'info_dict': {
-            'id': '643211948184596480',
+            'id': '643211870443208704',
+            'display_id': '643211948184596480',
             'ext': 'mp4',
             'title': 'FREE THE NIPPLE - FTN supporters on Hollywood Blvd today!',
             'thumbnail': r're:^https?://.*\.jpg',
@@ -213,6 +212,12 @@ class TwitterIE(TwitterBaseIE):
             'timestamp': 1442188653,
             'upload_date': '20150913',
             'age_limit': 18,
+            'uploader_url': 'https://twitter.com/freethenipple',
+            'comment_count': int,
+            'repost_count': int,
+            'like_count': int,
+            'tags': [],
+            'age_limit': 18,
         },
     }, {
         'url': 'https://twitter.com/giphz/status/657991469417025536/photo/1',
@@ -232,6 +237,7 @@ class TwitterIE(TwitterBaseIE):
         'url': 'https://twitter.com/starwars/status/665052190608723968',
         'info_dict': {
             'id': '665052190608723968',
+            'display_id': '665052190608723968',
             'ext': 'mp4',
             'title': 'Star Wars - A new beginning is coming December 18. Watch the official 60 second #TV spot for #StarWars: #TheForceAwakens.',
             'description': 'A new beginning is coming December 18. Watch the official 60 second #TV spot for #StarWars: #TheForceAwakens. https://t.co/OkSqT2fjWJ',
@@ -239,6 +245,12 @@ class TwitterIE(TwitterBaseIE):
             'uploader': 'Star Wars',
             'timestamp': 1447395772,
             'upload_date': '20151113',
+            'uploader_url': 'https://twitter.com/starwars',
+            'comment_count': int,
+            'repost_count': int,
+            'like_count': int,
+            'tags': ['TV', 'StarWars', 'TheForceAwakens'],
+            'age_limit': 0,
         },
     }, {
         'url': 'https://twitter.com/BTNBrentYarina/status/705235433198714880',
@@ -251,6 +263,12 @@ class TwitterIE(TwitterBaseIE):
             'uploader': 'Brent Yarina',
             'timestamp': 1456976204,
             'upload_date': '20160303',
+            'uploader_url': 'https://twitter.com/BTNBrentYarina',
+            'comment_count': int,
+            'repost_count': int,
+            'like_count': int,
+            'tags': [],
+            'age_limit': 0,
         },
         'params': {
             # The same video as https://twitter.com/i/videos/tweet/705235433198714880
@@ -260,16 +278,23 @@ class TwitterIE(TwitterBaseIE):
     }, {
         'url': 'https://twitter.com/jaydingeer/status/700207533655363584',
         'info_dict': {
-            'id': '700207533655363584',
+            'id': '700207414000242688',
+            'display_id': '700207533655363584',
             'ext': 'mp4',
-            'title': 'simon vertugo - BEAT PROD: @suhmeduh #Damndaniel',
+            'title': 'jaydin donte geer - BEAT PROD: @suhmeduh #Damndaniel',
             'description': 'BEAT PROD: @suhmeduh  https://t.co/HBrQ4AfpvZ #Damndaniel https://t.co/byBooq2ejZ',
             'thumbnail': r're:^https?://.*\.jpg',
-            'uploader': 'simon vertugo',
-            'uploader_id': 'simonvertugo',
+            'uploader': 'jaydin donte geer',
+            'uploader_id': 'jaydingeer',
             'duration': 30.0,
             'timestamp': 1455777459,
             'upload_date': '20160218',
+            'uploader_url': 'https://twitter.com/jaydingeer',
+            'comment_count': int,
+            'repost_count': int,
+            'like_count': int,
+            'tags': ['Damndaniel'],
+            'age_limit': 0,
         },
     }, {
         'url': 'https://twitter.com/Filmdrunk/status/713801302971588609',
@@ -282,12 +307,19 @@ class TwitterIE(TwitterBaseIE):
             'uploader_id': '1004126642786242560',
             'timestamp': 1402826626,
             'upload_date': '20140615',
+            'thumbnail': r're:^https?://.*\.jpg',
+            'alt_title': 'Vine by TAKUMA',
+            'comment_count': int,
+            'repost_count': int,
+            'like_count': int,
+            'view_count': int,
         },
         'add_ie': ['Vine'],
     }, {
         'url': 'https://twitter.com/captainamerica/status/719944021058060289',
         'info_dict': {
-            'id': '719944021058060289',
+            'id': '717462543795523584',
+            'display_id': '719944021058060289',
             'ext': 'mp4',
             'title': 'Captain America - @King0fNerd Are you sure you made the right choice? Find out in theaters.',
             'description': '@King0fNerd Are you sure you made the right choice? Find out in theaters. https://t.co/GpgYi9xMJI',
@@ -296,6 +328,13 @@ class TwitterIE(TwitterBaseIE):
             'duration': 3.17,
             'timestamp': 1460483005,
             'upload_date': '20160412',
+            'uploader_url': 'https://twitter.com/CaptainAmerica',
+            'thumbnail': r're:^https?://.*\.jpg',
+            'comment_count': int,
+            'repost_count': int,
+            'like_count': int,
+            'tags': [],
+            'age_limit': 0,
         },
     }, {
         'url': 'https://twitter.com/OPP_HSD/status/779210622571536384',
@@ -307,6 +346,7 @@ class TwitterIE(TwitterBaseIE):
             'uploader_id': '1PmKqpJdOJQoY',
             'uploader': 'Sgt Kerry Schmidt - Ontario Provincial Police',
             'timestamp': 1474613214,
+            'thumbnail': r're:^https?://.*\.jpg',
         },
         'add_ie': ['Periscope'],
     }, {
@@ -327,7 +367,8 @@ class TwitterIE(TwitterBaseIE):
     }, {
         'url': 'https://twitter.com/i/web/status/910031516746514432',
         'info_dict': {
-            'id': '910031516746514432',
+            'id': '910030238373089285',
+            'display_id': '910031516746514432',
             'ext': 'mp4',
             'title': 'Préfet de Guadeloupe - [Direct] #Maria Le centre se trouve actuellement au sud de Basse-Terre. Restez confinés. Réfugiez-vous dans la pièce la + sûre.',
             'thumbnail': r're:^https?://.*\.jpg',
@@ -337,6 +378,12 @@ class TwitterIE(TwitterBaseIE):
             'duration': 47.48,
             'timestamp': 1505803395,
             'upload_date': '20170919',
+            'uploader_url': 'https://twitter.com/Prefet971',
+            'comment_count': int,
+            'repost_count': int,
+            'like_count': int,
+            'tags': ['Maria'],
+            'age_limit': 0,
         },
         'params': {
             'skip_download': True,  # requires ffmpeg
@@ -345,7 +392,8 @@ class TwitterIE(TwitterBaseIE):
         # card via api.twitter.com/1.1/videos/tweet/config
         'url': 'https://twitter.com/LisPower1/status/1001551623938805763',
         'info_dict': {
-            'id': '1001551623938805763',
+            'id': '1001551417340022785',
+            'display_id': '1001551623938805763',
             'ext': 'mp4',
             'title': 're:.*?Shep is on a roll today.*?',
             'thumbnail': r're:^https?://.*\.jpg',
@@ -355,6 +403,12 @@ class TwitterIE(TwitterBaseIE):
             'duration': 111.278,
             'timestamp': 1527623489,
             'upload_date': '20180529',
+            'uploader_url': 'https://twitter.com/LisPower1',
+            'comment_count': int,
+            'repost_count': int,
+            'like_count': int,
+            'tags': [],
+            'age_limit': 0,
         },
         'params': {
             'skip_download': True,  # requires ffmpeg
@@ -362,7 +416,8 @@ class TwitterIE(TwitterBaseIE):
     }, {
         'url': 'https://twitter.com/foobar/status/1087791357756956680',
         'info_dict': {
-            'id': '1087791357756956680',
+            'id': '1087791272830607360',
+            'display_id': '1087791357756956680',
             'ext': 'mp4',
             'title': 'Twitter - A new is coming.  Some of you got an opt-in to try it now. Check out the emoji button, quick keyboard shortcuts, upgraded trends, advanced search, and more. Let us know your thoughts!',
             'thumbnail': r're:^https?://.*\.jpg',
@@ -372,6 +427,12 @@ class TwitterIE(TwitterBaseIE):
             'duration': 61.567,
             'timestamp': 1548184644,
             'upload_date': '20190122',
+            'uploader_url': 'https://twitter.com/Twitter',
+            'comment_count': int,
+            'repost_count': int,
+            'like_count': int,
+            'tags': [],
+            'age_limit': 0,
         },
     }, {
         # not available in Periscope
@@ -382,13 +443,17 @@ class TwitterIE(TwitterBaseIE):
             'title': 'Vivi - Vivi founder @lior_rauchy announcing our new student feedback tool live at @EduTECH_AU #EduTECH2019',
             'uploader': 'Vivi',
             'uploader_id': '1eVjYOLGkGrQL',
+            'thumbnail': r're:^https?://.*\.jpg',
+            'tags': ['EduTECH2019'],
+            'view_count': int,
         },
         'add_ie': ['TwitterBroadcast'],
     }, {
         # unified card
         'url': 'https://twitter.com/BrooklynNets/status/1349794411333394432?s=20',
         'info_dict': {
-            'id': '1349794411333394432',
+            'id': '1349774757969989634',
+            'display_id': '1349794411333394432',
             'ext': 'mp4',
             'title': 'md5:d1c4941658e4caaa6cb579260d85dcba',
             'thumbnail': r're:^https?://.*\.jpg',
@@ -398,10 +463,57 @@ class TwitterIE(TwitterBaseIE):
             'duration': 324.484,
             'timestamp': 1610651040,
             'upload_date': '20210114',
+            'uploader_url': 'https://twitter.com/BrooklynNets',
+            'comment_count': int,
+            'repost_count': int,
+            'like_count': int,
+            'tags': [],
+            'age_limit': 0,
         },
         'params': {
             'skip_download': True,
         },
+    }, {
+        'url': 'https://twitter.com/oshtru/status/1577855540407197696',
+        'info_dict': {
+            'id': '1577855447914409984',
+            'display_id': '1577855540407197696',
+            'ext': 'mp4',
+            'title': 'oshtru \U0001faac\U0001f47d - gm \u2728\ufe0f now I can post image and video. nice update.',
+            'description': 'gm \u2728\ufe0f now I can post image and video. nice update. https://t.co/cG7XgiINOm',
+            'upload_date': '20221006',
+            'uploader': 'oshtru \U0001faac\U0001f47d',
+            'uploader_id': 'oshtru',
+            'uploader_url': 'https://twitter.com/oshtru',
+            'thumbnail': r're:^https?://.*\.jpg',
+            'duration': 30.03,
+            'timestamp': 1665025050.0,
+            'comment_count': int,
+            'repost_count': int,
+            'like_count': int,
+            'tags': [],
+            'age_limit': 0,
+        },
+        'params': {'skip_download': True},
+    }, {
+        'url': 'https://twitter.com/UltimaShadowX/status/1577719286659006464',
+        'info_dict': {
+            'id': '1577719286659006464',
+            'title': 'Ultima | #\u0432\u029f\u043c - Test',
+            'description': 'Test https://t.co/Y3KEZD7Dad',
+            'uploader': 'Ultima | #\u0432\u029f\u043c',
+            'uploader_id': 'UltimaShadowX',
+            'uploader_url': 'https://twitter.com/UltimaShadowX',
+            'upload_date': '20221005',
+            'timestamp': 1664992565.0,
+            'comment_count': int,
+            'repost_count': int,
+            'like_count': int,
+            'tags': [],
+            'age_limit': 0,
+        },
+        'playlist_count': 4,
+        'params': {'skip_download': True},
     }, {
         # Twitch Clip Embed
         'url': 'https://twitter.com/GunB1g/status/1163218564784017422',
@@ -479,6 +591,8 @@ def _real_extract(self, url):
         }
 
         def extract_from_video_info(media):
+            media_id = traverse_obj(media, 'id_str', 'id', expected_type=str_or_none)
+            self.write_debug(f'Extracting from video info: {media_id}')
             video_info = media.get('video_info') or {}
 
             formats = []
@@ -503,90 +617,107 @@ def add_thumbnail(name, size):
                     add_thumbnail(name, size)
                 add_thumbnail('orig', media.get('original_info') or {})
 
-            info.update({
+            return {
+                'id': media_id,
                 'formats': formats,
                 'subtitles': subtitles,
                 'thumbnails': thumbnails,
                 'duration': float_or_none(video_info.get('duration_millis'), 1000),
-            })
+            }
 
-        media = traverse_obj(status, ((None, 'quoted_status'), 'extended_entities', 'media', 0), get_all=False)
-        if media and media.get('type') != 'photo':
-            extract_from_video_info(media)
-        else:
-            card = status.get('card')
-            if card:
-                binding_values = card['binding_values']
-
-                def get_binding_value(k):
-                    o = binding_values.get(k) or {}
-                    return try_get(o, lambda x: x[x['type'].lower() + '_value'])
-
-                card_name = card['name'].split(':')[-1]
-                if card_name == 'player':
-                    info.update({
-                        '_type': 'url',
-                        'url': get_binding_value('player_url'),
-                    })
-                elif card_name == 'periscope_broadcast':
-                    info.update({
-                        '_type': 'url',
-                        'url': get_binding_value('url') or get_binding_value('player_url'),
-                        'ie_key': PeriscopeIE.ie_key(),
-                    })
-                elif card_name == 'broadcast':
-                    info.update({
-                        '_type': 'url',
-                        'url': get_binding_value('broadcast_url'),
-                        'ie_key': TwitterBroadcastIE.ie_key(),
-                    })
-                elif card_name == 'summary':
-                    info.update({
-                        '_type': 'url',
-                        'url': get_binding_value('card_url'),
-                    })
-                elif card_name == 'unified_card':
-                    media_entities = self._parse_json(get_binding_value('unified_card'), twid)['media_entities']
-                    extract_from_video_info(next(iter(media_entities.values())))
-                # amplify, promo_video_website, promo_video_convo, appplayer,
-                # video_direct_message, poll2choice_video, poll3choice_video,
-                # poll4choice_video, ...
-                else:
-                    is_amplify = card_name == 'amplify'
-                    vmap_url = get_binding_value('amplify_url_vmap') if is_amplify else get_binding_value('player_stream_url')
-                    content_id = get_binding_value('%s_content_id' % (card_name if is_amplify else 'player'))
-                    formats, subtitles = self._extract_formats_from_vmap_url(vmap_url, content_id or twid)
-                    self._sort_formats(formats)
-
-                    thumbnails = []
-                    for suffix in ('_small', '', '_large', '_x_large', '_original'):
-                        image = get_binding_value('player_image' + suffix) or {}
-                        image_url = image.get('url')
-                        if not image_url or '/player-placeholder' in image_url:
-                            continue
-                        thumbnails.append({
-                            'id': suffix[1:] if suffix else 'medium',
-                            'url': image_url,
-                            'width': int_or_none(image.get('width')),
-                            'height': int_or_none(image.get('height')),
-                        })
-
-                    info.update({
-                        'formats': formats,
-                        'subtitles': subtitles,
-                        'thumbnails': thumbnails,
-                        'duration': int_or_none(get_binding_value(
-                            'content_duration_seconds')),
-                    })
-            else:
-                expanded_url = try_get(status, lambda x: x['entities']['urls'][0]['expanded_url'])
-                if not expanded_url:
-                    raise ExtractorError("There's no video in this tweet.")
-                info.update({
+        def extract_from_card_info(card):
+            if not card:
+                return
+
+            self.write_debug(f'Extracting from card info: {card.get("url")}')
+            binding_values = card['binding_values']
+
+            def get_binding_value(k):
+                o = binding_values.get(k) or {}
+                return try_get(o, lambda x: x[x['type'].lower() + '_value'])
+
+            card_name = card['name'].split(':')[-1]
+            if card_name == 'player':
+                return {
                     '_type': 'url',
-                    'url': expanded_url,
-                })
-        return info
+                    'url': get_binding_value('player_url'),
+                }
+            elif card_name == 'periscope_broadcast':
+                return {
+                    '_type': 'url',
+                    'url': get_binding_value('url') or get_binding_value('player_url'),
+                    'ie_key': PeriscopeIE.ie_key(),
+                }
+            elif card_name == 'broadcast':
+                return {
+                    '_type': 'url',
+                    'url': get_binding_value('broadcast_url'),
+                    'ie_key': TwitterBroadcastIE.ie_key(),
+                }
+            elif card_name == 'summary':
+                return {
+                    '_type': 'url',
+                    'url': get_binding_value('card_url'),
+                }
+            elif card_name == 'unified_card':
+                media_entities = self._parse_json(get_binding_value('unified_card'), twid)['media_entities']
+                media = traverse_obj(media_entities, ..., expected_type=dict, get_all=False)
+                return extract_from_video_info(media)
+            # amplify, promo_video_website, promo_video_convo, appplayer,
+            # video_direct_message, poll2choice_video, poll3choice_video,
+            # poll4choice_video, ...
+            else:
+                is_amplify = card_name == 'amplify'
+                vmap_url = get_binding_value('amplify_url_vmap') if is_amplify else get_binding_value('player_stream_url')
+                content_id = get_binding_value('%s_content_id' % (card_name if is_amplify else 'player'))
+                formats, subtitles = self._extract_formats_from_vmap_url(vmap_url, content_id or twid)
+                self._sort_formats(formats)
+
+                thumbnails = []
+                for suffix in ('_small', '', '_large', '_x_large', '_original'):
+                    image = get_binding_value('player_image' + suffix) or {}
+                    image_url = image.get('url')
+                    if not image_url or '/player-placeholder' in image_url:
+                        continue
+                    thumbnails.append({
+                        'id': suffix[1:] if suffix else 'medium',
+                        'url': image_url,
+                        'width': int_or_none(image.get('width')),
+                        'height': int_or_none(image.get('height')),
+                    })
+
+                return {
+                    'formats': formats,
+                    'subtitles': subtitles,
+                    'thumbnails': thumbnails,
+                    'duration': int_or_none(get_binding_value(
+                        'content_duration_seconds')),
+                }
+
+        media_path = ((None, 'quoted_status'), 'extended_entities', 'media', lambda _, m: m['type'] != 'photo')
+        videos = map(extract_from_video_info, traverse_obj(status, media_path, expected_type=dict))
+        entries = [{**info, **data, 'display_id': twid} for data in videos if data]
+
+        data = extract_from_card_info(status.get('card'))
+        if data:
+            entries.append({**info, **data, 'display_id': twid})
+
+        if not entries:
+            expanded_url = traverse_obj(status, ('entities', 'urls', 0, 'expanded_url'), expected_type=url_or_none)
+            if not expanded_url or expanded_url == url:
+                raise ExtractorError('No video could be found in this tweet', expected=True)
+
+            return self.url_result(expanded_url, display_id=twid, **info)
+
+        entries[0]['_old_archive_ids'] = [make_archive_id(self, twid)]
+
+        if len(entries) == 1:
+            return entries[0]
+
+        for index, entry in enumerate(entries, 1):
+            entry['title'] += f' #{index}'
+
+        return self.playlist_result(entries, **info)
 
 
 class TwitterAmplifyIE(TwitterBaseIE):
diff --git a/yt_dlp/extractor/viu.py b/yt_dlp/extractor/viu.py
index 63b6fd3a1..d27091c94 100644
--- a/yt_dlp/extractor/viu.py
+++ b/yt_dlp/extractor/viu.py
@@ -164,12 +164,17 @@ class ViuOTTIE(InfoExtractor):
         },
         'skip': 'Geo-restricted to Singapore',
     }, {
-        'url': 'http://www.viu.com/ott/hk/zh-hk/vod/7123/%E5%A4%A7%E4%BA%BA%E5%A5%B3%E5%AD%90',
+        'url': 'https://www.viu.com/ott/hk/zh-hk/vod/430078/%E7%AC%AC%E5%85%AD%E6%84%9F-3',
         'info_dict': {
-            'id': '7123',
+            'id': '430078',
             'ext': 'mp4',
-            'title': '這就是我的生活之道',
-            'description': 'md5:4eb0d8b08cf04fcdc6bbbeb16043434f',
+            'title': '大韓民國的1%',
+            'description': 'md5:74d6db47ddd9ddb9c89a05739103ccdb',
+            'episode_number': 1,
+            'duration': 6614,
+            'episode': '大韓民國的1%',
+            'series': '第六感 3',
+            'thumbnail': 'https://d2anahhhmp1ffz.cloudfront.net/1313295781/d2b14f48d008ef2f3a9200c98d8e9b63967b9cc2',
         },
         'params': {
             'skip_download': 'm3u8 download',
@@ -177,11 +182,12 @@ class ViuOTTIE(InfoExtractor):
         },
         'skip': 'Geo-restricted to Hong Kong',
     }, {
-        'url': 'https://www.viu.com/ott/hk/zh-hk/vod/68776/%E6%99%82%E5%B0%9A%E5%AA%BD%E5%92%AA',
-        'playlist_count': 12,
+        'url': 'https://www.viu.com/ott/hk/zh-hk/vod/444666/%E6%88%91%E7%9A%84%E5%AE%A4%E5%8F%8B%E6%98%AF%E4%B9%9D%E5%B0%BE%E7%8B%90',
+        'playlist_count': 16,
         'info_dict': {
-            'id': '3916',
-            'title': '時尚媽咪',
+            'id': '23807',
+            'title': '我的室友是九尾狐',
+            'description': 'md5:b42c95f2b4a316cdd6ae14ca695f33b9',
         },
         'params': {
             'skip_download': 'm3u8 download',
@@ -363,13 +369,19 @@ def download_playback():
 
         subtitles = {}
         for sub in video_data.get('subtitle') or []:
-            sub_url = sub.get('url')
-            if not sub_url:
-                continue
-            subtitles.setdefault(sub.get('name'), []).append({
-                'url': sub_url,
-                'ext': 'srt',
-            })
+            lang = sub.get('name') or 'und'
+            if sub.get('url'):
+                subtitles.setdefault(lang, []).append({
+                    'url': sub['url'],
+                    'ext': 'srt',
+                    'name': f'Spoken text for {lang}',
+                })
+            if sub.get('second_subtitle_url'):
+                subtitles.setdefault(f'{lang}_ost', []).append({
+                    'url': sub['second_subtitle_url'],
+                    'ext': 'srt',
+                    'name': f'On-screen text for {lang}',
+                })
 
         title = strip_or_none(video_data.get('synopsis'))
         return {
diff --git a/yt_dlp/extractor/vk.py b/yt_dlp/extractor/vk.py
index 69f518b69..0c856e2b0 100644
--- a/yt_dlp/extractor/vk.py
+++ b/yt_dlp/extractor/vk.py
@@ -536,7 +536,7 @@ def _real_extract(self, url):
 class VKUserVideosIE(VKBaseIE):
     IE_NAME = 'vk:uservideos'
     IE_DESC = "VK - User's Videos"
-    _VALID_URL = r'https?://(?:(?:m|new)\.)?vk\.com/video/@(?P<id>[^?$#/&]+)(?!\?.*\bz=video)(?:[/?#&](?:.*?\bsection=(?P<section>\w+))?|$)'
+    _VALID_URL = r'https?://(?:(?:m|new)\.)?vk\.com/video/(?:playlist/)?(?P<id>[^?$#/&]+)(?!\?.*\bz=video)(?:[/?#&](?:.*?\bsection=(?P<section>\w+))?|$)'
     _TEMPLATE_URL = 'https://vk.com/videos'
     _TESTS = [{
         'url': 'https://vk.com/video/@mobidevices',
@@ -550,6 +550,13 @@ class VKUserVideosIE(VKBaseIE):
             'id': '-17892518_uploaded',
         },
         'playlist_mincount': 182,
+    }, {
+        'url': 'https://vk.com/video/playlist/-174476437_2',
+        'info_dict': {
+            'id': '-174476437_2',
+            'title': 'Анонсы'
+        },
+        'playlist_mincount': 108,
     }]
     _VIDEO = collections.namedtuple('Video', ['owner_id', 'id'])
 
@@ -584,11 +591,19 @@ def _entries(self, page_id, section):
     def _real_extract(self, url):
         u_id, section = self._match_valid_url(url).groups()
         webpage = self._download_webpage(url, u_id)
-        page_id = self._search_regex(r'data-owner-id\s?=\s?"([^"]+)"', webpage, 'page_id')
+
+        if u_id.startswith('@'):
+            page_id = self._search_regex(r'data-owner-id\s?=\s?"([^"]+)"', webpage, 'page_id')
+        elif '_' in u_id:
+            page_id, section = u_id.split('_', 1)
+        else:
+            raise ExtractorError('Invalid URL', expected=True)
+
         if not section:
             section = 'all'
 
-        return self.playlist_result(self._entries(page_id, section), '%s_%s' % (page_id, section))
+        playlist_title = clean_html(get_element_by_class('VideoInfoPanel__title', webpage))
+        return self.playlist_result(self._entries(page_id, section), '%s_%s' % (page_id, section), playlist_title)
 
 
 class VKWallPostIE(VKBaseIE):
diff --git a/yt_dlp/extractor/wordpress.py b/yt_dlp/extractor/wordpress.py
index e90ae6c1e..53820b57a 100644
--- a/yt_dlp/extractor/wordpress.py
+++ b/yt_dlp/extractor/wordpress.py
@@ -1,6 +1,10 @@
+import re
+
 from .common import InfoExtractor
 from ..utils import (
+    extract_attributes,
     get_elements_by_class,
+    get_elements_text_and_html_by_attribute,
     int_or_none,
     parse_duration,
     traverse_obj,
@@ -67,3 +71,84 @@ def _extract_from_webpage(self, url, webpage):
                 'width': int_or_none(traverse_obj(track, ('dimensions', 'original', 'width'))),
             } for track in traverse_obj(playlist_json, ('tracks', ...), expected_type=dict)]
             yield self.playlist_result(entries, self._generic_id(url) + f'-wp-playlist-{i+1}', 'Wordpress Playlist')
+
+
+class WordpressMiniAudioPlayerEmbedIE(InfoExtractor):
+    # WordPress MB Mini Player Plugin
+    # https://wordpress.org/plugins/wp-miniaudioplayer/
+    # Note: This is for the WordPress plugin version only.
+    _VALID_URL = False
+    IE_NAME = 'wordpress:mb.miniAudioPlayer'
+    _WEBPAGE_TESTS = [{
+        # Version 1.8.10: https://plugins.trac.wordpress.org/browser/wp-miniaudioplayer/tags/1.8.10
+        'url': 'https://news.samsung.com/global/over-the-horizon-the-evolution-of-the-samsung-galaxy-brand-sound',
+        'info_dict': {
+            'id': 'over-the-horizon-the-evolution-of-the-samsung-galaxy-brand-sound',
+            'title': 'Over the Horizon: The Evolution of the Samsung Galaxy Brand Sound',
+            'age_limit': 0,
+            'thumbnail': 'https://img.global.news.samsung.com/global/wp-content/uploads/2015/04/OTH_Main_Title-e1429612467870.jpg',
+            'description': 'md5:bc3dd738d1f11d9232e94e6629983bf7',
+        },
+        'playlist': [{
+            'info_dict': {
+                'id': 'over_the_horizon_2013',
+                'ext': 'mp3',
+                'title': 'Over the Horizon 2013',
+                'url': 'http://news.samsung.com/global/wp-content/uploads/ringtones/over_the_horizon_2013.mp3'
+            }
+        }],
+        'playlist_count': 6,
+        'params': {'skip_download': True}
+    }, {
+        # Version 1.9.3: https://plugins.trac.wordpress.org/browser/wp-miniaudioplayer/tags/1.9.3
+        'url': 'https://www.booksontape.com/collections/audiobooks-with-teacher-guides/',
+        'info_dict': {
+            'id': 'audiobooks-with-teacher-guides',
+            'title': 'Audiobooks with Teacher Guides | Books on Tape',
+            'age_limit': 0,
+            'thumbnail': 'https://www.booksontape.com/wp-content/uploads/2016/09/bot-logo-1200x630.jpg',
+        },
+        'playlist_mincount': 12
+    }, {
+        # Version 1.9.7: https://plugins.trac.wordpress.org/browser/wp-miniaudioplayer/tags/1.9.7
+        # But has spaces around href filter
+        'url': 'https://www.estudiords.com.br/temas/',
+        'info_dict': {
+            'id': 'temas',
+            'title': 'Temas Variados',
+            'age_limit': 0,
+            'timestamp': float,
+            'upload_date': str,
+            'thumbnail': 'https://www.estudiords.com.br/wp-content/uploads/2021/03/LOGO-TEMAS.png',
+            'description': 'md5:ab24d6a7ed0312ad2d466e721679f5a0',
+        },
+        'playlist_mincount': 30
+    }]
+
+    def _extract_from_webpage(self, url, webpage):
+        # Common function for the WordPress plugin version only.
+        mb_player_params = self._search_regex(
+            r'function\s*initializeMiniAudioPlayer\(\){[^}]+jQuery([^;]+)\.mb_miniPlayer',
+            webpage, 'mb player params', default=None)
+        if not mb_player_params:
+            return
+        # v1.55 - 1.9.3 has "a[href*='.mp3'] ,a[href*='.m4a']"
+        # v1.9.4+ has "a[href*='.mp3']" only
+        file_exts = re.findall(r'a\[href\s*\*=\s*\'\.([a-zA-Z\d]+)\'', mb_player_params)
+        if not file_exts:
+            return
+
+        candidates = get_elements_text_and_html_by_attribute(
+            'href', rf'(?:[^\"\']+\.(?:{"|".join(file_exts)}))', webpage, escape_value=False, tag='a')
+
+        for title, html in candidates:
+            attrs = extract_attributes(html)
+            # XXX: not tested - have not found any example of it being used
+            if any(c in (attrs.get('class') or '') for c in re.findall(r'\.not\("\.([^"]+)', mb_player_params)):
+                continue
+            href = attrs['href']
+            yield {
+                'id': self._generic_id(href),
+                'title': title or self._generic_title(href),
+                'url': href,
+            }
diff --git a/yt_dlp/extractor/youtube.py b/yt_dlp/extractor/youtube.py
index 6f153bb3c..35e41753a 100644
--- a/yt_dlp/extractor/youtube.py
+++ b/yt_dlp/extractor/youtube.py
@@ -2832,7 +2832,7 @@ def _decrypt_nsig(self, s, video_id, player_url):
             self.report_warning(
                 f'Native nsig extraction failed: Trying with PhantomJS\n'
                 f'         n = {s} ; player = {player_url}', video_id)
-            self.write_debug(e)
+            self.write_debug(e, only_once=True)
 
             args, func_body = func_code
             ret = jsi.execute(
diff --git a/yt_dlp/jsinterp.py b/yt_dlp/jsinterp.py
index 4caad6f74..e25997129 100644
--- a/yt_dlp/jsinterp.py
+++ b/yt_dlp/jsinterp.py
@@ -236,7 +236,7 @@ def _regex_flags(cls, expr):
 
     @staticmethod
     def _separate(expr, delim=',', max_split=None):
-        OP_CHARS = '+-*/%&|^=<>!,;{}:'
+        OP_CHARS = '+-*/%&|^=<>!,;{}:['
         if not expr:
             return
         counters = {k: 0 for k in _MATCHING_PARENS.values()}
@@ -246,7 +246,9 @@ def _separate(expr, delim=',', max_split=None):
             if not in_quote and char in _MATCHING_PARENS:
                 counters[_MATCHING_PARENS[char]] += 1
             elif not in_quote and char in counters:
-                counters[char] -= 1
+                # Something's wrong if we get negative, but ignore it anyway
+                if counters[char]:
+                    counters[char] -= 1
             elif not escaping:
                 if char in _QUOTES and in_quote in (char, None):
                     if in_quote or after_op or char != '/':
diff --git a/yt_dlp/utils.py b/yt_dlp/utils.py
index cb14908c7..6cfbcdb8d 100644
--- a/yt_dlp/utils.py
+++ b/yt_dlp/utils.py
@@ -408,7 +408,7 @@ def get_elements_html_by_attribute(*args, **kwargs):
     return [whole for _, whole in get_elements_text_and_html_by_attribute(*args, **kwargs)]
 
 
-def get_elements_text_and_html_by_attribute(attribute, value, html, escape_value=True):
+def get_elements_text_and_html_by_attribute(attribute, value, html, *, tag=r'[\w:.-]+', escape_value=True):
     """
     Return the text (content) and the html (whole) of the tag with the specified
     attribute in the passed HTML document
@@ -419,7 +419,7 @@ def get_elements_text_and_html_by_attribute(attribute, value, html, escape_value
     value = re.escape(value) if escape_value else value
 
     partial_element_re = rf'''(?x)
-        <(?P<tag>[a-zA-Z0-9:._-]+)
+        <(?P<tag>{tag})
          (?:\s(?:[^>"']|"[^"]*"|'[^']*')*)?
          \s{re.escape(attribute)}\s*=\s*(?P<_q>['"]{quote})(?-x:{value})(?P=_q)
         '''
@@ -2574,7 +2574,9 @@ def strftime_or_none(timestamp, date_format, default=None):
     datetime_object = None
     try:
         if isinstance(timestamp, (int, float)):  # unix timestamp
-            datetime_object = datetime.datetime.utcfromtimestamp(timestamp)
+            # Using naive datetime here can break timestamp() in Windows
+            # Ref: https://github.com/yt-dlp/yt-dlp/issues/5185, https://github.com/python/cpython/issues/94414
+            datetime_object = datetime.datetime.fromtimestamp(timestamp, datetime.timezone.utc)
         elif isinstance(timestamp, str):  # assume YYYYMMDD
             datetime_object = datetime.datetime.strptime(timestamp, '%Y%m%d')
         date_format = re.sub(  # Support %s on windows
@@ -5499,7 +5501,8 @@ def jwt_encode_hs256(payload_data, key, headers={}):
 # can be extended in future to verify the signature and parse header and return the algorithm used if it's not HS256
 def jwt_decode_hs256(jwt):
     header_b64, payload_b64, signature_b64 = jwt.split('.')
-    payload_data = json.loads(base64.urlsafe_b64decode(payload_b64))
+    # add trailing ='s that may have been stripped, superfluous ='s are ignored
+    payload_data = json.loads(base64.urlsafe_b64decode(f'{payload_b64}==='))
     return payload_data
 
 


Test patch:
diff --git a/test/test_cookies.py b/test/test_cookies.py
index 61619df29..4155bcbf5 100644
--- a/test/test_cookies.py
+++ b/test/test_cookies.py
@@ -277,9 +277,24 @@ def test_lenient_parsing(self):
                 "a=b; invalid; Version=1; c=d",
                 {"a": "b", "c": "d"},
             ),
+            (
+                "Reset morsel after invalid to not capture attributes",
+                "a=b; $invalid; $Version=1; c=d",
+                {"a": "b", "c": "d"},
+            ),
             (
                 "Continue after non-flag attribute without value",
                 "a=b; path; Version=1; c=d",
                 {"a": "b", "c": "d"},
             ),
+            (
+                "Allow cookie attributes with `$` prefix",
+                'Customer="WILE_E_COYOTE"; $Version=1; $Secure; $Path=/acme',
+                {"Customer": ("WILE_E_COYOTE", {"version": "1", "secure": True, "path": "/acme"})},
+            ),
+            (
+                "Invalid Morsel keys should not result in an error",
+                "Key=Value; [Invalid]=Value; Another=Value",
+                {"Key": "Value", "Another": "Value"},
+            ),
         )
diff --git a/test/test_jsinterp.py b/test/test_jsinterp.py
index 92ef532f5..3c4391c4a 100644
--- a/test/test_jsinterp.py
+++ b/test/test_jsinterp.py
@@ -392,6 +392,11 @@ def test_regex(self):
         ''')
         self.assertEqual(jsi.call_function('x').pattern, r',][}",],()}(\[)')
 
+        jsi = JSInterpreter(R'''
+        function x() { let a=[/[)\\]/]; return a[0]; }
+        ''')
+        self.assertEqual(jsi.call_function('x').pattern, r'[)\\]')
+
     def test_char_code_at(self):
         jsi = JSInterpreter('function x(i){return "test".charCodeAt(i)}')
         self.assertEqual(jsi.call_function('x', 0), 116)
diff --git a/test/test_utils.py b/test/test_utils.py
index 90085a9c0..df23f1f47 100644
--- a/test/test_utils.py
+++ b/test/test_utils.py
@@ -1679,6 +1679,9 @@ def test_get_elements_text_and_html_by_attribute(self):
         self.assertEqual(list(get_elements_text_and_html_by_attribute('class', 'foo', html)), [])
         self.assertEqual(list(get_elements_text_and_html_by_attribute('class', 'no-such-foo', html)), [])
 
+        self.assertEqual(list(get_elements_text_and_html_by_attribute(
+            'class', 'foo', '<a class="foo">nice</a><span class="foo">nice</span>', tag='a')), [('nice', '<a class="foo">nice</a>')])
+
     GET_ELEMENT_BY_TAG_TEST_STRING = '''
     random text lorem ipsum</p>
     <div>
diff --git a/test/test_youtube_signature.py b/test/test_youtube_signature.py
index c3dcb4d68..6d753fbf0 100644
--- a/test/test_youtube_signature.py
+++ b/test/test_youtube_signature.py
@@ -130,6 +130,10 @@
         'https://www.youtube.com/s/player/5a3b6271/player_ias.vflset/en_US/base.js',
         'B2j7f_UPT4rfje85Lu_e', 'm5DmNymaGQ5RdQ',
     ),
+    (
+        'https://www.youtube.com/s/player/7a062b77/player_ias.vflset/en_US/base.js',
+        'NRcE3y3mVtm_cV-W', 'VbsCYUATvqlt5w',
+    ),
 ]