Repository: yt-dlp/yt-dlp

Issue:
VrtNu download fails on some videos
### Checklist

- [X] I'm reporting a broken site
- [X] I've verified that I'm running yt-dlp version **2021.12.27**. ([update instructions](https://github.com/yt-dlp/yt-dlp#update))
- [X] I've checked that all provided URLs are alive and playable in a browser
- [X] I've checked that all URLs and arguments with special characters are [properly quoted or escaped](https://github.com/ytdl-org/youtube-dl#video-url-contains-an-ampersand-and-im-getting-some-strange-output-1-2839-or-v-is-not-recognized-as-an-internal-or-external-command)
- [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)
- [ ] 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

Belgium

### Description

Although most downloads on vrtnu (with credentials) still work, one series fails: (run from local git, but same output from ytdlp)

```
[debug] Command-line config: ['https://www.vrt.be/vrtnu/a-z/raymond-goethals--magie-in-marseille/1/raymond-goethals--magie-in-marseille-s1a1/', '-v', '--netrc']
[debug] Encodings: locale UTF-8, fs utf-8, out utf-8, err utf-8, pref UTF-8
[debug] yt-dlp version 2021.12.27 [6223f67a8] (source)
[debug] Lazy loading extractors is disabled
[debug] Plugins: ['SamplePluginIE', 'SamplePluginPP']
[debug] Git HEAD: e540c56f3
[debug] Python version 3.9.7 (CPython 64bit) - Linux-5.13.0-22-generic-x86_64-with-glibc2.34
[debug] exe versions: ffmpeg 4.4 (setts), ffprobe 4.4, rtmpdump 2.4
[debug] Optional libraries: Cryptodome, secretstorage, sqlite
[debug] Proxy map: {}
[VrtNU] Logging in
[VrtNU] Requesting XSRF Token
[VrtNU] Performing login
[debug] [VrtNU] Extracting URL: https://www.vrt.be/vrtnu/a-z/raymond-goethals--magie-in-marseille/1/raymond-goethals--magie-in-marseille-s1a1/
[VrtNU] raymond-goethals--magie-in-marseille-s1a1: Downloading webpage
[debug] [Canvas] Extracting URL: https://mediazone.vrt.be/api/v1/vrtvideo/assets/pbs-pub-975eb3d7-86af-4a92-81cc-80074a58c8ff$vid-2602567b-2db5-4b8d-a533-51f24688bd5c
[Canvas] pbs-pub-975eb3d7-86af-4a92-81cc-80074a58c8ff$vid-2602567b-2db5-4b8d-a533-51f24688bd5c: refreshtoken: Retrieve vrtnutoken
[Canvas] pbs-pub-975eb3d7-86af-4a92-81cc-80074a58c8ff$vid-2602567b-2db5-4b8d-a533-51f24688bd5c: Downloading token
[Canvas] pbs-pub-975eb3d7-86af-4a92-81cc-80074a58c8ff$vid-2602567b-2db5-4b8d-a533-51f24688bd5c: Downloading video JSON
ERROR: [Canvas] pbs-pub-975eb3d7-86af-4a92-81cc-80074a58c8ff$vid-2602567b-2db5-4b8d-a533-51f24688bd5c: None
  File "/home/xyz/Development/yt-dlp/yt_dlp/extractor/common.py", line 599, in extract
    ie_result = self._real_extract(url)
  File "/home/xyz/Development/yt-dlp/yt_dlp/extractor/canvas.py", line 85, in _real_extract
    raise ExtractorError(data.get('message') or code, expected=True)
```

I debugged this issue, and apparently, this video has an empty `title` element in its json: 
`{'duration': 3121060, 'skinType': 'vod', 'title': '', 'shortDescription': None, 'drm': 'vrt|2021-12-28T.....Z|.../...', 'drmExpired': '2021-12-28...', 'aspectRatio': '16:9', 'posterImageUrl': None, 'channelId': None, 'targetUrls': [{'type': 'mpeg_dash', 'url': 'https://remix-cf.lwc.vrtcdn.be/remix/0603b9c1-b117-4caf-a4c1-xxx/remix.ism/.mpd'}, {'type': 'hls_aes', 'url': 'https://remix-cf.lwc.vrtcdn.be/remix/0603b9c1-b117-4caf-a4c1-xxx/remix_aes.ism/.m3u8'}, {'type': 'hls', 'url': 'https://remix-cf.lwc.vrtcdn.be/remix/0603b9c1-b117-4caf-a4c1-xx/remix.ism/.m3u8'}], 'playlist': {'content': [{'eventType': 'ONBOARDING', 'duration': 3000, 'skippable': False}, {'eventType': 'STANDARD', 'duration': 3121060, 'skippable': True}]}}`
 
The test on https://github.com/yt-dlp/yt-dlp/blob/aeec0e44e243dc625c8ff19964a624188d43e84b/yt_dlp/extractor/canvas.py#L79  seems to be the culprit. I think the test is not needed as issues with logon will be catched more early during the Token-logon steps. 


### Verbose log

```shell
[debug] Command-line config: ['https://www.vrt.be/vrtnu/a-z/raymond-goethals--magie-in-marseille/1/raymond-goethals--magie-in-marseille-s1a1/', '-v', '--netrc']
[debug] Encodings: locale UTF-8, fs utf-8, out utf-8, err utf-8, pref UTF-8
[debug] yt-dlp version 2021.12.27 [6223f67a8] (source)
[debug] Lazy loading extractors is disabled
[debug] Plugins: ['SamplePluginIE', 'SamplePluginPP']
[debug] Git HEAD: e540c56f3
[debug] Python version 3.9.7 (CPython 64bit) - Linux-5.13.0-22-generic-x86_64-with-glibc2.34
[debug] exe versions: ffmpeg 4.4 (setts), ffprobe 4.4, rtmpdump 2.4
[debug] Optional libraries: Cryptodome, secretstorage, sqlite
[debug] Proxy map: {}
[VrtNU] Logging in
[VrtNU] Requesting XSRF Token
[VrtNU] Performing login
[debug] [VrtNU] Extracting URL: https://www.vrt.be/vrtnu/a-z/raymond-goethals--magie-in-marseille/1/raymond-goethals--magie-in-marseille-s1a1/
[VrtNU] raymond-goethals--magie-in-marseille-s1a1: Downloading webpage
[debug] [Canvas] Extracting URL: https://mediazone.vrt.be/api/v1/vrtvideo/assets/pbs-pub-975eb3d7-86af-4a92-81cc-80074a58c8ff$vid-2602567b-2db5-4b8d-a533-51f24688bd5c
[Canvas] pbs-pub-975eb3d7-86af-4a92-81cc-80074a58c8ff$vid-2602567b-2db5-4b8d-a533-51f24688bd5c: refreshtoken: Retrieve vrtnutoken
[Canvas] pbs-pub-975eb3d7-86af-4a92-81cc-80074a58c8ff$vid-2602567b-2db5-4b8d-a533-51f24688bd5c: Downloading token
[Canvas] pbs-pub-975eb3d7-86af-4a92-81cc-80074a58c8ff$vid-2602567b-2db5-4b8d-a533-51f24688bd5c: Downloading video JSON
ERROR: [Canvas] pbs-pub-975eb3d7-86af-4a92-81cc-80074a58c8ff$vid-2602567b-2db5-4b8d-a533-51f24688bd5c: None
  File "/home/xyz/Development/yt-dlp/yt_dlp/extractor/common.py", line 599, in extract
    ie_result = self._real_extract(url)
  File "/home/xyz/Development/yt-dlp/yt_dlp/extractor/canvas.py", line 85, in _real_extract
    raise ExtractorError(data.get('message') or code, expected=True)
```


Discussion:
pukkandan: The title returned by extractor should never be empty
pgaig: Hi @pukkandan , thanks for your feedback

If I understand the code correctly, this empty `title` field is returned by the vrtnu/canvas backend. 
https://github.com/yt-dlp/yt-dlp/blob/master/yt_dlp/extractor/canvas.py#L73-L85

The check on `title` here only seems to be an error-check to identify if the logon / authorization was OK. 
I also tried to remove the complete `if`-block completely > this works:
https://github.com/yt-dlp/yt-dlp/blob/master/yt_dlp/extractor/canvas.py#L79-L85

In other words the `if` is dead code as the actual logon is handled elsewhere: 
https://github.com/yt-dlp/yt-dlp/blob/master/yt_dlp/extractor/canvas.py#L284-L315 

This PRQ tried to be as conservative a possible, because there are multiple URLs supported for that exractor. 

Please advise on how to tackle the issue. 


Patch:
diff --git a/README.md b/README.md
index 324a1565a..3490721b9 100644
--- a/README.md
+++ b/README.md
@@ -1090,7 +1090,7 @@ # OUTPUT TEMPLATE
 
 1. **Default**: A literal default value can be specified for when the field is empty using a `|` separator. This overrides `--output-na-template`. Eg: `%(uploader|Unknown)s`
 
-1. **More Conversions**: In addition to the normal format types `diouxXeEfFgGcrs`, `B`, `j`, `l`, `q`, `D`, `S` can be used for converting to **B**ytes, **j**son (flag `#` for pretty-printing), a comma separated **l**ist (flag `#` for `\n` newline-separated), a string **q**uoted for the terminal (flag `#` to split a list into different arguments), to add **D**ecimal suffixes (Eg: 10M), and to **S**anitize as filename (flag `#` for restricted), respectively
+1. **More Conversions**: In addition to the normal format types `diouxXeEfFgGcrs`, `B`, `j`, `l`, `q`, `D`, `S` can be used for converting to **B**ytes, **j**son (flag `#` for pretty-printing), a comma separated **l**ist (flag `#` for `\n` newline-separated), a string **q**uoted for the terminal (flag `#` to split a list into different arguments), to add **D**ecimal suffixes (Eg: 10M) (flag `#` to use 1024 as factor), and to **S**anitize as filename (flag `#` for restricted), respectively
 
 1. **Unicode normalization**: The format type `U` can be used for NFC [unicode normalization](https://docs.python.org/3/library/unicodedata.html#unicodedata.normalize). The alternate form flag (`#`) changes the normalization to NFD and the conversion flag `+` can be used for NFKC/NFKD compatibility equivalence normalization. Eg: `%(title)+.100U` is NFKC
 
diff --git a/yt_dlp/YoutubeDL.py b/yt_dlp/YoutubeDL.py
index 1d1429b5f..9cec43680 100644
--- a/yt_dlp/YoutubeDL.py
+++ b/yt_dlp/YoutubeDL.py
@@ -1166,7 +1166,9 @@ def create_key(outer_mobj):
                     'NF%s%s' % ('K' if '+' in flags else '', 'D' if '#' in flags else 'C'),
                     value), str_fmt
             elif fmt[-1] == 'D':  # decimal suffix
-                value, fmt = format_decimal_suffix(value, f'%{fmt[:-1]}f%s' if fmt[:-1] else '%d%s'), 's'
+                num_fmt, fmt = fmt[:-1].replace('#', ''), 's'
+                value = format_decimal_suffix(value, f'%{num_fmt}f%s' if num_fmt else '%d%s',
+                                              factor=1024 if '#' in flags else 1000)
             elif fmt[-1] == 'S':  # filename sanitization
                 value, fmt = filename_sanitizer(initial_field, value, restricted='#' in flags), str_fmt
             elif fmt[-1] == 'c':
diff --git a/yt_dlp/cookies.py b/yt_dlp/cookies.py
index 74e133bc9..d376d1460 100644
--- a/yt_dlp/cookies.py
+++ b/yt_dlp/cookies.py
@@ -790,7 +790,7 @@ def _get_linux_keyring_password(browser_keyring_name, keyring, logger):
     # Chromium supports a flag: --password-store=<basic|gnome|kwallet> so the automatic detection
     # will not be sufficient in all cases.
 
-    keyring = _LinuxKeyring[keyring] or _choose_linux_keyring(logger)
+    keyring = _LinuxKeyring[keyring] if keyring else _choose_linux_keyring(logger)
     logger.debug(f'Chosen keyring: {keyring.name}')
 
     if keyring == _LinuxKeyring.KWALLET:
diff --git a/yt_dlp/extractor/bilibili.py b/yt_dlp/extractor/bilibili.py
index 2cb01ff83..4223a80ff 100644
--- a/yt_dlp/extractor/bilibili.py
+++ b/yt_dlp/extractor/bilibili.py
@@ -1,5 +1,6 @@
 # coding: utf-8
 
+import base64
 import hashlib
 import itertools
 import functools
@@ -724,14 +725,30 @@ def _real_extract(self, url):
 
 class BiliIntlBaseIE(InfoExtractor):
     _API_URL = 'https://api.bilibili.tv/intl/gateway'
+    _NETRC_MACHINE = 'biliintl'
 
     def _call_api(self, endpoint, *args, **kwargs):
-        return self._download_json(self._API_URL + endpoint, *args, **kwargs)['data']
+        json = self._download_json(self._API_URL + endpoint, *args, **kwargs)
+        if json.get('code'):
+            if json['code'] in (10004004, 10004005, 10023006):
+                self.raise_login_required()
+            elif json['code'] == 10004001:
+                self.raise_geo_restricted()
+            else:
+                if json.get('message') and str(json['code']) != json['message']:
+                    errmsg = f'{kwargs.get("errnote", "Unable to download JSON metadata")}: {self.IE_NAME} said: {json["message"]}'
+                else:
+                    errmsg = kwargs.get('errnote', 'Unable to download JSON metadata')
+                if kwargs.get('fatal'):
+                    raise ExtractorError(errmsg)
+                else:
+                    self.report_warning(errmsg)
+        return json.get('data')
 
     def json2srt(self, json):
         data = '\n\n'.join(
             f'{i + 1}\n{srt_subtitles_timecode(line["from"])} --> {srt_subtitles_timecode(line["to"])}\n{line["content"]}'
-            for i, line in enumerate(json['body']))
+            for i, line in enumerate(json['body']) if line.get('content'))
         return data
 
     def _get_subtitles(self, ep_id):
@@ -755,16 +772,6 @@ def _get_subtitles(self, ep_id):
     def _get_formats(self, ep_id):
         video_json = self._call_api(f'/web/playurl?ep_id={ep_id}&platform=web', ep_id,
                                     note='Downloading video formats', errnote='Unable to download video formats')
-        if video_json.get('code'):
-            if video_json['code'] in (10004004, 10004005, 10023006):
-                self.raise_login_required(method='cookies')
-            elif video_json['code'] == 10004001:
-                self.raise_geo_restricted()
-            elif video_json.get('message') and str(video_json['code']) != video_json['message']:
-                raise ExtractorError(
-                    f'Unable to download video formats: {self.IE_NAME} said: {video_json["message"]}', expected=True)
-            else:
-                raise ExtractorError('Unable to download video formats')
         video_json = video_json['playurl']
         formats = []
         for vid in video_json.get('video') or []:
@@ -810,10 +817,49 @@ def _extract_ep_info(self, episode_data, ep_id):
             'extractor_key': BiliIntlIE.ie_key(),
         }
 
