code:
import calendar
import json
import functools
from datetime import datetime
from random import random

from .common import InfoExtractor
from ..compat import (
    compat_urllib_parse_urlparse,
    compat_urlparse
)

from ..utils import (
    bug_reports_message,
    ExtractorError,
    get_first,
    int_or_none,
    OnDemandPagedList,
    parse_qs,
    srt_subtitles_timecode,
    traverse_obj,
)

class PanoptoBaseIE(InfoExtractor):
    BASE_URL_RE = r'(?P<base_url>https?://[\w.-]+\.panopto.(?:com|eu)/Panopto)'

    # see panopto core.js
    _SUB_LANG_MAPPING = {
        0: 'en-US',
        1: 'en-GB',
        2: 'es-MX',
        3: 'es-ES',
        4: 'de-DE',
        5: 'fr-FR',
        6: 'nl-NL',
        7: 'th-TH',
        8: 'zh-CN',
        9: 'zh-TW',
        10: 'ko-KR',
        11: 'ja-JP',
        12: 'ru-RU',
        13: 'pt-PT',
        14: 'pl-PL',
        15: 'en-AU',
        16: 'da-DK',
        17: 'fi-FI',
        18: 'hu-HU',
        19: 'nb-NO',
        20: 'sv-SE',
        21: 'it-IT'
    }

    def _call_api(self, base_url, path, video_id, data=None, fatal=True, **kwargs):
        response = self._download_json(
            base_url + path, video_id, data=json.dumps(data).encode('utf8') if data else None,
            fatal=fatal, headers={'accept': 'application/json', 'content-type': 'application/json'}, **kwargs)
        if not response:
            return
        error_code = traverse_obj(response, 'ErrorCode')
        if error_code == 2:
            self.raise_login_required(method='cookies')
        elif error_code is not None:
            msg = f'Panopto said: {response.get("ErrorMessage")}'
            if fatal:
                raise ExtractorError(msg, video_id=video_id, expected=True)
            else:
                self.report_warning(msg, video_id=video_id)
        return response

    @staticmethod
    def _parse_fragment(url):
        return {k: json.loads(v[0]) for k, v in compat_urlparse.parse_qs(compat_urllib_parse_urlparse(url).fragment).items()}

class PanoptoIE(PanoptoBaseIE):
    _VALID_URL = PanoptoBaseIE.BASE_URL_RE + r'/Pages/(Viewer|Embed)\.aspx.*(?:\?|&)id=(?P<id>[a-f0-9-]+)'
    _EMBED_REGEX = [rf'<iframe[^>]+src=["\'](?P<url>{PanoptoBaseIE.BASE_URL_RE}/Pages/(Viewer|Embed|Sessions/List)\.aspx[^"\']+)']
    _TESTS = [
        {
            'url': 'https://demo.hosted.panopto.com/Panopto/Pages/Viewer.aspx?id=26b3ae9e-4a48-4dcc-96ba-0befba08a0fb',
            'info_dict': {
                'id': '26b3ae9e-4a48-4dcc-96ba-0befba08a0fb',
                'title': 'Panopto for Business - Use Cases',
                'timestamp': 1459184200,
                'thumbnail': r're:https://demo\.hosted\.panopto\.com/.+',
                'upload_date': '20160328',
                'ext': 'mp4',
                'cast': [],
                'chapters': [],
                'duration': 88.17099999999999,
                'average_rating': int,
                'uploader_id': '2db6b718-47a0-4b0b-9e17-ab0b00f42b1e',
                'channel_id': 'e4c6a2fc-1214-4ca0-8fb7-aef2e29ff63a',
                'channel': 'Showcase Videos'
            },
        },
        {
            'url': 'https://demo.hosted.panopto.com/Panopto/Pages/Viewer.aspx?id=ed01b077-c9e5-4c7b-b8ff-15fa306d7a59',
            'info_dict': {
                'id': 'ed01b077-c9e5-4c7b-b8ff-15fa306d7a59',
                'title': 'Overcoming Top 4 Challenges of Enterprise Video',
                'uploader': 'Panopto Support',
                'timestamp': 1449409251,
                'thumbnail': r're:https://demo\.hosted\.panopto\.com/.+',
                'upload_date': '20151206',
                'ext': 'mp4',
                'chapters': 'count:12',
                'cast': ['Panopto Support'],
                'uploader_id': 'a96d1a31-b4de-489b-9eee-b4a5b414372c',
                'average_rating': int,
                'description': 'md5:4391837802b3fc856dadf630c4b375d1',
                'duration': 1088.2659999999998,
                'channel_id': '9f3c1921-43bb-4bda-8b3a-b8d2f05a8546',
                'channel': 'Webcasts',
            },
        },
        {
            # Extra params in URL
            'url': 'https://howtovideos.hosted.panopto.com/Panopto/Pages/Viewer.aspx?randomparam=thisisnotreal&id=5fa74e93-3d87-4694-b60e-aaa4012214ed&advance=true',
            'info_dict': {
                'id': '5fa74e93-3d87-4694-b60e-aaa4012214ed',
                'ext': 'mp4',
                'duration': 129.513,
                'cast': ['Kathryn Kelly'],
                'uploader_id': '316a0a58-7fa2-4cd9-be1c-64270d284a56',
                'timestamp': 1569845768,
                'tags': ['Viewer', 'Enterprise'],
                'chapters': [],
                'upload_date': '20190930',
                'thumbnail': r're:https://howtovideos\.hosted\.panopto\.com/.+',
                'description': 'md5:2d844aaa1b1a14ad0e2601a0993b431f',
                'title': 'Getting Started: View a Video',
                'average_rating': int,
                'uploader': 'Kathryn Kelly',
                'channel_id': 'fb93bc3c-6750-4b80-a05b-a921013735d3',
                'channel': 'Getting Started',
            }
        },
        {
            # Does not allow normal Viewer.aspx. AUDIO livestream has no url, so should be skipped and only give one stream.
            'url': 'https://unisa.au.panopto.com/Panopto/Pages/Embed.aspx?id=9d9a0fa3-e99a-4ebd-a281-aac2017f4da4',
            'info_dict': {
                'id': '9d9a0fa3-e99a-4ebd-a281-aac2017f4da4',
                'ext': 'mp4',
                'cast': ['LTS CLI Script'],
                'chapters': [],
                'duration': 2178.45,
                'description': 'md5:ee5cf653919f55b72bce2dbcf829c9fa',
                'channel_id': 'b23e673f-c287-4cb1-8344-aae9005a69f8',
                'average_rating': int,
                'uploader_id': '38377323-6a23-41e2-9ff6-a8e8004bf6f7',
                'uploader': 'LTS CLI Script',
                'timestamp': 1572458134,
                'title': 'WW2 Vets Interview 3 Ronald Stanley George',
                'thumbnail': r're:https://unisa\.au\.panopto\.com/.+',
                'channel': 'World War II Veteran Interviews',
                'upload_date': '20191030',
            },
        },
        {
            # Slides/storyboard
            'url': 'https://demo.hosted.panopto.com/Panopto/Pages/Viewer.aspx?id=a7f12f1d-3872-4310-84b0-f8d8ab15326b',
            'info_dict': {
                'id': 'a7f12f1d-3872-4310-84b0-f8d8ab15326b',
                'ext': 'mhtml',
                'timestamp': 1448798857,
                'duration': 4712.681,
                'title': 'Cache Memory - CompSci 15-213, Lecture 12',
                'channel_id': 'e4c6a2fc-1214-4ca0-8fb7-aef2e29ff63a',
                'uploader_id': 'a96d1a31-b4de-489b-9eee-b4a5b414372c',
                'upload_date': '20151129',
                'average_rating': 0,
                'uploader': 'Panopto Support',
                'channel': 'Showcase Videos',
                'description': 'md5:55e51d54233ddb0e6c2ed388ca73822c',
                'cast': ['ISR Videographer', 'Panopto Support'],
                'chapters': 'count:28',
                'thumbnail': r're:https://demo\.hosted\.panopto\.com/.+',
            },
            'params': {'format': 'mhtml', 'skip_download': True}
        },
        {
            'url': 'https://na-training-1.hosted.panopto.com/Panopto/Pages/Viewer.aspx?id=8285224a-9a2b-4957-84f2-acb0000c4ea9',
            'info_dict': {
                'id': '8285224a-9a2b-4957-84f2-acb0000c4ea9',
                'ext': 'mp4',
                'chapters': [],
                'title': 'Company Policy',
                'average_rating': 0,
                'timestamp': 1615058901,
                'channel': 'Human Resources',
                'tags': ['HumanResources'],
                'duration': 1604.243,
                'thumbnail': r're:https://na-training-1\.hosted\.panopto\.com/.+',
                'uploader_id': '8e8ba0a3-424f-40df-a4f1-ab3a01375103',
                'uploader': 'Cait M.',
                'upload_date': '20210306',
                'cast': ['Cait M.'],
                'subtitles': {'en-US': [{'ext': 'srt', 'data': 'md5:a3f4d25963fdeace838f327097c13265'}],
                              'es-ES': [{'ext': 'srt', 'data': 'md5:57e9dad365fd0fbaf0468eac4949f189'}]},
            },
            'params': {'writesubtitles': True, 'skip_download': True}
        }, {
            # On Panopto there are two subs: "Default" and en-US. en-US is blank and should be skipped.
            'url': 'https://na-training-1.hosted.panopto.com/Panopto/Pages/Viewer.aspx?id=940cbd41-f616-4a45-b13e-aaf1000c915b',
            'info_dict': {
                'id': '940cbd41-f616-4a45-b13e-aaf1000c915b',
                'ext': 'mp4',
                'subtitles': 'count:1',
                'title': 'HR Benefits Review Meeting*',
                'cast': ['Panopto Support'],
                'chapters': [],
                'timestamp': 1575024251,
                'thumbnail': r're:https://na-training-1\.hosted\.panopto\.com/.+',
                'channel': 'Zoom',
                'description': 'md5:04f90a9c2c68b7828144abfb170f0106',
                'uploader': 'Panopto Support',
                'average_rating': 0,
                'duration': 409.34499999999997,
                'uploader_id': 'b6ac04ad-38b8-4724-a004-a851004ea3df',
                'upload_date': '20191129',

            },
            'params': {'writesubtitles': True, 'skip_download': True}
        },
        {
            'url': 'https://ucc.cloud.panopto.eu/Panopto/Pages/Viewer.aspx?id=0e8484a4-4ceb-4d98-a63f-ac0200b455cb',
            'only_matching': True
        },
        {
            'url': 'https://brown.hosted.panopto.com/Panopto/Pages/Embed.aspx?id=0b3ff73b-36a0-46c5-8455-aadf010a3638',
            'only_matching': True
        },
    ]

    @classmethod
    def suitable(cls, url):
        return False if PanoptoPlaylistIE.suitable(url) else super().suitable(url)

    def _mark_watched(self, base_url, video_id, delivery_info):
        duration = traverse_obj(delivery_info, ('Delivery', 'Duration'), expected_type=float)
        invocation_id = delivery_info.get('InvocationId')
        stream_id = traverse_obj(delivery_info, ('Delivery', 'Streams', ..., 'PublicID'), get_all=False, expected_type=str)
        if invocation_id and stream_id and duration:
            timestamp_str = f'/Date({calendar.timegm(datetime.utcnow().timetuple())}000)/'
            data = {
                'streamRequests': [
                    {
                        'ClientTimeStamp': timestamp_str,
                        'ID': 0,
                        'InvocationID': invocation_id,
                        'PlaybackSpeed': 1,
                        'SecondsListened': duration - 1,
                        'SecondsRejected': 0,
                        'StartPosition': 0,
                        'StartReason': 2,
                        'StopReason': None,
                        'StreamID': stream_id,
                        'TimeStamp': timestamp_str,
                        'UpdatesRejected': 0
                    },
                ]}

            self._download_webpage(
                base_url + '/Services/Analytics.svc/AddStreamRequests', video_id,
                fatal=False, data=json.dumps(data).encode('utf8'), headers={'content-type': 'application/json'},
                note='Marking watched', errnote='Unable to mark watched')

    @staticmethod
    def _extract_chapters(timestamps):
        chapters = []
        for timestamp in timestamps or []:
            caption = timestamp.get('Caption')
            start, duration = int_or_none(timestamp.get('Time')), int_or_none(timestamp.get('Duration'))
            if not caption or start is None or duration is None:
                continue
            chapters.append({
                'start_time': start,
                'end_time': start + duration,
                'title': caption
            })
        return chapters

    @staticmethod
    def _extract_mhtml_formats(base_url, timestamps):
        image_frags = {}
        for timestamp in timestamps or []:
            duration = timestamp.get('Duration')
            obj_id, obj_sn = timestamp.get('ObjectIdentifier'), timestamp.get('ObjectSequenceNumber'),
            if timestamp.get('EventTargetType') == 'PowerPoint' and obj_id is not None and obj_sn is not None:
                image_frags.setdefault('slides', []).append({
                    'url': base_url + f'/Pages/Viewer/Image.aspx?id={obj_id}&number={obj_sn}',
                    'duration': duration
                })

            obj_pid, session_id, abs_time = timestamp.get('ObjectPublicIdentifier'), timestamp.get('SessionID'), timestamp.get('AbsoluteTime')
            if None not in (obj_pid, session_id, abs_time):
                image_frags.setdefault('chapter', []).append({
                    'url': base_url + f'/Pages/Viewer/Thumb.aspx?eventTargetPID={obj_pid}&sessionPID={session_id}&number={obj_sn}&isPrimary=false&absoluteTime={abs_time}',
                    'duration': duration,
                })
        for name, fragments in image_frags.items():
            yield {
                'format_id': name,
                'ext': 'mhtml',
                'protocol': 'mhtml',
                'acodec': 'none',
                'vcodec': 'none',
                'url': 'about:invalid',
                'fragments': fragments
            }

    @staticmethod
    def _json2srt(data, delivery):
        def _gen_lines():
            for i, line in enumerate(data):
                start_time = line['Time']
                duration = line.get('Duration')
                if duration:
                    end_time = start_time + duration
                else:
                    end_time = traverse_obj(data, (i + 1, 'Time')) or delivery['Duration']
                yield f'{i + 1}\n{srt_subtitles_timecode(start_time)} --> {srt_subtitles_timecode(end_time)}\n{line["Caption"]}'
        return '\n\n'.join(_gen_lines())

    def _get_subtitles(self, base_url, video_id, delivery):
        subtitles = {}
        for lang in delivery.get('AvailableLanguages') or []:
            response = self._call_api(
                base_url, '/Pages/Viewer/DeliveryInfo.aspx', video_id, fatal=False,
                note='Downloading captions JSON metadata', query={
                    'deliveryId': video_id,
                    'getCaptions': True,
                    'language': str(lang),
                    'responseType': 'json'
                }
            )
            if not isinstance(response, list):
                continue
            subtitles.setdefault(self._SUB_LANG_MAPPING.get(lang) or 'default', []).append({
                'ext': 'srt',
                'data': self._json2srt(response, delivery),
            })
        return subtitles

    def _extract_streams_formats_and_subtitles(self, video_id, streams, **fmt_kwargs):
        formats = []
        subtitles = {}
        for stream in streams or []:
            stream_formats = []
            http_stream_url = stream.get('StreamHttpUrl')
            stream_url = stream.get('StreamUrl')

            if http_stream_url:
                stream_formats.append({'url': http_stream_url})

            if stream_url:
                media_type = stream.get('ViewerMediaFileTypeName')
                if media_type in ('hls', ):
                    m3u8_formats, stream_subtitles = self._extract_m3u8_formats_and_subtitles(stream_url, video_id)
                    stream_formats.extend(m3u8_formats)
                    subtitles = self._merge_subtitles(subtitles, stream_subtitles)
                else:
                    stream_formats.append({
                        'url': stream_url
                    })
            for fmt in stream_formats:
                fmt.update({
                    'format_note': stream.get('Tag'),
                    **fmt_kwargs
                })
            formats.extend(stream_formats)

        return formats, subtitles

    def _real_extract(self, url):
        base_url, video_id = self._match_valid_url(url).group('base_url', 'id')
        delivery_info = self._call_api(
            base_url, '/Pages/Viewer/DeliveryInfo.aspx', video_id,
            query={
                'deliveryId': video_id,
                'invocationId': '',
                'isLiveNotes': 'false',
                'refreshAuthCookie': 'true',
                'isActiveBroadcast': 'false',
                'isEditing': 'false',
                'isKollectiveAgentInstalled': 'false',
                'isEmbed': 'false',
                'responseType': 'json',
            }
        )

        delivery = delivery_info['Delivery']
        session_start_time = int_or_none(delivery.get('SessionStartTime'))
        timestamps = delivery.get('Timestamps')

        # Podcast stream is usually the combined streams. We will prefer that by default.
        podcast_formats, podcast_subtitles = self._extract_streams_formats_and_subtitles(
            video_id, delivery.get('PodcastStreams'), format_note='PODCAST')

        streams_formats, streams_subtitles = self._extract_streams_formats_and_subtitles(
            video_id, delivery.get('Streams'), preference=-10)

        formats = podcast_formats + streams_formats
        formats.extend(self._extract_mhtml_formats(base_url, timestamps))
        subtitles = self._merge_subtitles(
            podcast_subtitles, streams_subtitles, self.extract_subtitles(base_url, video_id, delivery))

        self._sort_formats(formats)
        self.mark_watched(base_url, video_id, delivery_info)

        return {
            'id': video_id,
            'title': delivery.get('SessionName'),
            'cast': traverse_obj(delivery, ('Contributors', ..., 'DisplayName'), default=[], expected_type=lambda x: x or None),
            'timestamp': session_start_time - 11640000000 if session_start_time else None,
            'duration': delivery.get('Duration'),
            'thumbnail': base_url + f'/Services/FrameGrabber.svc/FrameRedirect?objectId={video_id}&mode=Delivery&random={random()}',
            'average_rating': delivery.get('AverageRating'),
            'chapters': self._extract_chapters(timestamps),
            'uploader': delivery.get('OwnerDisplayName') or None,
            'uploader_id': delivery.get('OwnerId'),
            'description': delivery.get('SessionAbstract'),
            'tags': traverse_obj(delivery, ('Tags', ..., 'Content')),
            'channel_id': delivery.get('SessionGroupPublicID'),
            'channel': traverse_obj(delivery, 'SessionGroupLongName', 'SessionGroupShortName', get_all=False),
            'formats': formats,
            'subtitles': subtitles
        }

