1use crate::audio::{
4 generate_chapters_from_files, inject_chapters_mp4box, inject_metadata_atomicparsley,
5 parse_cue_file, write_mp4box_chapters, AacEncoder, FFmpeg,
6};
7use crate::models::{BookFolder, ProcessingResult};
8use anyhow::{Context, Result};
9use std::path::{Path, PathBuf};
10use std::sync::Arc;
11use std::time::Instant;
12use tokio::sync::Semaphore;
13
14pub struct Processor {
16 ffmpeg: FFmpeg,
17 keep_temp: bool,
18 encoder: AacEncoder,
19 enable_parallel_encoding: bool,
20 max_concurrent_files: usize,
21 quality_preset: Option<String>,
22}
23
24impl Processor {
25 pub fn new() -> Result<Self> {
27 Ok(Self {
28 ffmpeg: FFmpeg::new()?,
29 keep_temp: false,
30 encoder: crate::audio::get_encoder(),
31 enable_parallel_encoding: true,
32 max_concurrent_files: 8,
33 quality_preset: None,
34 })
35 }
36
37 pub fn with_options(
39 keep_temp: bool,
40 encoder: AacEncoder,
41 enable_parallel_encoding: bool,
42 max_concurrent_files: usize,
43 quality_preset: Option<String>,
44 ) -> Result<Self> {
45 Ok(Self {
46 ffmpeg: FFmpeg::new()?,
47 keep_temp,
48 encoder,
49 enable_parallel_encoding,
50 max_concurrent_files: max_concurrent_files.clamp(1, 32),
51 quality_preset,
52 })
53 }
54
55 pub async fn process_book(
57 &self,
58 book_folder: &BookFolder,
59 output_dir: &Path,
60 chapter_source: &str,
61 ) -> Result<ProcessingResult> {
62 let start_time = Instant::now();
63 let result = ProcessingResult::new(book_folder.name.clone());
64
65 if !output_dir.exists() {
67 std::fs::create_dir_all(output_dir)
68 .context("Failed to create output directory")?;
69 }
70
71 let output_filename = book_folder.get_output_filename();
73 let output_path = output_dir.join(&output_filename);
74
75 let temp_dir = self.create_temp_dir(&book_folder.name)?;
77
78 let use_copy = book_folder.can_use_concat_copy();
80
81 tracing::info!(
82 "Processing {} - {} tracks, copy_mode={}",
83 book_folder.name,
84 book_folder.tracks.len(),
85 use_copy
86 );
87
88 let mut quality = book_folder
90 .get_best_quality_profile(true)
91 .context("No tracks found")?
92 .clone();
93
94 if let Some(ref preset) = self.quality_preset {
96 quality = quality.apply_preset(Some(preset.as_str()));
97 tracing::info!("Applying quality preset '{}': {}", preset, quality);
98 }
99
100 if book_folder.tracks.len() == 1 {
101 self.ffmpeg
103 .convert_single_file(
104 &book_folder.tracks[0].file_path,
105 &output_path,
106 &quality,
107 use_copy,
108 self.encoder,
109 )
110 .await
111 .context("Failed to convert audio file")?;
112 } else if use_copy {
113 let concat_file = temp_dir.join("concat.txt");
115 let file_refs: Vec<&Path> = book_folder
116 .tracks
117 .iter()
118 .map(|t| t.file_path.as_path())
119 .collect();
120 FFmpeg::create_concat_file(&file_refs, &concat_file)?;
121
122 self.ffmpeg
123 .concat_audio_files(
124 &concat_file,
125 &output_path,
126 &quality,
127 use_copy,
128 self.encoder,
129 )
130 .await
131 .context("Failed to concatenate audio files")?;
132 } else if self.enable_parallel_encoding && book_folder.tracks.len() > 1 {
133 let effective_limit = self.max_concurrent_files.min(book_folder.tracks.len());
135
136 tracing::info!(
137 "Using parallel encoding: {} files with max {} concurrent",
138 book_folder.tracks.len(),
139 effective_limit
140 );
141
142 let semaphore = Arc::new(Semaphore::new(effective_limit));
144
145 let mut encoded_files = Vec::new();
147 let mut tasks = Vec::new();
148
149 for (i, track) in book_folder.tracks.iter().enumerate() {
150 let temp_output = temp_dir.join(format!("encoded_{:04}.m4a", i));
151 encoded_files.push(temp_output.clone());
152
153 let ffmpeg = self.ffmpeg.clone();
154 let input = track.file_path.clone();
155 let output = temp_output;
156 let quality = quality.clone();
157 let encoder = self.encoder;
158 let sem = Arc::clone(&semaphore);
159
160 let task = tokio::spawn(async move {
162 let _permit = sem.acquire().await.unwrap();
164
165 ffmpeg
166 .convert_single_file(&input, &output, &quality, false, encoder)
167 .await
168 });
170
171 tasks.push(task);
172 }
173
174 for (i, task) in tasks.into_iter().enumerate() {
176 match task.await {
177 Ok(Ok(())) => continue,
178 Ok(Err(e)) => {
179 return Err(e).context(format!("Track {} encoding failed", i));
180 }
181 Err(e) => {
182 return Err(anyhow::anyhow!("Task {} panicked: {}", i, e));
183 }
184 }
185 }
186
187 tracing::info!("All {} files encoded, now concatenating...", encoded_files.len());
188
189 let concat_file = temp_dir.join("concat.txt");
191 let file_refs: Vec<&Path> = encoded_files.iter().map(|p| p.as_path()).collect();
192 FFmpeg::create_concat_file(&file_refs, &concat_file)?;
193
194 self.ffmpeg
195 .concat_audio_files(
196 &concat_file,
197 &output_path,
198 &quality,
199 true, self.encoder,
201 )
202 .await
203 .context("Failed to concatenate encoded files")?;
204 } else {
205 tracing::info!("Using serial encoding (parallel encoding disabled in config)");
207
208 let concat_file = temp_dir.join("concat.txt");
209 let file_refs: Vec<&Path> = book_folder
210 .tracks
211 .iter()
212 .map(|t| t.file_path.as_path())
213 .collect();
214 FFmpeg::create_concat_file(&file_refs, &concat_file)?;
215
216 self.ffmpeg
217 .concat_audio_files(
218 &concat_file,
219 &output_path,
220 &quality,
221 false, self.encoder,
223 )
224 .await
225 .context("Failed to concatenate audio files")?;
226 }
227
228 tracing::info!("Audio processing complete: {}", output_path.display());
229
230 let chapters = self.generate_chapters(book_folder, chapter_source)?;
232
233 if !chapters.is_empty() {
234 let chapters_file = temp_dir.join("chapters.txt");
235 write_mp4box_chapters(&chapters, &chapters_file)
236 .context("Failed to write chapter file")?;
237
238 inject_chapters_mp4box(&output_path, &chapters_file)
239 .await
240 .context("Failed to inject chapters")?;
241
242 tracing::info!("Injected {} chapters", chapters.len());
243 }
244
245 let title = book_folder.get_album_title();
247 let artist = book_folder.get_album_artist();
248 let year = book_folder.get_year();
249 let genre = book_folder.get_genre();
250
251 inject_metadata_atomicparsley(
252 &output_path,
253 title.as_deref(),
254 artist.as_deref(),
255 title.as_deref(), year,
257 genre.as_deref(),
258 book_folder.cover_file.as_deref(),
259 )
260 .await
261 .context("Failed to inject metadata")?;
262
263 tracing::info!("Metadata injection complete");
264
265 if !self.keep_temp {
267 if let Err(e) = std::fs::remove_dir_all(&temp_dir) {
268 tracing::warn!("Failed to remove temp directory: {}", e);
269 }
270 }
271
272 let processing_time = start_time.elapsed().as_secs_f64();
274
275 Ok(result.success(output_path, processing_time, use_copy))
277 }
278
279 fn generate_chapters(
281 &self,
282 book_folder: &BookFolder,
283 chapter_source: &str,
284 ) -> Result<Vec<crate::audio::Chapter>> {
285 match chapter_source {
286 "cue" => {
287 if let Some(ref cue_file) = book_folder.cue_file {
289 tracing::info!("Using CUE file for chapters: {}", cue_file.display());
290 return parse_cue_file(cue_file);
291 }
292 Ok(Vec::new())
293 }
294 "files" | "auto" => {
295 if book_folder.tracks.len() > 1 {
297 let files: Vec<&Path> = book_folder
298 .tracks
299 .iter()
300 .map(|t| t.file_path.as_path())
301 .collect();
302 let durations: Vec<f64> = book_folder
303 .tracks
304 .iter()
305 .map(|t| t.quality.duration)
306 .collect();
307
308 tracing::info!(
309 "Generating {} chapters from files",
310 book_folder.tracks.len()
311 );
312 Ok(generate_chapters_from_files(&files, &durations))
313 } else {
314 if let Some(ref cue_file) = book_folder.cue_file {
316 tracing::info!("Using CUE file for single-file book");
317 parse_cue_file(cue_file)
318 } else {
319 Ok(Vec::new())
320 }
321 }
322 }
323 "none" => Ok(Vec::new()),
324 _ => {
325 tracing::warn!("Unknown chapter source: {}, using auto", chapter_source);
326 self.generate_chapters(book_folder, "auto")
327 }
328 }
329 }
330
331 fn create_temp_dir(&self, book_name: &str) -> Result<PathBuf> {
333 let temp_base = std::env::temp_dir();
334 let sanitized_name = sanitize_filename::sanitize(book_name);
335 let temp_dir = temp_base.join(format!("audiobook-forge-{}", sanitized_name));
336
337 if temp_dir.exists() {
338 std::fs::remove_dir_all(&temp_dir).ok();
339 }
340
341 std::fs::create_dir_all(&temp_dir).context("Failed to create temp directory")?;
342
343 Ok(temp_dir)
344 }
345}
346
347impl Default for Processor {
348 fn default() -> Self {
349 Self::new().expect("Failed to create processor")
350 }
351}
352
353#[cfg(test)]
354mod tests {
355 use super::*;
356
357 #[test]
358 fn test_processor_creation() {
359 let processor = Processor::new();
360 assert!(processor.is_ok());
361 }
362
363 #[test]
364 fn test_processor_with_options() {
365 let processor = Processor::with_options(true, AacEncoder::AppleSilicon, true, 8, None).unwrap();
366 assert!(processor.keep_temp);
367 assert_eq!(processor.encoder, AacEncoder::AppleSilicon);
368 assert_eq!(processor.max_concurrent_files, 8);
369 assert_eq!(processor.quality_preset, None);
370
371 let processor_with_preset = Processor::with_options(false, AacEncoder::Native, true, 4, Some("high".to_string())).unwrap();
372 assert_eq!(processor_with_preset.quality_preset, Some("high".to_string()));
373 }
374
375 #[test]
376 fn test_create_temp_dir() {
377 let processor = Processor::new().unwrap();
378 let temp_dir = processor.create_temp_dir("Test Book").unwrap();
379
380 assert!(temp_dir.exists());
381 assert!(temp_dir.to_string_lossy().contains("audiobook-forge"));
382
383 std::fs::remove_dir_all(temp_dir).ok();
385 }
386}