+    def _login(self):
+        username, password = self._get_login_info()
+        if username is None:
+            return
+
+        try:
+            from Cryptodome.PublicKey import RSA
+            from Cryptodome.Cipher import PKCS1_v1_5
+        except ImportError:
+            try:
+                from Crypto.PublicKey import RSA
+                from Crypto.Cipher import PKCS1_v1_5
+            except ImportError:
+                raise ExtractorError('pycryptodomex not found. Please install', expected=True)
+
+        key_data = self._download_json(
+            'https://passport.bilibili.tv/x/intl/passport-login/web/key?lang=en-US', None,
+            note='Downloading login key', errnote='Unable to download login key')['data']
+
+        public_key = RSA.importKey(key_data['key'])
+        password_hash = PKCS1_v1_5.new(public_key).encrypt((key_data['hash'] + password).encode('utf-8'))
+        login_post = self._download_json(
+            'https://passport.bilibili.tv/x/intl/passport-login/web/login/password?lang=en-US', None, data=urlencode_postdata({
+                'username': username,
+                'password': base64.b64encode(password_hash).decode('ascii'),
+                'keep_me': 'true',
+                's_locale': 'en_US',
+                'isTrusted': 'true'
+            }), note='Logging in', errnote='Unable to log in')
+        if login_post.get('code'):
+            if login_post.get('message'):
+                raise ExtractorError(f'Unable to log in: {self.IE_NAME} said: {login_post["message"]}', expected=True)
+            else:
+                raise ExtractorError('Unable to log in')
+
+    def _real_initialize(self):
+        self._login()
+
 
 class BiliIntlIE(BiliIntlBaseIE):
     _VALID_URL = r'https?://(?:www\.)?bili(?:bili\.tv|intl\.com)/(?:[a-z]{2}/)?play/(?P<season_id>\d+)/(?P<id>\d+)'
     _TESTS = [{
+        # Bstation page
         'url': 'https://www.bilibili.tv/en/play/34613/341736',
         'info_dict': {
             'id': '341736',
@@ -823,6 +869,7 @@ class BiliIntlIE(BiliIntlBaseIE):
             'episode_number': 2,
         }
     }, {
+        # Non-Bstation page
         'url': 'https://www.bilibili.tv/en/play/1033760/11005006',
         'info_dict': {
             'id': '11005006',
@@ -831,6 +878,17 @@ class BiliIntlIE(BiliIntlBaseIE):
             'thumbnail': r're:^https://pic\.bstarstatic\.com/ogv/.+\.png$',
             'episode_number': 3,
         }
+    }, {
+        # Subtitle with empty content
+        'url': 'https://www.bilibili.tv/en/play/1005144/10131790',
+        'info_dict': {
+            'id': '10131790',
+            'ext': 'mp4',
+            'title': 'E140 - Two Heartbeats: Kabuto\'s Trap',
+            'thumbnail': r're:^https://pic\.bstarstatic\.com/ogv/.+\.png$',
+            'episode_number': 140,
+        },
+        'skip': 'According to the copyright owner\'s request, you may only watch the video after you log in.'
     }, {
         'url': 'https://www.biliintl.com/en/play/34613/341736',
         'only_matching': True,
diff --git a/yt_dlp/extractor/canvas.py b/yt_dlp/extractor/canvas.py
index e97c91929..82fded4e1 100644
--- a/yt_dlp/extractor/canvas.py
+++ b/yt_dlp/extractor/canvas.py
@@ -76,7 +76,7 @@ def _real_extract(self, url):
                     'vrtPlayerToken': vrtPlayerToken,
                     'client': 'null',
                 }, expected_status=400)