class PanoptoPlaylistIE(PanoptoBaseIE):
    _VALID_URL = PanoptoBaseIE.BASE_URL_RE + r'/Pages/(Viewer|Embed)\.aspx.*(?:\?|&)pid=(?P<id>[a-f0-9-]+)'
    _TESTS = [
        {
            'url': 'https://howtovideos.hosted.panopto.com/Panopto/Pages/Viewer.aspx?pid=f3b39fcf-882f-4849-93d6-a9f401236d36&id=5fa74e93-3d87-4694-b60e-aaa4012214ed&advance=true',
            'info_dict': {
                'title': 'Featured Video Tutorials',
                'id': 'f3b39fcf-882f-4849-93d6-a9f401236d36',
                'description': '',
            },
            'playlist_mincount': 36
        },
        {
            'url': 'https://utsa.hosted.panopto.com/Panopto/Pages/Viewer.aspx?pid=e2900555-3ad4-4bdb-854d-ad2401686190',
            'info_dict': {
                'title': 'Library Website Introduction Playlist',
                'id': 'e2900555-3ad4-4bdb-854d-ad2401686190',
                'description': 'md5:f958bca50a1cbda15fdc1e20d32b3ecb',
            },
            'playlist_mincount': 4
        },

    ]

    def _entries(self, base_url, playlist_id, session_list_id):
        session_list_info = self._call_api(
            base_url, f'/Api/SessionLists/{session_list_id}?collections[0].maxCount=500&collections[0].name=items', playlist_id)

        items = session_list_info['Items']
        for item in items:
            if item.get('TypeName') != 'Session':
                self.report_warning('Got an item in the playlist that is not a Session' + bug_reports_message(), only_once=True)
                continue
            yield {
                '_type': 'url',
                'id': item.get('Id'),
                'url': item.get('ViewerUri'),
                'title': item.get('Name'),
                'description': item.get('Description'),
                'duration': item.get('Duration'),
                'channel': traverse_obj(item, ('Parent', 'Name')),
                'channel_id': traverse_obj(item, ('Parent', 'Id'))
            }

    def _real_extract(self, url):
        base_url, playlist_id = self._match_valid_url(url).group('base_url', 'id')

        video_id = get_first(parse_qs(url), 'id')
        if video_id:
            if self.get_param('noplaylist'):
                self.to_screen('Downloading just video %s because of --no-playlist' % video_id)
                return self.url_result(base_url + f'/Pages/Viewer.aspx?id={video_id}', ie_key=PanoptoIE.ie_key(), video_id=video_id)
            else:
                self.to_screen(f'Downloading playlist {playlist_id}; add --no-playlist to just download video {video_id}')

        playlist_info = self._call_api(base_url, f'/Api/Playlists/{playlist_id}', playlist_id)
        return self.playlist_result(
            self._entries(base_url, playlist_id, playlist_info['SessionListId']),
            playlist_id=playlist_id, playlist_title=playlist_info.get('Name'),
            playlist_description=playlist_info.get('Description'))

class PanoptoListIE(PanoptoBaseIE):
    _VALID_URL = PanoptoBaseIE.BASE_URL_RE + r'/Pages/Sessions/List\.aspx'
    _PAGE_SIZE = 250
    _TESTS = [
        {
            'url': 'https://demo.hosted.panopto.com/Panopto/Pages/Sessions/List.aspx#folderID=%22e4c6a2fc-1214-4ca0-8fb7-aef2e29ff63a%22',
            'info_dict': {
                'id': 'e4c6a2fc-1214-4ca0-8fb7-aef2e29ff63a',
                'title': 'Showcase Videos'
            },
            'playlist_mincount': 140

        },
        {
            'url': 'https://demo.hosted.panopto.com/Panopto/Pages/Sessions/List.aspx#view=2&maxResults=250',
            'info_dict': {
                'id': 'panopto_list',
                'title': 'panopto_list'
            },
            'playlist_mincount': 300
        },
        {
            # Folder that contains 8 folders and a playlist
            'url': 'https://howtovideos.hosted.panopto.com/Panopto/Pages/Sessions/List.aspx?noredirect=true#folderID=%224b9de7ae-0080-4158-8496-a9ba01692c2e%22',
            'info_dict': {
                'id': '4b9de7ae-0080-4158-8496-a9ba01692c2e',
                'title': 'Video Tutorials'
            },
            'playlist_mincount': 9
        }

    ]

    def _fetch_page(self, base_url, query_params, display_id, page):

        params = {
            'sortColumn': 1,
            'getFolderData': True,
            'includePlaylists': True,
            **query_params,
            'page': page,
            'maxResults': self._PAGE_SIZE,
        }

        response = self._call_api(
            base_url, '/Services/Data.svc/GetSessions', f'{display_id} page {page+1}',
            data={'queryParameters': params}, fatal=False)

        for result in get_first(response, 'Results', default=[]):
            # This could be a video, playlist (or maybe something else)
            item_id = result.get('DeliveryID')
            yield {
                '_type': 'url',
                'id': item_id,
                'title': result.get('SessionName'),
                'url': traverse_obj(result, 'ViewerUrl', 'EmbedUrl', get_all=False) or (base_url + f'/Pages/Viewer.aspx?id={item_id}'),
                'duration': result.get('Duration'),
                'channel': result.get('FolderName'),
                'channel_id': result.get('FolderID'),
            }

        for folder in get_first(response, 'Subfolders', default=[]):
            folder_id = folder.get('ID')
            yield self.url_result(
                base_url + f'/Pages/Sessions/List.aspx#folderID="{folder_id}"',
                ie_key=PanoptoListIE.ie_key(), video_id=folder_id, title=folder.get('Name'))

    def _extract_folder_metadata(self, base_url, folder_id):
        response = self._call_api(
            base_url, '/Services/Data.svc/GetFolderInfo', folder_id,
            data={'folderID': folder_id}, fatal=False)
        return {
            'title': get_first(response, 'Name', default=[])
        }

    def _real_extract(self, url):
        mobj = self._match_valid_url(url)
        base_url = mobj.group('base_url')

        query_params = self._parse_fragment(url)
        folder_id, display_id = query_params.get('folderID'), 'panopto_list'

        if query_params.get('isSubscriptionsPage'):
            display_id = 'subscriptions'
            if not query_params.get('subscribableTypes'):
                query_params['subscribableTypes'] = [0, 1, 2]
        elif query_params.get('isSharedWithMe'):
            display_id = 'sharedwithme'
        elif folder_id:
            display_id = folder_id

        query = query_params.get('query')
        if query:
            display_id += f': query "{query}"'

        info = {
            '_type': 'playlist',
            'id': display_id,
            'title': display_id,
        }
        if folder_id:
            info.update(self._extract_folder_metadata(base_url, folder_id))

        info['entries'] = OnDemandPagedList(
            functools.partial(self._fetch_page, base_url, query_params, display_id), self._PAGE_SIZE)

        return info


