code:
import itertools
import json

from .common import InfoExtractor
from ..utils import (
    ExtractorError,
    int_or_none,
    str_or_none,
    traverse_obj,
    unified_strdate,
    unified_timestamp,
    url_basename,
)

class TrillerBaseIE(InfoExtractor):
    _NETRC_MACHINE = 'triller'
    _API_BASE_URL = 'https://social.triller.co/v1.5'
    _API_HEADERS = {'Origin': 'https://triller.co'}

    def _perform_login(self, username, password):
        if self._API_HEADERS.get('Authorization'):
            return

        user_check = self._download_json(
            f'{self._API_BASE_URL}/api/user/is-valid-username', None, note='Checking username',
            fatal=False, expected_status=400, headers={
                'Content-Type': 'application/json',
                'Origin': 'https://triller.co',
            }, data=json.dumps({'username': username}, separators=(',', ':')).encode('utf-8'))
        if user_check.get('status'):  # endpoint returns "status":false if username exists
            raise ExtractorError('Unable to login: Invalid username', expected=True)

        credentials = {
            'username': username,
            'password': password,
        }
        login = self._download_json(
            f'{self._API_BASE_URL}/user/auth', None, note='Logging in',
            fatal=False, expected_status=400, headers={
                'Content-Type': 'application/json',
                'Origin': 'https://triller.co',
            }, data=json.dumps(credentials, separators=(',', ':')).encode('utf-8'))
        if not login.get('auth_token'):
            if login.get('error') == 1008:
                raise ExtractorError('Unable to login: Incorrect password', expected=True)
            raise ExtractorError('Unable to login')

        self._API_HEADERS['Authorization'] = f'Bearer {login["auth_token"]}'

    def _get_comments(self, video_id, limit=15):
        comment_info = self._download_json(
            f'{self._API_BASE_URL}/api/videos/{video_id}/comments_v2',
            video_id, fatal=False, note='Downloading comments API JSON',
            headers=self._API_HEADERS, query={'limit': limit}) or {}
        if not comment_info.get('comments'):
            return
        for comment_dict in comment_info['comments']:
            yield {
                'author': traverse_obj(comment_dict, ('author', 'username')),
                'author_id': traverse_obj(comment_dict, ('author', 'user_id')),
                'id': comment_dict.get('id'),
                'text': comment_dict.get('body'),
                'timestamp': unified_timestamp(comment_dict.get('timestamp')),
            }

    def _check_user_info(self, user_info):
        if not user_info:
            self.report_warning('Unable to extract user info')
        elif user_info.get('private') and not user_info.get('followed_by_me'):
            raise ExtractorError('This video is private', expected=True)
        elif traverse_obj(user_info, 'blocked_by_user', 'blocking_user'):
            raise ExtractorError('The author of the video is blocked', expected=True)
        return user_info

    def _parse_video_info(self, video_info, username, user_info=None):
        video_uuid = video_info.get('video_uuid')
        video_id = video_info.get('id')

        formats = []
        video_url = traverse_obj(video_info, 'video_url', 'stream_url')
        if video_url:
            formats.append({
                'url': video_url,
                'ext': 'mp4',
                'vcodec': 'h264',
                'width': video_info.get('width'),
                'height': video_info.get('height'),
                'format_id': url_basename(video_url).split('.')[0],
                'filesize': video_info.get('filesize'),
            })
        video_set = video_info.get('video_set') or []
        for video in video_set:
            resolution = video.get('resolution') or ''
            formats.append({
                'url': video['url'],
                'ext': 'mp4',
                'vcodec': video.get('codec'),
                'vbr': int_or_none(video.get('bitrate'), 1000),
                'width': int_or_none(resolution.split('x')[0]),
                'height': int_or_none(resolution.split('x')[1]),
                'format_id': url_basename(video['url']).split('.')[0],
            })
        audio_url = video_info.get('audio_url')
        if audio_url:
            formats.append({
                'url': audio_url,
                'ext': 'm4a',
                'format_id': url_basename(audio_url).split('.')[0],
            })

        manifest_url = video_info.get('transcoded_url')
        if manifest_url:
            formats.extend(self._extract_m3u8_formats(
                manifest_url, video_id, 'mp4', entry_protocol='m3u8_native',
                m3u8_id='hls', fatal=False))
        self._sort_formats(formats)

        comment_count = int_or_none(video_info.get('comment_count'))

        user_info = user_info or traverse_obj(video_info, 'user', default={})

        return {
            'id': str_or_none(video_id) or video_uuid,
            'title': video_info.get('description') or f'Video by {username}',
            'thumbnail': video_info.get('thumbnail_url'),
            'description': video_info.get('description'),
            'uploader': str_or_none(username),
            'uploader_id': str_or_none(user_info.get('user_id')),
            'creator': str_or_none(user_info.get('name')),
            'timestamp': unified_timestamp(video_info.get('timestamp')),
            'upload_date': unified_strdate(video_info.get('timestamp')),
            'duration': int_or_none(video_info.get('duration')),
            'view_count': int_or_none(video_info.get('play_count')),
            'like_count': int_or_none(video_info.get('likes_count')),
            'artist': str_or_none(video_info.get('song_artist')),
            'track': str_or_none(video_info.get('song_title')),
            'webpage_url': f'https://triller.co/@{username}/video/{video_uuid}',
            'uploader_url': f'https://triller.co/@{username}',
            'extractor_key': TrillerIE.ie_key(),
            'extractor': TrillerIE.IE_NAME,
            'formats': formats,
            'comment_count': comment_count,
            '__post_extractor': self.extract_comments(video_id, comment_count),
        }

class TrillerIE(TrillerBaseIE):
    _VALID_URL = r'''(?x)
            https?://(?:www\.)?triller\.co/
            @(?P<username>[\w\._]+)/video/
            (?P<id>[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12})
        '''
    _TESTS = [{
        'url': 'https://triller.co/@theestallion/video/2358fcd7-3df2-4c77-84c8-1d091610a6cf',
        'md5': '228662d783923b60d78395fedddc0a20',
        'info_dict': {
            'id': '71595734',
            'ext': 'mp4',
            'title': 'md5:9a2bf9435c5c4292678996a464669416',
            'thumbnail': r're:^https://uploads\.cdn\.triller\.co/.+\.jpg$',
            'description': 'md5:9a2bf9435c5c4292678996a464669416',
            'uploader': 'theestallion',
            'uploader_id': '18992236',
            'creator': 'Megan Thee Stallion',
            'timestamp': 1660598222,
            'upload_date': '20220815',
            'duration': 47,
            'height': 3840,
            'width': 2160,
            'view_count': int,
            'like_count': int,
            'artist': 'Megan Thee Stallion',
            'track': 'Her',
            'webpage_url': 'https://triller.co/@theestallion/video/2358fcd7-3df2-4c77-84c8-1d091610a6cf',
            'uploader_url': 'https://triller.co/@theestallion',
            'comment_count': int,
        }
    }, {
        'url': 'https://triller.co/@charlidamelio/video/46c6fcfa-aa9e-4503-a50c-68444f44cddc',
        'md5': '874055f462af5b0699b9dbb527a505a0',
        'info_dict': {
            'id': '71621339',
            'ext': 'mp4',
            'title': 'md5:4c91ea82760fe0fffb71b8c3aa7295fc',
            'thumbnail': r're:^https://uploads\.cdn\.triller\.co/.+\.jpg$',
            'description': 'md5:4c91ea82760fe0fffb71b8c3aa7295fc',
            'uploader': 'charlidamelio',
            'uploader_id': '1875551',
            'creator': 'charli damelio',
            'timestamp': 1660773354,
            'upload_date': '20220817',
            'duration': 16,
            'height': 1920,
            'width': 1080,
            'view_count': int,
            'like_count': int,
            'artist': 'Dixie',
            'track': 'Someone to Blame',
            'webpage_url': 'https://triller.co/@charlidamelio/video/46c6fcfa-aa9e-4503-a50c-68444f44cddc',
            'uploader_url': 'https://triller.co/@charlidamelio',
            'comment_count': int,
        }
    }]

    def _real_extract(self, url):
        username, video_uuid = self._match_valid_url(url).group('username', 'id')

        video_info = traverse_obj(self._download_json(
            f'{self._API_BASE_URL}/api/videos/{video_uuid}',
            video_uuid, note='Downloading video info API JSON',
            errnote='Unable to download video info API JSON',
            headers=self._API_HEADERS), ('videos', 0))
        if not video_info:
            raise ExtractorError('No video info found in API response')

        user_info = self._check_user_info(video_info.get('user') or {})
        return self._parse_video_info(video_info, username, user_info)

class TrillerUserIE(TrillerBaseIE):
    _VALID_URL = r'https?://(?:www\.)?triller\.co/@(?P<id>[\w\._]+)/?(?:$|[#?])'
    _TESTS = [{
        # first videos request only returns 2 videos
        'url': 'https://triller.co/@theestallion',
        'playlist_mincount': 9,
        'info_dict': {
            'id': '18992236',
            'title': 'theestallion',
            'thumbnail': r're:^https://uploads\.cdn\.triller\.co/.+\.jpg$',
        }
    }, {
        'url': 'https://triller.co/@charlidamelio',
        'playlist_mincount': 25,
        'info_dict': {
            'id': '1875551',
            'title': 'charlidamelio',
            'thumbnail': r're:^https://uploads\.cdn\.triller\.co/.+\.jpg$',
        }
    }]

    def _real_initialize(self):
        if not self._API_HEADERS.get('Authorization'):
            guest = self._download_json(
                f'{self._API_BASE_URL}/user/create_guest',
                None, note='Creating guest session', data=b'', headers=self._API_HEADERS, query={
                    'platform': 'Web',
                    'app_version': '',
                })
            if not guest.get('auth_token'):
                raise ExtractorError('Unable to fetch required auth token for user extraction')

            self._API_HEADERS['Authorization'] = f'Bearer {guest["auth_token"]}'

    def _extract_video_list(self, username, user_id, limit=6):
        query = {
            'limit': limit,
        }
        for page in itertools.count(1):
            for retry in self.RetryManager():
                try:
                    video_list = self._download_json(
                        f'{self._API_BASE_URL}/api/users/{user_id}/videos',
                        username, note=f'Downloading user video list page {page}',
                        errnote='Unable to download user video list', headers=self._API_HEADERS,
                        query=query)
                except ExtractorError as e:
                    if isinstance(e.cause, json.JSONDecodeError) and e.cause.pos == 0:
                        retry.error = e
                        continue
                    raise
            if not video_list.get('videos'):
                break
            yield from video_list['videos']
            query['before_time'] = traverse_obj(video_list, ('videos', -1, 'timestamp'))
            if not query['before_time']:
                break

    def _entries(self, videos, username, user_info):
        for video in videos:
            yield self._parse_video_info(video, username, user_info)

    def _real_extract(self, url):
        username = self._match_id(url)
        user_info = self._check_user_info(self._download_json(
            f'{self._API_BASE_URL}/api/users/by_username/{username}',
            username, note='Downloading user info',
            errnote='Failed to download user info', headers=self._API_HEADERS).get('user', {}))

        user_id = str_or_none(user_info.get('user_id'))
        videos = self._extract_video_list(username, user_id)
        thumbnail = user_info.get('avatar_url')

        return self.playlist_result(
            self._entries(videos, username, user_info), user_id, username, thumbnail=thumbnail)


code:
from .common import InfoExtractor
from ..utils import (
    ExtractorError,
    int_or_none,
    parse_iso8601,
)

