diff --git a/haruhi_dl/extractor/extractors.py b/haruhi_dl/extractor/extractors.py index 21618f052..d7ccc2ace 100644 --- a/haruhi_dl/extractor/extractors.py +++ b/haruhi_dl/extractor/extractors.py @@ -1618,6 +1618,10 @@ from .weibo import ( from .weiqitv import WeiqiTVIE from .wistia import WistiaIE from .worldstarhiphop import WorldStarHipHopIE +from .wppilot import ( + WPPilotIE, + WPPilotChannelsIE, +) from .wppl import WpPlIE from .wsj import ( WSJIE, diff --git a/haruhi_dl/extractor/wppilot.py b/haruhi_dl/extractor/wppilot.py new file mode 100644 index 000000000..6c6addda2 --- /dev/null +++ b/haruhi_dl/extractor/wppilot.py @@ -0,0 +1,184 @@ +# coding: utf-8 + +from .common import InfoExtractor +from ..utils import ( + std_headers, + try_get, + ExtractorError, +) + +import json +import random +import re + + +class WPPilotBaseIE(InfoExtractor): + _NETRC_MACHINE = 'wppilot' + _LOGGED_IN = False + + _VIDEO_URL = 'https://pilot.wp.pl/api/v1/channel/%s' + _VIDEO_GUEST_URL = 'https://pilot.wp.pl/api/v1/guest/channel/%s' + _VIDEO_LIST_URL = 'https://pilot.wp.pl/api/v1/channels/list' + _VIDEO_CLOSE_URL = 'https://pilot.wp.pl/api/v1/channels/close' + _LOGIN_URL = 'https://pilot.wp.pl/api/v1/user_auth/login' + + _HEADERS_ATV = { + 'User-Agent': 'ExoMedia 4.3.0 (43000) / Android 8.0.0 / foster_e', + 'Accept': 'application/json', + 'X-Version': 'pl.videostar|3.25.0|Android|26|foster_e', + 'Content-Type': 'application/json; charset=UTF-8', + } + _HEADERS_WEB = { + 'Content-Type': 'application/json; charset=UTF-8', + 'Referer': 'https://pilot.wp.pl/tv/', + } + _STREAM_HEADERS_WEB = { + 'Referer': 'https://pilot.wp.pl/', + 'Origin': 'https://pilot.wp.pl', + } + + def _real_initialize(self): + self._login() + + def _login(self): + username, password = self._get_login_info() + if not username: + return None + + login = self._download_json( + self._LOGIN_URL, None, 'Logging in', 'Unable to log in', + headers=self._HEADERS_ATV, + data=bytes(json.dumps({ + 'device': 'android_tv', + 'login': username, + 'password': password, + }).encode('utf-8'))) + + error = try_get(login, lambda x: x['_meta']['error']['name']) + if error: + raise ExtractorError(f'WP login error: "{error}"') + self._LOGGED_IN = True + + def _get_channel_list(self, cache=True): + if cache is True: + cache_res = self._downloader.cache.load('wppilot', 'channel-list') + if cache_res: + cache_res['_hdl_cached'] = True + return cache_res + res = self._download_json( + self._VIDEO_LIST_URL, None, 'Downloading channel list') + self._downloader.cache.store('wppilot', 'channel-list', res) + return res + + def _parse_channel(self, chan, categories): + thumbnails = [] + for key in ('thumbnail', 'thumbnail_mobile', 'thumbnail_mobile_bg', 'icon'): + if chan.get(key): + thumbnails.append({ + 'id': key, + 'url': chan[key], + }) + return { + 'id': str(chan['id']), + 'title': chan['name'], + 'categories': [categories[str(i)] for i in chan['categories']], + } + + +class WPPilotIE(WPPilotBaseIE): + _VALID_URL = r'(?:https?://pilot\.wp\.pl/tv/?#|wppilot:)(?P[a-z\d-]+)' + IE_NAME = 'wppilot' + + def _get_channel(self, id_or_slug): + video_list = self._get_channel_list(cache=True) + key = 'id' if re.match(r'^\d+$', id_or_slug) else 'slug' + for video in video_list['data']: + if video.get(key) == id_or_slug: + return self._parse_channel(video, video_list['_meta']['categories']) + # if cached channel not found, download and retry + if video_list.get('_hdl_cached') is True: + video_list = self._get_channel_list(cache=False) + for video in video_list['data']: + if video.get(key) == id_or_slug: + return self._parse_channel(video, video_list['_meta']['categories']) + raise ExtractorError('Channel not found') + + def _real_extract(self, url): + video_id = self._match_id(url) + + channel = self._get_channel(video_id) + video_id = str(channel['id']) + if self._LOGGED_IN: + video = self._download_json( + self._VIDEO_URL % video_id, video_id, query={ + 'format_id': '2', + 'device_type': 'android', + }, headers=self._HEADERS_ATV, expected_status=(200, 422)) + else: + video = self._download_json( + self._VIDEO_GUEST_URL % video_id, video_id, query={ + 'device_type': 'web', + }, headers=self._HEADERS_WEB, expected_status=(200)) + + stream_token = try_get(video, lambda x: x['_meta']['error']['info']['stream_token']) + if stream_token: + close = self._download_json( + self._VIDEO_CLOSE_URL, video_id, 'Invalidating previous stream session', + headers=self._HEADERS_ATV, + data=bytes(json.dumps({ + 'channelId': video_id, + 't': stream_token, + }).encode('utf-8'))) + if try_get(close, lambda x: x['data']['status']) == 'ok': + return self.url_result('wppilot:%s' % video_id, ie=WPPilotIE.ie_key()) + + formats = [] + stream_headers = {} + if self._LOGGED_IN: + ua = self._HEADERS_ATV['User-Agent'] + else: + ua = std_headers['User-Agent'] + stream_headers['User-Agent'] = ua + + for fmt in video['data']['stream_channel']['streams']: + # MPD does not work for some reason + # if fmt['type'] == 'dash@live:abr': + # formats.extend( + # self._extract_mpd_formats( + # random.choice(fmt['url']), video_id)) + if fmt['type'] == 'hls@live:abr': + formats.extend( + self._extract_m3u8_formats( + random.choice(fmt['url']), + video_id, headers=stream_headers)) + for i in range(len(formats)): + formats[i]['http_headers'] = stream_headers + + self._sort_formats(formats) + + channel['formats'] = formats + return channel + + +class WPPilotChannelsIE(WPPilotBaseIE): + _VALID_URL = r'(?:https?://pilot\.wp\.pl/(?:tv/?)?(?:\?[^#]*)?#?|wppilot:)$' + IE_NAME = 'wppilot:channels' + + def _real_extract(self, url): + channel_list = self._get_channel_list() + categories = channel_list['_meta']['categories'] + entries = [] + for chan in channel_list['data']: + entry = self._parse_channel(chan, categories) + entry.update({ + '_type': 'url_transparent', + 'url': f'wppilot:{chan["id"]}', + 'ie_key': WPPilotIE.ie_key(), + }) + entries.append(entry) + return { + '_type': 'playlist', + 'id': 'wppilot', + 'entries': entries, + 'title': 'WP Pilot', + }