code:
import itertools

from .common import InfoExtractor
from .cbs import CBSBaseIE
from ..utils import (
    int_or_none,
    url_or_none,
)

class ParamountPlusIE(CBSBaseIE):
    _VALID_URL = r'''(?x)
        (?:
            paramountplus:|
            https?://(?:www\.)?(?:
                paramountplus\.com/(?:shows|movies)/(?:video|[^/]+/video|[^/]+)/
        )(?P<id>[\w-]+))'''

    # All tests are blocked outside US
    _TESTS = [{
        'url': 'https://www.paramountplus.com/shows/video/Oe44g5_NrlgiZE3aQVONleD6vXc8kP0k/',
        'info_dict': {
            'id': 'Oe44g5_NrlgiZE3aQVONleD6vXc8kP0k',
            'ext': 'mp4',
            'title': 'CatDog - Climb Every CatDog/The Canine Mutiny',
            'description': 'md5:7ac835000645a69933df226940e3c859',
            'duration': 1418,
            'timestamp': 920264400,
            'upload_date': '19990301',
            'uploader': 'CBSI-NEW',
        },
        'params': {
            'skip_download': 'm3u8',
        },
    }, {
        'url': 'https://www.paramountplus.com/shows/video/6hSWYWRrR9EUTz7IEe5fJKBhYvSUfexd/',
        'info_dict': {
            'id': '6hSWYWRrR9EUTz7IEe5fJKBhYvSUfexd',
            'ext': 'mp4',
            'title': '7/23/21 WEEK IN REVIEW (Rep. Jahana Hayes/Howard Fineman/Sen. Michael Bennet/Sheera Frenkel & Cecilia Kang)',
            'description': 'md5:f4adcea3e8b106192022e121f1565bae',
            'duration': 2506,
            'timestamp': 1627063200,
            'upload_date': '20210723',
            'uploader': 'CBSI-NEW',
        },
        'params': {
            'skip_download': 'm3u8',
        },
    }, {
        'url': 'https://www.paramountplus.com/movies/video/vM2vm0kE6vsS2U41VhMRKTOVHyQAr6pC/',
        'info_dict': {
            'id': 'vM2vm0kE6vsS2U41VhMRKTOVHyQAr6pC',
            'ext': 'mp4',
            'title': 'Daddy\'s Home',
            'upload_date': '20151225',
            'description': 'md5:a0beaf24e8d3b0e81b2ee41d47c06f33',
            'uploader': 'CBSI-NEW',
            'timestamp': 1451030400,
        },
        'params': {
            'skip_download': 'm3u8',
        },
        'expected_warnings': ['Ignoring subtitle tracks'],  # TODO: Investigate this
    }, {
        'url': 'https://www.paramountplus.com/movies/video/5EKDXPOzdVf9voUqW6oRuocyAEeJGbEc/',
        'info_dict': {
            'id': '5EKDXPOzdVf9voUqW6oRuocyAEeJGbEc',
            'ext': 'mp4',
            'uploader': 'CBSI-NEW',
            'description': 'md5:bc7b6fea84ba631ef77a9bda9f2ff911',
            'timestamp': 1577865600,
            'title': 'Sonic the Hedgehog',
            'upload_date': '20200101',
        },
        'params': {
            'skip_download': 'm3u8',
        },
        'expected_warnings': ['Ignoring subtitle tracks'],
    }, {
        'url': 'https://www.paramountplus.com/shows/the-real-world/video/mOVeHeL9ub9yWdyzSZFYz8Uj4ZBkVzQg/the-real-world-reunion/',
        'only_matching': True,
    }, {
        'url': 'https://www.paramountplus.com/shows/video/mOVeHeL9ub9yWdyzSZFYz8Uj4ZBkVzQg/',
        'only_matching': True,
    }, {
        'url': 'https://www.paramountplus.com/movies/video/W0VyStQqUnqKzJkrpSAIARuCc9YuYGNy/',
        'only_matching': True,
    }, {
        'url': 'https://www.paramountplus.com/movies/paw-patrol-the-movie/W0VyStQqUnqKzJkrpSAIARuCc9YuYGNy/',
        'only_matching': True,
    }]

    def _extract_video_info(self, content_id, mpx_acc=2198311517):
        items_data = self._download_json(
            'https://www.paramountplus.com/apps-api/v2.0/androidtv/video/cid/%s.json' % content_id,
            content_id, query={'locale': 'en-us', 'at': 'ABCqWNNSwhIqINWIIAG+DFzcFUvF8/vcN6cNyXFFfNzWAIvXuoVgX+fK4naOC7V8MLI='}, headers=self.geo_verification_headers())

        asset_types = {
            item.get('assetType'): {
                'format': 'SMIL',
                'formats': 'MPEG4,M3U',
            } for item in items_data['itemList']
        }
        item = items_data['itemList'][-1]
        return self._extract_common_video_info(content_id, asset_types, mpx_acc, extra_info={
            'title': item.get('title'),
            'series': item.get('seriesTitle'),
            'season_number': int_or_none(item.get('seasonNum')),
            'episode_number': int_or_none(item.get('episodeNum')),
            'duration': int_or_none(item.get('duration')),
            'thumbnail': url_or_none(item.get('thumbnail')),
        })

class ParamountPlusSeriesIE(InfoExtractor):
    _VALID_URL = r'https?://(?:www\.)?paramountplus\.com/shows/(?P<id>[a-zA-Z0-9-_]+)/?(?:[#?]|$)'
    _TESTS = [{
        'url': 'https://www.paramountplus.com/shows/drake-josh',
        'playlist_mincount': 50,
        'info_dict': {
            'id': 'drake-josh',
        }
    }, {
        'url': 'https://www.paramountplus.com/shows/hawaii_five_0/',
        'playlist_mincount': 240,
        'info_dict': {
            'id': 'hawaii_five_0',
        }
    }, {
        'url': 'https://www.paramountplus.com/shows/spongebob-squarepants/',
        'playlist_mincount': 248,
        'info_dict': {
            'id': 'spongebob-squarepants',
        }
    }]

    def _entries(self, show_name):
        for page in itertools.count():
            show_json = self._download_json(
                f'https://www.paramountplus.com/shows/{show_name}/xhr/episodes/page/{page}/size/50/xs/0/season/0', show_name)
            if not show_json.get('success'):
                return
            for episode in show_json['result']['data']:
                yield self.url_result(
                    'https://www.paramountplus.com%s' % episode['url'],
                    ie=ParamountPlusIE.ie_key(), video_id=episode['content_id'])

    def _real_extract(self, url):
        show_name = self._match_id(url)
        return self.playlist_result(self._entries(show_name), playlist_id=show_name)

repo_name: Lesmiscore/ytdl


code:
from .common import InfoExtractor
from .youtube import YoutubeIE
from ..utils import (
    clean_html,
    format_field,
    int_or_none,
    strip_or_none,
    traverse_obj,
    unified_timestamp,
    urlencode_postdata,
)

class ParlerIE(InfoExtractor):
    IE_DESC = 'Posts on parler.com'
    _VALID_URL = r'https://parler\.com/feed/(?P<id>[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12})'
    _TESTS = [
        {
            'url': 'https://parler.com/feed/df79fdba-07cc-48fe-b085-3293897520d7',
            'md5': '16e0f447bf186bb3cf64de5bbbf4d22d',
            'info_dict': {
                'id': 'df79fdba-07cc-48fe-b085-3293897520d7',
                'ext': 'mp4',
                'thumbnail': 'https://bl-images.parler.com/videos/6ce7cdf3-a27a-4d72-bf9c-d3e17ce39a66/thumbnail.jpeg',
                'title': 'Parler video #df79fdba-07cc-48fe-b085-3293897520d7',
                'description': 'md5:6f220bde2df4a97cbb89ac11f1fd8197',
                'timestamp': 1659744000,
                'upload_date': '20220806',
                'uploader': 'Tulsi Gabbard',
                'uploader_id': 'TulsiGabbard',
                'uploader_url': 'https://parler.com/TulsiGabbard',
                'view_count': int,
                'comment_count': int,
                'repost_count': int,
            },
        },
        {
            'url': 'https://parler.com/feed/a7406eb4-91e5-4793-b5e3-ade57a24e287',
            'md5': '11687e2f5bb353682cee338d181422ed',
            'info_dict': {
                'id': 'a7406eb4-91e5-4793-b5e3-ade57a24e287',
                'ext': 'mp4',
                'thumbnail': 'https://bl-images.parler.com/videos/317827a8-1e48-4cbc-981f-7dd17d4c1183/thumbnail.jpeg',
                'title': 'Parler video #a7406eb4-91e5-4793-b5e3-ade57a24e287',
                'description': 'This man should run for office',
                'timestamp': 1659657600,
                'upload_date': '20220805',
                'uploader': 'Benny Johnson',
                'uploader_id': 'BennyJohnson',
                'uploader_url': 'https://parler.com/BennyJohnson',
                'view_count': int,
                'comment_count': int,
                'repost_count': int,
            },
        },
        {
            'url': 'https://parler.com/feed/f23b85c1-6558-470f-b9ff-02c145f28da5',
            'md5': 'eaba1ff4a10fe281f5ce74e930ab2cb4',
            'info_dict': {
                'id': 'r5vkSaz8PxQ',
                'ext': 'mp4',
                'thumbnail': 'https://i.ytimg.com/vi_webp/r5vkSaz8PxQ/maxresdefault.webp',
                'title': 'Tom MacDonald Names Reaction',
                'description': 'md5:33c21f0d35ae6dc2edf3007d6696baea',
                'upload_date': '20220716',
                'duration': 1267,
                'uploader': 'Mahesh Chookolingo',
                'uploader_id': 'maheshchookolingo',
                'uploader_url': 'http://www.youtube.com/user/maheshchookolingo',
                'channel': 'Mahesh Chookolingo',
                'channel_id': 'UCox6YeMSY1PQInbCtTaZj_w',
                'channel_url': 'https://www.youtube.com/channel/UCox6YeMSY1PQInbCtTaZj_w',
                'categories': ['Entertainment'],
                'tags': list,
                'availability': 'public',
                'live_status': 'not_live',
                'view_count': int,
                'comment_count': int,
                'like_count': int,
                'channel_follower_count': int,
                'age_limit': 0,
                'playable_in_embed': True,
            },
        },
    ]

    def _real_extract(self, url):
        video_id = self._match_id(url)
        data = self._download_json(
            'https://parler.com/open-api/ParleyDetailEndpoint.php', video_id,
            data=urlencode_postdata({'uuid': video_id}))['data'][0]
        primary = data['primary']

        embed = self._parse_json(primary.get('V2LINKLONG') or '', video_id, fatal=False)
        if embed:
            return self.url_result(embed[0], YoutubeIE)

        return {
            'id': video_id,
            'url': traverse_obj(primary, ('video_data', 'videoSrc')),
            'thumbnail': traverse_obj(primary, ('video_data', 'thumbnailUrl')),
            'title': '',
            'description': strip_or_none(clean_html(primary.get('full_body'))) or None,
            'timestamp': unified_timestamp(primary.get('date_created')),
            'uploader': strip_or_none(primary.get('name')),
            'uploader_id': strip_or_none(primary.get('username')),
            'uploader_url': format_field(strip_or_none(primary.get('username')), None, 'https://parler.com/%s'),
            'view_count': int_or_none(primary.get('view_count')),
            'comment_count': int_or_none(traverse_obj(data, ('engagement', 'commentCount'))),
            'repost_count': int_or_none(traverse_obj(data, ('engagement', 'echoCount'))),
        }


code:
from .common import InfoExtractor
from ..compat import compat_str
from ..utils import (
    int_or_none,
    try_get,
    unified_timestamp,
)

