use crate::audio::AacEncoder;
use crate::models::QualityProfile;
use anyhow::{Context, Result};
use serde_json::Value;
use std::path::Path;
use std::process::Stdio;
use tokio::process::Command;
#[derive(Debug, Clone, Default)]
pub struct AudioMetadata {
pub title: Option<String>,
pub artist: Option<String>,
pub album: Option<String>,
pub album_artist: Option<String>,
pub year: Option<u32>,
pub genre: Option<String>,
pub composer: Option<String>,
pub comment: Option<String>,
}
#[derive(Clone)]
pub struct FFmpeg {
ffmpeg_path: String,
ffprobe_path: String,
}
impl FFmpeg {
pub fn new() -> Result<Self> {
let ffmpeg_path = which::which("ffmpeg")
.context("FFmpeg not found in PATH")?
.to_string_lossy()
.to_string();
let ffprobe_path = which::which("ffprobe")
.context("FFprobe not found in PATH")?
.to_string_lossy()
.to_string();
Ok(Self {
ffmpeg_path,
ffprobe_path,
})
}
pub fn with_paths(ffmpeg_path: String, ffprobe_path: String) -> Self {
Self {
ffmpeg_path,
ffprobe_path,
}
}
pub async fn probe_audio_file(&self, path: &Path) -> Result<QualityProfile> {
let output = Command::new(&self.ffprobe_path)
.args(&[
"-v", "quiet",
"-print_format", "json",
"-show_streams",
"-show_format",
])
.arg(path)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.await
.context("Failed to execute ffprobe")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("FFprobe failed: {}", stderr);
}
let json: Value = serde_json::from_slice(&output.stdout)
.context("Failed to parse ffprobe JSON output")?;
self.parse_ffprobe_output(&json)
}
fn parse_ffprobe_output(&self, json: &Value) -> Result<QualityProfile> {
let streams = json["streams"]
.as_array()
.context("No streams in ffprobe output")?;
let audio_stream = streams
.iter()
.find(|s| s["codec_type"] == "audio")
.context("No audio stream found")?;
let bitrate = if let Some(bit_rate) = audio_stream["bit_rate"].as_str() {
bit_rate.parse::<u32>()? / 1000 } else {
json["format"]["bit_rate"]
.as_str()
.context("No bitrate found")?
.parse::<u32>()? / 1000
};
let sample_rate = audio_stream["sample_rate"]
.as_str()
.context("No sample rate found")?
.parse::<u32>()?;
let channels = audio_stream["channels"]
.as_u64()
.context("No channels found")? as u8;
let codec = audio_stream["codec_name"]
.as_str()
.context("No codec found")?
.to_string();
let duration = if let Some(dur) = audio_stream["duration"].as_str() {
dur.parse::<f64>()?
} else {
json["format"]["duration"]
.as_str()
.context("No duration found")?
.parse::<f64>()?
};
QualityProfile::new(bitrate, sample_rate, channels, codec, duration)
}
pub async fn concat_audio_files(
&self,
concat_file: &Path,
output_file: &Path,
quality: &QualityProfile,
use_copy: bool,
encoder: AacEncoder,
) -> Result<()> {
let mut cmd = Command::new(&self.ffmpeg_path);
cmd.args(&[
"-y",
"-f", "concat",
"-safe", "0",
"-i",
])
.arg(concat_file);
cmd.arg("-vn");
if use_copy {
cmd.args(&["-c", "copy"]);
} else {
cmd.args(&[
"-c:a", encoder.name(),
"-b:a", &format!("{}k", quality.bitrate),
"-ar", &quality.sample_rate.to_string(),
"-ac", &quality.channels.to_string(),
]);
if encoder.supports_threading() {
cmd.args(&["-threads", "0"]); }
}
cmd.args(&["-movflags", "+faststart"]);
cmd.arg(output_file);
tracing::debug!("FFmpeg concat command: {:?}", cmd.as_std());
tracing::info!(
"Concatenating {} ({}mode)",
concat_file.display(),
if use_copy { "copy " } else { "transcode " }
);
let output = cmd
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.await
.context("Failed to execute ffmpeg")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.to_lowercase().contains("encoder") {
anyhow::bail!(
"FFmpeg encoding failed with encoder '{}': {}\nTip: Run 'audiobook-forge check' to verify encoder availability",
encoder.name(),
stderr
);
}
anyhow::bail!("FFmpeg concatenation failed: {}", stderr);
}
Ok(())
}
pub async fn convert_single_file(
&self,
input_file: &Path,
output_file: &Path,
quality: &QualityProfile,
use_copy: bool,
encoder: AacEncoder,
) -> Result<()> {
let mut cmd = Command::new(&self.ffmpeg_path);
cmd.args(&["-y", "-i"])
.arg(input_file);
cmd.arg("-vn");
if use_copy {
cmd.args(&["-c", "copy"]);
} else {
cmd.args(&[
"-c:a", encoder.name(),
"-b:a", &format!("{}k", quality.bitrate),
"-ar", &quality.sample_rate.to_string(),
"-ac", &quality.channels.to_string(),
]);
if encoder.supports_threading() {
cmd.args(&["-threads", "0"]); }
}
cmd.args(&["-movflags", "+faststart"]);
cmd.arg(output_file);
tracing::debug!("FFmpeg convert command: {:?}", cmd.as_std());
tracing::info!(
"Converting {} → {} (encoder: {}, {}kbps)",
input_file.file_name().unwrap().to_string_lossy(),
output_file.file_name().unwrap().to_string_lossy(),
encoder.name(),
quality.bitrate
);
let output = cmd
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.await
.context("Failed to execute ffmpeg")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.to_lowercase().contains("encoder") {
anyhow::bail!(
"FFmpeg encoding failed with encoder '{}': {}\nTip: Run 'audiobook-forge check' to verify encoder availability",
encoder.name(),
stderr
);
}
anyhow::bail!("FFmpeg conversion failed: {}", stderr);
}
Ok(())
}
pub fn create_concat_file(files: &[&Path], output: &Path) -> Result<()> {
let mut content = String::new();
for file in files {
if !file.exists() {
anyhow::bail!("File not found: {}", file.display());
}
let abs_path = file.canonicalize()
.with_context(|| format!("Failed to resolve path: {}", file.display()))?;
let path_str = abs_path.to_string_lossy();
let escaped = path_str.replace('\'', r"'\''");
content.push_str(&format!("file '{}'\n", escaped));
}
std::fs::write(output, content)
.context("Failed to write concat file")?;
Ok(())
}
pub async fn concat_m4b_files(
&self,
concat_file: &Path,
output_file: &Path,
) -> Result<()> {
let mut cmd = Command::new(&self.ffmpeg_path);
cmd.args([
"-y",
"-f", "concat",
"-safe", "0",
"-i",
])
.arg(concat_file)
.args([
"-c", "copy", "-movflags", "+faststart",
])
.arg(output_file);
tracing::debug!("FFmpeg M4B concat command: {:?}", cmd.as_std());
tracing::info!("Concatenating M4B files (lossless copy mode)");
let output = cmd
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.await
.context("Failed to execute ffmpeg")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("FFmpeg M4B concatenation failed: {}", stderr);
}
Ok(())
}
pub async fn probe_metadata(&self, path: &Path) -> Result<AudioMetadata> {
let output = Command::new(&self.ffprobe_path)
.args([
"-v", "quiet",
"-print_format", "json",
"-show_format",
])
.arg(path)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.await
.context("Failed to execute ffprobe")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("FFprobe failed: {}", stderr);
}
let json: Value = serde_json::from_slice(&output.stdout)
.context("Failed to parse ffprobe JSON output")?;
let tags = &json["format"]["tags"];
Ok(AudioMetadata {
title: tags["title"].as_str().map(String::from),
artist: tags["artist"].as_str().map(String::from),
album: tags["album"].as_str().map(String::from),
album_artist: tags["album_artist"].as_str().map(String::from),
year: tags["date"].as_str().and_then(|s| s.get(..4).and_then(|y| y.parse().ok())),
genre: tags["genre"].as_str().map(String::from),
composer: tags["composer"].as_str().map(String::from),
comment: tags["comment"].as_str().map(String::from),
})
}
}
impl Default for FFmpeg {
fn default() -> Self {
Self::new().expect("FFmpeg not found")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_ffmpeg_initialization() {
let ffmpeg = FFmpeg::new();
assert!(ffmpeg.is_ok());
}
#[test]
fn test_parse_ffprobe_json() {
let json_str = r#"{
"streams": [{
"codec_type": "audio",
"codec_name": "mp3",
"sample_rate": "44100",
"channels": 2,
"bit_rate": "128000",
"duration": "3600.5"
}],
"format": {
"bit_rate": "128000",
"duration": "3600.5"
}
}"#;
let json: Value = serde_json::from_str(json_str).unwrap();
let ffmpeg = FFmpeg::new().unwrap();
let profile = ffmpeg.parse_ffprobe_output(&json).unwrap();
assert_eq!(profile.bitrate, 128);
assert_eq!(profile.sample_rate, 44100);
assert_eq!(profile.channels, 2);
assert_eq!(profile.codec, "mp3");
assert!((profile.duration - 3600.5).abs() < 0.1);
}
}