class TriluliluIE(InfoExtractor):
    _VALID_URL = r'https?://(?:(?:www|m)\.)?trilulilu\.ro/(?:[^/]+/)?(?P<id>[^/#\?]+)'
    _TESTS = [{
        'url': 'http://www.trilulilu.ro/big-buck-bunny-1',
        'md5': '68da087b676a6196a413549212f60cc6',
        'info_dict': {
            'id': 'ae2899e124140b',
            'ext': 'mp4',
            'title': 'Big Buck Bunny',
            'description': ':) pentru copilul din noi',
            'uploader_id': 'chipy',
            'upload_date': '20120304',
            'timestamp': 1330830647,
            'uploader': 'chipy',
            'view_count': int,
            'like_count': int,
            'comment_count': int,
        },
    }, {
        'url': 'http://www.trilulilu.ro/adena-ft-morreti-inocenta',
        'md5': '929dfb8729dc71750463af88bbbbf4a4',
        'info_dict': {
            'id': 'f299710e3c91c5',
            'ext': 'mp4',
            'title': 'Adena ft. Morreti - Inocenta',
            'description': 'pop music',
            'uploader_id': 'VEVOmixt',
            'upload_date': '20151204',
            'uploader': 'VEVOmixt',
            'timestamp': 1449187937,
            'view_count': int,
            'like_count': int,
            'comment_count': int,
        },
    }]

    def _real_extract(self, url):
        display_id = self._match_id(url)
        media_info = self._download_json('http://m.trilulilu.ro/%s?format=json' % display_id, display_id)

        age_limit = 0
        errors = media_info.get('errors', {})
        if errors.get('friends'):
            raise ExtractorError('This video is private.', expected=True)
        elif errors.get('geoblock'):
            raise ExtractorError('This video is not available in your country.', expected=True)
        elif errors.get('xxx_unlogged'):
            age_limit = 18

        media_class = media_info.get('class')
        if media_class not in ('video', 'audio'):
            raise ExtractorError('not a video or an audio')

        user = media_info.get('user', {})

        thumbnail = media_info.get('cover_url')
        if thumbnail:
            thumbnail.format(width='1600', height='1200')

        # TODO: get correct ext for audio files
        stream_type = media_info.get('stream_type')
        formats = [{
            'url': media_info['href'],
            'ext': stream_type,
        }]
        if media_info.get('is_hd'):
            formats.append({
                'format_id': 'hd',
                'url': media_info['hrefhd'],
                'ext': stream_type,
            })
        if media_class == 'audio':
            formats[0]['vcodec'] = 'none'
        else:
            formats[0]['format_id'] = 'sd'

        return {
            'id': media_info['identifier'].split('|')[1],
            'display_id': display_id,
            'formats': formats,
            'title': media_info['title'],
            'description': media_info.get('description'),
            'thumbnail': thumbnail,
            'uploader_id': user.get('username'),
            'uploader': user.get('fullname'),
            'timestamp': parse_iso8601(media_info.get('published'), ' '),
            'duration': int_or_none(media_info.get('duration')),
            'view_count': int_or_none(media_info.get('count_views')),
            'like_count': int_or_none(media_info.get('count_likes')),
            'comment_count': int_or_none(media_info.get('count_comments')),
            'age_limit': age_limit,
        }

repo_name: Lesmiscore/ytdl


code:
import itertools
import json
import random
import string

from .common import InfoExtractor
from ..utils import (
    ExtractorError,
    format_field,
    int_or_none,
    str_or_none,
    try_get,
)

class TrovoBaseIE(InfoExtractor):
    _VALID_URL_BASE = r'https?://(?:www\.)?trovo\.live/'
    _HEADERS = {'Origin': 'https://trovo.live'}

    def _call_api(self, video_id, data):
        if 'persistedQuery' in data.get('extensions', {}):
            url = 'https://gql.trovo.live'
        else:
            url = 'https://api-web.trovo.live/graphql'

        resp = self._download_json(
            url, video_id, data=json.dumps([data]).encode(), headers={'Accept': 'application/json'},
            query={
                'qid': ''.join(random.choices(string.ascii_uppercase + string.digits, k=10)),
            })[0]
        if 'errors' in resp:
            raise ExtractorError(f'Trovo said: {resp["errors"][0]["message"]}')
        return resp['data'][data['operationName']]

    def _extract_streamer_info(self, data):
        streamer_info = data.get('streamerInfo') or {}
        username = streamer_info.get('userName')
        return {
            'uploader': streamer_info.get('nickName'),
            'uploader_id': str_or_none(streamer_info.get('uid')),
            'uploader_url': format_field(username, None, 'https://trovo.live/%s'),
        }

class TrovoIE(TrovoBaseIE):
    _VALID_URL = TrovoBaseIE._VALID_URL_BASE + r'(?:s/)?(?!(?:clip|video)/)(?P<id>(?!s/)[^/?&#]+(?![^#]+[?&]vid=))'
    _TESTS = [{
        'url': 'https://trovo.live/Exsl',
        'only_matching': True,
    }, {
        'url': 'https://trovo.live/s/SkenonSLive/549759191497',
        'only_matching': True,
    }, {
        'url': 'https://trovo.live/s/zijo987/208251706',
        'info_dict': {
            'id': '104125853_104125853_1656439572',
            'ext': 'flv',
            'uploader_url': 'https://trovo.live/zijo987',
            'uploader_id': '104125853',
            'thumbnail': 'https://livecover.trovo.live/screenshot/73846_104125853_104125853-2022-06-29-04-00-22-852x480.jpg',
            'uploader': 'zijo987',
            'title': '💥IGRAMO IGRICE UPADAJTE💥2500/5000 2022-06-28 22:01',
            'live_status': 'is_live',
        },
        'skip': 'May not be live'
    }]

    def _real_extract(self, url):
        username = self._match_id(url)
        live_info = self._call_api(username, data={
            'operationName': 'live_LiveReaderService_GetLiveInfo',
            'variables': {
                'params': {
                    'userName': username,
                },
            },
        })
        if live_info.get('isLive') == 0:
            raise ExtractorError('%s is offline' % username, expected=True)
        program_info = live_info['programInfo']
        program_id = program_info['id']
        title = program_info['title']

        formats = []
        for stream_info in (program_info.get('streamInfo') or []):
            play_url = stream_info.get('playUrl')
            if not play_url:
                continue
            format_id = stream_info.get('desc')
            formats.append({
                'format_id': format_id,
                'height': int_or_none(format_id[:-1]) if format_id else None,
                'url': play_url,
                'tbr': stream_info.get('bitrate'),
                'http_headers': self._HEADERS,
            })
        self._sort_formats(formats)

        info = {
            'id': program_id,
            'title': title,
            'formats': formats,
            'thumbnail': program_info.get('coverUrl'),
            'is_live': True,
        }
        info.update(self._extract_streamer_info(live_info))
        return info

class TrovoVodIE(TrovoBaseIE):
    _VALID_URL = TrovoBaseIE._VALID_URL_BASE + r'(?:clip|video|s)/(?:[^/]+/\d+[^#]*[?&]vid=)?(?P<id>(?<!/s/)[^/?&#]+)'
    _TESTS = [{
        'url': 'https://trovo.live/clip/lc-5285890818705062210?ltab=videos',
        'params': {'getcomments': True},
        'info_dict': {
            'id': 'lc-5285890818705062210',
            'ext': 'mp4',
            'title': 'fatal moaning for a super good🤣🤣',
            'uploader': 'OneTappedYou',
            'timestamp': 1621628019,
            'upload_date': '20210521',
            'uploader_id': '100719456',
            'duration': 31,
            'view_count': int,
            'like_count': int,
            'comment_count': int,
            'comments': 'mincount:1',
            'categories': ['Call of Duty: Mobile'],
            'uploader_url': 'https://trovo.live/OneTappedYou',
            'thumbnail': r're:^https?://.*\.jpg',
        },
    }, {
        'url': 'https://trovo.live/s/SkenonSLive/549759191497?vid=ltv-100829718_100829718_387702301737980280',
        'info_dict': {
            'id': 'ltv-100829718_100829718_387702301737980280',
            'ext': 'mp4',
            'timestamp': 1654909624,
            'thumbnail': 'http://vod.trovo.live/1f09baf0vodtransger1301120758/ef9ea3f0387702301737980280/coverBySnapshot/coverBySnapshot_10_0.jpg',
            'uploader_id': '100829718',
            'uploader': 'SkenonSLive',
            'title': 'Trovo u secanju, uz par modova i muzike :)',
            'uploader_url': 'https://trovo.live/SkenonSLive',
            'duration': 10830,
            'view_count': int,
            'like_count': int,
            'upload_date': '20220611',
            'comment_count': int,
            'categories': ['Minecraft'],
        }
    }, {
        'url': 'https://trovo.live/video/ltv-100095501_100095501_1609596043',
        'only_matching': True,
    }, {
        'url': 'https://trovo.live/s/SkenonSLive/549759191497?foo=bar&vid=ltv-100829718_100829718_387702301737980280',
        'only_matching': True,
    }]

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

        # NOTE: It is also possible to extract this info from the Nuxt data on the website,
        # however that seems unreliable - sometimes it randomly doesn't return the data,
        # at least when using a non-residential IP.
        resp = self._call_api(vid, data={
            'operationName': 'batchGetVodDetailInfo',
            'variables': {
                'params': {
                    'vids': [vid],
                },
            },
            'extensions': {
                'persistedQuery': {
                    'version': 1,
                    'sha256Hash': 'ceae0355d66476e21a1dd8e8af9f68de95b4019da2cda8b177c9a2255dad31d0',
                },
            },
        })
        vod_detail_info = resp['VodDetailInfos'][vid]
        vod_info = vod_detail_info['vodInfo']
        title = vod_info['title']

        if try_get(vod_info, lambda x: x['playbackRights']['playbackRights'] != 'Normal'):
            playback_rights_setting = vod_info['playbackRights']['playbackRightsSetting']
            if playback_rights_setting == 'SubscriberOnly':
                raise ExtractorError('This video is only available for subscribers', expected=True)
            else:
                raise ExtractorError(f'This video is not available ({playback_rights_setting})', expected=True)

        language = vod_info.get('languageName')
        formats = []
        for play_info in (vod_info.get('playInfos') or []):
            play_url = play_info.get('playUrl')
            if not play_url:
                continue
            format_id = play_info.get('desc')
            formats.append({
                'ext': 'mp4',
                'filesize': int_or_none(play_info.get('fileSize')),
                'format_id': format_id,
                'height': int_or_none(format_id[:-1]) if format_id else None,
                'language': language,
                'protocol': 'm3u8_native',
                'tbr': int_or_none(play_info.get('bitrate')),
                'url': play_url,
                'http_headers': self._HEADERS,
            })
        self._sort_formats(formats)

        category = vod_info.get('categoryName')
        get_count = lambda x: int_or_none(vod_info.get(x + 'Num'))

        info = {
            'id': vid,
            'title': title,
            'formats': formats,
            'thumbnail': vod_info.get('coverUrl'),
            'timestamp': int_or_none(vod_info.get('publishTs')),
            'duration': int_or_none(vod_info.get('duration')),
            'view_count': get_count('watch'),
            'like_count': get_count('like'),
            'comment_count': get_count('comment'),
            'categories': [category] if category else None,
            '__post_extractor': self.extract_comments(vid),
        }
        info.update(self._extract_streamer_info(vod_detail_info))
        return info

    def _get_comments(self, vid):
        for page in itertools.count(1):
            comments_json = self._call_api(vid, data={
                'operationName': 'getCommentList',
                'variables': {
                    'params': {
                        'appInfo': {
                            'postID': vid,
                        },
                        'preview': {},
                        'pageSize': 99,
                        'page': page,
                    },
                },
                'extensions': {
                    'persistedQuery': {
                        'version': 1,
                        'sha256Hash': 'be8e5f9522ddac7f7c604c0d284fd22481813263580849926c4c66fb767eed25',
                    },
                },
            })
            for comment in comments_json['commentList']:
                content = comment.get('content')
                if not content:
                    continue
                author = comment.get('author') or {}
                parent = comment.get('parentID')
                yield {
                    'author': author.get('nickName'),
                    'author_id': str_or_none(author.get('uid')),
                    'id': str_or_none(comment.get('commentID')),
                    'text': content,
                    'timestamp': int_or_none(comment.get('createdAt')),
                    'parent': 'root' if parent == 0 else str_or_none(parent),
                }

            if comments_json['lastPage']:
                break

