This commit is contained in:
2026-01-18 03:18:48 -08:00
parent 3f874af54a
commit aa675a625a
8 changed files with 508 additions and 161 deletions

View File

@@ -145,6 +145,7 @@ def list_formats(
no_playlist: bool = False,
playlist_items: Optional[str] = None,
cookiefile: Optional[str] = None,
timeout_seconds: int = 20,
) -> Optional[List[Dict[str, Any]]]:
"""Get available formats for a URL.
@@ -154,47 +155,67 @@ def list_formats(
if not is_url_supported_by_ytdlp(url):
return None
ensure_yt_dlp_ready()
assert yt_dlp is not None
result_container: List[Optional[Any]] = [None, None] # [result, error]
ydl_opts: Dict[str, Any] = {
"quiet": True,
"no_warnings": True,
"skip_download": True,
"noprogress": True,
}
def _do_list() -> None:
try:
ensure_yt_dlp_ready()
assert yt_dlp is not None
if cookiefile:
ydl_opts["cookiefile"] = str(cookiefile)
else:
# Best effort attempt to use browser cookies if no file is explicitly passed
ydl_opts["cookiesfrombrowser"] = "chrome"
ydl_opts: Dict[str, Any] = {
"quiet": True,
"no_warnings": True,
"skip_download": True,
"noprogress": True,
"socket_timeout": min(10, max(1, int(timeout_seconds))),
"retries": 2,
}
if no_playlist:
ydl_opts["noplaylist"] = True
if playlist_items:
ydl_opts["playlist_items"] = str(playlist_items)
if cookiefile:
ydl_opts["cookiefile"] = str(cookiefile)
else:
# Best effort attempt to use browser cookies if no file is explicitly passed
ydl_opts["cookiesfrombrowser"] = "chrome"
try:
with yt_dlp.YoutubeDL(ydl_opts) as ydl: # type: ignore[arg-type]
info = ydl.extract_info(url, download=False)
except Exception as exc:
debug(f"yt-dlp format probe failed for {url}: {exc}")
if no_playlist:
ydl_opts["noplaylist"] = True
if playlist_items:
ydl_opts["playlist_items"] = str(playlist_items)
with yt_dlp.YoutubeDL(ydl_opts) as ydl: # type: ignore[arg-type]
info = ydl.extract_info(url, download=False)
if not isinstance(info, dict):
result_container[0] = None
return
formats = info.get("formats")
if not isinstance(formats, list):
result_container[0] = None
return
out: List[Dict[str, Any]] = []
for fmt in formats:
if isinstance(fmt, dict):
out.append(fmt)
result_container[0] = out
except Exception as exc:
debug(f"yt-dlp format probe failed for {url}: {exc}")
result_container[1] = exc
# Use daemon=True so a hung thread doesn't block process exit
thread = threading.Thread(target=_do_list, daemon=True)
thread.start()
thread.join(timeout=max(1, int(timeout_seconds)))
if thread.is_alive():
debug(f"yt-dlp format probe timed out for {url} (>={timeout_seconds}s)")
return None
if not isinstance(info, dict):
if result_container[1] is not None:
return None
formats = info.get("formats")
if not isinstance(formats, list):
return None
out: List[Dict[str, Any]] = []
for fmt in formats:
if isinstance(fmt, dict):
out.append(fmt)
return out
return cast(Optional[List[Dict[str, Any]]], result_container[0])
def probe_url(
@@ -216,6 +237,7 @@ def probe_url(
def _do_probe() -> None:
try:
debug(f"[probe] Starting probe for {url}")
ensure_yt_dlp_ready()
assert yt_dlp is not None
@@ -235,7 +257,9 @@ def probe_url(
ydl_opts["noplaylist"] = True
with yt_dlp.YoutubeDL(ydl_opts) as ydl: # type: ignore[arg-type]
debug(f"[probe] ytdlp extract_info (download=False) start: {url}")
info = ydl.extract_info(url, download=False)
debug(f"[probe] ytdlp extract_info (download=False) done: {url}")
if not isinstance(info, dict):
result_container[0] = None
@@ -258,7 +282,8 @@ def probe_url(
debug(f"Probe error for {url}: {exc}")
result_container[1] = exc
thread = threading.Thread(target=_do_probe, daemon=False)
# Use daemon=True so a hung probe doesn't block the process
thread = threading.Thread(target=_do_probe, daemon=True)
thread.start()
thread.join(timeout=timeout_seconds)
@@ -1194,6 +1219,7 @@ except ImportError:
def download_media(opts: DownloadOptions, *, debug_logger: Optional[DebugLogger] = None) -> Any:
"""Download streaming media exclusively via yt-dlp."""
debug(f"[download_media] start: {opts.url}")
try:
netloc = urlparse(opts.url).netloc.lower()
except Exception:
@@ -1536,20 +1562,37 @@ def _download_with_timeout(opts: DownloadOptions, timeout_seconds: int = 300) ->
except Exception as exc:
result_container[1] = exc
thread = threading.Thread(target=_do_download, daemon=False)
# Use daemon=True so a hung download doesn't block process exit if the wall timeout hits.
thread = threading.Thread(target=_do_download, daemon=True)
thread.start()
start_time = time.monotonic()
# We use two timeouts:
# 1. Activity timeout (no progress updates for X seconds)
# 2. Hard wall-clock timeout (total time for this URL)
# The wall-clock timeout is slightly larger than the activity timeout
# to allow for slow-but-steady progress, up to a hard cap (e.g. 10 minutes).
wall_timeout = max(timeout_seconds * 2, 600)
_record_progress_activity(start_time)
try:
while thread.is_alive():
thread.join(1)
if not thread.is_alive():
break
now = time.monotonic()
# Check activity timeout
last_activity = _get_last_progress_activity()
if last_activity <= 0:
last_activity = start_time
if time.monotonic() - last_activity > timeout_seconds:
raise DownloadError(f"Download timeout after {timeout_seconds} seconds for {opts.url}")
if now - last_activity > timeout_seconds:
raise DownloadError(f"Download activity timeout after {timeout_seconds} seconds for {opts.url}")
# Check hard wall-clock timeout
if now - start_time > wall_timeout:
raise DownloadError(f"Download hard timeout after {wall_timeout} seconds for {opts.url}")
finally:
_clear_progress_activity()