audiobook_forge/audio/
ffmpeg.rs1use 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#[derive(Clone)]
12pub struct FFmpeg {
13 ffmpeg_path: String,
15 ffprobe_path: String,
17}
18
19impl FFmpeg {
20 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 pub fn with_paths(ffmpeg_path: String, ffprobe_path: String) -> Self {
40 Self {
41 ffmpeg_path,
42 ffprobe_path,
43 }
44 }
45
46 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 fn parse_ffprobe_output(&self, json: &Value) -> Result<QualityProfile> {
75 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 let bitrate = if let Some(bit_rate) = audio_stream["bit_rate"].as_str() {
87 bit_rate.parse::<u32>()? / 1000 } else {
89 json["format"]["bit_rate"]
91 .as_str()
92 .context("No bitrate found")?
93 .parse::<u32>()? / 1000
94 };
95
96 let sample_rate = audio_stream["sample_rate"]
98 .as_str()
99 .context("No sample rate found")?
100 .parse::<u32>()?;
101
102 let channels = audio_stream["channels"]
104 .as_u64()
105 .context("No channels found")? as u8;
106
107 let codec = audio_stream["codec_name"]
109 .as_str()
110 .context("No codec found")?
111 .to_string();
112
113 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 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 cmd.arg("-vn");
147
148 if use_copy {
149 cmd.args(&["-c", "copy"]);
151 } else {
152 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 if !use_apple_silicon {
163 cmd.args(&["-threads", "0"]); }
166 }
167
168 cmd.args(&["-movflags", "+faststart"]);
170 cmd.arg(output_file);
171
172 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 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 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 if !use_apple_silicon {
218 cmd.args(&["-threads", "0"]); }
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 pub fn create_concat_file(files: &[&Path], output: &Path) -> Result<()> {
243 let mut content = String::new();
244 for file in files {
245 if !file.exists() {
247 anyhow::bail!("File not found: {}", file.display());
248 }
249
250 let abs_path = file.canonicalize()
252 .with_context(|| format!("Failed to resolve path: {}", file.display()))?;
253
254 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}