class TrovoChannelBaseIE(TrovoBaseIE):
    def _get_vod_json(self, page, uid):
        raise NotImplementedError('This method must be implemented by subclasses')

    def _entries(self, uid):
        for page in itertools.count(1):
            vod_json = self._get_vod_json(page, uid)
            vods = vod_json.get('vodInfos', [])
            for vod in vods:
                yield self.url_result(
                    'https://trovo.live/%s/%s' % (self._TYPE, vod.get('vid')),
                    ie=TrovoVodIE.ie_key())
            has_more = vod_json['hasMore']
            if not has_more:
                break

    def _real_extract(self, url):
        id = self._match_id(url)
        live_info = self._call_api(id, data={
            'operationName': 'live_LiveReaderService_GetLiveInfo',
            'variables': {
                'params': {
                    'userName': id,
                },
            },
        })
        uid = str(live_info['streamerInfo']['uid'])
        return self.playlist_result(self._entries(uid), playlist_id=uid)

class TrovoChannelVodIE(TrovoChannelBaseIE):
    _VALID_URL = r'trovovod:(?P<id>[^\s]+)'
    IE_DESC = 'All VODs of a trovo.live channel; "trovovod:" prefix'

    _TESTS = [{
        'url': 'trovovod:OneTappedYou',
        'playlist_mincount': 24,
        'info_dict': {
            'id': '100719456',
        },
    }]

    _TYPE = 'video'

    def _get_vod_json(self, page, uid):
        return self._call_api(uid, data={
            'operationName': 'getChannelLtvVideoInfos',
            'variables': {
                'params': {
                    'channelID': int(uid),
                    'pageSize': 99,
                    'currPage': page,
                },
            },
            'extensions': {
                'persistedQuery': {
                    'version': 1,
                    'sha256Hash': '78fe32792005eab7e922cafcdad9c56bed8bbc5f5df3c7cd24fcb84a744f5f78',
                },
            },
        })

class TrovoChannelClipIE(TrovoChannelBaseIE):
    _VALID_URL = r'trovoclip:(?P<id>[^\s]+)'
    IE_DESC = 'All Clips of a trovo.live channel; "trovoclip:" prefix'

    _TESTS = [{
        'url': 'trovoclip:OneTappedYou',
        'playlist_mincount': 29,
        'info_dict': {
            'id': '100719456',
        },
    }]

    _TYPE = 'clip'

    def _get_vod_json(self, page, uid):
        return self._call_api(uid, data={
            'operationName': 'getChannelClipVideoInfos',
            'variables': {
                'params': {
                    'channelID': int(uid),
                    'pageSize': 99,
                    'currPage': page,
                },
            },
            'extensions': {
                'persistedQuery': {
                    'version': 1,
                    'sha256Hash': 'e7924bfe20059b5c75fc8ff9e7929f43635681a7bdf3befa01072ed22c8eff31',
                },
            },
        })


code:
from .common import InfoExtractor
from ..compat import compat_HTTPError
from ..utils import (
    determine_ext,
    ExtractorError,
    int_or_none,
    parse_age_limit,
    traverse_obj,
    unified_timestamp,
    url_or_none
)

class TrueIDIE(InfoExtractor):
    _VALID_URL = r'https?://(?P<domain>vn\.trueid\.net|trueid\.(?:id|ph))/(?:movie|series/[^/]+)/(?P<id>[^/?#&]+)'
    _TESTS = [{
        'url': 'https://trueid.id/movie/XYNlDOZZJzL6/pengabdi-setan/',
        'md5': '2552c7535125885901f1a2a4bcf32ca3',
        'info_dict': {
            'id': 'XYNlDOZZJzL6',
            'ext': 'mp4',
            'title': 'Pengabdi Setan',
            'display_id': 'pengabdi-setan',
            'description': 'md5:b0b41df08601e85e5291496c9bbe52cd',
            'timestamp': 1600243511,
            'categories': ['Film Indonesia', 'Horror', 'Mystery'],
            'release_timestamp': 1593536400,
            'release_year': 1982,
            'cast': list,
            'thumbnail': 'https://cms.dmpcdn.com/movie/2020/09/18/8b6e35c0-f97f-11ea-81fe-c52fc9dd314f_original.png',
            'upload_date': '20200916',
            'release_date': '20200630',
        },
        'expected_warnings': ['Video is geo restricted.']
    }, {
        'url': 'https://trueid.id/series/zZOBVPb62EwR/qXY73rwyl7oj/one-piece-ep-1/',
        'md5': '1c6d976049bc3c89a8a25aed2c3fb081',
        'info_dict': {
            'id': 'qXY73rwyl7oj',
            'ext': 'mp4',
            'title': 'One Piece Ep. 1',
            'display_id': 'one-piece-ep-1',
            'description': 'md5:13226d603bd03c4150a1cf5758e842ea',
            'timestamp': 1610421085,
            'categories': ['Animation & Cartoon', 'Kids & Family', 'Adventure'],
            'release_timestamp': 1612112400,
            'release_year': 1999,
            'age_limit': 7,
            'cast': ['Kounosuke Uda', 'Junji Shimizu'],
            'thumbnail': 'https://cms.dmpcdn.com/movie/2021/01/13/f84e9e70-5562-11eb-9fe2-dd6c2099a468_original.png',
            'upload_date': '20210112',
            'release_date': '20210131',
        },
        'expected_warnings': ['Video is geo restricted.']
    }, {
        'url': 'https://vn.trueid.net/series/7DNPM7Bpa9wv/pwLgEQ4Xbda2/haikyu-vua-bong-chuyen-phan-1/',
        'info_dict': {
            'id': 'pwLgEQ4Xbda2',
            'ext': 'mp4',
            'title': 'Haikyu!!: Vua Bóng Chuyền Phần 1 - Tập 1',
            'display_id': 'haikyu-vua-bong-chuyen-phan-1-tap-1',
            'description': 'md5:0374dd44d247799169449ee30cca963a',
            'timestamp': 1629270901,
            'categories': ['Anime', 'Phim Hài', 'Phim Học Đường', 'Phim Thể Thao', 'Shounen'],
            'release_timestamp': 1629270720,
            'release_year': 2014,
            'age_limit': 13,
            'thumbnail': 'https://cms.dmpcdn.com/movie/2021/09/28/b6e7ec00-2039-11ec-8436-974544e5841f_webp_original.jpg',
            'upload_date': '20210818',
            'release_date': '20210818',
        },
        'expected_warnings': ['Video is geo restricted.']
    }, {
        'url': 'https://trueid.ph/series/l8rvvAw7Jwv8/l8rvvAw7Jwv8/naruto-trailer/',
        'only_matching': True,
    }]
    _CUSTOM_RATINGS = {
        'PG': 7,
    }

    def _real_extract(self, url):
        domain, video_id = self._match_valid_url(url).group('domain', 'id')
        webpage = self._download_webpage(url, video_id)
        initial_data = traverse_obj(
            self._search_nextjs_data(webpage, video_id, fatal=False), ('props', 'pageProps', 'initialContentData'), default={})

        try:
            stream_data = self._download_json(
                f'https://{domain}/cmsPostProxy/contents/video/{video_id}/streamer?os=android', video_id, data=b'')['data']
        except ExtractorError as e:
            if not isinstance(e.cause, compat_HTTPError):
                raise e
            errmsg = self._parse_json(e.cause.read().decode(), video_id)['meta']['message']
            if 'country' in errmsg:
                self.raise_geo_restricted(
                    errmsg, [initial_data['display_country']] if initial_data.get('display_country') else None, True)
            else:
                self.raise_no_formats(errmsg, video_id=video_id)

        if stream_data:
            stream_url = stream_data['stream']['stream_url']
            stream_ext = determine_ext(stream_url)
            if stream_ext == 'm3u8':
                formats, subs = self._extract_m3u8_formats_and_subtitles(stream_url, video_id, 'mp4')
            elif stream_ext == 'mpd':
                formats, subs = self._extract_mpd_formats_and_subtitles(stream_url, video_id)
            else:
                formats = [{'url': stream_url}]

        thumbnails = [
            {'id': thumb_key, 'url': thumb_url}
            for thumb_key, thumb_url in (initial_data.get('thumb_list') or {}).items()
            if url_or_none(thumb_url)]

        return {
            'id': video_id,
            'title': initial_data.get('title') or self._html_search_regex(
                [r'Nonton (?P<name>.+) Gratis',
                 r'Xem (?P<name>.+) Miễn phí',
                 r'Watch (?P<name>.+) Free'], webpage, 'title', group='name'),
            'display_id': initial_data.get('slug_title'),
            'description': initial_data.get('synopsis'),
            'timestamp': unified_timestamp(initial_data.get('create_date')),
            # 'duration': int_or_none(initial_data.get('duration'), invscale=60),  # duration field must atleast be accurate to the second
            'categories': traverse_obj(initial_data, ('article_category_details', ..., 'name')),
            'release_timestamp': unified_timestamp(initial_data.get('publish_date')),
            'release_year': int_or_none(initial_data.get('release_year')),
            'formats': formats,
            'subtitles': subs,
            'thumbnails': thumbnails,
            'age_limit': self._CUSTOM_RATINGS.get(initial_data.get('rate')) or parse_age_limit(initial_data.get('rate')),
            'cast': traverse_obj(initial_data, (('actor', 'director'), ...)),
            'view_count': int_or_none(initial_data.get('count_views')),
            'like_count': int_or_none(initial_data.get('count_likes')),
            'average_rating': int_or_none(initial_data.get('count_ratings')),
        }


code:
from .common import InfoExtractor

class TruNewsIE(InfoExtractor):
    _VALID_URL = r'https?://(?:www\.)?trunews\.com/stream/(?P<id>[^/?#&]+)'
    _TEST = {
        'url': 'https://www.trunews.com/stream/will-democrats-stage-a-circus-during-president-trump-s-state-of-the-union-speech',
        'info_dict': {
            'id': '5c5a21e65d3c196e1c0020cc',
            'display_id': 'will-democrats-stage-a-circus-during-president-trump-s-state-of-the-union-speech',
            'ext': 'mp4',
            'title': "Will Democrats Stage a Circus During President Trump's State of the Union Speech?",
            'description': 'md5:c583b72147cc92cf21f56a31aff7a670',
            'duration': 3685,
            'timestamp': 1549411440,
            'upload_date': '20190206',
        },
        'add_ie': ['Zype'],
    }
    _ZYPE_TEMPL = 'https://player.zype.com/embed/%s.js?api_key=X5XnahkjCwJrT_l5zUqypnaLEObotyvtUKJWWlONxDoHVjP8vqxlArLV8llxMbyt'

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

        zype_id = self._download_json(
            'https://api.zype.com/videos', display_id, query={
                'app_key': 'PUVKp9WgGUb3-JUw6EqafLx8tFVP6VKZTWbUOR-HOm__g4fNDt1bCsm_LgYf_k9H',
                'per_page': 1,
                'active': 'true',
                'friendly_title': display_id,
            })['response'][0]['_id']
        return self.url_result(self._ZYPE_TEMPL % zype_id, 'Zype', zype_id)