class ParlviewIE(InfoExtractor):

    _VALID_URL = r'https?://(?:www\.)?parlview\.aph\.gov\.au/(?:[^/]+)?\bvideoID=(?P<id>\d{6})'
    _TESTS = [{
        'url': 'https://parlview.aph.gov.au/mediaPlayer.php?videoID=542661',
        'info_dict': {
            'id': '542661',
            'ext': 'mp4',
            'title': "Australia's Family Law System [Part 2]",
            'duration': 5799,
            'description': 'md5:7099883b391619dbae435891ca871a62',
            'timestamp': 1621430700,
            'upload_date': '20210519',
            'uploader': 'Joint Committee',
        },
        'params': {
            'skip_download': True,
        }
    }, {
        'url': 'https://parlview.aph.gov.au/mediaPlayer.php?videoID=539936',
        'only_matching': True,
    }]
    _API_URL = 'https://parlview.aph.gov.au/api_v3/1/playback/getUniversalPlayerConfig?videoID=%s&format=json'
    _MEDIA_INFO_URL = 'https://parlview.aph.gov.au/ajaxPlayer.php?videoID=%s&tabNum=4&action=loadTab'

    def _real_extract(self, url):
        video_id = self._match_id(url)
        webpage = self._download_webpage(url, video_id)
        media = self._download_json(self._API_URL % video_id, video_id).get('media')
        timestamp = try_get(media, lambda x: x['timeMap']['source']['timecode_offsets'][0], compat_str) or '/'

        stream = try_get(media, lambda x: x['renditions'][0], dict)
        if not stream:
            self.raise_no_formats('No streams were detected')
        elif stream.get('streamType') != 'VOD':
            self.raise_no_formats('Unknown type of stream was detected: "%s"' % str(stream.get('streamType')))
        formats = self._extract_m3u8_formats(stream['url'], video_id, 'mp4', 'm3u8_native')
        self._sort_formats(formats)

        media_info = self._download_webpage(
            self._MEDIA_INFO_URL % video_id, video_id, note='Downloading media info', fatal=False)

        return {
            'id': video_id,
            'url': url,
            'title': self._html_search_regex(r'<h2>([^<]+)<', webpage, 'title', fatal=False),
            'formats': formats,
            'duration': int_or_none(media.get('duration')),
            'timestamp': unified_timestamp(timestamp.split('/', 1)[1].replace('_', ' ')),
            'description': self._html_search_regex(
                r'<div[^>]+class="descripti?on"[^>]*>[^>]+<strong>[^>]+>[^>]+>([^<]+)',
                webpage, 'description', fatal=False),
            'uploader': self._html_search_regex(
                r'<td>[^>]+>Channel:[^>]+>([^<]+)', media_info, 'channel', fatal=False),
            'thumbnail': media.get('staticImage'),
        }


code:
import itertools
from urllib.error import HTTPError

from .common import InfoExtractor
from .vimeo import VimeoIE

from ..compat import compat_urllib_parse_unquote
from ..utils import (
    clean_html,
    determine_ext,
    ExtractorError,
    int_or_none,
    KNOWN_EXTENSIONS,
    mimetype2ext,
    parse_iso8601,
    str_or_none,
    traverse_obj,
    try_get,
    url_or_none,
)

class PatreonBaseIE(InfoExtractor):
    USER_AGENT = 'Patreon/7.6.28 (Android; Android 11; Scale/2.10)'

    def _call_api(self, ep, item_id, query=None, headers=None, fatal=True, note=None):
        if headers is None:
            headers = {}
        if 'User-Agent' not in headers:
            headers['User-Agent'] = self.USER_AGENT
        if query:
            query.update({'json-api-version': 1.0})

        try:
            return self._download_json(
                f'https://www.patreon.com/api/{ep}',
                item_id, note='Downloading API JSON' if not note else note,
                query=query, fatal=fatal, headers=headers)
        except ExtractorError as e:
            if not isinstance(e.cause, HTTPError) or mimetype2ext(e.cause.headers.get('Content-Type')) != 'json':
                raise
            err_json = self._parse_json(self._webpage_read_content(e.cause, None, item_id), item_id, fatal=False)
            err_message = traverse_obj(err_json, ('errors', ..., 'detail'), get_all=False)
            if err_message:
                raise ExtractorError(f'Patreon said: {err_message}', expected=True)
            raise

class PatreonIE(PatreonBaseIE):
    _VALID_URL = r'https?://(?:www\.)?patreon\.com/(?:creation\?hid=|posts/(?:[\w-]+-)?)(?P<id>\d+)'
    _TESTS = [{
        'url': 'http://www.patreon.com/creation?hid=743933',
        'md5': 'e25505eec1053a6e6813b8ed369875cc',
        'info_dict': {
            'id': '743933',
            'ext': 'mp3',
            'title': 'Episode 166: David Smalley of Dogma Debate',
            'description': 'md5:34d207dd29aa90e24f1b3f58841b81c7',
            'uploader': 'Cognitive Dissonance Podcast',
            'thumbnail': 're:^https?://.*$',
            'timestamp': 1406473987,
            'upload_date': '20140727',
            'uploader_id': '87145',
            'like_count': int,
            'comment_count': int,
            'uploader_url': 'https://www.patreon.com/dissonancepod',
            'channel_id': '80642',
            'channel_url': 'https://www.patreon.com/dissonancepod',
            'channel_follower_count': int,
        },
    }, {
        'url': 'http://www.patreon.com/creation?hid=754133',
        'md5': '3eb09345bf44bf60451b8b0b81759d0a',
        'info_dict': {
            'id': '754133',
            'ext': 'mp3',
            'title': 'CD 167 Extra',
            'uploader': 'Cognitive Dissonance Podcast',
            'thumbnail': 're:^https?://.*$',
            'like_count': int,
            'comment_count': int,
            'uploader_url': 'https://www.patreon.com/dissonancepod',
        },
        'skip': 'Patron-only content',
    }, {
        'url': 'https://www.patreon.com/creation?hid=1682498',
        'info_dict': {
            'id': 'SU4fj_aEMVw',
            'ext': 'mp4',
            'title': 'I\'m on Patreon!',
            'uploader': 'TraciJHines',
            'thumbnail': 're:^https?://.*$',
            'upload_date': '20150211',
            'description': 'md5:8af6425f50bd46fbf29f3db0fc3a8364',
            'uploader_id': 'TraciJHines',
            'categories': ['Entertainment'],
            'duration': 282,
            'view_count': int,
            'tags': 'count:39',
            'age_limit': 0,
            'channel': 'TraciJHines',
            'channel_url': 'https://www.youtube.com/channel/UCGLim4T2loE5rwCMdpCIPVg',
            'live_status': 'not_live',
            'like_count': int,
            'channel_id': 'UCGLim4T2loE5rwCMdpCIPVg',
            'availability': 'public',
            'channel_follower_count': int,
            'playable_in_embed': True,
            'uploader_url': 'http://www.youtube.com/user/TraciJHines',
            'comment_count': int,
        },
        'params': {
            'noplaylist': True,
            'skip_download': True,
        }
    }, {
        'url': 'https://www.patreon.com/posts/episode-166-of-743933',
        'only_matching': True,
    }, {
        'url': 'https://www.patreon.com/posts/743933',
        'only_matching': True,
    }, {
        'url': 'https://www.patreon.com/posts/kitchen-as-seen-51706779',
        'md5': '96656690071f6d64895866008484251b',
        'info_dict': {
            'id': '555089736',
            'ext': 'mp4',
            'title': 'KITCHEN AS SEEN ON DEEZ NUTS EXTENDED!',
            'uploader': 'Cold Ones',
            'thumbnail': 're:^https?://.*$',
            'upload_date': '20210526',
            'description': 'md5:557a409bd79d3898689419094934ba79',
            'uploader_id': '14936315',
        },
        'skip': 'Patron-only content'
    }, {
        # m3u8 video (https://github.com/yt-dlp/yt-dlp/issues/2277)
        'url': 'https://www.patreon.com/posts/video-sketchbook-32452882',
        'info_dict': {
            'id': '32452882',
            'ext': 'mp4',
            'comment_count': int,
            'uploader_id': '4301314',
            'like_count': int,
            'timestamp': 1576696962,
            'upload_date': '20191218',
            'thumbnail': r're:^https?://.*$',
            'uploader_url': 'https://www.patreon.com/loish',
            'description': 'md5:e2693e97ee299c8ece47ffdb67e7d9d2',
            'title': 'VIDEO // sketchbook flipthrough',
            'uploader': 'Loish ',
            'tags': ['sketchbook', 'video'],
            'channel_id': '1641751',
            'channel_url': 'https://www.patreon.com/loish',
            'channel_follower_count': int,
        }
    }, {
        # bad videos under media (if media is included). Real one is under post_file
        'url': 'https://www.patreon.com/posts/premium-access-70282931',
        'info_dict': {
            'id': '70282931',
            'ext': 'mp4',
            'title': '[Premium Access + Uncut] The Office - 2x6 The Fight - Group Reaction',
            'channel_url': 'https://www.patreon.com/thenormies',
            'channel_id': '573397',
            'uploader_id': '2929435',
            'uploader': 'The Normies',
            'description': 'md5:79c9fd8778e2cef84049a94c058a5e23',
            'comment_count': int,
            'upload_date': '20220809',
            'thumbnail': r're:^https?://.*$',
            'channel_follower_count': int,
            'like_count': int,
            'timestamp': 1660052820,
            'tags': ['The Office', 'early access', 'uncut'],
            'uploader_url': 'https://www.patreon.com/thenormies',
        },
        'skip': 'Patron-only content',
    }]

    def _real_extract(self, url):
        video_id = self._match_id(url)
        post = self._call_api(
            f'posts/{video_id}', video_id, query={
                'fields[media]': 'download_url,mimetype,size_bytes',
                'fields[post]': 'comment_count,content,embed,image,like_count,post_file,published_at,title,current_user_can_view',
                'fields[user]': 'full_name,url',
                'fields[post_tag]': 'value',
                'fields[campaign]': 'url,name,patron_count',
                'json-api-use-default-includes': 'false',
                'include': 'audio,user,user_defined_tags,campaign,attachments_media',
            })
        attributes = post['data']['attributes']
        title = attributes['title'].strip()
        image = attributes.get('image') or {}
        info = {
            'id': video_id,
            'title': title,
            'description': clean_html(attributes.get('content')),
            'thumbnail': image.get('large_url') or image.get('url'),
            'timestamp': parse_iso8601(attributes.get('published_at')),
            'like_count': int_or_none(attributes.get('like_count')),
            'comment_count': int_or_none(attributes.get('comment_count')),
        }
        can_view_post = traverse_obj(attributes, 'current_user_can_view')
        if can_view_post and info['comment_count']:
            info['__post_extractor'] = self.extract_comments(video_id)

        for i in post.get('included', []):
            i_type = i.get('type')
            if i_type == 'media':
                media_attributes = i.get('attributes') or {}
                download_url = media_attributes.get('download_url')
                ext = mimetype2ext(media_attributes.get('mimetype'))

                # if size_bytes is None, this media file is likely unavailable
                # See: https://github.com/yt-dlp/yt-dlp/issues/4608
                size_bytes = int_or_none(media_attributes.get('size_bytes'))
                if download_url and ext in KNOWN_EXTENSIONS and size_bytes is not None:
                    # XXX: what happens if there are multiple attachments?
                    return {
                        **info,
                        'ext': ext,
                        'filesize': size_bytes,
                        'url': download_url,
                    }
            elif i_type == 'user':
                user_attributes = i.get('attributes')
                if user_attributes:
                    info.update({
                        'uploader': user_attributes.get('full_name'),
                        'uploader_id': str_or_none(i.get('id')),
                        'uploader_url': user_attributes.get('url'),
                    })

            elif i_type == 'post_tag':
                info.setdefault('tags', []).append(traverse_obj(i, ('attributes', 'value')))

            elif i_type == 'campaign':
                info.update({
                    'channel': traverse_obj(i, ('attributes', 'title')),
                    'channel_id': str_or_none(i.get('id')),
                    'channel_url': traverse_obj(i, ('attributes', 'url')),
                    'channel_follower_count': int_or_none(traverse_obj(i, ('attributes', 'patron_count'))),
                })

        # handle Vimeo embeds
        if try_get(attributes, lambda x: x['embed']['provider']) == 'Vimeo':
            embed_html = try_get(attributes, lambda x: x['embed']['html'])
            v_url = url_or_none(compat_urllib_parse_unquote(
                self._search_regex(r'(https(?:%3A%2F%2F|://)player\.vimeo\.com.+app_id(?:=|%3D)+\d+)', embed_html, 'vimeo url', fatal=False)))
            if v_url:
                return {
                    **info,
                    '_type': 'url_transparent',
                    'url': VimeoIE._smuggle_referrer(v_url, 'https://patreon.com'),
                    'ie_key': 'Vimeo',
                }

        embed_url = try_get(attributes, lambda x: x['embed']['url'])
        if embed_url:
            return {
                **info,
                '_type': 'url',
                'url': embed_url,
            }

        post_file = traverse_obj(attributes, 'post_file')
        if post_file:
            name = post_file.get('name')
            ext = determine_ext(name)
            if ext in KNOWN_EXTENSIONS:
                return {
                    **info,
                    'ext': ext,
                    'url': post_file['url'],
                }
            elif name == 'video':
                formats, subtitles = self._extract_m3u8_formats_and_subtitles(post_file['url'], video_id)
                self._sort_formats(formats)
                return {
                    **info,
                    'formats': formats,
                    'subtitles': subtitles,
                }

        if can_view_post is False:
            self.raise_no_formats('You do not have access to this post', video_id=video_id, expected=True)
        else:
            self.raise_no_formats('No supported media found in this post', video_id=video_id, expected=True)
        return info

    def _get_comments(self, post_id):
        cursor = None
        count = 0
        params = {
            'page[count]': 50,
            'include': 'parent.commenter.campaign,parent.post.user,parent.post.campaign.creator,parent.replies.parent,parent.replies.commenter.campaign,parent.replies.post.user,parent.replies.post.campaign.creator,commenter.campaign,post.user,post.campaign.creator,replies.parent,replies.commenter.campaign,replies.post.user,replies.post.campaign.creator,on_behalf_of_campaign',
            'fields[comment]': 'body,created,is_by_creator',
            'fields[user]': 'image_url,full_name,url',
            'filter[flair]': 'image_tiny_url,name',
            'sort': '-created',
            'json-api-version': 1.0,
            'json-api-use-default-includes': 'false',
        }

        for page in itertools.count(1):

            params.update({'page[cursor]': cursor} if cursor else {})
            response = self._call_api(
                f'posts/{post_id}/comments', post_id, query=params, note='Downloading comments page %d' % page)

            cursor = None
            for comment in traverse_obj(response, (('data', ('included', lambda _, v: v['type'] == 'comment')), ...), default=[]):
                count += 1
                comment_id = comment.get('id')
                attributes = comment.get('attributes') or {}
                if comment_id is None:
                    continue
                author_id = traverse_obj(comment, ('relationships', 'commenter', 'data', 'id'))
                author_info = traverse_obj(
                    response, ('included', lambda _, v: v['id'] == author_id and v['type'] == 'user', 'attributes'),
                    get_all=False, expected_type=dict, default={})

                yield {
                    'id': comment_id,
                    'text': attributes.get('body'),
                    'timestamp': parse_iso8601(attributes.get('created')),
                    'parent': traverse_obj(comment, ('relationships', 'parent', 'data', 'id'), default='root'),
                    'author_is_uploader': attributes.get('is_by_creator'),
                    'author_id': author_id,
                    'author': author_info.get('full_name'),
                    'author_thumbnail': author_info.get('image_url'),
                }

            if count < traverse_obj(response, ('meta', 'count')):
                cursor = traverse_obj(response, ('data', -1, 'id'))

            if cursor is None:
                break

