use std::fmt;
use std::io::{BufRead, BufReader, Read};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::mpsc::{self, Receiver, Sender};
use std::thread;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConversionMode {
ExtractAudio,
TranscodeVideo,
TranscodeAudio,
}
impl ConversionMode {
pub const ALL: [ConversionMode; 3] = [
ConversionMode::ExtractAudio,
ConversionMode::TranscodeVideo,
ConversionMode::TranscodeAudio,
];
pub fn title(self) -> &'static str {
match self {
ConversionMode::ExtractAudio => "Video -> Audio",
ConversionMode::TranscodeVideo => "Video -> Video",
ConversionMode::TranscodeAudio => "Audio -> Audio",
}
}
pub fn description(self) -> &'static str {
match self {
ConversionMode::ExtractAudio => "Strip video and encode the audio track only.",
ConversionMode::TranscodeVideo => "Re-encode video into another container/codec route.",
ConversionMode::TranscodeAudio => "Convert one audio file into another audio format.",
}
}
pub fn source_label(self) -> &'static str {
match self {
ConversionMode::ExtractAudio | ConversionMode::TranscodeVideo => "VIDEO SOURCE",
ConversionMode::TranscodeAudio => "AUDIO SOURCE",
}
}
pub fn note_text(self) -> &'static str {
match self {
ConversionMode::ExtractAudio => {
"EXTRACT ROUTE // SOURCE VIDEO -> AUDIO CODEC -> OUTPUT CONTAINER"
}
ConversionMode::TranscodeVideo => {
"VIDEO ROUTE // SOURCE STREAMS -> TRANSCODE MATRIX -> OUTPUT CONTAINER"
}
ConversionMode::TranscodeAudio => {
"AUDIO ROUTE // SOURCE AUDIO -> CODEC CONVERSION -> OUTPUT FILE"
}
}
}
pub fn expects_video_input(self) -> bool {
matches!(
self,
ConversionMode::ExtractAudio | ConversionMode::TranscodeVideo
)
}
pub fn output_extension(self, audio: AudioFormat, video: VideoFormat) -> &'static str {
match self {
ConversionMode::ExtractAudio | ConversionMode::TranscodeAudio => audio.extension(),
ConversionMode::TranscodeVideo => video.extension(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AudioFormat {
Mp3,
M4a,
Flac,
Wav,
Ogg,
Opus,
}
impl AudioFormat {
pub const ALL: [AudioFormat; 6] = [
AudioFormat::Mp3,
AudioFormat::M4a,
AudioFormat::Flac,
AudioFormat::Wav,
AudioFormat::Ogg,
AudioFormat::Opus,
];
pub fn label(self) -> &'static str {
match self {
AudioFormat::Mp3 => "MP3",
AudioFormat::M4a => "M4A/AAC",
AudioFormat::Flac => "FLAC",
AudioFormat::Wav => "WAV",
AudioFormat::Ogg => "OGG",
AudioFormat::Opus => "OPUS",
}
}
pub fn extension(self) -> &'static str {
match self {
AudioFormat::Mp3 => "mp3",
AudioFormat::M4a => "m4a",
AudioFormat::Flac => "flac",
AudioFormat::Wav => "wav",
AudioFormat::Ogg => "ogg",
AudioFormat::Opus => "opus",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VideoFormat {
Mp4,
Mkv,
Webm,
Mov,
}
impl VideoFormat {
pub const ALL: [VideoFormat; 4] = [
VideoFormat::Mp4,
VideoFormat::Mkv,
VideoFormat::Webm,
VideoFormat::Mov,
];
pub fn label(self) -> &'static str {
match self {
VideoFormat::Mp4 => "MP4",
VideoFormat::Mkv => "MKV",
VideoFormat::Webm => "WEBM",
VideoFormat::Mov => "MOV",
}
}
pub fn extension(self) -> &'static str {
match self {
VideoFormat::Mp4 => "mp4",
VideoFormat::Mkv => "mkv",
VideoFormat::Webm => "webm",
VideoFormat::Mov => "mov",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum QualityPreset {
Compact,
Balanced,
Archive,
}
impl QualityPreset {
pub const ALL: [QualityPreset; 3] = [
QualityPreset::Compact,
QualityPreset::Balanced,
QualityPreset::Archive,
];
pub fn label(self) -> &'static str {
match self {
QualityPreset::Compact => "Compact",
QualityPreset::Balanced => "Balanced",
QualityPreset::Archive => "Archive",
}
}
pub fn description(self) -> &'static str {
match self {
QualityPreset::Compact => "smaller file",
QualityPreset::Balanced => "general use",
QualityPreset::Archive => "higher fidelity",
}
}
fn x264_crf(self) -> &'static str {
match self {
QualityPreset::Compact => "28",
QualityPreset::Balanced => "23",
QualityPreset::Archive => "18",
}
}
fn vp9_crf(self) -> &'static str {
match self {
QualityPreset::Compact => "38",
QualityPreset::Balanced => "32",
QualityPreset::Archive => "24",
}
}
fn audio_bitrate(self) -> &'static str {
match self {
QualityPreset::Compact => "128k",
QualityPreset::Balanced => "192k",
QualityPreset::Archive => "256k",
}
}
fn vorbis_quality(self) -> &'static str {
match self {
QualityPreset::Compact => "3",
QualityPreset::Balanced => "5",
QualityPreset::Archive => "8",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConversionRequest {
pub mode: ConversionMode,
pub input_path: PathBuf,
pub output_path: PathBuf,
pub audio_format: AudioFormat,
pub video_format: VideoFormat,
pub quality: QualityPreset,
pub overwrite: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CommandSpec {
pub program: String,
pub args: Vec<String>,
}
impl fmt::Display for CommandSpec {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(formatter, "{}", self.program)?;
for arg in &self.args {
write!(formatter, " {}", shell_quote(arg))?;
}
Ok(())
}
}
#[derive(Debug, thiserror::Error)]
pub enum ConversionError {
#[error("select a source media file first")]
MissingInput,
#[error("source file does not exist: {0}")]
MissingInputFile(String),
#[error("source path must be a regular file: {0}")]
InputNotFile(String),
#[error("selected route expects a video source: {0}")]
ExpectedVideoInput(String),
#[error("selected route expects an audio source: {0}")]
ExpectedAudioInput(String),
#[error("choose an output file path first")]
MissingOutput,
#[error("output parent folder does not exist: {0}")]
MissingOutputFolder(String),
#[error("output cannot be the same file as the source")]
SameInputOutput,
#[error("output extension should be .{expected} for the selected route")]
OutputExtensionMismatch { expected: &'static str },
#[error("output already exists; enable overwrite or choose another path: {0}")]
OutputExists(String),
}
pub fn build_command(request: &ConversionRequest) -> Result<CommandSpec, ConversionError> {
validate_request(request)?;
let mut args = vec![
"-hide_banner".to_owned(),
"-nostdin".to_owned(),
"-progress".to_owned(),
"pipe:1".to_owned(),
"-nostats".to_owned(),
if request.overwrite { "-y" } else { "-n" }.to_owned(),
"-i".to_owned(),
request.input_path.display().to_string(),
];
match request.mode {
ConversionMode::ExtractAudio | ConversionMode::TranscodeAudio => {
args.push("-map".to_owned());
args.push("0:a:0".to_owned());
args.push("-vn".to_owned());
apply_audio_args(&mut args, request.audio_format, request.quality);
}
ConversionMode::TranscodeVideo => {
args.push("-map".to_owned());
args.push("0:v:0".to_owned());
args.push("-map".to_owned());
args.push("0:a?".to_owned());
args.push("-sn".to_owned());
apply_video_args(&mut args, request.video_format, request.quality);
}
}
args.push(request.output_path.display().to_string());
Ok(CommandSpec {
program: "ffmpeg".to_owned(),
args,
})
}
pub fn validate_request(request: &ConversionRequest) -> Result<(), ConversionError> {
if request.input_path.as_os_str().is_empty() {
return Err(ConversionError::MissingInput);
}
let input_display = request.input_path.display().to_string();
let metadata = std::fs::metadata(&request.input_path)
.map_err(|_| ConversionError::MissingInputFile(input_display.clone()))?;
if !metadata.is_file() {
return Err(ConversionError::InputNotFile(input_display));
}
if request.mode.expects_video_input() && !supported_video_extension(&request.input_path) {
return Err(ConversionError::ExpectedVideoInput(
request.input_path.display().to_string(),
));
}
if matches!(request.mode, ConversionMode::TranscodeAudio)
&& !supported_audio_extension(&request.input_path)
{
return Err(ConversionError::ExpectedAudioInput(
request.input_path.display().to_string(),
));
}
if request.output_path.as_os_str().is_empty() {
return Err(ConversionError::MissingOutput);
}
if request.input_path == request.output_path
|| canonical_match(&request.input_path, &request.output_path)
{
return Err(ConversionError::SameInputOutput);
}
if let Some(parent) = request.output_path.parent() {
if !parent.as_os_str().is_empty() && !parent.is_dir() {
return Err(ConversionError::MissingOutputFolder(
parent.display().to_string(),
));
}
}
let expected = request
.mode
.output_extension(request.audio_format, request.video_format);
if path_extension_lower(&request.output_path).as_deref() != Some(expected) {
return Err(ConversionError::OutputExtensionMismatch { expected });
}
if request.output_path.exists() && !request.overwrite {
return Err(ConversionError::OutputExists(
request.output_path.display().to_string(),
));
}
Ok(())
}
fn canonical_match(left: &Path, right: &Path) -> bool {
match (std::fs::canonicalize(left), std::fs::canonicalize(right)) {
(Ok(left), Ok(right)) => left == right,
_ => false,
}
}
fn apply_audio_args(args: &mut Vec<String>, format: AudioFormat, quality: QualityPreset) {
match format {
AudioFormat::Mp3 => {
push_args(
args,
["-c:a", "libmp3lame", "-b:a", quality.audio_bitrate()],
);
}
AudioFormat::M4a => {
push_args(args, ["-c:a", "aac", "-b:a", quality.audio_bitrate()]);
push_args(args, ["-movflags", "+faststart"]);
}
AudioFormat::Flac => push_args(args, ["-c:a", "flac"]),
AudioFormat::Wav => push_args(args, ["-c:a", "pcm_s16le"]),
AudioFormat::Ogg => {
push_args(
args,
["-c:a", "libvorbis", "-q:a", quality.vorbis_quality()],
);
}
AudioFormat::Opus => {
push_args(args, ["-c:a", "libopus", "-b:a", quality.audio_bitrate()]);
}
}
}
fn apply_video_args(args: &mut Vec<String>, format: VideoFormat, quality: QualityPreset) {
match format {
VideoFormat::Mp4 | VideoFormat::Mkv | VideoFormat::Mov => {
push_args(
args,
[
"-c:v",
"libx264",
"-preset",
"medium",
"-crf",
quality.x264_crf(),
"-pix_fmt",
"yuv420p",
"-c:a",
"aac",
"-b:a",
quality.audio_bitrate(),
],
);
if matches!(format, VideoFormat::Mp4 | VideoFormat::Mov) {
push_args(args, ["-movflags", "+faststart"]);
}
}
VideoFormat::Webm => {
push_args(
args,
[
"-c:v",
"libvpx-vp9",
"-b:v",
"0",
"-crf",
quality.vp9_crf(),
"-c:a",
"libopus",
"-b:a",
quality.audio_bitrate(),
],
);
}
}
}
fn push_args<const N: usize>(args: &mut Vec<String>, values: [&str; N]) {
args.extend(values.into_iter().map(str::to_owned));
}
pub fn probe_duration(path: &Path) -> Option<f64> {
let output = Command::new("ffprobe")
.args([
"-v",
"error",
"-show_entries",
"format=duration",
"-of",
"default=noprint_wrappers=1:nokey=1",
])
.arg(path)
.output()
.ok()?;
if !output.status.success() {
return None;
}
let text = String::from_utf8_lossy(&output.stdout);
text.lines()
.find_map(|line| line.trim().parse::<f64>().ok())
.filter(|duration| *duration > 0.0 && duration.is_finite())
}
#[derive(Debug, Clone, PartialEq)]
pub enum OperationEvent {
Started(String),
Log(String),
Progress {
current_seconds: f64,
total_seconds: f64,
},
Finished(Result<(), String>),
}
pub fn start_operation(
command: CommandSpec,
expected_duration: Option<f64>,
) -> Receiver<OperationEvent> {
let (tx, rx) = mpsc::channel();
thread::spawn(move || run_operation(command, expected_duration, tx));
rx
}
fn run_operation(command: CommandSpec, expected_duration: Option<f64>, tx: Sender<OperationEvent>) {
let _ = tx.send(OperationEvent::Started(command.to_string()));
let mut child = match Command::new(&command.program)
.args(&command.args)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
{
Ok(child) => child,
Err(error) => {
let _ = tx.send(OperationEvent::Finished(Err(format!(
"failed to start ffmpeg: {error}"
))));
return;
}
};
let stdout = child.stdout.take();
let stderr = child.stderr.take();
let stdout_thread = stdout.map(|stream| forward_stream(stream, tx.clone(), expected_duration));
let stderr_thread = stderr.map(|stream| forward_stream(stream, tx.clone(), expected_duration));
let result = match child.wait() {
Ok(status) if status.success() => Ok(()),
Ok(status) => Err(format!("ffmpeg exited with status {status}")),
Err(error) => Err(format!("failed to wait for ffmpeg: {error}")),
};
if let Some(thread) = stdout_thread {
let _ = thread.join();
}
if let Some(thread) = stderr_thread {
let _ = thread.join();
}
let _ = tx.send(OperationEvent::Finished(result));
}
fn forward_stream<R>(
stream: R,
tx: Sender<OperationEvent>,
expected_duration: Option<f64>,
) -> thread::JoinHandle<()>
where
R: Read + Send + 'static,
{
thread::spawn(move || {
let reader = BufReader::new(stream);
for line in reader.lines() {
let line = match line {
Ok(line) => line.trim().to_owned(),
Err(error) => {
let _ = tx.send(OperationEvent::Log(format!("stream read failed: {error}")));
break;
}
};
if line.is_empty() {
continue;
}
if let Some(current_seconds) = parse_ffmpeg_progress_seconds(&line) {
if let Some(total_seconds) = expected_duration {
let _ = tx.send(OperationEvent::Progress {
current_seconds: current_seconds.min(total_seconds),
total_seconds,
});
}
continue;
}
if is_ffmpeg_progress_metadata(&line) {
continue;
}
let _ = tx.send(OperationEvent::Log(line));
}
})
}
pub fn parse_ffmpeg_progress_seconds(line: &str) -> Option<f64> {
let (key, value) = line.trim().split_once('=')?;
match key {
"out_time_us" | "out_time_ms" => value
.trim()
.parse::<f64>()
.ok()
.map(|microseconds| microseconds / 1_000_000.0),
"out_time" => parse_ffmpeg_timestamp(value.trim()),
_ => None,
}
}
fn parse_ffmpeg_timestamp(value: &str) -> Option<f64> {
let mut parts = value.split(':');
let hours = parts.next()?.parse::<f64>().ok()?;
let minutes = parts.next()?.parse::<f64>().ok()?;
let seconds = parts.next()?.parse::<f64>().ok()?;
if parts.next().is_some() {
return None;
}
Some(hours * 3600.0 + minutes * 60.0 + seconds)
}
fn is_ffmpeg_progress_metadata(line: &str) -> bool {
let Some((key, _)) = line.split_once('=') else {
return false;
};
matches!(
key,
"bitrate"
| "drop_frames"
| "dup_frames"
| "fps"
| "frame"
| "progress"
| "speed"
| "stream_0_0_q"
| "total_size"
)
}
pub fn duration_progress_label(current_seconds: f64, total_seconds: f64) -> String {
let percent = if total_seconds <= 0.0 {
0.0
} else {
current_seconds / total_seconds * 100.0
}
.clamp(0.0, 100.0);
format!(
"{} / {} ({percent:.1}%)",
format_duration(current_seconds),
format_duration(total_seconds)
)
}
pub fn format_duration(seconds: f64) -> String {
let seconds = seconds.max(0.0).round() as u64;
let hours = seconds / 3600;
let minutes = (seconds % 3600) / 60;
let seconds = seconds % 60;
if hours > 0 {
format!("{hours}:{minutes:02}:{seconds:02}")
} else {
format!("{minutes}:{seconds:02}")
}
}
pub fn suggest_output_path(
input_path: &Path,
mode: ConversionMode,
audio_format: AudioFormat,
video_format: VideoFormat,
) -> Option<PathBuf> {
if input_path.as_os_str().is_empty() {
return None;
}
let stem = input_path.file_stem()?.to_string_lossy();
let extension = mode.output_extension(audio_format, video_format);
let filename = format!("{stem}-caery.{extension}");
let mut output = input_path.parent().map(PathBuf::from).unwrap_or_default();
output.push(filename);
Some(output)
}
pub fn supported_media_extension(path: &Path) -> bool {
supported_audio_extension(path) || supported_video_extension(path)
}
pub fn supported_audio_extension(path: &Path) -> bool {
matches!(
path_extension_lower(path).as_deref(),
Some(
"aac"
| "aif"
| "aiff"
| "alac"
| "flac"
| "m4a"
| "mp3"
| "oga"
| "ogg"
| "opus"
| "wav"
| "wma"
)
)
}
pub fn supported_video_extension(path: &Path) -> bool {
matches!(
path_extension_lower(path).as_deref(),
Some(
"3gp"
| "avi"
| "flv"
| "m2ts"
| "m4v"
| "mkv"
| "mov"
| "mp4"
| "mpeg"
| "mpg"
| "ogv"
| "ts"
| "webm"
| "wmv"
)
)
}
pub fn media_kind_label(path: &Path) -> &'static str {
if supported_video_extension(path) {
"VIDEO"
} else if supported_audio_extension(path) {
"AUDIO"
} else {
"FILE"
}
}
pub fn path_extension_lower(path: &Path) -> Option<String> {
path.extension()
.and_then(|extension| extension.to_str())
.map(|extension| extension.to_ascii_lowercase())
}
pub fn human_size(bytes: u64) -> String {
const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"];
let mut value = bytes as f64;
let mut unit = 0;
while value >= 1024.0 && unit < UNITS.len() - 1 {
value /= 1024.0;
unit += 1;
}
if unit == 0 {
format!("{} {}", bytes, UNITS[unit])
} else {
format!("{value:.1} {}", UNITS[unit])
}
}
pub fn shell_quote(value: &str) -> String {
if value.is_empty() {
return "''".to_owned();
}
format!("'{}'", value.replace('\'', "'\"'\"'"))
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::NamedTempFile;
fn request(input: &Path, output: &Path, mode: ConversionMode) -> ConversionRequest {
ConversionRequest {
mode,
input_path: input.to_path_buf(),
output_path: output.to_path_buf(),
audio_format: AudioFormat::Mp3,
video_format: VideoFormat::Mp4,
quality: QualityPreset::Balanced,
overwrite: true,
}
}
#[test]
fn quotes_shell_values_safely() {
assert_eq!(shell_quote(""), "''");
assert_eq!(shell_quote("clip.mp4"), "'clip.mp4'");
assert_eq!(
shell_quote("artist's clip.mp4"),
"'artist'\"'\"'s clip.mp4'"
);
}
#[test]
fn suggests_caery_output_name() {
let path = Path::new("/tmp/source.video.mp4");
let output = suggest_output_path(
path,
ConversionMode::ExtractAudio,
AudioFormat::Flac,
VideoFormat::Mp4,
)
.expect("suggest output");
assert_eq!(output, PathBuf::from("/tmp/source.video-caery.flac"));
}
#[test]
fn builds_audio_extraction_command() {
let mut input = NamedTempFile::with_suffix(".mp4").expect("temp video");
writeln!(input, "fake video").expect("write temp video");
let output = input.path().with_file_name("clip-caery.mp3");
let request = request(input.path(), &output, ConversionMode::ExtractAudio);
let command = build_command(&request).expect("build command");
assert_eq!(command.program, "ffmpeg");
assert!(command.args.contains(&"-vn".to_owned()));
assert!(command.args.contains(&"libmp3lame".to_owned()));
assert!(command.args.contains(&output.display().to_string()));
}
#[test]
fn builds_webm_transcode_command() {
let mut input = NamedTempFile::with_suffix(".mkv").expect("temp video");
writeln!(input, "fake video").expect("write temp video");
let output = input.path().with_file_name("clip-caery.webm");
let mut request = request(input.path(), &output, ConversionMode::TranscodeVideo);
request.video_format = VideoFormat::Webm;
let command = build_command(&request).expect("build command");
assert!(command.args.contains(&"libvpx-vp9".to_owned()));
assert!(command.args.contains(&"libopus".to_owned()));
assert!(command.args.contains(&"0:a?".to_owned()));
}
#[test]
fn rejects_existing_output_without_overwrite() {
let mut input = NamedTempFile::with_suffix(".wav").expect("temp audio");
writeln!(input, "fake audio").expect("write temp audio");
let output = NamedTempFile::with_suffix(".mp3").expect("temp output");
let mut request = request(input.path(), output.path(), ConversionMode::TranscodeAudio);
request.overwrite = false;
let error = validate_request(&request).expect_err("reject existing output");
assert!(matches!(error, ConversionError::OutputExists(_)));
}
#[test]
fn parses_ffmpeg_progress_lines() {
assert_eq!(
parse_ffmpeg_progress_seconds("out_time_us=1250000"),
Some(1.25)
);
assert_eq!(
parse_ffmpeg_progress_seconds("out_time_ms=2500000"),
Some(2.5)
);
assert_eq!(
parse_ffmpeg_progress_seconds("out_time=00:01:02.500000"),
Some(62.5)
);
assert_eq!(parse_ffmpeg_progress_seconds("frame=20"), None);
}
#[test]
fn labels_duration_progress() {
assert_eq!(duration_progress_label(30.0, 120.0), "0:30 / 2:00 (25.0%)");
}
#[test]
fn formats_human_sizes() {
assert_eq!(human_size(0), "0 B");
assert_eq!(human_size(1024), "1.0 KiB");
assert_eq!(human_size(4 * 1024 * 1024 * 1024), "4.0 GiB");
}
}