Switch bundled yt-dlp to a Python venv with curl_cffi for impersonation

The previous standalone PyInstaller binary couldn't run `--impersonate`
because curl_cffi isn't packaged into it. Bundled mode lost the
bot-detection bypass as a result.

Install layout is now:
  ~/.local/share/yt-offline/
    bin/deno              ← unchanged: prepended to PATH for JS deciphering
    venv/bin/yt-dlp       ← new: pip-installed yt-dlp[default] + curl_cffi

The install script:
- Errors clearly if python3 or the `venv` module isn't available
  (the latter is split out on Debian as `python3-venv`).
- Creates the venv if missing, otherwise reuses it for upgrades.
- pip-installs `yt-dlp[default]` and `curl_cffi`, both with PyPI's
  built-in SHA-256 verification.
- Cleans up the legacy `bin/yt-dlp` PyInstaller binary if a prior
  install left one behind.
- Probes `--list-impersonate-targets` at the end and logs whether
  curl_cffi loaded successfully.

Downloader::apply_impersonation now passes --impersonate
unconditionally — both bundled (via the venv) and system yt-dlp (via
pip-installed curl_cffi) honor it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Luna 2026-05-24 23:17:27 -07:00
parent 26bf619a23
commit 770ef66623
3 changed files with 148 additions and 124 deletions

View file

@ -184,14 +184,13 @@ impl Downloader {
} }
} }
/// Append `--impersonate Chrome-146:Macos-26` only when the active yt-dlp /// Append `--impersonate Chrome-146:Macos-26` to bypass YouTube's bot
/// supports it. The standalone PyInstaller binary we ship as the bundled /// detection. Both the bundled venv (which pip-installs `curl_cffi` via
/// option doesn't include `curl_cffi`, so passing `--impersonate` to it /// [`ytdlp_bin::install_command`]) and a system yt-dlp with curl_cffi
/// errors out with `Impersonate target "chrome-146:macos-26" is not /// support this. We leave it on unconditionally now; yt-dlp's
/// available`. System yt-dlp (typically pip-installed with curl_cffi) /// `--list-impersonate-targets` warning surfaces in the install log if
/// works fine. /// curl_cffi is unavailable, so the user has a clear path forward.
fn apply_impersonation(&self, cmd: &mut Command) { fn apply_impersonation(&self, cmd: &mut Command) {
if self.use_bundled_ytdlp { return; }
cmd.arg("--impersonate").arg("Chrome-146:Macos-26"); cmd.arg("--impersonate").arg("Chrome-146:Macos-26");
} }

View file

@ -1255,13 +1255,11 @@ async fn get_preview(
} else if !browser.is_empty() && browser != "none" { } else if !browser.is_empty() && browser != "none" {
cmd.arg("--cookies-from-browser").arg(&browser); cmd.arg("--cookies-from-browser").arg(&browser);
} }
// Only attach --impersonate if the active yt-dlp supports it. Our // The bundled venv install now ships `curl_cffi` (see
// bundled (PyInstaller) binary doesn't ship curl_cffi, so the flag // `crate::ytdlp_bin::install_command`), so --impersonate works in both
// would error out. System yt-dlp installed via pip with curl_cffi // bundled and system modes.
// does support it. let _ = use_bundled;
if !use_bundled {
cmd.arg("--impersonate").arg("Chrome-146:Macos-26"); cmd.arg("--impersonate").arg("Chrome-146:Macos-26");
}
let output = cmd let output = cmd
.arg(&url) .arg(&url)
.stdin(Stdio::null()) .stdin(Stdio::null())

View file

