use crate::config::YtdlpOptions;
use crate::error::YtdlpError;
use std::collections::HashMap;
use std::ffi::OsStr;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
use tokio::process::Command;
const MIN_VERSION: Version = Version {
year: 2024,
month: 7,
day: 1,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub struct Version {
pub year: u32,
pub month: u32,
pub day: u32,
}
impl std::fmt::Display for Version {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{:04}.{:02}.{:02}", self.year, self.month, self.day)
}
}
impl std::str::FromStr for Version {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
let mut parts = s.trim().split('.');
let mut next = || parts.next().and_then(|p| p.parse::<u32>().ok()).ok_or(());
let year = next()?;
let month = next()?;
let day = next()?;
if !(1..=12).contains(&month) || !(1..=31).contains(&day) {
return Err(());
}
Ok(Version { year, month, day })
}
}
#[derive(Debug, Clone)]
pub struct Tools {
pub ytdlp: PathBuf,
pub ffmpeg: Option<PathBuf>,
}
fn is_executable_file(path: &Path) -> bool {
let Ok(meta) = std::fs::metadata(path) else {
return false;
};
if !meta.is_file() {
return false;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
meta.permissions().mode() & 0o111 != 0
}
#[cfg(not(unix))]
{
true
}
}
#[cfg(windows)]
fn candidate_names(program: &str) -> Vec<String> {
let pathext = std::env::var("PATHEXT").unwrap_or_default();
let mut exts: Vec<String> = pathext
.split(';')
.map(|e| e.trim().to_ascii_lowercase())
.filter(|e| e.starts_with('.'))
.collect();
if exts.is_empty() {
exts = vec![".exe".into(), ".cmd".into(), ".bat".into()];
}
exts.iter().map(|e| format!("{program}{e}")).collect()
}
#[cfg(not(windows))]
fn candidate_names(program: &str) -> Vec<String> {
vec![program.to_owned()]
}
fn which(program: &str) -> Option<PathBuf> {
let path = std::env::var_os("PATH")?;
let names = candidate_names(program);
for dir in std::env::split_paths(&path) {
if dir.as_os_str().is_empty() {
continue;
}
for name in &names {
let candidate = dir.join(name);
if is_executable_file(&candidate) {
return std::fs::canonicalize(&candidate).ok().or(Some(candidate));
}
}
}
None
}
fn resolve_configured(configured: Option<&Path>, program: &str) -> Option<PathBuf> {
match configured {
Some(p) => is_executable_file(p)
.then(|| std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf())),
None => which(program),
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
struct ToolIdentity {
path: PathBuf,
len: u64,
modified: Option<std::time::SystemTime>,
}
impl ToolIdentity {
fn of(path: &Path) -> Option<Self> {
let meta = std::fs::metadata(path).ok()?;
Some(Self {
path: path.to_path_buf(),
len: meta.len(),
modified: meta.modified().ok(),
})
}
}
fn version_cache() -> &'static Mutex<HashMap<ToolIdentity, Result<Version, String>>> {
static CACHE: OnceLock<Mutex<HashMap<ToolIdentity, Result<Version, String>>>> = OnceLock::new();
CACHE.get_or_init(|| Mutex::new(HashMap::new()))
}
async fn query_version(ytdlp: &Path) -> Result<Version, String> {
let output = Command::new(ytdlp)
.arg("--version")
.kill_on_drop(true)
.output()
.await
.map_err(|e| e.to_string())?;
if !output.status.success() {
return Err(format!("`--version` exited with {}", output.status));
}
let stdout = String::from_utf8_lossy(&output.stdout);
stdout
.lines()
.next()
.unwrap_or_default()
.parse::<Version>()
.map_err(|_| format!("unrecognised version output {:?}", stdout.trim()))
}
async fn cached_version(ytdlp: &Path) -> Result<Version, String> {
let identity = ToolIdentity::of(ytdlp);
if let Some(id) = &identity
&& let Some(hit) = version_cache().lock().ok().and_then(|c| c.get(id).cloned())
{
return hit;
}
let result = query_version(ytdlp).await;
if let Some(id) = identity
&& let Ok(mut cache) = version_cache().lock()
{
cache.insert(id, result.clone());
}
result
}
pub async fn discover(opts: &YtdlpOptions) -> Result<Tools, YtdlpError> {
let ytdlp =
resolve_configured(opts.binary_path(), "yt-dlp").ok_or_else(|| YtdlpError::NotFound {
searched_path: opts.binary_path().map(|p| p.display().to_string()),
})?;
let version = cached_version(&ytdlp)
.await
.map_err(|message| YtdlpError::NotUsable {
path: ytdlp.display().to_string(),
message,
})?;
if version < MIN_VERSION {
return Err(YtdlpError::TooOld {
path: ytdlp.display().to_string(),
found: version.to_string(),
required: MIN_VERSION.to_string(),
});
}
Ok(Tools {
ffmpeg: resolve_configured(opts.ffmpeg_path(), "ffmpeg"),
ytdlp,
})
}
pub fn is_available(configured: Option<&Path>, program: &str) -> bool {
resolve_configured(configured, program).is_some()
}
pub fn command<S: AsRef<OsStr>>(program: S) -> Command {
let mut cmd = Command::new(program);
cmd.stdin(std::process::Stdio::null());
cmd.kill_on_drop(true);
cmd
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_release_and_nightly_versions() {
assert_eq!(
"2025.06.09".parse::<Version>().unwrap(),
Version {
year: 2025,
month: 6,
day: 9
}
);
assert_eq!(
"2025.06.09.232703".parse::<Version>().unwrap(),
Version {
year: 2025,
month: 6,
day: 9
}
);
assert!("2025.06".parse::<Version>().is_err());
assert!("not-a-version".parse::<Version>().is_err());
assert!("2025.13.01".parse::<Version>().is_err());
}
#[test]
fn versions_order_by_date() {
let older: Version = "2024.01.02".parse().unwrap();
let newer: Version = "2024.02.01".parse().unwrap();
assert!(older < newer);
assert!(older < MIN_VERSION);
}
#[test]
fn configured_path_is_not_looked_up_on_path() {
let missing = Path::new("/nonexistent/odl-test/yt-dlp");
assert!(resolve_configured(Some(missing), "yt-dlp").is_none());
}
#[test]
fn resolves_an_executable_from_path() {
let dir = tempfile::tempdir().unwrap();
let name = if cfg!(windows) {
"odl-fake-tool.exe"
} else {
"odl-fake-tool"
};
let path = dir.path().join(name);
std::fs::write(&path, b"#!/bin/sh\nexit 0\n").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
}
let found = resolve_configured(Some(&path), "odl-fake-tool");
assert!(found.is_some(), "configured path should resolve");
}
#[test]
fn non_executable_file_is_rejected_on_unix() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("not-executable");
std::fs::write(&path, b"data").unwrap();
#[cfg(unix)]
assert!(!is_executable_file(&path));
assert!(!is_executable_file(dir.path()), "a directory is not a tool");
}
#[tokio::test]
async fn discovery_reports_a_usable_tool_or_says_it_is_missing() {
let opts = YtdlpOptions::default();
match discover(&opts).await {
Ok(tools) => {
assert!(tools.ytdlp.is_absolute(), "resolved path must be absolute");
assert!(is_executable_file(&tools.ytdlp));
if let Some(ffmpeg) = &tools.ffmpeg {
assert!(is_executable_file(ffmpeg));
}
}
Err(YtdlpError::NotFound { .. }) => {}
Err(YtdlpError::TooOld { .. }) => {}
Err(e) => panic!("unexpected discovery failure: {e}"),
}
}
#[test]
fn tool_identity_changes_when_the_file_is_replaced() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("tool");
std::fs::write(&path, b"one").unwrap();
let first = ToolIdentity::of(&path).unwrap();
std::fs::write(&path, b"a different build").unwrap();
let second = ToolIdentity::of(&path).unwrap();
assert_ne!(first, second);
std::fs::remove_file(&path).unwrap();
assert!(
ToolIdentity::of(&path).is_none(),
"a deleted tool has no identity"
);
}
#[tokio::test]
async fn missing_configured_binary_is_not_found_not_a_silent_fallback() {
let opts = crate::config::YtdlpOptionsBuilder::default()
.binary_path(Some(PathBuf::from("/nonexistent/odl-test/yt-dlp")))
.build()
.unwrap();
assert!(matches!(
discover(&opts).await,
Err(YtdlpError::NotFound { .. })
));
}
}