mirror of
https://github.com/thegeneralist01/archivr
synced 2026-10-09 21:03:17 +02:00
feat(cli): add archivr yt-dlp update|status subcommand
`update` fetches the latest release tag from the GitHub API (or takes --version), downloads the cross-platform python zipapp, and installs it into archivr's state dir. The install is atomic — staged as yt-dlp.new, chmod +x'd, then renamed over the target — so a concurrently running capture never sees a half-written binary. A sibling .version file makes a repeat update a no-op instead of a 3MB re-download. The download is checked for the python3 shebang before install, which catches the usual failure mode of getting an HTML error page back. python3 itself is only warned about, not required: the server may run under a nix wrapper with its own PATH. `status` prints all three candidates (env / state-dir / PATH fallback) with their versions and stars whichever the resolver picks, so it is obvious which yt-dlp a capture will actually use. reqwest is pulled from the existing workspace dependency; the GitHub JSON is parsed with serde_json so the "json" feature is not needed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
4e7bd086ea
commit
585d0a049b
3 changed files with 195 additions and 4 deletions
|
|
@ -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<String>,
|
||||
},
|
||||
/// 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 `<state_dir>/yt-dlp/`, erroring out if there is no usable HOME.
|
||||
fn yt_dlp_state_dir() -> Result<PathBuf> {
|
||||
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<String> {
|
||||
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(())
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue