catacomb/src/ytdlp_bin.rs
Luna a71a193879 Bundled yt-dlp: install nightly (--pre) to fix broken impersonation
Symptom: with the bundled yt-dlp, every --impersonate target showed
"(unavailable)" and impersonation silently did nothing.

Root cause: it's a yt-dlp ⇄ curl_cffi version-gate mismatch, not a
curl_cffi problem. curl_cffi 0.15.0 was installed and works on its own
(a direct impersonate request returns 200), but stable yt-dlp 2026.3.17
caps support at curl_cffi <0.15:

  ImportError: Only curl_cffi versions 0.5.10 and 0.10.x through 0.14.x
  are supported

…so yt-dlp refuses to load the impersonate backend and disables every
target. yt-dlp master already raised the gate to <0.16, but that's only
in nightly, not stable.

Fix: install the nightly yt-dlp via `pip install --pre`. Nightly is
yt-dlp's recommended track for keeping up with YouTube's anti-bot
changes anyway (newly relevant now that we ship POT support), and it
accepts current curl_cffi so we don't have to pin a fragile version.

Verified on the bundled venv: nightly 2026.05.25 + curl_cffi 0.15.1b1
lists all impersonate targets, and `--impersonate chrome` resolves a
real video end-to-end. The install script's existing "grep chrome in
--list-impersonate-targets" verification now passes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 04:22:56 -07:00

264 lines
11 KiB
Rust

