Prose + commands updated to the new name: brand reads "Catacomb", while binary/path/package references are the lowercase `catacomb` (commands, deb/rpm names, ~/.local/share/catacomb, catacomb.db, catacomb.desktop). Codeberg repo URLs left pointing at the existing repo. Also folds in the pending doc edits from earlier in the session. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
264 lines
11 KiB
Rust
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/catacomb/venv/`. A bundled `deno` lives at
|
|
//! `~/.local/share/catacomb/bin/deno` so yt-dlp can evaluate the
|
|
//! JavaScript signature-deciphering code YouTube serves with the player.
|
|
//!
|
|
//! Layout:
|
|
//! ```text
|
|
//! ~/.local/share/catacomb/
|
|
//! 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("catacomb")
|
|
}
|
|
|
|
/// 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
|
|
}
|
|
}
|