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 tracing::debug!("FFmpeg concat command: {:?}", cmd.as_std());
173 tracing::info!(
174 "Concatenating {} ({}mode)",
175 concat_file.display(),
176 if use_copy { "copy " } else { "transcode " }
177 );
178
179 let output = cmd
181 .stdout(Stdio::piped())
182 .stderr(Stdio::piped())
183 .output()
184 .await
185 .context("Failed to execute ffmpeg")?;
186
187 if !output.status.success() {
188 let stderr = String::from_utf8_lossy(&output.stderr);
189 if stderr.to_lowercase().contains("encoder") {
191 anyhow::bail!(
192 "FFmpeg encoding failed with encoder '{}': {}\nTip: Run 'audiobook-forge check' to verify encoder availability",
193 encoder.name(),
194 stderr
195 );
196 }
197 anyhow::bail!("FFmpeg concatenation failed: {}", stderr);
198 }
199
200 Ok(())
201 }
202
203 pub async fn convert_single_file(
205 &self,
206 input_file: &Path,
207 output_file: &Path,
208 quality: &QualityProfile,
209 use_copy: bool,
210 encoder: AacEncoder,
211 ) -> Result<()> {
212 let mut cmd = Command::new(&self.ffmpeg_path);
213
214 cmd.args(&["-y", "-i"])
215 .arg(input_file);
216
217 cmd.arg("-vn");
219
220 if use_copy {
221 cmd.args(&["-c", "copy"]);
222 } else {
223 cmd.args(&[
224 "-c:a", encoder.name(),
225 "-b:a", &format!("{}k", quality.bitrate),
226 "-ar", &quality.sample_rate.to_string(),
227 "-ac", &quality.channels.to_string(),
228 ]);
229
230 if encoder.supports_threading() {
232 cmd.args(&["-threads", "0"]); }
234 }
235
236 cmd.args(&["-movflags", "+faststart"]);
237 cmd.arg(output_file);
238
239 tracing::debug!("FFmpeg convert command: {:?}", cmd.as_std());
241 tracing::info!(
242 "Converting {} → {} (encoder: {}, {}kbps)",
243 input_file.file_name().unwrap().to_string_lossy(),
244 output_file.file_name().unwrap().to_string_lossy(),
245 encoder.name(),
246 quality.bitrate
247 );
248
249 let output = cmd
250 .stdout(Stdio::piped())
251 .stderr(Stdio::piped())
252 .output()
253 .await
254 .context("Failed to execute ffmpeg")?;
255
256 if !output.status.success() {
257 let stderr = String::from_utf8_lossy(&output.stderr);
258 if stderr.to_lowercase().contains("encoder") {
260 anyhow::bail!(
261 "FFmpeg encoding failed with encoder '{}': {}\nTip: Run 'audiobook-forge check' to verify encoder availability",
262 encoder.name(),
263 stderr
264 );
265 }
266 anyhow::bail!("FFmpeg conversion failed: {}", stderr);
267 }
268
269 Ok(())
270 }
271
272 pub fn create_concat_file(files: &[&Path], output: &Path) -> Result<()> {
274 let mut content = String::new();
275 for file in files {
276 if !file.exists() {
278 anyhow::bail!("File not found: {}", file.display());
279 }
280
281 let abs_path = file.canonicalize()
283 .with_context(|| format!("Failed to resolve path: {}", file.display()))?;
284
285 let path_str = abs_path.to_string_lossy();
291 let escaped = path_str.replace('\'', r"'\''");
292
293 content.push_str(&format!("file '{}'\n", escaped));
294 }
295
296 std::fs::write(output, content)
297 .context("Failed to write concat file")?;
298
299 Ok(())
300 }
301}
302
303impl Default for FFmpeg {
304 fn default() -> Self {
305 Self::new().expect("FFmpeg not found")
306 }
307}
308
309#[cfg(test)]
310mod tests {
311 use super::*;
312
313 #[test]
314 fn test_ffmpeg_initialization() {
315 let ffmpeg = FFmpeg::new();
316 assert!(ffmpeg.is_ok());
317 }
318
319 #[test]
320 fn test_parse_ffprobe_json() {
321 let json_str = r#"{
322 "streams": [{
323 "codec_type": "audio",
324 "codec_name": "mp3",
325 "sample_rate": "44100",
326 "channels": 2,
327 "bit_rate": "128000",
328 "duration": "3600.5"
329 }],
330 "format": {
331 "bit_rate": "128000",
332 "duration": "3600.5"
333 }
334 }"#;
335
336 let json: Value = serde_json::from_str(json_str).unwrap();
337 let ffmpeg = FFmpeg::new().unwrap();
338 let profile = ffmpeg.parse_ffprobe_output(&json).unwrap();
339
340 assert_eq!(profile.bitrate, 128);
341 assert_eq!(profile.sample_rate, 44100);
342 assert_eq!(profile.channels, 2);
343 assert_eq!(profile.codec, "mp3");
344 assert!((profile.duration - 3600.5).abs() < 0.1);
345 }
346}