-            if not data.get('title'):
+            if 'title' not in data:
                 code = data.get('code')
                 if code == 'AUTHENTICATION_REQUIRED':
                     self.raise_login_required()
@@ -84,7 +84,8 @@ def _real_extract(self, url):
                     self.raise_geo_restricted(countries=['BE'])
                 raise ExtractorError(data.get('message') or code, expected=True)
 
-        title = data['title']
+        # Note: The title may be an empty string
+        title = data['title'] or f'{site_id} {video_id}'
         description = data.get('description')
 
         formats = []
diff --git a/yt_dlp/extractor/common.py b/yt_dlp/extractor/common.py
index 3260399cb..3821b7183 100644
--- a/yt_dlp/extractor/common.py
+++ b/yt_dlp/extractor/common.py
@@ -2712,11 +2712,15 @@ def extract_Initialization(source):
                     mime_type = representation_attrib['mimeType']
                     content_type = representation_attrib.get('contentType', mime_type.split('/')[0])
 
-                    codecs = representation_attrib.get('codecs', '')
+                    codecs = parse_codecs(representation_attrib.get('codecs', ''))
                     if content_type not in ('video', 'audio', 'text'):
                         if mime_type == 'image/jpeg':
                             content_type = mime_type
-                        elif codecs.split('.')[0] == 'stpp':
+                        elif codecs['vcodec'] != 'none':
+                            content_type = 'video'
+                        elif codecs['acodec'] != 'none':
+                            content_type = 'audio'
+                        elif codecs.get('tcodec', 'none') != 'none':
                             content_type = 'text'
                         elif mimetype2ext(mime_type) in ('tt', 'dfxp', 'ttml', 'xml', 'json'):
                             content_type = 'text'
