use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, ExitCode, Stdio};
use anyhow::{Context, Result, bail};
use clap::Parser;
use owo_colors::OwoColorize;
use rayon::prelude::*;
use crate::audio_conversion;
use crate::cli::AudioCodec;
use crate::cli::{Cli, Codec, EncoderChoice};
use crate::conversion::{self, Outcome, Settings};
use crate::encoder::Encoder;
use crate::progress::ProgressBars;
pub(crate) fn run() -> Result<ExitCode> {
let cli = Cli::parse();
if cli.audio_only && cli.audio_codec == AudioCodec::Copy {
bail!("--audio-only requires --audio-codec mp3, aac, or opus");
}
if cli.audio_only && cli.no_audio {
bail!("--no-audio cannot be used with --audio-only");
}
require_tool("ffmpeg")?;
require_tool("ffprobe")?;
let input_is_file = cli.input.is_file();
if !input_is_file && !cli.input.is_dir() {
bail!(
"input path is not a file or directory: {}",
cli.input.display()
);
}
let chosen_ext: Vec<String> = if cli.ext.is_empty() {
default_extensions(cli.audio_codec != AudioCodec::Copy || cli.audio_only)
} else {
cli.ext.clone()
};
let extensions: Vec<String> = chosen_ext
.iter()
.map(|e| e.trim().trim_start_matches('.').to_ascii_lowercase())
.filter(|e| !e.is_empty())
.collect();
if extensions.is_empty() {
bail!("no valid file extensions given to --ext");
}
let (files, input_root) = if input_is_file {
let matches = cli
.input
.extension()
.and_then(|e| e.to_str())
.is_some_and(|ext| extensions.iter().any(|x| x.eq_ignore_ascii_case(ext)));
(
if matches {
vec![cli.input.clone()]
} else {
vec![]
},
cli.input.parent().unwrap_or(Path::new(".")).to_path_buf(),
)
} else {
(collect_inputs(&cli.input, &extensions)?, cli.input.clone())
};
if files.is_empty() {
println!(
"{}",
format!(
"No files with extension(s) {} found.",
extensions.join(", ")
)
.yellow()
);
return Ok(ExitCode::SUCCESS);
}
if !cli.dry_run {
fs::create_dir_all(&cli.output)
.with_context(|| format!("cannot create output directory {}", cli.output.display()))?;
}
if input_is_file {
let has_video = crate::probe::has_video_stream(&files[0])?;
let target = if cli.audio_only || !has_video {
audio_conversion::output_path(&files[0], &input_root, &cli.output, cli.audio_codec)
.context("cannot determine audio output path")?
} else {
cli.output.join(files[0].file_name().unwrap_or_default())
};
ensure_distinct_file(&files[0], &target)?;
} else {
ensure_distinct_dirs(&cli.input, &cli.output)?;
}
let encoder = if cli.audio_only {
Encoder::Software
} else {
resolve_encoder(&cli)?
};
let settings = Settings {
encoder,
cq: cli.cq,
audio_codec: cli.audio_codec,
no_audio: cli.no_audio,
dynamic_cq: cli.dynamic_cq,
target_fps: cli.fps,
denoise: cli.denoise,
codec: cli.codec,
skip_same_codec: cli.skip_same_codec,
skip_existing: cli.skip_existing,
scale: cli.scale,
dry_run: cli.dry_run,
};
let batch_description = if cli.audio_only {
format!(
"Found {} file(s). Output=audio-only ({}); CQ={}",
files.len(),
settings.audio_codec,
settings.cq
)
} else {
format!(
"Found {} file(s). Video={} ({}); Audio={}; CQ={}; FPS={}; Denoise={}; Scale={}; SkipSameCodec={}",
files.len(),
settings.codec,
encoder.codec_name(settings.codec),
settings.audio_codec,
settings.cq,
settings
.target_fps
.map_or_else(|| "source".to_string(), |f| f.to_string()),
settings.denoise,
settings
.scale
.map_or_else(|| "source".to_string(), |w| w.to_string()),
settings.skip_same_codec,
)
};
println!("{}", batch_description.cyan());
let progress = ProgressBars::new(files.len() as u64);
progress.set_message("Processing files");
let convert = |file: &PathBuf| {
let file_name = file.file_name().unwrap_or_default().to_string_lossy();
let has_video = match crate::probe::has_video_stream(file) {
Ok(value) => value,
Err(err) => {
progress.println(format!("[ERR] {file_name}: {err:#}"));
progress.advance();
return Outcome::Failed;
}
};
let outcome = if use_audio_pipeline(cli.audio_only, has_video) {
match audio_conversion::output_path(file, &input_root, &cli.output, cli.audio_codec) {
Some(target) => audio_conversion::convert_one(
file,
&target,
cli.audio_codec,
cli.cq,
cli.dynamic_cq,
cli.dry_run,
cli.skip_existing,
&progress,
&file_name,
),
None => {
progress.println(format!("[ERR] {file_name}: standalone audio conversion requires --audio-codec mp3, aac, or opus"));
Outcome::Failed
}
}
} else {
conversion::convert_one(
&settings,
file,
&input_root,
&cli.output,
&progress,
&file_name,
)
};
progress.advance();
outcome
};
let outcomes = if cli.jobs == 1 {
files.iter().map(convert).collect::<Vec<_>>()
} else {
rayon::ThreadPoolBuilder::new()
.num_threads(cli.jobs)
.build()
.context("cannot create conversion worker pool")?
.install(|| files.par_iter().map(convert).collect::<Vec<_>>())
};
let summary = summarize(outcomes);
let elapsed = progress.elapsed().as_secs();
progress.clear()?;
let done_label = if settings.dry_run {
"Planned"
} else {
"Succeeded"
};
println!("\n{}", "=== Summary ===".yellow());
println!(
"{}",
format!(
"{done_label}: {} - Failed: {} - Skipped: {} - Elapsed: {:02}:{:02}:{:02}",
summary.done,
summary.failed,
summary.skipped,
elapsed / 3600,
(elapsed % 3600) / 60,
elapsed % 60
)
.yellow()
);
if summary.total_in > 0 {
println!(
"{}",
format!(
"Total: {:.1} MB -> {:.1} MB ({:.1}%)",
summary.total_in as f64 / conversion::BYTES_PER_MB,
summary.total_out as f64 / conversion::BYTES_PER_MB,
summary.total_out as f64 / summary.total_in as f64 * 100.0
)
.yellow()
);
}
Ok(if summary.failed > 0 {
ExitCode::FAILURE
} else {
ExitCode::SUCCESS
})
}
fn use_audio_pipeline(audio_only: bool, has_video: bool) -> bool {
audio_only || !has_video
}
fn default_extensions(include_audio: bool) -> Vec<String> {
if !include_audio {
return vec!["mp4".to_string()];
}
let mut extensions = vec!["mp4", "mkv", "mov", "m4v", "webm", "avi"];
extensions.extend(["mp3", "m4a", "aac", "flac", "wav", "ogg", "opus", "wma"]);
extensions.into_iter().map(str::to_owned).collect()
}
#[derive(Debug, Default, PartialEq, Eq)]
struct Summary {
done: u32,
failed: u32,
skipped: u32,
total_in: u64,
total_out: u64,
}
fn summarize(outcomes: impl IntoIterator<Item = Outcome>) -> Summary {
let mut summary = Summary::default();
for outcome in outcomes {
match outcome {
Outcome::Converted {
input_bytes,
output_bytes,
} => {
summary.done += 1;
summary.total_in += input_bytes;
summary.total_out += output_bytes;
}
Outcome::Skipped => summary.skipped += 1,
Outcome::Failed => summary.failed += 1,
}
}
summary
}
fn require_tool(name: &str) -> Result<()> {
Command::new(name)
.arg("-version")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map(|_| ())
.with_context(|| format!("`{name}` was not found in PATH"))
}
fn ensure_distinct_dirs(input: &Path, output: &Path) -> Result<()> {
if let (Ok(a), Ok(b)) = (fs::canonicalize(input), fs::canonicalize(output))
&& a == b
{
bail!("input and output directories must be different");
}
Ok(())
}
fn ensure_distinct_file(input: &Path, output: &Path) -> Result<()> {
if let (Ok(a), Ok(b)) = (fs::canonicalize(input), fs::canonicalize(output))
&& a == b
{
bail!("input and output files must be different");
}
Ok(())
}
fn collect_inputs(dir: &Path, extensions: &[String]) -> Result<Vec<PathBuf>> {
let mut files = Vec::new();
collect_inputs_recursive(dir, extensions, &mut files)?;
files.sort();
Ok(files)
}
fn collect_inputs_recursive(
dir: &Path,
extensions: &[String],
files: &mut Vec<PathBuf>,
) -> Result<()> {
let entries =
fs::read_dir(dir).with_context(|| format!("cannot read directory {}", dir.display()))?;
for entry in entries {
let path = entry?.path();
let file_type = fs::symlink_metadata(&path)
.with_context(|| format!("cannot inspect {}", path.display()))?
.file_type();
if file_type.is_dir() {
collect_inputs_recursive(&path, extensions, files)?;
} else if file_type.is_file()
&& path
.extension()
.and_then(|e| e.to_str())
.is_some_and(|ext| extensions.iter().any(|x| x.eq_ignore_ascii_case(ext)))
{
files.push(path);
}
}
Ok(())
}
fn resolve_encoder(cli: &Cli) -> Result<Encoder> {
if cli.encoder == EncoderChoice::Videotoolbox && cli.codec == Codec::Av1 {
bail!(
"encoder `videotoolbox` does not support AV1; choose auto, nvenc, qsv, amf or software"
);
}
match Encoder::from_choice(cli.encoder, cli.codec) {
Some(encoder) => {
if !cli.dry_run && !encoder.is_usable(cli.codec) {
bail!(
"encoder `{}` is not usable on this machine \
(check your FFmpeg build and drivers, or use --encoder auto)",
encoder.codec_name(cli.codec)
);
}
Ok(encoder)
}
None => {
let encoder = Encoder::detect(cli.codec);
println!(
"{}",
format!("Auto-detected encoder: {}", encoder.codec_name(cli.codec)).bright_black()
);
Ok(encoder)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_extensions_include_audio_only_when_audio_processing_is_requested() {
let video = default_extensions(false);
assert!(video.contains(&"mp4".to_string()));
assert_eq!(video, ["mp4"]);
let mixed = default_extensions(true);
assert!(mixed.contains(&"mkv".to_string()));
assert!(mixed.contains(&"flac".to_string()));
}
#[test]
fn audio_routing_handles_video_and_standalone_sources() {
assert!(!use_audio_pipeline(false, true));
assert!(use_audio_pipeline(false, false));
assert!(use_audio_pipeline(true, true));
assert!(use_audio_pipeline(true, false));
}
#[test]
fn summary_aggregates_outcomes_independent_of_completion_order() {
let summary = summarize([
Outcome::Failed,
Outcome::Converted {
input_bytes: 300,
output_bytes: 120,
},
Outcome::Skipped,
Outcome::Converted {
input_bytes: 500,
output_bytes: 200,
},
]);
assert_eq!(
summary,
Summary {
done: 2,
failed: 1,
skipped: 1,
total_in: 800,
total_out: 320,
}
);
}
#[test]
fn collect_inputs_filters_by_extension_case_insensitively() {
let dir = std::env::temp_dir().join(format!("shrivel-test-{}", std::process::id()));
fs::create_dir_all(dir.join("nested/deeper")).unwrap();
for name in ["b.MP4", "a.mp4", "c.mkv", "d.txt"] {
fs::write(dir.join(name), b"").unwrap();
}
fs::write(dir.join("nested/video.mp4"), b"").unwrap();
fs::write(dir.join("nested/deeper/other.MP4"), b"").unwrap();
let found = collect_inputs(&dir, &["mp4".to_string()]).unwrap();
let relative_paths: Vec<Vec<String>> = found
.iter()
.map(|p| {
p.strip_prefix(&dir)
.unwrap()
.components()
.map(|component| component.as_os_str().to_string_lossy().into_owned())
.collect()
})
.collect();
assert_eq!(
relative_paths,
[
vec!["a.mp4"],
vec!["b.MP4"],
vec!["nested", "deeper", "other.MP4"],
vec!["nested", "video.mp4"]
]
);
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn output_path_preserves_input_subdirectories() {
let input_root = Path::new("input");
let output_root = Path::new("output");
let input = input_root.join("nested/deeper/video.mp4");
assert_eq!(
conversion::output_path(&input, input_root, output_root),
Some(output_root.join("nested/deeper/video.mp4"))
);
}
}