@ -1,43 +1,66 @@
//! Management of bundled yt-dlp + deno binaries. //! Management of the bundled yt-dlp + deno binaries.
//! //!
//! When [`crate::config::BackupSection::use_bundled_ytdlp`] is enabled, all //! When [`crate::config::BackupSection::use_bundled_ytdlp`] is enabled the
//! yt-dlp invocations use a binary under `~/.local/share/yt-offline/bin/` //! app invokes its own yt-dlp instead of whatever's on PATH. To get the
//! instead of the system PATH. A bundled `deno` lives alongside so yt-dlp can //! full feature set — most importantly `curl_cffi`-backed `--impersonate`
//! evaluate the JavaScript signature-deciphering code YouTube serves with the //! support — we install yt-dlp into a self-contained Python virtualenv
//! player — without depending on a system-wide JS runtime. //! under `~/.local/share/yt-offline/venv/`. A bundled `deno` lives at
//! `~/.local/share/yt-offline/bin/deno` so yt-dlp can evaluate the
//! JavaScript signature-deciphering code YouTube serves with the player.
//! //!
//! Both binaries are downloaded on demand from the official GitHub releases by //! Layout:
//! [`install_command`], which returns a shell pipeline that curls and unpacks //! ```text
//! them. The pipeline is run as a regular yt-dlp [`crate::downloader::Job`] so //! ~/.local/share/yt-offline/
//! the user sees progress in the same UI. //! bin/ ← prepended to PATH so yt-dlp finds deno
//! deno
//! venv/
//! bin/yt-dlp ← the real entry point (or Scripts\yt-dlp.exe on Windows)
//! lib/python*/site-packages/{yt_dlp, curl_cffi, ...}
//! ```
//!
//! [`install_command`] returns the shell pipeline that builds this tree.
//! It's run as a regular [`crate::downloader::Job`] so the user sees
//! progress in the same UI as their other downloads.
use std::path::PathBuf; use std::path::PathBuf;
use std::process::Command; use std::process::Command;
/// Directory holding the bundled binaries (`yt-dlp`, `deno`). /// Root directory holding everything bundled — venv + bin.
pub fn bundled_dir() -> PathBuf { pub fn bundled_root() -> PathBuf {
let home = std::env::var_os("HOME") let home = std::env::var_os("HOME")
.map(PathBuf::from) .map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from(".")); .unwrap_or_else(|| PathBuf::from("."));
home.join(".local").join("share").join("yt-offline").join("bin") home.join(".local").join("share").join("yt-offline")
} }
/// File name of the yt-dlp executable on this platform. /// Directory holding non-Python binaries (currently just `deno`). Also the
fn ytdlp_filename() -> &'static str { /// path we prepend to `PATH` when spawning yt-dlp so it can locate deno.
if cfg!(windows) { "yt-dlp.exe" } else { "yt-dlp" } pub fn bundled_dir() -> PathBuf {
bundled_root().join("bin")
} }
/// Full path the bundled yt-dlp binary should live at. /// Root of the Python virtualenv that hosts the bundled yt-dlp install.
pub fn bundled_venv() -> PathBuf {
bundled_root().join("venv")
}
/// Full path to the bundled yt-dlp entry point. On Unix this lives at
/// `venv/bin/yt-dlp`; on Windows it's `venv/Scripts/yt-dlp.exe`.
pub fn bundled_ytdlp_path() -> PathBuf { pub fn bundled_ytdlp_path() -> PathBuf {
bundled_dir().join(ytdlp_filename()) let venv = bundled_venv();
if cfg!(windows) {
venv.join("Scripts").join("yt-dlp.exe")
} else {
venv.join("bin").join("yt-dlp")
}
} }
/// Returns the yt-dlp invocation target for [`std::process::Command::new`]. /// Returns the yt-dlp invocation target for [`std::process::Command::new`].
/// ///
/// With `use_bundled = true` returns the absolute path to the bundled binary /// With `use_bundled = true` returns the absolute path to the venv-installed
/// (even if it does not yet exist — yt-dlp simply fails to launch and the /// yt-dlp (even if it doesn't yet exist — the spawn error surfaces in the
/// error surfaces in the job log, prompting the user to click Update). /// job log and prompts the user to click Install). Otherwise returns the
/// Otherwise returns the bare `"yt-dlp"` string so the system PATH is used. /// bare `"yt-dlp"` string so the system PATH is used.
pub fn ytdlp_invocation(use_bundled: bool) -> PathBuf { pub fn ytdlp_invocation(use_bundled: bool) -> PathBuf {
if use_bundled { if use_bundled {
bundled_ytdlp_path() bundled_ytdlp_path()
@ -46,7 +69,7 @@ pub fn ytdlp_invocation(use_bundled: bool) -> PathBuf {
} }
} }
/// True if the bundled yt-dlp binary currently exists on disk. /// True if the bundled yt-dlp has been installed.
pub fn bundled_installed() -> bool { pub fn bundled_installed() -> bool {
bundled_ytdlp_path().exists() bundled_ytdlp_path().exists()
} }
@ -62,6 +85,8 @@ pub fn ensure_bundled_executable() {
#[cfg(unix)] #[cfg(unix)]
{ {
use std::os::unix::fs::PermissionsExt; use std::os::unix::fs::PermissionsExt;
// The venv's `bin/` already gets +x via pip; we only need to
// re-apply on the extras we placed in `bundled_dir/`.
let dir = bundled_dir(); let dir = bundled_dir();
let Ok(entries) = std::fs::read_dir(&dir) else { return }; let Ok(entries) = std::fs::read_dir(&dir) else { return };
for entry in entries.flatten() { for entry in entries.flatten() {
@ -80,17 +105,6 @@ pub fn ensure_bundled_executable() {
} }
} }
/// GitHub release asset name for yt-dlp on this platform.
fn ytdlp_asset() -> &'static str {
if cfg!(target_os = "windows") {
"yt-dlp.exe"
} else if cfg!(target_os = "macos") {
"yt-dlp_macos"
} else {
"yt-dlp_linux"
}
}
/// GitHub release asset name for deno on this platform. /// GitHub release asset name for deno on this platform.
fn deno_asset() -> &'static str { fn deno_asset() -> &'static str {
match (std::env::consts::OS, std::env::consts::ARCH) { match (std::env::consts::OS, std::env::consts::ARCH) {
@ -103,37 +117,46 @@ fn deno_asset() -> &'static str {
} }
} }
/// Build a [`Command`] that installs or updates the bundled yt-dlp + deno /// Build a [`Command`] that installs (or upgrades) the bundled yt-dlp into
/// binaries by running a curl/unzip pipeline through `bash -c` (or PowerShell /// a Python virtualenv with `curl_cffi` (for `--impersonate` support) and
/// on Windows). On success, both binaries are present in [`bundled_dir`] with /// the rest of `yt-dlp[default]`, plus a sibling `deno` for JS deciphering.
/// executable bit set. ///
/// Runs through `bash -c` on Unix and PowerShell on Windows.
pub fn install_command() -> Command { pub fn install_command() -> Command {
let dir = bundled_dir(); let root = bundled_root();
let dir_str = dir.display().to_string(); let root_str = root.display().to_string();
let ytdlp_url = format!( let bin_str = bundled_dir().display().to_string();
"https://github.com/yt-dlp/yt-dlp/releases/latest/download/{}", let venv_str = bundled_venv().display().to_string();
ytdlp_asset()
);
let deno_url = format!( let deno_url = format!(
"https://github.com/denoland/deno/releases/latest/download/{}", "https://github.com/denoland/deno/releases/latest/download/{}",
deno_asset() deno_asset()
); );
let ytdlp_name = ytdlp_filename();
#[cfg(windows)] #[cfg(windows)]
{ {
let script = format!( let script = format!(
"$ErrorActionPreference='Stop'; \ "$ErrorActionPreference='Stop'; \
New-Item -ItemType Directory -Force -Path '{dir}' | Out-Null; \ New-Item -ItemType Directory -Force -Path '{root}' | Out-Null; \
Write-Host '==> downloading yt-dlp'; \ New-Item -ItemType Directory -Force -Path '{bin}' | Out-Null; \
Invoke-WebRequest -Uri '{yurl}' -OutFile '{dir}\\{ybin}'; \ $py = (Get-Command py -ErrorAction SilentlyContinue) ?? (Get-Command python -ErrorAction SilentlyContinue); \
if (-not $py) {{ Write-Error 'python is not installed'; exit 1 }}; \
if (-not (Test-Path '{venv}\\Scripts\\python.exe')) {{ \
Write-Host '==> creating Python venv'; \
& $py.Source -m venv '{venv}'; \
}}; \
Write-Host '==> installing yt-dlp + curl_cffi'; \
& '{venv}\\Scripts\\python.exe' -m pip install --upgrade pip; \
& '{venv}\\Scripts\\python.exe' -m pip install --upgrade 'yt-dlp[default]' curl_cffi; \
Write-Host '==> downloading deno'; \ Write-Host '==> downloading deno'; \
Invoke-WebRequest -Uri '{durl}' -OutFile '{dir}\\deno.zip'; \ Invoke-WebRequest -Uri '{durl}' -OutFile '{bin}\\deno.zip'; \
Write-Host '==> extracting deno'; \ Write-Host '==> extracting deno'; \
Expand-Archive -Force '{dir}\\deno.zip' '{dir}'; \ Expand-Archive -Force '{bin}\\deno.zip' '{bin}'; \
Remove-Item '{dir}\\deno.zip'; \ Remove-Item '{bin}\\deno.zip'; \
Write-Host '==> versions'; & '{dir}\\{ybin}' --version; & '{dir}\\deno.exe' --version", Write-Host '==> versions'; \
dir = dir_str, yurl = ytdlp_url, durl = deno_url, ybin = ytdlp_name, & '{venv}\\Scripts\\yt-dlp.exe' --version; \
& '{bin}\\deno.exe' --version; \
Write-Host '==> done'",
root = root_str, bin = bin_str, venv = venv_str, durl = deno_url,
); );
let mut cmd = Command::new("powershell"); let mut cmd = Command::new("powershell");
cmd.arg("-NoProfile").arg("-Command").arg(script); cmd.arg("-NoProfile").arg("-Command").arg(script);
@ -141,83 +164,87 @@ pub fn install_command() -> Command {
} }
#[cfg(not(windows))] #[cfg(not(windows))]
{ {
// Download each file in the background and poll the growing file size // Set up yt-dlp inside a venv so we get the full pip-installable
// every 3 s so the job log shows visible progress during the transfer // distribution — including `curl_cffi`, which is what enables
// instead of going silent for minutes. // `--impersonate` support. The standalone PyInstaller binary we
// previously shipped lacks curl_cffi and errors out on impersonate
// targets. The venv strategy is also what yt-dlp's own docs
// recommend for getting a "full" install.
// //
// For yt-dlp we additionally fetch its published `SHA2-256SUMS` file // Pip-installed packages come with their own SHA-256 checks via
// and verify the binary against it — that file is signed-by-presence // PyPI's metadata, so we skip the manual checksum step we used to
// in the same GitHub release, so any tampering would have to compromise // do for the standalone binary.
// both URLs to slip a bad binary through. Deno doesn't publish a //
// similarly convenient single-file checksum manifest, so we just print // Deno is unchanged: it's a single static binary that we drop into
// its SHA-256 for visual inspection. // `bin/` alongside the (unused) directory; yt-dlp finds it via PATH
let sums_url = "https://github.com/yt-dlp/yt-dlp/releases/latest/download/SHA2-256SUMS"; // when we prepend `bin/` in [`crate::downloader::Downloader::spawn_job`].
let ytdlp_asset_name = ytdlp_asset();
let script = format!( let script = format!(
r#"set -e r#"set -e
command -v curl >/dev/null || {{ echo 'error: curl not installed'; exit 1; }} command -v curl >/dev/null || {{ echo 'error: curl not installed'; exit 1; }}
command -v unzip >/dev/null || {{ echo 'error: unzip not installed'; exit 1; }} command -v unzip >/dev/null || {{ echo 'error: unzip not installed'; exit 1; }}
command -v sha256sum >/dev/null || {{ echo 'error: sha256sum not installed'; exit 1; }} if ! command -v python3 >/dev/null; then
mkdir -p '{dir}' echo 'error: python3 is not installed.'
echo ' Install it from your distro package manager and try again.'
# yt-dlp
echo '==> downloading yt-dlp'
curl -fL --no-progress-meter --connect-timeout 30 --max-time 600 --retry 3 \
-o '{dir}/{ybin}' '{yurl}' &
DLPID=$!
while kill -0 $DLPID 2>/dev/null; do
sleep 3
SZ=$(wc -c < '{dir}/{ybin}' 2>/dev/null || echo 0)
echo " yt-dlp: $SZ bytes received..."
done
wait $DLPID
echo " done: $(wc -c < '{dir}/{ybin}') bytes"
echo '==> verifying yt-dlp SHA-256'
curl -fL --no-progress-meter --connect-timeout 30 --max-time 60 \
-o '{dir}/yt-dlp.sums' '{sums}'
EXPECTED=$(awk -v n="{yasset}" '$2==n {{ print $1 }}' '{dir}/yt-dlp.sums' | head -n1)
ACTUAL=$(sha256sum '{dir}/{ybin}' | awk '{{ print $1 }}')
rm '{dir}/yt-dlp.sums'
if [ -z "$EXPECTED" ]; then
echo " warn: no checksum entry for {yasset} in SHA2-256SUMS"
echo " actual SHA-256: $ACTUAL"
elif [ "$EXPECTED" != "$ACTUAL" ]; then
echo " error: SHA-256 mismatch"
echo " expected: $EXPECTED"
echo " actual: $ACTUAL"
rm -f '{dir}/{ybin}'
exit 1 exit 1
else
echo " ok: $ACTUAL"
fi fi
chmod +x '{dir}/{ybin}' # Detect whether the venv module is actually usable. On Debian/Ubuntu it
# ships in the `python3-venv` package and fails noisily when missing.
if ! python3 -c 'import venv' 2>/dev/null; then
echo 'error: the Python "venv" module is not installed.'
echo ' Install python3-venv (Debian/Ubuntu) or the equivalent.'
exit 1
fi
mkdir -p '{root}'
mkdir -p '{bin}'
# Remove any legacy PyInstaller binary from the prior install layout so
# bundled_ytdlp_path()'s new venv-based path is the only one resolvable.
rm -f '{bin}/yt-dlp'
if [ ! -x '{venv}/bin/python' ]; then
echo '==> creating Python venv at {venv}'
python3 -m venv '{venv}'
fi
echo '==> upgrading pip in venv'
'{venv}/bin/python' -m pip install --upgrade --quiet pip
echo '==> installing yt-dlp[default] + curl_cffi (this fetches a few packages)'
'{venv}/bin/python' -m pip install --upgrade --quiet --progress-bar off 'yt-dlp[default]' curl_cffi
# deno # deno
echo '==> downloading deno' echo '==> downloading deno'
curl -fL --no-progress-meter --connect-timeout 30 --max-time 600 --retry 3 \ curl -fL --no-progress-meter --connect-timeout 30 --max-time 600 --retry 3 \
-o '{dir}/deno.zip' '{durl}' & -o '{bin}/deno.zip' '{durl}' &
DLPID=$! DLPID=$!
while kill -0 $DLPID 2>/dev/null; do while kill -0 $DLPID 2>/dev/null; do
sleep 3 sleep 3
SZ=$(wc -c < '{dir}/deno.zip' 2>/dev/null || echo 0) SZ=$(wc -c < '{bin}/deno.zip' 2>/dev/null || echo 0)
echo " deno.zip: $SZ bytes received..." echo " deno.zip: $SZ bytes received..."
done done
wait $DLPID wait $DLPID
echo " done: $(wc -c < '{dir}/deno.zip') bytes" echo " done: $(wc -c < '{bin}/deno.zip') bytes"
echo " deno.zip SHA-256: $(sha256sum '{dir}/deno.zip' | awk '{{ print $1 }}')" if command -v sha256sum >/dev/null; then
echo " deno.zip SHA-256: $(sha256sum '{bin}/deno.zip' | awk '{{ print $1 }}')"
fi
echo '==> extracting deno' echo '==> extracting deno'
unzip -o '{dir}/deno.zip' -d '{dir}' unzip -o '{bin}/deno.zip' -d '{bin}'
chmod +x '{dir}/deno' chmod +x '{bin}/deno'
rm '{dir}/deno.zip' rm '{bin}/deno.zip'
echo '==> versions' echo '==> versions'
'{dir}/{ybin}' --version '{venv}/bin/yt-dlp' --version
'{dir}/deno' --version '{bin}/deno' --version
echo '==> verifying curl_cffi (impersonation) is available'
if '{venv}/bin/yt-dlp' --list-impersonate-targets 2>/dev/null | grep -qi 'chrome'; then
echo ' ok: impersonation targets available'
else
echo ' warn: curl_cffi did not load; --impersonate will be skipped'
fi
echo '==> done'"#, echo '==> done'"#,
dir = dir_str, yurl = ytdlp_url, durl = deno_url, ybin = ytdlp_name, root = root_str, bin = bin_str, venv = venv_str, durl = deno_url,
sums = sums_url, yasset = ytdlp_asset_name,
); );
let mut cmd = Command::new("bash"); let mut cmd = Command::new("bash");
cmd.arg("-c").arg(script); cmd.arg("-c").arg(script);