Repository: yt-dlp/yt-dlp
Pull request #6624 (2023-03-24 22:00:09)

[extractor/discogs] Add extractor
**IMPORTANT**: PRs without the template will be CLOSED

### Description of your *pull request* and other information

Add an extractor for the playlists embedded in Release and Master Release pages on discogs.com (an online music database). The generic extractor used to be able to read these, but that stopped working a long time ago.

Example URLs:

- https://www.discogs.com/release/1-The-Persuader-Stockholm
- https://www.discogs.com/master/113-Vince-Watson-Moments-In-Time

<details open><summary>Template</summary> <!-- OPEN is intentional -->

### Before submitting a *pull request* make sure you have:
- [x] At least skimmed through [contributing guidelines](https://github.com/yt-dlp/yt-dlp/blob/master/CONTRIBUTING.md#developer-instructions) including [yt-dlp coding conventions](https://github.com/yt-dlp/yt-dlp/blob/master/CONTRIBUTING.md#yt-dlp-coding-conventions)
- [x] [Searched](https://github.com/yt-dlp/yt-dlp/search?q=is%3Apr&type=Issues) the bugtracker for similar pull requests
- [x] Checked the code with [flake8](https://pypi.python.org/pypi/flake8) and [ran relevant tests](https://github.com/yt-dlp/yt-dlp/blob/master/CONTRIBUTING.md#developer-instructions)

### In order to be accepted and merged into yt-dlp each piece of code must be in public domain or released under [Unlicense](http://unlicense.org/). Check all of the following options that apply:
- [x] I am the original author of this code and I am willing to release it under [Unlicense](http://unlicense.org/)
- [ ] I am not the original author of this code but it is in public domain or released under [Unlicense](http://unlicense.org/) (provide reliable evidence)

### What is the purpose of your *pull request*?
- [ ] Fix or improvement to an extractor (Make sure to add/update tests)
- [x] New extractor ([Piracy websites will not be accepted](https://github.com/yt-dlp/yt-dlp/blob/master/CONTRIBUTING.md#is-the-website-primarily-used-for-piracy))
- [ ] Core bug fix/improvement
- [ ] New feature (It is strongly [recommended to open an issue first](https://github.com/yt-dlp/yt-dlp/blob/master/CONTRIBUTING.md#adding-new-feature-or-making-overarching-changes))

</details>



Patch:
diff --git a/yt_dlp/extractor/_extractors.py b/yt_dlp/extractor/_extractors.py
index f54024211..e4fd944e7 100644
--- a/yt_dlp/extractor/_extractors.py
+++ b/yt_dlp/extractor/_extractors.py
@@ -520,6 +520,7 @@
     DeuxMNewsIE
 )
 from .digitalconcerthall import DigitalConcertHallIE
+from .discogs import DiscogsReleasePlaylistIE
 from .discovery import DiscoveryIE
 from .disney import DisneyIE
 from .dispeak import DigitallySpeakingIE
@@ -664,6 +665,7 @@
     FunimationShowIE,
 )
 from .funk import FunkIE
+from .funker530 import Funker530IE
 from .fusion import FusionIE
 from .fuyintv import FuyinTVIE
 from .gab import (
@@ -2441,6 +2443,7 @@
 from .youporn import YouPornIE
 from .yourporn import YourPornIE
 from .yourupload import YourUploadIE
+from .zaiko import ZaikoIE
 from .zapiks import ZapiksIE
 from .zattoo import (
     BBVTVIE,
diff --git a/yt_dlp/extractor/discogs.py b/yt_dlp/extractor/discogs.py
new file mode 100644
index 000000000..048c62288
--- /dev/null
+++ b/yt_dlp/extractor/discogs.py
@@ -0,0 +1,35 @@
+from .common import InfoExtractor
+from .youtube import YoutubeIE
+from ..utils import traverse_obj
+
+
+class DiscogsReleasePlaylistIE(InfoExtractor):
+    _VALID_URL = r'https?://(?:www\.)?discogs\.com/(?P<type>release|master)/(?P<id>\d+)'
+    _TESTS = [{
+        'url': 'https://www.discogs.com/release/1-The-Persuader-Stockholm',
+        'info_dict': {
+            'id': 'release1',
+            'title': 'Stockholm',
+        },
+        'playlist_mincount': 7,
+    }, {
+        'url': 'https://www.discogs.com/master/113-Vince-Watson-Moments-In-Time',
+        'info_dict': {
+            'id': 'master113',
+            'title': 'Moments In Time',
+        },
+        'playlist_mincount': 53,
+    }]
+
+    def _real_extract(self, url):
+        playlist_id, playlist_type = self._match_valid_url(url).group('id', 'type')
+
+        display_id = f'{playlist_type}{playlist_id}'
+        response = self._download_json(
+            f'https://api.discogs.com/{playlist_type}s/{playlist_id}', display_id)
+
+        entries = [
+            self.url_result(video['uri'], YoutubeIE, video_title=video.get('title'))
+            for video in traverse_obj(response, ('videos', lambda _, v: YoutubeIE.suitable(v['uri'])))]
+
+        return self.playlist_result(entries, display_id, response.get('title'))
diff --git a/yt_dlp/extractor/dumpert.py b/yt_dlp/extractor/dumpert.py
old mode 100644
new mode 100755
index 010c2d092..0cf84263c
--- a/yt_dlp/extractor/dumpert.py
+++ b/yt_dlp/extractor/dumpert.py
@@ -1,12 +1,17 @@
 from .common import InfoExtractor
 from ..utils import (
+    determine_ext,
     int_or_none,
     qualities,
 )
 
 
 class DumpertIE(InfoExtractor):
-    _VALID_URL = r'(?P<protocol>https?)://(?:(?:www|legacy)\.)?dumpert\.nl/(?:mediabase|embed|item)/(?P<id>[0-9]+[/_][0-9a-zA-Z]+)'
+    _VALID_URL = r'''(?x)
+        (?P<protocol>https?)://(?:(?:www|legacy)\.)?dumpert\.nl(?:
+            /(?:mediabase|embed|item)/|
+            (?:/toppers|/latest|/?)\?selectedId=
+        )(?P<id>[0-9]+[/_][0-9a-zA-Z]+)'''
     _TESTS = [{
         'url': 'https://www.dumpert.nl/item/6646981_951bc60f',
         'md5': '1b9318d7d5054e7dcb9dc7654f21d643',
@@ -16,6 +21,9 @@ class DumpertIE(InfoExtractor):
             'title': 'Ik heb nieuws voor je',
             'description': 'Niet schrikken hoor',
             'thumbnail': r're:^https?://.*\.jpg$',
+            'duration': 9,
+            'view_count': int,
+            'like_count': int,
         }
     }, {
         'url': 'https://www.dumpert.nl/embed/6675421_dc440fe7',
@@ -26,6 +34,28 @@ class DumpertIE(InfoExtractor):
     }, {
         'url': 'http://legacy.dumpert.nl/embed/6675421/dc440fe7',
         'only_matching': True,
+    }, {
+        'url': 'https://www.dumpert.nl/item/100031688_b317a185',
+        'info_dict': {
+            'id': '100031688/b317a185',
+            'ext': 'mp4',
+            'title': 'Epic schijnbeweging',
+            'description': '<p>Die zag je niet eh</p>',
+            'thumbnail': r're:^https?://.*\.(?:jpg|png)$',
+            'duration': 12,
+            'view_count': int,
+            'like_count': int,
+        },
+        'params': {'skip_download': 'm3u8'}
+    }, {
+        'url': 'https://www.dumpert.nl/toppers?selectedId=100031688_b317a185',
+        'only_matching': True,
+    }, {
+        'url': 'https://www.dumpert.nl/latest?selectedId=100031688_b317a185',
+        'only_matching': True,
+    }, {
+        'url': 'https://www.dumpert.nl/?selectedId=100031688_b317a185',
+        'only_matching': True,
     }]
 
     def _real_extract(self, url):
@@ -36,18 +66,23 @@ def _real_extract(self, url):
         title = item['title']
         media = next(m for m in item['media'] if m.get('mediatype') == 'VIDEO')
 
-        quality = qualities(['flv', 'mobile', 'tablet', '720p'])
+        quality = qualities(['flv', 'mobile', 'tablet', '720p', '1080p'])
         formats = []
         for variant in media.get('variants', []):
             uri = variant.get('uri')
             if not uri:
                 continue
             version = variant.get('version')
-            formats.append({
-                'url': uri,
-                'format_id': version,
-                'quality': quality(version),
-            })
+            preference = quality(version)
+            if determine_ext(uri) == 'm3u8':
+                formats.extend(self._extract_m3u8_formats(
+                    uri, video_id, 'mp4', m3u8_id=version, quality=preference))
+            else:
+                formats.append({
+                    'url': uri,
+                    'format_id': version,
+                    'quality': preference,
+                })
 
         thumbnails = []
         stills = item.get('stills') or {}
diff --git a/yt_dlp/extractor/funker530.py b/yt_dlp/extractor/funker530.py
new file mode 100644
index 000000000..ba5ab7d4e
--- /dev/null
+++ b/yt_dlp/extractor/funker530.py
@@ -0,0 +1,79 @@
+from .common import InfoExtractor
+from .rumble import RumbleEmbedIE
+from .youtube import YoutubeIE
+from ..utils import ExtractorError, clean_html, get_element_by_class, strip_or_none
+
+
+class Funker530IE(InfoExtractor):
+    _VALID_URL = r'https?://(?:www\.)?funker530\.com/video/(?P<id>[^/?#]+)'
+    _TESTS = [{
+        'url': 'https://funker530.com/video/azov-patrol-caught-in-open-under-automatic-grenade-launcher-fire/',
+        'md5': '085f50fea27523a388bbc22e123e09c8',
+        'info_dict': {
+            'id': 'v2qbmu4',
+            'ext': 'mp4',
+            'title': 'Azov Patrol Caught In Open Under Automatic Grenade Launcher Fire',
+            'thumbnail': r're:^https?://.*\.jpg$',
+            'uploader': 'Funker530',
+            'channel': 'Funker530',
+            'channel_url': 'https://rumble.com/c/c-1199543',
+            'width': 1280,
+            'height': 720,
+            'fps': 25,
+            'duration': 27,
+            'upload_date': '20230608',
+            'timestamp': 1686241321,
+            'live_status': 'not_live',
+            'description': 'md5:bea2e1f458095414e04b5ac189c2f980',
+        }
+    }, {
+        'url': 'https://funker530.com/video/my-friends-joined-the-russians-civdiv/',
+        'md5': 'a42c2933391210662e93e867d7124b70',
+        'info_dict': {
+            'id': 'k-pk4bOvoac',
+            'ext': 'mp4',
+            'view_count': int,
+            'channel': 'Civ Div',
+            'comment_count': int,
+            'channel_follower_count': int,
+            'thumbnail': 'https://i.ytimg.com/vi/k-pk4bOvoac/maxresdefault.jpg',
+            'uploader_id': '@CivDiv',
+            'duration': 357,
+            'channel_url': 'https://www.youtube.com/channel/UCgsCiwJ88up-YyMHo7hL5-A',
+            'tags': [],
+            'uploader_url': 'https://www.youtube.com/@CivDiv',
+            'channel_id': 'UCgsCiwJ88up-YyMHo7hL5-A',
+            'like_count': int,
+            'description': 'md5:aef75ec3f59c07a0e39400f609b24429',
+            'live_status': 'not_live',
+            'age_limit': 0,
+            'uploader': 'Civ Div',
+            'categories': ['People & Blogs'],
+            'title': 'My “Friends” joined the Russians.',
+            'availability': 'public',
+            'upload_date': '20230608',
+            'playable_in_embed': True,
+            'heatmap': 'count:100',
+        }
+    }]
+
+    def _real_extract(self, url):
+        display_id = self._match_id(url)
+        webpage = self._download_webpage(url, display_id)
+        rumble_url = list(RumbleEmbedIE._extract_embed_urls(url, webpage))
+        if rumble_url:
+            info = {'url': rumble_url[0], 'ie_key': RumbleEmbedIE.ie_key()}
+        else:
+            youtube_url = list(YoutubeIE._extract_embed_urls(url, webpage))
+            if youtube_url:
+                info = {'url': youtube_url[0], 'ie_key': YoutubeIE.ie_key()}
+        if not info:
+            raise ExtractorError('No videos found on webpage', expected=True)
+
+        return {
+            **info,
+            '_type': 'url_transparent',
+            'description': strip_or_none(self._search_regex(
+                r'(?s)(.+)About the Author', clean_html(get_element_by_class('video-desc-paragraph', webpage)),
+                'description', default=None))
+        }
diff --git a/yt_dlp/extractor/lbry.py b/yt_dlp/extractor/lbry.py
index b5def1e07..23d3daf13 100644
--- a/yt_dlp/extractor/lbry.py
+++ b/yt_dlp/extractor/lbry.py
@@ -1,8 +1,8 @@
 import functools
 import json
+import urllib.parse
 
 from .common import InfoExtractor
-from ..compat import compat_str, compat_urllib_parse_unquote
 from ..utils import (
     ExtractorError,
     HEADRequest,
@@ -12,7 +12,10 @@
     int_or_none,
     mimetype2ext,
     parse_qs,
+    traverse_obj,
     try_get,
+    url_or_none,
+    urlhandle_detect_ext,
     urljoin,
 )
 
@@ -52,38 +55,25 @@ def _permanent_url(self, url, claim_name, claim_id):
             '/%s:%s' % (claim_name, claim_id))
 
     def _parse_stream(self, stream, url):
-        stream_value = stream.get('value') or {}
-        stream_type = stream_value.get('stream_type')
-        source = stream_value.get('source') or {}
-        media = stream_value.get(stream_type) or {}
-        signing_channel = stream.get('signing_channel') or {}
-        channel_name = signing_channel.get('name')
-        channel_claim_id = signing_channel.get('claim_id')
-        channel_url = None
-        if channel_name and channel_claim_id:
-            channel_url = self._permanent_url(url, channel_name, channel_claim_id)
+        stream_type = traverse_obj(stream, ('value', 'stream_type', {str}))
+
+        info = traverse_obj(stream, {
+            'title': ('value', 'title', {str}),
+            'thumbnail': ('value', 'thumbnail', 'url', {url_or_none}),
+            'description': ('value', 'description', {str}),
+            'license': ('value', 'license', {str}),
+            'timestamp': ('timestamp', {int_or_none}),
+            'release_timestamp': ('value', 'release_time', {int_or_none}),
+            'tags': ('value', 'tags', ..., {lambda x: x or None}),
+            'duration': ('value', stream_type, 'duration', {int_or_none}),
+            'channel': ('signing_channel', 'value', 'title', {str}),
+            'channel_id': ('signing_channel', 'claim_id', {str}),
+        })
+
+        channel_name = traverse_obj(stream, ('signing_channel', 'name', {str}))
+        if channel_name and info.get('channel_id'):
+            info['channel_url'] = self._permanent_url(url, channel_name, info['channel_id'])
 
-        info = {
-            'thumbnail': try_get(stream_value, lambda x: x['thumbnail']['url'], compat_str),
-            'description': stream_value.get('description'),
-            'license': stream_value.get('license'),
-            'timestamp': int_or_none(stream.get('timestamp')),
-            'release_timestamp': int_or_none(stream_value.get('release_time')),
-            'tags': stream_value.get('tags'),
-            'duration': int_or_none(media.get('duration')),
-            'channel': try_get(signing_channel, lambda x: x['value']['title']),
-            'channel_id': channel_claim_id,
-            'channel_url': channel_url,
-            'ext': determine_ext(source.get('name')) or mimetype2ext(source.get('media_type')),
-            'filesize': int_or_none(source.get('size')),
-        }
-        if stream_type == 'audio':
-            info['vcodec'] = 'none'
-        else:
-            info.update({
-                'width': int_or_none(media.get('width')),
-                'height': int_or_none(media.get('height')),
-            })
         return info
 
 
@@ -186,6 +176,28 @@ class LBRYIE(LBRYBaseIE):
             'license': 'None',
         },
         'params': {'skip_download': True}
+    }, {
+        # original quality format w/higher resolution than HLS formats
+        'url': 'https://odysee.com/@wickedtruths:2/Biotechnological-Invasion-of-Skin-(April-2023):4',
+        'md5': '305b0b3b369bde1b984961f005b67193',
+        'info_dict': {
+            'id': '41fbfe805eb73c8d3012c0c49faa0f563274f634',
+            'ext': 'mp4',
+            'title': 'Biotechnological Invasion of Skin (April 2023)',
+            'description': 'md5:709a2f4c07bd8891cda3a7cc2d6fcf5c',
+            'channel': 'Wicked Truths',
+            'channel_id': '23d2bbf856b0ceed5b1d7c5960bcc72da5a20cb0',
+            'channel_url': 'https://odysee.com/@wickedtruths:23d2bbf856b0ceed5b1d7c5960bcc72da5a20cb0',
+            'timestamp': 1685790036,
+            'upload_date': '20230603',
+            'release_timestamp': 1685617473,
+            'release_date': '20230601',
+            'duration': 1063,
+            'thumbnail': 'https://thumbs.odycdn.com/4e6d39da4df0cfdad45f64e253a15959.webp',
+            'tags': ['smart skin surveillance', 'biotechnology invasion of skin', 'morgellons'],
+            'license': 'None',
+            'protocol': 'https',  # test for direct mp4 download
+        },
     }, {
         'url': 'https://odysee.com/@BrodieRobertson:5/apple-is-tracking-everything-you-do-on:e',
         'only_matching': True,
@@ -221,41 +233,64 @@ def _real_extract(self, url):
             display_id = display_id.split('/', 2)[-1].replace('/', ':')
         else:
             display_id = display_id.replace(':', '#')
-        display_id = compat_urllib_parse_unquote(display_id)
+        display_id = urllib.parse.unquote(display_id)
         uri = 'lbry://' + display_id
         result = self._resolve_url(uri, display_id, 'stream')
         headers = {'Referer': 'https://odysee.com/'}
-        if result['value'].get('stream_type') in self._SUPPORTED_STREAM_TYPES:
+
+        formats = []
+        stream_type = traverse_obj(result, ('value', 'stream_type', {str}))
+
+        if stream_type in self._SUPPORTED_STREAM_TYPES:
             claim_id, is_live = result['claim_id'], False
             streaming_url = self._call_api_proxy(
                 'get', claim_id, {'uri': uri}, 'streaming url')['streaming_url']
+
+            # GET request returns original video/audio file if available
+            ext = urlhandle_detect_ext(self._request_webpage(
+                streaming_url, display_id, 'Checking for original quality', headers=headers))
+            if ext != 'm3u8':
+                formats.append({
+                    'url': streaming_url,
+                    'format_id': 'original',
+                    'quality': 1,
+                    **traverse_obj(result, ('value', {
+                        'ext': ('source', (('name', {determine_ext}), ('media_type', {mimetype2ext}))),
+                        'filesize': ('source', 'size', {int_or_none}),
+                        'width': ('video', 'width', {int_or_none}),
+                        'height': ('video', 'height', {int_or_none}),
+                    }), get_all=False),
+                    'vcodec': 'none' if stream_type == 'audio' else None,
+                })
+
+            # HEAD request returns redirect response to m3u8 URL if available
             final_url = self._request_webpage(
                 HEADRequest(streaming_url), display_id, headers=headers,
                 note='Downloading streaming redirect url info').geturl()
+
         elif result.get('value_type') == 'stream':
             claim_id, is_live = result['signing_channel']['claim_id'], True
             live_data = self._download_json(
                 'https://api.odysee.live/livestream/is_live', claim_id,
                 query={'channel_claim_id': claim_id},
                 note='Downloading livestream JSON metadata')['data']
-            streaming_url = final_url = live_data.get('VideoURL')
+            final_url = live_data.get('VideoURL')
             # Upcoming videos may still give VideoURL
             if not live_data.get('Live'):
-                streaming_url = final_url = None
+                final_url = None
                 self.raise_no_formats('This stream is not live', True, claim_id)
+
         else:
             raise UnsupportedError(url)
 
-        info = self._parse_stream(result, url)
         if determine_ext(final_url) == 'm3u8':
-            info['formats'] = self._extract_m3u8_formats(
-                final_url, display_id, 'mp4', 'm3u8_native', m3u8_id='hls', live=is_live, headers=headers)
-        else:
-            info['url'] = streaming_url
+            formats.extend(self._extract_m3u8_formats(
+                final_url, display_id, 'mp4', m3u8_id='hls', live=is_live, headers=headers))
+
         return {
-            **info,
+            **self._parse_stream(result, url),
             'id': claim_id,
-            'title': result['value']['title'],
+            'formats': formats,
             'is_live': is_live,
             'http_headers': headers,
         }
@@ -299,14 +334,12 @@ def _fetch_page(self, claim_id, url, params, page):
             if not (stream_claim_name and stream_claim_id):
                 continue
 
-            info = self._parse_stream(item, url)
-            info.update({
+            yield {
+                **self._parse_stream(item, url),
                 '_type': 'url',
                 'id': stream_claim_id,
-                'title': try_get(item, lambda x: x['value']['title']),
                 'url': self._permanent_url(url, stream_claim_name, stream_claim_id),
-            })
-            yield info
+            }
 
     def _real_extract(self, url):
         display_id = self._match_id(url).replace(':', '#')
diff --git a/yt_dlp/extractor/odnoklassniki.py b/yt_dlp/extractor/odnoklassniki.py
index 4b73eed37..0d0ad0bb8 100644
--- a/yt_dlp/extractor/odnoklassniki.py
+++ b/yt_dlp/extractor/odnoklassniki.py
@@ -1,3 +1,5 @@
+import urllib.parse
+
 from .common import InfoExtractor
 from ..compat import (
     compat_etree_fromstring,
@@ -7,6 +9,7 @@
 )
 from ..utils import (
     ExtractorError,
+    HEADRequest,
     float_or_none,
     int_or_none,
     qualities,
@@ -15,6 +18,7 @@
     unescapeHTML,
     unified_strdate,
     unsmuggle_url,
+    url_or_none,
     urlencode_postdata,
 )
 
@@ -41,7 +45,7 @@ class OdnoklassnikiIE(InfoExtractor):
             'ext': 'mp4',
             'timestamp': 1545580896,
             'view_count': int,
-            'thumbnail': 'https://coub-attachments.akamaized.net/coub_storage/coub/simple/cw_image/c5ac87553bd/608e806a1239c210ab692/1545580913_00026.jpg',
+            'thumbnail': r're:^https?://.*\.jpg$',
             'title': 'Народная забава',
             'uploader': 'Nevata',
             'upload_date': '20181223',
@@ -65,13 +69,14 @@ class OdnoklassnikiIE(InfoExtractor):
             'title': str,
             'uploader': str,
         },
+        'skip': 'vk extractor error',
     }, {
-        # metadata in JSON
+        # metadata in JSON, webm_dash with Firefox UA
         'url': 'http://ok.ru/video/20079905452',
-        'md5': '5d2b64756e2af296e3b383a0bc02a6aa',
+        'md5': '8f477d8931c531374a3e36daec617b2c',
         'info_dict': {
             'id': '20079905452',
-            'ext': 'mp4',
+            'ext': 'webm',
             'title': 'Культура меняет нас (прекрасный ролик!))',
             'thumbnail': str,
             'duration': 100,
@@ -81,10 +86,14 @@ class OdnoklassnikiIE(InfoExtractor):
             'like_count': int,
             'age_limit': 0,
         },
+        'params': {
+            'format': 'bv[ext=webm]',
+            'http_headers': {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; rv:102.0) Gecko/20100101 Firefox/102.0'},
+        },
     }, {
         # metadataUrl
         'url': 'http://ok.ru/video/63567059965189-0?fromTime=5',
-        'md5': 'f8c951122516af72e6e6ffdd3c41103b',
+        'md5': '2bae2f58eefe1b3d26f3926c4a64d2f3',
         'info_dict': {
             'id': '63567059965189-0',
             'ext': 'mp4',
@@ -98,10 +107,11 @@ class OdnoklassnikiIE(InfoExtractor):
             'age_limit': 0,
             'start_time': 5,
         },
+        'params': {'skip_download': 'm3u8'},
     }, {
         # YouTube embed (metadataUrl, provider == USER_YOUTUBE)
         'url': 'https://ok.ru/video/3952212382174',
-        'md5': '91749d0bd20763a28d083fa335bbd37a',
+        'md5': '5fb5f83ce16cb212d6bf887282b5da53',
         'info_dict': {
             'id': '5axVgHHDBvU',
             'ext': 'mp4',
@@ -116,7 +126,7 @@ class OdnoklassnikiIE(InfoExtractor):
             'live_status': 'not_live',
             'view_count': int,
             'thumbnail': 'https://i.mycdn.me/i?r=AEHujHvw2RjEbemUCNEorZbxYpb_p_9AcN2FmGik64Krkcmz37YtlY093oAM5-HIEAt7Zi9s0CiBOSDmbngC-I-k&fn=external_8',
-            'uploader_url': 'http://www.youtube.com/user/MrKewlkid94',
+            'uploader_url': 'https://www.youtube.com/@MrKewlkid94',
             'channel_follower_count': int,
             'tags': ['youtube-dl', 'youtube playlists', 'download videos', 'download audio'],
             'channel_id': 'UCVGtvURtEURYHtJFUegdSug',
@@ -145,7 +155,6 @@ class OdnoklassnikiIE(InfoExtractor):
         },
         'skip': 'Video has not been found',
     }, {
-        # TODO: HTTP Error 400: Bad Request, it only works if there's no cookies when downloading
         'note': 'Only available in mobile webpage',
         'url': 'https://m.ok.ru/video/2361249957145',
         'info_dict': {
@@ -153,8 +162,8 @@ class OdnoklassnikiIE(InfoExtractor):
             'ext': 'mp4',
             'title': 'Быковское крещение',
             'duration': 3038.181,
+            'thumbnail': r're:^https?://i\.mycdn\.me/videoPreview\?.+',
         },
-        'skip': 'HTTP Error 400',
     }, {
         'note': 'subtitles',
         'url': 'https://ok.ru/video/4249587550747',
@@ -226,6 +235,14 @@ class OdnoklassnikiIE(InfoExtractor):
         'skip': 'Site no longer embeds',
     }]
 
+    def _clear_cookies(self, cdn_url):
+        # Direct http downloads will fail if CDN cookies are set
+        # so we need to reset them after each format extraction
+        if self._get_cookies('https://notarealsubdomain.mycdn.me/'):
+            self.cookiejar.clear(domain='.mycdn.me')
+        if self._get_cookies(cdn_url):
+            self.cookiejar.clear(domain=urllib.parse.urlparse(cdn_url).hostname)
+
     @classmethod
     def _extract_embed_urls(cls, url, webpage):
         for x in super()._extract_embed_urls(url, webpage):
@@ -364,14 +381,22 @@ def _extract_desktop(self, url):
         formats = [{
             'url': f['url'],
             'ext': 'mp4',
-            'format_id': f['name'],
-        } for f in metadata['videos']]
+            'format_id': f.get('name'),
+        } for f in traverse_obj(metadata, ('videos', lambda _, v: url_or_none(v['url'])))]
 
-        m3u8_url = metadata.get('hlsManifestUrl')
+        m3u8_url = traverse_obj(metadata, 'hlsManifestUrl', 'ondemandHls')
         if m3u8_url:
             formats.extend(self._extract_m3u8_formats(
                 m3u8_url, video_id, 'mp4', 'm3u8_native',
                 m3u8_id='hls', fatal=False))
+            self._clear_cookies(m3u8_url)
+
+        for mpd_id, mpd_key in [('dash', 'ondemandDash'), ('webm', 'metadataWebmUrl')]:
+            mpd_url = metadata.get(mpd_key)
+            if mpd_url:
+                formats.extend(self._extract_mpd_formats(
+                    mpd_url, video_id, mpd_id=mpd_id, fatal=False))
+                self._clear_cookies(mpd_url)
 
         dash_manifest = metadata.get('metadataEmbedded')
         if dash_manifest:
@@ -390,6 +415,7 @@ def _extract_desktop(self, url):
         if m3u8_url:
             formats.extend(self._extract_m3u8_formats(
                 m3u8_url, video_id, 'mp4', m3u8_id='hls', fatal=False))
+            self._clear_cookies(m3u8_url)
         rtmp_url = metadata.get('rtmpUrl')
         if rtmp_url:
             formats.append({
@@ -423,6 +449,10 @@ def _extract_mobile(self, url):
             r'data-video="(.+?)"', webpage, 'json data')
         json_data = self._parse_json(unescapeHTML(json_data), video_id) or {}
 
+        redirect_url = self._request_webpage(HEADRequest(
+            json_data['videoSrc']), video_id, 'Requesting download URL').geturl()
+        self._clear_cookies(redirect_url)
+
         return {
             'id': video_id,
             'title': json_data.get('videoName'),
@@ -430,7 +460,7 @@ def _extract_mobile(self, url):
             'thumbnail': json_data.get('videoPosterSrc'),
             'formats': [{
                 'format_id': 'mobile',
-                'url': json_data.get('videoSrc'),
+                'url': redirect_url,
                 'ext': 'mp4',
             }]
         }
diff --git a/yt_dlp/extractor/rumble.py b/yt_dlp/extractor/rumble.py
index 98f660f8b..82f3f0f8c 100644
--- a/yt_dlp/extractor/rumble.py
+++ b/yt_dlp/extractor/rumble.py
@@ -144,7 +144,7 @@ def _extract_embed_urls(cls, url, webpage):
         if embeds:
             return embeds
         return [f'https://rumble.com/embed/{mobj.group("id")}' for mobj in re.finditer(
-            r'<script>\s*Rumble\(\s*"play"\s*,\s*{\s*[\'"]video[\'"]\s*:\s*[\'"](?P<id>[0-9a-z]+)[\'"]', webpage)]
+            r'<script>[^<]*\bRumble\(\s*"play"\s*,\s*{\s*[\'"]?video[\'"]?\s*:\s*[\'"](?P<id>[0-9a-z]+)[\'"]', webpage)]
 
     def _real_extract(self, url):
         video_id = self._match_id(url)
