diff --git a/README.md b/README.md index 634cff5a5..7b8f3f669 100644 --- a/README.md +++ b/README.md @@ -352,9 +352,13 @@ ## General Options: (Experimental) --no-live-from-start Download livestreams from the current time (default) - --wait-for-video MIN[-MAX] Wait for scheduled streams to become + --wait-for-video MIN[-MAX][:RETRIES] + Wait for scheduled streams to become available. Pass the minimum number of - seconds (or range) to wait between retries + seconds (or range) to wait between retries. + RETRIES is the maximum number of additional + attempts if the video is still unavailable + after waiting (default is infinite) --no-wait-for-video Do not wait for scheduled streams (default) --mark-watched Mark videos watched (even with --simulate) --no-mark-watched Do not mark videos watched (default) diff --git a/yt_dlp/YoutubeDL.py b/yt_dlp/YoutubeDL.py index 8790b326b..b207b98c9 100644 --- a/yt_dlp/YoutubeDL.py +++ b/yt_dlp/YoutubeDL.py @@ -105,6 +105,7 @@ Popen, PostProcessingError, ReExtractInfo, + ReExtractInfoLater, RejectedVideoReached, SameFileError, UnavailableVideoError, @@ -1646,11 +1647,27 @@ def extract_info(self, url, download=True, ie_key=None, extra_info=None, def _handle_extraction_exceptions(func): @functools.wraps(func) def wrapper(self, *args, **kwargs): + wait_retries = 0 + wait_range = self.params.get('wait_for_video') + max_retries = float('inf') + if wait_range and wait_range[2] is not None: + max_retries = wait_range[2] while True: try: return func(self, *args, **kwargs) except (CookieLoadError, DownloadCancelled, LazyList.IndexError, PagedList.IndexError): raise + except ReExtractInfoLater as e: + if wait_retries > max_retries: + if max_retries > 0: + self.report_error(f'[wait] Giving up after {wait_retries - 1} {"retries" if wait_retries != 2 else "retry"} while waiting.') + else: + self.report_error('[wait] Video is still unavailable after waiting.') + return + self._wait_until(e.time) + wait_retries += 1 + self.to_screen('[wait] Re-extracting data') + continue except ReExtractInfo as e: if e.expected: self.to_screen(f'{e}; Re-extracting data') @@ -1675,12 +1692,7 @@ def wrapper(self, *args, **kwargs): break return wrapper - def _wait_for_video(self, ie_result={}): - if (not self.params.get('wait_for_video') - or ie_result.get('_type', 'video') != 'video' - or ie_result.get('formats') or ie_result.get('url')): - return - + def _wait_until(self, till): format_dur = lambda dur: '%02d:%02d:%02d' % timetuple_from_msec(dur * 1000)[:-1] last_msg = '' @@ -1694,7 +1706,31 @@ def progress(msg): self.to_screen(full_msg, skip_eol=True) last_msg = msg - min_wait, max_wait = self.params.get('wait_for_video') + diff = till - time.time() + self.to_screen(f'[wait] Waiting for {format_dur(diff)} - Press Ctrl+C to interrupt') + try: + while True: + diff = till - time.time() + if diff <= 0: + progress('') + self.to_screen('[wait] Wait period ended') + return + progress(f'[wait] Remaining time until next attempt: {self._format_screen(format_dur(diff), self.Styles.EMPHASIS)}') + time.sleep(1) + except KeyboardInterrupt: + progress('') + self.to_screen('[wait] Interrupted by user') + except BaseException: + self.to_screen('') + raise + + def _wait_for_video(self, ie_result={}): + if (not self.params.get('wait_for_video') + or ie_result.get('_type', 'video') != 'video' + or ie_result.get('formats') or ie_result.get('url')): + return + + min_wait, max_wait, _ = self.params.get('wait_for_video') diff = try_get(ie_result, lambda x: x['release_timestamp'] - time.time()) if diff is None and ie_result.get('live_status') == 'is_upcoming': diff = round(random.uniform(min_wait, max_wait) if (max_wait and min_wait) else (max_wait or min_wait), 0) @@ -1702,24 +1738,7 @@ def progress(msg): elif ie_result and (diff or 0) <= 0: self.report_warning('Video should already be available according to extracted info') diff = min(max(diff or 0, min_wait or 0), max_wait or float('inf')) - self.to_screen(f'[wait] Waiting for {format_dur(diff)} - Press Ctrl+C to try now') - - wait_till = time.time() + diff - try: - while True: - diff = wait_till - time.time() - if diff <= 0: - progress('') - raise ReExtractInfo('[wait] Wait period ended', expected=True) - progress(f'[wait] Remaining time until next attempt: {self._format_screen(format_dur(diff), self.Styles.EMPHASIS)}') - time.sleep(1) - except KeyboardInterrupt: - progress('') - raise ReExtractInfo('[wait] Interrupted by user', expected=True) - except BaseException as e: - if not isinstance(e, ReExtractInfo): - self.to_screen('') - raise + raise ReExtractInfoLater(time.time() + diff) def _load_cookies(self, data, *, autoscope=True): """Loads cookies from a `Cookie` header diff --git a/yt_dlp/__init__.py b/yt_dlp/__init__.py index 7d8f10047..6998949fa 100644 --- a/yt_dlp/__init__.py +++ b/yt_dlp/__init__.py @@ -224,12 +224,37 @@ def validate_minmax(min_val, max_val, min_name, max_name=None): else: validate_minmax(opts.sleep_interval, opts.max_sleep_interval, 'sleep interval') + def parse_retries(name, value): + if value is None: + return None + elif value in ('inf', 'infinite'): + return float('inf') + try: + int_value = int(value) + except (TypeError, ValueError): + validate(False, f'{name} retry count', value) + validate_positive(f'{name} retry count', int_value) + return int_value + + def parse_range_with_arg(name, arg_name, value, + parse_limits=parse_duration, parse_arg=parse_retries): + # syntax: MIN[-MAX][:N] + m = re.fullmatch(r'([^-:]+)(-[^:]+)?(:.+)?', value) + validate(m, name, value) + min_val, max_val, arg_val = m.groups() + + min_lim, max_lim = map(parse_limits, [min_val, (max_val and max_val[1:])]) + validate(min_lim is not None, name, value) + validate(max_val is None or max_lim is not None, name, value) + validate_minmax(min_lim, max_lim, name) + + parsed_arg = parse_arg(arg_name, arg_val and arg_val[1:]) + return (min_lim, max_lim, parsed_arg) + if opts.wait_for_video is not None: - min_wait, max_wait, *_ = map(parse_duration, [*opts.wait_for_video.split('-', 1), None]) - validate(min_wait is not None and not (max_wait is None and '-' in opts.wait_for_video), - 'time range to wait for video', opts.wait_for_video) - validate_minmax(min_wait, max_wait, 'time range to wait for video') - opts.wait_for_video = (min_wait, max_wait) + min_wait, max_wait, wait_retries = parse_range_with_arg( + 'time range to wait for video', 'waiting', opts.wait_for_video) + opts.wait_for_video = (min_wait, max_wait, wait_retries) # Format sort for f in opts.format_sort: @@ -254,18 +279,6 @@ def validate_minmax(min_val, max_val, min_name, max_name=None): validate_positive('audio quality', int_or_none(float_or_none(opts.audioquality), default=0)) # Retries - def parse_retries(name, value): - if value is None: - return None - elif value in ('inf', 'infinite'): - return float('inf') - try: - int_value = int(value) - except (TypeError, ValueError): - validate(False, f'{name} retry count', value) - validate_positive(f'{name} retry count', int_value) - return int_value - opts.retries = parse_retries('download', opts.retries) opts.fragment_retries = parse_retries('fragment', opts.fragment_retries) opts.extractor_retries = parse_retries('extractor', opts.extractor_retries) diff --git a/yt_dlp/options.py b/yt_dlp/options.py index 91c2635a7..f045e99f4 100644 --- a/yt_dlp/options.py +++ b/yt_dlp/options.py @@ -445,10 +445,12 @@ def _alias_callback(option, opt_str, value, parser, opts, nargs): help='Download livestreams from the current time (default)') general.add_option( '--wait-for-video', - dest='wait_for_video', metavar='MIN[-MAX]', default=None, + dest='wait_for_video', metavar='MIN[-MAX][:RETRIES]', default=None, help=( 'Wait for scheduled streams to become available. ' - 'Pass the minimum number of seconds (or range) to wait between retries')) + 'Pass the minimum number of seconds (or range) to wait between retries. ' + 'RETRIES is the maximum number of additional attempts if the video ' + 'is still unavailable after waiting (default is infinite)')) general.add_option( '--no-wait-for-video', dest='wait_for_video', action='store_const', const=None, diff --git a/yt_dlp/utils/_utils.py b/yt_dlp/utils/_utils.py index 4093c238c..9216644a8 100644 --- a/yt_dlp/utils/_utils.py +++ b/yt_dlp/utils/_utils.py @@ -1115,6 +1115,15 @@ def __init__(self, msg, expected=False): self.expected = expected +class ReExtractInfoLater(ReExtractInfo): + """ Video info needs to be re-extracted after a waiting period. """ + msg = 'Video is not available yet' + + def __init__(self, time): + super().__init__(self.msg, expected=True) + self.time = time + + class ThrottledDownload(ReExtractInfo): """ Download speed below --throttled-rate. """ msg = 'The download speed is below throttle limit'