Repository: yt-dlp/yt-dlp

Issue:
Add support for ETTU.tv
### DO NOT REMOVE OR SKIP THE ISSUE TEMPLATE

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

### Checklist

- [X] I'm reporting a new site support request
- [X] I've verified that I'm running yt-dlp version **2023.02.17** ([update instructions](https://github.com/yt-dlp/yt-dlp#update)) or later (specify commit)
- [X] I've checked that all provided URLs are playable in a browser with the same IP and same login details
- [X] I've checked that none of provided URLs [violate any copyrights](https://github.com/yt-dlp/yt-dlp/blob/master/CONTRIBUTING.md#is-the-website-primarily-used-for-piracy) or contain any [DRM](https://en.wikipedia.org/wiki/Digital_rights_management) to the best of my knowledge
- [X] I've searched [known issues](https://github.com/yt-dlp/yt-dlp/issues/3766) and the [bugtracker](https://github.com/yt-dlp/yt-dlp/issues?q=) for similar issues **including closed ones**. DO NOT post duplicates
- [X] I've read the [guidelines for opening an issue](https://github.com/yt-dlp/yt-dlp/blob/master/CONTRIBUTING.md#opening-an-issue)
- [X] I've read about [sharing account credentials](https://github.com/yt-dlp/yt-dlp/blob/master/CONTRIBUTING.md#are-you-willing-to-share-account-details-if-needed) and am willing to share it if required

### Region

France

### Example URLs

https://www.ettu.tv/en-int/playerpage/1573849

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

Cannot download replays from ettu.tv

### Provide verbose output that clearly demonstrates the problem

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

### Complete Verbose Output

```shell
[debug] Command-line config: ['-vU', 'https://www.ettu.tv/en-int/playerpage/1573849']
[debug] User config "/home/cuicui/.config/yt-dlp/config": ['--merge-output-format', 'mkv']
[debug] Encodings: locale UTF-8, fs utf-8, pref UTF-8, out utf-8, error utf-8, screen utf-8
[debug] yt-dlp version 2023.02.17 [a0a7c01] (zip)
[debug] Python 3.8.10 (CPython x86_64 64bit) - Linux-5.4.0-139-generic-x86_64-with-glibc2.29 (OpenSSL 1.1.1f  31 Mar 2020, glibc 2.31)
[debug] exe versions: ffmpeg 4.2.7, ffprobe 4.2.7, phantomjs 2.1.1
[debug] Optional libraries: certifi-2022.12.07, pycrypto-3.15.0, pyxattr-0.6.1, secretstorage-2.3.1, sqlite3-2.6.0
[debug] Proxy map: {}
[debug] Loaded 1782 extractors
[debug] Fetching release info: https://api.github.com/repos/yt-dlp/yt-dlp/releases/latest
Latest version: 2023.02.17, Current version: 2023.02.17
yt-dlp is up to date (2023.02.17)
[generic] Extracting URL: https://www.ettu.tv/en-int/playerpage/1573849
[generic] 1573849: Downloading webpage
WARNING: [generic] Falling back on generic information extractor
[generic] 1573849: Extracting information
[debug] Looking for embeds
ERROR: Unsupported URL: https://www.ettu.tv/en-int/playerpage/1573849
Traceback (most recent call last):
  File "/home/cuicui/Sync/bin/yt-dlp/yt_dlp/YoutubeDL.py", line 1502, in wrapper
    return func(self, *args, **kwargs)
  File "/home/cuicui/Sync/bin/yt-dlp/yt_dlp/YoutubeDL.py", line 1578, in __extract_info
    ie_result = ie.extract(url)
  File "/home/cuicui/Sync/bin/yt-dlp/yt_dlp/extractor/common.py", line 693, in extract
    ie_result = self._real_extract(url)
  File "/home/cuicui/Sync/bin/yt-dlp/yt_dlp/extractor/generic.py", line 2509, in _real_extract
    raise UnsupportedError(url)
yt_dlp.utils.UnsupportedError: Unsupported URL: https://www.ettu.tv/en-int/playerpage/1573849
```


Discussion:

Patch:
diff --git a/yt_dlp/extractor/_extractors.py b/yt_dlp/extractor/_extractors.py
index f54024211..10e132b4b 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
@@ -578,6 +579,7 @@
     ESPNCricInfoIE,
 )
 from .esri import EsriVideoIE
+from .ettutv import EttuTvIE
 from .europa import EuropaIE, EuroParlWebstreamIE
 from .europeantour import EuropeanTourIE
 from .eurosport import EurosportIE
@@ -664,6 +666,7 @@
     FunimationShowIE,
 )
 from .funk import FunkIE
+from .funker530 import Funker530IE
 from .fusion import FusionIE
 from .fuyintv import FuyinTVIE
 from .gab import (
@@ -2441,6 +2444,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/ettutv.py b/yt_dlp/extractor/ettutv.py
new file mode 100644
index 000000000..46d725543
--- /dev/null
+++ b/yt_dlp/extractor/ettutv.py
@@ -0,0 +1,60 @@
+from .common import InfoExtractor
+from ..utils import bool_or_none, traverse_obj, unified_timestamp, url_or_none
+
+
+class EttuTvIE(InfoExtractor):
+    _VALID_URL = r'https?://(?:www\.)?ettu\.tv/[^?#]+/playerpage/(?P<id>[0-9]+)'
+
+    _TESTS = [{
+        'url': 'https://www.ettu.tv/en-int/playerpage/1573849',
+        'md5': '5874b7639a2aa866d1f6c3a4037c7c09',
+        'info_dict': {
+            'id': '1573849',
+            'title': 'Ni Xia Lian - Shao Jieni',
+            'description': 'ITTF Europe Top 16 Cup',
+            'timestamp': 1677348600,
+            'upload_date': '20230225',
+            'thumbnail': r're:^https?://.*\.(?:jpg|png)',
+            'ext': 'mp4',
+        },
+    }, {
+        'url': 'https://www.ettu.tv/en-int/playerpage/1573753',
+        'md5': '1fc094bf96cf2d5ec0f434d3a6dec9aa',
+        'info_dict': {
+            'id': '1573753',
+            'title': 'Qiu Dang - Jorgic Darko',
+            'description': 'ITTF Europe Top 16 Cup',
+            'timestamp': 1677423600,
+            'upload_date': '20230226',
+            'thumbnail': r're:^https?://.*\.(?:jpg|png)',
+            'ext': 'mp4',
+        },
+    }]
+
+    def _real_extract(self, url):
+        video_id = self._match_id(url)
+
+        player_settings = self._download_json(
+            f'https://www.ettu.tv/api/v3/contents/{video_id}/player-settings', video_id, query={
+                'language': 'en',
+                'showTitle': 'true',
+                'device': 'desktop',
+            })
+
+        stream_response = self._download_json(player_settings['streamAccess'], video_id, data={})
+
+        formats, subtitles = self._extract_m3u8_formats_and_subtitles(
+            stream_response['data']['stream'], video_id, 'mp4')
+
+        return {
+            'id': video_id,
+            'formats': formats,
+            'subtitles': subtitles,
+            **traverse_obj(player_settings, {
+                'title': 'title',
+                'description': ('metaInformation', 'competition'),
+                'thumbnail': ('image', {url_or_none}),
+                'timestamp': ('date', {unified_timestamp}),
+                'is_live': ('isLivestream', {bool_or_none}),
+            })
+        }
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',
+    ),
 ]