use crate::install::{install_desktop_launcher, InstallError};
use crate::media::{
build_command, duration_progress_label, path_extension_lower, probe_duration, start_operation,
suggest_output_path, supported_audio_extension, supported_video_extension, AudioFormat,
ConversionError, ConversionMode, ConversionRequest, OperationEvent, QualityPreset, VideoFormat,
};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StartupAction {
Gui,
Handled,
}
#[derive(Debug, thiserror::Error)]
pub enum CliError {
#[error("{0}")]
Usage(String),
#[error("missing required argument: {0}")]
MissingArgument(&'static str),
#[error("could not suggest an output path; pass --output")]
MissingOutputSuggestion,
#[error("cannot infer conversion route: {0}")]
CannotInferRoute(String),
#[error(transparent)]
Conversion(#[from] ConversionError),
#[error(transparent)]
Install(#[from] InstallError),
#[error("conversion event stream ended before completion")]
EventStreamClosed,
#[error("conversion failed: {0}")]
OperationFailed(String),
}
impl CliError {
pub fn exit_code(&self) -> i32 {
match self {
CliError::Usage(_)
| CliError::MissingArgument(_)
| CliError::MissingOutputSuggestion
| CliError::CannotInferRoute(_) => 2,
CliError::Conversion(_)
| CliError::Install(_)
| CliError::EventStreamClosed
| CliError::OperationFailed(_) => 1,
}
}
pub fn is_usage(&self) -> bool {
matches!(
self,
CliError::Usage(_)
| CliError::MissingArgument(_)
| CliError::MissingOutputSuggestion
| CliError::CannotInferRoute(_)
)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct CliOptions {
input_path: Option<PathBuf>,
output_path: Option<PathBuf>,
mode: ConversionMode,
audio_format: AudioFormat,
video_format: VideoFormat,
quality: QualityPreset,
overwrite: bool,
}
impl Default for CliOptions {
fn default() -> Self {
Self {
input_path: None,
output_path: None,
mode: ConversionMode::ExtractAudio,
audio_format: AudioFormat::Mp3,
video_format: VideoFormat::Mp4,
quality: QualityPreset::Balanced,
overwrite: false,
}
}
}
impl CliOptions {
fn into_request(self) -> Result<ConversionRequest, CliError> {
let input_path = self
.input_path
.ok_or(CliError::MissingArgument("--input"))?;
let output_path = match self.output_path {
Some(path) => path,
None => {
suggest_output_path(&input_path, self.mode, self.audio_format, self.video_format)
.ok_or(CliError::MissingOutputSuggestion)?
}
};
Ok(ConversionRequest {
mode: self.mode,
input_path,
output_path,
audio_format: self.audio_format,
video_format: self.video_format,
quality: self.quality,
overwrite: self.overwrite,
})
}
}
pub fn handle_args(args: impl IntoIterator<Item = String>) -> Result<StartupAction, CliError> {
let args = args.into_iter().collect::<Vec<_>>();
if args.is_empty() {
return Ok(StartupAction::Gui);
}
if args == ["--help"] || args == ["-h"] {
print_help();
return Ok(StartupAction::Handled);
}
if args == ["--version"] || args == ["-V"] {
println!("caery {}", env!("CARGO_PKG_VERSION"));
return Ok(StartupAction::Handled);
}
if args == ["--install"] {
let installed = install_desktop_launcher()?;
println!("CAERY INSTALL COMPLETE");
println!("Desktop entry: {}", installed.desktop_entry.display());
println!("Icon: {}", installed.icon.display());
if let Some(shortcut) = installed.desktop_shortcut {
println!("Desktop shortcut: {}", shortcut.display());
}
return Ok(StartupAction::Handled);
}
if args.first().map(String::as_str) == Some("--cli") {
if args.len() == 2 && matches!(args[1].as_str(), "--help" | "-h") {
print_cli_help();
return Ok(StartupAction::Handled);
}
let options = parse_cli_options(&args[1..])?;
run_cli(options)?;
return Ok(StartupAction::Handled);
}
if args.len() == 2 && args.iter().all(|arg| !arg.starts_with('-')) {
let options = infer_quick_options(&args[0], &args[1])?;
run_cli(options)?;
return Ok(StartupAction::Handled);
}
Err(CliError::Usage(format!(
"unknown startup option: {}",
args[0]
)))
}
fn parse_cli_options(args: &[String]) -> Result<CliOptions, CliError> {
let mut options = CliOptions::default();
let mut index = 0;
while index < args.len() {
match args[index].as_str() {
"--input" | "-i" => {
index += 1;
options.input_path = Some(PathBuf::from(next_value(args, index, "--input")?));
}
"--output" | "-o" => {
index += 1;
options.output_path = Some(PathBuf::from(next_value(args, index, "--output")?));
}
"--mode" | "-m" => {
index += 1;
options.mode = parse_mode(next_value(args, index, "--mode")?)?;
}
"--audio-format" | "--audio" => {
index += 1;
options.audio_format =
parse_audio_format(next_value(args, index, "--audio-format")?)?;
}
"--video-format" | "--video" => {
index += 1;
options.video_format =
parse_video_format(next_value(args, index, "--video-format")?)?;
}
"--quality" | "-q" => {
index += 1;
options.quality = parse_quality(next_value(args, index, "--quality")?)?;
}
"--overwrite" | "-y" => {
options.overwrite = true;
}
"--no-overwrite" => {
options.overwrite = false;
}
"--extract-audio" => {
options.mode = ConversionMode::ExtractAudio;
}
"--video-to-video" => {
options.mode = ConversionMode::TranscodeVideo;
}
"--audio-to-audio" => {
options.mode = ConversionMode::TranscodeAudio;
}
"--help" | "-h" => {
print_cli_help();
return Err(CliError::Usage("help requested inside --cli".to_owned()));
}
flag => return Err(CliError::Usage(format!("unknown CLI option: {flag}"))),
}
index += 1;
}
Ok(options)
}
fn infer_quick_options(input: &str, output: &str) -> Result<CliOptions, CliError> {
let input_path = PathBuf::from(input);
let output_path = PathBuf::from(output);
let input_is_audio = supported_audio_extension(&input_path);
let input_is_video = supported_video_extension(&input_path);
let output_audio_format = audio_format_from_output_path(&output_path);
let output_video_format = video_format_from_output_path(&output_path);
if !input_is_audio && !input_is_video {
return Err(CliError::CannotInferRoute(format!(
"unsupported input extension for {}",
input_path.display()
)));
}
let Some((mode, audio_format, video_format)) = (match (
input_is_audio,
input_is_video,
output_audio_format,
output_video_format,
) {
(true, _, Some(audio_format), _) => Some((
ConversionMode::TranscodeAudio,
audio_format,
VideoFormat::Mp4,
)),
(_, true, Some(audio_format), _) => {
Some((ConversionMode::ExtractAudio, audio_format, VideoFormat::Mp4))
}
(_, true, _, Some(video_format)) => Some((
ConversionMode::TranscodeVideo,
AudioFormat::Mp3,
video_format,
)),
(true, _, _, Some(_)) => {
return Err(CliError::CannotInferRoute(
"audio input cannot be converted into a video output".to_owned(),
));
}
_ => None,
}) else {
return Err(CliError::CannotInferRoute(format!(
"unsupported output extension for {}",
output_path.display()
)));
};
Ok(CliOptions {
input_path: Some(input_path),
output_path: Some(output_path),
mode,
audio_format,
video_format,
quality: QualityPreset::Balanced,
overwrite: false,
})
}
fn audio_format_from_output_path(path: &Path) -> Option<AudioFormat> {
match path_extension_lower(path).as_deref()? {
"mp3" => Some(AudioFormat::Mp3),
"m4a" => Some(AudioFormat::M4a),
"flac" => Some(AudioFormat::Flac),
"wav" => Some(AudioFormat::Wav),
"ogg" => Some(AudioFormat::Ogg),
"opus" => Some(AudioFormat::Opus),
_ => None,
}
}
fn video_format_from_output_path(path: &Path) -> Option<VideoFormat> {
match path_extension_lower(path).as_deref()? {
"mp4" => Some(VideoFormat::Mp4),
"mkv" => Some(VideoFormat::Mkv),
"webm" => Some(VideoFormat::Webm),
"mov" => Some(VideoFormat::Mov),
_ => None,
}
}
fn next_value<'a>(
args: &'a [String],
index: usize,
flag: &'static str,
) -> Result<&'a str, CliError> {
let value = args
.get(index)
.ok_or(CliError::MissingArgument(flag))?
.as_str();
if value.starts_with('-') {
return Err(CliError::MissingArgument(flag));
}
Ok(value)
}
fn parse_mode(value: &str) -> Result<ConversionMode, CliError> {
match normalize(value).as_str() {
"extract" | "extractaudio" | "videoaudio" | "videotoaudio" => {
Ok(ConversionMode::ExtractAudio)
}
"video" | "transcodevideo" | "videovideo" | "videotovideo" => {
Ok(ConversionMode::TranscodeVideo)
}
"audio" | "transcodeaudio" | "audioaudio" | "audiotoaudio" => {
Ok(ConversionMode::TranscodeAudio)
}
_ => Err(CliError::Usage(format!(
"unknown mode '{value}'; use extract-audio, video-to-video, or audio-to-audio"
))),
}
}
fn parse_audio_format(value: &str) -> Result<AudioFormat, CliError> {
match normalize(value).as_str() {
"mp3" => Ok(AudioFormat::Mp3),
"m4a" | "aac" => Ok(AudioFormat::M4a),
"flac" => Ok(AudioFormat::Flac),
"wav" | "wave" => Ok(AudioFormat::Wav),
"ogg" | "vorbis" => Ok(AudioFormat::Ogg),
"opus" => Ok(AudioFormat::Opus),
_ => Err(CliError::Usage(format!(
"unknown audio format '{value}'; use mp3, m4a, flac, wav, ogg, or opus"
))),
}
}
fn parse_video_format(value: &str) -> Result<VideoFormat, CliError> {
match normalize(value).as_str() {
"mp4" => Ok(VideoFormat::Mp4),
"mkv" => Ok(VideoFormat::Mkv),
"webm" => Ok(VideoFormat::Webm),
"mov" => Ok(VideoFormat::Mov),
_ => Err(CliError::Usage(format!(
"unknown video format '{value}'; use mp4, mkv, webm, or mov"
))),
}
}
fn parse_quality(value: &str) -> Result<QualityPreset, CliError> {
match normalize(value).as_str() {
"compact" | "small" | "low" => Ok(QualityPreset::Compact),
"balanced" | "medium" | "default" => Ok(QualityPreset::Balanced),
"archive" | "high" | "best" => Ok(QualityPreset::Archive),
_ => Err(CliError::Usage(format!(
"unknown quality '{value}'; use compact, balanced, or archive"
))),
}
}
fn normalize(value: &str) -> String {
value
.trim()
.to_ascii_lowercase()
.chars()
.filter(|ch| ch.is_ascii_alphanumeric())
.collect()
}
fn run_cli(options: CliOptions) -> Result<(), CliError> {
let request = options.into_request()?;
let command = build_command(&request)?;
let expected_duration = probe_duration(&request.input_path);
let rx = start_operation(command, expected_duration);
let mut last_progress_bucket = None;
println!("CAERY CLI // {}", request.mode.title().to_ascii_uppercase());
println!("SOURCE // {}", request.input_path.display());
println!("OUTPUT // {}", request.output_path.display());
println!(
"QUALITY // {}",
request.quality.label().to_ascii_uppercase()
);
if expected_duration.is_none() {
println!("DURATION // UNAVAILABLE // PROGRESS WILL BE INDETERMINATE");
}
loop {
match rx.recv().map_err(|_| CliError::EventStreamClosed)? {
OperationEvent::Started(command) => println!("START // {command}"),
OperationEvent::Log(line) => eprintln!("FFMPEG // {line}"),
OperationEvent::Progress {
current_seconds,
total_seconds,
} => {
let bucket = progress_bucket(current_seconds, total_seconds);
if last_progress_bucket != Some(bucket) || bucket == 100 {
println!(
"PROGRESS // {}",
duration_progress_label(current_seconds, total_seconds)
);
last_progress_bucket = Some(bucket);
}
}
OperationEvent::Finished(result) => match result {
Ok(()) => {
println!("COMPLETE // {}", request.output_path.display());
return Ok(());
}
Err(error) => return Err(CliError::OperationFailed(error)),
},
}
}
}
fn progress_bucket(current_seconds: f64, total_seconds: f64) -> u64 {
if total_seconds <= 0.0 {
return 0;
}
(current_seconds / total_seconds * 100.0).clamp(0.0, 100.0) as u64
}
pub fn short_usage() -> &'static str {
"Run `caery --help` for usage. Quick mode: `caery <input> <output>`."
}
fn print_help() {
println!(
"Caery {}\n\nUSAGE:\n caery\n caery <INPUT> <OUTPUT>\n caery --cli [OPTIONS]\n caery --install\n\nQUICK CONVERT:\n caery song.wav song.mp3\n caery clip.mp4 clip.mp3\n caery clip.mkv clip.webm\n\nOPTIONS:\n --cli Run in terminal-only conversion mode\n --install Install Linux desktop launcher and icon for this binary\n --help Print this help\n --version Print version\n",
env!("CARGO_PKG_VERSION")
);
}
fn print_cli_help() {
println!(
"Caery CLI\n\nUSAGE:\n caery --cli --input <FILE> [--output <FILE>] [OPTIONS]\n\nOPTIONS:\n -i, --input <FILE> Source media file\n -o, --output <FILE> Output path; defaults to <source>-caery.<format>\n -m, --mode <MODE> extract-audio, video-to-video, audio-to-audio\n --audio-format <FORMAT> mp3, m4a, flac, wav, ogg, opus\n --video-format <FORMAT> mp4, mkv, webm, mov\n -q, --quality <QUALITY> compact, balanced, archive\n -y, --overwrite Replace existing output file\n --extract-audio Shortcut for --mode extract-audio\n --video-to-video Shortcut for --mode video-to-video\n --audio-to-audio Shortcut for --mode audio-to-audio\n\nEXAMPLES:\n caery --cli -i clip.mp4 --audio-format mp3\n caery --cli -i clip.mkv -o clip.webm --mode video-to-video --video-format webm -q archive\n caery --cli -i song.wav --mode audio-to-audio --audio-format flac\n"
);
}
#[cfg(test)]
mod tests {
use super::*;
fn strings(values: &[&str]) -> Vec<String> {
values.iter().map(|value| (*value).to_owned()).collect()
}
#[test]
fn parses_cli_defaults() {
let options = parse_cli_options(&strings(&["--input", "clip.mp4"])).expect("parse cli");
assert_eq!(options.input_path, Some(PathBuf::from("clip.mp4")));
assert_eq!(options.mode, ConversionMode::ExtractAudio);
assert_eq!(options.audio_format, AudioFormat::Mp3);
assert_eq!(options.video_format, VideoFormat::Mp4);
assert_eq!(options.quality, QualityPreset::Balanced);
assert!(!options.overwrite);
}
#[test]
fn parses_video_route_options() {
let options = parse_cli_options(&strings(&[
"-i",
"clip.mkv",
"-o",
"clip.webm",
"--mode",
"video-to-video",
"--video-format",
"webm",
"--quality",
"archive",
"--overwrite",
]))
.expect("parse cli");
assert_eq!(options.output_path, Some(PathBuf::from("clip.webm")));
assert_eq!(options.mode, ConversionMode::TranscodeVideo);
assert_eq!(options.video_format, VideoFormat::Webm);
assert_eq!(options.quality, QualityPreset::Archive);
assert!(options.overwrite);
}
#[test]
fn missing_option_value_is_usage_error() {
let error = parse_cli_options(&strings(&["--input"])).expect_err("reject missing value");
assert!(matches!(error, CliError::MissingArgument("--input")));
}
#[test]
fn quick_mode_infers_audio_to_audio() {
let options = infer_quick_options("song.wav", "song.flac").expect("infer quick mode");
assert_eq!(options.mode, ConversionMode::TranscodeAudio);
assert_eq!(options.audio_format, AudioFormat::Flac);
assert_eq!(options.input_path, Some(PathBuf::from("song.wav")));
assert_eq!(options.output_path, Some(PathBuf::from("song.flac")));
}
#[test]
fn quick_mode_infers_video_to_audio() {
let options = infer_quick_options("clip.mp4", "clip.opus").expect("infer quick mode");
assert_eq!(options.mode, ConversionMode::ExtractAudio);
assert_eq!(options.audio_format, AudioFormat::Opus);
}
#[test]
fn quick_mode_infers_video_to_video() {
let options = infer_quick_options("clip.mkv", "clip.webm").expect("infer quick mode");
assert_eq!(options.mode, ConversionMode::TranscodeVideo);
assert_eq!(options.video_format, VideoFormat::Webm);
}
#[test]
fn quick_mode_rejects_audio_to_video() {
let error = infer_quick_options("song.wav", "song.mp4").expect_err("reject route");
assert!(matches!(error, CliError::CannotInferRoute(_)));
}
#[test]
fn quick_mode_rejects_unsupported_output() {
let error = infer_quick_options("song.wav", "song.txt").expect_err("reject output");
assert!(matches!(error, CliError::CannotInferRoute(_)));
}
#[test]
fn cli_request_suggests_output_when_omitted() {
let options = CliOptions {
input_path: Some(PathBuf::from("clip.mp4")),
audio_format: AudioFormat::Flac,
..Default::default()
};
let request = options.into_request().expect("request");
assert_eq!(request.output_path, PathBuf::from("clip-caery.flac"));
}
}