import collections import datetime as dt import functools import itertools import json import math import time import urllib.parse import xml.etree.ElementTree from .common import InfoExtractor from ..utils import ( ExtractorError, InAdvancePagedList, clean_html, extract_attributes, filter_dict, int_or_none, join_nonempty, parse_iso8601, parse_qs, str_or_none, update_url_query, url_or_none, urljoin, ) from ..utils.traversal import ( find_element, find_elements, require, traverse_obj, ) class OpenRecBaseIE(InfoExtractor): _API_BASE = 'https://apiv5.mellow-fan.com/api/v5' _BASE_URL = 'https://www.mellow-fan.com' _HEADERS = {'Referer': f'{_BASE_URL}/'} _NETRC_MACHINE = 'openrec' _PUBLIC_API_BASE = 'https://public.mellow-fan.com/external/api/v5' def _perform_login(self, username, password): if self._get_cookies(self._BASE_URL).get('access-token'): return login = self._download_json( f'{self._BASE_URL}/apiv5/email/login', None, 'Logging in', headers={ 'Content-Type': 'application/json', }, data=json.dumps({ 'email': username, 'password': password, }).encode()) if traverse_obj(login, ('status', {int_or_none})) != 0: err_msg = traverse_obj(login, ('message', {clean_html}, filter)) raise ExtractorError(err_msg or 'Failed to log in', expected=True) def _real_initialize(self): cookies = self._get_cookies(self._BASE_URL) self._api_headers = traverse_obj(cookies, { 'access-token': ('access_token', 'value', {str}, filter), 'random': ('random', 'value', {str}, filter), 'token': ('token', 'value', {str}, filter), 'uuid': ('uuid', 'value', {str}, filter), }) def _extract_pagestore(self, webpage, video_id): start = r'window\.pageStore\s*=' if store := self._search_regex( rf'{start}\s*JSON\.parse\s*\(\s*decodeURIComponent' r'\s*\(\s*(?P["\'])(?P.*?)(?P=q)\s*\)\s*\)', webpage, 'encoded window pagestore', group='json', default=None, ): return self._parse_json(store, video_id, transform_source=urllib.parse.unquote) return self._search_json(start, webpage, 'window pagestore', video_id) def _call_api(self, path, item_id): return self._download_json( f'{self._API_BASE}/{path}', item_id, headers=self._api_headers, expected_status=401) def _parse_openrec_metadata(self, page_store, video_id): info = traverse_obj(page_store, ('v8', 'movie', {dict})) target_members = traverse_obj(info, ( 'targetMembers', ..., 'type', {str}, filter, any)) needs_subscription = target_members == 'subscription' needs_auth = target_members == 'ppv' me = self._call_api('users/me', video_id) needs_premium = traverse_obj(info, ( 'publicType', {str}, filter)) == 'premium' is_premium = traverse_obj(me, ( 'data', 'items', ..., 'is_premium', {bool}, any)) or False detail = self._call_api(f'movies/{video_id}/detail', video_id) is_member = traverse_obj(detail, ( 'data', 'items', ..., 'membership', 'is_active', {bool}, any)) or False has_ppv = traverse_obj(detail, ( 'data', 'items', ..., 'ppv_ticket_products', ..., {dict}, any)) or False need = None if needs_premium and not is_premium: need = 'premium membership' elif needs_subscription and not is_member: need = 'channel subscription' elif needs_auth and not has_ppv: need = 'PPV purchase' if need: self.raise_login_required( f'This content requires a {need}', metadata_available=True) return info, detail, { 'id': video_id, 'availability': self._availability( needs_premium=needs_premium, needs_subscription=needs_subscription, needs_auth=needs_auth, ) or 'public', 'http_headers': self._HEADERS, 'tags': traverse_obj(page_store, ( 'movieStore', 'tags', ..., {clean_html}, filter, all, filter)), **traverse_obj(info, { 'title': ('title', {clean_html}, filter), 'cast': ('casts', ..., 'name', {clean_html}, filter, all, filter), 'categories': ('game', 'title', {clean_html}, filter, all, filter), 'description': ('introduction', {clean_html}, filter), 'duration': ('playTime', 'value', {int_or_none(scale=1000)}), 'thumbnail': (('lThumbnailUrl', 'thumbnailUrl'), {url_or_none}, any), 'timestamp': ('startedAt', 'time', {int_or_none(scale=1000)}), 'view_count': ('totalViews', {int_or_none}), }), **traverse_obj(info, ('channel', 'user', { 'channel_follower_count': ('followers', {int_or_none}), 'channel_id': ('id', {str_or_none}), 'channel': ('name', {clean_html}, filter), 'channel_is_verified': ('isOfficial', {bool}), })), } class OpenRecIE(OpenRecBaseIE): IE_NAME = 'mellow-fan' _VALID_URL = r'https?://(?:www\.)?(?:mellow-fan\.com|openrec\.tv)/(?:m/)?live/(?P[^/?#]+)' _TESTS = [{ 'url': 'https://www.openrec.tv/live/e2zwj0mp6ro', 'info_dict': { 'id': 'e2zwj0mp6ro', 'ext': 'mp4', 'title': '収束', 'availability': 'public', 'categories': ['雑談'], 'channel': 'おおえのたかゆき', 'channel_follower_count': int, 'channel_id': 'oekaki', 'channel_is_verified': True, 'comment_count': int, 'description': 'md5:62260f3060b40187282f3213bcd97abd', 'duration': 14257, 'live_status': 'was_live', 'tags': ['雑談'], 'thumbnail': r're:https?://.+', 'timestamp': 1685271819, 'upload_date': '20230528', 'view_count': int, }, 'skip': '404 Not Found', }, { # SP 'url': 'https://www.mellow-fan.com/live/2p8vv29438y', 'info_dict': { 'id': '2p8vv29438y', 'ext': 'mp4', 'title': 'それいけ加藤純一探検隊! 〜南の孤島で希少生物を探せスペシャル〜 (OPENRECプレミアム会員限定)', 'availability': 'premium_only', 'categories': ['雑談'], 'channel': '加藤 純一', 'channel_follower_count': int, 'channel_id': 'junichi_kato_channel', 'channel_is_verified': True, 'comment_count': int, 'description': 'md5:693bc0c838ff6080f9ca00d41dc5e840', 'duration': 24336, 'live_status': 'was_live', 'release_date': '20251008', 'release_timestamp': 1759892400, 'thumbnail': r're:https?://.+', 'timestamp': 1759891802, 'upload_date': '20251008', 'view_count': int, }, }, { # Members only 'url': 'https://www.mellow-fan.com/live/kdr7nldqgzj', 'info_dict': { 'id': 'kdr7nldqgzj', 'ext': 'mp4', 'title': '【ゲーム実況生配信】小森結梨のひきこもりゲーム部屋#20[ゲスト:梅澤めぐ]', 'availability': 'subscriber_only', 'categories': ['Human Fall Flat'], 'channel': 'セカンドショットGAME部', 'channel_follower_count': int, 'channel_id': 'secondshot_games', 'channel_is_verified': True, 'chapters': [ {'start_time': 0, 'title': 'バイオハザード RE:2', 'end_time': 443}, {'start_time': 443, 'title': 'ぷにゃん', 'end_time': 3215}, {'start_time': 3215, 'title': 'Human Fall Flat', 'end_time': 7163}, ], 'comment_count': int, 'description': 'md5:b77fac6fb0ad86048dc6377ec2c22646', 'duration': 7163, 'live_status': 'was_live', 'release_date': '20260220', 'release_timestamp': 1771581300, 'tags': ['声優'], 'thumbnail': r're:https?://.+', 'timestamp': 1771581322, 'upload_date': '20260220', 'view_count': int, }, 'skip': 'Subscribers only', }, { # PPV 'url': 'https://www.mellow-fan.com/live/e5rk93xn1zv', 'info_dict': { 'id': 'e5rk93xn1zv', 'ext': 'mp4', 'title': '三川華月生誕パーティー2026 【ゲスト:幸村恵理/北原沙弥香】', 'availability': 'needs_auth', 'categories': ['雑談'], 'channel': '三川華月の開店!はるちゃん食堂', 'channel_follower_count': int, 'channel_id': 'haruna_harusyoku', 'channel_is_verified': True, 'description': 'md5:912d0c2d60d343e28300fc18bd491d6c', 'duration': 6451, 'live_status': 'was_live', 'release_date': '20260222', 'release_timestamp': 1771758000, 'thumbnail': r're:https?://.+', 'timestamp': 1771758062, 'upload_date': '20260222', 'view_count': int, }, 'skip': 'Paid video', }] @staticmethod def _json2xml(subs, started_at): def filter_valid(items): yield from traverse_obj(items, ( lambda _, v: clean_html(v['message']) and not v['stamp'])) total = collections.Counter( traverse_obj(subs, (..., 'posted_at', {parse_iso8601}))) order = collections.defaultdict(int) root = xml.etree.ElementTree.Element('packet') for i, s in enumerate(filter_valid(subs), 1): posted_at = traverse_obj(s, ('posted_at', {parse_iso8601})) offset = order[posted_at] / total[posted_at] + 1 order[posted_at] += 1 vpos = int_or_none((posted_at - started_at + offset) * 100) xml.etree.ElementTree.SubElement( root, 'chat', filter_dict({ **traverse_obj(s, ('user', { 'premium': ('is_premium', {bool}, {lambda x: '1' if x else '0'}), 'user_id': ('id', {str_or_none}), 'name': ('nickname', {str}, filter), })), 'no': str(i), 'vpos': str_or_none(vpos), 'date': str_or_none(posted_at), }), ).text = traverse_obj(s, ('message', {clean_html}, filter)) xml.etree.ElementTree.indent(root, space=' ') return xml.etree.ElementTree.tostring( root, encoding='utf-8', xml_declaration=True).decode() def _get_subtitles(self, duration, started_at, video_id): ended_at = started_at + duration timestamp = started_at subs = [] for page in itertools.count(1): created_at = dt.datetime.fromtimestamp( timestamp, dt.timezone(dt.timedelta(hours=9))).strftime('%Y-%m-%dT%H:%M:%S%z') chats = self._download_json( f'{self._PUBLIC_API_BASE}/movies/{video_id}/chats', video_id, f'Downloading chats page {page}', query={ 'from_created_at': created_at, 'is_including_system_message': 'true', }) if not chats: break subs.extend(chats) last_posted_at = traverse_obj(chats, (-1, 'posted_at', {parse_iso8601})) if ended_at < last_posted_at: break timestamp = last_posted_at + 1 time.sleep(0.1) return { 'chats': [{ 'data': json.dumps(subs, indent=2, ensure_ascii=False), 'ext': 'json', }, { 'data': self._json2xml(subs, started_at), 'ext': 'xml', }], } def _real_extract(self, url): video_id = self._match_id(url) webpage = self._download_webpage(url, video_id, expected_status=404) page_store = self._extract_pagestore(webpage, video_id) if traverse_obj(page_store, ('movieStore', 'notFound', {bool})): raise ExtractorError('This video in no longer available', expected=True) info, detail, metadata = self._parse_openrec_metadata(page_store, video_id) live_status = { 'ARCHIVE': 'was_live', 'COMING_UP': 'is_upcoming', 'LIVE_STREAMING': 'is_live', 'UPLOADED': 'not_live', }.get(info.get('onAirStatus')) release_timestamp = traverse_obj(page_store, ('movieStore', 'willStartAt', {parse_iso8601})) if live_status == 'is_upcoming': if release_timestamp is not None: start_time = dt.datetime.fromtimestamp( release_timestamp, dt.timezone.utc, ).astimezone().strftime('%Y-%m-%d %H:%M:%S %Z') msg = f'This livestream is scheduled to start at {start_time}' else: msg = 'This livestream has not yet started' self.raise_no_formats(msg, expected=True) return { 'id': video_id, 'live_status': live_status, 'release_timestamp': release_timestamp, } duration = metadata['duration'] started_at = metadata['timestamp'] chapters = [] for chapter in traverse_obj(info, ( 'chapters', lambda _, v: int_or_none(v['chapterAt']['time']), )): chapter_at = traverse_obj(chapter, ('chapterAt', 'time', {int_or_none(scale=1000)})) chapters.append({ 'start_time': chapter_at - started_at, 'title': traverse_obj(chapter, ('title', {clean_html}, filter)), }) formats = [] is_dvr = live_status == 'is_live' and self.get_param('live_from_start') media_keys = ('url_dvr', 'url_dvr_audio') if is_dvr else ('url', 'url_audio') for m3u8_url in traverse_obj(detail, ( 'data', 'items', ..., ('media', 'subs_trial_media'), media_keys, {url_or_none}, )): fmts = self._extract_m3u8_formats( m3u8_url, video_id, 'mp4', headers=self._HEADERS) for fmt in fmts: if is_dvr: fmt.setdefault('downloader_options', {}).update({'ffmpeg_args': ['-live_start_index', '0']}) fmt['is_from_start'] = True formats.extend(fmts) return { 'chapters': chapters or None, 'comment_count': traverse_obj(page_store, ( 'commentStore', 'commentCount', {int_or_none})), 'formats': formats, 'live_status': live_status, 'release_timestamp': release_timestamp, 'subtitles': self.extract_subtitles(duration, started_at, video_id), **metadata, } class OpenRecCaptureIE(OpenRecBaseIE): IE_NAME = 'mellow-fan:capture' _VALID_URL = r'https?://(?:www\.)?(?:mellow-fan\.com|openrec\.tv)/(?:m/)?capture/(?P[^/?#]+)' _TESTS = [{ 'url': 'https://www.mellow-fan.com/capture/l2q00vxl8q8', 'info_dict': { 'id': 'l2q00vxl8q8', 'ext': 'mp4', 'title': '????', 'channel': '布団ちゃん', 'channel_id': 'indegnasen', 'duration': 89, 'thumbnail': r're:https?://.+', 'timestamp': 1637589871, 'upload_date': '20211122', }, }, { 'url': 'https://www.mellow-fan.com/capture/9pdz9334vng', 'info_dict': { 'id': '9pdz9334vng', 'ext': 'mp4', 'title': 'オプレの現実', 'channel': 'ゆゆうた&みゃこの泥沼バラエティ', 'channel_id': 'doronuma-variety', 'duration': 64, 'thumbnail': r're:https?://.+', 'timestamp': 1677585253, 'upload_date': '20230228', }, }] def _real_extract(self, url): video_id = self._match_id(url) webpage = self._download_webpage(url, video_id) page_store = self._extract_pagestore(webpage, video_id) capture = page_store['capture'] m3u8_url = traverse_obj(capture, ('source', {url_or_none})) return { 'id': video_id, 'formats': self._extract_m3u8_formats( m3u8_url, video_id, 'mp4', headers=self._HEADERS), 'http_headers': self._HEADERS, **traverse_obj(page_store, ('movie', 'channel', { 'channel': ('name', {clean_html}, filter), 'channel_id': ('id', {str}), })), **traverse_obj(capture, { 'title': ('title', {clean_html}, filter), 'duration': ({lambda x: int_or_none(x['endTime']) - int_or_none(x['startTime'])}), 'thumbnail': ('thumbnailUrl', {url_or_none}), 'timestamp': ('publishedAt', {parse_iso8601}), }), } class OpenRecMovieIE(OpenRecBaseIE): IE_NAME = 'mellow-fan:movie' _VALID_URL = r'https?://(?:www\.)?(?:mellow-fan\.com|openrec\.tv)/(?:m/)?movie/(?P[^/?#]+)' _TESTS = [{ 'url': 'https://www.mellow-fan.com/movie/e5rk9k4o6zv', 'info_dict': { 'id': 'e5rk9k4o6zv', 'ext': 'mp4', 'title': 'みゃこRaMuの企画会議#3 ~2人のやってみたいこと~', 'availability': 'public', 'categories': ['雑談'], 'channel': 'みゃことRaMuの何して遊ぶ?', 'channel_follower_count': int, 'channel_id': 'myakoramu', 'channel_is_verified': True, 'description': 'md5:90924fd73356ebd574bec8d761d4fa62', 'duration': 771, 'tags': ['雑談'], 'thumbnail': r're:https?://.+', 'view_count': int, }, }, { # Members only 'url': 'https://www.mellow-fan.com/movie/n9ze6q3eo84', 'info_dict': { 'id': 'n9ze6q3eo84', 'ext': 'mp4', 'title': '大西亜玖璃・高尾奏音のあぐのんる~むらぼ♪第123回傑作選vol.3【高画質・完全版】', 'availability': 'subscriber_only', 'categories': ['雑談'], 'channel': 'セカンドショットGAME部', 'channel_follower_count': int, 'channel_id': 'secondshot_games', 'channel_is_verified': True, 'description': 'md5:b7ab5ddd71ba5edc1141cb427af7a9c6', 'duration': 1800, 'tags': ['声優'], 'thumbnail': r're:https?://.+', 'view_count': int, }, 'skip': 'Subscribers only', }, { # PPV 'url': 'https://www.mellow-fan.com/movie/em8xvd4ljr2', 'info_dict': { 'id': 'em8xvd4ljr2', 'ext': 'mp4', 'title': '【PPV購入特典映像】KAWAII LAB. SESSION in OKINAWA', 'availability': 'needs_auth', 'categories': ['ミュージック'], 'channel': 'KAWAII LAB.チャンネル', 'channel_follower_count': int, 'channel_id': 'KAWAIILAB', 'channel_is_verified': True, 'description': 'md5:e9f67d8648d3cbe35df1dd689afc29a8', 'duration': 2002, 'tags': ['アイドル'], 'thumbnail': r're:https?://.+', 'view_count': int, }, 'skip': 'Paid video', }] def _real_extract(self, url): video_id = self._match_id(url) webpage = self._download_webpage(url, video_id) page_store = self._extract_pagestore(webpage, video_id) _, detail, metadata = self._parse_openrec_metadata(page_store, video_id) formats = [] for m3u8_url in traverse_obj(detail, ( 'data', 'items', ..., 'media', ('url', 'url_audio'), {url_or_none}, )): formats.extend(self._extract_m3u8_formats( m3u8_url, video_id, 'mp4', headers=self._HEADERS)) return { 'formats': formats, **metadata, } class OpenRecPlaylistIE(OpenRecBaseIE): IE_NAME = 'mellow-fan:playlist' _VALID_URL = r'https?://(?:www\.)?(?:mellow-fan\.com|openrec\.tv)/(?:m/)?user/[^/?#]+/playlist/(?P[^/?#]+)' _TESTS = [{ # live 'url': 'https://www.mellow-fan.com/user/DbD_BPF/playlist/j59svruhtua2z8t', 'info_dict': { 'id': 'j59svruhtua2z8t', 'title': 'BPFのおすすめ', }, 'playlist_mincount': 10, }, { # capture 'url': 'https://www.mellow-fan.com/user/sagara_mayu/playlist/xngNMzv71yLjGdW', 'info_dict': { 'id': 'xngNMzv71yLjGdW', 'title': '相良茉優のFAN!FUN!FACTORY!キャプチャ', }, 'playlist_mincount': 4, }, { # movie 'url': 'https://www.mellow-fan.com/user/oreranohonoka/playlist/sficoshvi9dgkqh', 'info_dict': { 'id': 'sficoshvi9dgkqh', 'title': 'SP(スペシャル)映像:(短編映像)おにいたむプランだけが視聴できます', }, 'playlist_mincount': 111, }] def _entries(self, items): for movie in traverse_obj(items, ( 'playlist_movies', ..., 'movie', 'id', {str_or_none}, )): is_live = traverse_obj(movie, ('movie', 'is_live', {bool})) path, ie = ('live', OpenRecIE) if is_live else ('movie', OpenRecMovieIE) movie_id = movie['movie']['id'] yield self.url_result(f'{self._BASE_URL}/{path}/{movie_id}', ie) for capture in traverse_obj(items, ( 'playlist_captures', ..., 'capture_relation', 'capture', 'id', {str_or_none}, )): capture_id = capture['capture_relation']['capture']['id'] yield self.url_result( f'{self._BASE_URL}/capture/{capture_id}', OpenRecCaptureIE) def _real_extract(self, url): playlist_id = self._match_id(url) me = self._call_api(f'users/me/playlists/{playlist_id}', playlist_id) if items := traverse_obj(me, ( 'data', 'items', ..., {dict}, any, )): return self.playlist_result( self._entries(items), playlist_id, traverse_obj(items, ('title', {clean_html}, filter))) webpage = self._download_webpage(url, playlist_id) playlist_title = traverse_obj(webpage, ( {find_element(cls='sc-1ak77bz-10')}, {clean_html}, filter)) return self.playlist_from_matches(traverse_obj(webpage, ( {find_elements(cls='sc-vc0xhn-0')}, ..., {find_element(cls='sc-1ddd11y-0', html=True)}, {extract_attributes}, 'href', {str}, )), playlist_id, playlist_title, getter=urljoin(f'{self._BASE_URL}/')) class OpenRecChannelIE(OpenRecBaseIE): IE_NAME = 'mellow-fan:channel' _PAGE_SIZE = 40 _VALID_URL = r'https?://(?:www\.)?(?:mellow-fan\.com|openrec\.tv)/(?:m/)?user/(?P[^/?#]+)$' _TESTS = [{ 'url': 'https://www.mellow-fan.com/user/OPENRECPARK', 'info_dict': { 'id': 'OPENRECPARK', 'title': 'OPENREC PARK', }, 'playlist_mincount': 40, }] def _fetch_page(self, channel_id, page): page += 1 search_movies = self._download_json( f'{self._PUBLIC_API_BASE}/search-movies', channel_id, f'Downloading page {page}', query={ 'channel_ids': channel_id, 'include_live': 'true', 'include_upload': 'true', 'onair_status': '2', 'include_deleted': 'true', 'sort': 'published_at', 'page': str(page), }) for movie in traverse_obj(search_movies, ( lambda _, v: str_or_none(v['movie_type']) and str_or_none(v['id']), )): path, ie = ('live', OpenRecIE) if movie['movie_type'] == '1' else ('movie', OpenRecMovieIE) yield self.url_result(f'{self._BASE_URL}/{path}/{movie["id"]}', ie) def _real_extract(self, url): channel_id = self._match_id(url) webpage = self._download_webpage(url, channel_id) page_store = self._extract_pagestore(webpage, channel_id) channel = traverse_obj(page_store, ('state', '_channel', {dict})) movie_count = traverse_obj(channel, ('movieCount', {int_or_none})) return self.playlist_result(InAdvancePagedList( functools.partial(self._fetch_page, channel_id), math.ceil(movie_count / self._PAGE_SIZE), self._PAGE_SIZE, ), channel_id, traverse_obj(channel, ('user', 'name', {clean_html}, filter))) class OpenRecChannelSearchIE(OpenRecBaseIE): IE_NAME = 'mellow-fan:channel:search' _VALID_URL = r'https?://(?:www\.)?(?:mellow-fan\.com|openrec\.tv)/(?:m/)?user/(?P[^/?#]+)/search(?:/(?Pcapture|movie))?(?:[/?#]|$)' _TESTS = [{ 'url': 'https://www.mellow-fan.com/user/indegnasen/search?search_query=%E3%82%B9%E3%82%A4%E3%82%AB', 'info_dict': { 'id': 'indegnasen', 'title': 'indegnasen:スイカ', }, 'playlist_count': 2, }, { 'url': 'https://www.mellow-fan.com/user/ofurekodesu/search/movie?search_query=%E3%82%A2%E3%83%AA%E3%82%AA%E5%85%AB%E5%B0%BE', 'info_dict': { 'id': 'ofurekodesu', 'title': 'ofurekodesu:アリオ八尾:movie', }, 'playlist_mincount': 31, }, { 'url': 'https://www.mellow-fan.com/user/DbD_BPF/search/capture?search_query=%E3%81%82%E3%81%A3%E3%81%95%E3%82%8A%E3%81%97%E3%82%87%E3%81%93', 'info_dict': { 'id': 'DbD_BPF', 'title': 'DbD_BPF:あっさりしょこ:capture', }, 'playlist_mincount': 10, }] def _entries(self, channel_id, search_type, search_query): api_url = f'{self._PUBLIC_API_BASE}/search-{search_type}s' type_map = { 'capture': OpenRecCaptureIE, 'live': OpenRecIE, 'movie': OpenRecMovieIE, } for page in itertools.count(1): search_items = self._download_json( api_url, channel_id, f'Downloading page {page}', query={ 'channel_ids': channel_id, 'page': page, 'search_query': search_query, }) if not search_items: break for item in search_items: item_type = 'live' if search_type == 'movie' and traverse_obj(item, ('is_live', {bool})) else search_type item_id = traverse_obj(item, ((None, 'capture'), 'id', {str_or_none}, any)) yield self.url_result( f'{self._BASE_URL}/{item_type}/{item_id}', type_map[item_type]) def _real_extract(self, url): channel_id, search_type = self._match_valid_url(url).group('id', 'type') search_query = traverse_obj(url, ( {parse_qs}, 'search_query', -1, {str}, filter, {require('search query', expected=True)})) if not search_type: entries = [] for search_type in ('capture', 'movie'): search_url = update_url_query( f'{self._BASE_URL}/user/{channel_id}/search/{search_type}', {'search_query': search_query}) entries.append(self.url_result(search_url, OpenRecChannelSearchIE)) return self.playlist_result( entries, channel_id, join_nonempty(channel_id, search_query, delim=':')) return self.playlist_result( self._entries(channel_id, search_type, search_query), channel_id, join_nonempty(channel_id, search_query, search_type, delim=':'))