1use crate::audio::AacEncoder;
4use crate::models::QualityProfile;
5use anyhow::{Context, Result};
6use serde_json::Value;
7use std::path::Path;
8use std::process::Stdio;
9use tokio::process::Command;
10
11#[derive(Clone)]
13pub struct FFmpeg {
14 ffmpeg_path: String,
16 ffprobe_path: String,
18}
19
20impl FFmpeg {
21 pub fn new() -> Result<Self> {
23 let ffmpeg_path = which::which("ffmpeg")
24 .context("FFmpeg not found in PATH")?
25 .to_string_lossy()
26 .to_string();
27
28 let ffprobe_path = which::which("ffprobe")
29 .context("FFprobe not found in PATH")?
30 .to_string_lossy()
31 .to_string();
32
33 Ok(Self {
34 ffmpeg_path,
35 ffprobe_path,
36 })
37 }
38
39 pub fn with_paths(ffmpeg_path: String, ffprobe_path: String) -> Self {
41 Self {
42 ffmpeg_path,
43 ffprobe_path,
44 }
45 }
46
47 pub async fn probe_audio_file(&self, path: &Path) -> Result<QualityProfile> {
49 let output = Command::new(&self.ffprobe_path)
50 .args(&[
51 "-v", "quiet",
52 "-print_format", "json",
53 "-show_streams",
54 "-show_format",
55 ])
56 .arg(path)
57 .stdout(Stdio::piped())
58 .stderr(Stdio::piped())
59 .output()
60 .await
61 .context("Failed to execute ffprobe")?;
62
63 if !output.status.success() {
64 let stderr = String::from_utf8_lossy(&output.stderr);
65 anyhow::bail!("FFprobe failed: {}", stderr);
66 }
67
68 let json: Value = serde_json::from_slice(&output.stdout)
69 .context("Failed to parse ffprobe JSON output")?;
70
71 self.parse_ffprobe_output(&json)
72 }
73
74 fn parse_ffprobe_output(&self, json: &Value) -> Result<QualityProfile> {
76 let streams = json["streams"]
78 .as_array()
79 .context("No streams in ffprobe output")?;
80
81 let audio_stream = streams
82 .iter()
83 .find(|s| s["codec_type"] == "audio")
84 .context("No audio stream found")?;
85
86 let bitrate = if let Some(bit_rate) = audio_stream["bit_rate"].as_str() {
88 bit_rate.parse::<u32>()? / 1000 } else {
90 json["format"]["bit_rate"]
92 .as_str()
93 .context("No bitrate found")?
94 .parse::<u32>()? / 1000
95 };
96
97 let sample_rate = audio_stream["sample_rate"]
99 .as_str()
100 .context("No sample rate found")?
101 .parse::<u32>()?;
102
103 let channels = audio_stream["channels"]
105 .as_u64()
106 .context("No channels found")? as u8;
107
108 let codec = audio_stream["codec_name"]
110 .as_str()
111 .context("No codec found")?
112 .to_string();
113
114 let duration = if let Some(dur) = audio_stream["duration"].as_str() {
116 dur.parse::<f64>()?
117 } else {
118 json["format"]["duration"]
119 .as_str()
120 .context("No duration found")?
121 .parse::<f64>()?
122 };
123
124 QualityProfile::new(bitrate, sample_rate, channels, codec, duration)
125 }
126
127 pub async fn concat_audio_files(
129 &self,
130 concat_file: &Path,
131 output_file: &Path,
132 quality: &QualityProfile,
133 use_copy: bool,
134 encoder: AacEncoder,
135 ) -> Result<()> {
136 let mut cmd = Command::new(&self.ffmpeg_path);
137
138 cmd.args(&[
139 "-y",
140 "-f", "concat",
141 "-safe", "0",
142 "-i",
143 ])
144 .arg(concat_file);
145
146 cmd.arg("-vn");
148
149 if use_copy {
150 cmd.args(&["-c", "copy"]);
152 } else {
153 cmd.args(&[
155 "-c:a", encoder.name(),
156 "-b:a", &format!("{}k", quality.bitrate),
157 "-ar", &quality.sample_rate.to_string(),
158 "-ac", &quality.channels.to_string(),
159 ]);
160
161 if encoder.supports_threading() {
163 cmd.args(&["-threads", "0"]); }
165 }
166
167 cmd.args(&["-movflags", "+faststart"]);
169 cmd.arg(output_file);
170
171 let output = cmd
173 .stdout(Stdio::piped())
174 .stderr(Stdio::piped())
175 .output()
176 .await
177 .context("Failed to execute ffmpeg")?;
178
179 if !output.status.success() {
180 let stderr = String::from_utf8_lossy(&output.stderr);
181 if stderr.to_lowercase().contains("encoder") {
183 anyhow::bail!(
184 "FFmpeg encoding failed with encoder '{}': {}\nTip: Run 'audiobook-forge check' to verify encoder availability",
185 encoder.name(),
186 stderr
187 );
188 }
189 anyhow::bail!("FFmpeg concatenation failed: {}", stderr);
190 }
191
192 Ok(())
193 }
194
195 pub async fn convert_single_file(
197 &self,
198 input_file: &Path,
199 output_file: &Path,
200 quality: &QualityProfile,
201 use_copy: bool,
202 encoder: AacEncoder,
203 ) -> Result<()> {
204 let mut cmd = Command::new(&self.ffmpeg_path);
205
206 cmd.args(&["-y", "-i"])
207 .arg(input_file);
208
209 cmd.arg("-vn");
211
212 if use_copy {
213 cmd.args(&["-c", "copy"]);
214 } else {
215 cmd.args(&[
216 "-c:a", encoder.name(),
217 "-b:a", &format!("{}k", quality.bitrate),
218 "-ar", &quality.sample_rate.to_string(),
219 "-ac", &quality.channels.to_string(),
220 ]);
221
222 if encoder.supports_threading() {
224 cmd.args(&["-threads", "0"]); }
226 }
227
228 cmd.args(&["-movflags", "+faststart"]);
229 cmd.arg(output_file);
230
231 let output = cmd
232 .stdout(Stdio::piped())
233 .stderr(Stdio::piped())
234 .output()
235 .await
236 .context("Failed to execute ffmpeg")?;
237
238 if !output.status.success() {
239 let stderr = String::from_utf8_lossy(&output.stderr);
240 if stderr.to_lowercase().contains("encoder") {
242 anyhow::bail!(
243 "FFmpeg encoding failed with encoder '{}': {}\nTip: Run 'audiobook-forge check' to verify encoder availability",
244 encoder.name(),
245 stderr
246 );
247 }
248 anyhow::bail!("FFmpeg conversion failed: {}", stderr);
249 }
250
251 Ok(())
252 }
253
254 pub fn create_concat_file(files: &[&Path], output: &Path) -> Result<()> {
256 let mut content = String::new();
257 for file in files {
258 if !file.exists() {
260 anyhow::bail!("File not found: {}", file.display());
261 }
262
263 let abs_path = file.canonicalize()
265 .with_context(|| format!("Failed to resolve path: {}", file.display()))?;
266
267 let path_str = abs_path.to_string_lossy();
273 let escaped = path_str.replace('\'', r"'\''");
274
275 content.push_str(&format!("file '{}'\n", escaped));
276 }
277
278 std::fs::write(output, content)
279 .context("Failed to write concat file")?;
280
281 Ok(())
282 }
283}
284
285impl Default for FFmpeg {
286 fn default() -> Self {
287 Self::new().expect("FFmpeg not found")
288 }
289}
290
291#[cfg(test)]
292mod tests {
293 use super::*;
294
295 #[test]
296 fn test_ffmpeg_initialization() {
297 let ffmpeg = FFmpeg::new();
298 assert!(ffmpeg.is_ok());
299 }
300
301 #[test]
302 fn test_parse_ffprobe_json() {
303 let json_str = r#"{
304 "streams": [{
305 "codec_type": "audio",
306 "codec_name": "mp3",
307 "sample_rate": "44100",
308 "channels": 2,
309 "bit_rate": "128000",
310 "duration": "3600.5"
311 }],
312 "format": {
313 "bit_rate": "128000",
314 "duration": "3600.5"
315 }
316 }"#;
317
318 let json: Value = serde_json::from_str(json_str).unwrap();
319 let ffmpeg = FFmpeg::new().unwrap();
320 let profile = ffmpeg.parse_ffprobe_output(&json).unwrap();
321
322 assert_eq!(profile.bitrate, 128);
323 assert_eq!(profile.sample_rate, 44100);
324 assert_eq!(profile.channels, 2);
325 assert_eq!(profile.codec, "mp3");
326 assert!((profile.duration - 3600.5).abs() < 0.1);
327 }
328}