class PatreonCampaignIE(PatreonBaseIE):

    _VALID_URL = r'https?://(?:www\.)?patreon\.com/(?!rss)(?:(?:m/(?P<campaign_id>\d+))|(?P<vanity>[-\w]+))'
    _TESTS = [{
        'url': 'https://www.patreon.com/dissonancepod/',
        'info_dict': {
            'title': 'Cognitive Dissonance Podcast',
            'channel_url': 'https://www.patreon.com/dissonancepod',
            'id': '80642',
            'description': 'md5:eb2fa8b83da7ab887adeac34da6b7af7',
            'channel_id': '80642',
            'channel': 'Cognitive Dissonance Podcast',
            'age_limit': 0,
            'channel_follower_count': int,
            'uploader_id': '87145',
            'uploader_url': 'https://www.patreon.com/dissonancepod',
            'uploader': 'Cognitive Dissonance Podcast',
            'thumbnail': r're:^https?://.*$',
        },
        'playlist_mincount': 68,
    }, {
        'url': 'https://www.patreon.com/m/4767637/posts',
        'info_dict': {
            'title': 'Not Just Bikes',
            'channel_follower_count': int,
            'id': '4767637',
            'channel_id': '4767637',
            'channel_url': 'https://www.patreon.com/notjustbikes',
            'description': 'md5:595c6e7dca76ae615b1d38c298a287a1',
            'age_limit': 0,
            'channel': 'Not Just Bikes',
            'uploader_url': 'https://www.patreon.com/notjustbikes',
            'uploader': 'Not Just Bikes',
            'uploader_id': '37306634',
            'thumbnail': r're:^https?://.*$',
        },
        'playlist_mincount': 71
    }, {
        'url': 'https://www.patreon.com/dissonancepod/posts',
        'only_matching': True
    }, {
        'url': 'https://www.patreon.com/m/5932659',
        'only_matching': True
    }]

    @classmethod
    def suitable(cls, url):
        return False if PatreonIE.suitable(url) else super(PatreonCampaignIE, cls).suitable(url)

    def _entries(self, campaign_id):
        cursor = None
        params = {
            'fields[post]': 'patreon_url,url',
            'filter[campaign_id]': campaign_id,
            'filter[is_draft]': 'false',
            'sort': '-published_at',
            'json-api-use-default-includes': 'false',
        }

        for page in itertools.count(1):

            params.update({'page[cursor]': cursor} if cursor else {})
            posts_json = self._call_api('posts', campaign_id, query=params, note='Downloading posts page %d' % page)

            cursor = traverse_obj(posts_json, ('meta', 'pagination', 'cursors', 'next'))
            for post in posts_json.get('data') or []:
                yield self.url_result(url_or_none(traverse_obj(post, ('attributes', 'patreon_url'))), 'Patreon')

            if cursor is None:
                break

    def _real_extract(self, url):

        campaign_id, vanity = self._match_valid_url(url).group('campaign_id', 'vanity')
        if campaign_id is None:
            webpage = self._download_webpage(url, vanity, headers={'User-Agent': self.USER_AGENT})
            campaign_id = self._search_regex(r'https://www.patreon.com/api/campaigns/(\d+)/?', webpage, 'Campaign ID')

        params = {
            'json-api-use-default-includes': 'false',
            'fields[user]': 'full_name,url',
            'fields[campaign]': 'name,summary,url,patron_count,creation_count,is_nsfw,avatar_photo_url',
            'include': 'creator'
        }

        campaign_response = self._call_api(
            f'campaigns/{campaign_id}', campaign_id,
            note='Downloading campaign info', fatal=False,
            query=params) or {}

        campaign_info = campaign_response.get('data') or {}
        channel_name = traverse_obj(campaign_info, ('attributes', 'name'))
        user_info = traverse_obj(
            campaign_response, ('included', lambda _, v: v['type'] == 'user'),
            default={}, expected_type=dict, get_all=False)

        return {
            '_type': 'playlist',
            'id': campaign_id,
            'title': channel_name,
            'entries': self._entries(campaign_id),
            'description': clean_html(traverse_obj(campaign_info, ('attributes', 'summary'))),
            'channel_url': traverse_obj(campaign_info, ('attributes', 'url')),
            'channel_follower_count': int_or_none(traverse_obj(campaign_info, ('attributes', 'patron_count'))),
            'channel_id': campaign_id,
            'channel': channel_name,
            'uploader_url': traverse_obj(user_info, ('attributes', 'url')),
            'uploader_id': str_or_none(user_info.get('id')),
            'uploader': traverse_obj(user_info, ('attributes', 'full_name')),
            'playlist_count': traverse_obj(campaign_info, ('attributes', 'creation_count')),
            'age_limit': 18 if traverse_obj(campaign_info, ('attributes', 'is_nsfw')) else 0,
            'thumbnail': url_or_none(traverse_obj(campaign_info, ('attributes', 'avatar_photo_url'))),
        }


code:
import re

from .common import InfoExtractor
from ..compat import compat_str
from ..utils import (
    ExtractorError,
    determine_ext,
    int_or_none,
    float_or_none,
    js_to_json,
    orderedSet,
    strip_jsonp,
    strip_or_none,
    unified_strdate,
    url_or_none,
    US_RATINGS,
)

