Repository: yt-dlp/yt-dlp

Issue:
Support for wasd.tv?
### Checklist

- [X] I'm reporting a new site support request
- [X] I've verified that I'm running yt-dlp version **2022.03.08.1**. ([update instructions](https://github.com/yt-dlp/yt-dlp#update))
- [X] I've checked that all provided URLs are alive and playable in a browser
- [X] I've checked that none of provided URLs [violate any copyrights](https://github.com/ytdl-org/youtube-dl#can-you-add-support-for-this-anime-video-site-or-site-which-shows-current-movies-for-free) or contain any [DRM](https://en.wikipedia.org/wiki/Digital_rights_management) to the best of my knowledge
- [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)
- [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

Europe

### Example URLs

https://wasd.tv/StasSatori?record=919135
https://wasd.tv/spacemita/videos
https://wasd.tv/spacemita/videos?record=907755
https://wasd.tv/spacemita/clips
https://wasd.tv/spacemita/clips?clip=26804

### Description

Hi.
It would be nice to add wasd.tv support.
For now it looks like only some patches for youtube-dl doing it.

https://gitea.datahoarding.agency/ZenulAbidin/youtube-dl/commit/cdf98a34eae6a6d31f4b2c5a9ef36cace4cb3d92
https://copr.fedorainfracloud.org/coprs/vr5/wasd.tv/

### Verbose log

```shell
./yt-dlp -vU "https://wasd.tv/StasSatori?record=919135"
[debug] Command-line config: ['-vU', 'https://wasd.tv/StasSatori?record=919135']
[debug] Encodings: locale UTF-8, fs utf-8, out utf-8, err utf-8, pref UTF-8
[debug] yt-dlp version 2022.03.08.1 [c0c2c57d3] (zip)
[debug] Python version 3.10.2 (CPython 64bit) - Linux-5.16.12-200.fc35.x86_64-x86_64-with-glibc2.34
[debug] exe versions: ffmpeg 4.4.1 (setts), ffprobe 4.4.1
[debug] Optional libraries: Crypto, sqlite
[debug] Proxy map: {}
Latest version: 2022.03.08.1, Current version: 2022.03.08.1
yt-dlp is up to date (2022.03.08.1)
[debug] [generic] Extracting URL: https://wasd.tv/StasSatori?record=919135
[generic] StasSatori?record=919135: Requesting header
WARNING: [generic] Falling back on generic information extractor.
[generic] StasSatori?record=919135: Downloading webpage
[generic] StasSatori?record=919135: Extracting information
[debug] Looking for video embeds
ERROR: Unsupported URL: https://wasd.tv/StasSatori?record=919135
Traceback (most recent call last):
  File "/home/anleal/./yt-dlp/yt_dlp/YoutubeDL.py", line 1389, in wrapper
    return func(self, *args, **kwargs)
  File "/home/anleal/./yt-dlp/yt_dlp/YoutubeDL.py", line 1459, in __extract_info
    ie_result = ie.extract(url)
  File "/home/anleal/./yt-dlp/yt_dlp/extractor/common.py", line 617, in extract
    ie_result = self._real_extract(url)
  File "/home/anleal/./yt-dlp/yt_dlp/extractor/generic.py", line 3985, in _real_extract
    raise UnsupportedError(url)
yt_dlp.utils.UnsupportedError: Unsupported URL: https://wasd.tv/StasSatori?record=919135
```


Discussion:
Ashish0804: I didn't review further as i see same mistakes( proly not the right word but whatever) in the rest of the code aswell.
Modify them accordingly 
pukkandan: > - [x] I am not the original author of this code but it is in public domain or released under [Unlicense](http://unlicense.org/) (provide reliable evidence)

??
hatienl0i261299: > > * [x]  I am not the original author of this code but it is in public domain or released under [Unlicense](http://unlicense.org/) (provide reliable evidence)
> 
> ??

As you can see, in the description of issue https://github.com/yt-dlp/yt-dlp/issues/3041 has a link `youtube-dl` that extracted this site, and the author of issue want add this site to yt-dlp, so that i copied and enhanced this extractor to yt-dlp.
pukkandan: this? https://github.com/ytdl-org/youtube-dl/pull/26500
hatienl0i261299: > this? [ytdl-org/youtube-dl#26500](https://github.com/ytdl-org/youtube-dl/pull/26500)

Im not sure, i used this link, and for now this link cant access
![image](https://user-images.githubusercontent.com/36532499/159124403-ca3f123a-2c03-49d3-9d6d-9891dec9b75b.png)

But maybe your link is true.

pukkandan: then how can I know the code is unlicense?

> it is in public domain or released under Unlicense

> provide reliable evidence
pukkandan: If you can confirm your code is based on that PR, that is sufficient evidence. Otherwise, the original author will need to confirm
hatienl0i261299: > If you can confirm your code is based on that PR, that is sufficient evidence. Otherwise, the original author will need to confirm

Oh sure, i understood what you mean, i apologize about it.
Lesmiscore: Looks like the LICENSE file is modified about a year ago, although I can't confirm its content.
https://web.archive.org/web/20210508010911/https://gitea.datahoarding.agency/ZenulAbidin/youtube-dl
mesb1: Hi.
Not sure i clearly understand discussion here related to license and unavailable repo (it was available when i created ticket).
Is there anything i can help you with?
pukkandan: This PR is using code from that (now unavailable) repo according to OP. But we are not sure how the said code is licensed. Until it can be confirmed to be in the public domain, this can't be merged
mesb1: 



> Until it can be confirmed to be in the public domain, this can't be merged

It looks like author is also exists on github:
https://github.com/ZenulAbidin

is it enough for you if he confirm it?
pukkandan: assuming they are the original author, yes

Patch:
diff --git a/README.md b/README.md
index c184d23c4..955a98557 100644
--- a/README.md
+++ b/README.md
@@ -1656,7 +1656,7 @@ # EXTRACTOR ARGUMENTS
 The following extractors use this feature:
 
 #### youtube
-* `skip`: `hls` or `dash` (or both) to skip download of the respective manifests
+* `skip`: One or more of `hls`, `dash` or `translated_subs` to skip extraction of the m3u8 manifests, dash manifests and auto-translated subtitles respectively
 * `player_client`: Clients to extract video data from. The main clients are `web`, `android`, `ios`, `mweb`. These also have `_music`, `_embedded`, `_agegate`, and `_creator` variants (Eg: `web_embedded`) (`mweb` has only `_agegate`). By default, `android,web` is used, but the agegate and creator variants are added as required for age-gated videos. Similarly the music variants are added for `music.youtube.com` urls. You can also use `all` to use all the clients, and `default` for the default clients.
 * `player_skip`: Skip some network requests that are generally needed for robust extraction. One or more of `configs` (skip client configs), `webpage` (skip initial webpage), `js` (skip js player). While these options can help reduce the number of requests needed or avoid some rate-limiting, they could cause some issues. See [#860](https://github.com/yt-dlp/yt-dlp/pull/860) for more details
 * `include_live_dash`: Include live dash formats even without `--live-from-start` (These formats don't download properly)
diff --git a/yt_dlp/YoutubeDL.py b/yt_dlp/YoutubeDL.py
index c2f4f3a95..6a8e45b1a 100644
--- a/yt_dlp/YoutubeDL.py
+++ b/yt_dlp/YoutubeDL.py
@@ -65,6 +65,7 @@
     ExistingVideoReached,
     expand_path,
     ExtractorError,
+    filter_dict,
     float_or_none,
     format_bytes,
     format_field,
@@ -1574,13 +1575,9 @@ def process_ie_result(self, ie_result, download=True, extra_info=None):
             if not info:
                 return info
 
-            force_properties = dict(
-                (k, v) for k, v in ie_result.items() if v is not None)
-            for f in ('_type', 'url', 'id', 'extractor', 'extractor_key', 'ie_key'):
-                if f in force_properties:
-                    del force_properties[f]
             new_result = info.copy()
-            new_result.update(force_properties)
+            new_result.update(filter_dict(ie_result, lambda k, v: (
+                v is not None and k not in {'_type', 'url', 'id', 'extractor', 'extractor_key', 'ie_key'})))
 
             # Extracted info may not be a video result (i.e.
             # info.get('_type', 'video') != video) but rather an url or
@@ -1818,7 +1815,7 @@ def get_entry(i):
         ie_result['entries'] = playlist_results
 
         # Write the updated info to json
-        if _infojson_written and self._write_info_json(
+        if _infojson_written is True and self._write_info_json(
                 'updated playlist', ie_result,
                 self.prepare_filename(ie_copy, 'pl_infojson'), overwrite=True) is None:
             return
@@ -2459,6 +2456,11 @@ def sanitize_numeric_fields(info):
         info_dict['__has_drm'] = any(f.get('has_drm') for f in formats)
         if not self.params.get('allow_unplayable_formats'):
             formats = [f for f in formats if not f.get('has_drm')]
+            if info_dict['__has_drm'] and all(
+                    f.get('acodec') == f.get('vcodec') == 'none' for f in formats):
+                self.report_warning(
+                    'This video is DRM protected and only images are available for download. '
+                    'Use --list-formats to see them')
 
         get_from_start = not info_dict.get('is_live') or bool(self.params.get('live_from_start'))
         if not get_from_start:
@@ -2631,8 +2633,9 @@ def is_wellformed(f):
 
         if not formats_to_download:
             if not self.params.get('ignore_no_formats_error'):
-                raise ExtractorError('Requested format is not available', expected=True,
-                                     video_id=info_dict['id'], ie=info_dict['extractor'])
+                raise ExtractorError(
+                    'Requested format is not available. Use --list-formats for a list of available formats',
+                    expected=True, video_id=info_dict['id'], ie=info_dict['extractor'])
             self.report_warning('Requested format is not available')
             # Process what we can, even without any available formats.
             formats_to_download = [{}]
@@ -3783,7 +3786,7 @@ def get_encoding(self):
         return encoding
 
     def _write_info_json(self, label, ie_result, infofn, overwrite=None):
-        ''' Write infojson and returns True = written, False = skip, None = error '''
+        ''' Write infojson and returns True = written, 'exists' = Already exists, False = skip, None = error '''
         if overwrite is None:
             overwrite = self.params.get('overwrites', True)
         if not self.params.get('writeinfojson'):
@@ -3795,14 +3798,15 @@ def _write_info_json(self, label, ie_result, infofn, overwrite=None):
             return None
         elif not overwrite and os.path.exists(infofn):
             self.to_screen(f'[info] {label.title()} metadata is already present')
-        else:
-            self.to_screen(f'[info] Writing {label} metadata as JSON to: {infofn}')
-            try:
-                write_json_file(self.sanitize_info(ie_result, self.params.get('clean_infojson', True)), infofn)
-            except (OSError, IOError):
-                self.report_error(f'Cannot write {label} metadata to JSON file {infofn}')
-                return None
-        return True
+            return 'exists'
+
+        self.to_screen(f'[info] Writing {label} metadata as JSON to: {infofn}')
+        try:
+            write_json_file(self.sanitize_info(ie_result, self.params.get('clean_infojson', True)), infofn)
+            return True
+        except (OSError, IOError):
+            self.report_error(f'Cannot write {label} metadata to JSON file {infofn}')
+            return None
 
     def _write_description(self, label, ie_result, descfn):
         ''' Write description and returns True = written, False = skip, None = error '''
diff --git a/yt_dlp/extractor/banbye.py b/yt_dlp/extractor/banbye.py
new file mode 100644
index 000000000..3d4d36ec3
--- /dev/null
+++ b/yt_dlp/extractor/banbye.py
@@ -0,0 +1,153 @@
+# coding: utf-8
+from __future__ import unicode_literals
+
+import math
+
+from .common import InfoExtractor
+from ..compat import (
+    compat_urllib_parse_urlparse,
+    compat_parse_qs,
+)
+from ..utils import (
+    format_field,
+    InAdvancePagedList,
+    traverse_obj,
+    unified_timestamp,
+)
+
+
+class BanByeBaseIE(InfoExtractor):
+    _API_BASE = 'https://api.banbye.com'
+    _CDN_BASE = 'https://cdn.banbye.com'
+    _VIDEO_BASE = 'https://banbye.com/watch'
+
+    @staticmethod
+    def _extract_playlist_id(url, param='playlist'):
+        return compat_parse_qs(
+            compat_urllib_parse_urlparse(url).query).get(param, [None])[0]
+
+    def _extract_playlist(self, playlist_id):
+        data = self._download_json(f'{self._API_BASE}/playlists/{playlist_id}', playlist_id)
+        return self.playlist_result([
+            self.url_result(f'{self._VIDEO_BASE}/{video_id}', BanByeIE)
+            for video_id in data['videoIds']], playlist_id, data.get('name'))
+
+
+class BanByeIE(BanByeBaseIE):
+    _VALID_URL = r'https?://(?:www\.)?banbye.com/(?:en/)?watch/(?P<id>\w+)'
+    _TESTS = [{
+        'url': 'https://banbye.com/watch/v_ytfmvkVYLE8T',
+        'md5': '2f4ea15c5ca259a73d909b2cfd558eb5',
+        'info_dict': {
+            'id': 'v_ytfmvkVYLE8T',
+            'ext': 'mp4',
+            'title': 'md5:5ec098f88a0d796f987648de6322ba0f',
+            'description': 'md5:4d94836e73396bc18ef1fa0f43e5a63a',
+            'uploader': 'wRealu24',
+            'channel_id': 'ch_wrealu24',
+            'channel_url': 'https://banbye.com/channel/ch_wrealu24',
+            'timestamp': 1647604800,
+            'upload_date': '20220318',
+            'duration': 1931,
+            'thumbnail': r're:https?://.*\.webp',
+            'tags': 'count:5',
+            'like_count': int,
+            'dislike_count': int,
+            'view_count': int,
+            'comment_count': int,
+        },
+    }, {
+        'url': 'https://banbye.com/watch/v_2JjQtqjKUE_F?playlistId=p_Ld82N6gBw_OJ',
+        'info_dict': {
+            'title': 'Krzysztof Karoń',
+            'id': 'p_Ld82N6gBw_OJ',
+        },
+        'playlist_count': 9,
+    }]
+
+    def _real_extract(self, url):
+        video_id = self._match_id(url)
+        playlist_id = self._extract_playlist_id(url, 'playlistId')
+
+        if self._yes_playlist(playlist_id, video_id):
+            return self._extract_playlist(playlist_id)
+
+        data = self._download_json(f'{self._API_BASE}/videos/{video_id}', video_id)
+        thumbnails = [{
+            'id': f'{quality}p',
+            'url': f'{self._CDN_BASE}/video/{video_id}/{quality}.webp',
+        } for quality in [48, 96, 144, 240, 512, 1080]]
+        formats = [{
+            'format_id': f'http-{quality}p',
+            'quality': quality,
+            'url': f'{self._CDN_BASE}/video/{video_id}/{quality}.mp4',
+        } for quality in data['quality']]
+
+        self._sort_formats(formats)
+
+        return {
+            'id': video_id,
+            'title': data.get('title'),
+            'description': data.get('desc'),
+            'uploader': traverse_obj(data, ('channel', 'name')),
+            'channel_id': data.get('channelId'),
+            'channel_url': format_field(data, 'channelId', 'https://banbye.com/channel/%s'),
+            'timestamp': unified_timestamp(data.get('publishedAt')),
+            'duration': data.get('duration'),
+            'tags': data.get('tags'),
+            'formats': formats,
+            'thumbnails': thumbnails,
+            'like_count': data.get('likes'),
+            'dislike_count': data.get('dislikes'),
+            'view_count': data.get('views'),
+            'comment_count': data.get('commentCount'),
+        }
+
+
+class BanByeChannelIE(BanByeBaseIE):
+    _VALID_URL = r'https?://(?:www\.)?banbye.com/(?:en/)?channel/(?P<id>\w+)'
+    _TESTS = [{
+        'url': 'https://banbye.com/channel/ch_wrealu24',
+        'info_dict': {
+            'title': 'wRealu24',
+            'id': 'ch_wrealu24',
+            'description': 'md5:da54e48416b74dfdde20a04867c0c2f6',
+        },
+        'playlist_mincount': 791,
+    }, {
+        'url': 'https://banbye.com/channel/ch_wrealu24?playlist=p_Ld82N6gBw_OJ',
+        'info_dict': {
+            'title': 'Krzysztof Karoń',
+            'id': 'p_Ld82N6gBw_OJ',
+        },
+        'playlist_count': 9,
+    }]
+    _PAGE_SIZE = 100
+
+    def _real_extract(self, url):
+        channel_id = self._match_id(url)
+        playlist_id = self._extract_playlist_id(url)
+
+        if playlist_id:
+            return self._extract_playlist(playlist_id)
+
+        def page_func(page_num):
+            data = self._download_json(f'{self._API_BASE}/videos', channel_id, query={
+                'channelId': channel_id,
+                'sort': 'new',
+                'limit': self._PAGE_SIZE,
+                'offset': page_num * self._PAGE_SIZE,
+            }, note=f'Downloading page {page_num+1}')
+            return [
+                self.url_result(f"{self._VIDEO_BASE}/{video['_id']}", BanByeIE)
+                for video in data['items']
+            ]
+
+        channel_data = self._download_json(f'{self._API_BASE}/channels/{channel_id}', channel_id)
+        entries = InAdvancePagedList(
+            page_func,
+            math.ceil(channel_data['videoCount'] / self._PAGE_SIZE),
+            self._PAGE_SIZE)
+
+        return self.playlist_result(
+            entries, channel_id, channel_data.get('name'), channel_data.get('description'))
diff --git a/yt_dlp/extractor/bilibili.py b/yt_dlp/extractor/bilibili.py
index b4eb20642..dd1ff512e 100644
--- a/yt_dlp/extractor/bilibili.py
+++ b/yt_dlp/extractor/bilibili.py
@@ -15,6 +15,7 @@
 )
 from ..utils import (
     ExtractorError,
+    filter_dict,
     int_or_none,
     float_or_none,
     mimetype2ext,
@@ -755,15 +756,21 @@ def json2srt(self, json):
             for i, line in enumerate(json['body']) if line.get('content'))
         return data
 
-    def _get_subtitles(self, ep_id):
-        sub_json = self._call_api(f'/web/v2/subtitle?episode_id={ep_id}&platform=web', ep_id)
+    def _get_subtitles(self, *, ep_id=None, aid=None):
+        sub_json = self._call_api(
+            '/web/v2/subtitle', ep_id or aid, note='Downloading subtitles list',
+            errnote='Unable to download subtitles list', query=filter_dict({
+                'platform': 'web',
+                'episode_id': ep_id,
+                'aid': aid,
+            }))
         subtitles = {}
         for sub in sub_json.get('subtitles') or []:
             sub_url = sub.get('url')
             if not sub_url:
                 continue
             sub_data = self._download_json(
-                sub_url, ep_id, errnote='Unable to download subtitles', fatal=False,
+                sub_url, ep_id or aid, errnote='Unable to download subtitles', fatal=False,
                 note='Downloading subtitles%s' % f' for {sub["lang"]}' if sub.get('lang') else '')
             if not sub_data:
                 continue
@@ -773,9 +780,14 @@ def _get_subtitles(self, ep_id):
             })
         return subtitles
 
-    def _get_formats(self, ep_id):
-        video_json = self._call_api(f'/web/playurl?ep_id={ep_id}&platform=web', ep_id,
-                                    note='Downloading video formats', errnote='Unable to download video formats')
+    def _get_formats(self, *, ep_id=None, aid=None):
+        video_json = self._call_api(
+            '/web/playurl', ep_id or aid, note='Downloading video formats',
+            errnote='Unable to download video formats', query=filter_dict({
+                'platform': 'web',
+                'ep_id': ep_id,
+                'aid': aid,
+            }))
         video_json = video_json['playurl']
         formats = []
         for vid in video_json.get('video') or []:
@@ -809,15 +821,15 @@ def _get_formats(self, ep_id):
         self._sort_formats(formats)
         return formats
 
-    def _extract_ep_info(self, episode_data, ep_id):
+    def _extract_video_info(self, video_data, *, ep_id=None, aid=None):
         return {
-            'id': ep_id,
-            'title': episode_data.get('title_display') or episode_data['title'],
-            'thumbnail': episode_data.get('cover'),
+            'id': ep_id or aid,
+            'title': video_data.get('title_display') or video_data.get('title'),
+            'thumbnail': video_data.get('cover'),
             'episode_number': int_or_none(self._search_regex(
-                r'^E(\d+)(?:$| - )', episode_data.get('title_display'), 'episode number', default=None)),
-            'formats': self._get_formats(ep_id),
-            'subtitles': self._get_subtitles(ep_id),
+                r'^E(\d+)(?:$| - )', video_data.get('title_display') or '', 'episode number', default=None)),
+            'formats': self._get_formats(ep_id=ep_id, aid=aid),
+            'subtitles': self._get_subtitles(ep_id=ep_id, aid=aid),
             'extractor_key': BiliIntlIE.ie_key(),
         }
 
@@ -854,7 +866,7 @@ def _perform_login(self, username, password):
 
 
 class BiliIntlIE(BiliIntlBaseIE):
-    _VALID_URL = r'https?://(?:www\.)?bili(?:bili\.tv|intl\.com)/(?:[a-z]{2}/)?play/(?P<season_id>\d+)/(?P<id>\d+)'
+    _VALID_URL = r'https?://(?:www\.)?bili(?:bili\.tv|intl\.com)/(?:[a-z]{2}/)?(play/(?P<season_id>\d+)/(?P<ep_id>\d+)|video/(?P<aid>\d+))'
     _TESTS = [{
         # Bstation page
         'url': 'https://www.bilibili.tv/en/play/34613/341736',
@@ -889,24 +901,35 @@ class BiliIntlIE(BiliIntlBaseIE):
     }, {
         'url': 'https://www.biliintl.com/en/play/34613/341736',
         'only_matching': True,
+    }, {
+        # User-generated content (as opposed to a series licensed from a studio)
+        'url': 'https://bilibili.tv/en/video/2019955076',
+        'only_matching': True,
+    }, {
+        # No language in URL
+        'url': 'https://www.bilibili.tv/video/2019955076',
+        'only_matching': True,
     }]
 
     def _real_extract(self, url):
-        season_id, video_id = self._match_valid_url(url).groups()
+        season_id, ep_id, aid = self._match_valid_url(url).group('season_id', 'ep_id', 'aid')
+        video_id = ep_id or aid
         webpage = self._download_webpage(url, video_id)
         # Bstation layout
         initial_data = self._parse_json(self._search_regex(
-            r'window\.__INITIAL_DATA__\s*=\s*({.+?});', webpage,
+            r'window\.__INITIAL_(?:DATA|STATE)__\s*=\s*({.+?});', webpage,
             'preload state', default='{}'), video_id, fatal=False) or {}
-        episode_data = traverse_obj(initial_data, ('OgvVideo', 'epDetail'), expected_type=dict)
+        video_data = (
+            traverse_obj(initial_data, ('OgvVideo', 'epDetail'), expected_type=dict)
+            or traverse_obj(initial_data, ('UgcVideo', 'videoData'), expected_type=dict) or {})
 
-        if not episode_data:
+        if season_id and not video_data:
             # Non-Bstation layout, read through episode list
             season_json = self._call_api(f'/web/v2/ogv/play/episodes?season_id={season_id}&platform=web', video_id)
-            episode_data = next(
+            video_data = next(
                 episode for episode in traverse_obj(season_json, ('sections', ..., 'episodes', ...), expected_type=dict)
-                if str(episode.get('episode_id')) == video_id)
-        return self._extract_ep_info(episode_data, video_id)
+                if str(episode.get('episode_id')) == ep_id)
+        return self._extract_video_info(video_data, ep_id=ep_id, aid=aid)
 
 
 class BiliIntlSeriesIE(BiliIntlBaseIE):
@@ -934,7 +957,7 @@ def _entries(self, series_id):
         series_json = self._call_api(f'/web/v2/ogv/play/episodes?season_id={series_id}&platform=web', series_id)
         for episode in traverse_obj(series_json, ('sections', ..., 'episodes', ...), expected_type=dict, default=[]):
             episode_id = str(episode.get('episode_id'))
-            yield self._extract_ep_info(episode, episode_id)
+            yield self._extract_video_info(episode, ep_id=episode_id)
 
     def _real_extract(self, url):
         series_id = self._match_id(url)
diff --git a/yt_dlp/extractor/common.py b/yt_dlp/extractor/common.py
index d3d13c40c..d0e57da23 100644
--- a/yt_dlp/extractor/common.py
+++ b/yt_dlp/extractor/common.py
@@ -49,6 +49,7 @@
     error_to_compat_str,
     extract_attributes,
     ExtractorError,
+    filter_dict,
     fix_xml_ampersands,
     float_or_none,
     format_field,
@@ -1588,7 +1589,7 @@ def traverse_json_ld(json_ld, at_top_level=True):
                     break
         traverse_json_ld(json_ld)
 
-        return dict((k, v) for k, v in info.items() if v is not None)
+        return filter_dict(info)
 
     def _search_nextjs_data(self, webpage, video_id, *, transform_source=None, fatal=True, **kw):
         return self._parse_json(
diff --git a/yt_dlp/extractor/extractors.py b/yt_dlp/extractor/extractors.py
index 867304e75..e5ae12a7d 100644
--- a/yt_dlp/extractor/extractors.py
+++ b/yt_dlp/extractor/extractors.py
@@ -122,6 +122,10 @@
 )
 from .azmedien import AZMedienIE
 from .baidu import BaiduVideoIE
+from .banbye import (
+    BanByeIE,
+    BanByeChannelIE,
+)
 from .bandaichannel import BandaiChannelIE
 from .bandcamp import (
     BandcampIE,
@@ -674,6 +678,12 @@
     IqIE,
     IqAlbumIE
 )
+
+from .itprotv import (
+    ITProTVIE,
+    ITProTVCourseIE
+)
+
 from .itv import (
     ITVIE,
     ITVBTCCIE,
@@ -1967,6 +1977,11 @@
     WashingtonPostIE,
     WashingtonPostArticleIE,
 )
+from .wasdtv import (
+    WASDTVStreamIE,
+    WASDTVRecordIE,
+    WASDTVClipIE,
+)
 from .wat import WatIE
 from .watchbox import WatchBoxIE
 from .watchindianporn import WatchIndianPornIE
diff --git a/yt_dlp/extractor/go.py b/yt_dlp/extractor/go.py
index 2ccc6df21..f92e16600 100644
--- a/yt_dlp/extractor/go.py
+++ b/yt_dlp/extractor/go.py
@@ -217,6 +217,7 @@ def _real_extract(self, url):
         title = video_data['title']
 
         formats = []
+        subtitles = {}
         for asset in video_data.get('assets', {}).get('asset', []):
             asset_url = asset.get('value')
             if not asset_url:
@@ -256,8 +257,10 @@ def _real_extract(self, url):
                     error_message = ', '.join([error['message'] for error in errors])
                     raise ExtractorError('%s said: %s' % (self.IE_NAME, error_message), expected=True)
                 asset_url += '?' + entitlement['uplynkData']['sessionKey']
-                formats.extend(self._extract_m3u8_formats(
-                    asset_url, video_id, 'mp4', m3u8_id=format_id or 'hls', fatal=False))
+                fmts, subs = self._extract_m3u8_formats_and_subtitles(
+                    asset_url, video_id, 'mp4', m3u8_id=format_id or 'hls', fatal=False)
+                formats.extend(fmts)
+                self._merge_subtitles(subs, target=subtitles)
             else:
                 f = {
                     'format_id': format_id,
@@ -281,7 +284,6 @@ def _real_extract(self, url):
                 formats.append(f)
         self._sort_formats(formats)
 
-        subtitles = {}
         for cc in video_data.get('closedcaption', {}).get('src', []):
             cc_url = cc.get('value')
             if not cc_url:
diff --git a/yt_dlp/extractor/itprotv.py b/yt_dlp/extractor/itprotv.py
new file mode 100644
index 000000000..64cb4e69a
--- /dev/null
+++ b/yt_dlp/extractor/itprotv.py
@@ -0,0 +1,141 @@
+# coding: utf-8
+
+import re
+
+from .common import InfoExtractor
+
+from ..utils import (
+    int_or_none,
+    str_or_none,
+    traverse_obj,
+    urljoin
+)
+
+
+class ITProTVBaseIE(InfoExtractor):
+    _ENDPOINTS = {
+        'course': 'course?url={}&brand=00002560-0000-3fa9-0000-1d61000035f3',
+        'episode': 'brand/00002560-0000-3fa9-0000-1d61000035f3/episode?url={}'
+    }
+
+    def _call_api(self, ep, item_id, webpage):
+        return self._download_json(
+            f'https://api.itpro.tv/api/urza/v3/consumer-web/{self._ENDPOINTS[ep].format(item_id)}',
+            item_id, note=f'Fetching {ep} data API',
+            headers={'Authorization': f'Bearer {self._fetch_jwt(webpage)}'})[ep]
+
+    def _fetch_jwt(self, webpage):
+        return self._search_regex(r'{"passedToken":"([\w-]+\.[\w-]+\.[\w-]+)",', webpage, 'jwt')
+
+    def _check_if_logged_in(self, webpage):
+        if re.match(r'{\s*member\s*:\s*null', webpage):
+            self.raise_login_required()
+
+
+class ITProTVIE(ITProTVBaseIE):
+    _VALID_URL = r'https://app.itpro.tv/course/(?P<course>[\w-]+)/(?P<id>[\w-]+)'
+    _TESTS = [{
+        'url': 'https://app.itpro.tv/course/guided-tour/introductionitprotv',
+        'md5': 'bca4a28c2667fd1a63052e71a94bb88c',
+        'info_dict': {
+            'id': 'introductionitprotv',
+            'ext': 'mp4',
+            'title': 'An Introduction to ITProTV 101',
+            'thumbnail': 'https://itprotv-image-bucket.s3.amazonaws.com/getting-started/itprotv-101-introduction-PGM.11_39_56_02.Still001.png',
+            'description': 'md5:b175c2c3061ce35a4dd33865b2c1da4e',
+            'duration': 269,
+            'series': 'ITProTV 101',
+            'series_id': 'guided-tour',
+            'availability': 'needs_auth',
+            'chapter': 'ITProTV 101',
+            'chapter_number': 1,
+            'chapter_id': '5dbb3de426b46c0010b5d1b6'
+        },
+    },
+        {
+        'url': 'https://app.itpro.tv/course/beyond-tech/job-interview-tips',
+        'md5': '101a299b98c47ccf4c67f9f0951defa8',
+        'info_dict': {
+            'id': 'job-interview-tips',
+            'ext': 'mp4',
+            'title': 'Job Interview Tips',
+            'thumbnail': 'https://s3.amazonaws.com:443/production-itprotv-thumbnails/2f370bf5-294d-4bbe-ab80-c0b5781630ea.png',
+            'description': 'md5:30d8ba483febdf89ec85623aad3c3cb6',
+            'duration': 267,
+            'series': 'Beyond Tech',
+            'series_id': 'beyond-tech',
+            'availability': 'needs_auth',
+            'chapter': 'Job Development',
+            'chapter_number': 2,
+            'chapter_id': '5f7c78d424330c000edf04d9'
+        },
+    }]
+
+    def _real_extract(self, url):
+        episode_id, course_name = self._match_valid_url(url).group('id', 'course')
+        webpage = self._download_webpage(url, episode_id)
+        self._check_if_logged_in(webpage)
+        course = self._call_api('course', course_name, webpage)
+        episode = self._call_api('episode', episode_id, webpage)
+
+        chapter_number, chapter = next((
+            (i, topic) for i, topic in enumerate(course.get('topics') or [], 1)
+            if traverse_obj(topic, 'id') == episode.get('topic')), {})
+
+        return {
+            'id': episode_id,
+            'title': episode.get('title'),
+            'description': episode.get('description'),
+            'thumbnail': episode.get('thumbnail'),
+            'formats': [
+                {'url': episode[f'jwVideo{h}Embed'], 'height': h}
+                for h in (320, 480, 720, 1080) if episode.get(f'jwVideo{h}Embed')
+            ],
+            'duration': int_or_none(episode.get('length')),
+            'series': course.get('name'),
+            'series_id': course.get('url'),
+            'chapter': str_or_none(chapter.get('title')),
+            'chapter_number': chapter_number,
+            'chapter_id': str_or_none(chapter.get('id')),
+            'subtitles': {
+                'en': [{'ext': 'vtt', 'data': episode['enCaptionData']}]
+            } if episode.get('enCaptionData') else None,
+        }
+
+
+class ITProTVCourseIE(ITProTVBaseIE):
+    _VALID_URL = r'https?://app.itpro.tv/course/(?P<id>[\w-]+)/?(?:$|[#?])'
+    _TESTS = [
+        {
+            'url': 'https://app.itpro.tv/course/guided-tour',
+            'info_dict': {
+                'id': 'guided-tour',
+                'description': 'md5:b175c2c3061ce35a4dd33865b2c1da4e',
+                'title': 'ITProTV 101',
+            },
+            'playlist_count': 6
+        },
+        {
+            'url': 'https://app.itpro.tv/course/beyond-tech',
+            'info_dict': {
+                'id': 'beyond-tech',
+                'description': 'md5:44cd99855e7f81a15ce1269bd0621fed',
+                'title': 'Beyond Tech'
+            },
+            'playlist_count': 15
+        },
+    ]
+
+    def _real_extract(self, url):
+        course_id = self._match_id(url)
+        webpage = self._download_webpage(url, course_id)
+        self._check_if_logged_in(webpage)
+        course = self._call_api('course', course_id, webpage)
+
+        entries = [self.url_result(
+            urljoin(url, f'{course_id}/{episode["url"]}'), ITProTVIE,
+            episode['url'], episode.get('title'), url_transparent=True)
+            for episode in course['episodes']]
+
+        return self.playlist_result(
+            entries, course_id, course.get('name'), course.get('description'))
diff --git a/yt_dlp/extractor/rai.py b/yt_dlp/extractor/rai.py
index 34f127285..9d243b2be 100644
--- a/yt_dlp/extractor/rai.py
+++ b/yt_dlp/extractor/rai.py
@@ -11,6 +11,7 @@
 from ..utils import (
     determine_ext,
     ExtractorError,
+    filter_dict,
     find_xpath_attr,
     fix_xml_ampersands,
     GeoRestrictedError,
@@ -110,11 +111,11 @@ def _extract_relinker_info(self, relinker_url, video_id, audio_only=False):
         if not audio_only:
             formats.extend(self._create_http_urls(relinker_url, formats))
 
-        return dict((k, v) for k, v in {
+        return filter_dict({
             'is_live': is_live,
             'duration': duration,
             'formats': formats,
-        }.items() if v is not None)
+        })
 
     def _create_http_urls(self, relinker_url, fmts):
         _RELINKER_REG = r'https?://(?P<host>[^/]+?)/(?:i/)?(?P<extra>[^/]+?)/(?P<path>.+?)/(?P<id>\d+)(?:_(?P<quality>[\d\,]+))?(?:\.mp4|/playlist\.m3u8).+?'
diff --git a/yt_dlp/extractor/viki.py b/yt_dlp/extractor/viki.py
index 8234ba7df..8a930798d 100644
--- a/yt_dlp/extractor/viki.py
+++ b/yt_dlp/extractor/viki.py
@@ -261,7 +261,7 @@ def _real_extract(self, url):
         mpd_content = self._download_webpage(mpd_url, video_id, note='Downloading initial MPD manifest')
         mpd_url = self._search_regex(
             r'(?mi)<BaseURL>(http.+.mpd)', mpd_content, 'new manifest', default=mpd_url)
-        if 'mpdhd_high' not in mpd_url:
+        if 'mpdhd_high' not in mpd_url and 'sig=' not in mpd_url:
             # Modify the URL to get 1080p
             mpd_url = mpd_url.replace('mpdhd', 'mpdhd_high')
         formats = self._extract_mpd_formats(mpd_url, video_id)
diff --git a/yt_dlp/extractor/viu.py b/yt_dlp/extractor/viu.py
index abd553f18..b0a1fca68 100644
--- a/yt_dlp/extractor/viu.py
+++ b/yt_dlp/extractor/viu.py
@@ -88,10 +88,9 @@ def _real_extract(self, url):
             #     r'(/hlsc_)[a-z]+(\d+\.m3u8)',
             #     r'\1whe\2', video_data['href'])
             m3u8_url = video_data['href']
-        formats = self._extract_m3u8_formats(m3u8_url, video_id, 'mp4')
+        formats, subtitles = self._extract_m3u8_formats_and_subtitles(m3u8_url, video_id, 'mp4')
         self._sort_formats(formats)
 
-        subtitles = {}
         for key, value in video_data.items():
             mobj = re.match(r'^subtitle_(?P<lang>[^_]+)_(?P<ext>(vtt|srt))', key)
             if not mobj:
diff --git a/yt_dlp/extractor/wasdtv.py b/yt_dlp/extractor/wasdtv.py
new file mode 100644
index 000000000..38c10dc62
--- /dev/null
+++ b/yt_dlp/extractor/wasdtv.py
@@ -0,0 +1,161 @@
+# coding: utf-8
+from __future__ import unicode_literals
+
+from .common import InfoExtractor
+from ..utils import (
+    ExtractorError,
+    int_or_none,
+    parse_iso8601,
+    traverse_obj,
+    try_get,
+)
+
+
+class WASDTVBaseIE(InfoExtractor):
+
+    def _fetch(self, path, video_id, description, query={}):
+        response = self._download_json(
+            f'https://wasd.tv/api/{path}', video_id, query=query,
+            note=f'Downloading {description} metadata',
+            errnote=f'Unable to download {description} metadata')
+        error = response.get('error')
+        if error:
+            raise ExtractorError(f'{self.IE_NAME} returned error: {error}', expected=True)
+        return response.get('result')
+
+    def _extract_thumbnails(self, thumbnails_dict):
+        return [{
+            'url': url,
+            'preference': index,
+        } for index, url in enumerate(
+            traverse_obj(thumbnails_dict, (('small', 'medium', 'large'),))) if url]
+
+    def _real_extract(self, url):
+        container = self._get_container(url)
+        stream = traverse_obj(container, ('media_container_streams', 0))
+        media = try_get(stream, lambda x: x['stream_media'][0])
+        if not media:
+            raise ExtractorError('Can not extract media data.', expected=True)
+        media_meta = media.get('media_meta')
+        media_url, is_live = self._get_media_url(media_meta)
+        video_id = media.get('media_id') or container.get('media_container_id')
+        formats, subtitles = self._extract_m3u8_formats_and_subtitles(media_url, video_id, 'mp4')
+        self._sort_formats(formats)
+        return {
+            'id': str(video_id),
+            'title': container.get('media_container_name') or self._og_search_title(self._download_webpage(url, video_id)),
+            'description': container.get('media_container_description'),
+            'thumbnails': self._extract_thumbnails(media_meta.get('media_preview_images')),
+            'timestamp': parse_iso8601(container.get('created_at')),
+            'view_count': int_or_none(stream.get('stream_current_viewers' if is_live else 'stream_total_viewers')),
+            'is_live': is_live,
+            'formats': formats,
+            'subtitles': subtitles,
+        }
+
+    def _get_container(self, url):
+        raise NotImplementedError('Subclass for get media container')
+
+    def _get_media_url(self, media_meta):
+        raise NotImplementedError('Subclass for get media url')
+
+
+class WASDTVStreamIE(WASDTVBaseIE):
+    IE_NAME = 'wasdtv:stream'
+    _VALID_URL = r'https?://wasd\.tv/(?P<id>[^/#?]+)$'
+    _TESTS = [{
+        'url': 'https://wasd.tv/24_7',
+        'info_dict': {
+            'id': '559738',
+            'ext': 'mp4',
+            'title': 'Live 24/7 Music',
+            'description': '24&#x2F;7 Music',
+            'timestamp': int,
+            'upload_date': r're:^\d{8}$',
+            'is_live': True,
+            'view_count': int,
+        },
+    }]
+
+    def _get_container(self, url):
+        nickname = self._match_id(url)
+        channel = self._fetch(f'channels/nicknames/{nickname}', video_id=nickname, description='channel')
+        channel_id = channel.get('channel_id')
+        containers = self._fetch(
+            'v2/media-containers', channel_id, 'running media containers',
+            query={
+                'channel_id': channel_id,
+                'media_container_type': 'SINGLE',
+                'media_container_status': 'RUNNING',
+            })
+        if not containers:
+            raise ExtractorError(f'{nickname} is offline', expected=True)
+        return containers[0]
+
+    def _get_media_url(self, media_meta):
+        return media_meta['media_url'], True
+
+
+class WASDTVRecordIE(WASDTVBaseIE):
+    IE_NAME = 'wasdtv:record'
+    _VALID_URL = r'https?://wasd\.tv/[^/#?]+/videos\?record=(?P<id>\d+)$'
+    _TESTS = [{
+        'url': 'https://wasd.tv/spacemita/videos?record=907755',
+        'md5': 'c9899dd85be4cc997816ff9f9ca516ce',
+        'info_dict': {
+            'id': '906825',
+            'ext': 'mp4',
+            'title': 'Музыкальный',
+            'description': 'md5:f510388d929ff60ae61d4c3cab3137cc',
+            'timestamp': 1645812079,
+            'upload_date': '20220225',
+            'thumbnail': r're:^https?://.+\.jpg',
+            'is_live': False,
+            'view_count': int,
+        },
+    }]
+
+    def _get_container(self, url):
+        container_id = self._match_id(url)
+        return self._fetch(
+            f'v2/media-containers/{container_id}', container_id, 'media container')
+
+    def _get_media_url(self, media_meta):
+        media_archive_url = media_meta.get('media_archive_url')
+        if media_archive_url:
+            return media_archive_url, False
+        return media_meta['media_url'], True
+
+
+class WASDTVClipIE(WASDTVBaseIE):
+    IE_NAME = 'wasdtv:clip'
+    _VALID_URL = r'https?://wasd\.tv/[^/#?]+/clips\?clip=(?P<id>\d+)$'
+    _TESTS = [{
+        'url': 'https://wasd.tv/spacemita/clips?clip=26804',
+        'md5': '818885e720143d7a4e776ff66fcff148',
+        'info_dict': {
+            'id': '26804',
+            'ext': 'mp4',
+            'title': 'Пуш флексит на голове стримера',
+            'timestamp': 1646682908,
+            'upload_date': '20220307',
+            'thumbnail': r're:^https?://.+\.jpg',
+            'view_count': int,
+        },
+    }]
+
+    def _real_extract(self, url):
+        clip_id = self._match_id(url)
+        clip = self._fetch(f'v2/clips/{clip_id}', video_id=clip_id, description='clip')
+        clip_data = clip.get('clip_data')
+        formats, subtitles = self._extract_m3u8_formats_and_subtitles(clip_data.get('url'), video_id=clip_id, ext='mp4')
+        self._sort_formats(formats)
+        return {
+            'id': clip_id,
+            'title': clip.get('clip_title') or self._og_search_title(self._download_webpage(url, clip_id, fatal=False)),
+            'thumbnails': self._extract_thumbnails(clip_data.get('preview')),
+            'timestamp': parse_iso8601(clip.get('created_at')),
+            'view_count': int_or_none(clip.get('clip_views_count')),
+            'formats': formats,
+            'subtitles': subtitles,
+        }
diff --git a/yt_dlp/extractor/youtube.py b/yt_dlp/extractor/youtube.py
index bd3a7d36b..e5097c264 100644
--- a/yt_dlp/extractor/youtube.py
+++ b/yt_dlp/extractor/youtube.py
@@ -807,6 +807,12 @@ def _extract_video(self, renderer):
         description = self._get_text(renderer, 'descriptionSnippet')
         duration = parse_duration(self._get_text(
             renderer, 'lengthText', ('thumbnailOverlays', ..., 'thumbnailOverlayTimeStatusRenderer', 'text')))
+        if duration is None:
+            duration = parse_duration(self._search_regex(
+                r'(?i)(ago)(?!.*\1)\s+(?P<duration>[a-z0-9 ,]+?)(?:\s+[\d,]+\s+views)?(?:\s+-\s+play\s+short)?$',
+                traverse_obj(renderer, ('title', 'accessibility', 'accessibilityData', 'label'), default='', expected_type=str),
+                video_id, default=None, group='duration'))
+
         view_count = self._get_count(renderer, 'viewCountText')
 
         uploader = self._get_text(renderer, 'ownerText', 'shortBylineText')
@@ -3479,6 +3485,7 @@ def process_language(container, base_url, lang_code, sub_name, query):
             subtitles, automatic_captions = {}, {}
             for lang_code, caption_track in captions.items():
                 base_url = caption_track.get('baseUrl')
+                orig_lang = parse_qs(base_url).get('lang', [None])[-1]
                 if not base_url:
                     continue
                 lang_name = self._get_text(caption_track, 'name', max_runs=1)
@@ -3492,19 +3499,20 @@ def process_language(container, base_url, lang_code, sub_name, query):
                 for trans_code, trans_name in translation_languages.items():
                     if not trans_code:
                         continue
+                    orig_trans_code = trans_code
                     if caption_track.get('kind') != 'asr':
+                        if 'translated_subs' in self._configuration_arg('skip'):
+                            continue
                         trans_code += f'-{lang_code}'
                         trans_name += format_field(lang_name, template=' from %s')
                     # Add an "-orig" label to the original language so that it can be distinguished.
                     # The subs are returned without "-orig" as well for compatibility
-                    if lang_code == f'a-{trans_code}':
+                    if lang_code == f'a-{orig_trans_code}':
                         process_language(
                             automatic_captions, base_url, f'{trans_code}-orig', f'{trans_name} (Original)', {})
                     # Setting tlang=lang returns damaged subtitles.
-                    # Not using lang_code == f'a-{trans_code}' here for future-proofing
-                    orig_lang = parse_qs(base_url).get('lang', [None])[-1]
                     process_language(automatic_captions, base_url, trans_code, trans_name,
-                                     {} if orig_lang == trans_code else {'tlang': trans_code})
+                                     {} if orig_lang == orig_trans_code else {'tlang': trans_code})
             info['automatic_captions'] = automatic_captions
             info['subtitles'] = subtitles
 
diff --git a/yt_dlp/utils.py b/yt_dlp/utils.py
index 6854dbb63..08e30d18f 100644
--- a/yt_dlp/utils.py
+++ b/yt_dlp/utils.py
@@ -2644,23 +2644,23 @@ def parse_duration(s):
         m = re.match(
             r'''(?ix)(?:P?
                 (?:
-                    [0-9]+\s*y(?:ears?)?\s*
+                    [0-9]+\s*y(?:ears?)?,?\s*
                 )?
                 (?:
-                    [0-9]+\s*m(?:onths?)?\s*
+                    [0-9]+\s*m(?:onths?)?,?\s*
                 )?
                 (?:
-                    [0-9]+\s*w(?:eeks?)?\s*
+                    [0-9]+\s*w(?:eeks?)?,?\s*
                 )?
                 (?:
-                    (?P<days>[0-9]+)\s*d(?:ays?)?\s*
+                    (?P<days>[0-9]+)\s*d(?:ays?)?,?\s*
                 )?
                 T)?
                 (?:
-                    (?P<hours>[0-9]+)\s*h(?:ours?)?\s*
+                    (?P<hours>[0-9]+)\s*h(?:ours?)?,?\s*
                 )?
                 (?:
-                    (?P<mins>[0-9]+)\s*m(?:in(?:ute)?s?)?\s*
+                    (?P<mins>[0-9]+)\s*m(?:in(?:ute)?s?)?,?\s*
                 )?
                 (?:
                     (?P<secs>[0-9]+)(?P<ms>\.[0-9]+)?\s*s(?:ec(?:ond)?s?)?\s*
@@ -3105,16 +3105,16 @@ def try_get(src, getter, expected_type=None):
                 return v
 
 
+def filter_dict(dct, cndn=lambda _, v: v is not None):
+    return {k: v for k, v in dct.items() if cndn(k, v)}
+
+
 def merge_dicts(*dicts):
     merged = {}
     for a_dict in dicts:
         for k, v in a_dict.items():
-            if v is None:
-                continue
-            if (k not in merged
-                    or (isinstance(v, compat_str) and v
-                        and isinstance(merged[k], compat_str)
-                        and not merged[k])):
+            if (v is not None and k not in merged
+                    or isinstance(v, str) and merged[k] == ''):
                 merged[k] = v
     return merged
 


Test patch:
diff --git a/test/test_utils.py b/test/test_utils.py
index 660ce03bf..31f168998 100644
--- a/test/test_utils.py
+++ b/test/test_utils.py
@@ -627,6 +627,8 @@ def test_parse_duration(self):
         self.assertEqual(parse_duration('3h 11m 53s'), 11513)
         self.assertEqual(parse_duration('3 hours 11 minutes 53 seconds'), 11513)
         self.assertEqual(parse_duration('3 hours 11 mins 53 secs'), 11513)
+        self.assertEqual(parse_duration('3 hours, 11 minutes, 53 seconds'), 11513)
+        self.assertEqual(parse_duration('3 hours, 11 mins, 53 secs'), 11513)
         self.assertEqual(parse_duration('62m45s'), 3765)
         self.assertEqual(parse_duration('6m59s'), 419)
         self.assertEqual(parse_duration('49s'), 49)