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(Debug, Clone, Default)]
13pub struct AudioMetadata {
14 pub title: Option<String>,
15 pub artist: Option<String>,
16 pub album: Option<String>,
17 pub year: Option<u32>,
18 pub genre: Option<String>,
19}
20
21#[derive(Clone)]
23pub struct FFmpeg {
24 ffmpeg_path: String,
26 ffprobe_path: String,
28}
29
30impl FFmpeg {
31 pub fn new() -> Result<Self> {
33 let ffmpeg_path = which::which("ffmpeg")
34 .context("FFmpeg not found in PATH")?
35 .to_string_lossy()
36 .to_string();
37
38 let ffprobe_path = which::which("ffprobe")
39 .context("FFprobe not found in PATH")?
40 .to_string_lossy()
41 .to_string();
42
43 Ok(Self {
44 ffmpeg_path,
45 ffprobe_path,
46 })
47 }
48
49 pub fn with_paths(ffmpeg_path: String, ffprobe_path: String) -> Self {
51 Self {
52 ffmpeg_path,
53 ffprobe_path,
54 }
55 }
56
57 pub async fn probe_audio_file(&self, path: &Path) -> Result<QualityProfile> {
59 let output = Command::new(&self.ffprobe_path)
60 .args(&[
61 "-v", "quiet",
62 "-print_format", "json",
63 "-show_streams",
64 "-show_format",
65 ])
66 .arg(path)
67 .stdout(Stdio::piped())
68 .stderr(Stdio::piped())
69 .output()
70 .await
71 .context("Failed to execute ffprobe")?;
72
73 if !output.status.success() {
74 let stderr = String::from_utf8_lossy(&output.stderr);
75 anyhow::bail!("FFprobe failed: {}", stderr);
76 }
77
78 let json: Value = serde_json::from_slice(&output.stdout)
79 .context("Failed to parse ffprobe JSON output")?;
80
81 self.parse_ffprobe_output(&json)
82 }
83
84 fn parse_ffprobe_output(&self, json: &Value) -> Result<QualityProfile> {
86 let streams = json["streams"]
88 .as_array()
89 .context("No streams in ffprobe output")?;
90
91 let audio_stream = streams
92 .iter()
93 .find(|s| s["codec_type"] == "audio")
94 .context("No audio stream found")?;
95
96 let bitrate = if let Some(bit_rate) = audio_stream["bit_rate"].as_str() {
98 bit_rate.parse::<u32>()? / 1000 } else {
100 json["format"]["bit_rate"]
102 .as_str()
103 .context("No bitrate found")?
104 .parse::<u32>()? / 1000
105 };
106
107 let sample_rate = audio_stream["sample_rate"]
109 .as_str()
110 .context("No sample rate found")?
111 .parse::<u32>()?;
112
113 let channels = audio_stream["channels"]
115 .as_u64()
116 .context("No channels found")? as u8;
117
118 let codec = audio_stream["codec_name"]
120 .as_str()
121 .context("No codec found")?
122 .to_string();
123
124 let duration = if let Some(dur) = audio_stream["duration"].as_str() {
126 dur.parse::<f64>()?
127 } else {
128 json["format"]["duration"]
129 .as_str()
130 .context("No duration found")?
131 .parse::<f64>()?
132 };
133
134 QualityProfile::new(bitrate, sample_rate, channels, codec, duration)
135 }
136
137 pub async fn concat_audio_files(
139 &self,
140 concat_file: &Path,
141 output_file: &Path,
142 quality: &QualityProfile,
143 use_copy: bool,
144 encoder: AacEncoder,
145 ) -> Result<()> {
146 let mut cmd = Command::new(&self.ffmpeg_path);
147
148 cmd.args(&[
149 "-y",
150 "-f", "concat",
151 "-safe", "0",
152 "-i",
153 ])
154 .arg(concat_file);
155
156 cmd.arg("-vn");
158
159 if use_copy {
160 cmd.args(&["-c", "copy"]);
162 } else {
163 cmd.args(&[
165 "-c:a", encoder.name(),
166 "-b:a", &format!("{}k", quality.bitrate),
167 "-ar", &quality.sample_rate.to_string(),
168 "-ac", &quality.channels.to_string(),
169 ]);
170
171 if encoder.supports_threading() {
173 cmd.args(&["-threads", "0"]); }
175 }
176
177 cmd.args(&["-movflags", "+faststart"]);
179 cmd.arg(output_file);
180
181 tracing::debug!("FFmpeg concat command: {:?}", cmd.as_std());
183 tracing::info!(
184 "Concatenating {} ({}mode)",
185 concat_file.display(),
186 if use_copy { "copy " } else { "transcode " }
187 );
188
189 let output = cmd
191 .stdout(Stdio::piped())
192 .stderr(Stdio::piped())
193 .output()
194 .await
195 .context("Failed to execute ffmpeg")?;
196
197 if !output.status.success() {
198 let stderr = String::from_utf8_lossy(&output.stderr);
199 if stderr.to_lowercase().contains("encoder") {
201 anyhow::bail!(
202 "FFmpeg encoding failed with encoder '{}': {}\nTip: Run 'audiobook-forge check' to verify encoder availability",
203 encoder.name(),
204 stderr
205 );
206 }
207 anyhow::bail!("FFmpeg concatenation failed: {}", stderr);
208 }
209
210 Ok(())
211 }
212
213 pub async fn convert_single_file(
215 &self,
216 input_file: &Path,
217 output_file: &Path,
218 quality: &QualityProfile,
219 use_copy: bool,
220 encoder: AacEncoder,
221 ) -> Result<()> {
222 let mut cmd = Command::new(&self.ffmpeg_path);
223
224 cmd.args(&["-y", "-i"])
225 .arg(input_file);
226
227 cmd.arg("-vn");
229
230 if use_copy {
231 cmd.args(&["-c", "copy"]);
232 } else {
233 cmd.args(&[
234 "-c:a", encoder.name(),
235 "-b:a", &format!("{}k", quality.bitrate),
236 "-ar", &quality.sample_rate.to_string(),
237 "-ac", &quality.channels.to_string(),
238 ]);
239
240 if encoder.supports_threading() {
242 cmd.args(&["-threads", "0"]); }
244 }
245
246 cmd.args(&["-movflags", "+faststart"]);
247 cmd.arg(output_file);
248
249 tracing::debug!("FFmpeg convert command: {:?}", cmd.as_std());
251 tracing::info!(
252 "Converting {} → {} (encoder: {}, {}kbps)",
253 input_file.file_name().unwrap().to_string_lossy(),
254 output_file.file_name().unwrap().to_string_lossy(),
255 encoder.name(),
256 quality.bitrate
257 );
258
259 let output = cmd
260 .stdout(Stdio::piped())
261 .stderr(Stdio::piped())
262 .output()
263 .await
264 .context("Failed to execute ffmpeg")?;
265
266 if !output.status.success() {
267 let stderr = String::from_utf8_lossy(&output.stderr);
268 if stderr.to_lowercase().contains("encoder") {
270 anyhow::bail!(
271 "FFmpeg encoding failed with encoder '{}': {}\nTip: Run 'audiobook-forge check' to verify encoder availability",
272 encoder.name(),
273 stderr
274 );
275 }
276 anyhow::bail!("FFmpeg conversion failed: {}", stderr);
277 }
278
279 Ok(())
280 }
281
282 pub fn create_concat_file(files: &[&Path], output: &Path) -> Result<()> {
284 let mut content = String::new();
285 for file in files {
286 if !file.exists() {
288 anyhow::bail!("File not found: {}", file.display());
289 }
290
291 let abs_path = file.canonicalize()
293 .with_context(|| format!("Failed to resolve path: {}", file.display()))?;
294
295 let path_str = abs_path.to_string_lossy();
301 let escaped = path_str.replace('\'', r"'\''");
302
303 content.push_str(&format!("file '{}'\n", escaped));
304 }
305
306 std::fs::write(output, content)
307 .context("Failed to write concat file")?;
308
309 Ok(())
310 }
311
312 pub async fn concat_m4b_files(
314 &self,
315 concat_file: &Path,
316 output_file: &Path,
317 ) -> Result<()> {
318 let mut cmd = Command::new(&self.ffmpeg_path);
319
320 cmd.args([
321 "-y",
322 "-f", "concat",
323 "-safe", "0",
324 "-i",
325 ])
326 .arg(concat_file)
327 .args([
328 "-c", "copy", "-movflags", "+faststart",
330 ])
331 .arg(output_file);
332
333 tracing::debug!("FFmpeg M4B concat command: {:?}", cmd.as_std());
334 tracing::info!("Concatenating M4B files (lossless copy mode)");
335
336 let output = cmd
337 .stdout(Stdio::piped())
338 .stderr(Stdio::piped())
339 .output()
340 .await
341 .context("Failed to execute ffmpeg")?;
342
343 if !output.status.success() {
344 let stderr = String::from_utf8_lossy(&output.stderr);
345 anyhow::bail!("FFmpeg M4B concatenation failed: {}", stderr);
346 }
347
348 Ok(())
349 }
350
351 pub async fn probe_metadata(&self, path: &Path) -> Result<AudioMetadata> {
353 let output = Command::new(&self.ffprobe_path)
354 .args([
355 "-v", "quiet",
356 "-print_format", "json",
357 "-show_format",
358 ])
359 .arg(path)
360 .stdout(Stdio::piped())
361 .stderr(Stdio::piped())
362 .output()
363 .await
364 .context("Failed to execute ffprobe")?;
365
366 if !output.status.success() {
367 let stderr = String::from_utf8_lossy(&output.stderr);
368 anyhow::bail!("FFprobe failed: {}", stderr);
369 }
370
371 let json: Value = serde_json::from_slice(&output.stdout)
372 .context("Failed to parse ffprobe JSON output")?;
373
374 let tags = &json["format"]["tags"];
375
376 Ok(AudioMetadata {
377 title: tags["title"].as_str().map(String::from),
378 artist: tags["artist"].as_str().map(String::from),
379 album: tags["album"].as_str().map(String::from),
380 year: tags["date"].as_str().and_then(|s| s.get(..4).and_then(|y| y.parse().ok())),
381 genre: tags["genre"].as_str().map(String::from),
382 })
383 }
384}
385
386impl Default for FFmpeg {
387 fn default() -> Self {
388 Self::new().expect("FFmpeg not found")
389 }
390}
391
392#[cfg(test)]
393mod tests {
394 use super::*;
395
396 #[test]
397 fn test_ffmpeg_initialization() {
398 let ffmpeg = FFmpeg::new();
399 assert!(ffmpeg.is_ok());
400 }
401
402 #[test]
403 fn test_parse_ffprobe_json() {
404 let json_str = r#"{
405 "streams": [{
406 "codec_type": "audio",
407 "codec_name": "mp3",
408 "sample_rate": "44100",
409 "channels": 2,
410 "bit_rate": "128000",
411 "duration": "3600.5"
412 }],
413 "format": {
414 "bit_rate": "128000",
415 "duration": "3600.5"
416 }
417 }"#;
418
419 let json: Value = serde_json::from_str(json_str).unwrap();
420 let ffmpeg = FFmpeg::new().unwrap();
421 let profile = ffmpeg.parse_ffprobe_output(&json).unwrap();
422
423 assert_eq!(profile.bitrate, 128);
424 assert_eq!(profile.sample_rate, 44100);
425 assert_eq!(profile.channels, 2);
426 assert_eq!(profile.codec, "mp3");
427 assert!((profile.duration - 3600.5).abs() < 0.1);
428 }
429}