@@ -2762,8 +2766,8 @@ def extract_Initialization(source):
                             'format_note': 'DASH %s' % content_type,
                             'filesize': filesize,
                             'container': mimetype2ext(mime_type) + '_dash',
+                            **codecs
                         }
-                        f.update(parse_codecs(codecs))
                     elif content_type == 'text':
                         f = {
                             'ext': mimetype2ext(mime_type),
diff --git a/yt_dlp/extractor/drooble.py b/yt_dlp/extractor/drooble.py
new file mode 100644
index 000000000..058425095
--- /dev/null
+++ b/yt_dlp/extractor/drooble.py
@@ -0,0 +1,116 @@
+# coding: utf-8
+from __future__ import unicode_literals
+
+import json
+
+from .common import InfoExtractor
+from ..utils import (
+    ExtractorError,
+    int_or_none,
+    try_get,
+)
+
+
+class DroobleIE(InfoExtractor):
+    _VALID_URL = r'''(?x)https?://drooble\.com/(?:
+        (?:(?P<user>[^/]+)/)?(?P<kind>song|videos|music/albums)/(?P<id>\d+)|
+        (?P<user_2>[^/]+)/(?P<kind_2>videos|music))
+    '''
+    _TESTS = [{
+        'url': 'https://drooble.com/song/2858030',
+        'md5': '5ffda90f61c7c318dc0c3df4179eb064',
+        'info_dict': {
+            'id': '2858030',
+            'ext': 'mp3',
+            'title': 'Skankocillin',
+            'upload_date': '20200801',
+            'timestamp': 1596241390,
+            'uploader_id': '95894',
+            'uploader': 'Bluebeat Shelter',
+        }
+    }, {
+        'url': 'https://drooble.com/karl340758/videos/2859183',
+        'info_dict': {
+            'id': 'J6QCQY_I5Tk',
+            'ext': 'mp4',
+            'title': 'Skankocillin',
+            'uploader_id': 'UCrSRoI5vVyeYihtWEYua7rg',
+            'description': 'md5:ffc0bd8ba383db5341a86a6cd7d9bcca',
+            'upload_date': '20200731',
+            'uploader': 'Bluebeat Shelter',
+        }
+    }, {
+        'url': 'https://drooble.com/karl340758/music/albums/2858031',
+        'info_dict': {
+            'id': '2858031',
+        },
+        'playlist_mincount': 8,
+    }, {
+        'url': 'https://drooble.com/karl340758/music',
+        'info_dict': {
+            'id': 'karl340758',
+        },
+        'playlist_mincount': 8,
+    }, {
+        'url': 'https://drooble.com/karl340758/videos',
+        'info_dict': {
+            'id': 'karl340758',
+        },
+        'playlist_mincount': 8,
+    }]
+
+    def _call_api(self, method, video_id, data=None):
+        response = self._download_json(
+            f'https://drooble.com/api/dt/{method}', video_id, data=json.dumps(data).encode())
+        if not response[0]:
+            raise ExtractorError('Unable to download JSON metadata')
+        return response[1]
+
+    def _real_extract(self, url):
+        mobj = self._match_valid_url(url)
+        user = mobj.group('user') or mobj.group('user_2')
+        kind = mobj.group('kind') or mobj.group('kind_2')
+        display_id = mobj.group('id') or user
+
+        if mobj.group('kind_2') == 'videos':
+            data = {'from_user': display_id, 'album': -1, 'limit': 18, 'offset': 0, 'order': 'new2old', 'type': 'video'}
+        elif kind in ('music/albums', 'music'):
+            data = {'user': user, 'public_only': True, 'individual_limit': {'singles': 1, 'albums': 1, 'playlists': 1}}
+        else:
+            data = {'url_slug': display_id, 'children': 10, 'order': 'old2new'}
+
+        method = 'getMusicOverview' if kind in ('music/albums', 'music') else 'getElements'
+        json_data = self._call_api(method, display_id, data=data)
+        if kind in ('music/albums', 'music'):
+            json_data = json_data['singles']['list']
+
+        entites = []
+        for media in json_data:
+            url = media.get('external_media_url') or media.get('link')
+            if url.startswith('https://www.youtube.com'):
+                entites.append({
+                    '_type': 'url',
+                    'url': url,
+                    'ie_key': 'Youtube'
+                })
+                continue
+            is_audio = (media.get('type') or '').lower() == 'audio'
+            entites.append({
+                'url': url,
+                'id': media['id'],
+                'title': media['title'],
+                'duration': int_or_none(media.get('duration')),
+                'timestamp': int_or_none(media.get('timestamp')),
+                'album': try_get(media, lambda x: x['album']['title']),
+                'uploader': try_get(media, lambda x: x['creator']['display_name']),
+                'uploader_id': try_get(media, lambda x: x['creator']['id']),
+                'thumbnail': media.get('image_comment'),
+                'like_count': int_or_none(media.get('likes')),
+                'vcodec': 'none' if is_audio else None,
+                'ext': 'mp3' if is_audio else None,
+            })
+
+        if len(entites) > 1:
+            return self.playlist_result(entites, display_id)
+
+        return entites[0]
diff --git a/yt_dlp/extractor/extractors.py b/yt_dlp/extractor/extractors.py
index fd5c86afe..77d6ef511 100644
--- a/yt_dlp/extractor/extractors.py
+++ b/yt_dlp/extractor/extractors.py
@@ -621,6 +621,7 @@
     InstagramIOSIE,
     InstagramUserIE,
     InstagramTagIE,
+    InstagramStoryIE,
 )
 from .internazionale import InternazionaleIE
 from .internetvideoarchive import InternetVideoArchiveIE