code:
from .turner import TurnerBaseIE
from ..utils import (
    int_or_none,
    parse_iso8601,
)

class TruTVIE(TurnerBaseIE):
    _VALID_URL = r'https?://(?:www\.)?trutv\.com/(?:shows|full-episodes)/(?P<series_slug>[0-9A-Za-z-]+)/(?:videos/(?P<clip_slug>[0-9A-Za-z-]+)|(?P<id>\d+))'
    _TEST = {
        'url': 'https://www.trutv.com/shows/the-carbonaro-effect/videos/sunlight-activated-flower.html',
        'info_dict': {
            'id': 'f16c03beec1e84cd7d1a51f11d8fcc29124cc7f1',
            'ext': 'mp4',
            'title': 'Sunlight-Activated Flower',
            'description': "A customer is stunned when he sees Michael's sunlight-activated flower.",
        },
        'params': {
            # m3u8 download
            'skip_download': True,
        },
    }

    def _real_extract(self, url):
        series_slug, clip_slug, video_id = self._match_valid_url(url).groups()

        if video_id:
            path = 'episode'
            display_id = video_id
        else:
            path = 'series/clip'
            display_id = clip_slug

        data = self._download_json(
            'https://api.trutv.com/v2/web/%s/%s/%s' % (path, series_slug, display_id),
            display_id)
        video_data = data['episode'] if video_id else data['info']
        media_id = video_data['mediaId']
        title = video_data['title'].strip()

        info = self._extract_ngtv_info(
            media_id, {}, {
                'url': url,
                'site_name': 'truTV',
                'auth_required': video_data.get('isAuthRequired'),
            })

        thumbnails = []
        for image in video_data.get('images', []):
            image_url = image.get('srcUrl')
            if not image_url:
                continue
            thumbnails.append({
                'url': image_url,
                'width': int_or_none(image.get('width')),
                'height': int_or_none(image.get('height')),
            })

        info.update({
            'id': media_id,
            'display_id': display_id,
            'title': title,
            'description': video_data.get('description'),
            'thumbnails': thumbnails,
            'timestamp': parse_iso8601(video_data.get('publicationDate')),
            'series': video_data.get('showTitle'),
            'season_number': int_or_none(video_data.get('seasonNum')),
            'episode_number': int_or_none(video_data.get('episodeNum')),
        })
        return info


code:
import re

from ..utils import (
    int_or_none,
    str_to_int,
)
from .keezmovies import KeezMoviesIE

class Tube8IE(KeezMoviesIE):
    _VALID_URL = r'https?://(?:www\.)?tube8\.com/(?:[^/]+/)+(?P<display_id>[^/]+)/(?P<id>\d+)'
    _EMBED_REGEX = [r']+\bsrc=["\'](?P<url>(?:https?:)?//(?:www\.)?tube8\.com/embed/(?:[^/]+/)+\d+)']
    _TESTS = [{
        'url': 'http://www.tube8.com/teen/kasia-music-video/229795/',
        'md5': '65e20c48e6abff62ed0c3965fff13a39',
        'info_dict': {
            'id': '229795',
            'display_id': 'kasia-music-video',
            'ext': 'mp4',
            'description': 'hot teen Kasia grinding',
            'uploader': 'unknown',
            'title': 'Kasia music video',
            'age_limit': 18,
            'duration': 230,
            'categories': ['Teen'],
            'tags': ['dancing'],
        },
    }, {
        'url': 'http://www.tube8.com/shemale/teen/blonde-cd-gets-kidnapped-by-two-blacks-and-punished-for-being-a-slutty-girl/19569151/',
        'only_matching': True,
    }]

    def _real_extract(self, url):
        webpage, info = self._extract_info(url)

        if not info['title']:
            info['title'] = self._html_search_regex(
                r'videoTitle\s*=\s*"([^"]+)', webpage, 'title')

        description = self._html_search_regex(
            r'(?s)Description:
\s*
(.+?)
', webpage, 'description', fatal=False)
        uploader = self._html_search_regex(
            r'\s*(.+?)\s*<',
            webpage, 'uploader', fatal=False)

        like_count = int_or_none(self._search_regex(
            r'rupVar\s*=\s*"(\d+)"', webpage, 'like count', fatal=False))
        dislike_count = int_or_none(self._search_regex(
            r'rdownVar\s*=\s*"(\d+)"', webpage, 'dislike count', fatal=False))
        view_count = str_to_int(self._search_regex(
            r'Views:\s*
\s*
([\d,\.]+)',
            webpage, 'view count', fatal=False))
        comment_count = str_to_int(self._search_regex(
            r'(\d+)',
            webpage, 'comment count', fatal=False))

        category = self._search_regex(
            r'Category:\s*
\s*
\s*]+href=[^>]+>([^<]+)',
            webpage, 'category', fatal=False)
        categories = [category] if category else None

        tags_str = self._search_regex(
            r'(?s)Tags:\s*
\s*
(.+?)</(?!a)',
            webpage, 'tags', fatal=False)
        tags = [t for t in re.findall(
            r']+href=[^>]+>([^<]+)', tags_str)] if tags_str else None

        info.update({
            'description': description,
            'uploader': uploader,
            'view_count': view_count,
            'like_count': like_count,
            'dislike_count': dislike_count,
            'comment_count': comment_count,
            'categories': categories,
            'tags': tags,
        })

        return info


code:
from .common import InfoExtractor
from ..utils import (
    float_or_none,
    parse_resolution,
    traverse_obj,
    urlencode_postdata,
    variadic,
)

class TubeTuGrazBaseIE(InfoExtractor):
    _NETRC_MACHINE = 'tubetugraz'

    _API_EPISODE = 'https://tube.tugraz.at/search/episode.json'
    _FORMAT_TYPES = ('presentation', 'presenter')

    def _perform_login(self, username, password):
        urlh = self._request_webpage(
            'https://tube.tugraz.at/Shibboleth.sso/Login?target=/paella/ui/index.html',
            None, fatal=False, note='downloading login page', errnote='unable to fetch login page')
        if not urlh:
            return

        urlh = self._request_webpage(
            urlh.geturl(), None, fatal=False, headers={'referer': urlh.geturl()},
            note='logging in', errnote='unable to log in', data=urlencode_postdata({
                'lang': 'de',
                '_eventId_proceed': '',
                'j_username': username,
                'j_password': password
            }))

        if urlh and urlh.geturl() != 'https://tube.tugraz.at/paella/ui/index.html':
            self.report_warning('unable to login: incorrect password')

    def _extract_episode(self, episode_info):
        id = episode_info.get('id')
        formats = list(self._extract_formats(
            traverse_obj(episode_info, ('mediapackage', 'media', 'track')), id))
        self._sort_formats(formats)

        title = traverse_obj(episode_info, ('mediapackage', 'title'), 'dcTitle')
        series_title = traverse_obj(episode_info, ('mediapackage', 'seriestitle'))
        creator = ', '.join(variadic(traverse_obj(
            episode_info, ('mediapackage', 'creators', 'creator'), 'dcCreator', default='')))
        return {
            'id': id,
            'title': title,
            'creator': creator or None,
            'duration': traverse_obj(episode_info, ('mediapackage', 'duration'), 'dcExtent'),
            'series': series_title,
            'series_id': traverse_obj(episode_info, ('mediapackage', 'series'), 'dcIsPartOf'),
            'episode': series_title and title,
            'formats': formats
        }

    def _set_format_type(self, formats, type):
        for f in formats:
            f['format_note'] = type
            if not type.startswith(self._FORMAT_TYPES[0]):
                f['preference'] = -2
        return formats

    def _extract_formats(self, format_list, id):
        has_hls, has_dash = False, False

        for format_info in format_list or []:
            url = traverse_obj(format_info, ('tags', 'url'), 'url')
            if url is None:
                continue

            type = format_info.get('type') or 'unknown'
            transport = (format_info.get('transport') or 'https').lower()

            if transport == 'https':
                formats = [{
                    'url': url,
                    'abr': float_or_none(traverse_obj(format_info, ('audio', 'bitrate')), 1000),
                    'vbr': float_or_none(traverse_obj(format_info, ('video', 'bitrate')), 1000),
                    'fps': traverse_obj(format_info, ('video', 'framerate')),
                    **parse_resolution(traverse_obj(format_info, ('video', 'resolution'))),
                }]
            elif transport == 'hls':
                has_hls, formats = True, self._extract_m3u8_formats(
                    url, id, 'mp4', fatal=False, note=f'downloading {type} HLS manifest')
            elif transport == 'dash':
                has_dash, formats = True, self._extract_mpd_formats(
                    url, id, fatal=False, note=f'downloading {type} DASH manifest')
            else:
                # RTMP, HDS, SMOOTH, and unknown formats
                # - RTMP url fails on every tested entry until now
                # - HDS url 404's on every tested entry until now
                # - SMOOTH url 404's on every tested entry until now
                continue

            yield from self._set_format_type(formats, type)

        # TODO: Add test for these
        for type in self._FORMAT_TYPES:
            if not has_hls:
                hls_formats = self._extract_m3u8_formats(
                    f'https://wowza.tugraz.at/matterhorn_engage/smil:engage-player_{id}_{type}.smil/playlist.m3u8',
                    id, 'mp4', fatal=False, note=f'Downloading {type} HLS manifest', errnote=False) or []
                yield from self._set_format_type(hls_formats, type)

            if not has_dash:
                dash_formats = self._extract_mpd_formats(
                    f'https://wowza.tugraz.at/matterhorn_engage/smil:engage-player_{id}_{type}.smil/manifest_mpm4sav_mvlist.mpd',
                    id, fatal=False, note=f'Downloading {type} DASH manifest', errnote=False)
                yield from self._set_format_type(dash_formats, type)

class TubeTuGrazIE(TubeTuGrazBaseIE):
    IE_DESC = 'tube.tugraz.at'

    _VALID_URL = r'''(?x)
        https?://tube\.tugraz\.at/paella/ui/watch.html\?id=
        (?P<id>[0-9a-fA-F]{8}-(?:[0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12})
    '''
    _TESTS = [
        {
            'url': 'https://tube.tugraz.at/paella/ui/watch.html?id=f2634392-e40e-4ac7-9ddc-47764aa23d40',
            'md5': 'a23a3d5c9aaca2b84932fdba66e17145',
            'info_dict': {
                'id': 'f2634392-e40e-4ac7-9ddc-47764aa23d40',
                'ext': 'mp4',
                'title': '#6 (23.11.2017)',
                'episode': '#6 (23.11.2017)',
                'series': '[INB03001UF] Einführung in die strukturierte Programmierung',
                'creator': 'Safran C',
                'duration': 3295818,
                'series_id': 'b1192fff-2aa7-4bf0-a5cf-7b15c3bd3b34',
            }
        }, {
            'url': 'https://tube.tugraz.at/paella/ui/watch.html?id=2df6d787-e56a-428d-8ef4-d57f07eef238',
            'md5': 'de0d854a56bf7318d2b693fe1adb89a5',
            'info_dict': {
                'id': '2df6d787-e56a-428d-8ef4-d57f07eef238',
                'title': 'TubeTuGraz video #2df6d787-e56a-428d-8ef4-d57f07eef238',
                'ext': 'mp4',
            },
            'expected_warnings': ['Extractor failed to obtain "title"'],
        }
    ]

    def _real_extract(self, url):
        video_id = self._match_id(url)
        episode_data = self._download_json(
            self._API_EPISODE, video_id, query={'id': video_id, 'limit': 1}, note='Downloading episode metadata')

        episode_info = traverse_obj(episode_data, ('search-results', 'result'), default={'id': video_id})
        return self._extract_episode(episode_info)

