Repository: yt-dlp/yt-dlp
Pull request #5176 (2022-10-08 02:59:38)

[extractor/generic] Separate embed extractor into own function
@coletdjnz Check that this works well enough your requirement. Will merge after you have confirmed

* I have tried to make minimal changes. But there is still changes to some of the extraction. But these only ever add more metadata, so it should be fine. Besides, there are changes that would eventually be needed for #4517 anyway

* It's not viable to make `_extract_embeds` a `classmethod`. But we still need to access it from other extractors. One option would be to move it into `common.py`, but then any related functions will also have to be moved there. Instead, Ive opted to create the `_extract_generic_embeds` wrapper. After #4517, if this function ends up being simple enough, we could still move it into common, replacing the current wrapper

* ~~I added a check to prevent accidental recursion of `extract_from_webpage`. Idk if it will ever actually be useful though. More importantly, do you think there is any use-case where it's harmful?~~



Patch:
diff --git a/yt_dlp/extractor/_extractors.py b/yt_dlp/extractor/_extractors.py
index 2b603f4f2..e5be35716 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,
@@ -1565,6 +1566,7 @@
 from .sapo import SapoIE
 from .savefrom import SaveFromIE
 from .sbs import SBSIE
+from .screen9 import Screen9IE
 from .screencast import ScreencastIE
 from .screencastomatic import ScreencastOMaticIE
 from .scrippsnetworks import (
@@ -2163,7 +2165,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/screen9.py b/yt_dlp/extractor/screen9.py
new file mode 100644
index 000000000..eae652af7
--- /dev/null
+++ b/yt_dlp/extractor/screen9.py
@@ -0,0 +1,63 @@
+from .common import InfoExtractor
+from ..utils import traverse_obj
+
+
+class Screen9IE(InfoExtractor):
+    _VALID_URL = r'https?://(?:\w+\.screen9\.(?:tv|com)|play\.su\.se)/(?:embed|media)/(?P<id>[^?#/]+)'
+    _TESTS = [
+        {
+            'url': 'https://api.screen9.com/embed/8kTNEjvoXGM33dmWwF0uDA',
+            'md5': 'd60d23f8980583b930724b01fa6ddb41',
+            'info_dict': {
+                'id': '8kTNEjvoXGM33dmWwF0uDA',
+                'ext': 'mp4',
+                'title': 'Östersjön i förändrat klimat',
+                'thumbnail': r're:^https?://.+\.jpg',
+            },
+        },
+        {
+            'url': 'https://folkhogskolekanalen.screen9.tv/media/gy35PKLHe-5K29RYHga2bw/ett-starkare-samhalle-en-snabbguide-om-sveriges-folkhogskolor',
+            'md5': 'c9389806e78573ea34fc48b6f94465dc',
+            'info_dict': {
+                'id': 'gy35PKLHe-5K29RYHga2bw',
+                'ext': 'mp4',
+                'title': 'Ett starkare samhälle - en snabbguide om Sveriges folkhögskolor',
+                'thumbnail': r're:^https?://.+\.jpg',
+            },
+        },
+        {
+            'url': 'https://play.su.se/media/H1YA0EYNCxiesrSU1kaRBQ/baltic-breakfast',
+            'md5': '2b817647c3058002526269deff4c0683',
+            'info_dict': {
+                'id': 'H1YA0EYNCxiesrSU1kaRBQ',
+                'ext': 'mp4',
+                'title': 'Baltic Breakfast',
+                'thumbnail': r're:^https?://.+\.jpg',
+            },
+        },
+    ]
+
+    def _real_extract(self, url):
+        video_id = self._match_id(url)
+        webpage = self._download_webpage(f'https://api.screen9.com/embed/{video_id}', video_id)
+        config = self._search_json(r'var\s+config\s*=', webpage, 'config', video_id)
+        formats, subtitles = self._extract_m3u8_formats_and_subtitles(
+            traverse_obj(config, ('src', lambda _, v: v['type'] == 'application/x-mpegURL', 'src'), get_all=False),
+            video_id, ext='mp4')
+        formats.append({
+            'url': traverse_obj(config, ('src', lambda _, v: v['type'] == 'video/mp4', 'src'), get_all=False),
+            'format': 'mp4',
+        })
+
+        self._sort_formats(formats)
+        return {
+            'id': video_id,
+            'title': traverse_obj(
+                config,
+                ('plugins', (('title', 'title'), ('googleAnalytics', 'title'), ('share', 'mediaTitle'))),
+                get_all=False),
+            'description': traverse_obj(config, ('plugins', 'title', 'description')),
+            'thumbnail': traverse_obj(config, ('poster')),
+            'formats': formats,
+            'subtitles': subtitles,
+        }
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/utils.py b/yt_dlp/utils.py
index d0be7f19e..5a88a928d 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)
         '''
@@ -5294,7 +5294,7 @@ def load_plugins(name, suffix, namespace):
 
 
 def traverse_obj(
-        obj, *paths, default=None, expected_type=None, get_all=True,
+        obj, *paths, default=NO_DEFAULT, expected_type=None, get_all=True,
         casesense=True, is_user_input=False, traverse_string=False):
     """
     Safely traverse nested `dict`s and `Sequence`s
@@ -5304,13 +5304,15 @@ def traverse_obj(
     "value"
 
     Each of the provided `paths` is tested and the first producing a valid result will be returned.
+    The next path will also be tested if the path branched but no results could be found.
+    Supported values for traversal are `Mapping`, `Sequence` and `re.Match`.
     A value of None is treated as the absence of a value.
 
     The paths will be wrapped in `variadic`, so that `'key'` is conveniently the same as `('key', )`.
 
     The keys in the path can be one of:
         - `None`:           Return the current object.
-        - `str`/`int`:      Return `obj[key]`.
+        - `str`/`int`:      Return `obj[key]`. For `re.Match, return `obj.group(key)`.
         - `slice`:          Branch out and return all values in `obj[key]`.
         - `Ellipsis`:       Branch out and return a list of all values.
         - `tuple`/`list`:   Branch out and return a list of all matching values.
@@ -5321,7 +5323,7 @@ def traverse_obj(
         - `dict`            Transform the current object and return a matching dict.
                             Read as: `{key: traverse_obj(obj, path) for key, path in dct.items()}`.
 
-        `tuple`, `list`, and `dict` all support nested paths and branches
+        `tuple`, `list`, and `dict` all support nested paths and branches.
 
     @params paths           Paths which to traverse by.
     @param default          Value to return if the paths do not match.
@@ -5342,6 +5344,7 @@ def traverse_obj(
     @returns                The result of the object traversal.
                             If successful, `get_all=True`, and the path branches at least once,
                             then a list of results is returned instead.
+                            A list is always returned if the last path branches and no `default` is given.
     """
     is_sequence = lambda x: isinstance(x, collections.abc.Sequence) and not isinstance(x, (str, bytes))
     casefold = lambda k: k.casefold() if isinstance(k, str) else k
@@ -5368,6 +5371,8 @@ def apply_key(key, obj):
                 yield from obj.values()
             elif is_sequence(obj):
                 yield from obj
+            elif isinstance(obj, re.Match):
+                yield from obj.groups()
             elif traverse_string:
                 yield from str(obj)
 
@@ -5376,6 +5381,8 @@ def apply_key(key, obj):
                 iter_obj = enumerate(obj)
             elif isinstance(obj, collections.abc.Mapping):
                 iter_obj = obj.items()
+            elif isinstance(obj, re.Match):
+                iter_obj = enumerate((obj.group(), *obj.groups()))
             elif traverse_string:
                 iter_obj = enumerate(str(obj))
             else:
@@ -5385,12 +5392,23 @@ def apply_key(key, obj):
         elif isinstance(key, dict):
             iter_obj = ((k, _traverse_obj(obj, v)) for k, v in key.items())
             yield {k: v if v is not None else default for k, v in iter_obj
-                   if v is not None or default is not None}
+                   if v is not None or default is not NO_DEFAULT}
 
-        elif isinstance(obj, dict):
+        elif isinstance(obj, collections.abc.Mapping):
             yield (obj.get(key) if casesense or (key in obj)
                    else next((v for k, v in obj.items() if casefold(k) == key), None))
 
+        elif isinstance(obj, re.Match):
+            if isinstance(key, int) or casesense:
+                with contextlib.suppress(IndexError):
+                    yield obj.group(key)
+                    return
+
+            if not isinstance(key, str):
+                return
+
+            yield next((v for k, v in obj.groupdict().items() if casefold(k) == key), None)
+
         else:
             if is_user_input:
                 key = (int_or_none(key) if ':' not in key
@@ -5426,18 +5444,22 @@ def apply_path(start_obj, path):
 
         return has_branched, objs
 
-    def _traverse_obj(obj, path):
+    def _traverse_obj(obj, path, use_list=True):
         has_branched, results = apply_path(obj, path)
         results = LazyList(x for x in map(type_test, results) if x is not None)
-        if results:
-            return results.exhaust() if get_all and has_branched else results[0]
 
-    for path in paths:
-        result = _traverse_obj(obj, path)
+        if get_all and has_branched:
+            return results.exhaust() if results or use_list else None
+
+        return results[0] if results else None
+
+    for index, path in enumerate(paths, 1):
+        use_list = default is NO_DEFAULT and index == len(paths)
+        result = _traverse_obj(obj, path, use_list)
         if result is not None:
             return result
 
-    return default
+    return None if default is NO_DEFAULT else default
 
 
 def traverse_dict(dictn, keys, casesense=True):


Test patch:
diff --git a/test/test_utils.py b/test/test_utils.py
index 69313564a..df23f1f47 100644
--- a/test/test_utils.py
+++ b/test/test_utils.py
@@ -2,6 +2,7 @@
 
 # Allow direct execution
 import os
+import re
 import sys
 import unittest
 
@@ -1678,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>
@@ -1890,6 +1894,7 @@ def test_traverse_obj(self):
                 {'index': 2},
                 {'index': 3},
             ),
+            'dict': {},
         }
 
         # Test base functionality
@@ -1926,11 +1931,15 @@ def test_traverse_obj(self):
 
         # Test alternative paths
         self.assertEqual(traverse_obj(_TEST_DATA, 'fail', 'str'), 'str',
-                         msg='multiple `path_list` should be treated as alternative paths')
+                         msg='multiple `paths` should be treated as alternative paths')
         self.assertEqual(traverse_obj(_TEST_DATA, 'str', 100), 'str',
                          msg='alternatives should exit early')
         self.assertEqual(traverse_obj(_TEST_DATA, 'fail', 'fail'), None,
                          msg='alternatives should return `default` if exhausted')
+        self.assertEqual(traverse_obj(_TEST_DATA, (..., 'fail'), 100), 100,
+                         msg='alternatives should track their own branching return')
+        self.assertEqual(traverse_obj(_TEST_DATA, ('dict', ...), ('data', ...)), list(_TEST_DATA['data']),
+                         msg='alternatives on empty objects should search further')
 
         # Test branch and path nesting
         self.assertEqual(traverse_obj(_TEST_DATA, ('urls', (3, 0), 'url')), ['https://www.example.com/0'],
@@ -1963,8 +1972,16 @@ def test_traverse_obj(self):
         self.assertEqual(traverse_obj(_TEST_DATA, {0: ('urls', ((1, ('fail', 'url')), (0, 'url')))}),
                          {0: ['https://www.example.com/1', 'https://www.example.com/0']},
                          msg='tripple nesting in dict path should be treated as branches')
-        self.assertEqual(traverse_obj({}, {0: 1}, default=...), {0: ...},
-                         msg='do not remove `None` values when dict key')
+        self.assertEqual(traverse_obj(_TEST_DATA, {0: 'fail'}), {},
+                         msg='remove `None` values when dict key')
+        self.assertEqual(traverse_obj(_TEST_DATA, {0: 'fail'}, default=...), {0: ...},
+                         msg='do not remove `None` values if `default`')
+        self.assertEqual(traverse_obj(_TEST_DATA, {0: 'dict'}), {0: {}},
+                         msg='do not remove empty values when dict key')
+        self.assertEqual(traverse_obj(_TEST_DATA, {0: 'dict'}, default=...), {0: {}},
+                         msg='do not remove empty values when dict key and a default')
+        self.assertEqual(traverse_obj(_TEST_DATA, {0: ('dict', ...)}), {0: []},
+                         msg='if branch in dict key not successful, return `[]`')
 
         # Testing default parameter behavior
         _DEFAULT_DATA = {'None': None, 'int': 0, 'list': []}
@@ -1981,7 +1998,13 @@ def test_traverse_obj(self):
         self.assertEqual(traverse_obj(_DEFAULT_DATA, ('list', 10)), None,
                          msg='`IndexError` should result in `default`')
         self.assertEqual(traverse_obj(_DEFAULT_DATA, (..., 'fail'), default=1), 1,
-                         msg='if branched but not successfull return `default`, not `[]`')
+                         msg='if branched but not successful return `default` if defined, not `[]`')
+        self.assertEqual(traverse_obj(_DEFAULT_DATA, (..., 'fail'), default=None), None,
+                         msg='if branched but not successful return `default` even if `default` is `None`')
+        self.assertEqual(traverse_obj(_DEFAULT_DATA, (..., 'fail')), [],
+                         msg='if branched but not successful return `[]`, not `default`')
+        self.assertEqual(traverse_obj(_DEFAULT_DATA, ('list', ...)), [],
+                         msg='if branched but object is empty return `[]`, not `default`')
 
         # Testing expected_type behavior
         _EXPECTED_TYPE_DATA = {'str': 'str', 'int': 0}
@@ -2061,6 +2084,25 @@ def test_traverse_obj(self):
         with self.assertRaises(TypeError, msg='too many params should result in error'):
             traverse_obj(_IS_USER_INPUT_DATA, ('range8', ':::'), is_user_input=True)
 
+        # Test re.Match as input obj
+        mobj = re.fullmatch(r'0(12)(?P<group>3)(4)?', '0123')
+        self.assertEqual(traverse_obj(mobj, ...), [x for x in mobj.groups() if x is not None],
+                         msg='`...` on a `re.Match` should give its `groups()`')
+        self.assertEqual(traverse_obj(mobj, lambda k, _: k in (0, 2)), ['0123', '3'],
+                         msg='function on a `re.Match` should give groupno, value starting at 0')
+        self.assertEqual(traverse_obj(mobj, 'group'), '3',
+                         msg='str key on a `re.Match` should give group with that name')
+        self.assertEqual(traverse_obj(mobj, 2), '3',
+                         msg='int key on a `re.Match` should give group with that name')
+        self.assertEqual(traverse_obj(mobj, 'gRoUp', casesense=False), '3',
+                         msg='str key on a `re.Match` should respect casesense')
+        self.assertEqual(traverse_obj(mobj, 'fail'), None,
+                         msg='failing str key on a `re.Match` should return `default`')
+        self.assertEqual(traverse_obj(mobj, 'gRoUpS', casesense=False), None,
+                         msg='failing str key on a `re.Match` should return `default`')
+        self.assertEqual(traverse_obj(mobj, 8), None,
+                         msg='failing int key on a `re.Match` should return `default`')
+
 
 if __name__ == '__main__':
     unittest.main()