Repository: yt-dlp/yt-dlp
Pull request #3171 (2022-03-23 09:05:32)

[youtube:tab] Fix duration extraction for shorts
## Please follow the guide below

- You will be asked some questions, please read them **carefully** and answer honestly
- Put an `x` into all the boxes [ ] relevant to your *pull request* (like that [x])
- Use *Preview* tab to see how your *pull request* will actually look like

---

### Before submitting a *pull request* make sure you have:
- [X] At least skimmed through [contributing guidelines](https://github.com/yt-dlp/yt-dlp/blob/master/CONTRIBUTING.md#developer-instructions) including [yt-dlp coding conventions](https://github.com/yt-dlp/yt-dlp/blob/master/CONTRIBUTING.md#yt-dlp-coding-conventions)
- [X] [Searched](https://github.com/yt-dlp/yt-dlp/search?q=is%3Apr&type=Issues) the bugtracker for similar pull requests
- [X] Checked the code with [flake8](https://pypi.python.org/pypi/flake8)

### In order to be accepted and merged into yt-dlp each piece of code must be in public domain or released under [Unlicense](http://unlicense.org/). Check one of the following options:
- [X] I am the original author of this code and I am willing to release it under [Unlicense](http://unlicense.org/)
- [ ] I am not the original author of this code but it is in public domain or released under [Unlicense](http://unlicense.org/) (provide reliable evidence)

### What is the purpose of your *pull request*?
- [X] Bug fix
- [ ] Improvement
- [ ] New extractor
- [ ] New feature

---

### Description of your *pull request* and other information

On some feeds (channels and search atm), YouTube has replaced the duration overlay (which is where we were getting duration from) with a shorts badge :roll_eyes: 

This PR adds a fallback to extract the duration from the accessibility data. 
I've had to modify `yt_dlp.utils.parse_duration` slightly to handle the duration format.

This data looks like this for reference:
```
WEIRD Sandwich Hack 😳 #shorts by Lionfield 13 hours ago 15 seconds 207,566 views - play Short
Italians get mad at VIRAL Tiktok videos by Lionfield 6 days ago 4 minutes, 39 seconds 168,049 views
this is it guys i quit by Yoe Mase Streamed 7 days ago 54 minutes 578 views
AN UNFILTERED STREAM OF CONTENT (Live Stream VOD) by Yoe Mase Streamed 11 months ago 2 hours, 24 minutes 678 views
# live
NASA Science Live: We Just Opened a 50-year-old Moon Sample by NASA
# in playlist
Why Self-Driving Cars DON'T Just CRASH by Techquickie 2 years ago 6 minutes, 15 seconds
```
(let me know if there are any edge cases I have missed!)


Related: https://github.com/TeamNewPipe/NewPipe/issues/8034 (thanks @TiA4f8R for alerting us)



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..434bef65f 100644
--- a/yt_dlp/YoutubeDL.py
+++ b/yt_dlp/YoutubeDL.py
@@ -2459,6 +2459,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 +2636,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 = [{}]
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/extractors.py b/yt_dlp/extractor/extractors.py
index 867304e75..de88a0f4a 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,
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/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/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..72f11691f 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*


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)