Skip to main content

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 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/// FFmpeg operations wrapper
25#[derive(Clone)]
26pub struct FFmpeg {
27    /// Path to ffmpeg binary
28    ffmpeg_path: String,
29    /// Path to ffprobe binary
30    ffprobe_path: String,
31}
32
33impl FFmpeg {
34    /// Create a new FFmpeg wrapper with default paths
35    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    /// Create FFmpeg wrapper with custom paths
53    pub fn with_paths(ffmpeg_path: String, ffprobe_path: String) -> Self {
54        Self {
55            ffmpeg_path,
56            ffprobe_path,
57        }
58    }
59
60    /// Probe audio file and extract quality information
61    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    /// Parse ffprobe JSON output into QualityProfile
88    fn parse_ffprobe_output(&self, json: &Value) -> Result<QualityProfile> {
89        // Find audio stream
90        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        // Extract bitrate
100        let bitrate = if let Some(bit_rate) = audio_stream["bit_rate"].as_str() {
101            bit_rate.parse::<u32>()? / 1000 // Convert to kbps
102        } else {
103            // Fallback to format bitrate
104            json["format"]["bit_rate"]
105                .as_str()
106                .context("No bitrate found")?
107                .parse::<u32>()? / 1000
108        };
109
110        // Extract sample rate
111        let sample_rate = audio_stream["sample_rate"]
112            .as_str()
113            .context("No sample rate found")?
114            .parse::<u32>()?;
115
116        // Extract channels
117        let channels = audio_stream["channels"]
118            .as_u64()
119            .context("No channels found")? as u8;
120
121        // Extract codec
122        let codec = audio_stream["codec_name"]
123            .as_str()
124            .context("No codec found")?
125            .to_string();
126
127        // Extract duration
128        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    /// Concatenate audio files using FFmpeg
141    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        // Skip video streams (embedded cover art in MP3s)
160        cmd.arg("-vn");
161
162        if use_copy {
163            // Copy mode - no re-encoding
164            cmd.args(&["-c", "copy"]);
165        } else {
166            // Transcode mode
167            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            // Use multiple threads for encoding if encoder supports it
175            if encoder.supports_threading() {
176                cmd.args(&["-threads", "0"]); // 0 = auto-detect optimal thread count
177            }
178        }
179
180        // Add faststart flag for better streaming
181        cmd.args(&["-movflags", "+faststart"]);
182        cmd.arg(output_file);
183
184        // Log command for debugging
185        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        // Execute command
193        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            // Provide helpful error message if encoder is the issue
203            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    /// Convert a single audio file to M4A/M4B
217    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        // Skip video streams (embedded cover art in MP3s)
231        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            // Use multiple threads for encoding if encoder supports it
244            if encoder.supports_threading() {
245                cmd.args(&["-threads", "0"]); // 0 = auto-detect optimal thread count
246            }
247        }
248
249        cmd.args(&["-movflags", "+faststart"]);
250        cmd.arg(output_file);
251
252        // Log command for debugging
253        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            // Provide helpful error message if encoder is the issue
272            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    /// Create concat file for FFmpeg with proper path escaping
286    pub fn create_concat_file(files: &[&Path], output: &Path) -> Result<()> {
287        let mut content = String::new();
288        for file in files {
289            // Verify file exists before adding to concat list
290            if !file.exists() {
291                anyhow::bail!("File not found: {}", file.display());
292            }
293
294            // Get absolute path for better compatibility
295            let abs_path = file.canonicalize()
296                .with_context(|| format!("Failed to resolve path: {}", file.display()))?;
297
298            // Escape the path for FFmpeg concat format
299            // FFmpeg concat format requires:
300            // - Single quotes around path
301            // - Single quotes within path must be escaped as '\''
302            // - Backslashes should be forward slashes (even on Windows for -safe 0)
303            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    /// Concatenate M4B files losslessly (copy mode only)
316    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",           // Lossless copy
332            "-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    /// Probe metadata from audio file
355    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}