@@ -655,6 +656,7 @@
 from .karaoketv import KaraoketvIE
 from .karrierevideos import KarriereVideosIE
 from .keezmovies import KeezMoviesIE
+from .kelbyone import KelbyOneIE
 from .ketnet import KetnetIE
 from .khanacademy import (
     KhanAcademyIE,
@@ -1691,6 +1693,7 @@
     DLiveVODIE,
     DLiveStreamIE,
 )
+from .drooble import DroobleIE
 from .umg import UMGDeIE
 from .unistra import UnistraIE
 from .unity import UnityIE
diff --git a/yt_dlp/extractor/instagram.py b/yt_dlp/extractor/instagram.py
index 0dd4aa54a..ab14e5b0a 100644
--- a/yt_dlp/extractor/instagram.py
+++ b/yt_dlp/extractor/instagram.py
@@ -542,3 +542,77 @@ def _query_vars_for(data):
             'tag_name':
                 data['entry_data']['TagPage'][0]['graphql']['hashtag']['name']
         }
+
+
+class InstagramStoryIE(InstagramBaseIE):
+    _VALID_URL = r'https?://(?:www\.)?instagram\.com/stories/(?P<user>[^/]+)/(?P<id>\d+)'
+    IE_NAME = 'instagram:story'
+
+    _TESTS = [{
+        'url': 'https://www.instagram.com/stories/highlights/18090946048123978/',
+        'info_dict': {
+            'id': '18090946048123978',
+            'title': 'Rare',
+        },
+        'playlist_mincount': 50
+    }]
+
+    def _real_extract(self, url):
+        username, story_id = self._match_valid_url(url).groups()
+
+        story_info_url = f'{username}/{story_id}/?__a=1' if username == 'highlights' else f'{username}/?__a=1'
+        story_info = self._download_json(f'https://www.instagram.com/stories/{story_info_url}', story_id, headers={
+            'X-IG-App-ID': 936619743392459,
+            'X-ASBD-ID': 198387,
+            'X-IG-WWW-Claim': 0,
+            'X-Requested-With': 'XMLHttpRequest',
+            'Referer': url,
+        })
+        user_id = story_info['user']['id']
+        highlight_title = traverse_obj(story_info, ('highlight', 'title'))
+
+        story_info_url = user_id if username != 'highlights' else f'highlight:{story_id}'
+        videos = self._download_json(f'https://i.instagram.com/api/v1/feed/reels_media/?reel_ids={story_info_url}', story_id, headers={
+            'X-IG-App-ID': 936619743392459,
+            'X-ASBD-ID': 198387,
+            'X-IG-WWW-Claim': 0,
+        })['reels']
+        entites = []
+
+        videos = traverse_obj(videos, (f'highlight:{story_id}', 'items'), (str(user_id), 'items'))
+        for video_info in videos:
+            formats = []
+            if isinstance(video_info, list):
+                video_info = video_info[0]
+            vcodec = video_info.get('video_codec')
+            dash_manifest_raw = video_info.get('video_dash_manifest')
+            videos_list = video_info.get('video_versions')
+            if not (dash_manifest_raw or videos_list):
+                continue
+            for format in videos_list:
+                formats.append({
+                    'url': format.get('url'),
+                    'width': format.get('width'),
+                    'height': format.get('height'),
+                    'vcodec': vcodec,
+                })
+            if dash_manifest_raw:
+                formats.extend(self._parse_mpd_formats(self._parse_xml(dash_manifest_raw, story_id), mpd_id='dash'))
+            self._sort_formats(formats)
+            thumbnails = [{
+                'url': thumbnail.get('url'),
+                'width': thumbnail.get('width'),
+                'height': thumbnail.get('height')
+            } for thumbnail in traverse_obj(video_info, ('image_versions2', 'candidates')) or []]
+            entites.append({
+                'id': video_info.get('id'),
+                'title': f'Story by {username}',
+                'timestamp': int_or_none(video_info.get('taken_at')),
+                'uploader': traverse_obj(videos, ('user', 'full_name')),
+                'duration': float_or_none(video_info.get('video_duration')),
+                'uploader_id': user_id,
+                'thumbnails': thumbnails,
+                'formats': formats,
+            })
+
+        return self.playlist_result(entites, playlist_id=story_id, playlist_title=highlight_title)
diff --git a/yt_dlp/extractor/kelbyone.py b/yt_dlp/extractor/kelbyone.py
new file mode 100644
index 000000000..20c26cf48
--- /dev/null
+++ b/yt_dlp/extractor/kelbyone.py
@@ -0,0 +1,84 @@
+# coding: utf-8
+from __future__ import unicode_literals
+
+from .common import InfoExtractor
+from ..utils import int_or_none
+
+
+class KelbyOneIE(InfoExtractor):
+    _VALID_URL = r'https?://members\.kelbyone\.com/course/(?P<id>[^$&?#/]+)'
+
+    _TESTS = [{
+        'url': 'https://members.kelbyone.com/course/glyn-dewis-mastering-selections/',
+        'playlist_mincount': 1,
+        'info_dict': {
+            'id': 'glyn-dewis-mastering-selections',
+            'title': 'Trailer - Mastering Selections in Photoshop',
+        },
+        'playlist': [{
+            'info_dict': {
+                'id': 'MkiOnLqK',
+                'ext': 'mp4',
+                'title': 'Trailer - Mastering Selections in Photoshop',
+                'description': 'md5:d41d8cd98f00b204e9800998ecf8427e',
+                'thumbnail': 'https://content.jwplatform.com/v2/media/MkiOnLqK/poster.jpg?width=720',
+                'timestamp': 1601568639,
+                'duration': 90,
+                'upload_date': '20201001',
+            },
+        }]
+    }]
+
+    def _entries(self, playlist):
+        for item in playlist:
+            video_id = item['mediaid']
+            thumbnails = [{
+                'url': image.get('src'),
+                'width': int_or_none(image.get('width')),
+            } for image in item.get('images') or []]
+            formats, subtitles = [], {}
+            for source in item.get('sources') or []:
+                if not source.get('file'):
+                    continue
+                if source.get('type') == 'application/vnd.apple.mpegurl':
+                    fmts, subs = self._extract_m3u8_formats_and_subtitles(source['file'], video_id)
+                    formats.extend(fmts)
+                    subtitles = self._merge_subtitles(subs, subtitles)
+                elif source.get('type') == 'audio/mp4':
+                    formats.append({
+                        'format_id': source.get('label'),
+                        'url': source['file'],
+                        'vcodec': 'none',
+                    })
+                else:
+                    formats.append({
+                        'format_id': source.get('label'),
+                        'height': source.get('height'),
+                        'width': source.get('width'),
+                        'url': source['file'],
+                    })
+            for track in item.get('tracks'):
+                if track.get('kind') == 'captions' and track.get('file'):
+                    subtitles.setdefault('en', []).append({
+                        'url': track['file'],
+                    })
+            self._sort_formats(formats)
+            yield {
+                'id': video_id,
+                'title': item['title'],
+                'description': item.get('description'),
+                'thumbnails': thumbnails,
+                'thumbnail': item.get('image'),
+                'timestamp': item.get('pubdate'),
+                'duration': item.get('duration'),
+                'formats': formats,
+                'subtitles': subtitles,
+            }
+
+    def _real_extract(self, url):
+        item_id = self._match_id(url)
+        webpage = self._download_webpage(url, item_id)
+        playlist_url = self._html_search_regex(r'playlist"\:"(https.*content\.jwplatform\.com.*json)"', webpage, 'playlist url').replace('\\', '')
+        course_data = self._download_json(playlist_url, item_id)
+        return self.playlist_result(self._entries(course_data['playlist']), item_id,
+                                    course_data.get('title'), course_data.get('description'))
diff --git a/yt_dlp/extractor/mixcloud.py b/yt_dlp/extractor/mixcloud.py
index a0c043d4b..c2dd078ac 100644
--- a/yt_dlp/extractor/mixcloud.py
+++ b/yt_dlp/extractor/mixcloud.py
@@ -12,6 +12,7 @@
     compat_zip
 )
 from ..utils import (
+    ExtractorError,
     int_or_none,
     parse_iso8601,
     strip_or_none,
@@ -125,7 +126,20 @@ def _real_extract(self, url):
       tag {
         name
       }
-    }''', track_id, username, slug)
+    }
+    restrictedReason
+    id''', track_id, username, slug)
+
+        if not cloudcast:
+            raise ExtractorError('Track not found', expected=True)
+
+        reason = cloudcast.get('restrictedReason')
+        if reason == 'tracklist':
+            raise ExtractorError('Track unavailable in your country due to licensing restrictions', expected=True)
+        elif reason == 'repeat_play':
+            raise ExtractorError('You have reached your play limit for this track', expected=True)
+        elif reason:
+            raise ExtractorError('Track is restricted', expected=True)
 
         title = cloudcast['name']
 
