use std::ffi::OsString;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use crate::cli::AudioCodec;
use crate::conversion::{Outcome, quote};
use crate::progress::ProgressBars;
use serde::Deserialize;
#[derive(Deserialize)]
struct Probe {
streams: Vec<Stream>,
format: Option<Format>,
}
#[derive(Deserialize)]
struct Stream {
bit_rate: Option<String>,
duration: Option<String>,
}
#[derive(Deserialize)]
struct Format {
bit_rate: Option<String>,
duration: Option<String>,
}
pub(super) fn output_path(
input: &Path,
root: &Path,
output: &Path,
codec: AudioCodec,
) -> Option<PathBuf> {
let mut path = output.join(input.strip_prefix(root).ok()?);
path.set_extension(match codec {
AudioCodec::Mp3 => "mp3",
AudioCodec::Aac => "m4a",
AudioCodec::Opus => "opus",
AudioCodec::Copy => return None,
});
Some(path)
}
fn audio_bitrate(cq: u8, high: u16, low: u16) -> u16 {
high - (((high - low) as u32 * cq.min(51) as u32 + 25) / 51) as u16
}
fn ffmpeg_args(
input: &Path,
output: &Path,
codec: AudioCodec,
cq: u8,
) -> Option<(Vec<OsString>, u16)> {
let (encoder, bitrate) = match codec {
AudioCodec::Mp3 => ("libmp3lame", audio_bitrate(cq, 320, 96)),
AudioCodec::Aac => ("aac", audio_bitrate(cq, 256, 64)),
AudioCodec::Opus => ("libopus", audio_bitrate(cq, 192, 48)),
AudioCodec::Copy => return None,
};
let args = vec![
"-hide_banner".into(),
"-loglevel".into(),
"error".into(),
"-nostdin".into(),
"-i".into(),
input.as_os_str().to_owned(),
"-map".into(),
"0:a".into(),
"-vn".into(),
"-sn".into(),
"-dn".into(),
"-map_metadata".into(),
"0".into(),
"-c:a".into(),
encoder.into(),
"-b:a".into(),
format!("{bitrate}k").into(),
"-y".into(),
output.as_os_str().to_owned(),
];
Some((args, bitrate))
}
pub(super) fn convert_one(
input: &Path,
output: &Path,
codec: AudioCodec,
cq: u8,
dynamic_cq: bool,
dry_run: bool,
skip_existing: bool,
progress: &ProgressBars,
label: &str,
) -> Outcome {
let probe = Command::new("ffprobe")
.args([
"-v",
"error",
"-select_streams",
"a",
"-show_entries",
"stream=codec_name,bit_rate,duration:format=bit_rate,duration",
"-of",
"json",
])
.arg(input)
.stdin(Stdio::null())
.output();
let raw = match probe.and_then(|o| {
if o.status.success() {
Ok(o.stdout)
} else {
Err(std::io::Error::other(
String::from_utf8_lossy(&o.stderr).to_string(),
))
}
}) {
Ok(bytes) => match serde_json::from_slice::<Probe>(&bytes) {
Ok(p) if !p.streams.is_empty() => p,
_ => {
progress.println(format!("[ERR] {label}: no readable audio stream"));
return Outcome::Failed;
}
},
Err(e) => {
progress.println(format!("[ERR] {label}: ffprobe failed: {e}"));
return Outcome::Failed;
}
};
if skip_existing && output.exists() {
progress.println(format!("[SKIP] {label}: output exists"));
return Outcome::Skipped;
}
if !dry_run && let Some(parent) = output.parent() {
if let Err(e) = fs::create_dir_all(parent) {
progress.println(format!(
"[ERR] {label}: cannot create output directory: {e}"
));
return Outcome::Failed;
}
}
let stream = &raw.streams[0];
let br = stream
.bit_rate
.as_deref()
.or_else(|| raw.format.as_ref().and_then(|f| f.bit_rate.as_deref()))
.and_then(|x| x.parse::<u32>().ok())
.map(|x| x / 1000)
.unwrap_or(0);
let duration = stream
.duration
.as_deref()
.or_else(|| raw.format.as_ref().and_then(|f| f.duration.as_deref()))
.and_then(|x| x.parse::<f64>().ok())
.filter(|x| x.is_finite() && *x > 0.0)
.unwrap_or(0.0);
let effective_cq = if dynamic_cq {
crate::job::calculate_cq(cq, br)
} else {
cq
};
let (args, bps) =
ffmpeg_args(input, output, codec, effective_cq).expect("audio codec required");
if dry_run {
progress.println(format!(
"[DRY] ffmpeg {}",
args.iter()
.map(|a| quote(&a.to_string_lossy()))
.collect::<Vec<_>>()
.join(" ")
));
let input_bytes = fs::metadata(input).map(|m| m.len()).unwrap_or(0);
let estimated = if duration > 0.0 {
(bps as f64 * 1000.0 / 8.0 * duration) as u64
} else {
input_bytes
};
progress.println(format!(
"[EST] {:.1} MB -> {:.1} MB",
input_bytes as f64 / 1_048_576.0,
estimated as f64 / 1_048_576.0
));
return Outcome::Converted {
input_bytes,
output_bytes: estimated,
};
}
let result = Command::new("ffmpeg")
.args(&args)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.output();
match result {
Ok(out) if out.status.success() => {
let valid = Command::new("ffprobe")
.args([
"-v",
"error",
"-select_streams",
"a",
"-show_entries",
"stream=codec_name,duration:format=duration",
"-of",
"json",
])
.arg(output)
.output()
.ok()
.and_then(|o| serde_json::from_slice::<Probe>(&o.stdout).ok())
.is_some_and(|p| {
!p.streams.is_empty() && {
let d = p.streams[0]
.duration
.as_deref()
.or_else(|| p.format.as_ref().and_then(|f| f.duration.as_deref()))
.and_then(|x| x.parse::<f64>().ok())
.unwrap_or(0.0);
duration <= 0.0 || d <= 0.0 || (duration - d).abs() <= 0.5
}
});
if !valid {
let _ = fs::remove_file(output);
progress.println(format!("[ERR] {label}: output validation failed"));
return Outcome::Failed;
}
let input_bytes = fs::metadata(input).map(|m| m.len()).unwrap_or(0);
let output_bytes = fs::metadata(output).map(|m| m.len()).unwrap_or(0);
progress.println(format!(
"[OK] {label}: {:.1} MB -> {:.1} MB",
input_bytes as f64 / 1_048_576.0,
output_bytes as f64 / 1_048_576.0
));
Outcome::Converted {
input_bytes,
output_bytes,
}
}
Ok(out) => {
let _ = fs::remove_file(output);
progress.println(format!(
"[ERR] {label}: {}",
String::from_utf8_lossy(&out.stderr).trim()
));
Outcome::Failed
}
Err(e) => {
progress.println(format!("[ERR] {label}: cannot run ffmpeg: {e}"));
Outcome::Failed
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn output_extension_matches_selected_audio_codec() {
let input = Path::new("source/nested/track.flac");
let root = Path::new("source");
let output = Path::new("converted");
assert_eq!(
output_path(input, root, output, AudioCodec::Mp3),
Some(PathBuf::from("converted/nested/track.mp3"))
);
assert_eq!(
output_path(input, root, output, AudioCodec::Aac),
Some(PathBuf::from("converted/nested/track.m4a"))
);
assert_eq!(
output_path(input, root, output, AudioCodec::Opus),
Some(PathBuf::from("converted/nested/track.opus"))
);
assert_eq!(output_path(input, root, output, AudioCodec::Copy), None);
}
#[test]
fn cq_maps_to_expected_bitrate_endpoints_and_direction() {
assert_eq!(audio_bitrate(0, 192, 48), 192);
assert_eq!(audio_bitrate(51, 192, 48), 48);
assert!(audio_bitrate(40, 192, 48) < audio_bitrate(10, 192, 48));
}
#[test]
fn ffmpeg_plan_maps_audio_only_and_selects_the_codec_encoder() {
let (args, bitrate) = ffmpeg_args(
Path::new("in.flac"),
Path::new("out.opus"),
AudioCodec::Opus,
26,
)
.unwrap();
let args: Vec<_> = args
.iter()
.map(|a| a.to_string_lossy().into_owned())
.collect();
assert!(args.windows(2).any(|w| w == ["-map", "0:a"]));
assert!(args.windows(2).any(|w| w == ["-c:a", "libopus"]));
assert!(args.windows(2).any(|w| w == ["-vn", "-sn"]));
assert!(
args.windows(2)
.any(|w| w == ["-b:a", &format!("{bitrate}k")])
);
assert!(!args.iter().any(|arg| arg == "-c:v"));
}
}