diff --git a/Cargo.lock b/Cargo.lock index c512560..ac0434c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -97,6 +97,7 @@ dependencies = [ "chrono", "clap", "regex", + "reqwest", "rusqlite", "serde_json", ] diff --git a/crates/archivr-cli/Cargo.toml b/crates/archivr-cli/Cargo.toml index 3a190d7..6f0d0bf 100644 --- a/crates/archivr-cli/Cargo.toml +++ b/crates/archivr-cli/Cargo.toml @@ -15,3 +15,4 @@ clap.workspace = true regex.workspace = true rusqlite.workspace = true serde_json.workspace = true +reqwest.workspace = true diff --git a/crates/archivr-cli/src/main.rs b/crates/archivr-cli/src/main.rs index ec10543..83dd947 100644 --- a/crates/archivr-cli/src/main.rs +++ b/crates/archivr-cli/src/main.rs @@ -1,12 +1,27 @@ -use anyhow::{Context, Result}; -use archivr_core::{archive, capture::CaptureConfig}; +use anyhow::{bail, Context, Result}; +use archivr_core::{ + archive, + capture::CaptureConfig, + downloader::ytdlp::{ + pinned_yt_dlp, probe_version, resolve_yt_dlp, state_dir, state_dir_yt_dlp, + }, +}; use clap::{Parser, Subcommand}; use std::{ env, - path::Path, + path::{Path, PathBuf}, process, + process::Command as ProcCommand, }; +/// GitHub release metadata endpoint for the upstream yt-dlp project. +const YT_DLP_LATEST_RELEASE: &str = + "https://api.github.com/repos/yt-dlp/yt-dlp/releases/latest"; + +/// Every python zipapp starts with this shebang; used as a sanity check that we +/// downloaded the artifact and not an HTML error page or an LFS pointer. +const ZIPAPP_SHEBANG: &[u8] = b"#!/usr/bin/env python3"; + #[derive(Parser, Debug)] #[command(version, about, long_about = None)] struct Args { @@ -48,6 +63,25 @@ enum Command { #[arg(long = "force-with-info-removal")] force_with_info_removal: bool, }, + + /// Inspect or update the yt-dlp binary archivr runs + #[command(name = "yt-dlp")] + YtDlp { + #[command(subcommand)] + subcmd: YtDlpCmd, + }, +} + +#[derive(Subcommand, Debug)] +enum YtDlpCmd { + /// Download the latest yt-dlp zipapp into archivr's state directory + Update { + /// Install this exact release tag instead of the latest (e.g. 2026.09.15) + #[arg(long)] + version: Option, + }, + /// Show every yt-dlp candidate, its version, and which one wins + Status, } fn main() -> Result<()> { @@ -96,7 +130,162 @@ fn main() -> Result<()> { ); Ok(()) - } // _ => eprintln!("Unknown command: {:?}", args.command), + } + + Command::YtDlp { subcmd } => match subcmd { + YtDlpCmd::Update { version } => yt_dlp_update(version.as_deref()), + YtDlpCmd::Status => yt_dlp_status(), + }, } } + +/// Resolves `/yt-dlp/`, erroring out if there is no usable HOME. +fn yt_dlp_state_dir() -> Result { + state_dir() + .map(|d| d.join("yt-dlp")) + .context("could not determine a state directory (is $HOME set?)") +} + +/// Renders one `status` row. Missing candidates show an em dash. +fn status_row(role: &str, path: Option<&Path>, chosen: &Path) { + match path { + Some(p) => { + let version = probe_version(p).unwrap_or_else(|| "—".to_string()); + let star = if p == chosen { "*" } else { "" }; + println!("{role}\t{}\t{version}\t{star}", p.display()); + } + None => println!("{role}\t—\t—\t"), + } +} + +fn yt_dlp_status() -> Result<()> { + let chosen = resolve_yt_dlp(); + + println!("role\tpath\tversion\tchosen"); + status_row("env (ARCHIVR_YT_DLP)", pinned_yt_dlp().as_deref(), &chosen); + + // Show the state-dir slot even when empty, so users can see where an + // `archivr yt-dlp update` would land. + let state_candidate = state_dir_yt_dlp().filter(|p| p.is_file()); + status_row("state-dir", state_candidate.as_deref(), &chosen); + + status_row( + "path-fallback (yt-dlp)", + Some(Path::new("yt-dlp")), + &chosen, + ); + + if let Ok(dir) = yt_dlp_state_dir() { + if state_dir_yt_dlp().is_none_or(|p| !p.is_file()) { + println!("\nNo state-dir install yet; `archivr yt-dlp update` would write to {}", dir.join("yt-dlp").display()); + } + } + + Ok(()) +} + +/// Asks the GitHub API for the newest yt-dlp release tag. +fn latest_yt_dlp_version(client: &reqwest::blocking::Client) -> Result { + let body = client + .get(YT_DLP_LATEST_RELEASE) + .send() + .context("failed to reach the GitHub releases API")? + .error_for_status() + .context("GitHub releases API returned an error")? + .text() + .context("failed to read the GitHub releases API response")?; + + let json: serde_json::Value = + serde_json::from_str(&body).context("GitHub releases API returned invalid JSON")?; + + json.get("tag_name") + .and_then(|t| t.as_str()) + .map(str::to_string) + .context("GitHub releases API response had no tag_name") +} + +fn yt_dlp_update(requested_version: Option<&str>) -> Result<()> { + let dir = yt_dlp_state_dir()?; + let target = dir.join("yt-dlp"); + let staging = dir.join("yt-dlp.new"); + let version_file = dir.join(".version"); + + let client = reqwest::blocking::Client::builder() + .user_agent(concat!("archivr-cli/", env!("CARGO_PKG_VERSION"))) + .build() + .context("failed to build an HTTP client")?; + + let version = match requested_version { + Some(v) => v.to_string(), + None => latest_yt_dlp_version(&client)?, + }; + + // The sibling .version file is what lets us skip a ~3MB download on a + // no-op update; the binary itself is a zipapp with no cheap version probe + // that doesn't cost a python startup. + let installed = std::fs::read_to_string(&version_file).ok(); + if target.is_file() && installed.as_deref().map(str::trim) == Some(version.as_str()) { + println!("yt-dlp {version} is already installed at {}", target.display()); + return Ok(()); + } + + println!("Downloading yt-dlp {version}…"); + let url = format!("https://github.com/yt-dlp/yt-dlp/releases/download/{version}/yt-dlp"); + let bytes = client + .get(&url) + .send() + .with_context(|| format!("failed to download {url}"))? + .error_for_status() + .with_context(|| format!("download failed — is {version} a real release tag?"))? + .bytes() + .context("failed to read the downloaded yt-dlp body")?; + + if !bytes.starts_with(ZIPAPP_SHEBANG) { + bail!( + "downloaded artifact from {url} is not a python zipapp \ + (expected it to start with `{}`) — refusing to install it", + String::from_utf8_lossy(ZIPAPP_SHEBANG) + ); + } + + std::fs::create_dir_all(&dir) + .with_context(|| format!("failed to create {}", dir.display()))?; + std::fs::write(&staging, &bytes) + .with_context(|| format!("failed to write {}", staging.display()))?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&staging, std::fs::Permissions::from_mode(0o755)) + .with_context(|| format!("failed to chmod +x {}", staging.display()))?; + } + + // Atomic swap: a concurrently-running archivr sees either the whole old + // binary or the whole new one, never a half-written file. + std::fs::rename(&staging, &target) + .with_context(|| format!("failed to install {}", target.display()))?; + std::fs::write(&version_file, format!("{version}\n")) + .with_context(|| format!("failed to record version in {}", version_file.display()))?; + + // The zipapp is python source, not a native binary — installing it on a + // host without python3 is legal (the server may run under a nix wrapper + // with its own PATH) but worth flagging loudly. + let has_python = ProcCommand::new("python3") + .arg("--version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false); + if !has_python { + eprintln!( + "warning: python3 was not found on PATH — the yt-dlp zipapp just installed \ + at {} will not run until python3 is available", + target.display() + ); + } + + println!("Installed yt-dlp {version} to {}", target.display()); + println!("archivr will now prefer it whenever it is newer than the pinned binary (ARCHIVR_YT_DLP)."); + + Ok(()) +}