class TubeTuGrazSeriesIE(TubeTuGrazBaseIE):
    _VALID_URL = r'''(?x)
        https?://tube\.tugraz\.at/paella/ui/browse\.html\?series=
        (?P<id>[0-9a-fA-F]{8}-(?:[0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12})
    '''
    _TESTS = [{
        'url': 'https://tube.tugraz.at/paella/ui/browse.html?series=0e6351b7-c372-491e-8a49-2c9b7e21c5a6',
        'id': '0e6351b7-c372-491e-8a49-2c9b7e21c5a6',
        'info_dict': {
            'id': '0e6351b7-c372-491e-8a49-2c9b7e21c5a6',
            'title': '[209351] Strassenwesen',
        },
        'playlist': [
            {
                'info_dict': {
                    'id': 'ee17ce5d-34e2-48b7-a76a-fed148614e11',
                    'series_id': '0e6351b7-c372-491e-8a49-2c9b7e21c5a6',
                    'ext': 'mp4',
                    'title': '#4 Detailprojekt',
                    'episode': '#4 Detailprojekt',
                    'series': '[209351] Strassenwesen',
                    'creator': 'Neuhold R',
                    'duration': 6127024,
                }
            },
            {
                'info_dict': {
                    'id': '87350498-799a-44d3-863f-d1518a98b114',
                    'series_id': '0e6351b7-c372-491e-8a49-2c9b7e21c5a6',
                    'ext': 'mp4',
                    'title': '#3 Generelles Projekt',
                    'episode': '#3 Generelles Projekt',
                    'series': '[209351] Strassenwesen',
                    'creator': 'Neuhold R',
                    'duration': 5374422,
                }
            },
            {
                'info_dict': {
                    'id': '778599ea-489e-4189-9e05-3b4888e19bcd',
                    'series_id': '0e6351b7-c372-491e-8a49-2c9b7e21c5a6',
                    'ext': 'mp4',
                    'title': '#2 Vorprojekt',
                    'episode': '#2 Vorprojekt',
                    'series': '[209351] Strassenwesen',
                    'creator': 'Neuhold R',
                    'duration': 5566404,
                }
            },
            {
                'info_dict': {
                    'id': '75e4c71c-d99d-4e56-b0e6-4f2bcdf11f29',
                    'series_id': '0e6351b7-c372-491e-8a49-2c9b7e21c5a6',
                    'ext': 'mp4',
                    'title': '#1 Variantenstudium',
                    'episode': '#1 Variantenstudium',
                    'series': '[209351] Strassenwesen',
                    'creator': 'Neuhold R',
                    'duration': 5420200,
                }
            }
        ],
        'min_playlist_count': 4
    }]

    def _real_extract(self, url):
        id = self._match_id(url)
        episodes_data = self._download_json(self._API_EPISODE, id, query={'sid': id}, note='Downloading episode list')
        series_data = self._download_json(
            'https://tube.tugraz.at/series/series.json', id, fatal=False,
            note='downloading series metadata', errnote='failed to download series metadata',
            query={
                'seriesId': id,
                'count': 1,
                'sort': 'TITLE'
            })

        return self.playlist_result(
            map(self._extract_episode, episodes_data['search-results']['result']), id,
            traverse_obj(series_data, ('catalogs', 0, 'http://purl.org/dc/terms/', 'title', 0, 'value')))


code:
import re

from .common import InfoExtractor
from ..utils import (
    ExtractorError,
    int_or_none,
    js_to_json,
    sanitized_Request,
    urlencode_postdata,
    traverse_obj,
)

class TubiTvIE(InfoExtractor):
    _VALID_URL = r'''(?x)
                    (?:
                        tubitv:|
                        https?://(?:www\.)?tubitv\.com/(?:video|movies|tv-shows)/
                    )
                    (?P<id>[0-9]+)'''
    _LOGIN_URL = 'http://tubitv.com/login'
    _NETRC_MACHINE = 'tubitv'
    _GEO_COUNTRIES = ['US']
    _TESTS = [{
        'url': 'http://tubitv.com/video/283829/the_comedian_at_the_friday',
        'md5': '43ac06be9326f41912dc64ccf7a80320',
        'info_dict': {
            'id': '283829',
            'ext': 'mp4',
            'title': 'The Comedian at The Friday',
            'description': 'A stand up comedian is forced to look at the decisions in his life while on a one week trip to the west coast.',
            'uploader_id': 'bc168bee0d18dd1cb3b86c68706ab434',
        },
    }, {
        'url': 'http://tubitv.com/tv-shows/321886/s01_e01_on_nom_stories',
        'only_matching': True,
    }, {
        'url': 'http://tubitv.com/movies/383676/tracker',
        'only_matching': True,
    }, {
        'url': 'https://tubitv.com/movies/560057/penitentiary?start=true',
        'info_dict': {
            'id': '560057',
            'ext': 'mp4',
            'title': 'Penitentiary',
            'description': 'md5:8d2fc793a93cc1575ff426fdcb8dd3f9',
            'uploader_id': 'd8fed30d4f24fcb22ec294421b9defc2',
            'release_year': 1979,
        },
        'params': {
            'skip_download': True,
        },
    }]

    def _perform_login(self, username, password):
        self.report_login()
        form_data = {
            'username': username,
            'password': password,
        }
        payload = urlencode_postdata(form_data)
        request = sanitized_Request(self._LOGIN_URL, payload)
        request.add_header('Content-Type', 'application/x-www-form-urlencoded')
        login_page = self._download_webpage(
            request, None, False, 'Wrong login info')
        if not re.search(r'id="tubi-logout"', login_page):
            raise ExtractorError(
                'Login failed (invalid username/password)', expected=True)

    def _real_extract(self, url):
        video_id = self._match_id(url)
        video_data = self._download_json(
            'https://tubitv.com/oz/videos/%s/content?video_resources=dash&video_resources=hlsv3&video_resources=hlsv6' % video_id, video_id)
        title = video_data['title']

        formats = []

        for resource in video_data['video_resources']:
            if resource['type'] in ('dash', ):
                formats += self._extract_mpd_formats(resource['manifest']['url'], video_id, mpd_id=resource['type'], fatal=False)
            elif resource['type'] in ('hlsv3', 'hlsv6'):
                formats += self._extract_m3u8_formats(resource['manifest']['url'], video_id, 'mp4', m3u8_id=resource['type'], fatal=False)

        self._sort_formats(formats)

        thumbnails = []
        for thumbnail_url in video_data.get('thumbnails', []):
            if not thumbnail_url:
                continue
            thumbnails.append({
                'url': self._proto_relative_url(thumbnail_url),
            })

        subtitles = {}
        for sub in video_data.get('subtitles', []):
            sub_url = sub.get('url')
            if not sub_url:
                continue
            subtitles.setdefault(sub.get('lang', 'English'), []).append({
                'url': self._proto_relative_url(sub_url),
            })

        season_number, episode_number, episode_title = self._search_regex(
            r'^S(\d+):E(\d+) - (.+)', title, 'episode info', fatal=False, group=(1, 2, 3), default=(None, None, None))

        return {
            'id': video_id,
            'title': title,
            'formats': formats,
            'subtitles': subtitles,
            'thumbnails': thumbnails,
            'description': video_data.get('description'),
            'duration': int_or_none(video_data.get('duration')),
            'uploader_id': video_data.get('publisher_id'),
            'release_year': int_or_none(video_data.get('year')),
            'season_number': int_or_none(season_number),
            'episode_number': int_or_none(episode_number),
            'episode_title': episode_title
        }

class TubiTvShowIE(InfoExtractor):
    _VALID_URL = r'https?://(?:www\.)?tubitv\.com/series/[0-9]+/(?P<show_name>[^/?#]+)'
    _TESTS = [{
        'url': 'https://tubitv.com/series/3936/the-joy-of-painting-with-bob-ross?start=true',
        'playlist_mincount': 390,
        'info_dict': {
            'id': 'the-joy-of-painting-with-bob-ross',
        }
    }]

    def _entries(self, show_url, show_name):
        show_webpage = self._download_webpage(show_url, show_name)

        show_json = self._parse_json(self._search_regex(
            r'window\.__data\s*=\s*({[^<]+});\s*</script>',
            show_webpage, 'data'), show_name, transform_source=js_to_json)['video']

        for episode_id in show_json['fullContentById'].keys():
            if traverse_obj(show_json, ('byId', episode_id, 'type')) == 's':
                continue
            yield self.url_result(
                'tubitv:%s' % episode_id,
                ie=TubiTvIE.ie_key(), video_id=episode_id)

    def _real_extract(self, url):
        show_name = self._match_valid_url(url).group('show_name')
        return self.playlist_result(self._entries(url, show_name), playlist_id=show_name)


code:
from .common import InfoExtractor
from ..utils import (
    ExtractorError,
    int_or_none,
    traverse_obj,
    urlencode_postdata
)