//! Management of the bundled yt-dlp + deno binaries.
//!
//! When [`crate::config::BackupSection::use_bundled_ytdlp`] is enabled the
//! app invokes its own yt-dlp instead of whatever's on PATH. To get the
//! full feature set — most importantly `curl_cffi`-backed `--impersonate`
//! support — we install yt-dlp into a self-contained Python virtualenv
//! 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.
//!
//! Layout:
//! ```text
//! ~/.local/share/yt-offline/
//! 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::process::Command;
/// Root directory holding everything bundled — venv + bin.
pub fn bundled_root() -> PathBuf {
let home = std::env::var_os("HOME")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."));
home.join(".local").join("share").join("yt-offline")
}
/// Directory holding non-Python binaries (currently just `deno`). Also the
/// path we prepend to `PATH` when spawning yt-dlp so it can locate deno.
pub fn bundled_dir() -> PathBuf {
bundled_root().join("bin")
}
/// 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 {
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`].
///
/// With `use_bundled = true` returns the absolute path to the venv-installed
/// yt-dlp (even if it doesn't yet exist — the spawn error surfaces in the
/// job log and prompts the user to click Install). Otherwise returns the
/// bare `"yt-dlp"` string so the system PATH is used.
pub fn ytdlp_invocation(use_bundled: bool) -> PathBuf {
if use_bundled {
bundled_ytdlp_path()
} else {
PathBuf::from("yt-dlp")
}
}
/// True if the bundled yt-dlp has been installed.
pub fn bundled_installed() -> bool {
bundled_ytdlp_path().exists()
}
/// Defensively re-apply `+x` to every regular file inside [`bundled_dir`].
///
/// Called before each bundled-mode invocation to guard against the install
/// script's `chmod` step having been skipped (e.g. partial install) or the
/// executable bit having been stripped by some other process.
///
/// No-op on Windows (executability is determined by the `.exe` suffix there).
pub fn ensure_bundled_executable() {
#[cfg(unix)]
{
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 Ok(entries) = std::fs::read_dir(&dir) else { return };
for entry in entries.flatten() {
let p = entry.path();
if !p.is_file() { continue; }
if let Ok(meta) = entry.metadata() {
let mut perms = meta.permissions();
let mode = perms.mode();
let want = mode | 0o111;
if mode != want {
perms.set_mode(want);
let _ = std::fs::set_permissions(&p, perms);
}
}
}
}
}
/// GitHub release asset name for deno on this platform.
fn deno_asset() -> &'static str {
match (std::env::consts::OS, std::env::consts::ARCH) {
("linux", "x86_64") => "deno-x86_64-unknown-linux-gnu.zip",
("linux", "aarch64") => "deno-aarch64-unknown-linux-gnu.zip",
("macos", "x86_64") => "deno-x86_64-apple-darwin.zip",
("macos", "aarch64") => "deno-aarch64-apple-darwin.zip",
("windows", _) => "deno-x86_64-pc-windows-msvc.zip",
_ => "deno-x86_64-unknown-linux-gnu.zip",
}
}
/// Build a [`Command`] that installs (or upgrades) the bundled yt-dlp into
/// a Python virtualenv with `curl_cffi` (for `--impersonate` support) and
/// the rest of `yt-dlp[default]`, plus a sibling `deno` for JS deciphering.
///
/// We install the **nightly** yt-dlp (`--pre`), not stable. YouTube's
/// anti-bot measures shift faster than yt-dlp's ~monthly stable cadence,
/// and stable lags on dependency support too: e.g. stable 2026.3.17 caps
/// `curl_cffi` at `<0.15` and silently disables *all* impersonate targets
/// when a newer curl_cffi is present, whereas nightly already accepts it.
/// Nightly is yt-dlp's own recommended track for users who need to keep
/// up with extractor + impersonation changes.
///
/// Runs through `bash -c` on Unix and PowerShell on Windows.
pub fn install_command() -> Command {
let root = bundled_root();
let root_str = root.display().to_string();
let bin_str = bundled_dir().display().to_string();
let venv_str = bundled_venv().display().to_string();
let deno_url = format!(
"https://github.com/denoland/deno/releases/latest/download/{}",
deno_asset()
);
#[cfg(windows)]
{
let script = format!(
"$ErrorActionPreference='Stop'; \
New-Item -ItemType Directory -Force -Path '{root}' | Out-Null; \
New-Item -ItemType Directory -Force -Path '{bin}' | Out-Null; \
$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 nightly yt-dlp + curl_cffi'; \
& '{venv}\\Scripts\\python.exe' -m pip install --upgrade pip; \
& '{venv}\\Scripts\\python.exe' -m pip install --upgrade --pre 'yt-dlp[default]' curl_cffi; \
Write-Host '==> downloading deno'; \
Invoke-WebRequest -Uri '{durl}' -OutFile '{bin}\\deno.zip'; \
Write-Host '==> extracting deno'; \
Expand-Archive -Force '{bin}\\deno.zip' '{bin}'; \
Remove-Item '{bin}\\deno.zip'; \
Write-Host '==> versions'; \
& '{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");
cmd.arg("-NoProfile").arg("-Command").arg(script);
cmd
}
#[cfg(not(windows))]
{
// Set up yt-dlp inside a venv so we get the full pip-installable
// distribution — including `curl_cffi`, which is what enables
// `--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.
//
// Pip-installed packages come with their own SHA-256 checks via
// PyPI's metadata, so we skip the manual checksum step we used to
// do for the standalone binary.
//
// Deno is unchanged: it's a single static binary that we drop into
// `bin/` alongside the (unused) directory; yt-dlp finds it via PATH
// when we prepend `bin/` in [`crate::downloader::Downloader::spawn_job`].
let script = format!(
r#"set -e
command -v curl >/dev/null || {{ echo 'error: curl not installed'; exit 1; }}
command -v unzip >/dev/null || {{ echo 'error: unzip not installed'; exit 1; }}
if ! command -v python3 >/dev/null; then
echo 'error: python3 is not installed.'
echo ' Install it from your distro package manager and try again.'
exit 1
fi
# 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
# --pre installs the nightly yt-dlp. Stable lags on extractor + dependency
# support (e.g. it caps curl_cffi at <0.15 and disables impersonation when
# a newer one is installed); nightly keeps pace with YouTube's changes.
echo '==> installing nightly yt-dlp[default] + curl_cffi (this fetches a few packages)'
'{venv}/bin/python' -m pip install --upgrade --pre --quiet --progress-bar off 'yt-dlp[default]' curl_cffi
# ── deno ────────────────────────────────────────────────────────────────────
echo '==> downloading deno'
curl -fL --no-progress-meter --connect-timeout 30 --max-time 600 --retry 3 \
-o '{bin}/deno.zip' '{durl}' &
DLPID=$!
while kill -0 $DLPID 2>/dev/null; do
sleep 3
SZ=$(wc -c < '{bin}/deno.zip' 2>/dev/null || echo 0)
echo " deno.zip: $SZ bytes received..."
done
wait $DLPID
echo " done: $(wc -c < '{bin}/deno.zip') bytes"
if command -v sha256sum >/dev/null; then
echo " deno.zip SHA-256: $(sha256sum '{bin}/deno.zip' | awk '{{ print $1 }}')"
fi
echo '==> extracting deno'
unzip -o '{bin}/deno.zip' -d '{bin}'
chmod +x '{bin}/deno'
rm '{bin}/deno.zip'
echo '==> versions'
'{venv}/bin/yt-dlp' --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'"#,
root = root_str, bin = bin_str, venv = venv_str, durl = deno_url,
);
let mut cmd = Command::new("bash");
cmd.arg("-c").arg(script);
cmd
}
}