class PBSIE(InfoExtractor):
    _STATIONS = (
        (r'(?:video|www|player)\.pbs\.org', 'PBS: Public Broadcasting Service'),  # http://www.pbs.org/
        (r'video\.aptv\.org', 'APT - Alabama Public Television (WBIQ)'),  # http://aptv.org/
        (r'video\.gpb\.org', 'GPB/Georgia Public Broadcasting (WGTV)'),  # http://www.gpb.org/
        (r'video\.mpbonline\.org', 'Mississippi Public Broadcasting (WMPN)'),  # http://www.mpbonline.org
        (r'video\.wnpt\.org', 'Nashville Public Television (WNPT)'),  # http://www.wnpt.org
        (r'video\.wfsu\.org', 'WFSU-TV (WFSU)'),  # http://wfsu.org/
        (r'video\.wsre\.org', 'WSRE (WSRE)'),  # http://www.wsre.org
        (r'video\.wtcitv\.org', 'WTCI (WTCI)'),  # http://www.wtcitv.org
        (r'video\.pba\.org', 'WPBA/Channel 30 (WPBA)'),  # http://pba.org/
        (r'video\.alaskapublic\.org', 'Alaska Public Media (KAKM)'),  # http://alaskapublic.org/kakm
        # (r'kuac\.org', 'KUAC (KUAC)'),  # http://kuac.org/kuac-tv/
        # (r'ktoo\.org', '360 North (KTOO)'),  # http://www.ktoo.org/
        # (r'azpm\.org', 'KUAT 6 (KUAT)'),  # http://www.azpm.org/
        (r'video\.azpbs\.org', 'Arizona PBS (KAET)'),  # http://www.azpbs.org
        (r'portal\.knme\.org', 'KNME-TV/Channel 5 (KNME)'),  # http://www.newmexicopbs.org/
        (r'video\.vegaspbs\.org', 'Vegas PBS (KLVX)'),  # http://vegaspbs.org/
        (r'watch\.aetn\.org', 'AETN/ARKANSAS ETV NETWORK (KETS)'),  # http://www.aetn.org/
        (r'video\.ket\.org', 'KET (WKLE)'),  # http://www.ket.org/
        (r'video\.wkno\.org', 'WKNO/Channel 10 (WKNO)'),  # http://www.wkno.org/
        (r'video\.lpb\.org', 'LPB/LOUISIANA PUBLIC BROADCASTING (WLPB)'),  # http://www.lpb.org/
        (r'videos\.oeta\.tv', 'OETA (KETA)'),  # http://www.oeta.tv
        (r'video\.optv\.org', 'Ozarks Public Television (KOZK)'),  # http://www.optv.org/
        (r'watch\.wsiu\.org', 'WSIU Public Broadcasting (WSIU)'),  # http://www.wsiu.org/
        (r'video\.keet\.org', 'KEET TV (KEET)'),  # http://www.keet.org
        (r'pbs\.kixe\.org', 'KIXE/Channel 9 (KIXE)'),  # http://kixe.org/
        (r'video\.kpbs\.org', 'KPBS San Diego (KPBS)'),  # http://www.kpbs.org/
        (r'video\.kqed\.org', 'KQED (KQED)'),  # http://www.kqed.org
        (r'vids\.kvie\.org', 'KVIE Public Television (KVIE)'),  # http://www.kvie.org
        (r'video\.pbssocal\.org', 'PBS SoCal/KOCE (KOCE)'),  # http://www.pbssocal.org/
        (r'video\.valleypbs\.org', 'ValleyPBS (KVPT)'),  # http://www.valleypbs.org/
        (r'video\.cptv\.org', 'CONNECTICUT PUBLIC TELEVISION (WEDH)'),  # http://cptv.org
        (r'watch\.knpb\.org', 'KNPB Channel 5 (KNPB)'),  # http://www.knpb.org/
        (r'video\.soptv\.org', 'SOPTV (KSYS)'),  # http://www.soptv.org
        # (r'klcs\.org', 'KLCS/Channel 58 (KLCS)'),  # http://www.klcs.org
        # (r'krcb\.org', 'KRCB Television & Radio (KRCB)'),  # http://www.krcb.org
        # (r'kvcr\.org', 'KVCR TV/DT/FM :: Vision for the Future (KVCR)'),  # http://kvcr.org
        (r'video\.rmpbs\.org', 'Rocky Mountain PBS (KRMA)'),  # http://www.rmpbs.org
        (r'video\.kenw\.org', 'KENW-TV3 (KENW)'),  # http://www.kenw.org
        (r'video\.kued\.org', 'KUED Channel 7 (KUED)'),  # http://www.kued.org
        (r'video\.wyomingpbs\.org', 'Wyoming PBS (KCWC)'),  # http://www.wyomingpbs.org
        (r'video\.cpt12\.org', 'Colorado Public Television / KBDI 12 (KBDI)'),  # http://www.cpt12.org/
        (r'video\.kbyueleven\.org', 'KBYU-TV (KBYU)'),  # http://www.kbyutv.org/
        (r'video\.thirteen\.org', 'Thirteen/WNET New York (WNET)'),  # http://www.thirteen.org
        (r'video\.wgbh\.org', 'WGBH/Channel 2 (WGBH)'),  # http://wgbh.org
        (r'video\.wgby\.org', 'WGBY (WGBY)'),  # http://www.wgby.org
        (r'watch\.njtvonline\.org', 'NJTV Public Media NJ (WNJT)'),  # http://www.njtvonline.org/
        # (r'ripbs\.org', 'Rhode Island PBS (WSBE)'),  # http://www.ripbs.org/home/
        (r'watch\.wliw\.org', 'WLIW21 (WLIW)'),  # http://www.wliw.org/
        (r'video\.mpt\.tv', 'mpt/Maryland Public Television (WMPB)'),  # http://www.mpt.org
        (r'watch\.weta\.org', 'WETA Television and Radio (WETA)'),  # http://www.weta.org
        (r'video\.whyy\.org', 'WHYY (WHYY)'),  # http://www.whyy.org
        (r'video\.wlvt\.org', 'PBS 39 (WLVT)'),  # http://www.wlvt.org/
        (r'video\.wvpt\.net', 'WVPT - Your Source for PBS and More! (WVPT)'),  # http://www.wvpt.net
        (r'video\.whut\.org', 'Howard University Television (WHUT)'),  # http://www.whut.org
        (r'video\.wedu\.org', 'WEDU PBS (WEDU)'),  # http://www.wedu.org
        (r'video\.wgcu\.org', 'WGCU Public Media (WGCU)'),  # http://www.wgcu.org/
        # (r'wjct\.org', 'WJCT Public Broadcasting (WJCT)'),  # http://www.wjct.org
        (r'video\.wpbt2\.org', 'WPBT2 (WPBT)'),  # http://www.wpbt2.org
        (r'video\.wucftv\.org', 'WUCF TV (WUCF)'),  # http://wucftv.org
        (r'video\.wuft\.org', 'WUFT/Channel 5 (WUFT)'),  # http://www.wuft.org
        (r'watch\.wxel\.org', 'WXEL/Channel 42 (WXEL)'),  # http://www.wxel.org/home/
        (r'video\.wlrn\.org', 'WLRN/Channel 17 (WLRN)'),  # http://www.wlrn.org/
        (r'video\.wusf\.usf\.edu', 'WUSF Public Broadcasting (WUSF)'),  # http://wusf.org/
        (r'video\.scetv\.org', 'ETV (WRLK)'),  # http://www.scetv.org
        (r'video\.unctv\.org', 'UNC-TV (WUNC)'),  # http://www.unctv.org/
        # (r'pbsguam\.org', 'PBS Guam (KGTF)'),  # http://www.pbsguam.org/
        (r'video\.pbshawaii\.org', 'PBS Hawaii - Oceanic Cable Channel 10 (KHET)'),  # http://www.pbshawaii.org/
        (r'video\.idahoptv\.org', 'Idaho Public Television (KAID)'),  # http://idahoptv.org
        (r'video\.ksps\.org', 'KSPS (KSPS)'),  # http://www.ksps.org/home/
        (r'watch\.opb\.org', 'OPB (KOPB)'),  # http://www.opb.org
        (r'watch\.nwptv\.org', 'KWSU/Channel 10 & KTNW/Channel 31 (KWSU)'),  # http://www.kwsu.org
        (r'video\.will\.illinois\.edu', 'WILL-TV (WILL)'),  # http://will.illinois.edu/
        (r'video\.networkknowledge\.tv', 'Network Knowledge - WSEC/Springfield (WSEC)'),  # http://www.wsec.tv
        (r'video\.wttw\.com', 'WTTW11 (WTTW)'),  # http://www.wttw.com/
        # (r'wtvp\.org', 'WTVP & WTVP.org, Public Media for Central Illinois (WTVP)'),  # http://www.wtvp.org/
        (r'video\.iptv\.org', 'Iowa Public Television/IPTV (KDIN)'),  # http://www.iptv.org/
        (r'video\.ninenet\.org', 'Nine Network (KETC)'),  # http://www.ninenet.org
        (r'video\.wfwa\.org', 'PBS39 Fort Wayne (WFWA)'),  # http://wfwa.org/
        (r'video\.wfyi\.org', 'WFYI Indianapolis (WFYI)'),  # http://www.wfyi.org
        (r'video\.mptv\.org', 'Milwaukee Public Television (WMVS)'),  # http://www.mptv.org
        (r'video\.wnin\.org', 'WNIN (WNIN)'),  # http://www.wnin.org/
        (r'video\.wnit\.org', 'WNIT Public Television (WNIT)'),  # http://www.wnit.org/
        (r'video\.wpt\.org', 'WPT (WPNE)'),  # http://www.wpt.org/
        (r'video\.wvut\.org', 'WVUT/Channel 22 (WVUT)'),  # http://wvut.org/
        (r'video\.weiu\.net', 'WEIU/Channel 51 (WEIU)'),  # http://www.weiu.net
        (r'video\.wqpt\.org', 'WQPT-TV (WQPT)'),  # http://www.wqpt.org
        (r'video\.wycc\.org', 'WYCC PBS Chicago (WYCC)'),  # http://www.wycc.org
        # (r'lakeshorepublicmedia\.org', 'Lakeshore Public Television (WYIN)'),  # http://lakeshorepublicmedia.org/
        (r'video\.wipb\.org', 'WIPB-TV (WIPB)'),  # http://wipb.org
        (r'video\.indianapublicmedia\.org', 'WTIU (WTIU)'),  # http://indianapublicmedia.org/tv/
        (r'watch\.cetconnect\.org', 'CET  (WCET)'),  # http://www.cetconnect.org
        (r'video\.thinktv\.org', 'ThinkTVNetwork (WPTD)'),  # http://www.thinktv.org
        (r'video\.wbgu\.org', 'WBGU-TV (WBGU)'),  # http://wbgu.org
        (r'video\.wgvu\.org', 'WGVU TV (WGVU)'),  # http://www.wgvu.org/
        (r'video\.netnebraska\.org', 'NET1 (KUON)'),  # http://netnebraska.org
        (r'video\.pioneer\.org', 'Pioneer Public Television (KWCM)'),  # http://www.pioneer.org
        (r'watch\.sdpb\.org', 'SDPB Television (KUSD)'),  # http://www.sdpb.org
        (r'video\.tpt\.org', 'TPT (KTCA)'),  # http://www.tpt.org
        (r'watch\.ksmq\.org', 'KSMQ (KSMQ)'),  # http://www.ksmq.org/
        (r'watch\.kpts\.org', 'KPTS/Channel 8 (KPTS)'),  # http://www.kpts.org/
        (r'watch\.ktwu\.org', 'KTWU/Channel 11 (KTWU)'),  # http://ktwu.org
        # (r'shptv\.org', 'Smoky Hills Public Television (KOOD)'),  # http://www.shptv.org
        # (r'kcpt\.org', 'KCPT Kansas City Public Television (KCPT)'),  # http://kcpt.org/
        # (r'blueridgepbs\.org', 'Blue Ridge PBS (WBRA)'),  # http://www.blueridgepbs.org/
        (r'watch\.easttennesseepbs\.org', 'East Tennessee PBS (WSJK)'),  # http://easttennesseepbs.org
        (r'video\.wcte\.tv', 'WCTE-TV (WCTE)'),  # http://www.wcte.org
        (r'video\.wljt\.org', 'WLJT, Channel 11 (WLJT)'),  # http://wljt.org/
        (r'video\.wosu\.org', 'WOSU TV (WOSU)'),  # http://wosu.org/
        (r'video\.woub\.org', 'WOUB/WOUC (WOUB)'),  # http://woub.org/tv/index.php?section=5
        (r'video\.wvpublic\.org', 'WVPB (WVPB)'),  # http://wvpublic.org/
        (r'video\.wkyupbs\.org', 'WKYU-PBS (WKYU)'),  # http://www.wkyupbs.org
        # (r'wyes\.org', 'WYES-TV/New Orleans (WYES)'),  # http://www.wyes.org
        (r'video\.kera\.org', 'KERA 13 (KERA)'),  # http://www.kera.org/
        (r'video\.mpbn\.net', 'MPBN (WCBB)'),  # http://www.mpbn.net/
        (r'video\.mountainlake\.org', 'Mountain Lake PBS (WCFE)'),  # http://www.mountainlake.org/
        (r'video\.nhptv\.org', 'NHPTV (WENH)'),  # http://nhptv.org/
        (r'video\.vpt\.org', 'Vermont PBS (WETK)'),  # http://www.vpt.org
        (r'video\.witf\.org', 'witf (WITF)'),  # http://www.witf.org
        (r'watch\.wqed\.org', 'WQED Multimedia (WQED)'),  # http://www.wqed.org/
        (r'video\.wmht\.org', 'WMHT Educational Telecommunications (WMHT)'),  # http://www.wmht.org/home/
        (r'video\.deltabroadcasting\.org', 'Q-TV (WDCQ)'),  # http://www.deltabroadcasting.org
        (r'video\.dptv\.org', 'WTVS Detroit Public TV (WTVS)'),  # http://www.dptv.org/
        (r'video\.wcmu\.org', 'CMU Public Television (WCMU)'),  # http://www.wcmu.org
        (r'video\.wkar\.org', 'WKAR-TV (WKAR)'),  # http://wkar.org/
        (r'wnmuvideo\.nmu\.edu', 'WNMU-TV Public TV 13 (WNMU)'),  # http://wnmutv.nmu.edu
        (r'video\.wdse\.org', 'WDSE - WRPT (WDSE)'),  # http://www.wdse.org/
        (r'video\.wgte\.org', 'WGTE TV (WGTE)'),  # http://www.wgte.org
        (r'video\.lptv\.org', 'Lakeland Public Television (KAWE)'),  # http://www.lakelandptv.org
        # (r'prairiepublic\.org', 'PRAIRIE PUBLIC (KFME)'),  # http://www.prairiepublic.org/
        (r'video\.kmos\.org', 'KMOS-TV - Channels 6.1, 6.2 and 6.3 (KMOS)'),  # http://www.kmos.org/
        (r'watch\.montanapbs\.org', 'MontanaPBS (KUSM)'),  # http://montanapbs.org
        (r'video\.krwg\.org', 'KRWG/Channel 22 (KRWG)'),  # http://www.krwg.org
        (r'video\.kacvtv\.org', 'KACV (KACV)'),  # http://www.panhandlepbs.org/home/
        (r'video\.kcostv\.org', 'KCOS/Channel 13 (KCOS)'),  # www.kcostv.org
        (r'video\.wcny\.org', 'WCNY/Channel 24 (WCNY)'),  # http://www.wcny.org
        (r'video\.wned\.org', 'WNED (WNED)'),  # http://www.wned.org/
        (r'watch\.wpbstv\.org', 'WPBS (WPBS)'),  # http://www.wpbstv.org
        (r'video\.wskg\.org', 'WSKG Public TV (WSKG)'),  # http://wskg.org
        (r'video\.wxxi\.org', 'WXXI (WXXI)'),  # http://wxxi.org
        (r'video\.wpsu\.org', 'WPSU (WPSU)'),  # http://www.wpsu.org
        # (r'wqln\.org', 'WQLN/Channel 54 (WQLN)'),  # http://www.wqln.org
        (r'on-demand\.wvia\.org', 'WVIA Public Media Studios (WVIA)'),  # http://www.wvia.org/
        (r'video\.wtvi\.org', 'WTVI (WTVI)'),  # http://www.wtvi.org/
        # (r'whro\.org', 'WHRO (WHRO)'),  # http://whro.org
        (r'video\.westernreservepublicmedia\.org', 'Western Reserve PBS (WNEO)'),  # http://www.WesternReservePublicMedia.org/
        (r'video\.ideastream\.org', 'WVIZ/PBS ideastream (WVIZ)'),  # http://www.wviz.org/
        (r'video\.kcts9\.org', 'KCTS 9 (KCTS)'),  # http://kcts9.org/
        (r'video\.basinpbs\.org', 'Basin PBS (KPBT)'),  # http://www.basinpbs.org
        (r'video\.houstonpbs\.org', 'KUHT / Channel 8 (KUHT)'),  # http://www.houstonpublicmedia.org/
        # (r'tamu\.edu', 'KAMU - TV (KAMU)'),  # http://KAMU.tamu.edu
        # (r'kedt\.org', 'KEDT/Channel 16 (KEDT)'),  # http://www.kedt.org
        (r'video\.klrn\.org', 'KLRN (KLRN)'),  # http://www.klrn.org
        (r'video\.klru\.tv', 'KLRU (KLRU)'),  # http://www.klru.org
        # (r'kmbh\.org', 'KMBH-TV (KMBH)'),  # http://www.kmbh.org
        # (r'knct\.org', 'KNCT (KNCT)'),  # http://www.knct.org
        # (r'ktxt\.org', 'KTTZ-TV (KTXT)'),  # http://www.ktxt.org
        (r'video\.wtjx\.org', 'WTJX Channel 12 (WTJX)'),  # http://www.wtjx.org/
        (r'video\.ideastations\.org', 'WCVE PBS (WCVE)'),  # http://ideastations.org/
        (r'video\.kbtc\.org', 'KBTC Public Television (KBTC)'),  # http://kbtc.org
    )

    IE_NAME = 'pbs'
    IE_DESC = 'Public Broadcasting Service (PBS) and member stations: %s' % ', '.join(list(zip(*_STATIONS))[1])

    _VALID_URL = r'''(?x)https?://
        (?:
           # Direct video URL
           (?:%s)/(?:(?:vir|port)alplayer|video)/(?P<id>[0-9]+)(?:[?/]|$) |
           # Article with embedded player (or direct video)
           (?:www\.)?pbs\.org/(?:[^/]+/){1,5}(?P<presumptive_id>[^/]+?)(?:\.html)?/?(?:$|[?\#]) |
           # Player
           (?:video|player)\.pbs\.org/(?:widget/)?partnerplayer/(?P<player_id>[^/]+)
        )
    ''' % '|'.join(list(zip(*_STATIONS))[0])

    _GEO_COUNTRIES = ['US']

    _TESTS = [
        {
            'url': 'http://www.pbs.org/tpt/constitution-usa-peter-sagal/watch/a-more-perfect-union/',
            'md5': '173dc391afd361fa72eab5d3d918968d',
            'info_dict': {
                'id': '2365006249',
                'ext': 'mp4',
                'title': 'Constitution USA with Peter Sagal - A More Perfect Union',
                'description': 'md5:31b664af3c65fd07fa460d306b837d00',
                'duration': 3190,
            },
        },
        {
            'url': 'http://www.pbs.org/wgbh/pages/frontline/losing-iraq/',
            'md5': '6f722cb3c3982186d34b0f13374499c7',
            'info_dict': {
                'id': '2365297690',
                'ext': 'mp4',
                'title': 'FRONTLINE - Losing Iraq',
                'description': 'md5:5979a4d069b157f622d02bff62fbe654',
                'duration': 5050,
            },
        },
        {
            'url': 'http://www.pbs.org/newshour/bb/education-jan-june12-cyberschools_02-23/',
            'md5': 'b19856d7f5351b17a5ab1dc6a64be633',
            'info_dict': {
                'id': '2201174722',
                'ext': 'mp4',
                'title': 'PBS NewsHour - Cyber Schools Gain Popularity, but Quality Questions Persist',
                'description': 'md5:86ab9a3d04458b876147b355788b8781',
                'duration': 801,
            },
        },
        {
            'url': 'http://www.pbs.org/wnet/gperf/dudamel-conducts-verdi-requiem-hollywood-bowl-full-episode/3374/',
            'md5': 'c62859342be2a0358d6c9eb306595978',
            'info_dict': {
                'id': '2365297708',
                'ext': 'mp4',
                'title': 'Great Performances - Dudamel Conducts Verdi Requiem at the Hollywood Bowl - Full',
                'description': 'md5:657897370e09e2bc6bf0f8d2cd313c6b',
                'duration': 6559,
                'thumbnail': r're:^https?://.*\.jpg$',
            },
        },
        {
            'url': 'http://www.pbs.org/wgbh/nova/earth/killer-typhoon.html',
            'md5': '908f3e5473a693b266b84e25e1cf9703',
            'info_dict': {
                'id': '2365160389',
                'display_id': 'killer-typhoon',
                'ext': 'mp4',
                'description': 'md5:c741d14e979fc53228c575894094f157',
                'title': 'NOVA - Killer Typhoon',
                'duration': 3172,
                'thumbnail': r're:^https?://.*\.jpg$',
                'upload_date': '20140122',
                'age_limit': 10,
            },
        },
        {
            'url': 'http://www.pbs.org/wgbh/pages/frontline/united-states-of-secrets/',
            'info_dict': {
                'id': 'united-states-of-secrets',
            },
            'playlist_count': 2,
        },
        {
            'url': 'http://www.pbs.org/wgbh/americanexperience/films/great-war/',
            'info_dict': {
                'id': 'great-war',
            },
            'playlist_count': 3,
        },
        {
            'url': 'http://www.pbs.org/wgbh/americanexperience/films/death/player/',
            'info_dict': {
                'id': '2276541483',
                'display_id': 'player',
                'ext': 'mp4',
                'title': 'American Experience - Death and the Civil War, Chapter 1',
                'description': 'md5:67fa89a9402e2ee7d08f53b920674c18',
                'duration': 682,
                'thumbnail': r're:^https?://.*\.jpg$',
            },
            'params': {
                'skip_download': True,  # requires ffmpeg
            },
        },
        {
            'url': 'http://www.pbs.org/video/2365245528/',
            'md5': '115223d41bd55cda8ae5cd5ed4e11497',
            'info_dict': {
                'id': '2365245528',
                'display_id': '2365245528',
                'ext': 'mp4',
                'title': 'FRONTLINE - United States of Secrets (Part One)',
                'description': 'md5:55756bd5c551519cc4b7703e373e217e',
                'duration': 6851,
                'thumbnail': r're:^https?://.*\.jpg$',
            },
        },
        {
            # Video embedded in iframe containing angle brackets as attribute's value (e.g.
            # "<iframe style='position: absolute;<br />\ntop: 0; left: 0;' ...", see
            # https://github.com/ytdl-org/youtube-dl/issues/7059)
            'url': 'http://www.pbs.org/food/features/a-chefs-life-season-3-episode-5-prickly-business/',
            'md5': '59b0ef5009f9ac8a319cc5efebcd865e',
            'info_dict': {
                'id': '2365546844',
                'display_id': 'a-chefs-life-season-3-episode-5-prickly-business',
                'ext': 'mp4',
                'title': "A Chef's Life - Season 3, Ep. 5: Prickly Business",
                'description': 'md5:c0ff7475a4b70261c7e58f493c2792a5',
                'duration': 1480,
                'thumbnail': r're:^https?://.*\.jpg$',
            },
        },
        {
            # Frontline video embedded via flp2012.js
            'url': 'http://www.pbs.org/wgbh/pages/frontline/the-atomic-artists',
            'info_dict': {
                'id': '2070868960',
                'display_id': 'the-atomic-artists',
                'ext': 'mp4',
                'title': 'FRONTLINE - The Atomic Artists',
                'description': 'md5:f677e4520cfacb4a5ce1471e31b57800',
                'duration': 723,
                'thumbnail': r're:^https?://.*\.jpg$',
            },
            'params': {
                'skip_download': True,  # requires ffmpeg
            },
        },
        {
            # Serves hd only via wigget/partnerplayer page
            'url': 'http://www.pbs.org/video/2365641075/',
            'md5': 'fdf907851eab57211dd589cf12006666',
            'info_dict': {
                'id': '2365641075',
                'ext': 'mp4',
                'title': 'FRONTLINE - Netanyahu at War',
                'duration': 6852,
                'thumbnail': r're:^https?://.*\.jpg$',
                'formats': 'mincount:8',
            },
        },
        {
            # https://github.com/ytdl-org/youtube-dl/issues/13801
            'url': 'https://www.pbs.org/video/pbs-newshour-full-episode-july-31-2017-1501539057/',
            'info_dict': {
                'id': '3003333873',
                'ext': 'mp4',
                'title': 'PBS NewsHour - full episode July 31, 2017',
                'description': 'md5:d41d8cd98f00b204e9800998ecf8427e',
                'duration': 3265,
                'thumbnail': r're:^https?://.*\.jpg$',
            },
            'params': {
                'skip_download': True,
            },
        },
        {
            'url': 'http://www.pbs.org/wgbh/roadshow/watch/episode/2105-indianapolis-hour-2/',
            'info_dict': {
                'id': '2365936247',
                'ext': 'mp4',
                'title': 'Antiques Roadshow - Indianapolis, Hour 2',
                'description': 'md5:524b32249db55663e7231b6b8d1671a2',
                'duration': 3180,
                'thumbnail': r're:^https?://.*\.jpg$',
            },
            'params': {
                'skip_download': True,
            },
            'expected_warnings': ['HTTP Error 403: Forbidden'],
        },
        {
            'url': 'https://www.pbs.org/wgbh/masterpiece/episodes/victoria-s2-e1/',
            'info_dict': {
                'id': '3007193718',
                'ext': 'mp4',
                'title': "Victoria - A Soldier's Daughter / The Green-Eyed Monster",
                'description': 'md5:37efbac85e0c09b009586523ec143652',
                'duration': 6292,
                'thumbnail': r're:^https?://.*\.(?:jpg|JPG)$',
            },
            'params': {
                'skip_download': True,
            },
            'expected_warnings': ['HTTP Error 403: Forbidden'],
        },
        {
            'url': 'https://player.pbs.org/partnerplayer/tOz9tM5ljOXQqIIWke53UA==/',
            'info_dict': {
                'id': '3011407934',
                'ext': 'mp4',
                'title': 'Stories from the Stage - Road Trip',
                'duration': 1619,
                'thumbnail': r're:^https?://.*\.(?:jpg|JPG)$',
            },
            'params': {
                'skip_download': True,
            },
            'expected_warnings': ['HTTP Error 403: Forbidden'],
        },
        {
            'url': 'http://player.pbs.org/widget/partnerplayer/2365297708/?start=0&end=0&chapterbar=false&endscreen=false&topbar=true',
            'only_matching': True,
        },
        {
            'url': 'http://watch.knpb.org/video/2365616055/',
            'only_matching': True,
        },
        {
            'url': 'https://player.pbs.org/portalplayer/3004638221/?uid=',
            'only_matching': True,
        }
    ]
    _ERRORS = {
        101: 'We\'re sorry, but this video is not yet available.',
        403: 'We\'re sorry, but this video is not available in your region due to right restrictions.',
        404: 'We are experiencing technical difficulties that are preventing us from playing the video at this time. Please check back again soon.',
        410: 'This video has expired and is no longer available for online streaming.',
    }

    def _real_initialize(self):
        cookie = (self._download_json(
            'http://localization.services.pbs.org/localize/auto/cookie/',
            None, headers=self.geo_verification_headers(), fatal=False) or {}).get('cookie')
        if cookie:
            station = self._search_regex(r'#?s=\["([^"]+)"', cookie, 'station')
            if station:
                self._set_cookie('.pbs.org', 'pbsol.station', station)

    def _extract_webpage(self, url):
        mobj = self._match_valid_url(url)

        description = None

        presumptive_id = mobj.group('presumptive_id')
        display_id = presumptive_id
        if presumptive_id:
            webpage = self._download_webpage(url, display_id)

            description = strip_or_none(self._og_search_description(
                webpage, default=None) or self._html_search_meta(
                'description', webpage, default=None))
            upload_date = unified_strdate(self._search_regex(
                r'<input type="hidden" id="air_date_[0-9]+" value="([^"]+)"',
                webpage, 'upload date', default=None))

            # tabbed frontline videos
            MULTI_PART_REGEXES = (
                r'<div[^>]+class="videotab[^"]*"[^>]+vid="(\d+)"',
                r'<a[^>]+href=["\']#(?:video-|part)\d+["\'][^>]+data-cove[Ii]d=["\'](\d+)',
            )
            for p in MULTI_PART_REGEXES:
                tabbed_videos = orderedSet(re.findall(p, webpage))
                if tabbed_videos:
                    return tabbed_videos, presumptive_id, upload_date, description

            MEDIA_ID_REGEXES = [
                r"div\s*:\s*'videoembed'\s*,\s*mediaid\s*:\s*'(\d+)'",  # frontline video embed
                r'class="coveplayerid">([^<]+)<',                       # coveplayer
                r'<section[^>]+data-coveid="(\d+)"',                    # coveplayer from http://www.pbs.org/wgbh/frontline/film/real-csi/
                r'<input type="hidden" id="pbs_video_id_[0-9]+" value="([0-9]+)"/>',  # jwplayer
                r"(?s)window\.PBS\.playerConfig\s*=\s*{.*?id\s*:\s*'([0-9]+)',",
                r'<div[^>]+\bdata-cove-id=["\'](\d+)"',  # http://www.pbs.org/wgbh/roadshow/watch/episode/2105-indianapolis-hour-2/
                r'<iframe[^>]+\bsrc=["\'](?:https?:)?//video\.pbs\.org/widget/partnerplayer/(\d+)',  # https://www.pbs.org/wgbh/masterpiece/episodes/victoria-s2-e1/
            ]

            media_id = self._search_regex(
                MEDIA_ID_REGEXES, webpage, 'media ID', fatal=False, default=None)
            if media_id:
                return media_id, presumptive_id, upload_date, description

            # Frontline video embedded via flp
            video_id = self._search_regex(
                r'videoid\s*:\s*"([\d+a-z]{7,})"', webpage, 'videoid', default=None)
            if video_id:
                # pkg_id calculation is reverse engineered from
                # http://www.pbs.org/wgbh/pages/frontline/js/flp2012.js
                prg_id = self._search_regex(
                    r'videoid\s*:\s*"([\d+a-z]{7,})"', webpage, 'videoid')[7:]
                if 'q' in prg_id:
                    prg_id = prg_id.split('q')[1]
                prg_id = int(prg_id, 16)
                getdir = self._download_json(
                    'http://www.pbs.org/wgbh/pages/frontline/.json/getdir/getdir%d.json' % prg_id,
                    presumptive_id, 'Downloading getdir JSON',
                    transform_source=strip_jsonp)
                return getdir['mid'], presumptive_id, upload_date, description

            for iframe in re.findall(r'(?s)<iframe(.+?)></iframe>', webpage):
                url = self._search_regex(
                    r'src=(["\'])(?P<url>.+?partnerplayer.+?)\1', iframe,
                    'player URL', default=None, group='url')
                if url:
                    break

            if not url:
                url = self._og_search_url(webpage)

            mobj = re.match(
                self._VALID_URL, self._proto_relative_url(url.strip()))

        player_id = mobj.group('player_id')
        if not display_id:
            display_id = player_id
        if player_id:
            player_page = self._download_webpage(
                url, display_id, note='Downloading player page',
                errnote='Could not download player page')
            video_id = self._search_regex(
                r'<div\s+id=["\']video_(\d+)', player_page, 'video ID',
                default=None)
            if not video_id:
                video_info = self._extract_video_data(
                    player_page, 'video data', display_id)
                video_id = compat_str(
                    video_info.get('id') or video_info['contentID'])
        else:
            video_id = mobj.group('id')
            display_id = video_id

        return video_id, display_id, None, description

    def _extract_video_data(self, string, name, video_id, fatal=True):
        return self._parse_json(
            self._search_regex(
                [r'(?s)PBS\.videoData\s*=\s*({.+?});\n',
                 r'window\.videoBridge\s*=\s*({.+?});'],
                string, name, default='{}'),
            video_id, transform_source=js_to_json, fatal=fatal)

    def _real_extract(self, url):
        video_id, display_id, upload_date, description = self._extract_webpage(url)

        if isinstance(video_id, list):
            entries = [self.url_result(
                'http://video.pbs.org/video/%s' % vid_id, 'PBS', vid_id)
                for vid_id in video_id]
            return self.playlist_result(entries, display_id)

        info = {}
        redirects = []
        redirect_urls = set()

        def extract_redirect_urls(info):
            for encoding_name in ('recommended_encoding', 'alternate_encoding'):
                redirect = info.get(encoding_name)
                if not redirect:
                    continue
                redirect_url = redirect.get('url')
                if redirect_url and redirect_url not in redirect_urls:
                    redirects.append(redirect)
                    redirect_urls.add(redirect_url)
            encodings = info.get('encodings')
            if isinstance(encodings, list):
                for encoding in encodings:
                    encoding_url = url_or_none(encoding)
                    if encoding_url and encoding_url not in redirect_urls:
                        redirects.append({'url': encoding_url})
                        redirect_urls.add(encoding_url)

        chapters = []
        # Player pages may also serve different qualities
        for page in ('widget/partnerplayer', 'portalplayer'):
            player = self._download_webpage(
                'http://player.pbs.org/%s/%s' % (page, video_id),
                display_id, 'Downloading %s page' % page, fatal=False)
            if player:
                video_info = self._extract_video_data(
                    player, '%s video data' % page, display_id, fatal=False)
                if video_info:
                    extract_redirect_urls(video_info)
                    if not info:
                        info = video_info
                if not chapters:
                    raw_chapters = video_info.get('chapters') or []
                    if not raw_chapters:
                        for chapter_data in re.findall(r'(?s)chapters\.push\(({.*?})\)', player):
                            chapter = self._parse_json(chapter_data, video_id, js_to_json, fatal=False)
                            if not chapter:
                                continue
                            raw_chapters.append(chapter)
                    for chapter in raw_chapters:
                        start_time = float_or_none(chapter.get('start_time'), 1000)
                        duration = float_or_none(chapter.get('duration'), 1000)
                        if start_time is None or duration is None:
                            continue
                        chapters.append({
                            'start_time': start_time,
                            'end_time': start_time + duration,
                            'title': chapter.get('title'),
                        })

        formats = []
        http_url = None
        hls_subs = {}
        for num, redirect in enumerate(redirects):
            redirect_id = redirect.get('eeid')

            redirect_info = self._download_json(
                '%s?format=json' % redirect['url'], display_id,
                'Downloading %s video url info' % (redirect_id or num),
                headers=self.geo_verification_headers())

            if redirect_info['status'] == 'error':
                message = self._ERRORS.get(
                    redirect_info['http_code'], redirect_info['message'])
                if redirect_info['http_code'] == 403:
                    self.raise_geo_restricted(
                        msg=message, countries=self._GEO_COUNTRIES)
                raise ExtractorError(
                    '%s said: %s' % (self.IE_NAME, message), expected=True)

            format_url = redirect_info.get('url')
            if not format_url:
                continue

            if determine_ext(format_url) == 'm3u8':
                hls_formats, hls_subs = self._extract_m3u8_formats_and_subtitles(
                    format_url, display_id, 'mp4', m3u8_id='hls', fatal=False)
                formats.extend(hls_formats)
            else:
                formats.append({
                    'url': format_url,
                    'format_id': redirect_id,
                })
                if re.search(r'^https?://.*(?:\d+k|baseline)', format_url):
                    http_url = format_url
        self._remove_duplicate_formats(formats)
        m3u8_formats = list(filter(
            lambda f: f.get('protocol') == 'm3u8' and f.get('vcodec') != 'none',
            formats))
        if http_url:
            for m3u8_format in m3u8_formats:
                bitrate = self._search_regex(r'(\d+)k', m3u8_format['url'], 'bitrate', default=None)
                # Lower qualities (150k and 192k) are not available as HTTP formats (see [1]),
                # we won't try extracting them.
                # Since summer 2016 higher quality formats (4500k and 6500k) are also available
                # albeit they are not documented in [2].
                # 1. https://github.com/ytdl-org/youtube-dl/commit/cbc032c8b70a038a69259378c92b4ba97b42d491#commitcomment-17313656
                # 2. https://projects.pbs.org/confluence/display/coveapi/COVE+Video+Specifications
                if not bitrate or int(bitrate) < 400:
                    continue
                f_url = re.sub(r'\d+k|baseline', bitrate + 'k', http_url)
                # This may produce invalid links sometimes (e.g.
                # http://www.pbs.org/wgbh/frontline/film/suicide-plan)
                if not self._is_valid_url(f_url, display_id, 'http-%sk video' % bitrate):
                    continue
                f = m3u8_format.copy()
                f.update({
                    'url': f_url,
                    'format_id': m3u8_format['format_id'].replace('hls', 'http'),
                    'protocol': 'http',
                })
                formats.append(f)
        for f in formats:
            if (f.get('format_note') or '').endswith(' AD'):  # Audio description
                f['language_preference'] = -10
        self._sort_formats(formats)

        rating_str = info.get('rating')
        if rating_str is not None:
            rating_str = rating_str.rpartition('-')[2]
        age_limit = US_RATINGS.get(rating_str)

        subtitles = {}
        captions = info.get('cc') or {}
        for caption_url in captions.values():
            subtitles.setdefault('en', []).append({
                'url': caption_url
            })
        subtitles = self._merge_subtitles(subtitles, hls_subs)

        # info['title'] is often incomplete (e.g. 'Full Episode', 'Episode 5', etc)
        # Try turning it to 'program - title' naming scheme if possible
        alt_title = info.get('program', {}).get('title')
        if alt_title:
            info['title'] = alt_title + ' - ' + re.sub(r'^' + alt_title + r'[\s\-:]+', '', info['title'])

        description = info.get('description') or info.get(
            'program', {}).get('description') or description

        return {
            'id': video_id,
            'display_id': display_id,
            'title': info['title'],
            'description': description,
            'thumbnail': info.get('image_url'),
            'duration': int_or_none(info.get('duration')),
            'age_limit': age_limit,
            'upload_date': upload_date,
            'formats': formats,
            'subtitles': subtitles,
            'chapters': chapters,
        }