class TumblrIE(InfoExtractor):
    _VALID_URL = r'https?://(?P<blog_name>[^/?#&]+)\.tumblr\.com/(?:post|video)/(?P<id>[0-9]+)(?:$|[/?#])'
    _NETRC_MACHINE = 'tumblr'
    _LOGIN_URL = 'https://www.tumblr.com/login'
    _OAUTH_URL = 'https://www.tumblr.com/api/v2/oauth2/token'
    _TESTS = [{
        'url': 'http://tatianamaslanydaily.tumblr.com/post/54196191430/orphan-black-dvd-extra-behind-the-scenes',
        'md5': '479bb068e5b16462f5176a6828829767',
        'info_dict': {
            'id': '54196191430',
            'ext': 'mp4',
            'title': 'md5:dfac39636969fe6bf1caa2d50405f069',
            'description': 'md5:390ab77358960235b6937ab3b8528956',
            'uploader_id': 'tatianamaslanydaily',
            'uploader_url': 'https://tatianamaslanydaily.tumblr.com/',
            'thumbnail': r're:^https?://.*\.jpg',
            'duration': 127,
            'like_count': int,
            'repost_count': int,
            'age_limit': 0,
            'tags': ['Orphan Black', 'Tatiana Maslany', 'Interview', 'Video', 'OB S1 DVD Extras'],
        }
    }, {
        'note': 'multiple formats',
        'url': 'https://maskofthedragon.tumblr.com/post/626907179849564160/mona-talking-in-english',
        'md5': 'f43ff8a8861712b6cf0e0c2bd84cfc68',
        'info_dict': {
            'id': '626907179849564160',
            'ext': 'mp4',
            'title': 'Mona\xa0“talking” in\xa0“english”',
            'description': 'md5:082a3a621530cb786ad2b7592a6d9e2c',
            'uploader_id': 'maskofthedragon',
            'uploader_url': 'https://maskofthedragon.tumblr.com/',
            'thumbnail': r're:^https?://.*\.jpg',
            'duration': 7,
            'like_count': int,
            'repost_count': int,
            'age_limit': 0,
            'tags': 'count:19',
        },
        'params': {
            'format': 'hd',
        },
    }, {
        'note': 'non-iframe video (with related posts)',
        'url': 'https://shieldfoss.tumblr.com/post/675519763813908480',
        'md5': '12bdb75661ef443bffe5a4dac1dbf118',
        'info_dict': {
            'id': '675519763813908480',
            'ext': 'mp4',
            'title': 'Shieldfoss',
            'uploader_id': 'nerviovago',
            'uploader_url': 'https://nerviovago.tumblr.com/',
            'thumbnail': r're:^https?://.*\.jpg',
            'like_count': int,
            'repost_count': int,
            'age_limit': 0,
            'tags': [],
        }
    }, {
        'note': 'dashboard only (original post)',
        'url': 'https://jujanon.tumblr.com/post/159704441298/my-baby-eating',
        'md5': '029f7c91ab386701b211e3d494d2d95e',
        'info_dict': {
            'id': '159704441298',
            'ext': 'mp4',
            'title': 'md5:ba79365861101f4911452728d2950561',
            'description': 'md5:773738196cea76b6996ec71e285bdabc',
            'uploader_id': 'jujanon',
            'uploader_url': 'https://jujanon.tumblr.com/',
            'thumbnail': r're:^https?://.*\.jpg',
            'like_count': int,
            'repost_count': int,
            'age_limit': 0,
            'tags': ['crabs', 'my video', 'my pets'],
        }
    }, {
        'note': 'dashboard only (reblog)',
        'url': 'https://bartlebyshop.tumblr.com/post/180294460076/duality-of-bird',
        'md5': '04334e7cadb1af680d162912559f51a5',
        'info_dict': {
            'id': '180294460076',
            'ext': 'mp4',
            'title': 'duality of bird',
            'description': 'duality of bird',
            'uploader_id': 'todaysbird',
            'uploader_url': 'https://todaysbird.tumblr.com/',
            'thumbnail': r're:^https?://.*\.jpg',
            'like_count': int,
            'repost_count': int,
            'age_limit': 0,
            'tags': [],
        }
    }, {
        'note': 'dashboard only (external)',
        'url': 'https://afloweroutofstone.tumblr.com/post/675661759168823296/the-blues-remembers-everything-the-country-forgot',
        'info_dict': {
            'id': 'q67_fd7b8SU',
            'ext': 'mp4',
            'title': 'The Blues Remembers Everything the Country Forgot',
            'alt_title': 'The Blues Remembers Everything the Country Forgot',
            'description': 'md5:1a6b4097e451216835a24c1023707c79',
            'release_date': '20201224',
            'creator': 'md5:c2239ba15430e87c3b971ba450773272',
            'uploader': 'Moor Mother - Topic',
            'upload_date': '20201223',
            'uploader_id': 'UCxrMtFBRkFvQJ_vVM4il08w',
            'uploader_url': 'http://www.youtube.com/channel/UCxrMtFBRkFvQJ_vVM4il08w',
            'thumbnail': r're:^https?://i.ytimg.com/.*',
            'channel': 'Moor Mother - Topic',
            'channel_id': 'UCxrMtFBRkFvQJ_vVM4il08w',
            'channel_url': 'https://www.youtube.com/channel/UCxrMtFBRkFvQJ_vVM4il08w',
            'channel_follower_count': int,
            'duration': 181,
            'view_count': int,
            'like_count': int,
            'age_limit': 0,
            'categories': ['Music'],
            'tags': 'count:7',
            'live_status': 'not_live',
            'playable_in_embed': True,
            'availability': 'public',
            'track': 'The Blues Remembers Everything the Country Forgot',
            'artist': 'md5:c2239ba15430e87c3b971ba450773272',
            'album': 'Brass',
            'release_year': 2020,
        },
        'add_ie': ['Youtube'],
    }, {
        'url': 'http://naked-yogi.tumblr.com/post/118312946248/naked-smoking-stretching',
        'md5': 'de07e5211d60d4f3a2c3df757ea9f6ab',
        'info_dict': {
            'id': 'Wmur',
            'ext': 'mp4',
            'title': 'naked smoking & stretching',
            'upload_date': '20150506',
            'timestamp': 1430931613,
            'age_limit': 18,
            'uploader_id': '1638622',
            'uploader': 'naked-yogi',
        },
        # 'add_ie': ['Vidme'],
        'skip': 'dead embedded video host'
    }, {
        'url': 'https://prozdvoices.tumblr.com/post/673201091169681408/what-recording-voice-acting-sounds-like',
        'md5': 'a0063fc8110e6c9afe44065b4ea68177',
        'info_dict': {
            'id': 'eomhW5MLGWA',
            'ext': 'mp4',
            'title': 'what recording voice acting sounds like',
            'description': 'md5:1da3faa22d0e0b1d8b50216c284ee798',
            'uploader': 'ProZD',
            'upload_date': '20220112',
            'uploader_id': 'ProZD',
            'uploader_url': 'http://www.youtube.com/user/ProZD',
            'thumbnail': r're:^https?://i.ytimg.com/.*',
            'channel': 'ProZD',
            'channel_id': 'UC6MFZAOHXlKK1FI7V0XQVeA',
            'channel_url': 'https://www.youtube.com/channel/UC6MFZAOHXlKK1FI7V0XQVeA',
            'channel_follower_count': int,
            'duration': 20,
            'view_count': int,
            'like_count': int,
            'age_limit': 0,
            'categories': ['Film & Animation'],
            'tags': [],
            'live_status': 'not_live',
            'playable_in_embed': True,
            'availability': 'public',
        },
        'add_ie': ['Youtube'],
    }, {
        'url': 'https://dominustempori.tumblr.com/post/673572712813297664/youtubes-all-right-for-some-pretty-cool',
        'md5': '203e9eb8077e3f45bfaeb4c86c1467b8',
        'info_dict': {
            'id': '87816359',
            'ext': 'mov',
            'title': 'Harold Ramis',
            'description': 'md5:be8e68cbf56ce0785c77f0c6c6dfaf2c',
            'uploader': 'Resolution Productions Group',
            'uploader_id': 'resolutionproductions',
            'uploader_url': 'https://vimeo.com/resolutionproductions',
            'upload_date': '20140227',
            'thumbnail': r're:^https?://i.vimeocdn.com/video/.*',
            'timestamp': 1393523719,
            'duration': 291,
        },
        'add_ie': ['Vimeo'],
    }, {
        'url': 'http://sutiblr.tumblr.com/post/139638707273',
        'md5': '2dd184b3669e049ba40563a7d423f95c',
        'info_dict': {
            'id': 'ir7qBEIKqvq',
            'ext': 'mp4',
            'title': 'Vine by sutiblr',
            'alt_title': 'Vine by sutiblr',
            'uploader': 'sutiblr',
            'uploader_id': '1198993975374495744',
            'upload_date': '20160220',
            'like_count': int,
            'comment_count': int,
            'repost_count': int,
            'thumbnail': r're:^https?://.*\.jpg',
            'timestamp': 1455940159,
            'view_count': int,
        },
        'add_ie': ['Vine'],
    }, {
        'url': 'https://silami.tumblr.com/post/84250043974/my-bad-river-flows-in-you-impression-on-maschine',
        'md5': '3c92d7c3d867f14ccbeefa2119022277',
        'info_dict': {
            'id': 'nYtvtTPuTl',
            'ext': 'mp4',
            'title': 'Video by silbulterman',
            'description': '#maschine',
            'uploader_id': '242859024',
            'thumbnail': r're:^https?://.*\.jpg',
            'timestamp': 1398801174,
            'like_count': int,
            'uploader': 'Sil',
            'channel': 'silbulterman',
            'comment_count': int,
            'upload_date': '20140429',
        },
        'add_ie': ['Instagram'],
    }]

    _providers = {
        'instagram': 'Instagram',
        'vimeo': 'Vimeo',
        'vine': 'Vine',
        'youtube': 'Youtube',
    }

    _ACCESS_TOKEN = None

    def _initialize_pre_login(self):
        login_page = self._download_webpage(
            self._LOGIN_URL, None, 'Downloading login page', fatal=False)
        if login_page:
            self._ACCESS_TOKEN = self._search_regex(
                r'"API_TOKEN":\s*"(\w+)"', login_page, 'API access token', fatal=False)
        if not self._ACCESS_TOKEN:
            self.report_warning('Failed to get access token; metadata will be missing and some videos may not work')

    def _perform_login(self, username, password):
        if not self._ACCESS_TOKEN:
            return

        self._download_json(
            self._OAUTH_URL, None, 'Logging in',
            data=urlencode_postdata({
                'password': password,
                'grant_type': 'password',
                'username': username,
            }), headers={
                'Content-Type': 'application/x-www-form-urlencoded',
                'Authorization': f'Bearer {self._ACCESS_TOKEN}',
            },
            errnote='Login failed', fatal=False)

    def _real_extract(self, url):
        blog, video_id = self._match_valid_url(url).groups()

        url = f'http://{blog}.tumblr.com/post/{video_id}/'
        webpage, urlh = self._download_webpage_handle(url, video_id)

        redirect_url = urlh.geturl()

        api_only = bool(self._search_regex(
            r'(tumblr.com|^)/(safe-mode|login_required|blog/view)',
            redirect_url, 'redirect', default=None))

        if api_only and not self._ACCESS_TOKEN:
            raise ExtractorError('Cannot get data for dashboard-only post without access token')

        post_json = {}
        if self._ACCESS_TOKEN:
            post_json = traverse_obj(
                self._download_json(
                    f'https://www.tumblr.com/api/v2/blog/{blog}/posts/{video_id}/permalink',
                    video_id, headers={'Authorization': f'Bearer {self._ACCESS_TOKEN}'}, fatal=False),
                ('response', 'timeline', 'elements', 0)) or {}
        content_json = traverse_obj(post_json, ('trail', 0, 'content'), ('content')) or []
        video_json = next(
            (item for item in content_json if item.get('type') == 'video'), {})
        media_json = video_json.get('media') or {}
        if api_only and not media_json.get('url') and not video_json.get('url'):
            raise ExtractorError('Failed to find video data for dashboard-only post')

        if not media_json.get('url') and video_json.get('url'):
            # external video host
            return self.url_result(
                video_json['url'],
                self._providers.get(video_json.get('provider'), 'Generic'))

        video_url = self._og_search_video_url(webpage, default=None)
        duration = None
        formats = []

        # iframes can supply duration and sometimes additional formats, so check for one
        iframe_url = self._search_regex(
            fr'src=\'(https?://www\.tumblr\.com/video/{blog}/{video_id}/[^\']+)\'',
            webpage, 'iframe url', default=None)
        if iframe_url:
            iframe = self._download_webpage(
                iframe_url, video_id, 'Downloading iframe page',
                headers={'Referer': redirect_url})

            options = self._parse_json(
                self._search_regex(
                    r'data-crt-options=(["\'])(?P<options>.+?)\1', iframe,
                    'hd video url', default='', group='options'),
                video_id, fatal=False)
            if options:
                duration = int_or_none(options.get('duration'))

                hd_url = options.get('hdUrl')
                if hd_url:
                    # there are multiple formats; extract them
                    # ignore other sources of width/height data as they may be wrong
                    sources = []
                    sd_url = self._search_regex(
                        r'<source[^>]+src=(["\'])(?P<url>.+?)\1', iframe,
                        'sd video url', default=None, group='url')
                    if sd_url:
                        sources.append((sd_url, 'sd'))
                    sources.append((hd_url, 'hd'))

                    formats = [{
                        'url': video_url,
                        'format_id': format_id,
                        'height': int_or_none(self._search_regex(
                            r'_(\d+)\.\w+$', video_url, 'height', default=None)),
                        'quality': quality,
                    } for quality, (video_url, format_id) in enumerate(sources)]

        if not media_json.get('url') and not video_url and not iframe_url:
            # external video host (but we weren't able to figure it out from the api)
            iframe_url = self._search_regex(
                r'src=["\'](https?://safe\.txmblr\.com/svc/embed/inline/[^"\']+)["\']',
                webpage, 'embed iframe url', default=None)
            return self.url_result(iframe_url or redirect_url, 'Generic')

        formats = formats or [{
            'url': media_json.get('url') or video_url,
            'width': int_or_none(
                media_json.get('width') or self._og_search_property('video:width', webpage, default=None)),
            'height': int_or_none(
                media_json.get('height') or self._og_search_property('video:height', webpage, default=None)),
        }]
        self._sort_formats(formats)

        # the url we're extracting from might be an original post or it might be a reblog.
        # if it's a reblog, og:description will be the reblogger's comment, not the uploader's.
        # content_json is always the op, so if it exists but has no text, there's no description
        if content_json:
            description = '\n\n'.join((
                item.get('text') for item in content_json if item.get('type') == 'text')) or None
        else:
            description = self._og_search_description(webpage, default=None)
        uploader_id = traverse_obj(post_json, 'reblogged_root_name', 'blog_name')

        return {
            'id': video_id,
            'title': post_json.get('summary') or (blog if api_only else self._html_search_regex(
                r'(?s)<title>(?P<title>.*?)(?: \| Tumblr)?</title>', webpage, 'title')),
            'description': description,
            'thumbnail': (traverse_obj(video_json, ('poster', 0, 'url'))
                          or self._og_search_thumbnail(webpage, default=None)),
            'uploader_id': uploader_id,
            'uploader_url': f'https://{uploader_id}.tumblr.com/' if uploader_id else None,
            'duration': duration,
            'like_count': post_json.get('like_count'),
            'repost_count': post_json.get('reblog_count'),
            'age_limit': {True: 18, False: 0}.get(post_json.get('is_nsfw')),
            'tags': post_json.get('tags'),
            'formats': formats,
        }