diff --git a/yt_dlp/extractor/tiktok.py b/yt_dlp/extractor/tiktok.py
index 49035e971..9c6d74007 100644
--- a/yt_dlp/extractor/tiktok.py
+++ b/yt_dlp/extractor/tiktok.py
@@ -218,8 +218,8 @@ def mp3_meta(url):
         def extract_addr(addr, add_meta={}):
             parsed_meta, res = parse_url_key(addr.get('url_key', ''))
             if res:
-                known_resolutions.setdefault(res, {}).setdefault('height', add_meta.get('height'))
-                known_resolutions[res].setdefault('width', add_meta.get('width'))
+                known_resolutions.setdefault(res, {}).setdefault('height', add_meta.get('height') or addr.get('height'))
+                known_resolutions[res].setdefault('width', add_meta.get('width') or addr.get('width'))
                 parsed_meta.update(known_resolutions.get(res, {}))
                 add_meta.setdefault('height', int_or_none(res[:-1]))
             return [{
diff --git a/yt_dlp/extractor/twitter.py b/yt_dlp/extractor/twitter.py
index 4624ce503..f854d9c4a 100644
--- a/yt_dlp/extractor/twitter.py
+++ b/yt_dlp/extractor/twitter.py
@@ -3,7 +3,6 @@
 
 from .common import InfoExtractor
 from .periscope import PeriscopeBaseIE, PeriscopeIE
-from ..compat import functools  # isort: split
 from ..compat import (
     compat_parse_qs,
     compat_urllib_parse_unquote,
@@ -30,11 +29,67 @@
 
 
 class TwitterBaseIE(InfoExtractor):
+    _NETRC_MACHINE = 'twitter'
     _API_BASE = 'https://api.twitter.com/1.1/'
     _GRAPHQL_API_BASE = 'https://twitter.com/i/api/graphql/'
     _BASE_REGEX = r'https?://(?:(?:www|m(?:obile)?)\.)?(?:twitter\.com|twitter3e4tixl4xyajtrzo62zg5vztmjuricljdp2c5kshju4avyoid\.onion)/'
     _AUTH = {'Authorization': 'Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA'}
     _guest_token = None
+    _flow_token = None
+
+    _LOGIN_INIT_DATA = json.dumps({
+        'input_flow_data': {
+            'flow_context': {
+                'debug_overrides': {},
+                'start_location': {
+                    'location': 'unknown'
+                }
+            }
+        },
+        'subtask_versions': {
+            'action_list': 2,
+            'alert_dialog': 1,
+            'app_download_cta': 1,
+            'check_logged_in_account': 1,
+            'choice_selection': 3,
+            'contacts_live_sync_permission_prompt': 0,
+            'cta': 7,
+            'email_verification': 2,
+            'end_flow': 1,
+            'enter_date': 1,
+            'enter_email': 2,
+            'enter_password': 5,
+            'enter_phone': 2,
+            'enter_recaptcha': 1,
+            'enter_text': 5,
+            'enter_username': 2,
+            'generic_urt': 3,
+            'in_app_notification': 1,
+            'interest_picker': 3,
+            'js_instrumentation': 1,
+            'menu_dialog': 1,
+            'notifications_permission_prompt': 2,
+            'open_account': 2,
+            'open_home_timeline': 1,
+            'open_link': 1,
+            'phone_verification': 4,
+            'privacy_options': 1,
+            'security_key': 3,
+            'select_avatar': 4,
+            'select_banner': 2,
+            'settings_list': 7,
+            'show_code': 1,
+            'sign_up': 2,
+            'sign_up_review': 4,
+            'tweet_selection_urt': 1,
+            'update_users': 1,
+            'upload_media': 1,
+            'user_recommendations_list': 4,
+            'user_recommendations_urt': 1,
+            'wait_spinner': 3,
+            'web_modal': 1
+        }
+    }, separators=(',', ':')).encode()
 
     def _extract_variant_formats(self, variant, video_id):
         variant_url = variant.get('url')
@@ -86,18 +141,151 @@ def _search_dimensions_in_video_url(a_format, video_url):
                 'height': int(m.group('height')),
             })
 
-    @functools.cached_property
+    @property
     def is_logged_in(self):
         return bool(self._get_cookies(self._API_BASE).get('auth_token'))
 
-    def _call_api(self, path, video_id, query={}, graphql=False):
-        cookies = self._get_cookies(self._API_BASE)
+    def _fetch_guest_token(self, headers, display_id):
+        headers.pop('x-guest-token', None)
+        self._guest_token = traverse_obj(self._download_json(
+            f'{self._API_BASE}guest/activate.json', display_id,
+            'Downloading guest token', data=b'', headers=headers), 'guest_token')
+        if not self._guest_token:
+            raise ExtractorError('Could not retrieve guest token')
+
+    def _set_base_headers(self):
         headers = self._AUTH.copy()
+        csrf_token = try_call(lambda: self._get_cookies(self._API_BASE)['ct0'].value)
+        if csrf_token:
+            headers['x-csrf-token'] = csrf_token
+        return headers
+
+    def _call_login_api(self, note, headers, query={}, data=None):
+        response = self._download_json(
+            f'{self._API_BASE}onboarding/task.json', None, note,
+            headers=headers, query=query, data=data, expected_status=400)
+        error = traverse_obj(response, ('errors', 0, 'message', {str}))
+        if error:
+            raise ExtractorError(f'Login failed, Twitter API says: {error}', expected=True)
+        elif traverse_obj(response, 'status') != 'success':
+            raise ExtractorError('Login was unsuccessful')
+
+        subtask = traverse_obj(
+            response, ('subtasks', ..., 'subtask_id', {str}), get_all=False)
+        if not subtask:
+            raise ExtractorError('Twitter API did not return next login subtask')
+
+        self._flow_token = response['flow_token']
+
+        return subtask
+
+    def _perform_login(self, username, password):
+        if self.is_logged_in:
+            return
+
+        self._request_webpage('https://twitter.com/', None, 'Requesting cookies')
+        headers = self._set_base_headers()
+        self._fetch_guest_token(headers, None)
+        headers.update({
+            'content-type': 'application/json',
+            'x-guest-token': self._guest_token,
+            'x-twitter-client-language': 'en',
+            'x-twitter-active-user': 'yes',
+            'Referer': 'https://twitter.com/',
+            'Origin': 'https://twitter.com',
+        })
 
-        csrf_cookie = cookies.get('ct0')
-        if csrf_cookie:
-            headers['x-csrf-token'] = csrf_cookie.value
+        def build_login_json(*subtask_inputs):
+            return json.dumps({
+                'flow_token': self._flow_token,
+                'subtask_inputs': subtask_inputs
+            }, separators=(',', ':')).encode()
 
+        def input_dict(subtask_id, text):
+            return {
+                'subtask_id': subtask_id,
+                'enter_text': {
+                    'text': text,
+                    'link': 'next_link'
+                }
+            }
+
+        next_subtask = self._call_login_api(
+            'Downloading flow token', headers, query={'flow_name': 'login'}, data=self._LOGIN_INIT_DATA)
+
+        while not self.is_logged_in:
+            if next_subtask == 'LoginJsInstrumentationSubtask':
+                next_subtask = self._call_login_api(
+                    'Submitting JS instrumentation response', headers, data=build_login_json({
+                        'subtask_id': next_subtask,
+                        'js_instrumentation': {
+                            'response': '{}',
+                            'link': 'next_link'
+                        }
+                    }))
+
+            elif next_subtask == 'LoginEnterUserIdentifierSSO':
+                next_subtask = self._call_login_api(
+                    'Submitting username', headers, data=build_login_json({
+                        'subtask_id': next_subtask,
+                        'settings_list': {
+                            'setting_responses': [{
+                                'key': 'user_identifier',
+                                'response_data': {
+                                    'text_data': {
+                                        'result': username
+                                    }
+                                }
+                            }],
+                            'link': 'next_link'
+                        }
+                    }))
+
+            elif next_subtask == 'LoginEnterAlternateIdentifierSubtask':
+                next_subtask = self._call_login_api(
+                    'Submitting alternate identifier', headers,
+                    data=build_login_json(input_dict(next_subtask, self._get_tfa_info(
+                        'one of username, phone number or email that was not used as --username'))))
+
+            elif next_subtask == 'LoginEnterPassword':
+                next_subtask = self._call_login_api(
+                    'Submitting password', headers, data=build_login_json({
+                        'subtask_id': next_subtask,
+                        'enter_password': {
+                            'password': password,
+                            'link': 'next_link'
+                        }
+                    }))
+
+            elif next_subtask == 'AccountDuplicationCheck':
+                next_subtask = self._call_login_api(
+                    'Submitting account duplication check', headers, data=build_login_json({
+                        'subtask_id': next_subtask,
+                        'check_logged_in_account': {
+                            'link': 'AccountDuplicationCheck_false'
+                        }
+                    }))
+
+            elif next_subtask == 'LoginTwoFactorAuthChallenge':
+                next_subtask = self._call_login_api(
+                    'Submitting 2FA token', headers, data=build_login_json(input_dict(
+                        next_subtask, self._get_tfa_info('two-factor authentication token'))))
+
+            elif next_subtask == 'LoginAcid':
+                next_subtask = self._call_login_api(
+                    'Submitting confirmation code', headers, data=build_login_json(input_dict(
+                        next_subtask, self._get_tfa_info('confirmation code sent to your email or phone'))))
+
+            elif next_subtask == 'LoginSuccessSubtask':
+                raise ExtractorError('Twitter API did not grant auth token cookie')
+
+            else:
+                raise ExtractorError(f'Unrecognized subtask ID "{next_subtask}"')
+
+        self.report_login()
+
+    def _call_api(self, path, video_id, query={}, graphql=False):
+        headers = self._set_base_headers()
         if self.is_logged_in:
             headers.update({
                 'x-twitter-auth-type': 'OAuth2Session',
@@ -106,15 +294,10 @@ def _call_api(self, path, video_id, query={}, graphql=False):
             })
 
         for first_attempt in (True, False):
-            if not self.is_logged_in and not self._guest_token:
-                headers.pop('x-guest-token', None)
-                self._guest_token = traverse_obj(self._download_json(
-                    f'{self._API_BASE}guest/activate.json', video_id,
-                    'Downloading guest token', data=b'', headers=headers), 'guest_token')
-            if self._guest_token:
+            if not self.is_logged_in:
+                if not self._guest_token:
+                    self._fetch_guest_token(headers, video_id)
                 headers['x-guest-token'] = self._guest_token
-            elif not self.is_logged_in:
-                raise ExtractorError('Could not retrieve guest token')
 
             allowed_status = {400, 401, 403, 404} if graphql else {403}
             result = self._download_json(
diff --git a/yt_dlp/extractor/urplay.py b/yt_dlp/extractor/urplay.py
index 5d69dadd6..7f97fc95f 100644
--- a/yt_dlp/extractor/urplay.py
+++ b/yt_dlp/extractor/urplay.py
@@ -112,18 +112,19 @@ def parse_lang_code(code):
                 lang = ISO639Utils.short2long(lang)
             return lang or None
 
-        for k, v in (urplayer_data['streamingInfo'].get('sweComplete') or {}).items():
-            if (k in ('sd', 'hd') or not isinstance(v, dict)):
-                continue
-            lang, sttl_url = (v.get(kk) for kk in ('language', 'location', ))
-            if not sttl_url:
-                continue
-            lang = parse_lang_code(lang)
-            if not lang:
-                continue
-            sttl = subtitles.get(lang) or []
-            sttl.append({'ext': k, 'url': sttl_url, })
-            subtitles[lang] = sttl
+        for stream in urplayer_data['streamingInfo'].values():
+            for k, v in stream.items():
+                if (k in ('sd', 'hd') or not isinstance(v, dict)):
+                    continue
+                lang, sttl_url = (v.get(kk) for kk in ('language', 'location', ))
+                if not sttl_url:
+                    continue
+                lang = parse_lang_code(lang)
+                if not lang:
+                    continue
+                sttl = subtitles.get(lang) or []
+                sttl.append({'ext': k, 'url': sttl_url, })
+                subtitles[lang] = sttl
 
         image = urplayer_data.get('image') or {}
         thumbnails = []
diff --git a/yt_dlp/extractor/voot.py b/yt_dlp/extractor/voot.py
index b709b74e2..dd41647aa 100644
--- a/yt_dlp/extractor/voot.py
+++ b/yt_dlp/extractor/voot.py
@@ -1,14 +1,86 @@
+import json
+import time
+import urllib.error
+import uuid
+
 from .common import InfoExtractor
 from ..compat import compat_str
 from ..utils import (
     ExtractorError,
+    float_or_none,
     int_or_none,
+    jwt_decode_hs256,
+    parse_age_limit,
+    traverse_obj,
+    try_call,
     try_get,
-    unified_timestamp,
+    unified_strdate,
 )
 
 
-class VootIE(InfoExtractor):
+class VootBaseIE(InfoExtractor):
+    _NETRC_MACHINE = 'voot'
+    _GEO_BYPASS = False
+    _LOGIN_HINT = 'Log in with "-u <email_address> -p <password>", or use "-u token -p <auth_token>" to login with auth token.'
+    _TOKEN = None
+    _EXPIRY = 0
+    _API_HEADERS = {'Origin': 'https://www.voot.com', 'Referer': 'https://www.voot.com/'}
+
+    def _perform_login(self, username, password):
+        if self._TOKEN and self._EXPIRY:
+            return
+
+        if username.lower() == 'token' and try_call(lambda: jwt_decode_hs256(password)):
+            VootBaseIE._TOKEN = password
+            VootBaseIE._EXPIRY = jwt_decode_hs256(password)['exp']
+            self.report_login()
+
+        # Mobile number as username is not supported
+        elif not username.isdigit():
+            check_username = self._download_json(
+                'https://userauth.voot.com/usersV3/v3/checkUser', None, data=json.dumps({
+                    'type': 'email',
+                    'email': username
+                }, separators=(',', ':')).encode(), headers={
+                    **self._API_HEADERS,
+                    'Content-Type': 'application/json;charset=utf-8',
+                }, note='Checking username', expected_status=403)
+            if not traverse_obj(check_username, ('isExist', {bool})):
+                if traverse_obj(check_username, ('status', 'code', {int})) == 9999:
+                    self.raise_geo_restricted(countries=['IN'])
+                raise ExtractorError('Incorrect username', expected=True)
+            auth_token = traverse_obj(self._download_json(
+                'https://userauth.voot.com/usersV3/v3/login', None, data=json.dumps({
+                    'type': 'traditional',
+                    'deviceId': str(uuid.uuid4()),
+                    'deviceBrand': 'PC/MAC',
+                    'data': {
+                        'email': username,
+                        'password': password
+                    }
+                }, separators=(',', ':')).encode(), headers={
+                    **self._API_HEADERS,
+                    'Content-Type': 'application/json;charset=utf-8',
+                }, note='Logging in', expected_status=400), ('data', 'authToken', {dict}))
+            if not auth_token:
+                raise ExtractorError('Incorrect password', expected=True)
+            VootBaseIE._TOKEN = auth_token['accessToken']
+            VootBaseIE._EXPIRY = auth_token['expirationTime']
+
+        else:
+            raise ExtractorError(self._LOGIN_HINT, expected=True)
+
+    def _check_token_expiry(self):
+        if int(time.time()) >= self._EXPIRY:
+            raise ExtractorError('Access token has expired', expected=True)
+
+    def _real_initialize(self):
+        if not self._TOKEN:
+            self.raise_login_required(self._LOGIN_HINT, method=None)
+        self._check_token_expiry()
+
+
+class VootIE(VootBaseIE):
     _VALID_URL = r'''(?x)
                     (?:
                         voot:|
@@ -20,27 +92,25 @@ class VootIE(InfoExtractor):
                      )
                     (?P<id>\d{3,})
                     '''
-    _GEO_COUNTRIES = ['IN']
     _TESTS = [{
         'url': 'https://www.voot.com/shows/ishq-ka-rang-safed/1/360558/is-this-the-end-of-kamini-/441353',
         'info_dict': {
-            'id': '0_8ledb18o',
+            'id': '441353',
             'ext': 'mp4',
-            'title': 'Ishq Ka Rang Safed - Season 01 - Episode 340',
+            'title': 'Is this the end of Kamini?',
             'description': 'md5:06291fbbbc4dcbe21235c40c262507c1',
-            'timestamp': 1472162937,
+            'timestamp': 1472103000,
             'upload_date': '20160825',
             'series': 'Ishq Ka Rang Safed',
             'season_number': 1,
             'episode': 'Is this the end of Kamini?',
             'episode_number': 340,
-            'view_count': int,
-            'like_count': int,
-        },
-        'params': {
-            'skip_download': True,
+            'release_date': '20160825',
+            'season': 'Season 1',
+            'age_limit': 13,
+            'duration': 1146.0,
         },
-        'expected_warnings': ['Failed to download m3u8 information'],
+        'params': {'skip_download': 'm3u8'},
     }, {
         'url': 'https://www.voot.com/kids/characters/mighty-cat-masked-niyander-e-/400478/school-bag-disappears/440925',
         'only_matching': True,
@@ -55,59 +125,50 @@ class VootIE(InfoExtractor):
     def _real_extract(self, url):
         video_id = self._match_id(url)
         media_info = self._download_json(
-            'https://wapi.voot.com/ws/ott/getMediaInfo.json', video_id,
-            query={
-                'platform': 'Web',
-                'pId': 2,
-                'mediaId': video_id,
-            })
-
-        status_code = try_get(media_info, lambda x: x['status']['code'], int)
-        if status_code != 0:
-            raise ExtractorError(media_info['status']['message'], expected=True)
-
-        media = media_info['assets']
-
-        entry_id = media['EntryId']
-        title = media['MediaName']
-        formats = self._extract_m3u8_formats(
-            'https://cdnapisec.kaltura.com/p/1982551/playManifest/pt/https/f/applehttp/t/web/e/' + entry_id,
-            video_id, 'mp4', m3u8_id='hls')
-
-        description, series, season_number, episode, episode_number = [None] * 5
-
-        for meta in try_get(media, lambda x: x['Metas'], list) or []:
-            key, value = meta.get('Key'), meta.get('Value')
-            if not key or not value:
-                continue
-            if key == 'ContentSynopsis':
-                description = value
-            elif key == 'RefSeriesTitle':
-                series = value
-            elif key == 'RefSeriesSeason':
-                season_number = int_or_none(value)
-            elif key == 'EpisodeMainTitle':
-                episode = value
-            elif key == 'EpisodeNo':
-                episode_number = int_or_none(value)
+            'https://psapi.voot.com/jio/voot/v1/voot-web/content/query/asset-details', video_id,
+            query={'ids': f'include:{video_id}', 'responseType': 'common'}, headers={'accesstoken': self._TOKEN})
+
+        try:
+            m3u8_url = self._download_json(
+                'https://vootapi.media.jio.com/playback/v1/playbackrights', video_id,
+                'Downloading playback JSON', data=b'{}', headers={
+                    **self.geo_verification_headers(),
+                    **self._API_HEADERS,
+                    'Content-Type': 'application/json;charset=utf-8',
+                    'platform': 'androidwebdesktop',
+                    'vootid': video_id,
+                    'voottoken': self._TOKEN,
+                })['m3u8']
+        except ExtractorError as e:
+            if isinstance(e.cause, urllib.error.HTTPError) and e.cause.code == 400:
+                self._check_token_expiry()
+            raise
+
+        formats = self._extract_m3u8_formats(m3u8_url, video_id, 'mp4', m3u8_id='hls')
+        self._remove_duplicate_formats(formats)
+
         return {
-            'extractor_key': 'Kaltura',
-            'id': entry_id,
-            'title': title,
-            'description': description,
-            'series': series,
-            'season_number': season_number,
-            'episode': episode,
-            'episode_number': episode_number,
-            'timestamp': unified_timestamp(media.get('CreationDate')),
-            'duration': int_or_none(media.get('Duration')),
-            'view_count': int_or_none(media.get('ViewCounter')),
-            'like_count': int_or_none(media.get('like_counter')),
-            'formats': formats,
+            'id': video_id,
+            # '/_definst_/smil:vod/' m3u8 manifests claim to have 720p+ formats but max out at 480p
+            'formats': traverse_obj(formats, (
+                lambda _, v: '/_definst_/smil:vod/' not in v['url'] or v['height'] <= 480)),
+            'http_headers': self._API_HEADERS,
+            **traverse_obj(media_info, ('result', 0, {
+                'title': ('fullTitle', {str}),
+                'description': ('fullSynopsis', {str}),
+                'series': ('showName', {str}),
+                'season_number': ('season', {int_or_none}),
+                'episode': ('fullTitle', {str}),
+                'episode_number': ('episode', {int_or_none}),
+                'timestamp': ('uploadTime', {int_or_none}),
+                'release_date': ('telecastDate', {unified_strdate}),
+                'age_limit': ('ageNemonic', {parse_age_limit}),
+                'duration': ('duration', {float_or_none}),
+            })),
         }
 
 
-class VootSeriesIE(InfoExtractor):
+class VootSeriesIE(VootBaseIE):
     _VALID_URL = r'https?://(?:www\.)?voot\.com/shows/[^/]+/(?P<id>\d{3,})'
     _TESTS = [{
         'url': 'https://www.voot.com/shows/chakravartin-ashoka-samrat/100002',
diff --git a/yt_dlp/extractor/wrestleuniverse.py b/yt_dlp/extractor/wrestleuniverse.py
index 946edf20a..b12b0f0a9 100644
--- a/yt_dlp/extractor/wrestleuniverse.py
+++ b/yt_dlp/extractor/wrestleuniverse.py
@@ -41,7 +41,7 @@ def _TOKEN(self):
             token = try_call(lambda: self._get_cookies('https://www.wrestle-universe.com/')['token'].value)
             if not token and not self._REFRESH_TOKEN:
                 self.raise_login_required()
-            self._REAL_TOKEN = token
+            self._TOKEN = token
 
         if not self._REAL_TOKEN or self._TOKEN_EXPIRY <= int(time.time()):
             if not self._REFRESH_TOKEN:
diff --git a/yt_dlp/extractor/ximalaya.py b/yt_dlp/extractor/ximalaya.py
index ff18ba697..3d5e6cf90 100644
--- a/yt_dlp/extractor/ximalaya.py
+++ b/yt_dlp/extractor/ximalaya.py
@@ -158,7 +158,7 @@ def _fetch_page(self, playlist_id, page_idx):
         return self._download_json(
             'https://www.ximalaya.com/revision/album/v1/getTracksList',
             playlist_id, note=f'Downloading tracks list page {page_idx}',
-            query={'albumId': playlist_id, 'pageNum': page_idx, 'sort': 1})['data']
+            query={'albumId': playlist_id, 'pageNum': page_idx})['data']
 
     def _get_entries(self, page_data):
         for e in page_data['tracks']:
diff --git a/yt_dlp/extractor/zaiko.py b/yt_dlp/extractor/zaiko.py
new file mode 100644
index 000000000..59fc64c5a
--- /dev/null
+++ b/yt_dlp/extractor/zaiko.py
@@ -0,0 +1,92 @@
+from .common import InfoExtractor
+from ..utils import (
+    ExtractorError,
+    extract_attributes,
+    int_or_none,
+    str_or_none,
+    traverse_obj,
+    unescapeHTML,
+    url_or_none,
+)
+
+
+class ZaikoIE(InfoExtractor):
+    _VALID_URL = r'https?://(?:[\w-]+\.)?zaiko\.io/event/(?P<id>\d+)/stream(?:/\d+)+'
+    _TESTS = [{
+        'url': 'https://zaiko.io/event/324868/stream/20571/20571',
+        'info_dict': {
+            'id': '324868',
+            'ext': 'mp4',
+            'title': 'ZAIKO STREAMING TEST',
+            'alt_title': '[VOD] ZAIKO STREAMING TEST_20210603(Do Not Delete)',
+            'uploader_id': '454',
+            'uploader': 'ZAIKO ZERO',
+            'release_timestamp': 1583809200,
+            'thumbnail': r're:https://[a-z0-9]+.cloudfront.net/[a-z0-9_]+/[a-z0-9_]+',
+            'release_date': '20200310',
+            'categories': ['Tech House'],
+            'live_status': 'was_live',
+        },
+        'params': {'skip_download': 'm3u8'},
+    }]
+
+    def _parse_vue_element_attr(self, name, string, video_id):
+        page_elem = self._search_regex(rf'(<{name}[^>]+>)', string, name)
+        attrs = {}
+        for key, value in extract_attributes(page_elem).items():
+            if key.startswith(':'):
+                attrs[key[1:]] = self._parse_json(
+                    value, video_id, transform_source=unescapeHTML, fatal=False)
+        return attrs
+
+    def _real_extract(self, url):
+        video_id = self._match_id(url)
+
+        webpage, urlh = self._download_webpage_handle(url, video_id)
+        final_url = urlh.geturl()
+        if 'zaiko.io/login' in final_url:
+            self.raise_login_required()
+        elif '/_buy/' in final_url:
+            raise ExtractorError('Your account does not have tickets to this event', expected=True)
+        stream_meta = self._parse_vue_element_attr('stream-page', webpage, video_id)
+
+        player_page = self._download_webpage(
+            stream_meta['stream-access']['video_source'], video_id,
+            'Downloading player page', headers={'referer': 'https://zaiko.io/'})
+        player_meta = self._parse_vue_element_attr('player', player_page, video_id)
+        status = traverse_obj(player_meta, ('initial_event_info', 'status', {str}))
+        live_status, msg, expected = {
+            'vod': ('was_live', 'No VOD stream URL was found', False),
+            'archiving': ('post_live', 'Event VOD is still being processed', True),
+            'deleting': ('post_live', 'This event has ended', True),
+            'deleted': ('post_live', 'This event has ended', True),
+            'error': ('post_live', 'This event has ended', True),
+            'disconnected': ('post_live', 'Stream has been disconnected', True),
+            'live_to_disconnected': ('post_live', 'Stream has been disconnected', True),
+            'live': ('is_live', 'No livestream URL found was found', False),
+            'waiting': ('is_upcoming', 'Live event has not yet started', True),
+            'cancelled': ('not_live', 'Event has been cancelled', True),
+        }.get(status) or ('not_live', f'Unknown event status "{status}"', False)
+
+        stream_url = traverse_obj(player_meta, ('initial_event_info', 'endpoint', {url_or_none}))
+        formats = self._extract_m3u8_formats(
+            stream_url, video_id, live=True, fatal=False) if stream_url else []
+        if not formats:
+            self.raise_no_formats(msg, expected=expected)
+
+        return {
+            'id': video_id,
+            'formats': formats,
+            'live_status': live_status,
+            **traverse_obj(stream_meta, {
+                'title': ('event', 'name', {str}),
+                'uploader': ('profile', 'name', {str}),
+                'uploader_id': ('profile', 'id', {str_or_none}),
+                'release_timestamp': ('stream', 'start', 'timestamp', {int_or_none}),
+                'categories': ('event', 'genres', ..., {lambda x: x or None}),
+            }),
+            **traverse_obj(player_meta, ('initial_event_info', {
+                'alt_title': ('title', {str}),
+                'thumbnail': ('poster_url', {url_or_none}),
+            })),
+        }
diff --git a/yt_dlp/extractor/zee5.py b/yt_dlp/extractor/zee5.py
index a64eb9ed0..b4734cc8f 100644
--- a/yt_dlp/extractor/zee5.py
+++ b/yt_dlp/extractor/zee5.py
@@ -1,14 +1,16 @@
 import json
-import random
-import string
+import time
+import uuid
 
 from .common import InfoExtractor
 from ..compat import compat_str
 from ..utils import (
     ExtractorError,
     int_or_none,
+    jwt_decode_hs256,
     parse_age_limit,
     str_or_none,
+    try_call,
     try_get,
     unified_strdate,
     unified_timestamp,
@@ -94,12 +96,12 @@ class Zee5IE(InfoExtractor):
         'url': 'https://www.zee5.com/music-videos/details/adhento-gaani-vunnapaatuga-jersey-nani-shraddha-srinath/0-0-56973',
         'only_matching': True
     }]
-    _DETAIL_API_URL = 'https://spapi.zee5.com/singlePlayback/getDetails/secure?content_id={}&device_id={}&platform_name=desktop_web&country=IN&check_parental_control=false'
-    _DEVICE_ID = ''.join(random.choices(string.ascii_letters + string.digits, k=20)).ljust(32, '0')
+    _DEVICE_ID = str(uuid.uuid4())
     _USER_TOKEN = None
     _LOGIN_HINT = 'Use "--username <mobile_number>" to login using otp or "--username token" and "--password <user_token>" to login using user token.'
     _NETRC_MACHINE = 'zee5'
     _GEO_COUNTRIES = ['IN']
+    _USER_COUNTRY = None
 
     def _perform_login(self, username, password):
         if len(username) == 10 and username.isdigit() and self._USER_TOKEN is None:
@@ -118,11 +120,16 @@ def _perform_login(self, username, password):
             self._USER_TOKEN = otp_verify_json.get('token')
             if not self._USER_TOKEN:
                 raise ExtractorError(otp_request_json['message'], expected=True)
-        elif username.lower() == 'token' and len(password) > 1198:
+        elif username.lower() == 'token' and try_call(lambda: jwt_decode_hs256(password)):
             self._USER_TOKEN = password
         else:
             raise ExtractorError(self._LOGIN_HINT, expected=True)
 
+        token = jwt_decode_hs256(self._USER_TOKEN)
+        if token.get('exp', 0) <= int(time.time()):
+            raise ExtractorError('User token has expired', expected=True)
+        self._USER_COUNTRY = token.get('current_country')
+
     def _real_extract(self, url):
         video_id, display_id = self._match_valid_url(url).group('id', 'display_id')
         access_token_request = self._download_json(
@@ -137,8 +144,13 @@ def _real_extract(self, url):
             data['X-Z5-Guest-Token'] = self._DEVICE_ID
 
         json_data = self._download_json(
-            self._DETAIL_API_URL.format(video_id, self._DEVICE_ID),
-            video_id, headers={'content-type': 'application/json'}, data=json.dumps(data).encode('utf-8'))
+            'https://spapi.zee5.com/singlePlayback/getDetails/secure', video_id, query={
+                'content_id': video_id,
+                'device_id': self._DEVICE_ID,
+                'platform_name': 'desktop_web',
+                'country': self._USER_COUNTRY or self.get_param('geo_bypass_country') or 'IN',
+                'check_parental_control': False,
+            }, headers={'content-type': 'application/json'}, data=json.dumps(data).encode('utf-8'))
         asset_data = json_data['assetDetails']
         show_data = json_data.get('showDetails', {})
         if 'premium' in asset_data['business_type']:
diff --git a/yt_dlp/jsinterp.py b/yt_dlp/jsinterp.py
index 7c7940efd..d6d555733 100644
--- a/yt_dlp/jsinterp.py
+++ b/yt_dlp/jsinterp.py
@@ -44,7 +44,7 @@ def wrapped(a, b):
 
 
 def _js_div(a, b):
-    if JS_Undefined in (a, b) or not (a and b):
+    if JS_Undefined in (a, b) or not (a or b):
         return float('nan')
     return (a or 0) / b if b else float('inf')
 
diff --git a/yt_dlp/utils/_utils.py b/yt_dlp/utils/_utils.py
index 4179d58c1..646210116 100644
--- a/yt_dlp/utils/_utils.py
+++ b/yt_dlp/utils/_utils.py
@@ -4147,6 +4147,7 @@ class ISO639Utils:
         'or': 'ori',
         'os': 'oss',
         'pa': 'pan',
+        'pe': 'per',
         'pi': 'pli',
         'pl': 'pol',
         'ps': 'pus',


Test patch:
diff --git a/test/test_jsinterp.py b/test/test_jsinterp.py
index 4d44e6efe..b01477e6f 100644
--- a/test/test_jsinterp.py
+++ b/test/test_jsinterp.py
@@ -28,6 +28,13 @@ def test_basic(self):
     def test_calc(self):
         self._test('function f(a){return 2*a+1;}', 7, args=[3])
 
+    def test_div(self):
+        jsi = JSInterpreter('function f(a, b){return a / b;}')
+        self.assertTrue(math.isnan(jsi.call_function('f', 0, 0)))
+        self.assertTrue(math.isnan(jsi.call_function('f', JS_Undefined, 1)))
+        self.assertTrue(math.isinf(jsi.call_function('f', 2, 0)))
+        self.assertEqual(jsi.call_function('f', 0, 3), 0)
+
     def test_empty_return(self):
         self._test('function f(){return; y()}', None)
 
diff --git a/test/test_youtube_signature.py b/test/test_youtube_signature.py
index 13120d97f..01f09de88 100644
--- a/test/test_youtube_signature.py
+++ b/test/test_youtube_signature.py
@@ -150,6 +150,10 @@
         'https://www.youtube.com/s/player/cfa9e7cb/player_ias.vflset/en_US/base.js',
         'aCi3iElgd2kq0bxVbQ', 'QX1y8jGb2IbZ0w',
     ),
+    (
+        'https://www.youtube.com/s/player/8c7583ff/player_ias.vflset/en_US/base.js',
+        '1wWCVpRR96eAmMI87L', 'KSkWAVv1ZQxC3A',
+    ),
 ]