code:
import re

from .common import InfoExtractor
from ..utils import (
    qualities,
    unified_timestamp,
    traverse_obj,
)

class PearVideoIE(InfoExtractor):
    _VALID_URL = r'https?://(?:www\.)?pearvideo\.com/video_(?P<id>\d+)'
    _TEST = {
        'url': 'http://www.pearvideo.com/video_1076290',
        'info_dict': {
            'id': '1076290',
            'ext': 'mp4',
            'title': '小浣熊在主人家玻璃上滚石头：没砸',
            'description': 'md5:01d576b747de71be0ee85eb7cac25f9d',
            'timestamp': 1494275280,
            'upload_date': '20170508',
        }
    }

    def _real_extract(self, url):
        video_id = self._match_id(url)

        webpage = self._download_webpage(url, video_id)

        quality = qualities(
            ('ldflv', 'ld', 'sdflv', 'sd', 'hdflv', 'hd', 'src'))

        formats = [{
            'url': mobj.group('url'),
            'format_id': mobj.group('id'),
            'quality': quality(mobj.group('id')),
        } for mobj in re.finditer(
            r'(?P<id>[a-zA-Z]+)Url\s*=\s*(["\'])(?P<url>(?:https?:)?//.+?)\2',
            webpage)]
        if not formats:
            info = self._download_json(
                'https://www.pearvideo.com/videoStatus.jsp', video_id=video_id,
                query={'contId': video_id}, headers={'Referer': url})
            formats = [{
                'format_id': k,
                'url': v.replace(info['systemTime'], f'cont-{video_id}') if k == 'srcUrl' else v
            } for k, v in traverse_obj(info, ('videoInfo', 'videos'), default={}).items() if v]
        self._sort_formats(formats)

        title = self._search_regex(
            (r'<h1[^>]+\bclass=(["\'])video-tt\1[^>]*>(?P<value>[^<]+)',
             r'<[^>]+\bdata-title=(["\'])(?P<value>(?:(?!\1).)+)\1'),
            webpage, 'title', group='value')
        description = self._search_regex(
            (r'<div[^>]+\bclass=(["\'])summary\1[^>]*>(?P<value>[^<]+)',
             r'<[^>]+\bdata-summary=(["\'])(?P<value>(?:(?!\1).)+)\1'),
            webpage, 'description', default=None,
            group='value') or self._html_search_meta('Description', webpage)
        timestamp = unified_timestamp(self._search_regex(
            r'<div[^>]+\bclass=["\']date["\'][^>]*>([^<]+)',
            webpage, 'timestamp', fatal=False))

        return {
            'id': video_id,
            'title': title,
            'description': description,
            'timestamp': timestamp,
            'formats': formats,
        }