code:
import re

from .common import InfoExtractor
from ..utils import ExtractorError
from ..compat import compat_urlparse

class TuneInBaseIE(InfoExtractor):
    _API_BASE_URL = 'http://tunein.com/tuner/tune/'

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

        content_info = self._download_json(
            self._API_BASE_URL + self._API_URL_QUERY % content_id,
            content_id, note='Downloading JSON metadata')

        title = content_info['Title']
        thumbnail = content_info.get('Logo')
        location = content_info.get('Location')
        streams_url = content_info.get('StreamUrl')
        if not streams_url:
            raise ExtractorError('No downloadable streams found', expected=True)
        if not streams_url.startswith('http://'):
            streams_url = compat_urlparse.urljoin(url, streams_url)

        streams = self._download_json(
            streams_url, content_id, note='Downloading stream data',
            transform_source=lambda s: re.sub(r'^\s*\((.*)\);\s*$', r'\1', s))['Streams']

        is_live = None
        formats = []
        for stream in streams:
            if stream.get('Type') == 'Live':
                is_live = True
            reliability = stream.get('Reliability')
            format_note = (
                'Reliability: %d%%' % reliability
                if reliability is not None else None)
            formats.append({
                'preference': (
                    0 if reliability is None or reliability > 90
                    else 1),
                'abr': stream.get('Bandwidth'),
                'ext': stream.get('MediaType').lower(),
                'acodec': stream.get('MediaType'),
                'vcodec': 'none',
                'url': stream.get('Url'),
                'source_preference': reliability,
                'format_note': format_note,
            })
        self._sort_formats(formats)

        return {
            'id': content_id,
            'title': title,
            'formats': formats,
            'thumbnail': thumbnail,
            'location': location,
            'is_live': is_live,
        }

class TuneInClipIE(TuneInBaseIE):
    IE_NAME = 'tunein:clip'
    _VALID_URL = r'https?://(?:www\.)?tunein\.com/station/.*?audioClipId\=(?P<id>\d+)'
    _API_URL_QUERY = '?tuneType=AudioClip&audioclipId=%s'

    _TESTS = [{
        'url': 'http://tunein.com/station/?stationId=246119&audioClipId=816',
        'md5': '99f00d772db70efc804385c6b47f4e77',
        'info_dict': {
            'id': '816',
            'title': '32m',
            'ext': 'mp3',
        },
    }]

class TuneInStationIE(TuneInBaseIE):
    IE_NAME = 'tunein:station'
    _VALID_URL = r'https?://(?:www\.)?tunein\.com/(?:radio/.*?-s|station/.*?StationId=|embed/player/s)(?P<id>\d+)'
    _EMBED_REGEX = [r'<iframe[^>]+src=["\'](?P<url>(?:https?://)?tunein\.com/embed/player/[pst]\d+)']
    _API_URL_QUERY = '?tuneType=Station&stationId=%s'

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

    _TESTS = [{
        'url': 'http://tunein.com/radio/Jazz24-885-s34682/',
        'info_dict': {
            'id': '34682',
            'title': 'Jazz 24 on 88.5 Jazz24 - KPLU-HD2',
            'ext': 'mp3',
            'location': 'Tacoma, WA',
        },
        'params': {
            'skip_download': True,  # live stream
        },
    }, {
        'url': 'http://tunein.com/embed/player/s6404/',
        'only_matching': True,
    }]

class TuneInProgramIE(TuneInBaseIE):
    IE_NAME = 'tunein:program'
    _VALID_URL = r'https?://(?:www\.)?tunein\.com/(?:radio/.*?-p|program/.*?ProgramId=|embed/player/p)(?P<id>\d+)'
    _API_URL_QUERY = '?tuneType=Program&programId=%s'

    _TESTS = [{
        'url': 'http://tunein.com/radio/Jazz-24-p2506/',
        'info_dict': {
            'id': '2506',
            'title': 'Jazz 24 on 91.3 WUKY-HD3',
            'ext': 'mp3',
            'location': 'Lexington, KY',
        },
        'params': {
            'skip_download': True,  # live stream
        },
    }, {
        'url': 'http://tunein.com/embed/player/p191660/',
        'only_matching': True,
    }]

class TuneInTopicIE(TuneInBaseIE):
    IE_NAME = 'tunein:topic'
    _VALID_URL = r'https?://(?:www\.)?tunein\.com/(?:topic/.*?TopicId=|embed/player/t)(?P<id>\d+)'
    _API_URL_QUERY = '?tuneType=Topic&topicId=%s'

    _TESTS = [{
        'url': 'http://tunein.com/topic/?TopicId=101830576',
        'md5': 'c31a39e6f988d188252eae7af0ef09c9',
        'info_dict': {
            'id': '101830576',
            'title': 'Votez pour moi du 29 octobre 2015 (29/10/15)',
            'ext': 'mp3',
            'location': 'Belgium',
        },
    }, {
        'url': 'http://tunein.com/embed/player/t101830576/',
        'only_matching': True,
    }]

class TuneInShortenerIE(InfoExtractor):
    IE_NAME = 'tunein:shortener'
    IE_DESC = False  # Do not list
    _VALID_URL = r'https?://tun\.in/(?P<id>[A-Za-z0-9]+)'

    _TEST = {
        # test redirection
        'url': 'http://tun.in/ser7s',
        'info_dict': {
            'id': '34682',
            'title': 'Jazz 24 on 88.5 Jazz24 - KPLU-HD2',
            'ext': 'mp3',
            'location': 'Tacoma, WA',
        },
        'params': {
            'skip_download': True,  # live stream
        },
    }

    def _real_extract(self, url):
        redirect_id = self._match_id(url)
        # The server doesn't support HEAD requests
        urlh = self._request_webpage(
            url, redirect_id, note='Downloading redirect page')
        url = urlh.geturl()
        self.to_screen('Following redirect: %s' % url)
        return self.url_result(url)


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

class TunePkIE(InfoExtractor):
    _VALID_URL = r'''(?x)
                    https?://
                        (?:
                            (?:www\.)?tune\.pk/(?:video/|player/embed_player.php?.*?\bvid=)|
                            embed\.tune\.pk/play/
                        )
                        (?P<id>\d+)
                    '''
    _TESTS = [{
        'url': 'https://tune.pk/video/6919541/maudie-2017-international-trailer-1-ft-ethan-hawke-sally-hawkins',
        'md5': '0c537163b7f6f97da3c5dd1e3ef6dd55',
        'info_dict': {
            'id': '6919541',
            'ext': 'mp4',
            'title': 'Maudie (2017) | International Trailer # 1 ft Ethan Hawke, Sally Hawkins',
            'description': 'md5:eb5a04114fafef5cec90799a93a2d09c',
            'thumbnail': r're:^https?://.*\.jpg$',
            'timestamp': 1487327564,
            'upload_date': '20170217',
            'uploader': 'Movie Trailers',
            'duration': 107,
            'view_count': int,
        }
    }, {
        'url': 'https://tune.pk/player/embed_player.php?vid=6919541&folder=2017/02/17/&width=600&height=350&autoplay=no',
        'only_matching': True,
    }, {
        'url': 'https://embed.tune.pk/play/6919541?autoplay=no&ssl=yes&inline=true',
        'only_matching': True,
    }]

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

        webpage = self._download_webpage(
            'https://tune.pk/video/%s' % video_id, video_id)

        details = self._parse_json(
            self._search_regex(
                r'new\s+TunePlayer\(({.+?})\)\s*;\s*\n', webpage, 'tune player'),
            video_id)['details']

        video = details['video']
        title = video.get('title') or self._og_search_title(
            webpage, default=None) or self._html_search_meta(
            'title', webpage, 'title', fatal=True)

        formats = self._parse_jwplayer_formats(
            details['player']['sources'], video_id)
        self._sort_formats(formats)

        description = self._og_search_description(
            webpage, default=None) or self._html_search_meta(
            'description', webpage, 'description')

        thumbnail = video.get('thumb') or self._og_search_thumbnail(
            webpage, default=None) or self._html_search_meta(
            'thumbnail', webpage, 'thumbnail')

        timestamp = unified_timestamp(video.get('date_added'))
        uploader = try_get(
            video, lambda x: x['uploader']['name'],
            compat_str) or self._html_search_meta('author', webpage, 'author')

        duration = int_or_none(video.get('duration'))
        view_count = int_or_none(video.get('views'))

        return {
            'id': video_id,
            'title': title,
            'description': description,
            'thumbnail': thumbnail,
            'timestamp': timestamp,
            'uploader': uploader,
            'duration': duration,
            'view_count': view_count,
            'formats': formats,
        }


code:
import re

from .common import InfoExtractor
from ..compat import compat_str
from ..utils import (
    ExtractorError,
    int_or_none,
    qualities,
    xpath_text,
)

