audiobook_forge/audio/
ffmpeg.rs

1//! FFmpeg wrapper for audio operations
2
3use 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/// Audio file metadata extracted from ffprobe
12#[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/// FFmpeg operations wrapper
22#[derive(Clone)]
23pub struct FFmpeg {
24    /// Path to ffmpeg binary
25    ffmpeg_path: String,
26    /// Path to ffprobe binary
27    ffprobe_path: String,
28}
29
30impl FFmpeg {
31    /// Create a new FFmpeg wrapper with default paths
32    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    /// Create FFmpeg wrapper with custom paths
50    pub fn with_paths(ffmpeg_path: String, ffprobe_path: String) -> Self {
51        Self {
52            ffmpeg_path,
53            ffprobe_path,
54        }
55    }
56
57    /// Probe audio file and extract quality information
58    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    /// Parse ffprobe JSON output into QualityProfile
85    fn parse_ffprobe_output(&self, json: &Value) -> Result<QualityProfile> {
86        // Find audio stream
87        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        // Extract bitrate
97        let bitrate = if let Some(bit_rate) = audio_stream["bit_rate"].as_str() {
98            bit_rate.parse::<u32>()? / 1000 // Convert to kbps
99        } else {
100            // Fallback to format bitrate
101            json["format"]["bit_rate"]
102                .as_str()
103                .context("No bitrate found")?
104                .parse::<u32>()? / 1000
105        };
106
107        // Extract sample rate
108        let sample_rate = audio_stream["sample_rate"]
109            .as_str()
110            .context("No sample rate found")?
111            .parse::<u32>()?;
112
113        // Extract channels
114        let channels = audio_stream["channels"]
115            .as_u64()
116            .context("No channels found")? as u8;
117
118        // Extract codec
119        let codec = audio_stream["codec_name"]
120            .as_str()
121            .context("No codec found")?
122            .to_string();
123
124        // Extract duration
125        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    /// Concatenate audio files using FFmpeg
138    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        // Skip video streams (embedded cover art in MP3s)
157        cmd.arg("-vn");
158
159        if use_copy {
160            // Copy mode - no re-encoding
161            cmd.args(&["-c", "copy"]);
162        } else {
163            // Transcode mode
164            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            // Use multiple threads for encoding if encoder supports it
172            if encoder.supports_threading() {
173                cmd.args(&["-threads", "0"]); // 0 = auto-detect optimal thread count
174            }
175        }
176
177        // Add faststart flag for better streaming
178        cmd.args(&["-movflags", "+faststart"]);
179        cmd.arg(output_file);
180
181        // Log command for debugging
182        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        // Execute command
190        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            // Provide helpful error message if encoder is the issue
200            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    /// Convert a single audio file to M4A/M4B
214    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        // Skip video streams (embedded cover art in MP3s)
228        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            // Use multiple threads for encoding if encoder supports it
241            if encoder.supports_threading() {
242                cmd.args(&["-threads", "0"]); // 0 = auto-detect optimal thread count
243            }
244        }
245
246        cmd.args(&["-movflags", "+faststart"]);
247        cmd.arg(output_file);
248
249        // Log command for debugging
250        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            // Provide helpful error message if encoder is the issue
269            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    /// Create concat file for FFmpeg with proper path escaping
283    pub fn create_concat_file(files: &[&Path], output: &Path) -> Result<()> {
284        let mut content = String::new();
285        for file in files {
286            // Verify file exists before adding to concat list
287            if !file.exists() {
288                anyhow::bail!("File not found: {}", file.display());
289            }
290
291            // Get absolute path for better compatibility
292            let abs_path = file.canonicalize()
293                .with_context(|| format!("Failed to resolve path: {}", file.display()))?;
294
295            // Escape the path for FFmpeg concat format
296            // FFmpeg concat format requires:
297            // - Single quotes around path
298            // - Single quotes within path must be escaped as '\''
299            // - Backslashes should be forward slashes (even on Windows for -safe 0)
300            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    /// Concatenate M4B files losslessly (copy mode only)
313    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",           // Lossless copy
329            "-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    /// Probe metadata from audio file
352    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}