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