diff --git a/yt_dlp/extractor/_extractors.py b/yt_dlp/extractor/_extractors.py index 348cb0e984..d913084534 100644 --- a/yt_dlp/extractor/_extractors.py +++ b/yt_dlp/extractor/_extractors.py @@ -2180,7 +2180,10 @@ from .tvc import ( TVCIE, TVCArticleIE, ) -from .tver import TVerIE +from .tver import ( + TVerIE, + TVerOlympicIE, +) from .tvigle import TvigleIE from .tviplayer import TVIPlayerIE from .tvn24 import TVN24IE diff --git a/yt_dlp/extractor/streaks.py b/yt_dlp/extractor/streaks.py index 60123d67b7..642e0527e3 100644 --- a/yt_dlp/extractor/streaks.py +++ b/yt_dlp/extractor/streaks.py @@ -22,7 +22,7 @@ class StreaksBaseIE(InfoExtractor): _GEO_BYPASS = False _GEO_COUNTRIES = ['JP'] - def _extract_from_streaks_api(self, project_id, media_id, headers=None, query=None, ssai=False): + def _extract_from_streaks_api(self, project_id, media_id, headers=None, query=None, ssai=False, live_from_start=False): try: response = self._download_json( self._API_URL_TEMPLATE.format('playback', project_id, media_id, ''), @@ -83,6 +83,10 @@ class StreaksBaseIE(InfoExtractor): fmts, subs = self._extract_m3u8_formats_and_subtitles( src_url, media_id, 'mp4', m3u8_id='hls', fatal=False, live=is_live, query=query) + for fmt in fmts: + if live_from_start: + fmt.setdefault('downloader_options', {}).update({'ffmpeg_args': ['-live_start_index', '0']}) + fmt['is_from_start'] = True formats.extend(fmts) self._merge_subtitles(subs, target=subtitles) diff --git a/yt_dlp/extractor/tver.py b/yt_dlp/extractor/tver.py index ffcc6a76b7..97f4d4feb5 100644 --- a/yt_dlp/extractor/tver.py +++ b/yt_dlp/extractor/tver.py @@ -4,6 +4,7 @@ from .streaks import StreaksBaseIE from ..utils import ( ExtractorError, GeoRestrictedError, + clean_html, int_or_none, join_nonempty, make_archive_id, @@ -11,7 +12,9 @@ from ..utils import ( str_or_none, strip_or_none, time_seconds, + unified_timestamp, update_url_query, + url_or_none, ) from ..utils.traversal import require, traverse_obj @@ -257,3 +260,113 @@ class TVerIE(StreaksBaseIE): 'id': video_id, '_old_archive_ids': [make_archive_id('BrightcoveNew', brightcove_id)] if brightcove_id else None, } + + +class TVerOlympicIE(StreaksBaseIE): + IE_NAME = 'tver:olympic' + + _API_BASE = 'https://olympic-data.tver.jp/api' + _VALID_URL = r'https?://(?:www\.)?tver\.jp/olympic/milanocortina2026/(?Plive|video)/play/(?P\w+)' + _TESTS = [{ + 'url': 'https://tver.jp/olympic/milanocortina2026/video/play/3b1d4462150b42558d9cc8aabb5238d0/', + 'info_dict': { + 'id': '3b1d4462150b42558d9cc8aabb5238d0', + 'ext': 'mp4', + 'title': '【開会式】ぎゅっと凝縮ハイライト', + 'display_id': 'ref:3b1d4462150b42558d9cc8aabb5238d0', + 'duration': 712.045, + 'live_status': 'not_live', + 'modified_date': r're:\d{8}', + 'modified_timestamp': int, + 'tags': 'count:1', + 'thumbnail': r're:https://.+\.(?:jpg|png)', + 'timestamp': 1770420187, + 'upload_date': '20260206', + 'uploader_id': 'tver-olympic', + }, + }, { + 'url': 'https://tver.jp/olympic/milanocortina2026/live/play/glts313itwvj/', + 'info_dict': { + 'id': 'glts313itwvj', + 'ext': 'mp4', + 'title': '開会式ハイライト', + 'channel_id': 'ntv', + 'display_id': 'ref:sp_260207_spc_01_dvr', + 'duration': 7680, + 'live_status': 'was_live', + 'modified_date': r're:\d{8}', + 'modified_timestamp': int, + 'thumbnail': r're:https://.+\.(?:jpg|png)', + 'timestamp': 1770420300, + 'upload_date': '20260206', + 'uploader_id': 'tver-olympic-live', + }, + }] + + def _real_extract(self, url): + video_type, video_id = self._match_valid_url(url).group('type', 'id') + live_from_start = self.get_param('live_from_start') + + if video_type == 'live': + project_id = 'tver-olympic-live' + api_key = 'a35ebb1ca7d443758dc7fcc5d99b1f72' + olympic_data = traverse_obj(self._download_json( + f'{self._API_BASE}/live/{video_id}', video_id), ('contents', 'live', {dict})) + media_id = traverse_obj(olympic_data, ('video_id', {str})) + + now = time_seconds() + start_timestamp_str = traverse_obj(olympic_data, ('onair_start_date', {str})) + start_timestamp = unified_timestamp(start_timestamp_str, tz_offset=9) + if not start_timestamp: + raise ExtractorError('Unable to extract on-air start time') + end_timestamp = traverse_obj(olympic_data, ( + 'onair_end_date', {unified_timestamp(tz_offset=9)}, {require('on-air end time')})) + + if now < start_timestamp: + self.raise_no_formats( + f'This program is scheduled to start at {start_timestamp_str} JST', expected=True) + + return { + 'id': video_id, + 'live_status': 'is_upcoming', + 'release_timestamp': start_timestamp, + } + elif start_timestamp <= now < end_timestamp: + live_status = 'is_live' + if live_from_start: + media_id += '_dvr' + elif end_timestamp <= now: + dvr_end_timestamp = traverse_obj(olympic_data, ( + 'dvr_end_date', {unified_timestamp(tz_offset=9)})) + if dvr_end_timestamp and now < dvr_end_timestamp: + live_status = 'was_live' + media_id += '_dvr' + else: + raise ExtractorError( + 'This program is no longer available', expected=True) + else: + project_id = 'tver-olympic' + api_key = '4b55a4db3cce4ad38df6dd8543e3e46a' + media_id = video_id + live_status = 'not_live' + olympic_data = traverse_obj(self._download_json( + f'{self._API_BASE}/video/{video_id}', video_id), ('contents', 'video', {dict})) + + return { + **self._extract_from_streaks_api(project_id, f'ref:{media_id}', { + 'Origin': 'https://tver.jp', + 'Referer': 'https://tver.jp/', + 'X-Streaks-Api-Key': api_key, + }, live_from_start=live_from_start), + **traverse_obj(olympic_data, { + 'title': ('title', {clean_html}, filter), + 'alt_title': ('sub_title', {clean_html}, filter), + 'channel': ('channel', {clean_html}, filter), + 'channel_id': ('channel_id', {clean_html}, filter), + 'description': (('description', 'description_l', 'description_s'), {clean_html}, filter, any), + 'timestamp': ('onair_start_date', {unified_timestamp(tz_offset=9)}), + 'thumbnail': (('picture_l_url', 'picture_m_url', 'picture_s_url'), {url_or_none}, any), + }), + 'id': video_id, + 'live_status': live_status, + } diff --git a/yt_dlp/options.py b/yt_dlp/options.py index 9658a9da51..39bd16e57b 100644 --- a/yt_dlp/options.py +++ b/yt_dlp/options.py @@ -511,7 +511,7 @@ def create_parser(): general.add_option( '--live-from-start', action='store_true', dest='live_from_start', - help='Download livestreams from the start. Currently experimental and only supported for YouTube and Twitch') + help='Download livestreams from the start. Currently experimental and only supported for YouTube, Twitch, and TVer') general.add_option( '--no-live-from-start', action='store_false', dest='live_from_start',