use crate::{config::DownloadOptions, error::YtdlpError};
use reqwest::Client;
use std::path::{Path, PathBuf};
const YTDLP_RELEASE_API: &str = "https://api.github.com/repos/yt-dlp/yt-dlp/releases/latest";
const FFMPEG_RELEASE_API: &str =
"https://api.github.com/repos/yt-dlp/FFmpeg-Builds/releases/latest";
const MAX_LISTING_BYTES: u64 = 1024 * 1024;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Tool {
Ytdlp,
Ffmpeg,
}
impl Tool {
pub fn as_str(&self) -> &'static str {
match self {
Tool::Ytdlp => "yt-dlp",
Tool::Ffmpeg => "ffmpeg",
}
}
pub fn source_description(&self) -> &'static str {
match self {
Tool::Ytdlp => "the official yt-dlp GitHub releases (github.com/yt-dlp/yt-dlp)",
Tool::Ffmpeg => {
"the ffmpeg builds maintained by the yt-dlp project (github.com/yt-dlp/FFmpeg-Builds)"
}
}
}
pub fn purpose(&self) -> &'static str {
match self {
Tool::Ytdlp => {
"yt-dlp is what turns a media page link into a downloadable video. \
Without it, such a link downloads the web page instead of the video."
}
Tool::Ffmpeg => {
"ffmpeg joins the separate video and audio streams that sites serve for \
higher qualities. Without it, downloads are limited to the qualities that \
come as a single file — often 720p or below."
}
}
}
pub fn manual_instructions(&self) -> String {
match std::env::consts::OS {
"macos" => format!("brew install {}", self.as_str()),
"windows" => format!("winget install {}", self.as_str()),
_ => format!("apt/dnf/pacman install {}", self.as_str()),
}
}
pub fn config_key(&self) -> &'static str {
match self {
Tool::Ytdlp => "ytdlp.binary_path",
Tool::Ffmpeg => "ytdlp.ffmpeg_path",
}
}
}
pub fn can_install(tool: Tool) -> bool {
match tool {
Tool::Ytdlp => asset_name_for_ytdlp().is_some(),
Tool::Ffmpeg => !cfg!(target_os = "macos") && asset_fragment_for_ffmpeg().is_some(),
}
}
fn asset_name_for_ytdlp() -> Option<&'static str> {
Some(match (std::env::consts::OS, std::env::consts::ARCH) {
("linux", "x86_64") => "yt-dlp_linux",
("linux", "aarch64") => "yt-dlp_linux_aarch64",
("macos", _) => "yt-dlp_macos",
("windows", "x86_64") => "yt-dlp.exe",
("windows", "aarch64") => "yt-dlp_arm64.exe",
("windows", "x86") => "yt-dlp_x86.exe",
_ => return None,
})
}
fn asset_fragment_for_ffmpeg() -> Option<&'static str> {
Some(match (std::env::consts::OS, std::env::consts::ARCH) {
("linux", "x86_64") => "linux64-gpl",
("linux", "aarch64") => "linuxarm64-gpl",
("windows", "x86_64") => "win64-gpl",
("windows", "aarch64") => "winarm64-gpl",
("windows", "x86") => "win32-gpl",
_ => return None,
})
}
pub fn tools_dir() -> PathBuf {
crate::fs_utils::get_odl_dir().join("tools")
}
#[derive(Debug, serde::Deserialize)]
struct Release {
#[serde(default)]
tag_name: String,
#[serde(default)]
assets: Vec<ReleaseAsset>,
}
#[derive(Debug, serde::Deserialize)]
struct ReleaseAsset {
name: String,
browser_download_url: String,
#[serde(default)]
size: u64,
}
fn other(message: impl Into<String>) -> YtdlpError {
YtdlpError::Other {
message: message.into(),
}
}
async fn fetch_release(client: &Client, api: &str) -> Result<Release, YtdlpError> {
let response = client
.get(api)
.header(reqwest::header::USER_AGENT, "odl")
.header(reqwest::header::ACCEPT, "application/vnd.github+json")
.send()
.await
.map_err(|e| other(format!("could not reach {api}: {e}")))?
.error_for_status()
.map_err(|e| other(format!("{api} returned {e}")))?;
let body = response
.bytes()
.await
.map_err(|e| other(format!("could not read the release listing: {e}")))?;
serde_json::from_slice::<Release>(&body)
.map_err(|e| other(format!("could not parse the release listing: {e}")))
}
async fn fetch_text(client: &Client, url: &str, declared_size: u64) -> Result<String, YtdlpError> {
if declared_size > MAX_LISTING_BYTES {
return Err(other(format!("{url} is larger than a checksum listing")));
}
let bytes = client
.get(url)
.header(reqwest::header::USER_AGENT, "odl")
.send()
.await
.map_err(|e| other(format!("could not download {url}: {e}")))?
.error_for_status()
.map_err(|e| other(format!("{url} returned {e}")))?
.bytes()
.await
.map_err(|e| other(format!("could not read {url}: {e}")))?;
if bytes.len() as u64 > MAX_LISTING_BYTES {
return Err(other(format!("{url} sent more data than odl will accept")));
}
Ok(String::from_utf8_lossy(&bytes).into_owned())
}
fn expected_digest(listing: &str, name: &str) -> Option<String> {
listing.lines().find_map(|line| {
let mut parts = line.split_whitespace();
let digest = parts.next()?;
let file = parts.next()?.trim_start_matches('*');
(file == name && digest.len() == 64).then(|| digest.to_ascii_lowercase())
})
}
pub fn staging_dir() -> PathBuf {
tools_dir().join("staging")
}
#[derive(Debug, Clone)]
pub struct AssetPlan {
pub name: String,
pub url: String,
pub sha256: String,
pub size: u64,
}
pub async fn plan(net: &DownloadOptions, tool: Tool) -> Result<AssetPlan, YtdlpError> {
let client = crate::http::client_for(net).map_err(|e| YtdlpError::Other {
message: e.to_string(),
})?;
match tool {
Tool::Ytdlp => plan_ytdlp(&client).await,
Tool::Ffmpeg => plan_ffmpeg(&client).await,
}
}
pub async fn finish(tool: Tool, downloaded: &Path, dir: &Path) -> Result<PathBuf, YtdlpError> {
match tool {
Tool::Ytdlp => {
let name = if cfg!(windows) {
"yt-dlp.exe"
} else {
"yt-dlp"
};
let path = dir.join(name);
install_file(downloaded, &path).await?;
Ok(path)
}
Tool::Ffmpeg => {
let staging = tempfile::tempdir()
.map_err(|e| other(format!("could not create a temporary directory: {e}")))?;
extract_ffmpeg(downloaded, staging.path(), dir).await
}
}
}
async fn plan_ytdlp(client: &Client) -> Result<AssetPlan, YtdlpError> {
let wanted = asset_name_for_ytdlp()
.ok_or_else(|| other("yt-dlp publishes no build for this platform"))?;
let release = fetch_release(client, YTDLP_RELEASE_API).await?;
let asset = release
.assets
.iter()
.find(|a| a.name == wanted)
.ok_or_else(|| other(format!("release {} has no {wanted}", release.tag_name)))?;
let sums = release
.assets
.iter()
.find(|a| a.name == "SHA2-256SUMS")
.ok_or_else(|| other("release publishes no checksums; refusing to install"))?;
let listing = fetch_text(client, &sums.browser_download_url, sums.size).await?;
let sha256 = expected_digest(&listing, wanted)
.ok_or_else(|| other(format!("no checksum published for {wanted}")))?;
Ok(AssetPlan {
name: wanted.to_owned(),
url: asset.browser_download_url.clone(),
sha256,
size: asset.size,
})
}
async fn plan_ffmpeg(client: &Client) -> Result<AssetPlan, YtdlpError> {
if !can_install(Tool::Ffmpeg) {
return Err(other(
"odl has no verified ffmpeg build for this platform; install it yourself",
));
}
let fragment = asset_fragment_for_ffmpeg().expect("checked by can_install");
let release = fetch_release(client, FFMPEG_RELEASE_API).await?;
let asset = release
.assets
.iter()
.find(|a| {
a.name.contains(fragment)
&& !a.name.contains("-shared")
&& (a.name.ends_with(".tar.xz") || a.name.ends_with(".zip"))
})
.ok_or_else(|| other(format!("release publishes no {fragment} build")))?;
let sums = release
.assets
.iter()
.find(|a| a.name == "checksums.sha256")
.ok_or_else(|| other("release publishes no checksums; refusing to install"))?;
let listing = fetch_text(client, &sums.browser_download_url, sums.size).await?;
let sha256 = expected_digest(&listing, &asset.name)
.ok_or_else(|| other(format!("no checksum published for {}", asset.name)))?;
Ok(AssetPlan {
name: asset.name.clone(),
url: asset.browser_download_url.clone(),
sha256,
size: asset.size,
})
}
async fn extract_ffmpeg(
archive_path: &Path,
staging: &Path,
dir: &Path,
) -> Result<PathBuf, YtdlpError> {
let status = tokio::process::Command::new("tar")
.arg("-xf")
.arg(archive_path)
.current_dir(staging)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.kill_on_drop(true)
.status()
.await
.map_err(|e| {
other(format!(
"could not run `tar` to unpack ffmpeg ({e}); install ffmpeg yourself instead"
))
})?;
if !status.success() {
return Err(other("`tar` could not unpack the ffmpeg archive"));
}
let binary = if cfg!(windows) {
"ffmpeg.exe"
} else {
"ffmpeg"
};
let found = find_file(staging, binary, 4)
.ok_or_else(|| other("the ffmpeg archive contained no ffmpeg binary"))?;
let path = dir.join(binary);
install_file(&found, &path).await?;
Ok(path)
}
fn find_file(root: &Path, name: &str, depth: usize) -> Option<PathBuf> {
if depth == 0 {
return None;
}
let entries = std::fs::read_dir(root).ok()?;
let mut dirs = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
dirs.push(path);
} else if path.file_name().is_some_and(|f| f == name) {
return Some(path);
}
}
dirs.into_iter()
.find_map(|d| find_file(&d, name, depth - 1))
}
async fn install_file(from: &Path, path: &Path) -> Result<(), YtdlpError> {
let dir = path
.parent()
.ok_or_else(|| other("install path has no parent directory"))?;
tokio::fs::create_dir_all(dir)
.await
.map_err(|e| other(format!("could not create {}: {e}", dir.display())))?;
let temp = path.with_extension("partial");
tokio::fs::copy(from, &temp)
.await
.map_err(|e| other(format!("could not write {}: {e}", temp.display())))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
tokio::fs::set_permissions(&temp, std::fs::Permissions::from_mode(0o700))
.await
.map_err(|e| other(format!("could not make {} executable: {e}", temp.display())))?;
}
tokio::fs::rename(&temp, path)
.await
.map_err(|e| other(format!("could not install to {}: {e}", path.display())))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn digest_is_read_from_a_checksum_listing() {
let listing = "\
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa yt-dlp
bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb yt-dlp_linux
cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc *yt-dlp.exe
";
assert_eq!(
expected_digest(listing, "yt-dlp_linux").unwrap(),
"b".repeat(64)
);
assert_eq!(
expected_digest(listing, "yt-dlp.exe").unwrap(),
"c".repeat(64)
);
assert!(expected_digest(listing, "yt-dlp_macos").is_none());
}
#[test]
fn a_truncated_or_malformed_digest_is_not_accepted() {
let listing = "abc yt-dlp_linux\n";
assert!(expected_digest(listing, "yt-dlp_linux").is_none());
assert!(expected_digest("", "yt-dlp_linux").is_none());
}
#[test]
fn a_yt_dlp_build_is_offered_exactly_where_upstream_has_one() {
let upstream_builds_for_this_platform = cfg!(target_os = "macos")
|| cfg!(target_os = "windows")
|| (cfg!(target_os = "linux")
&& (cfg!(target_arch = "x86_64") || cfg!(target_arch = "aarch64")));
if upstream_builds_for_this_platform {
assert!(
can_install(Tool::Ytdlp),
"the asset table lost a platform upstream still builds for"
);
} else {
assert!(
!can_install(Tool::Ytdlp),
"an asset name was invented for a platform yt-dlp does not build for"
);
}
}
#[test]
fn ffmpeg_is_not_offered_where_no_verified_build_exists() {
if cfg!(target_os = "macos") {
assert!(
!can_install(Tool::Ffmpeg),
"macOS has no checksummed build odl can vouch for"
);
}
}
#[tokio::test]
async fn an_installed_tool_is_executable_and_replaces_atomically() {
let dir = tempfile::tempdir().unwrap();
let staged = dir.path().join("staged");
std::fs::write(&staged, b"#!/bin/sh\nexit 0\n").unwrap();
let path = dir.path().join("tool");
install_file(&staged, &path).await.unwrap();
assert!(path.exists());
assert!(
!path.with_extension("partial").exists(),
"the staging file must not survive a successful install"
);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o700, "tools odl executes stay owner-only");
}
}
#[test]
fn a_nested_binary_is_found_within_the_depth_limit() {
let dir = tempfile::tempdir().unwrap();
let nested = dir
.path()
.join("ffmpeg-master-latest-linux64-gpl")
.join("bin");
std::fs::create_dir_all(&nested).unwrap();
std::fs::write(nested.join("ffmpeg"), b"x").unwrap();
assert!(find_file(dir.path(), "ffmpeg", 4).is_some());
assert!(
find_file(dir.path(), "ffmpeg", 1).is_none(),
"the search must stay bounded"
);
}
}