diff --git a/yt_dlp/extractor/tiktok.py b/yt_dlp/extractor/tiktok.py
index c34235e96..c44fd49b0 100644
--- a/yt_dlp/extractor/tiktok.py
+++ b/yt_dlp/extractor/tiktok.py
@@ -22,8 +22,8 @@
 
 
 class TikTokBaseIE(InfoExtractor):
-    _APP_VERSION = '20.9.3'
-    _MANIFEST_APP_VERSION = '291'
+    _APP_VERSION = '20.1.0'
+    _MANIFEST_APP_VERSION = '200'
     _APP_NAME = 'trill'
     _AID = 1180
     _API_HOSTNAME = 'api-h2.tiktokv.com'
@@ -161,6 +161,10 @@ def extract_addr(addr, add_meta={}):
                 }))
 
         self._remove_duplicate_formats(formats)
+        auth_cookie = self._get_cookies(self._WEBPAGE_HOST).get('sid_tt')
+        if auth_cookie:
+            for f in formats:
+                self._set_cookie(f['url'], 'sid_tt', auth_cookie.value)
         self._sort_formats(formats, ('quality', 'codec', 'size', 'br'))
 
         thumbnails = []
@@ -220,12 +224,13 @@ def extract_addr(addr, add_meta={}):
 
     def _parse_aweme_video_web(self, aweme_detail, webpage_url):
         video_info = aweme_detail['video']
-        author_info = traverse_obj(aweme_detail, 'author', 'authorInfo', default={})
+        author_info = traverse_obj(aweme_detail, 'authorInfo', 'author', expected_type=dict, default={})
         music_info = aweme_detail.get('music') or {}
         stats_info = aweme_detail.get('stats') or {}
         user_url = self._UPLOADER_URL_FORMAT % (traverse_obj(author_info,
                                                              'secUid', 'id', 'uid', 'uniqueId',
-                                                             expected_type=str_or_none, get_all=False))
+                                                             expected_type=str_or_none, get_all=False)
+                                                or aweme_detail.get('authorSecId'))
 
         formats = []
         play_url = video_info.get('playAddr')
@@ -277,8 +282,8 @@ def _parse_aweme_video_web(self, aweme_detail, webpage_url):
             'comment_count': int_or_none(stats_info.get('commentCount')),
             'timestamp': int_or_none(aweme_detail.get('createTime')),
             'creator': str_or_none(author_info.get('nickname')),
-            'uploader': str_or_none(author_info.get('uniqueId')),
-            'uploader_id': str_or_none(author_info.get('id')),
+            'uploader': str_or_none(author_info.get('uniqueId') or aweme_detail.get('author')),
+            'uploader_id': str_or_none(author_info.get('id') or aweme_detail.get('authorId')),
             'uploader_url': user_url,
             'track': str_or_none(music_info.get('title')),
             'album': str_or_none(music_info.get('album')) or None,
@@ -342,9 +347,51 @@ class TikTokIE(TikTokBaseIE):
             'comment_count': int,
         }
     }, {
-        # Promoted content/ad
-        'url': 'https://www.tiktok.com/@MS4wLjABAAAAAR29F6J2Ktu0Daw03BJyXPNoRQ-W7U5a0Mn3lVCq2rQhjOd_WNLclHUoFgwX8Eno/video/6932675057474981122',
-        'only_matching': True,
+        # Banned audio, only available on the app
+        'url': 'https://www.tiktok.com/@barudakhb_/video/6984138651336838402',
+        'info_dict': {
+            'id': '6984138651336838402',
+            'ext': 'mp4',
+            'title': 'Balas @yolaaftwsr hayu yu ? #SquadRandom_ 🔥',
+            'description': 'Balas @yolaaftwsr hayu yu ? #SquadRandom_ 🔥',
+            'uploader': 'barudakhb_',
+            'creator': 'md5:29f238c49bc0c176cb3cef1a9cea9fa6',
+            'uploader_id': '6974687867511718913',
+            'uploader_url': 'https://www.tiktok.com/@MS4wLjABAAAAbhBwQC-R1iKoix6jDFsF-vBdfx2ABoDjaZrM9fX6arU3w71q3cOWgWuTXn1soZ7d',
+            'track': 'Boka Dance',
+            'artist': 'md5:29f238c49bc0c176cb3cef1a9cea9fa6',
+            'timestamp': 1626121503,
+            'duration': 18,
+            'thumbnail': r're:^https?://[\w\/\.\-]+(~[\w\-]+\.image)?',
+            'upload_date': '20210712',
+            'view_count': int,
+            'like_count': int,
+            'repost_count': int,
+            'comment_count': int,
+        }
+    }, {
+        # Sponsored video, only available with feed workaround
+        'url': 'https://www.tiktok.com/@MS4wLjABAAAATh8Vewkn0LYM7Fo03iec3qKdeCUOcBIouRk1mkiag6h3o_pQu_dUXvZ2EZlGST7_/video/7042692929109986561',
+        'info_dict': {
+            'id': '7042692929109986561',
+            'ext': 'mp4',
+            'title': 'Slap and Run!',
+            'description': 'Slap and Run!',
+            'uploader': 'user440922249',
+            'creator': 'Slap And Run',
+            'uploader_id': '7036055384943690754',
+            'uploader_url': 'https://www.tiktok.com/@MS4wLjABAAAATh8Vewkn0LYM7Fo03iec3qKdeCUOcBIouRk1mkiag6h3o_pQu_dUXvZ2EZlGST7_',
+            'track': 'Promoted Music',
+            'timestamp': 1639754738,
+            'duration': 30,
+            'thumbnail': r're:^https?://[\w\/\.\-]+(~[\w\-]+\.image)?',
+            'upload_date': '20211217',
+            'view_count': int,
+            'like_count': int,
+            'repost_count': int,
+            'comment_count': int,
+        },
+        'expected_warnings': ['Video not available']
     }]
 
     def _extract_aweme_app(self, aweme_id):
@@ -373,19 +420,26 @@ def _real_extract(self, url):
         # If we only call once, we get a 403 when downlaoding the video.
         self._download_webpage(url, video_id)
         webpage = self._download_webpage(url, video_id, note='Downloading video webpage')
-        json_string = self._search_regex(
-            r'id=\"__NEXT_DATA__\"\s+type=\"application\/json\"\s*[^>]+>\s*(?P<json_string_ld>[^<]+)',
-            webpage, 'json_string', group='json_string_ld')
-        json_data = self._parse_json(json_string, video_id)
-        props_data = try_get(json_data, lambda x: x['props'], expected_type=dict)
-
-        # Chech statusCode for success
-        status = props_data.get('pageProps').get('statusCode')
+        next_json = self._search_regex(
+            r'id=\"__NEXT_DATA__\"\s+type=\"application\/json\"\s*[^>]+>\s*(?P<next_data>[^<]+)',
+            webpage, 'next data', group='next_data', default=None)
+
+        if next_json:
+            next_data = self._parse_json(next_json, video_id)
+            status = traverse_obj(next_data, ('props', 'pageProps', 'statusCode'), expected_type=int) or 0
+            video_data = traverse_obj(next_data, ('props', 'pageProps', 'itemInfo', 'itemStruct'), expected_type=dict)
+        else:
+            sigi_json = self._search_regex(
+                r'>\s*window\[[\'"]SIGI_STATE[\'"]\]\s*=\s*(?P<sigi_state>{.+});',
+                webpage, 'sigi data', group='sigi_state')
+            sigi_data = self._parse_json(sigi_json, video_id)
+            status = traverse_obj(sigi_data, ('VideoPage', 'statusCode'), expected_type=int) or 0
+            video_data = traverse_obj(sigi_data, ('ItemModule', video_id), expected_type=dict)
+
         if status == 0:
