use std::path::PathBuf;
use std::str::FromStr;
use std::time::Duration;
use clap::{Parser, Subcommand};
#[derive(clap::ValueEnum, Clone, Copy, Debug, PartialEq, Eq)]
pub enum OutputFormat {
Text,
Json,
}
#[derive(clap::ValueEnum, Clone, Copy, Debug)]
pub enum LogLevel {
Off,
Error,
Warn,
Info,
Debug,
Trace,
}
#[derive(clap::ValueEnum, Clone, Debug)]
pub enum FileChangedAction {
Abort,
Restart,
}
#[derive(clap::ValueEnum, Clone, Debug)]
pub enum NotResumableAction {
Abort,
Restart,
}
#[derive(clap::ValueEnum, Clone, Debug)]
pub enum SameDownloadAction {
Abort,
Resume,
AddNumberToNameAndContinue,
}
#[derive(clap::ValueEnum, Clone, Debug)]
pub enum FinalFileAction {
Abort,
ReplaceAndContinue,
AddNumberToNameAndContinue,
}
#[derive(clap::Subcommand, Debug)]
pub enum ToolsAction {
Status,
Install {
#[arg(value_enum)]
tool: Option<ToolChoice>,
#[arg(long, short = 'y')]
yes: bool,
},
}
#[derive(clap::ValueEnum, Clone, Copy, Debug, PartialEq, Eq)]
pub enum ToolChoice {
#[value(name = "yt-dlp")]
Ytdlp,
Ffmpeg,
}
#[derive(clap::ValueEnum, Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum EngineChoice {
#[default]
Auto,
Http,
Ytdlp,
}
#[derive(clap::ValueEnum, Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum ChooseFormat {
#[default]
Auto,
Always,
Never,
}
fn parse_speed(s: &str) -> Result<u64, String> {
let s = s.trim();
if s.is_empty() {
return Err("empty speed string".to_string());
}
let mut working = s.to_string();
let lower = working.to_lowercase();
if lower.ends_with("/s") {
working.truncate(working.len() - 2);
} else if lower.ends_with("bps") {
working.truncate(working.len() - 3);
}
let working = working.trim();
let mut idx = 0usize;
for (i, ch) in working.char_indices() {
if !(ch.is_ascii_digit() || ch == '.') {
idx = i;
break;
}
idx = i + ch.len_utf8();
}
let (num_part, suf_part) = if idx == 0 {
return Err(format!("invalid speed '{}': missing numeric value", s));
} else if idx >= working.len() {
(working, "")
} else {
(working[..idx].trim(), working[idx..].trim())
};
let value =
f64::from_str(num_part).map_err(|e| format!("invalid number '{}': {}", num_part, e))?;
if value < 0.0 {
return Err("speed must be non-negative".to_string());
}
let suffix_owned = suf_part
.trim()
.trim_start_matches([' ', '\t', '\''])
.to_lowercase();
let multiplier: f64 = match suffix_owned.as_str() {
"" | "b" | "byte" | "bytes" => 1.0,
"k" | "kb" | "kib" | "kibibyte" | "kb/s" => 1024f64,
"m" | "mb" | "mib" | "mibibyte" => 1024f64.powi(2),
"g" | "gb" | "gib" | "gibibyte" => 1024f64.powi(3),
other => {
let o = other.trim();
if o.starts_with('k') {
1024f64
} else if o.starts_with('m') {
1024f64.powi(2)
} else if o.starts_with('g') {
1024f64.powi(3)
} else {
return Err(format!("unknown size suffix '{}'", other));
}
}
};
let bytes_f = value * multiplier;
if !bytes_f.is_finite() || bytes_f < 0.0 {
return Err("resulting speed is out of range".to_string());
}
let bytes = bytes_f as u128; if bytes > (u64::MAX as u128) {
return Err("speed too large".to_string());
}
Ok(bytes as u64)
}
const MACHINE_INTERFACE_HELP: &str = "\
EXIT CODES:
0 success
1 other / internal error
2 usage or invalid input (bad URL, missing input, invalid config/flags)
3 network error (DNS, timeout, HTTP status, connection)
4 conflict (save/server conflict, checksum mismatch)
5 I/O error
6 metadata error (lockfile in use, decode failure)
7 yt-dlp error (missing, too old, failed, or unsupported URL)
130 cancelled
JSON OUTPUT (--format json):
Downloads stream newline-delimited JSON (NDJSON) to stdout, one object
per line, each tagged with \"type\" and \"url\":
phase {\"phase\": evaluating|resolving_conflicts|downloading|post_processing|assembling|flushing|verifying}
filename {\"filename\"}
progress {\"downloaded\", \"total\": <int|null>}
message {\"message\"}
completed {\"path\", \"already_complete\"}
failed {\"message\"}
cancelled {}
One-shot commands emit a single JSON document on stdout:
probe {\"type\":\"probe\", filename, size, size_is_approx, engine, quality, resumable, etag, last_modified, checksums, ...}
status/list {\"type\":\"status\", count, downloads:[...]}
config {\"type\":\"config\", path, config} (config_saved on write)
Errors print one JSON object to stderr:
{\"type\":\"error\", \"kind\", \"message\", \"exit_code\"}
ENGINES:
Links on known media hosts are handed to an installed `yt-dlp`; everything
else uses odl's own multipart HTTP downloader. `--engine` forces the choice.
A delegated download reports \"engine\":\"ytdlp\" and cannot observe the
underlying HTTP exchange, so etag/last_modified are null and checksums is
empty; sizes may be estimates (\"size_is_approx\": true). Configure it under
[ytdlp] in config.toml.
Quality is chosen once and pinned: a resume never mixes encodings. To change
it, name another format (--format-id) or ask again (--choose-format always);
either discards what was downloaded and starts over.";
#[derive(Parser, Debug)]
#[command(version, about, long_about = None, after_long_help = MACHINE_INTERFACE_HELP)]
pub struct Args {
pub input: Option<String>,
#[arg(long, default_value_t = false)]
pub remote_list: bool,
#[arg(long, value_enum, default_value_t = EngineChoice::Auto)]
pub engine: EngineChoice,
#[arg(long, value_enum, default_value_t = ChooseFormat::Auto)]
pub choose_format: ChooseFormat,
#[arg(long)]
pub no_verify_checksums: bool,
#[arg(long)]
pub ascii_filenames: bool,
#[arg(long, value_name = "ID")]
pub format_id: Option<String>,
#[arg(long, value_name = "COUNT")]
pub max_connections: Option<u64>,
#[arg(long, value_name = "COUNT")]
pub max_concurrent_downloads: Option<usize>,
#[arg(short, long, value_name = "FILE|DIR")]
pub output: Option<PathBuf>,
#[arg(short, long, value_name = "DIR")]
pub download_dir: Option<PathBuf>,
#[arg(short, long, value_name = "FILE")]
pub config_file: Option<PathBuf>,
#[arg(short = 'U', long)]
pub user_agent: Option<String>,
#[arg(long)]
pub randomize_user_agent: Option<bool>,
#[arg(long, value_name = "(http(s)|socks)://")]
pub proxy: Option<String>,
#[arg(short, long = "timeout", value_name = "DURATION", value_parser = humantime::parse_duration)]
pub timeout: Option<Duration>,
#[arg(long, value_name = "COUNT")]
pub max_retries: Option<u32>,
#[arg(long, value_name = "COUNT")]
pub n_fixed_retries: Option<u32>,
#[arg(long, value_name = "DURATION", value_parser = humantime::parse_duration)]
pub wait_between_retries: Option<Duration>,
#[arg(short, long)]
pub use_server_time: Option<bool>,
#[arg(long, value_enum, default_value_t = FileChangedAction::Restart)]
pub on_file_changed: FileChangedAction,
#[arg(long, value_enum, default_value_t = NotResumableAction::Restart)]
pub on_not_resumable: NotResumableAction,
#[arg(long)]
pub accept_invalid_certs: Option<bool>,
#[arg(long)]
pub http2: Option<bool>,
#[arg(long)]
pub dynamic_split: Option<bool>,
#[arg(long)]
pub rampup: Option<bool>,
#[arg(long, value_name = "COUNT")]
pub rampup_batch_size: Option<u64>,
#[arg(long, value_name = "DURATION", value_parser = humantime::parse_duration)]
pub rampup_delay_min: Option<Duration>,
#[arg(long, value_name = "DURATION", value_parser = humantime::parse_duration)]
pub rampup_delay_max: Option<Duration>,
#[arg(long = "header", value_name = "KEY:VALUE", num_args = 0.., action = clap::ArgAction::Append)]
pub headers: Vec<String>,
#[arg(long, value_enum, default_value_t = SameDownloadAction::Resume)]
pub on_same_download_exists: SameDownloadAction,
#[arg(long, value_enum, default_value_t = FinalFileAction::ReplaceAndContinue)]
pub on_final_file_exists: FinalFileAction,
#[arg(long = "checksum", value_name = "ALGO:DIGEST", action = clap::ArgAction::Append)]
pub checksums: Vec<String>,
#[arg(long, value_name = "USER")]
pub http_user: Option<String>,
#[arg(long, value_name = "PASSWORD")]
pub http_password: Option<String>,
#[arg(short, long, value_name = "BYTES_PER_SEC", value_parser = parse_speed)]
pub speed_limit: Option<u64>,
#[arg(long, value_enum, default_value_t = LogLevel::Warn)]
pub log_level: LogLevel,
#[arg(long, value_enum, default_value_t = OutputFormat::Text, global = true)]
pub format: OutputFormat,
#[command(subcommand)]
pub command: Option<Commands>,
}
#[derive(Subcommand, Debug)]
#[allow(clippy::large_enum_variant)]
pub enum Commands {
Config {
#[arg(long)]
show: bool,
#[arg(long, value_name = "FILE")]
config_file: Option<PathBuf>,
#[arg(long, value_name = "DIR")]
download_dir: Option<PathBuf>,
#[arg(long, value_name = "COUNT")]
max_connections: Option<u64>,
#[arg(long, value_name = "COUNT")]
max_concurrent_downloads: Option<usize>,
#[arg(long, value_name = "COUNT")]
max_retries: Option<u32>,
#[arg(long, value_name = "COUNT")]
n_fixed_retries: Option<u32>,
#[arg(long, value_name = "DURATION", value_parser = humantime::parse_duration)]
wait_between_retries: Option<Duration>,
#[arg(short, long, value_name = "BYTES_PER_SEC", value_parser = parse_speed)]
speed_limit: Option<u64>,
#[arg(long)]
user_agent: Option<String>,
#[arg(long)]
randomize_user_agent: Option<bool>,
#[arg(long)]
proxy: Option<String>,
#[arg(short, long = "timeout", value_name = "DURATION", value_parser = humantime::parse_duration)]
timeout: Option<Duration>,
#[arg(long)]
use_server_time: Option<bool>,
#[arg(long)]
accept_invalid_certs: Option<bool>,
#[arg(long)]
http2: Option<bool>,
#[arg(long)]
dynamic_split: Option<bool>,
#[arg(long)]
rampup: Option<bool>,
#[arg(long, value_name = "COUNT")]
rampup_batch_size: Option<u64>,
#[arg(long, value_name = "DURATION", value_parser = humantime::parse_duration)]
rampup_delay_min: Option<Duration>,
#[arg(long, value_name = "DURATION", value_parser = humantime::parse_duration)]
rampup_delay_max: Option<Duration>,
},
Tools {
#[command(subcommand)]
action: ToolsAction,
},
#[cfg(feature = "self-update")]
Update {
#[arg(long)]
check: bool,
#[arg(short = 'y', long)]
yes: bool,
},
Probe {
#[arg(value_name = "URL")]
url: String,
},
Status {
#[arg(value_name = "FILTER")]
filter: Option<String>,
},
List {
#[arg(value_name = "FILTER")]
filter: Option<String>,
},
}
#[cfg(test)]
mod tests {
use super::parse_speed;
use std::time::Duration;
#[test]
fn test_simple_bytes() {
assert_eq!(parse_speed("100").unwrap(), 100);
assert_eq!(parse_speed("100B").unwrap(), 100);
}
#[test]
fn test_kilobytes() {
assert_eq!(parse_speed("1K").unwrap(), 1024);
assert_eq!(parse_speed("1KB").unwrap(), 1024);
assert_eq!(parse_speed("100kib").unwrap(), 100 * 1024);
}
#[test]
fn test_megabytes() {
assert_eq!(parse_speed("1M").unwrap(), 1024u64.pow(2));
assert_eq!(
parse_speed("1.5MB").unwrap(),
((1.5f64 * (1024f64.powi(2))) as u64)
);
}
#[test]
fn test_gigabytes() {
assert_eq!(parse_speed("2G").unwrap(), 2 * 1024u64.pow(3));
assert_eq!(parse_speed("2GiB").unwrap(), 2 * 1024u64.pow(3));
}
#[test]
fn test_suffix_with_per_second() {
assert_eq!(parse_speed("100KB/s").unwrap(), 100 * 1024);
assert_eq!(
parse_speed("1.5MiB/s").unwrap(),
((1.5f64 * (1024f64.powi(2))) as u64)
);
}
#[test]
fn test_parse_duration_seconds_and_variants() {
assert_eq!(
humantime::parse_duration("30s").unwrap(),
Duration::from_secs(30)
);
assert_eq!(
humantime::parse_duration("30sec").unwrap(),
Duration::from_secs(30)
);
assert_eq!(
humantime::parse_duration("30seconds").unwrap(),
Duration::from_secs(30)
);
}
#[test]
fn test_parse_duration_minutes_hours_days() {
assert_eq!(
humantime::parse_duration("2m").unwrap(),
Duration::from_secs(120)
);
assert_eq!(
humantime::parse_duration("2min").unwrap(),
Duration::from_secs(120)
);
assert_eq!(
humantime::parse_duration("1h").unwrap(),
Duration::from_secs(3600)
);
assert_eq!(
humantime::parse_duration("1d").unwrap(),
Duration::from_secs(86400)
);
let d = humantime::parse_duration("1.5h").unwrap();
assert!((d.as_secs_f64() - 1.5 * 3600.0).abs() < 1e-6);
}
}