class TurboIE(InfoExtractor):
    _VALID_URL = r'https?://(?:www\.)?turbo\.fr/videos-voiture/(?P<id>[0-9]+)-'
    _API_URL = 'http://www.turbo.fr/api/tv/xml.php?player_generique=player_generique&id={0:}'
    _TEST = {
        'url': 'http://www.turbo.fr/videos-voiture/454443-turbo-du-07-09-2014-renault-twingo-3-bentley-continental-gt-speed-ces-guide-achat-dacia.html',
        'md5': '33f4b91099b36b5d5a91f84b5bcba600',
        'info_dict': {
            'id': '454443',
            'ext': 'mp4',
            'duration': 3715,
            'title': 'Turbo du 07/09/2014 : Renault Twingo 3, Bentley Continental GT Speed, CES, Guide Achat Dacia... ',
            'description': 'Turbo du 07/09/2014 : Renault Twingo 3, Bentley Continental GT Speed, CES, Guide Achat Dacia...',
            'thumbnail': r're:^https?://.*\.jpg$',
        }
    }

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

        webpage = self._download_webpage(url, video_id)

        playlist = self._download_xml(self._API_URL.format(video_id), video_id)
        item = playlist.find('./channel/item')
        if item is None:
            raise ExtractorError('Playlist item was not found', expected=True)

        title = xpath_text(item, './title', 'title')
        duration = int_or_none(xpath_text(item, './durate', 'duration'))
        thumbnail = xpath_text(item, './visuel_clip', 'thumbnail')
        description = self._html_search_meta('description', webpage)

        formats = []
        get_quality = qualities(['3g', 'sd', 'hq'])
        for child in item:
            m = re.search(r'url_video_(?P<quality>.+)', child.tag)
            if m:
                quality = compat_str(m.group('quality'))
                formats.append({
                    'format_id': quality,
                    'url': child.text,
                    'quality': get_quality(quality),
                })
        self._sort_formats(formats)

        return {
            'id': video_id,
            'title': title,
            'duration': duration,
            'thumbnail': thumbnail,
            'description': description,
            'formats': formats,
        }


code:
import re

from .adobepass import AdobePassIE
from ..compat import compat_str
from ..utils import (
    fix_xml_ampersands,
    xpath_text,
    int_or_none,
    determine_ext,
    float_or_none,
    parse_duration,
    xpath_attr,
    update_url_query,
    ExtractorError,
    strip_or_none,
    url_or_none,
)

class TurnerBaseIE(AdobePassIE):
    _AKAMAI_SPE_TOKEN_CACHE = {}

    def _extract_timestamp(self, video_data):
        return int_or_none(xpath_attr(video_data, 'dateCreated', 'uts'))

    def _add_akamai_spe_token(self, tokenizer_src, video_url, content_id, ap_data, custom_tokenizer_query=None):
        secure_path = self._search_regex(r'https?://[^/]+(.+/)', video_url, 'secure path') + '*'
        token = self._AKAMAI_SPE_TOKEN_CACHE.get(secure_path)
        if not token:
            query = {
                'path': secure_path,
            }
            if custom_tokenizer_query:
                query.update(custom_tokenizer_query)
            else:
                query['videoId'] = content_id
            if ap_data.get('auth_required'):
                query['accessToken'] = self._extract_mvpd_auth(ap_data['url'], content_id, ap_data['site_name'], ap_data['site_name'])
            auth = self._download_xml(
                tokenizer_src, content_id, query=query)
            error_msg = xpath_text(auth, 'error/msg')
            if error_msg:
                raise ExtractorError(error_msg, expected=True)
            token = xpath_text(auth, 'token')
            if not token:
                return video_url
            self._AKAMAI_SPE_TOKEN_CACHE[secure_path] = token
        return video_url + '?hdnea=' + token

    def _extract_cvp_info(self, data_src, video_id, path_data={}, ap_data={}, fatal=False):
        video_data = self._download_xml(
            data_src, video_id,
            transform_source=lambda s: fix_xml_ampersands(s).strip(),
            fatal=fatal)
        if not video_data:
            return {}
        video_id = video_data.attrib['id']
        title = xpath_text(video_data, 'headline', fatal=True)
        content_id = xpath_text(video_data, 'contentId') or video_id
        # rtmp_src = xpath_text(video_data, 'akamai/src')
        # if rtmp_src:
        #     split_rtmp_src = rtmp_src.split(',')
        #     if len(split_rtmp_src) == 2:
        #         rtmp_src = split_rtmp_src[1]
        # aifp = xpath_text(video_data, 'akamai/aifp', default='')

        urls = []
        formats = []
        thumbnails = []
        subtitles = {}
        rex = re.compile(
            r'(?P<width>[0-9]+)x(?P<height>[0-9]+)(?:_(?P<bitrate>[0-9]+))?')
        # Possible formats locations: files/file, files/groupFiles/files
        # and maybe others
        for video_file in video_data.findall('.//file'):
            video_url = url_or_none(video_file.text.strip())
            if not video_url:
                continue
            ext = determine_ext(video_url)
            if video_url.startswith('/mp4:protected/'):
                continue
                # TODO Correct extraction for these files
                # protected_path_data = path_data.get('protected')
                # if not protected_path_data or not rtmp_src:
                #     continue
                # protected_path = self._search_regex(
                #     r'/mp4:(.+)\.[a-z0-9]', video_url, 'secure path')
                # auth = self._download_webpage(
                #     protected_path_data['tokenizer_src'], query={
                #         'path': protected_path,
                #         'videoId': content_id,
                #         'aifp': aifp,
                #     })
                # token = xpath_text(auth, 'token')
                # if not token:
                #     continue
                # video_url = rtmp_src + video_url + '?' + token
            elif video_url.startswith('/secure/'):
                secure_path_data = path_data.get('secure')
                if not secure_path_data:
                    continue
                video_url = self._add_akamai_spe_token(
                    secure_path_data['tokenizer_src'],
                    secure_path_data['media_src'] + video_url,
                    content_id, ap_data)
            elif not re.match('https?://', video_url):
                base_path_data = path_data.get(ext, path_data.get('default', {}))
                media_src = base_path_data.get('media_src')
                if not media_src:
                    continue
                video_url = media_src + video_url
            if video_url in urls:
                continue
            urls.append(video_url)
            format_id = video_file.get('bitrate')
            if ext in ('scc', 'srt', 'vtt'):
                subtitles.setdefault('en', []).append({
                    'ext': ext,
                    'url': video_url,
                })
            elif ext == 'png':
                thumbnails.append({
                    'id': format_id,
                    'url': video_url,
                })
            elif ext == 'smil':
                formats.extend(self._extract_smil_formats(
                    video_url, video_id, fatal=False))
            elif re.match(r'https?://[^/]+\.akamaihd\.net/[iz]/', video_url):
                formats.extend(self._extract_akamai_formats(
                    video_url, video_id, {
                        'hds': path_data.get('f4m', {}).get('host'),
                        # nba.cdn.turner.com, ht.cdn.turner.com, ht2.cdn.turner.com
                        # ht3.cdn.turner.com, i.cdn.turner.com, s.cdn.turner.com
                        # ssl.cdn.turner.com
                        'http': 'pmd.cdn.turner.com',
                    }))
            elif ext == 'm3u8':
                m3u8_formats = self._extract_m3u8_formats(
                    video_url, video_id, 'mp4',
                    m3u8_id=format_id or 'hls', fatal=False)
                if '/secure/' in video_url and '?hdnea=' in video_url:
                    for f in m3u8_formats:
                        f['downloader_options'] = {'ffmpeg_args': ['-seekable', '0']}
                formats.extend(m3u8_formats)
            elif ext == 'f4m':
                formats.extend(self._extract_f4m_formats(
                    update_url_query(video_url, {'hdcore': '3.7.0'}),
                    video_id, f4m_id=format_id or 'hds', fatal=False))
            else:
                f = {
                    'format_id': format_id,
                    'url': video_url,
                    'ext': ext,
                }
                mobj = rex.search(video_url)
                if mobj:
                    f.update({
                        'width': int(mobj.group('width')),
                        'height': int(mobj.group('height')),
                        'tbr': int_or_none(mobj.group('bitrate')),
                    })
                elif isinstance(format_id, compat_str):
                    if format_id.isdigit():
                        f['tbr'] = int(format_id)
                    else:
                        mobj = re.match(r'ios_(audio|[0-9]+)$', format_id)
                        if mobj:
                            if mobj.group(1) == 'audio':
                                f.update({
                                    'vcodec': 'none',
                                    'ext': 'm4a',
                                })
                            else:
                                f['tbr'] = int(mobj.group(1))
                formats.append(f)
        self._sort_formats(formats)

        for source in video_data.findall('closedCaptions/source'):
            for track in source.findall('track'):
                track_url = url_or_none(track.get('url'))
                if not track_url or track_url.endswith('/big'):
                    continue
                lang = track.get('lang') or track.get('label') or 'en'
                subtitles.setdefault(lang, []).append({
                    'url': track_url,
                    'ext': {
                        'scc': 'scc',
                        'webvtt': 'vtt',
                        'smptett': 'tt',
                    }.get(source.get('format'))
                })

        thumbnails.extend({
            'id': image.get('cut') or image.get('name'),
            'url': image.text,
            'width': int_or_none(image.get('width')),
            'height': int_or_none(image.get('height')),
        } for image in video_data.findall('images/image'))

        is_live = xpath_text(video_data, 'isLive') == 'true'

        return {
            'id': video_id,
            'title': title,
            'formats': formats,
            'subtitles': subtitles,
            'thumbnails': thumbnails,
            'thumbnail': xpath_text(video_data, 'poster'),
            'description': strip_or_none(xpath_text(video_data, 'description')),
            'duration': parse_duration(xpath_text(video_data, 'length') or xpath_text(video_data, 'trt')),
            'timestamp': self._extract_timestamp(video_data),
            'upload_date': xpath_attr(video_data, 'metas', 'version'),
            'series': xpath_text(video_data, 'showTitle'),
            'season_number': int_or_none(xpath_text(video_data, 'seasonNumber')),
            'episode_number': int_or_none(xpath_text(video_data, 'episodeNumber')),
            'is_live': is_live,
        }

    def _extract_ngtv_info(self, media_id, tokenizer_query, ap_data=None):
        is_live = ap_data.get('is_live')
        streams_data = self._download_json(
            'http://medium.ngtv.io/media/%s/tv' % media_id,
            media_id)['media']['tv']
        duration = None
        chapters = []
        formats = []
        for supported_type in ('unprotected', 'bulkaes'):
            stream_data = streams_data.get(supported_type, {})
            m3u8_url = stream_data.get('secureUrl') or stream_data.get('url')
            if not m3u8_url:
                continue
            if stream_data.get('playlistProtection') == 'spe':
                m3u8_url = self._add_akamai_spe_token(
                    'http://token.ngtv.io/token/token_spe',
                    m3u8_url, media_id, ap_data or {}, tokenizer_query)
            formats.extend(self._extract_m3u8_formats(
                m3u8_url, media_id, 'mp4', m3u8_id='hls', live=is_live, fatal=False))

            duration = float_or_none(stream_data.get('totalRuntime'))

            if not chapters and not is_live:
                for chapter in stream_data.get('contentSegments', []):
                    start_time = float_or_none(chapter.get('start'))
                    chapter_duration = float_or_none(chapter.get('duration'))
                    if start_time is None or chapter_duration is None:
                        continue
                    chapters.append({
                        'start_time': start_time,
                        'end_time': start_time + chapter_duration,
                    })
        self._sort_formats(formats)

        return {
            'formats': formats,
            'chapters': chapters,
            'duration': duration,
        }