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        // Execute command
172        let output = cmd
173            .stdout(Stdio::piped())
174            .stderr(Stdio::piped())
175            .output()
176            .await
177            .context("Failed to execute ffmpeg")?;
178
179        if !output.status.success() {
180            let stderr = String::from_utf8_lossy(&output.stderr);
181            // Provide helpful error message if encoder is the issue
182            if stderr.to_lowercase().contains("encoder") {
183                anyhow::bail!(
184                    "FFmpeg encoding failed with encoder '{}': {}\nTip: Run 'audiobook-forge check' to verify encoder availability",
185                    encoder.name(),
186                    stderr
187                );
188            }
189            anyhow::bail!("FFmpeg concatenation failed: {}", stderr);
190        }
191
192        Ok(())
193    }
194
195    /// Convert a single audio file to M4A/M4B
196    pub async fn convert_single_file(
197        &self,
198        input_file: &Path,
199        output_file: &Path,
200        quality: &QualityProfile,
201        use_copy: bool,
202        encoder: AacEncoder,
203    ) -> Result<()> {
204        let mut cmd = Command::new(&self.ffmpeg_path);
205
206        cmd.args(&["-y", "-i"])
207            .arg(input_file);
208
209        // Skip video streams (embedded cover art in MP3s)
210        cmd.arg("-vn");
211
212        if use_copy {
213            cmd.args(&["-c", "copy"]);
214        } else {
215            cmd.args(&[
216                "-c:a", encoder.name(),
217                "-b:a", &format!("{}k", quality.bitrate),
218                "-ar", &quality.sample_rate.to_string(),
219                "-ac", &quality.channels.to_string(),
220            ]);
221
222            // Use multiple threads for encoding if encoder supports it
223            if encoder.supports_threading() {
224                cmd.args(&["-threads", "0"]); // 0 = auto-detect optimal thread count
225            }
226        }
227
228        cmd.args(&["-movflags", "+faststart"]);
229        cmd.arg(output_file);
230
231        let output = cmd
232            .stdout(Stdio::piped())
233            .stderr(Stdio::piped())
234            .output()
235            .await
236            .context("Failed to execute ffmpeg")?;
237
238        if !output.status.success() {
239            let stderr = String::from_utf8_lossy(&output.stderr);
240            // Provide helpful error message if encoder is the issue
241            if stderr.to_lowercase().contains("encoder") {
242                anyhow::bail!(
243                    "FFmpeg encoding failed with encoder '{}': {}\nTip: Run 'audiobook-forge check' to verify encoder availability",
244                    encoder.name(),
245                    stderr
246                );
247            }
248            anyhow::bail!("FFmpeg conversion failed: {}", stderr);
249        }
250
251        Ok(())
252    }
253
254    /// Create concat file for FFmpeg with proper path escaping
255    pub fn create_concat_file(files: &[&Path], output: &Path) -> Result<()> {
256        let mut content = String::new();
257        for file in files {
258            // Verify file exists before adding to concat list
259            if !file.exists() {
260                anyhow::bail!("File not found: {}", file.display());
261            }
262
263            // Get absolute path for better compatibility
264            let abs_path = file.canonicalize()
265                .with_context(|| format!("Failed to resolve path: {}", file.display()))?;
266
267            // Escape the path for FFmpeg concat format
268            // FFmpeg concat format requires:
269            // - Single quotes around path
270            // - Single quotes within path must be escaped as '\''
271            // - Backslashes should be forward slashes (even on Windows for -safe 0)
272            let path_str = abs_path.to_string_lossy();
273            let escaped = path_str.replace('\'', r"'\''");
274
275            content.push_str(&format!("file '{}'\n", escaped));
276        }
277
278        std::fs::write(output, content)
279            .context("Failed to write concat file")?;
280
281        Ok(())
282    }
283}
284
285impl Default for FFmpeg {
286    fn default() -> Self {
287        Self::new().expect("FFmpeg not found")
288    }
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294
295    #[test]
296    fn test_ffmpeg_initialization() {
297        let ffmpeg = FFmpeg::new();
298        assert!(ffmpeg.is_ok());
299    }
300
301    #[test]
302    fn test_parse_ffprobe_json() {
303        let json_str = r#"{
304            "streams": [{
305                "codec_type": "audio",
306                "codec_name": "mp3",
307                "sample_rate": "44100",
308                "channels": 2,
309                "bit_rate": "128000",
310                "duration": "3600.5"
311            }],
312            "format": {
313                "bit_rate": "128000",
314                "duration": "3600.5"
315            }
316        }"#;
317
318        let json: Value = serde_json::from_str(json_str).unwrap();
319        let ffmpeg = FFmpeg::new().unwrap();
320        let profile = ffmpeg.parse_ffprobe_output(&json).unwrap();
321
322        assert_eq!(profile.bitrate, 128);
323        assert_eq!(profile.sample_rate, 44100);
324        assert_eq!(profile.channels, 2);
325        assert_eq!(profile.codec, "mp3");
326        assert!((profile.duration - 3600.5).abs() < 0.1);
327    }
328}