audiobook_forge/audio/
ffmpeg.rs

1//! FFmpeg wrapper for audio operations
2
3use crate::models::QualityProfile;
4use anyhow::{Context, Result};
5use serde_json::Value;
6use std::path::Path;
7use std::process::Stdio;
8use tokio::process::Command;
9
10/// FFmpeg operations wrapper
11#[derive(Clone)]
12pub struct FFmpeg {
13    /// Path to ffmpeg binary
14    ffmpeg_path: String,
15    /// Path to ffprobe binary
16    ffprobe_path: String,
17}
18
19impl FFmpeg {
20    /// Create a new FFmpeg wrapper with default paths
21    pub fn new() -> Result<Self> {
22        let ffmpeg_path = which::which("ffmpeg")
23            .context("FFmpeg not found in PATH")?
24            .to_string_lossy()
25            .to_string();
26
27        let ffprobe_path = which::which("ffprobe")
28            .context("FFprobe not found in PATH")?
29            .to_string_lossy()
30            .to_string();
31
32        Ok(Self {
33            ffmpeg_path,
34            ffprobe_path,
35        })
36    }
37
38    /// Create FFmpeg wrapper with custom paths
39    pub fn with_paths(ffmpeg_path: String, ffprobe_path: String) -> Self {
40        Self {
41            ffmpeg_path,
42            ffprobe_path,
43        }
44    }
45
46    /// Probe audio file and extract quality information
47    pub async fn probe_audio_file(&self, path: &Path) -> Result<QualityProfile> {
48        let output = Command::new(&self.ffprobe_path)
49            .args(&[
50                "-v", "quiet",
51                "-print_format", "json",
52                "-show_streams",
53                "-show_format",
54            ])
55            .arg(path)
56            .stdout(Stdio::piped())
57            .stderr(Stdio::piped())
58            .output()
59            .await
60            .context("Failed to execute ffprobe")?;
61
62        if !output.status.success() {
63            let stderr = String::from_utf8_lossy(&output.stderr);
64            anyhow::bail!("FFprobe failed: {}", stderr);
65        }
66
67        let json: Value = serde_json::from_slice(&output.stdout)
68            .context("Failed to parse ffprobe JSON output")?;
69
70        self.parse_ffprobe_output(&json)
71    }
72
73    /// Parse ffprobe JSON output into QualityProfile
74    fn parse_ffprobe_output(&self, json: &Value) -> Result<QualityProfile> {
75        // Find audio stream
76        let streams = json["streams"]
77            .as_array()
78            .context("No streams in ffprobe output")?;
79
80        let audio_stream = streams
81            .iter()
82            .find(|s| s["codec_type"] == "audio")
83            .context("No audio stream found")?;
84
85        // Extract bitrate
86        let bitrate = if let Some(bit_rate) = audio_stream["bit_rate"].as_str() {
87            bit_rate.parse::<u32>()? / 1000 // Convert to kbps
88        } else {
89            // Fallback to format bitrate
90            json["format"]["bit_rate"]
91                .as_str()
92                .context("No bitrate found")?
93                .parse::<u32>()? / 1000
94        };
95
96        // Extract sample rate
97        let sample_rate = audio_stream["sample_rate"]
98            .as_str()
99            .context("No sample rate found")?
100            .parse::<u32>()?;
101
102        // Extract channels
103        let channels = audio_stream["channels"]
104            .as_u64()
105            .context("No channels found")? as u8;
106
107        // Extract codec
108        let codec = audio_stream["codec_name"]
109            .as_str()
110            .context("No codec found")?
111            .to_string();
112
113        // Extract duration
114        let duration = if let Some(dur) = audio_stream["duration"].as_str() {
115            dur.parse::<f64>()?
116        } else {
117            json["format"]["duration"]
118                .as_str()
119                .context("No duration found")?
120                .parse::<f64>()?
121        };
122
123        QualityProfile::new(bitrate, sample_rate, channels, codec, duration)
124    }
125
126    /// Concatenate audio files using FFmpeg
127    pub async fn concat_audio_files(
128        &self,
129        concat_file: &Path,
130        output_file: &Path,
131        quality: &QualityProfile,
132        use_copy: bool,
133        use_apple_silicon: bool,
134    ) -> Result<()> {
135        let mut cmd = Command::new(&self.ffmpeg_path);
136
137        cmd.args(&[
138            "-y",
139            "-f", "concat",
140            "-safe", "0",
141            "-i",
142        ])
143        .arg(concat_file);
144
145        // Skip video streams (embedded cover art in MP3s)
146        cmd.arg("-vn");
147
148        if use_copy {
149            // Copy mode - no re-encoding
150            cmd.args(&["-c", "copy"]);
151        } else {
152            // Transcode mode
153            let encoder = if use_apple_silicon { "aac_at" } else { "aac" };
154            cmd.args(&[
155                "-c:a", encoder,
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 (auto = use all available cores)
162            if !use_apple_silicon {
163                // Standard aac encoder benefits from threading
164                cmd.args(&["-threads", "0"]); // 0 = auto-detect optimal thread count
165            }
166        }
167
168        // Add faststart flag for better streaming
169        cmd.args(&["-movflags", "+faststart"]);
170        cmd.arg(output_file);
171
172        // Execute command
173        let output = cmd
174            .stdout(Stdio::piped())
175            .stderr(Stdio::piped())
176            .output()
177            .await
178            .context("Failed to execute ffmpeg")?;
179
180        if !output.status.success() {
181            let stderr = String::from_utf8_lossy(&output.stderr);
182            anyhow::bail!("FFmpeg concatenation failed: {}", stderr);
183        }
184
185        Ok(())
186    }
187
188    /// Convert a single audio file to M4A/M4B
189    pub async fn convert_single_file(
190        &self,
191        input_file: &Path,
192        output_file: &Path,
193        quality: &QualityProfile,
194        use_copy: bool,
195        use_apple_silicon: bool,
196    ) -> Result<()> {
197        let mut cmd = Command::new(&self.ffmpeg_path);
198
199        cmd.args(&["-y", "-i"])
200            .arg(input_file);
201
202        // Skip video streams (embedded cover art in MP3s)
203        cmd.arg("-vn");
204
205        if use_copy {
206            cmd.args(&["-c", "copy"]);
207        } else {
208            let encoder = if use_apple_silicon { "aac_at" } else { "aac" };
209            cmd.args(&[
210                "-c:a", encoder,
211                "-b:a", &format!("{}k", quality.bitrate),
212                "-ar", &quality.sample_rate.to_string(),
213                "-ac", &quality.channels.to_string(),
214            ]);
215
216            // Use multiple threads for encoding (auto = use all available cores)
217            if !use_apple_silicon {
218                // Standard aac encoder benefits from threading
219                cmd.args(&["-threads", "0"]); // 0 = auto-detect optimal thread count
220            }
221        }
222
223        cmd.args(&["-movflags", "+faststart"]);
224        cmd.arg(output_file);
225
226        let output = cmd
227            .stdout(Stdio::piped())
228            .stderr(Stdio::piped())
229            .output()
230            .await
231            .context("Failed to execute ffmpeg")?;
232
233        if !output.status.success() {
234            let stderr = String::from_utf8_lossy(&output.stderr);
235            anyhow::bail!("FFmpeg conversion failed: {}", stderr);
236        }
237
238        Ok(())
239    }
240
241    /// Create concat file for FFmpeg with proper path escaping
242    pub fn create_concat_file(files: &[&Path], output: &Path) -> Result<()> {
243        let mut content = String::new();
244        for file in files {
245            // Verify file exists before adding to concat list
246            if !file.exists() {
247                anyhow::bail!("File not found: {}", file.display());
248            }
249
250            // Get absolute path for better compatibility
251            let abs_path = file.canonicalize()
252                .with_context(|| format!("Failed to resolve path: {}", file.display()))?;
253
254            // Escape the path for FFmpeg concat format
255            // FFmpeg concat format requires:
256            // - Single quotes around path
257            // - Single quotes within path must be escaped as '\''
258            // - Backslashes should be forward slashes (even on Windows for -safe 0)
259            let path_str = abs_path.to_string_lossy();
260            let escaped = path_str.replace('\'', r"'\''");
261
262            content.push_str(&format!("file '{}'\n", escaped));
263        }
264
265        std::fs::write(output, content)
266            .context("Failed to write concat file")?;
267
268        Ok(())
269    }
270}
271
272impl Default for FFmpeg {
273    fn default() -> Self {
274        Self::new().expect("FFmpeg not found")
275    }
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281
282    #[test]
283    fn test_ffmpeg_initialization() {
284        let ffmpeg = FFmpeg::new();
285        assert!(ffmpeg.is_ok());
286    }
287
288    #[test]
289    fn test_parse_ffprobe_json() {
290        let json_str = r#"{
291            "streams": [{
292                "codec_type": "audio",
293                "codec_name": "mp3",
294                "sample_rate": "44100",
295                "channels": 2,
296                "bit_rate": "128000",
297                "duration": "3600.5"
298            }],
299            "format": {
300                "bit_rate": "128000",
301                "duration": "3600.5"
302            }
303        }"#;
304
305        let json: Value = serde_json::from_str(json_str).unwrap();
306        let ffmpeg = FFmpeg::new().unwrap();
307        let profile = ffmpeg.parse_ffprobe_output(&json).unwrap();
308
309        assert_eq!(profile.bitrate, 128);
310        assert_eq!(profile.sample_rate, 44100);
311        assert_eq!(profile.channels, 2);
312        assert_eq!(profile.codec, "mp3");
313        assert!((profile.duration - 3600.5).abs() < 0.1);
314    }
315}