Repository: yt-dlp/yt-dlp

Issue:
[ok.ru] request download video from webm format, save size/quality
### Checklist

- [X] I'm reporting a site feature request
- [X] I've verified that I'm running yt-dlp version **2022.02.04**. ([update instructions](https://github.com/yt-dlp/yt-dlp#update))
- [X] I've checked that all provided URLs are alive and playable in a browser
- [X] I've searched the [bugtracker](https://github.com/yt-dlp/yt-dlp/issues?q=) for similar issues including closed ones. DO NOT post duplicates
- [X] I've read the [guidelines for opening an issue](https://github.com/yt-dlp/yt-dlp/blob/master/CONTRIBUTING.md#opening-an-issue)
- [ ] I've read about [sharing account credentials](https://github.com/yt-dlp/yt-dlp/blob/master/CONTRIBUTING.md#are-you-willing-to-share-account-details-if-needed) and I'm willing to share it if required

### Region

_No response_

### Example URLs

https://ok.ru/video/3812509420088

### Description

Hello, default get format from ok.ru
```
C:\Users\Administrator>youtube-dl -F "https://ok.ru/video/3812509420088" --user-agent "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:97.0) Gecko/20100101 Firefox/97.0"
[Odnoklassniki] 3812509420088: Downloading desktop webpage
[Odnoklassniki] 3812509420088: Downloading m3u8 information
[info] Available formats for 3812509420088:
ID       EXT RESOLUTION FPS │    FILESIZE   TBR PROTO  │ VCODEC        VBR ACODEC    ABR     ASR MORE INFO
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
mobile   mp4 unknown        │                   https  │ unknown           unknown
hls-208  mp4 256x106     25 │ ~ 231.93MiB  208k m3u8_n │ unknown      208k unknown    0k
mpd-0    mp4 256x106     25 │ ~ 136.89MiB  122k https  │ avc1.64000B  122k mp4a.40.2  0k 44100Hz DASH video, mp4_dash
lowest   mp4 unknown        │                   https  │ unknown           unknown
hls-434  mp4 426x178     25 │ ~ 484.87MiB  434k m3u8_n │ unknown      434k unknown    0k
mpd-1    mp4 426x178     25 │ ~ 400.45MiB  359k https  │ avc1.64000D  359k mp4a.40.2  0k 44100Hz DASH video, mp4_dash
low      mp4 unknown        │                   https  │ unknown           unknown
hls-903  mp4 640x266     25 │ ~1007.17MiB  903k m3u8_n │ unknown      903k unknown    0k
mpd-2    mp4 640x266     25 │ ~ 853.57MiB  765k https  │ avc1.4D4015  765k mp4a.40.2  0k 44100Hz DASH video, mp4_dash
sd       mp4 unknown        │                   https  │ unknown           unknown
hls-1496 mp4 852x356     25 │ ~   1.63GiB 1496k m3u8_n │ unknown     1496k unknown    0k
mpd-3    mp4 852x356     25 │ ~   1.48GiB 1361k https  │ avc1.4D401E 1361k mp4a.40.2  0k 44100Hz DASH video, mp4_dash
hd       mp4 unknown        │                   https  │ unknown           unknown
hls-4148 mp4 1280x534    25 │ ~   4.52GiB 4148k m3u8_n │ unknown     4148k unknown    0k
mpd-4    mp4 1280x534    25 │ ~   4.33GiB 3976k https  │ avc1.4D401F 3976k mp4a.40.2  0k 44100Hz DASH video, mp4_dash
```
And when you view-source, you will get [metadataWebmUrl]() download video from it can save size and quality for some reason.
![Screenshot_6](https://user-images.githubusercontent.com/1846222/156878563-88c27631-b0a4-4ec3-b356-e1e0999b1f31.png)

Hope you can add this to extract_url function. Thank you.

Regards,



### Verbose log

```shell
C:\Users\Administrator>youtube-dl -FvU "https://vd196.mycdn.me/?expires=1646557772885&srcIp=116.xxx.xxx.161&pr=10&srcAg=GECKO&ms=45.136.22.4&type=4&sig=hLJz8ZaZSIk&ct=6&urls=185.226.53.6&clientType=0&id=2254678985272" --user-agent "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:97.0) Gecko/20100101 Firefox/97.0"
[debug] Command-line config: ['-FvU', 'https://vd196.mycdn.me/?expires=1646557772885&srcIp=116.xxx.xxx.161&pr=10&srcAg=GECKO&ms=45.136.22.4&type=4&sig=hLJz8ZaZSIk&ct=6&urls=185.226.53.6&clientType=0&id=2254678985272', '--user-agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:97.0) Gecko/20100101 Firefox/97.0']
[debug] Encodings: locale cp1252, fs utf-8, out utf-8, err utf-8, pref cp1252
[debug] yt-dlp version 2022.02.04 [c1653e9] (win_exe)
[debug] Python version 3.8.10 (CPython 64bit) - Windows-10-10.0.19042-SP0
[debug] exe versions: ffmpeg 5.0-full_build-www.gyan.dev (setts), ffprobe 4.4.1-full_build-www.gyan.dev
[debug] Optional libraries: Cryptodome, mutagen, sqlite, websockets
[debug] Proxy map: {}
Latest version: 2022.02.04, Current version: 2022.02.04
yt-dlp is up to date (2022.02.04)
[debug] [generic] Extracting URL: https://vd196.mycdn.me/?expires=1646557772885&srcIp=116.xxx.xxx.161&pr=10&srcAg=GECKO&ms=45.136.22.4&type=4&sig=hLJz8ZaZSIk&ct=6&urls=185.226.53.6&clientType=0&id=2254678985272
[generic] ?expires=1646557772885&srcIp=116.xxx.xxx.161&pr=10&srcAg=GECKO&ms=45.136.22.4&type=4&sig=hLJz8ZaZSIk&ct=6&urls=185.226.53: Requesting header
WARNING: [generic] Falling back on generic information extractor.
[generic] ?expires=1646557772885&srcIp=116.xxx.xxx.161&pr=10&srcAg=GECKO&ms=45.136.22.4&type=4&sig=hLJz8ZaZSIk&ct=6&urls=185.226.53: Downloading webpage
[generic] ?expires=1646557772885&srcIp=116.xxx.xxx.161&pr=10&srcAg=GECKO&ms=45.136.22.4&type=4&sig=hLJz8ZaZSIk&ct=6&urls=185.226.53: Extracting information
[debug] Identified a DASH manifest
[debug] Formats sorted by: hasvid, ie_pref, lang, quality, res, fps, hdr:12(7), vcodec:vp9.2(10), acodec, filesize, fs_approx, tbr, vbr, abr, asr, proto, vext, aext, hasaud, source, id
WARNING: Extractor failed to obtain "title". Creating a generic title instead
[info] Available formats for ?expires=1646557772885&srcIp=116.xxx.xxx.161&pr=10&srcAg=GECKO&ms=45.136.22.4&type=4&sig=hLJz8ZaZSIk&ct=6&urls=185.226.53:
ID EXT  RESOLUTION FPS │   TBR PROTO │ VCODEC       VBR ACODEC      ABR     ASR MORE INFO
──────────────────────────────────────────────────────────────────────────────────────────────────────
5  webm audio only     │   32k https │ audio only       opus        32k 48000Hz DASH audio, webm_dash
6  webm audio only     │  128k https │ audio only       opus       128k 48000Hz DASH audio, webm_dash
7  webm audio only     │  192k https │ audio only       opus       192k 48000Hz DASH audio, webm_dash
0  webm 256x106     25 │  158k https │ vp9         158k video only              DASH video, webm_dash
2  webm 426x178     25 │  164k https │ vp9         164k video only              DASH video, webm_dash
1  webm 640x266     25 │  279k https │ vp9         279k video only              DASH video, webm_dash
3  webm 852x356     25 │  512k https │ vp9         512k video only              DASH video, webm_dash
4  webm 1280x534    25 │ 1800k https │ vp9        1800k video only              DASH video, webm_dash

--------------------------

C:\Users\Administrator>youtube-dl -Uv -N 8 "https://vd196.mycdn.me/?expires=1646557772885&srcIp=116.xxx.xxx.161&pr=10&srcAg=GECKO&ms=45.136.22.4&type=4&sig=hLJz8ZaZSIk&ct=6&urls=185.226.53.6&clientType=0&id=2254678985272" --user-agent "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:97.0) Gecko/20100101 Firefox/97.0" -o "test.mp4"
[debug] Command-line config: ['-Uv', '-N', '8', 'https://vd196.mycdn.me/?expires=1646557772885&srcIp=116.xxx.xxx.161&pr=10&srcAg=GECKO&ms=45.136.22.4&type=4&sig=hLJz8ZaZSIk&ct=6&urls=185.226.53.6&clientType=0&id=2254678985272', '--user-agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:97.0) Gecko/20100101 Firefox/97.0', '-o', 'test.mp4']
[debug] Encodings: locale cp1252, fs utf-8, out utf-8, err utf-8, pref cp1252
[debug] yt-dlp version 2022.02.04 [c1653e9] (win_exe)
[debug] Python version 3.8.10 (CPython 64bit) - Windows-10-10.0.19042-SP0
[debug] exe versions: ffmpeg 5.0-full_build-www.gyan.dev (setts), ffprobe 4.4.1-full_build-www.gyan.dev
[debug] Optional libraries: Cryptodome, mutagen, sqlite, websockets
[debug] Proxy map: {}
Latest version: 2022.02.04, Current version: 2022.02.04
yt-dlp is up to date (2022.02.04)
[debug] [generic] Extracting URL: https://vd196.mycdn.me/?expires=1646557772885&srcIp=116.xxx.xxx.161&pr=10&srcAg=GECKO&ms=45.136.22.4&type=4&sig=hLJz8ZaZSIk&ct=6&urls=185.226.53.6&clientType=0&id=2254678985272
[generic] ?expires=1646557772885&srcIp=116.xxx.xxx.161&pr=10&srcAg=GECKO&ms=45.136.22.4&type=4&sig=hLJz8ZaZSIk&ct=6&urls=185.226.53: Requesting header
WARNING: [generic] Falling back on generic information extractor.
[generic] ?expires=1646557772885&srcIp=116.xxx.xxx.161&pr=10&srcAg=GECKO&ms=45.136.22.4&type=4&sig=hLJz8ZaZSIk&ct=6&urls=185.226.53: Downloading webpage
[generic] ?expires=1646557772885&srcIp=116.xxx.xxx.161&pr=10&srcAg=GECKO&ms=45.136.22.4&type=4&sig=hLJz8ZaZSIk&ct=6&urls=185.226.53: Extracting information
[debug] Identified a DASH manifest
[debug] Formats sorted by: hasvid, ie_pref, lang, quality, res, fps, hdr:12(7), vcodec:vp9.2(10), acodec, filesize, fs_approx, tbr, vbr, abr, asr, proto, vext, aext, hasaud, source, id
WARNING: Extractor failed to obtain "title". Creating a generic title instead
[debug] Default format spec: bestvideo*+bestaudio/best
[info] ?expires=1646557772885&srcIp=116.xxx.xxx.161&pr=10&srcAg=GECKO&ms=45.136.22.4&type=4&sig=hLJz8ZaZSIk&ct=6&urls=185.226.53: Downloading 1 format(s): 4+7
[debug] Invoking downloader on "https://vd196.mycdn.me/?expires=1646557772885&srcIp=116.xxx.xxx.161&pr=10&srcAg=GECKO&ms=45.136.22.4&type=3&sig=5zzC4L6DCJE&ct=21&urls=185.226.53.6&clientType=0&id=2254678985272"
[download] Resuming download at byte 6958108
[download] Destination: test.mp4.f4.webm
[download]   0.9% of 1.06GiB at 247.80KiB/s ETA 01:13:59

```


Discussion:

Patch:
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/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/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/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/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')
 


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',
+    ),
 ]