Harden yt-dlp binary resolution and analysis subprocess handling

- get_yt_dlp_path() no longer implicitly executes a bare "yt-dlp" from
  PATH (on Windows that lookup includes the CWD, so a planted binary
  in a writable directory could be run). A system yt-dlp is used only
  behind the explicit advanced.allow_system_ytdlp config opt-in, and
  then always as a which()-resolved absolute path. Analysis and
  download refuse to exec the not-installed sentinel.
- Analysis subprocesses now run in their own session and the whole
  process group is killed on timeout, so deno grandchildren no longer
  leak; partial stderr is preserved and logged, and output decoding is
  pinned to utf-8 with replacement (Windows locale codecs crashed on
  non-UTF8 titles).
- Flat-playlist entries are filtered for None (private/deleted first
  video no longer breaks analysis).
- update_yt_dlp() normalizes the sentinel to Path, unbreaking the pip
  fallback path that crashed on str.exists().

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jaroslav Beneš
2026-07-25 01:30:08 +02:00
parent 852804ee5c
commit ebb9422591
4 changed files with 80 additions and 22 deletions
+5 -1
View File
@@ -244,8 +244,12 @@ class DownloadThread(QThread):
def _build_yt_dlp_command(self) -> List[str]:
"""Build the yt-dlp command line with all options for direct execution."""
yt_dlp_path: str = get_yt_dlp_path()
if str(yt_dlp_path) == "yt-dlp":
# Sentinel: no managed binary and no opted-in system binary.
# Never exec a bare command name from PATH.
raise FileNotFoundError("yt-dlp is not installed - run the yt-dlp setup first")
# Build the command line array
cmd: List[str] = [yt_dlp_path]
cmd: List[str] = [str(yt_dlp_path)]
logger.debug(f"Using yt-dlp from: {yt_dlp_path}")
# Add concurrent fragments setting
+3 -2
View File
@@ -432,8 +432,9 @@ def save_path(main_window_instance: Any, path: Union[str, Path]) -> bool:
def update_yt_dlp() -> bool:
"""Check for yt-dlp updates and update if a newer version is available."""
try:
# Get the yt-dlp path
yt_dlp_path: Path = get_yt_dlp_path()
# Get the yt-dlp path (may be the bare "yt-dlp" sentinel string when
# not installed - normalize to Path so .exists()/.samefile() work)
yt_dlp_path: Path = Path(get_yt_dlp_path())
# Extra logic moved to src\utils\ytsage_constants.py
+17 -6
View File
@@ -612,19 +612,30 @@ def check_ytdlp_installed() -> bool:
def get_yt_dlp_path() -> Path:
"""
Get the yt-dlp path, either from the app's bin directory or system PATH.
This replaces the function in ytsage_utils.py.
Get the yt-dlp path. Prefers the app-managed, SHA256-verified binary;
a system-installed yt-dlp is only used when the user explicitly opts in
via the advanced.allow_system_ytdlp config key (resolved to an absolute
path so Windows' implicit CWD lookup can never pick up a planted binary).
Returns:
str: Path to yt-dlp binary
Path to yt-dlp binary, or the bare string "yt-dlp" sentinel meaning
"not installed" (triggers the setup dialog).
"""
# First check if we have yt-dlp in our app's bin directory or system PATH
ytdlp_path = check_ytdlp_binary()
if ytdlp_path:
logger.info(f"Using yt-dlp from: {ytdlp_path}")
return ytdlp_path
# If not found anywhere, fall back to the command name as a last resort
logger.info("yt-dlp not found in app directory or PATH, falling back to command name")
from ..utils.ytsage_config_manager import ConfigManager
if ConfigManager.get("advanced.allow_system_ytdlp"):
system_ytdlp = shutil.which("yt-dlp")
if system_ytdlp:
resolved = Path(system_ytdlp).resolve()
logger.info(f"Using system yt-dlp (advanced.allow_system_ytdlp): {resolved}")
return resolved
# Not installed - return the sentinel that triggers the setup dialog
logger.info("yt-dlp not found in app directory, returning setup sentinel")
return "yt-dlp" # type: ignore[return-value]