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/// FFmpeg operations wrapper
12#[derive(Clone)]
13pub struct FFmpeg {
14    /// Path to ffmpeg binary
15    ffmpeg_path: String,
16    /// Path to ffprobe binary
17    ffprobe_path: String,
18}
19
20impl FFmpeg {
21    /// Create a new FFmpeg wrapper with default paths
22    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    /// Create FFmpeg wrapper with custom paths
40    pub fn with_paths(ffmpeg_path: String, ffprobe_path: String) -> Self {
41        Self {
42            ffmpeg_path,
43            ffprobe_path,
44        }
45    }
46
47    /// Probe audio file and extract quality information
48    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    /// Parse ffprobe JSON output into QualityProfile
75    fn parse_ffprobe_output(&self, json: &Value) -> Result<QualityProfile> {
76        // Find audio stream
77        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        // Extract bitrate
87        let bitrate = if let Some(bit_rate) = audio_stream["bit_rate"].as_str() {
88            bit_rate.parse::<u32>()? / 1000 // Convert to kbps
89        } else {
90            // Fallback to format bitrate
91            json["format"]["bit_rate"]
92                .as_str()
93                .context("No bitrate found")?
94                .parse::<u32>()? / 1000
95        };
96
97        // Extract sample rate
98        let sample_rate = audio_stream["sample_rate"]
99            .as_str()
100            .context("No sample rate found")?
101            .parse::<u32>()?;
102
103        // Extract channels
104        let channels = audio_stream["channels"]
105            .as_u64()
106            .context("No channels found")? as u8;
107
108        // Extract codec
109        let codec = audio_stream["codec_name"]
110            .as_str()
111            .context("No codec found")?
112            .to_string();
113
114        // Extract duration
115        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    /// Concatenate audio files using FFmpeg
128    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        // Skip video streams (embedded cover art in MP3s)
147        cmd.arg("-vn");
148
149        if use_copy {
150            // Copy mode - no re-encoding
151            cmd.args(&["-c", "copy"]);
152        } else {
153            // Transcode mode
154            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            // Use multiple threads for encoding if encoder supports it
162            if encoder.supports_threading() {
163                cmd.args(&["-threads", "0"]); // 0 = auto-detect optimal thread count
164            }
165        }
166
167        // Add faststart flag for better streaming
168        cmd.args(&["-movflags", "+faststart"]);
169        cmd.arg(output_file);
170
171        // Log command for debugging
172        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        // Execute command
180        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            // Provide helpful error message if encoder is the issue
190            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    /// Convert a single audio file to M4A/M4B
204    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        // Skip video streams (embedded cover art in MP3s)
218        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            // Use multiple threads for encoding if encoder supports it
231            if encoder.supports_threading() {
232                cmd.args(&["-threads", "0"]); // 0 = auto-detect optimal thread count
233            }
234        }
235
236        cmd.args(&["-movflags", "+faststart"]);
237        cmd.arg(output_file);
238
239        // Log command for debugging
240        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            // Provide helpful error message if encoder is the issue
259            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    /// Create concat file for FFmpeg with proper path escaping
273    pub fn create_concat_file(files: &[&Path], output: &Path) -> Result<()> {
274        let mut content = String::new();
275        for file in files {
276            // Verify file exists before adding to concat list
277            if !file.exists() {
278                anyhow::bail!("File not found: {}", file.display());
279            }
280
281            // Get absolute path for better compatibility
282            let abs_path = file.canonicalize()
283                .with_context(|| format!("Failed to resolve path: {}", file.display()))?;
284
285            // Escape the path for FFmpeg concat format
286            // FFmpeg concat format requires:
287            // - Single quotes around path
288            // - Single quotes within path must be escaped as '\''
289            // - Backslashes should be forward slashes (even on Windows for -safe 0)
290            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}