-            return self._parse_aweme_video_web(props_data['pageProps']['itemInfo']['itemStruct'], url)
+            return self._parse_aweme_video_web(video_data, url)
         elif status == 10216:
             raise ExtractorError('This video is private', expected=True)
-
         raise ExtractorError('Video not available', video_id=video_id)
 
 
diff --git a/yt_dlp/options.py b/yt_dlp/options.py
index 4f51c37c0..83531497c 100644
--- a/yt_dlp/options.py
+++ b/yt_dlp/options.py
@@ -1572,7 +1572,8 @@ def _dict_from_options_callback(
         '--no-hls-split-discontinuity',
         dest='hls_split_discontinuity', action='store_false',
         help='Do not split HLS playlists to different formats at discontinuities such as ad breaks (default)')
-    _extractor_arg_parser = lambda key, vals='': (key.strip().lower().replace('-', '_'), [val.strip() for val in vals.split(',')])
+    _extractor_arg_parser = lambda key, vals='': (key.strip().lower().replace('-', '_'), [
+        val.replace(r'\,', ',').strip() for val in re.split(r'(?<!\\),', vals)])
     extractor.add_option(
         '--extractor-args',
         metavar='KEY:ARGS', dest='extractor_args', default={}, type='str',
diff --git a/yt_dlp/utils.py b/yt_dlp/utils.py
index 788bf16b7..56b31bc41 100644
--- a/yt_dlp/utils.py
+++ b/yt_dlp/utils.py
@@ -2116,9 +2116,11 @@ def format_decimal_suffix(num, fmt='%d%s', *, factor=1000):
     if num is None:
         return None
     exponent = 0 if num == 0 else int(math.log(num, factor))
-    suffix = ['', *'KMGTPEZY'][exponent]
+    suffix = ['', *'kMGTPEZY'][exponent]
+    if factor == 1024:
+        suffix = {'k': 'Ki', '': ''}.get(suffix, f'{suffix}i')
     converted = num / (factor ** exponent)
-    return fmt % (converted, f'{suffix}i' if suffix and factor == 1024 else suffix)
+    return fmt % (converted, suffix)
 
 
 def format_bytes(bytes):
@@ -3194,7 +3196,7 @@ def parse_codecs(codecs_str):
         return {}
     split_codecs = list(filter(None, map(
         str.strip, codecs_str.strip().strip(',').split(','))))
-    vcodec, acodec, hdr = None, None, None
+    vcodec, acodec, tcodec, hdr = None, None, None, None
     for full_codec in split_codecs:
         parts = full_codec.split('.')
         codec = parts[0].replace('0', '')
@@ -3211,13 +3213,17 @@ def parse_codecs(codecs_str):
         elif codec in ('flac', 'mp4a', 'opus', 'vorbis', 'mp3', 'aac', 'ac-3', 'ec-3', 'eac3', 'dtsc', 'dtse', 'dtsh', 'dtsl'):
             if not acodec:
                 acodec = full_codec
+        elif codec in ('stpp', 'wvtt',):
+            if not tcodec:
+                tcodec = full_codec
         else:
             write_string('WARNING: Unknown codec %s\n' % full_codec, sys.stderr)
-    if vcodec or acodec:
+    if vcodec or acodec or tcodec:
         return {
             'vcodec': vcodec or 'none',
             'acodec': acodec or 'none',
             'dynamic_range': hdr,
+            **({'tcodec': tcodec} if tcodec is not None else {}),
         }
     elif len(split_codecs) == 2:
         return {


Test patch:
diff --git a/test/helper.py b/test/helper.py
index b63a5c897..598f803f4 100644
--- a/test/helper.py
+++ b/test/helper.py
@@ -211,7 +211,7 @@ def sanitize_got_info_dict(got_dict):
 
         # Auto-generated
         'autonumber', 'playlist', 'format_index', 'video_ext', 'audio_ext', 'duration_string', 'epoch',
-        'fulltitle', 'extractor', 'extractor_key', 'filepath', 'infojson_filename', 'original_url',
+        'fulltitle', 'extractor', 'extractor_key', 'filepath', 'infojson_filename', 'original_url', 'n_entries',
 
         # Only live_status needs to be checked
         'is_live', 'was_live',
diff --git a/test/test_YoutubeDL.py b/test/test_YoutubeDL.py
index 61923513e..d2cc423d6 100644
--- a/test/test_YoutubeDL.py
+++ b/test/test_YoutubeDL.py
@@ -645,6 +645,7 @@ def test_add_extra_info(self):
         'ext': 'mp4',
         'width': None,
         'height': 1080,
+        'filesize': 1024,
         'title1': '$PATH',
         'title2': '%PATH%',
         'title3': 'foo/bar\\test',
@@ -778,8 +779,9 @@ def expect_same_infodict(out):
         test('%(title5)#U', 'a\u0301e\u0301i\u0301 𝐀')
         test('%(title5)+U', 'áéí A')
         test('%(title5)+#U', 'a\u0301e\u0301i\u0301 A')
-        test('%(height)D', '1K')
-        test('%(height)5.2D', ' 1.08K')
+        test('%(height)D', '1k')
+        test('%(filesize)#D', '1Ki')
+        test('%(height)5.2D', ' 1.08k')
         test('%(title4)#S', 'foo_bar_test')
         test('%(title4).10S', ('foo \'bar\' ', 'foo \'bar\'' + ('#' if compat_os_name == 'nt' else ' ')))
         if compat_os_name == 'nt':